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