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.
This commit is contained in:
morluto committed 2026-10-15 01:41:31 +08:00
1 parent 0df0e4f53e
commit c5fb86d194
7 files changed
+125 -21

No files matched your search

+8 -3
View File
@@ -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
+15 -9
View File
@@ -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;
+7 -2
View File
@@ -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<Record<string, string | undefined>>,
): ReturnType<typeof createCatalogIdentity> => {
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",
);
};
+5 -2
View File
@@ -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",
);
}
/**
+53 -5
View File
@@ -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,
);
@@ -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" });
+33
View File
@@ -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");