From cc35e61be5688feec8aafed4cd0e8d5c84b41e7d Mon Sep 17 00:00:00 2001 From: Chung Eun Kim Date: Thu, 25 Jun 2026 23:09:36 -0700 Subject: [PATCH 1/8] feat(eve): add session mode to mcp connection definitions Signed-off-by: Chung Eun Kim --- .../src/public/definitions/connections/mcp.ts | 13 ++++++ packages/eve/src/runtime/connections/types.ts | 16 +++++++ .../src/runtime/resolve-connection.test.ts | 46 +++++++++++++++++++ .../eve/src/runtime/resolve-connection.ts | 5 ++ packages/eve/src/runtime/types.ts | 6 +++ 5 files changed, 86 insertions(+) diff --git a/packages/eve/src/public/definitions/connections/mcp.ts b/packages/eve/src/public/definitions/connections/mcp.ts index e4e586c8b..f225cba5e 100644 --- a/packages/eve/src/public/definitions/connections/mcp.ts +++ b/packages/eve/src/public/definitions/connections/mcp.ts @@ -1,6 +1,7 @@ import type { ConnectionAuthDefinition, HeadersDefinition, + McpSessionMode, ToolFilterDefinition, } from "#runtime/connections/types.js"; import { normalizeAuthorizationSpec } from "#runtime/connections/validate-authorization.js"; @@ -80,6 +81,18 @@ export interface McpClientConnectionDefinition { * Specify exactly one of `allow` or `block`. */ tools?: ToolFilterDefinition; + /** + * Whether this connection keeps its MCP session alive across eve step + * boundaries. + * + * Defaults to `"stateless"`: each step opens a new MCP session. Set to + * `"stateful"` to persist the server-assigned `Mcp-Session-Id` and reuse + * it on later steps, so a stateful MCP server (one that returns an + * `Mcp-Session-Id` on `initialize`) treats the whole eve session as a + * single session. The id is scoped per authenticated principal and + * re-negotiated automatically if the server expires it. + */ + session?: McpSessionMode; } /** diff --git a/packages/eve/src/runtime/connections/types.ts b/packages/eve/src/runtime/connections/types.ts index 8a281ecb9..f659adf72 100644 --- a/packages/eve/src/runtime/connections/types.ts +++ b/packages/eve/src/runtime/connections/types.ts @@ -40,6 +40,22 @@ export interface TokenResult { */ export type ConnectionProtocol = "mcp" | "openapi"; +/** + * Whether an MCP connection persists its server-assigned `Mcp-Session-Id` + * across eve step boundaries. + * + * - `"stateless"` (default): every step negotiates a fresh MCP session. + * - `"stateful"`: the negotiated `Mcp-Session-Id` is persisted in + * `session.state` (keyed by connection name + principal) and replayed on + * the next step, so a stateful MCP server sees one continuous session for + * the life of the eve session. Re-initializes automatically on a server + * `404` (expired/unknown session). + * + * A string union (not a boolean) so future modes (e.g. `"per-turn"`) can be + * added without a breaking change. + */ +export type McpSessionMode = "stateful" | "stateless"; + /** A single header value, supporting static strings and per-caller resolution. */ export type HeaderValue = | string diff --git a/packages/eve/src/runtime/resolve-connection.test.ts b/packages/eve/src/runtime/resolve-connection.test.ts index d7cebe27f..ecd7963ae 100644 --- a/packages/eve/src/runtime/resolve-connection.test.ts +++ b/packages/eve/src/runtime/resolve-connection.test.ts @@ -8,6 +8,30 @@ import type { CompiledModuleMap } from "#compiler/module-map.js"; import { resolveConnectionDefinition } from "#runtime/resolve-connection.js"; import type { ConnectionAuthResolver, HeadersDefinition } from "#runtime/connections/types.js"; +const mcpCompiledDef: CompiledConnectionDefinition = { + connectionName: "test-mcp", + description: "Test MCP connection", + logicalPath: "connections/test-mcp.ts", + protocol: "mcp", + sourceId: "connections/test-mcp", + sourceKind: "module", + url: "https://mcp.example.com", +}; + +function moduleMapReturning(exportValue: Record): CompiledModuleMap { + return { + nodes: { + [ROOT_COMPILED_AGENT_NODE_ID]: { + modules: { + [mcpCompiledDef.sourceId]: { + default: exportValue, + }, + }, + }, + }, + }; +} + describe("resolveConnectionDefinition", () => { it("preserves context-aware auth and header callbacks for request-time resolution", async () => { const auth: ConnectionAuthResolver = (ctx) => ({ @@ -45,4 +69,26 @@ describe("resolveConnectionDefinition", () => { expect(resolved.authorization).toBe(auth); expect(resolved.headers).toBe(headers); }); + + it('carries session: "stateful" through to the resolved definition', async () => { + const resolved = await resolveConnectionDefinition( + mcpCompiledDef, + moduleMapReturning({ + url: "https://mcp.example.com", + description: "test", + session: "stateful", + }), + undefined, + ); + expect(resolved.session).toBe("stateful"); + }); + + it("leaves session undefined when not set", async () => { + const resolved = await resolveConnectionDefinition( + mcpCompiledDef, + moduleMapReturning({ url: "https://mcp.example.com", description: "test" }), + undefined, + ); + expect(resolved.session).toBeUndefined(); + }); }); diff --git a/packages/eve/src/runtime/resolve-connection.ts b/packages/eve/src/runtime/resolve-connection.ts index bb43b2631..0acc483ab 100644 --- a/packages/eve/src/runtime/resolve-connection.ts +++ b/packages/eve/src/runtime/resolve-connection.ts @@ -71,6 +71,7 @@ export async function resolveConnectionDefinition( headers?: Readonly; logicalPath: string; protocol: ResolvedConnectionDefinition["protocol"]; + session?: ResolvedConnectionDefinition["session"]; sourceId: string; sourceKind: "module"; spec?: ResolvedConnectionDefinition["spec"]; @@ -113,6 +114,10 @@ export async function resolveConnectionDefinition( result.tools = filter as Readonly; } + if (definition.protocol === "mcp" && resolvedRecord.session === "stateful") { + result.session = "stateful"; + } + if (definition.protocol === "openapi" && resolvedRecord.spec !== undefined) { result.spec = resolvedRecord.spec as ResolvedConnectionDefinition["spec"]; } diff --git a/packages/eve/src/runtime/types.ts b/packages/eve/src/runtime/types.ts index 4ec16f788..fac1e5db2 100644 --- a/packages/eve/src/runtime/types.ts +++ b/packages/eve/src/runtime/types.ts @@ -15,6 +15,7 @@ import type { ConnectionAuthResolver, ConnectionProtocol, HeadersDefinition, + McpSessionMode, ToolFilterDefinition, } from "#runtime/connections/types.js"; import type { OpenAPISpecSource } from "#public/definitions/connections/openapi.js"; @@ -105,6 +106,11 @@ export interface ResolvedConnectionDefinition extends ResolvedModuleSourceRef { * OpenAPI connections). */ readonly protocol: ConnectionProtocol; + /** + * MCP session persistence mode. Present only for `protocol: "mcp"` + * connections; `undefined` (treated as `"stateless"`) for OpenAPI. + */ + readonly session?: McpSessionMode; /** * OpenAPI document source (URL or inline object). Present only for * `protocol: "openapi"` connections; the OpenAPI client fetches and From 3db5ea5345b448e8bde6ffa74d39d910ce79d824 Mon Sep 17 00:00:00 2001 From: Chung Eun Kim Date: Thu, 25 Jun 2026 23:11:57 -0700 Subject: [PATCH 2/8] feat(eve): add mcp session durable-state store Signed-off-by: Chung Eun Kim --- .../connections/mcp-session-store.test.ts | 35 ++++++++++ .../runtime/connections/mcp-session-store.ts | 65 +++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 packages/eve/src/runtime/connections/mcp-session-store.test.ts create mode 100644 packages/eve/src/runtime/connections/mcp-session-store.ts diff --git a/packages/eve/src/runtime/connections/mcp-session-store.test.ts b/packages/eve/src/runtime/connections/mcp-session-store.test.ts new file mode 100644 index 000000000..4157a8332 --- /dev/null +++ b/packages/eve/src/runtime/connections/mcp-session-store.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { + collectMcpSessionUpdates, + mcpSessionStateKey, + type McpSessionSlot, +} from "#runtime/connections/mcp-session-store.js"; + +describe("mcpSessionStateKey", () => { + it("includes connection name and principal", () => { + expect(mcpSessionStateKey("linear", "user:slack:U123")).toBe( + "eve.mcp.session.linear.user:slack:U123", + ); + }); + + it('falls back to "anonymous" for no principal', () => { + expect(mcpSessionStateKey("linear", null)).toBe("eve.mcp.session.linear.anonymous"); + expect(mcpSessionStateKey("linear", undefined)).toBe("eve.mcp.session.linear.anonymous"); + }); +}); + +describe("collectMcpSessionUpdates", () => { + it("returns captured ids that differ from the seeded id", () => { + const slots = new Map([ + ["a", { stateKey: "k.a", initialId: undefined, sessionId: "s-a" }], + ["b", { stateKey: "k.b", initialId: "s-b", sessionId: "s-b" }], // unchanged + ["c", { stateKey: "k.c", initialId: "old", sessionId: "new" }], // re-init + ["d", { stateKey: "k.d", initialId: undefined, sessionId: undefined }], // never set + ]); + expect(collectMcpSessionUpdates(slots)).toEqual([ + { stateKey: "k.a", sessionId: "s-a" }, + { stateKey: "k.c", sessionId: "new" }, + ]); + }); +}); diff --git a/packages/eve/src/runtime/connections/mcp-session-store.ts b/packages/eve/src/runtime/connections/mcp-session-store.ts new file mode 100644 index 000000000..b43ac232a --- /dev/null +++ b/packages/eve/src/runtime/connections/mcp-session-store.ts @@ -0,0 +1,65 @@ +/** + * Durable-state helpers for stateful MCP connections. + * + * A connection authored with `session: "stateful"` keeps its + * server-assigned `Mcp-Session-Id` in `session.state` so it survives eve + * step boundaries. This module owns the key derivation and the per-step + * "slot" the runtime mutates while a step runs; the `connectionProvider` + * seeds slots on `create` and drains them on `commit`. + */ + +/** Namespace prefix for all persisted MCP session keys in `session.state`. */ +export const MCP_SESSION_STATE_PREFIX = "eve.mcp.session"; + +/** + * Builds the `session.state` key for a connection's persisted MCP session id. + * + * Keyed by connection name and authenticated principal so two principals + * sharing one connection in the same eve session never reuse each other's + * server-side session. `session.state` is itself per-eve-session, so the + * `"anonymous"` fallback for an unauthenticated caller is safe. + */ +export function mcpSessionStateKey( + connectionName: string, + principalId: string | null | undefined, +): string { + return `${MCP_SESSION_STATE_PREFIX}.${connectionName}.${principalId ?? "anonymous"}`; +} + +/** + * Per-step, per-connection holder for a stateful MCP session id. + * + * `initialId` is the id seeded from durable state at step start (read-only + * for the step). `sessionId` is the live id the transport injects and the + * server may replace; the provider compares the two on `commit` to decide + * what to persist. + */ +export interface McpSessionSlot { + readonly stateKey: string; + readonly initialId?: string; + sessionId?: string; +} + +/** Map of connection name → live session slot for one step. */ +export type McpSessionSlots = ReadonlyMap; + +/** A single durable write the provider applies on `commit`. */ +export interface McpSessionUpdate { + readonly stateKey: string; + readonly sessionId: string; +} + +/** + * Returns the durable writes for slots whose live `sessionId` was set and + * differs from the seeded `initialId` (new session or server-driven + * re-initialization). Unchanged and never-connected slots produce nothing. + */ +export function collectMcpSessionUpdates(slots: McpSessionSlots): readonly McpSessionUpdate[] { + const updates: McpSessionUpdate[] = []; + for (const slot of slots.values()) { + if (slot.sessionId !== undefined && slot.sessionId !== slot.initialId) { + updates.push({ stateKey: slot.stateKey, sessionId: slot.sessionId }); + } + } + return updates; +} From dbbba3013eaeb4ec6454df38853027033a650ec0 Mon Sep 17 00:00:00 2001 From: Chung Eun Kim Date: Thu, 25 Jun 2026 23:13:51 -0700 Subject: [PATCH 3/8] feat(eve): add mcp session-capturing fetch wrapper Signed-off-by: Chung Eun Kim --- .../connections/mcp-session-fetch.test.ts | 63 +++++++++++++++++++ .../runtime/connections/mcp-session-fetch.ts | 35 +++++++++++ 2 files changed, 98 insertions(+) create mode 100644 packages/eve/src/runtime/connections/mcp-session-fetch.test.ts create mode 100644 packages/eve/src/runtime/connections/mcp-session-fetch.ts diff --git a/packages/eve/src/runtime/connections/mcp-session-fetch.test.ts b/packages/eve/src/runtime/connections/mcp-session-fetch.test.ts new file mode 100644 index 000000000..98826998c --- /dev/null +++ b/packages/eve/src/runtime/connections/mcp-session-fetch.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + createSessionCapturingFetch, + MCP_SESSION_ID_HEADER, +} from "#runtime/connections/mcp-session-fetch.js"; +import type { McpSessionSlot } from "#runtime/connections/mcp-session-store.js"; + +function slot(overrides: Partial = {}): McpSessionSlot { + return { stateKey: "k", ...overrides }; +} + +afterEach(() => vi.restoreAllMocks()); + +describe("createSessionCapturingFetch", () => { + it("injects the session id header when the slot has one", async () => { + const inner = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response(null, { status: 200 })); + const s = slot({ sessionId: "sess-1" }); + + await createSessionCapturingFetch(s)("https://mcp.example.com", { method: "POST" }); + + const init = inner.mock.calls[0]![1]!; + expect(new Headers(init.headers).get(MCP_SESSION_ID_HEADER)).toBe("sess-1"); + }); + + it("does not inject when the slot has no session id", async () => { + const inner = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response(null, { status: 200 })); + + await createSessionCapturingFetch(slot())("https://mcp.example.com", {}); + + const init = inner.mock.calls[0]![1]!; + expect(new Headers(init.headers).has(MCP_SESSION_ID_HEADER)).toBe(false); + }); + + it("captures the server-assigned session id from the response", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(null, { status: 200, headers: { [MCP_SESSION_ID_HEADER]: "sess-server" } }), + ); + const s = slot(); + + await createSessionCapturingFetch(s)("https://mcp.example.com", {}); + + expect(s.sessionId).toBe("sess-server"); + }); + + it("does not clobber a session id already present on the request", async () => { + const inner = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response(null, { status: 200 })); + const s = slot({ sessionId: "slot-id" }); + + await createSessionCapturingFetch(s)("https://mcp.example.com", { + headers: { [MCP_SESSION_ID_HEADER]: "explicit-id" }, + }); + + const init = inner.mock.calls[0]![1]!; + expect(new Headers(init.headers).get(MCP_SESSION_ID_HEADER)).toBe("explicit-id"); + }); +}); diff --git a/packages/eve/src/runtime/connections/mcp-session-fetch.ts b/packages/eve/src/runtime/connections/mcp-session-fetch.ts new file mode 100644 index 000000000..76a29c0dc --- /dev/null +++ b/packages/eve/src/runtime/connections/mcp-session-fetch.ts @@ -0,0 +1,35 @@ +import type { McpSessionSlot } from "#runtime/connections/mcp-session-store.js"; + +/** + * The MCP session header (lowercase; `Headers` is case-insensitive). See + * https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management + */ +export const MCP_SESSION_ID_HEADER = "mcp-session-id"; + +/** + * Wraps `globalThis.fetch` so a stateful MCP connection injects and captures + * its `Mcp-Session-Id`. + * + * On each request it adds the slot's current `sessionId` as the + * `Mcp-Session-Id` header (unless the caller already set one), and on each + * response it records any `Mcp-Session-Id` the server returned back onto the + * slot. The slot is the single source of truth for the id, so clearing it on + * a `404` (see {@link McpConnectionClient}) makes the next `initialize` + * negotiate a fresh session. + */ +export function createSessionCapturingFetch(slot: McpSessionSlot): typeof globalThis.fetch { + return async (input, init) => { + const headers = new Headers(init?.headers); + if (slot.sessionId !== undefined && !headers.has(MCP_SESSION_ID_HEADER)) { + headers.set(MCP_SESSION_ID_HEADER, slot.sessionId); + } + + const response = await globalThis.fetch(input, { ...init, headers }); + + const assigned = response.headers.get(MCP_SESSION_ID_HEADER); + if (assigned !== null && assigned !== slot.sessionId) { + slot.sessionId = assigned; + } + return response; + }; +} From 46b045c050049d1bc2aa9a27dc541c88c83e2660 Mon Sep 17 00:00:00 2001 From: Chung Eun Kim Date: Thu, 25 Jun 2026 23:17:40 -0700 Subject: [PATCH 4/8] feat(eve): stateful mcp client session inject/capture + 404 re-init Signed-off-by: Chung Eun Kim --- .../runtime/connections/mcp-client.test.ts | 57 +++++++++++++++ .../eve/src/runtime/connections/mcp-client.ts | 70 +++++++++++++------ 2 files changed, 106 insertions(+), 21 deletions(-) diff --git a/packages/eve/src/runtime/connections/mcp-client.test.ts b/packages/eve/src/runtime/connections/mcp-client.test.ts index 0df8352f6..720c00ef8 100644 --- a/packages/eve/src/runtime/connections/mcp-client.test.ts +++ b/packages/eve/src/runtime/connections/mcp-client.test.ts @@ -221,6 +221,63 @@ describe("McpConnectionClient", () => { }); }); +describe("McpConnectionClient stateful session", () => { + beforeEach(() => { + createMCPClient.mockReset(); + }); + + it("passes a custom fetch to the transport only for stateful connections", async () => { + const client = { close: vi.fn(), listTools: vi.fn(), toolsFromDefinitions: vi.fn() }; + createMCPClient.mockResolvedValue(client); + + await new McpConnectionClient(makeConnection({ session: "stateful" }), { + stateKey: "k", + }).connect(); + expect(createMCPClient.mock.calls[0]![0].transport.fetch).toBeTypeOf("function"); + + createMCPClient.mockClear(); + await new McpConnectionClient(makeConnection()).connect(); + expect(createMCPClient.mock.calls[0]![0].transport.fetch).toBeUndefined(); + }); + + it("clears the slot and retries once on a 404 during tool execution", async () => { + const sdkTool = { + execute: vi.fn().mockRejectedValueOnce({ status: 404 }).mockResolvedValueOnce("ok"), + }; + const client = { + close: vi.fn(), + listTools: vi.fn().mockResolvedValue({ tools: [{ name: "t", inputSchema: {} }] }), + toolsFromDefinitions: vi.fn().mockReturnValue({ t: sdkTool }), + }; + createMCPClient.mockResolvedValue(client); + + const slot = { stateKey: "k", sessionId: "stale" } as { stateKey: string; sessionId?: string }; + const result = await new McpConnectionClient( + makeConnection({ session: "stateful" }), + slot, + ).executeTool("t", {}); + + expect(result).toBe("ok"); + expect(slot.sessionId).toBeUndefined(); + expect(sdkTool.execute).toHaveBeenCalledTimes(2); + }); + + it("does not retry a 404 for a stateless connection", async () => { + const sdkTool = { execute: vi.fn().mockRejectedValue({ status: 404 }) }; + const client = { + close: vi.fn(), + listTools: vi.fn().mockResolvedValue({ tools: [{ name: "t", inputSchema: {} }] }), + toolsFromDefinitions: vi.fn().mockReturnValue({ t: sdkTool }), + }; + createMCPClient.mockResolvedValue(client); + + await expect( + new McpConnectionClient(makeConnection()).executeTool("t", {}), + ).rejects.toMatchObject({ status: 404 }); + expect(sdkTool.execute).toHaveBeenCalledTimes(1); + }); +}); + describe("isMcpAuthRequiredError", () => { it("treats a 401 invalid_token as authorization-required", () => { const error = Object.assign( diff --git a/packages/eve/src/runtime/connections/mcp-client.ts b/packages/eve/src/runtime/connections/mcp-client.ts index cb046f456..6dfd3e98e 100644 --- a/packages/eve/src/runtime/connections/mcp-client.ts +++ b/packages/eve/src/runtime/connections/mcp-client.ts @@ -7,6 +7,8 @@ import type { SessionContext } from "#public/definitions/callback-context.js"; import type { ResolvedConnectionDefinition } from "#runtime/types.js"; import { evictScopedToken, resolveScopedToken } from "#runtime/connections/scoped-authorization.js"; import { resolveConnectionAuthorization } from "#runtime/connections/resolve-authorization.js"; +import { createSessionCapturingFetch } from "#runtime/connections/mcp-session-fetch.js"; +import type { McpSessionSlot } from "#runtime/connections/mcp-session-store.js"; import { isObject } from "#shared/guards.js"; import type { AuthorizationDefinition, @@ -34,9 +36,11 @@ export class McpConnectionClient implements ConnectionClient { #toolsPromise: Promise | undefined; #tools: McpToolCache | undefined; #connection: ResolvedConnectionDefinition; + #sessionSlot: McpSessionSlot | undefined; - constructor(connection: ResolvedConnectionDefinition) { + constructor(connection: ResolvedConnectionDefinition, sessionSlot?: McpSessionSlot) { this.#connection = connection; + this.#sessionSlot = sessionSlot; } /** @@ -68,17 +72,19 @@ export class McpConnectionClient implements ConnectionClient { async #createClient(): Promise { const headers = await resolveHeaders(this.#connection); const url = this.#connection.url; + const fetch = + this.#sessionSlot !== undefined ? createSessionCapturingFetch(this.#sessionSlot) : undefined; try { return await createMCPClient({ - transport: { type: "http", url, headers }, + transport: { type: "http", url, headers, fetch }, }); } catch (error) { if (!isMcpHttpFallbackRetryableError(error)) { throw error; } return await createMCPClient({ - transport: { type: "sse", url, headers }, + transport: { type: "sse", url, headers, fetch }, }); } } @@ -117,20 +123,20 @@ export class McpConnectionClient implements ConnectionClient { * opaque transport error. */ async executeTool(toolName: string, args: unknown): Promise { - try { - const { tools } = await this.#ensureTools(); - - const sdkTool = tools[toolName]; - if (sdkTool?.execute === undefined) { - throw new Error( - `Tool "${toolName}" not found in connection "${this.#connection.connectionName}".`, - ); + return await this.#withSessionRetry(async () => { + try { + const { tools } = await this.#ensureTools(); + const sdkTool = tools[toolName]; + if (sdkTool?.execute === undefined) { + throw new Error( + `Tool "${toolName}" not found in connection "${this.#connection.connectionName}".`, + ); + } + return await sdkTool.execute(args, {} as never); + } catch (error) { + return await this.#rethrowClassified(error); } - - return await sdkTool.execute(args, {} as never); - } catch (error) { - return await this.#rethrowClassified(error); - } + }); } async #ensureTools(): Promise { @@ -153,11 +159,13 @@ export class McpConnectionClient implements ConnectionClient { } async #fetchTools(): Promise { - try { - return await this.#fetchToolsInner(); - } catch (error) { - return await this.#rethrowClassified(error); - } + return await this.#withSessionRetry(async () => { + try { + return await this.#fetchToolsInner(); + } catch (error) { + return await this.#rethrowClassified(error); + } + }); } async #fetchToolsInner(): Promise { @@ -200,6 +208,26 @@ export class McpConnectionClient implements ConnectionClient { this.#tools = undefined; } + /** + * Runs `operation`, and on a server `404` for a stateful connection clears + * the persisted session id, tears down the client, and retries once so the + * next attempt re-`initialize`s a fresh session. The MCP spec requires a + * client to start a new session when the server returns `404` for a request + * bearing a session id. + */ + async #withSessionRetry(operation: () => Promise): Promise { + try { + return await operation(); + } catch (error) { + if (this.#sessionSlot === undefined || readHttpStatus(error) !== 404) { + throw error; + } + this.#sessionSlot.sessionId = undefined; + await this.close(); + return await operation(); + } + } + /** * Always rethrows — this only classifies the error first. A non-auth * error (timeout, `5xx`, `403`, "tool not found", network failure) is From 04ea3b2c5af3a9121b3f2f00e8869e053ff67922 Mon Sep 17 00:00:00 2001 From: Chung Eun Kim Date: Thu, 25 Jun 2026 23:21:56 -0700 Subject: [PATCH 5/8] feat(eve): thread mcp session slots through the connection registry Signed-off-by: Chung Eun Kim --- .../src/runtime/connections/registry.test.ts | 25 ++++++++++++++++++- .../eve/src/runtime/connections/registry.ts | 22 ++++++++++++++-- packages/eve/src/runtime/connections/types.ts | 7 ++++++ 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/packages/eve/src/runtime/connections/registry.test.ts b/packages/eve/src/runtime/connections/registry.test.ts index 5acdc8d2c..54215830e 100644 --- a/packages/eve/src/runtime/connections/registry.test.ts +++ b/packages/eve/src/runtime/connections/registry.test.ts @@ -1,9 +1,14 @@ import { describe, expect, it } from "vitest"; import type { ResolvedConnectionDefinition } from "#runtime/types.js"; +import type { McpSessionSlot } from "#runtime/connections/mcp-session-store.js"; import { ConnectionRegistryImpl } from "#runtime/connections/registry.js"; -function makeConnection(name: string): ResolvedConnectionDefinition { +function makeConnection( + nameOrOpts: string | (Partial & { connectionName: string }), +): ResolvedConnectionDefinition { + const name = typeof nameOrOpts === "string" ? nameOrOpts : nameOrOpts.connectionName; + const overrides = typeof nameOrOpts === "string" ? {} : nameOrOpts; return { authorization: { getToken: async () => ({ token: `token-${name}` }), @@ -16,6 +21,7 @@ function makeConnection(name: string): ResolvedConnectionDefinition { sourceId: `connections/${name}`, sourceKind: "module", url: `https://${name}.example.com/mcp`, + ...overrides, }; } @@ -86,4 +92,21 @@ describe("ConnectionRegistryImpl", () => { expect(before).not.toBe(after); }); + + it("hands the matching session slot to a stateful mcp client and reports updates", async () => { + const slots = new Map([ + ["a", { stateKey: "eve.mcp.session.a.anonymous", initialId: undefined } as McpSessionSlot], + ]); + const registry = new ConnectionRegistryImpl( + [makeConnection({ connectionName: "a", session: "stateful" })], + slots, + ); + + // The client mutates its slot when it negotiates a session. + slots.get("a")!.sessionId = "negotiated"; + + expect(registry.collectMcpSessionUpdates()).toEqual([ + { stateKey: "eve.mcp.session.a.anonymous", sessionId: "negotiated" }, + ]); + }); }); diff --git a/packages/eve/src/runtime/connections/registry.ts b/packages/eve/src/runtime/connections/registry.ts index 71637fd8d..026b763ed 100644 --- a/packages/eve/src/runtime/connections/registry.ts +++ b/packages/eve/src/runtime/connections/registry.ts @@ -1,6 +1,11 @@ import type { Approval } from "#public/definitions/approval.js"; import type { ResolvedConnectionDefinition } from "#runtime/types.js"; import { McpConnectionClient } from "#runtime/connections/mcp-client.js"; +import { + collectMcpSessionUpdates, + type McpSessionSlots, + type McpSessionUpdate, +} from "#runtime/connections/mcp-session-store.js"; import { OpenApiConnectionClient } from "#runtime/connections/openapi-client.js"; import type { ConnectionClient, ConnectionRegistry } from "#runtime/connections/types.js"; @@ -14,9 +19,14 @@ import type { ConnectionClient, ConnectionRegistry } from "#runtime/connections/ export class ConnectionRegistryImpl implements ConnectionRegistry { #clients = new Map(); #connections: readonly ResolvedConnectionDefinition[]; + #sessionSlots: McpSessionSlots; - constructor(connections: readonly ResolvedConnectionDefinition[]) { + constructor( + connections: readonly ResolvedConnectionDefinition[], + sessionSlots?: McpSessionSlots, + ) { this.#connections = connections; + this.#sessionSlots = sessionSlots ?? new Map(); } /** @@ -37,7 +47,7 @@ export class ConnectionRegistryImpl implements ConnectionRegistry { const client: ConnectionClient = connection.protocol === "openapi" ? new OpenApiConnectionClient(connection) - : new McpConnectionClient(connection); + : new McpConnectionClient(connection, this.#sessionSlots.get(connectionName)); this.#clients.set(connectionName, client); return client; } @@ -65,6 +75,14 @@ export class ConnectionRegistryImpl implements ConnectionRegistry { return this.#connections; } + /** + * Returns durable writes for stateful MCP connections whose session id + * changed this step. + */ + collectMcpSessionUpdates(): readonly McpSessionUpdate[] { + return collectMcpSessionUpdates(this.#sessionSlots); + } + /** * Closes all active client connections. */ diff --git a/packages/eve/src/runtime/connections/types.ts b/packages/eve/src/runtime/connections/types.ts index f659adf72..12f48369f 100644 --- a/packages/eve/src/runtime/connections/types.ts +++ b/packages/eve/src/runtime/connections/types.ts @@ -12,6 +12,7 @@ import type { ConnectionAuthorizationChallenge } from "#public/connections/error import type { Approval } from "#public/definitions/approval.js"; import type { SessionContext } from "#public/definitions/callback-context.js"; import type { JsonValue } from "#public/types/json.js"; +import type { McpSessionUpdate } from "#runtime/connections/mcp-session-store.js"; import type { ResolvedConnectionDefinition } from "#runtime/types.js"; /** @@ -455,6 +456,12 @@ export interface ConnectionClient { /** Per-session container mapping connection names to clients. */ export interface ConnectionRegistry { + /** + * Durable writes for stateful MCP connections whose session id changed + * this step. The `connectionProvider` applies these to `session.state` on + * commit. Empty when no stateful connection negotiated or rotated a session. + */ + collectMcpSessionUpdates(): readonly McpSessionUpdate[]; dispose(): Promise; getClient(connectionName: string): ConnectionClient; getConnectionApproval(connectionName: string): Approval | undefined; From 17739f23a72d1f04e3a6fe6706bc884ebc865b03 Mon Sep 17 00:00:00 2001 From: Chung Eun Kim Date: Thu, 25 Jun 2026 23:26:11 -0700 Subject: [PATCH 6/8] feat(eve): connectionProvider seeds and drains stateful MCP session slots `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 Signed-off-by: Chung Eun Kim --- .../src/context/providers/connection.test.ts | 196 ++++++++++++++++++ .../eve/src/context/providers/connection.ts | 34 ++- 2 files changed, 228 insertions(+), 2 deletions(-) create mode 100644 packages/eve/src/context/providers/connection.test.ts diff --git a/packages/eve/src/context/providers/connection.test.ts b/packages/eve/src/context/providers/connection.test.ts new file mode 100644 index 000000000..2036d9d66 --- /dev/null +++ b/packages/eve/src/context/providers/connection.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from "vitest"; + +import type { HarnessSession } from "#harness/types.js"; +import { AuthKey, type SessionAuthContext } from "#context/keys.js"; +import { BundleKey, type CompiledBundle } from "#runtime/sessions/runtime-context-keys.js"; +import { ContextContainer } from "#context/container.js"; +import { ConnectionRegistryImpl } from "#runtime/connections/registry.js"; +import { mcpSessionStateKey, type McpSessionSlot } from "#runtime/connections/mcp-session-store.js"; +import type { ResolvedConnectionDefinition } from "#runtime/types.js"; +import { createBundledRuntimeCompiledArtifactsSource } from "#runtime/compiled-artifacts-source.js"; +import { connectionProvider } from "#context/providers/connection.js"; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +function createHarnessSession(state?: Record): HarnessSession { + return { + agent: { + modelReference: { id: "openai/gpt-5.4" }, + system: "", + tools: [], + }, + compaction: { + recentWindowSize: 0, + threshold: 0, + }, + continuationToken: "", + history: [], + sessionId: "session_1", + state, + }; +} + +function makeStatefulMcpConnection(name: string): ResolvedConnectionDefinition { + return { + connectionName: name, + description: "test connection", + protocol: "mcp", + session: "stateful", + url: `https://example.com/${name}`, + modulePath: `agent/connections/${name}.ts`, + } as unknown as ResolvedConnectionDefinition; +} + +function makeStatelessMcpConnection(name: string): ResolvedConnectionDefinition { + return { + connectionName: name, + description: "test connection", + protocol: "mcp", + url: `https://example.com/${name}`, + modulePath: `agent/connections/${name}.ts`, + } as unknown as ResolvedConnectionDefinition; +} + +function createBundle(connections: readonly ResolvedConnectionDefinition[]): CompiledBundle { + return { + compiledArtifactsSource: createBundledRuntimeCompiledArtifactsSource(), + graph: { + root: { + agent: { + connections, + }, + nodeId: "__root__", + }, + }, + } as CompiledBundle; +} + +function userAuth(principalId: string): SessionAuthContext { + return { + principalId, + issuer: "test-issuer", + } as SessionAuthContext; +} + +// --------------------------------------------------------------------------- +// commit: round-trip — slot changed → state updated +// --------------------------------------------------------------------------- + +describe("connectionProvider.commit", () => { + it("writes updated sessionId into session.state", () => { + const principalId = "user-42"; + const connectionName = "linear"; + const stateKey = mcpSessionStateKey(connectionName, principalId); + + const initialId = "old-session-id"; + const newSessionId = "new-session-id"; + + const slot: McpSessionSlot = { stateKey, initialId, sessionId: newSessionId }; + const slots = new Map([[connectionName, slot]]); + const registry = new ConnectionRegistryImpl([makeStatefulMcpConnection(connectionName)], slots); + + const session = createHarnessSession({ existingKey: "should-survive" }); + const committed = connectionProvider.commit!(registry, session) as HarnessSession; + + expect(committed.state?.[stateKey]).toBe(newSessionId); + // Unrelated keys must be preserved. + expect(committed.state?.["existingKey"]).toBe("should-survive"); + }); + + it("does not mutate the original session object", () => { + const connectionName = "slack"; + const stateKey = mcpSessionStateKey(connectionName, "u1"); + + const slot: McpSessionSlot = { stateKey, initialId: "a", sessionId: "b" }; + const slots = new Map([[connectionName, slot]]); + const registry = new ConnectionRegistryImpl([makeStatefulMcpConnection(connectionName)], slots); + + const session = createHarnessSession(); + const committed = connectionProvider.commit!(registry, session) as HarnessSession; + + expect(committed).not.toBe(session); + expect(session.state).toBeUndefined(); + }); + + it("returns the same session reference when no slot changed (no-op)", () => { + const connectionName = "notion"; + const stateKey = mcpSessionStateKey(connectionName, "anon"); + const sessionId = "unchanged-id"; + + // sessionId === initialId → no update + const slot: McpSessionSlot = { stateKey, initialId: sessionId, sessionId }; + const slots = new Map([[connectionName, slot]]); + const registry = new ConnectionRegistryImpl([makeStatefulMcpConnection(connectionName)], slots); + + const session = createHarnessSession({ [stateKey]: sessionId }); + const result = connectionProvider.commit!(registry, session); + + expect(result).toBe(session); + }); + + it("returns the same session reference when no stateful connections exist", () => { + const registry = new ConnectionRegistryImpl([makeStatelessMcpConnection("github")]); + const session = createHarnessSession(); + const result = connectionProvider.commit!(registry, session); + + expect(result).toBe(session); + }); +}); + +// --------------------------------------------------------------------------- +// create: seeding from session.state +// --------------------------------------------------------------------------- + +describe("connectionProvider.create", () => { + it("seeds stateful MCP slots from session.state so no update is emitted when unchanged", async () => { + const connectionName = "linear"; + const principalId = "user-7"; + const persistedId = "persisted-session-abc"; + const stateKey = mcpSessionStateKey(connectionName, principalId); + + const ctx = new ContextContainer(); + ctx.set(BundleKey, createBundle([makeStatefulMcpConnection(connectionName)])); + ctx.set(AuthKey, userAuth(principalId)); + + const session = createHarnessSession({ [stateKey]: persistedId }); + const result = await connectionProvider.create(ctx, session); + + expect(result).toBeDefined(); + const registry = result!.value as ConnectionRegistryImpl; + + // The slot was seeded with initialId === persistedId and sessionId === + // persistedId (unchanged), so collectMcpSessionUpdates must be empty. + const updates = registry.collectMcpSessionUpdates(); + expect(updates).toHaveLength(0); + }); + + it("uses 'anonymous' principal when no AuthKey is set", async () => { + const connectionName = "mcp-anon"; + const persistedId = "anon-session"; + const stateKey = mcpSessionStateKey(connectionName, undefined); + + const ctx = new ContextContainer(); + ctx.set(BundleKey, createBundle([makeStatefulMcpConnection(connectionName)])); + // intentionally no AuthKey set + + const session = createHarnessSession({ [stateKey]: persistedId }); + const result = await connectionProvider.create(ctx, session); + + expect(result).toBeDefined(); + const updates = result!.value.collectMcpSessionUpdates(); + // Seeded and unchanged → no updates. + expect(updates).toHaveLength(0); + }); + + it("returns undefined when there are no connections", () => { + const ctx = new ContextContainer(); + ctx.set(BundleKey, createBundle([])); + + const session = createHarnessSession(); + const result = connectionProvider.create(ctx, session); + + expect(result).toBeUndefined(); + }); +}); diff --git a/packages/eve/src/context/providers/connection.ts b/packages/eve/src/context/providers/connection.ts index 271f32627..61fc377e0 100644 --- a/packages/eve/src/context/providers/connection.ts +++ b/packages/eve/src/context/providers/connection.ts @@ -1,5 +1,11 @@ import { ContextKey } from "#context/key.js"; +import { AuthKey } from "#context/keys.js"; import { ConnectionRegistryImpl } from "#runtime/connections/registry.js"; +import { + mcpSessionStateKey, + type McpSessionSlot, + type McpSessionSlots, +} from "#runtime/connections/mcp-session-store.js"; import type { ConnectionRegistry } from "#runtime/connections/types.js"; import { BundleKey } from "#runtime/sessions/runtime-context-keys.js"; import { getActiveRuntimeNode } from "#context/node.js"; @@ -17,13 +23,37 @@ export const ConnectionRegistryKey = new ContextKey("eve.con export const connectionProvider: FrameworkContextProvider = { key: ConnectionRegistryKey, - create(ctx, _session) { + create(ctx, session) { const bundle = ctx.get(BundleKey); if (bundle === undefined) return undefined; const node = getActiveRuntimeNode(ctx); const connections = node.agent?.connections; if (!connections || connections.length === 0) return undefined; - return { value: new ConnectionRegistryImpl(connections) }; + const principalId = ctx.get(AuthKey)?.principalId; + + const slots: Map = new Map(); + for (const connection of connections) { + if (connection.protocol === "mcp" && connection.session === "stateful") { + const stateKey = mcpSessionStateKey(connection.connectionName, principalId); + const persisted = session.state?.[stateKey]; + const initialId = typeof persisted === "string" ? persisted : undefined; + slots.set(connection.connectionName, { stateKey, initialId, sessionId: initialId }); + } + } + + const sessionSlots: McpSessionSlots = slots; + return { value: new ConnectionRegistryImpl(connections, sessionSlots) }; + }, + + commit(registry, session) { + const updates = registry.collectMcpSessionUpdates(); + if (updates.length === 0) return session; + + const newState: Record = { ...session.state }; + for (const { stateKey, sessionId } of updates) { + newState[stateKey] = sessionId; + } + return { ...session, state: newState }; }, }; From 24d8eaa1afaef697648e91e38d037e15af6c9427 Mon Sep 17 00:00:00 2001 From: Chung Eun Kim Date: Thu, 25 Jun 2026 23:31:31 -0700 Subject: [PATCH 7/8] docs(eve): document stateful mcp connection sessions + changeset 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 Signed-off-by: Chung Eun Kim --- .changeset/mcp-stateful-sessions.md | 5 +++++ docs/connections/mcp.mdx | 18 +++++++++++++++ docs/connections/overview.mdx | 22 +++++++++---------- .../src/context/providers/connection.test.ts | 12 ++++++---- 4 files changed, 42 insertions(+), 15 deletions(-) create mode 100644 .changeset/mcp-stateful-sessions.md diff --git a/.changeset/mcp-stateful-sessions.md b/.changeset/mcp-stateful-sessions.md new file mode 100644 index 000000000..f649f7f11 --- /dev/null +++ b/.changeset/mcp-stateful-sessions.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Add `session: "stateful"` to `defineMcpClientConnection`. Stateful connections persist their MCP `Mcp-Session-Id` across step boundaries (scoped per principal) so a stateful MCP server treats the whole eve session as one session, re-initializing automatically if the server expires it. diff --git a/docs/connections/mcp.mdx b/docs/connections/mcp.mdx index 5b4624c32..ced8e1c5f 100644 --- a/docs/connections/mcp.mdx +++ b/docs/connections/mcp.mdx @@ -54,6 +54,24 @@ MCP connections support the shared connection options: See [Connections](/docs/connections) for the shared auth, headers, and approval shapes. +## Stateful sessions + +By default, each eve step opens a new MCP session (`session: "stateless"`). If the MCP server is session-aware — it returns an `Mcp-Session-Id` header on `initialize` and maintains per-session state — set `session: "stateful"` to persist that id across steps: + +```ts title="agent/connections/my-stateful-server.ts" +import { defineMcpClientConnection } from "eve/connections"; + +export default defineMcpClientConnection({ + url: "https://mcp.example.com/mcp", + description: "My stateful MCP server.", + session: "stateful", +}); +``` + +eve stores the `Mcp-Session-Id` scoped to the eve session and the authenticated principal (`"anonymous"` for unauthenticated callers), so two different users each get their own server-side session. If the server returns `404` for a stored id (expired or unknown), eve re-initializes the MCP session transparently and continues. + +Leave `session` unset (or `"stateless"`) for servers that do not maintain per-session state. Stateless is the safe default and imposes no storage overhead. + ## What to read next - [OpenAPI connections](./openapi): generate tools from OpenAPI operations. diff --git a/docs/connections/overview.mdx b/docs/connections/overview.mdx index a5d78ca3c..6c276c5d0 100644 --- a/docs/connections/overview.mdx +++ b/docs/connections/overview.mdx @@ -32,11 +32,11 @@ eve resolves and caches connection tokens per step; they never land in conversat A connection credential can belong to the agent or to the person using it. This choice is separate from route auth, but user-scoped connection auth depends on route auth: eve can only resolve a user token when the active session has `ctx.session.auth.current?.principalType === "user"`. -| Credential owner | Use when | Auth shape | -| ---------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| App | The agent should use one shared service, bot, installation, or app credential. | `auth: { getToken }` defaults to `principalType: "app"`, or use `connect({ connector: "linear/myagent", principalType: "app" })` with Vercel Connect. | -| User | Each end-user should authorize and use their own third-party account. | `connect("linear/myagent")`, `connect({ connector: "linear/myagent", principalType: "user" })`, or `auth: { principalType: "user", getToken }`. | -| User from a job | Background work should use the same user's OAuth grant that started the work. | Start or resume the session through a channel whose route auth resolved that user, or pass an explicit user auth context when dispatching through a channel. | +| Credential owner | Use when | Auth shape | +| ---------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| App | The agent should use one shared service, bot, installation, or app credential. | `auth: { getToken }` defaults to `principalType: "app"`, or use `connect({ connector: "linear/myagent", principalType: "app" })` with Vercel Connect. | +| User | Each end-user should authorize and use their own third-party account. | `connect("linear/myagent")`, `connect({ connector: "linear/myagent", principalType: "user" })`, or `auth: { principalType: "user", getToken }`. | +| User from a job | Background work should use the same user's OAuth grant that started the work. | Start or resume the session through a channel whose route auth resolved that user, or pass an explicit user auth context when dispatching through a channel. | `principalType: "user"` does not mean "ask any human later." It means "key this credential to the authenticated user already attached to the eve session." If the run was started by a schedule, a same-project runtime token, `localDev()`, or another internal runtime path without an end-user principal, a user-scoped connection fails with `reason: "principal_required"` instead of starting OAuth. In that case, either authenticate the inbound channel as a user or configure the connection as app-scoped. @@ -157,12 +157,12 @@ App-scoped Connect auth is non-interactive. eve asks Vercel Connect for an app t ### Troubleshooting Vercel Connect auth -| Symptom | What it means | Fix | -| -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `reason: "principal_required"` | A user-scoped connection ran without an authenticated user on the active session. | Return `principalType: "user"` from the channel's route auth, or change the connection to `principalType: "app"` if it should be shared. | -| `authorization.required` appears but no UI | eve parked the turn for OAuth, but the channel or frontend is not rendering the challenge. | Render the challenge from the stream event and continue the same session after the callback. | -| OAuth works locally but fails after deploy | The project may not be linked to the Connect client, or the deployed runtime may not have the expected Vercel OIDC/project scope. | Run Connect setup from the consuming project directory, link the project, deploy again, and verify the connector UID in `connect("...")`. | -| A scheduled or internal run needs user OAuth | Schedules and runtime callers do not automatically carry an end-user principal. | Dispatch through a user-authenticated channel when work is user-owned, or use app-scoped auth for agent-owned background work. | +| Symptom | What it means | Fix | +| -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `reason: "principal_required"` | A user-scoped connection ran without an authenticated user on the active session. | Return `principalType: "user"` from the channel's route auth, or change the connection to `principalType: "app"` if it should be shared. | +| `authorization.required` appears but no UI | eve parked the turn for OAuth, but the channel or frontend is not rendering the challenge. | Render the challenge from the stream event and continue the same session after the callback. | +| OAuth works locally but fails after deploy | The project may not be linked to the Connect client, or the deployed runtime may not have the expected Vercel OIDC/project scope. | Run Connect setup from the consuming project directory, link the project, deploy again, and verify the connector UID in `connect("...")`. | +| A scheduled or internal run needs user OAuth | Schedules and runtime callers do not automatically carry an end-user principal. | Dispatch through a user-authenticated channel when work is user-owned, or use app-scoped auth for agent-owned background work. | ## Self-hosted interactive OAuth diff --git a/packages/eve/src/context/providers/connection.test.ts b/packages/eve/src/context/providers/connection.test.ts index 2036d9d66..37a02a9f5 100644 --- a/packages/eve/src/context/providers/connection.test.ts +++ b/packages/eve/src/context/providers/connection.test.ts @@ -36,21 +36,25 @@ function makeStatefulMcpConnection(name: string): ResolvedConnectionDefinition { return { connectionName: name, description: "test connection", + logicalPath: `connections/${name}.ts`, protocol: "mcp", session: "stateful", + sourceId: `connections/${name}`, + sourceKind: "module", url: `https://example.com/${name}`, - modulePath: `agent/connections/${name}.ts`, - } as unknown as ResolvedConnectionDefinition; + }; } function makeStatelessMcpConnection(name: string): ResolvedConnectionDefinition { return { connectionName: name, description: "test connection", + logicalPath: `connections/${name}.ts`, protocol: "mcp", + sourceId: `connections/${name}`, + sourceKind: "module", url: `https://example.com/${name}`, - modulePath: `agent/connections/${name}.ts`, - } as unknown as ResolvedConnectionDefinition; + }; } function createBundle(connections: readonly ResolvedConnectionDefinition[]): CompiledBundle { From 23b6d0b7883c20f4424c275cc893431decd2ae07 Mon Sep 17 00:00:00 2001 From: Chung Eun Kim Date: Thu, 25 Jun 2026 23:39:46 -0700 Subject: [PATCH 8/8] fix(eve): skip SSE fallback when re-initializing an expired mcp session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Signed-off-by: Chung Eun Kim --- .../eve/src/context/providers/connection.ts | 9 ++--- .../runtime/connections/mcp-client.test.ts | 34 +++++++++++++++++++ .../eve/src/runtime/connections/mcp-client.ts | 6 ++++ 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/packages/eve/src/context/providers/connection.ts b/packages/eve/src/context/providers/connection.ts index 61fc377e0..7bac0dd43 100644 --- a/packages/eve/src/context/providers/connection.ts +++ b/packages/eve/src/context/providers/connection.ts @@ -1,11 +1,7 @@ import { ContextKey } from "#context/key.js"; import { AuthKey } from "#context/keys.js"; import { ConnectionRegistryImpl } from "#runtime/connections/registry.js"; -import { - mcpSessionStateKey, - type McpSessionSlot, - type McpSessionSlots, -} from "#runtime/connections/mcp-session-store.js"; +import { mcpSessionStateKey, type McpSessionSlot } from "#runtime/connections/mcp-session-store.js"; import type { ConnectionRegistry } from "#runtime/connections/types.js"; import { BundleKey } from "#runtime/sessions/runtime-context-keys.js"; import { getActiveRuntimeNode } from "#context/node.js"; @@ -42,8 +38,7 @@ export const connectionProvider: FrameworkContextProvider = } } - const sessionSlots: McpSessionSlots = slots; - return { value: new ConnectionRegistryImpl(connections, sessionSlots) }; + return { value: new ConnectionRegistryImpl(connections, slots) }; }, commit(registry, session) { diff --git a/packages/eve/src/runtime/connections/mcp-client.test.ts b/packages/eve/src/runtime/connections/mcp-client.test.ts index 720c00ef8..800275cb9 100644 --- a/packages/eve/src/runtime/connections/mcp-client.test.ts +++ b/packages/eve/src/runtime/connections/mcp-client.test.ts @@ -260,6 +260,40 @@ describe("McpConnectionClient stateful session", () => { expect(result).toBe("ok"); expect(slot.sessionId).toBeUndefined(); expect(sdkTool.execute).toHaveBeenCalledTimes(2); + expect(client.close).toHaveBeenCalledTimes(1); + }); + + it("does not fall back to SSE when the initialize for a replayed session id gets a 404", async () => { + // The FIRST HTTP `initialize` rejects with 404 inside #createClient — the + // session id on disk expired. The guard must rethrow past the SSE fallback + // so #withSessionRetry clears the slot, closes, and retries; the SECOND + // createMCPClient resolves a working client over HTTP. Without the guard + // the first 404 would route to the SSE fallback (transport.type === "sse"). + createMCPClient.mockRejectedValueOnce({ status: 404 }); + const client = { + close: vi.fn(), + listTools: vi.fn().mockResolvedValue({ tools: [{ name: "t", inputSchema: {} }] }), + toolsFromDefinitions: vi + .fn() + .mockReturnValue({ t: { execute: vi.fn().mockResolvedValue("ok") } }), + }; + createMCPClient.mockResolvedValue(client); + + const slot = { stateKey: "k", sessionId: "stale" } as { + stateKey: string; + sessionId?: string; + }; + const result = await new McpConnectionClient( + makeConnection({ session: "stateful" }), + slot, + ).executeTool("t", {}); + + expect(result).toBe("ok"); + expect(createMCPClient.mock.calls.length).toBeGreaterThanOrEqual(2); + // No createMCPClient call may have attempted the SSE transport. + for (const call of createMCPClient.mock.calls) { + expect(call[0].transport.type).toBe("http"); + } }); it("does not retry a 404 for a stateless connection", async () => { diff --git a/packages/eve/src/runtime/connections/mcp-client.ts b/packages/eve/src/runtime/connections/mcp-client.ts index 6dfd3e98e..967b0c910 100644 --- a/packages/eve/src/runtime/connections/mcp-client.ts +++ b/packages/eve/src/runtime/connections/mcp-client.ts @@ -80,6 +80,12 @@ export class McpConnectionClient implements ConnectionClient { transport: { type: "http", url, headers, fetch }, }); } catch (error) { + // A 404 while replaying a persisted session id means the session expired + // on the server — handled by #withSessionRetry re-initializing a fresh + // session. Skip the SSE fallback: this is not a transport incompatibility. + if (this.#sessionSlot?.sessionId !== undefined && readHttpStatus(error) === 404) { + throw error; + } if (!isMcpHttpFallbackRetryableError(error)) { throw error; }