* MCP image handling * clean upg * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: return MCP error results so image content is not dropped FastMCP client.call_tool raises ToolError by default on an is_error result, so it never reaches _flatten_result and any returned image is dropped. Pass raise_on_error=False so error results flow through _flatten_result and keep their images. Transport failures still raise and hit the existing handler. Add a regression test for the real path. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: accept raise_on_error kwarg in MCP test fake clients The call_tool_sync fix passes raise_on_error=False to client.call_tool. Update the fake MCP clients patched into mcp_client._client so their call_tool signatures accept the keyword, keeping the stdio/servers MCP test suites green. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: tighten MCP raise_on_error rationale comments * Studio: only strip MCP image sentinel when suffix is a valid image envelope * Studio: validate MCP image envelope in chat adapter and keep base64 out of exports * Studio: sanitize MCP images in all export formats and fall through to sandbox parser on invalid marker --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: danielhanchen <danielhanchen@gmail.com>
177 lines
6 KiB
Python
177 lines
6 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
|
|
if _BACKEND_DIR not in sys.path:
|
|
sys.path.insert(0, _BACKEND_DIR)
|
|
|
|
from core.inference import mcp_client
|
|
from core.inference.mcp_client import (
|
|
MAX_IMAGE_PAYLOAD_CHARS,
|
|
MCP_IMAGES_SENTINEL,
|
|
_flatten_result,
|
|
call_tool_sync,
|
|
)
|
|
from core.inference.tool_loop_controller import is_tool_error, strip_result_for_model
|
|
|
|
PNG_B64 = "iVBORw0KGgoAAAANSUhEUg=="
|
|
|
|
|
|
def _text(value: str) -> SimpleNamespace:
|
|
return SimpleNamespace(type = "text", text = value)
|
|
|
|
|
|
def _image(data: str = PNG_B64, mime: str = "image/png") -> SimpleNamespace:
|
|
return SimpleNamespace(type = "image", data = data, mimeType = mime)
|
|
|
|
|
|
def _result(
|
|
*blocks,
|
|
is_error = False,
|
|
structured = None,
|
|
) -> SimpleNamespace:
|
|
return SimpleNamespace(
|
|
content = list(blocks),
|
|
is_error = is_error,
|
|
structured_content = structured,
|
|
)
|
|
|
|
|
|
def test_text_only_result_unchanged():
|
|
assert _flatten_result(_result(_text("hello"))) == "hello"
|
|
|
|
|
|
def test_image_only_result_keeps_image_and_notes_model():
|
|
flat = _flatten_result(_result(_image()))
|
|
body, payload = flat.split("\n" + MCP_IMAGES_SENTINEL, 1)
|
|
assert body == "[1 image attached; displayed to the user]"
|
|
assert json.loads(payload) == [{"data": PNG_B64, "mimeType": "image/png"}]
|
|
|
|
|
|
def test_text_plus_image_keeps_both():
|
|
flat = _flatten_result(_result(_text("Took a screenshot"), _image()))
|
|
body, payload = flat.split("\n" + MCP_IMAGES_SENTINEL, 1)
|
|
assert body == "Took a screenshot\n[1 image attached; displayed to the user]"
|
|
assert json.loads(payload)[0]["mimeType"] == "image/png"
|
|
|
|
|
|
def test_multiple_images_pluralized():
|
|
flat = _flatten_result(_result(_image(), _image(mime = "image/jpeg")))
|
|
body, payload = flat.split("\n" + MCP_IMAGES_SENTINEL, 1)
|
|
assert "[2 images attached; displayed to the user]" in body
|
|
assert [img["mimeType"] for img in json.loads(payload)] == ["image/png", "image/jpeg"]
|
|
|
|
|
|
def test_strip_result_for_model_drops_image_payload():
|
|
flat = _flatten_result(_result(_text("Took a screenshot"), _image()))
|
|
stripped = strip_result_for_model(flat)
|
|
assert stripped == "Took a screenshot\n[1 image attached; displayed to the user]"
|
|
assert PNG_B64 not in stripped
|
|
|
|
|
|
def test_strip_preserves_literal_mcp_sentinel_in_text():
|
|
# A tool that legitimately returns text containing the marker (e.g. reading
|
|
# source/docs that quote it) must not be truncated: the suffix is not a
|
|
# valid JSON image array.
|
|
text = "before\n__MCP_IMAGES__: literal from source\nafter"
|
|
assert strip_result_for_model(text) == text
|
|
|
|
|
|
def test_strip_preserves_non_image_json_after_marker():
|
|
text = 'log line\n__MCP_IMAGES__:["not", "image", "dicts"]'
|
|
assert strip_result_for_model(text) == text
|
|
|
|
|
|
def test_strip_removes_only_valid_terminal_envelope():
|
|
text = (
|
|
"Earlier mention: __MCP_IMAGES__: is documented here"
|
|
"\n[1 image attached; displayed to the user]"
|
|
'\n__MCP_IMAGES__:[{"data": "AAAA", "mimeType": "image/png"}]'
|
|
)
|
|
assert strip_result_for_model(text) == (
|
|
"Earlier mention: __MCP_IMAGES__: is documented here"
|
|
"\n[1 image attached; displayed to the user]"
|
|
)
|
|
|
|
|
|
def test_strip_still_handles_images_and_rag_sentinels():
|
|
assert strip_result_for_model("output\n__IMAGES__:['a.png']") == "output"
|
|
assert strip_result_for_model("answer\n__RAG_SOURCES__:[{}]") == "answer"
|
|
|
|
|
|
def test_error_result_keeps_error_prefix_and_images():
|
|
flat = _flatten_result(_result(_text("boom"), _image(), is_error = True))
|
|
assert flat.startswith("Error: boom")
|
|
assert is_tool_error(flat)
|
|
assert MCP_IMAGES_SENTINEL in flat
|
|
|
|
|
|
def test_image_only_error_no_longer_reports_no_content():
|
|
flat = _flatten_result(_result(_image(), is_error = True))
|
|
assert flat.startswith("Error: [1 image attached")
|
|
assert "tool returned no content" not in flat
|
|
|
|
|
|
def test_oversized_image_omitted_with_note():
|
|
huge = "A" * (MAX_IMAGE_PAYLOAD_CHARS + 1)
|
|
flat = _flatten_result(_result(_image(data = huge)))
|
|
assert flat == "[1 image omitted (too large)]"
|
|
assert MCP_IMAGES_SENTINEL not in flat
|
|
|
|
|
|
def test_oversized_budget_shared_across_images():
|
|
big = "A" * (MAX_IMAGE_PAYLOAD_CHARS - 10)
|
|
flat = _flatten_result(_result(_image(data = big), _image()))
|
|
body, payload = flat.split("\n" + MCP_IMAGES_SENTINEL, 1)
|
|
assert "1 image attached" in body
|
|
assert "1 image omitted (too large)" in body
|
|
images = json.loads(payload)
|
|
assert len(images) == 1 and images[0]["data"] == big
|
|
|
|
|
|
def test_non_image_binary_block_still_ignored():
|
|
flat = _flatten_result(
|
|
_result(SimpleNamespace(type = "audio", data = PNG_B64, mimeType = "audio/wav"))
|
|
)
|
|
assert flat == ""
|
|
|
|
|
|
def test_structured_content_fallback_still_used():
|
|
flat = _flatten_result(_result(structured = {"ok": True}))
|
|
assert flat == "{'ok': True}"
|
|
|
|
|
|
def test_call_tool_sync_passes_raise_on_error_false_and_keeps_error_images(monkeypatch):
|
|
# Guards that call_tool_sync passes raise_on_error=False, so an is_error result
|
|
# with image content reaches _flatten_result instead of FastMCP raising ToolError.
|
|
seen = {}
|
|
|
|
class _FakeClient:
|
|
async def call_tool(
|
|
self,
|
|
name,
|
|
args,
|
|
raise_on_error = True,
|
|
):
|
|
seen["raise_on_error"] = raise_on_error
|
|
return _result(_text("boom"), _image(), is_error = True)
|
|
|
|
@contextlib.asynccontextmanager
|
|
async def _fake_client(url, headers, use_oauth):
|
|
yield _FakeClient()
|
|
|
|
monkeypatch.setattr(mcp_client, "_client", _fake_client)
|
|
out = call_tool_sync("http://x", None, "take_screenshot", {})
|
|
|
|
assert seen["raise_on_error"] is False
|
|
assert out.startswith("Error: boom")
|
|
assert MCP_IMAGES_SENTINEL in out
|
|
assert is_tool_error(out)
|