Skip to content

fix(client): treat HTTP 404 with session ID as session expiry#2125

Open
dsp-ant wants to merge 2 commits into
mainfrom
fix/streamable-http-session-expiry-404
Open

fix(client): treat HTTP 404 with session ID as session expiry#2125
dsp-ant wants to merge 2 commits into
mainfrom
fix/streamable-http-session-expiry-404

Conversation

@dsp-ant
Copy link
Copy Markdown
Member

@dsp-ant dsp-ant commented May 20, 2026

Motivation

The MCP spec (Streamable HTTP, Session Management) states:

When a client receives HTTP 404 in response to a request containing an Mcp-Session-Id, it MUST start a new session by sending a new InitializeRequest without a session ID.

StreamableHTTPClientTransport did not implement this. Every non-401/403 error status — including 404 — was surfaced as a generic SdkErrorCode.ClientHttpNotImplemented (POST) or SdkErrorCode.ClientHttpFailedToOpenStream (GET), with no way for a caller to tell session expiry apart from any other HTTP failure.

In practice consumers worked around this by string-matching the response body (e.g. looking for a -32001 JSON-RPC code). That only matches the reference server. Servers that report an expired session with a different body slip through and the session stays wedged until the app is restarted, for example:

  • a different JSON-RPC error code (-32002)
  • a plain-text or HTML body from a proxy that never produces a JSON-RPC envelope
  • an empty body

Change

Detect session expiry by status code alone, scoped to requests that actually carried a session ID (exactly what the spec describes):

  • On a 404 when this._sessionId is set, the transport clears the stale session ID and throws SdkError with a new SdkErrorCode.ClientHttpSessionExpired. Clearing the ID means a subsequent client.connect(transport) issues a fresh initialize instead of a reconnect (Client.connect() branches on transport.sessionId).
  • A 404 for a request that did not carry a session ID (e.g. a wrong URL on the initial connect) is unchanged and still surfaces as ClientHttpNotImplemented.
  • Detection is body-agnostic, so non-reference bodies are handled correctly.

Applied to both the POST (_send) and standalone GET SSE (_startOrAuthSse) paths.

try {
    await transport.send(message);
} catch (error) {
    if (error instanceof SdkError && error.code === SdkErrorCode.ClientHttpSessionExpired) {
        // transport.sessionId is already cleared — reconnect to start a fresh session
        await client.connect(transport);
    }
}

Tests

packages/client/test/client/streamableHttp.test.ts — replaced the single 404 test with a session expiry (HTTP 404) group:

  • a parametrized case asserting ClientHttpSessionExpired for a 404 with a session ID across varied bodies (plain text, -32002, -32001, HTML, empty), and asserting transport.sessionId is cleared
  • a negative case asserting a 404 without a session ID stays ClientHttpNotImplemented

Full workspace typecheck, build, and lint pass (pre-push hook).

Docs

Added SdkErrorCode.ClientHttpSessionExpired and the new behavior to docs/migration.md and docs/migration-SKILL.md.

Per the MCP spec (Streamable HTTP, Session Management), when a client receives an HTTP 404 in response to a request that carried an Mcp-Session-Id, the session has expired or been terminated server-side and the client must start a new session.

StreamableHTTPClientTransport previously surfaced every non-401/403 error status — including 404 — as a generic ClientHttpNotImplemented (POST) or ClientHttpFailedToOpenStream (GET) error, with no way to distinguish session expiry from other failures. Consumers were left matching the response body, which only works against the reference server; servers that report expiry with a different body (e.g. a -32002 JSON-RPC code, or a plain-text/HTML proxy response) slipped through.

Detect session expiry by status code alone, scoped to requests that actually carried a session ID: on a 404 when a session ID is set, clear the stale session ID (so a subsequent connect() issues a fresh initialize) and throw SdkError with the new SdkErrorCode.ClientHttpSessionExpired. A 404 without a session ID (e.g. wrong URL on initial connect) is unchanged and still surfaces as ClientHttpNotImplemented. Applied to both the POST (_send) and standalone GET SSE (_startOrAuthSse) paths; documents the new code in the migration guides.
@dsp-ant dsp-ant requested a review from a team as a code owner May 20, 2026 15:48
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 20, 2026

⚠️ No Changeset found

Latest commit: 6cc7726

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 20, 2026

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2125

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2125

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2125

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2125

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2125

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2125

commit: 6cc7726

Comment thread packages/client/src/client/streamableHttp.ts
Comment thread packages/client/src/client/streamableHttp.ts
Comment thread packages/client/test/client/streamableHttp.test.ts
… in terminateSession

Snapshot whether the request carried a session ID before the fetch, so the 404 session-expiry check reflects the request rather than post-response state (the response handler may write a new mcp-session-id into _sessionId). Apply the same snapshot in the GET path for symmetry.

