Skip to content

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

Closed

Description

@lizexinxin

Which client?

All / shared core — the Node transport fetch chain in core/mcp/node/transport.ts is shared by the Web, CLI, and TUI clients.

The problem

I'm testing an MCP server deployed as multiple replicas behind a load balancer that routes by cookie affinity (in my case an nginx ingress in front of a multi-replica MCPHub-based proxy; the ingress does Set-Cookie: <affinity>=… on the first response and pins later requests carrying that cookie to the same replica).

The Inspector drops Set-Cookie entirely — the transport fetch chains never store or replay cookies — so:

  1. initialize lands on replica A, succeeds, and A hands out an Mcp-Session-Id (and the LB hands out an affinity cookie);
  2. every later request (notifications/initialized, tools/list, tools/call) carries no cookie, so the LB round-robins; requests landing on replica B/C return 404 Session not found;
  3. the connection is unusable: initialize succeeds and nothing else works.

This happens every time against this deployment shape — it is not intermittent, and both Streamable HTTP and legacy SSE servers behind such a balancer are affected. Related history: #2783 (a load-balancer-related SSE failure, closed without touching the cookie gap) and #1454 (the credentials: 'include' request, declined for v1 — the closing comment invited a v2 issue describing the workflow that cannot be completed, which is this one).

What I do instead today: test the server with Cherry Studio over stdio (bypasses the LB entirely), or manually copy the affinity cookie out of a curl session and set it as a static custom header per session — both defeat the purpose of a UI inspector.

Solution you have in mind (optional)

