fix(mcp): advertise JSON values without recursive schemas (#995)

`z.json()` projects a self-referential `$defs/__schema0` entry. Twenty
tool input schemas and 123 output schemas carried it, and clients or
model APIs that reject recursive JSON Schemas refuse the whole tool
list. Every JSON value satisfies `{}`, so the shared JSON value schema
now advertises that. Runtime parsing still admits only JSON values.

Refs #919

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: N0zoM1z0 <1705004398@qq.com>
This commit is contained in:
authored and GitHub committed 2026-10-08 11:48:57 +08:00
1 parent 28f10cf2bc
commit 7425f70fd0
9 files changed
+147 -41

No files matched your search

+18 -34
View File
@@ -29,7 +29,7 @@
"details": {
"type": "object",
"propertyNames": { "type": "string" },
"additionalProperties": { "$ref": "#/$defs/__schema0" }
"additionalProperties": {}
}
},
"required": ["code", "category", "message", "retryable", "remediation"],
@@ -63,7 +63,7 @@
"details": {
"type": "object",
"propertyNames": { "type": "string" },
"additionalProperties": { "$ref": "#/$defs/__schema0" }
"additionalProperties": {}
}
},
"required": ["code", "category", "message", "retryable", "remediation"],
@@ -97,7 +97,7 @@
"details": {
"type": "object",
"propertyNames": { "type": "string" },
"additionalProperties": { "$ref": "#/$defs/__schema0" }
"additionalProperties": {}
}
},
"required": ["code", "category", "message", "retryable", "remediation"],
@@ -131,7 +131,7 @@
"details": {
"type": "object",
"propertyNames": { "type": "string" },
"additionalProperties": { "$ref": "#/$defs/__schema0" }
"additionalProperties": {}
}
},
"required": ["code", "category", "message", "retryable", "remediation"],
@@ -165,7 +165,7 @@
"details": {
"type": "object",
"propertyNames": { "type": "string" },
"additionalProperties": { "$ref": "#/$defs/__schema0" }
"additionalProperties": {}
}
},
"required": ["code", "category", "message", "retryable", "remediation"],
@@ -199,7 +199,7 @@
"details": {
"type": "object",
"propertyNames": { "type": "string" },
"additionalProperties": { "$ref": "#/$defs/__schema0" }
"additionalProperties": {}
}
},
"required": ["code", "category", "message", "retryable", "remediation"],
@@ -233,7 +233,7 @@
"details": {
"type": "object",
"propertyNames": { "type": "string" },
"additionalProperties": { "$ref": "#/$defs/__schema0" }
"additionalProperties": {}
}
},
"required": ["code", "category", "message", "retryable", "remediation"],
@@ -267,7 +267,7 @@
"details": {
"type": "object",
"propertyNames": { "type": "string" },
"additionalProperties": { "$ref": "#/$defs/__schema0" }
"additionalProperties": {}
}
},
"required": ["code", "category", "message", "retryable", "remediation"],
@@ -357,7 +357,7 @@
"details": {
"type": "object",
"propertyNames": { "type": "string" },
"additionalProperties": { "$ref": "#/$defs/__schema0" }
"additionalProperties": {}
}
},
"required": ["code", "category", "message", "retryable", "remediation"],
@@ -391,7 +391,7 @@
"details": {
"type": "object",
"propertyNames": { "type": "string" },
"additionalProperties": { "$ref": "#/$defs/__schema0" }
"additionalProperties": {}
}
},
"required": ["code", "category", "message", "retryable", "remediation"],
@@ -425,7 +425,7 @@
"details": {
"type": "object",
"propertyNames": { "type": "string" },
"additionalProperties": { "$ref": "#/$defs/__schema0" }
"additionalProperties": {}
}
},
"required": ["code", "category", "message", "retryable", "remediation"],
@@ -459,7 +459,7 @@
"details": {
"type": "object",
"propertyNames": { "type": "string" },
"additionalProperties": { "$ref": "#/$defs/__schema0" }
"additionalProperties": {}
}
},
"required": ["code", "category", "message", "retryable", "remediation"],
@@ -493,7 +493,7 @@
"details": {
"type": "object",
"propertyNames": { "type": "string" },
"additionalProperties": { "$ref": "#/$defs/__schema0" }
"additionalProperties": {}
}
},
"required": ["code", "category", "message", "retryable", "remediation"],
@@ -527,7 +527,7 @@
"details": {
"type": "object",
"propertyNames": { "type": "string" },
"additionalProperties": { "$ref": "#/$defs/__schema0" }
"additionalProperties": {}
}
},
"required": ["code", "category", "message", "retryable", "remediation"],
@@ -561,7 +561,7 @@
"details": {
"type": "object",
"propertyNames": { "type": "string" },
"additionalProperties": { "$ref": "#/$defs/__schema0" }
"additionalProperties": {}
}
},
"required": ["code", "category", "message", "retryable", "remediation"],
@@ -595,7 +595,7 @@
"details": {
"type": "object",
"propertyNames": { "type": "string" },
"additionalProperties": { "$ref": "#/$defs/__schema0" }
"additionalProperties": {}
}
},
"required": ["code", "category", "message", "retryable", "remediation"],
@@ -629,27 +629,11 @@
"details": {
"type": "object",
"propertyNames": { "type": "string" },
"additionalProperties": { "$ref": "#/$defs/__schema0" }
"additionalProperties": {}
}
},
"required": ["code", "category", "message", "retryable", "remediation"],
"additionalProperties": false
}
],
"$defs": {
"__schema0": {
"anyOf": [
{ "type": "string" },
{ "type": "number" },
{ "type": "boolean" },
{ "type": "null" },
{ "type": "array", "items": { "$ref": "#/$defs/__schema0" } },
{
"type": "object",
"propertyNames": { "type": "string" },
"additionalProperties": { "$ref": "#/$defs/__schema0" }
}
]
}
}
]
}
+2 -2
View File
@@ -689,9 +689,9 @@
"mcp_prompts": 6
},
"digests": {
"tools_sha256": "d6f2493e9ad693a762f1bd3daf9b6736571f2a190b2a41986e045bf6ff48af89",
"tools_sha256": "eb4749bb14998e23991c77e2d07e7cc69b28d98f2323f7fc3bbe6b4c2da79611",
"prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d",
"combined_sha256": "e41a84b460b65f3a8d0d33498d958d249a1de81a9660c18580a0e3ab5d6941e1",
"combined_sha256": "0417b6ac9d07ff805aa6ec17f58577fc341320d46da50137da73b1c6dd0d3947",
"providers_sha256": "e601cdceb5981ee04d6f431492f961f8867b1faa4f08ca0ecd29be2a239bc467"
}
}
@@ -1,5 +1,5 @@
{
"manifest_id": "ecm_0615df0eb6f041eb2d145edbca9c4068a7dc9cbe53801dd237c7dd9394a2fa40",
"manifest_id": "ecm_6c5378cbeb0a0d0c0b127b1961f2b5e50d6541601a5f36ad7f5fb292e17ff203",
"verifier": {
"id": "rea-managed-conformance",
"version": "1"
@@ -13,7 +13,7 @@
"skill_digests": [
{
"skill_id": "reverse-engineer-anything",
"sha256": "49845719d9ea2d229fb954ba92203921e843908b2f116c94ecb94636812edc42"
"sha256": "7777298dede63ed91211af502a4779850bfe451a11758349150d5a3df4261a93"
}
],
"claims": [
+1 -1
View File
@@ -4,7 +4,7 @@ description: Reverse engineer native, managed, Electron/JavaScript, packaged, fi
metadata:
version: "28"
tool_count: 134
catalog_digest: "e41a84b460b65f3a8d0d33498d958d249a1de81a9660c18580a0e3ab5d6941e1"
catalog_digest: "0417b6ac9d07ff805aa6ec17f58577fc341320d46da50137da73b1c6dd0d3947"
---
# REA
+3 -1
View File
@@ -1,5 +1,7 @@
import { z } from "zod";
import { jsonValueSchema } from "../domain/jsonValue.js";
const categorySchema = z.enum([
"invalid_input",
"unsupported_provider",
@@ -21,7 +23,7 @@ const common = {
retryable: z.boolean(),
remediation: remediationSchema,
};
const genericDetails = z.record(z.string(), z.json()).optional();
const genericDetails = z.record(z.string(), jsonValueSchema).optional();
const generic = <Code extends string>(code: Code) =>
z
.object({ code: z.literal(code), ...common, details: genericDetails })
+1 -1
View File
@@ -363,7 +363,7 @@ export const sessionProvider = z
negotiated_protocol_version: z.string().nullable(),
client: z.object({ name: z.string(), version: z.string() }).nullable(),
skill: z.object({ name: z.string(), expected_version: z.string() }),
catalog: z.record(z.string(), z.json()),
catalog: z.record(z.string(), jsonValueSchema),
protocol_features: z.object({
progress: z.boolean(),
cancellation: z.boolean(),
+37
View File
@@ -0,0 +1,37 @@
import { Ajv2020 } from "ajv/dist/2020.js";
import { expect, it } from "vitest";
import { z } from "zod";
import { jsonObjectSchema, jsonValueSchema } from "./jsonValue.js";
const values = [
"text",
0,
false,
null,
[],
{},
{ nested: [{ deeper: [1, "two", null, { three: true }] }] },
];
it("projects JSON values without recursive definitions", () => {
for (const io of ["input", "output"] as const) {
const schema = z.toJSONSchema(
z.object({ value: jsonValueSchema, map: jsonObjectSchema }),
{ io },
);
expect(JSON.stringify(schema)).not.toContain("$ref");
expect(schema).not.toHaveProperty("$defs");
const validate = new Ajv2020({ strict: false }).compile(schema);
for (const value of values)
expect(validate({ value, map: { value } })).toBe(true);
expect(validate({ value: 1, map: [] })).toBe(false);
}
});
it("still parses only JSON values at runtime", () => {
for (const value of values)
expect(jsonValueSchema.safeParse(value).success).toBe(true);
for (const value of [undefined, Number.NaN, Infinity, () => 0, [undefined]])
expect(jsonValueSchema.safeParse(value).success).toBe(false);
});
+4
View File
@@ -2,6 +2,10 @@ import { z } from "zod";
/** JSON-safe value shared by domain, application, and adapter boundaries. */
export const jsonValueSchema = z.json();
// Every JSON value satisfies `{}`. Advertise that instead of Zod's
// self-referential `$defs` projection, which MCP clients and model APIs that
// reject recursive JSON Schemas refuse. Runtime parsing still admits only JSON.
jsonValueSchema._zod.toJSONSchema = () => ({});
/** JSON object boundary for caller-visible parameter maps. */
export const jsonObjectSchema = z.record(z.string(), jsonValueSchema);
@@ -129,6 +129,55 @@ function expectRecursivePropertyDescriptions(
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
function resolveReference(schema: unknown, reference: string): unknown {
if (reference === "#") return schema;
if (!reference.startsWith("#/")) return undefined;
let value = schema;
for (const token of reference.slice(2).split("/")) {
const key = token.replaceAll("~1", "/").replaceAll("~0", "~");
if (
typeof value !== "object" ||
value === null ||
!Object.hasOwn(value, key)
)
return undefined;
value = Reflect.get(value, key);
}
return value;
}
function recursiveReferences(tools: readonly ToolSchemas[]): string[] {
return tools.flatMap((tool) =>
(["inputSchema", "outputSchema"] as const).flatMap((kind) => {
const schema = tool[kind];
const found = new Set<string>();
const visit = (node: unknown, active: readonly string[]): void => {
if (typeof node !== "object" || node === null) return;
for (const [key, child] of Object.entries(node)) {
if (
[
"$defs",
"definitions",
"examples",
"default",
"const",
"enum",
].includes(key)
)
continue;
if (key !== "$ref" || typeof child !== "string") visit(child, active);
else if (active.includes(child)) found.add(child);
else visit(resolveReference(schema, child), [...active, child]);
}
};
visit(schema, []);
return [...found].map(
(reference) => `${tool.name}.${kind}: ${reference}`,
);
}),
);
}
const advertiseAndEnforceProcessEnvironmentKeyConstraint =
async (): Promise<void> => {
const contract = TOOL_CONTRACTS.find(
@@ -240,6 +289,7 @@ describe("MCP JSON Schema validity", () => {
TOOL_CONTRACTS.map(({ name }) => name).sort(),
);
expect(schemaErrors(tools)).toEqual([]);
expect(recursiveReferences(tools)).toEqual([]);
expectKnownAuthorityHints(tools);
const byName = new Map(tools.map((tool) => [tool.name, tool]));
const catalogProjection = tools.map((tool) => ({
@@ -414,3 +464,32 @@ 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(recursiveReferences(GENERATED_MCP_TOOL_CATALOG)).toEqual([]);
});
it("distinguishes recursive references from shared definitions", () => {
const diamond = {
type: "object",
properties: {
left: { $ref: "#/$defs/shared" },
right: { $ref: "#/$defs/shared" },
},
$defs: { shared: { type: "string" } },
};
expect(
recursiveReferences([{ name: "diamond", inputSchema: diamond }]),
).toEqual([]);
expect(
recursiveReferences([{ name: "root", inputSchema: { $ref: "#" } }]),
).toEqual(["root.inputSchema: #"]);
const escaped = {
$ref: "#/$defs/a~1b",
$defs: { "a/b": { $ref: "#/$defs/a~1b" } },
};
expect(
recursiveReferences([{ name: "escaped", inputSchema: escaped }]),
).toEqual(["escaped.inputSchema: #/$defs/a~1b"]);
});