mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
feat(mcp): ship search/fetch tools, annotations, and the Origin gate
- Add the `search` + `fetch` retrieval pair with the exact ChatGPT wire
shapes (`{id,title,url}[]` and `{id,title,text,url,metadata}`), backed by
the same server-authoritative reads and returning real deep-link URLs into
the web app for citations. Without these the server is invisible in
default-mode ChatGPT.
- Annotate every read tool `readOnlyHint`/`destructiveHint:false`/
`openWorldHint:false`; structured tools also declare an `outputSchema` and
return `structuredContent`. Omitting the hints is a validation error in the
connectors.
- Reject a cross-origin browser request to `/mcp` with 403 (DNS-rebinding
defense) and point the 401 `WWW-Authenticate` at the resource metadata.
This commit is contained in:
1 parent
7ec3bf0dad
commit
63e4b17843
6 files changed
+486
-10
No files matched your search
@@ -67,6 +67,8 @@ const READ_TOOLS = [
|
||||
"get_medication_compliance",
|
||||
"get_metric_series",
|
||||
"list_metrics",
|
||||
"search",
|
||||
"fetch",
|
||||
].sort();
|
||||
|
||||
/** A valid, narrow-scope (`health:read`) token resolution. */
|
||||
@@ -148,6 +150,48 @@ describe("/mcp — happy path", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("/mcp — Origin gate (DNS-rebinding defense, spec PR #1439)", () => {
|
||||
it("rejects a cross-origin browser request with 403 before auth", async () => {
|
||||
validToken();
|
||||
const res = await POST(
|
||||
postRpc(TOOLS_LIST, { origin: "https://evil.example" }),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
// The Origin check runs first — the token table is never consulted.
|
||||
expect(resolveBearerToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows a same-origin request", async () => {
|
||||
validToken();
|
||||
const res = await POST(postRpc(TOOLS_LIST, { origin: "http://localhost" }));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("allows a request with no Origin header (non-browser MCP client)", async () => {
|
||||
validToken();
|
||||
const res = await POST(postRpc(TOOLS_LIST));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("/mcp — 401 carries the RFC 9728 resource_metadata pointer", () => {
|
||||
it("includes resource_metadata in the WWW-Authenticate challenge", async () => {
|
||||
const res = await POST(
|
||||
new Request("http://localhost/mcp", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(TOOLS_LIST),
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
const challenge = res.headers.get("www-authenticate") ?? "";
|
||||
expect(challenge).toMatch(/Bearer/);
|
||||
expect(challenge).toMatch(
|
||||
/resource_metadata="[^"]*\/\.well-known\/oauth-protected-resource"/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("/mcp — authentication", () => {
|
||||
it("rejects a request with no Authorization header (401 + challenge)", async () => {
|
||||
const res = await POST(
|
||||
|
||||
+37
-7
@@ -47,6 +47,8 @@ import { withBackgroundEvent } from "@/lib/logging/background";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkMcpRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { resolveBaseOrigin } from "@/lib/mcp/oauth/config";
|
||||
import { wwwAuthenticateChallenge } from "@/lib/mcp/oauth/metadata";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
|
||||
// The Bearer resolver + module gate + rate limiter all touch Prisma, so the
|
||||
@@ -56,19 +58,41 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
const MCP_MODULE: ModuleKey = "mcp";
|
||||
|
||||
/** One blunt 401 for every authentication failure class — no oracle. */
|
||||
function unauthorized(): Response {
|
||||
/**
|
||||
* One blunt 401 for every authentication failure class — no oracle. The
|
||||
* `WWW-Authenticate` carries the RFC 9728 `resource_metadata` pointer so a
|
||||
* remote client can discover the Authorization Server and start the OAuth flow
|
||||
* (REQ-T3). An optional `scope` drives incremental consent (SEP-835).
|
||||
*/
|
||||
function unauthorized(request: Request, scope?: string): Response {
|
||||
return Response.json(
|
||||
{ error: "unauthorized" },
|
||||
{
|
||||
status: 401,
|
||||
// Pre-OAuth bridge: a bare Bearer challenge. The RFC 9728
|
||||
// `resource_metadata` parameter is added when OAuth discovery lands.
|
||||
headers: { "WWW-Authenticate": 'Bearer realm="healthlog-mcp"' },
|
||||
headers: {
|
||||
"WWW-Authenticate": wwwAuthenticateChallenge(request.url, scope),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* DNS-rebinding defense (MCP spec PR #1439): reject a request whose `Origin`
|
||||
* header does not match this deployment's own origin with a 403. A non-browser
|
||||
* MCP client (Claude.ai / ChatGPT server-side) sends no `Origin`, so the absence
|
||||
* of the header is allowed; only a present, mismatched browser `Origin` is
|
||||
* refused — closing the rebinding hole without breaking the real clients.
|
||||
*/
|
||||
function originAllowed(request: Request): boolean {
|
||||
const origin = request.headers.get("origin");
|
||||
if (!origin) return true;
|
||||
return origin === resolveBaseOrigin(request.url);
|
||||
}
|
||||
|
||||
function forbiddenOrigin(): Response {
|
||||
return Response.json({ error: "forbidden_origin" }, { status: 403 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Module off → the surface does not exist for this account. A 404 (not a
|
||||
* 403) keeps the remote endpoint invisible until the operator / user opts
|
||||
@@ -101,11 +125,17 @@ function readBearer(request: Request): string | null {
|
||||
|
||||
async function handleMcp(request: Request): Promise<Response> {
|
||||
return withBackgroundEvent("mcp.request", async () => {
|
||||
// 0. ORIGIN — refuse a cross-origin browser request before anything else.
|
||||
if (!originAllowed(request)) {
|
||||
annotate({ action: { name: "mcp.origin.rejected" } });
|
||||
return forbiddenOrigin();
|
||||
}
|
||||
|
||||
// 1. AUTH — Bearer only; a cookie is never consulted (admin unreachable).
|
||||
const raw = readBearer(request);
|
||||
if (!raw) {
|
||||
annotate({ action: { name: "mcp.auth.missing" } });
|
||||
return unauthorized();
|
||||
return unauthorized(request);
|
||||
}
|
||||
|
||||
let ctx;
|
||||
@@ -114,7 +144,7 @@ async function handleMcp(request: Request): Promise<Response> {
|
||||
} catch {
|
||||
// Do not echo the token or the rejection reason — one blunt 401.
|
||||
annotate({ action: { name: "mcp.auth.rejected" } });
|
||||
return unauthorized();
|
||||
return unauthorized(request);
|
||||
}
|
||||
|
||||
// 2. RATE LIMIT — per `<userId>:<tokenId>` credential binding.
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* `search` + `fetch` — the ChatGPT default-mode retrieval pair.
|
||||
*
|
||||
* Pins the EXACT wire shapes OpenAI mandates (`{id,title,url}[]` /
|
||||
* `{id,title,text,url,metadata}`), real deep-link URLs, and the read-only
|
||||
* annotations.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
process.env.APP_URL = "https://health.example";
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/ai/coach/tools/inventory", () => ({
|
||||
buildCoachDataInventory: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/tools/executor", () => ({ executeCoachTool: vi.fn() }));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medication: { findMany: vi.fn(), findFirst: vi.fn() },
|
||||
labResult: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
import { MCP_TOOLS } from "../tools";
|
||||
import { buildCoachDataInventory } from "@/lib/ai/coach/tools/inventory";
|
||||
import { executeCoachTool } from "@/lib/ai/coach/tools/executor";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { McpAuthContext } from "../auth";
|
||||
|
||||
const CTX: McpAuthContext = {
|
||||
userId: "user-1",
|
||||
tokenId: "token-1",
|
||||
scopes: ["health:read"],
|
||||
binding: "user-1:token-1",
|
||||
canRead: true,
|
||||
canWrite: false,
|
||||
};
|
||||
|
||||
function tool(name: string) {
|
||||
const def = MCP_TOOLS.find((t) => t.name === name);
|
||||
if (!def) throw new Error(`tool ${name} not registered`);
|
||||
return def;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(buildCoachDataInventory).mockResolvedValue({
|
||||
entries: [
|
||||
{
|
||||
tool: "get_metric_series",
|
||||
metric: "weight",
|
||||
domain: "weight",
|
||||
present: true,
|
||||
count: 10,
|
||||
},
|
||||
],
|
||||
window: "90d",
|
||||
restMode: false,
|
||||
cycleEnabled: false,
|
||||
} as never);
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
{ id: "med-1", name: "Ramipril", dose: "5mg" },
|
||||
] as never);
|
||||
vi.mocked(prisma.labResult.findMany).mockResolvedValue([
|
||||
{ analyte: "LDL" },
|
||||
] as never);
|
||||
});
|
||||
|
||||
describe("search", () => {
|
||||
it("returns { results: [{id,title,url}] } with real deep-link URLs", async () => {
|
||||
const def = tool("search");
|
||||
const result = (await def.run(CTX, { query: "" })) as {
|
||||
results: Array<{ id: string; title: string; url: string }>;
|
||||
};
|
||||
|
||||
// Output conforms to the declared schema.
|
||||
const schema = z.object(def.outputShape!);
|
||||
expect(schema.safeParse(result).success).toBe(true);
|
||||
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
for (const r of result.results) {
|
||||
expect(r.id).toMatch(/^(metric|med|lab|domain):/);
|
||||
// ChatGPT only cites when `url` is a non-empty absolute URL.
|
||||
expect(r.url.startsWith("https://health.example/")).toBe(true);
|
||||
}
|
||||
// The internal id stays separate from the citation url.
|
||||
const med = result.results.find((r) => r.id === "med:med-1");
|
||||
expect(med?.url).toBe("https://health.example/medications");
|
||||
});
|
||||
|
||||
it("filters by query (case-insensitive substring)", async () => {
|
||||
const def = tool("search");
|
||||
const result = (await def.run(CTX, { query: "ldl" })) as {
|
||||
results: Array<{ id: string }>;
|
||||
};
|
||||
expect(result.results.map((r) => r.id)).toContain("lab:LDL");
|
||||
expect(result.results.some((r) => r.id.startsWith("med:"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetch", () => {
|
||||
it("returns { id,title,text,url,metadata } for a metric id", async () => {
|
||||
vi.mocked(executeCoachTool).mockResolvedValue({
|
||||
present: true,
|
||||
data: { mean: 80 },
|
||||
} as never);
|
||||
const def = tool("fetch");
|
||||
const result = (await def.run(CTX, { id: "metric:weight" })) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const schema = z.object(def.outputShape!);
|
||||
expect(schema.safeParse(result).success).toBe(true);
|
||||
expect(result.id).toBe("metric:weight");
|
||||
expect(result.url).toBe("https://health.example/insights");
|
||||
expect(typeof result.text).toBe("string");
|
||||
});
|
||||
|
||||
it("returns a not-found shape for an unknown id (still schema-valid)", async () => {
|
||||
const def = tool("fetch");
|
||||
const result = (await def.run(CTX, { id: "bogus" })) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const schema = z.object(def.outputShape!);
|
||||
expect(schema.safeParse(result).success).toBe(true);
|
||||
expect(result.title).toBe("Not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("annotations", () => {
|
||||
it("search + fetch are read-only, non-destructive, closed-world", () => {
|
||||
for (const name of ["search", "fetch"]) {
|
||||
const a = tool(name).annotations;
|
||||
expect(a.readOnlyHint).toBe(true);
|
||||
expect(a.destructiveHint).toBe(false);
|
||||
expect(a.openWorldHint).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,17 @@ vi.mock("@/lib/ai/coach/tools/inventory", () => ({
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
// v1.22.0 — `search` reads the record directly via Prisma; stub it so the
|
||||
// registry-wide loops never reach a DB.
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medication: {
|
||||
findMany: vi.fn(async () => []),
|
||||
findFirst: vi.fn(async () => null),
|
||||
},
|
||||
labResult: { findMany: vi.fn(async () => []) },
|
||||
},
|
||||
}));
|
||||
|
||||
import { MCP_TOOLS, MCP_TOOL_NAMES } from "../tools";
|
||||
import { executeCoachTool } from "@/lib/ai/coach/tools/executor";
|
||||
@@ -44,10 +55,21 @@ describe("MCP tool registry — surface", () => {
|
||||
"get_medication_compliance",
|
||||
"get_metric_series",
|
||||
"list_metrics",
|
||||
// v1.22.0 — the ChatGPT default-mode retrieval pair.
|
||||
"search",
|
||||
"fetch",
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("annotates every tool read-only / closed-world (cloud-connector requirement)", () => {
|
||||
for (const def of MCP_TOOLS) {
|
||||
expect(def.annotations.readOnlyHint).toBe(true);
|
||||
expect(def.annotations.destructiveHint).toBe(false);
|
||||
expect(def.annotations.openWorldHint).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes no admin / write tool", () => {
|
||||
for (const name of MCP_TOOL_NAMES) {
|
||||
expect(name).not.toMatch(/admin/i);
|
||||
|
||||
+18
-3
@@ -39,12 +39,27 @@ export function createMcpServer(ctx: McpAuthContext): McpServer {
|
||||
title: tool.title,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputShape,
|
||||
// Annotations are MANDATORY for the cloud connectors — the ChatGPT
|
||||
// Apps SDK treats an omitted hint as a validation error. Every read
|
||||
// tool is read-only, non-destructive, closed-world (ADR-003).
|
||||
annotations: tool.annotations,
|
||||
...(tool.outputShape ? { outputSchema: tool.outputShape } : {}),
|
||||
},
|
||||
async (args: Record<string, unknown>) => {
|
||||
const result = await tool.run(ctx, args ?? {});
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
||||
};
|
||||
const content = [
|
||||
{ type: "text" as const, text: JSON.stringify(result) },
|
||||
];
|
||||
// When the tool declares an outputSchema (search / fetch), also return
|
||||
// the object as `structuredContent` so ChatGPT + the model can read the
|
||||
// typed shape; the JSON-in-`content` keeps backwards compatibility.
|
||||
if (tool.outputShape) {
|
||||
return {
|
||||
content,
|
||||
structuredContent: result as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
return { content };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { executeCoachTool } from "@/lib/ai/coach/tools/executor";
|
||||
import { buildCoachDataInventory } from "@/lib/ai/coach/tools/inventory";
|
||||
@@ -26,8 +27,24 @@ import {
|
||||
coachScopeSourceSchema,
|
||||
coachScopeWindowSchema,
|
||||
} from "@/lib/ai/coach/types";
|
||||
import { resolveBaseOrigin } from "@/lib/mcp/oauth/config";
|
||||
import type { McpAuthContext } from "./auth";
|
||||
|
||||
/**
|
||||
* Tool annotations (MCP 2025-11-25). The cloud connectors REQUIRE these on every
|
||||
* tool — the ChatGPT Apps SDK treats an omitted hint as a validation error, and
|
||||
* the Claude directory requires a safety hint per tool. Every tool the MCP
|
||||
* surface exposes is read-only over a closed personal record, so they all share
|
||||
* the same shape (ADR-003): the read-only guarantee is structural, the
|
||||
* annotation merely advertises it.
|
||||
*/
|
||||
const READ_ONLY_ANNOTATIONS = {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* A registry entry. `inputShape` is a zod/v4 raw shape handed straight to the
|
||||
* SDK's `registerTool` for argument validation; `run` receives the validated
|
||||
@@ -38,6 +55,19 @@ export interface McpToolDefinition {
|
||||
title: string;
|
||||
description: string;
|
||||
inputShape: z.ZodRawShape;
|
||||
/** Tool annotations — mandatory for the cloud connectors (see above). */
|
||||
annotations: {
|
||||
readOnlyHint: boolean;
|
||||
destructiveHint: boolean;
|
||||
idempotentHint: boolean;
|
||||
openWorldHint: boolean;
|
||||
};
|
||||
/**
|
||||
* When set, the tool declares a structured `outputSchema` and its result is
|
||||
* returned as `structuredContent`. Used by `search` / `fetch` so ChatGPT can
|
||||
* consume the exact `{id,title,url}[]` / `{id,title,text,url,metadata}` shapes.
|
||||
*/
|
||||
outputShape?: z.ZodRawShape;
|
||||
run: (ctx: McpAuthContext, args: Record<string, unknown>) => Promise<unknown>;
|
||||
}
|
||||
|
||||
@@ -65,6 +95,194 @@ async function runCoachTool(
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `search` + `fetch` pair — the de-facto two-tool retrieval convention and,
|
||||
* critically, the ONLY tools ChatGPT can call in its default (non-Developer)
|
||||
* mode. Without them HealthLog is invisible in default ChatGPT. The exact wire
|
||||
* shapes are mandated by OpenAI:
|
||||
*
|
||||
* - `search({ query })` → `{ results: [{ id, title, url }] }`
|
||||
* - `fetch({ id })` → `{ id, title, text, url, metadata? }`
|
||||
*
|
||||
* Every result carries a REAL, user-openable HTTPS deep link (`url`) into the
|
||||
* HealthLog web app — ChatGPT only creates citation metadata when `url` is a
|
||||
* non-empty string. The internal `id` stays separate from `url`. Both tools are
|
||||
* thin façades over the SAME server-authoritative read paths the other tools
|
||||
* use; free-text fields (medication names, lab analytes) are returned as DATA,
|
||||
* never interpreted as instructions (R-SEC-2).
|
||||
*/
|
||||
function searchAndFetchTools(): McpToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "search",
|
||||
title: "Search your health records",
|
||||
description:
|
||||
"Search the user's own health record — metric domains, medications, and lab biomarkers — for items matching a free-text query. Returns a list of { id, title, url }; pass an id to the `fetch` tool to hydrate it. Each `url` deep-links into the HealthLog web app for citation. Returns an empty list when nothing matches.",
|
||||
inputShape: { query: z.string().max(200) },
|
||||
annotations: READ_ONLY_ANNOTATIONS,
|
||||
outputShape: {
|
||||
results: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
url: z.string(),
|
||||
}),
|
||||
),
|
||||
},
|
||||
async run(ctx, args) {
|
||||
const query =
|
||||
typeof args.query === "string" ? args.query.trim().toLowerCase() : "";
|
||||
const origin = resolveBaseOrigin();
|
||||
const results: Array<{ id: string; title: string; url: string }> = [];
|
||||
|
||||
const inventory = await buildCoachDataInventory(ctx.userId, undefined);
|
||||
for (const entry of inventory.entries) {
|
||||
if (!entry.present) continue;
|
||||
const hay = `${entry.domain} ${entry.metric ?? ""}`.toLowerCase();
|
||||
if (query && !hay.includes(query)) continue;
|
||||
results.push({
|
||||
id: entry.metric
|
||||
? `metric:${entry.metric}`
|
||||
: `domain:${entry.domain}`,
|
||||
title: entry.domain,
|
||||
url: `${origin}/insights`,
|
||||
});
|
||||
}
|
||||
|
||||
const meds = await prisma.medication.findMany({
|
||||
where: { userId: ctx.userId },
|
||||
select: { id: true, name: true, dose: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 200,
|
||||
});
|
||||
for (const med of meds) {
|
||||
if (query && !med.name.toLowerCase().includes(query)) continue;
|
||||
results.push({
|
||||
id: `med:${med.id}`,
|
||||
title: med.dose ? `${med.name} ${med.dose}` : med.name,
|
||||
url: `${origin}/medications`,
|
||||
});
|
||||
}
|
||||
|
||||
const labs = await prisma.labResult.findMany({
|
||||
where: { userId: ctx.userId, deletedAt: null },
|
||||
select: { analyte: true },
|
||||
distinct: ["analyte"],
|
||||
take: 200,
|
||||
});
|
||||
for (const lab of labs) {
|
||||
if (query && !lab.analyte.toLowerCase().includes(query)) continue;
|
||||
results.push({
|
||||
id: `lab:${lab.analyte}`,
|
||||
title: lab.analyte,
|
||||
url: `${origin}/labs`,
|
||||
});
|
||||
}
|
||||
|
||||
const capped = results.slice(0, 50);
|
||||
annotate({
|
||||
action: { name: "mcp.tool.invoked" },
|
||||
meta: { tool: "search", present: capped.length > 0 },
|
||||
});
|
||||
return { results: capped };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fetch",
|
||||
title: "Fetch one health record",
|
||||
description:
|
||||
"Hydrate a single record returned by `search`, by its id (e.g. `metric:weight`, `med:<id>`, `lab:LDL`). Returns { id, title, text, url, metadata } where `text` is a server-authoritative, plain-text summary suitable for citation and `url` deep-links into HealthLog. Returns a not-found message when the id does not resolve.",
|
||||
inputShape: { id: z.string().min(1).max(200) },
|
||||
annotations: READ_ONLY_ANNOTATIONS,
|
||||
outputShape: {
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
text: z.string(),
|
||||
url: z.string(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
},
|
||||
async run(ctx, args) {
|
||||
const id = typeof args.id === "string" ? args.id : "";
|
||||
const origin = resolveBaseOrigin();
|
||||
const sep = id.indexOf(":");
|
||||
const kind = sep > 0 ? id.slice(0, sep) : "";
|
||||
const rid = sep > 0 ? id.slice(sep + 1) : "";
|
||||
|
||||
annotate({
|
||||
action: { name: "mcp.tool.invoked" },
|
||||
meta: { tool: "fetch", kind: kind || "unknown" },
|
||||
});
|
||||
|
||||
if (kind === "metric" && rid) {
|
||||
const result = await executeCoachTool({
|
||||
userId: ctx.userId,
|
||||
name: "get_metric_series",
|
||||
rawArguments: JSON.stringify({ metric: rid }),
|
||||
});
|
||||
return {
|
||||
id,
|
||||
title: rid,
|
||||
text: JSON.stringify(result),
|
||||
url: `${origin}/insights`,
|
||||
metadata: { type: "metric", metric: rid },
|
||||
};
|
||||
}
|
||||
|
||||
if (kind === "lab" && rid) {
|
||||
const result = await executeCoachTool({
|
||||
userId: ctx.userId,
|
||||
name: "get_labs",
|
||||
rawArguments: JSON.stringify({ analyte: rid }),
|
||||
});
|
||||
return {
|
||||
id,
|
||||
title: rid,
|
||||
text: JSON.stringify(result),
|
||||
url: `${origin}/labs`,
|
||||
metadata: { type: "lab", analyte: rid },
|
||||
};
|
||||
}
|
||||
|
||||
if (kind === "med" && rid) {
|
||||
const med = await prisma.medication.findFirst({
|
||||
where: { id: rid, userId: ctx.userId },
|
||||
select: {
|
||||
name: true,
|
||||
dose: true,
|
||||
treatmentClass: true,
|
||||
asNeeded: true,
|
||||
},
|
||||
});
|
||||
if (!med) {
|
||||
return {
|
||||
id,
|
||||
title: "Not found",
|
||||
text: "No medication matches this id.",
|
||||
url: `${origin}/medications`,
|
||||
metadata: { type: "medication" },
|
||||
};
|
||||
}
|
||||
return {
|
||||
id,
|
||||
title: med.name,
|
||||
text: JSON.stringify(med),
|
||||
url: `${origin}/medications`,
|
||||
metadata: { type: "medication" },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
title: "Not found",
|
||||
text: `No record matches the id "${id}".`,
|
||||
url: `${origin}/insights`,
|
||||
metadata: { type: "unknown" },
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const MCP_TOOLS: McpToolDefinition[] = [
|
||||
{
|
||||
name: "list_metrics",
|
||||
@@ -72,6 +290,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [
|
||||
description:
|
||||
"Enumerate which of the user's health data exists and how to fetch it: one row per domain with whether data is present, an approximate sample count, and the tool that retrieves it. Call this first to discover what is available before fetching figures.",
|
||||
inputShape: {},
|
||||
annotations: READ_ONLY_ANNOTATIONS,
|
||||
async run(ctx) {
|
||||
const inventory = await buildCoachDataInventory(ctx.userId, undefined);
|
||||
annotate({
|
||||
@@ -96,6 +315,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [
|
||||
metric: coachScopeSourceSchema,
|
||||
window: coachScopeWindowSchema.optional(),
|
||||
},
|
||||
annotations: READ_ONLY_ANNOTATIONS,
|
||||
run(ctx, args) {
|
||||
return runCoachTool(ctx, "get_metric_series", args);
|
||||
},
|
||||
@@ -108,6 +328,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [
|
||||
inputShape: {
|
||||
window: coachScopeWindowSchema.optional(),
|
||||
},
|
||||
annotations: READ_ONLY_ANNOTATIONS,
|
||||
run(ctx, args) {
|
||||
return runCoachTool(ctx, "get_medication_compliance", args);
|
||||
},
|
||||
@@ -120,6 +341,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [
|
||||
inputShape: {
|
||||
analyte: z.string().min(1).max(80).optional(),
|
||||
},
|
||||
annotations: READ_ONLY_ANNOTATIONS,
|
||||
run(ctx, args) {
|
||||
return runCoachTool(ctx, "get_labs", args);
|
||||
},
|
||||
@@ -130,10 +352,12 @@ export const MCP_TOOLS: McpToolDefinition[] = [
|
||||
description:
|
||||
"Fetch the user's statistically-vetted (FDR-controlled) day-to-next-day driver pairs between behaviours (daylight, mood, glucose, blood pressure, steps) and outcomes (sleep, HRV, resting HR, weight), each with direction, lag, sample size, and a descriptive — never causal — note over a fixed trailing window. Returns { present: false } when too little paired data exists.",
|
||||
inputShape: {},
|
||||
annotations: READ_ONLY_ANNOTATIONS,
|
||||
run(ctx, args) {
|
||||
return runCoachTool(ctx, "get_correlations", args);
|
||||
},
|
||||
},
|
||||
...searchAndFetchTools(),
|
||||
];
|
||||
|
||||
/** Stable list of the registered tool names. */
|
||||
|
||||
Reference in new issue
Block a user