Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/migration-SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ Three error classes now exist:
| 403 after upscoping | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpForbidden` |
| Unexpected content type | `StreamableHTTPError` | `SdkError` with `SdkErrorCode.ClientHttpUnexpectedContent` |
| Session termination failed | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpFailedToTerminateSession` |
| Session expired (404 w/ session) | `StreamableHTTPError` (status 404) | `SdkHttpError` with `SdkErrorCode.ClientHttpSessionExpired` |
| Response result fails schema | `ZodError` (raw) | `SdkError` with `SdkErrorCode.InvalidResult` |

New `SdkErrorCode` enum values:
Expand All @@ -140,6 +141,7 @@ New `SdkErrorCode` enum values:
- `SdkErrorCode.ClientHttpUnexpectedContent` = `'CLIENT_HTTP_UNEXPECTED_CONTENT'`
- `SdkErrorCode.ClientHttpFailedToOpenStream` = `'CLIENT_HTTP_FAILED_TO_OPEN_STREAM'`
- `SdkErrorCode.ClientHttpFailedToTerminateSession` = `'CLIENT_HTTP_FAILED_TO_TERMINATE_SESSION'`
- `SdkErrorCode.ClientHttpSessionExpired` = `'CLIENT_HTTP_SESSION_EXPIRED'` (thrown on HTTP 404 when a session ID was set; transport clears `sessionId` so reconnect re-`initialize`s; detection is status-only, body-agnostic)

Update error handling:

Expand Down
28 changes: 28 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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;
Expand All @@ -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

View check run for this annotation

Claude / Claude Code Review

