eve
Version:
Filesystem-first framework for durable backend AI agents that run anywhere.
243 lines (180 loc) • 13.4 kB
text/mdx
---
title: "Connections"
description: "Expose external MCP and OpenAPI servers to the model, with connection tokens the model never sees."
---
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
`defineMcpClientConnection` points at an MCP server. Supply a `url` and a `description`:
```ts title="agent/connections/linear.ts"
import { defineMcpClientConnection } from "eve/connections";
export default defineMcpClientConnection({
url: "https://mcp.linear.app/sse",
description: "Linear workspace: issues, projects, cycles, and comments.",
auth: {
getToken: async () => ({ token: process.env.LINEAR_API_TOKEN! }),
},
});
```
The `url` must speak Streamable HTTP or SSE. Write the `description` for the model, not for yourself. It shows up in `connection_search`, and the model uses it to decide which connection to query.
### 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.
### No auth
Drop `auth` entirely for servers that need no token, such as a localhost server during development or a public one:
```ts
export default defineMcpClientConnection({
url: "http://localhost:3001/mcp",
description: "Local dev server.",
});
```
We recommend using 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`:
```ts
export default defineMcpClientConnection({
url: "https://example.com/mcp",
description: "Example service.",
headers: { "X-Api-Key": process.env.EXAMPLE_API_KEY! },
});
```
### Tool filters
To narrow which remote tools the model sees, set exactly one of `tools.allow` or `tools.block`. Filtered-out tools do not appear in `connection_search`:
```ts
export default defineMcpClientConnection({
url: "https://mcp.linear.app/sse",
description: "Linear: read-only.",
auth: { getToken: async () => ({ token: process.env.LINEAR_API_TOKEN! }) },
tools: { allow: ["search_issues", "get_issue"] },
});
```
### Per-connection approval
To put every tool a connection serves behind a human, use the helpers from `eve/tools/approval`:
```ts
import { once } from "eve/tools/approval";
export default defineMcpClientConnection({
url: "https://mcp.linear.app/sse",
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](./tools).
For connection tools that can create, modify, delete, transmit, purchase, message, or access sensitive data, use approval, tool allow-lists, or other safeguards appropriate to the action.
## OpenAPI connections
`defineOpenAPIConnection` turns any OpenAPI 3.x document into connection tools, one per operation. Pass an HTTPS URL eve fetches at runtime, or an inline parsed object:
```ts title="agent/connections/petstore.ts"
import { defineOpenAPIConnection } from "eve/connections";
export default defineOpenAPIConnection({
spec: "https://petstore3.swagger.io/api/v3/openapi.json",
description: "Pet store inventory and orders.",
auth: { getToken: async () => ({ token: process.env.PETSTORE_TOKEN! }) },
});
```
Each operation becomes `<connection>__<operationId>` (e.g. `petstore__getInventory`). When an operation has no `operationId`, eve derives a deterministic `<method>_<sanitized-path>` name instead.
`auth`, `headers`, and `approval` work exactly as they do for MCP. There are two fields specific to OpenAPI:
| Field | Purpose |
| ------------ | ----------------------------------------------------------------------------------------------------------------------- |
| `baseUrl` | Base URL operation paths resolve against. Optional; defaults to the document's first usable `servers` entry. |
| `operations` | Filter keyed on `operationId` (`allow` or `block`). Mirrors `tools` on MCP connections, but names operations not tools. |
## 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/sse",
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-managed OAuth is user-scoped by default, so the runtime resolves the per-user token before each tool call. The full setup (Connect client provisioning, project linking, the runtime consent flow) lives in [Auth & route protection](./guides/auth-and-route-protection).
## 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/sse",
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
- [Integrations](/integrations): browse every channel and connection eve ships, in one gallery.
- [Tools](./tools): authored tools live alongside connection-provided tools; the same approval helpers apply.
- [Auth & route protection](./guides/auth-and-route-protection): the full interactive-OAuth flow with Vercel Connect.
- [Security model](./concepts/security-model): how connection credentials stay out of the model's reach.