fix(mcp): emit valid schemas for empty arrays (#535)

Replace empty tuples with a shared empty-array schema. Validate all input
and output schemas against Draft 2020-12 during catalog generation and
packaged MCP verification, with runtime and catalog regression coverage.

Closes #528

Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing)
This commit is contained in:
N0zoM1z0
2026-10-05 15:32:58 +08:00
committed by GitHub
parent 3e4d4d93fd
commit 4007b20fc2
18 changed files with 224 additions and 22 deletions
+2 -2
View File
@@ -537,9 +537,9 @@
"mcp_prompts": 6
},
"digests": {
"tools_sha256": "5748cec2ff7ec6b44193516a851d8668386d4b9fc679b93d6cc950907a59e213",
"tools_sha256": "1a32398486b1e65f50a1a81de2d8867e6a4c296c165bf9994e53034b466c41b4",
"prompts_sha256": "fa889fc671185ac9c968c6dc8309cf609e709cdc837dbbf39b7cdfadb84ac2d7",
"combined_sha256": "39c2c4c55c1197e5a8c05c829bf9ba024d2508ed3a59b672f95c5ed19c016a5c",
"combined_sha256": "a87e48bc626cb4361f494c074e71852304bfedee17fbba89976d32bb6244f230",
"providers_sha256": "c5a88a49990fafc8244972ee8ff543fc19995eb79db960da55f6a311e15340d1"
}
}
@@ -1,5 +1,5 @@
{
"manifest_id": "ecm_e610b840572bebe6ace76b30a850e38eee696e1000e008a9e11ebc841115b9b3",
"manifest_id": "ecm_fcfc6a2f890ccb4a5a7d19dce941b8f7644f05babd89e223100ef7b8b7a55afc",
"verifier": {
"id": "rea-managed-conformance",
"version": "1"
@@ -13,7 +13,7 @@
"skill_digests": [
{
"skill_id": "reverse-engineer-anything",
"sha256": "b1220b8f956506c2347c2c165e10cb3d2e557c23fab8d1ad0f95428a5d9fb184"
"sha256": "6a884e8e2df17da0df0e8fda01aa3d66726fd7454c5606752c2b3ba3aa9e5449"
}
],
"claims": [
+59
View File
@@ -47,6 +47,7 @@
"@types/write-file-atomic": "4.0.3",
"@types/ws": "8.18.1",
"@vitest/coverage-v8": "4.1.10",
"ajv": "8.20.0",
"husky": "9.1.7",
"jscpd": "5.0.12",
"knip": "6.26.0",
@@ -2158,6 +2159,23 @@
"node": ">=6.5"
}
},
"node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ansi-escapes": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz",
@@ -2712,6 +2730,13 @@
"node": ">=12.17.0"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-string-truncated-width": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz",
@@ -2727,6 +2752,23 @@
"fast-string-truncated-width": "^3.0.2"
}
},
"node_modules/fast-uri": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/fast-wrap-ansi": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz",
@@ -3376,6 +3418,13 @@
"win32"
]
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
"license": "MIT"
},
"node_modules/knip": {
"version": "6.26.0",
"resolved": "https://registry.npmjs.org/knip/-/knip-6.26.0.tgz",
@@ -4475,6 +4524,16 @@
"node": ">= 12.13.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
+1
View File
@@ -200,6 +200,7 @@
"@types/write-file-atomic": "4.0.3",
"@types/ws": "8.18.1",
"@vitest/coverage-v8": "4.1.10",
"ajv": "8.20.0",
"husky": "9.1.7",
"jscpd": "5.0.12",
"knip": "6.26.0",
+11
View File
@@ -2,6 +2,7 @@ import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { format } from "prettier";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { Ajv2020 } from "ajv/dist/2020.js";
import { McpServer } from "@modelcontextprotocol/server";
import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js";
@@ -28,6 +29,16 @@ const sessionToolNames = new Set([
...MANAGED_WORKFLOW_TOOL_CONTRACTS.map(({ name }) => name),
]);
const advertisedTools = await sdkToolCatalog();
const ajv = new Ajv2020({ strict: false, validateFormats: false });
for (const tool of advertisedTools.values()) {
for (const kind of ["inputSchema", "outputSchema"]) {
const schema = tool[kind];
if (schema !== undefined && !ajv.validateSchema(schema))
throw new Error(
`Invalid JSON Schema for ${tool.name}.${kind}: ${ajv.errorsText(ajv.errors)}`,
);
}
}
const catalog = TOOL_CONTRACTS.map((contract) => {
const advertised = advertisedTools.get(contract.name);
if (advertised === undefined)
+12 -1
View File
@@ -1,6 +1,7 @@
import { execFile } from "node:child_process";
import { lstat } from "node:fs/promises";
import { promisify } from "node:util";
import { Ajv2020 } from "ajv/dist/2020.js";
/** Promisified child_process execution helper. */
export const exec = promisify(execFile);
@@ -28,9 +29,19 @@ export const runWithStatus = async (command, args, env) => {
/** Parse JSON from a command output string. */
export const json = (text) => JSON.parse(text);
/** Verify that the complete advertised MCP catalog matches session metadata. */
/** Verify schema validity and that the MCP catalog matches session metadata. */
export const verifyCompleteToolCatalog = async (client, options) => {
const listed = await client.listTools(undefined, options);
const ajv = new Ajv2020({ strict: false, validateFormats: false });
for (const tool of listed.tools) {
for (const kind of ["inputSchema", "outputSchema"]) {
const schema = tool[kind];
if (schema !== undefined && !ajv.validateSchema(schema))
throw new Error(
`packaged MCP has invalid ${tool.name}.${kind}: ${ajv.errorsText(ajv.errors)}`,
);
}
}
const status = await client.callTool(
{ name: "binary_session", arguments: {} },
options,
+1 -1
View File
@@ -4,7 +4,7 @@ description: Reverse engineer native, managed, Electron/JavaScript, packaged, an
metadata:
version: "23"
tool_count: 122
catalog_digest: "39c2c4c55c1197e5a8c05c829bf9ba024d2508ed3a59b672f95c5ed19c016a5c"
catalog_digest: "a87e48bc626cb4361f494c074e71852304bfedee17fbba89976d32bb6244f230"
---
# REA
+4 -3
View File
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
import { z } from "zod";
import { emptyArraySchema } from "./emptyArraySchema.js";
import { sanitizedBrowserUrlSchema } from "./browserObservation.js";
import { webScreenshotArtifactSchema } from "./webScreenshot.js";
@@ -229,14 +230,14 @@ export const browserScenarioCompletenessSchema = z.discriminatedUnion(
z.strictObject({
status: z.literal("complete"),
equality_eligible: z.literal(true),
missing_sections: z.tuple([]),
truncated_sections: z.tuple([]),
missing_sections: emptyArraySchema,
truncated_sections: emptyArraySchema,
}),
z.strictObject({
status: z.literal("incomplete"),
equality_eligible: z.literal(false),
missing_sections: z.array(completenessSectionSchema).min(1),
truncated_sections: z.tuple([]),
truncated_sections: emptyArraySchema,
}),
z.strictObject({
status: z.literal("truncated"),
+3 -2
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { emptyArraySchema } from "./emptyArraySchema.js";
import { evidenceSchema } from "./evidence.js";
const addressSchema = z
@@ -87,14 +88,14 @@ export const callPathResultSchema = z.union([
status: z.literal("not_found"),
shortest_hops: z.null(),
search_scope: z.object({ exhaustive: z.literal(true) }),
paths: z.tuple([]),
paths: emptyArraySchema,
}),
z.object({
...resultContextShape,
status: z.literal("unknown"),
shortest_hops: z.null(),
search_scope: z.object({ exhaustive: z.literal(false) }),
paths: z.tuple([]),
paths: emptyArraySchema,
}),
]);
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { emptyArraySchema } from "./emptyArraySchema.js";
describe("empty array schema", () => {
it("accepts only an empty array", () => {
expect(emptyArraySchema.parse([])).toEqual([]);
for (const value of [
[null],
["candidate"],
[0],
[false],
[{}],
[[]],
null,
{},
]) {
expect(emptyArraySchema.safeParse(value).success).toBe(false);
}
});
});
+4
View File
@@ -0,0 +1,4 @@
import { z } from "zod";
/** An empty array whose JSON Schema is valid under Draft 2020-12. */
export const emptyArraySchema = z.array(z.never()).length(0);
@@ -1,5 +1,6 @@
import { z } from "zod";
import { emptyArraySchema } from "./emptyArraySchema.js";
import { evidenceSchema } from "./evidence.js";
import { javascriptApplicationGraphSchema } from "./javascriptApplicationGraph.js";
import { JAVASCRIPT_APPLICATION_NODE_KINDS } from "./javascriptApplicationGraphSchemas.js";
@@ -37,7 +38,7 @@ export const compareApplicationVersionsInputSchema = z
}
});
const emptyCandidateNodesSchema = z.tuple([]);
const emptyCandidateNodesSchema = emptyArraySchema;
const candidateNodesSchema = z.tuple([nodeIdSchema]).rest(nodeIdSchema);
const exactMatchSchema = z.strictObject({
status: z.literal("matched"),
+5 -4
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { emptyArraySchema } from "./emptyArraySchema.js";
import { evidenceSchema } from "./evidence.js";
import {
cliMetadataGuidSchema,
@@ -48,15 +49,15 @@ const matchedComparisonSchema = z.strictObject({
status: z.literal("matched"),
basis: concreteMatchBasisSchema,
confidence: z.enum(["exact", "high"]),
candidate_left_tokens: z.tuple([]),
candidate_right_tokens: z.tuple([]),
candidate_left_tokens: emptyArraySchema,
candidate_right_tokens: emptyArraySchema,
});
const unmatchedComparisonSchema = z.strictObject({
status: z.literal("unmatched"),
basis: z.literal("none"),
confidence: z.literal("unknown"),
candidate_left_tokens: z.tuple([]),
candidate_right_tokens: z.tuple([]),
candidate_left_tokens: emptyArraySchema,
candidate_right_tokens: emptyArraySchema,
});
const ambiguousComparisonSchema = z.strictObject({
status: z.literal("ambiguous"),
@@ -1,5 +1,6 @@
import { z } from "zod";
import { emptyArraySchema } from "./emptyArraySchema.js";
import { evidenceSchema } from "./evidence.js";
import { cliMetadataGuidSchema } from "./managedArtifact.js";
@@ -72,7 +73,7 @@ export const pinvokeVerificationSchema = z.discriminatedUnion("status", [
basis: z.enum(["no-native-candidate", "unsupported-native-evidence"]),
confidence: z.literal("unknown"),
matched_native: z.null(),
candidates: z.tuple([]),
candidates: emptyArraySchema,
}),
z.strictObject({
...pinvokeVerificationContextShape,
+4 -3
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { emptyArraySchema } from "./emptyArraySchema.js";
import { inspectWebPageWithSourceInputSchema } from "./browserObservation.js";
import { webTextArtifactSchema } from "./webContentArtifact.js";
@@ -71,9 +72,9 @@ const sourceMapSchema = z.union([
...parsedSourceMapShape,
status: z.enum(["fetch_failed", "invalid", "policy_filtered"]),
artifact: z.null(),
original_sources: z.tuple([]),
original_module_edges: z.tuple([]),
mappings: z.tuple([]),
original_sources: emptyArraySchema,
original_module_edges: emptyArraySchema,
mappings: emptyArraySchema,
limitation: z.string(),
}),
]);
+2 -1
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { emptyArraySchema } from "./emptyArraySchema.js";
import { webPageInspectionSchema } from "./browserObservation.js";
import { webMcpDiscoverySchema } from "./webMcpDiscovery.js";
@@ -21,7 +22,7 @@ const changeSchema = z.object({
});
const emptyDimensionShape = {
total_changes: z.literal(0),
changes: z.tuple([]),
changes: emptyArraySchema,
};
const dimensionSchema = z.union([
z
File diff suppressed because one or more lines are too long
@@ -0,0 +1,88 @@
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { McpServer } from "@modelcontextprotocol/server";
import { Ajv2020 } from "ajv/dist/2020.js";
import { describe, expect, it } from "vitest";
import { z } from "zod";
import { TOOL_CONTRACTS } from "../../../src/contracts/toolContracts.js";
import { emptyArraySchema } from "../../../src/domain/emptyArraySchema.js";
import { GENERATED_MCP_TOOL_CATALOG } from "../../../src/generatedMcpToolCatalog.js";
import { toolRegistrationOptions } from "../../../src/server/toolRegistrationOptions.js";
interface ToolSchemas {
readonly name: string;
readonly inputSchema: Record<string, unknown>;
readonly outputSchema?: Record<string, unknown> | undefined;
}
function schemaErrors(tools: readonly ToolSchemas[]): string[] {
const ajv = new Ajv2020({ strict: false, validateFormats: false });
return tools.flatMap((tool) =>
["inputSchema", "outputSchema"].flatMap((kind) => {
const schema =
kind === "inputSchema" ? tool.inputSchema : tool.outputSchema;
if (schema === undefined || ajv.validateSchema(schema)) return [];
return [`${tool.name}.${kind}: ${ajv.errorsText(ajv.errors)}`];
}),
);
}
describe("MCP JSON Schema validity", () => {
it("preserves empty-array validation in the advertised representation", () => {
const schema = z.toJSONSchema(emptyArraySchema);
const ajv = new Ajv2020({ strict: false, validateFormats: false });
expect(ajv.validateSchema(schema)).toBe(true);
const validate = ajv.compile(schema);
expect(validate([])).toBe(true);
for (const value of [
[null],
["candidate"],
[0],
[false],
[{}],
[[]],
null,
{},
])
expect(validate(value)).toBe(false);
});
it("uses an oracle that rejects empty prefixItems under Draft 2020-12", () => {
const ajv = new Ajv2020({ strict: false, validateFormats: false });
expect(ajv.defaultMeta()).toBe(
"https://json-schema.org/draft/2020-12/schema",
);
expect(ajv.validateSchema({ type: "array", prefixItems: [] })).toBe(false);
expect(ajv.validateSchema({ type: "array", maxItems: 0 })).toBe(true);
});
it("advertises valid input and output schemas for every canonical tool", async () => {
const server = new McpServer({ name: "schema-validation", version: "0" });
const client = new Client({ name: "schema-validation", version: "0" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
for (const contract of TOOL_CONTRACTS)
server.registerTool(
contract.name,
toolRegistrationOptions(contract),
async () => ({
content: [],
}),
);
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
const { tools } = await client.listTools();
expect(tools.map(({ name }) => name).sort()).toEqual(
TOOL_CONTRACTS.map(({ name }) => name).sort(),
);
expect(schemaErrors(tools)).toEqual([]);
} finally {
await Promise.allSettled([client.close(), server.close()]);
}
});
it("ships valid input and output schemas in the generated catalog", () => {
expect(schemaErrors(GENERATED_MCP_TOOL_CATALOG)).toEqual([]);
});
});