What you are building
The first tutorial in this series built a Sahha MCP server that runs locally over stdio. That works well for individual developers, but it has limits:
- It only runs on the machine where it is installed
- Every developer on your team needs to set it up themselves
- It cannot be connected to hosted AI agents or remote workflows
Switching to Streamable HTTP solves all of this. Your MCP server becomes a hosted endpoint — one URL that any MCP-compatible client can connect to, from any machine.
MCP Host (Claude Desktop, Claude Code, AI agent)
↓
HTTPS request to your Railway endpoint
↓
Sahha MCP Server (running on Railway)
↓
Sahha REST API
↓
Health data returned to the AIBy the end of this tutorial you will have:
- A Sahha MCP server running over Streamable HTTP with bearer token auth
- The server deployed and live on Railway
- Instructions for connecting any MCP client to the remote endpoint
Prerequisites
This tutorial builds directly on Build a Sahha MCP Server. You will need:
- The Sahha MCP server project from that tutorial
- A Railway account (free tier works)
- The Railway CLI (
npm install -g @railway/cli)
What changes between stdio and Streamable HTTP
In the stdio version, the MCP server communicates over stdin/stdout — the MCP host spawns it as a child process. In the Streamable HTTP version, the server runs as a standard HTTP service and listens for JSON-RPC requests on a single /mcp endpoint.
The tools (get_scores, get_biomarkers, etc.) and the Sahha API client stay exactly the same. The only thing that changes is the transport layer and the addition of an HTTP server.
1. Install additional dependencies
In your sahha-mcp-server project, install Express and the Node.js MCP middleware:
npm install express @modelcontextprotocol/node
npm install -D @types/expressexpress— HTTP server@modelcontextprotocol/node— thin MCP adapter for Node.jsIncomingMessage/ServerResponse
2. Add a bearer token to your environment
The remote endpoint needs to be protected so only authorised clients can call it. You will use a simple bearer token — a long random string that clients must include in every request.
Generate one:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"Add it to your .env:
SAHHA_CLIENT_ID=your-client-id
SAHHA_CLIENT_SECRET=your-client-secret
SAHHA_BASE_URL=https://sandbox-api.sahha.ai
MCP_BEARER_TOKEN=your-generated-token-here3. Create the HTTP server entry point
Create src/http.ts. This is a new entry point alongside src/index.ts (the stdio version) — both can coexist in the same project.
// src/http.ts
import express from 'express'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
import { z } from 'zod'
import { getScores, getBiomarkers, getArchetypes, getProfile } from './sahha.js'
const BEARER_TOKEN = process.env.MCP_BEARER_TOKEN
if (!BEARER_TOKEN) {
throw new Error('MCP_BEARER_TOKEN must be set')
}
// --- Auth middleware ---
function requireBearerToken(
req: express.Request,
res: express.Response,
next: express.NextFunction
) {
const authHeader = req.headers['authorization']
if (!authHeader || !authHeader.startsWith('Bearer ')) {
res.status(401).json({ error: 'Missing or invalid Authorization header' })
return
}
const token = authHeader.slice(7)
if (token !== BEARER_TOKEN) {
res.status(401).json({ error: 'Invalid bearer token' })
return
}
next()
}
// --- Build the MCP server (same tools as the stdio version) ---
function createMcpServer(): McpServer {
const server = new McpServer({
name: 'sahha',
version: '1.0.0',
})
server.tool(
'get_scores',
'Fetch health scores for a Sahha profile. Returns activity, sleep, readiness, wellbeing, and mental wellbeing scores.',
{
externalId: z.string().describe('The externalId of the Sahha profile to query'),
types: z.string().optional().describe('Comma-separated score types, e.g. "activity,sleep,readiness"'),
startDateTime: z.string().optional().describe('ISO 8601 start date'),
endDateTime: z.string().optional().describe('ISO 8601 end date'),
},
async (params) => {
const data = await getScores(params)
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }
}
)
server.tool(
'get_biomarkers',
'Fetch biomarkers for a Sahha profile. Returns granular health signals such as step count, heart rate, sleep stages, and more.',
{
externalId: z.string().describe('The externalId of the Sahha profile to query'),
categories: z.string().optional().describe('Comma-separated categories, e.g. "activity,sleep"'),
types: z.string().optional().describe('Comma-separated biomarker types'),
startDateTime: z.string().optional().describe('ISO 8601 start date'),
endDateTime: z.string().optional().describe('ISO 8601 end date'),
},
async (params) => {
const data = await getBiomarkers(params)
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }
}
)
server.tool(
'get_archetypes',
'Fetch archetypes for a Sahha profile. Archetypes are long-term behavioural classifications such as sleep patterns, activity habits, and lifestyle categories.',
{
externalId: z.string().describe('The externalId of the Sahha profile to query'),
types: z.string().optional().describe('Comma-separated archetype types to filter'),
periodicity: z.string().optional().describe('"weekly" or "monthly"'),
startDateTime: z.string().optional().describe('ISO 8601 start date'),
endDateTime: z.string().optional().describe('ISO 8601 end date'),
},
async (params) => {
const data = await getArchetypes(params)
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }
}
)
server.tool(
'get_profile',
'Fetch demographic information for a Sahha profile, including date of birth and gender.',
{
externalId: z.string().describe('The externalId of the Sahha profile to query'),
},
async (params) => {
const data = await getProfile(params)
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }
}
)
return server
}
// --- Express app ---
const app = express()
app.use(express.json())
// Health check — used by Railway to confirm the service is running
app.get('/health', (_req, res) => {
res.status(200).json({ ok: true })
})
// MCP endpoint — all MCP communication goes through here
app.post('/mcp', requireBearerToken, async (req, res) => {
const server = createMcpServer()
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // stateless mode
})
try {
await server.connect(transport)
await transport.handleRequest(req, res, req.body)
} catch (error) {
console.error('MCP request error:', error)
if (!res.headersSent) {
res.status(500).json({ error: 'Internal server error' })
}
}
})
// GET /mcp — required for SSE streaming (some MCP clients use this)
app.get('/mcp', requireBearerToken, async (req, res) => {
const server = createMcpServer()
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
})
try {
await server.connect(transport)
await transport.handleRequest(req, res)
} catch (error) {
console.error('MCP SSE error:', error)
if (!res.headersSent) {
res.status(500).json({ error: 'Internal server error' })
}
}
})
const PORT = process.env.PORT ?? 3000
app.listen(PORT, () => {
console.log(`Sahha MCP server listening on port ${PORT}`)
}):::info Stateless mode
This server uses stateless mode (sessionIdGenerator: undefined), which means each request is independent. This is the right choice for Railway and most cloud deployments — it scales horizontally without needing shared session state.
:::
4. Update package.json
Add a start script for the HTTP server and update the build output:
{
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/http.js",
"start:stdio": "node dist/index.js",
"dev": "tsx src/http.ts",
"dev:stdio": "tsx src/index.ts"
}
}5. Test locally before deploying
Build and run the HTTP server locally:
npm run build
npm startIn a separate terminal, verify the health check:
curl http://localhost:3000/health
# → {"ok":true}Verify the bearer token is required:
curl -X POST http://localhost:3000/mcp \
-H "Content-Type: application/json" \
-d '{}'
# → {"error":"Missing or invalid Authorization header"}Verify a valid request is accepted:
curl -X POST http://localhost:3000/mcp \
-H "Content-Type: application/json" \
-H "Authorization: Bearer your-generated-token-here" \
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}'
# → returns the list of available tools6. Deploy to Railway
Login to Railway:
railway loginCreate a new Railway project:
railway initChoose “Empty project” and give it a name like sahha-mcp-server.
Set your environment variables on Railway:
railway variables set SAHHA_CLIENT_ID=your-client-id
railway variables set SAHHA_CLIENT_SECRET=your-client-secret
railway variables set SAHHA_BASE_URL=https://sandbox-api.sahha.ai
railway variables set MCP_BEARER_TOKEN=your-generated-token-here:::caution Production
When you are ready to go live, update SAHHA_BASE_URL to https://api.sahha.ai.
:::
Deploy:
railway upRailway will detect your Node.js project, run npm run build, and start the server with npm start.
Get your public URL:
railway domainThis gives you a URL like https://sahha-mcp-server-production.up.railway.app. Your MCP endpoint is:
https://sahha-mcp-server-production.up.railway.app/mcpVerify the deployment:
curl https://your-railway-url.up.railway.app/health
# → {"ok":true}7. Connect MCP clients to the remote endpoint
With your server live, any MCP client can connect to it using mcp-remote — a small proxy that bridges Streamable HTTP to the client’s expected transport.
Claude Desktop:
Update claude_desktop_config.json:
{
"mcpServers": {
"sahha": {
"command": "npx",
"args": [
"mcp-remote",
"https://your-railway-url.up.railway.app/mcp",
"--header",
"Authorization: Bearer your-generated-token-here"
]
}
}
}Claude Code:
Update .claude/mcp.json in your project:
{
"mcpServers": {
"sahha": {
"command": "npx",
"args": [
"mcp-remote",
"https://your-railway-url.up.railway.app/mcp",
"--header",
"Authorization: Bearer your-generated-token-here"
]
}
}
}Restart your MCP client after saving the config. You should see the same four Sahha tools available — now backed by your hosted server instead of a local process.
Project structure
sahha-mcp-server/
├── src/
│ ├── index.ts # stdio entry point (local use)
│ ├── http.ts # Streamable HTTP entry point (remote use)
│ └── sahha.ts # Sahha API client — shared by both
├── dist/
├── .env
├── tsconfig.json
└── package.jsonKeeping credentials secure
A few things to be careful about in production:
- Never commit your
.envfile. Use Railway’s environment variable dashboard for all secrets. - Rotate your
MCP_BEARER_TOKENif it is ever exposed. Update it in Railway variables and in all client configs. - Use a different bearer token per environment. Keep sandbox and production tokens separate.
- Only send the minimum Sahha data you need. Avoid forwarding full payloads with unnecessary personal data to the AI.
What to build next
With a live remote MCP server, the final step in this series is using it as the data layer for a full health AI agent:
- Build a Health AI Agent with the Sahha MCP Server — wire your remote Sahha MCP server into an agentic loop that autonomously queries health data and takes actions based on what it finds