test(mcp): reuse immutable schema validators within analysis view suite

This commit is contained in:
morluto
2026-10-24 09:07:53 +08:00
parent 54f5c55f62
commit 3d3ee837c4
4 changed files with 176 additions and 6 deletions
+15 -4
View File
@@ -1,3 +1,4 @@
import { createMcpSchemaValidation } from "../../fixtures/mcpSchemaValidation.js";
import { advertisedMcpToolSchemas } from "../../fixtures/advertisedMcpTool.js";
import { API_SURFACE_EXAMPLE } from "../../../src/contracts/analysisViewApiSurfaceExample.js";
import { ENVIRONMENT_MANIFEST_EXAMPLE } from "../../../src/contracts/analysisViewEnvironmentManifestExample.js";
@@ -14,8 +15,7 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { STDIO_DEFAULT_MAX_BUFFER_SIZE } from "@modelcontextprotocol/server";
import { Ajv2020 } from "ajv/dist/2020.js";
import { expect, it, onTestFinished } from "vitest";
import { afterAll, expect, it, onTestFinished } from "vitest";
import { z } from "zod";
import { BinaryLayoutService } from "../../../src/application/binaryDiagnostics/BinaryLayoutService.js";
@@ -51,6 +51,9 @@ import { wasmArtifactFixture } from "../../fixtures/wasm/artifact.js";
import { evmInterfaceFixture } from "../../fixtures/evm/interface.js";
import type { RelationshipCoverage } from "../../../src/domain/relationshipCoverage.js";
const schemaValidation = createMcpSchemaValidation();
afterAll(() => schemaValidation.clear());
const connect = async (
binaryLayout?: BinaryLayoutService,
webNetworkCapture?: WebNetworkCaptureService,
@@ -67,7 +70,10 @@ const connect = async (
...(webNetworkCapture === undefined ? {} : { webNetworkCapture }),
},
);
const client = new Client({ name: "analysis-view-mcp", version: "1" });
const client = new Client(
{ name: "analysis-view-mcp", version: "1" },
{ jsonSchemaValidator: schemaValidation },
);
onTestFinished(async () => {
await client.close();
await server.close();
@@ -85,6 +91,7 @@ it("preserves native relationship coverage through inline and retained MCP views
const { ajv, outputSchema } = await advertisedMcpToolSchemas(
client,
"inspect_analysis_view",
schemaValidation.assertions,
);
const coverages: (RelationshipCoverage | undefined)[] = [
{
@@ -156,6 +163,7 @@ it("advertises typed module references and preserves portable/retained Evidence
const { ajv, inputSchema, outputSchema } = await advertisedMcpToolSchemas(
client,
"inspect_analysis_view",
schemaValidation.assertions,
);
const view = {
kind: "javascript",
@@ -407,6 +415,7 @@ it("advertises exact schemas and projects one layout section from retained Evide
const { ajv, inputSchema, outputSchema } = await advertisedMcpToolSchemas(
client,
"inspect_analysis_view",
schemaValidation.assertions,
);
expect(
ajv.validate(inputSchema, {
@@ -758,7 +767,7 @@ it("delivers a bounded native dossier view over MCP without a Ghidra provider or
);
if (advertised?.outputSchema === undefined)
throw new Error("missing view schemas");
const ajv = new Ajv2020({ strict: false, validateFormats: false });
const ajv = schemaValidation.assertions;
const inputSchema: Record<string, unknown> = advertised.inputSchema;
const outputSchema: Record<string, unknown> = advertised.outputSchema;
const arguments_ = {
@@ -1232,6 +1241,7 @@ it("extracts an environment state identically from retained, portable and file M
const { ajv, inputSchema, outputSchema } = await advertisedMcpToolSchemas(
client,
"inspect_analysis_view",
schemaValidation.assertions,
);
const capture = environmentStateCaptureFixture();
const parent = analysisViewCaptureEvidence(capture);
@@ -1305,6 +1315,7 @@ it("projects an environment manifest identically from retained, portable and fil
const { ajv, inputSchema, outputSchema } = await advertisedMcpToolSchemas(
client,
"inspect_analysis_view",
schemaValidation.assertions,
);
const capture = environmentManifestCaptureFixture();
const parent = analysisViewCaptureEvidence(capture);
@@ -0,0 +1,87 @@
import { expect, it } from "vitest";
import { createMcpSchemaValidation } from "../../fixtures/mcpSchemaValidation.js";
it("reuses equal schemas and recompiles changed schemas sharing an id", () => {
const validation = createMcpSchemaValidation();
try {
const schema = {
$schema: "https://json-schema.org/draft/2020-12/schema",
$id: "https://fixture.test/result",
type: "string" as "string" | "integer",
};
const first = validation.getValidator(schema);
expect(validation.getValidator(structuredClone(schema))).toBe(first);
expect(first("recorded").valid).toBe(true);
expect(first(42).valid).toBe(false);
expect(validation.assertions.validate(schema, "recorded")).toBe(true);
expect(validation.assertions.validate(schema, 42)).toBe(false);
schema.type = "integer";
const changed = validation.getValidator(schema);
expect(changed).not.toBe(first);
expect(changed(42).valid).toBe(true);
expect(changed("recorded").valid).toBe(false);
expect(validation.assertions.validate(schema, 42)).toBe(true);
expect(validation.assertions.validate(schema, "recorded")).toBe(false);
validation.clear();
expect(validation.getValidator(schema)).not.toBe(changed);
} finally {
validation.clear();
}
});
it("preserves SDK format validation and declared dialect dispatch", () => {
const validation = createMcpSchemaValidation();
try {
const schema = {
$schema: "http://json-schema.org/draft-07/schema#",
type: "string",
format: "email",
} as const;
const validator = validation.getValidator(schema);
expect(validator("analyst@example.test").valid).toBe(true);
expect(validator("invalid email").valid).toBe(false);
// Explicit schema assertions retain their separate format-off policy.
expect(
validation.assertions.validate(
{ type: "string", format: "email" },
"invalid email",
),
).toBe(true);
expect(
validation.assertions.validateSchema({ type: "string", minLength: -1 }),
).toBe(false);
} finally {
validation.clear();
}
});
it("owns nested schema values independently of later caller mutation", () => {
const validation = createMcpSchemaValidation();
try {
const schema = { enum: [{ state: "recorded" }, { state: "other" }] };
const originalSchema = structuredClone(schema);
const original = validation.getValidator(schema);
expect(validation.assertions.validate(schema, { state: "recorded" })).toBe(
true,
);
schema.enum[0]!.state = "changed";
const changed = validation.getValidator(schema);
expect(original({ state: "recorded" }).valid).toBe(true);
expect(original({ state: "changed" }).valid).toBe(false);
expect(changed({ state: "changed" }).valid).toBe(true);
expect(changed({ state: "recorded" }).valid).toBe(false);
expect(
validation.assertions.validate(originalSchema, { state: "recorded" }),
).toBe(true);
expect(validation.assertions.validate(schema, { state: "recorded" })).toBe(
false,
);
expect(validation.assertions.validate(schema, { state: "changed" })).toBe(
true,
);
} finally {
validation.clear();
}
});
+14 -2
View File
@@ -5,17 +5,29 @@ import { toolContract } from "../../src/contracts/toolContracts.js";
import { parseEvidence } from "../../src/domain/evidence.js";
import type { BinarySession } from "../../src/application/binary/BinarySession.js";
/** Verify the actual advertised schemas, retaining a private Ajv registry. */
interface AdvertisedSchemaAssertions {
readonly errors: unknown;
validateSchema(schema: Record<string, unknown>): boolean | Promise<unknown>;
validate(
schema: Record<string, unknown>,
value: unknown,
): boolean | Promise<unknown>;
}
/** Verify actual advertised schemas; callers may own file-local preparation. */
export const advertisedMcpToolSchemas = async (
client: Client,
name: string,
ajv: AdvertisedSchemaAssertions = new Ajv2020({
strict: false,
validateFormats: false,
}),
) => {
const advertised = (await client.listTools()).tools.find(
(tool) => tool.name === name,
);
if (advertised?.outputSchema === undefined)
throw new Error(`${name} must publish both schemas`);
const ajv = new Ajv2020({ strict: false, validateFormats: false });
const inputSchema: Record<string, unknown> = advertised.inputSchema;
const outputSchema: Record<string, unknown> = advertised.outputSchema;
expect(ajv.validateSchema(inputSchema)).toBe(true);
+60
View File
@@ -0,0 +1,60 @@
import type {
JsonSchemaType,
JsonSchemaValidator,
} from "@modelcontextprotocol/client";
import { AjvJsonSchemaValidator } from "@modelcontextprotocol/client/validators/ajv";
import { Ajv2020 } from "ajv/dist/2020.js";
import type { ValidateFunction } from "ajv";
/** Share immutable schema preparation within one test file, never its sessions. */
export const createMcpSchemaValidation = () => {
const sdkValidators = new Map<string, JsonSchemaValidator<unknown>>();
const assertionValidators = new Map<string, ValidateFunction>();
const meta = new Ajv2020({ strict: false, validateFormats: false });
let errors: unknown = null;
return {
getValidator<T>(schema: JsonSchemaType): JsonSchemaValidator<T> {
const key = JSON.stringify(schema);
let validator = sdkValidators.get(key);
if (validator === undefined) {
// Each full schema owns its default provider, including dialect dispatch.
// Distinct schemas may legally reuse the same $id in separate servers.
validator = new AjvJsonSchemaValidator().getValidator(JSON.parse(key));
sdkValidators.set(key, validator);
}
return validator as JsonSchemaValidator<T>;
},
assertions: {
get errors() {
return errors;
},
validateSchema(schema: Record<string, unknown>) {
const valid = meta.validateSchema(schema);
errors = meta.errors;
return valid;
},
validate(schema: Record<string, unknown>, value: unknown) {
const key = JSON.stringify(schema);
let validator = assertionValidators.get(key);
if (validator === undefined) {
// Preserve the explicit assertions' existing options, independently
// of the SDK default provider's format/all-errors policy.
validator = new Ajv2020({
strict: false,
validateFormats: false,
}).compile(JSON.parse(key));
assertionValidators.set(key, validator);
}
const valid = validator(value);
errors = validator.errors;
return valid;
},
},
clear() {
sdkValidators.clear();
assertionValidators.clear();
meta.removeSchema();
errors = null;
},
};
};