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

# MCP Channel



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`.

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, outputSchema? }`  | 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 task-mode 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`: consume the optional `result`.
* `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 or external `outputSchema`. | `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. Optional output schemas are limited to 64 KiB, 32 levels, and 2,048 nodes, and external `$ref` values are rejected. 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`.

## 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


---

For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md)

For an index of all available documentation, see [/llms.txt](/llms.txt)

For agent-facing discovery, including API and MCP surfaces, see [/agents.md](/agents.md)