mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
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:
@@ -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": [
|
||||
|
||||
Generated
+59
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user