refactor(mcp): consolidate retained-summary inspection workflows
This commit is contained in:
1 parent
9a13c4f420
commit
08b8e62914
6 files changed
+161
-185
No files matched your search
@@ -3,17 +3,21 @@ import type {
|
||||
ServerContext,
|
||||
} from "@modelcontextprotocol/server";
|
||||
import type { Logger } from "pino";
|
||||
import type { z } from "zod";
|
||||
|
||||
import type { ExecutionOptions } from "../application/AnalysisProvider.js";
|
||||
import type { AnalysisViewRetention } from "../application/analysisView/AnalysisViewService.js";
|
||||
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
|
||||
import type { ToolContract } from "../contracts/toolContractTypes.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
import { err, type Result } from "../domain/result.js";
|
||||
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
|
||||
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
|
||||
import { logToolExecution } from "./toolLogging.js";
|
||||
import type { WithAdmittedAnalysis } from "./analysisAdmission.js";
|
||||
import { runAdmittedToolOperation } from "./admittedToolOperation.js";
|
||||
import { retainedSummaryDelivery } from "./retainedSummaryDelivery.js";
|
||||
|
||||
/** Application service executing one named contract operation. */
|
||||
export interface ContractToolService<TOperation extends string> {
|
||||
@@ -62,6 +66,54 @@ export const createContractToolHandler =
|
||||
withAdmittedAnalysis,
|
||||
)(contract);
|
||||
|
||||
/** Validate delivery selection before inspection and retain its complete source for summaries. */
|
||||
export const createRetainedSummaryToolHandler = <
|
||||
Name extends string,
|
||||
InputSchema extends z.ZodType<
|
||||
Readonly<Record<string, unknown>> & {
|
||||
readonly detail: "complete" | "summary";
|
||||
}
|
||||
>,
|
||||
>(
|
||||
server: EvidenceMcpServer,
|
||||
contract: ToolContract<Name, InputSchema>,
|
||||
inspect: (
|
||||
input: Omit<z.output<InputSchema>, "detail">,
|
||||
options: ExecutionOptions,
|
||||
) => Promise<Result<Evidence, AnalysisError>>,
|
||||
logger: Logger,
|
||||
retention: AnalysisViewRetention,
|
||||
withAdmittedAnalysis?: WithAdmittedAnalysis,
|
||||
) => {
|
||||
const delivery = retainedSummaryDelivery(new Set([contract.name]), retention);
|
||||
return createEvidenceToolHandler(
|
||||
server,
|
||||
async (rawInput, options) => {
|
||||
const parsed = contract.inputSchema.safeParse(rawInput);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(
|
||||
contract.name,
|
||||
parsed.error.issues,
|
||||
rawInput,
|
||||
{ cause: parsed.error },
|
||||
),
|
||||
);
|
||||
const { detail, ...input } = parsed.data;
|
||||
const parameters = { detail };
|
||||
const ready = delivery.preflight(contract.name, parameters);
|
||||
if (!ready.ok) return ready;
|
||||
const inspected = await inspect(input, options);
|
||||
return inspected.ok
|
||||
? delivery.projectEvidence(contract.name, parameters, inspected.value)
|
||||
: inspected;
|
||||
},
|
||||
logger,
|
||||
retention.recordEvidence,
|
||||
withAdmittedAnalysis,
|
||||
)(contract);
|
||||
};
|
||||
|
||||
/** Bind a named Evidence-producing operation to shared logging and delivery. */
|
||||
export const createEvidenceToolHandler =
|
||||
(
|
||||
|
||||
@@ -3,13 +3,10 @@ import type { DevicetreeService } from "../application/binaryDiagnostics/Devicet
|
||||
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
|
||||
import { toolContract } from "../contracts/toolContracts.js";
|
||||
import type { Logger } from "pino";
|
||||
import { createEvidenceToolHandler } from "./contractToolHandler.js";
|
||||
import { createRetainedSummaryToolHandler } from "./contractToolHandler.js";
|
||||
import type { WithAdmittedAnalysis } from "./analysisAdmission.js";
|
||||
import { applicationToolRegistrationOptions } from "./toolRegistrationOptions.js";
|
||||
import type { EvidenceLookup } from "../application/EvidenceInputResolver.js";
|
||||
import { retainedSummaryDelivery } from "./retainedSummaryDelivery.js";
|
||||
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
|
||||
import { err } from "../domain/result.js";
|
||||
|
||||
/** Register the explicit-file devicetree workflow and retain its complete Evidence. */
|
||||
export const registerDevicetreeTool = (
|
||||
@@ -21,39 +18,17 @@ export const registerDevicetreeTool = (
|
||||
withAdmittedAnalysis?: WithAdmittedAnalysis,
|
||||
): void => {
|
||||
const contract = toolContract("inspect_devicetree");
|
||||
const delivery = retainedSummaryDelivery(new Set([contract.name]), {
|
||||
recordEvidence,
|
||||
evidenceById,
|
||||
});
|
||||
const handler = createEvidenceToolHandler(
|
||||
const handler = createRetainedSummaryToolHandler(
|
||||
server,
|
||||
async (rawInput, options) => {
|
||||
const parsed = contract.inputSchema.safeParse(rawInput);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(
|
||||
contract.name,
|
||||
parsed.error.issues,
|
||||
rawInput,
|
||||
{ cause: parsed.error },
|
||||
),
|
||||
);
|
||||
const { detail, ...input } = parsed.data;
|
||||
const parameters = { detail };
|
||||
const ready = delivery.preflight(contract.name, parameters);
|
||||
if (!ready.ok) return ready;
|
||||
const inspected = await service.inspect(input, options);
|
||||
return inspected.ok
|
||||
? delivery.projectEvidence(contract.name, parameters, inspected.value)
|
||||
: inspected;
|
||||
},
|
||||
contract,
|
||||
(input, options) => service.inspect(input, options),
|
||||
logger,
|
||||
recordEvidence,
|
||||
{ recordEvidence, evidenceById },
|
||||
withAdmittedAnalysis,
|
||||
);
|
||||
server.registerTool(
|
||||
contract.name,
|
||||
applicationToolRegistrationOptions(contract),
|
||||
handler(contract),
|
||||
handler,
|
||||
);
|
||||
};
|
||||
@@ -3,13 +3,10 @@ import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
|
||||
import type { EvmInterfaceService } from "../application/evm/EvmInterfaceService.js";
|
||||
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
|
||||
import type { EvidenceLookup } from "../application/EvidenceInputResolver.js";
|
||||
import { retainedSummaryDelivery } from "./retainedSummaryDelivery.js";
|
||||
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
|
||||
import { err } from "../domain/result.js";
|
||||
import { evmInterfaceModule } from "../composition/evm.js";
|
||||
import type { Logger } from "pino";
|
||||
import type { WithAdmittedAnalysis } from "./analysisAdmission.js";
|
||||
import { createEvidenceToolHandler } from "./contractToolHandler.js";
|
||||
import { createRetainedSummaryToolHandler } from "./contractToolHandler.js";
|
||||
import { applicationToolRegistrationOptions } from "./toolRegistrationOptions.js";
|
||||
|
||||
/** Bind the named EVM contract to shared validation and session Evidence recording. */
|
||||
@@ -22,39 +19,17 @@ export const registerEvmTools = (
|
||||
evidenceById?: EvidenceLookup,
|
||||
): void => {
|
||||
const contract = evmInterfaceModule.contract;
|
||||
const delivery = retainedSummaryDelivery(new Set([contract.name]), {
|
||||
recordEvidence,
|
||||
evidenceById,
|
||||
});
|
||||
const handler = createEvidenceToolHandler(
|
||||
const handler = createRetainedSummaryToolHandler(
|
||||
server,
|
||||
async (rawInput, options) => {
|
||||
const parsed = contract.inputSchema.safeParse(rawInput);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(
|
||||
contract.name,
|
||||
parsed.error.issues,
|
||||
rawInput,
|
||||
{ cause: parsed.error },
|
||||
),
|
||||
);
|
||||
const { detail, ...input } = parsed.data;
|
||||
const parameters = { detail };
|
||||
const ready = delivery.preflight(contract.name, parameters);
|
||||
if (!ready.ok) return ready;
|
||||
const inspected = await service.inspect(input, options);
|
||||
return inspected.ok
|
||||
? delivery.projectEvidence(contract.name, parameters, inspected.value)
|
||||
: inspected;
|
||||
},
|
||||
contract,
|
||||
(input, options) => service.inspect(input, options),
|
||||
logger,
|
||||
recordEvidence,
|
||||
{ recordEvidence, evidenceById },
|
||||
withAdmittedAnalysis,
|
||||
);
|
||||
server.registerTool(
|
||||
contract.name,
|
||||
applicationToolRegistrationOptions(contract),
|
||||
handler(contract),
|
||||
handler,
|
||||
);
|
||||
};
|
||||
@@ -3,13 +3,10 @@ import type { NativeExportsService } from "../application/binaryDiagnostics/Nati
|
||||
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
|
||||
import { toolContract } from "../contracts/toolContracts.js";
|
||||
import type { Logger } from "pino";
|
||||
import { createEvidenceToolHandler } from "./contractToolHandler.js";
|
||||
import { createRetainedSummaryToolHandler } from "./contractToolHandler.js";
|
||||
import type { WithAdmittedAnalysis } from "./analysisAdmission.js";
|
||||
import { applicationToolRegistrationOptions } from "./toolRegistrationOptions.js";
|
||||
import type { EvidenceLookup } from "../application/EvidenceInputResolver.js";
|
||||
import { retainedSummaryDelivery } from "./retainedSummaryDelivery.js";
|
||||
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
|
||||
import { err } from "../domain/result.js";
|
||||
|
||||
/** Register the explicit-file export-table workflow and retain its complete Evidence. */
|
||||
export const registerNativeExportsTool = (
|
||||
@@ -21,39 +18,17 @@ export const registerNativeExportsTool = (
|
||||
withAdmittedAnalysis?: WithAdmittedAnalysis,
|
||||
): void => {
|
||||
const contract = toolContract("inspect_native_exports");
|
||||
const delivery = retainedSummaryDelivery(new Set([contract.name]), {
|
||||
recordEvidence,
|
||||
evidenceById,
|
||||
});
|
||||
const handler = createEvidenceToolHandler(
|
||||
const handler = createRetainedSummaryToolHandler(
|
||||
server,
|
||||
async (rawInput, options) => {
|
||||
const parsed = contract.inputSchema.safeParse(rawInput);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(
|
||||
contract.name,
|
||||
parsed.error.issues,
|
||||
rawInput,
|
||||
{ cause: parsed.error },
|
||||
),
|
||||
);
|
||||
const { detail, ...input } = parsed.data;
|
||||
const parameters = { detail };
|
||||
const ready = delivery.preflight(contract.name, parameters);
|
||||
if (!ready.ok) return ready;
|
||||
const inspected = await service.inspect(input, options);
|
||||
return inspected.ok
|
||||
? delivery.projectEvidence(contract.name, parameters, inspected.value)
|
||||
: inspected;
|
||||
},
|
||||
contract,
|
||||
(input, options) => service.inspect(input, options),
|
||||
logger,
|
||||
recordEvidence,
|
||||
{ recordEvidence, evidenceById },
|
||||
withAdmittedAnalysis,
|
||||
);
|
||||
server.registerTool(
|
||||
contract.name,
|
||||
applicationToolRegistrationOptions(contract),
|
||||
handler(contract),
|
||||
handler,
|
||||
);
|
||||
};
|
||||
@@ -7,12 +7,14 @@ 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 { retainedSummaryDelivery } from "./retainedSummaryDelivery.js";
|
||||
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
|
||||
import { err } from "../domain/result.js";
|
||||
import { wasmArtifactModule } from "../composition/wasm.js";
|
||||
import type { Logger } from "pino";
|
||||
import { createEvidenceToolHandler } from "./contractToolHandler.js";
|
||||
import {
|
||||
createEvidenceToolHandler,
|
||||
createRetainedSummaryToolHandler,
|
||||
} from "./contractToolHandler.js";
|
||||
import type { WithAdmittedAnalysis } from "./analysisAdmission.js";
|
||||
import {
|
||||
applicationToolRegistrationOptions,
|
||||
@@ -61,39 +63,17 @@ export const registerWasmTools = (
|
||||
compareHandler(comparison),
|
||||
);
|
||||
const contract = wasmArtifactModule.contract;
|
||||
const delivery = retainedSummaryDelivery(new Set([contract.name]), {
|
||||
recordEvidence,
|
||||
evidenceById,
|
||||
});
|
||||
const handler = createEvidenceToolHandler(
|
||||
const handler = createRetainedSummaryToolHandler(
|
||||
server,
|
||||
async (rawInput, options) => {
|
||||
const parsed = contract.inputSchema.safeParse(rawInput);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(
|
||||
contract.name,
|
||||
parsed.error.issues,
|
||||
rawInput,
|
||||
{ cause: parsed.error },
|
||||
),
|
||||
);
|
||||
const { detail, ...input } = parsed.data;
|
||||
const parameters = { detail };
|
||||
const ready = delivery.preflight(contract.name, parameters);
|
||||
if (!ready.ok) return ready;
|
||||
const inspected = await service.inspect(input, options);
|
||||
return inspected.ok
|
||||
? delivery.projectEvidence(contract.name, parameters, inspected.value)
|
||||
: inspected;
|
||||
},
|
||||
contract,
|
||||
(input, options) => service.inspect(input, options),
|
||||
logger,
|
||||
recordEvidence,
|
||||
{ recordEvidence, evidenceById },
|
||||
withAdmittedAnalysis,
|
||||
);
|
||||
server.registerTool(
|
||||
contract.name,
|
||||
applicationToolRegistrationOptions(contract),
|
||||
handler(contract),
|
||||
handler,
|
||||
);
|
||||
};
|
||||
@@ -238,72 +238,91 @@ it("advertises exact valid schemas and records inline WASM interface evidence wi
|
||||
expect(close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("records complete WASM Evidence when an MCP request is cancelled during provider work", async () => {
|
||||
const value = wasmArtifactFixture();
|
||||
let releaseProvider: (() => void) | undefined;
|
||||
let providerStarted: (() => void) | undefined;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
providerStarted = resolve;
|
||||
});
|
||||
const providerGate = new Promise<void>((resolve) => {
|
||||
releaseProvider = resolve;
|
||||
});
|
||||
const service = new WasmArtifactService({
|
||||
close: () => Promise.resolve(),
|
||||
inspect: async (_input, options) => {
|
||||
providerStarted?.();
|
||||
await providerGate;
|
||||
expect(options?.signal?.aborted).toBe(true);
|
||||
return ok(wasmArtifactExecution(value));
|
||||
},
|
||||
});
|
||||
const inspect = vi.spyOn(service, "inspect");
|
||||
const session = createTestBinarySession(() => {
|
||||
throw new Error("deep provider must not start");
|
||||
});
|
||||
const harness = await createApplicationMcpHarness({
|
||||
session,
|
||||
serverOptions: { wasmArtifact: service },
|
||||
});
|
||||
onTestFinished(harness.close);
|
||||
const controller = new AbortController();
|
||||
const cancellation = harness.client.callTool(
|
||||
{
|
||||
name: "inspect_wasm_artifact",
|
||||
arguments: { path: value.artifact.path },
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
await started;
|
||||
controller.abort(new Error("cancel after provider work starts"));
|
||||
releaseProvider?.();
|
||||
await expect(cancellation).rejects.toThrow();
|
||||
expect(inspect).toHaveBeenCalledOnce();
|
||||
const result = await inspect.mock.results[0]?.value;
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisCancelledError",
|
||||
partialObservation: {
|
||||
operation: "inspect_wasm_artifact",
|
||||
normalized_result: value,
|
||||
confidence: "observed",
|
||||
it.each([undefined, "summary"] as const)(
|
||||
"records complete WASM Evidence when an MCP request is cancelled during provider work (detail=%s)",
|
||||
async (detail) => {
|
||||
const value = wasmArtifactFixture();
|
||||
let releaseProvider: (() => void) | undefined;
|
||||
let providerStarted: (() => void) | undefined;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
providerStarted = resolve;
|
||||
});
|
||||
const providerGate = new Promise<void>((resolve) => {
|
||||
releaseProvider = resolve;
|
||||
});
|
||||
let serverSignal: AbortSignal | undefined;
|
||||
const service = new WasmArtifactService({
|
||||
close: () => Promise.resolve(),
|
||||
inspect: async (_input, options) => {
|
||||
serverSignal = options?.signal;
|
||||
providerStarted?.();
|
||||
await providerGate;
|
||||
expect(options?.signal?.aborted).toBe(true);
|
||||
return ok(wasmArtifactExecution(value));
|
||||
},
|
||||
},
|
||||
});
|
||||
await harness.client.ping();
|
||||
await vi.waitFor(() => {
|
||||
expect(session.exportEvidenceBundle().records).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
});
|
||||
const inspect = vi.spyOn(service, "inspect");
|
||||
const session = createTestBinarySession(() => {
|
||||
throw new Error("deep provider must not start");
|
||||
});
|
||||
const harness = await createApplicationMcpHarness({
|
||||
session,
|
||||
serverOptions: { wasmArtifact: service },
|
||||
});
|
||||
onTestFinished(harness.close);
|
||||
const controller = new AbortController();
|
||||
const cancellation = harness.client.callTool(
|
||||
{
|
||||
name: "inspect_wasm_artifact",
|
||||
arguments: {
|
||||
path: value.artifact.path,
|
||||
...(detail === undefined ? {} : { detail }),
|
||||
},
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
const rejected = expect(cancellation).rejects.toThrow();
|
||||
await started;
|
||||
controller.abort(new Error("cancel after provider work starts"));
|
||||
let serverReason: unknown;
|
||||
try {
|
||||
await harness.client.ping();
|
||||
serverReason = serverSignal?.reason;
|
||||
} finally {
|
||||
releaseProvider?.();
|
||||
}
|
||||
await rejected;
|
||||
expect(inspect).toHaveBeenCalledOnce();
|
||||
const result = await inspect.mock.results[0]?.value;
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisCancelledError",
|
||||
partialObservation: {
|
||||
operation: "inspect_wasm_artifact",
|
||||
normalized_result: value,
|
||||
confidence: "observed",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
if (result === undefined || result.ok)
|
||||
throw new Error("Expected cancelled WASM Evidence");
|
||||
expect(serverSignal?.aborted).toBe(true);
|
||||
expect(result.error.cause).toBe(serverReason);
|
||||
const partial = parseEvidence(result.error.partialObservation);
|
||||
expect(partial.parameters).toEqual({
|
||||
path: value.artifact.path,
|
||||
glue_paths: [],
|
||||
candidate_paths: [],
|
||||
});
|
||||
await harness.client.ping();
|
||||
await vi.waitFor(() => {
|
||||
// Failed summary delivery retains the complete producer, without a derived view.
|
||||
expect(session.exportEvidenceBundle().records).toEqual([partial]);
|
||||
});
|
||||
expect(session.evidenceById(partial.evidence_id)).toEqual(partial);
|
||||
},
|
||||
);
|
||||
|
||||
it("attributes rejected WASM paths to their selected fields before provider work", async () => {
|
||||
const value = wasmArtifactFixture();
|
||||
|
||||
Reference in new issue
Block a user