Skip to content
5 changes: 5 additions & 0 deletions .changeset/mcp-stateful-sessions.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 18 additions & 0 deletions docs/connections/mcp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 11 additions & 11 deletions docs/connections/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand Down
200 changes: 200 additions & 0 deletions packages/eve/src/context/providers/connection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
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<string, unknown>): 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",
logicalPath: `connections/${name}.ts`,
protocol: "mcp",
session: "stateful",
sourceId: `connections/${name}`,
sourceKind: "module",
url: `https://example.com/${name}`,
};
}

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}`,
};
}

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();
});
});
29 changes: 27 additions & 2 deletions packages/eve/src/context/providers/connection.ts
Original file line number Diff line number Diff line change
@@ -1,5 +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 } 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";
Expand All @@ -17,13 +19,36 @@ export const ConnectionRegistryKey = new ContextKey<ConnectionRegistry>("eve.con
export const connectionProvider: FrameworkContextProvider<ConnectionRegistry> = {
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<string, McpSessionSlot> = 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 });
}
}

return { value: new ConnectionRegistryImpl(connections, slots) };
},

commit(registry, session) {
const updates = registry.collectMcpSessionUpdates();
if (updates.length === 0) return session;

const newState: Record<string, unknown> = { ...session.state };
for (const { stateKey, sessionId } of updates) {
newState[stateKey] = sessionId;
}
return { ...session, state: newState };
},
};
13 changes: 13 additions & 0 deletions packages/eve/src/public/definitions/connections/mcp.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
ConnectionAuthDefinition,
HeadersDefinition,
McpSessionMode,
ToolFilterDefinition,
} from "#runtime/connections/types.js";
import { normalizeAuthorizationSpec } from "#runtime/connections/validate-authorization.js";
Expand Down Expand Up @@ -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;
}

/**
Expand Down
Loading