---
title: "MCP Channel"
description: "Publish an eve agent as an MCP server with auth support."
---

The MCP channel lets clients such as Claude Code delegate durable work to an eve agent through four tools: `agent_start`, `agent_get`, `agent_update`, and `agent_cancel`. It can also publish the agent's own tools, so a client calls them directly, and its skills, so a client reads them; see [Publish the agent's tools](#publish-the-agents-tools) and [Publish the agent's skills](#publish-the-agents-skills).

Use an [MCP connection](../connections/mcp) instead when your eve agent needs to call someone else's MCP server.

## Configure the channel

Create `agent/channels/mcp.ts`. Authentication is required explicitly, even during development.

```ts title="agent/channels/mcp.ts"
import { localDev } from "eve/channels/auth";
import { mcpChannel } from "eve/channels/mcp";

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

This accepts a synthetic local principal under `eve dev` or `vercel dev` and rejects all requests in production. Before deploying, replace it with one of the production authentication modes below. `localDev()` checks the running environment, not the request hostname: accessing an `eve start` production process through localhost does not activate it.

### Routes

The default Streamable HTTP endpoint is `/eve/v1/mcp`. Set `route` when the application should publish it somewhere else:

```ts title="agent/channels/mcp.ts"
import { localDev } from "eve/channels/auth";
import { mcpChannel } from "eve/channels/mcp";

export default mcpChannel({
  auth: localDev(),
  route: "/mcp",
});
```

The channel registers `GET`, `POST`, and `DELETE` at the selected route.

## Interactive OAuth

For an MCP client that should open a sign-in flow, configure eve as an OAuth protected resource. Wrap the access-token verifier in `oauthResource()`:

```ts title="agent/channels/mcp.ts"
import { oauthResource, oidc } from "eve/channels/auth";
import { mcpChannel } from "eve/channels/mcp";

const issuer = "https://auth.example.com";
const resource = "https://agent.example.com/eve/v1/mcp";

const authenticateRequest = oidc({
  issuer,
  audiences: [resource],
});

export default mcpChannel({
  auth: oauthResource(authenticateRequest, {
    issuer,
    resource,
    scopes: ["agent:invoke"],
  }),
});
```

The authorization server identified by `issuer` must issue tokens accepted by the wrapped verifier. `scopes` advertises what clients should request; the verifier remains responsible for signature, expiration, audience/resource, and scope enforcement.

`oauthResource()` does not issue tokens or run an authorization server. It decorates an ordinary inbound `AuthFn` with OAuth protected-resource metadata so `mcpChannel()` can publish discovery and add `resource_metadata` to Bearer challenges. Client registration, consent, token issuance, and authorization-server metadata remain the identity provider's responsibility.

Most hosted MCP clients (Claude, ChatGPT, Grok, and similar) register themselves with the authorization server through Dynamic Client Registration ([RFC 7591](https://www.rfc-editor.org/rfc/rfc7591)) the first time a user connects. Choose an issuer that supports it, or pre-register each client with the issuer before the demo. If registration fails, the client stops before it ever reaches eve.

### Verify the bearer token yourself

Use a custom `AuthFn` when token verification needs application-specific logic:

```ts title="agent/channels/mcp.ts"
import { extractBearerToken, oauthResource, verifyOidc } from "eve/channels/auth";
import { mcpChannel } from "eve/channels/mcp";

const issuer = "https://auth.example.com";
const resource = "https://agent.example.com/eve/v1/mcp";

async function verifyToken(request: Request) {
  const token = extractBearerToken(request.headers.get("authorization"));
  const result = await verifyOidc(token, {
    audiences: [resource],
    issuer,
  });

  return result.ok ? result.sessionAuth : null;
}

export default mcpChannel({
  auth: oauthResource(verifyToken, {
    issuer,
    resource,
    scopes: ["agent:invoke"],
  }),
});
```

`verifyOidc()` uses the issuer's discovery document to validate the token signature and claims. Returning `sessionAuth` accepts the request and binds invocation ownership to that verified principal. Returning `null` lets the auth walk continue; when no strategy accepts the request, eve returns `401`.

You can wrap any strategy in `oauthResource()`, including `vercelOidc()`, but the advertised authorization server must issue tokens that strategy accepts. `vercelOidc()` by itself is preconfigured Vercel workload identity and does not advertise an interactive login.

### Protected-resource metadata

By default, eve derives the metadata path from the complete MCP resource identifier according to RFC 9728:

| MCP resource                           | Protected-resource metadata                                                 |
| -------------------------------------- | --------------------------------------------------------------------------- |
| `https://agent.example.com/eve/v1/mcp` | `https://agent.example.com/.well-known/oauth-protected-resource/eve/v1/mcp` |
| `https://agent.example.com/mcp`        | `https://agent.example.com/.well-known/oauth-protected-resource/mcp`        |

Set `resource` when the public resource identifier cannot be derived from the incoming request. Set `metadataPath` only when discovery must live at a non-derived location:

```ts
oauthResource(verifyToken, {
  issuer,
  metadataPath: "/.well-known/custom-resource",
  resource: "https://agent.example.com/eve/v1/mcp",
  scopes: ["agent:invoke"],
});
```

The metadata endpoint serves cross-origin `GET`, `HEAD`, and `OPTIONS` requests so browser-hosted clients can discover the authorization server. MCP protocol requests remain same-origin.

## Other authentication modes

`mcpChannel()` accepts the same inbound auth strategies as other eve channels: Basic auth, HMAC or ECDSA JWTs, generic OIDC, Vercel OIDC, custom `AuthFn` policies, or an ordered array of them. These modes do not automatically produce an interactive login unless wrapped in `oauthResource()`; configure credentials in the MCP client out of band.

When a protected request has no accepted credentials, eve returns a Bearer challenge. A supplied Bearer token rejected by every strategy gets `error="invalid_token"`. To report a verified caller that lacks the necessary scopes, throw `ForbiddenError` with an `error="insufficient_scope"` Bearer challenge. The MCP channel preserves that challenge and adds the protected-resource metadata URL.

For the complete strategy and auth-walk model, see [Authentication](../guides/auth-and-route-protection).

### Public access

To intentionally expose the MCP endpoint without authentication, use `none()`:

```ts title="agent/channels/mcp.ts"
import { none } from "eve/channels/auth";
import { mcpChannel } from "eve/channels/mcp";

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

This allows anyone to invoke the agent. Every caller shares the anonymous principal, so invocation IDs become bearer capabilities until workflow retention expires: anyone holding an ID can read, answer, or cancel that invocation. Use public access only when anonymous invocation is intentional. For a public demo, prefer [Interactive OAuth](#interactive-oauth) so each caller owns their own invocations.

## HTTP security

The MCP transport validates the request before authentication:

- Remote endpoints require HTTPS; HTTP is accepted only on loopback.
- `Host` must match the request URL.
- Browser protocol requests must have an exact same-origin `Origin`.

When a gateway or reverse proxy changes the public origin or path, set `resource` explicitly so metadata and authentication challenges advertise the client-facing MCP resource.

The protected-resource metadata endpoint is intentionally CORS-readable. The MCP transport itself does not enable cross-origin browser access; place a same-origin backend or authenticated server-side proxy in front of it when a browser application needs to connect.

The endpoint serves MCP `2026-07-28` directly and retains stateless `2025-11-25` Streamable HTTP compatibility. eve does not keep an MCP transport session in either mode.

## Invoke the agent

MCP clients receive four tools:

| Tool           | Input                         | Purpose                                                     |
| -------------- | ----------------------------- | ----------------------------------------------------------- |
| `agent_start`  | `{ message }`                 | Start durable work and immediately return an invocation ID. |
| `agent_get`    | `{ invocationId }`            | Read the invocation's complete current state.               |
| `agent_update` | `{ invocationId, responses }` | Answer the complete pending human-input batch.              |
| `agent_cancel` | `{ invocationId }`            | Request cooperative cancellation of non-terminal work.      |

The server also returns `instructions` from `initialize` and `server/discover` that summarize this
protocol for the connecting model, so a hosted client does not have to infer it from the tool
schemas alone.

`agent_start` creates one eve session and returns after durable acceptance without
waiting for the session continuation hook to become readable. Keep its `invocationId`, then call
`agent_get` until the invocation reaches a terminal state. While the status is `working`, wait at
least `pollAfterMs` before polling again.

The invocation response is discriminated by `status`:

- `working`: work is active; continue polling according to `pollAfterMs`.
- `input_required`: present `inputRequests`, then send the complete answer batch through `agent_update`. A successful update returns the current invocation state.
- `authorization_required`: present the returned sign-in URL, user code, or instructions. The connection callback resumes the invocation automatically; continue polling.
- `completed`: the agent's turn settled, after any [tasks](/docs/tools/workflows#run-calls-as-tasks-task) it started finished. Consume the optional `result`: the final assistant message.
- `failed`: inspect the structured `error`.
- `cancelled`: cancellation reached a terminal state.

A tool result with `isError: true` means the call itself was rejected. A `failed` status means the call succeeded and the task itself failed. Handle them differently: correct the call in the first case, report the task failure in the second. Rejected calls carry `structuredContent.error` with a stable `code`, a short `message`, and `retryable`:

| `code`          | Meaning                                                                      | `retryable` |
| --------------- | ---------------------------------------------------------------------------- | ----------- |
| `invalid_input` | An argument was rejected, for example an oversized `message`.                | `false`     |
| `not_found`     | The invocation does not exist, has expired, or belongs to another caller.    | `false`     |
| `conflict`      | The invocation is not in the expected state; read it with `agent_get` first. | `true`      |
| `internal`      | eve failed; `errorId` correlates with server logs. No details are exposed.   | `false`     |

Cancellation is cooperative, so call `agent_get` after `agent_cancel` until the state becomes terminal.

Requests are bounded: the whole MCP request body is limited to 1 MiB, `message` to 64 KiB, each input-response `text` to 16 KiB (both measured as UTF-8 bytes, not characters), and one `agent_update` to 64 responses. Oversized bodies receive a JSON-RPC `413`; oversized fields fail input validation before any work starts.

### Durability guarantees

- Once `agent_start` returns, the work is durable. A dropped HTTP connection, a client restart, or a closed MCP session does not cancel it. Only `agent_cancel` stops work.
- `agent_start` is not idempotent. If its response is lost, the client has no `invocationId` to check, and a second call starts a second task. Ask the user before starting again rather than retrying blindly.
- `agent_update` answers one pending batch. Re-sending the same answers after eve has accepted them returns the current invocation state; sending different answers for an already-answered batch is a conflict.
- `agent_cancel` is cooperative and can race with completion. Poll `agent_get` until the status is terminal (`cancelled`, `completed`, or `failed`), not specifically `cancelled`.

## Publish the agent's tools

By default the channel serves only the `agent_*` tools. Set `tools: true` to also publish every tool the agent can run outside a conversation, and `agent: false` to stop serving the `agent_*` tools:

```ts title="agent/channels/mcp.ts"
import { localDev } from "eve/channels/auth";
import { mcpChannel } from "eve/channels/mcp";

export default mcpChannel({
  auth: localDev(),
  tools: true,
});
```

`tools/list` returns the `agent_*` tools first, then the agent's tools in name order with their input and output JSON schemas. Framework tools, workflow and provider tools, tools without `execute`, and tools added by dynamic resolvers or subagents are not published. While `agent` is on, an agent tool named `agent_start`, `agent_get`, `agent_update`, or `agent_cancel` is not published, and eve logs a warning. With `agent: false`, the channel sends no `agent_*` instructions. Turning off `agent`, `tools`, and `skills` all at once throws, since the channel would publish nothing.

Each `tools/call` checks the arguments against the tool's input schema, then runs the tool as the route-authenticated caller, or as a [forwarded user](#forwarded-principals), with no model, turn, or workflow run. Every caller the channel's `auth` admits can call these tools, outside the agent's instructions, so review what becomes callable before you turn this on. Unless the call joins a [tool session](#tool-sessions), it gets its own session:

- Authored state starts from its initial value, and updates are not kept.
- A sandbox the tool opens is deleted when the call ends.
- Calls may run in parallel, and a call retried after a lost response runs again.

A tool that cannot finish over MCP returns an error result with `isError: true` and a `structuredContent.error.code`:

| Code                     | When                                                                                             |
| ------------------------ | ------------------------------------------------------------------------------------------------ |
| `invalid_input`          | The tool rejected its input.                                                                     |
| `denied`                 | The tool's approval policy denied the call, or an anonymous caller sent a tool session key.      |
| `approval_required`      | The approval policy asks a person. The tool did not run; call it from a conversation instead.    |
| `authorization_required` | The tool needs a sign-in to a connection first. Sign in from a conversation, then call it again. |
| `internal`               | The tool failed. The message carries an error id that matches the server logs.                   |

### Trace context

MCP clients send W3C trace context in each request's `params._meta`, not in HTTP headers. When a request carries a valid `_meta.traceparent`, that span becomes the parent of everything the request runs, including a published tool's `execute_tool` span. It takes precedence over any parent your platform extracted from the HTTP request's headers. A missing, malformed, or all-zero `traceparent` leaves that parent in place. `_meta.tracestate` is kept only alongside a valid `_meta.traceparent` and only up to 512 characters; a longer value is dropped.

`_meta.baggage` is never read. A caller cannot choose the trace audience, session lineage, or content capture: those come from the channel and your `tracePolicy`, as they do for conversations. The `execute_tool` span records the call's arguments and result only when that content decision allows it, and never records exception text.

### Forwarded principals

A router that calls the channel on behalf of its own users, such as another eve agent's [`forwardPrincipal` connection](../connections/mcp#forward-the-caller-to-an-eve-agent), can name the user in the `eve-forwarded-principal` header: unpadded base64url of UTF-8 JSON `{ current, initiator? }`, at most 16 KiB. eve accepts the header only when `trustedForwarders` is set, and only from a route-authenticated forwarder the predicate accepts:

```ts title="agent/channels/mcp.ts"
import { vercelOidc, vercelSubject } from "eve/channels/auth";
import { mcpChannel } from "eve/channels/mcp";

export default mcpChannel({
  auth: [vercelOidc()],
  tools: true,
  // Only the router deployment may name the user a tool runs as.
  trustedForwarders: (forwarder) =>
    forwarder.subject === vercelSubject({ teamSlug: "acme", projectName: "router" }),
});
```

The predicate receives the verified forwarder and the principals it asserts, as the [eve channel](./eve)'s `trustedForwarders` does, so match the forwarder precisely; see [Accepting forwarded identity](../guides/auth-and-route-protection#accepting-forwarded-identity-from-another-deployment). A published tool called with an accepted header runs as the forwarded user: `ctx.session.auth.current` is that user, `ctx.session.auth.initiator` is the asserted initiator or that user, and both carry the forwarder's `principalId` in the `eve:forwarded-by` attribute. The `agent_*` tools still act as the route-authenticated caller.

| Request                                                | Response                 |
| ------------------------------------------------------ | ------------------------ |
| No header                                              | Runs as the route caller |
| Malformed or over 16 KiB                               | `400`                    |
| The predicate refuses the forwarder or what it asserts | `403`                    |
| The forwarder is anonymous (`none()`)                  | `403`                    |
| The channel has no `trustedForwarders`                 | `403`                    |
| The predicate throws                                   | `500`                    |

### Tool sessions

A tool session lets several `tools/call` requests share one sandbox, so a file one call writes is there for the next. With `tools: true`, the channel advertises the `dev.eve/tool-sessions` extension in `capabilities.extensions` of `server/discover`. A client joins a session by declaring the same extension in the request's `io.modelcontextprotocol/clientCapabilities` and sending a key of 1 to 512 characters in `_meta["dev.eve/tool-session"]`:

```json
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "write_note",
    "arguments": { "text": "Feed Biscuit at 18:00." },
    "_meta": {
      "dev.eve/tool-session": "alice-front-desk",
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientInfo": { "name": "front-desk", "version": "1.0.0" },
      "io.modelcontextprotocol/clientCapabilities": {
        "extensions": { "dev.eve/tool-sessions": {} }
      }
    }
  }
}
```

eve derives the session id from the principal the call runs as (the route-authenticated caller, or a [forwarded user](#forwarded-principals) together with the verified router that forwarded them) and the key, so calls from the same principal with the same key share the session, and another caller who sends the same key gets a different one. That holds for forwarded users too: two users forwarded with the same key, or one user forwarded by two routers, get separate sessions. The router counted is the one the channel's `auth` verified, never one named in the forwarded principal. A key from a client that did not declare the extension is ignored, and the call gets its own session. Clients on the 2025-11-25 protocol send no per-request capabilities, so they always get their own session.

Tool sessions need an authenticated caller. With `auth: none()` every request is the same anonymous principal, so a key alone would name the session and anyone who knew it could read what another client left in the sandbox. A keyed call from an anonymous caller is therefore denied with the `denied` error code before anything runs; unkeyed anonymous calls still get a one-off session.

In a tool session:

- The sandbox opens on the first `ctx.getSandbox()` and is kept for later calls with the same key. Concurrent first calls share one sandbox.
- The sandbox belongs to the agent's current sandbox definition. A deploy that changes it (the template, its snapshot, or the open options in `sandbox.ts` and its resources) starts a fresh, empty sandbox for the same key; the old one expires as below.
- `ctx.session.id` is the same for every call with the key.
- Traces still show each call as its own run. Its `agent.run.id` and `gen_ai.conversation.id` are a fresh id for that call, not `ctx.session.id`, so traces do not link calls that share a key.
- Authored state still starts from its initial value on every call, and updates are not kept. Keep data that must outlive a call in the sandbox.
- Calls may still run in parallel, and nothing orders them.

Tool sessions need a sandbox provider that finds the session's sandbox again: Vercel Sandbox or just-bash. On another provider, a call with a key fails with a `Sandbox provider "<name>" cannot keep a sandbox between invokeTool calls` error when the tool opens its sandbox. Tools that never open a sandbox still run. just-bash keeps its sandboxes on the local disk, so on Vercel Functions a just-bash tool session lasts only as long as the function instance.

A tool session has no end, so eve bounds how long its sandbox is kept. On Vercel Sandbox, a tool-session sandbox's saved filesystem expires a day after its last use, and each call that resumes it restarts that day. The next call with the key after that replaces it with a fresh, empty sandbox. If two calls with the key find the expired filesystem at the same time, each can replace the sandbox, and files the first one writes can be lost. Vercel only keeps the latest snapshot of the sandbox. Tool sessions use this one-day expiry whatever `snapshotExpiration` `sandbox.ts` sets; sessions that conversations start keep the authored setting.

just-bash keeps tool-session sandboxes on the local disk, and nothing removes them.

## Publish the agent's skills

Set `skills: true` to serve the agent's skills as [SEP-2640](https://modelcontextprotocol.io/seps/2640) skills under `skill://`, so a client that calls the agent's tools can also read the workflow knowledge behind them:

