From c64548252d9dbdb624b00962e0f87f2f915093f5 Mon Sep 17 00:00:00 2001 From: Hussain Phalasiya Date: Mon, 5 Oct 2026 00:49:39 +0300 Subject: [PATCH 1/5] WIP(VC-621): hand off provider-safe dialect follow-up Done: fetched/synced the ticket worktree, installed frozen offline dependencies with scripts disabled, and inspected validation, docs, runtime tests, and verifier artifacts. No source changes made. Not done: 2020-12 body check, actionable provider rejection reason, superset-of-main compatibility rule, docs/tests, focused verification, probes, or CI. Ownership transferred to Board Session 144f856f before implementation. From 8295a9b6ab2c8db3a5e83bd5a99eb9d433114590 Mon Sep 17 00:00:00 2001 From: Hussain Phalasiya Date: Mon, 5 Oct 2026 01:04:38 +0300 Subject: [PATCH 2/5] fix(mcp): bound new dialect admission to provider-safe schemas (VC-621) --- docs/mcp.md | 53 +++- .../agent-runtime/src/pi/mcp-tools.test.ts | 183 +++++++++++-- packages/host-core/src/mcp/discovery.test.ts | 44 +++ packages/shared/src/mcp.test.ts | 253 +++++++++++++++++- packages/shared/src/mcp.ts | 137 +++++++++- .../shared/src/session-event-codec.test.ts | 51 ++++ 6 files changed, 675 insertions(+), 46 deletions(-) diff --git a/docs/mcp.md b/docs/mcp.md index 1a46dcaa0..3addff955 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -565,17 +565,48 @@ or without a trailing empty `#` fragment): - 2019-09: `https://json-schema.org/draft/2019-09/schema` - 2020-12: `https://json-schema.org/draft/2020-12/schema` -Without a declaration, the existing 2020-12 default applies. Unknown or malformed -declarations are refused; Volli never downloads a meta-schema or resolves a -server's `$ref` during discovery. Each recognized draft is checked against its -own meta-schema: draft-07 and 2019-09 tuple `items` are valid, for instance, but -2020-12 uses `prefixItems` instead. The published schema, including `$schema`, -is kept unchanged in the catalog, frozen Session definition and Pi parameters; -it is not rebuilt through TypeBox. Discovery's meta-schema check is distinct -from Pi's TypeBox argument validation at call time. - -A tool's **output schema** is checked against the same schema limits, but a -failing one does not make the tool unusable: the schema is left off and the +Without a declaration, the existing 2020-12 default applies. The entire +pre-VC-621 `Ajv2020.validateSchema` acceptance path is preserved, including an +empty `$schema` and bundled 2020-12 `/meta/*` URIs. Those declarations can exist +in older frozen Sessions; refusing them after an upgrade would prevent replay. +Unknown or malformed declarations still fail validation. Volli never downloads +a meta-schema, resolves a server's `$ref`, or registers server schemas during +discovery. + +Newly admitted draft-07 and 2019-09 **input schemas** must pass both their own +meta-schema and the 2020-12 meta-schema applied to a validation-only view without +the root `$schema`. Valid old-draft tuple `items: [...]` arrays are therefore +**unusable**: model providers reject them, potentially failing every turn in a +Session. The catalog tells the server to publish 2020-12 `prefixItems` instead; +Volli does not translate tuples or silently change the tool's meaning. + +Dual meta-validation alone is not a provider-compatibility guarantee. Legacy +`dependencies` and recursive-reference keywords can lose semantics under +2020-12, and Pi's non-strict Anthropic adapter retains only root `type`, +`properties`, and `required`. For newly admitted old-draft inputs, Volli also +refuses references (`$ref`/`$dynamicRef`, including references into `definitions` +or `$defs`) and root assertions the adapter drops, such as `additionalProperties`, +`allOf`, and property dependencies. The reason names the keyword and asks the +server to inline references, use 2020-12 dependencies, or put supported +constraints inside `properties`. Literal examples/defaults are not schemas; +unreferenced definitions and inert `additionalItems` beside non-tuple `items` +do not add provider constraints. Plain old-draft object/property schemas remain +usable. An old-draft object with root `additionalProperties: false` is outside +this new admission subset, even without tuples. + +The published schema, including `$schema`, is kept unchanged in the catalog, +frozen Session definition and Pi parameters; it is not rebuilt through TypeBox. +Native compaction also receives that unchanged schema. These additional guards +apply only to newly admitted input dialects, not schemas accepted by the original +2020-12 check. Pre-existing provider projection limitations are not repaired by +this change, and full provider/runtime dialect conformance is not promised. +Discovery's meta-schema check is distinct from Pi's TypeBox argument validation +at call time. + +A tool's **output schema** is checked against the same schema limits and its own +bundled dialect's meta-schema, but not the provider input subset (output schemas +are never sent to the model). A failing one does not make the tool unusable: the +schema is left off and the tool works without it, and the main-process log says why. An output schema only describes the structured half of a result (see [What a call returns](#what-a-call-returns)); the model never sees it. diff --git a/packages/agent-runtime/src/pi/mcp-tools.test.ts b/packages/agent-runtime/src/pi/mcp-tools.test.ts index f677f2c07..73e9ed568 100644 --- a/packages/agent-runtime/src/pi/mcp-tools.test.ts +++ b/packages/agent-runtime/src/pi/mcp-tools.test.ts @@ -9,15 +9,23 @@ import { MCP_RESULT_MAX_BYTES, MCP_RESULT_MAX_IMAGES, mcpProviderToolName, + sanitizeMcpToolDefinition, type McpToolDefinition, type RuntimeMcpPort, } from "@volli/shared"; -import { validateToolArguments } from "@earendil-works/pi-ai"; +import { + normalizeContext, + validateToolArguments, + type Api, + type Model, +} from "@earendil-works/pi-ai"; +import { stream as streamAnthropic } from "@earendil-works/pi-ai/api/anthropic-messages"; import { build } from "esbuild"; import sharp from "sharp"; import { MAX_READ_IMAGE_BASE64_BYTES } from "./read-image-processor"; import { ToolOutputStore } from "./tool-output"; +import { compactProviderNative } from "./provider-compaction"; import { createSessionTools, MCP_UNTRUSTED_DATA_WARNING, SAVED_TOOL_OUTPUT_WARNING } from "./tools"; function definition(overrides: Partial = {}): McpToolDefinition { @@ -71,7 +79,8 @@ describe("MCP Pi tool wrapper", () => { }); const context = createContext({}, { codeGeneration: { strings: false, wasm: false } }); // Match the renderer's script-src 'self': importing shared vocabulary must - // not compile Ajv validators. Catalog validation runs on the host later. + // not compile Ajv validators on import. Discovery and frozen replay invoke + // validation later; renderer replay under CSP is a separate issue (VC-636). expect(() => runInContext(bundled.outputFiles[0]!.text, context)).not.toThrow(); expect(runInContext("typeof McpSchema.sanitizeMcpToolDefinition", context)).toBe("function"); }); @@ -125,26 +134,14 @@ describe("MCP Pi tool wrapper", () => { }); it.each([ - [ - "http://json-schema.org/draft-07/schema#", - { items: [{ type: "string" }], additionalItems: false }, - ], - [ - "https://json-schema.org/draft/2019-09/schema", - { items: [{ type: "string" }], additionalItems: false }, - ], + ["http://json-schema.org/draft-07/schema#", { items: { type: "string" }, maxItems: 1 }], + ["https://json-schema.org/draft/2019-09/schema", { items: { type: "string" }, maxItems: 1 }], [ "https://json-schema.org/draft/2020-12/schema", { prefixItems: [{ type: "string" }], items: false }, ], - [ - "https://json-schema.org/draft-07/schema#", - { items: [{ type: "string" }], additionalItems: false }, - ], - [ - "http://json-schema.org/draft/2019-09/schema", - { items: [{ type: "string" }], additionalItems: false }, - ], + ["https://json-schema.org/draft-07/schema#", { items: { type: "string" }, maxItems: 1 }], + ["http://json-schema.org/draft/2019-09/schema", { items: { type: "string" }, maxItems: 1 }], [ "http://json-schema.org/draft/2020-12/schema", { prefixItems: [{ type: "string" }], items: false }, @@ -157,10 +154,13 @@ describe("MCP Pi tool wrapper", () => { type: "object", properties: { tuple: { type: "array", ...tupleSchema, minItems: 1 } }, required: ["tuple"], - additionalProperties: false, } as const; + const candidate = definition({ inputSchema }); + const sanitized = sanitizeMcpToolDefinition({ ...candidate, serverName: "Fixture" }); + expect(sanitized.ok).toBe(true); + if (!sanitized.ok) throw new Error(sanitized.reason); const call = vi.fn(async () => ({ content: [], isError: false })); - const registered = tool({ call }, { tool: definition({ inputSchema }) }); + const registered = tool({ call }, { tool: sanitized.definition }); expect(registered.parameters).toBe(inputSchema); const valid = { type: "toolCall" as const, @@ -183,6 +183,149 @@ describe("MCP Pi tool wrapper", () => { }, ); + it.each([ + "http://json-schema.org/draft-07/schema#", + "https://json-schema.org/draft/2019-09/schema", + "https://json-schema.org/draft-07/schema#", + "http://json-schema.org/draft/2019-09/schema", + ])( + "keeps %s tuples out of provider declarations, even though Pi can validate their arguments", + ($schema) => { + const inputSchema = { + $schema, + type: "object", + properties: { + pair: { type: "array", items: [{ type: "string" }], additionalItems: false }, + }, + }; + const candidate = definition({ inputSchema }); + expect(sanitizeMcpToolDefinition({ ...candidate, serverName: "Fixture" })).toEqual({ + ok: false, + reason: + "input schema uses tuple `items` arrays, which model providers reject; the server should use 2020-12 `prefixItems`", + }); + }, + ); + + it.each([ + "http://json-schema.org/draft-07/schema#", + "https://json-schema.org/draft/2019-09/schema", + "https://json-schema.org/draft/2020-12/schema", + ])( + "emits a 2020-12-valid %s body through the actual Anthropic adapter and native compaction", + async ($schema) => { + const inputSchema = { + $schema, + type: "object", + properties: { + query: { type: "string", minLength: 1 }, + filter: { + type: "object", + properties: { limit: { type: "integer", minimum: 1 } }, + additionalProperties: false, + }, + }, + required: ["query"], + }; + const candidate = definition({ inputSchema }); + const sanitized = sanitizeMcpToolDefinition({ ...candidate, serverName: "Fixture" }); + expect(sanitized.ok).toBe(true); + if (!sanitized.ok) throw new Error(sanitized.reason); + const registered = tool( + { call: async () => ({ content: [], isError: false }) }, + { tool: sanitized.definition }, + ); + const model: Model = { + id: "claude-opus-5", + name: "Fixture", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 1_024, + }; + const messages = [{ role: "user" as const, content: "fixture", timestamp: 1 }]; + const sent: Record[] = []; + // No real credentials or network: exercise Pi's request conversion, then + // end its stream with a synthetic error response after capturing the body. + const fetch = vi.fn(async (_url: URL | RequestInfo, init?: RequestInit) => { + sent.push(JSON.parse(init!.body as string)); + return new Response( + JSON.stringify({ + type: "error", + error: { type: "invalid_request_error", message: "fixture" }, + }), + { + status: 400, + headers: { "content-type": "application/json" }, + }, + ); + }); + await streamAnthropic( + model as Model<"anthropic-messages">, + normalizeContext({ messages, tools: [registered] }), + { + apiKey: "fixture-only", + fetch, + maxRetries: 0, + }, + ).result(); + expect(fetch).toHaveBeenCalledOnce(); + const emitted = (sent[0]!["tools"] as { input_schema: Record }[])[0]! + .input_schema; + expect(emitted).toEqual({ + type: "object", + properties: inputSchema.properties, + required: inputSchema.required, + }); + // With no declaration this runs the original 2020-12 meta-schema check. + const checkBody = (bodySchema: unknown) => + sanitizeMcpToolDefinition({ ...candidate, serverName: "Fixture", inputSchema: bodySchema }); + expect(checkBody(emitted).ok).toBe(true); + expect(registered.parameters).toBe(inputSchema); + + const compactFetch = vi.fn(async (url: URL | RequestInfo, init?: RequestInit) => { + sent.push(JSON.parse(init!.body as string)); + return new Response( + JSON.stringify( + String(url).endsWith("/count_tokens") + ? { input_tokens: 60_000 } + : { + stop_reason: "compaction", + content: [{ type: "compaction", content: "fixture summary" }], + }, + ), + { + headers: { "content-type": "application/json" }, + }, + ); + }); + const outcome = await compactProviderNative({ + model, + messages, + tools: [registered], + enabled: true, + fetch: compactFetch, + models: { + getAuth: async () => ({ auth: { apiKey: "fixture-only" }, source: "test" }), + } as never, + }); + expect(outcome.kind).toBe("compacted"); + expect(compactFetch).toHaveBeenCalledTimes(2); + for (const request of sent.slice(1)) { + const raw = (request["tools"] as { input_schema: Record }[])[0]! + .input_schema; + expect(raw).toEqual(inputSchema); + const { $schema: _declaredDialect, ...body } = raw; + expect(checkBody(body).ok).toBe(true); + } + expect(registered.parameters).toBe(inputSchema); + }, + ); + it("declares the frozen output schema, which the model is never sent", () => { const outputSchema = { type: "object", properties: { z: { type: "number" } } } as const; const registered = tool( diff --git a/packages/host-core/src/mcp/discovery.test.ts b/packages/host-core/src/mcp/discovery.test.ts index f3c5928d9..2dd49e8ee 100644 --- a/packages/host-core/src/mcp/discovery.test.ts +++ b/packages/host-core/src/mcp/discovery.test.ts @@ -138,6 +138,50 @@ describe("discoverMcpServer", () => { }); }); + it("marks old-draft provider hazards unusable while preserving safe plain definitions", async () => { + const plain = { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { query: { type: "string" } }, + }; + const tuple = { + ...plain, + properties: { pair: { type: "array", items: [{ type: "string" }], additionalItems: false } }, + }; + const connection = client([ + { name: "plain", inputSchema: plain }, + { name: "tuple", inputSchema: tuple }, + { name: "dependencies", inputSchema: { ...plain, dependencies: { query: ["limit"] } } }, + { name: "unknown", inputSchema: { ...plain, $schema: "https://example.invalid/dialect" } }, + ]); + const catalog = await discoverMcpServer({ + server, + enabledToolNames: ["plain", "tuple", "dependencies", "unknown"], + signal: new AbortController().signal, + open: async () => connection, + workspacePath: "/repo", + }); + expect(catalog[0]).toMatchObject({ enabled: true, error: null }); + expect(catalog[0]!.definition!.inputSchema).toBe(plain); + expect(catalog[1]).toMatchObject({ + enabled: false, + definition: null, + error: + "input schema uses tuple `items` arrays, which model providers reject; the server should use 2020-12 `prefixItems`", + }); + expect(catalog[2]).toMatchObject({ + enabled: false, + definition: null, + error: expect.stringContaining("dependentRequired"), + }); + expect(catalog[3]).toMatchObject({ + enabled: false, + definition: null, + error: "input schema must be valid JSON Schema", + }); + expect(connection.close).toHaveBeenCalledOnce(); + }); + it("fails duplicate names and tool-count overflow clearly and always closes the connection", async () => { const duplicate = client([ { name: "echo", inputSchema: { type: "object" } }, diff --git a/packages/shared/src/mcp.test.ts b/packages/shared/src/mcp.test.ts index 45fc190b3..e925e7785 100644 --- a/packages/shared/src/mcp.test.ts +++ b/packages/shared/src/mcp.test.ts @@ -1,3 +1,4 @@ +import Ajv2020 from "ajv/dist/2020.js"; import { describe, expect, it } from "vite-plus/test"; import { @@ -139,7 +140,6 @@ describe("sanitizeMcpToolDefinition", () => { type: "object", properties: { query: { type: "string", minLength: 1 }, hidden: false }, required: ["query"], - additionalProperties: false, // Annotations are data, not schemas or dialect declarations to resolve. examples: [{ $schema: "urn:annotation-only" }], } as const; @@ -159,13 +159,16 @@ describe("sanitizeMcpToolDefinition", () => { it.each([ "http://json-schema.org/draft-07/schema#", "https://json-schema.org/draft/2019-09/schema", - ])("validates tuple items using the declared dialect %s", ($schema) => { + ])("refuses provider-incompatible tuple items under %s without translating them", ($schema) => { const tuple = { type: "array", items: [{ type: "string" }], additionalItems: false }; - expect( - sanitizeMcpToolDefinition( - toolCandidate({ inputSchema: { $schema, type: "object", properties: { tuple } } }), - ).ok, - ).toBe(true); + const inputSchema = { $schema, type: "object", properties: { tuple } }; + const wire = JSON.stringify(inputSchema); + expect(sanitizeMcpToolDefinition(toolCandidate({ inputSchema }))).toEqual({ + ok: false, + reason: + "input schema uses tuple `items` arrays, which model providers reject; the server should use 2020-12 `prefixItems`", + }); + expect(JSON.stringify(inputSchema)).toBe(wire); for (const declaration of [{}, { $schema: "https://json-schema.org/draft/2020-12/schema" }]) { expect( sanitizeMcpToolDefinition( @@ -196,7 +199,6 @@ describe("sanitizeMcpToolDefinition", () => { }); it.each([ - "", "urn:unknown", "https://example.com/custom-meta-schema", "http://json-schema.org/draft-04/schema#", @@ -225,9 +227,9 @@ describe("sanitizeMcpToolDefinition", () => { type: "object", properties: { query: { $ref: "https://example.com/remote-definition" } }, }; - expect(sanitizeMcpToolDefinition(toolCandidate({ inputSchema: schema })).ok).toBe(true); + expect(sanitizeMcpToolDefinition(toolCandidate({ outputSchema: schema })).ok).toBe(true); const customMeta = { ...schema, $id: "urn:untrusted-meta" }; - expect(sanitizeMcpToolDefinition(toolCandidate({ inputSchema: customMeta })).ok).toBe(true); + expect(sanitizeMcpToolDefinition(toolCandidate({ outputSchema: customMeta })).ok).toBe(true); expect( sanitizeMcpToolDefinition( toolCandidate({ inputSchema: { type: "object", $schema: "urn:untrusted-meta" } }), @@ -235,6 +237,237 @@ describe("sanitizeMcpToolDefinition", () => { ).toBe(false); }); + it("preserves the pre-VC-621 Ajv2020 acceptance set and exact schema over a corpus", () => { + // A fresh, unaliased validator is the check shipped before #733. Do not + // replace this oracle with the new dialect selection or body validation. + const baseline = new Ajv2020({ addUsedSchema: false, strict: false, validateFormats: false }); + const declarations = [ + undefined, + "", + "https://json-schema.org/draft/2020-12/schema", + "https://json-schema.org/draft/2020-12/schema#", + "http://json-schema.org/schema", + ...[ + "core", + "applicator", + "validation", + "unevaluated", + "format-annotation", + "content", + "meta-data", + ].flatMap((meta) => [ + `https://json-schema.org/draft/2020-12/meta/${meta}`, + `https://json-schema.org/draft/2020-12/meta/${meta}#`, + ]), + ]; + const bodies = [ + {}, + { + properties: { query: { type: "string" } }, + required: ["query"], + additionalProperties: false, + }, + { properties: { pair: { type: "array", prefixItems: [{ type: "string" }], items: false } } }, + { properties: { pair: { type: "array", items: [{ type: "string" }] } } }, + { properties: { query: { type: "not-a-type" } } }, + { dependencies: { query: ["limit"] } }, + { $defs: { query: { type: "string" } }, properties: { query: { $ref: "#/$defs/query" } } }, + { + definitions: { query: { type: "string" } }, + properties: { query: { $ref: "#/definitions/query" } }, + }, + { examples: [{ $schema: "urn:annotation", items: [1] }] }, + ]; + let accepted = 0; + for (const $schema of declarations) { + for (const body of bodies) { + const schema = { type: "object", ...($schema === undefined ? {} : { $schema }), ...body }; + if (!baseline.validateSchema(schema)) continue; + accepted += 1; + const result = sanitizeMcpToolDefinition( + toolCandidate({ inputSchema: schema, outputSchema: schema }), + ); + expect(result.ok, JSON.stringify(schema)).toBe(true); + if (!result.ok) throw new Error(result.reason); + expect(result.definition.inputSchema).toBe(schema); + expect(result.definition.outputSchema).toBe(schema); + const frozen = [result.definition]; + expect(validateMcpToolDefinitions(frozen)).toBe(frozen); + } + } + expect(accepted).toBeGreaterThan(100); + }); + + it("checks 2020-12 keywords even when draft-07 treats them as annotations", () => { + expect( + sanitizeMcpToolDefinition( + toolCandidate({ + inputSchema: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + $defs: { query: { type: 7 } }, + }, + }), + ), + ).toEqual({ + ok: false, + reason: + "input schema is not compatible with JSON Schema 2020-12 required by model providers; the server should publish a 2020-12-compatible schema", + }); + }); + + it.each([ + [ + "dependencies", + { properties: { query: { type: "object", dependencies: { a: ["b"] } } } }, + "dependentRequired` or `dependentSchemas", + ], + [ + "dependencies", + { dependencies: { query: { required: ["limit"] } } }, + "dependentRequired` or `dependentSchemas", + ], + ["$recursiveRef", { properties: { query: { $recursiveRef: "#" } } }, "$dynamicRef"], + [ + "$ref", + { + definitions: { query: { type: "string" } }, + properties: { query: { $ref: "#/definitions/query" } }, + }, + "inline referenced schemas", + ], + [ + "$ref", + { $defs: { query: { type: "string" } }, properties: { query: { $ref: "#/$defs/query" } } }, + "inline referenced schemas", + ], + [ + "$ref", + { properties: { query: { $ref: "https://example.invalid/schema" } } }, + "inline referenced schemas", + ], + ["additionalProperties", { additionalProperties: false }, "inside `properties`"], + ["allOf", { allOf: [{ required: ["query"] }] }, "inside `properties`"], + ])( + "refuses newly admitted legacy semantics or lost provider constraints: %s (%#)", + (keyword, body, remedy) => { + for (const $schema of [ + "http://json-schema.org/draft-07/schema#", + "https://json-schema.org/draft/2019-09/schema", + ]) { + const inputSchema = { $schema, type: "object", ...body }; + const result = sanitizeMcpToolDefinition(toolCandidate({ inputSchema })); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Unexpected provider-incompatible schema"); + expect(result.reason).toContain(keyword); + expect(result.reason).toContain(remedy); + expect(result.reason).not.toContain("example.invalid"); + } + }, + ); + + it("refuses 2019-09 boolean recursive anchors that do not match the 2020-12 body", () => { + expect( + sanitizeMcpToolDefinition( + toolCandidate({ + inputSchema: { + $schema: "https://json-schema.org/draft/2019-09/schema", + type: "object", + $recursiveAnchor: true, + }, + }), + ), + ).toEqual({ + ok: false, + reason: + "input schema is not compatible with JSON Schema 2020-12 required by model providers; the server should publish a 2020-12-compatible schema", + }); + expect( + sanitizeMcpToolDefinition( + toolCandidate({ + inputSchema: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + $recursiveAnchor: "anchor", + }, + }), + ), + ).toMatchObject({ ok: false, reason: expect.stringContaining("$dynamicAnchor") }); + }); + + it.each([ + { properties: { value: { not: { dependencies: { a: ["b"] } } } } }, + { properties: { value: { allOf: [{ dependencies: { a: ["b"] } }] } } }, + { properties: { value: { dependentSchemas: { a: { $dynamicRef: "#anchor" } } } } }, + ])("checks unsupported semantics through single, array and map schema positions (%#)", (body) => { + expect( + sanitizeMcpToolDefinition( + toolCandidate({ + inputSchema: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + ...body, + }, + }), + ).ok, + ).toBe(false); + }); + + it("accepts provider-preserved combinators and schema maps inside properties", () => { + expect( + sanitizeMcpToolDefinition( + toolCandidate({ + inputSchema: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + query: { + type: "object", + additionalProperties: { type: "string" }, + allOf: [{ required: ["a"] }, true], + patternProperties: { "^a": { type: "string" } }, + }, + list: { type: "array", prefixItems: [{ type: "string" }], items: false }, + }, + }, + }), + ).ok, + ).toBe(true); + }); + + it("keeps old-draft output tuples unchanged because output schemas are not sent to providers", () => { + const outputSchema = { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + pair: { type: "array", items: [{ type: "string" }], additionalItems: false }, + }, + }; + const result = sanitizeMcpToolDefinition(toolCandidate({ outputSchema })); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error(result.reason); + expect(result.definition.outputSchema).toBe(outputSchema); + }); + + it("visits schema positions, not literal JSON annotations or property names", () => { + const inputSchema = { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + dependencies: { type: "string" }, + list: { type: "array", items: { type: "string" }, additionalItems: false }, + query: { type: "string", default: { $ref: "literal", dependencies: { a: ["b"] } } }, + }, + examples: [{ $ref: "literal", items: [1], dependencies: { a: ["b"] } }], + // Unreferenced definitions are not provider assertions. + definitions: { unused: { type: "string" } }, + }; + const result = sanitizeMcpToolDefinition(toolCandidate({ inputSchema })); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error(result.reason); + expect(result.definition.inputSchema).toBe(inputSchema); + }); + it.each([ ["empty name", "", "must not be empty"], ["oversized name", "x".repeat(MCP_TOOL_NAME_MAX_CHARS + 1), "is too long"], diff --git a/packages/shared/src/mcp.ts b/packages/shared/src/mcp.ts index 36d869b71..7bf623ad8 100644 --- a/packages/shared/src/mcp.ts +++ b/packages/shared/src/mcp.ts @@ -341,9 +341,11 @@ const JSON_SCHEMA_2020 = new Ajv2020(JSON_SCHEMA_OPTIONS); /** * Only bundled, recognized meta-schemas: never fetch a server's $schema URI. * Each draft gets its own validator, not merely an alias of the 2020-12 - * meta-schema (draft-07/2019-09 allow tuple `items`, for example). - * Undeclared schemas keep the existing 2020-12 default. This is meta-schema - * validation only; compiling a server's schema here would resolve its $refs + * meta-schema. Newly admitted input schemas must also pass 2020-12 body + * validation and the lossless provider subset below; old tuple `items`, for + * example, must never enter a frozen Session's provider declarations. + * Keep the pre-VC-621 Ajv2020 acceptance path for durable replay compatibility. + * This is meta-schema validation only; compiling a server's schema here would resolve its $refs * and retain untrusted schemas in Ajv's cache across catalog refreshes. */ const JSON_SCHEMA_VALIDATORS = new Map([ @@ -359,7 +361,8 @@ for (const [uri, validator] of [...JSON_SCHEMA_VALIDATORS]) { const alias = uri.replace(/^https?:/, uri.startsWith("https:") ? "http:" : "https:"); // This fixed host-authored wrapper needs no meta-validation at registration. // Ajv would compile it eagerly and violate renderer CSP on module import. - // Server schemas still undergo validateSchema below, on the host at discovery. + // Server schemas still undergo validateSchema below at discovery and replay. + // Renderer replay's pre-existing CSP limitation is tracked separately (VC-636). validator.addMetaSchema({ $ref: uri }, alias, false); JSON_SCHEMA_VALIDATORS.set(alias, validator); } @@ -489,6 +492,98 @@ export function isMcpToolId(value: unknown): value is McpToolId { return typeof value === "string" && value.startsWith("mcp__") && PROVIDER_NAME.test(value); } +// Pi's non-strict Anthropic adapter projects only root type/properties/required. +// Do not expand old-draft admission to assertions this projection would drop. +// This is deliberately NOT applied to the original Ajv2020 acceptance path: +// changing it would invalidate already frozen Sessions, not fix their schemas. +const PROJECTED_ROOT_ASSERTIONS = [ + "additionalProperties", + "patternProperties", + "propertyNames", + "unevaluatedProperties", + "minProperties", + "maxProperties", + "dependentRequired", + "dependentSchemas", + "allOf", + "anyOf", + "oneOf", + "not", + "if", + "then", + "else", + "const", + "enum", +] as const; +const LEGACY_PROVIDER_KEYWORDS = new Map([ + ["dependencies", "use 2020-12 `dependentRequired` or `dependentSchemas`"], + ["$recursiveRef", "use 2020-12 `$dynamicRef`"], + ["$recursiveAnchor", "use 2020-12 `$dynamicAnchor`"], + ["$ref", "inline referenced schemas"], + ["$dynamicRef", "inline referenced schemas"], +]); +const SINGLE_SCHEMA_KEYWORDS = [ + "additionalProperties", + "unevaluatedProperties", + "propertyNames", + "contains", + "items", + "unevaluatedItems", + "not", + "if", + "then", + "else", + "contentSchema", +] as const; +const ARRAY_SCHEMA_KEYWORDS = ["allOf", "anyOf", "oneOf", "prefixItems"] as const; +const MAP_SCHEMA_KEYWORDS = [ + "properties", + "patternProperties", + "$defs", + "definitions", + "dependentSchemas", +] as const; + +/** Only schema positions, never examples/defaults/extension data or property names. */ +function legacyProviderFailure(schema: Record | boolean): string | null { + if (typeof schema === "boolean") return null; + for (const [keyword, remedy] of LEGACY_PROVIDER_KEYWORDS) { + if (schema[keyword] !== undefined) { + return `input schema uses \`${keyword}\`, which model providers cannot preserve for this dialect; the server should ${remedy}`; + } + } + // Meta-validation has already checked each child shape. No $ref resolution, + // registration, rewriting or unbounded traversal is needed here. + for (const keyword of SINGLE_SCHEMA_KEYWORDS) { + const child = schema[keyword] as Record | boolean | undefined; + if (child !== undefined) { + const failure = legacyProviderFailure(child); + if (failure !== null) return failure; + } + } + for (const keyword of ARRAY_SCHEMA_KEYWORDS) { + const children = schema[keyword] as readonly (Record | boolean)[] | undefined; + if (children !== undefined) { + for (const child of children) { + const failure = legacyProviderFailure(child); + if (failure !== null) return failure; + } + } + } + for (const keyword of MAP_SCHEMA_KEYWORDS) { + const children = schema[keyword] as + | Record | boolean> + | undefined; + if (children !== undefined) { + for (const child of Object.values(children)) { + const failure = legacyProviderFailure(child); + if (failure !== null) return failure; + } + } + } + return null; +} + /** * Why a published schema cannot be used, or `null` when it can. * @@ -557,6 +652,15 @@ function schemaFailure(value: unknown, label: "input schema" | "output schema"): if (failure !== null) return failure; const encoded = JSON.stringify(value); if (encoded.length > MCP_SCHEMA_MAX_CHARS) return `${label} is too large`; + try { + // Keep every schema the original check accepted, identically. This includes + // empty $schema and bundled 2020-12 /meta/* URIs, not only the main schema + // URI. Frozen definitions are revalidated on attach by the event codec. + if (JSON_SCHEMA_2020.validateSchema(value)) return null; + } catch { + // A recognized old-draft declaration has no meta-schema in Ajv2020. + // Try only our fixed bundled dialects below, never a server's URI. + } try { const dialect = root["$schema"]; // An empty fragment identifies the same meta-schema. Normalize only the @@ -568,7 +672,30 @@ function schemaFailure(value: unknown, label: "input schema" | "output schema"): ? JSON_SCHEMA_VALIDATORS.get(dialect.replace(/#$/, "")) : undefined; if (validator === undefined) throw new Error("Unsupported JSON Schema dialect"); - return validator.validateSchema(value) ? null : `${label} must be valid JSON Schema`; + if (!validator.validateSchema(value)) return `${label} must be valid JSON Schema`; + // Outputs never reach a model provider. Keep their own-dialect validation. + if (label === "output schema") return null; + // A validation-only shallow view chooses the 2020-12 meta-schema without + // changing the advertised object, its declaration or any frozen bytes. + const { $schema: _declaration, ...body } = root; + if (!JSON_SCHEMA_2020.validateSchema(body)) { + if ( + JSON_SCHEMA_2020.errors?.some( + (error) => error.keyword === "type" && error.instancePath.endsWith("/items"), + ) + ) { + return "input schema uses tuple `items` arrays, which model providers reject; the server should use 2020-12 `prefixItems`"; + } + return "input schema is not compatible with JSON Schema 2020-12 required by model providers; the server should publish a 2020-12-compatible schema"; + } + const legacyFailure = legacyProviderFailure(root); + if (legacyFailure !== null) return legacyFailure; + for (const keyword of PROJECTED_ROOT_ASSERTIONS) { + if (root[keyword] !== undefined) { + return `input schema uses root \`${keyword}\`, which model providers omit; the server should put constraints inside \`properties\` or publish a supported tool schema`; + } + } + return null; } catch { return `${label} must be valid JSON Schema`; } diff --git a/packages/shared/src/session-event-codec.test.ts b/packages/shared/src/session-event-codec.test.ts index 98b05263c..6a19e74e2 100644 --- a/packages/shared/src/session-event-codec.test.ts +++ b/packages/shared/src/session-event-codec.test.ts @@ -20,6 +20,7 @@ import { } from "./session-event-codec"; import type { RendererSessionEventPayload } from "./session-event-codec"; import { DEFAULT_CODE_MODE_LIMITS } from "./code-mode"; +import { mcpProviderToolName } from "./mcp"; import { readInteractionPrompts, SESSION_PROJECTION_EVENT_KINDS } from "./session-ledger"; import type { CommandReceipt, @@ -779,6 +780,56 @@ const resolved = (resolution: unknown) => "payload", ); +describe("frozen MCP dialect replay (VC-621)", () => { + it.each([ + "", + "https://json-schema.org/draft/2020-12/schema", + "https://json-schema.org/draft/2020-12/meta/core", + "https://json-schema.org/draft/2020-12/meta/applicator", + "https://json-schema.org/draft/2020-12/meta/validation", + "https://json-schema.org/draft/2020-12/meta/unevaluated", + "https://json-schema.org/draft/2020-12/meta/format-annotation", + "https://json-schema.org/draft/2020-12/meta/content", + "https://json-schema.org/draft/2020-12/meta/meta-data", + "http://json-schema.org/draft-07/schema#", + "https://json-schema.org/draft/2019-09/schema", + ])("replays %s without changing the frozen input or output schema", ($schema) => { + const inputSchema = { + $schema, + type: "object", + properties: { query: { type: "string" } }, + required: ["query"], + }; + const providerName = mcpProviderToolName("fixture", "Fixture", "query"); + const payload = { + kind: "session.input.recorded", + input: { + kind: "tool-surface", + tools: [providerName], + mcpTools: [ + { + serverId: "fixture", + toolName: "query", + providerName, + description: "Query", + inputSchema, + outputSchema: inputSchema, + }, + ], + }, + }; + const wire = JSON.parse(encodeSessionJson(payload)); + const decoded = decodeSessionEventPayload(wire, "payload"); + expect(decoded).toEqual(payload); + expect(encodeSessionJson(decoded)).toBe(encodeSessionJson(payload)); + // This checks the ordinary renderer decoder, not CSP execution (VC-636). + expect(parseRendererSessionEvent(rendererEvent({ payload: wire }), "event")).toMatchObject({ + ok: true, + event: { payload }, + }); + }); +}); + describe("decodeSessionEventPayload tolerance and corruption", () => { it("reads a stopped-run Attention written before `resetsAt` existed as stating no reset", () => { const legacy = decodeSessionEventPayload( From f71dd0f3e258abeedc9829ad0fc2dce78da2ae11 Mon Sep 17 00:00:00 2001 From: Hussain Phalasiya Date: Mon, 5 Oct 2026 01:14:24 +0300 Subject: [PATCH 3/5] fix(mcp): reject cross-dialect prefixItems semantic drift (VC-621) --- docs/mcp.md | 6 ++++- packages/shared/src/mcp.test.ts | 42 ++++++++++++++++++++++++++++++++- packages/shared/src/mcp.ts | 1 + 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/docs/mcp.md b/docs/mcp.md index 3addff955..4a23f2935 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -582,7 +582,11 @@ Volli does not translate tuples or silently change the tool's meaning. Dual meta-validation alone is not a provider-compatibility guarantee. Legacy `dependencies` and recursive-reference keywords can lose semantics under -2020-12, and Pi's non-strict Anthropic adapter retains only root `type`, +2020-12. Combining `prefixItems` with old-draft `items` can also weaken constraints: +old drafts ignore `prefixItems` and apply `items` to every element, whereas +2020-12 applies `items` only after the prefix. Newly admitted old-draft inputs +therefore cannot use `prefixItems`; the server must declare 2020-12 to use it. +Pi's non-strict Anthropic adapter retains only root `type`, `properties`, and `required`. For newly admitted old-draft inputs, Volli also refuses references (`$ref`/`$dynamicRef`, including references into `definitions` or `$defs`) and root assertions the adapter drops, such as `additionalProperties`, diff --git a/packages/shared/src/mcp.test.ts b/packages/shared/src/mcp.test.ts index e925e7785..72228e343 100644 --- a/packages/shared/src/mcp.test.ts +++ b/packages/shared/src/mcp.test.ts @@ -1,3 +1,5 @@ +import Ajv from "ajv"; +import Ajv2019 from "ajv/dist/2019.js"; import Ajv2020 from "ajv/dist/2020.js"; import { describe, expect, it } from "vite-plus/test"; @@ -427,7 +429,7 @@ describe("sanitizeMcpToolDefinition", () => { allOf: [{ required: ["a"] }, true], patternProperties: { "^a": { type: "string" } }, }, - list: { type: "array", prefixItems: [{ type: "string" }], items: false }, + list: { type: "array", items: { type: "string" }, contains: { type: "string" } }, }, }, }), @@ -435,6 +437,44 @@ describe("sanitizeMcpToolDefinition", () => { ).toBe(true); }); + it.each([false, { type: "number" }])( + "refuses prefixItems that would weaken old-draft items constraints (%#)", + (items) => { + const options = { strict: false, validateFormats: false }; + const provider = new Ajv2020(options); + for (const [$schema, own] of [ + ["http://json-schema.org/draft-07/schema#", new Ajv(options)], + ["https://json-schema.org/draft/2019-09/schema", new Ajv2019(options)], + ] as const) { + const body = { + type: "object", + properties: { + list: { type: "array", prefixItems: [{ type: "string" }], items }, + }, + }; + const schema = { $schema, ...body }; + expect(own.validateSchema(schema)).toBe(true); + expect(provider.validateSchema(body)).toBe(true); + // Synthetic test-only compilation proves why dual meta-validation + // alone cannot admit this unchanged schema. Production never compiles + // server schemas or resolves/registers their references. + expect(own.compile(schema)({ list: ["x"] })).toBe(false); + expect(provider.compile(body)({ list: ["x"] })).toBe(true); + const before = JSON.stringify(schema); + expect(sanitizeMcpToolDefinition(toolCandidate({ inputSchema: schema }))).toEqual({ + ok: false, + reason: + "input schema uses `prefixItems`, which model providers cannot preserve for this dialect; the server should declare 2020-12 to use `prefixItems` without changing legacy `items` semantics", + }); + expect(JSON.stringify(schema)).toBe(before); + const output = sanitizeMcpToolDefinition(toolCandidate({ outputSchema: schema })); + expect(output.ok).toBe(true); + if (!output.ok) throw new Error(output.reason); + expect(output.definition.outputSchema).toBe(schema); + } + }, + ); + it("keeps old-draft output tuples unchanged because output schemas are not sent to providers", () => { const outputSchema = { $schema: "http://json-schema.org/draft-07/schema#", diff --git a/packages/shared/src/mcp.ts b/packages/shared/src/mcp.ts index 7bf623ad8..e65438aeb 100644 --- a/packages/shared/src/mcp.ts +++ b/packages/shared/src/mcp.ts @@ -517,6 +517,7 @@ const PROJECTED_ROOT_ASSERTIONS = [ ] as const; const LEGACY_PROVIDER_KEYWORDS = new Map([ ["dependencies", "use 2020-12 `dependentRequired` or `dependentSchemas`"], + ["prefixItems", "declare 2020-12 to use `prefixItems` without changing legacy `items` semantics"], ["$recursiveRef", "use 2020-12 `$dynamicRef`"], ["$recursiveAnchor", "use 2020-12 `$dynamicAnchor`"], ["$ref", "inline referenced schemas"], From 9d4a8810556e54e284460fb6357fb1573a648112 Mon Sep 17 00:00:00 2001 From: Hussain Phalasiya Date: Tue, 20 Oct 2026 13:12:26 +0300 Subject: [PATCH 4/5] test(mcp): cover restored dialect replay under renderer CSP --- packages/agent-runtime/src/pi/mcp-tools.test.ts | 4 ++-- packages/shared/src/mcp.ts | 4 ++-- packages/shared/src/session-event-codec-csp.test.ts | 10 ++++++++++ packages/shared/src/session-event-codec.test.ts | 2 +- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/agent-runtime/src/pi/mcp-tools.test.ts b/packages/agent-runtime/src/pi/mcp-tools.test.ts index 73e9ed568..36c34ac0b 100644 --- a/packages/agent-runtime/src/pi/mcp-tools.test.ts +++ b/packages/agent-runtime/src/pi/mcp-tools.test.ts @@ -79,8 +79,8 @@ describe("MCP Pi tool wrapper", () => { }); const context = createContext({}, { codeGeneration: { strings: false, wasm: false } }); // Match the renderer's script-src 'self': importing shared vocabulary must - // not compile Ajv validators on import. Discovery and frozen replay invoke - // validation later; renderer replay under CSP is a separate issue (VC-636). + // not compile Ajv validators on import. Discovery and durable host replay + // validate later; renderer presentation checks only the validated wire shape. expect(() => runInContext(bundled.outputFiles[0]!.text, context)).not.toThrow(); expect(runInContext("typeof McpSchema.sanitizeMcpToolDefinition", context)).toBe("function"); }); diff --git a/packages/shared/src/mcp.ts b/packages/shared/src/mcp.ts index 46245fe28..9b7a21692 100644 --- a/packages/shared/src/mcp.ts +++ b/packages/shared/src/mcp.ts @@ -361,8 +361,8 @@ for (const [uri, validator] of [...JSON_SCHEMA_VALIDATORS]) { const alias = uri.replace(/^https?:/, uri.startsWith("https:") ? "http:" : "https:"); // This fixed host-authored wrapper needs no meta-validation at registration. // Ajv would compile it eagerly and violate renderer CSP on module import. - // Server schemas still undergo validateSchema below at discovery and replay. - // Renderer replay's pre-existing CSP limitation is tracked separately (VC-636). + // Server schemas still undergo validateSchema below at discovery and durable + // host replay. Renderer presentation checks only their host-validated shape. validator.addMetaSchema({ $ref: uri }, alias, false); JSON_SCHEMA_VALIDATORS.set(alias, validator); } diff --git a/packages/shared/src/session-event-codec-csp.test.ts b/packages/shared/src/session-event-codec-csp.test.ts index c7438bc79..2b7fdd2cc 100644 --- a/packages/shared/src/session-event-codec-csp.test.ts +++ b/packages/shared/src/session-event-codec-csp.test.ts @@ -90,6 +90,16 @@ function parseUnderCsp(event: SessionEvent): unknown { describe("renderer Session input under CSP", () => { it.each([ undefined, + "", + ...[ + "core", + "applicator", + "validation", + "unevaluated", + "format-annotation", + "content", + "meta-data", + ].map((meta) => `https://json-schema.org/draft/2020-12/meta/${meta}`), "http://json-schema.org/draft-07/schema#", "https://json-schema.org/draft/2019-09/schema", "https://json-schema.org/draft/2020-12/schema", diff --git a/packages/shared/src/session-event-codec.test.ts b/packages/shared/src/session-event-codec.test.ts index d13973b54..92dbbd2fe 100644 --- a/packages/shared/src/session-event-codec.test.ts +++ b/packages/shared/src/session-event-codec.test.ts @@ -835,7 +835,7 @@ describe("frozen MCP dialect replay (VC-621)", () => { const decoded = decodeSessionEventPayload(wire, "payload"); expect(decoded).toEqual(payload); expect(encodeSessionJson(decoded)).toBe(encodeSessionJson(payload)); - // This checks the ordinary renderer decoder, not CSP execution (VC-636). + // The browser-bundled CSP path is covered in session-event-codec-csp.test.ts. expect(parseRendererSessionEvent(rendererEvent({ payload: wire }), "event")).toMatchObject({ ok: true, event: { payload }, From 37b9321c59c2d8c7d17a90a1637f373f7e683fbc Mon Sep 17 00:00:00 2001 From: Hussain Phalasiya Date: Tue, 20 Oct 2026 16:29:56 +0300 Subject: [PATCH 5/5] fix(mcp): preserve shipped dialect acceptance on frozen replay --- packages/shared/src/mcp.ts | 52 +++++--- .../src/session-event-codec-csp.test.ts | 37 +++++- .../shared/src/session-event-codec.test.ts | 116 +++++++++++++++++- packages/shared/src/session-event-codec.ts | 23 ++-- 4 files changed, 198 insertions(+), 30 deletions(-) diff --git a/packages/shared/src/mcp.ts b/packages/shared/src/mcp.ts index 9b7a21692..b8c18d2d2 100644 --- a/packages/shared/src/mcp.ts +++ b/packages/shared/src/mcp.ts @@ -343,8 +343,8 @@ const JSON_SCHEMA_2020 = new Ajv2020(JSON_SCHEMA_OPTIONS); * Each draft gets its own validator, not merely an alias of the 2020-12 * meta-schema. Newly admitted input schemas must also pass 2020-12 body * validation and the lossless provider subset below; old tuple `items`, for - * example, must never enter a frozen Session's provider declarations. - * Keep the pre-VC-621 Ajv2020 acceptance path for durable replay compatibility. + * example, must not enter newly discovered provider declarations. Durable replay + * keeps the own-dialect acceptance shipped in #733, plus the earlier Ajv2020 path. * This is meta-schema validation only; compiling a server's schema here would resolve its $refs * and retain untrusted schemas in Ajv's cache across catalog refreshes. */ @@ -494,8 +494,8 @@ export function isMcpToolId(value: unknown): value is McpToolId { // Pi's non-strict Anthropic adapter projects only root type/properties/required. // Do not expand old-draft admission to assertions this projection would drop. -// This is deliberately NOT applied to the original Ajv2020 acceptance path: -// changing it would invalidate already frozen Sessions, not fix their schemas. +// This is deliberately NOT applied to durable replay or the original Ajv2020 +// acceptance path: tightening those would invalidate already frozen Sessions. const PROJECTED_ROOT_ASSERTIONS = [ "additionalProperties", "patternProperties", @@ -592,7 +592,11 @@ function legacyProviderFailure(schema: Record | boolean): strin * much third-party JSON as an input schema, it is frozen into Session history * the same way, and the MCP specification requires both to be object schemas. */ -function schemaFailure(value: unknown, label: "input schema" | "output schema"): string | null { +function schemaFailure( + value: unknown, + label: "input schema" | "output schema", + purpose: "discovery" | "replay", +): string | null { if (value === null || Array.isArray(value) || typeof value !== "object") { return `${label} must be a JSON Schema object`; } @@ -674,8 +678,11 @@ function schemaFailure(value: unknown, label: "input schema" | "output schema"): : undefined; if (validator === undefined) throw new Error("Unsupported JSON Schema dialect"); if (!validator.validateSchema(value)) return `${label} must be valid JSON Schema`; - // Outputs never reach a model provider. Keep their own-dialect validation. - if (label === "output schema") return null; + // Frozen definitions keep the own-dialect acceptance shipped in #733, + // even when fresh discovery now refuses their provider semantics. Never + // drop, rewrite or re-admit them using today's discovery policy on attach. + // Outputs never reach a model provider either. + if (purpose === "replay" || label === "output schema") return null; // A validation-only shallow view chooses the 2020-12 meta-schema without // changing the advertised object, its declaration or any frozen bytes. const { $schema: _declaration, ...body } = root; @@ -702,7 +709,11 @@ function schemaFailure(value: unknown, label: "input schema" | "output schema"): } } -export function sanitizeMcpToolDefinition(input: McpToolCandidate): McpToolSanitization { +/** Fresh discovery is strict; durable readers must explicitly retain replay acceptance. */ +export function sanitizeMcpToolDefinition( + input: McpToolCandidate, + purpose: "discovery" | "replay" = "discovery", +): McpToolSanitization { if (!SERVER_ID.test(input.serverId) || input.serverId.length > MCP_SERVER_ID_MAX_CHARS) { return { ok: false, reason: "server id is invalid" }; } @@ -717,10 +728,12 @@ export function sanitizeMcpToolDefinition(input: McpToolCandidate): McpToolSanit if (description.length > MCP_DESCRIPTION_MAX_CHARS) { return { ok: false, reason: "tool description is too long" }; } - const schemaError = schemaFailure(input.inputSchema, "input schema"); + const schemaError = schemaFailure(input.inputSchema, "input schema", purpose); if (schemaError !== null) return { ok: false, reason: schemaError }; const outputSchemaError = - input.outputSchema === undefined ? null : schemaFailure(input.outputSchema, "output schema"); + input.outputSchema === undefined + ? null + : schemaFailure(input.outputSchema, "output schema", purpose); const definition: McpToolDefinition = { serverId: input.serverId, toolName: input.toolName, @@ -1010,14 +1023,17 @@ export function validateMcpToolDefinitions( const identities = new Set(); const providerNames = new Set(); for (const definition of definitions) { - const sanitized = sanitizeMcpToolDefinition({ - serverId: definition.serverId, - serverName: "recorded", - toolName: definition.toolName, - description: definition.description, - inputSchema: definition.inputSchema, - ...(definition.outputSchema === undefined ? {} : { outputSchema: definition.outputSchema }), - }); + const sanitized = sanitizeMcpToolDefinition( + { + serverId: definition.serverId, + serverName: "recorded", + toolName: definition.toolName, + description: definition.description, + inputSchema: definition.inputSchema, + ...(definition.outputSchema === undefined ? {} : { outputSchema: definition.outputSchema }), + }, + "replay", + ); if (!sanitized.ok) throw new Error(`Invalid MCP tool ${definition.toolName}: ${sanitized.reason}`); // Discovery drops an output schema it cannot accept, so a frozen one that diff --git a/packages/shared/src/session-event-codec-csp.test.ts b/packages/shared/src/session-event-codec-csp.test.ts index 2b7fdd2cc..9a10f0684 100644 --- a/packages/shared/src/session-event-codec-csp.test.ts +++ b/packages/shared/src/session-event-codec-csp.test.ts @@ -2,7 +2,7 @@ import { fileURLToPath } from "node:url"; import { createContext, runInContext } from "node:vm"; import { build } from "vite-plus"; import { beforeAll, describe, expect, it } from "vite-plus/test"; -import { sanitizeMcpToolDefinition } from "./mcp"; +import { sanitizeMcpToolDefinition, type McpJsonObject } from "./mcp"; import { DEFAULT_CODE_MODE_LIMITS } from "./code-mode"; import { assertSessionEvent, @@ -108,6 +108,41 @@ describe("renderer Session input under CSP", () => { expect(parseUnderCsp(event)).toEqual({ ok: true, event: scrubSessionEvent(event) }); }); + it.each([ + { additionalProperties: false }, + { + properties: { pair: { type: "array", items: [{ type: "string" }], additionalItems: false } }, + }, + { dependencies: { id: ["title"] } }, + ])("keeps previously frozen schemas that fresh discovery now rejects (%j)", (body) => { + const event = shippedEvent(); + if ( + event.payload.kind !== "session.input.recorded" || + event.payload.input.kind !== "tool-surface" + ) { + throw new Error("Missing tool surface"); + } + const inputSchema = { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + ...body, + }; + const definition = { ...event.payload.input.mcpTools![0]!, inputSchema }; + expect(sanitizeMcpToolDefinition({ ...definition, serverName: "Paper" }).ok).toBe(false); + event.payload.input = { ...event.payload.input, mcpTools: [definition] }; + // Existing records are decoded by the host before the renderer sees them. + const decoded = decodeSessionEventPayload( + JSON.parse(encodeSessionJson(event.payload)), + "payload", + ); + expect(decoded).toEqual(event.payload); + assertSessionEvent(event, "event"); + expect(parseUnderCsp({ ...event, payload: decoded })).toEqual({ + ok: true, + event: scrubSessionEvent(event), + }); + }); + it("keeps frozen output schemas and parallel-read eligibility", () => { const event = shippedEvent(undefined, true); expect(parseUnderCsp(event)).toEqual({ ok: true, event: scrubSessionEvent(event) }); diff --git a/packages/shared/src/session-event-codec.test.ts b/packages/shared/src/session-event-codec.test.ts index 92dbbd2fe..9ebfd5cbe 100644 --- a/packages/shared/src/session-event-codec.test.ts +++ b/packages/shared/src/session-event-codec.test.ts @@ -1,3 +1,6 @@ +import Ajv from "ajv"; +import Ajv2019 from "ajv/dist/2019.js"; +import Ajv2020 from "ajv/dist/2020.js"; import { describe, expect, it } from "vite-plus/test"; import { assertSession, @@ -21,7 +24,7 @@ import { } from "./session-event-codec"; import type { RendererSessionEventPayload } from "./session-event-codec"; import { DEFAULT_CODE_MODE_LIMITS } from "./code-mode"; -import { mcpProviderToolName } from "./mcp"; +import { mcpProviderToolName, sanitizeMcpToolDefinition } from "./mcp"; import { readInteractionPrompts, SESSION_PROJECTION_EVENT_KINDS } from "./session-ledger"; import type { CommandReceipt, @@ -794,6 +797,117 @@ const resolved = (resolution: unknown) => ); describe("frozen MCP dialect replay (VC-621)", () => { + it("preserves main's own-dialect acceptance across every new discovery restriction", () => { + // Frozen oracle: main a662494f3's #733 acceptance is own-dialect meta-validation, + // not a 2020-12 body check or today's provider-admission policy. All corpus + // entries are bounded JSON object schemas, so main's unchanged shape/size + // checks also pass. Keep this independent of the production sanitizer. + const options = { addUsedSchema: false, strict: false, validateFormats: false }; + const validators = [ + ["http://json-schema.org/draft-07/schema", new Ajv(options)], + ["https://json-schema.org/draft/2019-09/schema", new Ajv2019(options)], + ["https://json-schema.org/draft/2020-12/schema", new Ajv2020(options)], + ] as const; + const bodies = [ + {}, + // Every root assertion forbidden only at fresh legacy discovery. + { additionalProperties: false }, + { patternProperties: { "^x": { type: "string" } } }, + { propertyNames: { minLength: 1 } }, + { unevaluatedProperties: false }, + { minProperties: 1 }, + { maxProperties: 2 }, + { dependentRequired: { x: ["y"] } }, + { dependentSchemas: { x: { required: ["y"] } } }, + { allOf: [{ required: ["x"] }] }, + { anyOf: [{ required: ["x"] }] }, + { oneOf: [{ required: ["x"] }] }, + { not: { required: ["x"] } }, + { if: { required: ["x"] } }, + // eslint-disable-next-line unicorn/no-thenable -- JSON Schema keyword, not an awaitable. + { then: { required: ["x"] } }, + { else: { required: ["x"] } }, + { const: { x: 1 } }, + { enum: [{ x: 1 }] }, + // Body incompatibilities: tuples and keywords unknown to older drafts. + { + properties: { + pair: { type: "array", items: [{ type: "string" }], additionalItems: false }, + }, + }, + { $defs: { unknownInDraft07: { type: 7 } } }, + { $recursiveAnchor: true }, + // Every keyword rejected by the legacy provider walk, including refs + // that must stay opaque: no fetching, registering or resolving them. + { dependencies: { x: ["y"] } }, + { properties: { list: { type: "array", prefixItems: [{ type: "string" }], items: false } } }, + { $recursiveRef: "#" }, + { $recursiveAnchor: "anchor" }, + { properties: { x: { $ref: "https://example.invalid/schema" } } }, + { $dynamicRef: "#anchor" }, + // Single, array and map child schema positions, not just the root. + { properties: { x: { not: { dependencies: { a: ["b"] } } } } }, + { properties: { x: { allOf: [{ dependencies: { a: ["b"] } }] } } }, + { properties: { x: { dependentSchemas: { a: { $dynamicRef: "#anchor" } } } } }, + { examples: [{ $ref: "literal", items: [1] }] }, + ]; + let accepted = 0; + let discoveryRejected = 0; + for (const [uri, validator] of validators) { + const alias = uri.replace(/^https?:/, uri.startsWith("https:") ? "http:" : "https:"); + validator.addMetaSchema({ $ref: uri }, alias, false); + for (const $schema of [uri, `${uri}#`, alias, `${alias}#`]) { + for (const body of bodies) { + const inputSchema = { $schema, type: "object", ...body }; + if (!validator.validateSchema(inputSchema)) continue; + accepted += 1; + const candidate = { + serverId: "fixture", + serverName: "Fixture", + toolName: "query", + description: "Query", + inputSchema, + outputSchema: inputSchema, + }; + if (!sanitizeMcpToolDefinition(candidate).ok) discoveryRejected += 1; + const providerName = mcpProviderToolName("fixture", "Fixture", "query"); + const definition = { + serverId: "fixture", + toolName: "query", + providerName, + description: "Query", + inputSchema, + outputSchema: inputSchema, + parallelRead: true, + }; + for (const payload of [ + { + kind: "session.input.recorded", + input: { kind: "tool-surface", tools: [providerName], mcpTools: [definition] }, + }, + { + kind: "session.tools.refreshed", + trigger: "cold-turn", + added: [providerName], + changed: [], + delta: { mcpTools: [definition] }, + }, + ]) { + const before = encodeSessionJson(payload); + const decoded = decodeSessionEventPayload(JSON.parse(before), "payload"); + expect(decoded, before).toEqual(payload); + expect(encodeSessionJson(decoded)).toBe(before); + expect(encodeSessionJson(payload)).toBe(before); + } + } + } + } + // The oracle must exercise both accepted legacy tools and the new admission + // exclusions, rather than silently skipping all stricter cases. + expect(accepted).toBeGreaterThan(300); + expect(discoveryRejected).toBeGreaterThan(200); + }); + it.each([ "", "https://json-schema.org/draft/2020-12/schema", diff --git a/packages/shared/src/session-event-codec.ts b/packages/shared/src/session-event-codec.ts index 9b935b5ed..61571970e 100644 --- a/packages/shared/src/session-event-codec.ts +++ b/packages/shared/src/session-event-codec.ts @@ -1383,16 +1383,19 @@ function decodeMcpToolDefinitions(value: unknown, context: string): readonly Mcp const toolName = readString(row.toolName, `${context}[${index}].toolName`); const providerName = readString(row.providerName, `${context}[${index}].providerName`); const description = readString(row.description, `${context}[${index}].description`); - const sanitized = sanitizeMcpToolDefinition({ - serverId, - serverName: "recorded", - toolName, - description, - inputSchema: row.inputSchema, - // Absent on every definition frozen before VC-469, which replays as it - // was written: without one. - ...(row.outputSchema === undefined ? {} : { outputSchema: row.outputSchema }), - }); + const sanitized = sanitizeMcpToolDefinition( + { + serverId, + serverName: "recorded", + toolName, + description, + inputSchema: row.inputSchema, + // Absent on every definition frozen before VC-469, which replays as it + // was written: without one. + ...(row.outputSchema === undefined ? {} : { outputSchema: row.outputSchema }), + }, + "replay", + ); if (!sanitized.ok) throw new Error(`${context}[${index}] ${sanitized.reason}`); if (sanitized.outputSchemaRejected !== undefined) { throw new Error(`${context}[${index}] ${sanitized.outputSchemaRejected}`);