Docs/JSDoc claim 404-with-session-ID always means session expiry, but the GET-stream path was deliberately excluded

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 ("throw
Comment on lines +781 to +793

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 The "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 _startOrAuthSse so a 404 on the standalone GET SSE stream is deliberately not treated as session expiry: it throws SdkErrorCode.ClientHttpFailedToOpenStream and leaves _sessionId intact. 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 touched streamableHttp.ts and 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 an Mcp-Session-Id:

  1. docs/migration.md (lines ~781–793): "when a client receives an HTTP 404 in response to a request that carried an Mcp-Session-Id ... On such a 404 the transport clears its stale session ID ... and throws SdkHttpError with SdkErrorCode.ClientHttpSessionExpired."
  2. docs/migration-SKILL.md line 144: ClientHttpSessionExpired is "thrown on HTTP 404 when a session ID was set; transport clears sessionId".
  3. packages/core/src/errors/sdkErrors.ts — the new JSDoc on SdkErrorCode.ClientHttpSessionExpired: "Server returned HTTP 404 for a request that carried an Mcp-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 the mcp-session-id header whenever this._sessionId is set, and _startOrAuthSse builds its GET request from those headers — so the standalone GET stream (and resumeStream() / resumption-token reconnects, which also go through _startOrAuthSse) does carry the Mcp-Session-Id. Per the prose, a 404 there should clear the session ID and surface as ClientHttpSessionExpired. Per the code at HEAD, it does neither.

Step-by-step proof

  1. Construct new StreamableHTTPClientTransport(url, { sessionId: 'existing-session-id' }) and call start().
  2. _startOrAuthSse({}) (the standalone GET stream, or a resumeStream() reconnect) calls _commonHeaders(), which adds mcp-session-id: existing-session-id — the request carries the session ID, so the docs' precondition is met.
  3. The server responds 404 Not Found (e.g. it evicted the session).
  4. In the !response.ok block, the 401 and 405 branches are skipped, and execution reaches the catch-all under the new NOTE: throw new SdkHttpError(SdkErrorCode.ClientHttpFailedToOpenStream, ...). this._sessionId is never cleared.
  5. A consumer who read the migration guide / SKILL doc / JSDoc and wrote catch (e) { if (e.code === SdkErrorCode.ClientHttpSessionExpired) reconnect(); } around the reconnect path never hits that branch — they get ClientHttpFailedToOpenStream and a still-set transport.sessionId, the opposite of what the prose promises. The unit test added in dcc1757 asserts exactly this outcome (ClientHttpFailedToOpenStream, sessionId still 'existing-session-id').

Why existing review comments don't cover this

The earlier inline comment on docs/migration.md line 794 is about the negative sentence (a 404 without a session ID surfacing as ClientHttpNotImplemented vs ClientHttpFailedToOpenStream on 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. (The terminateSession() 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 a 404 the transport clears its stale session ID ... A 404 on the standalone GET SSE stream (the optional notification channel, including resumeStream() reconnects) is deliberately not treated as expiry — it surfaces as ClientHttpFailedToOpenStream and leaves the session intact." Add an analogous parenthetical to the SKILL-doc bullet and a sentence to the ClientHttpSessionExpired JSDoc. 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.

A `404` for a request that did **not** carry a session ID (for example a wrong URL on
Comment on lines +793 to +794

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Extended reasoning...

What the prose claims vs. what the code does

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

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

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

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

Step-by-step proof for the GET path

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

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

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

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

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

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

How to fix

Qualify the sentence or list both codes. For example:

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

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
Expand Down
38 changes: 35 additions & 3 deletions packages/client/src/client/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
});
}

Comment thread
claude[bot] marked this conversation as resolved.
Comment thread
claude[bot] marked this conversation as resolved.
throw new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, `Error POSTing to endpoint: ${text}`, {
status: response.status,
statusText: response.statusText,
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Extended reasoning...

What changed vs. what the JSDoc says

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

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

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

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

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

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

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

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

Step-by-step

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

Fix

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

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

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

throw new SdkHttpError(
SdkErrorCode.ClientHttpFailedToTerminateSession,
`Failed to terminate session: ${response.statusText}`,
Expand Down
132 changes: 117 additions & 15 deletions packages/client/test/client/streamableHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,33 +220,135 @@ describe('StreamableHTTPClientTransport', () => {
await expect(transport.terminateSession()).resolves.not.toThrow();
});

it('should handle 404 response when session expires', async () => {
describe('session expiry (HTTP 404)', () => {
const message: JSONRPCMessage = {
jsonrpc: '2.0',
method: 'test',
params: {},
id: 'test-id'
};

(globalThis.fetch as Mock).mockResolvedValueOnce({
ok: false,
status: 404,
statusText: 'Not Found',
text: () => Promise.resolve('Session not found'),
headers: new Headers()
// Per the MCP spec (Streamable HTTP, Session Management): a 404 in response
// to a request that carried an `Mcp-Session-Id` means the session expired and
// a new one must be started. Detection is by status code alone — non-reference
// servers report it with varying bodies, so we must not require a body shape.
it.each([
['plain text', 'Session not found'],
['JSON-RPC -32002 (Figma Desktop)', '{"jsonrpc":"2.0","error":{"code":-32002,"message":"Session not found"},"id":null}'],
['JSON-RPC -32001 (reference server)', '{"jsonrpc":"2.0","error":{"code":-32001,"message":"Session not found"},"id":null}'],
['arbitrary HTML', '<html><body>404 page not found</body></html>'],
['empty body', '']
Comment thread
claude[bot] marked this conversation as resolved.
])('treats 404 with a session ID as session expiry regardless of body (%s)', async (_label, body) => {
const sessionTransport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
sessionId: 'existing-session-id'
});

(globalThis.fetch as Mock).mockResolvedValueOnce({
ok: false,
status: 404,
statusText: 'Not Found',
text: () => Promise.resolve(body),
headers: new Headers()
});

const errorSpy = vi.fn();
sessionTransport.onerror = errorSpy;

const error = await sessionTransport.send(message).then(
() => null,
e => e
);

expect(error).toBeInstanceOf(SdkHttpError);
expect((error as SdkHttpError).code).toBe(SdkErrorCode.ClientHttpSessionExpired);
expect((error as SdkHttpError).data).toEqual({ status: 404, statusText: 'Not Found', text: body });
expect(errorSpy).toHaveBeenCalled();
// The dead session ID is cleared so a subsequent reconnect issues a fresh `initialize`.
expect(sessionTransport.sessionId).toBeUndefined();

await sessionTransport.close().catch(() => {});
});

const errorSpy = vi.fn();
transport.onerror = errorSpy;
it('treats a 404 without a session ID as a generic HTTP error, not session expiry', async () => {
// No session ID was ever established (e.g. a 404 on the initial connect, or a
// wrong URL). The spec rule only applies to requests carrying an Mcp-Session-Id,
// so this must remain a generic error rather than triggering a session reset.
(globalThis.fetch as Mock).mockResolvedValueOnce({
ok: false,
status: 404,
statusText: 'Not Found',
text: () => Promise.resolve('Not Found'),
headers: new Headers()
});

const errorSpy = vi.fn();
transport.onerror = errorSpy;

const error = await transport.send(message).then(
() => null,
e => e
);

expect(error).toBeInstanceOf(SdkHttpError);
expect((error as SdkHttpError).code).toBe(SdkErrorCode.ClientHttpNotImplemented);
expect(errorSpy).toHaveBeenCalled();
expect(transport.sessionId).toBeUndefined();
});

it('does NOT treat a 404 on the standalone GET stream as session expiry', async () => {
// The standalone GET stream is the optional notification channel; a 404 there
// must not tear down the session. It surfaces as a generic open-stream failure
// and leaves the session ID intact so POST requests keep working.
const sessionTransport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
sessionId: 'existing-session-id'
});

await expect(transport.send(message)).rejects.toThrow(
new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, 'Error POSTing to endpoint: Session not found', {
(globalThis.fetch as Mock).mockResolvedValueOnce({
ok: false,
status: 404,
statusText: 'Not Found',
text: 'Session not found'
})
);
expect(errorSpy).toHaveBeenCalled();
text: () => Promise.resolve('Session not found'),
headers: new Headers()
});

const errorSpy = vi.fn();
sessionTransport.onerror = errorSpy;

await sessionTransport.start();
// Trigger the GET stream directly using the internal method for a clean test.
const error = await sessionTransport['_startOrAuthSse']({}).then(
() => null,
e => e
);

expect(error).toBeInstanceOf(SdkHttpError);
expect((error as SdkHttpError).code).toBe(SdkErrorCode.ClientHttpFailedToOpenStream);
// Session is preserved — the optional stream failing does not expire the session.
expect(sessionTransport.sessionId).toBe('existing-session-id');

await sessionTransport.close().catch(() => {});
});

it('treats a 404 from terminateSession as already-terminated (clears session, no throw)', async () => {
const sessionTransport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
sessionId: 'existing-session-id'
});

(globalThis.fetch as Mock).mockResolvedValueOnce({
ok: false,
status: 404,
statusText: 'Not Found',
text: () => Promise.resolve('Session not found'),
headers: new Headers()
});

// The session is already gone server-side — terminating it is exactly the
// caller's intent, so this must resolve rather than throw, and clear the ID.
await expect(sessionTransport.terminateSession()).resolves.toBeUndefined();
expect(sessionTransport.sessionId).toBeUndefined();

await sessionTransport.close().catch(() => {});
});
});

