mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
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:
1 parent
0df0e4f53e
commit
c5fb86d194
7 files changed
+125
-21
No files matched your search
@@ -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
@@ -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
@@ -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",
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user