Custom MCP HTTP connector handshake stalls after notifications/initialized and GET /mcp — "Couldn't reach the MCP server"

Resolved 💬 4 comments Opened Apr 27, 2026 by alsaraujo-dev Closed May 31, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

A custom MCP server configured via .mcp.json (Streamable HTTP transport, static Bearer token auth) cannot complete handshake with the Cowork CUSTOM connector, even though:

  • The server is publicly reachable on Railway with HTTPS
  • Authentication succeeds (server logs ✅ Auth ok for every Cowork request)
  • The MCP initialize handshake completes (200 + valid application/json response)
  • notifications/initialized is received (202 + text/plain)
  • GET /mcp SSE stream is opened by Cowork (200 + text/event-stream)
  • The same server works PERFECTLY with npx @modelcontextprotocol/inspector and raw curl

Cowork shows: "Couldn't reach the MCP server" and never proceeds to tools/list.

Reference IDs from failed attempts: ofid_94b3c4f598e2d3ad, ofid_4f8f790b18f9d8f3, ofid_e6f7f557d5a31ad0, ofid_8f183333aba70478, ofid_4bf76d52e87fea00, ofid_c74b241ec28f25a1, ofid_32f6ccf6e3fff10a

What Should Happen?

Cowork should complete the MCP handshake (initialize → notifications/initialized → tools/list) and show the connector as Connected, exposing the server's tools to Claude.

Error Messages/Logs

Cowork sends initialize, receives valid JSON response, sends notifications/initialized (202), opens GET /mcp SSE stream (200), then shows "Couldn't reach the MCP server" without ever sending tools/list. Server logs show no further requests after the GET /mcp opens.

Cowork retries the entire handshake 5+ times before giving up.

Detailed server logs and trace available in attached technical report.

Steps to Reproduce

  1. Build a minimal MCP HTTP server using @modelcontextprotocol/sdk 1.29.0 with:
  • StreamableHTTPServerTransport (sessionIdGenerator: undefined, enableJsonResponse: true)
  • Static Bearer token auth via Authorization header
  • Permissive CORS
  1. Deploy publicly (e.g., Railway). Server URL: https://alva-server-production.up.railway.app/mcp
  1. Create a Cowork plugin with .mcp.json:

{
"mcpServers": {
"alva": {
"type": "http",
"url": "https://alva-server-production.up.railway.app/mcp",
"headers": { "Authorization": "Bearer <token>" }
}
}
}

  1. Install plugin in Cowork (drag .plugin into chat window, accept)
  1. Customize → Personal plugins → <plugin> → Connectors → <name> → Connect

Result: "Couldn't reach the MCP server" error appears.

The same server URL + token works flawlessly via:

  • npx @modelcontextprotocol/inspector
  • curl with Streamable HTTP semantics

Claude Model

Opus

Is this a regression?

No, this never worked

Last Working Version

_No response_

Claude Code Version

Claude 1.4758.0 (fb266c) 2026-04-24T20:22:30.000Z

Platform

Anthropic API

Operating System

macOS

Terminal/Shell

Terminal.app (macOS)

Additional Information

  1. CRITICAL: Same server tested with npx @modelcontextprotocol/inspector works perfectly (connects, lists tools, executes tool calls). This isolates the issue to Cowork's CUSTOM connector implementation.
  1. We tried 10+ workarounds, none resolved the issue:
  • Multiple plugin install/uninstall cycles
  • Full Cowork app quits and restarts
  • Connector toggle OFF/ON
  • Hardcoded vs ${ENV_VAR} Bearer token (env var substitution does NOT work — server receives literal "${ALVA_BEARER_TOKEN}")
  • enableJsonResponse: true (vs SSE)
  • Permissive CORS with all MCP headers exposed
  • Manual Accept header normalization (forcing application/json + text/event-stream)
  • Custom GET /mcp handler vs SDK-delegated
  • OAuth discovery endpoints implemented (/.well-known/oauth-protected-resource etc.)
  • Multiple server version bumps (0.3.0 → 0.3.7)
  1. Secondary issue worth investigating: ${ENV_VAR} substitution in .mcp.json headers does not happen. With launchctl setenv ALVA_BEARER_TOKEN tk_xxx and "Authorization": "Bearer ${ALVA_BEARER_TOKEN}" in .mcp.json, Cowork sends the literal string "${ALVA_BEARER_TOKEN}" to the server. This blocks the documented multi-user distribution pattern.
  1. Hypothesis: Cowork (claude-code SDK) opens the GET /mcp stream and waits for an event the server doesn't emit in stateless mode (e.g., SDK might be expecting an "endpoint" event from the older HTTP+SSE transport, falling back to that mode after Streamable HTTP handshake).
  1. Full technical report with annotated server logs available on request.

View original on GitHub ↗

This issue has 4 comments on GitHub. Read the full discussion on GitHub ↗