Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 opt-in `session: { mode: "stateful" }` support for MCP connections. Stateful MCP connections persist Streamable HTTP session metadata across eve step boundaries and reattach through the native AI SDK MCP session hooks, retrying with a fresh session when the server expires the saved one.
66 changes: 66 additions & 0 deletions docs/connections/mcp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,74 @@ MCP connections support the shared connection options:

See [Connections](/docs/connections) for the shared auth, headers, and approval shapes.

## Stateful sessions

By default, every eve step opens a new MCP client session. Keep that default for MCP servers whose tools are independent from call to call.

For Streamable HTTP MCP servers that keep server-side session state, opt in with `session: { mode: "stateful" }`:

```ts title="agent/connections/stateful-server.ts"
import { defineMcpClientConnection } from "eve/connections";

export default defineMcpClientConnection({
url: "https://mcp.example.com/mcp",
description: "Stateful MCP server.",
session: { mode: "stateful" },
});
```

When the server negotiates an MCP session, eve stores the protocol session id and initialize result in framework-owned session state. On the next step, eve reattaches through the MCP Streamable HTTP session hooks instead of sending another `initialize` request.

The stored session is scoped to the current eve session, connection name, and authenticated principal. If the MCP server returns `404` for a stored session, eve clears it and retries once with a fresh MCP session.

### Pair stateful MCP with `defineState`

`session: { mode: "stateful" }` is transport continuity. It preserves the MCP protocol session metadata that the server negotiated, and eve owns that metadata internally. Do not store raw MCP session ids in authored state.

Use [`defineState`](../guides/state) for semantic application state that your agent understands, such as the workspace, project, or account the user selected. That state survives even if the upstream MCP server expires its protocol session and eve has to initialize a fresh one.

For example, a stateful MCP server might remember the active workspace after a setup tool runs:

```ts title="agent/connections/workspace-server.ts"
import { defineMcpClientConnection } from "eve/connections";

export default defineMcpClientConnection({
url: "https://mcp.example.com/mcp",
description: "Workspace MCP server.",
session: { mode: "stateful" },
});
```

Then keep the user's chosen workspace in authored state, because that is a business fact rather than MCP transport metadata:

```ts title="agent/lib/workspace-state.ts"
import { defineState } from "eve/context";

export const activeWorkspace = defineState("my-agent.active-workspace", () => ({
workspaceId: null as string | null,
}));
```

```ts title="agent/tools/remember-workspace.ts"
import { defineTool } from "eve/tools";
import { z } from "zod";
import { activeWorkspace } from "../lib/workspace-state.js";

export default defineTool({
description: "Remember the workspace the user selected for later MCP calls.",
inputSchema: z.object({ workspaceId: z.string() }),
execute({ workspaceId }) {
activeWorkspace.update(() => ({ workspaceId }));
return { workspaceId };
},
});
```

With that split, normal follow-up MCP calls can reuse the server-side MCP session. If the server later returns `404` and eve reinitializes the MCP session, the agent still has the selected `workspaceId` in `defineState()` and can repeat the server's setup call or include the workspace id in future tool arguments.

## What to read next

- [OpenAPI connections](./openapi): generate tools from OpenAPI operations.
- [State](../guides/state): durable per-session memory with `defineState`.
- [Auth & route protection](../guides/auth-and-route-protection): the full interactive-OAuth flow with Vercel Connect.
- [Security model](../concepts/security-model): how connection credentials stay out of the model's reach.
2 changes: 2 additions & 0 deletions docs/guides/state.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ Every [subagent](../subagents) starts with its own fresh state, whether it's a b

`defineState` holds conversation-scoped working memory that lives and dies with the session, including counters, the current plan, and what the user has told you this conversation. It is the agent's short-term memory, persisted durably for the life of the session. Anything that has to outlive the session, be shared across sessions or users, or be queried independently of a turn belongs in an external store, either a [connection](../connections) or your own database.

