---
title: "Use an MCP Gateway With Vercel Eve Agents"
description: "Most MCP gateways are built for a human to sign in, but a scheduled agent has no one at the keyboard. Here's how to run a Vercel Eve agent against Zuplo's MCP Gateway unattended, no static key required."
canonicalUrl: "https://zuplo.com/blog/2026/06/19/use-an-mcp-gateway-with-vercel-eve-agents"
pageType: "blog"
date: "2026-06-19"
authors: "martyn"
tags: "Model Context Protocol, ai-agents"
image: "https://zuplo.com/og?text=Use%20an%20MCP%20Gateway%20With%20Vercel%20Eve%20Agents"
---
[Eve](https://eve.dev) is Vercel's filesystem-first framework for building
durable agents, currently in beta, and connecting one to an MCP server is a
single file. That makes it a clean place to show a pattern worth copying:
instead of handing the agent a static API key for the upstream, point it at an
OAuth-protected route on the
[Zuplo MCP Gateway](/blog/introducing-zuplo-mcp-gateway) and let the gateway
hold the key.

I'll use Buffer as the example. Its MCP server authenticates with one static key
and nothing else, so the gateway is what gives it OAuth, tool curation, and a
per-call audit. The catch: most agents run with no human at runtime, and the
gateway dictates how a headless one can authenticate, not you. A grant that
needs a browser is a non-starter for a cron job, so what the gateway's OAuth
allows decides the whole design. Check it before you write any agent code.

<CalloutAudience
  variant="bestFor"
  items={[
    "You run Eve agents headless, on a schedule or behind a service",
    "The MCP server you need only ships a static API key",
    "You want OAuth, tool curation, and a per-call audit without a key in the agent",
  ]}
/>

## Anatomy of an Eve agent

Eve scaffolds with one command that drops in the `eve`, `ai`, and `zod`
dependencies and starts a dev server:

```bash
npx eve@latest init my-agent
```

An agent is a directory of files rather than a config object:

```
agent/
  agent.ts          # model and config
  instructions.md   # system prompt
  tools/            # your own tools, one file each
  connections/      # external MCP servers, one file each
```

The model lives in `agent/agent.ts`:

```ts
// agent/agent.ts
import { defineAgent } from "eve";

export default defineAgent({
  model: "anthropic/claude-opus-4.8",
});
```

A connection's filename becomes its identifier, so `agent/connections/buffer.ts`
registers as `buffer`. The model discovers its tools automatically and never
sees the connection's URL or credentials.

## Connect Eve directly to Buffer

Of course you can point the agent straight at Buffer, no gateway involved. The
shortest path is a static token: the agent talks to Buffer's hosted MCP server
with an API key from
[Buffer's settings](https://publish.buffer.com/settings/api):

```ts
// agent/connections/buffer.ts
import { defineMcpClientConnection } from "eve/connections";

export default defineMcpClientConnection({
  url: "https://mcp.buffer.com/mcp",
  description: "Buffer: channels, posts, drafts, and post analytics.",
  auth: {
    getToken: async () => ({ token: process.env.BUFFER_API_KEY! }),
  },
});
```

That's fine while it's just you experimenting locally. Share it and you're
holding the static key's usual problems:

- **The key lives in the agent.** Your Buffer credential sits in the agent
  runtime's environment, copied wherever the agent runs.
- **No curation.** Every tool Buffer ships is in scope, `create_post` and
  `delete_post` included.
- **All-or-nothing revocation.** Cut off access and you rotate the key
  everywhere at once.

## Front Buffer with the gateway

The gateway fronts one or more upstream MCP servers behind a single
OAuth-protected route. The shape is
`https://<your-gateway>/mcp/<upstream-name>`, so a Buffer route reads like
`https://mcp.yourdomain.xyz/mcp/buffer`. It speaks OAuth 2.1, which Buffer
doesn't offer yet, and holds Buffer's API key upstream so the agent no longer
carries it.

The gateway's auth model decides how your agent authenticates, so it's the first
thing I checked. Its token endpoint, in Zuplo's words, "accepts
`authorization_code` and `refresh_token` grants" and nothing else: no
`client_credentials`, no service account, nothing a headless process can present
on its own. Tokens come from authorization-code with PKCE, and self-registered
clients expire after 90 days, which matters later.

That leaves a headless agent one way in: a person signs in through the browser
once to mint a refresh token, then the agent trades that refresh token for fresh
access tokens on its own, with no browser after the first time.

## Bootstrap once, run headless

The headless pattern is **bootstrap once, refresh forever**:

1. **Once, with a human (`npm run bootstrap`).** Register a public client via
   dynamic client registration (DCR) and run authorization-code + PKCE in the
   browser. The code exchange returns an access token and a refresh token; you
   save the refresh token.
2. **Every run, headless.** The connection's `getToken` trades that saved
   refresh token for a fresh access token. No browser.

I keep the refresh in a helper, so the runtime connection stays tiny:

```ts
// agent/connections/buffer.ts
import { defineMcpClientConnection } from "eve/connections";
import { refreshAccessToken } from "../lib/oauth.ts";

export default defineMcpClientConnection({
  url: process.env.MCP_GATEWAY_URL!, // https://mcp.yourdomain.xyz/mcp/buffer
  description: "Buffer: channels, posts, drafts, and post analytics.",
  // Headless: trade a pre-authorized grant for a fresh token. principalType
  // defaults to "app", one shared grant across sessions, no user to wire up.
  auth: { getToken: () => refreshAccessToken() },
});
```

There's no user principal to wire up: `principalType` stays at its default
`"app"`, one grant shared across every session.

Eve ships a managed OAuth path of its own, Vercel Connect, and it's worth saying
why this doesn't use it. `connect()` manages consent, token storage, and refresh
for you, and for a per-user agent against a provider it already knows, it's the
right tool. This isn't that. The upstream is a route on your own gateway, not
one of Connect's registered providers, and a scheduled service wants a single
app-level grant rather than a per-user sign-in. So the refresh lives in
`getToken`, the one auth hook that's a plain async call instead of an
interactive flow.

The refresh half is the only other moving part, and the grant is the whole point
of it:

```ts
// agent/lib/oauth.ts
import { getGrant, storeRefreshToken } from "./token-store.ts";

// Trade the stored refresh token for a fresh access token. No browser.
export async function refreshAccessToken() {
  const { clientId, refreshToken } = await getGrant();
  const endpoint = await tokenEndpoint(); // from the gateway's well-known metadata

  const res = await fetch(endpoint, {
    method: "POST",
    headers: { "content-type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "refresh_token",
      refresh_token: refreshToken,
      client_id: clientId,
      resource: process.env.MCP_GATEWAY_URL!, // RFC 8707, the gateway requires it
    }),
  });

  const tokens = await res.json();
  if (tokens.refresh_token) await storeRefreshToken(tokens.refresh_token); // they rotate
  return {
    token: tokens.access_token,
    expiresAt: Date.now() + tokens.expires_in * 1000,
  };
}
```

Eve injects whatever `getToken` returns as `Authorization: Bearer` and refreshes
ahead of `expiresAt` on its own, so most steps reuse a cached token. It doesn't
persist anything for you on this path though (only Vercel Connect does), so you
keep the refresh token yourself. That leaves four small files behind the
one-line connection:

| File                          | Runs               | What it does                                                 |
| ----------------------------- | ------------------ | ------------------------------------------------------------ |
| `scripts/bootstrap.ts`        | once, with a human | runs the one-time browser sign-in and stores the first grant |
| `agent/lib/token-store.ts`    | every run          | reads and writes `{ clientId, refreshToken }`                |
| `agent/lib/oauth.ts`          | every run          | trades the refresh token for a fresh access token            |
| `agent/connections/buffer.ts` | every run          | the Eve connection; its `getToken` calls the refresh helper  |

Only `bootstrap.ts` involves a person, and only the first time. The honest cost
is re-bootstrapping when the refresh token or the 90-day registered client
expires.

One thing to get right: that refresh token is a long-lived secret. The sample
keeps it in a plaintext `.eve/oauth-store.json` so it's easy to inspect, but in
production `token-store.ts` should read from a secrets manager or encrypted KV,
not a file on disk.

<CalloutSample
  title="Eve agent behind the MCP Gateway"
  description="The complete project, including the one-time bootstrap script and token store this post abstracts over, the refresh helper, the Buffer connection, and the weekly-metrics schedule."
  repoUrl="https://github.com/zuplo-samples/eve-agent-with-gateways"
/>

## Run the agent on a schedule

Headless auth removes the human from the credential, not from the trigger:
something still has to start the agent. An Eve session can start plenty of ways,
from an HTTP call or a Slack or GitHub mention to an inbound webhook or a
schedule. The one that runs with nobody prompting it is the schedule, and
schedules run in task mode, which the docs are blunt about:

> A task-mode session runs to completion or fails, and cannot park to wait for a
> person or an OAuth sign-in.

The interactive connection authorizes by parking the turn for a browser sign-in,
and task mode forbids parking, so an interactive, per-user connection can't run
on a schedule at all. The headless connection refreshes its token with a plain
`getToken`, an ordinary async call, so it runs fine under cron. The
refresh-token bootstrap isn't just a way to go headless; it's the only way to
put this agent on a schedule. Pick interactive and a human triggers every run.
Pick headless and you've unlocked cron. Same decision, two consequences.

The schedule that runs it is one file: the `cron` frontmatter sets the cadence,
the body is the prompt.

```md
## <!-- agent/schedules/weekly-metrics.md -->

## cron: "0 9 \* \* 1"

Produce a weekly Buffer performance summary covering the last 7 days.

1. Call `get_account`, then `list_channels` to enumerate every channel.
2. For each channel, pull aggregated post metrics for that 7-day window.
3. Combine everything into ONE cross-channel summary: weekly totals, a short
   per-channel breakdown, and the best and worst performer.

Use only the tools the `buffer` connection exposes. If a metric isn't available,
say so rather than inventing numbers.
```

`cron` is a standard 5-field expression, so `0 9 * * 1` is Mondays at 9am. On
Vercel each schedule becomes a Vercel Cron Job evaluated in UTC. The model reads
the seven-day window from the prompt, so I keep it explicit there rather than
leaving it implied.

Worth knowing before you rely on it:

- **Task mode discards the delivered output.** Fine when the run streams to you
  or the job is a side effect, but a digest someone should read needs a
  destination: the handler form (`run`) can `receive(...)` into Slack or
  Discord, or you tell the agent to POST the summary to an endpoint.
- **Only a real cron runner fires it on cadence.** `eve dev` never fires
  schedules on their cron cadence; `eve start` or Vercel Cron does.

## The headless trade-off

The gateway also supports the interactive flow, where a person signs in each
session and the agent acts for that named user. Headless trades that identity
for unattended runs:

| Gateway job                      | Interactive (per user)         | Headless (bootstrapped grant)         |
| -------------------------------- | ------------------------------ | ------------------------------------- |
| Buffer key held upstream         | yes                            | yes                                   |
| Tool curation (`MethodNotFound`) | yes                            | yes                                   |
| Per-call audit                   | yes, per named person          | yes, attributed to the one grant      |
| Revocation                       | revoke the user in your IdP    | revoke the grant or rotate the client |
| Which person did this            | yes                            | no, it's the service, by design       |
| Human at runtime                 | every new session, first touch | none, one upfront bootstrap           |

Tool curation is a configuration change on the route, not a redeploy of the
agent. For an analytics agent I drop `create_post` and `delete_post` from the
Buffer route: it can still read `get_aggregated_post_metrics` but can never
publish, because a tool the gateway doesn't expose returns `MethodNotFound`.

<CalloutDoc
  title="MCP Gateway Quickstart"
  description="Build a virtual MCP server in the browser: pick an upstream, wire up OAuth against your IdP, curate the tools, and hand an agent the route URL."
  href="https://zuplo.com/docs/mcp-gateway/quickstart"
  icon="book"
/>

## Run it for real

We front Buffer through our own gateway exactly this way, so the pattern here is
the one we run in-house. It is the same move we use to
[wrap a token-only MCP server in OAuth](/blog/wrap-token-only-mcp-server-in-oauth)
and the boundary
[Anthropic argued for](/blog/anthropic-made-the-case-for-mcp-gateways) without
naming a vendor: contain the agent at a deterministic boundary rather than
trusting the credential in its environment.

On the agent side it stays small: one connection file, a refresh helper you
write once, and a one-file schedule. After a single sign-in you never repeat,
the same agent pulls a weekly Buffer summary across every channel on a
Monday-morning cron with no human in the loop, reaching Buffer through a route
that holds the key, exposes only the tools you allow, and logs every call.

The MCP Gateway is in public beta on every plan, the free tier included.
[Spin up a free Zuplo project](https://portal.zuplo.com/signup?utm_source=zuplo-blog&utm_medium=web&utm_campaign=mcp-gateway)
and point your first Eve agent at a governed route.