Materialize MCP image attachments

Save MCP image and image-resource payloads as scoped artifacts, return their paths in tool text and attachment metadata, and keep them model-visible through raw image history content. This gives downstream media delivery a real file path instead of only an inline data URL.

Alessandro committed Jun 25, 2026 at 09:40 UTC bd7e829a0fcaf5e9ba964f463f275ef4477abb7a
3 files changed +66 -19
helpers/mcp_handler.py
+42 -9
@@ -166,23 +166,45 @@ class MCPTool(Tool):
166 encoded: str,
167 mime_type: str,
168 label: str,
169 - ) -> tuple[str, dict[str, Any] | None]:
169 + index: int,
170 + preferred_name: str = "",
171 + ) -> tuple[str, dict[str, Any] | None, str]:
172 try:
171 - image = media_artifacts.image_data_url_from_base64(
173 + safe_mime = media_artifacts.normalize_mime(
174 + mime_type,
175 + default="image/png",
176 + required_prefix="image/",
177 + )
178 + artifact = media_artifacts.save_base64_artifact(
179 encoded,
173 - mime_type=mime_type,
180 + mime_type=safe_mime,
181 + directory_parts=self._artifact_directory_parts(),
182 + preferred_name=preferred_name,
183 + default_filename=self._default_artifact_filename(
184 + label=label,
185 + index=index,
186 + mime_type=safe_mime,
187 + ),
188 )
189 except media_artifacts.EmptyBase64Data:
176 - return f"MCP returned an empty {label} attachment.", None
190 + return f"MCP returned an empty {label} attachment.", None, ""
191 except media_artifacts.InvalidBase64Data:
178 - return f"MCP returned a {label} attachment that could not be decoded.", None
192 + return (
193 + f"MCP returned a {label} attachment that could not be decoded.",
194 + None,
195 + "",
196 + )
197
198 return (
181 - f"MCP returned {label} attachment ({image.mime}, {image.size} bytes).",
199 + (
200 + f"Saved MCP {label} attachment "
201 + f"({artifact.mime}, {artifact.size} bytes) to {artifact.path}."
202 + ),
203 {
204 "type": "image_url",
184 - "image_url": {"url": image.url},
205 + "image_url": {"url": artifact.path},
206 },
207 + artifact.path,
208 )
209
210 def _materialize_binary_content(
@@ -266,6 +288,7 @@ class MCPTool(Tool):
288 text_parts: list[str] = []
289 notes: list[str] = []
290 raw_images: list[dict[str, Any]] = []
291 + image_paths: list[str] = []
292 content_items = list(getattr(response, "content", []) or [])
293
294 for index, item in enumerate(content_items, start=1):
@@ -278,14 +301,17 @@ class MCPTool(Tool):
301 continue
302
303 if item_type == "image":
281 - note, raw_content = self._format_image_content(
304 + note, raw_content, path = self._format_image_content(
305 encoded=str(_mcp_get(item, "data", "") or ""),
306 mime_type=str(_mcp_get(item, "mimeType", "") or "image/png"),
307 label="image",
308 + index=index,
309 )
310 notes.append(note)
311 if raw_content:
312 raw_images.append(raw_content)
313 + if path:
314 + image_paths.append(path)
315 continue
316
317 if item_type == "audio":
@@ -312,10 +338,12 @@ class MCPTool(Tool):
338 _mcp_get(resource, "mimeType", "") or "application/octet-stream"
339 ).strip().lower()
340 if mime_type.startswith("image/"):
315 - note, raw_content = self._format_image_content(
341 + note, raw_content, path = self._format_image_content(
342 encoded=blob,
343 mime_type=mime_type,
344 label="resource image",
345 + index=index,
346 + preferred_name=uri,
347 )
348 else:
349 note = self._materialize_binary_content(
@@ -326,9 +354,12 @@ class MCPTool(Tool):
354 preferred_name=uri,
355 )
356 raw_content = None
357 + path = ""
358 notes.append(note)
359 if raw_content:
360 raw_images.append(raw_content)
361 + if path:
362 + image_paths.append(path)
363 continue
364
365 if uri:
@@ -356,6 +387,8 @@ class MCPTool(Tool):
387 "raw_content": raw_images,
388 "preview": f"<MCP image attachments: {len(raw_images)}>",
389 "_tokens": MCP_MEDIA_TOKENS_ESTIMATE * len(raw_images),
390 + "attachments": image_paths,
391 + "media_paths": image_paths,
392 }
393
394 return message, additional
helpers/mcp_handler.py.dox.md
+1
@@ -81,6 +81,7 @@
81 - MCP tool names are qualified as `server_name.tool_name`; server names are normalized without dots, and the tool portion may contain dots.
82 - Servers may define `disabled_tools` as a list of MCP tool names. Disabled tools are omitted from agent-facing prompts, status counts, `has_tool`, and calls, while detail views can still retrieve them through `get_all_tools()` with a `disabled` flag so users can re-enable them.
83 - Server-specific `init_timeout` and `tool_timeout` override global MCP client timeout settings for list-tools and call-tool operations.
84 +- MCP image and image-resource content is materialized to scoped artifact files and returned both as model-visible image attachments and as path metadata (`attachments`/`media_paths`) for downstream delivery.
85 - MCP config locks must not be held across awaited server initialization or tool-call operations. Slow or wedged MCP servers must not block status reads, prompt construction, unrelated MCP servers, or later tool calls through the shared config lock.
86 - MCP client session work runs inside disposable isolated `DeferredTask` workers with an outer timeout. Normal `AsyncExitStack` cleanup is also bounded; if cleanup or transport shutdown does not finish, the operation reports failure or warning while Agent Zero keeps control of the agent loop.
87 - Server status marks initialized server objects with cached initialization errors as disconnected, even if the config object exists.
tests/test_mcp_handler_multimodal.py
+23 -10
@@ -443,7 +443,7 @@ def test_mcp_isolated_operation_timeout_returns_control(mcp_handler_module):
443
444
445 def test_mcp_image_content_becomes_history_image_attachment(mcp_handler_module, monkeypatch):
446 - module, _tmp_path = mcp_handler_module
446 + module, tmp_path = mcp_handler_module
447 agent, log, tool_results, messages, updates, warnings = _agent_recorder()
448 image_b64 = base64.b64encode(b"image-bytes").decode("ascii")
449 result = _FakeCallToolResult(
@@ -470,16 +470,24 @@ def test_mcp_image_content_becomes_history_image_attachment(mcp_handler_module,
470 response = asyncio.run(tool.execute())
471
472 assert "[Tool returned no textual content]" not in response.message
473 - assert response.message == "MCP returned image attachment (image/webp, 11 bytes)."
473 + assert (
474 + "Saved MCP image attachment (image/webp, 11 bytes) to "
475 + "/a0/tmp/mcp/ctx_mcp/venice_image/"
476 + ) in response.message
477 assert response.additional is not None
475 - data_url = response.additional["raw_content"][0]["image_url"]["url"]
476 - assert data_url == f"data:image/webp;base64,{image_b64}"
478 + image_path = response.additional["raw_content"][0]["image_url"]["url"]
479 + assert image_path.startswith("/a0/tmp/mcp/ctx_mcp/venice_image/")
480 + assert response.additional["attachments"] == [image_path]
481 + assert response.additional["media_paths"] == [image_path]
482 + assert (tmp_path / image_path.removeprefix("/a0/")).exists()
483
484 asyncio.run(tool.after_execution(response))
485
486 assert tool_results[0][0] == ("venice_image", response.message)
487 + assert tool_results[0][1]["attachments"] == [image_path]
488 + assert tool_results[0][1]["media_paths"] == [image_path]
489 raw_message = messages[0][1]["content"]
482 - assert raw_message["raw_content"][0]["image_url"]["url"] == data_url
490 + assert raw_message["raw_content"][0]["image_url"]["url"] == image_path
491 assert messages[0][1]["tokens"] == module.MCP_MEDIA_TOKENS_ESTIMATE
492 assert updates[-1]["content"] == response.message
493 assert warnings == []
@@ -527,7 +535,7 @@ def test_mcp_audio_content_is_saved_instead_of_discarded(mcp_handler_module, mon
535
536
537 def test_mcp_image_resource_blob_becomes_history_image_attachment(mcp_handler_module, monkeypatch):
530 - module, _tmp_path = mcp_handler_module
538 + module, tmp_path = mcp_handler_module
539 agent, log, tool_results, messages, updates, warnings = _agent_recorder()
540 image_b64 = base64.b64encode(b"resource-image").decode("ascii")
541 result = _FakeCallToolResult(
@@ -562,16 +570,21 @@ def test_mcp_image_resource_blob_becomes_history_image_attachment(mcp_handler_mo
570
571 response = asyncio.run(tool.execute())
572
565 - assert response.message == "MCP returned resource image attachment (image/webp, 14 bytes)."
573 + assert (
574 + "Saved MCP resource image attachment (image/webp, 14 bytes) to "
575 + "/a0/tmp/mcp/ctx_mcp/venice_resource_image/"
576 + ) in response.message
577 assert response.additional is not None
567 - data_url = response.additional["raw_content"][0]["image_url"]["url"]
568 - assert data_url == f"data:image/webp;base64,{image_b64}"
578 + image_path = response.additional["raw_content"][0]["image_url"]["url"]
579 + assert image_path.startswith("/a0/tmp/mcp/ctx_mcp/venice_resource_image/")
580 + assert response.additional["attachments"] == [image_path]
581 + assert (tmp_path / image_path.removeprefix("/a0/")).exists()
582
583 asyncio.run(tool.after_execution(response))
584
585 assert tool_results[0][0] == ("venice_resource_image", response.message)
586 raw_message = messages[0][1]["content"]
574 - assert raw_message["raw_content"][0]["image_url"]["url"] == data_url
587 + assert raw_message["raw_content"][0]["image_url"]["url"] == image_path
588 assert updates[-1]["content"] == response.message
589 assert warnings == []
590