Fix MCP multimodal content handling
Preserve MCP image, audio, and resource tool results instead of collapsing non-text responses into an empty textual result. Images and image resources now flow into raw history as data URL attachments, while audio and non-image binary resources are saved as artifacts with normalized paths. Extract shared media artifact helpers for base64 validation, image data URLs, decoded-size checks, artifact saving, MIME normalization, and safe filenames. Reuse the shared helpers from MCP, browser connector, and computer-use artifact paths, and add focused regression coverage.
Alessandro committed
May 26, 2026 at 15:31 UTC
4f06aa0a8eae842f295cc8304519f08f7fb7812b
6 files changed
+859
-81
helpers/mcp_handler.py
+259
-50
@@ -21,6 +21,7 @@ from contextlib import AsyncExitStack
21
from shutil import which
22
from datetime import timedelta
23
import json
24
+import uuid
25
from helpers import errors
26
from helpers import settings
27
from helpers.log import LogItem
@@ -39,11 +40,21 @@ from anyio.streams.memory import (
40
)
41
42
from pydantic import BaseModel, Field, Discriminator, Tag, PrivateAttr
42
-from helpers import dirty_json
43
+from helpers import dirty_json, media_artifacts
44
from helpers.print_style import PrintStyle
45
from helpers.tool import Tool, Response
46
47
48
+MCP_MEDIA_TOKENS_ESTIMATE = 1500
49
+MAX_MCP_RESOURCE_TEXT_CHARS = 12_000
50
+
51
+
52
+def _mcp_get(item: Any, key: str, default: Any = None) -> Any:
53
+ if isinstance(item, dict):
54
+ return item.get(key, default)
55
+ return getattr(item, key, default)
56
+
57
+
58
def normalize_name(name: str) -> str:
59
# Lowercase and strip whitespace
60
name = name.strip().lower()
@@ -102,7 +113,6 @@ class MCPTool(Tool):
113
"""MCP Tool wrapper"""
114
115
def get_log_object(self) -> LogItem:
105
- import uuid
116
return self.agent.context.log.log(
117
type="mcp",
118
heading=f"icon://extension {self.agent.agent_name}: Using MCP tool '{self.name}'",
@@ -111,17 +121,235 @@ class MCPTool(Tool):
121
id=str(uuid.uuid4()),
122
)
123
124
+ def _context_id(self) -> str:
125
+ return str(getattr(getattr(self.agent, "context", None), "id", "") or "").strip()
126
+
127
+ def _raw_tool_response(self, response: Response) -> str:
128
+ raw_tool_response = response.message.strip() if response.message else ""
129
+ if not raw_tool_response:
130
+ PrintStyle(font_color="red").print(
131
+ f"Warning: Tool '{self.name}' returned an empty message."
132
+ )
133
+ raw_tool_response = "[Tool returned no textual content]"
134
+ return raw_tool_response
135
+
136
+ def _coerce_media_token_estimate(self, value: object) -> int:
137
+ try:
138
+ estimate = int(value or 0)
139
+ except (TypeError, ValueError):
140
+ estimate = 0
141
+ return estimate if estimate > 0 else MCP_MEDIA_TOKENS_ESTIMATE
142
+
143
+ def _format_image_content(
144
+ self,
145
+ *,
146
+ encoded: str,
147
+ mime_type: str,
148
+ label: str,
149
+ ) -> tuple[str, dict[str, Any] | None]:
150
+ try:
151
+ image = media_artifacts.image_data_url_from_base64(
152
+ encoded,
153
+ mime_type=mime_type,
154
+ )
155
+ except media_artifacts.EmptyBase64Data:
156
+ return f"MCP returned an empty {label} attachment.", None
157
+ except media_artifacts.InvalidBase64Data:
158
+ return f"MCP returned a {label} attachment that could not be decoded.", None
159
+
160
+ return (
161
+ f"MCP returned {label} attachment ({image.mime}, {image.size} bytes).",
162
+ {
163
+ "type": "image_url",
164
+ "image_url": {"url": image.url},
165
+ },
166
+ )
167
+
168
+ def _materialize_binary_content(
169
+ self,
170
+ *,
171
+ encoded: str,
172
+ mime_type: str,
173
+ label: str,
174
+ index: int,
175
+ preferred_name: str = "",
176
+ ) -> str:
177
+ try:
178
+ safe_mime = media_artifacts.normalize_mime(mime_type)
179
+ artifact = media_artifacts.save_base64_artifact(
180
+ encoded,
181
+ mime_type=safe_mime,
182
+ directory_parts=self._artifact_directory_parts(),
183
+ preferred_name=preferred_name,
184
+ default_filename=self._default_artifact_filename(
185
+ label=label,
186
+ index=index,
187
+ mime_type=safe_mime,
188
+ ),
189
+ )
190
+ except media_artifacts.EmptyBase64Data:
191
+ return f"MCP returned an empty {label} attachment."
192
+ except media_artifacts.InvalidBase64Data:
193
+ return f"MCP returned a {label} attachment that could not be decoded."
194
+
195
+ return f"Saved MCP {label} attachment ({artifact.mime}, {artifact.size} bytes) to {artifact.path}."
196
+
197
+ def _artifact_directory_parts(self) -> tuple[str, ...]:
198
+ context_id = normalize_name(self._context_id() or "shared") or "shared"
199
+ tool_name = normalize_name(self.name or "mcp_tool") or "mcp_tool"
200
+ return ("tmp", "mcp", context_id, tool_name)
201
+
202
+ def _default_artifact_filename(self, *, label: str, index: int, mime_type: str) -> str:
203
+ tool_name = normalize_name(self.name or "mcp_tool") or "mcp_tool"
204
+ ext = media_artifacts.guess_extension(mime_type, ".bin")
205
+ return f"{tool_name}_{label}_{index}{ext}"
206
+
207
+ def _format_resource_text(self, text: str, uri: str = "") -> str:
208
+ body = str(text or "").strip()
209
+ if not body:
210
+ return ""
211
+ if len(body) > MAX_MCP_RESOURCE_TEXT_CHARS:
212
+ body = body[:MAX_MCP_RESOURCE_TEXT_CHARS].rstrip() + "\n...[truncated]"
213
+ if uri:
214
+ return f"Resource {uri}:\n{body}"
215
+ return body
216
+
217
+ def _content_item_dump(self, item: Any) -> dict[str, Any]:
218
+ if isinstance(item, dict):
219
+ return dict(item)
220
+ model_dump = getattr(item, "model_dump", None)
221
+ if callable(model_dump):
222
+ dumped = model_dump(mode="python")
223
+ if isinstance(dumped, dict):
224
+ return dumped
225
+ item_vars = getattr(item, "__dict__", None)
226
+ if isinstance(item_vars, dict):
227
+ return dict(item_vars)
228
+ return {}
229
+
230
+ def _summarize_unknown_item(self, item: Any, item_type: str) -> str:
231
+ dumped = self._content_item_dump(item)
232
+ if dumped:
233
+ dumped.pop("data", None)
234
+ resource = dumped.get("resource")
235
+ if isinstance(resource, dict):
236
+ resource.pop("blob", None)
237
+ summary = json.dumps(dumped, ensure_ascii=False)
238
+ if len(summary) > 600:
239
+ summary = summary[:600] + "...[truncated]"
240
+ return f"MCP returned unsupported content item type '{item_type}': {summary}"
241
+ return f"MCP returned unsupported content item type '{item_type}'."
242
+
243
+ def _format_tool_result(
244
+ self, response: CallToolResult
245
+ ) -> tuple[str, dict[str, Any] | None]:
246
+ text_parts: list[str] = []
247
+ notes: list[str] = []
248
+ raw_images: list[dict[str, Any]] = []
249
+ content_items = list(getattr(response, "content", []) or [])
250
+
251
+ for index, item in enumerate(content_items, start=1):
252
+ item_type = str(_mcp_get(item, "type", "") or "").strip().lower()
253
+
254
+ if item_type == "text":
255
+ text = str(_mcp_get(item, "text", "") or "").strip()
256
+ if text:
257
+ text_parts.append(text)
258
+ continue
259
+
260
+ if item_type == "image":
261
+ note, raw_content = self._format_image_content(
262
+ encoded=str(_mcp_get(item, "data", "") or ""),
263
+ mime_type=str(_mcp_get(item, "mimeType", "") or "image/png"),
264
+ label="image",
265
+ )
266
+ notes.append(note)
267
+ if raw_content:
268
+ raw_images.append(raw_content)
269
+ continue
270
+
271
+ if item_type == "audio":
272
+ note = self._materialize_binary_content(
273
+ encoded=str(_mcp_get(item, "data", "") or ""),
274
+ mime_type=str(_mcp_get(item, "mimeType", "") or "audio/wav"),
275
+ label="audio",
276
+ index=index,
277
+ )
278
+ notes.append(note)
279
+ continue
280
+
281
+ if item_type == "resource":
282
+ resource = _mcp_get(item, "resource", None)
283
+ uri = str(_mcp_get(resource, "uri", "") or "").strip()
284
+ text = _mcp_get(resource, "text", None)
285
+ if isinstance(text, str) and text.strip():
286
+ text_parts.append(self._format_resource_text(text, uri))
287
+ continue
288
+
289
+ blob = str(_mcp_get(resource, "blob", "") or "").strip()
290
+ if blob:
291
+ mime_type = str(
292
+ _mcp_get(resource, "mimeType", "") or "application/octet-stream"
293
+ ).strip().lower()
294
+ if mime_type.startswith("image/"):
295
+ note, raw_content = self._format_image_content(
296
+ encoded=blob,
297
+ mime_type=mime_type,
298
+ label="resource image",
299
+ )
300
+ else:
301
+ note = self._materialize_binary_content(
302
+ encoded=blob,
303
+ mime_type=mime_type,
304
+ label="resource",
305
+ index=index,
306
+ preferred_name=uri,
307
+ )
308
+ raw_content = None
309
+ notes.append(note)
310
+ if raw_content:
311
+ raw_images.append(raw_content)
312
+ continue
313
+
314
+ if uri:
315
+ mime_type = str(_mcp_get(resource, "mimeType", "") or "").strip()
316
+ details = f" ({mime_type})" if mime_type else ""
317
+ notes.append(f"MCP returned a resource reference: {uri}{details}.")
318
+ continue
319
+
320
+ notes.append("MCP returned a resource item without text or binary data.")
321
+ continue
322
+
323
+ if item_type:
324
+ notes.append(self._summarize_unknown_item(item, item_type))
325
+ continue
326
+
327
+ notes.append(self._summarize_unknown_item(item, "unknown"))
328
+
329
+ message = "\n\n".join(part for part in [*text_parts, *notes] if part.strip())
330
+ if not message and content_items:
331
+ message = "MCP tool returned content that could not be rendered as text."
332
+
333
+ additional = None
334
+ if raw_images:
335
+ additional = {
336
+ "raw_content": raw_images,
337
+ "preview": f"<MCP image attachments: {len(raw_images)}>",
338
+ "_tokens": MCP_MEDIA_TOKENS_ESTIMATE * len(raw_images),
339
+ }
340
+
341
+ return message, additional
342
+
343
async def execute(self, **kwargs: Any):
344
error = ""
345
+ additional: dict[str, Any] | None = None
346
try:
347
response: CallToolResult = await MCPConfig.get_instance().call_tool(
348
self.name, kwargs
349
)
120
- message = "\n\n".join(
121
- [item.text for item in response.content if item.type == "text"]
122
- )
350
+ message, additional = self._format_tool_result(response)
351
if response.isError:
124
- error = message
352
+ error = message or "MCP tool returned an error without textual content."
353
except Exception as e:
354
error = f"MCP Tool Exception: {str(e)}"
355
message = f"ERROR: {str(e)}"
@@ -139,7 +367,7 @@ class MCPTool(Tool):
367
content=f"{self.name}: {error}",
368
)
369
142
- return Response(message=message, break_loop=False)
370
+ return Response(message=message, break_loop=False, additional=additional)
371
372
async def before_execution(self, **kwargs: Any):
373
(
@@ -159,48 +387,29 @@ class MCPTool(Tool):
387
PrintStyle().print()
388
389
async def after_execution(self, response: Response, **kwargs: Any):
162
- raw_tool_response = response.message.strip() if response.message else ""
163
- if not raw_tool_response:
164
- PrintStyle(font_color="red").print(
165
- f"Warning: Tool '{self.name}' returned an empty message."
390
+ final_text_for_agent = self._raw_tool_response(response)
391
+ additional = dict(response.additional or {})
392
+ raw_content = additional.pop("raw_content", None)
393
+ preview = str(additional.pop("preview", "") or "").strip()
394
+ token_estimate = self._coerce_media_token_estimate(additional.pop("_tokens", 0))
395
+
396
+ self.agent.hist_add_tool_result(
397
+ self.name,
398
+ final_text_for_agent,
399
+ id=self.log.id if self.log else "",
400
+ **additional,
401
+ )
402
+ if raw_content:
403
+ from helpers import history
404
+
405
+ self.agent.hist_add_message(
406
+ False,
407
+ content=history.RawMessage(
408
+ raw_content=raw_content,
409
+ preview=preview or final_text_for_agent,
410
+ ),
411
+ tokens=token_estimate,
412
)
167
- # Even if empty, we might still want to provide context for the agent
168
- raw_tool_response = "[Tool returned no textual content]"
169
-
170
- # Prepare user message context
171
- # user_message_text = (
172
- # "No specific user message context available for this exact step."
173
- # )
174
- # if (
175
- # self.agent
176
- # and self.agent.last_user_message
177
- # and self.agent.last_user_message.content
178
- # ):
179
- # content = self.agent.last_user_message.content
180
- # if isinstance(content, dict):
181
- # # Attempt to get a 'message' field, otherwise stringify the dict
182
- # user_message_text = str(content.get(
183
- # "message", json.dumps(content, indent=2)
184
- # ))
185
- # elif isinstance(content, str):
186
- # user_message_text = content
187
- # else:
188
- # # Fallback for any other types (e.g. list, if that were possible for content)
189
- # user_message_text = str(content)
190
-
191
- # # Ensure user_message_text is a string before length check and slicing
192
- # user_message_text = str(user_message_text)
193
-
194
- # # Truncate user message context if it's too long to avoid overwhelming the prompt
195
- # max_user_context_len = 500 # characters
196
- # if len(user_message_text) > max_user_context_len:
197
- # user_message_text = (
198
- # user_message_text[:max_user_context_len] + "... (truncated)"
199
- # )
200
-
201
- final_text_for_agent = raw_tool_response
202
-
203
- self.agent.hist_add_tool_result(self.name, final_text_for_agent, id=self.log.id if self.log else "")
413
(
414
PrintStyle(
415
font_color="#1B4F72", background_color="white", padding=True, bold=True
@@ -210,8 +419,8 @@ class MCPTool(Tool):
419
)
420
# Print only the raw response to console for brevity, agent gets the full context.
421
PrintStyle(font_color="#85C1E9").print(
213
- raw_tool_response
214
- if raw_tool_response
422
+ final_text_for_agent
423
+ if final_text_for_agent
424
else "[No direct textual output from tool]"
425
)
426
if self.log:
helpers/media_artifacts.py
new
+186
@@ -0,0 +1,186 @@
1
+from __future__ import annotations
2
+
3
+import base64
4
+import binascii
5
+import mimetypes
6
+import uuid
7
+from dataclasses import dataclass
8
+from pathlib import Path
9
+from urllib.parse import urlparse
10
+
11
+from helpers import files
12
+
13
+
14
+DEFAULT_MAX_ARTIFACT_SIZE_BYTES = 25 * 1024 * 1024
15
+
16
+
17
+class MediaArtifactError(ValueError):
18
+ pass
19
+
20
+
21
+class EmptyBase64Data(MediaArtifactError):
22
+ pass
23
+
24
+
25
+class InvalidBase64Data(MediaArtifactError):
26
+ pass
27
+
28
+
29
+class ArtifactTooLarge(MediaArtifactError):
30
+ def __init__(self, size: int, limit: int):
31
+ super().__init__(f"artifact is too large ({size} bytes, limit {limit} bytes)")
32
+ self.size = size
33
+ self.limit = limit
34
+
35
+
36
+@dataclass(frozen=True)
37
+class Base64Payload:
38
+ data: str
39
+ payload: bytes
40
+ size: int
41
+
42
+
43
+@dataclass(frozen=True)
44
+class ImageDataUrl:
45
+ url: str
46
+ mime: str
47
+ size: int
48
+
49
+
50
+@dataclass(frozen=True)
51
+class SavedArtifact:
52
+ path: str
53
+ mime: str
54
+ size: int
55
+
56
+
57
+def compact_base64(data: str) -> str:
58
+ return "".join(char for char in str(data or "") if not char.isspace())
59
+
60
+
61
+def estimated_base64_decoded_size(data: str) -> int:
62
+ compact_length = len(compact_base64(data))
63
+ return (compact_length * 3) // 4
64
+
65
+
66
+def decode_base64_payload(
67
+ data: str,
68
+ *,
69
+ max_bytes: int | None = None,
70
+) -> Base64Payload:
71
+ compact = compact_base64(data)
72
+ if not compact:
73
+ raise EmptyBase64Data("base64 data is empty")
74
+
75
+ if max_bytes is not None:
76
+ estimated_size = estimated_base64_decoded_size(compact)
77
+ if estimated_size > max_bytes:
78
+ raise ArtifactTooLarge(estimated_size, max_bytes)
79
+
80
+ try:
81
+ payload = base64.b64decode(compact, validate=True)
82
+ except (binascii.Error, ValueError) as exc:
83
+ raise InvalidBase64Data("base64 data could not be decoded") from exc
84
+
85
+ if max_bytes is not None and len(payload) > max_bytes:
86
+ raise ArtifactTooLarge(len(payload), max_bytes)
87
+
88
+ return Base64Payload(data=compact, payload=payload, size=len(payload))
89
+
90
+
91
+def normalize_mime(
92
+ mime_type: str,
93
+ *,
94
+ default: str = "application/octet-stream",
95
+ required_prefix: str = "",
96
+) -> str:
97
+ value = str(mime_type or "").strip().lower()
98
+ if not value:
99
+ return default
100
+ if required_prefix and not value.startswith(required_prefix):
101
+ return default
102
+ return value
103
+
104
+
105
+def guess_extension(mime_type: str, fallback: str = ".bin") -> str:
106
+ ext = mimetypes.guess_extension(str(mime_type or "").strip().lower()) or fallback
107
+ return ".jpg" if ext == ".jpe" else ext
108
+
109
+
110
+def filename_from_uri(uri: str) -> str:
111
+ value = str(uri or "").strip()
112
+ if not value:
113
+ return ""
114
+ parsed = urlparse(value)
115
+ return Path(parsed.path or value).name
116
+
117
+
118
+def safe_filename(
119
+ value: str,
120
+ *,
121
+ default: str = "artifact.bin",
122
+ default_extension: str = ".bin",
123
+) -> str:
124
+ source = str(value or "").strip() or default
125
+ cleaned = "".join(
126
+ char if char.isalnum() or char in {"-", "_", "."} else "_"
127
+ for char in source
128
+ )
129
+ cleaned = cleaned.strip("._") or default
130
+ if "." not in cleaned:
131
+ ext = default_extension if default_extension.startswith(".") else f".{default_extension}"
132
+ cleaned += ext
133
+ return cleaned
134
+
135
+
136
+def image_data_url_from_base64(
137
+ data: str,
138
+ *,
139
+ mime_type: str = "image/png",
140
+ max_bytes: int | None = None,
141
+) -> ImageDataUrl:
142
+ payload = decode_base64_payload(data, max_bytes=max_bytes)
143
+ safe_mime = normalize_mime(
144
+ mime_type,
145
+ default="image/png",
146
+ required_prefix="image/",
147
+ )
148
+ return ImageDataUrl(
149
+ url=f"data:{safe_mime};base64,{payload.data}",
150
+ mime=safe_mime,
151
+ size=payload.size,
152
+ )
153
+
154
+
155
+def save_base64_artifact(
156
+ data: str,
157
+ *,
158
+ mime_type: str = "application/octet-stream",
159
+ directory_parts: tuple[str, ...],
160
+ preferred_name: str = "",
161
+ default_filename: str = "artifact.bin",
162
+ max_bytes: int | None = None,
163
+) -> SavedArtifact:
164
+ payload = decode_base64_payload(data, max_bytes=max_bytes)
165
+ safe_mime = normalize_mime(mime_type)
166
+ preferred_filename = filename_from_uri(preferred_name) or default_filename
167
+ default_extension = guess_extension(safe_mime, Path(default_filename).suffix or ".bin")
168
+ filename = safe_filename(
169
+ preferred_filename,
170
+ default=default_filename,
171
+ default_extension=default_extension,
172
+ )
173
+ filename_path = Path(filename)
174
+ stem = filename_path.stem or Path(default_filename).stem or "artifact"
175
+ suffix = filename_path.suffix or default_extension
176
+
177
+ artifact_dir = Path(files.get_abs_path(*directory_parts))
178
+ artifact_dir.mkdir(parents=True, exist_ok=True)
179
+ path = artifact_dir / f"{stem}_{uuid.uuid4().hex[:8]}{suffix}"
180
+ path.write_bytes(payload.payload)
181
+
182
+ return SavedArtifact(
183
+ path=files.normalize_a0_path(str(path)),
184
+ mime=safe_mime,
185
+ size=payload.size,
186
+ )
plugins/_a0_connector/tools/computer_use_remote.py
+7
-16
@@ -6,7 +6,7 @@ from pathlib import Path
6
import uuid
7
from typing import Any
8
9
-from helpers import history
9
+from helpers import history, media_artifacts
10
from helpers.print_style import PrintStyle
11
from helpers.tool import Response, Tool
12
from helpers.ws import NAMESPACE
@@ -750,7 +750,7 @@ class ComputerUseRemote(Tool):
750
if isinstance(artifact, dict) and str(artifact.get("encoding", "")).strip().lower() == "base64":
751
encoded = str(artifact.get("data") or "")
752
if encoded:
753
- estimated_size = _estimated_base64_decoded_size(encoded)
753
+ estimated_size = media_artifacts.estimated_base64_decoded_size(encoded)
754
if estimated_size > MAX_CAPTURE_ARTIFACT_SIZE_BYTES:
755
raise RuntimeError(
756
"Computer-use capture artifact is too large to attach safely "
@@ -759,7 +759,11 @@ class ComputerUseRemote(Tool):
759
mime = str(artifact.get("mime") or "image/png").strip()
760
if not mime.startswith("image/"):
761
mime = "image/png"
762
- filename = _safe_filename(str(artifact.get("filename") or "computer-use-capture.png"))
762
+ filename = media_artifacts.safe_filename(
763
+ str(artifact.get("filename") or "computer-use-capture.png"),
764
+ default=f"computer-use-{uuid.uuid4().hex}.png",
765
+ default_extension=".png",
766
+ )
767
return f"data:{mime};base64,{encoded}", Path(filename).stem
768
769
if path_error is not None:
@@ -845,19 +849,6 @@ class ComputerUseRemote(Tool):
849
return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
850
851
848
-def _safe_filename(value: str) -> str:
849
- cleaned = "".join(char if char.isalnum() or char in {"-", "_", "."} else "_" for char in value)
850
- cleaned = cleaned.strip("._") or f"computer-use-{uuid.uuid4().hex}.png"
851
- if "." not in cleaned:
852
- cleaned += ".png"
853
- return cleaned
854
-
855
-
856
-def _estimated_base64_decoded_size(data: str) -> int:
857
- compact_length = sum(1 for char in data if not char.isspace())
858
- return (compact_length * 3) // 4
859
-
860
-
852
def _sanitize_tool_text(value: str) -> str:
853
try:
854
from helpers.strings import sanitize_string
plugins/_browser/helpers/connector_runtime.py
+7
-15
@@ -9,7 +9,7 @@ from pathlib import Path
9
from typing import Any
10
from urllib.parse import urlparse
11
12
-from helpers import ephemeral_images
12
+from helpers import ephemeral_images, media_artifacts
13
14
try:
15
from helpers.ws import NAMESPACE
@@ -425,13 +425,17 @@ class ConnectorBrowserRuntime:
425
data = str(artifact.get("data") or "")
426
if not data:
427
return result
428
- estimated_size = _estimated_base64_decoded_size(data)
428
+ estimated_size = media_artifacts.estimated_base64_decoded_size(data)
429
if estimated_size > MAX_ARTIFACT_SIZE_BYTES:
430
raise RuntimeError(
431
"Host browser artifact is too large to attach safely "
432
f"({estimated_size} bytes, limit {MAX_ARTIFACT_SIZE_BYTES} bytes)."
433
)
434
- filename = _safe_filename(str(artifact.get("filename") or "host-browser.jpg"))
434
+ filename = media_artifacts.safe_filename(
435
+ str(artifact.get("filename") or "host-browser.jpg"),
436
+ default=f"host-browser-{uuid.uuid4().hex}.jpg",
437
+ default_extension=".jpg",
438
+ )
439
try:
440
ref = ephemeral_images.put_image(
441
context_id=self.context_id,
@@ -558,15 +562,3 @@ def _api_base_is_local(api_base: str) -> bool:
562
hostname = (parsed.hostname or "").strip().lower()
563
return hostname in _LOCAL_HOSTS
564
561
-
562
-def _safe_filename(value: str) -> str:
563
- cleaned = "".join(char if char.isalnum() or char in {"-", "_", "."} else "_" for char in value)
564
- cleaned = cleaned.strip("._") or f"host-browser-{uuid.uuid4().hex}.jpg"
565
- if "." not in cleaned:
566
- cleaned += ".jpg"
567
- return cleaned
568
-
569
-
570
-def _estimated_base64_decoded_size(data: str) -> int:
571
- compact_length = sum(1 for char in data if not char.isspace())
572
- return (compact_length * 3) // 4
tests/test_mcp_handler_multimodal.py
new
+334
@@ -0,0 +1,334 @@
1
+from __future__ import annotations
2
+
3
+import asyncio
4
+import base64
5
+import importlib
6
+import sys
7
+from dataclasses import dataclass
8
+from pathlib import Path
9
+from types import ModuleType, SimpleNamespace
10
+
11
+import pytest
12
+
13
+
14
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
15
+if str(PROJECT_ROOT) not in sys.path:
16
+ sys.path.insert(0, str(PROJECT_ROOT))
17
+
18
+
19
+@dataclass
20
+class _StubResponse:
21
+ message: str
22
+ break_loop: bool
23
+ additional: dict | None = None
24
+
25
+
26
+class _StubTool:
27
+ def __init__(
28
+ self,
29
+ agent=None,
30
+ name="",
31
+ method=None,
32
+ args=None,
33
+ message="",
34
+ loop_data=None,
35
+ **kwargs,
36
+ ):
37
+ self.agent = agent
38
+ self.name = name
39
+ self.method = method
40
+ self.args = args or {}
41
+ self.message = message
42
+ self.loop_data = loop_data
43
+ self.log = None
44
+
45
+ def nice_key(self, key: str) -> str:
46
+ return key
47
+
48
+
49
+class _FakeContent(SimpleNamespace):
50
+ pass
51
+
52
+
53
+class _FakeCallToolResult(SimpleNamespace):
54
+ pass
55
+
56
+
57
+@pytest.fixture
58
+def mcp_handler_module(monkeypatch, tmp_path):
59
+ monkeypatch.delitem(sys.modules, "helpers.mcp_handler", raising=False)
60
+
61
+ agent_module = ModuleType("agent")
62
+ agent_module.AgentContext = type("AgentContext", (), {})
63
+ agent_module.Agent = type("Agent", (), {})
64
+ agent_module.LoopData = type("LoopData", (), {})
65
+ monkeypatch.setitem(sys.modules, "agent", agent_module)
66
+
67
+ tool_module = ModuleType("helpers.tool")
68
+ tool_module.Response = _StubResponse
69
+ tool_module.Tool = _StubTool
70
+ monkeypatch.setitem(sys.modules, "helpers.tool", tool_module)
71
+
72
+ settings_module = ModuleType("helpers.settings")
73
+ monkeypatch.setitem(sys.modules, "helpers.settings", settings_module)
74
+
75
+ history_module = ModuleType("helpers.history")
76
+ history_module.RawMessage = lambda **kwargs: dict(kwargs)
77
+ monkeypatch.setitem(sys.modules, "helpers.history", history_module)
78
+
79
+ mcp_module = ModuleType("mcp")
80
+ mcp_module.ClientSession = type("ClientSession", (), {})
81
+ mcp_module.StdioServerParameters = type("StdioServerParameters", (), {})
82
+ monkeypatch.setitem(sys.modules, "mcp", mcp_module)
83
+
84
+ mcp_client_stdio = ModuleType("mcp.client.stdio")
85
+ mcp_client_stdio.stdio_client = lambda *args, **kwargs: None
86
+ monkeypatch.setitem(sys.modules, "mcp.client.stdio", mcp_client_stdio)
87
+
88
+ mcp_client_sse = ModuleType("mcp.client.sse")
89
+ mcp_client_sse.sse_client = lambda *args, **kwargs: None
90
+ monkeypatch.setitem(sys.modules, "mcp.client.sse", mcp_client_sse)
91
+
92
+ mcp_client_streamable_http = ModuleType("mcp.client.streamable_http")
93
+ mcp_client_streamable_http.streamablehttp_client = lambda *args, **kwargs: None
94
+ monkeypatch.setitem(
95
+ sys.modules,
96
+ "mcp.client.streamable_http",
97
+ mcp_client_streamable_http,
98
+ )
99
+
100
+ mcp_shared_message = ModuleType("mcp.shared.message")
101
+ mcp_shared_message.SessionMessage = type("SessionMessage", (), {})
102
+ monkeypatch.setitem(sys.modules, "mcp.shared.message", mcp_shared_message)
103
+
104
+ mcp_types = ModuleType("mcp.types")
105
+ mcp_types.CallToolResult = _FakeCallToolResult
106
+ mcp_types.ListToolsResult = type("ListToolsResult", (), {})
107
+ monkeypatch.setitem(sys.modules, "mcp.types", mcp_types)
108
+
109
+ module = importlib.import_module("helpers.mcp_handler")
110
+
111
+ class _SilentPrintStyle:
112
+ def __init__(self, *args, **kwargs):
113
+ pass
114
+
115
+ def print(self, *args, **kwargs):
116
+ return self
117
+
118
+ def stream(self, *args, **kwargs):
119
+ return self
120
+
121
+ def _fake_get_abs_path(*parts):
122
+ return str(tmp_path.joinpath(*parts))
123
+
124
+ def _fake_normalize_a0_path(path: str) -> str:
125
+ path_obj = Path(path)
126
+ try:
127
+ rel = path_obj.relative_to(tmp_path)
128
+ except ValueError:
129
+ return str(path_obj)
130
+ return "/a0/" + str(rel).replace("\\", "/")
131
+
132
+ monkeypatch.setattr(module, "PrintStyle", _SilentPrintStyle)
133
+ monkeypatch.setattr(module.media_artifacts.files, "get_abs_path", _fake_get_abs_path)
134
+ monkeypatch.setattr(module.media_artifacts.files, "normalize_a0_path", _fake_normalize_a0_path)
135
+ return module, tmp_path
136
+
137
+
138
+def _agent_recorder(context_id: str = "ctx-mcp"):
139
+ tool_results: list[tuple[tuple, dict]] = []
140
+ messages: list[tuple[tuple, dict]] = []
141
+ updates: list[dict] = []
142
+ warnings: list[dict] = []
143
+ agent = SimpleNamespace(
144
+ agent_name="Agent Zero",
145
+ context=SimpleNamespace(
146
+ id=context_id,
147
+ log=SimpleNamespace(log=lambda **kwargs: warnings.append(kwargs)),
148
+ ),
149
+ hist_add_tool_result=lambda *args, **kwargs: tool_results.append((args, kwargs)),
150
+ hist_add_message=lambda *args, **kwargs: messages.append((args, kwargs)),
151
+ )
152
+ log = SimpleNamespace(id="mcp-log", update=lambda **kwargs: updates.append(kwargs))
153
+ return agent, log, tool_results, messages, updates, warnings
154
+
155
+
156
+def test_mcp_image_content_becomes_history_image_attachment(mcp_handler_module, monkeypatch):
157
+ module, _tmp_path = mcp_handler_module
158
+ agent, log, tool_results, messages, updates, warnings = _agent_recorder()
159
+ image_b64 = base64.b64encode(b"image-bytes").decode("ascii")
160
+ result = _FakeCallToolResult(
161
+ content=[_FakeContent(type="image", data=image_b64, mimeType="image/webp")],
162
+ isError=False,
163
+ )
164
+
165
+ class _FakeConfig:
166
+ async def call_tool(self, name, kwargs):
167
+ return result
168
+
169
+ monkeypatch.setattr(module.MCPConfig, "get_instance", lambda: _FakeConfig())
170
+
171
+ tool = module.MCPTool(
172
+ agent=agent,
173
+ name="venice_image",
174
+ method=None,
175
+ args={},
176
+ message="",
177
+ loop_data=None,
178
+ )
179
+ tool.log = log
180
+
181
+ response = asyncio.run(tool.execute())
182
+
183
+ assert "[Tool returned no textual content]" not in response.message
184
+ assert response.message == "MCP returned image attachment (image/webp, 11 bytes)."
185
+ assert response.additional is not None
186
+ data_url = response.additional["raw_content"][0]["image_url"]["url"]
187
+ assert data_url == f"data:image/webp;base64,{image_b64}"
188
+
189
+ asyncio.run(tool.after_execution(response))
190
+
191
+ assert tool_results[0][0] == ("venice_image", response.message)
192
+ raw_message = messages[0][1]["content"]
193
+ assert raw_message["raw_content"][0]["image_url"]["url"] == data_url
194
+ assert messages[0][1]["tokens"] == module.MCP_MEDIA_TOKENS_ESTIMATE
195
+ assert updates[-1]["content"] == response.message
196
+ assert warnings == []
197
+
198
+
199
+def test_mcp_audio_content_is_saved_instead_of_discarded(mcp_handler_module, monkeypatch):
200
+ module, tmp_path = mcp_handler_module
201
+ agent, log, tool_results, messages, updates, warnings = _agent_recorder()
202
+ audio_b64 = base64.b64encode(b"audio-bytes").decode("ascii")
203
+ result = _FakeCallToolResult(
204
+ content=[_FakeContent(type="audio", data=audio_b64, mimeType="audio/mpeg")],
205
+ isError=False,
206
+ )
207
+
208
+ class _FakeConfig:
209
+ async def call_tool(self, name, kwargs):
210
+ return result
211
+
212
+ monkeypatch.setattr(module.MCPConfig, "get_instance", lambda: _FakeConfig())
213
+
214
+ tool = module.MCPTool(
215
+ agent=agent,
216
+ name="venice_audio",
217
+ method=None,
218
+ args={},
219
+ message="",
220
+ loop_data=None,
221
+ )
222
+ tool.log = log
223
+
224
+ response = asyncio.run(tool.execute())
225
+
226
+ assert response.additional is None
227
+ assert "[Tool returned no textual content]" not in response.message
228
+ assert "Saved MCP audio attachment (audio/mpeg, 11 bytes) to /a0/tmp/mcp/ctx_mcp/venice_audio/" in response.message
229
+ saved_path = response.message.split(" to ", 1)[1].rstrip(".")
230
+ assert (tmp_path / saved_path.removeprefix("/a0/")).exists()
231
+
232
+ asyncio.run(tool.after_execution(response))
233
+
234
+ assert tool_results[0][0] == ("venice_audio", response.message)
235
+ assert messages == []
236
+ assert updates[-1]["content"] == response.message
237
+ assert warnings == []
238
+
239
+
240
+def test_mcp_image_resource_blob_becomes_history_image_attachment(mcp_handler_module, monkeypatch):
241
+ module, _tmp_path = mcp_handler_module
242
+ agent, log, tool_results, messages, updates, warnings = _agent_recorder()
243
+ image_b64 = base64.b64encode(b"resource-image").decode("ascii")
244
+ result = _FakeCallToolResult(
245
+ content=[
246
+ _FakeContent(
247
+ type="resource",
248
+ resource=_FakeContent(
249
+ uri="memory://venice/image.webp",
250
+ mimeType="image/webp",
251
+ blob=image_b64,
252
+ ),
253
+ )
254
+ ],
255
+ isError=False,
256
+ )
257
+
258
+ class _FakeConfig:
259
+ async def call_tool(self, name, kwargs):
260
+ return result
261
+
262
+ monkeypatch.setattr(module.MCPConfig, "get_instance", lambda: _FakeConfig())
263
+
264
+ tool = module.MCPTool(
265
+ agent=agent,
266
+ name="venice_resource_image",
267
+ method=None,
268
+ args={},
269
+ message="",
270
+ loop_data=None,
271
+ )
272
+ tool.log = log
273
+
274
+ response = asyncio.run(tool.execute())
275
+
276
+ assert response.message == "MCP returned resource image attachment (image/webp, 14 bytes)."
277
+ assert response.additional is not None
278
+ data_url = response.additional["raw_content"][0]["image_url"]["url"]
279
+ assert data_url == f"data:image/webp;base64,{image_b64}"
280
+
281
+ asyncio.run(tool.after_execution(response))
282
+
283
+ assert tool_results[0][0] == ("venice_resource_image", response.message)
284
+ raw_message = messages[0][1]["content"]
285
+ assert raw_message["raw_content"][0]["image_url"]["url"] == data_url
286
+ assert updates[-1]["content"] == response.message
287
+ assert warnings == []
288
+
289
+
290
+def test_mcp_resource_text_is_preserved(mcp_handler_module, monkeypatch):
291
+ module, _tmp_path = mcp_handler_module
292
+ agent, log, tool_results, messages, updates, warnings = _agent_recorder()
293
+ result = _FakeCallToolResult(
294
+ content=[
295
+ _FakeContent(
296
+ type="resource",
297
+ resource=_FakeContent(
298
+ uri="memory://venice/caption.txt",
299
+ mimeType="text/plain",
300
+ text="Generated caption text",
301
+ ),
302
+ )
303
+ ],
304
+ isError=False,
305
+ )
306
+
307
+ class _FakeConfig:
308
+ async def call_tool(self, name, kwargs):
309
+ return result
310
+
311
+ monkeypatch.setattr(module.MCPConfig, "get_instance", lambda: _FakeConfig())
312
+
313
+ tool = module.MCPTool(
314
+ agent=agent,
315
+ name="venice_resource",
316
+ method=None,
317
+ args={},
318
+ message="",
319
+ loop_data=None,
320
+ )
321
+ tool.log = log
322
+
323
+ response = asyncio.run(tool.execute())
324
+
325
+ assert response.additional is None
326
+ assert "Resource memory://venice/caption.txt:" in response.message
327
+ assert "Generated caption text" in response.message
328
+
329
+ asyncio.run(tool.after_execution(response))
330
+
331
+ assert tool_results[0][0] == ("venice_resource", response.message)
332
+ assert messages == []
333
+ assert updates[-1]["content"] == response.message
334
+ assert warnings == []
tests/test_media_artifacts.py
new
+66
@@ -0,0 +1,66 @@
1
+from __future__ import annotations
2
+
3
+import base64
4
+import sys
5
+from pathlib import Path
6
+
7
+import pytest
8
+
9
+
10
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
11
+if str(PROJECT_ROOT) not in sys.path:
12
+ sys.path.insert(0, str(PROJECT_ROOT))
13
+
14
+from helpers import media_artifacts
15
+
16
+
17
+def test_image_data_url_from_base64_compacts_and_normalizes_mime():
18
+ encoded = " \n" + base64.b64encode(b"image-bytes").decode("ascii") + "\n"
19
+
20
+ image = media_artifacts.image_data_url_from_base64(
21
+ encoded,
22
+ mime_type="IMAGE/WEBP",
23
+ )
24
+
25
+ assert image.mime == "image/webp"
26
+ assert image.size == 11
27
+ assert image.url == "data:image/webp;base64,aW1hZ2UtYnl0ZXM="
28
+
29
+
30
+def test_decode_base64_payload_rejects_empty_invalid_and_oversized_data():
31
+ with pytest.raises(media_artifacts.EmptyBase64Data):
32
+ media_artifacts.decode_base64_payload(" \n ")
33
+
34
+ with pytest.raises(media_artifacts.InvalidBase64Data):
35
+ media_artifacts.decode_base64_payload("not base64")
36
+
37
+ with pytest.raises(media_artifacts.ArtifactTooLarge) as exc_info:
38
+ media_artifacts.decode_base64_payload("ZmFrZQ==", max_bytes=2)
39
+
40
+ assert exc_info.value.size == media_artifacts.estimated_base64_decoded_size("ZmFrZQ==")
41
+ assert exc_info.value.limit == 2
42
+
43
+
44
+def test_save_base64_artifact_uses_uri_filename_and_normalized_a0_path(monkeypatch, tmp_path):
45
+ def fake_get_abs_path(*parts):
46
+ return str(tmp_path.joinpath(*parts))
47
+
48
+ def fake_normalize_a0_path(path: str):
49
+ return "/a0/" + str(Path(path).relative_to(tmp_path)).replace("\\", "/")
50
+
51
+ monkeypatch.setattr(media_artifacts.files, "get_abs_path", fake_get_abs_path)
52
+ monkeypatch.setattr(media_artifacts.files, "normalize_a0_path", fake_normalize_a0_path)
53
+
54
+ artifact = media_artifacts.save_base64_artifact(
55
+ base64.b64encode(b"audio-bytes").decode("ascii"),
56
+ mime_type="audio/mpeg",
57
+ directory_parts=("tmp", "media-test"),
58
+ preferred_name="memory://venice/generated track.mp3",
59
+ default_filename="fallback.bin",
60
+ )
61
+
62
+ assert artifact.mime == "audio/mpeg"
63
+ assert artifact.size == 11
64
+ assert artifact.path.startswith("/a0/tmp/media-test/generated_track_")
65
+ assert artifact.path.endswith(".mp3")
66
+ assert (tmp_path / artifact.path.removeprefix("/a0/")).read_bytes() == b"audio-bytes"