What you are building
The Model Context Protocol (MCP) is an open standard that lets AI applications connect to external tools and data sources in a structured, reusable way. Instead of writing a one-off integration for each AI provider, you build one MCP server and any MCP-compatible host can use it.
In this tutorial, you will build a local Sahha MCP server that exposes four tools:
get_scores— fetch health scores for a profileget_biomarkers— fetch biomarkers for a profileget_archetypes— fetch archetypes for a profileget_profile— fetch demographic information for a profile
Once connected, an AI like Claude can query live Sahha health data on your behalf — asking questions like “What was this user’s sleep score yesterday?” or “What archetype is this user currently assigned to?” and getting structured answers directly from your Sahha account.
MCP Host (Claude Desktop, Cursor, etc.)
↓
MCP Client (built into the host)
↓
Your Sahha MCP Server (this tutorial)
↓
Sahha REST API
↓
Health data returned to the AIThis is the foundational tutorial in the Sahha MCP series. Later tutorials build on this to cover remote deployment and wiring the server into an agentic workflow.
Prerequisites
Before you start, make sure you have:
- A Sahha account with a
clientIdandclientSecret(get them here) - Node.js 18 or later
- Claude Desktop installed (download here)
How Sahha authentication works in this server
The MCP server runs server-side and uses your Account Token to query data on behalf of any profile by externalId. Account tokens expire after 24 hours, so the server handles token refresh automatically.
You will pass your clientId and clientSecret as environment variables. The server exchanges them for an account token on startup and re-fetches it when it expires.
1. Create the project
mkdir sahha-mcp-server
cd sahha-mcp-server
npm init -yInstall dependencies:
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx@modelcontextprotocol/sdk— the official MCP TypeScript SDKzod— for input validation on each tooltsx— to run TypeScript directly during development
Add a tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"rootDir": "./src",
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"types": ["node"]
},
"include": ["src/**/*"]
}Update package.json to add a build script and mark the package as ESM:
{
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx src/index.ts"
}
}2. Set up your environment
Create a .env file for reference:
SAHHA_CLIENT_ID=your-client-id
SAHHA_CLIENT_SECRET=your-client-secret
SAHHA_BASE_URL=https://sandbox-api.sahha.ai.env is useful if you later add a package like dotenv for local development with npm run dev. https://sandbox-api.sahha.ai while developing. Switch to https://api.sahha.ai only when you are ready for production. Add .env to .gitignore:
.env
dist/
node_modules/3. Build the Sahha API client
Create src/sahha.ts. This handles authentication and all four data-fetching functions.
// src/sahha.ts
const BASE_URL = process.env.SAHHA_BASE_URL ?? 'https://sandbox-api.sahha.ai'
const CLIENT_ID = process.env.SAHHA_CLIENT_ID
const CLIENT_SECRET = process.env.SAHHA_CLIENT_SECRET
if (!CLIENT_ID || !CLIENT_SECRET) {
throw new Error('SAHHA_CLIENT_ID and SAHHA_CLIENT_SECRET must be set')
}
// Returns an ISO 8601 date string N days ago from now
function daysAgo(n: number): string {
const d = new Date()
d.setDate(d.getDate() - n)
return d.toISOString()
}
// Token cache — the server refreshes this automatically when it expires
let accountToken: string | null = null
let tokenExpiresAt: number = 0
async function getAccountToken(): Promise<string> {
const now = Date.now()
// Return cached token if still valid (with 60s buffer)
if (accountToken && now < tokenExpiresAt - 60_000) {
return accountToken
}
const response = await fetch(`${BASE_URL}/api/v1/oauth/account/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
clientId: CLIENT_ID,
clientSecret: CLIENT_SECRET,
}),
})
if (!response.ok) {
throw new Error(`Failed to get account token: ${response.status}`)
}
const data = await response.json() as { accountToken: string; expiresIn: number }
accountToken = data.accountToken
tokenExpiresAt = now + data.expiresIn * 1000
return accountToken
}
// Helper: makes an authenticated GET request to the Sahha API
async function sahhaGet(path: string, params: Record<string, string> = {}): Promise<unknown> {
const token = await getAccountToken()
const url = new URL(`${BASE_URL}${path}`)
for (const [key, value] of Object.entries(params)) {
if (value) {
// Some Sahha params (e.g. types, categories) require repeated params rather than
// comma-separated values: ?types=sleep&types=activity, not ?types=sleep,activity
for (const v of value.split(',')) {
url.searchParams.append(key, v.trim())
}
}
}
const response = await fetch(url.toString(), {
headers: { Authorization: `account ${token}` },
})
// 204 means valid request but no data for the given date range or profile
if (response.status === 204) {
return []
}
if (!response.ok) {
throw new Error(`Sahha API error: ${response.status} ${response.statusText}`)
}
return response.json()
}
// --- Tool functions ---
export async function getScores(params: {
externalId: string
types?: string
startDateTime?: string
endDateTime?: string
}) {
return sahhaGet(`/api/v1/profile/score/${params.externalId}`, {
types: params.types ?? 'activity,sleep,readiness,wellbeing,mental_wellbeing',
startDateTime: params.startDateTime ?? daysAgo(7),
endDateTime: params.endDateTime ?? new Date().toISOString(),
})
}
export async function getBiomarkers(params: {
externalId: string
categories?: string
types?: string
startDateTime?: string
endDateTime?: string
}) {
return sahhaGet(`/api/v1/profile/biomarker/${params.externalId}`, {
categories: params.categories ?? 'activity,sleep,device,demographic',
...(params.types ? { types: params.types } : {}),
startDateTime: params.startDateTime ?? daysAgo(7),
endDateTime: params.endDateTime ?? new Date().toISOString(),
})
}
export async function getArchetypes(params: {
externalId: string
types?: string
periodicity?: string
startDateTime?: string
endDateTime?: string
}) {
return sahhaGet(`/api/v1/profile/archetypes/${params.externalId}`, {
...(params.types ? { types: params.types } : {}),
...(params.periodicity ? { periodicity: params.periodicity } : {}),
...(params.startDateTime ? { startDateTime: params.startDateTime } : {}),
...(params.endDateTime ? { endDateTime: params.endDateTime } : {}),
})
}
export async function getProfile(params: { externalId: string }) {
return sahhaGet(`/api/v1/account/profile/${params.externalId}`)
}4. Build the MCP server
Create src/index.ts. This is where you define the MCP server and register each Sahha function as a tool.
// src/index.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { z } from 'zod'
import { getScores, getBiomarkers, getArchetypes, getProfile } from './sahha.js'
const server = new McpServer({
name: 'sahha',
version: '1.0.0',
})
// --- Tool: get_scores ---
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, e.g. "2024-01-01T00:00:00Z"'),
endDateTime: z.string().optional().describe('ISO 8601 end date, e.g. "2024-01-07T23:59:59Z"'),
},
async (params) => {
const data = await getScores(params)
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
}
}
)
// --- Tool: get_biomarkers ---
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, e.g. "step_count,heart_rate"'),
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) }],
}
}
)
// --- Tool: get_archetypes ---
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) }],
}
}
)
// --- Tool: get_profile ---
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) }],
}
}
)
// Start the server using stdio transport
const transport = new StdioServerTransport()
await server.connect(transport)5. Build the project
npm run buildThis compiles TypeScript to dist/. You should see dist/index.js and dist/sahha.js.
6. Connect to Claude Desktop
Open your Claude Desktop config file:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json
Add your Sahha MCP server under mcpServers:
{
"mcpServers": {
"sahha": {
"command": "node",
"args": ["/absolute/path/to/sahha-mcp-server/dist/index.js"],
"env": {
"SAHHA_CLIENT_ID": "your-client-id",
"SAHHA_CLIENT_SECRET": "your-client-secret",
"SAHHA_BASE_URL": "https://sandbox-api.sahha.ai"
}
}
}
}Replace /absolute/path/to/sahha-mcp-server with the actual path to your project directory.
Restart Claude Desktop after saving the config.
7. Verify it’s working
Once Claude Desktop restarts, open a new conversation. You should see a Connections panel in the sidebar — your Sahha server will be listed there with its four tools.
Try these prompts to verify each tool — replace your-external-id with a real externalId from your Sahha account:
What health scores are available for profile "your-external-id"?Fetch the sleep and activity scores for "your-external-id" for the past 7 days.What archetypes is "your-external-id" currently assigned to?Show me the biomarkers for "your-external-id" in the sleep category.Claude will call your MCP server, which will call the Sahha API, and return the results directly into the conversation.
204 response from Sahha means the query was valid but there is no data for that profile or date range yet. Use a Sample Profile or the Demo App to generate test data. Project structure
sahha-mcp-server/
├── src/
│ ├── index.ts # MCP server — tool definitions and server startup
│ └── sahha.ts # Sahha API client — auth and data fetching
├── dist/ # Compiled output (generated by tsc)
├── .env # Your credentials (never commit this)
├── tsconfig.json
└── package.jsonHow each tool maps to the Sahha API
| MCP Tool | Sahha Endpoint | Auth |
|---|---|---|
get_scores | GET /api/v1/profile/score/{externalId} | Account token |
get_biomarkers | GET /api/v1/profile/biomarker/{externalId} | Account token |
get_archetypes | GET /api/v1/profile/archetypes/{externalId} | Account token |
get_profile | GET /api/v1/account/profile/{externalId} | Account token |
What to build next
Now that your local Sahha MCP server is running, you have a few natural next steps:
- Use it in Claude Code — connect the server to Claude Code so you can query live Sahha data while building
- Add more tools — expose
get_insights(trends and comparisons) andget_logsusing the same pattern - Deploy it remotely — switch from stdio to Streamable HTTP so any MCP client can connect to a hosted endpoint, not just your local machine
The next tutorial in this series covers deploying the Sahha MCP server as a remote HTTP endpoint.