fix(#2038): don't treat MCP 'pending' status as degraded

pending is the claude CLI's normal init-event race for a freshly
spawned stdio server (status reported before the handshake completes,
then flips to connected moments later in the same turn — see
anthropics/claude-agent-sdk-typescript#368). flagging it fired on
nearly every turn for all three configured servers with zero actual
impact, which is why #2038's persistent-vs-one-turn repro question
looked answered when it wasn't — that was this false positive, not
the real bug.
This commit is contained in:
damocles 2026-07-22 23:59:22 +02:00 committed by mara
commit ee072149c9

View file

@ -677,9 +677,18 @@ impl Bus {
/// never touches the session. It exists because the harness is
/// otherwise blind to the init event — the only place claude reports
/// MCP-server status — so a dropped/failed stdio bridge (e.g. matrix
/// after a core bounce) went silent. Emitted every degraded turn on
/// purpose: whether it persists across fresh turns is the signal that
/// picks the real fix.
/// after a core bounce) went silent.
///
/// `pending` is deliberately NOT treated as degraded: it's the claude
/// CLI's normal init-event race for a freshly spawned stdio server
/// (status is reported before the handshake completes, then flips to
/// `connected` moments later in the same turn — see the upstream
/// claude-agent-sdk-typescript repo, issue number 368, for confirmation).
/// Flagging it fired on nearly every turn for all three configured
/// servers with zero actual impact (confirmed: tools kept working),
/// which is why the MCP-dropped-after-restart tracker issue's
/// persistent-vs-one-turn repro question looked answered "persistent"
/// when it wasn't — that was this false positive, not the real bug.
pub(crate) fn observe_mcp_health(&self, v: &serde_json::Value) {
if v.get("type").and_then(|t| t.as_str()) != Some("system")
|| v.get("subtype").and_then(|s| s.as_str()) != Some("init")
@ -886,8 +895,11 @@ impl Default for Bus {
/// Compare the configured MCP server names against the `{name -> status}`
/// map parsed from the init event, returning display strings for the
/// degraded ones: a configured server absent from the report was dropped
/// entirely; one present with a non-`connected` status failed to register.
/// Pure so it's unit-testable without a live `Bus`.
/// entirely; one present with a non-`connected`, non-`pending` status
/// failed to register. `pending` is excluded — it's the CLI's normal
/// init-event race for a server still completing its handshake, not a
/// failure (see the doc comment on `observe_mcp_health`). Pure so it's
/// unit-testable without a live `Bus`.
fn degraded_mcp_servers(
configured: &[String],
reported: &std::collections::HashMap<&str, &str>,
@ -895,7 +907,7 @@ fn degraded_mcp_servers(
configured
.iter()
.filter_map(|name| match reported.get(name.as_str()).copied() {
Some("connected") => None,
Some("connected" | "pending") => None,
Some(status) => Some(format!("{name} ({status})")),
None => Some(format!("{name} (absent)")),
})
@ -963,4 +975,18 @@ mod tests {
.collect();
assert!(super::degraded_mcp_servers(&configured, &reported).is_empty());
}
#[test]
fn degraded_mcp_servers_ignores_pending() {
// `pending` is the CLI's normal init-event race for a stdio server
// still completing its handshake (flips to `connected` moments
// later in the same turn) — not a real failure, per the
// MCP-dropped-after-restart tracker issue.
use std::collections::HashMap;
let configured = ["hyperhive".to_owned(), "matrix".to_owned()];
let reported: HashMap<&str, &str> = [("hyperhive", "pending"), ("matrix", "pending")]
.into_iter()
.collect();
assert!(super::degraded_mcp_servers(&configured, &reported).is_empty());
}
}