Skip to content

fix(proxy): give each MCP caller its own upstream session - #160

Merged
mayankpande88 merged 1 commit into
mainfrom
fix/mcp-session-isolation
Sep 30, 2026
Merged

mayankpande88 merged 1 commit into
mainfrom
fix/mcp-session-isolation

Conversation

@blue4209211

@blue4209211 blue4209211 commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Description

Conversations using one MCP server through forager at the same time shared one session, so on stateful servers (browser, shell, database) one saw and changed another's state, with no error. Forager now gives each caller its own MCP session and closes idle ones. MCP servers that require sessions, such as Playwright MCP over HTTP, also start working through forager.

Type of change

  • Bug fix (non-breaking change which fixes an issue)

How Has This Been Tested?

  • Unit tests
  • Manual testing (against a real Playwright MCP server; results in the fold)
  1. Point a forager mcp-proxy datasource at a browser MCP server over HTTP (e.g. npx @playwright/mcp --port 8931).
  2. From two conversations at the same time, ask each to open a different page and describe it.
  3. Each should describe only its own page. Before, Playwright MCP rejected every call through forager (406).

Risks

  • The stdio transport is unchanged: one process per datasource is still shared by all callers. This is documented; isolating it would mean one process per conversation.

Checklist

  • make validate passes (fmt + lint + test)
  • Docs updated (proxy module behavior)
Engineering detail

Before. handleHTTP and handleSSE POSTed each JSON-RPC message with no initialize handshake, no Mcp-Session-Id, and Accept set to at most one of the two types. The session_id request param (the relay forwards the caller's action_params as Params) was ignored.

Now (pkg/proxy/mcp/session.go, http and sse transports):

  • initialize + notifications/initialized per session_id; Mcp-Session-Id on later requests; Accept: application/json, text/event-stream. SSE-framed replies are unwrapped by Content-Type, or always for transport: sse, as before.
  • Per-Proxy session map (no package-level state). Idle expiry is 30 min, extended on use. A per-Proxy sweeper, started in Configure and stopped in Close, DELETEs idle sessions one at a time. Close DELETEs all live sessions in parallel; a closed proxy opens no new sessions.
  • 404 for a sent session → re-initialize and retry once (MCP spec). A 400 is never treated as "session gone": guessing from its text would drop a live session and replay a tool call on an ordinary tool error.
  • A server that answers initialize with no session id, or refuses it with a 4xx, is remembered as sessionless for 30 min, so it isn't re-initialized on every call. Transport errors and 5xx aren't remembered, so a blip can't switch sessions off for a server that needs them.
  • A lost concurrent-initialize race DELETEs the extra session.

Tests (session_test.go, fake Streamable HTTP server that returns 406 without the dual Accept, 404 for unknown sessions, and SSE-framed replies):

  • separate session_ids get separate sessions, and one session_id keeps its session
  • the initialized notification is sent
  • a missing session_id shares one session
  • a 404 re-initializes and retries
  • an idle session is DELETEd and replaced
  • Close DELETEs all sessions
  • a sessionless server is initialized once
  • a closed proxy opens no session

make validate: 0 lint issues, all packages pass under -race.

Live check against a real Playwright MCP server (npx @playwright/mcp@latest --headless --isolated --port 8931). The harness drives mcp.Proxy exactly as the websocket handler does, against two local pages, PAGE-A and PAGE-B. It is a build-tagged throwaway and is not committed.

Check main (before) this branch
Two callers (conv-a, conv-b) navigate concurrently, then snapshot every call 406 Not Acceptable: Client must accept both application/json and text/event-stream conv-a sees only PAGE-A, conv-b sees only PAGE-B
Control: one session_id navigates to A, then to B n/a snapshot shows PAGE-B (shared state, as intended)
Close ends the session upstream n/a raw request with the old session id: 200 before Close, 404 after
Server drops the session behind the proxy n/a next call returns 200 on a new session id

So on main, forager could not talk to a Streamable HTTP server like Playwright MCP at all.

End-to-end through the product (dev environment). This branch build ran as a proxy agent, with a local Playwright MCP datasource. Two chats on the same account ran concurrently: one opened page ALPHA, the other page BRAVO, both waited 15 s, then took a snapshot. From the tool-call records:

Time Chat Call Snapshot contains
+0.0 s ALPHA navigate alpha
+0.7 s BRAVO navigate bravo (the last navigation)
+34.8 s BRAVO snapshot BRAVO-4402 only
+35.0 s ALPHA snapshot ALPHA-7731 only

With a shared session, ALPHA's snapshot would have shown BRAVO. Each chat's final answer reported its own marker.

A fresh-context review found and got fixed: the per-call initialize on sessionless servers, an unsynchronised sweeper start/stop, and the 400-text heuristic.

🤖 Generated with Claude Code

Comment thread pkg/proxy/mcp/session.go Dismissed

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request introduces upstream session management for the MCP proxy (http/sse) to prevent concurrent callers from sharing a single upstream session. It implements the MCP initialize handshake, tracks sessions per session_id, closes idle sessions, and handles session expiration. The review feedback highlights a critical issue where requests might still proceed on a closed proxy when ensureSession returns errProxyClosed; it is recommended to check for this error and abort the request immediately.

Comment thread pkg/proxy/mcp/session.go
@blue4209211
blue4209211 marked this pull request as draft September 30, 2026 03:23
The MCP proxy sent every http/sse request as a bare POST: no initialize
handshake, no Mcp-Session-Id, and the caller's session_id was ignored.
Concurrent conversations therefore shared one state on stateful MCP
servers (browser, shell, database session), and Streamable HTTP servers
that require a session could not be used at all.

- initialize + notifications/initialized, Mcp-Session-Id on requests,
  Accept "application/json, text/event-stream"
- one upstream session per session_id request param (none = shared)
- 30 min idle expiry extended on use; best-effort DELETE on idle
  eviction and on Close
- 404 for a sent session re-initializes and retries once
- servers that answer without a session id are remembered as
  sessionless instead of re-initialized on every call
- stdio is unchanged (one process per datasource) and documented so

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@blue4209211
blue4209211 force-pushed the fix/mcp-session-isolation branch from be8493d to 8cedc92 Compare September 30, 2026 03:59
@blue4209211
blue4209211 marked this pull request as ready for review September 30, 2026 04:00
@mayankpande88
mayankpande88 merged commit 8aeb283 into main Sep 30, 2026
6 checks passed
@mayankpande88
mayankpande88 deleted the fix/mcp-session-isolation branch September 30, 2026 04:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants