Skip to content

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

Open
Azazi wants to merge 3 commits into
modelcontextprotocol:mainfrom
Azazi:fix/streamable-http-404-session-expiry-rebase
Open

fix(client): treat HTTP 404 with session ID as session expiry#2660
Azazi wants to merge 3 commits into
modelcontextprotocol:mainfrom
Azazi:fix/streamable-http-404-session-expiry-rebase

Conversation

@Azazi

@Azazi Azazi commented Aug 13, 2026

Copy link
Copy Markdown

Summary

Picks up #2125 (thank you @dsp-ant for the original work) — rebased onto current main to resolve the merge conflict with #2469, which touched the same lines after #2125 was opened and left it stuck as CONFLICTING for a while. The behavior is the same as #2125's final state:

  • Per the MCP spec (Streamable HTTP, Session Management): a 404 to a request that carried an Mcp-Session-Id means the session has expired or been terminated server-side. StreamableHTTPClientTransport now detects this, clears the stale session ID, and throws a new, distinguishable SdkErrorCode.ClientHttpSessionExpired instead of the generic ClientHttpNotImplemented — so a subsequent connect() starts a fresh session automatically.
  • terminateSession() now also treats a 404 (session already gone) the same as the existing 405 case: resolves instead of throwing.
  • Deliberately not applied to the standalone GET SSE stream — a 404 there must not tear down an otherwise-healthy session (this was fix(client): treat HTTP 404 with session ID as session expiry #2125's own last commit, reverting an earlier version of itself after review; kept as-is here).

Relates to #1708.

What changed since #2125 (the actual diff, not just a merge)

git cherry-pick of #2125's three commits doesn't apply cleanly against current main — worth calling out so this doesn't read as reinventing the fix:

Given that, I reproduced the behavior fresh against current main rather than fighting three-way merges through a stale branch, and credited David via Co-authored-by on the fix commit.

Also, beyond #2125's original scope:

  • Updated packages/core-internal/test/types/errorSurfacePins.test.ts — this repo added an explicit ABI pin over the full SdkErrorCode membership since fix(client): treat HTTP 404 with session ID as session expiry #2125 was opened (docs/behavior-surface-pins.md), which the new enum member correctly turns red without a pin update.
  • Added a changeset (.changeset/streamable-http-404-session-expiry.md) — fix(client): treat HTTP 404 with session ID as session expiry #2125 didn't have one.
  • Added a migration-guide entry in docs/migration/upgrade-to-v2.md, per docs/behavior-surface-pins.md's protocol for a deliberate, consumer-facing SdkErrorCode change.
  • Corrected an existing test (should handle 404 response when session expires) that, despite its name, never actually established a session before triggering the 404 — it was only ever exercising the unrelated generic-fallback path. Renamed it to describe what it actually tests, and added real coverage for the session-bound case, the GET-stream non-clearing regression guard, and terminateSession()'s 404 handling.

Scope note

This intentionally does not include #2150's transparent reinit-and-retry-original-request behavior or its new onsessionexpired hook — that's a substantively different (and larger) design question. #2466's closing comment framed exactly this kind of minimal fix as the thing worth landing now, with fuller recovery "available... if we want it as its own PR later." Happy to be pointed at a different direction if maintainers prefer #2150's approach instead — this is meant to unblock the minimal, already-reviewed fix, not to preempt that decision.

Test plan

  • pnpm check:all (typecheck + lint across the whole monorepo) — clean
  • pnpm test:all — all tests green in every package this change touches or that depends on it (client: 800/800, core-internal: 1433/1433 including the updated pin); two pre-existing, unrelated failures observed in test/integration (IPv6/host-header) and test/e2e (protocol:timeout:max-total, a fake-timer race) were confirmed present and unrelated to this diff by reproducing them on a clean, unmodified main checkout
  • Four new/corrected unit tests specifically for this change, confirmed passing by name

Housekeeping

Since this rebases #2125, I've left a comment there and on #1708 pointing here — feel free to close #2125 in favor of this one if that's easier, or redirect review however's most convenient.

Azazi and others added 3 commits August 13, 2026 17:11
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) 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 (snapshotted before the fetch, and before the isHandshake
header-stripping check, so a sessionless initialize is never misclassified):
on a 404 when the request carried a session ID, clear the stale session ID
(so a subsequent connect() issues a fresh initialize) and throw SdkHttpError
with the new SdkErrorCode.ClientHttpSessionExpired. A 404 without a session ID
is unchanged and still surfaces as ClientHttpNotImplemented. Not applied to
the standalone GET SSE stream, whose failure must not tear down an otherwise
healthy session.

terminateSession() now also treats a 404 (session already gone server-side)
the same as the existing 405 (termination unsupported) case: it resolves
instead of throwing ClientHttpFailedToTerminateSession, since the session
being already gone is exactly the caller's intent.

Rebases the essential behavior of modelcontextprotocol#2125 onto current main, resolving the
conflict introduced by modelcontextprotocol#2469 (the isHandshake / SdkHttpError changes did not
exist when modelcontextprotocol#2125 was opened) and updating the newly-added errorSurfacePins
test and migration guide per docs/behavior-surface-pins.md's protocol for a
deliberate SdkErrorCode membership change.

Co-authored-by: David Soria Parra <davidsp@anthropic.com>
…est coverage

The existing 'should handle 404 response when session expires' test never
established a session before sending the 404-triggering request, so despite
its name it only ever exercised the generic ClientHttpNotImplemented fallback
-- it did not cover session expiry at all. Renamed to describe what it
actually tests and kept as regression coverage for the "404 with no active
session" case, which is intentionally unchanged by this fix.

Added:
- The actual session-expiry case: establish a session, get a 404, assert
  ClientHttpSessionExpired is thrown, the session ID is cleared, and a
  subsequent request no longer carries a session ID.
- A regression guard for the standalone GET SSE stream: a 404 there must not
  clear the session.
- terminateSession() treating a 404 (session already gone) as success, mirror
  of the existing 405 test.
@Azazi
Azazi requested a review from a team as a code owner August 13, 2026 23:13
@changeset-bot

changeset-bot Bot commented Aug 13, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9a88b3f

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@modelcontextprotocol/client Patch
@modelcontextprotocol/core Patch
@modelcontextprotocol/server Patch
@modelcontextprotocol/server-legacy Patch
@modelcontextprotocol/codemod Patch
@modelcontextprotocol/core-internal Patch

Not sure what this means? Click here to learn what changesets are.

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

@pkg-pr-new

pkg-pr-new Bot commented Aug 13, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

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

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@2660

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@2660

@modelcontextprotocol/server

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

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@2660

@modelcontextprotocol/express

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

@modelcontextprotocol/fastify

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

@modelcontextprotocol/hono

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

@modelcontextprotocol/node

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

commit: 9a88b3f

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