diff --git a/docs/error-contract.schema.json b/docs/error-contract.schema.json index 1403c21316..39a04a5baa 100644 --- a/docs/error-contract.schema.json +++ b/docs/error-contract.schema.json @@ -28,6 +28,33 @@ "required": ["action"], "additionalProperties": false }, + "recovery": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "inspect_input_contract" }, + "operation": { "type": "string", "minLength": 1 } + }, + "required": ["kind", "operation"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "inspect_timeout_state" }, + "operation": { "type": "string", "minLength": 1 }, + "timeout_ms": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["kind", "operation", "timeout_ms"], + "additionalProperties": false + } + ] + }, "details": { "type": "object", "propertyNames": { "type": "string" }, @@ -64,6 +91,33 @@ "required": ["action"], "additionalProperties": false }, + "recovery": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "inspect_input_contract" }, + "operation": { "type": "string", "minLength": 1 } + }, + "required": ["kind", "operation"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "inspect_timeout_state" }, + "operation": { "type": "string", "minLength": 1 }, + "timeout_ms": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["kind", "operation", "timeout_ms"], + "additionalProperties": false + } + ] + }, "details": { "type": "object", "propertyNames": { "type": "string" }, @@ -100,6 +154,33 @@ "required": ["action"], "additionalProperties": false }, + "recovery": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "inspect_input_contract" }, + "operation": { "type": "string", "minLength": 1 } + }, + "required": ["kind", "operation"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "inspect_timeout_state" }, + "operation": { "type": "string", "minLength": 1 }, + "timeout_ms": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["kind", "operation", "timeout_ms"], + "additionalProperties": false + } + ] + }, "details": { "type": "object", "propertyNames": { "type": "string" }, @@ -136,6 +217,33 @@ "required": ["action"], "additionalProperties": false }, + "recovery": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "inspect_input_contract" }, + "operation": { "type": "string", "minLength": 1 } + }, + "required": ["kind", "operation"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "inspect_timeout_state" }, + "operation": { "type": "string", "minLength": 1 }, + "timeout_ms": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["kind", "operation", "timeout_ms"], + "additionalProperties": false + } + ] + }, "details": { "type": "object", "propertyNames": { "type": "string" }, @@ -172,6 +280,33 @@ "required": ["action"], "additionalProperties": false }, + "recovery": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "inspect_input_contract" }, + "operation": { "type": "string", "minLength": 1 } + }, + "required": ["kind", "operation"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "inspect_timeout_state" }, + "operation": { "type": "string", "minLength": 1 }, + "timeout_ms": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["kind", "operation", "timeout_ms"], + "additionalProperties": false + } + ] + }, "details": { "type": "object", "propertyNames": { "type": "string" }, @@ -208,6 +343,33 @@ "required": ["action"], "additionalProperties": false }, + "recovery": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "inspect_input_contract" }, + "operation": { "type": "string", "minLength": 1 } + }, + "required": ["kind", "operation"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "inspect_timeout_state" }, + "operation": { "type": "string", "minLength": 1 }, + "timeout_ms": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["kind", "operation", "timeout_ms"], + "additionalProperties": false + } + ] + }, "details": { "type": "object", "propertyNames": { "type": "string" }, @@ -244,6 +406,33 @@ "required": ["action"], "additionalProperties": false }, + "recovery": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "inspect_input_contract" }, + "operation": { "type": "string", "minLength": 1 } + }, + "required": ["kind", "operation"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "inspect_timeout_state" }, + "operation": { "type": "string", "minLength": 1 }, + "timeout_ms": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["kind", "operation", "timeout_ms"], + "additionalProperties": false + } + ] + }, "details": { "type": "object", "propertyNames": { "type": "string" }, @@ -280,6 +469,33 @@ "required": ["action"], "additionalProperties": false }, + "recovery": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "inspect_input_contract" }, + "operation": { "type": "string", "minLength": 1 } + }, + "required": ["kind", "operation"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "inspect_timeout_state" }, + "operation": { "type": "string", "minLength": 1 }, + "timeout_ms": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["kind", "operation", "timeout_ms"], + "additionalProperties": false + } + ] + }, "details": { "type": "object", "propertyNames": { "type": "string" }, @@ -316,6 +532,33 @@ "required": ["action"], "additionalProperties": false }, + "recovery": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "inspect_input_contract" }, + "operation": { "type": "string", "minLength": 1 } + }, + "required": ["kind", "operation"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "inspect_timeout_state" }, + "operation": { "type": "string", "minLength": 1 }, + "timeout_ms": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["kind", "operation", "timeout_ms"], + "additionalProperties": false + } + ] + }, "details": { "type": "object", "propertyNames": { "type": "string" }, @@ -352,6 +595,33 @@ "required": ["action"], "additionalProperties": false }, + "recovery": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "inspect_input_contract" }, + "operation": { "type": "string", "minLength": 1 } + }, + "required": ["kind", "operation"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "inspect_timeout_state" }, + "operation": { "type": "string", "minLength": 1 }, + "timeout_ms": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["kind", "operation", "timeout_ms"], + "additionalProperties": false + } + ] + }, "details": { "type": "object", "propertyNames": { "type": "string" }, @@ -388,6 +658,33 @@ "required": ["action"], "additionalProperties": false }, + "recovery": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "inspect_input_contract" }, + "operation": { "type": "string", "minLength": 1 } + }, + "required": ["kind", "operation"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "inspect_timeout_state" }, + "operation": { "type": "string", "minLength": 1 }, + "timeout_ms": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["kind", "operation", "timeout_ms"], + "additionalProperties": false + } + ] + }, "details": { "anyOf": [ { @@ -476,6 +773,33 @@ "required": ["action"], "additionalProperties": false }, + "recovery": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "inspect_input_contract" }, + "operation": { "type": "string", "minLength": 1 } + }, + "required": ["kind", "operation"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "inspect_timeout_state" }, + "operation": { "type": "string", "minLength": 1 }, + "timeout_ms": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["kind", "operation", "timeout_ms"], + "additionalProperties": false + } + ] + }, "details": { "type": "object", "propertyNames": { "type": "string" }, @@ -512,6 +836,33 @@ "required": ["action"], "additionalProperties": false }, + "recovery": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "inspect_input_contract" }, + "operation": { "type": "string", "minLength": 1 } + }, + "required": ["kind", "operation"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "inspect_timeout_state" }, + "operation": { "type": "string", "minLength": 1 }, + "timeout_ms": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["kind", "operation", "timeout_ms"], + "additionalProperties": false + } + ] + }, "details": { "type": "object", "propertyNames": { "type": "string" }, @@ -548,6 +899,33 @@ "required": ["action"], "additionalProperties": false }, + "recovery": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "inspect_input_contract" }, + "operation": { "type": "string", "minLength": 1 } + }, + "required": ["kind", "operation"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "inspect_timeout_state" }, + "operation": { "type": "string", "minLength": 1 }, + "timeout_ms": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["kind", "operation", "timeout_ms"], + "additionalProperties": false + } + ] + }, "details": { "type": "object", "propertyNames": { "type": "string" }, @@ -584,6 +962,33 @@ "required": ["action"], "additionalProperties": false }, + "recovery": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "inspect_input_contract" }, + "operation": { "type": "string", "minLength": 1 } + }, + "required": ["kind", "operation"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "inspect_timeout_state" }, + "operation": { "type": "string", "minLength": 1 }, + "timeout_ms": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["kind", "operation", "timeout_ms"], + "additionalProperties": false + } + ] + }, "details": { "type": "object", "propertyNames": { "type": "string" }, @@ -620,6 +1025,33 @@ "required": ["action"], "additionalProperties": false }, + "recovery": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "inspect_input_contract" }, + "operation": { "type": "string", "minLength": 1 } + }, + "required": ["kind", "operation"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "inspect_timeout_state" }, + "operation": { "type": "string", "minLength": 1 }, + "timeout_ms": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["kind", "operation", "timeout_ms"], + "additionalProperties": false + } + ] + }, "details": { "type": "object", "propertyNames": { "type": "string" }, @@ -656,6 +1088,33 @@ "required": ["action"], "additionalProperties": false }, + "recovery": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "inspect_input_contract" }, + "operation": { "type": "string", "minLength": 1 } + }, + "required": ["kind", "operation"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "inspect_timeout_state" }, + "operation": { "type": "string", "minLength": 1 }, + "timeout_ms": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["kind", "operation", "timeout_ms"], + "additionalProperties": false + } + ] + }, "details": { "type": "object", "propertyNames": { "type": "string" }, @@ -692,6 +1151,33 @@ "required": ["action"], "additionalProperties": false }, + "recovery": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "inspect_input_contract" }, + "operation": { "type": "string", "minLength": 1 } + }, + "required": ["kind", "operation"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "inspect_timeout_state" }, + "operation": { "type": "string", "minLength": 1 }, + "timeout_ms": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["kind", "operation", "timeout_ms"], + "additionalProperties": false + } + ] + }, "details": { "type": "object", "propertyNames": { "type": "string" }, @@ -728,6 +1214,33 @@ "required": ["action"], "additionalProperties": false }, + "recovery": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "inspect_input_contract" }, + "operation": { "type": "string", "minLength": 1 } + }, + "required": ["kind", "operation"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "inspect_timeout_state" }, + "operation": { "type": "string", "minLength": 1 }, + "timeout_ms": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["kind", "operation", "timeout_ms"], + "additionalProperties": false + } + ] + }, "details": { "type": "object", "propertyNames": { "type": "string" }, @@ -764,6 +1277,33 @@ "required": ["action"], "additionalProperties": false }, + "recovery": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "inspect_input_contract" }, + "operation": { "type": "string", "minLength": 1 } + }, + "required": ["kind", "operation"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "inspect_timeout_state" }, + "operation": { "type": "string", "minLength": 1 }, + "timeout_ms": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["kind", "operation", "timeout_ms"], + "additionalProperties": false + } + ] + }, "details": { "type": "object", "propertyNames": { "type": "string" }, diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 8b36a61423..b442a3c02b 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -90,13 +90,30 @@ metadata in the supported JSON payloads (`parameters`, `raw_result`, Tool execution failures return `isError: true` and the complete canonical `{ "error": ... }` diagnostic as JSON in text `content`. Read that diagnostic -for the error code, target details, partial observations, and remediation. +for the error code, target details, partial observations, remediation, and the +optional bounded `recovery` next step. `recovery.kind=inspect_input_contract` +names the rejected operation: re-read that operation's advertised input schema +(call `tools/list` if the catalog may be stale), correct the listed fields, and +do not repeat the unchanged call. `recovery.kind=inspect_timeout_state` names +the operation and the REA server/provider deadline; inspect session/provider +state before retrying, and raise the MCP client's tool deadline only when the +client ended the call before REA returned this response. These hints never +include the complete catalog or caller arguments. Failures omit `structuredContent`, since each advertised `outputSchema` describes successful results. Successful replies retain their schema-validated structured data and matching text projection. Oversized errors are retained as Evidence before delivery of a bounded diagnostic with an export reference; if retention is unavailable or fails, the diagnostic reports that reason. +SDK-generated input validation failures are normalized into the same canonical +`invalid_request` text when the bounded diagnostic fits the response budget. +The complete field/type diagnostic is preserved when normalization fits the +response budget, including request-specific rejected fields and reasons. Oversized +SDK diagnostics keep the existing retained-Evidence recovery path. An unknown +tool remains an MCP JSON-RPC `-32602` error, but its message directs the caller +to `tools/list` and an advertised operation instead of repeating the unknown +name. + `get_call_graph` returns a `graph` keyed by traversal depth and a top-level `limitations` array with deduplicated execution caveats. Each node keeps its relationship coverage and read-specific `limitations`, alongside observed diff --git a/docs/user-visible-errors.csv b/docs/user-visible-errors.csv index 3e0ccff0ee..8b56991794 100644 --- a/docs/user-visible-errors.csv +++ b/docs/user-visible-errors.csv @@ -17,7 +17,7 @@ harm_rank,location,trigger,current_copy,user_risk,proposed_replacement,implement 13,src/domain/errors.ts:EvidenceIntegrityError,Evidence validation fails,Raw constructor message,May expose validation internals and lacks recovery,"Evidence is invalid or has changed. Recreate or re-import it, then try again.",implemented,focused assertions passed; full `npm run check` passed 14,src/domain/errors.ts:UnknownRegistryError,Evidence-state mutation fails,"Residual unknown registry mutation failed: {reason}",Exposes internal subsystem and state token,"Missing IDs identify the session and retry; concurrent revision conflicts refresh current state.",implemented,focused assertions passed; full `npm run check` passed 15,src/domain/errors.ts:ProcessCaptureError,Process capture fails,Raw constructor message,May expose process and cleanup internals,"Process capture could not complete. Check command access and limits, then try again.",implemented,focused assertions passed; full `npm run check` passed -16,src/domain/errors.ts:AnalysisTimeoutError,Analysis deadline expires,"Analysis operation timed out after {timeoutMs}ms: {operation}",Exposes internal deadline and operation; weak recovery,"Analysis took too long. Try a smaller request, then run `rea doctor` if it continues.",implemented,focused assertions passed; full `npm run check` passed +16,src/domain/errors.ts:AnalysisTimeoutError,Analysis deadline expires,"REA's {operation} operation reached its {timeoutMs}ms server/provider deadline; the MCP client may have ended the call earlier",Explains the two deadline owners and avoids blind retry,"Inspect session/provider state first. If the MCP client ended the call, raise its tool deadline above {timeoutMs}ms; otherwise retry with a smaller request and do not repeat an unchanged call.",implemented,verified by projection tests and real MCP/Codex CLI timeout-recovery probes 17,src/domain/errors.ts:HopperTimeoutError,Provider deadline expires,"Hopper did not respond within {timeoutMs}ms",Exposes provider and internal deadline,"Analysis took too long. Try a smaller request, then run `rea doctor` if it continues.",implemented,focused assertions passed; full `npm run check` passed 18,src/domain/errors.ts:ProviderSelectionError,Deep-provider selection fails,"Analysis provider selection failed ({reason}) for {requestedProviderId}; candidates: {candidateIds}",Retains non-secret local selection context that needs a safe structured projection,Reason-specific guidance with stable candidate IDs and actionable rejection diagnostics,implemented,focused registry/CLI/MCP assertions passed; full `npm run check` passed 20,src/domain/errors.ts:AnalysisInputError,Parsed analysis input invalid,"Invalid analysis input for {operation}",Exposes operation identifier and lacks correction,"Analysis input is invalid. Check the arguments and try again.",implemented,focused assertions passed; full `npm run check` passed @@ -114,5 +114,5 @@ Then rerun this installer.",implemented,verified by shell branch inspection 88,install.sh:binary absent,npm exits successfully without executable,the rea command was not installed at local path,Leaks local path and no recovery,"npm completed without installing the rea command. Check the npm global bin directory and PATH, then retry.",implemented,verified by exact absent-installed-command test 89,install.sh:version read,Installed rea executable cannot report its version,could not read the installed REA version.,No recovery instruction,"The installed REA version could not be read. Reinstall the requested version, then retry.",implemented,verified by exact unreadable-installed-version test 90,install.sh:version mismatch,Installed rea reports a different version,installed version ACTUAL does not match EXPECTED.,Low risk; gives exact integrity mismatch,Keep current copy,unchanged,verified by exact installer test -90.5,@modelcontextprotocol/server:tool input validation,Caller sends arguments that violate a declared tool schema,"Input validation error: Invalid arguments for tool TOOL: FIELD: Invalid input: expected TYPE, received TYPE",Low risk; public tool and field names plus exact type correction,Keep SDK-generated copy,unchanged,verified by real in-memory MCP client/server call with wrong argument type +90.5,@modelcontextprotocol/server:tool input validation,Caller sends arguments that violate a declared tool schema,"Canonical invalid_request with the SDK diagnostic plus recovery.kind=inspect_input_contract and a bounded instruction to read the advertised schema or call tools/list",Keeps field/type correction while telling the agent how to recover; diagnostics over 4 KiB stay on the existing retained-error path,Normalize ordinary validation failures and retain oversized diagnostics,verified by real in-memory and stdio MCP client/server calls with wrong argument types 90.6,incur:unknown CLI command,Caller enters an unknown REA command,"'COMMAND' is not a command for 'rea'. Suggested command: rea --help",Low risk; identifies input and exact discovery action,Keep framework-generated copy,unchanged,verified by real packaged dispatcher invocation diff --git a/src/contracts/errorSchemas.ts b/src/contracts/errorSchemas.ts index 371f24e8b0..05768e1931 100644 --- a/src/contracts/errorSchemas.ts +++ b/src/contracts/errorSchemas.ts @@ -1,6 +1,8 @@ import { z } from "zod"; +import { analysisErrorRecoverySchema } from "../domain/analysisErrorRecovery.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; +import type { AnalysisErrorProjection } from "../domain/analysisErrorProjection.js"; const categorySchema = z.enum([ "invalid_input", @@ -24,6 +26,7 @@ const common = { message: z.string().min(1), retryable: z.boolean(), remediation: remediationSchema, + recovery: analysisErrorRecoverySchema.optional(), }; const genericDetails = z.record(z.string(), jsonValueSchema).optional(); const generic = (code: Code) => @@ -76,7 +79,7 @@ export const analysisErrorProjectionSchema = z.discriminatedUnion("code", [ generic("configuration_invalid"), generic("target_unavailable"), generic("execution_failure"), -]); +]) as unknown as z.ZodType; /** CLI label and input diagnostics surrounding the strict canonical error projection. */ export const analysisCliErrorEnvelopeSchema = z diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts index f8bf0106fe..57cc940ea7 100644 --- a/src/domain/analysisErrorPresentation.ts +++ b/src/domain/analysisErrorPresentation.ts @@ -82,6 +82,7 @@ export const analysisErrorRemediationAction = ( error.remediationAction !== undefined ) return error.remediationAction; + if (error instanceof AnalysisTimeoutError) return timeoutRemediation(error); if (error instanceof HopperTimeoutError) return error.operation === undefined ? "Review details.launcher and inspect Hopper for ongoing initial analysis or a pending loader, demo, or license dialog. For a large target, increase REA_HOPPER_STARTUP_TIMEOUT_MS in the environment that starts REA and allow at least that long in the MCP client. Resolve retained cleanup before reopening." @@ -130,7 +131,7 @@ export const analysisErrorRemediationAction = ( if (error instanceof ConfigurationError && error.settings.length > 0) return `Correct ${[...new Set(error.settings.map(({ setting }) => setting))].join(", ")} in the environment that starts REA (your shell or the MCP client's registration), then rerun.`; if (error instanceof AnalysisInputError) - return "Correct the listed arguments and retry."; + return `Read the declared input schema or CLI usage for ${error.operation}; if using MCP and the catalog may be stale, call tools/list. Correct the listed fields, then retry. Do not repeat the unchanged call.`; if ( error instanceof AnalysisOutputError && error.remediationAction !== undefined @@ -254,6 +255,8 @@ export const analysisErrorUserMessage = (error: AnalysisError): string => { error.userMessage ?? "Analysis input is invalid. Check the arguments and try again." ); + if (error instanceof AnalysisTimeoutError) + return error.userMessage ?? timeoutUserMessage(error); const hopperMessage = hopperErrorUserMessage(error); if (hopperMessage !== undefined) return hopperMessage; if (error.userMessage !== undefined) return error.userMessage; @@ -383,6 +386,12 @@ const standardErrorMessage = (tag: AnalysisErrorTag): string | undefined => { return undefined; }; +const timeoutUserMessage = (error: AnalysisTimeoutError): string => + `REA's ${error.operation} operation reached its ${String(error.timeoutMs)}ms server/provider deadline. If the MCP client timed out before this response, increase its tool deadline above ${String(error.timeoutMs)}ms; otherwise retry with a smaller request after checking session/provider state. Do not repeat an unchanged call while work or cleanup may still be active.`; + +const timeoutRemediation = (error: AnalysisTimeoutError): string => + `Inspect session/provider state for ${error.operation} before retrying. If the MCP client ended the call before REA returned this error, raise its tool deadline above ${String(error.timeoutMs)}ms; otherwise retry with a smaller request. Do not repeat an unchanged call while work or cleanup may still be active.`; + const UNREADABLE_OUTPUT_TAGS: ReadonlySet = new Set([ "AnalysisProtocolError", "AnalysisOutputError", diff --git a/src/domain/analysisErrorProjection.test.ts b/src/domain/analysisErrorProjection.test.ts index cc8fac59b9..1db3180099 100644 --- a/src/domain/analysisErrorProjection.test.ts +++ b/src/domain/analysisErrorProjection.test.ts @@ -25,6 +25,52 @@ import { ProviderSelectionError } from "./providerSelectionError.js"; import { analysisErrorProjectionSchema } from "../contracts/errorSchemas.js"; import { nativeCallPartialObservationSchema } from "./native/nativeCallPartialObservation.js"; +describe("agent recovery metadata", () => { + it("names the input contract for malformed analysis arguments", () => { + const projected = projectAnalysisError( + new AnalysisInputError("inspect_analysis_view", undefined, [ + { + path: ["view", "kind"], + reason: "invalid_value", + expected: ["summary", "module"], + }, + ]), + ); + + expect(projected).toMatchObject({ + recovery: { + kind: "inspect_input_contract", + operation: "inspect_analysis_view", + }, + remediation: { + action: + "Read the declared input schema or CLI usage for inspect_analysis_view; if using MCP and the catalog may be stale, call tools/list. Correct the listed fields, then retry. Do not repeat the unchanged call.", + }, + }); + expect(analysisErrorProjectionSchema.parse(projected)).toEqual(projected); + }); + + it("separates timeout-state inspection from client deadline recovery", () => { + const projected = projectAnalysisError( + new AnalysisTimeoutError("inspect_binary_layout", 2_500), + ); + + expect(projected).toMatchObject({ + code: "provider_timeout", + recovery: { + kind: "inspect_timeout_state", + operation: "inspect_binary_layout", + timeout_ms: 2_500, + }, + message: expect.stringContaining("server/provider deadline"), + remediation: { + action: expect.stringContaining("MCP client"), + }, + }); + expect(analysisErrorProjectionSchema.parse(projected)).toEqual(projected); + }); +}); + describe("capability failure retry disposition", () => { it.each([ { options: {}, retryable: false, category: "unsupported_provider" }, diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts index 4060a560df..e523a9ff65 100644 --- a/src/domain/analysisErrorProjection.ts +++ b/src/domain/analysisErrorProjection.ts @@ -1,4 +1,5 @@ import { projectArtifactIntegrityDetails } from "./artifactIntegrity.js"; +import type { AnalysisErrorRecovery } from "./analysisErrorRecovery.js"; import { jsonValueSchema, type JsonValue } from "./jsonValue.js"; import { isImmutableJsonSnapshot } from "./immutableJson.js"; @@ -102,6 +103,7 @@ export const projectAnalysisError = ( : jsonValueSchema.parse(partialObservation), }), }; + const recovery = errorRecovery(error); return { code, category: analysisErrorCategory(error), @@ -116,10 +118,28 @@ export const projectAnalysisError = ( remediation: { action: analysisErrorRemediationAction(error), }, + ...(recovery === undefined ? {} : { recovery }), ...(Object.keys(details).length === 0 ? {} : { details }), }; }; +const errorRecovery = ( + error: AnalysisError, +): AnalysisErrorRecovery | undefined => { + if (error instanceof AnalysisInputError) + return { + kind: "inspect_input_contract", + operation: error.operation, + }; + if (error instanceof AnalysisTimeoutError) + return { + kind: "inspect_timeout_state", + operation: error.operation, + timeout_ms: error.timeoutMs, + }; + return undefined; +}; + const underlyingErrorCode = ( error: AnalysisError, ): AnalysisErrorProjection["code"] => { @@ -619,5 +639,6 @@ export interface AnalysisErrorProjection extends Readonly< readonly remediation: Readonly<{ action: string; }>; + readonly recovery?: AnalysisErrorRecovery; readonly details?: Readonly>; } diff --git a/src/domain/analysisErrorRecovery.ts b/src/domain/analysisErrorRecovery.ts new file mode 100644 index 0000000000..e6bdb6f7e0 --- /dev/null +++ b/src/domain/analysisErrorRecovery.ts @@ -0,0 +1,36 @@ +import { z } from "zod"; + +/** + * Small, bounded next-step metadata for agent-facing analysis failures. + * + * The prose recovery remains in `remediation.action`; these fields give a + * caller a stable way to choose the next inspection without embedding a tool + * catalog or caller arguments in every error. + */ +export type AnalysisErrorRecovery = + | { + readonly kind: "inspect_input_contract"; + readonly operation: string; + } + | { + readonly kind: "inspect_timeout_state"; + readonly operation: string; + readonly timeout_ms: number; + }; + +export const analysisErrorRecoverySchema: z.ZodType = + z.discriminatedUnion("kind", [ + z + .object({ + kind: z.literal("inspect_input_contract"), + operation: z.string().min(1), + }) + .strict(), + z + .object({ + kind: z.literal("inspect_timeout_state"), + operation: z.string().min(1), + timeout_ms: z.number().int().positive(), + }) + .strict(), + ]); diff --git a/src/server/EvidenceMcpServer.test.ts b/src/server/EvidenceMcpServer.test.ts index ea81b53eae..e7c399a10f 100644 --- a/src/server/EvidenceMcpServer.test.ts +++ b/src/server/EvidenceMcpServer.test.ts @@ -31,6 +31,67 @@ const firstText = (result: { return undefined; }; +const expectFittingSdkValidationRecovery = (result: { + readonly isError?: boolean | undefined; + readonly content: readonly { + readonly type: string; + readonly text?: string; + }[]; +}): void => { + expect(result.isError).toBe(true); + expect(JSON.parse(firstText(result) ?? "null")).toMatchObject({ + error: { + code: "invalid_request", + recovery: { + kind: "inspect_input_contract", + operation: "procedure_pseudo_code", + }, + details: { + operation: "procedure_pseudo_code", + issues: [ + { + path: [], + reason: "invalid_value", + message: expect.stringContaining("required"), + }, + ], + }, + }, + }); +}; + +const expectEscapedSdkDiagnosticRetention = async ( + client: Client, + records: InvestigationRecords, + previousEvidenceId: string, +): Promise => { + const quotedKey = '"\\'.repeat(350); + const quoted = await client.callTool({ + name: "procedure_pseudo_code", + arguments: { required: "present", [quotedKey]: true }, + }); + expect(quoted.isError).toBe(true); + expect(JSON.parse(firstText(quoted) ?? "null")).toMatchObject({ + error: { + code: "resource_constraint", + details: { + reported_limits: { + evidence_reference: { kind: "retained-evidence" }, + }, + }, + }, + }); + expect(Buffer.byteLength(JSON.stringify(quoted))).toBeLessThan(4096); + const bundle = records.exportEvidenceBundle(); + expect(bundle.records).toHaveLength(2); + const quotedOriginal = bundle.records.find( + (record) => record.evidence_id !== previousEvidenceId, + ); + expect(quotedOriginal?.raw_result).toMatchObject({ + content: [{ text: expect.stringContaining(quotedKey) }], + }); +}; + describe("MCP request bookkeeping", () => { it("releases list, tool, and completion state when cancellation arrives", () => { const completion = new AbortController(); @@ -55,7 +116,7 @@ describe("MCP request bookkeeping", () => { describe("Modern protocol error delivery", () => { it("preserves the modern wire envelope when replacing an oversized error", async () => { const records = new InvestigationRecords(); - const delivery = new ToolResultDelivery(4096); + const delivery = new ToolResultDelivery(16384); const info = { name: "wire-error-recovery", version: "1" }; const handler = createMcpHandler(() => { const server = new EvidenceMcpServer( @@ -92,16 +153,28 @@ describe("Modern protocol error delivery", () => { }); try { await client.connect(transport); + const invalidField = `unexpected_${"x".repeat(5000)}_tail`; const fitting = await client.callTool({ name: "procedure_pseudo_code", - arguments: { unexpected: true }, + arguments: { [invalidField]: true }, }); expect(fitting.isError).toBe(true); - expect(firstText(fitting)).toContain("Input validation error"); + expect(JSON.parse(firstText(fitting) ?? "null")).toMatchObject({ + error: { + code: "invalid_request", + recovery: { + kind: "inspect_input_contract", + operation: "procedure_pseudo_code", + }, + details: { + issues: [{ message: expect.stringContaining(invalidField) }], + }, + }, + }); const recovered = await client.callTool({ name: "procedure_pseudo_code", - arguments: { selected: "value", ["x".repeat(10000)]: true }, + arguments: { selected: "value", ["x".repeat(20000)]: true }, }); expect(recovered.isError).toBe(true); expect(recovered.structuredContent).toBeUndefined(); @@ -137,7 +210,7 @@ describe("Modern protocol error delivery", () => { }); } expect(Buffer.byteLength(JSON.stringify(toolResults[1]))).toBeLessThan( - 4096, + 16384, ); expect(toolResults[1]?._meta).toMatchObject({ provenance: { untrusted_content: true, source: "analysis_target" }, @@ -203,8 +276,13 @@ describe("SDK validation error delivery", () => { name: "procedure_pseudo_code", arguments: { unexpected: true }, }); - expect(fitting.isError).toBe(true); - expect(firstText(fitting)).toContain("Input validation error"); + expectFittingSdkValidationRecovery(fitting); + + await expect( + client.callTool({ name: "missing_tool", arguments: {} }), + ).rejects.toThrow( + "Call tools/list, select an advertised tool, and retry", + ); const oversized = await client.callTool({ name: "procedure_pseudo_code", @@ -260,15 +338,11 @@ describe("SDK validation error delivery", () => { evidenceReference.evidence_id, ); - const quotedKey = '"\\'.repeat(350); - const quoted = await client.callTool({ - name: "procedure_pseudo_code", - arguments: { required: "present", [quotedKey]: true }, - }); - expect(quoted.isError).toBe(true); - expect(firstText(quoted)).toContain("Input validation error"); - expect(Buffer.byteLength(JSON.stringify(quoted))).toBeLessThan(4096); - expect(records.exportEvidenceBundle().records).toHaveLength(1); + await expectEscapedSdkDiagnosticRetention( + client, + records, + evidenceReference.evidence_id, + ); const recovered = await client.callTool({ name: "procedure_pseudo_code", diff --git a/src/server/EvidenceMcpServer.ts b/src/server/EvidenceMcpServer.ts index 7577358998..2339edc8cb 100644 --- a/src/server/EvidenceMcpServer.ts +++ b/src/server/EvidenceMcpServer.ts @@ -2,9 +2,11 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { isCallToolResult, + isJSONRPCErrorResponse, isJSONRPCRequest, isJSONRPCNotification, isJSONRPCResultResponse, + type CallToolResult, type Implementation, type Icon, type JSONRPCMessage, @@ -27,7 +29,10 @@ import { import { CATALOG_IDENTITY, createCatalogIdentity } from "../catalogIdentity.js"; import { transformAdvertisedInputJsonSchema } from "../contracts/toolSchemaMetadata.js"; import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; -import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js"; +import { + AnalysisInputError, + AnalysisResourceConstraintError, +} from "../domain/analysisErrorCore.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { createEvidence } from "../domain/evidence.js"; import { jsonObjectSchema } from "../domain/jsonValue.js"; @@ -65,6 +70,39 @@ const hasAdvertisedInputJsonSchema = (value: unknown): boolean => { const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); +/** Recognize the SDK's argument diagnostic after its wire size is checked. */ +const sdkInputValidationDiagnostic = ( + text: string | undefined, + tool: string | undefined, +): string | undefined => { + if (text === undefined || tool === undefined) return undefined; + const prefix = `Input validation error: Invalid arguments for tool ${tool}`; + if (!text.startsWith(prefix) || text[prefix.length] !== ":") return undefined; + const diagnostic = text.slice(prefix.length + 1).trimStart(); + return diagnostic.length === 0 ? undefined : diagnostic; +}; + +/** Replace the tool payload after the SDK codec while retaining its envelope. */ +const replaceToolResultPayload = ( + original: CallToolResult, + replacement: CallToolResult, +): CallToolResult => { + const { + content: _content, + structuredContent: _structuredContent, + isError: _isError, + _meta, + ...envelope + } = original; + return { + ...envelope, + ...replacement, + ...(_meta === undefined && replacement._meta === undefined + ? {} + : { _meta: { ..._meta, ...replacement._meta } }), + }; +}; + /** Remove only client-facing output schemas; SDK registrations stay canonical. */ const omitOutputSchemasFromToolsList = ( message: JSONRPCMessage, @@ -305,7 +343,7 @@ class ErrorEvidenceTransport implements Transport { id !== undefined && this.pendingToolLists.has(id); try { - const recovered = this.recoverError(message, tool); + const recovered = this.recoverProtocolError(message, tool); const advertised = omitToolSchemas ? omitOutputSchemasFromToolsList(recovered, id) : recovered; @@ -377,16 +415,45 @@ class ErrorEvidenceTransport implements Transport { }, } : message.result; - const encoded = - structured !== undefined - ? encodeToolResult(structured, budget, "error-text", targetContent) - : measureMcpResultWithinBudget(sdkResultWithMarker, budget); - if (encoded.ok) return message; - const operation = tool ?? "mcp_tool_error"; - const originalResult = jsonObjectSchema.parse(message.result); const sdkDiagnostic = !isStructured ? message.result.content.find((item) => item.type === "text")?.text : undefined; + let encoded = + structured !== undefined + ? encodeToolResult(structured, budget, "error-text", targetContent) + : measureMcpResultWithinBudget(sdkResultWithMarker, budget); + const normalized = encoded.ok + ? this.normalizeSdkValidationError( + message, + tool, + targetContent, + sdkInputValidationDiagnostic(sdkDiagnostic, tool), + ) + : undefined; + if ( + normalized !== undefined && + isJSONRPCResultResponse(normalized) && + isCallToolResult(normalized.result) + ) { + // The structured diagnostic is a private carrier removed before sending. + // Measure the actual wire result, including its preserved SDK metadata. + const { structuredContent, ...wireResult } = normalized.result; + encoded = + normalized.result.content.length === 0 + ? encodeToolResult( + jsonObjectSchema.parse(structuredContent), + budget, + "error-text", + targetContent, + ) + : measureMcpResultWithinBudget(wireResult, budget); + if (encoded.ok) return normalized; + // JSON escaping and recovery fields can exceed the budget even when + // the original SDK result fits. Retain that original before replacing it. + } + if (encoded.ok) return message; + const operation = tool ?? "mcp_tool_error"; + const originalResult = jsonObjectSchema.parse(message.result); const retainedResult = structured ?? originalResult; const originalError = isStructured ? analysisErrorProjectionSchema.safeParse(structured?.["error"]) @@ -449,25 +516,58 @@ class ErrorEvidenceTransport implements Transport { : "Restart REA with a larger REA_MCP_MAX_RESPONSE_BYTES and matching client receive buffer, then retry. The original error could not be retained by this server.", }, ); - // This transport runs after the SDK's wire codec. Replace the tool payload - // while retaining its protocol envelope, including the revision's result - // discriminator and server identity metadata. - const { - content: _content, - structuredContent: _structuredContent, - isError: _isError, - _meta, - ...envelope - } = message.result; const replacement = this.delivery.toErrorToolResult(failure, targetContent); return { ...message, - result: { - ...envelope, - ...replacement, - ...(_meta === undefined && replacement._meta === undefined - ? {} - : { _meta: { ..._meta, ...replacement._meta } }), + result: replaceToolResultPayload(message.result, replacement), + }; + } + + private normalizeSdkValidationError( + message: JSONRPCMessage, + tool: string | undefined, + targetContent: boolean, + diagnostic: string | undefined, + ): JSONRPCMessage | undefined { + if ( + tool === undefined || + diagnostic === undefined || + !isJSONRPCResultResponse(message) || + !isCallToolResult(message.result) + ) + return undefined; + const error = new AnalysisInputError( + tool, + { + userMessage: `The arguments for MCP tool ${tool} do not match its advertised input schema.`, + }, + [{ path: [], reason: "invalid_value", message: diagnostic }], + ); + const result = this.delivery.toErrorToolResult(error, targetContent); + return { + ...message, + result: replaceToolResultPayload(message.result, result), + }; + } + + private recoverProtocolError( + message: JSONRPCMessage, + tool: string | undefined, + ): JSONRPCMessage { + const recovered = this.recoverError(message, tool); + if ( + !isJSONRPCErrorResponse(recovered) || + tool === undefined || + recovered.error.code !== -32602 || + recovered.error.message !== `Tool ${tool} not found` + ) + return recovered; + return { + ...recovered, + error: { + ...recovered.error, + message: + "The requested MCP tool is not advertised by this REA server. Call tools/list, select an advertised tool, and retry; do not repeat the unknown tool name.", }, }; } diff --git a/tests/acceptance/applications/javascriptAnalysisLimits.test.ts b/tests/acceptance/applications/javascriptAnalysisLimits.test.ts index 593b89ae91..d3b1d7fcfa 100644 --- a/tests/acceptance/applications/javascriptAnalysisLimits.test.ts +++ b/tests/acceptance/applications/javascriptAnalysisLimits.test.ts @@ -164,7 +164,18 @@ cliTest( }, }); expect(parseMcpToolError(response)).toMatchObject({ - error: { code: "provider_timeout" }, + error: { + code: "provider_timeout", + message: expect.stringContaining("server/provider deadline"), + remediation: { + action: expect.stringContaining("MCP client"), + }, + recovery: { + kind: "inspect_timeout_state", + operation: "analyze_javascript_application", + timeout_ms: 1, + }, + }, }); const bundle = await getBundle(client); const partial = bundle.records.find( diff --git a/tests/boundary/cli/browserInputDiagnostics.test.ts b/tests/boundary/cli/browserInputDiagnostics.test.ts index 745948e925..b502e950ba 100644 --- a/tests/boundary/cli/browserInputDiagnostics.test.ts +++ b/tests/boundary/cli/browserInputDiagnostics.test.ts @@ -132,7 +132,13 @@ describe("browser and Inspector CLI input diagnostics", () => { message: "Analysis input is invalid. Check the arguments and try again.", retryable: true, - remediation: { action: "Correct the listed arguments and retry." }, + remediation: { + action: `Read the declared input schema or CLI usage for ${input.operation}; if using MCP and the catalog may be stale, call tools/list. Correct the listed fields, then retry. Do not repeat the unchanged call.`, + }, + recovery: { + kind: "inspect_input_contract", + operation: input.operation, + }, details: { operation: input.operation, issues: expect.arrayContaining([input.issue]), diff --git a/tests/boundary/filesystem/processCliFileInput.test.ts b/tests/boundary/filesystem/processCliFileInput.test.ts index 10548091a9..23f3f9631b 100644 --- a/tests/boundary/filesystem/processCliFileInput.test.ts +++ b/tests/boundary/filesystem/processCliFileInput.test.ts @@ -184,7 +184,14 @@ describe("process CLI input diagnostics", () => { category: "invalid_input", message: "Analysis input is invalid. Check the arguments and try again.", retryable: true, - remediation: { action: "Correct the listed arguments and retry." }, + remediation: { + action: + "Read the declared input schema or CLI usage for capture_process_scenario; if using MCP and the catalog may be stale, call tools/list. Correct the listed fields, then retry. Do not repeat the unchanged call.", + }, + recovery: { + kind: "inspect_input_contract", + operation: "capture_process_scenario", + }, details: expect.objectContaining({ operation: "capture_process_scenario", issues: expect.arrayContaining([ diff --git a/tests/boundary/mcp/adbMcp.test.ts b/tests/boundary/mcp/adbMcp.test.ts index 703f75b22d..bb3bb2330e 100644 --- a/tests/boundary/mcp/adbMcp.test.ts +++ b/tests/boundary/mcp/adbMcp.test.ts @@ -7,6 +7,7 @@ import { parseEvidence } from "../../../src/domain/evidence.js"; import { ok } from "../../../src/domain/result.js"; import { createServer } from "../../../src/server/createServer.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { assertAdvertisedMcpContracts } from "./mcpContractHarness.js"; const identity = { id: "adb", name: "Android Debug Bridge", version: null }; @@ -391,8 +392,24 @@ it("rejects invalid tool input before reaching the provider", async () => { arguments: { serial: "bad serial" }, }); expect(rejected.isError).toBe(true); - const text = (rejected.content as { readonly text?: string }[]) - .map((part) => part.text ?? "") - .join(""); - expect(text).toContain("Invalid arguments for tool inspect_adb_device"); + expect(parseMcpToolError(rejected)).toMatchObject({ + error: { + code: "invalid_request", + recovery: { + kind: "inspect_input_contract", + operation: "inspect_adb_device", + }, + details: { + operation: "inspect_adb_device", + issues: [ + { + path: [], + reason: "invalid_value", + message: + "serial: Device serial cannot contain whitespace or quotes", + }, + ], + }, + }, + }); }); diff --git a/tests/boundary/mcp/browserMcp.test.ts b/tests/boundary/mcp/browserMcp.test.ts index 779888cd84..f26981cb0c 100644 --- a/tests/boundary/mcp/browserMcp.test.ts +++ b/tests/boundary/mcp/browserMcp.test.ts @@ -245,6 +245,16 @@ const verifySessionAndComparisonTools = async ( }, }); expect(malformedComparison.isError).toBe(true); + expect(parseMcpToolError(malformedComparison)).toMatchObject({ + error: { + code: "invalid_request", + recovery: { + kind: "inspect_input_contract", + operation: "compare_web_captures", + }, + details: { operation: "compare_web_captures" }, + }, + }); expect(JSON.stringify(malformedComparison.content)).toContain("types"); const incompleteComparison = await connected.client.callTool({ name: "compare_web_captures", diff --git a/tests/boundary/mcp/browserScenarioInputDiagnosticsMcp.test.ts b/tests/boundary/mcp/browserScenarioInputDiagnosticsMcp.test.ts index bb8760bcc4..42fc7020cb 100644 --- a/tests/boundary/mcp/browserScenarioInputDiagnosticsMcp.test.ts +++ b/tests/boundary/mcp/browserScenarioInputDiagnosticsMcp.test.ts @@ -101,7 +101,14 @@ cliTest( code: "invalid_request", category: "invalid_input", retryable: true, - remediation: { action: "Correct the listed arguments and retry." }, + remediation: { + action: + "Read the declared input schema or CLI usage for capture_browser_scenario; if using MCP and the catalog may be stale, call tools/list. Correct the listed fields, then retry. Do not repeat the unchanged call.", + }, + recovery: { + kind: "inspect_input_contract", + operation: "capture_browser_scenario", + }, details: { operation: "capture_browser_scenario", issues: [ diff --git a/tests/boundary/mcp/ghidraInventoryMcp.test.ts b/tests/boundary/mcp/ghidraInventoryMcp.test.ts index 130088cc64..b16e757820 100644 --- a/tests/boundary/mcp/ghidraInventoryMcp.test.ts +++ b/tests/boundary/mcp/ghidraInventoryMcp.test.ts @@ -160,9 +160,15 @@ it("retains byte-search coverage and rejects malformed pages before creating Evi arguments: args, }); expect(invalid.isError).toBe(true); - expect(JSON.stringify(invalid.content)).toContain( - "Input validation error", - ); + expect(parseMcpToolError(invalid)).toMatchObject({ + error: { + code: "invalid_request", + recovery: { + kind: "inspect_input_contract", + operation: "search_bytes", + }, + }, + }); } expect(harness.calls).toHaveLength(calls); } finally { diff --git a/tests/boundary/mcp/largeBytePattern.test.ts b/tests/boundary/mcp/largeBytePattern.test.ts index 21d18778a2..236dd74f55 100644 --- a/tests/boundary/mcp/largeBytePattern.test.ts +++ b/tests/boundary/mcp/largeBytePattern.test.ts @@ -8,13 +8,17 @@ const LARGE_PATTERN_BYTES = 4 * 1024 * 1024; const inputValidationText = (result: CallToolResult): string => { expect(result.isError).toBe(true); - const text = result.content.find((item) => item.type === "text"); - if (text?.type !== "text") throw new Error("Input error omitted text"); - expect(text.text).toMatch( - /^Input validation error: Invalid arguments for tool search_bytes:/u, - ); - expect(text.text).not.toMatch(/RangeError|Maximum call stack/i); - return text.text; + const diagnostic = parseMcpToolError(result).error; + expect(diagnostic).toMatchObject({ + code: "invalid_request", + recovery: { + kind: "inspect_input_contract", + operation: "search_bytes", + }, + }); + const text = JSON.stringify(diagnostic); + expect(text).not.toMatch(/RangeError|Maximum call stack/i); + return text; }; it("validates large search_bytes inputs without breaking stdio MCP", async () => { diff --git a/tests/boundary/mcp/sessionMcp.test.ts b/tests/boundary/mcp/sessionMcp.test.ts index 6bb80eee48..5748db10bf 100644 --- a/tests/boundary/mcp/sessionMcp.test.ts +++ b/tests/boundary/mcp/sessionMcp.test.ts @@ -578,10 +578,26 @@ describe("json depth bounds over MCP", () => { right: input.right, }, }); - const text = JSON.stringify(result.content); - expect(result.isError, text).toBe(true); - expect(text).toContain("Input validation"); - expect(text).toContain("maximum nesting depth"); + expect(result.isError, JSON.stringify(result.content)).toBe(true); + expect(parseMcpToolError(result)).toMatchObject({ + error: { + code: "invalid_request", + recovery: { + kind: "inspect_input_contract", + operation: "compare_process_captures", + }, + details: { + operation: "compare_process_captures", + issues: [ + { + path: [], + reason: "invalid_value", + message: `left.parameters: JSON value exceeds maximum nesting depth of ${MAX_JSON_DEPTH}`, + }, + ], + }, + }, + }); }, 10_000); }); diff --git a/tests/boundary/mcp/webBundleContainmentMcp.test.ts b/tests/boundary/mcp/webBundleContainmentMcp.test.ts index c4367ea8fe..1768318cf1 100644 --- a/tests/boundary/mcp/webBundleContainmentMcp.test.ts +++ b/tests/boundary/mcp/webBundleContainmentMcp.test.ts @@ -77,7 +77,18 @@ it("retains timeout Evidence and recovers on the same MCP server", async () => { }, }); const timeout = parseMcpToolError(interrupted).error; - expect(timeout.code).toBe("provider_timeout"); + expect(timeout).toMatchObject({ + code: "provider_timeout", + message: expect.stringContaining("server/provider deadline"), + remediation: { + action: expect.stringContaining("MCP client"), + }, + recovery: { + kind: "inspect_timeout_state", + operation: "analyze_web_bundle", + timeout_ms: 1, + }, + }); const timeoutDetails = z .object({ partial_observation: z.unknown() }) .passthrough()