refactor(mcp): consolidate Evidence recording and operation progress

This commit is contained in:
morluto
2026-10-23 22:59:14 +08:00
parent 6ba6aea5eb
commit c343b787f0
6 changed files with 102 additions and 59 deletions
@@ -1,14 +1,14 @@
import type { ProgressReporter } from "../application/ProgressReporter.js";
/** Report one lifecycle operation start through the shared monotonic boundary. */
export const reportLifecycleStart = (
/** Report one operation start through the shared monotonic boundary. */
export const reportOperationStart = (
progress: ProgressReporter,
phase: string,
message = "started",
): Promise<void> => progress.report({ phase, completed: 0, total: 1, message });
/** Report one lifecycle operation's terminal disposition. */
export const reportLifecycleEnd = (
/** Report one operation's terminal disposition. */
export const reportOperationEnd = (
progress: ProgressReporter,
phase: string,
succeeded: boolean,
+7 -7
View File
@@ -1,7 +1,7 @@
import {
reportLifecycleEnd,
reportLifecycleStart,
} from "./lifecycleProgress.js";
reportOperationEnd,
reportOperationStart,
} from "./operationProgress.js";
import { err, ok } from "../domain/result.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
@@ -83,9 +83,9 @@ export const registerCloseLifecycleTool = ({
const closing = logToolExecution(logger, closeContract.name, () =>
closeWithResources(() => session.close({ progress })),
);
await reportLifecycleStart(progress, closeContract.name);
await reportOperationStart(progress, closeContract.name);
const closed = await closing;
await reportLifecycleEnd(progress, closeContract.name, closed.ok);
await reportOperationEnd(progress, closeContract.name, closed.ok);
return server.delivery.toCallToolResult(closed, closeContract);
}
const closing = logToolExecution(logger, closeContract.name, () =>
@@ -95,13 +95,13 @@ export const registerCloseLifecycleTool = ({
}),
),
);
await reportLifecycleStart(
await reportOperationStart(
progress,
closeContract.name,
"saving snapshot; closing provider",
);
const closed = await closing;
await reportLifecycleEnd(progress, closeContract.name, closed.ok);
await reportOperationEnd(progress, closeContract.name, closed.ok);
return server.delivery.toCallToolResult(
closed.ok ? ok({ ...closed.value }) : closed,
closeContract,
+7 -20
View File
@@ -31,6 +31,10 @@ import { logToolExecution } from "./toolLogging.js";
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
import { executeFunctionAnalysisEvidence } from "../application/FunctionAnalysisEvidence.js";
import type { WithAdmittedAnalysis } from "./analysisAdmission.js";
import {
reportOperationStart,
reportOperationEnd,
} from "./operationProgress.js";
/** Optional session services used by enhanced tool registration. */
export interface EnhancedToolRegistration {
@@ -222,12 +226,7 @@ const executeEnhancedTool = async (
const { validatedCall, context } = request;
const name = validatedCall.name;
const progress = mcpProgressReporter(context);
await progress.report({
phase: name,
completed: 0,
total: 1,
message: "started",
});
await reportOperationStart(progress, name);
const parsedInput = validatedCall.input;
const parameters = jsonParameters(parsedInput);
if (name === "analyze_function")
@@ -251,13 +250,7 @@ const executeEnhancedTool = async (
),
),
);
await progress.report({
phase: name,
completed: 1,
total: 1,
message: result.ok ? "completed" : "failed",
terminal: true,
});
await reportOperationEnd(progress, name, result.ok);
if (result.ok) {
const evidence = createWorkflowEvidence({
target: registration.activeTarget?.(),
@@ -338,13 +331,7 @@ const executeFunctionTool = async (
},
),
);
await progress.report({
phase: "analyze_function",
completed: 1,
total: 1,
message: result.ok ? "completed" : "failed",
terminal: true,
});
await reportOperationEnd(progress, "analyze_function", result.ok);
if (!result.ok)
return registration.delivery.toCallToolResult(result, contract);
return registration.delivery.toEvidenceToolResult(
+13 -21
View File
@@ -16,6 +16,11 @@ import { mcpProgressReporter } from "./mcpProgress.js";
import { logToolExecution } from "./toolLogging.js";
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
import type { WithAdmittedAnalysis } from "./analysisAdmission.js";
import { recordSessionEvidenceSources } from "./sessionEvidence.js";
import {
reportOperationStart,
reportOperationEnd,
} from "./operationProgress.js";
interface EvidenceToolRegistration {
readonly logger: Logger;
@@ -73,12 +78,7 @@ export const registerEvidenceTools = (
options.analysisForAdmission?.(admittedAnalysis) ??
admittedAnalysis;
const progress = mcpProgressReporter(context);
await progress.report({
phase: contract.name,
completed: 0,
total: 1,
message: "started",
});
await reportOperationStart(progress, contract.name);
const executionParameters =
options.parametersForExecution?.(contract.name, parameters) ??
parameters;
@@ -91,13 +91,7 @@ export const registerEvidenceTools = (
progress,
}),
);
await progress.report({
phase: contract.name,
completed: 1,
total: 1,
message: execution.ok ? "completed" : "failed",
terminal: true,
});
await reportOperationEnd(progress, contract.name, execution.ok);
if (!execution.ok)
return server.delivery.toCallToolResult(execution, contract);
const sourceEvidence =
@@ -129,14 +123,12 @@ export const registerEvidenceTools = (
evidenceLinks: sourceEvidence.map(({ evidence_id: id }) => id),
},
);
for (const source of sourceEvidence) {
const sourceRecorded = options.recordEvidence?.(source);
if (sourceRecorded !== undefined && !sourceRecorded.ok)
return server.delivery.toCallToolResult(
sourceRecorded,
contract,
);
}
const sourceRecorded = recordSessionEvidenceSources(
options.recordEvidence,
sourceEvidence,
);
if (!sourceRecorded.ok)
return server.delivery.toCallToolResult(sourceRecorded, contract);
const projected =
options.projectEvidence?.(contract.name, parameters, evidence) ??
ok(evidence);
+5 -6
View File
@@ -9,6 +9,7 @@ import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
import type { WithAdmittedAnalysis } from "./analysisAdmission.js";
import { runAdmittedToolOperation } from "./admittedToolOperation.js";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { deliverEvidenceResult } from "./contractToolHandler.js";
/** Register local script export without requiring a live browser provider. */
export const registerWebScriptTool = (
@@ -39,13 +40,11 @@ export const registerWebScriptTool = (
signal: context.mcpReq.signal,
}),
);
if (!result.ok)
return server.delivery.toCallToolResult(result, contract);
const recorded = options.recordEvidence?.(result.value);
return server.delivery.toEvidenceToolResult(
result.value,
return deliverEvidenceResult(
server,
result,
contract,
recorded,
options.recordEvidence,
);
},
),
@@ -1,5 +1,5 @@
import { execFile } from "node:child_process";
import { access, mkdir, readdir, writeFile } from "node:fs/promises";
import { access, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
@@ -16,6 +16,10 @@ import { readWithoutFifoWriter } from "../../fixtures/fifoInput.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { scriptScenarioFixture } from "../../fixtures/webScriptCapture.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { createServer } from "../../../src/server/createServer.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { webScriptExportResultSchema } from "../../../src/domain/webScriptExport.js";
it.skipIf(process.platform === "win32")(
"returns a capture_path invalid_request for a named pipe without waiting or publishing output",
@@ -136,3 +140,64 @@ it("refuses an existing output directory as invalid output_directory input", asy
});
expect(await readdir(outputDirectory)).toEqual(["marker.txt"]);
});
it("publishes script bytes and retains the returned Evidence without starting an analysis provider", async () => {
const root = await createTestTempDirectory("rea-script-export-retained-mcp-");
const capturePath = join(root, "capture.json");
const outputDirectory = join(root, "export");
const bytes = Buffer.from("export const callerValue = 'retained';\n");
await writeFile(
capturePath,
JSON.stringify(
scriptScenarioFixture([
{ url: "https://caller.test/app/main.js", bytes },
]),
),
);
const session = createTestBinarySession(() => {
throw new Error("local script export must not start an analysis provider");
});
const server = createServer({ kind: "session", session });
const client = new Client({ name: "script-export-retention", version: "1" });
onTestFinished(async () => {
await client.close();
await server.close();
await session.close();
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
const response = await client.callTool({
name: "export_web_scripts",
arguments: { capture_path: capturePath, output_directory: outputDirectory },
});
expect(response.isError).not.toBe(true);
const evidence = parseEvidence(response.structuredContent);
expect(evidence.operation).toBe("export_web_scripts");
expect(session.evidenceById(evidence.evidence_id)).toEqual(evidence);
expect(session.exportEvidenceBundle().records).toEqual([evidence]);
const exported = webScriptExportResultSchema.parse(
evidence.normalized_result,
);
expect(exported.output_directory).toBe(outputDirectory);
expect(exported.scripts).toHaveLength(1);
const content = exported.scripts[0]?.content;
expect(content?.state).toBe("exported");
if (content?.state !== "exported")
throw new Error("script bytes were not published");
if (exported.analysis_input === null)
throw new Error("published script bytes have no analysis input root");
expect(
await readFile(
join(exported.analysis_input.input_path, content.relative_path),
),
).toEqual(bytes);
expect(
JSON.parse(await readFile(exported.manifest.path, "utf8")),
).toMatchObject({
capture_path: capturePath,
output_directory: outputDirectory,
scripts: exported.scripts,
});
});