eve
Version:
Filesystem-first framework for durable backend AI agents that run anywhere.
280 lines (205 loc) • 19.1 kB
text/mdx
---
title: "Overview"
description: "Expose external MCP and OpenAPI servers to the model, with connection tokens the model never sees."
url: /connections
---
A connection wires an agent into an external server you don't author, either an MCP server (Linear, GitHub, a warehouse) or any HTTP API with an OpenAPI document. eve handles the parts you'd otherwise hand-roll, discovering the remote tools, surfacing them to the model, and brokering auth.
Connections live under `agent/connections/`. The runtime name comes from the filename, so `agent/connections/linear.ts` registers as `"linear"`. The model never sees a connection's URL or credentials. It discovers tools through the built-in `connection_search` and calls them by their qualified name, `<connection>__<tool>` (e.g. `linear__list_issues`).
## MCP connections
Use an MCP connection when the external service already exposes an MCP server. The server publishes its tools and schemas, and eve makes the matched tools callable by the model.
Read [MCP connections](/docs/connections/mcp) for `defineMcpClientConnection`, transport requirements, and MCP tool filters.
## OpenAPI connections
Use an OpenAPI connection when the service exposes an OpenAPI 3.x document. eve turns operations in the document into connection tools, one per operation.
Read [OpenAPI connections](/docs/connections/openapi) for `defineOpenAPIConnection`, `baseUrl`, and operation filters.
## Static-token auth
`getToken` returns a `TokenResult` (`{ token, expiresAt? }`), and eve sends it as `Authorization: Bearer <token>` on every request. Because it runs on each connection attempt, you can mint a fresh token from wherever you keep secrets, including an env var, a secrets manager, an internal vault, or your own OAuth exchange. If the token has a known TTL, set `expiresAt` (milliseconds since epoch) and eve refreshes ahead of time rather than waiting for a `401`.
When `getToken` is the only auth, `principalType` defaults to `"app"`: one shared credential keyed across all sessions. Switch to `principalType: "user"` when each end-user carries their own token.
eve resolves and caches connection tokens per step; they never land in conversation history or reach the model.
## Choose app vs. user auth
A connection credential can belong to the agent or to the person using it. This choice is separate from route auth, but user-scoped connection auth depends on route auth: eve can only resolve a user token when the active session has `ctx.session.auth.current?.principalType === "user"`.
| Credential owner | Use when | Auth shape |
| ---------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| App | The agent should use one shared service, bot, installation, or app credential. | `auth: { getToken }` defaults to `principalType: "app"`, or use `connect({ connector: "linear/myagent", principalType: "app" })` with Vercel Connect. |
| User | Each end-user should authorize and use their own third-party account. | `connect("linear/myagent")`, `connect({ connector: "linear/myagent", principalType: "user" })`, or `auth: { principalType: "user", getToken }`. |
| User from a job | Background work should use the same user's OAuth grant that started the work. | Start or resume the session through a channel whose route auth resolved that user, or pass an explicit user auth context when dispatching through a channel. |
`principalType: "user"` does not mean "ask any human later." It means "key this credential to the authenticated user already attached to the eve session." If the run was started by a schedule, a same-project runtime token, `localDev()`, or another internal runtime path without an end-user principal, a user-scoped connection fails with `reason: "principal_required"` instead of starting OAuth. In that case, either authenticate the inbound channel as a user or configure the connection as app-scoped.
## No auth
Drop `auth` entirely for servers that need no token, such as a localhost server during development or a public one:
```ts title="agent/connections/local.ts"
import { defineMcpClientConnection } from "eve/connections";
export default defineMcpClientConnection({
url: "http://localhost:3001/mcp",
description: "Local dev server.",
});
```
Use no-auth connections only for services that are intentionally public, local-only, or otherwise protected outside eve. Do not use no-auth connections for sensitive third-party services.
## Headers
Use `headers` when the server wants a non-Bearer scheme (an API-key header) or extra configuration. Headers stack on top of `auth` and work for both MCP and OpenAPI connections:
```ts title="agent/connections/example.ts"
import { defineMcpClientConnection } from "eve/connections";
export default defineMcpClientConnection({
url: "https://example.com/mcp",
description: "Example service.",
headers: { "X-Api-Key": process.env.EXAMPLE_API_KEY! },
});
```
## Per-caller auth and headers
When credentials or routing depend on the caller, make `auth` or `headers` a function. eve calls it inside the active turn and passes the same session context exposed to tools and hooks:
```ts title="agent/connections/warehouse.ts"
import { defineOpenAPIConnection } from "eve/connections";
export default defineOpenAPIConnection({
spec: "https://warehouse.example.com/openapi.json",
description: "The caller's tenant-scoped warehouse.",
auth: (ctx) => ({
principalType: "user",
getToken: async () => ({
token: await tenantToken(ctx.session.auth.current),
}),
}),
headers: (ctx) => ({
"X-Tenant-Id": tenantId(ctx.session.auth.current),
}),
});
```
An `auth` resolver returns the same provider object accepted by static `auth`, including a `connect(...)` provider for interactive OAuth. The resolver itself can be async. Header callbacks can return the whole map, as above, or resolve individual values:
```ts
headers: {
"X-Tenant-Id": (ctx) => tenantId(ctx.session.auth.current),
}
```
Use `principalType: "user"` for per-user tokens so eve rejects unauthenticated callers and keys its step-local token cache by user. The resolver supplements route auth; it does not authenticate the inbound request. Static auth objects and header maps remain available when every caller shares the same configuration.
## Per-connection approval
To put every tool a connection serves behind a human, use the helpers from `eve/tools/approval`:
```ts title="agent/connections/linear.ts"
import { defineMcpClientConnection } from "eve/connections";
import { once } from "eve/tools/approval";
export default defineMcpClientConnection({
url: "https://mcp.linear.app/mcp",
description: "Linear workspace.",
auth: { getToken: async () => ({ token: process.env.LINEAR_API_TOKEN! }) },
approval: once(),
});
```
`never()` lets every call through, `once()` asks for approval the first time in a session, and `always()` asks every time. The pause and resume is the same human-in-the-loop flow covered in [Tools](/docs/tools).
For connection tools that can create, modify, delete, transmit, purchase, message, or access sensitive data, use approval, allow-lists, or other safeguards appropriate to the action.
## Interactive OAuth via Vercel Connect
When the server uses OAuth and you want each end-user to sign in through their own browser, turn on interactive authorization with [Vercel Connect](https://vercel.com/docs/connect). The `connect()` helper from `@vercel/connect/eve` handles consent, encrypted token storage, and refresh, then hooks all of that into eve's authorization flow:
```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 workspace: issues, projects, cycles, and comments.",
auth: connect("linear/myagent"),
});
```
`"linear/myagent"` is the UID you chose when registering the Connect client. `connect("linear/myagent")` is shorthand for a user-scoped interactive OAuth connection: eve resolves a token for the active user before each tool call, emits `authorization.required` when that user has not authorized yet, and resumes the parked turn after the callback completes.
When a local subagent needs interactive authorization, eve surfaces the authorization lifecycle on the root session's channel, including through nested subagent chains. The challenge still points to the child session's callback, so completing it resumes the child directly while the parent continues waiting for its result.
That means the channel that creates or continues the session must authenticate a real user. For a web app, configure `agent/channels/eve.ts` so your app session maps to `principalType: "user"`; for platform channels, use the built-in channel auth that maps the sender to a user principal. If no authenticated user is attached to the session, the first user-scoped connection call fails with `reason: "principal_required"`.
If the remote service should act as the agent itself instead of the end-user, make the Connect connection app-scoped:
```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 workspace: issues, projects, cycles, and comments.",
auth: connect({ connector: "linear/myagent", principalType: "app" }),
});
```
App-scoped Connect auth is non-interactive. eve asks Vercel Connect for an app token and does not emit a browser consent challenge; if the connector is not installed or cannot issue an app token, the tool call fails terminally so an operator can fix the connector setup. The full setup (Connect client provisioning, project linking, the runtime consent flow) lives in [Auth & route protection](/docs/guides/auth-and-route-protection).
### Troubleshooting Vercel Connect auth
| Symptom | What it means | Fix |
| -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `reason: "principal_required"` | A user-scoped connection ran without an authenticated user on the active session. | Return `principalType: "user"` from the channel's route auth, or change the connection to `principalType: "app"` if it should be shared. |
| `authorization.required` appears but no UI | eve parked the turn for OAuth, but the channel or frontend is not rendering the challenge. | Render the challenge from the stream event and continue the same session after the callback. |
| OAuth works locally but fails after deploy | The project may not be linked to the Connect client, or the deployed runtime may not have the expected Vercel OIDC/project scope. | Run Connect setup from the consuming project directory, link the project, deploy again, and verify the connector UID in `connect("...")`. |
| A scheduled or internal run needs user OAuth | Schedules and runtime callers do not automatically carry an end-user principal. | Dispatch through a user-authenticated channel when work is user-owned, or use app-scoped auth for agent-owned background work. |
## Self-hosted interactive OAuth
To run your own OAuth, use `defineInteractiveAuthorization` from `eve/connections`, which takes a three-method form and needs no Vercel Connect. eve mints a callback URL, parks (durably suspends) the turn on a framework-owned webhook, and resumes once the token comes back. Interactive auth is always `principalType: "user"`, and the factory pins that for you.
```ts title="agent/connections/linear.ts"
import {
ConnectionAuthorizationRequiredError,
defineInteractiveAuthorization,
defineMcpClientConnection,
} from "eve/connections";
export default defineMcpClientConnection({
url: "https://mcp.linear.app/mcp",
description: "Linear workspace.",
auth: defineInteractiveAuthorization<{ verifier: string }>({
// Probed before every tool call. Return a token to run the tool;
// throw `Required` to start the consent flow.
getToken: async ({ principal }) => {
const token = await lookupCachedToken(principal);
if (!token) throw new ConnectionAuthorizationRequiredError("linear");
return { token };
},
// Runs in a durable step. Return the user-facing `challenge` and
// an optional `resume` value the runtime journals across the park.
startAuthorization: async ({ callbackUrl }) => {
const verifier = makePkceVerifier();
return {
challenge: { url: buildAuthorizeUrl(callbackUrl, verifier) },
resume: { verifier },
};
},
// Runs when the provider redirects to the callback URL. `resume` is
// typed as `{ verifier: string } | undefined`; `callback.params`
// holds the IdP's returned query/body params.
completeAuthorization: async ({ resume, callback }) => {
const token = await exchangeCode(resume!.verifier, callback.params.code!);
return { token };
},
}),
});
```
`getToken` runs before every tool call. `startAuthorization` and `completeAuthorization` are both-or-neither: provide one without the other and you get a definition error. The `challenge` rides along verbatim on the `authorization.required` event. Its fields:
| Field | Purpose |
| -------------- | ----------------------------------------------------------------------------------------- |
| `url` | The authorize URL for redirect or device flows. |
| `userCode` | The device code, for device flows. |
| `instructions` | The call to action when there's no URL. |
| `displayName` | Human-readable provider name channels show on the sign-in affordance (e.g. "Salesforce"). |
Drop `resume` when the provider keeps flow state server-side, so nothing has to cross the step boundary.
`displayName` is presentation-only. The connection's path-derived name still keys the authorization flow, token cache, and callback URL. You can also set `displayName` on the `auth` definition itself (e.g. `auth: { ...connect("salesforce/myagent"), displayName: "Salesforce" }`); that definition-level value wins over one the strategy stamps on the challenge, and channels fall back to title-casing the connection name when neither is set.
### Signaling authorization state
Two error classes drive the consent flow. Throw them from `getToken` or `completeAuthorization`; both are exported from `eve/connections`.
- `ConnectionAuthorizationRequiredError(connectionName)`: the user must authorize. Throw it from `getToken` to emit `authorization.required` and kick off the flow.
- `ConnectionAuthorizationFailedError(connectionName, { reason?, retryable? })`: authorization failed. `reason` is a stable machine-readable code (e.g. `"access_denied"`) that shows up on the `authorization.completed` event and the failed tool result. `retryable` defaults to `true`; set it to `false` for terminal cases like user denial so the runtime stops re-prompting.
```ts
import { ConnectionAuthorizationFailedError } from "eve/connections";
throw new ConnectionAuthorizationFailedError("linear", {
reason: "access_denied",
retryable: false,
});
```
To narrow a caught error, use `isConnectionAuthorizationRequiredError(err)` and `isConnectionAuthorizationFailedError(err)`. They match on `err.name`, which is why they survive the class-identity split `instanceof` can hit after bundling.
### Handling a revoked token mid-call
`getToken` only runs _before_ a tool call, so a grant revoked while a tool is mid-flight first surfaces as a downstream `401` inside your `execute`. A plain throw there is only a tool error, so the model sees a failure and the cached bearer sticks around. Instead, map a provider `401` to `ctx.requireAuth(provider)`. eve then evicts the rejected token from its per-step cache and re-runs the consent flow with a fresh one, exactly as it does for a connection whose server rejects the bearer.
```ts title="agent/tools/list_issues.ts"
import { connect } from "@vercel/connect/eve";
import { defineTool } from "eve/tools";
import { z } from "zod";
const linearAuth = connect("linear/myagent");
export default defineTool({
description: "List open Linear issues.",
inputSchema: z.object({}),
async execute(_input, ctx) {
const { token } = await ctx.getToken(linearAuth);
const res = await fetch("https://api.linear.app/graphql", {
headers: { authorization: `Bearer ${token}` },
});
// The grant was revoked since getToken ran: re-challenge instead of
// returning a dead-token error to the model.
if (res.status === 401) ctx.requireAuth(linearAuth);
return await res.json();
},
});
```
### Authorization and approval together
A tool can require both sign-in (`auth`) and a human approval. The model's approval gate runs before the tool's `execute`, so the order the user sees is **approve, then sign in**. eve records the approval on session state the moment it's granted, and that record survives the sign-in park, so when the turn resumes after authorization the tool is not put through approval again. You get one approval and one sign-in, never a double prompt.
## What to read next
- [MCP connections](/docs/connections/mcp): connect to remote MCP servers.
- [OpenAPI connections](/docs/connections/openapi): generate tools from OpenAPI operations.
- [Integrations](/integrations): browse every channel and connection eve ships, in one gallery.
- [Tools](/docs/tools): authored tools live alongside connection-provided tools; the same approval helpers apply.
- [Security model](/docs/concepts/security-model): how connection credentials stay out of the model's reach.