diff --git a/src/server/lifecycleProgress.ts b/src/server/operationProgress.ts similarity index 67% rename from src/server/lifecycleProgress.ts rename to src/server/operationProgress.ts index 49df9ddaed..0fa3d56df2 100644 --- a/src/server/lifecycleProgress.ts +++ b/src/server/operationProgress.ts @@ -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 => 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, diff --git a/src/server/registerCloseLifecycleTool.ts b/src/server/registerCloseLifecycleTool.ts index 39d29c8d0a..4a8fd5a2b8 100644 --- a/src/server/registerCloseLifecycleTool.ts +++ b/src/server/registerCloseLifecycleTool.ts @@ -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, diff --git a/src/server/registerEnhancedTools.ts b/src/server/registerEnhancedTools.ts index 6176d47c32..56ab29473d 100644 --- a/src/server/registerEnhancedTools.ts +++ b/src/server/registerEnhancedTools.ts @@ -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( diff --git a/src/server/registerEvidenceTools.ts b/src/server/registerEvidenceTools.ts index cf1f71cf2d..6f927b315c 100644 --- a/src/server/registerEvidenceTools.ts +++ b/src/server/registerEvidenceTools.ts @@ -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); diff --git a/src/server/registerWebScriptTool.ts b/src/server/registerWebScriptTool.ts index 05427e73e5..297e92782e 100644 --- a/src/server/registerWebScriptTool.ts +++ b/src/server/registerWebScriptTool.ts @@ -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, ); }, ), diff --git a/tests/boundary/mcp/webScriptSelectionMcp.test.ts b/tests/boundary/mcp/webScriptSelectionMcp.test.ts index 7b75e9d49e..07df4d0830 100644 --- a/tests/boundary/mcp/webScriptSelectionMcp.test.ts +++ b/tests/boundary/mcp/webScriptSelectionMcp.test.ts @@ -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, + }); +});