Files
HealthLog/src/lib/openapi/routes/mcp.ts
T
Marc-André Bombeck c8035fdd78 Describe the integration, notification and MCP surfaces in the contract
Sixteen routes the native client already calls were absent from the OpenAPI
registry, so the published spec said nothing about them. `openapi:check`
compares the registry against the YAML and never the routes against the
registry, which is why the gap survived.

Nightscout status / test / disconnect, the WHOOP credential and status reads,
and the Withings status / disconnect pair land in the integrations module. The
three per-channel configs under `/api/settings/*` join the settings module; the
notification preference and delivery-health surfaces get their own module, as do
the MCP connector connections and tokens.

These are credential-bearing surfaces, so the contract states what each response
actually carries. Every channel GET reduces its secret to a boolean, and the
descriptions say so, along with the consequence: a client cannot read the value
back, so an empty value on the way in preserves the stored one rather than
clearing it. `POST /api/mcp/tokens` is the one operation that returns a usable
Bearer, and its description says outright that this is the only copy.

Two inline request schemas move to the validations modules they belong in, so
the registry documents the same object the handler parses rather than a
restatement of it.
2026-08-22 12:21:16 +02:00

277 lines
11 KiB
TypeScript

/**
* OpenAPI route table — MCP connector credentials.
*
* Part of the OpenAPI route table; aggregated in `./index.ts`. The request
* body comes from `src/lib/validations/mcp.ts` so the wire contract stays
* single-source; the response DTOs mirror the handlers under
* `src/app/api/mcp/*`.
*
* Two credential families reach the `/mcp` endpoint and both are managed here,
* because a user revoking access needs one place that lists everything that can
* read their record over MCP:
*
* - A **connection** is what the OAuth bridge (`/api/mcp/oauth/*`) creates
* for a remote connector. It outlives the 60-minute access tokens it
* issues, so revoking the connection ends the whole refresh chain.
* - A **connector token** is the manual / stdio path: a `hlk_` Bearer minted
* here, scoped `health:read` (optionally plus `health:write`), never the
* `["*"]` wildcard.
*
* The OAuth routes themselves are deliberately absent: they answer RFC-format
* error bodies rather than the standard envelope, and they are described by the
* OAuth metadata documents, not by this contract.
*/
import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import { createMcpTokenSchema } from "@/lib/validations/mcp";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
/**
* The operator kill switch. `isApiGloballyEnabled()` gates every route in this
* module, so an instance with the API turned off refuses connector management
* as well as connector traffic — a valid session does not get past it.
*/
const apiDisabledResponse = {
"403": {
description:
"The operator has disabled the API instance-wide. Every route in this module refuses while that setting is off, for a cookie session as well as for a Bearer token; it is not a per-user permission and retrying will not help.",
content: { "application/json": { schema: errorEnvelope } },
},
};
const mcpConnection = z
.object({
id: z.string(),
clientName: z
.string()
.describe(
"Name the OAuth client registered itself under. Client-supplied at registration time — display it as such, do not treat it as verified.",
),
scope: z
.string()
.describe("Space-separated scope string granted to this connection."),
createdAt: z.iso.datetime({ offset: true }),
lastUsedAt: z.iso
.datetime({ offset: true })
.nullable()
.describe("Null when the connection has never been exercised."),
})
.meta({
id: "McpConnection",
description:
"A live remote-connector authorization created through the MCP OAuth bridge. Carries no token material: the access tokens it issued are not addressable here, they are ended by revoking the connection.",
});
const mcpConnectorToken = z
.object({
id: z.string(),
name: z.string(),
permissions: z
.array(z.string())
.describe(
'The granted scopes — `["health:read"]` or `["health:read", "health:write"]`. A `health:write` token is audience-bound to `/mcp`: the resource-server guard refuses it on every REST write, so it only admits the confirmed in-process write tools.',
),
lastUsedAt: z.iso.datetime({ offset: true }).nullable(),
expiresAt: z.iso.datetime({ offset: true }).nullable(),
createdAt: z.iso.datetime({ offset: true }),
revoked: z
.boolean()
.describe(
"Revoked tokens stay in this list rather than disappearing from it, so a client must filter if it wants live credentials only.",
),
})
.meta({
id: "McpConnectorToken",
description:
"A manually-minted MCP connector token. The token VALUE is not part of this shape — it exists in one response only, the 201 that mints it.",
});
const createMcpTokenRequest = createMcpTokenSchema.meta({
id: "CreateMcpTokenRequest",
description:
"Mint a connector Bearer. `scope` is a closed two-value choice, not a permission list: `read` (the default) grants `health:read`, `read_write` adds `health:write`. The permission array is built field by field from that choice, so no request shape can widen the grant. `expiresInDays` defaults to 90.",
});
const createMcpTokenResponse = z
.object({
token: z
.string()
.describe(
"The raw `hlk_` Bearer, RETURNED EXACTLY ONCE. It is stored only as an HMAC-SHA256 hash, so there is no way to retrieve it later; a client that does not capture it here has to mint a replacement. Treat it as a secret in logs, crash reports and analytics.",
),
name: z.string(),
expiresAt: z.iso.datetime({ offset: true }),
})
.meta({
id: "CreateMcpTokenResponse",
description:
"The one response in the API that carries a usable Bearer token value for an MCP connector.",
});
export const mcpPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/mcp/connections": {
get: {
tags: ["MCP"],
summary: "List the caller's live MCP connector connections",
description:
"Returns the caller's non-revoked OAuth-bridge connections, newest first. `data` is the array itself, not nested under a named key. Auth via cookie or Bearer.",
responses: {
...apiDisabledResponse,
"200": {
description: "Live connections.",
content: {
"application/json": {
schema: dataEnvelope(
z.array(mcpConnection),
"McpConnectionListEnvelope",
),
},
},
},
...stdResponses,
},
},
},
"/api/mcp/connections/{id}": {
delete: {
tags: ["MCP"],
summary: "Revoke an MCP connector connection",
description:
"Stamps the connection revoked so every future refresh fails, and revokes every access token it ever issued. Ownership is enforced inside the revoke, so a connection id alone cannot reach somebody else's connection — an id the caller does not own is indistinguishable from one that does not exist. Auth via cookie or Bearer.",
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string" },
description: "Connection id from the list endpoint.",
},
],
responses: {
...apiDisabledResponse,
"200": {
description: "The connection was revoked.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ revoked: z.literal(true) }),
"McpConnectionRevokeEnvelope",
),
},
},
},
"404": {
description:
"No live connection with this id for this caller — also the answer for an already-revoked one, so a repeat revoke is 404 rather than an idempotent 200.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
},
"/api/mcp/tokens": {
get: {
tags: ["MCP"],
summary: "List the caller's MCP connector tokens",
description:
"Returns the caller's manually-minted `health:read` tokens, newest first, revoked ones included. Access tokens issued by the OAuth bridge are excluded — they are transient 60-minute rows that would flood the list, and they are surfaced and revoked as connections instead. `data` is the array itself, not nested under a named key. Auth via cookie or Bearer.",
responses: {
...apiDisabledResponse,
"200": {
description: "Connector tokens.",
content: {
"application/json": {
schema: dataEnvelope(
z.array(mcpConnectorToken),
"McpConnectorTokenListEnvelope",
),
},
},
},
...stdResponses,
},
},
post: {
tags: ["MCP"],
summary: "Mint an MCP connector token",
description:
"Mints a `health:read` (optionally read+write) Bearer for the manual / stdio path and audits the mint. THE RESPONSE CARRIES THE RAW TOKEN — this is the only place it ever exists, and it cannot be retrieved again. Body capped at 16 KiB. Auth via cookie or Bearer.",
requestBody: {
required: true,
content: { "application/json": { schema: createMcpTokenRequest } },
},
responses: {
...apiDisabledResponse,
"201": {
description:
"Token minted. The `token` field is the only copy of the secret.",
content: {
"application/json": {
schema: dataEnvelope(
createMcpTokenResponse,
"CreateMcpTokenEnvelope",
),
},
},
},
"400": {
description: "Body is not parseable JSON.",
content: { "application/json": { schema: errorEnvelope } },
},
"413": {
description: "Body exceeds 16 KiB.",
content: { "application/json": { schema: errorEnvelope } },
},
"415": {
description: "Content-Type is not `application/json`.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
"422": {
description:
"Validation failed. The envelope carries every offending issue, not just the first.",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
},
"/api/mcp/tokens/{id}": {
delete: {
tags: ["MCP"],
summary: "Revoke an MCP connector token",
description:
"Marks the token revoked and audits it. The lookup requires the token to be the caller's own AND to carry `health:read`, so this endpoint can never revoke a differently-scoped credential by id alone — a native-client or ingest token is a 404 here. Auth via cookie or Bearer.",
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string" },
description: "Token id from the list endpoint.",
},
],
responses: {
...apiDisabledResponse,
"200": {
description: "The token was revoked.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ revoked: z.literal(true) }),
"McpConnectorTokenRevokeEnvelope",
),
},
},
},
"404": {
description:
"No `health:read` token with this id for this caller. An already-revoked token is still found, so a repeat revoke answers 200.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
},
};