mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
feat(mcp): support compact output schema advertisements (#7218)
Add opt-in bounded output-schema advertisements while preserving the full default, canonical server validation and complete structured content. Verify real stdio discovery under a 1 MiB receive cap, all 5,367 libcrypto export coordinates, complete Evidence parity, whole-task serialization estimates and resource bounds. Fixes #2512. Integrates #7054 by Akshad Jogi with original authorship retained. Co-authored-by: Akshad Jogi <akshadjogi@gmail.com>
This commit is contained in:
1 parent
e596a48251
commit
9085222ca1
22 files changed
+1831
-771
No files matched your search
@@ -63,6 +63,64 @@ const assertCliCatalogIdentity = async (
|
||||
}
|
||||
};
|
||||
|
||||
const catalogProfiles = [
|
||||
{
|
||||
name: "default compact input",
|
||||
environment: {
|
||||
REA_MCP_INPUT_SCHEMA_PROFILE: undefined,
|
||||
REA_MCP_OUTPUT_SCHEMA_PROFILE: "full",
|
||||
},
|
||||
identity: createCatalogIdentity(
|
||||
compactPresentationForMcpInputSchemaProfile(undefined),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "full",
|
||||
environment: {
|
||||
REA_MCP_INPUT_SCHEMA_PROFILE: "full",
|
||||
REA_MCP_OUTPUT_SCHEMA_PROFILE: "full",
|
||||
},
|
||||
identity: FULL_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: "output schemas compact",
|
||||
environment: {
|
||||
REA_MCP_INPUT_SCHEMA_PROFILE: "full",
|
||||
REA_MCP_OUTPUT_SCHEMA_PROFILE: "compact",
|
||||
},
|
||||
identity: createCatalogIdentity(undefined, "compact"),
|
||||
},
|
||||
{
|
||||
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",
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
describe("production MCP doctor", () => {
|
||||
it("reuses immutable compiled profile digests and preserves custom-budget identity", async () => {
|
||||
// Exercise the generated module with Node, outside Vitest's source loader.
|
||||
@@ -79,7 +137,7 @@ describe("production MCP doctor", () => {
|
||||
assert.ok(Object.isFrozen(profiles));
|
||||
for (const input of ["full", "compact"]) {
|
||||
assert.ok(Object.isFrozen(profiles[input]));
|
||||
for (const output of ["full", "off"]) {
|
||||
for (const output of ["full", "compact", "off"]) {
|
||||
const identity = createCatalogIdentity(input === "compact" ? { budgetBytes } : undefined, output);
|
||||
assert.equal(identity.digests, profiles[input][output]);
|
||||
assert.ok(Object.isFrozen(identity.digests));
|
||||
@@ -124,55 +182,7 @@ describe("production MCP doctor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "default compact input",
|
||||
environment: {
|
||||
REA_MCP_INPUT_SCHEMA_PROFILE: undefined,
|
||||
REA_MCP_OUTPUT_SCHEMA_PROFILE: "full",
|
||||
},
|
||||
identity: createCatalogIdentity(
|
||||
compactPresentationForMcpInputSchemaProfile(undefined),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "full",
|
||||
environment: {
|
||||
REA_MCP_INPUT_SCHEMA_PROFILE: "full",
|
||||
REA_MCP_OUTPUT_SCHEMA_PROFILE: "full",
|
||||
},
|
||||
identity: FULL_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",
|
||||
),
|
||||
},
|
||||
])(
|
||||
it.each(catalogProfiles)(
|
||||
"verifies the $name catalog through production stdio and both CLI inventories",
|
||||
async ({ environment, identity }) => {
|
||||
const result = await runProductionMcpDoctor({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import { z } from "zod";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
@@ -6,24 +7,34 @@ import {
|
||||
TOOL_CONTRACTS,
|
||||
toolContract,
|
||||
} from "../../../src/contracts/toolContracts.js";
|
||||
import { COMPACT_OUTPUT_SCHEMA_BUDGET_BYTES } from "../../../src/config/mcpOutputSchemaProfile.js";
|
||||
import { COMPACT_INPUT_SCHEMA_BUDGET_BYTES } from "../../../src/config/mcpInputSchemaProfile.js";
|
||||
import type { CompactInputSchemaPresentation } from "../../../src/contracts/compactInputSchema.js";
|
||||
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 { FULL_CATALOG_IDENTITY } from "../../../src/catalogIdentity.js";
|
||||
import type { McpOutputSchemaProfile } from "../../../src/config/mcpOutputSchemaProfile.js";
|
||||
import { advertisedSchemaErrors } from "../../fixtures/mcpAdvertisedSchemaValidity.js";
|
||||
|
||||
const createServer = (omitOutputSchemas: boolean) =>
|
||||
const createServer = (
|
||||
outputSchemaProfile: McpOutputSchemaProfile,
|
||||
compactInputPresentation:
|
||||
| CompactInputSchemaPresentation
|
||||
| undefined = undefined,
|
||||
) =>
|
||||
new EvidenceMcpServer(
|
||||
{ name: "output-schema-fixture", version: "1" },
|
||||
{ capabilities: {} },
|
||||
undefined,
|
||||
createToolResultDelivery(10 * 1024 * 1024),
|
||||
undefined,
|
||||
omitOutputSchemas,
|
||||
compactInputPresentation,
|
||||
outputSchemaProfile,
|
||||
);
|
||||
|
||||
it("omits only output schema advertisements and keeps canonical MCP output validation", async () => {
|
||||
const server = createServer(true);
|
||||
const server = createServer("off");
|
||||
expect(server.catalogIdentity.digests.combined_sha256).not.toBe(
|
||||
FULL_CATALOG_IDENTITY.digests.combined_sha256,
|
||||
);
|
||||
@@ -86,9 +97,97 @@ it("omits only output schema advertisements and keeps canonical MCP output valid
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a full catalog below a measured one-megabyte frame when output schemas are off", async () => {
|
||||
const measure = async (omitOutputSchemas: boolean): Promise<number> => {
|
||||
const server = createServer(omitOutputSchemas);
|
||||
it("bounds output schema advertisements and keeps canonical MCP output validation", async () => {
|
||||
const server = createServer("compact");
|
||||
expect(server.catalogIdentity.digests.combined_sha256).not.toBe(
|
||||
FULL_CATALOG_IDENTITY.digests.combined_sha256,
|
||||
);
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
const client = new Client({ name: "compact-output-test", version: "1" });
|
||||
const contract = {
|
||||
...toolContract("comment"),
|
||||
name: "compact_output_schema_fixture",
|
||||
inputSchema: z.strictObject({}),
|
||||
outputSchema: z.strictObject({
|
||||
value: z.string().describe("Nested guidance leaves."),
|
||||
nested: z
|
||||
.object({ count: z.number().int() })
|
||||
.describe("Nested guidance leaves."),
|
||||
}),
|
||||
} satisfies ToolContract;
|
||||
server.registerTool(
|
||||
contract.name,
|
||||
toolRegistrationOptions(contract),
|
||||
async () => ({
|
||||
content: [{ type: "text" as const, text: "fixture" }],
|
||||
structuredContent: { value: "complete", nested: { count: 1 } },
|
||||
}),
|
||||
);
|
||||
const invalidContract = {
|
||||
...contract,
|
||||
name: "invalid_compact_output_schema_fixture",
|
||||
} satisfies ToolContract;
|
||||
server.registerTool(
|
||||
invalidContract.name,
|
||||
toolRegistrationOptions(invalidContract),
|
||||
async () => ({
|
||||
content: [{ type: "text" as const, text: "invalid fixture" }],
|
||||
structuredContent: { value: 42, nested: { count: 1 } },
|
||||
}),
|
||||
);
|
||||
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
try {
|
||||
const tools = (await client.listTools()).tools;
|
||||
expect(tools).toHaveLength(2);
|
||||
const advertised = tools.find((tool) => tool.name === contract.name)!
|
||||
.outputSchema as Record<string, unknown>;
|
||||
expect(advertised.type).toBe("object");
|
||||
expect(advertised.$id).toBeUndefined();
|
||||
expect((advertised.properties as Record<string, unknown>).value).toEqual({
|
||||
type: "string",
|
||||
});
|
||||
expect(
|
||||
(advertised.properties as Record<string, unknown>).nested,
|
||||
).toMatchObject({ type: "object" });
|
||||
|
||||
const success = await client.callTool({
|
||||
name: contract.name,
|
||||
arguments: {},
|
||||
});
|
||||
expect(success.isError).not.toBe(true);
|
||||
expect(success.structuredContent).toEqual({
|
||||
value: "complete",
|
||||
nested: { count: 1 },
|
||||
});
|
||||
const invalid = await client.callTool({
|
||||
name: invalidContract.name,
|
||||
arguments: {},
|
||||
});
|
||||
expect(invalid.isError).toBe(true);
|
||||
expect(invalid.content).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "text",
|
||||
text: expect.stringContaining("Output validation error"),
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the default compact-input catalog below a measured one-megabyte frame", async () => {
|
||||
const compactInput: CompactInputSchemaPresentation = {
|
||||
budgetBytes: COMPACT_INPUT_SCHEMA_BUDGET_BYTES,
|
||||
};
|
||||
const measure = async (
|
||||
profile: McpOutputSchemaProfile,
|
||||
inputPresentation: CompactInputSchemaPresentation | undefined,
|
||||
): Promise<number> => {
|
||||
const server = createServer(profile, inputPresentation);
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
const client = new Client({ name: "catalog-size-test", version: "1" });
|
||||
@@ -104,6 +203,26 @@ it("keeps a full catalog below a measured one-megabyte frame when output schemas
|
||||
await client.connect(clientTransport);
|
||||
try {
|
||||
const listed = await client.listTools();
|
||||
if (profile === "compact") {
|
||||
// Validate the advertised documents as the client receives them, not
|
||||
// just their bytes: a projection that broke a reference or left an
|
||||
// invalid schema would otherwise still pass the budget assertion.
|
||||
expect(
|
||||
advertisedSchemaErrors(
|
||||
listed.tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
inputSchema: tool.inputSchema,
|
||||
outputSchema: tool.outputSchema,
|
||||
})),
|
||||
),
|
||||
).toEqual([]);
|
||||
for (const tool of listed.tools) {
|
||||
expect(tool.outputSchema).toBeDefined();
|
||||
expect(
|
||||
Buffer.byteLength(JSON.stringify(tool.outputSchema), "utf8"),
|
||||
).toBeLessThanOrEqual(COMPACT_OUTPUT_SCHEMA_BUDGET_BYTES);
|
||||
}
|
||||
}
|
||||
return Buffer.byteLength(JSON.stringify(listed), "utf8");
|
||||
} finally {
|
||||
await client.close();
|
||||
@@ -111,9 +230,120 @@ it("keeps a full catalog below a measured one-megabyte frame when output schemas
|
||||
}
|
||||
};
|
||||
|
||||
const fullBytes = await measure(false);
|
||||
const compactBytes = await measure(true);
|
||||
const fullBytes = await measure("full", undefined);
|
||||
const compactBytes = await measure("compact", compactInput);
|
||||
const offBytes = await measure("off", compactInput);
|
||||
const fullInputOffBytes = await measure("off", undefined);
|
||||
expect(fullBytes).toBeGreaterThan(1024 * 1024);
|
||||
expect(compactBytes).toBeLessThan(1024 * 1024);
|
||||
expect(offBytes).toBeLessThan(1024 * 1024);
|
||||
expect(fullInputOffBytes).toBeLessThan(1024 * 1024);
|
||||
expect(compactBytes).toBeLessThan(fullBytes);
|
||||
expect(compactBytes).toBeGreaterThan(offBytes);
|
||||
});
|
||||
|
||||
it("rejects canonical-invalid output that the reduced advertisement accepts", async () => {
|
||||
// Enough nested detail that the compact projection cannot inline the
|
||||
// canonical schema within the budget, so the advertised form is the shallow
|
||||
// root shape rather than the complete validation structure.
|
||||
const nestedDetail = Object.fromEntries(
|
||||
Array.from({ length: 150 }, (_, index) => [
|
||||
`nested_${String(index).padStart(3, "0")}`,
|
||||
z.string().optional(),
|
||||
]),
|
||||
);
|
||||
const outputSchema = z.strictObject({
|
||||
value: z.string(),
|
||||
payload: z.object({
|
||||
inner: z.string(),
|
||||
count: z.number().int(),
|
||||
...nestedDetail,
|
||||
}),
|
||||
});
|
||||
const server = createServer("compact");
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
const client = new Client({ name: "reduced-output-test", version: "1" });
|
||||
const contract = {
|
||||
...toolContract("comment"),
|
||||
name: "reduced_output_schema_fixture",
|
||||
inputSchema: z.strictObject({}),
|
||||
outputSchema,
|
||||
} satisfies ToolContract;
|
||||
server.registerTool(
|
||||
contract.name,
|
||||
toolRegistrationOptions(contract),
|
||||
async () => ({
|
||||
content: [{ type: "text" as const, text: "fixture" }],
|
||||
structuredContent: {
|
||||
value: "complete",
|
||||
payload: { inner: "text", count: 1 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
// `payload.inner` is a string canonically. The shallow advertisement keeps
|
||||
// only `payload: {type: "object"}`, so it accepts the malformed value.
|
||||
const malformed = {
|
||||
value: "complete",
|
||||
payload: { inner: 42, count: 1 },
|
||||
};
|
||||
const invalidContract = {
|
||||
...contract,
|
||||
name: "invalid_reduced_output_schema_fixture",
|
||||
} satisfies ToolContract;
|
||||
server.registerTool(
|
||||
invalidContract.name,
|
||||
toolRegistrationOptions(invalidContract),
|
||||
async () => ({
|
||||
content: [{ type: "text" as const, text: "invalid fixture" }],
|
||||
structuredContent: malformed,
|
||||
}),
|
||||
);
|
||||
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
try {
|
||||
const tools = (await client.listTools()).tools;
|
||||
const advertised = tools.find((tool) => tool.name === contract.name)!
|
||||
.outputSchema as Record<string, unknown>;
|
||||
expect(advertised.description).toContain("reduced form");
|
||||
expect((advertised.properties as Record<string, unknown>).payload).toEqual({
|
||||
type: "object",
|
||||
});
|
||||
expect(
|
||||
Buffer.byteLength(JSON.stringify(advertised), "utf8"),
|
||||
).toBeLessThanOrEqual(COMPACT_OUTPUT_SCHEMA_BUDGET_BYTES);
|
||||
// The regression this guards: the advertisement accepts a value the
|
||||
// canonical contract rejects, so validating results against the
|
||||
// advertisement instead of the contract would deliver malformed output.
|
||||
const validateAdvertised = new Ajv2020({
|
||||
strict: false,
|
||||
validateFormats: false,
|
||||
}).compile(advertised);
|
||||
expect(validateAdvertised(malformed)).toBe(true);
|
||||
|
||||
const success = await client.callTool({
|
||||
name: contract.name,
|
||||
arguments: {},
|
||||
});
|
||||
expect(success.isError).not.toBe(true);
|
||||
expect(success.structuredContent).toEqual({
|
||||
value: "complete",
|
||||
payload: { inner: "text", count: 1 },
|
||||
});
|
||||
const invalid = await client.callTool({
|
||||
name: invalidContract.name,
|
||||
arguments: {},
|
||||
});
|
||||
expect(invalid.isError).toBe(true);
|
||||
expect(invalid.content).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "text",
|
||||
text: expect.stringContaining("Output validation error"),
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
arrayItemSchemaError,
|
||||
unsupportedInputLiteralSchemaError,
|
||||
} from "../../fixtures/mcpClientSchemaConstraints.js";
|
||||
import { advertisedSchemaErrors } from "../../fixtures/mcpAdvertisedSchemaValidity.js";
|
||||
import { toolRegistrationOptions } from "../../../src/server/toolRegistrationOptions.js";
|
||||
import { servedCatalog } from "../../../src/application/environment/environmentState.js";
|
||||
import { servedFrom } from "../../fixtures/environmentStateServing.js";
|
||||
@@ -31,47 +32,6 @@ interface ToolSchemas {
|
||||
readonly annotations?: Record<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
function schemaErrors(tools: readonly ToolSchemas[]): string[] {
|
||||
// Reuse Ajv's meta-schema validators, then clear each root's reference
|
||||
// namespace, including nested IDs, before validating the next schema.
|
||||
const ajv = new Ajv2020({
|
||||
strict: false,
|
||||
validateFormats: false,
|
||||
addUsedSchema: false,
|
||||
});
|
||||
return tools.flatMap((tool) =>
|
||||
["inputSchema", "outputSchema"].flatMap((kind) => {
|
||||
const schema =
|
||||
kind === "inputSchema" ? tool.inputSchema : tool.outputSchema;
|
||||
if (schema === undefined) return [];
|
||||
try {
|
||||
if (!ajv.validateSchema(schema))
|
||||
return [`${tool.name}.${kind}: ${ajv.errorsText(ajv.errors)}`];
|
||||
ajv.compile(schema);
|
||||
const clientError = arrayItemSchemaError(
|
||||
schema,
|
||||
`${tool.name}.${kind}`,
|
||||
);
|
||||
if (clientError !== undefined) return [clientError];
|
||||
if (kind === "inputSchema") {
|
||||
const literalError = unsupportedInputLiteralSchemaError(
|
||||
schema,
|
||||
`${tool.name}.${kind}`,
|
||||
);
|
||||
if (literalError !== undefined) return [literalError];
|
||||
}
|
||||
return [];
|
||||
} catch (cause: unknown) {
|
||||
return [
|
||||
`${tool.name}.${kind}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
];
|
||||
} finally {
|
||||
ajv.removeSchema();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function expectStrictInputSchemaParity(
|
||||
tools: readonly ToolSchemas[],
|
||||
ajv: Ajv2020,
|
||||
@@ -484,7 +444,7 @@ describe("served environment catalogs", () => {
|
||||
);
|
||||
expect(tools.some((tool) => tool.name === "delete_books")).toBe(true);
|
||||
expect(
|
||||
schemaErrors(
|
||||
advertisedSchemaErrors(
|
||||
tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
inputSchema: tool.inputSchema as unknown as Record<string, unknown>,
|
||||
@@ -534,7 +494,7 @@ describe("MCP JSON Schema validity", () => {
|
||||
expect(tools.map(({ name }) => name).sort()).toEqual(
|
||||
TOOL_CONTRACTS.map(({ name }) => name).sort(),
|
||||
);
|
||||
expect(schemaErrors(tools)).toEqual([]);
|
||||
expect(advertisedSchemaErrors(tools)).toEqual([]);
|
||||
expect(localInputReferences(tools)).toEqual([]);
|
||||
expect(recursiveReferences(tools)).toEqual([]);
|
||||
expectKnownAuthorityHints(tools);
|
||||
@@ -879,7 +839,7 @@ describe("MCP process input JSON Schema", () => {
|
||||
});
|
||||
|
||||
it("ships nonrecursive input and output schemas in the generated catalog", () => {
|
||||
expect(schemaErrors(GENERATED_MCP_TOOL_CATALOG)).toEqual([]);
|
||||
expect(advertisedSchemaErrors(GENERATED_MCP_TOOL_CATALOG)).toEqual([]);
|
||||
expect(recursiveReferences(GENERATED_MCP_TOOL_CATALOG)).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -888,7 +848,7 @@ it("ships input schemas without local references in the generated catalog", () =
|
||||
});
|
||||
|
||||
it("rejects unresolved output-schema references even for uncalled tools", () => {
|
||||
const errors = schemaErrors([
|
||||
const errors = advertisedSchemaErrors([
|
||||
{
|
||||
name: "dangling",
|
||||
inputSchema: { type: "object" },
|
||||
@@ -901,7 +861,7 @@ it("rejects unresolved output-schema references even for uncalled tools", () =>
|
||||
|
||||
it("validates each tool independently when output schema IDs repeat", () => {
|
||||
const outputSchema = { $id: "urn:rea:test:shared-output", type: "string" };
|
||||
const errors = schemaErrors([
|
||||
const errors = advertisedSchemaErrors([
|
||||
{ name: "first", inputSchema: { type: "object" }, outputSchema },
|
||||
{
|
||||
name: "second",
|
||||
@@ -974,7 +934,7 @@ it.each([false, true])(
|
||||
"rejects references to another tool's nested IDs (earlier compilation fails: %s)",
|
||||
(invalidPrevious) => {
|
||||
const nestedId = "urn:rea:test:nested-output";
|
||||
const errors = schemaErrors([
|
||||
const errors = advertisedSchemaErrors([
|
||||
{
|
||||
name: "first",
|
||||
inputSchema: { type: "object" },
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
COMPACT_OUTPUT_SCHEMA_BUDGET_BYTES,
|
||||
compactPresentationForMcpOutputSchemaProfile,
|
||||
parseMcpOutputSchemaProfile,
|
||||
} from "../../../src/config/mcpOutputSchemaProfile.js";
|
||||
|
||||
describe("MCP output schema profile configuration", () => {
|
||||
it("accepts the full, compact, and off presentations", () => {
|
||||
expect(parseMcpOutputSchemaProfile(undefined)).toEqual({
|
||||
ok: true,
|
||||
value: undefined,
|
||||
});
|
||||
for (const value of ["full", "compact", "off"] as const)
|
||||
expect(parseMcpOutputSchemaProfile(value)).toEqual({
|
||||
ok: true,
|
||||
value,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["", "FULL", "compact ", "off ", "minimal", "0"])(
|
||||
"rejects unknown profiles: %j",
|
||||
(value) => {
|
||||
const parsed = parseMcpOutputSchemaProfile(value);
|
||||
if (parsed.ok) throw new Error("Expected invalid profile");
|
||||
expect(parsed.error.message).toContain("REA_MCP_OUTPUT_SCHEMA_PROFILE");
|
||||
},
|
||||
);
|
||||
|
||||
it("selects a bounded presentation only for compact", () => {
|
||||
expect(compactPresentationForMcpOutputSchemaProfile("compact")).toEqual({
|
||||
budgetBytes: COMPACT_OUTPUT_SCHEMA_BUDGET_BYTES,
|
||||
});
|
||||
expect(compactPresentationForMcpOutputSchemaProfile(undefined)).toBe(
|
||||
undefined,
|
||||
);
|
||||
expect(compactPresentationForMcpOutputSchemaProfile("full")).toBe(
|
||||
undefined,
|
||||
);
|
||||
expect(compactPresentationForMcpOutputSchemaProfile("off")).toBe(undefined);
|
||||
});
|
||||
});
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
|
||||
import {
|
||||
arrayItemSchemaError,
|
||||
unsupportedInputLiteralSchemaError,
|
||||
} from "./mcpClientSchemaConstraints.js";
|
||||
|
||||
/** Advertised schema pair as a client receives it from `tools/list`. */
|
||||
export interface AdvertisedToolSchemas {
|
||||
readonly name: string;
|
||||
readonly inputSchema: Record<string, unknown>;
|
||||
readonly outputSchema?: Record<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate advertised schemas as clients receive them: Ajv's declared-dialect
|
||||
* meta-schema and reference resolution, then the constraints a real client
|
||||
* enforces. Compiling catches a compact projection that drops a referenced
|
||||
* definition or leaves a local `$ref` without its target, which a byte-budget
|
||||
* assertion alone would not.
|
||||
*/
|
||||
export function advertisedSchemaErrors(
|
||||
tools: readonly AdvertisedToolSchemas[],
|
||||
): string[] {
|
||||
// Reuse Ajv's meta-schema validators, then clear each root's reference
|
||||
// namespace, including nested IDs, before validating the next schema.
|
||||
const ajv = new Ajv2020({
|
||||
strict: false,
|
||||
validateFormats: false,
|
||||
addUsedSchema: false,
|
||||
// This check compiles schemas to detect invalid references but never runs
|
||||
// their validators. Generated-code optimization adds substantial work here
|
||||
// without checking another contract; actual SDK validators keep defaults.
|
||||
code: { optimize: false },
|
||||
});
|
||||
return tools.flatMap((tool) =>
|
||||
["inputSchema", "outputSchema"].flatMap((kind) => {
|
||||
const schema =
|
||||
kind === "inputSchema" ? tool.inputSchema : tool.outputSchema;
|
||||
if (schema === undefined) return [];
|
||||
try {
|
||||
if (!ajv.validateSchema(schema))
|
||||
return [`${tool.name}.${kind}: ${ajv.errorsText(ajv.errors)}`];
|
||||
ajv.compile(schema);
|
||||
const clientError = arrayItemSchemaError(
|
||||
schema,
|
||||
`${tool.name}.${kind}`,
|
||||
);
|
||||
if (clientError !== undefined) return [clientError];
|
||||
if (kind === "inputSchema") {
|
||||
const literalError = unsupportedInputLiteralSchemaError(
|
||||
schema,
|
||||
`${tool.name}.${kind}`,
|
||||
);
|
||||
if (literalError !== undefined) return [literalError];
|
||||
}
|
||||
return [];
|
||||
} catch (cause: unknown) {
|
||||
return [
|
||||
`${tool.name}.${kind}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
];
|
||||
} finally {
|
||||
ajv.removeSchema();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user