microsoft / microsoft/vscode

MCP: initialized notification is not ordered before the first request, breaking stateful Streamable HTTP servers intermittently

Open
#336,841 0 comments 0 reactions 1 assignee View on GitHub

Nobody has claimed this yet.

new release
Dominant language
TypeScript
Stars
193k
Forks
42.7k
PR merge metrics
PR metrics pending

Description

Does this issue occur when all extensions are disabled?: Yes (MCP client is core)

  • VS Code Version: 1.138.0 (also reproduced on 1.137.0)
  • OS Version: macOS 15 / Windows 11 ARM64
What happens

Connecting to a stateful Streamable HTTP MCP server — one that issues Mcp-Session-Id on initialize and requires notifications/initialized before it will serve other requests — fails intermittently on the first real request:

Connection state: Error 400 status sending message to <server>/mcp:
{"jsonrpc":"2.0","id":2,"error":{"code":-32600,"message":"Session not initialized. Send notifications/initialized first."}}; will retry with new session ID

VS Code then retries with a new session and can fail the same way indefinitely, so the server appears permanently broken. Restarting it doesn't help; restarting repeatedly eventually succeeds. The failure rate scales with network latency — rare on a fast connection, near-constant for users on a higher-latency corporate network.

Why

notifications/initialized is sent fire-and-forget and is not ordered against the first real request, so the two are concurrent HTTP POSTs with no ordering guarantee.

  • mcpServerRequestHandler.ts:144 — the handshake calls sendNotification({ method: 'notifications/initialized' }) without awaiting, then create() returns and the caller immediately issues tools/list (id:2).
  • mcpServerRequestHandler.ts:281sendNotification is void; send() bottoms out in IMcpMessageTransport.send(), which is also void by interface design, so there is nothing to await at this layer.
  • extHostMcp.ts:385-392 — in McpHTTPHandle.send(), messages are only serialized through _requestSequencer while _mode.value === HttpMode.Unknown. Once initialize returns and the mode becomes HttpMode.Http, every subsequent send takes the unserialized else branch.
  • _sendStreamableHttp also does await this._addAuthHeader(headers) before each fetch, adding enough jitter to flip the ordering.

So the sequencer that would have ordered these is switched off by the act of recognising the server as a session-based Streamable HTTP server — exactly the case where ordering matters most.

Verified server behaviour

Measured directly with raw HTTP against such a server:

Request Result
initialize 200, issues Mcp-Session-Id, < 1s
request on a session before notifications/initialized 400, -32600 Session not initialized
notifications/initialized 202
request after the notification 200
request with an unknown session id 404 (spec-correct)
request with no session header 400 (spec-correct)

Issuing the same calls sequentially always succeeds. Only the concurrent ordering fails.

Suggested fix

Keep serializing through the existing _requestSequencer until the initialized notification has actually been sent, instead of stopping as soon as the transport mode is known. This keeps the change local to McpHTTPHandle — no change to IMcpMessageTransport or the extension-host protocol — and leaves post-handshake traffic unserialized:

if (this._mode.value === HttpMode.Unknown || !this._didSendInitialized) {
    await this._requestSequencer.queue(async () => {
        await this._send(message);
        if (isInitializedNotification(message)) {
            this._didSendInitialized = true;
        }
    });
} else {
    await this._send(message);
}

The flag is set only after the notification's POST resolves, so a request entering send() while it is still in flight queues behind it.

On the spec

To be upfront: I don't believe VS Code is violating the spec here. The only client-side ordering requirement in the lifecycle spec is "SHOULD NOT send requests other than pings before the server has responded to the initialize request", which VS Code honours; the rule referencing the initialized notification binds the server. The transports spec defines no server error for this state, and since every message is a separate POST, strict ordering isn't something the transport can guarantee — so this is being raised on the server side too.

Raising it here because servers that do enforce this ordering exist and are deployed, the failure is opaque from the user's side (it presents as an unrecoverable retry loop), and ordering one notification at startup is a cheap way to be robust against them.

Related: #330022 was a similar Streamable HTTP interop fix in the same client.

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 with mcpServerRequestHandler.ts around lines 144 and 281, then trace McpHTTPHandle.send() and _sendStreamableHttp in extHostMcp.ts around lines 385-392. Reproduce against a stateful Streamable HTTP server and verify that notifications/initialized completes before the first tools/list request, without the intermittent initialization error or retry loop.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
api, networking
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
74/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.