Skip to content
Open
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 secure MCP channel that reuses eve route auth and lets clients start, inspect, update, authorize, and cancel principal-bound durable agent invocations over MCP 2026-07-28 with a stateless 2025 compatibility path.
164 changes: 164 additions & 0 deletions docs/channels/mcp.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
---
title: MCP
description: Publish an eve agent as a durable MCP invocation service.
---

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

## Configure the channel

For a remotely accessible MCP endpoint, start with OAuth resource
authentication. Create `agent/channels/mcp.ts` and decorate your access-token
verifier with `oauthResource()`:

```ts
import { oauthResource } from "eve/channels/auth";
import { mcpChannel } from "eve/channels/mcp";
import { verifyAccessToken } from "../lib/auth";

export default mcpChannel({
auth: oauthResource(verifyAccessToken, {
issuer: process.env.AUTH_ISSUER!,
scopes: ["agent:invoke"],
}),
});
```

`verifyAccessToken` is an ordinary `AuthFn<Request>`. It verifies the access
token and returns a `SessionAuthContext`; it may use an eve strategy such as
`jwtEcdsa()` or your identity provider's verifier. `oauthResource()` does not
issue tokens or run an authorization server. It adds portable resource-server
metadata to the policy so `mcpChannel()` can mount
`/.well-known/oauth-protected-resource` and include its URL in authentication
challenges.

The `scopes` option advertises the scopes clients should request. Your
underlying verifier remains responsible for signature or introspection,
expiry, audience/resource, and scope enforcement.

OAuth client registration remains between the MCP client and the authorization
server. For MCP `2026-07-28`, prefer an authorization server that supports
[Client ID Metadata Documents](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#client-id-metadata-documents)
for URL-based client IDs, or pre-register the client ID. Dynamic Client
Registration is a deprecated compatibility fallback. eve publishes the
authorization-server location in protected-resource metadata; it does not
implement client registration, CIMD hosting, or an authorization server.

By default the resource is the request origin plus `/mcp`. Set `resource` when
a public gateway fronts a private eve origin, or `metadataPath` when the
well-known route must be mounted elsewhere. Issuer and resource identifiers
must use HTTPS without credentials, query, or fragment; loopback HTTP is
accepted for local development.

### Other auth modes

`mcpChannel()` also accepts the same inbound auth strategies as any other eve
channel. For preconfigured Vercel workload identity with a local-development
fallback:

```ts
import { localDev, vercelOidc } from "eve/channels/auth";
import { mcpChannel } from "eve/channels/mcp";

export default mcpChannel({
auth: [vercelOidc(), localDev()],
});
```

This form does not advertise an interactive OAuth login. A deployed caller
must send a Vercel OIDC bearer token; a request addressed to a loopback host
falls through to `localDev()` and needs no credential. Static Basic, bearer,
JWT, generic OIDC, and custom `AuthFn` policies work the same way, but clients
must be configured with their credentials out of band.

Authentication is required by default. Use `none()` only when you intentionally
want a public MCP endpoint:

```ts
import { none } from "eve/channels/auth";
import { mcpChannel } from "eve/channels/mcp";

export default mcpChannel({
auth: none(),
});
```

The `/mcp` endpoint validates `Host` and `Origin` before authentication.
Browser protocol requests must be exact same-origin, and plain HTTP is accepted
only on loopback; remote endpoints must use HTTPS. Put a same-origin backend or
an authenticated server-side proxy in front of it when a browser application
needs to connect. The public OAuth protected-resource metadata route is the
exception: it serves cross-origin `GET`, `HEAD`, and `OPTIONS` requests so
browser-hosted clients can discover the authorization server.

When a protected request has no credentials, eve returns a bare Bearer
challenge. A supplied Bearer token that every configured strategy rejects gets
`error="invalid_token"`. Scope enforcement stays in the verifier; report a
verified caller that lacks required scopes with a `ForbiddenError` carrying a
Bearer `error="insufficient_scope"` challenge and the required `scope` value.
`mcpChannel()` preserves that challenge and adds `resource_metadata`.

The endpoint serves MCP `2026-07-28` directly and keeps a stateless
`2025-11-25` compatibility path for existing Streamable HTTP clients. Current
clients use `server/discover`; older clients can continue to initialize
normally. eve does not retain an MCP transport session in either mode.

## Invoke the agent

The server name and `agent_start` description come from the compiled root
agent. Current clients receive four compatibility tools:

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

`agent_start` creates one task-mode eve session and returns after durable
acceptance. Keep the returned `invocationId` and call `agent_get` until the
invocation is terminal, honoring `pollAfterMs` while it is working. A failed
start whose response is ambiguous should not be retried automatically because
each call creates a new invocation. Optional output schemas are limited to 64
KiB, 32 levels, and 2,048 nodes; external `$ref` values are rejected.

Every operation reruns the configured auth policy. With authenticated
policies, invocation access is bound to the same principal that started it;
knowing an invocation ID is not sufficient. Bearer tokens are neither
persisted with the invocation nor forwarded to the agent's tools.

With `none()`, every caller shares the anonymous principal, so the random
invocation ID is a bearer capability. Keep it out of logs and URLs, and treat
it as usable until the underlying workflow retention expires. When the
workflow backend reports a retention deadline, responses include `expiresAt`.

`agent_get` reconstructs active state from a bounded tail snapshot of the
durable eve session event stream and does not start work or run another model.
Terminal state comes directly from the workflow run without reading the event
stream.

Active invocations use three non-terminal states:

- `working`: continue polling according to `pollAfterMs`.
- `input_required`: present the structured `inputRequests` and pass answers to
`agent_update`. A successful update acknowledges the accepted answers with a
fresh `working` state; continue polling rather than submitting them again.
- `authorization_required`: present each entry in `authorizations`, including
its sign-in URL, user code, or instructions when supplied. Connection
authorization completes through its callback and resumes the invocation
automatically; continue polling according to `pollAfterMs`.

Cancellation is cooperative; poll the invocation until it reports `cancelled`
or another terminal state.

## Connect Claude Code

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

MCP Tasks support and direct capability export are separate follow-ups. The
compatibility tools already use a protocol-neutral invocation service so Tasks
can map onto the same durable state machine later.
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
46 changes: 46 additions & 0 deletions docs/guides/auth-and-route-protection.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ export default eveChannel({

If every entry skips, the request gets a `401` whose `WWW-Authenticate` header advertises the challenge scheme(s) the configured entries declare — `Basic` for `httpBasic()`, `Bearer` for the token-based helpers (`jwtHmac`, `jwtEcdsa`, `oidc`, `vercelOidc`), both when you mix them, and `Bearer` as a fallback for entries that don't declare a scheme (custom `AuthFn`s, or an empty array). See [`withAuthChallenges`](#custom-verifiers) to declare a scheme on a custom `AuthFn`.

If the request supplied a Bearer credential and every declared Bearer strategy
skips, the challenge includes `error="invalid_token"`. The error is added only
after the complete walk, so another token strategy or a final `localDev()` /
`none()` fallback can still accept the request. Missing credentials keep the
bare Bearer challenge.

```ts
import { type AuthFn, localDev, vercelOidc } from "eve/channels/auth";
import { eveChannel } from "eve/channels/eve";
Expand Down Expand Up @@ -197,6 +203,46 @@ export default defineChannel({

`UnauthenticatedError` and `ForbiddenError` wrap this builder (status `401` / `403`). Throw those from an `AuthFn` that `routeAuth` walks. Call `createUnauthorizedResponse` directly only when you're returning a `Response` from a hand-rolled route.

## OAuth protected resources

`oauthResource()` adds portable OAuth resource-server metadata to an existing
inbound auth policy without changing how it authenticates:

```ts
import { jwtEcdsa, oauthResource } from "eve/channels/auth";

const auth = oauthResource(
jwtEcdsa({
algorithm: "ES256",
issuer: process.env.AUTH_ISSUER!,
audiences: [process.env.RESOURCE_URL!],
publicKey: process.env.AUTH_PUBLIC_KEY!,
}),
{
issuer: process.env.AUTH_ISSUER!,
scopes: ["agent:invoke"],
},
);
```

The result is still an `AuthFn<Request>`, so any inbound HTTP channel can use
it. Protocol-aware channels can read the attached metadata to publish discovery
and authentication challenges. `mcpChannel()` does this automatically.

`oauthResource()` does not verify or issue tokens. The wrapped strategy remains
responsible for signature or introspection, expiry, audience/resource, claims,
and scope enforcement. The configured `scopes` are discovery metadata for
clients, not an authorization check by themselves. Authorization-server and
resource identifiers must be HTTPS URLs without credentials, query strings, or
fragments. HTTP is accepted only for loopback development URLs. A custom
`metadataPath` must be a same-origin absolute path; network-path references
such as `//example.com/metadata` are rejected.

When a verified caller lacks a required scope, throw `ForbiddenError` with a
Bearer challenge whose parameters include `error: "insufficient_scope"` and a
space-separated `scope`. Protocol-aware channels preserve that `403` challenge
and can add their resource metadata.

## Network policy

`eve/channels/auth` exports `createIpAllowList(...)` and `isIpAllowed(...)` for cutting off requests before any model work starts. A request that fails the network policy is dropped ahead of both auth and runtime execution.
Expand Down
6 changes: 6 additions & 0 deletions packages/eve/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,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 @@ -319,6 +324,7 @@
"@chat-adapter/state-memory": "4.34.0",
"@chat-adapter/twilio": "4.34.0",
"@clack/core": "1.3.1",
"@modelcontextprotocol/server": "2.0.0",
"@nuxt/kit": "^4.0.0",
"@opentelemetry/otlp-transformer": "0.214.0",
"@opentelemetry/sdk-trace-base": "catalog:",
Expand Down
109 changes: 109 additions & 0 deletions packages/eve/scripts/vendor-compiled/@modelcontextprotocol/server.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
export default {
packageName: "@modelcontextprotocol/server",
compiledPath: "@modelcontextprotocol/server",
chunkGroup: "workflow",
entries: [
{
entry: "dist/index.mjs",
outputPath: "index",
declaration: `
export interface CallToolRequest {
readonly params: {
readonly arguments?: Readonly<Record<string, unknown>>;
readonly name: string;
};
}

export interface McpRequestHandlerExtra {
readonly mcpReq: {
readonly signal: AbortSignal;
};
}

export interface McpToolAnnotations {
readonly destructiveHint?: boolean;
readonly idempotentHint?: boolean;
readonly openWorldHint?: boolean;
readonly readOnlyHint?: boolean;
}

export interface StandardSchemaWithJSON<T = unknown> {
readonly "~standard": unknown;
}

export declare class Server {
constructor(info: { readonly name: string; readonly version: string }, options?: {
readonly capabilities?: Readonly<Record<string, unknown>>;
});
setRequestHandler<Result>(
method: "tools/list",
handler: (
request: { readonly params: Readonly<Record<string, unknown>> },
context: McpRequestHandlerExtra,
) => Result | Promise<Result>,
): void;
setRequestHandler<Result>(
method: "tools/call",
handler: (
request: CallToolRequest,
context: McpRequestHandlerExtra,
) => Result | Promise<Result>,
): void;
}

export declare class McpServer {
constructor(info: { readonly name: string; readonly version: string }, options?: {
readonly capabilities?: Readonly<Record<string, unknown>>;
});
registerTool<T = unknown>(
name: string,
config: {
readonly annotations?: McpToolAnnotations;
readonly description?: string;
readonly inputSchema: StandardSchemaWithJSON<T>;
readonly outputSchema?: StandardSchemaWithJSON;
},
callback: (
input: T,
context: McpRequestHandlerExtra,
) => unknown | Promise<unknown>,
): void;
}

export interface McpRequestContext {
readonly era: "legacy" | "modern";
readonly requestInfo: Request;
}

export interface McpHandler {
close(): Promise<void>;
fetch(request: Request, options?: { readonly parsedBody?: unknown }): Promise<Response>;
}

export declare function fromJsonSchema<T = unknown>(
schema: Readonly<Record<string, unknown>>,
): StandardSchemaWithJSON<T>;

export declare function hostHeaderValidationResponse(
request: Request,
allowedHostnames: readonly string[],
): Response | undefined;

export declare function originValidationResponse(
request: Request,
allowedOriginHostnames: readonly string[],
): Response | undefined;

export declare function createMcpHandler(
factory: (context: McpRequestContext) => McpServer | Server | Promise<McpServer | Server>,
options?: {
readonly legacy?: "reject" | "stateless";
readonly onerror?: (error: Error) => void;
readonly responseMode?: "auto" | "json" | "stream";
},
): McpHandler;
`,
},
],
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 modelContextProtocolServer from "./@modelcontextprotocol/server.mjs";
import opentelemetryApi from "./@opentelemetry/api.mjs";
import opentelemetryOtlpTransformer from "./@opentelemetry/otlp-transformer.mjs";
import standardSchemaSpec from "./@standard-schema/spec.mjs";
Expand Down Expand Up @@ -65,6 +66,7 @@ export const MODULES = [
jsonSchema,
marked,
mcp,
modelContextProtocolServer,
openai,
opentelemetryApi,
opentelemetryOtlpTransformer,
Expand Down
Loading
Loading