-
Notifications
You must be signed in to change notification settings - Fork 2.1k
fix(client): treat HTTP 404 with session ID as session expiry #2125
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
690d902
6cc7726
8dee2cf
dcc1757
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -723,6 +723,7 @@ | |
| | `SdkErrorCode.ClientHttpUnexpectedContent` | Unexpected content type in HTTP response | | ||
| | `SdkErrorCode.ClientHttpFailedToOpenStream` | Failed to open SSE stream | | ||
| | `SdkErrorCode.ClientHttpFailedToTerminateSession` | Failed to terminate session | | ||
| | `SdkErrorCode.ClientHttpSessionExpired` | Server returned 404 for a request carrying a session ID — the session expired, start a new one | | ||
|
|
||
| #### `StreamableHTTPError` removed | ||
|
|
||
|
|
@@ -763,6 +764,12 @@ | |
| case SdkErrorCode.ClientHttpFailedToOpenStream: | ||
| console.log('Failed to open SSE stream'); | ||
| break; | ||
| case SdkErrorCode.ClientHttpSessionExpired: | ||
| // Server returned 404 for a request carrying a session ID. | ||
| // The transport already cleared its session ID; reconnect to | ||
| // start a fresh session (per the MCP spec, Session Management). | ||
| console.log('Session expired — reconnecting'); | ||
| break; | ||
| case SdkErrorCode.ClientHttpNotImplemented: | ||
| console.log('HTTP request failed'); | ||
| break; | ||
|
|
@@ -771,6 +778,27 @@ | |
| } | ||
| ``` | ||
|
|
||
| #### Session expiry now surfaces as `ClientHttpSessionExpired` | ||
|
|
||
| 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` now detects this by status code alone — it no longer | ||
| inspects the response body, so servers that report expiry with a non-reference body | ||
| (a different JSON-RPC error code, plain text, or HTML) are handled correctly. On such | ||
| a `404` the transport clears its stale session ID (so `transport.sessionId` becomes | ||
| `undefined` and a subsequent `client.connect(transport)` issues a fresh `initialize`) | ||
| and throws `SdkHttpError` with `SdkErrorCode.ClientHttpSessionExpired`. | ||
|
|
||
|
Check warning on line 793 in docs/migration.md
|
||
| A `404` for a request that did **not** carry a session ID (for example a wrong URL on | ||
|
Comment on lines
+793
to
+794
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 The new sentence stating a 404 without a session ID "still surfaces as Extended reasoning...What the prose claims vs. what the code doesThe added paragraph under Session expiry now surfaces as
That sentence reads as a general claim about every 404-without-session path in
Step-by-step proof for the GET path
So the GET path produces Why it matters (and why it's only a nit)A consumer writing reconnect/recovery logic from the migration guide could reasonably write a handler that branches on That said, this is a low-impact documentation imprecision rather than a code bug:
How to fixQualify the sentence or list both codes. For example:
|
||
| the initial connection) is unchanged: it still surfaces as `SdkErrorCode.ClientHttpNotImplemented`. | ||
|
|
||
| `terminateSession()` follows the same rule: a `404` to the `DELETE` means the session is | ||
| already gone server-side — which is what the caller asked for — so it now resolves and clears | ||
| the session ID instead of throwing `ClientHttpFailedToTerminateSession` (mirroring the existing | ||
| `405 Method Not Allowed` handling). | ||
|
|
||
| #### Why this change? | ||
|
|
||
| Previously, `ErrorCode.RequestTimeout` (-32001) and `ErrorCode.ConnectionClosed` (-32000) were used for local timeout/connection errors. However, these errors never cross the wire as JSON-RPC responses - they are rejected locally. Using protocol error codes for local errors was | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -290,6 +290,12 @@ export class StreamableHTTPClientTransport implements Transport { | |
| return; | ||
| } | ||
|
|
||
| // NOTE: a 404 here is deliberately NOT treated as session expiry. The | ||
| // standalone GET stream is the optional server→client notification | ||
| // channel; its failure (including a 404) must not tear down the session | ||
| // — the client keeps the session and continues issuing POST requests. | ||
| // Genuine session expiry is detected on the POST path in `_send`, where a | ||
| // 404 to an actual request means the session is gone. | ||
| throw new SdkHttpError(SdkErrorCode.ClientHttpFailedToOpenStream, `Failed to open SSE stream: ${response.statusText}`, { | ||
| status: response.status, | ||
| statusText: response.statusText | ||
|
|
@@ -554,6 +560,12 @@ export class StreamableHTTPClientTransport implements Transport { | |
| signal: this._abortController?.signal | ||
| }; | ||
|
|
||
| // Capture whether *this request* carried a session ID before processing the | ||
| // response — the response handling below may write a new `mcp-session-id` | ||
| // into `this._sessionId`, and the 404 session-expiry rule is defined in terms | ||
| // of the request, not the post-response state. | ||
| const requestHadSessionId = this._sessionId !== undefined; | ||
|
|
||
| const response = await (this._fetch ?? fetch)(this._url, init); | ||
|
|
||
| // Handle session ID received during initialization | ||
|
|
@@ -633,6 +645,23 @@ export class StreamableHTTPClientTransport implements Transport { | |
| } | ||
| } | ||
|
|
||
| // Per the MCP spec (Streamable HTTP, Session Management): a 404 in | ||
| // response to a request that carried an `Mcp-Session-Id` means the | ||
| // session has expired or been terminated server-side, and the client | ||
| // must start a new session. Detect this by the status code alone — | ||
| // not the response body — since non-reference servers report it with | ||
| // varying bodies (different JSON-RPC error codes, plain text, HTML). | ||
| // Clear the dead session ID so a subsequent reconnect issues a fresh | ||
| // `initialize`, and surface a distinct, body-agnostic error code. | ||
| if (response.status === 404 && requestHadSessionId) { | ||
| this._sessionId = undefined; | ||
| throw new SdkHttpError(SdkErrorCode.ClientHttpSessionExpired, `Session expired (HTTP 404): ${text}`, { | ||
| status: 404, | ||
| statusText: response.statusText, | ||
| text | ||
| }); | ||
| } | ||
|
|
||
|
claude[bot] marked this conversation as resolved.
claude[bot] marked this conversation as resolved.
|
||
| throw new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, `Error POSTing to endpoint: ${text}`, { | ||
| status: response.status, | ||
| statusText: response.statusText, | ||
|
|
@@ -727,9 +756,12 @@ export class StreamableHTTPClientTransport implements Transport { | |
| const response = await (this._fetch ?? fetch)(this._url, init); | ||
| await response.text?.().catch(() => {}); | ||
|
|
||
| // We specifically handle 405 as a valid response according to the spec, | ||
| // meaning the server does not support explicit session termination | ||
| if (!response.ok && response.status !== 405) { | ||
| // 405 Method Not Allowed: per the spec the server does not support explicit | ||
| // session termination — treat as success. | ||
| // 404 Not Found: the session is already gone server-side, which is exactly | ||
| // what the caller asked for — treat as success rather than a failure. In both | ||
| // cases fall through to clear the local session ID. | ||
| if (!response.ok && response.status !== 405 && response.status !== 404) { | ||
|
Comment on lines
+759
to
+764
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 The Extended reasoning...What changed vs. what the JSDoc saysThis PR changes // 405 Method Not Allowed: per the spec the server does not support explicit
// session termination — treat as success.
// 404 Not Found: the session is already gone server-side, ... — treat as success ...
if (!response.ok && response.status !== 405 && response.status !== 404) {
throw new SdkError(SdkErrorCode.ClientHttpFailedToTerminateSession, ...);
}
this._sessionId = undefined;The behavior is documented in three places: the inline implementation comment,
It enumerates exactly one tolerated non-OK status, which now reads as a complete list when it isn't. Why it matters (and why it's a nit, not a blocker)The refutation correctly notes there's no contradiction — the JSDoc never claimed 404 throws, and the 405 sentence is a near-verbatim spec quote about server behavior, not a claim about SDK error handling. So this isn't a prose-vs-implementation mismatch in the strict sense. But the JSDoc does go out of its way to call out one specific non-OK status that is silently tolerated. By enumerating one special case, it implies a closed set. Adding a sibling special case in the implementation while leaving the doc untouched makes the doc misleadingly incomplete: a consumer reading only the hover tooltip would reasonably assume any non-405, non-OK response (including 404) throws Step-by-step
FixAdd one sentence to the JSDoc at This keeps the public-facing doc in sync with both the implementation and the migration guide. Pure documentation-completeness nit; non-blocking. |
||
| throw new SdkHttpError( | ||
| SdkErrorCode.ClientHttpFailedToTerminateSession, | ||
| `Failed to terminate session: ${response.statusText}`, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -35,7 +35,15 @@ | |
| ClientHttpForbidden = 'CLIENT_HTTP_FORBIDDEN', | ||
| ClientHttpUnexpectedContent = 'CLIENT_HTTP_UNEXPECTED_CONTENT', | ||
| ClientHttpFailedToOpenStream = 'CLIENT_HTTP_FAILED_TO_OPEN_STREAM', | ||
| ClientHttpFailedToTerminateSession = 'CLIENT_HTTP_FAILED_TO_TERMINATE_SESSION' | ||
| ClientHttpFailedToTerminateSession = 'CLIENT_HTTP_FAILED_TO_TERMINATE_SESSION', | ||
| /** | ||
| * Server returned HTTP 404 for a request that carried an `Mcp-Session-Id`. | ||
| * Per the MCP spec (Streamable HTTP, Session Management), this means the | ||
| * session has expired or been terminated server-side and the client must | ||
| * start a new session. The transport clears its stale session ID before | ||
| * throwing this, so reconnecting issues a fresh `initialize`. | ||
| */ | ||
| ClientHttpSessionExpired = 'CLIENT_HTTP_SESSION_EXPIRED' | ||
|
Check warning on line 46 in packages/core/src/errors/sdkErrors.ts
|
||
|
Comment on lines
+38
to
+46
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 No changeset was added for this PR (the changeset-bot reports "No Changeset found"), but it introduces a new public enum member ( Extended reasoning...Missing changeset for a consumer-visible API/behavior changeThis repository uses changesets to drive releases: Why this PR warrants oneThe PR is not internal-only — it changes the published API surface and runtime behavior that consumers will observe:
Repo conventionDirectly comparable prior changes shipped changesets. The closest analogue, Concrete walk-through of the impact
Nuance and how to fixOne nuance: ---
'@modelcontextprotocol/client': minor
'@modelcontextprotocol/core': minor
---
Treat HTTP 404 with a session ID as session expiry: `StreamableHTTPClientTransport` now throws `SdkHttpError` with the new `SdkErrorCode.ClientHttpSessionExpired` and clears its session ID, and `terminateSession()` resolves on 404 instead of throwing.(Patch vs minor is the author's call; |
||
| } | ||
|
|
||
| /** | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 The "Session expiry now surfaces as ClientHttpSessionExpired" prose states the rule unconditionally — "when a client receives an HTTP 404 in response to a request that carried an Mcp-Session-Id ... throws SdkErrorCode.ClientHttpSessionExpired" — but after dcc1757 the standalone GET SSE stream (which does carry the Mcp-Session-Id header) deliberately throws ClientHttpFailedToOpenStream and preserves the session instead. The same over-broad claim appears in docs/migration-SKILL.md line 144 ("thrown on HTTP 404 when a session ID was set") and the ClientHttpSessionExpired JSDoc in packages/core/src/errors/sdkErrors.ts. Qualify the prose to say expiry detection happens only on the POST request path, mirroring the inline NOTE in _startOrAuthSse.
Extended reasoning...
What the docs claim vs. what the code does
Commit dcc1757 ("fix(client): don't treat GET-stream 404 as session expiry") changed
_startOrAuthSseso a 404 on the standalone GET SSE stream is deliberately not treated as session expiry: it throwsSdkErrorCode.ClientHttpFailedToOpenStreamand leaves_sessionIdintact. The new inline NOTE and the test'does NOT treat a 404 on the standalone GET stream as session expiry'both confirm this is intentional. However, that commit only touchedstreamableHttp.tsand the test file — the prose added earlier in this PR was not updated, and it still describes the rule unconditionally in terms of any request that carried anMcp-Session-Id:docs/migration.md(lines ~781–793): "when a client receives an HTTP404in response to a request that carried anMcp-Session-Id... On such a404the transport clears its stale session ID ... and throwsSdkHttpErrorwithSdkErrorCode.ClientHttpSessionExpired."docs/migration-SKILL.mdline 144:ClientHttpSessionExpiredis "thrown on HTTP 404 when a session ID was set; transport clearssessionId".packages/core/src/errors/sdkErrors.ts— the new JSDoc onSdkErrorCode.ClientHttpSessionExpired: "Server returned HTTP 404 for a request that carried anMcp-Session-Id... The transport clears its stale session ID before throwing this."Why the GET stream falls under the prose but not the code
_commonHeaders()sets themcp-session-idheader wheneverthis._sessionIdis set, and_startOrAuthSsebuilds its GET request from those headers — so the standalone GET stream (andresumeStream()/ resumption-token reconnects, which also go through_startOrAuthSse) does carry theMcp-Session-Id. Per the prose, a 404 there should clear the session ID and surface asClientHttpSessionExpired. Per the code at HEAD, it does neither.Step-by-step proof
new StreamableHTTPClientTransport(url, { sessionId: 'existing-session-id' })and callstart()._startOrAuthSse({})(the standalone GET stream, or aresumeStream()reconnect) calls_commonHeaders(), which addsmcp-session-id: existing-session-id— the request carries the session ID, so the docs' precondition is met.404 Not Found(e.g. it evicted the session).!response.okblock, the 401 and 405 branches are skipped, and execution reaches the catch-all under the new NOTE:throw new SdkHttpError(SdkErrorCode.ClientHttpFailedToOpenStream, ...).this._sessionIdis never cleared.catch (e) { if (e.code === SdkErrorCode.ClientHttpSessionExpired) reconnect(); }around the reconnect path never hits that branch — they getClientHttpFailedToOpenStreamand a still-settransport.sessionId, the opposite of what the prose promises. The unit test added in dcc1757 asserts exactly this outcome (ClientHttpFailedToOpenStream,sessionIdstill'existing-session-id').Why existing review comments don't cover this
The earlier inline comment on
docs/migration.mdline 794 is about the negative sentence (a 404 without a session ID surfacing asClientHttpNotImplementedvsClientHttpFailedToOpenStreamon the GET path), and both prior bot comments predate dcc1757 — the commit that created this particular prose-vs-code divergence. The affirmative "404 with a session ID ⇒ClientHttpSessionExpired" claim becoming inaccurate is new and unflagged. (TheterminateSession()DELETE path is fine as documented: the very next paragraph in migration.md describes its 404-resolves-silently behavior, so only the POST/GET asymmetry needs a fix.)How to fix
Qualify the three locations to scope expiry detection to the POST request path, mirroring the inline NOTE in
_startOrAuthSse. For example, in migration.md: "...detects this by status code alone on POST requests: on such a404the transport clears its stale session ID ... A404on the standalone GET SSE stream (the optional notification channel, includingresumeStream()reconnects) is deliberately not treated as expiry — it surfaces asClientHttpFailedToOpenStreamand leaves the session intact." Add an analogous parenthetical to the SKILL-doc bullet and a sentence to theClientHttpSessionExpiredJSDoc. This is a documentation-precision fix — a few sentences across the three files — but worth doing before merge since the contradiction was introduced within this same PR.