Skip to content

Add an opt-in per-server HTTP cookie jar for sticky sessions (#2936) - #2976

Merged
cliffhall merged 7 commits into
v2/mainfrom
v2/feat/2936-cookie-jar
Nov 12, 2026
Merged

cliffhall merged 7 commits into
v2/mainfrom
v2/feat/2936-cookie-jar

Conversation

@cliffhall

Copy link
Copy Markdown
Member

Closes #2936

What

A new per-server cookieJar setting (default off) that gives each Streamable HTTP or legacy SSE connection an in-memory cookie jar, so servers running as several replicas behind a cookie-affinity load balancer stay pinned to the replica holding the MCP session instead of failing with 404 Session not found right after initialize.

This follows the design the issue proposed (narrowest version of it):

  • core/mcp/node/cookieJarFetch.ts — ConnectionCookieJar + createCookieJarFetch. Same-origin only (scheme + host + port of the server URL), so OAuth discovery/token requests to other origins are neither read nor stamped. Host-only and path-less; Max-Age (wins) / Expires expiry, a server-expired cookie is deleted; same-name overwrite; malformed lines dropped; bounded at 50 cookies / 4096 bytes per pair, oldest evicted first. An explicit Cookie custom header is kept and wins on a shared name.
  • Security posture: cookies are credentials, so the jar is never logged, never persisted, lives for exactly one transport (a reconnect re-learns affinity), and is never shared between servers or sent to another origin. It sits above the fetch tracker so the Network log shows the headers actually sent (notifications/cancelled api does not attach mcp-name header in the request #2385) — Cookie / Set-Cookie are already redacted there.
  • core/mcp/node/transport.ts — one jar per transport, wrapped around both legacy-SSE fetches (the GET stream and the POSTs share it) and under the notification-header stamping on Streamable HTTP. With the setting off the chains are unchanged.
  • Settings plumbing like suppressNotificationStream (Add a per-server setting to suppress the standalone GET notification stream #2317): types.ts, serverList.ts (omit-on-false, only an explicit true is read), /api/servers validation (settings.cookieJar must be a boolean), and a checkbox in the server settings form shown for streamable-http and sse (hidden for stdio).
  • Docs: docs/mcp-server-configuration.md field table.

Tests

  • clients/web/src/test/core/mcp/node/cookieJarFetch.test.ts — store/replay, cross-origin isolation (other host, scheme, port), URL/Request inputs, explicit-Cookie merge, expiry and deletion, eviction, bounds, malformed input, separate jars.
  • clients/web/src/test/integration/mcp/cookie-jar-affinity.test.ts — two real test-server replicas behind an in-process round-robin proxy that pins on an lb cookie, on both Streamable HTTP and legacy SSE: without the jar the connection fails and no request ever carries a cookie (control); with it every request after the first carries lb=0 and tools/list succeeds.
  • Round-trip tests in serverList.test.ts, settings-route reject/persist/omit tests, form + modal tests.
  • npm run local:gate green.

Screenshots

Server settings → Options, Streamable HTTP server with the jar enabled:

cookie jar checked

Legacy SSE server (the jar is offered; the Streamable-HTTP-only stream suppression box is not):

cookie jar on sse

🤖 Generated with Claude Code

cliffhall and others added 2 commits November 12, 2026 00:07
Servers deployed as several replicas behind a load balancer that routes
by cookie affinity were unusable past initialize: the transports never
stored Set-Cookie, so later requests round-robined onto replicas without
the MCP session and failed with 404 Session not found.

A new cookieJar server setting (default off) gives each Streamable HTTP
or legacy SSE connection an in-memory, same-origin jar that stores
Set-Cookie and replays Cookie. It sits above the fetch tracker so the
Network log shows (redacted) what was sent, never persists, and never
touches requests to other origins. Threaded through settings, disk
persistence, the /api/servers validation, and the server settings form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
…ump (#2936)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Redirected responses can cause another origin’s cookies to be stored and later sent to the MCP origin because the cookie origin check covers only the initial request.

1 open issue
What changed in this PR

Adds an opt-in, per-server cookie jar to Inspector’s shared Node transports so MCP connections can stay on the same replica behind cookie-affinity load balancers.

Changes:

  • Adds connection-scoped cookie storage and replay for Streamable HTTP and legacy SSE.
  • Threads the default-off setting through configuration persistence, API validation, and the web settings UI.
  • Adds unit, settings, and two-replica integration coverage, plus configuration documentation.
File Description
docs/​mcp-server-configuration.md Documents the cookie jar setting and lifetime.
core/​mcp/​types.ts Declares stored and runtime cookie jar settings.
core/​mcp/​serverList.ts Reads explicit true and omits disabled settings on save.
core/​mcp/​remote/​node/​server.ts Validates and accepts the setting through the API.
core/​mcp/​node/​transport.ts Wires one shared jar into each HTTP transport.
core/​mcp/​node/​cookieJarFetch.ts Implements bounded cookie storage, expiry, and replay.
clients/​web/​src/​test/​integration/​mcp/​remote/​server-extra-coverage.test.ts Tests API validation and persistence.
clients/​web/​src/​test/​integration/​mcp/​cookie-jar-affinity.test.ts Exercises both transports behind a sticky-session proxy.
clients/​web/​src/​test/​core/​mcp/​serverList.test.ts Tests configuration round-trips and default omission.
clients/​web/​src/​test/​core/​mcp/​node/​cookieJarFetch.test.ts Tests cookie handling, bounds, expiry, and isolation.
clients/​web/​src/​components/​groups/​ServerSettingsModal/​ServerSettingsModal.tsx Maps checkbox changes into server settings.
clients/​web/​src/​components/​groups/​ServerSettingsModal/​ServerSettingsModal.test.tsx Tests enabling and clearing the setting.
clients/​web/​src/​components/​groups/​ServerSettingsForm/​ServerSettingsForm.tsx Adds the checkbox for HTTP transports.
clients/​web/​src/​components/​groups/​ServerSettingsForm/​ServerSettingsForm.test.tsx Tests checkbox state, callbacks, and visibility.
clients/​web/​src/​components/​groups/​ServerSettingsForm/​ServerSettingsForm.stories.tsx Wires the setting into interactive stories.

Review effort: Balanced

Comment thread core/mcp/node/cookieJarFetch.ts Resolved
…gin (#2936)

fetch follows redirects, so a cross-origin redirect could otherwise plant
another origin's cookies in the jar and replay them to the MCP server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall

Copy link
Copy Markdown
Member Author

Copilot round 1: one finding (cross-origin redirects could plant another origin's cookies in the jar) — fixed in c346b17, with a regression test; replied inline. Requesting round 2.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Endpoint overrides can erase the final response URL, allowing cookies from cross-origin redirects to be stored and replayed to the MCP server.

1 open issue
1 previously missed issue

In code that hasn't changed since last review

Medium severity Prune expired cookies before enforcing the capacity limit

core/​mcp/​node/​cookieJarFetch.ts:83

Expired entries still count toward the capacity limit here. If a live affinity cookie is followed by 49 short-lived cookies that expire while a request is in flight, storing one new cookie evicts the live affinity cookie and loses replica affinity despite there being room for both live cookies. Prune expired entries before enforcing the limit.

1 resolved since last review

Review effort: Balanced

Comment thread core/mcp/node/cookieJarFetch.ts Resolved
…#2936)

A response rebuilt by a wrapper below the jar (OAuth endpoint overrides,
discovery compat) reports an empty url and has lost its provenance, so it
can no longer be trusted as same-origin. And expired entries no longer
hold capacity that would evict a live affinity cookie.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Cookie updates are lost on intercepted authentication challenges and same-origin redirects, causing OAuth retries to reuse stale cookies and preventing session affinity.

No open issues 🎉

2 previously missed issues

In code that hasn't changed since last review

Medium severity Preserve cookies set on same-origin redirect hops

core/​mcp/​node/​cookieJarFetch.ts:160

If /entry returns a same-origin 307 to /mcp with Set-Cookie: lb=0, automatic redirect following hides that header from the jar; when the final response sets no cookie, the jar stays empty. Neither the redirected request nor later requests carry the affinity cookie, so this deployment still cannot keep its session pinned. Capture and replay cookies on same-origin redirect hops before following them, preserving cross-origin isolation and fetch redirect semantics, and add a redirect regression test.

Medium severity Read Set-Cookie headers from intercepted auth responses

core/​mcp/​node/​transport.ts:136

With auth-challenge interception enabled, a 401 or challenged 403 throws before the jar can read its Set-Cookie headers, so cookie updates and deletions are lost. Send-time OAuth recovery retries on the existing remote session and therefore reuses the stale cookies. Place the jar above tracking but below interception for both HTTP transports, and add a regression test for cookie changes on an intercepted response.

1 resolved since last review

Review effort: Balanced

The interceptor throws on a 401/403, so a jar above it never saw that
response's Set-Cookie and the OAuth retry carried stale cookies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall

Copy link
Copy Markdown
Member Author

Copilot round 3: no inline comments; two "previously missed" findings in the review body (no threads, so answered here).

  • Read Set-Cookie from intercepted auth responses — fixed in 8a79da8. The jar now sits above the tracker and below the auth-challenge interceptor on both HTTP transports, so a 401/403 the interceptor throws on still updates the jar before the OAuth retry. Regression test: transportCookieJar.test.ts (a cookie set on an intercepted 401 is replayed on the next request).
  • Preserve cookies set on same-origin redirect hops — declined as out of scope. Capturing a Set-Cookie from an intermediate redirect hop means turning off fetch's redirect following and re-implementing it (method/body rewriting per status, hop limits, cross-origin header stripping) inside the jar. The issue's deployment shape — an affinity cookie stamped on the endpoint's own response — is covered; an affinity cookie set only on a redirect hop in front of the MCP endpoint is a different deployment shape, and can be raised as its own issue if someone hits it.

Requesting round 4.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 Approval recommended

No active review issues remain for the opt-in, per-server HTTP cookie jar change.

No open issues 🎉

Review effort: Balanced

@cliffhall

Copy link
Copy Markdown
Member Author

Copilot review closed: round 4 was clean (no inline comments, no headline or previously-missed findings). Exit: clean round, after 4 rounds.

CodeQL js/request-forgery: the proxy resolved the incoming request URL
against the replica origin, which an absolute URL could override.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
CodeQL still traced the request path into the forwarded URL; the path is
now picked from the fixture's two endpoints and only a validated
sessionId is carried over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall
cliffhall merged commit 4993561 into v2/main Nov 12, 2026
11 checks passed
@cliffhall
cliffhall deleted the v2/feat/2936-cookie-jar branch November 12, 2026 07:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2 Issues and PRs for v2

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cannot test MCP servers behind cookie-affinity load balancers: Set-Cookie is dropped, later requests hit replicas without the session

3 participants