Skip to content

mcp: a Streamable HTTP server that dies after initialization is never detected #358

Description

@MitulShah1

Follow-up to the MCP death detection added in v1.3.2 (#353).

Problem

The v1.3.2 detection covers the stdio transport only. A Streamable HTTP MCP server that initializes successfully and later becomes unreachable stays marked ready: its tools keep being advertised to the model, FindToolServer keeps resolving them, and every call fails.

Two reasons:

  • The reactive check in internal/mcp/executor.go matches transport.ErrTransportClosed, which is defined in the stdio transport. The HTTP client in internal/mcp/client.go builds its errors with fresh fmt.Errorf wraps (mcp http do: %w around a *url.Error, or a formatted string for a non-2xx), so the match is never true.
  • The proactive watcher is registered behind a client.(interface{ Exited() <-chan struct{} }) assertion. Only the stdio client implements it, so no watcher is started for HTTP.

Observed against a closed httptest server after a successful handshake:

POST-DEATH errors.Is(err, transport.ErrTransportClosed) = false
POST-DEATH IsReady=true  AllTools=[http_tool]  FindToolServer=true

A server down at initialization is handled correctly — it never goes ready, and /readyz reports it. The gap is only post-initialization death.

Impact

Degraded rather than fatal: Executor returns the failure as a tool-result message, so the model sees the error and the request completes. The cost is a wasted round trip per call, and tools advertised that cannot be used.

With mcp_servers[].required: true the readiness gate is also affected — a required HTTP server that has died reports healthy, because readiness reads the same ready flag that never clears.

Why this was not fixed alongside the stdio case

Marking a server unready is terminal. There is no periodic re-probe; recovery requires a configuration reload or a restart. That is correct for a crashed subprocess, which cannot return on its own, but wrong for HTTP: a refused connection is routinely transient — a rolling deploy, a pod restart, a load balancer draining a backend.

Treating the first HTTP error as death would therefore turn a few seconds of redeployment into a permanent withdrawal of that server's tools, and with required: true into a permanent 503 that takes the instance out of rotation until an operator intervenes. That is worse than the bug.

Proposed

Both halves are needed; neither works alone.

  1. A consecutive-failure threshold (or a failure window) before an HTTP server is withdrawn, so a blip is distinguished from an outage.
  2. A periodic re-probe of unready servers, so withdrawal is recoverable without a configuration reload. This is worth having for stdio as well.

Related

AGENTS.md scopes the post-initialization death-detection guarantee to stdio transports. That note should be removed once this ships.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions