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/tidy-mice-invoke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

Add a pluggable-auth MCP channel that lets clients start, inspect, continue, update, and cancel durable agent invocations over stateless Streamable HTTP.
97 changes: 97 additions & 0 deletions docs/channels/mcp.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
---
title: MCP
description: Publish an eve agent as a durable MCP invocation service.
---

The MCP channel lets external harnesses such as Claude Code delegate durable work to an eve agent. It exposes only agent invocation—not the agent's authored tools, skills, instructions, connections, or subagents.

## Configure the channel

Create `agent/channels/mcp.ts`:

```ts
import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
import { bearerAuth, mcpChannel } from "eve/channels/mcp";

async function verifyToken(request: Request, bearerToken?: string): Promise<AuthInfo | undefined> {
// Verify with your identity provider, gateway, or resource server.
if (!bearerToken) return undefined;
return myTokenVerifier(request, bearerToken);
}

export default mcpChannel({
auth: bearerAuth(verifyToken, {
requiredScopes: ["agent:invoke"],
protectedResource: {
authorizationServers: ["https://vercel.com"],
},
}),
});
```

The MCP server name and `agent_start` description come from the compiled root
agent definition. `mcpChannel` options are limited to transport concerns such
as a custom `path`. Define a default structured output schema on the agent, or
pass `outputSchema` to an individual `agent_start` invocation; it is not channel
identity.

`bearerAuth` returns the public `McpAuth` strategy consumed by the channel. The
verifier uses the standard MCP SDK `AuthInfo` contract and is portable with
`mcp-handler`'s `withMcpAuth`. External integrations can return the strategy
directly, for example `auth: vercelMcpAuth()` or
`auth: betterAuthMcpAuth(auth)`. eve extracts bearer credentials, enforces
token expiration and required scopes, returns MCP-compatible `401`/`403`
challenges, and projects the verified identity into `ctx.session.auth`. It
does not introspect Vercel tokens, run Better Auth, register OAuth clients, or
issue tokens.

Use `toSessionAuth` when provider claims need a custom principal mapping. The
default mapping never persists the bearer token. A gateway can instead verify
the user and forward a signed identity token to a private eve origin; the
`verifyToken` implementation verifies that signature and returns `AuthInfo`.
Set `protectedResource.resource` to the gateway's canonical public MCP URL so
metadata and challenges point clients at the public resource.

When `protectedResource.resource` is omitted, eve derives it from the request
origin and channel path. Unauthenticated MCP requests receive an RFC 9728
protected-resource challenge. Authentication cannot be omitted: use
`publicMcpAuth()` only when intentionally publishing an unauthenticated MCP
endpoint.

## Connect Claude Code

```sh
claude mcp add --transport http eve-demo https://<deployment>/mcp
claude mcp login eve-demo
claude mcp get eve-demo
```

Ask Claude to call `agent_start` once, retain its `invocationId`, and call `agent_get` until the invocation is terminal. Each `agent_start` call creates a new invocation, so an ambiguously failed start should not be retried automatically.

The compatibility tools are:

- `agent_start({ message, outputSchema? })`
- `agent_get({ invocationId })`
- `agent_send({ invocationId, message })`
- `agent_update({ invocationId, responses })`
- `agent_cancel({ invocationId })`

`agent_start` defaults to task mode. Pass `mode: "conversation"` to create a
multi-turn session; wait for `agent_get` to report `waiting`, then use
`agent_send` for the next user message.

Every operation reauthenticates against the channel's configured policy. The unguessable invocation ID is a capability handle: any caller authorized to use the endpoint who has the ID may inspect, update, or cancel that invocation. `agent_get` reconstructs invocation state from the existing durable session event stream and does not start work or run a model. While an invocation is working, clients should honor its `pollAfterMs` hint before reading it again.

Route authorization is distinct from the agent's runtime capability ceiling.
This slice does not add per-token tool, connection, cost, or delegated-resource
limits; configure the agent's existing capabilities and approval policies
independently. Fine-grained authorization can only narrow that ceiling in a
follow-up.

## Manual acceptance

1. Ask Claude to start deterministic work and retain the returned invocation ID.
2. Disconnect and reconnect the MCP server, then retrieve the invocation.
3. Authenticate as another authorized principal and confirm it can retrieve the invocation when given its ID.
4. Exercise a task that calls `ask_question`, answer it through `agent_update`, and retrieve the result.
5. Start another task, call `agent_cancel`, and read until cancellation is acknowledged.
1 change: 1 addition & 0 deletions docs/channels/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"pages": [
"overview",
"eve",
"mcp",
"slack",
"discord",
"teams",
Expand Down
7 changes: 7 additions & 0 deletions packages/eve/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,11 @@
"import": "./dist/src/public/channels/auth.js",
"default": "./dist/src/public/channels/auth.js"
},
"./channels/mcp": {
"types": "./dist/src/public/channels/mcp.d.ts",
"import": "./dist/src/public/channels/mcp.js",
"default": "./dist/src/public/channels/mcp.js"
},
"./channels/slack": {
"types": "./dist/src/public/channels/slack/index.d.ts",
"import": "./dist/src/public/channels/slack/index.js",
Expand Down Expand Up @@ -296,6 +301,7 @@
"generate:web-template": "node src/setup/build.ts --write",
"check:web-template": "node src/setup/build.ts --check",
"test:tui": "pnpm run build:js && tsc -p tsconfig.tui.json --noEmit && node test/tui-client/run-all.mjs",
"mcp:inspector-smoke": "node scripts/mcp-inspector-smoke.mjs",
"test:vercel": "vitest run --config vitest.vercel.config.ts"
},
"dependencies": {
Expand All @@ -313,6 +319,7 @@
"@chat-adapter/state-memory": "4.34.0",
"@chat-adapter/twilio": "4.34.0",
"@clack/core": "1.3.1",
"@modelcontextprotocol/sdk": "1.29.0",
"@nuxt/kit": "^4.0.0",
"@standard-schema/spec": "1.1.0",
"@sveltejs/kit": "^2.0.0",
Expand Down
19 changes: 19 additions & 0 deletions packages/eve/scripts/mcp-inspector-smoke.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#!/usr/bin/env node

const endpoint = process.argv[2];
if (!endpoint) {
console.error("Usage: pnpm --filter eve mcp:inspector-smoke <https://agent.example/mcp>");
process.exit(1);
}

console.log(`Opening the official MCP Inspector for ${endpoint}`);
console.log("In Inspector, select Streamable HTTP, enter the URL, authenticate, then run:");
console.log(" 1. initialize (protocol 2025-06-18)");
console.log(" 2. tools/list");
console.log(" 3. tools/call");

const child = await import("node:child_process");
const result = child.spawnSync("pnpm", ["dlx", "@modelcontextprotocol/inspector", endpoint], {
stdio: "inherit",
});
process.exit(result.status ?? 1);
77 changes: 77 additions & 0 deletions packages/eve/scripts/vendor-compiled/@modelcontextprotocol/sdk.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
export default {
packageName: "@modelcontextprotocol/sdk",
compiledPath: "@modelcontextprotocol/sdk",
chunkGroup: "workflow",
entries: [
{
entry: "dist/esm/server/index.js",
outputPath: "server",
declaration: `
export interface McpSdkRequest {
readonly params: Readonly<Record<string, unknown>>;
}

export interface McpRequestHandlerExtra {
readonly signal: AbortSignal;
}

export declare class Server {
constructor(info: { readonly name: string; readonly version: string }, options?: {
readonly capabilities?: Readonly<Record<string, unknown>>;
});
connect(transport: WebStandardTransport): Promise<void>;
close(): Promise<void>;
setRequestHandler<Request extends McpSdkRequest, Result>(
schema: McpRequestSchema<Request>,
handler: (request: Request, extra: McpRequestHandlerExtra) => Result | Promise<Result>,
): void;
}

interface McpRequestSchema<Request extends McpSdkRequest> {
readonly __request?: Request;
}

interface WebStandardTransport {
handleRequest(request: Request): Promise<Response>;
}
`,
},
{
entry: "dist/esm/server/webStandardStreamableHttp.js",
outputPath: "web-standard-streamable-http",
declaration: `
export declare class WebStandardStreamableHTTPServerTransport {
constructor(options?: {
readonly enableJsonResponse?: boolean;
readonly sessionIdGenerator?: undefined;
});
handleRequest(request: Request): Promise<Response>;
}
`,
},
{
entry: "dist/esm/types.js",
outputPath: "types",
declaration: `
export interface CallToolRequest {
readonly params: {
readonly arguments?: Readonly<Record<string, unknown>>;
readonly name: string;
};
}

export interface ListToolsRequest {
readonly params: Readonly<Record<string, unknown>>;
}

interface McpRequestSchema<Request> {
readonly __request?: Request;
}

export declare const CallToolRequestSchema: McpRequestSchema<CallToolRequest>;
export declare const ListToolsRequestSchema: McpRequestSchema<ListToolsRequest>;
`,
},
],
platform: "neutral",
};
2 changes: 2 additions & 0 deletions packages/eve/scripts/vendor-compiled/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import chatAdapterSlack from "./@chat-adapter/slack.mjs";
import chatAdapterStateMemory from "./@chat-adapter/state-memory.mjs";
import chatAdapterTwilio from "./@chat-adapter/twilio.mjs";

import modelContextProtocolSdk from "./@modelcontextprotocol/sdk.mjs";
import opentelemetryApi from "./@opentelemetry/api.mjs";
import standardSchemaSpec from "./@standard-schema/spec.mjs";
import vercelDetectAgent from "./@vercel/detect-agent.mjs";
Expand Down Expand Up @@ -62,6 +63,7 @@ export const MODULES = [
jsonSchema,
marked,
mcp,
modelContextProtocolSdk,
openai,
opentelemetryApi,
otel,
Expand Down
5 changes: 5 additions & 0 deletions packages/eve/src/channel/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,11 @@ export interface RunInput {
* parent depth + 1.
*/
readonly subagentDepth?: number;
/** Framework-owned metadata for a protocol-neutral external invocation. */
readonly externalInvocation?: {
readonly continuationToken: string;
readonly mode: RunMode;
};
}

export interface DeliverInput {
Expand Down
2 changes: 0 additions & 2 deletions packages/eve/src/execution/workflow-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ export interface WorkflowEntryInput {
export interface WorkflowEntryResult {
readonly output: unknown;
}

/**
* Long-lived workflow entrypoint. Handles both root sessions and
* delegated child sessions: root sessions expose only parent
Expand All @@ -70,7 +69,6 @@ export interface WorkflowEntryResult {
*/
export async function workflowEntry(input: WorkflowEntryInput): Promise<WorkflowEntryResult> {
"use workflow";

const { workflowRunId: sessionId } = getWorkflowMetadata();
const continuationToken = (input.serializedContext["eve.continuationToken"] as string) || "";
const mode = input.serializedContext["eve.mode"] as RunMode;
Expand Down
9 changes: 8 additions & 1 deletion packages/eve/src/execution/workflow-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
sessionCancelHookToken,
type TurnCancelPayload,
} from "#execution/turn-cancellation-token.js";
import { buildInvocationAttributes } from "#internal/invocation/metadata.js";

const WORKFLOW_ENTRY_NAME = "workflowEntry";
const TURN_WORKFLOW_NAME = "turnWorkflow";
Expand Down Expand Up @@ -115,7 +116,7 @@ export function createWorkflowRuntime(config: {
const ctx = buildRunContext({ bundle, run: input });
const serializedContext = serializeContext(ctx);
const parentLineage = readParentLineage(serializedContext);
const attributes =
const sessionAttributes =
parentLineage.sessionId === undefined
? buildSessionAttributes({
inputMessage: input.title ?? input.input.message,
Expand All @@ -129,6 +130,12 @@ export function createWorkflowRuntime(config: {
rootSessionId: parentLineage.rootSessionId ?? parentLineage.sessionId,
serializedContext,
});
const attributes = {
...sessionAttributes,
...(input.externalInvocation === undefined
? {}
: buildInvocationAttributes(input.externalInvocation)),
};

let run: Awaited<ReturnType<typeof startWorkflowPreferLatest>>;
try {
Expand Down
Loading