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");