mirror of
https://github.com/morluto/rea.git
synced 2026-11-10 17:30:32 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2c99444c6 | ||
|
|
21971e3896 | ||
|
|
80b11e6960 | ||
|
|
dd28b19f8a | ||
|
|
14bda0f0cf | ||
|
|
27549e256f | ||
|
|
4649fcba7c | ||
|
|
09980ead00 | ||
|
|
a7655ef29e | ||
|
|
531f2bbfb0 |
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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([]);
|
||||
},
|
||||
);
|
||||
@@ -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))}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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"] }],
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user