fix(mcp): resolve generated file paths containing spaces (#5819)

* fix(mcp): resolve generated file paths containing spaces

* fix(mcp): avoid partial rewrites at whitespace suffixes

---------

Co-authored-by: xbzz1018 <186267668+xbzz1018@users.noreply.github.com>
This commit is contained in:
xbzz1018 2026-09-24 20:13:18 +08:00 committed by GitHub
parent df63139321
commit f83bc05a5f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 189 additions and 0 deletions

View File

@ -288,6 +288,50 @@ def _rewrite_unique_bare_filenames(
return rewritten
def _rewrite_changed_paths_with_spaces(
text: str,
*,
changed_files: Iterable[Path],
thread_id: str,
user_id: str,
source_base_dir: Path | None,
) -> str:
"""Rewrite changed paths with spaces only at unambiguous text boundaries."""
candidates: dict[str, set[str]] = {}
for path in changed_files:
spellings = [str(path)]
if path.anchor == "/":
# Some servers print file URIs without percent-encoding spaces.
spellings.extend((f"file:{path}", f"file://{path}"))
if source_base_dir is not None:
try:
relative = path.relative_to(source_base_dir).as_posix()
except ValueError:
pass
else:
spellings.extend((relative, f"./{relative}"))
matching_spellings = [spelling for spelling in spellings if any(char.isspace() for char in spelling) and spelling in text]
if not matching_spellings:
continue
virtual_path = _local_uri_to_virtual_path(str(path), thread_id=thread_id, user_id=user_id)
if virtual_path is None:
continue
for spelling in matching_spellings:
candidates.setdefault(spelling, set()).add(virtual_path)
rewritten = text
for spelling in sorted(candidates, key=len, reverse=True):
destinations = candidates[spelling]
if len(destinations) != 1 or spelling not in rewritten:
continue
# Whitespace after an unquoted spelling may continue a longer filename.
# Require punctuation or the end of the text instead of rewriting a prefix.
pattern = re.compile(rf"(?<![\w./\\-]){re.escape(spelling)}(?!(?:[\w\s/\\-]|\.[\w]))")
replacement = next(iter(destinations))
rewritten = pattern.sub(lambda _match: replacement, rewritten)
return rewritten
def _rewrite_local_paths_in_text(
text: str,
*,
@ -327,6 +371,16 @@ def _rewrite_local_paths_in_text(
return token
return f"{rewritten}{trailing}"
if changed_files is not None:
changed_files = list(changed_files)
text = _rewrite_changed_paths_with_spaces(
text,
changed_files=changed_files,
thread_id=thread_id,
user_id=user_id,
source_base_dir=source_base_dir,
)
rewritten = _LOCAL_PATH_IN_TEXT_RE.sub(_replace, text)
if changed_files is None:
return rewritten

View File

@ -212,6 +212,125 @@ class TestRewriteLocalPathsInText:
assert result == f"Screenshot saved to {VIRTUAL_PATH_PREFIX}/workspace/absolute-output/page.png"
def test_new_absolute_path_with_spaces_is_rewritten(self, paths: Paths):
workspace = paths.sandbox_work_dir("t1", user_id="u1")
src = _workspace_file(paths, "reports/final report.txt")
text = f'Saved as "{src}" and ready.'
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/reports/final report.txt" and ready.'
def test_new_relative_path_with_spaces_is_rewritten(self, paths: Paths):
workspace = paths.sandbox_work_dir("t1", user_id="u1")
src = _workspace_file(paths, "final reports/result.txt")
with _patch_paths(paths):
result = mcp_tools._rewrite_local_paths_in_text(
"Saved as final reports/result.txt.",
thread_id="t1",
user_id="u1",
source_base_dir=workspace,
changed_files=[src],
)
assert result == f"Saved as {VIRTUAL_PATH_PREFIX}/workspace/final reports/result.txt."
def test_new_dot_relative_path_with_spaces_is_rewritten(self, paths: Paths):
workspace = paths.sandbox_work_dir("t1", user_id="u1")
src = _workspace_file(paths, "final reports/result.txt")
with _patch_paths(paths):
result = mcp_tools._rewrite_local_paths_in_text(
"Saved as ./final reports/result.txt.",
thread_id="t1",
user_id="u1",
source_base_dir=workspace,
changed_files=[src],
)
assert result == f"Saved as {VIRTUAL_PATH_PREFIX}/workspace/final reports/result.txt."
def test_changed_path_with_spaces_outside_user_data_is_untouched(self, tmp_path: Path, paths: Paths):
outside = tmp_path / "outside report.txt"
outside.write_text("outside", encoding="utf-8")
text = f"Saved as {outside}"
with _patch_paths(paths):
result = mcp_tools._rewrite_local_paths_in_text(
text,
thread_id="t1",
user_id="u1",
changed_files=[outside],
)
assert result == text
@pytest.mark.skipif(os.name == "nt", reason="exercises POSIX file URI spellings")
def test_new_file_uri_with_literal_spaces_is_rewritten(self, paths: Paths):
src = _workspace_file(paths, "final report.txt")
text = f"Saved as file://{src}"
with _patch_paths(paths):
result = mcp_tools._rewrite_local_paths_in_text(
text,
thread_id="t1",
user_id="u1",
changed_files=[src],
)
assert result == f"Saved as {VIRTUAL_PATH_PREFIX}/workspace/final report.txt"
def test_changed_path_with_spaces_does_not_rewrite_longer_name(self, paths: Paths):
src = _workspace_file(paths, "final report.txt")
text = f"Backup: {src}.bak"
with _patch_paths(paths):
result = mcp_tools._rewrite_local_paths_in_text(
text,
thread_id="t1",
user_id="u1",
changed_files=[src],
)
assert result == text
def test_changed_path_with_spaces_does_not_rewrite_whitespace_suffix(self, paths: Paths):
src = _workspace_file(paths, "final report.txt")
longer = _workspace_file(paths, "final report.txt copy")
text = f"Backup: {longer}"
with _patch_paths(paths):
result = mcp_tools._rewrite_local_paths_in_text(
text,
thread_id="t1",
user_id="u1",
changed_files=[src],
)
assert result == text
def test_changed_path_with_spaces_followed_by_prose_is_untouched(self, paths: Paths):
src = _workspace_file(paths, "final report.txt")
text = f"Saved as {src} and ready."
with _patch_paths(paths):
result = mcp_tools._rewrite_local_paths_in_text(
text,
thread_id="t1",
user_id="u1",
changed_files=[src],
)
assert result == text
def test_tmpdir_output_under_workspace_is_rewritten(self, paths: Paths):
src = _workspace_file(paths, ".mcp/tmp/page.png")
text = f"Saved to {src}"
@ -629,6 +748,22 @@ class TestConvertCallToolResultRewrites:
assert content[0]["text"] == f"Saved as {VIRTUAL_PATH_PREFIX}/workspace/page-2026.yml"
def test_text_path_with_spaces_rewritten_from_changed_files(self, paths: Paths):
workspace = paths.sandbox_work_dir("t1", user_id="u1")
src = _workspace_file(paths, "report with spaces.txt")
result = CallToolResult(content=[TextContent(type="text", text=f"Saved as {src}")], 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/report with spaces.txt"
def test_no_context_does_not_rewrite(self, paths: Paths):
src = _workspace_file(paths, "x.png", content=b"png")
uri = src.as_uri()