diff --git a/tests/boundary/mcp/analysisViewMcp.test.ts b/tests/boundary/mcp/analysisViewMcp.test.ts index 6d95d6f85f..3cc51ae163 100644 --- a/tests/boundary/mcp/analysisViewMcp.test.ts +++ b/tests/boundary/mcp/analysisViewMcp.test.ts @@ -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 = advertised.inputSchema; const outputSchema: Record = 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); diff --git a/tests/boundary/mcp/mcpSchemaValidation.test.ts b/tests/boundary/mcp/mcpSchemaValidation.test.ts new file mode 100644 index 0000000000..2ce015a4f9 --- /dev/null +++ b/tests/boundary/mcp/mcpSchemaValidation.test.ts @@ -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(); + } +}); diff --git a/tests/fixtures/advertisedMcpTool.ts b/tests/fixtures/advertisedMcpTool.ts index e10e603e40..649dbb4865 100644 --- a/tests/fixtures/advertisedMcpTool.ts +++ b/tests/fixtures/advertisedMcpTool.ts @@ -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): boolean | Promise; + validate( + schema: Record, + value: unknown, + ): boolean | Promise; +} + +/** 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 = advertised.inputSchema; const outputSchema: Record = advertised.outputSchema; expect(ajv.validateSchema(inputSchema)).toBe(true); diff --git a/tests/fixtures/mcpSchemaValidation.ts b/tests/fixtures/mcpSchemaValidation.ts new file mode 100644 index 0000000000..67300764bb --- /dev/null +++ b/tests/fixtures/mcpSchemaValidation.ts @@ -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>(); + const assertionValidators = new Map(); + const meta = new Ajv2020({ strict: false, validateFormats: false }); + let errors: unknown = null; + return { + getValidator(schema: JsonSchemaType): JsonSchemaValidator { + 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; + }, + assertions: { + get errors() { + return errors; + }, + validateSchema(schema: Record) { + const valid = meta.validateSchema(schema); + errors = meta.errors; + return valid; + }, + validate(schema: Record, 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; + }, + }; +};