it('should handle non-streaming JSON response', async () => {
Expand Down
10 changes: 9 additions & 1 deletion packages/core/src/errors/sdkErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

View check run for this annotation

Claude / Claude Code Review

Missing changeset for new public error code and changed transport behavior

No changeset was added for this PR (the changeset-bot reports "No Changeset found"), but it introduces a new public enum member (`SdkErrorCode.ClientHttpSessionExpired`) and changes user-visible runtime behavior of `StreamableHTTPClientTransport` (404-with-session-ID now throws the new code and clears `sessionId`; `terminateSession()` now resolves on 404 instead of throwing). Please add a changeset (patch or minor) for `@modelcontextprotocol/client` and `@modelcontextprotocol/core` so the change
Comment on lines +38 to +46

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 (SdkErrorCode.ClientHttpSessionExpired) and changes user-visible runtime behavior of StreamableHTTPClientTransport (404-with-session-ID now throws the new code and clears sessionId; terminateSession() now resolves on 404 instead of throwing). Please add a changeset (patch or minor) for @modelcontextprotocol/client and @modelcontextprotocol/core so the change produces a version bump and changelog entry, mirroring .changeset/add-sdk-http-error.md from the prior SdkHttpError change.

Extended reasoning...

Missing changeset for a consumer-visible API/behavior change

This repository uses changesets to drive releases: .changeset/ contains dozens of per-PR entries, .github/workflows/release.yml runs changesets/action, and the packages are currently in alpha pre-release mode (.changeset/pre.json, mode: "pre", tag alpha), where each merged changeset is what produces the next 2.0.0-alpha.N bump and its changelog entry. The changeset-bot comment on this PR (latest commit dcc1757) confirms "No Changeset found", so as it stands merging will not bump any package or record this change in a changelog.

Why this PR warrants one

The PR is not internal-only — it changes the published API surface and runtime behavior that consumers will observe:

  1. New public enum member: SdkErrorCode.ClientHttpSessionExpired is added to packages/core/src/errors/sdkErrors.ts and is re-exported through @modelcontextprotocol/client (version 2.0.0-alpha.2, published with public access — the pkg-pr-new bot even builds an installable preview of it for this PR).
  2. Changed runtime behavior of StreamableHTTPClientTransport: a 404 in response to a POST that carried an Mcp-Session-Id now throws SdkHttpError with the new code and clears transport.sessionId (previously it surfaced as ClientHttpNotImplemented and left the session ID intact), and terminateSession() now resolves on a 404 and clears the session ID instead of throwing ClientHttpFailedToTerminateSession.
  3. Both behaviors are documented as part of the public API in docs/migration.md and docs/migration-SKILL.md in this very PR — consumers are explicitly told to write switch (error.code) handlers for the new code, so they need a release that actually contains it.

Repo convention

Directly comparable prior changes shipped changesets. The closest analogue, .changeset/add-sdk-http-error.md (the PR that introduced SdkHttpError and the SdkErrorCode.ClientHttp* handling), declared a minor bump for both @modelcontextprotocol/core and @modelcontextprotocol/client. Other client/core fixes in the directory (e.g. fix-session-status-codes.md, the related server-side 404 fix) follow the same pattern. This PR touches exactly the same surface and should follow suit.

Concrete walk-through of the impact

  1. This PR merges with no changeset.
  2. The next time the release workflow runs changeset version, only the other pending changesets are consumed; nothing records the new error code or the changed 404/terminateSession() semantics.
  3. @modelcontextprotocol/client is published at whatever version the other changesets produce, with a CHANGELOG that never mentions ClientHttpSessionExpired — even though the migration guide added in this PR tells users to handle it.
  4. A consumer pinning by changelog/release notes has no signal that 404 handling and terminateSession() behavior changed between alpha versions.

Nuance and how to fix

One nuance: @modelcontextprotocol/core is marked "private": true in its package.json, so the bump that matters for npm consumers is the one for @modelcontextprotocol/client. Including core in the changeset is still consistent with the existing convention (add-sdk-http-error.md lists both) and is harmless. The fix is a single file, e.g. .changeset/client-http-session-expired.md:

---
'@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; add-sdk-http-error.md used minor for the analogous error-code addition.) This is a release-process completeness issue rather than a code defect, and the changeset-bot already surfaces it on the PR, so it is non-blocking — but it should be added before merge so the release notes and version bump reflect the change.

}

/**
Expand Down
Loading