Zuplo
Model Context Protocol

Use an MCP Gateway With Vercel Eve Agents

Martyn DaviesMartyn Davies
June 19, 2026
9 min read

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.

Eve 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 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.

Best for:
  • 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:

Terminalbash
npx eve@latest init my-agent

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

plaintext
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:

TypeScriptts
// 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:

TypeScriptts
// 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:

TypeScriptts
// 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:

TypeScriptts
// 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:

FileRunsWhat it does
scripts/bootstrap.tsonce, with a humanruns the one-time browser sign-in and stores the first grant
agent/lib/token-store.tsevery runreads and writes { clientId, refreshToken }
agent/lib/oauth.tsevery runtrades the refresh token for a fresh access token
agent/connections/buffer.tsevery runthe 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.

Try it yourself

Eve agent behind the MCP Gateway

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.

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.

Markdownmd
## <!-- 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 jobInteractive (per user)Headless (bootstrapped grant)
Buffer key held upstreamyesyes
Tool curation (MethodNotFound)yesyes
Per-call audityes, per named personyes, attributed to the one grant
Revocationrevoke the user in your IdPrevoke the grant or rotate the client
Which person did thisyesno, it’s the service, by design
Human at runtimeevery new session, first touchnone, 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.

MCP Gateway Quickstart

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.

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 and the boundary Anthropic argued for 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 and point your first Eve agent at a governed route.