diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 011dc9826c..c71c9633e3 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -205,7 +205,8 @@ that complete Evidence in the session and returns the derived `normalized_result.parent_evidence_id` and `evidence_links` name the retained analysis, so later views and application workflows read it without repeating analysis or moving the complete record through the response. A server without -session retention refuses summary detail with `capability_unavailable`. +session retention refuses summary detail with `capability_unavailable` before +any analysis starts. For `inspect_artifact`, omission also keeps the original request parameters and complete output shape. Delivery selection never reaches the inventory engine; the complete inspection and its atomic inventory Evidence remain retained. diff --git a/src/server/registerWasmTools.ts b/src/server/registerWasmTools.ts index a7a056cc74..479de21242 100644 --- a/src/server/registerWasmTools.ts +++ b/src/server/registerWasmTools.ts @@ -7,7 +7,10 @@ import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; import type { WasmArtifactService } from "../application/wasm/WasmArtifactService.js"; import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; import type { EvidenceLookup } from "../application/EvidenceInputResolver.js"; -import { summarizeRetainedAnalysis } from "../application/analysisView/AnalysisViewService.js"; +import { + requireAnalysisViewRetention, + summarizeRetainedAnalysis, +} from "../application/analysisView/AnalysisViewService.js"; import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; import { err } from "../domain/result.js"; import { wasmArtifactModule } from "../composition/wasm.js"; @@ -72,12 +75,14 @@ export const registerWasmTools = ( ), ); const { detail, ...input } = parsed.data; + const retention = { recordEvidence, evidenceById }; + if (detail === "summary") { + const ready = requireAnalysisViewRetention(contract.name, retention); + if (!ready.ok) return ready; + } const inspected = await service.inspect(input, options); return detail === "summary" && inspected.ok - ? summarizeRetainedAnalysis(inspected.value, { - recordEvidence, - evidenceById, - }) + ? summarizeRetainedAnalysis(inspected.value, retention) : inspected; }, logger, diff --git a/tests/boundary/mcp/wasmArtifactMcp.test.ts b/tests/boundary/mcp/wasmArtifactMcp.test.ts index 1be804f61c..aaa46f6c77 100644 --- a/tests/boundary/mcp/wasmArtifactMcp.test.ts +++ b/tests/boundary/mcp/wasmArtifactMcp.test.ts @@ -14,7 +14,8 @@ import { wasmArtifactSummaryViewResultSchema } from "../../../src/domain/analysi import { createHash } from "node:crypto"; import { z } from "zod"; import { createServer } from "../../../src/server/createServer.js"; -import { ok } from "../../../src/domain/result.js"; +import { err, ok } from "../../../src/domain/result.js"; +import { AnalysisInputError } from "../../../src/domain/analysisErrorCore.js"; import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -608,11 +609,14 @@ it("rejects absent or inconsistent WASM source identity before deriving portable await harness.client.ping(); }); -it("requires retention for WASM summary delivery while keeping fixed-server complete delivery", async () => { +it("refuses WASM summary delivery before provider work when retention is unavailable and keeps fixed-server complete delivery", async () => { const value = wasmArtifactFixture(); + const inspect = vi.fn(() => + Promise.resolve(ok(wasmArtifactExecution(value))), + ); const service = new WasmArtifactService({ close: () => Promise.resolve(), - inspect: () => Promise.resolve(ok(wasmArtifactExecution(value))), + inspect, }); const session = createTestBinarySession(() => { throw new Error("No deep provider"); @@ -626,9 +630,10 @@ it("requires retention for WASM summary delivery while keeping fixed-server comp try { await server.connect(b); await client.connect(a); + const summaryInput = { path: value.artifact.path, detail: "summary" }; const summary = await client.callTool({ name: "inspect_wasm_artifact", - arguments: { path: value.artifact.path, detail: "summary" }, + arguments: summaryInput, }); expect(parseMcpToolError(summary)).toMatchObject({ error: { @@ -636,14 +641,35 @@ it("requires retention for WASM summary delivery while keeping fixed-server comp message: expect.stringContaining("does not retain Evidence"), }, }); - const complete = await client.callTool({ - name: "inspect_wasm_artifact", - arguments: { path: value.artifact.path }, - }); - expect(complete.isError).not.toBe(true); - expect(parseEvidence(complete.structuredContent).normalized_result).toEqual( - value, + expect(inspect).not.toHaveBeenCalled(); + for (const input of [ + { path: value.artifact.path }, + { path: value.artifact.path, detail: "complete" }, + ]) { + const complete = await client.callTool({ + name: "inspect_wasm_artifact", + arguments: input, + }); + expect(complete.isError).not.toBe(true); + expect( + parseEvidence(complete.structuredContent).normalized_result, + ).toEqual(value); + } + expect(inspect).toHaveBeenCalledTimes(2); + inspect.mockImplementation(() => + Promise.resolve(err(new AnalysisInputError("inspect_wasm_artifact"))), ); + const refused = await client.callTool({ + name: "inspect_wasm_artifact", + arguments: summaryInput, + }); + expect(parseMcpToolError(refused)).toMatchObject({ + error: { + code: "capability_unavailable", + message: expect.stringContaining("Select detail complete"), + }, + }); + expect(inspect).toHaveBeenCalledTimes(2); await client.ping(); } finally { await client.close();