terminateSession() now treats a 404 like the existing 405 case: the session is already gone server-side, which is the caller's intent, so it clears the session ID and resolves instead of throwing ClientHttpFailedToTerminateSession.

Add tests for the GET-stream 404 path (distinct error data shape) and the terminateSession 404 path.
@dsp-ant
Copy link
Copy Markdown
Member Author

dsp-ant commented May 22, 2026

Thanks for the review — addressed all three in 6cc7726:

  1. Request vs post-response session state — the 404 check now branches on a requestHadSessionId snapshot taken before the fetch, so it reflects whether the request carried an Mcp-Session-Id rather than post-response state (the response handler can write a new mcp-session-id into _sessionId). Applied the same snapshot to the GET path for symmetry.

  2. terminateSession() 404 — now treated like the existing 405 case: a 404 means the session is already gone server-side (the caller's intent), so it clears _sessionId and resolves instead of throwing ClientHttpFailedToTerminateSession.

  3. GET-path coverage — added a test for the standalone GET stream 404 (which carries the { status, statusText } error-data shape) plus a terminateSession() 404 test.

Full workspace typecheck/build/lint pass.

Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 1 feedback fully addressed in 6cc7726 — request-side session-ID snapshot, terminateSession() 404-as-success, and GET-path test coverage all look correct. Only doc nits remain; note the changeset bot is still flagging a missing changeset for this behavior change in the client package.

Extended reasoning...

Overview

This PR adds spec-mandated handling for HTTP 404 responses on requests that carried an Mcp-Session-Id: the transport clears the stale session ID and surfaces a new SdkErrorCode.ClientHttpSessionExpired. Changes span packages/client/src/client/streamableHttp.ts (POST, GET SSE, and terminateSession() paths), packages/core/src/errors/sdkErrors.ts (new enum value), tests, and migration docs.

Security risks

None. The change narrows error classification by HTTP status code on the client transport. No new auth surface, no input parsing, no injection vectors. The requestHadSessionId snapshot taken before fetch correctly avoids the post-response state-mutation issue raised in round 1.

Level of scrutiny

Medium — this is a public client transport behavior change in error handling, which downstream consumers branch on. However, the change is anchored directly to the MCP spec (Streamable HTTP, Session Management), the author bundled a migration-docs update, and tests cover the POST path, GET SSE path, the no-session-ID negative case, and terminateSession() 404. All three of my prior review comments were addressed precisely as suggested.

Other factors

The two remaining inline comments are documentation-completeness nits (JSDoc on terminateSession() not mentioning the new 404 case; migration.md slightly overstating the no-session-ID error code as POST-only behavior). Neither blocks correctness. The changeset-bot has flagged a missing changeset; given this changes observable error codes in @modelcontextprotocol/client and adds a new public enum value to @modelcontextprotocol/core, a patch changeset is warranted before merge.

Comment on lines +765 to +770
// 405 Method Not Allowed: per the spec the server does not support explicit
// session termination — treat as success.
// 404 Not Found: the session is already gone server-side, which is exactly
// what the caller asked for — treat as success rather than a failure. In both
// cases fall through to clear the local session ID.
if (!response.ok && response.status !== 405 && response.status !== 404) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The terminateSession() JSDoc still only documents the 405 Method Not Allowed special case, but this PR adds a sibling success case: a 404 now also resolves and clears the session ID instead of throwing ClientHttpFailedToTerminateSession. Add a one-sentence note mirroring the 405 sentence so the public-facing JSDoc matches the implementation.

Extended reasoning...

What changed vs. what the JSDoc says

This PR changes terminateSession() so that an HTTP 404 response is treated as a second success case alongside the existing 405:

// 405 Method Not Allowed: per the spec the server does not support explicit
// session termination — treat as success.
// 404 Not Found: the session is already gone server-side, ... — treat as success ...
if (!response.ok && response.status !== 405 && response.status !== 404) {
    throw new SdkError(SdkErrorCode.ClientHttpFailedToTerminateSession, ...);
}
this._sessionId = undefined;

The behavior is documented in three places: the inline implementation comment, docs/migration.md ("Session expiry now surfaces as ClientHttpSessionExpired" section), and docs/migration-SKILL.md. But the public-facing JSDoc on terminateSession() — the text consumers see in editor hover tooltips and generated API docs — still reads:

The server MAY respond with HTTP 405 Method Not Allowed, indicating that the server does not allow clients to terminate sessions.

It enumerates exactly one tolerated non-OK status, which now reads as a complete list when it isn't.

Why it matters (and why it's a nit, not a blocker)

The refutation correctly notes there's no contradiction — the JSDoc never claimed 404 throws, and the 405 sentence is a near-verbatim spec quote about server behavior, not a claim about SDK error handling. So this isn't a prose-vs-implementation mismatch in the strict sense.

But the JSDoc does go out of its way to call out one specific non-OK status that is silently tolerated. By enumerating one special case, it implies a closed set. Adding a sibling special case in the implementation while leaving the doc untouched makes the doc misleadingly incomplete: a consumer reading only the hover tooltip would reasonably assume any non-405, non-OK response (including 404) throws ClientHttpFailedToTerminateSession, and might write try/catch logic for a case that no longer fires.

Step-by-step

  1. A consumer with an active session calls transport.terminateSession() while the server has already evicted the session (idle timeout, restart, etc.).
  2. The server returns 404. Pre-PR this threw ClientHttpFailedToTerminateSession; post-PR it resolves and clears _sessionId.
  3. A consumer who reads the JSDoc to understand what failure modes to handle sees only the 405 carve-out and doesn't learn that 404 is now a silent success — they'd have to dig into the migration guide or the source to discover it.

Fix

Add one sentence to the JSDoc at packages/client/src/client/streamableHttp.ts around lines 757–759, mirroring the existing 405 line:

The server may also respond with HTTP `404 Not Found` if the session has
already expired or been terminated; this is treated as successful termination.

This keeps the public-facing doc in sync with both the implementation and the migration guide. Pure documentation-completeness nit; non-blocking.

Comment thread docs/migration.md
Comment on lines +792 to +793

A `404` for a request that did **not** carry a session ID (for example a wrong URL on
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The new sentence stating a 404 without a session ID "still surfaces as SdkErrorCode.ClientHttpNotImplemented" is only true for the POST path (_send); on the standalone GET SSE path (_startOrAuthSse) the same scenario falls through to SdkErrorCode.ClientHttpFailedToOpenStream. Consider qualifying the sentence as POST-only or listing both codes so consumers writing recovery logic from the docs handle both paths.

Extended reasoning...

What the prose claims vs. what the code does

The added paragraph under Session expiry now surfaces as ClientHttpSessionExpired states unconditionally:

A 404 for a request that did not carry a session ID (for example a wrong URL on the initial connection) is unchanged: it still surfaces as SdkErrorCode.ClientHttpNotImplemented.

That sentence reads as a general claim about every 404-without-session path in StreamableHTTPClientTransport, but it is only accurate for the POST path. The transport has two places where the new 404 branch was added, and their fall-through error codes differ:

  • _send (POST): after the new if (response.status === 404 && requestHadSessionId) branch, the catch-all throws SdkError(SdkErrorCode.ClientHttpNotImplemented, ...) — matches the prose.
  • _startOrAuthSse (standalone GET SSE): after the same new 404 branch, the catch-all throws SdkError(SdkErrorCode.ClientHttpFailedToOpenStream, ...)not ClientHttpNotImplemented.

Step-by-step proof for the GET path

  1. A transport is constructed with no sessionId option, so this._sessionId is undefined.
  2. _startOrAuthSse({}) runs. The new snapshot const requestHadSessionId = this._sessionId !== undefined evaluates to false.
  3. The server replies 404 Not Found.
  4. response.ok is false, so the error block runs. The 401, 405, and the new 404 && requestHadSessionId checks are all skipped (the last one because requestHadSessionId is false).
  5. Execution reaches the fall-through: throw new SdkError(SdkErrorCode.ClientHttpFailedToOpenStream, \Failed to open SSE stream: ${response.statusText}`, ...)`.

So the GET path produces ClientHttpFailedToOpenStream, not the ClientHttpNotImplemented the prose promises.

Why it matters (and why it's only a nit)

A consumer writing reconnect/recovery logic from the migration guide could reasonably write a handler that branches on ClientHttpSessionExpired vs. ClientHttpNotImplemented to decide "session expired — reconnect" vs. "plain 404 — give up." The GET path's plain-404 case would slip through neither branch.

That said, this is a low-impact documentation imprecision rather than a code bug:

  • The prose's own parenthetical example ("a wrong URL on the initial connection") points the reader at the POST initialize path, where the claim is correct.
  • The GET-without-session-then-404 scenario is rare in practice — the standalone GET stream is normally opened post-initialize against the same URL, by which point a session ID would be set if the server uses sessions.
  • The GET path's 404-without-session error code is unchanged from before this PR; only the unqualified attribution to ClientHttpNotImplemented is new and misleading.

How to fix

Qualify the sentence or list both codes. For example:

A 404 for a request that did not carry a session ID is unchanged: it still surfaces as SdkErrorCode.ClientHttpNotImplemented on POST and SdkErrorCode.ClientHttpFailedToOpenStream on the standalone GET stream.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant