# nLight.fit MCP server

nLight.fit runs a remote [Model Context Protocol](https://modelcontextprotocol.io) server at `POST https://nlight.fit/api/mcp`. It lets an AI client you already use — Claude Desktop, Claude Code, Cursor, or anything else that speaks MCP — query your own tracked data, WHOOP metrics, bloodwork and correlations directly.

The tools are the same handlers the in-app coach uses. There is no second implementation, so an answer you get in Cursor is computed exactly the way the answer in the app is.

## What you can reach

Eighteen tools, in three groups.

**Curated tools** are the eleven the in-app coach also calls. They return a conclusion the analysis pipeline already reached: a streak, a metric series with statistics, a correlation the nightly engine judged significant. They are small, fast, and hard to misuse.

**Warehouse tools** are six that exist only over MCP. They return the underlying evidence instead of a verdict — the raw daily feature matrix, the correlation graph as structured edges, the precomputed statistical models — so a client can test a hypothesis the engine never considered. They are deliberately not offered to the in-app coach, because a 365-day by 40-metric matrix dropped into a chat context produces a worse answer than the single precomputed edge it already had.

**Account tools** are `get_profile`, which returns what you have said about yourself rather than anything measured: your Mission Statement and the injuries, allergies and exclusions any recommendation has to respect. Every other tool reports what happened to you; this is the one that reports what you are trying to do and what would harm you, so a client that plans training or nutrition should read it first. Your name and your free-form notes are never exposed — see [what is not exposed](#what-is-not-exposed).

The [tool reference](/docs/mcp-tools) lists all eighteen with full input schemas.

## What is not exposed

Connecting a client does not hand over your whole profile. Two fields are withheld from MCP on purpose.

Your **name** stays out because nothing an external model does with a health record needs it, and it is the single field that turns a de-identified record into an identified one. Clients are told this so they neither ask for it nor guess at it.

Your **free-form notes** — the open "anything else" field — stay out because their contents cannot be known in advance, which means they cannot be cleared for sharing in advance either.

Coaching-style settings are also withheld, for a different reason: they configure how the in-app coach talks to you, and an external client is there to analyse your record, not to imitate a persona.

Everything else in the profile is shared when a client calls `get_profile`, including injuries and allergies. That is deliberate. This endpoint already returns your bloodwork, your genotypes and every metric you track, so withholding the two fields whose whole purpose is preventing a harmful suggestion would protect nothing and cost you safety.

## Read-only, and why

Every exposed tool is a query. Nothing reachable over MCP can change your data.

That is enforced in three places rather than assumed. The endpoint requires the `read` scope. Tools are published from an explicit allowlist, not from "everything the coach can call", so a write tool added for the in-app coach cannot become publicly reachable by merging. And every tool descriptor carries `annotations.readOnlyHint: true`, which lets a client run any of them without prompting you for confirmation.

Write tools are a separate phase. They will require a token carrying the `write` scope, which the auth layer already understands but nothing currently grants over this endpoint.

## Try it without an account

A sandbox at `POST https://nlight.fit/api/sandbox` speaks this exact protocol with these exact tool schemas, answered from a synthetic member record, and needs no credential at all. Point a client at it to learn the surface before anyone signs up, then change the URL. See [sandbox](/docs/sandbox).

## Transport

Streamable HTTP. Stateless JSON-RPC 2.0 over HTTP POST — one message per request, no session, no handshake required, no long-lived stream.

This matters for a practical reason: the older session-based MCP transport needed a connection pinned to a single server instance, which a serverless platform cannot promise. Protocol revision `2026-07-28` removed the handshake and made every message self-contained, which is what allows this to be an ordinary Vercel function rather than a separate always-on service.

Older clients that still open with `initialize` are handled too. Which era is in play is decided by the `MCP-Protocol-Version` header. See [errors and limits](/docs/errors) for the supported list.

Three consequences worth knowing:

- `GET` returns `405`, with `Allow: POST, OPTIONS`. That is what the transport prescribes for a server offering no server-to-client event stream, and this one is stateless with nothing to push.
- JSON-RPC batching is rejected. Send one message per request.
- Send `Accept: application/json, text/event-stream`, as the transport requires. You will get JSON. A client that accepts only `text/event-stream` gets the same envelope as a single SSE `message` event instead.

Every response carries `MCP-Protocol-Version` naming the revision that answered, `RateLimit-Policy` describing your quota, and `Api-Version`. A response that spends quota also carries `RateLimit` with what is left of it. Send a credential and every request is counted. Send none and an anonymous `tools/list` is still counted, while the negotiation methods (`initialize`, `server/discover`, `ping`) are answered without touching the limiter and carry the policy alone. A machine-readable description of the server — endpoint, transport, authorization, protocol revisions and tool list — is published at [`/.well-known/mcp.json`](https://nlight.fit/.well-known/mcp.json).

## Supported methods

| Method | Purpose |
| --- | --- |
| `server/discover` | Stateless capability discovery. Returns protocol version, capabilities, server info and the orientation instructions. |
| `initialize` | The legacy handshake. Still answered for older clients; negotiates the highest revision you both speak. Also carries the instructions. |
| `tools/list` | The tool catalog, built for the authenticated account. Carries a one-hour cache hint scoped `private`. |
| `tools/call` | Invoke a tool. |
| `prompts/list` | The published workflows. |
| `prompts/get` | Expand one workflow into a message, with arguments filled in. |
| `ping` | Health check. Returns an empty object. |

There are no MCP resources today.

## Reading a tool result

Every tool returns the same envelope. The payload is a JSON document, pretty-printed into a single text block:

```json
{
  "content": [{ "type": "text", "text": "{\n  \"found\": true,\n  …\n}" }],
  "isError": false
}
```

Two things about that shape are worth designing around.

**A tool that fails still returns `200`.** A failure is reported as `isError: true` with the reason inside the payload, not as a JSON-RPC error. That is deliberate: a model can read what went wrong and adjust, whereas a transport-level error tells the client the whole call is broken. Only protocol problems — malformed JSON, an unknown method, bad arguments — come back as JSON-RPC errors. See [errors and limits](/docs/errors).

**The text block is capped.** Past the cap the JSON is cut and an explicit `[TRUNCATED: …]` marker is appended, so a clipped series can never read as a complete one. The cap and the exact marker are on the [errors page](/docs/errors#result-size).

## How current is this result?

Different tools answer as of different moments, and the difference is not cosmetic. `get_recovery_context` reads the live ledger and answers as of today. `get_user_facts` and `get_insights` reflect the last time the background analysis pipeline ran. Quoting the two side by side as though they described the same moment is the specific error this next field exists to prevent.

So a tool result carries an as-of date:

```json
{
  "found": true,
  "coverage": { "lastDay": "2026-08-09" },
  "computedAt": "2026-08-15T12:20:17.721Z",
  "dataThrough": "2026-08-09"
}
```

`dataThrough` is always a plain `YYYY-MM-DD` calendar date. It means **this result describes your record up to and including that day** — not when the result was computed. Where a tool has both, the data horizon wins: the example above was computed on the 15th from a record that ends on the 9th, and `dataThrough` reports the 9th, because that is the date the numbers are actually about.

Three rules for using it:

- **Do not compare two results as contemporaneous unless their `dataThrough` values agree.** If they differ, say so in your answer rather than resolving the gap silently.
- **Absence is not a freshness claim.** A tool with no dated horizon — `list_metrics` returns a catalog, not a series — carries no `dataThrough`, and neither does one whose payload does not expose a date the server can read. Treat a missing field as "unknown", never as "current".
- **It is a horizon, not a guarantee of completeness.** A day inside the range can still be unlogged. Coverage fields on the individual tools tell you which days actually hold data.

## Orientation

An external client starts colder than the in-app coach. The coach reaches these same handlers through a system prompt built from your profile, your columns, your labs and your correlations. A client connecting over MCP gets the protocol and nothing else, and a model with the right tools and no framing produces confident wrong answers rather than merely inefficient ones.

Three things close that gap.

**Instructions.** `initialize` and `server/discover` both return an `instructions` string, which clients fold into the model's system prompt. It describes the four layers of the data — ledger, feature matrix, precomputed statistics, narrative — and states the things that are not guessable from a schema: that `r` means a confounder-controlled partial correlation in `get_correlation_edges` and a raw Pearson in the `leadLag` insights section, which confidence tiers exist and what each one survived, how stale each layer can be, and the window and size budgets. It is identical for every account, so it costs nothing to cache.

**A catalog built for you.** `tools/list` stamps every tool description with your account's real context: how many days you have logged and over what span, the actual names of your custom columns, whether WHOOP is connected, and whether you have enough history for the correlation and insights engines to have run at all. Without this a model learns your thresholds by calling tools until one refuses. This is why the catalog is marked `cacheScope: private` — it now definitely carries one account's vocabulary, so a shared cache serving it to another user would be a real leak rather than a theoretical one.

**Prompts.** Four published workflows — a morning readiness check, a weekly review, a hypothesis test over any two metrics, and a single-metric explainer. Clients that render prompts, Claude Desktop among them, show these as pickable entries, so you can run a multi-tool analysis without knowing the tool graph exists. The hypothesis test is the one worth knowing about: it directs the model to check the precomputed verdict *before* running its own analysis, because an ad-hoc query over a short window will happily rediscover a confound the nightly scan already rejected and present it as a finding.

## A minimal call

```bash
curl -sX POST https://nlight.fit/api/mcp \
  -H "Authorization: Bearer $NLIGHT_TOKEN" \
  -H "Content-Type: application/json" \
  -H "MCP-Protocol-Version: 2026-07-28" \
  -H "Mcp-Method: tools/list" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

From revision `2026-07-28` onward the `Mcp-Method` header is required, and `Mcp-Name` is required for messages that address something. The server checks both against the request body and rejects disagreement, so an intermediary cannot route on one operation while the server performs another.

## Browser requests are refused

Any request carrying an `Origin` header is rejected with `403` unless that origin is explicitly allowlisted by the server operator.

This is real access control rather than defensive tidying. The endpoint returns a complete medical record, so a request that looks like it came from a browser is refused. Genuine MCP clients are servers and send no `Origin` at all, so refusing every request that carries one costs nothing and closes the DNS-rebinding case the transport specification warns about.

## Connecting over OAuth

The server is also an OAuth 2.1 authorization server, which is what makes Claude's *Add custom connector* work: paste the endpoint URL, approve a consent screen, done. No token, no config file.

A client with no credential POSTs to the endpoint and gets a `401` whose `WWW-Authenticate` header carries a `resource_metadata` pointer. From there the flow is entirely standard:

| Endpoint | Purpose |
| --- | --- |
| `/.well-known/oauth-protected-resource` | Names the resource and its authorization server ([RFC 9728](https://www.rfc-editor.org/rfc/rfc9728)) |
| `/.well-known/oauth-authorization-server` | Endpoint locations and supported parameters ([RFC 8414](https://www.rfc-editor.org/rfc/rfc8414)) |
| `POST /api/oauth/register` | Dynamic client registration ([RFC 7591](https://www.rfc-editor.org/rfc/rfc7591)) |
| `GET /api/oauth/authorize` | Consent screen |
| `POST /api/oauth/token` | Code exchange and refresh |

Four constraints are worth stating because they are enforced rather than recommended:

- **Public clients only.** No client secret is issued. A desktop application cannot keep one, so issuing them would create a credential that looks confidential and is not. PKCE carries the proof instead.
- **`S256` only.** The `plain` challenge method is advertised nowhere and accepted nowhere.
- **`read` only.** The authorization server cannot issue a write scope at all. A connector asking for `write` is granted `read`, so no consent screen can be the thing that lets an outside application modify a medical record.
- **Authorization codes are single-use, and a replay is treated as theft.** Presenting a code twice means one of the two holders stole it and the server cannot tell which, so it revokes the entire grant — an attacker who wins the race still ends up with nothing.

The access token a connector receives is an ordinary API token. It appears in your token list under the name of the application that asked, it carries the same `read` scope, and revoking it disconnects the client immediately.

## Getting connected

If your client speaks OAuth, paste `https://nlight.fit/api/mcp` into its connector dialog and approve the screen.

Otherwise:

1. Mint an API token in Settings → MCP / CLI Access — see [token management](/docs/token-scripts).
2. Add the server to your client — see [client setup](/docs/clients).
3. Ask your client to list tools, and confirm you get eighteen back.

## Related

- [MCP tool reference](/docs/mcp-tools) — all eighteen tools with input schemas
- [Authentication](/docs/authentication) — how API tokens and scopes work
- [Errors and limits](/docs/errors) — status codes, rate limits, protocol versions