```ts title="agent/channels/mcp.ts"
import { localDev } from "eve/channels/auth";
import { mcpChannel } from "eve/channels/mcp";

export default mcpChannel({
  auth: localDev(),
  skills: true,
  tools: true,
});
```

The channel declares the `io.modelcontextprotocol/skills` extension and serves `skills/list`, `skills/get`, `resources/list`, `resources/templates/list`, `resources/read`, and `resources/directory/read`. Every caller the channel's `auth` admits can read every served skill. Each skill's entry is `skill://<name>/SKILL.md`, and its `resources` list every file with its `size` in bytes and a `sha256:` digest. `resources/list` returns one resource per served skill, its `SKILL.md`; the other files of a skill are reachable through `resources/directory/read` and the entry's `resources`, not listed there. `resources/directory/read` lists every directory of a skill, including empty ones, which return no children. `resources/templates/list` is always empty. `resources/read` returns UTF-8 text as `text` and anything else, such as images or Latin-1 files, as a base64 `blob`, byte for byte. Every skill and resource method carries the same cache hint as `tools/list`: private, for 5 minutes. The server's `resources` capability declares `listChanged: false`; eve never sends `notifications/resources/list_changed`, so a client re-reads after the hint expires. For each compiled artifact version, eve builds each skill's catalog snapshot once, listing its files and reading them to compute their sizes and digests. Only the served `SKILL.md` is kept in memory; `resources/read` reads other files from storage again, and a file removed since the snapshot is an unknown resource (`-32602`).

