Spec
See specs/072-multimodal-mcp-passthrough/plan.md §"P2 — Decode, Validate, Attach (PR 3)". Parent issue: #6229. P0 landed in PR #6238. Depends on P1 (#6239) merging first — plan.md orders the persistence strip before any code path can produce an Image part, so P2's own tests can rely on P1's strip instead of re-verifying it.
Summary
Add MediaSanitizer (magic-byte sniff, format allowlist, size/dimension/pixel caps), wire the MCP-side opt-in decode path, and add the sibling-MessagePart::Image emission + vision-tier routing gate in process_one_tool_result. This is the phase where Image parts can first actually be produced from MCP tool results.
Scope corrections vs. the original plan.md text
plan.md's P0 section originally listed ToolResultClassification.media (field + population in classify_tool_result) as a P0 deliverable. During P0's review (PR #6238), the critic found that adding a write-only media field to the private, exhaustively-destructured ToolResultClassification struct with no reader yet would fail cargo clippy -D warnings (dead-code/unused-field). P0 deferred this entirely — field and reader must land together, in this phase (P2), not split across phases. Concretely, this issue's scope now includes, as an explicit first deliverable:
crates/zeph-core/src/agent/tool_execution/mod.rs — add media: Vec<zeph_llm::ImageData> to ToolResultClassification. crates/zeph-core/src/agent/tool_execution/tool_result.rs — populate it in classify_tool_result's Ok(Some(out)) arm (media: out.media) and empty in the Ok(None)/Err arms. Land this together with deliverable 6 below (the reader) in the same PR — do not split.
Scope (from plan.md, full deliverable list has exact file/line references)
crates/zeph-sanitizer — new MediaSanitizer (mirrors ContentSanitizer's policy-object shape): magic-byte sniff vs. declared MIME, format allowlist, byte-size cap before decode, spawn_blocking decode via the image crate, dimension/pixel caps enforced post-decode, MediaRejected (thiserror) enum.
crates/zeph-config/src/channels.rs — McpServerConfig.media_passthrough: bool (default false), McpMediaConfig with the pinned defaults (5 MiB / 8192px / 64MP / 4-per-result / 8-per-turn).
crates/zeph-mcp/src/executor.rs — opt-in gate (hard-blocked when server trust is Sandboxed), sanitize each ContentBlock::Image, collect into ToolOutput.media, log accept/reject via the existing tool audit path.
crates/zeph-core/src/agent/tool_execution/tool_result.rs — process_one_tool_result: push sibling MessagePart::Image per validated entry when the turn's tier is (or will become) vision-capable; respect max_images_per_turn; drop with a warning otherwise (never a runtime 400/422 — AC-6).
crates/zeph-llm/src/router/triage.rs — vision-capability signal for provider/tier selection when the pending request has media parts. Must use the existing provider-registry pattern (no hardcoded model/provider) per CLAUDE.md's Multi-Model Design Principle.
- System-prompt caveat: static line added when any server has
media_passthrough = true, marking tool-sourced images as untrusted data.
Additional finding to fold in (from P0's security validation, PR #6238)
The redacting Debug impl for ImageData (landed in P0) does not cover the debug-dump JSON export path, which serializes via serde_json/Serialize rather than Debug and therefore bypasses the redaction. Confirmed sites: crates/zeph-core/src/debug_dump/mod.rs:402 (serde_json::to_value(request.messages)) and the custom image handler at :622-627 (base64::...encode(&img.data)), both writing raw image bytes into the dump. This is pre-existing (already reachable via the shipped user-upload MessagePart::Image path) and not exercised by P0 (P0 never populates media), but must be fixed as part of this phase since P2 is where MCP-sourced images first actually flow — AC-3 ("no raw bytes in logs/debug-dumps") requires it. Redact or omit ImageData.data in both debug_dump paths.
Mandatory gate
Per the LLM Serialization Gate (.claude/rules/continuous-improvement.md), a live cascade + MCP-image session test is required before this PR merges: run the agent with cargo run --features full -- --config .local/config/testing.toml, exercise a mock or real MCP server with media_passthrough = true behind a cascade/triage provider pool with mixed vision capability, and verify no 400/422 in the debug dump. Document the result in the PR description.
Acceptance criteria
AC-1 through AC-4, AC-6 through AC-9 (full), AC-13, AC-14, AC-15. Full test list: specs/072-multimodal-mcp-passthrough/plan.md §P2.
Suggested next step
Run /rust-agents:team-develop new-feature on this issue after #6239 (P1) merges. The architect should re-verify the ToolResultClassification.media deferral note above against HEAD before the developer starts, since P1/main will have moved since this issue was filed.
Spec
See
specs/072-multimodal-mcp-passthrough/plan.md§"P2 — Decode, Validate, Attach (PR 3)". Parent issue: #6229. P0 landed in PR #6238. Depends on P1 (#6239) merging first — plan.md orders the persistence strip before any code path can produce anImagepart, so P2's own tests can rely on P1's strip instead of re-verifying it.Summary
Add
MediaSanitizer(magic-byte sniff, format allowlist, size/dimension/pixel caps), wire the MCP-side opt-in decode path, and add the sibling-MessagePart::Imageemission + vision-tier routing gate inprocess_one_tool_result. This is the phase whereImageparts can first actually be produced from MCP tool results.Scope corrections vs. the original plan.md text
plan.md's P0 section originally listedToolResultClassification.media(field + population inclassify_tool_result) as a P0 deliverable. During P0's review (PR #6238), the critic found that adding a write-onlymediafield to the private, exhaustively-destructuredToolResultClassificationstruct with no reader yet would failcargo clippy -D warnings(dead-code/unused-field). P0 deferred this entirely — field and reader must land together, in this phase (P2), not split across phases. Concretely, this issue's scope now includes, as an explicit first deliverable:crates/zeph-core/src/agent/tool_execution/mod.rs— addmedia: Vec<zeph_llm::ImageData>toToolResultClassification.crates/zeph-core/src/agent/tool_execution/tool_result.rs— populate it inclassify_tool_result'sOk(Some(out))arm (media: out.media) and empty in theOk(None)/Errarms. Land this together with deliverable 6 below (the reader) in the same PR — do not split.Scope (from plan.md, full deliverable list has exact file/line references)
crates/zeph-sanitizer— newMediaSanitizer(mirrorsContentSanitizer's policy-object shape): magic-byte sniff vs. declared MIME, format allowlist, byte-size cap before decode,spawn_blockingdecode via theimagecrate, dimension/pixel caps enforced post-decode,MediaRejected(thiserror) enum.crates/zeph-config/src/channels.rs—McpServerConfig.media_passthrough: bool(default false),McpMediaConfigwith the pinned defaults (5 MiB / 8192px / 64MP / 4-per-result / 8-per-turn).crates/zeph-mcp/src/executor.rs— opt-in gate (hard-blocked when server trust isSandboxed), sanitize eachContentBlock::Image, collect intoToolOutput.media, log accept/reject via the existing tool audit path.crates/zeph-core/src/agent/tool_execution/tool_result.rs—process_one_tool_result: push siblingMessagePart::Imageper validated entry when the turn's tier is (or will become) vision-capable; respectmax_images_per_turn; drop with a warning otherwise (never a runtime 400/422 — AC-6).crates/zeph-llm/src/router/triage.rs— vision-capability signal for provider/tier selection when the pending request has media parts. Must use the existing provider-registry pattern (no hardcoded model/provider) per CLAUDE.md's Multi-Model Design Principle.media_passthrough = true, marking tool-sourced images as untrusted data.Additional finding to fold in (from P0's security validation, PR #6238)
The redacting
Debugimpl forImageData(landed in P0) does not cover the debug-dump JSON export path, which serializes viaserde_json/Serializerather thanDebugand therefore bypasses the redaction. Confirmed sites:crates/zeph-core/src/debug_dump/mod.rs:402(serde_json::to_value(request.messages)) and the custom image handler at:622-627(base64::...encode(&img.data)), both writing raw image bytes into the dump. This is pre-existing (already reachable via the shipped user-uploadMessagePart::Imagepath) and not exercised by P0 (P0 never populatesmedia), but must be fixed as part of this phase since P2 is where MCP-sourced images first actually flow — AC-3 ("no raw bytes in logs/debug-dumps") requires it. Redact or omitImageData.datain both debug_dump paths.Mandatory gate
Per the LLM Serialization Gate (
.claude/rules/continuous-improvement.md), a live cascade + MCP-image session test is required before this PR merges: run the agent withcargo run --features full -- --config .local/config/testing.toml, exercise a mock or real MCP server withmedia_passthrough = truebehind a cascade/triage provider pool with mixed vision capability, and verify no 400/422 in the debug dump. Document the result in the PR description.Acceptance criteria
AC-1 through AC-4, AC-6 through AC-9 (full), AC-13, AC-14, AC-15. Full test list:
specs/072-multimodal-mcp-passthrough/plan.md§P2.Suggested next step
Run
/rust-agents:team-develop new-featureon this issue after #6239 (P1) merges. The architect should re-verify theToolResultClassification.mediadeferral note above against HEAD before the developer starts, since P1/main will have moved since this issue was filed.