MCP: tool-result bytes double-capped at 8KB — replay log loses original blob; doc-comment is wrong #21

Closed
opened 2026-05-12 19:04:54 +00:00 by KofTwentyTwo · 1 comment
KofTwentyTwo commented 2026-05-12 19:04:54 +00:00 (Migrated from github.com)

Summary

Tool-result bytes are 8 KB-capped TWICE — once in the dispatcher's SanitizeForModel.prepareForBoundary(rawText, capBytes: 8192), then again in ToolResultPacker.pack. The orchestrator's "full blob in replay log" promise is a lie: packed.fullBytes is already 8 KB. The "full" in toolResultFull is never larger than 8 KB. Combined with CRIT-1, no production path records the original tool output bytes anywhere on disk.

Evidence

  • File: packages/MCP/Sources/MCP/MCPToolDispatcher.swift:114 — stdio path returns Data(SanitizeForModel.prepareForBoundary(rawText, capBytes: 8192).utf8)
  • File: packages/MCP/Sources/MCP/InProcess/InProcessAwareToolDispatcher.swift:89 — same 8 KB cap on in-process path
  • File: packages/AgentCore/Sources/AgentOrchestrator/AgentOrchestrator.swift:580-587 — orchestrator calls ToolResultPacker.pack(resultData) which caps AGAIN at 8 KB; then writes .toolResultFull(toolUseId: req.id, bytes: packed.fullBytes) to replay
  • File: packages/AgentCore/Sources/AgentCore/ToolResultPacker.swift:23 — doc comment says "The full blob still flows to ReplayLog via ReplayEvent.toolResultFull(toolUseId:bytes:) — observability is not lossy (OBS-02)." This is false in production.

Root cause / hypothesis

The two layers were designed independently — one in MCP (boundary defense), one in AgentCore (model-facing protection) — and the intended division was "MCP caps the model-facing string, orchestrator passes through to replay". But the orchestrator pipeline doesn't have a "raw bytes" inlet — it consumes the dispatcher's return value as both model-facing AND replay-facing payload. ToolResultPacker.pack unit tests verify capping correctly but on synthetic inputs that haven't gone through the dispatcher's pre-cap; MCPToolDispatcher unit tests verify sanitize correctness on its output but don't trace the byte path into the orchestrator.

Recommended fix

Move prepareForBoundary call OUT of both dispatchers and into the orchestrator (or into ToolResultPacker.pack). Have dispatchers return the raw helper bytes. Then orchestrator:

  1. Send raw bytes to ReplayLog as .toolResultFull (true to the name)
  2. Run prepareForBoundary + ToolResultPacker.pack once, embed capped string in model-facing tool_result

This also collapses the dead observer dual-write back into a single source of truth, fixing CRIT-1 by deletion.

Test

  • Unit: ToolDispatcher returns >8KB bytes when helper output is >8KB
  • Integration: a tool returning a 100KB blob produces a replay row with the full 100KB; the model-facing payload is 8KB-capped
  • Assert ReplayEvent.toolResultFull.bytes.count > 8192 is achievable in test

Source

  • Audit: .planning/audit-2026-05-12/mcp-agent-audit.md (2026-05-12) — finding CRIT-2

Cross-references

  • Related: #20 (CRIT-1) — collapsing the dispatcher path dissolves the dead observer wiring too
## Summary Tool-result bytes are 8 KB-capped TWICE — once in the dispatcher's `SanitizeForModel.prepareForBoundary(rawText, capBytes: 8192)`, then again in `ToolResultPacker.pack`. The orchestrator's "full blob in replay log" promise is a lie: `packed.fullBytes` is already 8 KB. The "full" in `toolResultFull` is never larger than 8 KB. Combined with CRIT-1, no production path records the original tool output bytes anywhere on disk. ## Evidence - File: `packages/MCP/Sources/MCP/MCPToolDispatcher.swift:114` — stdio path returns `Data(SanitizeForModel.prepareForBoundary(rawText, capBytes: 8192).utf8)` - File: `packages/MCP/Sources/MCP/InProcess/InProcessAwareToolDispatcher.swift:89` — same 8 KB cap on in-process path - File: `packages/AgentCore/Sources/AgentOrchestrator/AgentOrchestrator.swift:580-587` — orchestrator calls `ToolResultPacker.pack(resultData)` which caps AGAIN at 8 KB; then writes `.toolResultFull(toolUseId: req.id, bytes: packed.fullBytes)` to replay - File: `packages/AgentCore/Sources/AgentCore/ToolResultPacker.swift:23` — doc comment says "The full blob still flows to ReplayLog via `ReplayEvent.toolResultFull(toolUseId:bytes:)` — observability is not lossy (OBS-02)." **This is false in production.** ## Root cause / hypothesis The two layers were designed independently — one in MCP (boundary defense), one in AgentCore (model-facing protection) — and the intended division was "MCP caps the model-facing string, orchestrator passes through to replay". But the orchestrator pipeline doesn't have a "raw bytes" inlet — it consumes the dispatcher's return value as both model-facing AND replay-facing payload. `ToolResultPacker.pack` unit tests verify capping correctly but on synthetic inputs that haven't gone through the dispatcher's pre-cap; `MCPToolDispatcher` unit tests verify sanitize correctness on its output but don't trace the byte path into the orchestrator. ## Recommended fix Move `prepareForBoundary` call OUT of both dispatchers and into the orchestrator (or into `ToolResultPacker.pack`). Have dispatchers return the raw helper bytes. Then orchestrator: 1. Send raw bytes to ReplayLog as `.toolResultFull` (true to the name) 2. Run `prepareForBoundary` + `ToolResultPacker.pack` once, embed capped string in model-facing tool_result This also collapses the dead observer dual-write back into a single source of truth, fixing CRIT-1 by deletion. ## Test - Unit: ToolDispatcher returns >8KB bytes when helper output is >8KB - Integration: a tool returning a 100KB blob produces a replay row with the full 100KB; the model-facing payload is 8KB-capped - Assert `ReplayEvent.toolResultFull.bytes.count > 8192` is achievable in test ## Source - Audit: `.planning/audit-2026-05-12/mcp-agent-audit.md` (2026-05-12) — finding CRIT-2 ## Cross-references - Related: #20 (CRIT-1) — collapsing the dispatcher path dissolves the dead observer wiring too
KofTwentyTwo commented 2026-05-13 11:51:53 +00:00 (Migrated from github.com)

Closed by v0.1 cluster merges on 2026-05-13. Develop now at a8c7f1a0.

Closed by v0.1 cluster merges on 2026-05-13. Develop now at `a8c7f1a0`.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: KofTwentyTwo/Jarvis#21