March 30, 2026 · 10 min read

Build a Sahha MCP Server

Expose Sahha health data as tools in any MCP-compatible AI client using the Model Context Protocol and TypeScript.

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 profile
  • get_biomarkers — fetch biomarkers for a profile
  • get_archetypes — fetch archetypes for a profile
  • get_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 AI

This 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.

Want to skip the manual setup? Open Claude Code, paste this tutorial, and ask it to follow the steps exactly. It will create all the files, install dependencies, and run the build for you.

Prerequisites

Before you start, make sure you have:

Just exploring? You can create a Sample Profile in the Sahha dashboard or use the Demo App to have test data ready before you start.

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 -y

Install dependencies:

npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx
  • @modelcontextprotocol/sdk — the official MCP TypeScript SDK
  • zod — for input validation on each tool
  • tsx — 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
When running via Claude Desktop, credentials are passed through the config JSON in Step 6 — not from this file. The .env is useful if you later add a package like dotenv for local development with npm run dev.
Sandbox first: Use 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 build

This 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.

No data returned? A 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.json

How each tool maps to the Sahha API

MCP ToolSahha EndpointAuth
get_scoresGET /api/v1/profile/score/{externalId}Account token
get_biomarkersGET /api/v1/profile/biomarker/{externalId}Account token
get_archetypesGET /api/v1/profile/archetypes/{externalId}Account token
get_profileGET /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) and get_logs using 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.