Skip to content

Commit c4e107f

Browse files
shadowinlifegithub-copilot
andcommitted
fix: preserve swarm MCP collision prefixes
Preserve full-config MCP server-name resolution when pruning SWARM worker registries so collision hash suffixes remain stable after narrowing discovery. Also redact nested sensitive values from tool event previews, including remote MCP result previews. Co-Authored-By: Claude Code <noreply@anthropic.com> AI-Model: GitHub Copilot Co-Authored-By: github-copilot <noreply@ai-tool.com> AI-Contributed/Feature: 71/71 AI-Contributed/UT: 53/53
1 parent ad50b9e commit c4e107f

4 files changed

Lines changed: 109 additions & 15 deletions

File tree

agent/src/swarm/worker.py

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import time
1313
from datetime import datetime, timezone
1414
from pathlib import Path
15-
from typing import Callable
15+
from typing import Any, Callable
1616

1717
from src.agent.context import ContextBuilder
1818
from src.agent.progress import HeartbeatTimer
@@ -576,7 +576,7 @@ def _on_heartbeat(payload: dict) -> None:
576576
event_callback, "tool_result", agent_id, task_id,
577577
{"tool": tc.name, "elapsed_ms": int(tc_elapsed * 1000),
578578
"status": "ok", "iteration": iteration,
579-
"result_preview": str(result)[:200],
579+
"result_preview": _preview_tool_result(result),
580580
**mcp_meta},
581581
)
582582
messages.append(
@@ -657,11 +657,40 @@ def _preview_tool_arguments(arguments: dict) -> dict[str, str]:
657657
if _is_sensitive_tool_argument(key):
658658
preview[key] = "[redacted]"
659659
continue
660-
text = str(value)
661-
preview[key] = text if len(text) <= 200 else text[:200] + "..."
660+
preview[key] = _truncate_preview(_redact_preview_payload(value))
662661
return preview
663662

664663

664+
def _preview_tool_result(result: str) -> str:
665+
"""Return a short, redacted result preview for streamed events."""
666+
try:
667+
parsed = json.loads(result)
668+
except (TypeError, ValueError):
669+
return _truncate_preview(result)
670+
return _truncate_preview(_redact_preview_payload(parsed))
671+
672+
673+
def _redact_preview_payload(value: Any) -> Any:
674+
"""Recursively redact sensitive keys before event preview stringification."""
675+
if isinstance(value, dict):
676+
return {
677+
key: "[redacted]" if _is_sensitive_tool_argument(str(key)) else _redact_preview_payload(item)
678+
for key, item in value.items()
679+
}
680+
if isinstance(value, list):
681+
return [_redact_preview_payload(item) for item in value]
682+
return value
683+
684+
685+
def _truncate_preview(value: Any, *, limit: int = 200) -> str:
686+
"""Stringify and truncate an event preview payload."""
687+
if isinstance(value, (dict, list)):
688+
text = json.dumps(value, ensure_ascii=False, default=str)
689+
else:
690+
text = str(value)
691+
return text if len(text) <= limit else text[:limit] + "..."
692+
693+
665694
def _is_sensitive_tool_argument(key: str) -> bool:
666695
"""Return whether a tool argument name should be redacted in events."""
667696
normalized = key.strip().lower()

agent/src/tools/__init__.py

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import importlib
1414
import logging
1515
import pkgutil
16+
from collections.abc import Mapping
1617
from collections import deque
1718
from pathlib import Path
1819
from typing import TYPE_CHECKING, Callable
@@ -70,6 +71,7 @@ def build_registry(
7071
session_id: str | None = None,
7172
event_callback: Callable[[str, dict], None] | None = None,
7273
warn_callback: Callable[[str], None] | None = None,
74+
_mcp_server_tool_name_segments: Mapping[str, str] | None = None,
7375
) -> ToolRegistry:
7476
"""Build the tool registry via auto-discovery, optionally enriched with MCP tools.
7577
@@ -132,10 +134,16 @@ def build_registry(
132134
if agent_config and agent_config.mcp_servers:
133135
from src.tools.mcp import build_mcp_tool_wrappers, resolve_mcp_server_tool_name_segments
134136

135-
local_server_names = resolve_mcp_server_tool_name_segments(
136-
agent_config.mcp_servers.keys(),
137-
warn_callback=warn_callback,
138-
)
137+
if _mcp_server_tool_name_segments is None:
138+
local_server_names = resolve_mcp_server_tool_name_segments(
139+
agent_config.mcp_servers.keys(),
140+
warn_callback=warn_callback,
141+
)
142+
else:
143+
local_server_names = {
144+
server_name: _mcp_server_tool_name_segments[server_name]
145+
for server_name in agent_config.mcp_servers
146+
}
139147

140148
for server_name, server_config in agent_config.mcp_servers.items():
141149
try:
@@ -212,25 +220,29 @@ def build_swarm_registry(
212220
ToolRegistry containing the whitelist intersection of local tools
213221
and any operator-surfaced MCP tools.
214222
"""
215-
swarm_agent_config = _prune_agent_config_for_swarm_tools(agent_config, tool_names)
223+
swarm_agent_config, swarm_local_server_names = _prune_agent_config_for_swarm_tools(
224+
agent_config,
225+
tool_names,
226+
)
216227
full = build_registry(
217228
agent_config=swarm_agent_config,
218229
include_shell_tools=include_shell_tools,
230+
_mcp_server_tool_name_segments=swarm_local_server_names,
219231
)
220232
return _filter_registry(full, tool_names, include_shell_tools=include_shell_tools)
221233

222234

223235
def _prune_agent_config_for_swarm_tools(
224236
agent_config: "AgentConfig | None",
225237
tool_names: list[str],
226-
) -> "AgentConfig | None":
238+
) -> tuple["AgentConfig | None", dict[str, str] | None]:
227239
"""Keep only MCP servers whose local tool prefix appears in ``tool_names``."""
228240
if not agent_config or not agent_config.mcp_servers:
229-
return agent_config
241+
return agent_config, None
230242

231243
requested_mcp_tool_names = [name for name in tool_names if name.startswith("mcp_")]
232244
if not requested_mcp_tool_names:
233-
return None
245+
return None, None
234246

235247
from src.config.schema import AgentConfig
236248
from src.tools.mcp import resolve_mcp_server_tool_name_segments
@@ -244,7 +256,11 @@ def _prune_agent_config_for_swarm_tools(
244256
for tool_name in requested_mcp_tool_names
245257
)
246258
}
247-
return AgentConfig(mcp_servers=selected_servers)
259+
selected_local_server_names = {
260+
server_name: local_server_names[server_name]
261+
for server_name in selected_servers
262+
}
263+
return AgentConfig(mcp_servers=selected_servers), selected_local_server_names
248264

249265

250266
def _filter_registry(

agent/tests/test_swarm_m2_registry_assembly.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232

3333
from src.config.schema import AgentConfig
3434
from src.tools import build_swarm_registry
35-
from src.tools.mcp import MCPRemoteTool
35+
from src.tools.mcp import MCPRemoteTool, resolve_mcp_server_tool_name_segments
3636

3737

3838
def _make_agent_config(servers: dict[str, dict[str, Any]]) -> AgentConfig:
@@ -218,6 +218,35 @@ def fake_build_mcp_tool_wrappers(server_name, *_args, **_kwargs):
218218
assert [call.args[0] for call in build_wrappers.call_args_list] == ["kb"]
219219

220220

221+
def test_build_swarm_registry_preserves_collision_hash_prefix_after_pruning() -> None:
222+
"""Pruning keeps full-config MCP collision disambiguation stable."""
223+
cfg = _make_agent_config(
224+
{
225+
"foo-bar": {"command": "uvx", "args": ["foo-bar-server"]},
226+
"foo_bar": {"command": "uvx", "args": ["foo-bar-alt-server"]},
227+
"expensive": {"command": "uvx", "args": ["expensive-server"]},
228+
}
229+
)
230+
resolved_names = resolve_mcp_server_tool_name_segments(cfg.mcp_servers.keys())
231+
requested_tool = f"mcp_{resolved_names['foo-bar']}_ping"
232+
233+
def fake_build_mcp_tool_wrappers(_server_name, *_args, **kwargs):
234+
return _make_fake_wrappers(kwargs["local_server_name"], ["ping"])
235+
236+
with patch(
237+
"src.tools.mcp.build_mcp_tool_wrappers",
238+
side_effect=fake_build_mcp_tool_wrappers,
239+
) as build_wrappers:
240+
registry = build_swarm_registry(
241+
[requested_tool],
242+
agent_config=cfg,
243+
)
244+
245+
assert registry.tool_names == [requested_tool]
246+
assert [call.args[0] for call in build_wrappers.call_args_list] == ["foo-bar"]
247+
assert build_wrappers.call_args.kwargs["local_server_name"] == resolved_names["foo-bar"]
248+
249+
221250
def test_build_swarm_registry_skips_mcp_discovery_for_local_only_whitelist() -> None:
222251
"""A local-only agent whitelist must not discover any configured MCP server."""
223252
cfg = _make_agent_config({"kb": {"command": "uvx", "args": ["kb-server"]}})

agent/tests/test_swarm_m4_e2e.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -413,7 +413,15 @@ def test_tool_call_events_carry_mcp_metadata_and_redact_sensitive_arguments(
413413
},
414414
)
415415
]],
416-
call_outcomes=[_ok_call_result({"hit": "ok"})],
416+
call_outcomes=[
417+
_ok_call_result(
418+
{
419+
"hit": "ok",
420+
"token": "result-token-should-not-appear",
421+
"nested": {"authorization": "Bearer result-secret"},
422+
}
423+
)
424+
],
417425
)
418426
remote_tools = build_mcp_tool_wrappers(
419427
"kb", _make_server_config(), client_factory=_make_factory(state)
@@ -427,6 +435,10 @@ def test_tool_call_events_carry_mcp_metadata_and_redact_sensitive_arguments(
427435
"query": "AAPL",
428436
"api_key": "should-not-appear-in-events",
429437
"token": "also-secret",
438+
"request": {
439+
"headers": {"Authorization": "Bearer nested-secret"},
440+
"payload": {"password": "nested-password"},
441+
},
430442
},
431443
),
432444
_final_response("done"),
@@ -473,11 +485,19 @@ def test_tool_call_events_carry_mcp_metadata_and_redact_sensitive_arguments(
473485
assert call_data["arguments"]["api_key"] == "[redacted]"
474486
assert call_data["arguments"]["token"] == "[redacted]"
475487
assert call_data["arguments"]["query"] == "AAPL"
488+
assert "nested-secret" not in call_data["arguments"]["request"]
489+
assert "nested-password" not in call_data["arguments"]["request"]
490+
assert "result-token-should-not-appear" not in result_data["result_preview"]
491+
assert "result-secret" not in result_data["result_preview"]
476492
# Defense-in-depth: the secret values must never appear anywhere in
477493
# the event payload (including via str-coerced views).
478494
serialized = json.dumps([e.data for e in events], ensure_ascii=False)
479495
assert "should-not-appear-in-events" not in serialized
480496
assert "also-secret" not in serialized
497+
assert "nested-secret" not in serialized
498+
assert "nested-password" not in serialized
499+
assert "result-token-should-not-appear" not in serialized
500+
assert "result-secret" not in serialized
481501

482502

483503
# --------------------------------------------------------------------------- #

0 commit comments

Comments
 (0)