mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-14 16:08:41 +00:00
* fix(mcp): resolve drive-qualified paths in file reference rewriting
urlparse reads a Windows drive prefix ("C:/...") as the URI scheme, so
_local_path_from_uri() returned None for every drive-qualified path and
MCP file references were never rewritten to /mnt/user-data/... virtual
paths on Windows hosts. file:// URIs were parsed with urlparse().path
alone, which also drops the drive qualifier.
- resolve file URIs through url2pathname so the /C:/... form keeps its
drive, and treat single-letter schemes as bare drive paths;
- match drive-qualified absolute paths in the free-text reference regex;
- build test URIs with Path.as_uri() and anchor absolute-path fixtures
at tmp_path so expectations are host-portable, and cover the
drive-prefix scheme quirk explicitly.
* fix(mcp): decode file URIs once and guard Windows path rejection
Review follow-up on #5242:
- url2pathname already percent-decodes on both platforms, so the extra
unquote() wrapper decoded references twice and broke filenames that
contain a literal '%'. Pass parsed.path straight through.
- On Windows, url2pathname raises OSError for paths containing a raw
'|' (e.g. file:///C:/tmp/a|b.png); catch it so one odd URI cannot
abort the whole best-effort rewrite pass.
- The relative-reference regex alternative now accepts backslash
separators, which is what Windows servers print for relative paths.
- Add Windows-only regressions driving the backslash free-text form
and a file:///C:/ URI end to end, plus the OSError rejection.
* fix(mcp): resolve file://C:/… URIs with a drive-qualified authority
Review follow-up on #5242 (two-slash Windows drive form):
- Some Windows tools emit file://C:/… without the third slash, which
puts the drive in the URI authority. Consult parsed.netloc: rebuild
the /C:/… URL path for a drive-qualified authority, keep the current
handling for empty and localhost authorities, and reject any other
host instead of silently treating its path as local.
- Extend the free-text regex so the two-slash form matches as one token
instead of the previous stray e://… mid-token match.
- Cover the two-slash form at the _local_path_from_uri unit, through
_rewrite_local_paths_in_text, and add a portable case asserting that
a remote-host file URI is ignored.
* fix(mcp): anchor the drive-qualified text alternative with a lookbehind
Review follow-up on #5242:
- [A-Za-z]:[\/] could steal a token at an earlier scan position:
for file:/tmp/… (single-slash form per RFC 8089 / Java File.toURI())
the match became e:/tmp/…, which resolves as a bare drive path and
left the reference unrewritten where /tmp/… was rewritten before.
Anchor the alternative with (?<![\w.-]) so word:/… shapes fall
through to the earlier alternatives.
- Add the missing coverage for the relative alternative's backslash
support (temp\page.yml through _rewrite_local_paths_in_text) and
a portable regression pinning the file:/… tokenization.
694 lines
30 KiB
Python
694 lines
30 KiB
Python
"""Tests for translating MCP-produced local files into virtual sandbox paths.
|
||
|
||
Regression coverage for GitHub issue #3597: Playwright MCP (and similar stdio
|
||
servers) write files to a path the sandbox/artifact API cannot resolve. The MCP
|
||
tool wrapper pins stdio cwd/temp under the thread's mounted user-data tree and
|
||
rewrites returned file references to ``/mnt/user-data/...`` virtual paths.
|
||
"""
|
||
|
||
import os
|
||
from pathlib import Path
|
||
from unittest.mock import patch
|
||
|
||
import pytest
|
||
from mcp.types import CallToolResult, ResourceLink, TextContent
|
||
|
||
from deerflow.config.paths import VIRTUAL_PATH_PREFIX, Paths
|
||
from deerflow.constants import MCP_TMP_SUBDIR
|
||
from deerflow.mcp import tools as mcp_tools
|
||
|
||
|
||
@pytest.fixture
|
||
def paths(tmp_path: Path) -> Paths:
|
||
return Paths(tmp_path)
|
||
|
||
|
||
def _patch_paths(paths: Paths):
|
||
return patch("deerflow.mcp.tools.get_paths", return_value=paths)
|
||
|
||
|
||
def _workspace_file(paths: Paths, relative_path: str, *, content: bytes = b"data") -> Path:
|
||
file_path = paths.sandbox_work_dir("t1", user_id="u1") / relative_path
|
||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||
file_path.write_bytes(content)
|
||
return file_path
|
||
|
||
|
||
class TestLocalPathFromUri:
|
||
def test_file_uri(self, tmp_path: Path):
|
||
src = tmp_path / "shot.png"
|
||
assert mcp_tools._local_path_from_uri(src.as_uri()) == src
|
||
|
||
def test_bare_absolute_path(self, tmp_path: Path):
|
||
src = tmp_path / "data" / "out.pdf"
|
||
assert mcp_tools._local_path_from_uri(str(src)) == src
|
||
|
||
def test_file_uri_with_url_encoded_spaces(self, tmp_path: Path):
|
||
src = tmp_path / "my shot.png"
|
||
assert mcp_tools._local_path_from_uri(src.as_uri()) == src
|
||
|
||
def test_remote_uri_is_ignored(self):
|
||
assert mcp_tools._local_path_from_uri("https://example.com/a.png") is None
|
||
assert mcp_tools._local_path_from_uri("data:image/png;base64,AAAA") is None
|
||
|
||
def test_malformed_uri_is_ignored(self):
|
||
assert mcp_tools._local_path_from_uri("//[::1/foo.png") is None
|
||
|
||
def test_relative_path_is_ignored_without_base_dir(self):
|
||
assert mcp_tools._local_path_from_uri("relative/path.txt") is None
|
||
|
||
def test_relative_path_uses_base_dir_when_provided(self, tmp_path: Path):
|
||
assert mcp_tools._local_path_from_uri("./shot.png", base_dir=tmp_path) == tmp_path / "shot.png"
|
||
assert mcp_tools._local_path_from_uri("temp/page.yml", base_dir=tmp_path) == tmp_path / "temp/page.yml"
|
||
|
||
def test_file_uri_with_relative_path_is_ignored(self):
|
||
assert mcp_tools._local_path_from_uri("file:relative.txt") is None
|
||
|
||
def test_file_uri_with_empty_path_is_ignored(self):
|
||
assert mcp_tools._local_path_from_uri("file://") is None
|
||
|
||
def test_file_uri_with_localhost_host(self, tmp_path: Path):
|
||
# file://localhost/abs/path is the host form of file:///abs/path.
|
||
src = tmp_path / "shot.png"
|
||
assert mcp_tools._local_path_from_uri(src.as_uri().replace("file://", "file://localhost", 1)) == src
|
||
|
||
def test_windows_drive_letter_path_is_resolved(self):
|
||
# urlparse reads a Windows drive prefix ("C:/...") as the URI scheme.
|
||
# On Windows hosts it must still resolve as a bare local path; on
|
||
# POSIX it is not a local path at all.
|
||
path = mcp_tools._local_path_from_uri("C:/Users/shot.png")
|
||
if os.name == "nt":
|
||
assert path == Path("C:/Users/shot.png")
|
||
else:
|
||
assert path is None
|
||
|
||
@pytest.mark.skipif(os.name != "nt", reason="a raw '|' in a file URI path rejects with OSError only on Windows")
|
||
def test_windows_url2pathname_oserror_is_left_untouched(self):
|
||
assert mcp_tools._local_path_from_uri("file:///C:/tmp/a|b.png") is None
|
||
|
||
@pytest.mark.skipif(os.name != "nt", reason="exercises the file://C:/… two-slash Windows drive URI form")
|
||
def test_windows_two_slash_file_uri_resolves_drive(self):
|
||
assert mcp_tools._local_path_from_uri("file://C:/Users/shot.png") == Path("C:/Users/shot.png")
|
||
|
||
def test_remote_host_file_uri_is_ignored(self):
|
||
assert mcp_tools._local_path_from_uri("file://example.com/a.png") is None
|
||
|
||
def test_empty_is_ignored(self):
|
||
assert mcp_tools._local_path_from_uri("") is None
|
||
|
||
|
||
class TestLocalUriToVirtualPath:
|
||
def test_workspace_file_translates_to_virtual_workspace_path(self, paths: Paths):
|
||
src = _workspace_file(paths, "temp/page.yml")
|
||
|
||
with _patch_paths(paths):
|
||
result = mcp_tools._local_uri_to_virtual_path(str(src), thread_id="t1", user_id="u1")
|
||
|
||
assert result == f"{VIRTUAL_PATH_PREFIX}/workspace/temp/page.yml"
|
||
|
||
def test_outputs_file_translates_without_copy(self, paths: Paths):
|
||
outputs = paths.sandbox_outputs_dir("t1", user_id="u1")
|
||
outputs.mkdir(parents=True)
|
||
src = outputs / "report.pdf"
|
||
src.write_bytes(b"pdf")
|
||
|
||
with _patch_paths(paths):
|
||
result = mcp_tools._local_uri_to_virtual_path(str(src), thread_id="t1", user_id="u1")
|
||
|
||
assert result == f"{VIRTUAL_PATH_PREFIX}/outputs/report.pdf"
|
||
assert list(outputs.iterdir()) == [src]
|
||
|
||
def test_relative_review_case_translates_against_cwd(self, paths: Paths):
|
||
workspace = paths.sandbox_work_dir("t1", user_id="u1")
|
||
_workspace_file(paths, "temp/page-2026-06-16T10-21-46-864Z.yml")
|
||
|
||
with _patch_paths(paths):
|
||
result = mcp_tools._local_uri_to_virtual_path(
|
||
"temp/page-2026-06-16T10-21-46-864Z.yml",
|
||
thread_id="t1",
|
||
user_id="u1",
|
||
source_base_dir=workspace,
|
||
)
|
||
|
||
assert result == f"{VIRTUAL_PATH_PREFIX}/workspace/temp/page-2026-06-16T10-21-46-864Z.yml"
|
||
|
||
def test_file_uri_inside_user_data_translates(self, paths: Paths):
|
||
src = _workspace_file(paths, "shot.png")
|
||
|
||
with _patch_paths(paths):
|
||
result = mcp_tools._local_uri_to_virtual_path(src.as_uri(), thread_id="t1", user_id="u1")
|
||
|
||
assert result == f"{VIRTUAL_PATH_PREFIX}/workspace/shot.png"
|
||
|
||
@pytest.mark.skipif(os.name != "nt", reason="exercises the file:///C:/... drive-qualified URI form")
|
||
def test_windows_file_uri_translates_to_virtual_path(self, paths: Paths):
|
||
src = _workspace_file(paths, "shot.png")
|
||
assert src.as_uri().startswith("file:///C:/")
|
||
|
||
with _patch_paths(paths):
|
||
result = mcp_tools._local_uri_to_virtual_path(src.as_uri(), thread_id="t1", user_id="u1")
|
||
|
||
assert result == f"{VIRTUAL_PATH_PREFIX}/workspace/shot.png"
|
||
|
||
def test_file_outside_user_data_is_not_exposed(self, tmp_path: Path, paths: Paths):
|
||
src = tmp_path / "outside.txt"
|
||
src.write_text("secret")
|
||
|
||
with _patch_paths(paths):
|
||
result = mcp_tools._local_uri_to_virtual_path(str(src), thread_id="t1", user_id="u1")
|
||
|
||
assert result is None
|
||
assert not paths.sandbox_outputs_dir("t1", user_id="u1").exists()
|
||
|
||
def test_missing_file_directory_and_remote_uri_are_ignored(self, tmp_path: Path, paths: Paths):
|
||
with _patch_paths(paths):
|
||
assert mcp_tools._local_uri_to_virtual_path(str(tmp_path / "missing.png"), thread_id="t1", user_id="u1") is None
|
||
assert mcp_tools._local_uri_to_virtual_path(str(tmp_path), thread_id="t1", user_id="u1") is None
|
||
assert mcp_tools._local_uri_to_virtual_path("https://example.com/a.png", thread_id="t1", user_id="u1") is None
|
||
|
||
def test_symlink_escape_is_not_exposed(self, tmp_path: Path, paths: Paths):
|
||
outside = tmp_path / "outside.txt"
|
||
outside.write_text("secret")
|
||
link = paths.sandbox_work_dir("t1", user_id="u1") / "link.txt"
|
||
link.parent.mkdir(parents=True)
|
||
try:
|
||
link.symlink_to(outside)
|
||
except (OSError, NotImplementedError):
|
||
pytest.skip("symlinks not supported on this platform")
|
||
|
||
with _patch_paths(paths):
|
||
result = mcp_tools._local_uri_to_virtual_path(str(link), thread_id="t1", user_id="u1")
|
||
|
||
assert result is None
|
||
|
||
|
||
class TestRewriteLocalPathsInText:
|
||
def test_review_case_temp_relative_path_is_rewritten(self, paths: Paths):
|
||
workspace = paths.sandbox_work_dir("t1", user_id="u1")
|
||
_workspace_file(paths, "temp/page-2026-06-16T10-21-46-864Z.yml")
|
||
text = "Saved as temp/page-2026-06-16T10-21-46-864Z.yml."
|
||
|
||
with _patch_paths(paths):
|
||
result = mcp_tools._rewrite_local_paths_in_text(text, thread_id="t1", user_id="u1", source_base_dir=workspace)
|
||
|
||
assert result == f"Saved as {VIRTUAL_PATH_PREFIX}/workspace/temp/page-2026-06-16T10-21-46-864Z.yml."
|
||
|
||
def test_relative_output_dir_path_is_rewritten(self, paths: Paths):
|
||
workspace = paths.sandbox_work_dir("t1", user_id="u1")
|
||
_workspace_file(paths, "artifacts/page.png")
|
||
text = "Screenshot saved to artifacts/page.png"
|
||
|
||
with _patch_paths(paths):
|
||
result = mcp_tools._rewrite_local_paths_in_text(text, thread_id="t1", user_id="u1", source_base_dir=workspace)
|
||
|
||
assert result == f"Screenshot saved to {VIRTUAL_PATH_PREFIX}/workspace/artifacts/page.png"
|
||
|
||
def test_absolute_output_dir_path_inside_user_data_is_rewritten(self, paths: Paths):
|
||
src = _workspace_file(paths, "absolute-output/page.png")
|
||
text = f"Screenshot saved to {src}"
|
||
|
||
with _patch_paths(paths):
|
||
result = mcp_tools._rewrite_local_paths_in_text(text, thread_id="t1", user_id="u1")
|
||
|
||
assert result == f"Screenshot saved to {VIRTUAL_PATH_PREFIX}/workspace/absolute-output/page.png"
|
||
|
||
def test_tmpdir_output_under_workspace_is_rewritten(self, paths: Paths):
|
||
src = _workspace_file(paths, ".mcp/tmp/page.png")
|
||
text = f"Saved to {src}"
|
||
|
||
with _patch_paths(paths):
|
||
result = mcp_tools._rewrite_local_paths_in_text(text, thread_id="t1", user_id="u1")
|
||
|
||
assert result == f"Saved to {VIRTUAL_PATH_PREFIX}/workspace/.mcp/tmp/page.png"
|
||
|
||
@pytest.mark.skipif(os.name != "nt", reason="exercises backslash drive-qualified paths in free text")
|
||
def test_windows_backslash_drive_path_in_text_is_rewritten(self, paths: Paths):
|
||
src = _workspace_file(paths, "shot.png")
|
||
text = f"Saved as {src}"
|
||
assert "\\" in text
|
||
|
||
with _patch_paths(paths):
|
||
result = mcp_tools._rewrite_local_paths_in_text(text, thread_id="t1", user_id="u1")
|
||
|
||
assert result == f"Saved as {VIRTUAL_PATH_PREFIX}/workspace/shot.png"
|
||
|
||
@pytest.mark.skipif(os.name != "nt", reason="exercises backslash relative paths in free text")
|
||
def test_windows_backslash_relative_path_in_text_is_rewritten(self, paths: Paths):
|
||
_workspace_file(paths, "temp/page.yml")
|
||
workspace = paths.sandbox_work_dir("t1", user_id="u1")
|
||
|
||
with _patch_paths(paths):
|
||
result = mcp_tools._rewrite_local_paths_in_text("Saved as temp\\page.yml", thread_id="t1", user_id="u1", source_base_dir=workspace)
|
||
|
||
assert result == f"Saved as {VIRTUAL_PATH_PREFIX}/workspace/temp/page.yml"
|
||
|
||
def test_single_slash_file_uri_is_matched_as_posix_absolute(self):
|
||
# file:/… (single slash, as RFC 8089 and Java's File.toURI() produce)
|
||
# must not be stolen mid-token by the drive-qualified alternative: the
|
||
# engine has to fall through to the /… absolute alternative.
|
||
match = mcp_tools._LOCAL_PATH_IN_TEXT_RE.search("Saved as file:/tmp/workspace/shot.png")
|
||
assert match.group(0) == "/tmp/workspace/shot.png"
|
||
|
||
@pytest.mark.skipif(os.name != "nt", reason="exercises the file://C:/… two-slash URI form in free text")
|
||
def test_windows_two_slash_file_uri_in_text_is_rewritten(self, paths: Paths):
|
||
src = _workspace_file(paths, "shot.png")
|
||
two_slash_uri = src.as_uri().replace("file:///", "file://", 1)
|
||
assert two_slash_uri.startswith("file://C:")
|
||
|
||
with _patch_paths(paths):
|
||
result = mcp_tools._rewrite_local_paths_in_text(f"Saved as {two_slash_uri}", thread_id="t1", user_id="u1")
|
||
|
||
assert result == f"Saved as {VIRTUAL_PATH_PREFIX}/workspace/shot.png"
|
||
|
||
def test_old_tmp_path_outside_user_data_is_left_untouched(self, tmp_path: Path, paths: Paths):
|
||
src = tmp_path / "playwright-mcp-output" / "page.png"
|
||
src.parent.mkdir()
|
||
src.write_bytes(b"png")
|
||
text = f"Saved to {src}"
|
||
|
||
with _patch_paths(paths):
|
||
result = mcp_tools._rewrite_local_paths_in_text(text, thread_id="t1", user_id="u1")
|
||
|
||
assert result == text
|
||
|
||
def test_malformed_path_like_text_is_left_untouched(self, paths: Paths):
|
||
text = "Saved at //[::1/foo.png"
|
||
|
||
with _patch_paths(paths):
|
||
result = mcp_tools._rewrite_local_paths_in_text(text, thread_id="t1", user_id="u1")
|
||
|
||
assert result == text
|
||
|
||
def test_oversized_path_like_text_is_left_untouched(self, paths: Paths):
|
||
workspace = paths.sandbox_work_dir("t1", user_id="u1")
|
||
text = f"手术室/重症监护室(OR/ICU)整体解决方案{'说明' * 200}"
|
||
|
||
with _patch_paths(paths):
|
||
result = mcp_tools._rewrite_local_paths_in_text(
|
||
text,
|
||
thread_id="t1",
|
||
user_id="u1",
|
||
source_base_dir=workspace,
|
||
)
|
||
|
||
assert result == text
|
||
|
||
def test_playwright_markdown_path_is_rewritten_twice_without_copy(self, paths: Paths):
|
||
workspace = paths.sandbox_work_dir("t1", user_id="u1")
|
||
_workspace_file(paths, ".playwright-mcp/page.png", content=b"png")
|
||
text = "### Result\n- [Screenshot](.playwright-mcp/page.png)\npath: '.playwright-mcp/page.png'"
|
||
|
||
with _patch_paths(paths):
|
||
result = mcp_tools._rewrite_local_paths_in_text(text, thread_id="t1", user_id="u1", source_base_dir=workspace)
|
||
|
||
assert result.count(f"{VIRTUAL_PATH_PREFIX}/workspace/.playwright-mcp/page.png") == 2
|
||
assert not paths.sandbox_outputs_dir("t1", user_id="u1").exists()
|
||
|
||
def test_bare_filename_is_rewritten_only_when_changed_file_matches_uniquely(self, paths: Paths):
|
||
workspace = paths.sandbox_work_dir("t1", user_id="u1")
|
||
src = _workspace_file(paths, "page-2026-06-16T10-21-46-864Z.yml")
|
||
text = "Saved as page-2026-06-16T10-21-46-864Z.yml."
|
||
|
||
with _patch_paths(paths):
|
||
result = mcp_tools._rewrite_local_paths_in_text(
|
||
text,
|
||
thread_id="t1",
|
||
user_id="u1",
|
||
source_base_dir=workspace,
|
||
changed_files=[src],
|
||
)
|
||
|
||
assert result == f"Saved as {VIRTUAL_PATH_PREFIX}/workspace/page-2026-06-16T10-21-46-864Z.yml."
|
||
|
||
def test_bare_filename_without_changed_file_is_left_untouched(self, paths: Paths):
|
||
workspace = paths.sandbox_work_dir("t1", user_id="u1")
|
||
_workspace_file(paths, "page.yml")
|
||
text = "Saved as page.yml"
|
||
|
||
with _patch_paths(paths):
|
||
result = mcp_tools._rewrite_local_paths_in_text(text, thread_id="t1", user_id="u1", source_base_dir=workspace)
|
||
|
||
assert result == text
|
||
|
||
def test_bare_filename_with_multiple_changed_matches_is_left_untouched(self, paths: Paths):
|
||
workspace = paths.sandbox_work_dir("t1", user_id="u1")
|
||
a = _workspace_file(paths, "a/page.yml")
|
||
b = _workspace_file(paths, "b/page.yml")
|
||
text = "Saved as page.yml"
|
||
|
||
with _patch_paths(paths):
|
||
result = mcp_tools._rewrite_local_paths_in_text(
|
||
text,
|
||
thread_id="t1",
|
||
user_id="u1",
|
||
source_base_dir=workspace,
|
||
changed_files=[a, b],
|
||
)
|
||
|
||
assert result == text
|
||
|
||
def test_bare_filename_does_not_rewrite_longer_filename(self, paths: Paths):
|
||
workspace = paths.sandbox_work_dir("t1", user_id="u1")
|
||
src = _workspace_file(paths, "page.yml")
|
||
text = "Backup is page.yml.bak"
|
||
|
||
with _patch_paths(paths):
|
||
result = mcp_tools._rewrite_local_paths_in_text(
|
||
text,
|
||
thread_id="t1",
|
||
user_id="u1",
|
||
source_base_dir=workspace,
|
||
changed_files=[src],
|
||
)
|
||
|
||
assert result == text
|
||
|
||
def test_multiple_distinct_paths_in_one_message_all_rewritten(self, paths: Paths):
|
||
workspace = paths.sandbox_work_dir("t1", user_id="u1")
|
||
_workspace_file(paths, "temp/a.png")
|
||
_workspace_file(paths, "temp/b.png")
|
||
text = "Saved temp/a.png and temp/b.png together."
|
||
|
||
with _patch_paths(paths):
|
||
result = mcp_tools._rewrite_local_paths_in_text(text, thread_id="t1", user_id="u1", source_base_dir=workspace)
|
||
|
||
assert result == (f"Saved {VIRTUAL_PATH_PREFIX}/workspace/temp/a.png and {VIRTUAL_PATH_PREFIX}/workspace/temp/b.png together.")
|
||
|
||
def test_markdown_link_in_parentheses_is_rewritten_without_eating_paren(self, paths: Paths):
|
||
workspace = paths.sandbox_work_dir("t1", user_id="u1")
|
||
_workspace_file(paths, "temp/shot.png")
|
||
text = "See  now"
|
||
|
||
with _patch_paths(paths):
|
||
result = mcp_tools._rewrite_local_paths_in_text(text, thread_id="t1", user_id="u1", source_base_dir=workspace)
|
||
|
||
assert result == f"See  now"
|
||
|
||
def test_path_for_nonexistent_relative_file_is_left_untouched(self, paths: Paths):
|
||
workspace = paths.sandbox_work_dir("t1", user_id="u1")
|
||
text = "Saved as temp/never-created.png"
|
||
|
||
with _patch_paths(paths):
|
||
result = mcp_tools._rewrite_local_paths_in_text(text, thread_id="t1", user_id="u1", source_base_dir=workspace)
|
||
|
||
assert result == text
|
||
|
||
def test_bare_filename_is_case_sensitive(self, paths: Paths):
|
||
workspace = paths.sandbox_work_dir("t1", user_id="u1")
|
||
src = _workspace_file(paths, "Page.yml")
|
||
text = "saved as page.yml"
|
||
|
||
with _patch_paths(paths):
|
||
result = mcp_tools._rewrite_local_paths_in_text(
|
||
text,
|
||
thread_id="t1",
|
||
user_id="u1",
|
||
source_base_dir=workspace,
|
||
changed_files=[src],
|
||
)
|
||
|
||
assert result == text
|
||
|
||
def test_bare_filename_not_rewritten_when_used_as_directory_segment(self, paths: Paths):
|
||
workspace = paths.sandbox_work_dir("t1", user_id="u1")
|
||
src = _workspace_file(paths, "page.yml")
|
||
text = "nested page.yml/inner.txt path"
|
||
|
||
with _patch_paths(paths):
|
||
result = mcp_tools._rewrite_local_paths_in_text(
|
||
text,
|
||
thread_id="t1",
|
||
user_id="u1",
|
||
source_base_dir=workspace,
|
||
changed_files=[src],
|
||
)
|
||
|
||
assert result == text
|
||
|
||
|
||
class TestWorkspaceSnapshots:
|
||
def test_changed_workspace_files_detects_created_and_modified_files(self, paths: Paths):
|
||
import time
|
||
|
||
workspace = paths.sandbox_work_dir("t1", user_id="u1")
|
||
existing = _workspace_file(paths, "existing.txt", content=b"old")
|
||
before = mcp_tools._snapshot_workspace_files(workspace)
|
||
|
||
# Ensure the mtime advances so the change is detectable. Without the
|
||
# sleep, write_bytes(b"new") may land in the same nanosecond as the
|
||
# snapshot, and since b"old" and b"new" have the same length, the
|
||
# (mtime_ns, size) signature stays identical → _changed_workspace_files
|
||
# misses the modification.
|
||
time.sleep(0.05)
|
||
existing.write_bytes(b"new_content") # different length guarantees size change too
|
||
created = _workspace_file(paths, "created.txt", content=b"created")
|
||
|
||
changed = set(mcp_tools._changed_workspace_files(workspace, before))
|
||
|
||
assert changed == {existing, created}
|
||
|
||
def test_snapshot_of_missing_directory_is_empty(self, tmp_path: Path):
|
||
assert mcp_tools._snapshot_workspace_files(tmp_path / "does-not-exist") == {}
|
||
|
||
def test_no_change_yields_no_changed_files(self, paths: Paths):
|
||
workspace = paths.sandbox_work_dir("t1", user_id="u1")
|
||
_workspace_file(paths, "stable.txt")
|
||
before = mcp_tools._snapshot_workspace_files(workspace)
|
||
|
||
assert mcp_tools._changed_workspace_files(workspace, before) == []
|
||
|
||
def test_deleted_file_is_not_reported_as_changed(self, paths: Paths):
|
||
workspace = paths.sandbox_work_dir("t1", user_id="u1")
|
||
victim = _workspace_file(paths, "victim.txt")
|
||
before = mcp_tools._snapshot_workspace_files(workspace)
|
||
|
||
victim.unlink()
|
||
|
||
assert mcp_tools._changed_workspace_files(workspace, before) == []
|
||
|
||
|
||
class TestPrepareStdioWorkspace:
|
||
def test_creates_dirs_and_returns_snapshot(self, paths: Paths):
|
||
existing = _workspace_file(paths, "existing.txt", content=b"old")
|
||
|
||
source_base_dir, tmp_dir, before = mcp_tools._prepare_stdio_workspace(paths, thread_id="t1", user_id="u1")
|
||
|
||
assert source_base_dir == paths.sandbox_work_dir("t1", user_id="u1")
|
||
assert tmp_dir == source_base_dir / MCP_TMP_SUBDIR
|
||
assert tmp_dir.is_dir()
|
||
assert before == {existing: (existing.stat().st_mtime_ns, existing.stat().st_size)}
|
||
|
||
|
||
class TestResultHasTextContent:
|
||
def test_text_content_is_detected(self):
|
||
result = CallToolResult(content=[TextContent(type="text", text="hi")], isError=False)
|
||
assert mcp_tools._result_has_text_content(result) is True
|
||
|
||
def test_embedded_text_resource_is_detected(self):
|
||
from mcp.types import EmbeddedResource, TextResourceContents
|
||
|
||
res = TextResourceContents(uri="mem://n.txt", text="n", mimeType="text/plain")
|
||
result = CallToolResult(content=[EmbeddedResource(type="resource", resource=res)], isError=False)
|
||
assert mcp_tools._result_has_text_content(result) is True
|
||
|
||
def test_image_only_result_has_no_text(self):
|
||
from mcp.types import ImageContent
|
||
|
||
result = CallToolResult(content=[ImageContent(type="image", data="QUJD", mimeType="image/png")], isError=False)
|
||
assert mcp_tools._result_has_text_content(result) is False
|
||
|
||
def test_empty_content_has_no_text(self):
|
||
result = CallToolResult(content=[], isError=False)
|
||
assert mcp_tools._result_has_text_content(result) is False
|
||
|
||
|
||
class TestConvertCallToolResultRewrites:
|
||
def test_resource_link_image_inside_workspace_rewritten(self, paths: Paths):
|
||
src = _workspace_file(paths, "page.png", content=b"png")
|
||
result = CallToolResult(
|
||
content=[ResourceLink(type="resource_link", name="page", uri=src.as_uri(), mimeType="image/png")],
|
||
isError=False,
|
||
)
|
||
|
||
with _patch_paths(paths):
|
||
content, _ = mcp_tools._convert_call_tool_result(result, thread_id="t1", user_id="u1")
|
||
|
||
assert content[0]["type"] == "image"
|
||
assert content[0]["url"] == f"{VIRTUAL_PATH_PREFIX}/workspace/page.png"
|
||
|
||
def test_resource_link_file_inside_outputs_rewritten(self, paths: Paths):
|
||
outputs = paths.sandbox_outputs_dir("t1", user_id="u1")
|
||
outputs.mkdir(parents=True)
|
||
src = outputs / "doc.pdf"
|
||
src.write_bytes(b"pdf")
|
||
result = CallToolResult(
|
||
content=[ResourceLink(type="resource_link", name="doc", uri=src.as_uri(), mimeType="application/pdf")],
|
||
isError=False,
|
||
)
|
||
|
||
with _patch_paths(paths):
|
||
content, _ = mcp_tools._convert_call_tool_result(result, thread_id="t1", user_id="u1")
|
||
|
||
assert content[0]["type"] == "file"
|
||
assert content[0]["url"] == f"{VIRTUAL_PATH_PREFIX}/outputs/doc.pdf"
|
||
|
||
def test_resource_link_outside_user_data_untouched(self, tmp_path: Path, paths: Paths):
|
||
src = tmp_path / "page.png"
|
||
src.write_bytes(b"png")
|
||
uri = src.as_uri()
|
||
result = CallToolResult(
|
||
content=[ResourceLink(type="resource_link", name="page", uri=uri, mimeType="image/png")],
|
||
isError=False,
|
||
)
|
||
|
||
with _patch_paths(paths):
|
||
content, _ = mcp_tools._convert_call_tool_result(result, thread_id="t1", user_id="u1")
|
||
|
||
assert content[0]["url"] == uri
|
||
|
||
def test_remote_resource_link_untouched(self, paths: Paths):
|
||
url = "https://example.com/remote.png"
|
||
result = CallToolResult(
|
||
content=[ResourceLink(type="resource_link", name="r", uri=url, mimeType="image/png")],
|
||
isError=False,
|
||
)
|
||
|
||
with _patch_paths(paths):
|
||
content, _ = mcp_tools._convert_call_tool_result(result, thread_id="t1", user_id="u1")
|
||
|
||
assert content[0]["url"] == url
|
||
|
||
def test_text_review_case_rewritten(self, paths: Paths):
|
||
workspace = paths.sandbox_work_dir("t1", user_id="u1")
|
||
_workspace_file(paths, "temp/page-2026-06-16T10-21-46-864Z.yml")
|
||
result = CallToolResult(
|
||
content=[TextContent(type="text", text="Saved as temp/page-2026-06-16T10-21-46-864Z.yml")],
|
||
isError=False,
|
||
)
|
||
|
||
with _patch_paths(paths):
|
||
content, _ = mcp_tools._convert_call_tool_result(result, thread_id="t1", user_id="u1", source_base_dir=workspace)
|
||
|
||
assert content[0]["text"] == f"Saved as {VIRTUAL_PATH_PREFIX}/workspace/temp/page-2026-06-16T10-21-46-864Z.yml"
|
||
|
||
def test_text_bare_filename_rewritten_from_changed_files(self, paths: Paths):
|
||
workspace = paths.sandbox_work_dir("t1", user_id="u1")
|
||
src = _workspace_file(paths, "page-2026.yml")
|
||
result = CallToolResult(content=[TextContent(type="text", text="Saved as page-2026.yml")], isError=False)
|
||
|
||
with _patch_paths(paths):
|
||
content, _ = mcp_tools._convert_call_tool_result(
|
||
result,
|
||
thread_id="t1",
|
||
user_id="u1",
|
||
source_base_dir=workspace,
|
||
changed_files=[src],
|
||
)
|
||
|
||
assert content[0]["text"] == f"Saved as {VIRTUAL_PATH_PREFIX}/workspace/page-2026.yml"
|
||
|
||
def test_no_context_does_not_rewrite(self, paths: Paths):
|
||
src = _workspace_file(paths, "x.png", content=b"png")
|
||
uri = src.as_uri()
|
||
result = CallToolResult(
|
||
content=[ResourceLink(type="resource_link", name="x", uri=uri, mimeType="image/png")],
|
||
isError=False,
|
||
)
|
||
|
||
with _patch_paths(paths):
|
||
content, _ = mcp_tools._convert_call_tool_result(result)
|
||
|
||
assert content[0]["url"] == uri
|
||
|
||
def test_text_content_passthrough(self, paths: Paths):
|
||
result = CallToolResult(content=[TextContent(type="text", text="hello")], isError=False)
|
||
|
||
with _patch_paths(paths):
|
||
content, _ = mcp_tools._convert_call_tool_result(result, thread_id="t1", user_id="u1")
|
||
|
||
assert content[0]["type"] == "text"
|
||
assert content[0]["text"] == "hello"
|
||
|
||
def test_malformed_path_like_text_result_does_not_raise(self, paths: Paths):
|
||
result = CallToolResult(content=[TextContent(type="text", text="Saved at //[::1/foo.png")], isError=False)
|
||
|
||
with _patch_paths(paths):
|
||
content, _ = mcp_tools._convert_call_tool_result(result, thread_id="t1", user_id="u1")
|
||
|
||
assert content[0]["type"] == "text"
|
||
assert content[0]["text"] == "Saved at //[::1/foo.png"
|
||
|
||
def test_image_content_passthrough(self, paths: Paths):
|
||
from mcp.types import ImageContent
|
||
|
||
result = CallToolResult(content=[ImageContent(type="image", data="QUJD", mimeType="image/png")], isError=False)
|
||
|
||
with _patch_paths(paths):
|
||
content, _ = mcp_tools._convert_call_tool_result(result, thread_id="t1", user_id="u1")
|
||
|
||
assert content[0]["type"] == "image"
|
||
|
||
def test_embedded_text_resource(self, paths: Paths):
|
||
from mcp.types import EmbeddedResource, TextResourceContents
|
||
|
||
res = TextResourceContents(uri="mem://note.txt", text="note", mimeType="text/plain")
|
||
result = CallToolResult(content=[EmbeddedResource(type="resource", resource=res)], isError=False)
|
||
|
||
with _patch_paths(paths):
|
||
content, _ = mcp_tools._convert_call_tool_result(result, thread_id="t1", user_id="u1")
|
||
|
||
assert content[0]["type"] == "text"
|
||
assert content[0]["text"] == "note"
|
||
|
||
def test_embedded_blob_image_resource(self, paths: Paths):
|
||
from mcp.types import BlobResourceContents, EmbeddedResource
|
||
|
||
res = BlobResourceContents(uri="mem://img.png", blob="QUJD", mimeType="image/png")
|
||
result = CallToolResult(content=[EmbeddedResource(type="resource", resource=res)], isError=False)
|
||
|
||
with _patch_paths(paths):
|
||
content, _ = mcp_tools._convert_call_tool_result(result, thread_id="t1", user_id="u1")
|
||
|
||
assert content[0]["type"] == "image"
|
||
|
||
def test_embedded_blob_file_resource(self, paths: Paths):
|
||
from mcp.types import BlobResourceContents, EmbeddedResource
|
||
|
||
res = BlobResourceContents(uri="mem://doc.pdf", blob="QUJD", mimeType="application/pdf")
|
||
result = CallToolResult(content=[EmbeddedResource(type="resource", resource=res)], isError=False)
|
||
|
||
with _patch_paths(paths):
|
||
content, _ = mcp_tools._convert_call_tool_result(result, thread_id="t1", user_id="u1")
|
||
|
||
assert content[0]["type"] == "file"
|
||
|
||
def test_unknown_content_item_stringified(self, paths: Paths):
|
||
class _Weird:
|
||
def __str__(self) -> str:
|
||
return "weird-item"
|
||
|
||
result = CallToolResult(content=[TextContent(type="text", text="x")], isError=False)
|
||
result.content = [_Weird()] # bypass pydantic validation on the union
|
||
|
||
with _patch_paths(paths):
|
||
content, _ = mcp_tools._convert_call_tool_result(result, thread_id="t1", user_id="u1")
|
||
|
||
assert content[0]["type"] == "text"
|
||
assert content[0]["text"] == "weird-item"
|
||
|
||
def test_error_result_raises_tool_exception(self, paths: Paths):
|
||
from langchain_core.tools import ToolException
|
||
|
||
result = CallToolResult(content=[TextContent(type="text", text="boom")], isError=True)
|
||
|
||
with _patch_paths(paths), pytest.raises(ToolException, match="boom"):
|
||
mcp_tools._convert_call_tool_result(result, thread_id="t1", user_id="u1")
|
||
|
||
def test_structured_content_becomes_artifact(self, paths: Paths):
|
||
result = CallToolResult(content=[TextContent(type="text", text="ok")], structuredContent={"k": "v"}, isError=False)
|
||
|
||
with _patch_paths(paths):
|
||
_, artifact = mcp_tools._convert_call_tool_result(result, thread_id="t1", user_id="u1")
|
||
|
||
assert artifact == {"structured_content": {"k": "v"}}
|