refactor(providers): consolidate IDA and JEB MCP result admission

Give the object-structured provider envelope one owner that retains original dictionary members. Remove duplicate schemas, text extraction and JSON-or-text decoding plus the downstream casts that repaired lossy record clones. Keep provider-specific error, truncation, wrapper and payload-selection policies in each adapter.

Validation: IDA/JEB compatibility tests, pinned SDK transport tests and provider regressions passed; check:fast passed. Real IDA/JEB applications were not exercised.
This commit is contained in:
morluto committed 2026-10-26 22:30:58 +08:00
1 parent 2b84efe0da
commit 49816235f5
5 files changed
+156 -44

No files matched your search

+56
View File
@@ -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();
});
+10 -18
View File
@@ -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<typeof toolResultSchema>;
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.
+44
View File
@@ -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");
});
+11 -26
View File
@@ -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<void>;
}
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<typeof toolResultSchema>;
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(
+35
View File
@@ -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<Record<string, unknown>>((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<typeof mcpToolResultSchema>["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;
}
};