mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Merge branch 'fix/mcp-schema-2020-12' into release/v1.40.6
This commit is contained in:
commit
5328c6a534
5 files changed
+244
No files matched your search
@@ -123,6 +123,7 @@
|
||||
"@types/web-push": "^3.6.4",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"ajv": "^8.20.0",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.3.8",
|
||||
"knip": "^6.32.3",
|
||||
|
||||
Generated
+3
@@ -242,6 +242,9 @@ importers:
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^4.1.5
|
||||
version: 4.1.5(vitest@4.1.11)
|
||||
ajv:
|
||||
specifier: ^8.20.0
|
||||
version: 8.20.0
|
||||
eslint:
|
||||
specifier: ^9
|
||||
version: 9.39.3(jiti@2.7.0)(supports-color@7.2.0)
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Guard: every schema `tools/list` advertises is valid JSON Schema 2020-12.
|
||||
*
|
||||
* The SDK converts each Zod shape with a hard-coded draft-07 target, so every
|
||||
* `inputSchema` and `outputSchema` used to carry
|
||||
* `"$schema": "http://json-schema.org/draft-07/schema#"`. A client that
|
||||
* validates with a 2020-12-only allowlist refuses the dialect at registration
|
||||
* and every tool becomes unusable (#1170). The server now advertises 2020-12
|
||||
* with no `$schema` key — the MCP default dialect — so a strict client and a
|
||||
* draft-07 client both accept it.
|
||||
*
|
||||
* The list is read through a real SDK client over an in-memory transport, so
|
||||
* the assertion is on the wire, not on the registry. A write-scoped session is
|
||||
* used so the write tools are covered too.
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import Ajv2020 from "ajv/dist/2020";
|
||||
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
process.env.APP_URL = "http://localhost";
|
||||
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||
import { createMcpServer } from "../server";
|
||||
import { MCP_TOOLS } from "../tools";
|
||||
import { MCP_WRITE_TOOLS } from "../write-tools";
|
||||
import type { McpAuthContext } from "../auth";
|
||||
|
||||
const ctx: McpAuthContext = {
|
||||
userId: "u-1",
|
||||
tokenId: "t-1",
|
||||
scopes: ["health:read", "health:write"],
|
||||
binding: "u-1:t-1",
|
||||
canRead: true,
|
||||
canWrite: true,
|
||||
};
|
||||
|
||||
async function listTools() {
|
||||
const server = createMcpServer(ctx);
|
||||
const client = new Client({ name: "dialect-test", version: "1.0.0" });
|
||||
const [clientSide, serverSide] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([server.connect(serverSide), client.connect(clientSide)]);
|
||||
const { tools } = await client.listTools();
|
||||
await client.close();
|
||||
await server.close();
|
||||
return tools;
|
||||
}
|
||||
|
||||
/** Every draft-07-only construct, anywhere in a schema tree. */
|
||||
function draft07Constructs(node: unknown, path = "$"): string[] {
|
||||
if (Array.isArray(node)) {
|
||||
return node.flatMap((v, i) => draft07Constructs(v, `${path}[${i}]`));
|
||||
}
|
||||
if (!node || typeof node !== "object") return [];
|
||||
const found: string[] = [];
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
const at = `${path}.${key}`;
|
||||
if (
|
||||
key === "$schema" &&
|
||||
value !== "https://json-schema.org/draft/2020-12/schema"
|
||||
) {
|
||||
found.push(`${at} = ${String(value)}`);
|
||||
}
|
||||
if (key === "definitions") found.push(at);
|
||||
if (key === "dependencies") found.push(at);
|
||||
if (key === "additionalItems") found.push(at);
|
||||
if (key === "items" && Array.isArray(value)) found.push(`${at} (array)`);
|
||||
if (
|
||||
key === "$ref" &&
|
||||
typeof value === "string" &&
|
||||
value.includes("/definitions/")
|
||||
) {
|
||||
found.push(`${at} = ${value}`);
|
||||
}
|
||||
found.push(...draft07Constructs(value, at));
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
describe("tools/list advertises JSON Schema 2020-12", () => {
|
||||
it("advertises every read and write tool", async () => {
|
||||
const tools = await listTools();
|
||||
expect(tools.map((t) => t.name).sort()).toEqual(
|
||||
[...MCP_TOOLS, ...MCP_WRITE_TOOLS].map((t) => t.name).sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("carries no draft-07 marker or construct anywhere", async () => {
|
||||
const tools = await listTools();
|
||||
expect(JSON.stringify(tools)).not.toContain("draft-07");
|
||||
const offenders = tools.flatMap((tool) => [
|
||||
...draft07Constructs(tool.inputSchema, `${tool.name}.inputSchema`),
|
||||
...(tool.outputSchema
|
||||
? draft07Constructs(tool.outputSchema, `${tool.name}.outputSchema`)
|
||||
: []),
|
||||
]);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("compiles every input and output schema under a 2020-12 validator", async () => {
|
||||
const tools = await listTools();
|
||||
const ajv = new Ajv2020({ strict: false, validateSchema: true });
|
||||
let compiled = 0;
|
||||
for (const tool of tools) {
|
||||
for (const [kind, schema] of [
|
||||
["inputSchema", tool.inputSchema],
|
||||
["outputSchema", tool.outputSchema],
|
||||
] as const) {
|
||||
if (!schema) continue;
|
||||
expect(
|
||||
ajv.validateSchema(schema),
|
||||
`${tool.name}.${kind}: ${ajv.errorsText()}`,
|
||||
).toBe(true);
|
||||
expect(() => ajv.compile(schema), `${tool.name}.${kind}`).not.toThrow();
|
||||
compiled++;
|
||||
}
|
||||
}
|
||||
// Every tool has an inputSchema, and the surface declares output shapes;
|
||||
// a count of zero would mean the loop above proved nothing.
|
||||
expect(compiled).toBeGreaterThan(tools.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* The JSON Schema dialect `tools/list` advertises.
|
||||
*
|
||||
* The SDK converts every tool's Zod shape with a hard-coded draft-07 target and
|
||||
* stamps `"$schema": "http://json-schema.org/draft-07/schema#"` on each
|
||||
* `inputSchema` and `outputSchema`. Clients that validate with a 2020-12-only
|
||||
* validator refuse that dialect when the tool list arrives, so every tool is
|
||||
* unusable there, before a call ever reaches the server (#1170).
|
||||
*
|
||||
* This module re-renders each advertised schema from the same Zod shape with
|
||||
* the 2020-12 target (`$defs`, `prefixItems`, …) and drops the `$schema` key.
|
||||
* A schema without `$schema` is 2020-12 under the MCP spec, and a draft-07
|
||||
* client still compiles it, so omitting the key serves both. Everything else
|
||||
* the SDK puts on a tool entry is kept as it is.
|
||||
*
|
||||
* Only the advertised copy changes. The SDK validates `structuredContent`
|
||||
* against the Zod shape itself, never against this JSON, so result validation
|
||||
* is untouched. Prompts advertise an argument list rather than a schema, and
|
||||
* resource templates carry no schema, so tools are the whole surface.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import {
|
||||
ListToolsRequestSchema,
|
||||
type ListToolsRequest,
|
||||
type ListToolsResult,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
import type { McpToolDefinition } from "./tools";
|
||||
|
||||
type JsonSchemaObject = Record<string, unknown>;
|
||||
|
||||
/** Render one Zod raw shape as a 2020-12 schema with no `$schema` key. */
|
||||
export function toAdvertisedJsonSchema(
|
||||
shape: z.ZodRawShape,
|
||||
io: "input" | "output",
|
||||
): JsonSchemaObject {
|
||||
const rendered = z.toJSONSchema(z.object(shape), {
|
||||
target: "draft-2020-12",
|
||||
io,
|
||||
}) as JsonSchemaObject;
|
||||
delete rendered.$schema;
|
||||
return rendered;
|
||||
}
|
||||
|
||||
type ListToolsHandler = (
|
||||
request: ListToolsRequest,
|
||||
extra: unknown,
|
||||
) => Promise<ListToolsResult>;
|
||||
|
||||
/**
|
||||
* Wrap the SDK's `tools/list` handler so every advertised schema is 2020-12.
|
||||
* Call once, after every tool is registered. `tools` maps each registered name
|
||||
* to its definition; a listed tool missing from it is a wiring error and
|
||||
* throws rather than slipping through in the SDK's dialect.
|
||||
*
|
||||
* The SDK exposes no way to read its own handler, so it is taken from the
|
||||
* protocol's handler map. When no tool was registered there is no handler and
|
||||
* nothing to rewrite. `tool-schema-dialect.test.ts` reads the list through a
|
||||
* real client, so an SDK change that moves the map fails there.
|
||||
*/
|
||||
export function advertiseToolSchemasAs2020(
|
||||
server: McpServer,
|
||||
tools: ReadonlyMap<string, McpToolDefinition>,
|
||||
): void {
|
||||
const handlers = (
|
||||
server.server as unknown as {
|
||||
_requestHandlers?: Map<string, ListToolsHandler>;
|
||||
}
|
||||
)._requestHandlers;
|
||||
const sdkList = handlers?.get("tools/list");
|
||||
if (!sdkList) return;
|
||||
|
||||
server.server.setRequestHandler(
|
||||
ListToolsRequestSchema,
|
||||
async (request, extra) => {
|
||||
const result = await sdkList(request, extra);
|
||||
return {
|
||||
...result,
|
||||
tools: result.tools.map((entry) => {
|
||||
const definition = tools.get(entry.name);
|
||||
if (!definition) {
|
||||
throw new Error(
|
||||
`tools/list: no definition registered for "${entry.name}"`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
...entry,
|
||||
inputSchema: toAdvertisedJsonSchema(
|
||||
definition.inputShape,
|
||||
"input",
|
||||
) as ListToolsResult["tools"][number]["inputSchema"],
|
||||
...(definition.outputShape
|
||||
? {
|
||||
outputSchema: toAdvertisedJsonSchema(
|
||||
definition.outputShape,
|
||||
"output",
|
||||
) as NonNullable<
|
||||
ListToolsResult["tools"][number]["outputSchema"]
|
||||
>,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import { MCP_WRITE_TOOLS } from "./write-tools";
|
||||
import { MCP_RESOURCES, MCP_RESOURCE_TEMPLATES } from "./resources";
|
||||
import { MCP_PROMPTS } from "./prompts";
|
||||
import { resolveBaseOrigin } from "./oauth/config";
|
||||
import { advertiseToolSchemasAs2020 } from "./schema-dialect";
|
||||
|
||||
export const MCP_SERVER_NAME = "healthlog";
|
||||
export const MCP_SERVER_VERSION = "1.0.0";
|
||||
@@ -84,7 +85,9 @@ export function createMcpServer(ctx: McpAuthContext): McpServer {
|
||||
|
||||
// Register one tool definition. Shared by the read registry and the
|
||||
// write-scoped registry so the wire shape can never fork between them.
|
||||
const registeredTools = new Map<string, McpToolDefinition>();
|
||||
const registerTool = (tool: McpToolDefinition) => {
|
||||
registeredTools.set(tool.name, tool);
|
||||
server.registerTool(
|
||||
tool.name,
|
||||
{
|
||||
@@ -130,6 +133,11 @@ export function createMcpServer(ctx: McpAuthContext): McpServer {
|
||||
}
|
||||
}
|
||||
|
||||
// Advertise every tool schema as JSON Schema 2020-12 instead of the SDK's
|
||||
// draft-07, which strict clients refuse (#1170). After the last
|
||||
// registerTool, so the wrap covers every tool this session sees.
|
||||
advertiseToolSchemasAs2020(server, registeredTools);
|
||||
|
||||
for (const prompt of MCP_PROMPTS) {
|
||||
server.registerPrompt(
|
||||
prompt.name,
|
||||
|
||||
Reference in new issue
Block a user