---
title: MCP Connections
description: Connect an eve agent to a remote MCP server, authorize it with Vercel Connect or static credentials, and control which tools the model can discover.
url: "https://eve.dev/docs/connections/mcp"
docs_index: /llms.txt
---

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

An MCP connection gives your agent the tools of a remote MCP server you don't author. It lives in one file under `agent/connections/`. The server publishes its tools, and the model finds them with `eve__search` and calls them with `eve__tool`.

Use MCP when the service has an MCP server that defines its own tools, or when one connection should expose a family of related tools.

## Define an MCP connection

The filename is the connection name, so `agent/connections/linear.ts` registers as `linear`, and events report its tools as `linear__<tool>`.

```ts title="agent/connections/linear.ts"
import { connect } from "@vercel/connect/eve";
import { defineMcpClientConnection } from "eve/connections";

export default defineMcpClientConnection({
  url: "https://mcp.linear.app/mcp", // required: Streamable HTTP or SSE endpoint
  description: "Linear workspace: issues, projects, cycles, and comments.", // required
  auth: connect("mcp.linear.app/linear"), // optional: omit for a public server
});
```

Write the `description` for the model. eve lists it next to the connection name, and the model uses it to decide which connection to search.

### Options

The [shared connection options](../connections#options) apply, with `tools` as the filter. MCP adds these:

| Field                      | Default | What it does                                                                                                             |
| -------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------ |
| `protocolVersionDiscovery` | `true`  | Set `false` for a server that needs the `initialize` handshake. See [protocol discovery](#configure-protocol-discovery). |
| `forwardPrincipal`         | `false` | Send the turn's user to another eve agent. See [Forward the caller](#forward-the-caller-to-an-eve-agent).                |
| `experimental.events`      | None    | Run your code when the server sends an event. See [Events](#events-experimental).                                        |

## Use Vercel Connect for OAuth

For an MCP server that uses OAuth, follow the shared [Vercel Connect flow](../connections#interactive-oauth-via-vercel-connect) and pass the connector UID to `connect()`. The MCP URL and the Connect service identifier differ. Linear uses `https://mcp.linear.app/mcp` as the endpoint and `mcp.linear.app` when you create the connector.

By default, `connect("...")` uses each user's own sign-in, so the session needs a signed-in user. Use `connect({ connector, principalType: "app" })` to have the server act as the agent.

## When eve asks for sign-in

eve asks the user to sign in only when the MCP server asks for it. When the user has no token yet, `getToken` throws `ConnectionAuthorizationRequiredError`. eve then connects without an `Authorization` header and lists the server's tools. Any other `getToken` error fails the connection.

- **The server rejects every request without a token.** Most hosted OAuth servers, such as Linear, GitHub, and Notion, answer `initialize` or `tools/list` with HTTP `401`. `eve__search` lists the connection as one result named after it, such as `linear`. Calling that name with `eve__tool({ name: "linear" })`, or any of its tools with `eve__tool`, asks the user to sign in.
- **The server lists tools without a token.** `eve__search` returns its tools with no prompt, and `eve__tool` runs tools the server answers anonymously. Sign-in starts only when a tool call comes back with HTTP `401`, or with an error result that carries a `WWW-Authenticate` challenge in `_meta["mcp/www_authenticate"]`.

After the user signs in, the turn continues and the model retries the call. eve reconnects with the token and reloads the tool list, which may include tools the server hides from anonymous callers. Once a connection has a token, it sends the token on every request.

A server that leaves protected tools out of its anonymous `tools/list` never triggers sign-in, because the model can't find those tools. A server that supports anonymous use should list every tool and refuse the protected calls.

eve reuses a token until its `expiresAt` passes, then calls the token provider again. On HTTP `401`, eve calls the provider's optional `evict` hook, gets a new token, and retries once before it asks for sign-in. The provider decides whether to keep a refresh grant to renew tokens without a prompt. HTTP `403` never triggers a refresh or retry.

## Static tokens and headers

MCP connections accept the shared `auth` and `headers` options. Use `auth.getToken` for a bearer token, `headers` for another scheme, and resolver functions when credentials or routing depend on the caller. See [Static-token auth](../connections#static-token-auth), [Headers](../connections#headers), and [Per-caller auth and headers](../connections#per-caller-auth-and-headers).

## No auth

Omit `auth` and `headers` only for an intentionally public or loopback MCP server. See [No auth](../connections#no-auth).

## Tool filters

Narrow which server tools the model can discover with exactly one of `tools.allow` or `tools.block`:

```ts title="agent/connections/linear.ts"
import { connect } from "@vercel/connect/eve";
import { defineMcpClientConnection } from "eve/connections";

export default defineMcpClientConnection({
  url: "https://mcp.linear.app/mcp",
  description: "Linear: read issue and project data.",
  auth: connect("mcp.linear.app/linear"),
  tools: { allow: ["search_issues", "get_issue"] },
});
```

Prefer `allow` for the smallest safe surface, especially when the server exposes write tools. Use `block` to hide a few tools from a broad, stable surface.

## Approval gates

The shared `approval` option applies to every tool on an MCP connection. The [connections overview](../connections#per-connection-approval) defines the `never()`, `once()`, `always()`, and `auto()` helpers. [Human-in-the-loop](../human-in-the-loop#approvals) explains how a call waits for a person.

### Gate specific tools by name or input

With `always()`, a server that mixes read tools with destructive ones prompts on every harmless call. Pass a custom policy instead, the same [`Approval`](/docs/human-in-the-loop#approvals) shape authored tools use, to ask only for the calls that matter. The policy receives `{ session, toolName, toolInput, toolAnnotations, callId, abortSignal }` and returns an approval status, directly or as a promise.

This connection always asks before a delete, asks before a publish only when the call schedules a post, and lets everything else run:

```ts title="agent/connections/social.ts"
import { defineMcpClientConnection } from "eve/connections";

// Bare tool names whose effects are irreversible — always gate these.
const DELETE_TOOLS = ["delete_draft", "delete_thread"];
// Tools that can publish — gate only when the call schedules a post.
const PUBLISH_TOOLS = ["create_draft", "edit_draft"];

// Read `requestBody.publish_at` without trusting the input's shape.
const publishesNow = (input: unknown): boolean => {
  const body = (input as { requestBody?: { publish_at?: unknown } })?.requestBody;
  return typeof body?.publish_at === "string" && body.publish_at.length > 0;
};

export default defineMcpClientConnection({
  url: "https://mcp.example.com/mcp",
  description: "Social publishing: draft, schedule, and manage posts.",
  auth: {
    principalType: "app",
    getToken: async () => ({ token: process.env.SOCIAL_API_KEY! }),
  },
  approval: ({ toolName, toolInput }) => {
    if (DELETE_TOOLS.some((t) => toolName.includes(t))) return "user-approval";
    if (PUBLISH_TOOLS.some((t) => toolName.includes(t))) {
      return publishesNow(toolInput) ? "user-approval" : "not-applicable";
    }
    return "not-applicable";
  },
});
```

Two details differ from authored tools:

- **`toolName` is qualified** as `<connection>__<tool>`, such as `social__delete_draft`, so match the bare name with `.includes()` or `.endsWith()` rather than `===`.
- **`toolInput` is the model's raw input**, typed as `Record<string, unknown> | undefined`. The remote server owns the schema, so you can't rely on its shape, and it is `undefined` when the input isn't an object. Read nested fields defensively, as `publishesNow` does.

| Return                        | Effect                       |
| ----------------------------- | ---------------------------- |
| `"user-approval"` or `true`   | Pause for a person           |
| `"not-applicable"` or `false` | Run without a prompt         |
| `"approved"` or `"denied"`    | Decide without asking anyone |

### Gate by the server's tool annotations

Many MCP servers declare [tool annotations](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#tool) such as `readOnlyHint` and `destructiveHint`. eve passes the called tool's annotations as `toolAnnotations`, typed as `ConnectionToolAnnotations` from `eve/tools/approval`. It is `undefined` when the server declares none, so keep a fallback:

```ts title="agent/connections/linear.ts"
import { connect } from "@vercel/connect/eve";
import { defineMcpClientConnection } from "eve/connections";

export default defineMcpClientConnection({
  url: "https://mcp.linear.app/mcp",
  description: "Linear issues and projects.",
  auth: connect("mcp.linear.app/linear"),
  approval: ({ toolAnnotations }) => {
    if (toolAnnotations?.readOnlyHint === true) return "not-applicable";
    return "user-approval";
  },
});
```

Annotations are hints, not guarantees. Trust them only as far as you trust the server.

[Human-in-the-loop](/docs/human-in-the-loop#approvals) covers every status, how long an approval lasts, and `session.auth`.

## Control result size

An MCP tool call returns the tool's `structuredContent` when the server sends it. Otherwise it returns the result's text, parsed as JSON when the tool declares no output schema. Image, audio, and resource content reaches the model as file parts, and a result with `isError: true` fails the call with its text. MCP connections have no per-result transform like an authored tool's [`toModelOutput`](/docs/tools#shape-what-the-model-sees-with-tomodeloutput).

When a remote tool returns more than the model needs, narrow the result at the server. Add a search or summary tool, or return a handle another call can use to fetch a smaller slice.

If you don't control the server, replace that operation with an authored tool that calls an equivalent upstream API. The authored tool can store the full payload outside model context and return only the needed fields through `toModelOutput`. Hide the original with [`tools.block`](#tool-filters) so the model doesn't see duplicate tools.

## Application-provided tool arguments

Some MCP servers require arguments that come from the application, not the model. For example, UCP servers expect the agent profile in `arguments.meta` on every tool call. Set those values with `toolCall.providedArguments`:

```ts title="agent/connections/storefront.ts"
import { defineMcpClientConnection } from "eve/connections";

const profileUrl = "https://agent.example.com/.well-known/ucp";

export default defineMcpClientConnection({
  url: "https://store.example.com/api/ucp/mcp",
  description: "Storefront catalog, carts, checkouts, and orders.",
  toolCall: {
    providedArguments: {
      meta: ({ session }) => ({
        "ucp-agent": {
          profile: `${profileUrl}?session=${encodeURIComponent(session.id)}`,
        },
      }),
    },
  },
});
```

Values may be JSON values, promises, or callbacks. Callbacks receive the session context, the bare remote `toolName`, and a `callId`. The `callId` is unique to the tool call and stays the same if eve runs the call again, so it works as an idempotency key.

The model doesn't see these keys in any tool's input schema. eve adds their values just before each call and overwrites any value the model sent. Approval policies see only the model's input.

## Configure protocol discovery

By default, eve first asks the server which MCP protocol version it speaks. A server can answer with an unsupported protocol version error (`-32022`) that lists a handshake version eve supports. eve then uses `initialize` with the newest version both sides support. If the server needs the `initialize` handshake but lists no supported version, turn discovery off:

```ts
export default defineMcpClientConnection({
  url: "https://mcp.example.com/mcp",
  description: "Search support cases",
  protocolVersionDiscovery: false,
});
```

With `protocolVersionDiscovery: false`, eve skips `server/discover` and starts with `initialize`. It proposes `2025-11-25` and agrees on a version the server supports. Dynamic connections can return this option too.

## Forward the caller to an eve agent

When the server is another eve agent's [`mcpChannel`](../channels/mcp), set `forwardPrincipal: true` so its tools run as the current turn's user instead of as this deployment:

```ts title="agent/connections/specialist.ts"
import { defineMcpClientConnection } from "eve/connections";

export default defineMcpClientConnection({
  url: "https://specialist.example.com/eve/v1/mcp",
  description: "The specialist agent's tools.",
  headers: { authorization: `Bearer ${process.env.SPECIALIST_TOKEN}` },
  forwardPrincipal: true,
});
```

The `eve-forwarded-principal` header on every request names the turn's user, and the person who started the session when that differs. An anonymous turn, or one with no caller, sends no header. The server must list the identity this deployment authenticates as in `trustedForwarders`, or it refuses the request with `403`. See [Forwarded principals](../channels/mcp#forwarded-principals).

A request that carries the header fails instead of following a redirect, so the header never reaches another host. If the encoded user is over 16 KiB, the call fails before it is sent; trim the user's attributes.

Authenticate the connection as this deployment with a fixed service credential, like the token above. Don't use a user's `connect("...")` sign-in or a per-caller resolver. eve can reuse the same `auth` and `headers` for different users in a shared session. The header names the user on each request, and the server decides what that user can reach.

## Events (experimental)

An MCP connection can watch the server's events and run your code when one arrives. Vercel Connect holds the subscription and forwards each event to your agent.

Requirements:

- A statically authored MCP connection with a static `connect()` auth provider. Dynamic connections and auth resolvers are not supported.
- `@vercel/connect@2.7.0` or later.
- An MCP server that supports events over Streamable HTTP.

### Handle events

Add `experimental: { events: { ... } }` to the connection. In this example, a user asks the agent to "watch the support inbox and draft replies to new bug reports". The model subscribes to Bird's `email_mailbox.message_received` event for that mailbox. Each new email starts a turn in the same conversation with instructions to triage it:

```ts title="agent/connections/bird.ts"
import { connect } from "@vercel/connect/eve";
import { defineMcpClientConnection } from "eve/connections";

export default defineMcpClientConnection({
  url: "https://mcp.bird.com/",
  description: "Bird: email, SMS, and contacts. Can watch a mailbox for new mail.",
  auth: connect("bird/bird"),
  experimental: {
    events: {
      async onEvent({ event, origin, auth, attachSession }) {
        if (event.name !== "email_mailbox.message_received") return;
        await attachSession(origin.sessionId).send(
          [
            "A new email arrived in the mailbox you are watching:",
            JSON.stringify(event.data),
            "If it is a bug report, look up the sender's earlier threads, draft a reply " +
              "that says what we know and what happens next, and ask me before sending it. " +
              "Otherwise, summarize it in one line.",
          ].join("\n\n"),
          { auth, turnPolicy: "queue" },
        );
      },
    },
  },
});
```

The model does the work with the connection's tools, then waits for the user's approval in the conversation. `turnPolicy: "queue"` runs a burst of emails one turn after another instead of interrupting the user.

`onEvent` is required. Defining it doesn't subscribe to anything; the model subscribes while it works.

Callbacks run outside any conversation, as the user who subscribed (`auth`). Nothing reaches a session unless you call `attachSession(sessionId).send(...)`, and `attachSession` never creates a session.

Callbacks can run concurrently, out of order, and more than once. Use `deliveryId` to deduplicate side effects:

```ts
async onEvent({ event, deliveryId }) {
  await fetch("https://crm.example/notes", {
    method: "POST",
    headers: { "Idempotency-Key": deliveryId },
    body: JSON.stringify(event.data),
  });
},
```

### Handle missed events and ended subscriptions

```ts
experimental: {
  events: {
    async onEvent({ event }) {
      // ...
    },
    async onGap({ origin, auth, attachSession }) {
      // The server could not deliver every event. Re-read the source.
      await attachSession(origin.sessionId).send(
        "Some Bird events were missed. Re-check the mailbox.",
        { auth },
      );
    },
    async onTerminated({ origin, auth, error, attachSession }) {
      // The upstream subscription ended. The watch ends when this returns.
      await attachSession(origin.sessionId).send(
        `The Bird watch ended: ${error.message}`,
        { auth },
      );
    },
  },
},
```

### What the model can call

`eve__search` lists three kinds of actions for the connection:

| Action                    | Input                  | Result                                  |
| ------------------------- | ---------------------- | --------------------------------------- |
| `events_subscribe_<hash>` | `{ arguments, ttlMs }` | `{ id, status, expiresAt, ... }`        |
| `events_get_subscription` | `{ id }`               | The subscription, or unknown once ended |
| `events_unsubscribe`      | `{ id }`               | `{ id, status: "stopping" }`            |

Each event the server offers gets one subscribe action, whose description names the event. `ttlMs` is required: an integer from `120000` (2 minutes) to `31536000000` (365 days), or `null` to watch until stopped. `expiresAt` is the resulting deadline, or `null`.

- **Pending.** If Connect has not confirmed in time, subscribe returns the `id` with `status: "pending"`. Call get with that `id` later.
- **Repeats.** Subscribing again to the same event with the same arguments from the same session returns the existing watch. Another session gets its own watch.
- **Stopping.** Get and stop accept the `id` from any of the creator's sessions. Until Connect confirms the unsubscribe, get still reports the watch and stop can be called again.
- **Lifetime.** A watch outlives the session that created it. It ends on stop, at `expiresAt`, or when the upstream subscription terminates.

`tools.allow` and `tools.block` apply to these actions, so an allow list must name the exact `events_subscribe_<hash>` names from `eve__search`. `toolCall.providedArguments` doesn't apply to them.

### Receiver

eve serves `POST /eve/v1/hooks/:connectionName`, under any configured agent or public prefix. It accepts only requests that carry Connect's token, and only for a watch that is still active. It answers `410` once the watch has ended. The callback's `auth` always comes from the saved watch, never from the event body.

## Troubleshooting

| What you see                               | What to do                                                                                                                                 |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `principal_required`                       | `connect("...")` needs a signed-in user, and the session had none. Return `principalType: "user"` from route auth, or use app-scoped auth. |
| The model does not find the remote tool    | Improve the connection `description`, then check `tools.allow` / `tools.block`.                                                            |
| OAuth works locally but fails after deploy | Attach the Connect connector to the deployed Vercel project and verify the UID in `connect("...")`.                                        |
| The server rejects requests                | Confirm the MCP URL, transport support, auth scheme, required headers, and application-provided arguments.                                 |

## What to read next

- [Connections](../connections): shared auth, headers, approval, and per-caller patterns.
- [OpenAPI connections](./openapi): use one when the service publishes an HTTP API contract and you want one tool per operation.
- [Authentication](../guides/auth-and-route-protection): establish the caller identity required by user-scoped auth.
- [Security model](../concepts/security-model): how connection credentials stay out of the model's reach.

---

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)