A skill is served whole or not at all. eve leaves a skill out of every method, and logs one warning naming the reason, when:

- its `SKILL.md` frontmatter does not meet the Agent Skills format: a `name` of up to 64 lowercase letters, digits, and single hyphens, and a `description` of 1 to 1024 characters;
- it has more than 512 files or more than 16 MiB in total;
- any of its files is over 512 KiB, unreadable, or has a path that cannot be a `skill://` URI.

A `SKILL.md` whose frontmatter already names the skill and has a `description` is served as authored. Otherwise, as for flat `.md` and module skills, eve serves it with frontmatter that carries the skill's name and description, and the digest describes that served document. Skills added by dynamic resolvers or subagents are not served.

Production builds ship skill files as eve-owned server assets, so they count toward the deployed function's size. `eve build` also drops Nitro's default `server` server-asset entry, the one that would bundle the app's `assets/` directory; eve reads only its own asset bases, and a route that reads `assets:server` storage finds it empty in production.

## Invocation ownership

Every MCP operation reruns the configured auth policy. With authenticated policies, an invocation belongs to the principal that started it; knowing its ID is not sufficient. Bearer tokens are not stored with the invocation or forwarded to the agent's tools.

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

## What to read next

- [Authentication](../guides/auth-and-route-protection): configure inbound route authentication
- [MCP connections](../connections/mcp): let an eve agent call another MCP server
- [Sessions, runs & streaming](../concepts/sessions-runs-and-streaming): understand the durable sessions behind invocations
