UNPKG

eve

Version:

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

243 lines (180 loc) 13.4 kB
--- 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.