|
| 1 | +# ADR 0015: MCP fuzz targets result envelopes only; conformance replays against the live server |
| 2 | + |
| 3 | +- Status: accepted |
| 4 | +- Date: 2026-07-03 |
| 5 | + |
| 6 | +## Context |
| 7 | + |
| 8 | +M11 brings adversarial testing to the MCP boundary (ADR-0014). Two design questions: |
| 9 | +(1) *what* may the fuzzer mutate in a recorded MCP session, and (2) what does "conformance" |
| 10 | +mean for an MCP-server author using Volo as a regression gate? |
| 11 | + |
| 12 | +## Decision |
| 13 | + |
| 14 | +**Fuzz (`volo_mcp.fuzz`)** reuses the generic `volo-scenarios` operators unchanged, wrapped by |
| 15 | +two MCP-specific rules: |
| 16 | + |
| 17 | +1. **Fuzz targets are only real tool responses** — `tool_call` steps with an `mcp.tool:` prefix |
| 18 | + whose response is a `{"result": <object>}` envelope. Handshake/meta steps |
| 19 | + (`mcp:initialize`, `mcp:tools/list`, …) and recorded *protocol errors* are byte-intact in |
| 20 | + every mutation, so a fuzzed session still boots and error behavior stays authentic. |
| 21 | +2. **Operators run inside the envelope.** The fuzzer extracts the target steps into a |
| 22 | + sub-recording, unwraps `{"result": X}` → `X`, applies the operator, re-wraps, and merges the |
| 23 | + steps back into their original positions. Operators therefore mutate the object the agent |
| 24 | + actually reads (e.g. `corrupt_field` flips `isError`; `prompt_injection` lands inside the |
| 25 | + content), and the mutated recording replays through `MCPReplayServer` with no special cases. |
| 26 | + |
| 27 | +Default library: `drop_tool_result`, `corrupt_field`, `prompt_injection`, `reorder_steps` |
| 28 | +(failure classes: resilience, robustness, security, order_sensitivity). Excluded: |
| 29 | +`inject_latency` (latency metadata is never served over the wire) and `ambiguous_user_turn` |
| 30 | +(MCP recordings contain no model calls). Mutations are seeded → reproducible in CI. |
| 31 | + |
| 32 | +**Conformance (`volo_mcp.conformance`)** treats a recording as a behavioral contract: every |
| 33 | +recorded request is rebuilt (`messages.request_message`, the inverse of `tool_key`) and sent to |
| 34 | +a freshly spawned live server; each reply is compared to the recorded envelope. Verdicts: |
| 35 | +`identical` / `different` / `no_reply`; anything non-identical fails (exit 1 in the CLI). |
| 36 | +Recorded protocol errors are part of the contract and must reproduce. |
| 37 | + |
| 38 | +## Consequences |
| 39 | + |
| 40 | +- Every fuzz output is itself a valid, servable recording — `volo mcp serve` and future |
| 41 | + reliability scoring work on hostile worlds with zero extra machinery. |
| 42 | +- Restricting targets to result envelopes means the fuzzer never breaks the transport layer — |
| 43 | + by design. Malformed-protocol fuzzing (bad JSON-RPC framing, wrong ids) is a separate future |
| 44 | + concern, closer to the transport tests than to scenario operators. |
| 45 | +- Conformance request reconstruction is lossy for `tools/call` params beyond |
| 46 | + `name`/`arguments` (deliberately dropped from the cache identity in ADR-0014). Servers keying |
| 47 | + behavior on exotic params need a fresh recording rather than conformance replay. |
| 48 | +- Conformance compares byte-equal envelopes; servers with legitimately nondeterministic fields |
| 49 | + (timestamps, ids) will report `different`. A normalization hook is future work if demanded. |
| 50 | + |
| 51 | +## Alternatives considered |
| 52 | + |
| 53 | +- **MCP-specific operator implementations** — rejected: duplicates the M2 taxonomy and forks |
| 54 | + the failure-class vocabulary the reliability engine already understands. |
| 55 | +- **Fuzzing everything, including handshake/meta and error envelopes** — rejected: trivially |
| 56 | + broken sessions (failed `initialize`) mask the interesting failures, and mutating a recorded |
| 57 | + error's shape produces worlds no real server can express. |
| 58 | +- **Conformance via the simulator (replay both sides offline)** — rejected: that tests Volo |
| 59 | + against itself; the author's question is whether the *live build* still honors the contract. |
0 commit comments