modelcontextprotocol / modelcontextprotocol/typescript-sdk

StreamableHTTP server transport returns bare 400 for a notification when the request has Connection: close

Open
#2,730 1 comment 1 reaction 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
13.4k
Forks
2.2k
Avg merge
3d 15h
Merged PRs (30d)
4

Description

Summary

A JSON-RPC notification (no id, spec expects a bare 202 empty ack) receives an unlogged,
bodyless 400 Bad Request from the Node StreamableHTTP server transport if — and only if — the
inbound HTTP request's Connection header is literally the string close. Requests with
Connection: keep-alive (or the header omitted) on an otherwise identical connection succeed with
202 as expected.

Reproduced identically on both:

  • @modelcontextprotocol/sdk 1.30.0 (StreamableHTTPServerTransport, v1)
  • @modelcontextprotocol/server + @modelcontextprotocol/node 2.0.0 (NodeStreamableHTTPServerTransport, v2)

Both wrap the same @hono/node-server-based Node HTTP transport, which is presumably why the
defect is shared across both SDK major versions.

Impact

Any real MCP client whose HTTP client sends Connection: close on the notifications/initialized
call (observed: claude-code's own MCP client does this on some requests) breaks immediately after
initialize succeeds
— one call before tools/list is ever reached. From the client's perspective
this presents as "no tools available" / a silent, unrecoverable connect failure, with nothing useful
logged server-side (not in application code, not in hono's own handleRequestError /
handleFetchError / handleResponseError paths).

This is easy to hit unintentionally for any deployment behind a reverse proxy that forwards the
client's own Connection header to the upstream (e.g. an nginx keepalive upstream block that
declares keepalive N but omits the accompanying proxy_set_header Connection ""; — nginx's own
docs require that pairing, but a server without it silently passes the client's header through
instead of clearing it).

Reproduction (bisected, one variable at a time)

  • A normal request with an id (e.g. tools/list) succeeds fine under identical conditions.
  • A bare hono getRequestListener handler that directly returns new Response(null, {status: 202})
    (no MCP SDK layer involved at all) also succeeds fine under identical conditions.
  • The only combination that fails: a notification (no id, 202 expected) and the incoming
    request's Connection header equal to "close". This isolates the defect to the SDK's own
    notification-response code path, not hono generically and not application code (the MCP server's
    own request handlers never run for this case).

Minimal repro (pseudocode — happy to provide a runnable script):

POST /mcp HTTP/1.1
Content-Type: application/json
Accept: application/json, text/event-stream
Mcp-Session-Id: <valid, already-initialized session or stateless mode>
Connection: close

{"jsonrpc":"2.0","method":"notifications/initialized"}

Expected: HTTP/1.1 202 Accepted (empty body).
Actual: HTTP/1.1 400 Bad Request (empty body, nothing logged).

Same request with Connection: keep-alive (or header omitted) → correct 202.

Environment

  • Node 24.18.1
  • @modelcontextprotocol/sdk 1.30.0, and separately @modelcontextprotocol/server 2.0.0 +
    @modelcontextprotocol/node 2.0.0
  • Both transports are StreamableHTTPServerTransport / NodeStreamableHTTPServerTransport
    respectively, in stateless mode (sessionIdGenerator: undefined) — also reproduces in stateful
    mode with a valid session id, mode is not the differentiator.

Workaround in place

Not a fix — noting for anyone else who hits this. At the reverse-proxy layer, strip/clear the
client's Connection header before forwarding (nginx: proxy_set_header Connection ""; alongside
proxy_http_version 1.1;). This avoids the defect entirely without touching the SDK, but only helps
deployments that sit behind a proxy that can be configured this way — a client connecting directly
to a Node process running the transport with no intervening proxy would still hit this.

Ask

Happy to provide a minimal runnable repro script (Node) and/or a packet capture on request. Filed as
a report only — have not attempted a fix in the SDK's own transport code.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start at the notification-response path in StreamableHTTPServerTransport and NodeStreamableHTTPServerTransport, then reproduce the request with Connection: close using the provided pseudocode. Compare it with the keep-alive and request-with-id cases, and verify that the notification returns an empty 202 response without an unlogged 400.

Written by the indexing model from the issue text.

Assessment

Tech stack
node.js, typescript
Domain
api, backend
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
58/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.