HTTP-transport MCP server tools unreachable ("No such tool available") despite /mcp showing connected + listing tools, persists across full app restart

Status Open
Reported on v2.1.104
Maintainer reply None cached
Activity 3 comments · opened Aug 15, 2026

Description

An HTTP-transport MCP server added via claude mcp add --transport http -s user <name> <url> --header "Authorization: Bearer <token>" shows as connected in /mcp, and /mcp's detail view correctly lists all of its tools by name. But calling any of those tools directly fails immediately with Error: No such tool available: <toolname> — the call never reaches the MCP server (no corresponding request observed server-side).

This looks related to #49152 but that issue is closed (fixed for 2.1.104→2.1.109) and this reproduces on versions outside that range.

Steps to Reproduce

  1. claude mcp add --transport http -s user myserver https://example.com/api/mcp --header "Authorization: Bearer <token>"
  2. Restart/reload so the new server connects
  3. Run /mcp — server shows connected, tool list is shown (e.g. 5 named tools)
  4. Ask Claude to call one of those tools, or have Claude call it directly by name

Expected

The tool call reaches the MCP server and returns a result (or a server-side error).

Actual

Immediate client-side error: Error: No such tool available: <toolname>. No request ever reaches the MCP server. ToolSearch/tool-discovery on the model side also finds nothing for the tool, even by exact name, despite /mcp showing it.

Additional notes

  • Reproduced on 2.1.80 (before upgrading)
  • Upgraded via claude update to 2.1.233 — same behavior persists
  • Tried multiple remediation steps between attempts, all ineffective:
  • VS Code "Developer: Reload Window"
  • Fully quitting and reopening VS Code (not just window reload)
  • Suspect the same open chat/conversation may be getting resumed across restarts rather than the tool registry being rebuilt fresh, since no fresh MCP-server "instructions" system message (which did appear the one time this worked, immediately after the server was first added) reappeared on subsequent restarts — but /mcp itself still reports the server as freshly connected each time.
  • Server confirmed independently reachable and correctly authenticating (verified via manual curl/direct HTTP calls to the same endpoint with the same bearer token, outside of Claude Code) — not a network/auth/server-side issue.
  • Other MCP servers (SSE transport) in the same config work fine and are callable normally; only the HTTP-transport server exhibits this.

Environment

  • Claude Code 2.1.80 → 2.1.233 (native installer, ~/.local/share/claude/versions/)
  • Linux
  • MCP transport: http (Streamable HTTP)

View original on GitHub ↗

3 Comments

jrparks · 11 days ago

