mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(mcp): expose glucose, sleep, workout, illness, and cycle reads
Bridge the five already-built, grounded Coach retrieval tools the inventory already advertised but the wire was missing — closing the advertise-but-missing self-inconsistency — each a thin wrapper over the existing read with the same gates and present:false contract. Add a multi-metric fan-out that loops the single-metric read, and a per-analyte lab-history mode on the labs read. Declare a structured output schema on every remaining tool so structured content validates for both a miss and a hit. Page the large reads (search, lab history, multi-metric) behind an opaque cursor. Return plain-text, citation-ready summaries from fetch with per-item deep links instead of a JSON dump.
This commit is contained in:
1 parent
b40a963cb1
commit
af8afde63a
4 files changed
+601
-25
No files matched your search
@@ -81,6 +81,14 @@ const READ_TOOLS = [
|
||||
"compare_metric",
|
||||
"get_metric_baseline",
|
||||
"detect_changepoints",
|
||||
// v1.24 — Coach-F1 reads bridged to the wire.
|
||||
"get_glucose_panel",
|
||||
"get_sleep",
|
||||
"get_workouts",
|
||||
"get_illness_recovery",
|
||||
"get_cycle",
|
||||
// v1.24 — multi-metric fan-out.
|
||||
"get_metrics",
|
||||
].sort();
|
||||
|
||||
/** A valid, narrow-scope (`health:read`) token resolution. */
|
||||
@@ -342,6 +350,7 @@ describe("/mcp — write tools gate on health:write", () => {
|
||||
);
|
||||
expect(names).toContain("log_measurement");
|
||||
expect(names).toContain("log_mood");
|
||||
expect(names).toContain("log_blood_pressure");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -100,10 +100,11 @@ describe("search", () => {
|
||||
});
|
||||
|
||||
describe("fetch", () => {
|
||||
it("returns { id,title,text,url,metadata } for a metric id", async () => {
|
||||
it("returns { id,title,text,url,metadata } for a metric id with a plain-text summary + deep link", async () => {
|
||||
vi.mocked(executeCoachTool).mockResolvedValue({
|
||||
present: true,
|
||||
data: { mean: 80 },
|
||||
data: { section: { aggregate: { mean: 80, count: 12 } } },
|
||||
grounding: "Population band 60–100.",
|
||||
} as never);
|
||||
const def = tool("fetch");
|
||||
const result = (await def.run(CTX, { id: "metric:weight" })) as Record<
|
||||
@@ -113,8 +114,29 @@ describe("fetch", () => {
|
||||
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");
|
||||
// Per-item deep-link, not just the section root.
|
||||
expect(result.url).toBe("https://health.example/insights?metric=weight");
|
||||
// Plain-text prose, not a JSON blob.
|
||||
expect(typeof result.text).toBe("string");
|
||||
expect(result.text as string).not.toContain("{");
|
||||
expect(result.text as string).toContain("80");
|
||||
});
|
||||
|
||||
it("deep-links a medication id to the per-item page with prose text", async () => {
|
||||
vi.mocked(prisma.medication.findFirst).mockResolvedValue({
|
||||
name: "Ramipril",
|
||||
dose: "5mg",
|
||||
treatmentClass: null,
|
||||
asNeeded: false,
|
||||
} as never);
|
||||
const def = tool("fetch");
|
||||
const result = (await def.run(CTX, { id: "med:med-1" })) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(result.url).toBe("https://health.example/medications/med-1");
|
||||
expect(result.text as string).not.toContain("{");
|
||||
expect(result.text as string).toContain("Ramipril");
|
||||
});
|
||||
|
||||
it("returns a not-found shape for an unknown id (still schema-valid)", async () => {
|
||||
|
||||
@@ -29,11 +29,14 @@ vi.mock("../rich-reads", () => ({
|
||||
compareMetric: vi.fn(async () => ({ present: true })),
|
||||
getMetricBaseline: vi.fn(async () => ({ present: true })),
|
||||
detectChangepoints: vi.fn(async () => ({ present: true })),
|
||||
getLabHistory: vi.fn(async () => ({ present: true })),
|
||||
LAB_HISTORY_MAX_LIMIT: 50,
|
||||
}));
|
||||
|
||||
import { MCP_TOOLS, MCP_TOOL_NAMES } from "../tools";
|
||||
import { executeCoachTool } from "@/lib/ai/coach/tools/executor";
|
||||
import { buildCoachDataInventory } from "@/lib/ai/coach/tools/inventory";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { McpAuthContext } from "../auth";
|
||||
|
||||
const CTX: McpAuthContext = {
|
||||
@@ -56,7 +59,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("MCP tool registry — surface", () => {
|
||||
it("registers exactly the read tools (Phase 1 + Phase 4)", () => {
|
||||
it("registers exactly the read tools", () => {
|
||||
expect([...MCP_TOOL_NAMES].sort()).toEqual(
|
||||
[
|
||||
"get_correlations",
|
||||
@@ -72,10 +75,43 @@ describe("MCP tool registry — surface", () => {
|
||||
"compare_metric",
|
||||
"get_metric_baseline",
|
||||
"detect_changepoints",
|
||||
// v1.24 — Coach-F1 reads bridged to the wire.
|
||||
"get_glucose_panel",
|
||||
"get_sleep",
|
||||
"get_workouts",
|
||||
"get_illness_recovery",
|
||||
"get_cycle",
|
||||
// v1.24 — multi-metric fan-out.
|
||||
"get_metrics",
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("every read tool declares a structured outputSchema", () => {
|
||||
for (const def of MCP_TOOLS) {
|
||||
expect(def.outputShape, `${def.name} lacks outputShape`).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("advertised inventory tools all exist on the wire (no advertise-but-missing drift)", () => {
|
||||
// The `list_metrics` inventory advertises a fixed set of tool names; every
|
||||
// one must be a registered read tool or the wire is self-inconsistent.
|
||||
const advertised = [
|
||||
"get_metric_series",
|
||||
"get_glucose_panel",
|
||||
"get_sleep",
|
||||
"get_medication_compliance",
|
||||
"get_workouts",
|
||||
"get_labs",
|
||||
"get_illness_recovery",
|
||||
"get_correlations",
|
||||
"get_cycle",
|
||||
];
|
||||
for (const name of advertised) {
|
||||
expect(MCP_TOOL_NAMES).toContain(name);
|
||||
}
|
||||
});
|
||||
|
||||
it("annotates every tool read-only / closed-world (cloud-connector requirement)", () => {
|
||||
for (const def of MCP_TOOLS) {
|
||||
expect(def.annotations.readOnlyHint).toBe(true);
|
||||
@@ -252,3 +288,106 @@ describe("no prose verdict (ADR-004 / REQ-SEC-2)", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("get_metrics — multi-metric fan-out + pagination", () => {
|
||||
it("fans out over get_metric_series and returns one result per metric", async () => {
|
||||
vi.mocked(executeCoachTool).mockResolvedValue({
|
||||
present: true,
|
||||
data: { metric: "x", section: { aggregate: { mean: 1 } } },
|
||||
});
|
||||
const result = (await tool("get_metrics").run(CTX, {
|
||||
metrics: ["weight", "pulse", "hrv"],
|
||||
window: "last30days",
|
||||
})) as {
|
||||
present: boolean;
|
||||
results: Array<{ metric: string; present: boolean }>;
|
||||
nextCursor?: string;
|
||||
};
|
||||
expect(result.present).toBe(true);
|
||||
expect(result.results.map((r) => r.metric)).toEqual([
|
||||
"weight",
|
||||
"pulse",
|
||||
"hrv",
|
||||
]);
|
||||
// The window threads through to the single-metric read.
|
||||
expect(executeCoachTool).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "get_metric_series",
|
||||
rawArguments: JSON.stringify({
|
||||
metric: "weight",
|
||||
window: "last30days",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result.nextCursor).toBeUndefined();
|
||||
});
|
||||
|
||||
it("paginates with an opaque cursor that round-trips", async () => {
|
||||
vi.mocked(executeCoachTool).mockResolvedValue({ present: true, data: {} });
|
||||
const metrics = Array.from({ length: 10 }, (_, i) => `m${i}`);
|
||||
const page1 = (await tool("get_metrics").run(CTX, { metrics })) as {
|
||||
results: Array<{ metric: string }>;
|
||||
nextCursor?: string;
|
||||
};
|
||||
// First page is bounded (METRICS_PAGE_SIZE = 8) and offers a cursor.
|
||||
expect(page1.results).toHaveLength(8);
|
||||
expect(typeof page1.nextCursor).toBe("string");
|
||||
|
||||
const page2 = (await tool("get_metrics").run(CTX, {
|
||||
metrics,
|
||||
cursor: page1.nextCursor,
|
||||
})) as { results: Array<{ metric: string }>; nextCursor?: string };
|
||||
expect(page2.results).toHaveLength(2);
|
||||
expect(page2.results[0].metric).toBe("m8");
|
||||
expect(page2.nextCursor).toBeUndefined();
|
||||
});
|
||||
|
||||
it("caps the metrics array at the per-call maximum", async () => {
|
||||
vi.mocked(executeCoachTool).mockResolvedValue({ present: true, data: {} });
|
||||
const metrics = Array.from({ length: 40 }, (_, i) => `m${i}`);
|
||||
// Page through and count distinct metrics actually fetched.
|
||||
const seen = new Set<string>();
|
||||
let cursor: string | undefined;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const page = (await tool("get_metrics").run(CTX, {
|
||||
metrics,
|
||||
...(cursor ? { cursor } : {}),
|
||||
})) as { results: Array<{ metric: string }>; nextCursor?: string };
|
||||
for (const r of page.results) seen.add(r.metric);
|
||||
cursor = page.nextCursor;
|
||||
if (!cursor) break;
|
||||
}
|
||||
expect(seen.size).toBe(24); // MAX_METRICS_PER_CALL
|
||||
});
|
||||
});
|
||||
|
||||
describe("search — cursor pagination", () => {
|
||||
it("returns a bounded page and an opaque nextCursor when more results exist", async () => {
|
||||
// 60 lab analytes → exceeds the 50-result page.
|
||||
vi.mocked(buildCoachDataInventory).mockResolvedValue({
|
||||
entries: [],
|
||||
restMode: false,
|
||||
cycleEnabled: false,
|
||||
window: "last30days",
|
||||
probeScope: { sources: [] },
|
||||
} as never);
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.labResult.findMany).mockResolvedValue(
|
||||
Array.from({ length: 60 }, (_, i) => ({ analyte: `A${i}` })) as never,
|
||||
);
|
||||
|
||||
const page1 = (await tool("search").run(CTX, { query: "" })) as {
|
||||
results: unknown[];
|
||||
nextCursor?: string;
|
||||
};
|
||||
expect(page1.results).toHaveLength(50);
|
||||
expect(typeof page1.nextCursor).toBe("string");
|
||||
|
||||
const page2 = (await tool("search").run(CTX, {
|
||||
query: "",
|
||||
cursor: page1.nextCursor,
|
||||
})) as { results: unknown[]; nextCursor?: string };
|
||||
expect(page2.results).toHaveLength(10);
|
||||
expect(page2.nextCursor).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+427
-21
@@ -33,7 +33,11 @@ import {
|
||||
compareMetric,
|
||||
getMetricBaseline,
|
||||
detectChangepoints,
|
||||
getLabHistory,
|
||||
LAB_HISTORY_MAX_LIMIT,
|
||||
type DateRange,
|
||||
} from "@/lib/mcp/rich-reads";
|
||||
import { encodeOffsetCursor, decodeOffsetCursor } from "@/lib/mcp/pagination";
|
||||
import type { McpAuthContext } from "./auth";
|
||||
|
||||
/**
|
||||
@@ -101,6 +105,78 @@ async function runCoachTool(
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Finite-number guard for the citation summarisers. */
|
||||
function num(v: unknown): number | null {
|
||||
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
||||
}
|
||||
|
||||
function round1(n: number): number {
|
||||
return Math.round(n * 10) / 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a `fetch` result for a metric id as a short, human-readable citation
|
||||
* sentence (NOT a JSON blob). Pulls the recent aggregate + the grounding band
|
||||
* out of the server-authoritative result; falls back to a plain factual line
|
||||
* when a field is absent. Never fabricates a number the result did not carry.
|
||||
*/
|
||||
function plainMetricText(rid: string, result: unknown): string {
|
||||
const r = result as {
|
||||
present?: boolean;
|
||||
data?: { section?: Record<string, unknown> } & Record<string, unknown>;
|
||||
grounding?: string;
|
||||
};
|
||||
if (!r?.present) return `No recent ${rid} data is on file.`;
|
||||
const section = (r.data?.section ?? r.data) as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const agg = section?.aggregate as Record<string, unknown> | undefined;
|
||||
const parts: string[] = [];
|
||||
const mean = num(agg?.mean);
|
||||
const count = num(agg?.count);
|
||||
if (mean !== null) parts.push(`recent mean ${round1(mean)}`);
|
||||
if (count !== null) parts.push(`${count} readings`);
|
||||
const head =
|
||||
parts.length > 0
|
||||
? `${rid}: ${parts.join(", ")}.`
|
||||
: `${rid}: data is available for the recent window.`;
|
||||
return [head, typeof r.grounding === "string" ? r.grounding : ""]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a `fetch` result for a lab analyte id as a short citation sentence,
|
||||
* quoting the latest reading's value/unit/status/date verbatim from the
|
||||
* server-authoritative result.
|
||||
*/
|
||||
function plainLabText(rid: string, result: unknown): string {
|
||||
const r = result as {
|
||||
present?: boolean;
|
||||
data?: { recent?: Array<Record<string, unknown>> };
|
||||
};
|
||||
if (!r?.present) return `No lab result for ${rid} is on file.`;
|
||||
const reading = Array.isArray(r.data?.recent) ? r.data!.recent[0] : undefined;
|
||||
if (!reading) return `${rid}: a reading is on file.`;
|
||||
const analyte = typeof reading.analyte === "string" ? reading.analyte : rid;
|
||||
const value =
|
||||
reading.value != null
|
||||
? String(reading.value)
|
||||
: typeof reading.valueText === "string"
|
||||
? reading.valueText
|
||||
: "—";
|
||||
const unit = typeof reading.unit === "string" ? reading.unit : "";
|
||||
const status =
|
||||
typeof reading.rangeStatus === "string" ? reading.rangeStatus : "";
|
||||
const taken =
|
||||
typeof reading.takenAt === "string" ? reading.takenAt.slice(0, 10) : "";
|
||||
return (
|
||||
`${analyte}: ${value}${unit ? ` ${unit}` : ""}` +
|
||||
`${status && status !== "unknown" ? ` (${status})` : ""}` +
|
||||
`${taken ? `, measured ${taken}` : ""}.`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The `search` + `fetch` pair — the de-facto two-tool retrieval convention and,
|
||||
* critically, the ONLY tools ChatGPT can call in its default (non-Developer)
|
||||
@@ -123,8 +199,17 @@ function searchAndFetchTools(): McpToolDefinition[] {
|
||||
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) },
|
||||
"Search the user's own health record — metric domains, medications, and lab biomarkers — for items matching a free-text query. Returns { results: [{ id, title, url }], nextCursor? }; pass an id to the `fetch` tool to hydrate it. Each `url` deep-links into the HealthLog web app for citation. When more results exist, pass the opaque `nextCursor` back as `cursor` for the next page. Returns an empty list when nothing matches.",
|
||||
inputShape: {
|
||||
query: z.string().max(200),
|
||||
cursor: z
|
||||
.string()
|
||||
.max(256)
|
||||
.optional()
|
||||
.describe(
|
||||
"Opaque pagination cursor from a previous response's nextCursor. Treat as a black box; do not construct one.",
|
||||
),
|
||||
},
|
||||
annotations: READ_ONLY_ANNOTATIONS,
|
||||
outputShape: {
|
||||
results: z.array(
|
||||
@@ -134,6 +219,7 @@ function searchAndFetchTools(): McpToolDefinition[] {
|
||||
url: z.string(),
|
||||
}),
|
||||
),
|
||||
nextCursor: z.string().optional(),
|
||||
},
|
||||
async run(ctx, args) {
|
||||
const query =
|
||||
@@ -185,19 +271,30 @@ function searchAndFetchTools(): McpToolDefinition[] {
|
||||
});
|
||||
}
|
||||
|
||||
const capped = results.slice(0, 50);
|
||||
// Cursor pagination over the assembled result set (was a silent
|
||||
// slice(0,50)). The set is rebuilt deterministically each call (stable
|
||||
// ordering: metrics → medications → labs), so an opaque offset cursor
|
||||
// pages it reliably and the response stays token-bounded.
|
||||
const offset = decodeOffsetCursor(args.cursor);
|
||||
const page = results.slice(offset, offset + SEARCH_PAGE_SIZE);
|
||||
const hasMore = offset + SEARCH_PAGE_SIZE < results.length;
|
||||
annotate({
|
||||
action: { name: "mcp.tool.invoked" },
|
||||
meta: { tool: "search", present: capped.length > 0 },
|
||||
meta: { tool: "search", present: page.length > 0 },
|
||||
});
|
||||
return { results: capped };
|
||||
return {
|
||||
results: page,
|
||||
...(hasMore
|
||||
? { nextCursor: encodeOffsetCursor(offset + SEARCH_PAGE_SIZE) }
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
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.",
|
||||
"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 prose summary suitable for citation and `url` deep-links to the specific record in 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: {
|
||||
@@ -228,8 +325,9 @@ function searchAndFetchTools(): McpToolDefinition[] {
|
||||
return {
|
||||
id,
|
||||
title: rid,
|
||||
text: JSON.stringify(result),
|
||||
url: `${origin}/insights`,
|
||||
text: plainMetricText(rid, result),
|
||||
// The metric page deep-link; the insights surface renders the series.
|
||||
url: `${origin}/insights?metric=${encodeURIComponent(rid)}`,
|
||||
metadata: { type: "metric", metric: rid },
|
||||
};
|
||||
}
|
||||
@@ -243,8 +341,9 @@ function searchAndFetchTools(): McpToolDefinition[] {
|
||||
return {
|
||||
id,
|
||||
title: rid,
|
||||
text: JSON.stringify(result),
|
||||
url: `${origin}/labs`,
|
||||
text: plainLabText(rid, result),
|
||||
// Deep-link to the labs surface filtered to this analyte.
|
||||
url: `${origin}/labs?analyte=${encodeURIComponent(rid)}`,
|
||||
metadata: { type: "lab", analyte: rid },
|
||||
};
|
||||
}
|
||||
@@ -268,12 +367,19 @@ function searchAndFetchTools(): McpToolDefinition[] {
|
||||
metadata: { type: "medication" },
|
||||
};
|
||||
}
|
||||
// Plain-text prose, not a JSON blob. `dose` / `treatmentClass` are
|
||||
// user-controlled free-text returned as DATA, never interpreted.
|
||||
const text =
|
||||
`${med.name}${med.dose ? ` ${med.dose}` : ""}` +
|
||||
`${med.asNeeded ? " (as needed)" : ""}` +
|
||||
`${med.treatmentClass ? ` — ${med.treatmentClass}` : ""}.`;
|
||||
return {
|
||||
id,
|
||||
title: med.name,
|
||||
text: JSON.stringify(med),
|
||||
url: `${origin}/medications`,
|
||||
metadata: { type: "medication" },
|
||||
text,
|
||||
// Per-item deep-link to the medication detail page.
|
||||
url: `${origin}/medications/${encodeURIComponent(rid)}`,
|
||||
metadata: { type: "medication", medicationId: rid },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -289,16 +395,53 @@ function searchAndFetchTools(): McpToolDefinition[] {
|
||||
];
|
||||
}
|
||||
|
||||
// ── Phase 4 deep-value output schemas ────────────────────────────────
|
||||
// Every field except `present` is optional so a grounded `{ present: false }`
|
||||
// miss and a full hit both conform — the SDK validates `structuredContent`
|
||||
// against these (REQ: structuredContent + outputSchema on the rich reads).
|
||||
// ── Output schemas ───────────────────────────────────────────────────
|
||||
// Every field except the presence anchor (`present`) is optional so a grounded
|
||||
// `{ present: false }` miss and a full hit both conform — the SDK validates the
|
||||
// returned `structuredContent` against these (ChatGPT Apps-SDK conformance).
|
||||
|
||||
const bandShape = z
|
||||
.object({ low: z.number(), high: z.number() })
|
||||
.nullable()
|
||||
.optional();
|
||||
|
||||
/**
|
||||
* The shared output shape for the Coach-F1-backed reads (`get_metric_series`,
|
||||
* `get_glucose_panel`, `get_sleep`, `get_workouts`, `get_illness_recovery`,
|
||||
* `get_cycle`, `get_medication_compliance`, `get_correlations`). The executor
|
||||
* returns the grounded `{ present, reason?, data?, grounding? }` contract; `data`
|
||||
* is the server-authoritative domain section (its inner shape is the snapshot's,
|
||||
* carried opaque here) so both a miss and a full hit validate.
|
||||
*/
|
||||
const coachReadOutput: z.ZodRawShape = {
|
||||
present: z.boolean(),
|
||||
reason: z.string().optional(),
|
||||
data: z.unknown().optional(),
|
||||
grounding: z.string().optional(),
|
||||
};
|
||||
|
||||
/**
|
||||
* Optional explicit `{from,to}` ISO date range that the comparison /
|
||||
* changepoint reads accept IN PLACE OF one of the five fixed trailing windows.
|
||||
* Bounds are inclusive; the read serves the range over the same rollup tier the
|
||||
* trailing windows use (see `rich-reads.ts`).
|
||||
*/
|
||||
const dateRangeShape = z
|
||||
.object({
|
||||
from: z.iso.datetime({ offset: true }),
|
||||
to: z.iso.datetime({ offset: true }),
|
||||
})
|
||||
.describe(
|
||||
"An explicit inclusive date range. Use INSTEAD OF `window` for an arbitrary span (e.g. before vs after a date). Both bounds are ISO-8601 instants.",
|
||||
);
|
||||
|
||||
/** Cap on metrics fetched in one `get_metrics` call (paginated past the cap). */
|
||||
const MAX_METRICS_PER_CALL = 24;
|
||||
/** Metrics resolved per `get_metrics` page. */
|
||||
const METRICS_PAGE_SIZE = 8;
|
||||
/** Results per `search` page. */
|
||||
const SEARCH_PAGE_SIZE = 50;
|
||||
|
||||
const getCorrelationOutput: z.ZodRawShape = {
|
||||
present: z.boolean(),
|
||||
reason: z.string().optional(),
|
||||
@@ -328,8 +471,73 @@ const metricWindowSnapshotSchema = z.object({
|
||||
mean: z.number().nullable(),
|
||||
min: z.number().nullable(),
|
||||
max: z.number().nullable(),
|
||||
// Present only when the side was an explicit `{from,to}` range.
|
||||
from: z.string().optional(),
|
||||
to: z.string().optional(),
|
||||
});
|
||||
|
||||
/** Output schema for `list_metrics` — the inventory rows + flags. */
|
||||
const listMetricsOutput: z.ZodRawShape = {
|
||||
present: z.boolean(),
|
||||
window: z.string().optional(),
|
||||
restMode: z.boolean().optional(),
|
||||
cycleEnabled: z.boolean().optional(),
|
||||
metrics: z
|
||||
.array(
|
||||
z.object({
|
||||
tool: z.string(),
|
||||
domain: z.string(),
|
||||
present: z.boolean(),
|
||||
count: z.number().optional(),
|
||||
metric: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
};
|
||||
|
||||
/** Output schema for `get_labs` — latest-per-biomarker OR a paginated history. */
|
||||
const getLabsOutput: z.ZodRawShape = {
|
||||
present: z.boolean(),
|
||||
reason: z.string().optional(),
|
||||
// Latest-mode payload (the Coach labs section, carried opaque).
|
||||
data: z.unknown().optional(),
|
||||
grounding: z.string().optional(),
|
||||
// History-mode payload (per-analyte trajectory + pagination).
|
||||
analyte: z.string().optional(),
|
||||
readings: z
|
||||
.array(
|
||||
z.object({
|
||||
value: z.number().nullable(),
|
||||
valueText: z.string().nullable(),
|
||||
unit: z.string(),
|
||||
referenceLow: z.number().nullable(),
|
||||
referenceHigh: z.number().nullable(),
|
||||
rangeStatus: z.enum(["in-range", "below", "above", "unknown"]),
|
||||
takenAt: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
nextCursor: z.string().optional(),
|
||||
};
|
||||
|
||||
/** Output schema for `get_metrics` — the multi-metric fan-out + pagination. */
|
||||
const getMetricsOutput: z.ZodRawShape = {
|
||||
present: z.boolean(),
|
||||
window: z.string().optional(),
|
||||
results: z
|
||||
.array(
|
||||
z.object({
|
||||
metric: z.string(),
|
||||
present: z.boolean(),
|
||||
reason: z.string().optional(),
|
||||
data: z.unknown().optional(),
|
||||
grounding: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
nextCursor: z.string().optional(),
|
||||
};
|
||||
|
||||
const compareMetricOutput: z.ZodRawShape = {
|
||||
present: z.boolean(),
|
||||
reason: z.string().optional(),
|
||||
@@ -388,6 +596,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [
|
||||
"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,
|
||||
outputShape: listMetricsOutput,
|
||||
async run(ctx) {
|
||||
const inventory = await buildCoachDataInventory(ctx.userId, undefined);
|
||||
annotate({
|
||||
@@ -413,6 +622,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [
|
||||
window: coachScopeWindowSchema.optional(),
|
||||
},
|
||||
annotations: READ_ONLY_ANNOTATIONS,
|
||||
outputShape: coachReadOutput,
|
||||
run(ctx, args) {
|
||||
return runCoachTool(ctx, "get_metric_series", args);
|
||||
},
|
||||
@@ -426,6 +636,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [
|
||||
window: coachScopeWindowSchema.optional(),
|
||||
},
|
||||
annotations: READ_ONLY_ANNOTATIONS,
|
||||
outputShape: coachReadOutput,
|
||||
run(ctx, args) {
|
||||
return runCoachTool(ctx, "get_medication_compliance", args);
|
||||
},
|
||||
@@ -434,13 +645,46 @@ export const MCP_TOOLS: McpToolDefinition[] = [
|
||||
name: "get_labs",
|
||||
title: "Get lab results",
|
||||
description:
|
||||
"Fetch the user's most recent lab results — the latest reading per biomarker over the last 12 months, optionally filtered to one named analyte. Each reading carries its value, unit, reference range, and in-range/below/above status. Returns { present: false } when no labs are on file.",
|
||||
"Fetch the user's lab results. By default returns the latest reading per biomarker over the last 12 months, optionally filtered to one named analyte. Pass history:true with an analyte to return that analyte's reading TRAJECTORY (newest first, paginated). Each reading carries its value, unit, reference range, and in-range/below/above status. Returns { present: false } when no labs match.",
|
||||
inputShape: {
|
||||
analyte: z.string().min(1).max(80).optional(),
|
||||
history: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"When true (requires `analyte`), return that analyte's full reading trajectory (newest first, paginated) instead of the latest-per-biomarker snapshot.",
|
||||
),
|
||||
cursor: z
|
||||
.string()
|
||||
.max(256)
|
||||
.optional()
|
||||
.describe(
|
||||
"Opaque pagination cursor from a previous history response's nextCursor. Treat as a black box.",
|
||||
),
|
||||
},
|
||||
annotations: READ_ONLY_ANNOTATIONS,
|
||||
outputShape: getLabsOutput,
|
||||
run(ctx, args) {
|
||||
return runCoachTool(ctx, "get_labs", args);
|
||||
// History mode: one analyte's paginated trajectory over the labs read
|
||||
// path. Requires an analyte — there is no whole-record history dump.
|
||||
if (args.history === true) {
|
||||
const analyte = typeof args.analyte === "string" ? args.analyte : "";
|
||||
if (!analyte.trim()) {
|
||||
return Promise.resolve({
|
||||
present: false,
|
||||
reason: "analyte_required_for_history",
|
||||
});
|
||||
}
|
||||
return getLabHistory(ctx.userId, {
|
||||
analyte,
|
||||
offset: decodeOffsetCursor(args.cursor),
|
||||
limit: LAB_HISTORY_MAX_LIMIT,
|
||||
});
|
||||
}
|
||||
// Default: latest reading per biomarker via the Coach labs read.
|
||||
return runCoachTool(ctx, "get_labs", {
|
||||
...(typeof args.analyte === "string" ? { analyte: args.analyte } : {}),
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -450,6 +694,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [
|
||||
"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,
|
||||
outputShape: coachReadOutput,
|
||||
run(ctx, args) {
|
||||
return runCoachTool(ctx, "get_correlations", args);
|
||||
},
|
||||
@@ -477,12 +722,14 @@ export const MCP_TOOLS: McpToolDefinition[] = [
|
||||
name: "compare_metric",
|
||||
title: "Compare metrics or windows",
|
||||
description:
|
||||
"Compare one metric against another over the same trailing window, OR a single metric across two trailing windows (e.g. last 30 days vs last 90 days). Returns each side's rollup statistics (count, mean, min, max) with units and reference bands, plus a delta + percent change when both sides share a unit. Windows are trailing-to-now. Returns { present: false } when a side has no data.",
|
||||
"Compare one metric against another over the same horizon, OR a single metric across two horizons. A horizon is either one of the five fixed trailing windows OR an explicit {from,to} date range (use `range`/`rangeB` for before-vs-after-a-date). Returns each side's rollup statistics (count, mean, min, max) with units and reference bands, plus a delta + percent change when both sides share a unit. Returns { present: false } when a side has no data.",
|
||||
inputShape: {
|
||||
metric: z.string().min(1).max(60),
|
||||
metricB: z.string().min(1).max(60).optional(),
|
||||
window: coachScopeWindowSchema.optional(),
|
||||
windowB: coachScopeWindowSchema.optional(),
|
||||
range: dateRangeShape.optional(),
|
||||
rangeB: dateRangeShape.optional(),
|
||||
},
|
||||
annotations: READ_ONLY_ANNOTATIONS,
|
||||
outputShape: compareMetricOutput,
|
||||
@@ -492,6 +739,8 @@ export const MCP_TOOLS: McpToolDefinition[] = [
|
||||
metricB: typeof args.metricB === "string" ? args.metricB : undefined,
|
||||
window: args.window as never,
|
||||
windowB: args.windowB as never,
|
||||
range: args.range as DateRange | undefined,
|
||||
rangeB: args.rangeB as DateRange | undefined,
|
||||
});
|
||||
},
|
||||
},
|
||||
@@ -515,10 +764,11 @@ export const MCP_TOOLS: McpToolDefinition[] = [
|
||||
name: "detect_changepoints",
|
||||
title: "Detect level shifts in a metric",
|
||||
description:
|
||||
"Surface points where a metric's level shifted over a trailing window (e.g. 'when did my weight trend change?'). Runs a conservative changepoint scan over the rollup tier's bucket means and reports each shift's date, direction, and before/after means with units. High firing bar — returns { present: false } when too little data exists or no shift clears the threshold.",
|
||||
"Surface points where a metric's level shifted over a trailing window OR an explicit {from,to} date range (e.g. 'when did my weight trend change?'). Runs a conservative changepoint scan over the rollup tier's bucket means and reports each shift's date, direction, and before/after means with units. High firing bar — returns { present: false } when too little data exists or no shift clears the threshold.",
|
||||
inputShape: {
|
||||
metric: z.string().min(1).max(60),
|
||||
window: coachScopeWindowSchema.optional(),
|
||||
range: dateRangeShape.optional(),
|
||||
},
|
||||
annotations: READ_ONLY_ANNOTATIONS,
|
||||
outputShape: detectChangepointsOutput,
|
||||
@@ -526,9 +776,165 @@ export const MCP_TOOLS: McpToolDefinition[] = [
|
||||
return detectChangepoints(ctx.userId, {
|
||||
metric: String(args.metric ?? ""),
|
||||
window: args.window as never,
|
||||
range: args.range as DateRange | undefined,
|
||||
});
|
||||
},
|
||||
},
|
||||
// ── Coach-F1 reads bridged to the wire (catalogue Tier 1) ───────────
|
||||
// Each is a thin `runCoachTool` glue over an already-built, grounded Coach
|
||||
// retrieval tool — same gates, units, and `{ present: false }` contract. The
|
||||
// `list_metrics` inventory already advertises these, so wiring them here
|
||||
// closes the prior advertise-but-missing self-inconsistency.
|
||||
{
|
||||
name: "get_glucose_panel",
|
||||
title: "Get the glucose panel",
|
||||
description:
|
||||
"Fetch the user's glucose data: per-context daily means plus the trailing-30-day clinical panel (time-in-range, GMI, CV%, estimated A1c). Returns { present: false } when the user logs no glucose.",
|
||||
inputShape: {
|
||||
window: coachScopeWindowSchema.optional(),
|
||||
},
|
||||
annotations: READ_ONLY_ANNOTATIONS,
|
||||
outputShape: coachReadOutput,
|
||||
run(ctx, args) {
|
||||
return runCoachTool(ctx, "get_glucose_panel", args);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_sleep",
|
||||
title: "Get sleep",
|
||||
description:
|
||||
"Fetch the user's sleep: per-night asleep + stage minutes plus the sleep-rhythm summary (sleep debt + chronotype). Returns { present: false } when no sleep is tracked.",
|
||||
inputShape: {
|
||||
window: coachScopeWindowSchema.optional(),
|
||||
},
|
||||
annotations: READ_ONLY_ANNOTATIONS,
|
||||
outputShape: coachReadOutput,
|
||||
run(ctx, args) {
|
||||
return runCoachTool(ctx, "get_sleep", args);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_workouts",
|
||||
title: "Get workouts",
|
||||
description:
|
||||
"Fetch the user's workouts: the most recent sessions (sport, duration, energy, distance, avg/max HR) plus a per-sport rollup over the window. Use for training-load and 'how were my runs / am I overtraining?' questions. Returns { present: false } when no workouts are tracked.",
|
||||
inputShape: {
|
||||
window: coachScopeWindowSchema.optional(),
|
||||
},
|
||||
annotations: READ_ONLY_ANNOTATIONS,
|
||||
outputShape: coachReadOutput,
|
||||
run(ctx, args) {
|
||||
return runCoachTool(ctx, "get_workouts", args);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_illness_recovery",
|
||||
title: "Get illness & recovery",
|
||||
description:
|
||||
"Fetch the user's illness + recovery context: rest mode, active and recently-resolved illnesses, the recovery / strain composites, and the illness retrospective (recovery-gap, nadir, red flags). Carries the rest-mode safety flag for framing. Returns { present: false } when there is nothing to report.",
|
||||
inputShape: {},
|
||||
annotations: READ_ONLY_ANNOTATIONS,
|
||||
outputShape: coachReadOutput,
|
||||
run(ctx, args) {
|
||||
return runCoachTool(ctx, "get_illness_recovery", args);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_cycle",
|
||||
title: "Get menstrual cycle",
|
||||
description:
|
||||
"Fetch the user's menstrual-cycle context: current phase + day-of-cycle, the next predicted event, and the headline phase-correlation finding. Descriptive only — never a contraception-grade or 'safe day' claim. Gated on cycle tracking being enabled for the account; returns { present: false } when cycle tracking is off or there is no data.",
|
||||
inputShape: {},
|
||||
annotations: READ_ONLY_ANNOTATIONS,
|
||||
outputShape: coachReadOutput,
|
||||
run(ctx, args) {
|
||||
return runCoachTool(ctx, "get_cycle", args);
|
||||
},
|
||||
},
|
||||
// ── Multi-metric fan-out (catalogue: query expressiveness) ──────────
|
||||
{
|
||||
name: "get_metrics",
|
||||
title: "Get several metric series at once",
|
||||
description:
|
||||
"Fetch the time series for SEVERAL metrics in one call — a fan-out over the same single-metric read `get_metric_series` runs, returning one grounded result per metric (each with { present } and the metric's aggregate/timelines when present). Paginated: when more metrics remain than fit one page, pass the opaque nextCursor back as `cursor`. Use for a multi-metric question instead of many separate calls.",
|
||||
inputShape: {
|
||||
metrics: z
|
||||
.array(z.string().min(1).max(60))
|
||||
.min(1)
|
||||
.max(MAX_METRICS_PER_CALL)
|
||||
.describe(
|
||||
"The metrics to fetch (e.g. ['weight','pulse','hrv']). Unknown metrics return { present: false } for that entry, never an error.",
|
||||
),
|
||||
window: coachScopeWindowSchema.optional(),
|
||||
cursor: z
|
||||
.string()
|
||||
.max(256)
|
||||
.optional()
|
||||
.describe(
|
||||
"Opaque pagination cursor from a previous response's nextCursor. Treat as a black box.",
|
||||
),
|
||||
},
|
||||
annotations: READ_ONLY_ANNOTATIONS,
|
||||
outputShape: getMetricsOutput,
|
||||
async run(ctx, args) {
|
||||
const requested = Array.isArray(args.metrics)
|
||||
? args.metrics
|
||||
.filter((m): m is string => typeof m === "string")
|
||||
.map((m) => m.trim())
|
||||
.filter((m) => m.length > 0)
|
||||
.slice(0, MAX_METRICS_PER_CALL)
|
||||
: [];
|
||||
const window = typeof args.window === "string" ? args.window : undefined;
|
||||
|
||||
const offset = decodeOffsetCursor(args.cursor);
|
||||
const page = requested.slice(offset, offset + METRICS_PAGE_SIZE);
|
||||
const hasMore = offset + METRICS_PAGE_SIZE < requested.length;
|
||||
|
||||
// Fan out over the SAME single-metric read; the executor validates each
|
||||
// metric and returns a grounded `{ present, … }` (never throws), so one
|
||||
// unknown metric cannot break the batch.
|
||||
const results = await Promise.all(
|
||||
page.map(async (metric) => {
|
||||
const r = (await executeCoachTool({
|
||||
userId: ctx.userId,
|
||||
name: "get_metric_series",
|
||||
rawArguments: JSON.stringify({
|
||||
metric,
|
||||
...(window ? { window } : {}),
|
||||
}),
|
||||
})) as {
|
||||
present: boolean;
|
||||
reason?: string;
|
||||
data?: unknown;
|
||||
grounding?: string;
|
||||
};
|
||||
return {
|
||||
metric,
|
||||
present: r.present,
|
||||
...(r.reason ? { reason: r.reason } : {}),
|
||||
...(r.data !== undefined ? { data: r.data } : {}),
|
||||
...(r.grounding ? { grounding: r.grounding } : {}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
annotate({
|
||||
action: { name: "mcp.tool.invoked" },
|
||||
meta: {
|
||||
tool: "get_metrics",
|
||||
present: results.some((r) => r.present),
|
||||
},
|
||||
});
|
||||
return {
|
||||
present: results.some((r) => r.present),
|
||||
...(window ? { window } : {}),
|
||||
results,
|
||||
...(hasMore
|
||||
? { nextCursor: encodeOffsetCursor(offset + METRICS_PAGE_SIZE) }
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
},
|
||||
...searchAndFetchTools(),
|
||||
];
|
||||
|
||||
|
||||
Reference in new issue
Block a user