A per-server cookie jar toggle (default off), implemented as a connection-scoped fetch wrapper on both HTTP transports:

  • Store Set-Cookie from responses on the server's origin; replay as Cookie on later requests to the same origin only. OAuth requests to other origins are untouched — the existing SSRF/auth layers are unaffected.
  • Simplified host-only semantics: name=value + Max-Age/Expires expiry, same-name overwrite, bounded jar (50 entries, 4 KB per entry). Not a full RFC 6265 implementation — an affinity cookie needs nothing more.
  • One jar per connection: cookies never outlive the session, a reconnect re-learns affinity.
  • Persists as cookieJar in server settings (omit-on-false, mirroring suppressNotificationStream from Add a per-server setting to suppress the standalone GET notification stream #2317), validated in the settings API, and a checkbox in the server settings form.
  • The wrapper sits above the fetch tracker so the Network log records what was actually sent; Cookie/Set-Cookie are already in the log's redaction list.

This is one possible answer — the problem above is the request.

Alternatives or workarounds you have tried

  • Cherry Studio (stdio) — works but bypasses the HTTP deployment being tested.
  • Static Cookie custom header — requires capturing the cookie out-of-band first, breaks when the LB reassigns, impossible to discover before connecting.
  • Source-IP affinity on the LB instead of cookies — an infrastructure change outside my control in shared clusters.

Already built it locally?

Per CONTRIBUTING.md I have not opened a PR. I implemented the change locally (both Streamable HTTP and legacy SSE transports) and verified it.

Behavior before. With the setting off (the only behavior today), initialize succeeds, but every later request leaves without the affinity cookie; behind a cookie-affinity LB the session-bound requests round-robin, and replicas that do not hold the session answer 404 Session not found. The connection is unusable after initialize.

Behavior after. With the per-server cookieJar setting enabled, the transport's connection-scoped jar stores Set-Cookie from the server's origin and replays Cookie on every later same-origin request, so the whole session pins to the replica that holds it, and session-bound requests work end to end (verified with tools/list; the same replay carries every later request, tools/call included). With the setting off, both transport fetch chains are byte-identical to today's composition (asserted by tests).

How I verified it. All gates from clients/web/:

  • npm run check (prettier format:check + eslint --max-warnings 0 + build) — green.
  • npm test (unit) — 7,893 tests / 409 files, all passing, including 17 new tests for the jar wrapper (cookieJarFetch.test.ts) and 4 transport-wiring tests (transportCookieJar.test.ts, which also asserts the byte-identical default-off behavior).
  • npm run test:integration — 2,279 tests / 102 files, all passing.
  • npm run test:coverage — 10,172 tests / 511 files, passing the repo's uniform per-file gate (lines/statements/functions/branches ≥ 90 each). Overall: 98.75% statements, 96.69% branches, 98.81% functions, 99.13% lines. New/touched files:
File Stmts Branch Funcs Lines
core/mcp/node/cookieJarFetch.ts 100% 96.15% 100% 100%
core/mcp/node/transport.ts 100% 94.91% 100% 100%
core/mcp/serverList.ts 99.36% 99.03% 100% 100%
core/mcp/remote/node/server.ts 97.13% 93.42% 93.89% 97.73%
clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx 97.45% 97.74% 95.52% 97.43%
clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx 95.65% 94.59% 93.75% 95.45%

(core/mcp/types.ts is excluded from the coverage report as a pure-type module by the repo's existing config; the change there is type-only.)

The motivating end-to-end test (clients/web/src/test/integration/mcp/cookie-jar-affinity.test.ts) stands up two single-pod MCP "replicas" behind an in-process round-robin proxy shaped like an nginx sticky ingress: the proxy stamps Set-Cookie: lb_affinity=<pod> on the first response, and each replica answers session-bound requests with 404 {"error":"Session not found"} unless they carry that replica's own affinity cookie. Both arms run Streamable HTTP with suppressNotificationStream: true, so the failure signal lands on session-bound tools/list:

  • Jar off (control): initialize succeeds and the proxy assigns a pod, but the fetch chain drops the Set-Cookie, so the tools/list POST round-robins to the other pod and is answered 404 {"error":"Session not found"}; listTools() rejects with exactly the SDK's rendering of that failure: Error POSTing to endpoint (HTTP 404): {"error":"Session not found"}.
  • Jar on: the jar stores lb_affinity from the initialize response and replays it on every subsequent same-origin request; the proxy pins the whole session to that one pod, and tools/list succeeds, returning ["ping"].

The exact prompt used to generate the change (reproduce it through your pipeline):

Implement a per-server "cookie jar" setting for the MCP Inspector (v2, base branch v2/main), default off, mirroring how suppressNotificationStream (#2317) is threaded through settings:

1. Add `cookieJar?: boolean` to both `InspectorServerSettings` and the inspector-specific body of the `StoredMCPServer` disk type in core/mcp/types.ts (`export type StoredMCPServer = MCPServerConfig & { … }` — the exact home of `suppressNotificationStream`, so persisted sse and streamable-http entries can both carry it; runtime gates the jar to the HTTP transports, and stdio ignores it). Give both declarations a doc comment explaining the use case (multi-replica deployments behind cookie-affinity load balancers: Set-Cookie must be stored and replayed or requests land on replicas without the MCP session, and the connection dies with "404 Session not found" right after a successful initialize).
2. Thread the field through core/mcp/serverList.ts exactly like suppressNotificationStream: StoredInspectorFields union, hasAny check, read side lifts only an explicit true (hand-edited junk values are dropped), write side omits when false, register in INSPECTOR_FIELD_KEY_MAP. Round-trip tests in serverList.test.ts.
3. Add core/mcp/node/cookieJarFetch.ts exporting createCookieJarFetch(baseFetch, serverUrl): a fetch wrapper with a connection-scoped jar. Same-origin only (store Set-Cookie from and replay Cookie onto requests whose origin matches serverUrl; never touch other origins, so OAuth discovery/token requests are unaffected). Simplified host-only RFC 6265: name=value, Max-Age (wins) then Expires expiry, same-name overwrite, malformed lines dropped, bounded at 50 entries / 4096 bytes per name=value pair, oldest evicted first. Follow the doc-comment density and style of suppressNotificationStreamFetch.ts.
4. Wire it into core/mcp/node/transport.ts for BOTH the Streamable HTTP branch and the legacy SSE branch when settings.cookieJar === true: on the Streamable HTTP path wrap fetchWithOptionalAuthIntercept so the jar sits above the fetch tracker but below the notification-header stamping; on the SSE path wrap trackedFetch and pass it as both the eventSourceInit GET-stream fetch and the transport's POST fetch, so an affinity cookie delivered on either response pins the whole session. The Network log must record the headers actually sent (#2385); Cookie/Set-Cookie are already redacted there. When the setting is off, both paths must be byte-identical to the pre-jar composition.
5. Validate the new field in the /api/servers settings route in core/mcp/remote/node/server.ts (non-boolean → 400 "settings.cookieJar must be a boolean"; normalize to carry only an explicit true), with integration tests next to the existing settings-route tests in clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts (reject, persist, omit-when-off + read-back).
6. Add an "Enable HTTP Cookie Jar (sticky sessions)" checkbox to ServerSettingsForm shown only for streamable-http and sse server types, with a handler in ServerSettingsModal that omits the field when off, plus story and component tests mirroring the suppressNotificationStream ones.
7. Tests: unit tests for the wrapper (store/replay, merge onto an existing Cookie header, expiry via Max-Age and Expires with Max-Age precedence, same-name overwrite, size/count bounds, malformed input, cross-origin isolation, Request-object input, independent jars), transport wiring tests with a mocked fetchFn and the real SDK transports (assert the replayed Cookie header per transport, plus a default-off control arm asserting byte-identical fetches), and an integration test that stands up two single-pod MCP replicas behind an in-process round-robin affinity proxy (each replica answers session-bound requests with 404 "Session not found" without its own affinity cookie): without the jar listTools must fail with that error after a successful initialize, with it the session must pin to one pod and tools/list must succeed. Per-file coverage ≥ 90% on lines/statements/functions/branches; keep new dependencies at zero; English comments and UI copy; conventional commits.

Activity

  1. lizexinxin commented on Nov 9, 2026

    @lizexinxin
    Author

    I've also pushed my local implementation to a fork branch, in case it's useful for diffing or reproducing through your pipeline:

    Compare: v2/main...lizexinxin:cookie-jar-sticky-sessions

    Per CONTRIBUTING.md I am not opening a pull request — the branch is just the reference implementation behind this issue (9 commits on top of v2/main, code and tests only, zero new dependencies):

    • core/mcp/node/cookieJarFetch.ts — the connection-scoped jar: same-origin only (OAuth discovery/token requests on other origins are untouched), simplified host-only RFC 6265 (name=value + Max-Age/Expires with Max-Age precedence, same-name overwrite, malformed lines dropped), bounded at 50 entries / 4096 bytes per pair, oldest evicted first.
    • cookieJar?: boolean per-server setting (default off) threaded through settings types, persistence (serverList.ts, omit-on-false — mirroring suppressNotificationStream from Add a per-server setting to suppress the standalone GET notification stream #2317), the /api/servers settings API (400 on non-boolean, read-back-tested), and an "Enable HTTP Cookie Jar (sticky sessions)" checkbox in the server settings form shown for streamable-http and sse.
    • Transport wiring (core/mcp/node/transport.ts) for both Streamable HTTP and legacy SSE: the jar sits above the fetch tracker (so the Network log records the headers actually sent, notifications/cancelled api does not attach mcp-name header in the request #2385 — Cookie/Set-Cookie are already in the log's redaction list) and below the notification-header stamping. With the setting off, both fetch chains are byte-identical to the pre-jar composition (asserted by tests).
    • Tests: 17 unit tests for the wrapper; transport wiring tests with the real SDK transports (per-transport replay + default-off control arm); and an end-to-end integration test (clients/web/src/test/integration/mcp/cookie-jar-affinity.test.ts) that stands up two single-pod MCP replicas behind an in-process round-robin cookie-affinity proxy — without the jar, listTools() fails with exactly the "Session not found" 404 described above; with it, the session pins to one pod.

    All gates pass locally (npm run check; unit 7,893 tests / integration 2,279 tests; per-file coverage ≥ 90% on lines/statements/functions/branches for every touched file). Happy to rework anything to fit your conventions — the problem statement above is the request; this branch is just one possible answer.

  2. cliffhall commented on Nov 10, 2026

    @cliffhall
    Member

    Triage: Priority Low (total 5)

    • Severity 3 — a missing feature makes one deployment shape (cookie-affinity load balancers) unusable over HTTP. A static Cookie custom header works as a partial workaround.
    • Urgency 2 — wanted eventually, nothing blocked on it
    • Bonuses: none

    Board: #28, Status Incoming (awaiting maintainer review — no milestone yet).

  3. added this to the v2.16.0 milestone on Nov 11, 2026
  4. self-assigned this
    on Nov 12, 2026
  5. cliffhall commented on Nov 12, 2026

    @cliffhall
    Member

    Shipped in #2976 (merged as 4993561).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature requestv2Issues and PRs for v2

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions