Files
rea/tests/boundary/mcp/compactInputSchemaMcp.test.ts
T
morluto ba98130049 fix(mcp): distinguish schema references from literal metadata
Share schema keyword roles and preserve reference bases for explicit local pointer targets and uncertain intermediate scopes. Retain literal values and canonical validation. Refs #4936.
2026-10-18 09:32:28 +08:00

462 lines
16 KiB
TypeScript

import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { STDIO_DEFAULT_MAX_BUFFER_SIZE } from "@modelcontextprotocol/server";
import { Ajv2020 } from "ajv/dist/2020.js";
import { describe, expect, it } from "vitest";
import { z } from "zod";
import {
TOOL_CONTRACTS,
toolContract,
} from "../../../src/contracts/toolContracts.js";
import { PROMPT_CONTRACTS } from "../../../src/contracts/promptContracts.js";
import { advertisedInputJsonSchema } from "../../../src/contracts/toolSchemaMetadata.js";
import { presentAdvertisedInputSchema } from "../../../src/contracts/compactInputSchema.js";
import { createCatalogDigests } from "../../../src/catalogIdentity.js";
import {
schemaArrays,
schemaChildren,
schemaMaps,
} from "../../../src/contracts/jsonSchemaKeywords.js";
import {
COMPACT_INPUT_SCHEMA_BUDGET_BYTES,
parseMcpInputSchemaProfile,
} from "../../../src/config/mcpInputSchemaProfile.js";
import { processScenarioSchema } from "../../../src/domain/process/processScenario.js";
import { EvidenceMcpServer } from "../../../src/server/EvidenceMcpServer.js";
import { ToolResultDelivery } from "../../../src/server/toolResult.js";
import { toolRegistrationOptions } from "../../../src/server/toolRegistrationOptions.js";
import {
arrayItemSchemaError,
booleanLiteralSchemaError,
} from "../../fixtures/mcpClientSchemaConstraints.js";
interface AdvertisedTool {
readonly name: string;
readonly inputSchema: Record<string, unknown>;
readonly outputSchema?: Record<string, unknown> | undefined;
readonly description?: string | undefined;
}
const serializedBytes = (value: unknown): number =>
Buffer.byteLength(JSON.stringify(value), "utf8");
const schemaDepth = (value: unknown, depth = 0): number => {
if (Array.isArray(value))
return Math.max(
depth,
...value.map((child) => schemaDepth(child, depth + 1)),
);
if (typeof value !== "object" || value === null) return depth;
return Math.max(
depth,
...Object.values(value).map((child) => schemaDepth(child, depth + 1)),
);
};
const unsupportedDependencyKeywords = (
schema: unknown,
path = "$",
): readonly string[] => {
if (Array.isArray(schema))
return schema.flatMap((child, index) =>
unsupportedDependencyKeywords(child, `${path}[${index}]`),
);
if (typeof schema !== "object" || schema === null) return [];
return Object.entries(schema).flatMap(([key, value]) => {
const childPath = `${path}.${key}`;
if (key === "dependencies" || key === "dependentRequired")
return [childPath];
if (["const", "enum", "default", "examples"].includes(key)) return [];
if (schemaMaps.has(key) && typeof value === "object" && value !== null)
return Object.entries(value).flatMap(([name, child]) =>
unsupportedDependencyKeywords(child, `${childPath}.${name}`),
);
if (schemaArrays.has(key) && Array.isArray(value))
return unsupportedDependencyKeywords(value, childPath);
if (schemaChildren.has(key))
return unsupportedDependencyKeywords(value, childPath);
return [];
});
};
const listToolsThroughTransport = async (
server: EvidenceMcpServer,
): Promise<readonly AdvertisedTool[]> => {
const client = new Client({ name: "compact-profile", version: "0" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
return (await client.listTools()).tools;
} finally {
await Promise.allSettled([client.close(), server.close()]);
}
};
const registerCatalog = (server: EvidenceMcpServer): void => {
for (const contract of TOOL_CONTRACTS)
server.registerTool(
contract.name,
toolRegistrationOptions(contract),
async () => ({ content: [] }),
);
};
const compactServer = (): EvidenceMcpServer =>
new EvidenceMcpServer(
{ name: "compact-profile", version: "0" },
{ capabilities: {} },
undefined,
new ToolResultDelivery(STDIO_DEFAULT_MAX_BUFFER_SIZE),
{ budgetBytes: COMPACT_INPUT_SCHEMA_BUDGET_BYTES },
);
const fullServer = (): EvidenceMcpServer =>
new EvidenceMcpServer(
{ name: "full-profile", version: "0" },
{ capabilities: {} },
undefined,
new ToolResultDelivery(STDIO_DEFAULT_MAX_BUFFER_SIZE),
);
describe("MCP compact input schema profile", () => {
it("matches profile digests to tools/list and excludes stripped annotations", async () => {
const compactPresentation = {
budgetBytes: COMPACT_INPUT_SCHEMA_BUDGET_BYTES,
};
const full = fullServer();
const compact = compactServer();
registerCatalog(full);
registerCatalog(compact);
const [fullTools, compactTools] = await Promise.all([
listToolsThroughTransport(full),
listToolsThroughTransport(compact),
]);
const toolsByName = [
new Map(fullTools.map((tool) => [tool.name, tool])),
new Map(compactTools.map((tool) => [tool.name, tool])),
];
for (const [index, presentation] of [
undefined,
compactPresentation,
].entries()) {
for (const contract of TOOL_CONTRACTS) {
expect(toolsByName[index]?.get(contract.name)?.inputSchema).toEqual(
presentAdvertisedInputSchema(
advertisedInputJsonSchema(contract),
presentation,
),
);
}
}
const fullIdentity = createCatalogDigests();
const compactIdentity = createCatalogDigests(
TOOL_CONTRACTS,
PROMPT_CONTRACTS,
compactPresentation,
);
expect(full.catalogIdentity.digests).toEqual(fullIdentity);
expect(compact.catalogIdentity.digests).toEqual(compactIdentity);
expect(compactIdentity.tools_sha256).not.toBe(fullIdentity.tools_sha256);
expect(
createCatalogDigests(TOOL_CONTRACTS, PROMPT_CONTRACTS, {
budgetBytes: 1,
}).tools_sha256,
).not.toBe(compactIdentity.tools_sha256);
const withoutExamples = TOOL_CONTRACTS.map((contract) =>
contract.examples.length === 0 ? contract : { ...contract, examples: [] },
);
expect(createCatalogDigests(withoutExamples).tools_sha256).not.toBe(
fullIdentity.tools_sha256,
);
expect(
createCatalogDigests(
withoutExamples,
PROMPT_CONTRACTS,
compactPresentation,
).tools_sha256,
).toBe(compactIdentity.tools_sha256);
});
});
it("advertises every tool input schema within the provider budget", async () => {
const server = compactServer();
registerCatalog(server);
const tools = await listToolsThroughTransport(server);
expect(tools.map(({ name }) => name).sort()).toEqual(
TOOL_CONTRACTS.map(({ name }) => name).sort(),
);
const oversized = tools.filter(
({ inputSchema }) =>
serializedBytes(inputSchema) > COMPACT_INPUT_SCHEMA_BUDGET_BYTES,
);
expect(oversized.map(({ name }) => name)).toEqual([]);
const tooDeep = tools.filter(
({ inputSchema }) => schemaDepth(inputSchema) > 10,
);
expect(
tooDeep.map(({ name, inputSchema }) => [name, schemaDepth(inputSchema)]),
).toEqual([]);
});
it("keeps output schemas and tool descriptions identical to the default profile", async () => {
const compact = compactServer();
const full = fullServer();
registerCatalog(compact);
registerCatalog(full);
const [compactTools, fullTools] = await Promise.all([
listToolsThroughTransport(compact),
listToolsThroughTransport(full),
]);
const fullByName = new Map(fullTools.map((tool) => [tool.name, tool]));
expect(compactTools.length).toBe(fullTools.length);
for (const tool of compactTools) {
const reference = fullByName.get(tool.name);
if (reference === undefined) throw new Error(`Missing ${tool.name}`);
expect(tool.outputSchema, tool.name).toEqual(reference.outputSchema);
expect(tool.description, tool.name).toBe(reference.description);
}
});
it("detects missing client-required items in nested array alternatives", () => {
const malformed = {
type: "object",
properties: {
payload: {
type: ["null", "array"],
items: { type: ["string", "array"] },
},
},
};
expect(arrayItemSchemaError(malformed, "injected")).toBe(
"injected.properties.payload.items: tool parameters array type must have items",
);
// `items: false` is valid JSON Schema, but the targeted Copilot
// normalizer rejects it because it requires a truthy items schema.
expect(arrayItemSchemaError({ type: "array", items: false }, "closed")).toBe(
"closed: tool parameters array type must have items",
);
});
it("preserves arbitrary nested JSON values at the converted SDK boundary", async () => {
const contract = {
...toolContract("binary_session"),
inputSchema: z.strictObject({ value: z.unknown() }),
};
const value = {
deeply_nested: [[[[null, true, 7, "text"]]], { mixed: [[], [false]] }],
};
for (const profile of ["full", "compact"] as const) {
const server = profile === "full" ? fullServer() : compactServer();
server.registerTool(
contract.name,
toolRegistrationOptions(contract),
async () => ({ content: [] }),
);
const tool = (await listToolsThroughTransport(server)).find(
({ name }) => name === contract.name,
);
if (tool === undefined) throw new Error("Probe tool was not advertised");
const validate = new Ajv2020({
strict: false,
validateFormats: false,
}).compile(tool.inputSchema);
expect(contract.inputSchema.safeParse({ value }).success).toBe(true);
expect(validate({ value }), `${profile} profile`).toBe(true);
}
});
it("keeps SDK-converted schemas valid and accepting canonical examples", async () => {
for (const profile of ["full", "compact"] as const) {
const server = profile === "full" ? fullServer() : compactServer();
registerCatalog(server);
const tools = await listToolsThroughTransport(server);
const ajv = new Ajv2020({ strict: false, validateFormats: false });
const contractsByName = new Map<string, (typeof TOOL_CONTRACTS)[number]>(
TOOL_CONTRACTS.map((contract) => [contract.name, contract]),
);
for (const tool of tools) {
expect(
arrayItemSchemaError(tool.inputSchema, tool.name),
`${profile} profile: ${tool.name}`,
).toBeUndefined();
expect(
booleanLiteralSchemaError(tool.inputSchema, tool.name),
).toBeUndefined();
expect(
ajv.validateSchema(tool.inputSchema),
`${profile} profile, ${tool.name}: ${ajv.errorsText(ajv.errors)}`,
).toBe(true);
const contract = contractsByName.get(tool.name);
const validate = ajv.compile(tool.inputSchema);
for (const example of contract?.examples ?? [])
expect(
validate(example.input),
`${profile} profile, ${tool.name}: ${example.title}`,
).toBe(true);
}
}
});
it("omits Gemini-unsupported dependency keywords from both catalog profiles", async () => {
const full = fullServer();
const compact = compactServer();
registerCatalog(full);
registerCatalog(compact);
const [fullTools, compactTools] = await Promise.all([
listToolsThroughTransport(full),
listToolsThroughTransport(compact),
]);
for (const tools of [fullTools, compactTools])
expect(
tools.flatMap(({ name, inputSchema }) =>
unsupportedDependencyKeywords(inputSchema).map(
(path) => `${name}${path.slice(1)}`,
),
),
).toEqual([]);
const sqlite = fullTools.find(
({ name }) => name === "inspect_sqlite_database",
);
expect(sqlite?.inputSchema.description).toContain(
"When row_limit is provided, also provide table.",
);
for (const tools of [fullTools, compactTools])
expect(
tools.find(({ name }) => name === "inspect_sqlite_database")?.inputSchema
.description,
).toContain("When schema_scope is selected_table, also provide table.");
});
it("retains parameter types and avoids client-side reference expansion", async () => {
const server = compactServer();
registerCatalog(server);
const tools = await listToolsThroughTransport(server);
for (const tool of tools) {
// Kimi Code 2.1.1 expands references and defaults description-only
// properties to string. Inline schemas with explicit shallow types
// avoid both conversions for the structurally oversized tools.
expect(JSON.stringify(tool.inputSchema), tool.name).not.toContain(
'"$ref":',
);
}
const comparison = tools.find(
({ name }) => name === "compare_managed_members",
);
expect(comparison?.inputSchema.properties).toMatchObject({
left: { type: "object" },
});
const ledger = tools.find(
({ name }) => name === "build_reconstruction_obligation_ledger",
);
expect(ledger?.inputSchema.properties).toMatchObject({
evidence_bundle: { type: "object" },
reviewed_obligations: { type: "array" },
manifest: { type: "object" },
});
const coverage = tools.find(
({ name }) => name === "evaluate_reconstruction_coverage",
);
expect(coverage?.inputSchema.properties).toMatchObject({
coverage: { type: "object" },
boundary_id: { type: "string" },
});
});
it("parses the profile selection the server startup reads", () => {
expect(parseMcpInputSchemaProfile("compact")).toEqual({
ok: true,
value: "compact",
});
expect(parseMcpInputSchemaProfile("surprise")).not.toHaveProperty("value");
});
describe("MCP compact input schema canonical enforcement", () => {
it("still rejects canonical-invalid arguments the compact advertisement accepts", async () => {
const server = compactServer();
const contract = TOOL_CONTRACTS.find(
({ name }) => name === "capture_process_scenario",
);
if (contract === undefined)
throw new Error("Process capture contract was not registered");
let handlerCalled = false;
server.registerTool(
contract.name,
toolRegistrationOptions(contract),
async () => {
handlerCalled = true;
return {
content: [{ type: "text" as const, text: "handler ran" }],
isError: true,
};
},
);
const client = new Client({ name: "compact-validation", version: "0" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
const advertised = (await client.listTools()).tools.find(
({ name }) => name === contract.name,
);
if (advertised === undefined)
throw new Error("Process capture tool was not advertised");
const ajv = new Ajv2020({ strict: false, validateFormats: false });
const validate = ajv.compile(advertised.inputSchema);
const valid = { executable: "node", environment: { APP_MODE: "test" } };
const reserved = {
executable: "node",
environment: { REA_PROCESS_RUN_ID: "caller-value" },
};
expect(validate(valid)).toBe(true);
expect(processScenarioSchema.safeParse(valid).success).toBe(true);
expect(processScenarioSchema.safeParse(reserved).success).toBe(false);
const result = await client.callTool({
name: contract.name,
arguments: reserved,
});
expect(result.isError).toBe(true);
expect(handlerCalled).toBe(false);
} finally {
await Promise.allSettled([client.close(), server.close()]);
}
});
it("compacts plain standard schemas that are not Zod instances", async () => {
// Mirrors registerWebNetworkCaptureTool: a plain standard schema reusing
// the advertised projection while owning its own permissive validator.
const server = compactServer();
const contract = TOOL_CONTRACTS.find(
({ name }) => name === "inspect_web_network_capture",
);
if (contract === undefined)
throw new Error("Network capture contract was not registered");
const registration = toolRegistrationOptions(contract);
const inputSchema = {
"~standard": {
...registration.inputSchema["~standard"],
vendor: "rea-application",
validate: (value: unknown) => ({ value }),
},
};
server.registerTool(
contract.name,
{ ...registration, inputSchema },
async () => ({ content: [] }),
);
const tools = await listToolsThroughTransport(server);
const advertised = tools.find(({ name }) => name === contract.name);
if (advertised === undefined)
throw new Error("Network capture tool was not advertised");
expect(serializedBytes(advertised.inputSchema)).toBeLessThanOrEqual(
COMPACT_INPUT_SCHEMA_BUDGET_BYTES,
);
const ajv = new Ajv2020({ strict: false, validateFormats: false });
expect(ajv.validateSchema(advertised.inputSchema)).toBe(true);
});
});