Update: hit a variant of this today on the same server referenced in the original report (Dozzle's HTTP-transport MCP, --transport http).

claude mcp list now shows:

dozzle: https://.../api/mcp (HTTP) - ! Connected · tools fetch failed — MCP error -32000: Connection closed

So this time it's not "No such tool available" after a listed-but-unreachable tool — it's the client failing to complete tools/list at all, right after reporting the server as connected.

Confirmed server-side is completely healthy: replayed the full Streamable HTTP handshake by hand with curl (same bearer token, same endpoint) —

  1. POST /api/mcp initialize → 200, returns Mcp-Session-Id header and full serverInfo/capabilities
  2. POST /api/mcp notifications/initialized (with the session header) → 202
  3. POST /api/mcp tools/list (with the session header) → 200, returns all 5 tools with full schemas

No errors, no timeouts, clean JSON-RPC responses at every step. So the server correctly implements the initialize → notifications/initialized → tools/list handshake and session continuity — whatever's failing client-side isn't a protocol-compliance gap on the server end.

Seems like the Claude Code HTTP-transport MCP client has more than one way to fail keeping the Streamable HTTP session alive across the handshake / first real call — this session was freshly re-authenticated (a token that had previously been outright rejected, i.e. this isn't a stale-session-reuse case like the earlier report speculated).

Environment: Claude Code (native, Linux), same --transport http config style as the original report.

sjh9714 · 7 days ago

Maintainer disclosure: I built MCP Host Canary. A narrow A/B that may help separate the two failure modes here is to create a 30-minute baseline run and add its generated URL as a second no-auth HTTP server:

claude mcp add --transport http host-canary <generated-mcp-url>

Confirm /mcp lists canary_ping, ask Claude to call it, then finalize the run. The server-side receipt records optional modern server/discover or the legacy initialize lifecycle, tools/list, the tools/call request, and actual callback execution as separate boundaries.

If it stops after tools/list.response, that independently reproduces the client registration/dispatch break against a disposable known endpoint. If canary_ping executes while the authenticated Dozzle server still fails, the useful delta is the Bearer/session-continuity path or server-specific response behavior rather than generic HTTP tool registration.

The canary is no-auth and expires after 30 minutes, so it cannot test the authenticated session path or establish root cause. Please do not post the live capability URL; only share finalized safe receipt fields.

jrparks · 6 days ago

Ran the canary A/B @sjh9714 suggested, plus a third data point against a real authenticated HTTP-transport server. Findings flip the "Bearer/session-continuity" hypothesis:

1. Canary baseline (no-auth, disposable, 3 tools) — full round-trip after fresh reload:
initializeinitializedtools/listtools/call.requesttools/call.executed, all 200/202. Receipt fingerprint ZiK4mHQZWiUdYO4iRydhlT1j.

2. Real server, direct HTTP transport (Bearer-token auth, Streamable HTTP, 5 tools): /mcp shows it connected with named tools, same as the report above — but none of those tools ever reach the client's dispatchable tool registry, even after a full app restart (confirmed via claude mcp add --transport http, checked with ToolSearch/direct call).

3. Same server, manually replaying the identical protocol over stdio: wrote a local wrapper that does initializenotifications/initializedtools/call by hand via plain urllib, holding Mcp-Session-Id across calls, using the same Bearer token. Works every time, no drop-off.

(3) rules out server, auth, and session-continuity as the cause — a plain, naive HTTP client handling the same handshake against the same server/token works fine. (1) rules out a blanket "no HTTP-transport tool call ever registers" bug. So the break looks specific to Claude Code's native HTTP-transport client under some condition this server hits and the canary doesn't (tool count, response shape, SSE framing — haven't isolated which yet).

Wrapper code for reference (secrets pulled from env, none hardcoded; internal hostname redacted):

#!/usr/bin/env python3
# Local stdio MCP wrapper proxying our Dozzle HTTP-transport MCP server.
#
# Works around a Claude Code bug (github.com/anthropics/claude-code/issues/86875)
# where the HTTP-transport MCP client can't reliably complete tools/list or
# tool calls, even though the server responds correctly (confirmed via
# manual replay below). stdio transport isn't affected, so this re-exposes
# the same 5 tools over stdio and proxies each call through to the HTTP
# server itself, holding the Mcp-Session-Id across calls.
#
# Setup:
#   pip install mcp
#   claude mcp remove dozzle -s user
#   claude mcp add --transport stdio -s user dozzle-wrapper \
#     --env DOZZLE_MCP_TOKEN=<jwt> \
#     -- python3 dozzle_mcp_wrapper.py
#
# Remove this and switch back to the plain HTTP entry once the upstream
# bug is fixed.

import json
import os
import urllib.error
import urllib.request

from mcp.server import MCPServer

DOZZLE_URL = os.environ.get("DOZZLE_URL", "https://<redacted-host>")
MCP_URL = f"{DOZZLE_URL}/api/mcp"
TOKEN = os.environ["DOZZLE_MCP_TOKEN"]

_session_id: str | None = None


def _call(method: str, params: dict | None = None, id_: int | None = None):
    global _session_id
    body: dict = {"jsonrpc": "2.0", "method": method}
    if id_ is not None:
        body["id"] = id_
    if params is not None:
        body["params"] = params

    req = urllib.request.Request(MCP_URL, data=json.dumps(body).encode(), method="POST")
    req.add_header("Content-Type", "application/json")
    req.add_header("Accept", "application/json, text/event-stream")
    req.add_header("Authorization", f"Bearer {TOKEN}")
    if _session_id:
        req.add_header("Mcp-Session-Id", _session_id)

    with urllib.request.urlopen(req) as resp:
        sid = resp.headers.get("Mcp-Session-Id")
        if sid:
            _session_id = sid
        raw = resp.read().decode()

    # Streamable HTTP responses may be plain JSON, or SSE (an `event: message`
    # line followed by `data: {...}` -- the data line isn't necessarily first).
    for line in raw.splitlines():
        if line.startswith("data: "):
            raw = line[len("data: "):]
            break
    return json.loads(raw) if raw.strip() else None


def _ensure_session() -> None:
    if _session_id:
        return
    _call(
        "initialize",
        {
            "protocolVersion": "2025-11-25",
            "capabilities": {},
            "clientInfo": {"name": "dozzle-mcp-wrapper", "version": "0"},
        },
        id_=1,
    )
    _call("notifications/initialized")


def _proxy(name: str, arguments: dict):
    global _session_id
    _ensure_session()
    try:
        response = _call("tools/call", {"name": name, "arguments": arguments}, id_=2)
    except urllib.error.HTTPError as e:
        # Session appears to expire well before the client is idle for long --
        # a 404 here means the Mcp-Session-Id is no longer recognized.
        # Re-initialize once and retry.
        if e.code != 404:
            raise
        _session_id = None
        _ensure_session()
        response = _call("tools/call", {"name": name, "arguments": arguments}, id_=2)
    if response and "error" in response:
        raise RuntimeError(response["error"].get("message", "mcp error"))
    return response["result"] if response else None


mcp = MCPServer("dozzle-wrapper")


@mcp.tool()
def list_hosts() -> dict:
    """List all Docker hosts connected to Dozzle."""
    return _proxy("list_hosts", {})


@mcp.tool()
def list_containers(state: str | None = None) -> dict:
    """List all Docker containers across all hosts."""
    return _proxy("list_containers", {"state": state} if state else {})


@mcp.tool()
def get_container_logs(
    host: str,
    container_id: str,
    since_minutes: int | None = None,
    stream: str | None = None,
) -> dict:
    """Fetch processed logs from a Docker container."""
    args = {"host": host, "container_id": container_id}
    if since_minutes is not None:
        args["since_minutes"] = since_minutes
    if stream is not None:
        args["stream"] = stream
    return _proxy("get_container_logs", args)


@mcp.tool()
def search_container_logs(
    host: str,
    container_id: str,
    query: str,
    since_minutes: int | None = None,
    stream: str | None = None,
    case_sensitive: bool | None = None,
) -> dict:
    """Search container logs for a keyword or phrase."""
    args = {"host": host, "container_id": container_id, "query": query}
    if since_minutes is not None:
        args["since_minutes"] = since_minutes
    if stream is not None:
        args["stream"] = stream
    if case_sensitive is not None:
        args["case_sensitive"] = case_sensitive
    return _proxy("search_container_logs", args)


@mcp.tool()
def get_container_stats(host: str, container_id: str) -> dict:
    """Get CPU and memory usage stats for a Docker container."""
    return _proxy("get_container_stats", {"host": host, "container_id": container_id})


if __name__ == "__main__":
    mcp.run(transport="stdio")