Compare commits

...
Author SHA1 Message Date
Cursor Agent c2c99444c6 test(desktop): expect each exported task summary
The export result lists task summaries on tasks. The seed-policy check
was still looking for a single task object.
2026-11-10 11:38:32 +00:00
Cursor Agent 21971e3896 merge main into fix/8304-deferred-output-schema-projection 2026-11-10 10:52:14 +00:00
Cursor Agent 80b11e6960 fix: keep unrecorded alias kind omitted and endpoint cleanup attached
A capture that does not record selected-path kind must not gain an
unknown kind on the export plan. An oversized Windows Ghidra endpoint
whose close fails stays an invalid endpoint and keeps that cleanup.
2026-11-10 10:37:28 +00:00
Cursor Agent dd28b19f8a merge main into fix/8304-deferred-output-schema-projection 2026-11-10 10:18:31 +00:00
Cursor Agent 14bda0f0cf merge main into fix/8304-deferred-output-schema-projection 2026-11-10 06:59:24 +00:00
Cursor Agent 27549e256f merge main into fix/8304-deferred-output-schema-projection 2026-11-10 05:51:52 +00:00
Cursor Agent 4649fcba7c merge main into fix/8304-deferred-output-schema-projection
Keep the MCP result-envelope note and main's guidance match-scope rules.
2026-11-10 02:09:32 +00:00
Kaoru0822 09980ead00 Merge remote-tracking branch 'origin/main' into fix/8304-deferred-output-schema-projection 2026-10-31 15:59:04 +00:00
Kaoru0822andClaude Opus 5.5 a7655ef29e test(mcp): validate guidance results against the live advertisement
Load tools/list before calling the guidance tools, so the SDK client checks
each result against the advertised, SDK-converted output schema.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 15:19:42 +00:00
Kaoru0822andClaude Opus 5.5 531f2bbfb0 fix(mcp): deliver guidance results under their advertised result object
`search_guidance` and `read_guidance_section` were the only tool contracts
with a non-object output root. The build records the stdio catalog through a
2025-era `tools/list`, which the SDK projects as a `{result}` wrapper. The
deferred stdio server then registered that object-rooted projection, so the
SDK neither re-wrapped it nor wrapped the payload. Every successful call
returned the bare union, and the official TypeScript client rejected it with
"data must have required property 'result'".

Both contracts now declare the `{result}` object that session tools already
use, and the adapter delivers the union under `result`. The CLI output is
unchanged. A catalog-wide test requires every tool's advertised output root
to be an object, so a future root union cannot reintroduce the mismatch. A
stdio boundary test drives `rea mcp` with the validating SDK client. The
in-memory test no longer accepts both shapes.

Fixes #8304

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 14:59:06 +00:00
13 changed files with 149 additions and 46 deletions

No files matched your search

+4
View File
@@ -29,6 +29,10 @@ are bound to the installed bundle and report stale or unavailable guidance witho
returning cached section content. A no-match result says nothing about analysis
capability availability.
Over MCP, both tools deliver their structured result under `result`, matching
the object root of their advertised output schemas, as the session tools do.
The CLI prints the result itself.
Each match reports its `match_scope`. A `section` match has every term in its
own text, meaning its heading and body before its first subsection. A
`subsections` match has the terms only spread across subsections, with no
@@ -7,7 +7,11 @@ import {
READER_RUNNER_SLACK_S,
readerTimeout,
} from "../../domain/desktop/appTaskReaders.js";
import { BUILD_REA_TIMEOUT_S } from "../../desktop/BuildComputerController.js";
import {
BUILD_REA_TIMEOUT_S,
fetchHomeFolder,
type ControlledComputer,
} from "../../desktop/BuildComputerController.js";
import { BuildComputerLongRuns } from "../../desktop/BuildComputerLongRuns.js";
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
@@ -31,10 +35,6 @@ import {
import { jsonValueSchema, type JsonValue } from "../../domain/jsonValue.js";
import { err, ok, type Result } from "../../domain/result.js";
import { AGENTENV_PROVIDER } from "../../desktop/AgentEnvComputerHelper.js";
import {
fetchHomeFolder,
type ControlledComputer,
} from "../../desktop/BuildComputerController.js";
import {
adapterError,
ENDS,
+4 -4
View File
@@ -38,9 +38,9 @@ import type { ToolContract } from "./toolContractTypes.js";
import { toolContractMetadata } from "./toolEffects.js";
import {
readGuidanceSectionInputSchema,
readGuidanceSectionOutputSchema,
readGuidanceSectionToolOutputSchema,
searchGuidanceInputSchema,
searchGuidanceOutputSchema,
searchGuidanceToolOutputSchema,
} from "./guidanceContracts.js";
import {
guidedWorkflowsInputSchema,
@@ -103,7 +103,7 @@ export const APPLICATION_TOOL_CONTRACTS = [
"Search only the installed REA workflow guidance. Every non-generic query word must occur in a section; sections holding every word in their own text rank before parents whose words are spread across subsections. Returns ranked sections, bounded pages, and locators for read_guidance_section. No target or provider is accessed.",
kind: "application",
inputSchema: searchGuidanceInputSchema,
outputSchema: searchGuidanceOutputSchema,
outputSchema: searchGuidanceToolOutputSchema,
examples: [
{
title: "Find exact JavaScript module guidance",
@@ -141,7 +141,7 @@ export const APPLICATION_TOOL_CONTRACTS = [
"Retrieve a section selected by search_guidance from the installed REA guidance bundle. Reports stale locators and unavailable bundles explicitly. Returns documentation, not target observations or Evidence.",
kind: "application",
inputSchema: readGuidanceSectionInputSchema,
outputSchema: readGuidanceSectionOutputSchema,
outputSchema: readGuidanceSectionToolOutputSchema,
examples: [],
},
{
+14
View File
@@ -119,3 +119,17 @@ export type SearchGuidanceOutput = z.output<typeof searchGuidanceOutputSchema>;
export type ReadGuidanceSectionOutput = z.output<
typeof readGuidanceSectionOutputSchema
>;
/**
* MCP 2025 wire output roots are objects, so the adapters deliver each result
* union under `result`, as session tools do. A root union would be wrapped in
* the advertisement alone by the recorded catalog and rejected by validating
* clients.
*/
export const searchGuidanceToolOutputSchema = z.strictObject({
result: searchGuidanceOutputSchema,
});
export const readGuidanceSectionToolOutputSchema = z.strictObject({
result: readGuidanceSectionOutputSchema,
});
+20
View File
@@ -0,0 +1,20 @@
import { expect, it } from "vitest";
import { TOOL_CONTRACTS } from "./toolContracts.js";
import { toolOutputSchemaWithMetadata } from "./toolSchemaMetadata.js";
// MCP 2025 output roots are objects. The stdio server registers recorded
// advertisements, so a non-object root would be wrapped there alone and its
// structured results would fail validating clients (#8304).
it.each(["draft-2020-12", "draft-07"] as const)(
"advertises an object output root for every tool in %s",
(target) => {
const nonObject = TOOL_CONTRACTS.filter(
(contract) =>
toolOutputSchemaWithMetadata(contract)["~standard"].jsonSchema.output({
target,
}).type !== "object",
).map(({ name }) => name);
expect(nonObject).toEqual([]);
},
);
+16 -15
View File
@@ -100,10 +100,10 @@ export interface DesktopAppExportPlan {
readonly alias: string;
readonly target: string;
/**
* Whether the selected path is a directory. Captures written before kind
* was recorded leave this unknown; the helper must not invent a directory.
* Whether the selected path is a directory. Absent when the capture did
* not record kind. The rendered helper treats that as unknown.
*/
readonly kind: "file" | "directory" | "unknown";
readonly kind?: "file" | "directory";
}[];
/** Files under the owned paths that existed before the capture but whose bytes were not retained. */
readonly unseededFiles: readonly string[];
@@ -313,7 +313,7 @@ const capturedStateAliases = (
): {
readonly alias: string;
readonly target: string;
readonly kind: "file" | "directory" | "unknown";
readonly kind?: "file" | "directory";
}[] => {
const { home } = capture.host;
const walked = capture.state.state_directories ?? [];
@@ -327,21 +327,22 @@ const capturedStateAliases = (
const state = index < 0 ? undefined : roots[index]?.path;
const alias = relative(path);
const target = state === undefined ? null : relative(state);
const recorded: "file" | "directory" | "unknown" =
kind === "directory"
? "directory"
: kind === "file"
? "file"
: "unknown";
return alias === null || target === null || alias === target
? []
: [{ alias, target, kind: recorded }];
if (alias === null || target === null || alias === target) return [];
const recorded =
kind === "directory" || kind === "file" ? kind : undefined;
return [
{
alias,
target,
...(recorded === undefined ? {} : { kind: recorded }),
},
];
},
);
const retained: {
alias: string;
target: string;
kind: "file" | "directory" | "unknown";
kind?: "file" | "directory";
}[] = [];
for (const entry of aliases.toSorted(
(left, right) => left.alias.length - right.alias.length,
@@ -643,7 +644,7 @@ TEXT_SEEDS = frozenset(${pyList(plan.textSeeds)})
SEED_MODES = {${Object.entries(plan.seedModes)
.map(([home, mode]) => `${py(home)}: 0o${mode.toString(8)}`)
.join(", ")}}
STATE_ALIASES = [${(plan.stateAliases ?? []).map(({ alias, target, kind }) => `(${py(alias)}, ${py(target)}, ${py(kind)})`).join(", ")}]
STATE_ALIASES = [${(plan.stateAliases ?? []).map(({ alias, target, kind }) => `(${py(alias)}, ${py(target)}, ${py(kind ?? "unknown")})`).join(", ")}]
WINDOW_CLASS = ${py(plan.windowClass)}
WITHHELD = {${(plan.withheld ?? []).map(({ setting, file }) => `${py(setting)}: ${py(file)}`).join(", ")}}
${renderPythonHelpers(desktopViewPolicy(plan.viewPolicy))}
+1 -1
View File
@@ -184,7 +184,7 @@ describe("withheld files", () => {
"/bin/sh",
[
"-c",
`${command.replace("base64 -w0", "cat")}\nprintf '%s' \"$${item.variable}\"`,
`${command.replace("base64 -w0", "cat")}\nprintf '%s' "$${item.variable}"`,
],
{
env: { ...process.env, HOME: home },
+2
View File
@@ -95,6 +95,8 @@ export const observeGhidraEndpoint = async (
return { path: endpoint.path };
}
if (process.platform === "win32") {
// A limit failure, including one retained with a failed close, is
// classified by observationFailure so cleanup stays on the error.
const encoded = readWindowsPrivateRuntimeFile(
endpoint.path,
MAX_TCP_ENDPOINT_BYTES,
+2 -2
View File
@@ -21,7 +21,7 @@ export const registerGuidanceTools = (
const result = await searchGuidance(input, resources);
return server.delivery.toCallToolResult(
result.ok
? { ok: true, value: jsonValueSchema.parse(result.value) }
? { ok: true, value: jsonValueSchema.parse({ result: result.value }) }
: result,
search,
);
@@ -36,7 +36,7 @@ export const registerGuidanceTools = (
const result = await readGuidanceSection(input, resources);
return server.delivery.toCallToolResult(
result.ok
? { ok: true, value: jsonValueSchema.parse(result.value) }
? { ok: true, value: jsonValueSchema.parse({ result: result.value }) }
: result,
read,
);
+62
View File
@@ -0,0 +1,62 @@
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { resolve } from "node:path";
import { expect, it } from "vitest";
import {
readGuidanceSectionToolOutputSchema,
searchGuidanceToolOutputSchema,
} from "../../../src/contracts/guidanceContracts.js";
it(
"delivers guidance results the validating SDK client accepts over the stdio server",
{ timeout: 60_000 },
async () => {
const client = new Client({ name: "guidance-stdio-test", version: "1" });
const transport = new StdioClientTransport({
command: process.execPath,
args: [resolve("scripts/rea.mjs"), "mcp"],
stderr: "pipe",
});
await client.connect(transport);
try {
// Loading the live definitions makes the SDK validate every result
// against the advertised, SDK-converted output schema.
const advertised = (await client.listTools()).tools.filter(({ name }) =>
["search_guidance", "read_guidance_section"].includes(name),
);
expect(advertised.map(({ outputSchema }) => outputSchema?.type)).toEqual([
"object",
"object",
]);
const found = await client.callTool({
name: "search_guidance",
arguments: { query: "exact JavaScript module", limit: 1 },
});
expect(found.isError).not.toBe(true);
const search = searchGuidanceToolOutputSchema.parse(
found.structuredContent,
).result;
if (search.status !== "matches")
throw new Error("Expected guidance matches");
const locator = search.matches[0]?.locator;
const read = await client.callTool({
name: "read_guidance_section",
arguments: { locator },
});
expect(
readGuidanceSectionToolOutputSchema.parse(read.structuredContent)
.result,
).toMatchObject({ status: "section", locator });
const none = await client.callTool({
name: "search_guidance",
arguments: { query: "qzxv-unlikely-guidance-term" },
});
expect(
searchGuidanceToolOutputSchema.parse(none.structuredContent).result,
).toMatchObject({ status: "no_matches", total_matches: 0 });
} finally {
await client.close();
}
},
);
@@ -2,9 +2,9 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { afterEach, expect, it } from "vitest";
import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js";
import {
readGuidanceSectionOutputSchema,
readGuidanceSectionToolOutputSchema,
searchGuidanceInputSchema,
searchGuidanceOutputSchema,
searchGuidanceToolOutputSchema,
} from "../../../src/contracts/guidanceContracts.js";
import { EvidenceMcpServer } from "../../../src/server/EvidenceMcpServer.js";
import { registerGuidanceTools } from "../../../src/server/registerGuidanceTools.js";
@@ -15,16 +15,11 @@ const open: {
server: EvidenceMcpServer;
resources: ArtifactResourceScope;
}[] = [];
// Every successful guidance result is the advertised object with `result`.
const guidanceOutput = <T>(
schema: { parse(value: unknown): T },
schema: { parse(value: unknown): { readonly result: T } },
value: unknown,
): T => {
// MCP's legacy output-schema projection wraps root-level union schemas.
// Newer transports pass the object unchanged.
if (typeof value === "object" && value !== null && "result" in value)
return schema.parse(value.result);
return schema.parse(value);
};
): T => schema.parse(value).result;
afterEach(async () => {
await Promise.all(
open
@@ -79,7 +74,7 @@ it("advertises strict provider-free contracts and returns packaged guide results
});
expect(response.isError).not.toBe(true);
const parsed = guidanceOutput(
searchGuidanceOutputSchema,
searchGuidanceToolOutputSchema,
response.structuredContent,
);
expect(parsed).toMatchObject({
@@ -97,7 +92,10 @@ it("advertises strict provider-free contracts and returns packaged guide results
});
expect(section.isError).not.toBe(true);
expect(
guidanceOutput(readGuidanceSectionOutputSchema, section.structuredContent),
guidanceOutput(
readGuidanceSectionToolOutputSchema,
section.structuredContent,
),
).toMatchObject({
status: "section",
heading: selected?.heading,
@@ -108,7 +106,7 @@ it("advertises strict provider-free contracts and returns packaged guide results
arguments: { query: "module", limit: 1 },
});
const firstResult = guidanceOutput(
searchGuidanceOutputSchema,
searchGuidanceToolOutputSchema,
firstPage.structuredContent,
);
if (firstResult.status !== "matches" || firstResult.next_cursor === undefined)
@@ -122,6 +120,6 @@ it("advertises strict provider-free contracts and returns packaged guide results
},
});
expect(
guidanceOutput(searchGuidanceOutputSchema, nextPage.structuredContent),
guidanceOutput(searchGuidanceToolOutputSchema, nextPage.structuredContent),
).toMatchObject({ status: "matches", offset: 1 });
});
@@ -45,11 +45,13 @@ it.each(["normal", "copied", "parameters", "forged", "source"] as const)(
() => ({
execute: async () =>
ok({
...createAnalysisExecution(["0x100"], provider, {
...(mode === "source" && source !== undefined
...createAnalysisExecution(
["0x100"],
provider,
mode === "source" && source !== undefined
? { sourceEvidence: [source] }
: {}),
}),
: {},
),
...(mode === "forged" && forged !== undefined
? { evidenceId: forged.evidence_id }
: {}),
@@ -145,7 +145,7 @@ print(json.dumps(_view(Path(sys.argv[2]))))
} else {
if (withTask instanceof Error) throw withTask;
expect(withTask.normalized_result).toMatchObject({
task: { failing_on_seed: ["done"] },
tasks: [{ failing_on_seed: ["done"] }],
});
}
},