Skip to content

Commit 49b08de

Browse files
committed
fix(mcp): harden MCP tool registry lifecycle and address PR #155 review findings
- Publish connected MCP tools before raising on partial connect failure, so servers that connected stay callable when a sibling fails. - Rebuild the published MCP registry atomically on disconnect/refresh/reconnect: a failed refresh no longer drops live tools, and disconnecting a server falls its shadowed tool names back to other still-connected servers instead of orphaning them. - Convert refresh inventory timeouts/errors to MCPRuntimeError at the boundary. - Validate /mcp verb arity instead of silently using only the first operand. - Convert MCP config errors to typer.BadParameter in CLI parsing. - Stage suggestion prefill unconditionally so an empty prefill clears stale text. - Document telemetry slice budget, in-place mcpServers normalization, and recall windowing params; strengthen suggest/config/emoji assertions; drop a duplicate ExitPlanMode description test; add MCP lifecycle failure-path tests.
1 parent 5662e3f commit 49b08de

12 files changed

Lines changed: 190 additions & 44 deletions

File tree

src/pythinker_code/cli/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,8 @@ def _load_mcp_configs_from_cli_inputs(
270270
file_configs.append(project_mcp_file)
271271

272272
configs: list[Any] = []
273+
from pythinker_code.exception import MCPConfigError
274+
273275
from .mcp import prepare_mcp_config_dict
274276

275277
for conf in file_configs:
@@ -285,12 +287,19 @@ def _load_mcp_configs_from_cli_inputs(
285287
f"Cannot read MCP config file {conf}: {e}",
286288
param_hint="--mcp-config-file",
287289
) from e
290+
except MCPConfigError as e:
291+
raise typer.BadParameter(
292+
f"Invalid MCP config in file {conf}: {e}",
293+
param_hint="--mcp-config-file",
294+
) from e
288295

289296
for conf in raw_mcp_config:
290297
try:
291298
configs.append(prepare_mcp_config_dict(json.loads(conf)))
292299
except json.JSONDecodeError as e:
293300
raise typer.BadParameter(f"Invalid JSON: {e}", param_hint="--mcp-config") from e
301+
except MCPConfigError as e:
302+
raise typer.BadParameter(f"Invalid MCP config: {e}", param_hint="--mcp-config") from e
294303

295304
for path in _yaml_files_with_misplaced_mcp_servers():
296305
from pythinker_code.utils.logging import logger

src/pythinker_code/soul/toolset.py

Lines changed: 40 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -624,6 +624,23 @@ def _publish_connected_mcp_tools(self, runtime: Runtime) -> None:
624624

625625
runtime.mcp_tools[mcp_tool_runtime_key(server_name, tool.name)] = tool
626626

627+
def _rebuild_published_mcp_tools(self, runtime: Runtime) -> None:
628+
"""Atomically rebuild the published MCP tool registry from connected servers.
629+
630+
Drop every currently-published MCP tool (non-MCP tools are preserved), then
631+
republish all *connected* servers in configured order via
632+
:meth:`_publish_connected_mcp_tools`. This keeps last-wins collision order
633+
deterministic and lets a disconnect/refresh re-claim a tool name another
634+
still-connected server provides, instead of orphaning it. The method runs
635+
synchronously (no ``await`` between the drop and the republish), so the two
636+
registries are never observed half-rebuilt.
637+
"""
638+
stale = [name for name, tool in self._tool_dict.items() if isinstance(tool, MCPTool)]
639+
for name in stale:
640+
del self._tool_dict[name]
641+
runtime.mcp_tools.clear()
642+
self._publish_connected_mcp_tools(runtime)
643+
627644
def hide(self, tool_name: str) -> bool:
628645
"""Hide a tool from the LLM tool list. Returns True if the tool exists."""
629646
if tool_name in self._tool_dict:
@@ -1300,10 +1317,12 @@ async def _connect():
13001317
results = await asyncio.gather(*tasks) if tasks else []
13011318
failed_servers = {name: error for name, error in results if error is not None}
13021319

1320+
# Publish before raising so servers that DID connect become callable in
1321+
# this session even when another server fails the aggregate connect.
1322+
self._publish_connected_mcp_tools(runtime)
13031323
if failed_servers:
13041324
_toast_mcp("mcp connection failed")
13051325
raise MCPRuntimeError(f"Failed to connect MCP servers: {failed_servers}")
1306-
self._publish_connected_mcp_tools(runtime)
13071326
if unauthorized_servers:
13081327
_toast_mcp("mcp authorization needed")
13091328
else:
@@ -1353,19 +1372,6 @@ async def wait_for_mcp_tools(self) -> None:
13531372
if self._mcp_loading_task is task and task.done():
13541373
self._mcp_loading_task = None
13551374

1356-
def _unregister_mcp_server_tools(self, server_name: str, runtime: Runtime) -> None:
1357-
info = self._mcp_servers.get(server_name)
1358-
if info is None:
1359-
return
1360-
from pythinker_code.utils.mcp_names import mcp_tool_runtime_key
1361-
1362-
for tool in info.tools:
1363-
registered = self._tool_dict.get(tool.name)
1364-
if registered is tool:
1365-
del self._tool_dict[tool.name]
1366-
runtime.mcp_tools.pop(mcp_tool_runtime_key(server_name, tool.name), None)
1367-
info.tools = []
1368-
13691375
async def _inventory_mcp_server(
13701376
self, server_name: str, server_info: MCPServerInfo, runtime: Runtime
13711377
) -> None:
@@ -1427,16 +1433,6 @@ async def _connect_mcp_server(
14271433
server_info.error = _classify_mcp_connect_error(e, server_name)
14281434
return server_name, e
14291435

1430-
def _publish_mcp_server_tools(self, server_name: str, runtime: Runtime) -> None:
1431-
info = self._mcp_servers.get(server_name)
1432-
if info is None:
1433-
return
1434-
self._register_mcp_tools(server_name, info.tools)
1435-
from pythinker_code.utils.mcp_names import mcp_tool_runtime_key
1436-
1437-
for tool in info.tools:
1438-
runtime.mcp_tools[mcp_tool_runtime_key(server_name, tool.name)] = tool
1439-
14401436
def _ensure_mcp_idle(self) -> None:
14411437
if self._mcp_loading_task is not None and not self._mcp_loading_task.done():
14421438
raise MCPRuntimeError("MCP servers are still loading")
@@ -1463,7 +1459,10 @@ async def disconnect_mcp_server(self, server_name: str, runtime: Runtime) -> Non
14631459
if info is None:
14641460
raise MCPRuntimeError(f"Unknown MCP server: {server_name}")
14651461
await self._stop_mcp_session_holder(info)
1466-
self._unregister_mcp_server_tools(server_name, runtime)
1462+
# Drop this server's inventory, then rebuild the published registry so any
1463+
# tool name it was shadowing falls back to another still-connected server.
1464+
info.tools = []
1465+
self._rebuild_published_mcp_tools(runtime)
14671466
prior_error = info.error
14681467
close_error: str | None = None
14691468
try:
@@ -1504,12 +1503,21 @@ async def refresh_mcp_server(self, server_name: str, runtime: Runtime) -> None:
15041503
raise MCPRuntimeError(
15051504
f"MCP server '{server_name}' is not connected (status={info.status})"
15061505
)
1507-
self._unregister_mcp_server_tools(server_name, runtime)
1508-
await asyncio.wait_for(
1509-
self._inventory_mcp_server(server_name, info, runtime),
1510-
timeout=runtime.config.mcp.client.startup_timeout_ms / 1000,
1511-
)
1512-
self._publish_mcp_server_tools(server_name, runtime)
1506+
# Inventory first: on failure ``info.tools`` keeps its last-known-good value
1507+
# and the live registry is untouched, so a failed refresh never drops tools.
1508+
# Convert raw timeout/inventory errors to MCPRuntimeError so callers (e.g. the
1509+
# /mcp slash handler) receive a single typed boundary error.
1510+
try:
1511+
await asyncio.wait_for(
1512+
self._inventory_mcp_server(server_name, info, runtime),
1513+
timeout=runtime.config.mcp.client.startup_timeout_ms / 1000,
1514+
)
1515+
except TimeoutError as exc:
1516+
raise MCPRuntimeError(f"Refresh of MCP server '{server_name}' timed out") from exc
1517+
except Exception as exc:
1518+
raise MCPRuntimeError(f"Failed to refresh MCP server '{server_name}': {exc}") from exc
1519+
# Inventory succeeded; swap the old tool set for the new one atomically.
1520+
self._rebuild_published_mcp_tools(runtime)
15131521

15141522
async def reconnect_mcp_server(self, server_name: str, runtime: Runtime) -> None:
15151523
"""Close and reconnect one MCP server from its stored config."""
@@ -1532,7 +1540,7 @@ async def reconnect_mcp_server(self, server_name: str, runtime: Runtime) -> None
15321540
raise MCPRuntimeError(
15331541
info.error or f"Failed to reconnect MCP server '{server_name}': {error}"
15341542
)
1535-
self._publish_mcp_server_tools(server_name, runtime)
1543+
self._rebuild_published_mcp_tools(runtime)
15361544

15371545
async def cleanup(self) -> None:
15381546
"""Cleanup any resources held by the toolset."""

src/pythinker_code/telemetry/names.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,5 +37,7 @@ def _bounded_mcp_label(server: str, tool: str) -> str:
3737
if len(label) <= _TELEMETRY_TOOL_NAME_MAX:
3838
return label
3939
digest = hashlib.sha256(label.encode("utf-8")).hexdigest()[:8]
40+
# Slice budget: "mcp__" (5) + server[:16] + "__" (2) + tool[:24] = 47, then
41+
# head[:55] + "_" + 8-char digest = 56 — always within _TELEMETRY_TOOL_NAME_MAX (64).
4042
head = f"mcp__{safe_server[:16]}__{safe_tool[:24]}"
4143
return f"{head[: _TELEMETRY_TOOL_NAME_MAX - 9]}_{digest}"

src/pythinker_code/tools/recall/__init__.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,15 @@ def _render_transcript(
103103
104104
Internal (``_``-prefixed) roles are skipped. Each message's text is sanitized;
105105
a block that trips the secret/injection scanner becomes ``[redacted]`` rather
106-
than leaking or silently vanishing. Stops once the char budget is reached.
106+
than leaking or silently vanishing.
107+
108+
Windowing operates on the renderable (post-filter) message stream — i.e. after
109+
internal-role and empty-segment messages are dropped:
110+
111+
- ``message_offset``: skip this many renderable messages before emitting any.
112+
- ``max_messages``: emit at most this many renderable messages (``None`` = no limit).
113+
114+
Stops once the char budget or ``max_messages`` is reached.
107115
"""
108116
out: list[str] = []
109117
used = 0

src/pythinker_code/ui/shell/slash.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2258,6 +2258,13 @@ async def mcp(app: Shell, args: str):
22582258
if soul is None:
22592259
return
22602260
server_name = parts[1] if len(parts) > 1 else None
2261+
# Reject extra operands instead of silently using only the first.
2262+
if verb in {"reconnect", "disconnect", "retry"} and len(parts) != 2:
2263+
console.print(f"[{_get_tok_mcp().warning}]Usage: /mcp {verb} <server>[/]")
2264+
return
2265+
if verb == "refresh" and len(parts) > 2:
2266+
console.print(f"[{_get_tok_mcp().warning}]Usage: /mcp refresh [server][/]")
2267+
return
22612268
if verb == "retry":
22622269
verb = "reconnect"
22632270
try:

src/pythinker_code/ui/shell/visualize/_interactive.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -517,8 +517,10 @@ def dispatch_wire_message(self, msg: WireMessage) -> None:
517517

518518
def display_suggestion(self, event: Suggestion) -> None:
519519
super().display_suggestion(event)
520-
if event.prefill.strip():
521-
self._prompt_session.stage_suggestion_prefill(event.prefill)
520+
# Stage unconditionally: an empty prefill clears any prior staged value
521+
# (stage_suggestion_prefill stores ``None`` for blank input), so a later
522+
# suggestion without a prefill cannot leave stale Esc+s text behind.
523+
self._prompt_session.stage_suggestion_prefill(event.prefill)
522524

523525
# -- Running prompt rendering --------------------------------------------
524526

src/pythinker_code/utils/mcp_names.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,11 @@ def mcp_tool_runtime_key(server_name: str, tool_name: str) -> str:
3636

3737

3838
def normalize_mcp_servers_in_config(config: dict[str, Any]) -> dict[str, Any]:
39-
"""Re-key ``mcpServers`` entries to normalized names; fail on collisions."""
39+
"""Re-key ``mcpServers`` entries to normalized names; fail on collisions.
40+
41+
Mutates ``config`` in place (reassigns ``config["mcpServers"]``) and returns
42+
the same dict for chaining.
43+
"""
4044
servers = config.get("mcpServers")
4145
if not isinstance(servers, dict):
4246
return config
Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
from pythinker_code.tools.plan import ExitPlanMode
21
from pythinker_code.tools.plan.__init__ import _plan_lacks_verification_section
32

43

@@ -7,6 +6,7 @@ def test_plan_lacks_verification_section_detects_missing_heading() -> None:
76
assert not _plan_lacks_verification_section("## Plan\n## Verification\nmake test\n")
87

98

10-
def test_exit_plan_mode_description_requires_verification_section():
11-
tool = ExitPlanMode()
12-
assert "Verification section" in tool.base.description
9+
# The ExitPlanMode description assertion lives in
10+
# tests/tools/test_tool_descriptions.py::test_exit_plan_mode_description_requires_verification_section
11+
# (it checks both the "Verification section" heading and the "smallest command,
12+
# test, or check" guidance). This file is scoped to the _plan_lacks_verification_section helper.

tests/core/test_mcp_lifecycle.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,3 +201,106 @@ class _FakeClient:
201201

202202
with pytest.raises(MCPRuntimeError, match="command not found"):
203203
await toolset.reconnect_mcp_server("alpha", runtime)
204+
205+
206+
@pytest.mark.asyncio
207+
async def test_refresh_failure_preserves_live_tools(monkeypatch: pytest.MonkeyPatch) -> None:
208+
"""A failed refresh must not drop the server's still-working tools."""
209+
toolset = PythinkerToolset()
210+
runtime = _runtime()
211+
live_tool = _fake_mcp_tool("alpha", "LiveTool")
212+
info = MCPServerInfo(
213+
status="connected",
214+
client=cast(Any, SimpleNamespace()),
215+
tools=[live_tool],
216+
resources=[],
217+
prompts=[],
218+
)
219+
toolset._mcp_servers["alpha"] = info
220+
toolset.add(live_tool)
221+
runtime.mcp_tools["mcp__alpha__LiveTool"] = live_tool
222+
223+
async def _failing_inventory(_server: str, _info: MCPServerInfo, _runtime: Any) -> None:
224+
raise RuntimeError("list_tools blew up")
225+
226+
monkeypatch.setattr(toolset, "_inventory_mcp_server", _failing_inventory)
227+
228+
with pytest.raises(MCPRuntimeError, match="Failed to refresh"):
229+
await toolset.refresh_mcp_server("alpha", runtime)
230+
231+
assert toolset.find("LiveTool") is live_tool
232+
assert runtime.mcp_tools["mcp__alpha__LiveTool"] is live_tool
233+
assert info.tools == [live_tool]
234+
235+
236+
@pytest.mark.asyncio
237+
async def test_disconnect_reclaims_shadowed_tool_from_other_server() -> None:
238+
"""Disconnecting the winning server must fall the tool name back, not orphan it."""
239+
toolset = PythinkerToolset()
240+
runtime = _runtime()
241+
shared_alpha = _fake_mcp_tool("alpha", "Shared")
242+
shared_beta = _fake_mcp_tool("beta", "Shared")
243+
toolset._mcp_servers["alpha"] = MCPServerInfo(
244+
status="connected",
245+
client=cast(Any, SimpleNamespace(close=AsyncMock())),
246+
tools=[shared_alpha],
247+
resources=[],
248+
prompts=[],
249+
server_config={"command": "echo"},
250+
)
251+
toolset._mcp_servers["beta"] = MCPServerInfo(
252+
status="connected",
253+
client=cast(Any, SimpleNamespace(close=AsyncMock())),
254+
tools=[shared_beta],
255+
resources=[],
256+
prompts=[],
257+
server_config={"command": "echo"},
258+
)
259+
# Configured order publishes beta last, so it wins the shared name.
260+
toolset._publish_connected_mcp_tools(runtime)
261+
assert toolset.find("Shared") is shared_beta
262+
263+
await toolset.disconnect_mcp_server("beta", runtime)
264+
265+
assert toolset.find("Shared") is shared_alpha
266+
assert runtime.mcp_tools["mcp__alpha__Shared"] is shared_alpha
267+
assert "mcp__beta__Shared" not in runtime.mcp_tools
268+
assert toolset._mcp_servers["beta"].status == "failed"
269+
270+
271+
@pytest.mark.asyncio
272+
async def test_partial_connect_publishes_connected_servers(monkeypatch: pytest.MonkeyPatch) -> None:
273+
"""A server that connects must be published even when a sibling fails the aggregate."""
274+
from fastmcp.mcp_config import MCPConfig
275+
276+
toolset = PythinkerToolset()
277+
runtime = _runtime()
278+
good_tool = _fake_mcp_tool("good", "GoodTool")
279+
280+
async def _connect(
281+
server_name: str, server_info: MCPServerInfo, _runtime: Any
282+
) -> tuple[str, Exception | None]:
283+
if server_name == "good":
284+
server_info.status = "connected"
285+
server_info.tools = [good_tool]
286+
return server_name, None
287+
server_info.status = "failed"
288+
server_info.error = "boom"
289+
return server_name, RuntimeError("boom")
290+
291+
monkeypatch.setattr(toolset, "_connect_mcp_server", _connect)
292+
monkeypatch.setattr(
293+
"pythinker_code.soul.toolset._configure_mcp_client_handlers",
294+
lambda *args, **kwargs: None,
295+
)
296+
monkeypatch.setattr("fastmcp.Client", lambda *args, **kwargs: SimpleNamespace())
297+
298+
config = MCPConfig.model_validate(
299+
{"mcpServers": {"good": {"command": "echo"}, "bad": {"command": "echo"}}}
300+
)
301+
302+
with pytest.raises(MCPRuntimeError, match="Failed to connect"):
303+
await toolset.load_mcp_tools([config], runtime, in_background=False)
304+
305+
assert toolset.find("GoodTool") is good_tool
306+
assert runtime.mcp_tools["mcp__good__GoodTool"] is good_tool

tests/core/test_toolset.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,7 @@ def fire_and_forget_trigger(*args: Any, **kwargs: Any) -> None:
309309
)
310310
)
311311
assert isinstance(result, asyncio.Task)
312-
await result
312+
_ = await result # drive the task to completion; the awaited value is unused
313313

314314
assert post_inputs == [{"payload": {"value": "original"}}]
315315

0 commit comments

Comments
 (0)