feat(mcp): return structured typed tool results
This commit is contained in:
parent
3be439cbf3
commit
eb38e4c402
|
|
@ -181,8 +181,22 @@ const symbolDiscoveryOutput = (property: "classes" | "protocols") =>
|
|||
const analysisErrorProjectionSchema = z
|
||||
.object({
|
||||
tag: z.enum(ANALYSIS_ERROR_TAGS),
|
||||
category: z.enum([
|
||||
"invalid_input",
|
||||
"permission_required",
|
||||
"unsupported_provider",
|
||||
"integrity_mismatch",
|
||||
"truncated",
|
||||
"cancelled",
|
||||
"timeout",
|
||||
"unavailable",
|
||||
"execution_failure",
|
||||
]),
|
||||
message: z.string(),
|
||||
details: z.record(z.string(), z.union([z.string(), z.number(), z.null()])),
|
||||
details: z.record(
|
||||
z.string(),
|
||||
z.union([z.string(), z.number(), z.boolean(), z.null()]),
|
||||
),
|
||||
})
|
||||
.strict();
|
||||
const graphNode = z.discriminatedUnion("status", [
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { z } from "zod";
|
||||
import { jsonValueSchema } from "./jsonValue.js";
|
||||
|
||||
const hexAddress = z.string().regex(/^0x[a-fA-F0-9]+$/u);
|
||||
const coverage = <Schema extends z.ZodType>(item: Schema) =>
|
||||
|
|
@ -112,7 +113,7 @@ export const inspectSignatureSchema = z.object({
|
|||
hash_algorithms: z.array(z.string()),
|
||||
authorities: z.array(z.string()),
|
||||
designated_requirement: z.string().nullable(),
|
||||
entitlements: z.unknown().nullable(),
|
||||
entitlements: jsonValueSchema.nullable(),
|
||||
timestamp: z.string().nullable(),
|
||||
hardened_runtime: z.boolean().nullable(),
|
||||
provenance: z.array(nativeCommandInvocationSchema),
|
||||
|
|
@ -121,7 +122,7 @@ export const inspectSignatureSchema = z.object({
|
|||
|
||||
export const inspectPlistSchema = z.object({
|
||||
format: z.enum(["xml", "binary", "json", "unknown"]),
|
||||
value: z.unknown(),
|
||||
value: jsonValueSchema,
|
||||
bundle: z.object({
|
||||
identifier: z.string().nullable(),
|
||||
executable: z.string().nullable(),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { z } from "zod";
|
||||
import { jsonValueSchema } from "./jsonValue.js";
|
||||
|
||||
import {
|
||||
LEGACY_PROCESS_CAPTURE_MESSAGE,
|
||||
|
|
@ -200,10 +201,10 @@ export const processCaptureSchema: z.ZodType<ProcessCapture> = z.object({
|
|||
pty_backend: z.literal("node-pty"),
|
||||
started_at: z.iso.datetime(),
|
||||
completed_at: z.iso.datetime(),
|
||||
scenario: z.record(z.string(), z.unknown()),
|
||||
comparison_contract: z.record(z.string(), z.unknown()),
|
||||
shim_plan: z.array(z.unknown()),
|
||||
replay_plan: z.record(z.string(), z.unknown()),
|
||||
scenario: z.record(z.string(), jsonValueSchema),
|
||||
comparison_contract: z.record(z.string(), jsonValueSchema),
|
||||
shim_plan: z.array(jsonValueSchema),
|
||||
replay_plan: z.record(z.string(), jsonValueSchema),
|
||||
full_scenario_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
comparison_contract_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
executable_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { z } from "zod";
|
|||
|
||||
import type { ProcessCapture } from "./processCapture.js";
|
||||
import { validateProcessCapture } from "./processCapture.js";
|
||||
import { jsonValueSchema } from "./jsonValue.js";
|
||||
|
||||
/** Comparison classification that never equates incomplete evidence. */
|
||||
export const comparisonStatusSchema = z.enum([
|
||||
|
|
@ -42,8 +43,8 @@ export const processCaptureComparisonSchema = z
|
|||
index: z.number().int().nonnegative(),
|
||||
left_at_ms: z.number().int().nonnegative().nullable(),
|
||||
right_at_ms: z.number().int().nonnegative().nullable(),
|
||||
left: z.unknown().nullable(),
|
||||
right: z.unknown().nullable(),
|
||||
left: jsonValueSchema.nullable(),
|
||||
right: jsonValueSchema.nullable(),
|
||||
}),
|
||||
]),
|
||||
limitations: z.array(z.string()),
|
||||
|
|
@ -165,8 +166,8 @@ const firstCollectionDivergence = (
|
|||
index,
|
||||
left_at_ms: eventTime(leftValue),
|
||||
right_at_ms: eventTime(rightValue),
|
||||
left: leftValue,
|
||||
right: rightValue,
|
||||
left: jsonValueSchema.parse(leftValue),
|
||||
right: jsonValueSchema.parse(rightValue),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import type { CallToolResult } from "@modelcontextprotocol/server";
|
||||
|
||||
import { projectAnalysisError, type AnalysisError } from "../domain/errors.js";
|
||||
import {
|
||||
AnalysisOutputError,
|
||||
projectAnalysisError,
|
||||
type AnalysisError,
|
||||
} from "../domain/errors.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type { ToolContract } from "../contracts/toolContracts.js";
|
||||
|
|
@ -22,9 +26,10 @@ const errorResult = (error: AnalysisError): CallToolResult => {
|
|||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `${projected.tag}: ${projected.message}`,
|
||||
text: projected.message,
|
||||
},
|
||||
],
|
||||
structuredContent: { error: projected },
|
||||
isError: true,
|
||||
};
|
||||
};
|
||||
|
|
@ -40,9 +45,17 @@ const successResult = (
|
|||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "AnalysisOutputError: Analysis output does not match the tool contract",
|
||||
text: "Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",
|
||||
},
|
||||
],
|
||||
structuredContent: {
|
||||
error: projectAnalysisError(
|
||||
new AnalysisOutputError(
|
||||
contract.name,
|
||||
"output does not match the tool contract",
|
||||
),
|
||||
),
|
||||
},
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { createPackageWithOptions } from "@electron/asar";
|
||||
import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
|
@ -16,6 +18,63 @@ import { createServer } from "../src/server/createServer.js";
|
|||
import { observed } from "./fixtures/analysisExecution.js";
|
||||
|
||||
describe("artifact graph MCP integration", () => {
|
||||
it("returns ASAR integrity path and hashes as structured error data", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-asar-integrity-mcp-"));
|
||||
const source = join(root, "source");
|
||||
const original = "console.log('ok');\n";
|
||||
const changed = "changed();\n";
|
||||
await mkdir(source);
|
||||
await writeFile(join(source, "main.js"), original);
|
||||
const archive = join(root, "fixture.asar");
|
||||
await createPackageWithOptions(source, archive, { unpack: "*.js" });
|
||||
await writeFile(join(`${archive}.unpacked`, "main.js"), changed);
|
||||
|
||||
const session = new BinarySession(new ArtifactProvider());
|
||||
const server = createServer(session, session);
|
||||
const client = new Client({ name: "asar-integrity-test", version: "1" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const opened = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: archive },
|
||||
});
|
||||
expect(opened.isError).not.toBe(true);
|
||||
|
||||
const result = await client.callTool({
|
||||
name: "inventory_artifact",
|
||||
arguments: {},
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.structuredContent).toEqual({
|
||||
error: {
|
||||
tag: "ArtifactOperationError",
|
||||
category: "integrity_mismatch",
|
||||
message:
|
||||
"Artifact is invalid or has changed. Get a fresh copy and try again.",
|
||||
details: {
|
||||
operation: "inventory_artifact",
|
||||
reason: "integrity",
|
||||
logicalPath: "main.js",
|
||||
declaredSha256: createHash("sha256").update(original).digest("hex"),
|
||||
calculatedSha256: createHash("sha256")
|
||||
.update(changed)
|
||||
.digest("hex"),
|
||||
unpacked: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await Promise.allSettled([
|
||||
client.close(),
|
||||
server.close(),
|
||||
session.close(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects altered payloads that reuse session Evidence IDs", async () => {
|
||||
const session = new BinarySession(() => ({
|
||||
health: () => Promise.resolve(),
|
||||
|
|
|
|||
|
|
@ -14,6 +14,19 @@ import {
|
|||
sessionOutputSchemas,
|
||||
} from "../src/contracts/toolOutputSchemas.js";
|
||||
|
||||
const emptySchemaPaths = (value: unknown, path = "$"): string[] => {
|
||||
if (Array.isArray(value))
|
||||
return value.flatMap((item, index) =>
|
||||
emptySchemaPaths(item, `${path}[${String(index)}]`),
|
||||
);
|
||||
if (typeof value !== "object" || value === null) return [];
|
||||
const entries = Object.entries(value);
|
||||
if (entries.length === 0) return [path];
|
||||
return entries.flatMap(([key, item]) =>
|
||||
emptySchemaPaths(item, `${path}.${key}`),
|
||||
);
|
||||
};
|
||||
|
||||
describe("tool contract surface", () => {
|
||||
it("advertises complete typed schemas and annotations for all analysis tools", () => {
|
||||
const contracts = [
|
||||
|
|
@ -122,4 +135,22 @@ describe("tool contract surface", () => {
|
|||
expect(contract.description).toMatch(/[.;]/u);
|
||||
}
|
||||
});
|
||||
|
||||
it("publishes no unconstrained output-schema holes across all 68 tools", () => {
|
||||
const contracts = [
|
||||
...OFFICIAL_TOOL_CONTRACTS,
|
||||
...ENHANCED_TOOL_CONTRACTS,
|
||||
...NATIVE_TOOL_CONTRACTS,
|
||||
...ARTIFACT_TOOL_CONTRACTS,
|
||||
...SESSION_TOOL_CONTRACTS,
|
||||
];
|
||||
expect(contracts).toHaveLength(68);
|
||||
for (const contract of contracts) {
|
||||
const schema = z.toJSONSchema(contract.outputSchema, {
|
||||
target: "draft-07",
|
||||
unrepresentable: "any",
|
||||
});
|
||||
expect(emptySchemaPaths(schema), contract.name).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -312,7 +312,9 @@ describe("enhanced MCP tools", () => {
|
|||
status: "error",
|
||||
error: {
|
||||
tag: "AnalysisOutputError",
|
||||
message: "Invalid analysis output for decompile: failed",
|
||||
category: "execution_failure",
|
||||
message:
|
||||
"Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",
|
||||
details: { operation: "decompile", reason: "failed" },
|
||||
},
|
||||
},
|
||||
|
|
@ -352,7 +354,11 @@ describe("enhanced MCP tools", () => {
|
|||
{
|
||||
address: "0x1",
|
||||
status: "error",
|
||||
error: { tag: "AnalysisOutputError" },
|
||||
error: {
|
||||
category: "execution_failure",
|
||||
message:
|
||||
"Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -551,8 +557,8 @@ describe("enhanced MCP tools", () => {
|
|||
});
|
||||
expect(result.isError).toBe(true);
|
||||
const text = result.content.find((item) => item.type === "text");
|
||||
expect(text?.type === "text" ? text.text : "").toContain(
|
||||
"non-advancing pagination offset",
|
||||
expect(text?.type === "text" ? text.text : "").toBe(
|
||||
"Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -598,8 +604,8 @@ describe("enhanced MCP tools", () => {
|
|||
});
|
||||
expect(result.isError).toBe(true);
|
||||
const text = result.content.find((item) => item.type === "text");
|
||||
expect(text?.type === "text" ? text.text : "").toContain(
|
||||
"HopperProtocolError",
|
||||
expect(text?.type === "text" ? text.text : "").toBe(
|
||||
"Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -619,8 +625,8 @@ describe("enhanced MCP tools", () => {
|
|||
});
|
||||
expect(result.isError).toBe(true);
|
||||
const text = result.content.find((item) => item.type === "text");
|
||||
expect(text?.type === "text" ? text.text : "").toContain(
|
||||
"AnalysisOutputError",
|
||||
expect(text?.type === "text" ? text.text : "").toBe(
|
||||
"Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -655,8 +661,8 @@ describe("enhanced MCP tools", () => {
|
|||
});
|
||||
expect(result.isError).toBe(true);
|
||||
const text = result.content.find((item) => item.type === "text");
|
||||
expect(text?.type === "text" ? text.text : "").toContain(
|
||||
"AnalysisOutputError",
|
||||
expect(text?.type === "text" ? text.text : "").toBe(
|
||||
"Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -252,6 +252,17 @@ describe("full MCP integration with multi-tool sequences", () => {
|
|||
},
|
||||
});
|
||||
expect(unavailable.isError).toBe(true);
|
||||
expect(structured(unavailable)).toMatchObject({
|
||||
error: {
|
||||
tag: "AnalysisCapabilityUnavailableError",
|
||||
category: "unsupported_provider",
|
||||
details: {
|
||||
providerId: "partial",
|
||||
operation: "procedure_pseudo_code",
|
||||
reason: "Decompiler is not installed.",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(received[0]).not.toHaveProperty("unknown_registry_approved");
|
||||
expect(
|
||||
structured(
|
||||
|
|
@ -354,7 +365,7 @@ describe("full MCP integration with multi-tool sequences", () => {
|
|||
expect(names).not.toContain("open_binary");
|
||||
});
|
||||
|
||||
it("propagates remote Hopper errors with structured content", async () => {
|
||||
it("projects remote failures without provider or bridge details", async () => {
|
||||
const client = await connect({
|
||||
execute: () =>
|
||||
Promise.resolve(err(new HopperRemoteError(-32000, "bridge timeout"))),
|
||||
|
|
@ -365,8 +376,20 @@ describe("full MCP integration with multi-tool sequences", () => {
|
|||
arguments: {},
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(text(result)).toContain("HopperRemoteError");
|
||||
expect(text(result)).toContain("bridge timeout");
|
||||
expect(structured(result)).toMatchObject({
|
||||
error: {
|
||||
tag: "HopperRemoteError",
|
||||
category: "execution_failure",
|
||||
details: {
|
||||
code: -32000,
|
||||
safeMessage: "bridge timeout",
|
||||
diagnosticType: "remote",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(text(result)).toBe(
|
||||
"Analysis could not complete. Retry once; if it continues, run `rea doctor`.",
|
||||
);
|
||||
});
|
||||
|
||||
it("handles concurrent tool calls without corruption", async () => {
|
||||
|
|
|
|||
|
|
@ -199,7 +199,7 @@ describe("official Hopper proxy tools", () => {
|
|||
expect(result.content).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: "HopperRemoteError: Hopper request failed (-1): denied",
|
||||
text: "Analysis could not complete. Retry once; if it continues, run `rea doctor`.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -58,7 +58,9 @@ describe("target-free MCP lifecycle", () => {
|
|||
arguments: {},
|
||||
});
|
||||
expect(before.isError).toBe(true);
|
||||
expect(text(before)).toContain("NoBinaryOpenError");
|
||||
expect(text(before)).toBe(
|
||||
"No app is open. Ask the user which app to investigate, then call open_binary with its local path.",
|
||||
);
|
||||
expect((await mcp.listTools()).tools).toHaveLength(68);
|
||||
const deniedCapture = await mcp.callTool({
|
||||
name: "capture_process_scenario",
|
||||
|
|
@ -69,7 +71,15 @@ describe("target-free MCP lifecycle", () => {
|
|||
},
|
||||
});
|
||||
expect(deniedCapture.isError).toBe(true);
|
||||
expect(text(deniedCapture)).toContain("process capture is disabled");
|
||||
expect(structured(deniedCapture)).toMatchObject({
|
||||
error: {
|
||||
tag: "ProcessCaptureError",
|
||||
category: "permission_required",
|
||||
},
|
||||
});
|
||||
expect(text(deniedCapture)).toBe(
|
||||
"Process capture is disabled. Set `REA_PROCESS_CAPTURE_ENABLED=true`, configure approved roots, then restart REA.",
|
||||
);
|
||||
expect(
|
||||
(await mcp.callTool({ name: "open_binary", arguments: { path: first } }))
|
||||
.isError,
|
||||
|
|
|
|||
|
|
@ -26,9 +26,21 @@ describe("tool result projection", () => {
|
|||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "AnalysisOutputError: Analysis output does not match the tool contract",
|
||||
text: "Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",
|
||||
},
|
||||
],
|
||||
structuredContent: {
|
||||
error: {
|
||||
tag: "AnalysisOutputError",
|
||||
category: "execution_failure",
|
||||
message:
|
||||
"Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",
|
||||
details: {
|
||||
operation: "provider_neutral_fixture",
|
||||
reason: "output does not match the tool contract",
|
||||
},
|
||||
},
|
||||
},
|
||||
isError: true,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue