Skip to content

feat(eve): stateful MCP client sessions#331

Open
ckblockit wants to merge 8 commits into
vercel:mainfrom
ckblockit:feat/stateful-mcp-sessions
Open

feat(eve): stateful MCP client sessions#331
ckblockit wants to merge 8 commits into
vercel:mainfrom
ckblockit:feat/stateful-mcp-sessions

Conversation

@ckblockit

Copy link
Copy Markdown

Summary

Adds a session: "stateful" option to defineMcpClientConnection. When set, an MCP connection persists its server-assigned Mcp-Session-Id across eve step boundaries, so a stateful MCP server (one that returns Mcp-Session-Id on initialize, per the MCP spec) sees one continuous session for the life of the eve session instead of a net-new session every step.

defineMcpClientConnection({
  url: "https://mcp.example.com",
  description: "...",
  session: "stateful", // default: "stateless" (unchanged behavior)
})

The problem

eve rebuilds the connection registry on every step — it's a derived context key holding live client instances that can't survive a step boundary. So each step created a fresh @ai-sdk/mcp client, opened a new transport, and sent a new initialize, getting a new Mcp-Session-Id every step. The previously negotiated session was never captured or persisted, so a stateful MCP server treated every step as a brand-new session.

How it works

The work splits into cheap replay and a small capture:

  • Replay (steps 2…N): inject the persisted Mcp-Session-Id as a request header.
  • Capture (first initialize): read the Mcp-Session-Id off the server's response.

Both happen through a custom fetch passed to @ai-sdk/mcp's HTTP transport, driven by a per-step mutable "slot". Persistence mirrors the existing sandboxProvider:

  1. session flows from the public definition → ResolvedConnectionDefinition.
  2. connectionProvider.create seeds a slot from session.state, keyed by mcpSessionStateKey(connectionName, principalId).
  3. The registry hands each stateful McpConnectionClient its slot; the client's fetch wrapper injects + captures the Mcp-Session-Id.
  4. On a server 404 (expired/unknown session), the client clears the slot, closes, and re-initializes once.
  5. connectionProvider.commit drains changed ids back into session.state, which survives the step boundary.

Scoping: per connection + authenticated principal, with an "anonymous" fallback for unauthenticated callers. session.state is itself per-eve-session, so there's no cross-session or cross-principal leakage.

Stateless connections are unchanged — no slot, no fetch wrapper, no retry.

Deliberately deferred: HTTP DELETE termination

The MCP spec says a client SHOULD send DELETE to terminate a session when done. eve has no clean per-eve-session "end" hook (commit/dispose run every step; HarnessSession has no terminal flag), and firing DELETE at any existing hook would kill the session we're deliberately persisting. Since DELETE is SHOULD (not MUST) and correctness is preserved by server-side TTL + the mandatory 404 re-init path, it's left as a follow-up rather than adding session-lifecycle plumbing here.

Testing

  • Unit: key derivation (principal vs. anonymous), stateful-vs-stateless header injection, 404 → re-init, the fetch wrapper's inject/capture/no-clobber, and the connectionProvider create→commit round-trip (persist + re-seed across a simulated step boundary).
  • A regression test confirms a 404 while replaying a persisted session re-initializes over HTTP rather than falling back to SSE (it fails without the guard).
  • 161 unit tests green across the touched areas; typecheck, lint, fmt, guard:invariants, and docs:check all pass.

Changeset

patch — new feature, pre-1.0.

🤖 Generated with Claude Code

ckblockit and others added 8 commits June 25, 2026 23:09
Signed-off-by: Chung Eun Kim <ck@blockit.com>
Signed-off-by: Chung Eun Kim <ck@blockit.com>
Signed-off-by: Chung Eun Kim <ck@blockit.com>
Signed-off-by: Chung Eun Kim <ck@blockit.com>
Signed-off-by: Chung Eun Kim <ck@blockit.com>
…lots

`connectionProvider.create` now reads persisted `Mcp-Session-Id` values
from `session.state` and seeds `McpSessionSlots` into `ConnectionRegistryImpl`
so stateful MCP connections resume their server-side session across eve step
boundaries. `connectionProvider.commit` drains `registry.collectMcpSessionUpdates()`
back into `session.state`, completing the durable round-trip.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Chung Eun Kim <ck@blockit.com>
Add a "Stateful sessions" subsection to docs/connections/mcp.mdx
documenting the session: "stateful" option, per-principal scoping, and
automatic 404 re-initialize. Add a patch changeset for the eve package.
Fix as-unknown-as double casts in connection.test.ts to satisfy
guard:invariants (add required ModuleSourceRef fields instead).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Chung Eun Kim <ck@blockit.com>
A 404 during HTTP initialize while replaying a persisted session id means
the session expired on the server, not a streamable-HTTP/SSE transport
mismatch. Rethrow immediately so #withSessionRetry clears the slot and
re-initializes over HTTP. The 404→SSE path is unchanged for connections
that are not replaying a session id.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Chung Eun Kim <ck@blockit.com>
@vercel

vercel Bot commented Jun 26, 2026

Copy link
Copy Markdown

@ckblockit is attempting to deploy a commit to the Vercel Team on Vercel.

A member of the Team first needs to authorize it.

@ckblockit

Copy link
Copy Markdown
Author

@allenzhou101 could you review this when you have a chance? Thanks! (Requesting via comment — I'm contributing from a fork, so I can't assign you as a formal reviewer.)

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.

1 participant