Overview
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. To instead let eve communicate with users through an external service, use a channel.
Connections live under agent/connections/. A static connection's runtime name comes from the filename, so agent/connections/linear.ts registers as "linear". A dynamic connection file can instead resolve a caller-specific connection set at session or turn boundaries. 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 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 for defineOpenAPIConnection, baseUrl, and operation filters.
Dynamic connections
Use defineDynamic when the connection set depends on the authenticated user,
tenant, or another runtime lookup. Import it with the connection factories from
eve/connections, then return one connection definition, a map of definitions,
or null from session.started or turn.started.
import { defineDynamic, defineMcpClientConnection } from "eve/connections";
import { listAccounts } from "../lib/accounts";
export default defineDynamic({
events: {
"session.started": async (_event, ctx) => {
const accounts = await listAccounts(ctx.session.auth.current);
return Object.fromEntries(
accounts.map((account) => [
account.slug,
defineMcpClientConnection({
url: account.mcpUrl,
description: account.description,
instanceKey: account.id,
auth: account.auth,
}),
]),
);
},
},
});For a single returned definition, the filename supplies the connection name.
For a map, each bare map key becomes a connection name. Every entry must use
defineMcpClientConnection or defineOpenAPIConnection. Authenticated entries
must also set instanceKey to a stable, non-secret account or tenant identifier.
See Dynamic capabilities
for event scope, naming, conflicts, and durable recovery behavior.
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:
import { defineMcpClientConnection } from "eve/connections";
export default defineMcpClientConnection({
url: "https://mcp.linear.app/mcp",
description: "Linear workspace.",
auth: {
getToken: async () => ({ token: process.env.LINEAR_API_TOKEN! }),
},
});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:
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:
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:
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:
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:
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.
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. The connect() helper from @vercel/connect/eve handles consent, encrypted token storage, and refresh, then hooks all of that into eve's authorization flow.
Create and attach the connector from the Vercel project or agent app that will use it:
npm install @vercel/connect
vercel link
vercel connect create <service> --name <name>
vercel connect attach <connector-uid> --yes
vercel env pullUse the connector UID returned by the CLI in connect("<connector-uid>"). The service identifier accepted by vercel connect create depends on the provider; it is not necessarily the same as an MCP runtime URL or OpenAPI base URL.
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:
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. See Authentication when a user-scoped connection needs your HTTP channel to establish the caller's identity.
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.
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 resolved name still keys
the callback URL, while eve scopes token caching and authorization completion
to the opaque resolved instance identity. 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 fromgetTokento emitauthorization.requiredand kick off the flow.ConnectionAuthorizationFailedError(connectionName, { reason?, retryable? }): authorization failed.reasonis a stable machine-readable code (e.g."access_denied") that shows up on theauthorization.completedevent and the failed tool result.retryabledefaults totrue; set it tofalsefor terminal cases like user denial so the runtime stops re-prompting.
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.
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: connect to remote MCP servers.
- OpenAPI connections: generate tools from OpenAPI operations.
- Integrations: browse every connection eve ships using the Connections filter.
- Tools: authored tools live alongside connection-provided tools; the same approval helpers apply.
- Security model: how connection credentials stay out of the model's reach.