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

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.

Azaziand 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 ownerAugust 13, 2026 23:13
@changeset-bot

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
NameType
@modelcontextprotocol/clientPatch
@modelcontextprotocol/corePatch
@modelcontextprotocol/serverPatch
@modelcontextprotocol/server-legacyPatch
@modelcontextprotocol/codemodPatch
@modelcontextprotocol/core-internalPatch

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

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

@claudeclaudeBot added the v2 Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes label Aug 18, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Azazi
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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

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.

Azaziand 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 ownerAugust 13, 2026 23:13
@changeset-bot

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
NameType
@modelcontextprotocol/clientPatch
@modelcontextprotocol/corePatch
@modelcontextprotocol/serverPatch
@modelcontextprotocol/server-legacyPatch
@modelcontextprotocol/codemodPatch
@modelcontextprotocol/core-internalPatch

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

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

@claudeclaudeBot added the v2 Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes label Aug 18, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Azazi
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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

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.

Azaziand 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 ownerAugust 13, 2026 23:13
@changeset-bot

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
NameType
@modelcontextprotocol/clientPatch
@modelcontextprotocol/corePatch
@modelcontextprotocol/serverPatch
@modelcontextprotocol/server-legacyPatch
@modelcontextprotocol/codemodPatch
@modelcontextprotocol/core-internalPatch

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

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

@claudeclaudeBot added the v2 Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes label Aug 18, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Azazi
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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

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.

Azaziand 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 ownerAugust 13, 2026 23:13
@changeset-bot

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
NameType
@modelcontextprotocol/clientPatch
@modelcontextprotocol/corePatch
@modelcontextprotocol/serverPatch
@modelcontextprotocol/server-legacyPatch
@modelcontextprotocol/codemodPatch
@modelcontextprotocol/core-internalPatch

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

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

@claudeclaudeBot added the v2 Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes label Aug 18, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Azazi
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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

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.

Azaziand 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 ownerAugust 13, 2026 23:13
@changeset-bot

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
NameType
@modelcontextprotocol/clientPatch
@modelcontextprotocol/corePatch
@modelcontextprotocol/serverPatch
@modelcontextprotocol/server-legacyPatch
@modelcontextprotocol/codemodPatch
@modelcontextprotocol/core-internalPatch

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

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

@claudeclaudeBot added the v2 Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes label Aug 18, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Azazi
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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

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.

Azaziand 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 ownerAugust 13, 2026 23:13
@changeset-bot

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
NameType
@modelcontextprotocol/clientPatch
@modelcontextprotocol/corePatch
@modelcontextprotocol/serverPatch
@modelcontextprotocol/server-legacyPatch
@modelcontextprotocol/codemodPatch
@modelcontextprotocol/core-internalPatch

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

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

@claudeclaudeBot added the v2 Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes label Aug 18, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Azazi
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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

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.

Azaziand 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 ownerAugust 13, 2026 23:13
@changeset-bot

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
NameType
@modelcontextprotocol/clientPatch
@modelcontextprotocol/corePatch
@modelcontextprotocol/serverPatch
@modelcontextprotocol/server-legacyPatch
@modelcontextprotocol/codemodPatch
@modelcontextprotocol/core-internalPatch

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

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

@claudeclaudeBot added the v2 Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes label Aug 18, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Azazi
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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

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.

Azaziand 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 ownerAugust 13, 2026 23:13
@changeset-bot

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
NameType
@modelcontextprotocol/clientPatch
@modelcontextprotocol/corePatch
@modelcontextprotocol/serverPatch
@modelcontextprotocol/server-legacyPatch
@modelcontextprotocol/codemodPatch
@modelcontextprotocol/core-internalPatch

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

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

@claudeclaudeBot added the v2 Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes label Aug 18, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Azazi