From dc5af6d68f74155d32a0be957c88afb0ee4be699 Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:49:09 +0800 Subject: [PATCH] perf(mcp): reuse output validators across catalog refreshes (#1433) Advertise content- and dialect-bound identities for self-contained output schemas so the SDK can reuse unchanged Ajv validators after complete tools/list refreshes. Preserve explicit IDs and relative reference bases. Verify changed schemas, availability transitions, reconnects, numeric and selector constraints, explicit IDs, relative references, and advertised dialects through the pinned SDK boundaries. Refs #1065, #723 --- docs/mcp-contracts.md | 7 ++ src/contracts/toolOutputReferences.test.ts | 58 ++++++++++ src/contracts/toolSchemaMetadata.ts | 44 ++++++- .../boundary/mcp/outputValidatorReuse.test.ts | 108 ++++++++++++++++++ 4 files changed, 213 insertions(+), 4 deletions(-) create mode 100644 tests/boundary/mcp/outputValidatorReuse.test.ts diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 7bf2fb76c..70d843335 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -36,6 +36,13 @@ after SDK conversion and check advertised validation and actual calls. Individual model APIs can impose additional nesting limits; complete producer captures can exceed ten structural levels. +Self-contained output schemas advertise a content-bound `$id`, including their +declared dialect. SDK validators can reuse compiled schemas across complete +catalog refreshes and equivalent tool outputs; changing the schema changes its +identity. Explicit schema IDs and relative external reference bases are +preserved. This keeps every tool and validation rule in discovery and does not +change the SDK's catalog invalidation or availability checks. + `compare_web_captures` accepts exactly one of two input shapes: - Passive: `before` and `after` each contain `inspection`, the complete diff --git a/src/contracts/toolOutputReferences.test.ts b/src/contracts/toolOutputReferences.test.ts index 70c1f5ea5..cda49f1d5 100644 --- a/src/contracts/toolOutputReferences.test.ts +++ b/src/contracts/toolOutputReferences.test.ts @@ -1,11 +1,69 @@ import { Ajv } from "ajv"; import { Ajv2020 } from "ajv/dist/2020.js"; +import { AjvJsonSchemaValidator } from "@modelcontextprotocol/client/validators/ajv"; import { expect, it } from "vitest"; import { z } from "zod"; import { toolContract } from "./toolContracts.js"; import { toolOutputSchemaWithMetadata } from "./toolSchemaMetadata.js"; +it("keeps dialect-specific validators distinct after JSON schema serialization", () => { + const contract = { + ...toolContract("binary_session"), + outputSchema: z.strictObject({ value: z.number().int().min(1).max(8) }), + }; + const output = toolOutputSchemaWithMetadata(contract); + const provider = new AjvJsonSchemaValidator(); + for (const target of ["draft-2020-12", "draft-07"] as const) { + const projected = output["~standard"].jsonSchema.output({ target }); + const parsed = z + .record(z.string(), z.unknown()) + .parse(JSON.parse(JSON.stringify(projected))); + const validate = provider.getValidator(parsed); + expect(validate({ value: 8 }).valid).toBe(true); + expect(validate({ value: 9 }).valid).toBe(false); + expect(validate({ value: 1.5 }).valid).toBe(false); + } +}); + +it("preserves explicit schema identity and relative reference resolution", () => { + const value = z.string(); + const canonical = z.strictObject({ value }); + const contract = { + ...toolContract("binary_session"), + outputSchema: canonical, + }; + const projected = toolOutputSchemaWithMetadata(contract)[ + "~standard" + ].jsonSchema.output({ + target: "draft-2020-12", + libraryOptions: { + override: (context: { + readonly zodSchema: unknown; + readonly jsonSchema: Record; + }) => { + if (context.zodSchema !== value) return; + delete context.jsonSchema.type; + context.jsonSchema.$ref = "child.json"; + }, + }, + }); + const ajv = new Ajv2020({ strict: false }); + ajv.addSchema({ $id: "child.json", type: "string", minLength: 3 }); + const validate = ajv.compile(projected); + expect(validate({ value: "abc" })).toBe(true); + expect(validate({ value: "ab" })).toBe(false); + + const identified = toolOutputSchemaWithMetadata({ + ...contract, + outputSchema: canonical.meta({ $id: "https://example.test/result" }), + })["~standard"].jsonSchema.output({ target: "draft-2020-12" }); + expect(identified.$id).toBe("https://example.test/result"); + const explicit = ajv.compile(identified); + expect(explicit({ value: "abc" })).toBe(true); + expect(explicit({ value: 1 })).toBe(false); +}); + it.each(["draft-2020-12", "draft-07"] as const)( "preserves refined numeric bounds and strict repeated objects through %s references", (target) => { diff --git a/src/contracts/toolSchemaMetadata.ts b/src/contracts/toolSchemaMetadata.ts index f2d395ece..8f43f1708 100644 --- a/src/contracts/toolSchemaMetadata.ts +++ b/src/contracts/toolSchemaMetadata.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import type { ToolContract } from "./toolContractTypes.js"; import { presentInputJsonSchema } from "./inputSchemaPresentation.js"; +import { digestCanonicalValue } from "../domain/canonicalDigest.js"; const PROPERTY_DESCRIPTIONS: Readonly> = { addresses: "Ordered provider-normalized procedure addresses to analyze.", @@ -69,20 +70,55 @@ export const toolInputSchemaWithMetadata = ( }, ); -/** Share repeated output definitions without changing canonical validation. */ +/** Share output definitions and content-bound validator identities on the wire. */ export const toolOutputSchemaWithMetadata = ( contract: Contract, ): Contract["outputSchema"] => withAdvertisedJsonSchema( contract.outputSchema, "output", - (project) => (options) => - project({ + (project) => (options) => { + const shared = project({ ...options, libraryOptions: { reused: "ref", ...options.libraryOptions }, - }), + }); + // An explicit ID owns the reference base. Relative external references + // also depend on that base, so leave those projections unchanged. + if ("$id" in shared || hasRelativeSchemaReference(shared)) return shared; + return { + ...shared, + $id: `urn:rea:tool-output:sha256:${digestCanonicalValue( + { target: options.target, schema: shared }, + "Tool output schema", + )}`, + }; + }, ); +const hasRelativeSchemaReference = ( + schema: Record, +): boolean => { + const pending: unknown[] = [schema]; + const visited = new WeakSet(); + while (pending.length > 0) { + const current = pending.pop(); + if (current === null || typeof current !== "object" || visited.has(current)) + continue; + visited.add(current); + for (const [key, value] of Object.entries(current)) { + if ( + ["$id", "$ref", "$dynamicRef", "$recursiveRef"].includes(key) && + typeof value === "string" && + !value.startsWith("#") && + !URL.canParse(value) + ) + return true; + if (value !== null && typeof value === "object") pending.push(value); + } + } + return false; +}; + const withAdvertisedJsonSchema = ( canonical: Schema, io: "input" | "output", diff --git a/tests/boundary/mcp/outputValidatorReuse.test.ts b/tests/boundary/mcp/outputValidatorReuse.test.ts new file mode 100644 index 000000000..256c29d58 --- /dev/null +++ b/tests/boundary/mcp/outputValidatorReuse.test.ts @@ -0,0 +1,108 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { McpServer } from "@modelcontextprotocol/server"; +import { expect, it } from "vitest"; +import { z } from "zod"; + +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { toolOutputSchemaWithMetadata } from "../../../src/contracts/toolSchemaMetadata.js"; + +const advertised = < + Schema extends z.ZodType>>, +>( + schema: Schema, +): Schema => + toolOutputSchemaWithMetadata({ + ...toolContract("binary_session"), + outputSchema: schema, + }); + +const connect = async (server: McpServer, client: Client): Promise => { + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); +}; + +it("validates changed schemas after catalog refreshes and availability changes", async () => { + const client = new Client({ name: "output-reuse-test", version: "1" }); + const server = new McpServer({ name: "output-reuse-fixture", version: "1" }); + const schema = advertised(z.strictObject({ state: z.literal("ready") })); + let state = "ready"; + const selected = server.registerTool( + "selected", + { inputSchema: z.strictObject({}), outputSchema: schema }, + () => ({ content: [], structuredContent: { state } }), + ); + server.registerTool( + "equivalent", + { inputSchema: z.strictObject({}), outputSchema: schema }, + () => ({ content: [], structuredContent: { state: "ready" } }), + ); + try { + await connect(server, client); + await client.listTools(); + expect( + (await client.callTool({ name: "selected" })).structuredContent, + ).toEqual({ + state: "ready", + }); + await client.listTools(); + await client.callTool({ name: "equivalent" }); + + selected.update({ + outputSchema: advertised(z.strictObject({ state: z.literal("updated") })), + }); + state = "updated"; + await client.listTools(); + expect( + (await client.callTool({ name: "selected" })).structuredContent, + ).toEqual({ + state: "updated", + }); + selected.disable(); + const unavailable = await client.listTools(); + expect(unavailable.tools.some((tool) => tool.name === "selected")).toBe( + false, + ); + await expect(client.callTool({ name: "selected" })).rejects.toThrow( + /not found|disabled/iu, + ); + await client.callTool({ name: "equivalent" }); + selected.enable(); + await client.listTools(); + await client.callTool({ name: "selected" }); + } finally { + await Promise.allSettled([client.close(), server.close()]); + } +}); + +it("uses the new server's schema after reconnecting the client", async () => { + const client = new Client({ name: "output-reconnect-test", version: "1" }); + const servers: McpServer[] = []; + try { + for (const state of ["first", "second"]) { + const server = new McpServer({ name: `fixture-${state}`, version: "1" }); + servers.push(server); + server.registerTool( + "selected", + { + inputSchema: z.strictObject({}), + outputSchema: advertised(z.strictObject({ state: z.literal(state) })), + }, + () => ({ content: [], structuredContent: { state } }), + ); + await connect(server, client); + await client.listTools(); + expect( + (await client.callTool({ name: "selected" })).structuredContent, + ).toEqual({ state }); + await client.close(); + await server.close(); + } + } finally { + await Promise.allSettled([ + client.close(), + ...servers.map((server) => server.close()), + ]); + } +});