Repository navigation
Description
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:
initializelands on replica A, succeeds, and A hands out anMcp-Session-Id(and the LB hands out an affinity cookie);- every later request (
notifications/initialized,tools/list,tools/call) carries no cookie, so the LB round-robins; requests landing on replica B/C return404 Session not found; - the connection is unusable:
initializesucceeds 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-Cookiefrom responses on the server's origin; replay asCookieon 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/Expiresexpiry, 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
cookieJarin server settings (omit-on-false, mirroringsuppressNotificationStreamfrom 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-Cookieare 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
Cookiecustom 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):
initializesucceeds and the proxy assigns a pod, but the fetch chain drops theSet-Cookie, so thetools/listPOST round-robins to the other pod and is answered404 {"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_affinityfrom theinitializeresponse and replays it on every subsequent same-origin request; the proxy pins the whole session to that one pod, andtools/listsucceeds, 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
lizexinxin commentedon Nov 9, 2026
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-sessionsPer 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/Expireswith Max-Age precedence, same-name overwrite, malformed lines dropped), bounded at 50 entries / 4096 bytes per pair, oldest evicted first.cookieJar?: booleanper-server setting (default off) threaded through settings types, persistence (serverList.ts, omit-on-false — mirroringsuppressNotificationStreamfrom Add a per-server setting to suppress the standalone GET notification stream #2317), the/api/serverssettings API (400 on non-boolean, read-back-tested), and an "Enable HTTP Cookie Jar (sticky sessions)" checkbox in the server settings form shown forstreamable-httpandsse.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-Cookieare 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).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.cliffhall commentedon Nov 10, 2026
Triage: Priority Low (total 5)
Cookiecustom header works as a partial workaround.Board: #28, Status
Incoming(awaiting maintainer review — no milestone yet).Store cookies only from responses whose final URL is the server's ori…
Refuse cookies from URL-less responses; prune expired before evicting (…
Place the cookie jar below the auth-challenge interceptor (#2936)
Forward only path and query in the affinity test proxy (#2936)
Build the affinity test proxy's upstream URL from an allowlist (#2936)
Merge pull request #2976 from modelcontextprotocol/v2/feat/2936-cooki…
cliffhall commentedon Nov 12, 2026
Shipped in #2976 (merged as 4993561).