fix(stream): only warn about MCP servers we configured

The unconnected-server warning fired on everything `init` listed, which
includes whatever the host's own claude config carries. On the pi that
was seven unrelated connectors parked at `pending` / `needs-auth`
forever — seven bogus lines on every turn, burying the one line the
warning exists to surface.

Scope it to the servers passed in `mcp_servers`. Those are the ones we
have an expectation about; the rest are the host's business.
This commit is contained in:
hh
2026-07-28 03:27:27 +02:00
parent 621da90623
commit b0d7c6d32d
3 changed files with 57 additions and 7 deletions
+1
View File
@@ -576,6 +576,7 @@ class ClaudeCodeBackend:
proc, proc,
include_meta_user=self._opts.include_meta_user, include_meta_user=self._opts.include_meta_user,
include_partial_messages=self._opts.include_partial_messages, include_partial_messages=self._opts.include_partial_messages,
expected_mcp_servers=tuple(self._opts.mcp_servers or ()),
on_parse_error=self._on_parse_error, on_parse_error=self._on_parse_error,
) )
await tm.start() await tm.start()
+22 -6
View File
@@ -547,6 +547,7 @@ class StreamTurnManager:
*, *,
include_meta_user: bool = False, include_meta_user: bool = False,
include_partial_messages: bool = False, include_partial_messages: bool = False,
expected_mcp_servers: Iterable[str] = (),
on_parse_error: Callable[[MessageParseError, dict[str, Any]], None] on_parse_error: Callable[[MessageParseError, dict[str, Any]], None]
| None = None, | None = None,
owns_proc: bool = True, owns_proc: bool = True,
@@ -554,6 +555,7 @@ class StreamTurnManager:
self._proc = proc self._proc = proc
self._include_meta_user = include_meta_user self._include_meta_user = include_meta_user
self._include_partial_messages = include_partial_messages self._include_partial_messages = include_partial_messages
self._expected_mcp = frozenset(expected_mcp_servers)
self._on_parse_error = on_parse_error self._on_parse_error = on_parse_error
self._owns_proc = owns_proc self._owns_proc = owns_proc
self._started = False self._started = False
@@ -749,7 +751,7 @@ class StreamTurnManager:
if rtype == "system" and record.get("subtype") in _BOOKKEEPING_SUBTYPES: if rtype == "system" and record.get("subtype") in _BOOKKEEPING_SUBTYPES:
if record.get("subtype") == "init": if record.get("subtype") == "init":
_warn_on_unconnected_mcp(record, sid) _warn_on_unconnected_mcp(record, sid, self._expected_mcp)
continue continue
try: try:
@@ -835,8 +837,10 @@ class StreamTurnManager:
await self.aclose() await self.aclose()
def _warn_on_unconnected_mcp(record: Mapping[str, Any], session_id: str) -> None: def _warn_on_unconnected_mcp(
"""Log the MCP servers this turn is starting without. record: Mapping[str, Any], session_id: str, expected: frozenset[str]
) -> None:
"""Log the MCP servers *we asked for* that this turn is starting without.
A turn that begins while a server is still ``pending`` runs without A turn that begins while a server is still ``pending`` runs without
that server's tools — silently, from the model's point of view: it that server's tools — silently, from the model's point of view: it
@@ -844,19 +848,31 @@ def _warn_on_unconnected_mcp(record: Mapping[str, Any], session_id: str) -> None
the failure :meth:`StreamTurnManager.warmup` exists to prevent, so the failure :meth:`StreamTurnManager.warmup` exists to prevent, so
when it happens anyway it should be one grep away rather than a when it happens anyway it should be one grep away rather than a
mystery about why the agent "forgot" it had a tool. mystery about why the agent "forgot" it had a tool.
Scoped to `expected` deliberately. The CLI also loads servers from
the host's own claude config, and on a box where somebody once
logged in interactively that can be a fistful of unrelated
connectors sitting at ``pending`` or ``needs-auth`` forever —
observed in prod: seven of them, which would have meant seven bogus
warnings on every single turn, burying the one line that matters.
We only have expectations about servers we configured.
""" """
if not expected:
return
servers = record.get("mcp_servers") servers = record.get("mcp_servers")
if not isinstance(servers, list): if not isinstance(servers, list):
return return
stragglers = [ stragglers = [
f"{s.get('name')}:{s.get('status')}" f"{s.get('name')}:{s.get('status')}"
for s in servers for s in servers
if isinstance(s, Mapping) and s.get("status") not in ("connected", None) if isinstance(s, Mapping)
and s.get("name") in expected
and s.get("status") not in ("connected", None)
] ]
if stragglers: if stragglers:
_log.warning( _log.warning(
"session_id=%s turn starting without %d MCP server(s): %s " "session_id=%s turn starting without %d configured MCP server(s): "
"their tools are unavailable for this turn", "%stheir tools are unavailable for this turn",
session_id, session_id,
len(stragglers), len(stragglers),
", ".join(stragglers), ", ".join(stragglers),
+34 -1
View File
@@ -371,7 +371,7 @@ async def test_unconnected_mcp_servers_are_logged(
_result(), _result(),
] ]
) )
tm = StreamTurnManager(proc) # type: ignore[arg-type] tm = StreamTurnManager(proc, expected_mcp_servers=("telegram", "firefly")) # type: ignore[arg-type]
await tm.start() await tm.start()
with caplog.at_level(logging.WARNING, logger="claude_code_api.stream"): with caplog.at_level(logging.WARNING, logger="claude_code_api.stream"):
await _drain(tm) await _drain(tm)
@@ -379,6 +379,39 @@ async def test_unconnected_mcp_servers_are_logged(
assert "firefly" not in caplog.text assert "firefly" not in caplog.text
async def test_unexpected_mcp_servers_are_not_warned_about(caplog: Any) -> None:
"""Servers we didn't configure are the host's business, not ours.
A box where somebody logged in interactively can carry a pile of
unrelated connectors parked at ``needs-auth`` forever. Warning about
those every turn would bury the one line that matters.
"""
import logging
proc = FakeStreamProcess(
[
{
"type": "system",
"subtype": "init",
"session_id": "s",
"mcp_servers": [
{"name": "telegram", "status": "connected"},
{"name": "claude.ai Gmail", "status": "needs-auth"},
{"name": "claude.ai Drive", "status": "pending"},
],
},
_assistant({"type": "text", "text": "x"}),
_result(),
]
)
tm = StreamTurnManager(proc, expected_mcp_servers=("telegram",)) # type: ignore[arg-type]
await tm.start()
with caplog.at_level(logging.WARNING, logger="claude_code_api.stream"):
await _drain(tm)
assert "claude.ai" not in caplog.text
assert "MCP server" not in caplog.text
# --- failures ------------------------------------------------------------ # --- failures ------------------------------------------------------------