UNPKG

eve

Version:

Filesystem-first framework for durable backend AI agents that run anywhere.

225 lines (155 loc) 13.2 kB
--- 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`. 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