From c5fb86d1948af0a287a581b29b2b1d554743c607 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:41:23 +0800 Subject: [PATCH] fix(mcp): bind catalog identity to output schema profiles Include the selected output advertisement profile while retaining canonical output validation in the identity. Reuse the SDK output projection and select doctor expectations from both profiles. Correct the compact input error documentation. --- docs/mcp-contracts.md | 11 +++- src/catalogIdentity.ts | 24 +++++--- src/mcpDoctor.ts | 9 ++- src/server/EvidenceMcpServer.ts | 7 ++- tests/boundary/mcp/mcpDoctor.test.ts | 58 +++++++++++++++++-- .../boundary/mcp/outputSchemaProfile.test.ts | 4 ++ tests/boundary/mcp/serverIdentity.test.ts | 33 +++++++++++ 7 files changed, 125 insertions(+), 21 deletions(-) diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 02218bf51f..8df9845001 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -711,9 +711,9 @@ JSON Schema: in a reduced form: property names, reference-resolved types (including union alternatives), first-level guidance, and the root's required-field constraints, with nested validation detail elided. The - canonical schema still rejects malformed calls server-side, so callers - following the reduced advertisement receive the canonical typed error - instead of silent acceptance. + registered canonical schema still rejects malformed calls before invoking a + handler. That rejection can use the MCP SDK's input-validation diagnostic; + it does not guarantee the application's typed input error. - Schemas deeper than ten nodes use the same reduced projection, with root-property names, types, guidance, and required fields retained. The server continues to enforce the full canonical input contract. @@ -732,6 +732,11 @@ 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. +The server identity's tool-schema digest includes both the canonical output +contracts and the selected output-schema profile. `full` and `off` therefore +have distinct identities even though `off` keeps the same canonical output +validation on the server. + Some stdio hosts reject a complete `tools/list` frame before they load any tools. The opt-in `REA_MCP_OUTPUT_SCHEMA_PROFILE=off` setting omits `outputSchema` from the advertised catalog for hosts with a small frame limit (the measured diff --git a/src/catalogIdentity.ts b/src/catalogIdentity.ts index 74515ef755..676251dd02 100644 --- a/src/catalogIdentity.ts +++ b/src/catalogIdentity.ts @@ -1,7 +1,6 @@ import { createHash } from "node:crypto"; import canonicalize from "canonicalize"; -import { z } from "zod"; import { PROMPT_CONTRACTS } from "./contracts/promptContracts.js"; import type { PromptContract } from "./contracts/promptContracts.js"; @@ -9,11 +8,15 @@ import { TOOL_CONTRACTS } from "./contracts/toolContracts.js"; import type { ToolContract } from "./contracts/toolContractTypes.js"; import { CLI_COMMAND_NAMES } from "./cliCommandNames.js"; import { compareUnicodeCodePoints } from "./domain/unicodeCodePointOrder.js"; -import { advertisedInputJsonSchema } from "./contracts/toolSchemaMetadata.js"; +import { + advertisedInputJsonSchema, + toolOutputSchemaWithMetadata, +} from "./contracts/toolSchemaMetadata.js"; import { presentAdvertisedInputSchema, type CompactInputSchemaPresentation, } from "./contracts/compactInputSchema.js"; +import type { McpOutputSchemaProfile } from "./config/mcpOutputSchemaProfile.js"; const sortByName = ( contracts: readonly ToolContract[], @@ -46,12 +49,9 @@ const createToolCatalog = ( advertisedInputJsonSchema(contract), compactPresentation, ), - output_schema: { - type: "object", - ...z.toJSONSchema(contract.outputSchema, { - unrepresentable: "any", - }), - }, + output_schema: toolOutputSchemaWithMetadata(contract)[ + "~standard" + ].jsonSchema.output({ target: "draft-2020-12" }), })); const createPromptCatalog = (contracts: readonly PromptContract[]) => @@ -84,8 +84,12 @@ export const createCatalogDigests = ( contracts: readonly ToolContract[] = TOOL_CONTRACTS, promptContracts: readonly PromptContract[] = PROMPT_CONTRACTS, compactPresentation: CompactInputSchemaPresentation | undefined = undefined, + outputSchemaProfile: McpOutputSchemaProfile = "full", ) => { - const tools = createToolCatalog(sortByName(contracts), compactPresentation); + const tools = { + output_schema_profile: outputSchemaProfile, + catalog: createToolCatalog(sortByName(contracts), compactPresentation), + }; const prompts = createPromptCatalog(promptContracts); return { tools_sha256: digest(tools), @@ -100,6 +104,7 @@ export const createCatalogDigests = ( export const createCatalogIdentity = ( compactPresentation: CompactInputSchemaPresentation | undefined = undefined, + outputSchemaProfile: McpOutputSchemaProfile = "full", ) => { const selectedPresentation = compactPresentation === undefined @@ -117,6 +122,7 @@ export const createCatalogIdentity = ( TOOL_CONTRACTS, PROMPT_CONTRACTS, selectedPresentation, + outputSchemaProfile, )); }, } as const; diff --git a/src/mcpDoctor.ts b/src/mcpDoctor.ts index b502b1b1bd..d1dc0d34a7 100644 --- a/src/mcpDoctor.ts +++ b/src/mcpDoctor.ts @@ -10,6 +10,7 @@ import { compactPresentationForMcpInputSchemaProfile, parseMcpInputSchemaProfile, } from "./config/mcpInputSchemaProfile.js"; +import { parseMcpOutputSchemaProfile } from "./config/mcpOutputSchemaProfile.js"; import { PROMPT_CONTRACTS } from "./contracts/promptContracts.js"; import { TOOL_CONTRACTS } from "./contracts/toolContracts.js"; import { err, ok } from "./domain/result.js"; @@ -492,13 +493,17 @@ const targetFreeEnvironment = ( const catalogIdentityForEnvironment = ( environment: Readonly>, ): ReturnType => { - const parsed = parseMcpInputSchemaProfile( + const inputProfile = parseMcpInputSchemaProfile( environment.REA_MCP_INPUT_SCHEMA_PROFILE, ); + const outputProfile = parseMcpOutputSchemaProfile( + environment.REA_MCP_OUTPUT_SCHEMA_PROFILE, + ); return createCatalogIdentity( compactPresentationForMcpInputSchemaProfile( - parsed.ok ? parsed.value : undefined, + inputProfile.ok ? inputProfile.value : undefined, ), + outputProfile.ok ? (outputProfile.value ?? "full") : "full", ); }; diff --git a/src/server/EvidenceMcpServer.ts b/src/server/EvidenceMcpServer.ts index 3d2cd0da2c..cbf694bd7f 100644 --- a/src/server/EvidenceMcpServer.ts +++ b/src/server/EvidenceMcpServer.ts @@ -126,9 +126,12 @@ export class EvidenceMcpServer extends McpServer { ? undefined : { budgetBytes: compactPresentation.budgetBytes }; this.catalogIdentity = - this.compactPresentation === undefined + this.compactPresentation === undefined && !this.omitOutputSchemas ? CATALOG_IDENTITY - : createCatalogIdentity(this.compactPresentation); + : createCatalogIdentity( + this.compactPresentation, + this.omitOutputSchemas ? "off" : "full", + ); } /** diff --git a/tests/boundary/mcp/mcpDoctor.test.ts b/tests/boundary/mcp/mcpDoctor.test.ts index 3b91a9950f..12919a7fb2 100644 --- a/tests/boundary/mcp/mcpDoctor.test.ts +++ b/tests/boundary/mcp/mcpDoctor.test.ts @@ -12,7 +12,11 @@ import { runProductionMcpDoctor, runProductionMcpDoctorCli, } from "../../../src/mcpDoctor.js"; -import { CATALOG_IDENTITY } from "../../../src/catalogIdentity.js"; +import { + CATALOG_IDENTITY, + createCatalogIdentity, +} from "../../../src/catalogIdentity.js"; +import { compactPresentationForMcpInputSchemaProfile } from "../../../src/config/mcpInputSchemaProfile.js"; import { MCP_STARTUP_POLICY } from "../../../src/mcpStartupPolicy.js"; afterEach(() => vi.useRealTimers()); @@ -40,14 +44,52 @@ describe("production MCP doctor", () => { }); }); - it.each(["full", "compact"] as const)( - "connects to the production stdio child and verifies the %s catalog", - async (profile) => { + it.each([ + { + name: "full", + environment: { + REA_MCP_INPUT_SCHEMA_PROFILE: "full", + REA_MCP_OUTPUT_SCHEMA_PROFILE: "full", + }, + identity: CATALOG_IDENTITY, + }, + { + name: "compact", + environment: { + REA_MCP_INPUT_SCHEMA_PROFILE: "compact", + REA_MCP_OUTPUT_SCHEMA_PROFILE: "full", + }, + identity: createCatalogIdentity( + compactPresentationForMcpInputSchemaProfile("compact"), + ), + }, + { + name: "output schemas off", + environment: { + REA_MCP_INPUT_SCHEMA_PROFILE: "full", + REA_MCP_OUTPUT_SCHEMA_PROFILE: "off", + }, + identity: createCatalogIdentity(undefined, "off"), + }, + { + name: "compact input and output schemas off", + environment: { + REA_MCP_INPUT_SCHEMA_PROFILE: "compact", + REA_MCP_OUTPUT_SCHEMA_PROFILE: "off", + }, + identity: createCatalogIdentity( + compactPresentationForMcpInputSchemaProfile("compact"), + "off", + ), + }, + ])( + "connects to the production stdio child and verifies the $name catalog", + async ({ environment, identity }) => { const result = await runProductionMcpDoctor({ command: process.execPath, args: [resolve("scripts/rea.mjs"), "mcp"], cwd: process.cwd(), - environment: { ...process.env, REA_MCP_INPUT_SCHEMA_PROFILE: profile }, + environment: { ...process.env, ...environment }, }); expect(result).toMatchObject({ @@ -69,6 +111,12 @@ describe("production MCP doctor", () => { expect(result.inventory?.tools.expected).toBe( CATALOG_IDENTITY.counts.mcp_tools, ); + expect(result.identity?.expected_catalog_digest).toBe( + identity.digests.combined_sha256, + ); + expect(result.identity?.observed_catalog_digest).toBe( + identity.digests.combined_sha256, + ); }, 30_000, ); diff --git a/tests/boundary/mcp/outputSchemaProfile.test.ts b/tests/boundary/mcp/outputSchemaProfile.test.ts index a77dddf8c9..a298ef0918 100644 --- a/tests/boundary/mcp/outputSchemaProfile.test.ts +++ b/tests/boundary/mcp/outputSchemaProfile.test.ts @@ -10,6 +10,7 @@ import { EvidenceMcpServer } from "../../../src/server/EvidenceMcpServer.js"; import { toolRegistrationOptions } from "../../../src/server/toolRegistrationOptions.js"; import { createToolResultDelivery } from "../../../src/server/toolResult.js"; import type { ToolContract } from "../../../src/contracts/toolContractTypes.js"; +import { CATALOG_IDENTITY } from "../../../src/catalogIdentity.js"; const createServer = (omitOutputSchemas: boolean) => new EvidenceMcpServer( @@ -23,6 +24,9 @@ const createServer = (omitOutputSchemas: boolean) => it("omits only output schema advertisements and keeps canonical MCP output validation", async () => { const server = createServer(true); + expect(server.catalogIdentity.digests.combined_sha256).not.toBe( + CATALOG_IDENTITY.digests.combined_sha256, + ); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const client = new Client({ name: "schema-profile-test", version: "1" }); diff --git a/tests/boundary/mcp/serverIdentity.test.ts b/tests/boundary/mcp/serverIdentity.test.ts index 9219405e2e..6eb431f9f2 100644 --- a/tests/boundary/mcp/serverIdentity.test.ts +++ b/tests/boundary/mcp/serverIdentity.test.ts @@ -140,6 +140,39 @@ describe("server and catalog identity", () => { ); }); + it("binds output schema profile and canonical output contracts into identity", () => { + const full = createCatalogDigests(); + const off = createCatalogDigests( + TOOL_CONTRACTS, + PROMPT_CONTRACTS, + undefined, + "off", + ); + expect(off.tools_sha256).not.toBe(full.tools_sha256); + expect(off.combined_sha256).not.toBe(full.combined_sha256); + expect(off.prompts_sha256).toBe(full.prompts_sha256); + + const changedOutput = TOOL_CONTRACTS.map((contract, index) => + index === 0 + ? { + ...contract, + outputSchema: z.strictObject({ profile_probe: z.literal(true) }), + } + : contract, + ); + const changedFull = createCatalogDigests(changedOutput); + const changedOff = createCatalogDigests( + changedOutput, + PROMPT_CONTRACTS, + undefined, + "off", + ); + expect(changedFull.tools_sha256).not.toBe(full.tools_sha256); + expect(changedOff.tools_sha256).not.toBe(off.tools_sha256); + expect(changedFull.combined_sha256).not.toBe(full.combined_sha256); + expect(changedOff.combined_sha256).not.toBe(off.combined_sha256); + }); + it("changes catalog identity when advertised input examples change", () => { const contract = TOOL_CONTRACTS.find((item) => item.examples.length > 0); if (contract === undefined) throw new Error("expected an input example");