diff --git a/src/ida/IdaMcpConnection.test.ts b/src/ida/IdaMcpConnection.test.ts index ba1b8afea7..a37ed3a557 100644 --- a/src/ida/IdaMcpConnection.test.ts +++ b/src/ida/IdaMcpConnection.test.ts @@ -695,3 +695,59 @@ it("keeps error, truncation and native-text precedence ahead of structured JSON ), ).toEqual({ complete: true }); }); + +it("requires a valid content array even when IDA supplies structured evidence", () => { + for (const content of [ + undefined, + null, + {}, + [null], + [{ type: "text", text: 42 }], + ]) + expect(() => + decodeIdaToolResult({ content, structuredContent: { observed: true } }), + ).toThrow(); +}); + +it("selects structured IDA evidence ahead of text while admitting mixed content", () => { + const image = { type: "image", data: "AA==", mimeType: "image/png" }; + expect( + decodeIdaToolResult({ + content: [image, { type: "text", text: "fallback" }], + structuredContent: { observed: true }, + }), + ).toEqual({ observed: true }); + expect( + decodeIdaToolResult({ + content: [image, { type: "text", text: "fallback" }], + }), + ).toBe("fallback"); + for (const structuredContent of [null, [], "invalid", { observed: Infinity }]) + expect(() => + decodeIdaToolResult({ + content: [{ type: "text", text: "fallback" }], + structuredContent, + }), + ).toThrow(); + expect(() => decodeIdaToolResult({ content: [image] })).toThrow( + "empty observation", + ); + expect(() => + decodeIdaToolResult({ + content: [ + { type: "text", text: "first" }, + { type: "text", text: "second" }, + ], + }), + ).toThrow("empty observation"); +}); + +it("requires complete native IDA JSON text despite usable structured content", () => { + for (const content of [[], [{ type: "text", text: "plain text" }]]) + expect(() => + decodeIdaToolResult( + { content, structuredContent: { observed: true } }, + "native", + ), + ).toThrow(); +}); diff --git a/src/ida/IdaMcpConnection.ts b/src/ida/IdaMcpConnection.ts index 3d20dd2f85..f8032c40a8 100644 --- a/src/ida/IdaMcpConnection.ts +++ b/src/ida/IdaMcpConnection.ts @@ -1,3 +1,8 @@ +import { + mcpToolResultSchema, + mcpTextBlocks, + parseMcpJsonOrText, +} from "../providers/McpToolResult.js"; import { StructuredContentMcpClient } from "../providers/StructuredContentMcpClient.js"; import { createIdaDiagnosticRedactor, @@ -71,12 +76,7 @@ export type IdaMcpConnectionDependencies = Readonly<{ createDiagnosticRedactor?: typeof createIdaDiagnosticRedactor; }>; -const toolResultSchema = z.object({ - isError: z.boolean().optional(), - structuredContent: z.record(z.string(), z.unknown()).optional(), - content: z.array( - z.object({ type: z.string(), text: z.string().optional() }).passthrough(), - ), +const toolResultSchema = mcpToolResultSchema.extend({ _meta: z .object({ ida_mcp: z @@ -110,11 +110,7 @@ export const decodeIdaToolResult = ( diagnostic: IdaDiagnosticRedactor = createIdaDiagnosticRedactor(), ): JsonValue => { const result = toolResultSchema.parse(input); - // Shape validation's record copy can omit own __proto__ members; JSON owns the original dictionary. - const source = input as z.input; - const blocks = result.content.flatMap((block) => - block.type === "text" && block.text !== undefined ? [block.text] : [], - ); + const blocks = mcpTextBlocks(result.content); if (result.isError === true) { const reason = diagnostic.blocks(blocks); throw new AnalysisProtocolError( @@ -144,8 +140,8 @@ export const decodeIdaToolResult = ( ); return parseIdaJsonText(text); } - if (source.structuredContent !== undefined) { - const value = jsonObjectSchema.parse(source.structuredContent); + if (result.structuredContent !== undefined) { + const value = jsonObjectSchema.parse(result.structuredContent); return Object.keys(value).length === 1 && Object.hasOwn(value, "result") ? value.result! : value; @@ -154,11 +150,7 @@ export const decodeIdaToolResult = ( throw new AnalysisProtocolError( "IDA MCP omitted a single structured or text result; unsupported content cannot establish an empty observation.", ); - try { - return jsonValueSchema.parse(JSON.parse(text)); - } catch { - return text; - } + return parseMcpJsonOrText(text); }; // Retain only producer-sanitized JSON diagnostics, never a native parser cause. diff --git a/src/jeb/JebMcpConnection.test.ts b/src/jeb/JebMcpConnection.test.ts index 99b1d58299..d0a3850a64 100644 --- a/src/jeb/JebMcpConnection.test.ts +++ b/src/jeb/JebMcpConnection.test.ts @@ -462,3 +462,47 @@ it.each([false, true])( expect(JSON.stringify(decoded)).toBe(JSON.stringify(structured)); }, ); + +it("accepts omitted JEB content only when structured evidence establishes an observation", () => { + expect( + decodeJebToolResult({ structuredContent: { result: "ordinary field" } }), + ).toEqual({ result: "ordinary field" }); + expect(() => decodeJebToolResult({})).toThrow("empty observation"); + for (const content of [null, {}, [null], [{ type: "text", text: 42 }]]) + expect(() => + decodeJebToolResult({ content, structuredContent: { success: true } }), + ).toThrow(); +}); + +it("selects structured JEB evidence ahead of text while admitting mixed content", () => { + const image = { type: "image", data: "AA==", mimeType: "image/png" }; + expect( + decodeJebToolResult({ + content: [image, { type: "text", text: "fallback" }], + structuredContent: { observed: true }, + }), + ).toEqual({ observed: true }); + expect( + decodeJebToolResult({ + content: [image, { type: "text", text: "fallback" }], + }), + ).toBe("fallback"); + for (const structuredContent of [null, [], "invalid", { observed: Infinity }]) + expect(() => + decodeJebToolResult({ + content: [{ type: "text", text: "fallback" }], + structuredContent, + }), + ).toThrow(); + expect(() => decodeJebToolResult({ content: [image] })).toThrow( + "empty observation", + ); + expect(() => + decodeJebToolResult({ + content: [ + { type: "text", text: "first" }, + { type: "text", text: "second" }, + ], + }), + ).toThrow("empty observation"); +}); diff --git a/src/jeb/JebMcpConnection.ts b/src/jeb/JebMcpConnection.ts index 2da9c29c4f..36d8ca67e2 100644 --- a/src/jeb/JebMcpConnection.ts +++ b/src/jeb/JebMcpConnection.ts @@ -2,12 +2,12 @@ import { type Client, DEFAULT_REQUEST_TIMEOUT_MSEC, } from "@modelcontextprotocol/client"; -import { z } from "zod"; import { - jsonObjectSchema, - jsonValueSchema, - type JsonValue, -} from "../domain/jsonValue.js"; + mcpToolResultSchema, + mcpTextBlocks, + parseMcpJsonOrText, +} from "../providers/McpToolResult.js"; +import { jsonObjectSchema, type JsonValue } from "../domain/jsonValue.js"; import { AnalysisCancelledError, AnalysisProtocolError, @@ -48,38 +48,23 @@ export interface JebMcpConnection { close(): Promise; } -const toolResultSchema = z.object({ - isError: z.boolean().optional(), - structuredContent: z.record(z.string(), z.unknown()).optional(), - content: z - .array( - z.object({ type: z.string(), text: z.string().optional() }).passthrough(), - ) - .default([]), +const toolResultSchema = mcpToolResultSchema.extend({ + content: mcpToolResultSchema.shape.content.default([]), }); /** Decode a JEB MCP tool result without inventing an empty observation. */ export const decodeJebToolResult = (input: unknown): JsonValue => { const result = toolResultSchema.parse(input); - // Shape validation's record copy can omit own __proto__ members; JSON owns the original dictionary. - const source = input as z.input; - const blocks = result.content.flatMap((block) => - block.type === "text" && block.text !== undefined ? [block.text] : [], - ); + const blocks = mcpTextBlocks(result.content); let decoded: JsonValue; - if (source.structuredContent !== undefined) { - decoded = jsonObjectSchema.parse(source.structuredContent); + if (result.structuredContent !== undefined) { + decoded = jsonObjectSchema.parse(result.structuredContent); } else { if (blocks.length !== 1) throw new AnalysisProtocolError( "JEB MCP omitted a single structured or text result; unsupported content cannot establish an empty observation.", ); - const text = blocks[0] ?? "null"; - try { - decoded = jsonValueSchema.parse(JSON.parse(text)); - } catch { - decoded = text; - } + decoded = parseMcpJsonOrText(blocks[0]!); } if (result.isError === true && !isJebFailureEnvelope(decoded)) throw new AnalysisProtocolError( diff --git a/src/providers/McpToolResult.ts b/src/providers/McpToolResult.ts new file mode 100644 index 0000000000..0450e95287 --- /dev/null +++ b/src/providers/McpToolResult.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; +import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; + +/** Borrow the dictionary shape; selected payloads receive JSON validation in the adapter. */ +const structuredContentSchema = z.custom>((value) => { + if (typeof value !== "object" || value === null || Array.isArray(value)) + return false; + const prototype: unknown = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +}); + +/** IDA/JEB's object-structured envelope; each adapter owns selection and error policy. */ +export const mcpToolResultSchema = z.object({ + isError: z.boolean().optional(), + structuredContent: structuredContentSchema.optional(), + content: z.array( + z.object({ type: z.string(), text: z.string().optional() }).passthrough(), + ), +}); + +export const mcpTextBlocks = ( + content: z.infer["content"], +): string[] => + content.flatMap((block) => + block.type === "text" && block.text !== undefined ? [block.text] : [], + ); + +/** Preserve ordinary text when the selected upstream payload is not valid JSON. */ +export const parseMcpJsonOrText = (text: string): JsonValue => { + try { + return jsonValueSchema.parse(JSON.parse(text)); + } catch { + return text; + } +};