diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts index b31849b19..e0c77361c 100644 --- a/src/domain/analysisView/analysisView.ts +++ b/src/domain/analysisView/analysisView.ts @@ -30,44 +30,42 @@ const facetViewSchema = z.strictObject({ kind: z.literal("facet"), facet: z.enum(["mitigations", "linkage"]), }); -const itemSelectorSchema = z.union([ - z.strictObject({ index: z.number().int().nonnegative() }), - z.strictObject({ name: z.string() }), - z.strictObject({ - path: z - .string() - .describe( - "Exact observed module path or original source-map reference from a module page; an empty reference is valid.", - ), - }), - z.strictObject({ node_id: prefixedDigestSchema("jag_node") }), +const indexSelectorSchema = z.strictObject({ + index: z.number().int().nonnegative(), +}); +const nameSelectorSchema = z.strictObject({ name: z.string() }); +const pathSelectorSchema = z.strictObject({ + path: z + .string() + .describe( + "Exact observed module path or original source-map reference from a module page; an empty reference is valid.", + ), +}); +const nodeIdSelectorSchema = z.strictObject({ + node_id: prefixedDigestSchema("jag_node"), +}); +const moduleItemViewSchema = z.strictObject({ + kind: z.literal("item"), + collection: z.literal("modules"), + selector: z.union([pathSelectorSchema, nodeIdSelectorSchema]), +}); +const sectionOrSymbolItemViewSchema = z.strictObject({ + kind: z.literal("item"), + collection: z.enum(["sections", "symbols"]), + selector: z.union([indexSelectorSchema, nameSelectorSchema]), +}); +/** + * The collection-specific selector alternatives are expressed in the owning + * contract so the advertised JSON Schema preserves them: modules accept + * path/node_id, sections and symbols accept index/name. Refinement-only + * constraints do not survive JSON Schema conversion, so the advertised + * schema previously accepted combinations that runtime input validation + * rejects. + */ +const itemViewSchema = z.discriminatedUnion("collection", [ + moduleItemViewSchema, + sectionOrSymbolItemViewSchema, ]); -const itemViewSchema = z - .strictObject({ - kind: z.literal("item"), - collection: z.enum(["sections", "symbols", "modules"]), - selector: itemSelectorSchema, - }) - .superRefine((value, context) => { - const selector = value.selector; - if (value.collection === "modules") { - if (!("path" in selector) && !("node_id" in selector)) - context.addIssue({ - code: "custom", - path: ["selector"], - message: - "Module items are selected by exact path or node_id, not index or name.", - }); - return; - } - if (!("index" in selector) && !("name" in selector)) - context.addIssue({ - code: "custom", - path: ["selector"], - message: - "Section and symbol items are selected by index or exact name, not path or node_id.", - }); - }); const pageViewSchema = z.strictObject({ kind: z.literal("page"), collection: z.enum(["sections", "symbols", "modules"]), diff --git a/tests/boundary/mcp/toolSchemaValidity.test.ts b/tests/boundary/mcp/toolSchemaValidity.test.ts index 1a27826d8..6855f8188 100644 --- a/tests/boundary/mcp/toolSchemaValidity.test.ts +++ b/tests/boundary/mcp/toolSchemaValidity.test.ts @@ -460,6 +460,95 @@ it( advertiseAndEnforceProcessEnvironmentKeyConstraint, ); +const advertiseAndEnforceItemSelectorCollectionConstraints = + async (): Promise => { + const contract = TOOL_CONTRACTS.find( + ({ name }) => name === "inspect_analysis_view", + ); + if (contract === undefined) + throw new Error("inspect_analysis_view contract was not registered"); + + const server = new McpServer({ + name: "item-selector-schema", + version: "0", + }); + server.registerTool( + contract.name, + toolRegistrationOptions(contract), + async () => ({ + content: [], + }), + ); + const client = new Client({ name: "item-selector-schema", version: "0" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const advertised = (await client.listTools()).tools.find( + ({ name }) => name === contract.name, + ); + if (advertised === undefined) + throw new Error("inspect_analysis_view tool was not advertised"); + + const validate = new Ajv2020({ + strict: false, + validateFormats: false, + }).compile(advertised.inputSchema); + const source = { + kind: "retained-evidence", + evidence_id: `ev_${"a".repeat(64)}`, + }; + const itemInput = (collection: string, selector: unknown) => ({ + source, + view: { kind: "item", collection, selector }, + }); + const nodeId = `jag_node_${"b".repeat(64)}`; + // Collection-specific selector constraints must survive JSON Schema + // conversion: the advertised schema rejects exactly what runtime + // input validation rejects. + const invalid: ReadonlyArray = [ + ["modules", { index: 0 }], + ["modules", { name: "entry" }], + ["sections", { path: "/src/entry.js" }], + ["symbols", { node_id: nodeId }], + ]; + const valid: ReadonlyArray = [ + ["modules", { path: "/src/entry.js" }], + ["modules", { node_id: nodeId }], + ["sections", { index: 0 }], + ["sections", { name: ".text" }], + ["symbols", { index: 0 }], + ["symbols", { name: "main" }], + ]; + for (const [collection, selector] of invalid) { + const input = itemInput(collection, selector); + const label = `${collection}/${Object.keys(selector as object)[0]}`; + expect(contract.inputSchema.safeParse(input).success, label).toBe( + false, + ); + expect(validate(input), `advertised schema rejects ${label}`).toBe( + false, + ); + } + for (const [collection, selector] of valid) { + const input = itemInput(collection, selector); + const label = `${collection}/${Object.keys(selector as object)[0]}`; + expect(contract.inputSchema.safeParse(input).success, label).toBe(true); + expect(validate(input), `advertised schema accepts ${label}`).toBe( + true, + ); + } + } finally { + await Promise.allSettled([client.close(), server.close()]); + } + }; + +it( + "advertises and enforces the item selector collection constraints", + advertiseAndEnforceItemSelectorCollectionConstraints, +); + describe("MCP process input JSON Schema", () => { it("advertises process strings without weakening terminal input", async () => { const server = new McpServer({