Connection protocol metadata is separate from authored state. For example, a stateful [MCP connection](../connections/mcp#stateful-sessions) stores its negotiated MCP session id in framework-owned session state so eve can reattach across step boundaries. Use `defineState` for the durable semantic fact behind that workflow, such as the selected workspace id, not for raw protocol session ids.

## What to read next

- Read state inside dynamic resolvers → [Dynamic capabilities](./dynamic-capabilities)
Expand Down
10 changes: 10 additions & 0 deletions packages/eve/src/compiler/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,16 @@ const compiledConnectionDefinitionSchema = z
* runtime.
*/
protocol: z.enum(["mcp", "openapi"]).default("mcp"),
/**
* MCP session policy. Omitted for older manifests and for connections
* using the default stateless behavior.
*/
session: z
.object({
mode: z.enum(["stateful", "stateless"]),
})
.strict()
.optional(),
sourceId: z.string(),
sourceKind: z.literal("module"),
/**
Expand Down
1 change: 1 addition & 0 deletions packages/eve/src/compiler/normalize-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export async function compileConnectionDefinition(
...shared,
description: normalized.description,
protocol: "mcp",
session: normalized.session,
url: normalized.url,
};
auth = normalized.auth;
Expand Down
181 changes: 181 additions & 0 deletions packages/eve/src/context/providers/connection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { describe, expect, it } from "vitest";

import { ContextContainer } from "#context/container.js";
import { AuthKey, type SessionAuthContext } from "#context/keys.js";
import { connectionProvider } from "#context/providers/connection.js";
import type { HarnessSession } from "#harness/types.js";
import { createBundledRuntimeCompiledArtifactsSource } from "#runtime/compiled-artifacts-source.js";
import {
mcpSessionStateKey,
type DurableMcpSessionState,
type McpSessionSlot,
} from "#runtime/connections/mcp-session-store.js";
import { ConnectionRegistryImpl } from "#runtime/connections/registry.js";
import { BundleKey, type CompiledBundle } from "#runtime/sessions/runtime-context-keys.js";
import type { ResolvedConnectionDefinition } from "#runtime/types.js";

const initializeResult = {
capabilities: {},
protocolVersion: "2025-11-25",
serverInfo: { name: "test-server", version: "1.0.0" },
} as const;

function durableState(sessionId: string): DurableMcpSessionState {
return { initializeResult, sessionId };
}

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 makeMcpConnection(
name: string,
overrides: Partial<ResolvedConnectionDefinition> = {},
): ResolvedConnectionDefinition {
return {
connectionName: name,
description: "test connection",
logicalPath: `connections/${name}.ts`,
protocol: "mcp",
sourceId: `connections/${name}`,
sourceKind: "module",
url: `https://example.com/${name}`,
...overrides,
};
}

function createBundle(connections: readonly ResolvedConnectionDefinition[]): CompiledBundle {
return {
compiledArtifactsSource: createBundledRuntimeCompiledArtifactsSource(),
graph: {
root: {
agent: {
connections,
},
nodeId: "__root__",
},
},
} as CompiledBundle;
}

function userAuth(principalId: string, issuer = "test-issuer"): SessionAuthContext {
return {
attributes: {},
authenticator: "test",
issuer,
principalId,
principalType: "user",
};
}

describe("connectionProvider.create", () => {
it("seeds stateful MCP slots from session.state", async () => {
const connectionName = "linear";
const principalKey = "test-issuer:user-7";
const stateKey = mcpSessionStateKey(connectionName, principalKey);

const ctx = new ContextContainer();
ctx.set(
BundleKey,
createBundle([makeMcpConnection(connectionName, { session: { mode: "stateful" } })]),
);
ctx.set(AuthKey, userAuth("user-7"));

const session = createHarnessSession({ [stateKey]: durableState("persisted-session") });
const result = await connectionProvider.create(ctx, session);

expect(result).toBeDefined();
expect(result!.value.collectMcpSessionUpdates()).toEqual([]);
});

it('uses the "anonymous" state key when no AuthKey is set', async () => {
const connectionName = "anonymous-mcp";
const stateKey = mcpSessionStateKey(connectionName, undefined);

const ctx = new ContextContainer();
ctx.set(
BundleKey,
createBundle([makeMcpConnection(connectionName, { session: { mode: "stateful" } })]),
);

const session = createHarnessSession({ [stateKey]: durableState("anon-session") });
const result = await connectionProvider.create(ctx, session);

expect(result).toBeDefined();
expect(result!.value.collectMcpSessionUpdates()).toEqual([]);
});

it("returns undefined when there are no connections", () => {
const ctx = new ContextContainer();
ctx.set(BundleKey, createBundle([]));

expect(connectionProvider.create(ctx, createHarnessSession())).toBeUndefined();
});
});

describe("connectionProvider.commit", () => {
it("writes updated MCP session metadata into session.state", () => {
const connectionName = "linear";
const stateKey = mcpSessionStateKey(connectionName, "test-issuer:user-42");
const slot: McpSessionSlot = {
current: durableState("new-session"),
initial: durableState("old-session"),
stateKey,
};
const registry = new ConnectionRegistryImpl(
[makeMcpConnection(connectionName, { session: { mode: "stateful" } })],
new Map([[connectionName, slot]]),
);

const session = createHarnessSession({ existingKey: "should-survive" });
const committed = connectionProvider.commit!(registry, session) as HarnessSession;

expect(committed.state?.[stateKey]).toEqual(durableState("new-session"));
expect(committed.state?.existingKey).toBe("should-survive");
});

it("deletes expired MCP session metadata from session.state", () => {
const connectionName = "linear";
const stateKey = mcpSessionStateKey(connectionName, "test-issuer:user-42");
const slot: McpSessionSlot = {
initial: durableState("expired-session"),
stateKey,
};
const registry = new ConnectionRegistryImpl(
[makeMcpConnection(connectionName, { session: { mode: "stateful" } })],
new Map([[connectionName, slot]]),
);

const session = createHarnessSession({ [stateKey]: durableState("expired-session") });
const committed = connectionProvider.commit!(registry, session) as HarnessSession;

expect(committed.state?.[stateKey]).toBeUndefined();
});

it("returns the same session reference when no slot changed", () => {
const connectionName = "notion";
const stateKey = mcpSessionStateKey(connectionName, "anonymous");
const unchanged = durableState("same-session");
const registry = new ConnectionRegistryImpl(
[makeMcpConnection(connectionName, { session: { mode: "stateful" } })],
new Map([[connectionName, { current: unchanged, initial: unchanged, stateKey }]]),
);
const session = createHarnessSession({ [stateKey]: unchanged });

expect(connectionProvider.commit!(registry, session)).toBe(session);
});
});
43 changes: 41 additions & 2 deletions packages/eve/src/context/providers/connection.ts
Original file line number Diff line number Diff line change
@@ -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,
readMcpSessionState,
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 +23,46 @@ 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 auth = ctx.get(AuthKey);
const principalKey =
auth !== undefined && auth !== null ? `${auth.issuer}:${auth.principalId}` : undefined;

const slots = new Map<string, McpSessionSlot>();
for (const connection of connections) {
if (connection.protocol !== "mcp" || connection.session?.mode !== "stateful") {
continue;
}
const stateKey = mcpSessionStateKey(connection.connectionName, principalKey);
const persisted = readMcpSessionState(session.state?.[stateKey]);
slots.set(connection.connectionName, {
current: persisted,
initial: persisted,
stateKey,
});
}

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

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

const state: Record<string, unknown> = { ...session.state };
for (const update of updates) {
if (update.state === undefined) {
delete state[update.stateKey];
} else {
state[update.stateKey] = update.state;
}
}
return { ...session, state };
},
};
23 changes: 23 additions & 0 deletions packages/eve/src/internal/authored-definition/connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,15 @@ describe("normalizeMcpClientConnectionDefinition", () => {
expect(result.url).toBe("http://localhost:3000/mcp");
});

it("accepts stateful MCP session mode", () => {
const result = normalizeMcpClientConnectionDefinition(
validInput({ session: { mode: "stateful" } }),
MSG,
);

expect(result.session).toEqual({ mode: "stateful" });
});

it("preserves the optional vercelConnect marker on auth", () => {
// The `connect()` helper from `@vercel/connect/eve` attaches a
// `vercelConnect: { connector }` marker so downstream tooling can
Expand Down Expand Up @@ -179,6 +188,20 @@ describe("normalizeMcpClientConnectionDefinition", () => {
});
});

describe("session validation", () => {
it("rejects unknown session modes", () => {
expect(() =>
normalizeMcpClientConnectionDefinition(validInput({ session: { mode: "sticky" } }), MSG),
).toThrow(/session\.mode.*must be "stateful" or "stateless"/);
});

it("rejects the legacy string session shape", () => {
expect(() =>
normalizeMcpClientConnectionDefinition(validInput({ session: "stateful" }), MSG),
).toThrow(/The "session" field.*must be an object/);
});
});

describe("description validation", () => {
it("rejects a non-string description", () => {
expect(() =>
Expand Down
Loading
Loading