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()), + ]); + } +});