Skip to content

feat(core): session-scoped MCP servers - #52305

Closed
nexxeln wants to merge 3 commits into
devfrom
session-mcp
Closed

nexxeln wants to merge 3 commits into
devfrom
session-mcp

Conversation

@nexxeln

@nexxeln nexxeln commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

#40978 | #51788 | ACP port: #35457

Why the change

Right now an MCP server that ACP passes in for one session is registered for the whole directory, so other sessions can see its tools and headers, a server with the same name from another session replaces it, and nothing stops it when the session goes away; with this change a session owns its servers, they are visible only to that session and its subagents, and they stop when the session is closed, deleted, or moved.

Special things to note

  • Plugin tool transforms (Tool.Service.transform overrides) don't apply to session-owned MCP tools, because those tools never enter the Location tool registry. They are layered onto each step's snapshot instead.
  • view(sessionID) lives on the new McpSession service, not on Mcp. The Location Mcp interface keeps its session-free signatures and doesn't depend on SessionStore, and callTool.sessionID is still only MCP request metadata.
  • Out of scope: OAuth for session servers (they only use the headers they're given, and ACP already sends oauth: false), session-scoped MCP prompts and mcp.list status (both stay Location-only), and cleanup when the ACP process exits or when a re-attach drops servers from its list.

Change outline

The new endpoints are nested under the session, like experimental.session.instructions.entry.*. sessionLocationMiddleware routes each request to the session's Location and returns SessionNotFoundError when the session doesn't exist.

+PUT    /api/experimental/session/:sessionID/mcp/:server   client.session.mcp.add
+       body   { config: Mcp.ServerConfig }
+       204 | 404 SessionNotFoundError
+       re-adding an identical config does nothing; a different config replaces only this session's server
+
+DELETE /api/experimental/session/:sessionID/mcp/:server   client.session.mcp.remove
+       204 | 404 SessionNotFoundError | 404 McpServerNotFoundError

Each server entry has a typed owner. The Location registry and the session registry each build their own Mcp.makeRuntime, which holds the shared connection code, and each supplies its own lock. Only Location entries publish mcp.*.changed events.

// mcp/index.ts
type Owner = { type: "location" } | { type: "session"; id: Session.ID }
type Entry<O extends Owner> = { owner: O; config; status; startup; client?; tools?; ... }

// mcp/session.ts
interface McpSession.Interface {
  add(sessionID, server, config): Effect<void>
  remove(sessionID, server): Effect<void, Mcp.NotFoundError>
  view(sessionID): Effect<View>
}

// Servers owned by the session or an ancestor (nearest owner wins), plus Location servers they don't shadow
interface View {
  shadowed: ReadonlySet<string> // Location server names hidden for this session
  servers; instructions
  tools: Mcp.Tool[]             // visible Location tools
  owned: { tool: Mcp.Tool; call(input) }[] // call is bound to the owning server entry
  resourceCatalog; resources(server); readResource({ server, uri })
}

// tool.ts
interface Tool.Overlay {
  tools: Tool.Info[]            // session-owned tools for one snapshot
  hidden: ReadonlySet<string>   // registry tools to hide
}

A step builds one view and passes it to both the tool snapshot and the MCP instructions.

 SessionContext.select(sessionID)
   mcpTools.flush
   agent.select
+  view = McpSession.view(sessionID)            // walks the parent chain once; no store reads when no session servers exist
-  tools = registry.snapshot(permissions)
+  tools = McpTool.overlay(view)                // hide Location tools whose server is in view.shadowed, add view.owned
+            -> registry.snapshot(permissions, overlay)
-  mcp   = McpInstructions.load(permissions)
+  mcp   = McpInstructions.load(permissions, view)

Servers are released without the session calling into MCP. McpSession listens on its Location bus.

session.deleted                        -> release that session's servers (in parallel)
session.moved to another Location      -> the old Location releases them; the new Location ignores the event
Session.remove(root)                   -> deletes children first, so the whole tree is released
McpSession.remove / replacement config -> stop that one entry (its scope closes and its process exits)
ACP session/close                      -> client.session.mcp.remove for each registered name
ACP session/delete                     -> relies on session.deleted

Responsibilities by file:

 packages/core/src/
 ├── mcp/
 │   ├── index.ts          # Location registry; connection code moved into Mcp.makeRuntime; typed Owner and Entry
+│   ├── session.ts        # McpSession: session-owned registry, per-session locks, parent-chain walk, View, release on delete/move
+│   ├── elicitation.ts    # elicitation form handler; forms belong to the owning session, or "global" for Location servers
 │   └── instructions.ts   # load(permissions, view)
 ├── tool.ts               # snapshot(permissions, overlay?)
 ├── tool/mcp.ts           # overlay(view)
 ├── tool/plugin/mcp-resource.ts  # resource tools read through the session's view
 └── session/context.ts    # builds the view once per step
 packages/protocol/src/groups/session.ts  # session.mcp.add / remove
 packages/server/src/handlers/session.ts  # handlers, reusing notFound from handlers/mcp.ts
 packages/cli/src/acp/service.ts          # PUT on every attach, a Set of names per session, release on close

Tests use a real stdio MCP server, real sessions, and a real Session.move (packages/core/test/mcp-session.test.ts), plus an HTTP test in packages/server and updated ACP tests in packages/cli.

Fixes #40978
Fixes #51788

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

Labels

None yet

Projects

None yet

1 participant