mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
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:
9 files changed
+147
-41
No files matched your search
@@ -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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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": [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
Reference in new issue
Block a user