Zuplo
Model Context Protocol

Wrap a Token-Only MCP Server in OAuth

Martyn DaviesMartyn Davies
June 10, 2026
5 min read

Plenty of MCP servers ship one auth option: a static token you paste into every client. Front it with the Zuplo MCP Gateway and clients get a real OAuth flow while the token stays sealed in the gateway.

Plenty of useful MCP servers authenticate one way: a static token you paste into every client config. The token is usually powerful, it sits in plain text wherever the client keeps its connectors, and it carries no per-user identity or revocation story. Tinybird’s MCP server works this way. It speaks Streamable HTTP and takes its token as a query parameter (?token=), with no OAuth option at all.

The Zuplo MCP Gateway fronts any MCP server whose only upstream auth is a static API key or token. It presents OAuth 2.1 to clients against your own identity provider and supplies the static credential upstream, so the key never reaches the client. I’ll use Tinybird as the worked example, but the same steps apply to any MCP server that uses tokens or API keys as its authentication method.

MCP client connects to the Zuplo MCP Gateway over OAuth 2.1 with PKCE; the gateway verifies the user against your identity provider, then reaches the Tinybird MCP server upstream by injecting the static token as a query parameter, so the token never reaches the client.

Best for:
  • The upstream MCP server only supports a static API key or token
  • You want users to connect through your own identity provider
  • You don't want the credential living in client configs
  • You need per-user identity or central revocation

Front Tinybird in the portal

Open a Zuplo project, go to the Code tab, click Add Route, and pick MCP Gateway Virtual Server. That route type fronts one upstream MCP server and hands you back a governed Gateway URL.

The wizard opens on a library of known servers. Tinybird isn’t a preset, so choose the custom MCP server option and set the upstream URL to https://mcp.tinybird.co. Leave the token out of this URL. It goes in later as a policy, not baked into the address.

Click Next to set inbound authentication, the layer that gates who connects to the gateway. Pick your identity provider here. I use Google, and Zuplo ships presets for Auth0, Okta, WorkOS, Entra, generic OIDC, and many more. Whichever you pick, clients authenticate against the same login my team already uses. The wizard names the inbound policy and lists the environment variables it needs.

On the Tools step you decide what the gateway exposes downstream. Passthrough forwards every tool the upstream offers. Curate lets you tick exactly which ones, and it is worth doing here. Tinybird’s server exposes discovery tools like list_datasources, list_endpoints, and explore_data, alongside execute_query and text_to_sql, which run arbitrary SQL against your workspace.

If your agents only need to read and explore, leave the discovery tools on and drop execute_query and text_to_sql so nothing can run SQL you didn’t intend. A tool the gateway doesn’t expose can’t be called, so curation is a hard boundary, not a hint. Finish the wizard and Save.

Inject the static token as a query parameter

The wizard handled the client side. The upstream side, how the gateway reaches Tinybird, is separate. Tinybird authenticates with a query-string token, not OAuth and not a header, so the upstream credential is an ordinary Zuplo policy rather than a wizard toggle. In the route’s policy list, add the Add or Set Query Parameters policy, set the Name field to set-query-params-inbound, and give it one token parameter with the value $env(TINYBIRD_TOKEN), which reads from a Zuplo environment variable at request time. The policy body looks like this:

JSONjson
{
  "export": "SetQueryParamsInboundPolicy",
  "module": "$import(@zuplo/runtime)",
  "options": {
    "params": [
      {
        "name": "token",
        "value": "$env(TINYBIRD_TOKEN)"
      }
    ]
  }
}

Zuplo portal policy editor for the Add or Set Query Parameters policy. The Name field is set to set-query-params-inbound and the policy body sets a single token query parameter to $env(TINYBIRD_TOKEN).

Then go to Settings, Environment Variables, and set TINYBIRD_TOKEN to a token you generate in Tinybird. Use a resource-scoped token or a JWT, not an admin token, so least privilege holds even inside the gateway.

The policy sets the value rather than appending, so it overwrites any token a client tries to send. The first time I wired this up I half-expected a client-supplied token to slip through, but the overwrite default silently won, which is exactly the behavior you want here. The secret stays in an environment variable, out of both the upstream URL and the client.

Connect an upstream that doesn't speak OAuth

Zuplo's guide to composing ordinary policies for non-OAuth upstreams, plus Tinybird's own MCP server docs for token types and scoping.

What the client connects to

Save and deploy, then point an MCP client at the route URL. That URL is your gateway’s base hostname plus the path you set in the wizard, like https://mcp-gateway-ui-main-76f74a7.d2.zuplo.dev/mcp/tinybird.

When I connect Claude to mine, it sees a normal OAuth connector. It identifies itself automatically (Claude uses Client ID Metadata Documents, older clients fall back to Dynamic Client Registration), so there’s no app to set up by hand, runs the PKCE handshake against your identity provider, and asks the user to approve once.

Claude's Authorize access dialog asking to authorize the Claude OAuth client metadata URL to access the tinybird-mcp-server on the user's behalf, with Cancel and Authorize buttons.

No token is pasted anywhere, and revoking a person’s access is a change in your IdP, not a token rotation across every machine that holds it. Once it’s connected, Claude lists the Tinybird tools served through the gateway.

Claude's Connectors panel showing Tinybird connected as a custom Web connector, served from a Zuplo gateway URL, with tool permissions listed: execute_query, explore_data, list_datasources, list_endpoints, list_service_datasources, and text_to_sql.

That hostname is my Working Copy, Zuplo’s development environment, where saves deploy instantly while you test the connect flow. It’s not where production agents should point. Before you hand the URL to other users, deploy to production from source control and put the gateway on a custom domain, so the URL you give clients is one of yours rather than a generated dev hostname.

Zuplo environments

How Working Copy, preview, and production environments differ, and how source control and a custom domain shape the URL you hand to clients.

Why the token never leaks

The differentiated part is not tool filtering or auth translation, which most gateways now do. It is two things.

First, you wrapped a server that has no OAuth at all. Tinybird offers a static token and nothing else, and the gateway turns that into a standards-based connect flow without Tinybird changing anything.

Second, the credential the gateway issues is bound to this one route. The gateway treats each virtual server as its own OAuth resource, so the access token a client holds is only valid at the route it was issued for. A token minted for your Tinybird route can’t be replayed against any other route or upstream.

The Tinybird token itself stays scoped by the JWT or resource-scoped token you chose, so least privilege holds upstream too. You can also lean on the gateway’s per-tool, per-user analytics to watch the SQL agents run, exactly what Tinybird recommends.

This is just a Zuplo route, so the rest of the policy library composes on top: rate limits, request validation, logging. The same pattern fronts any token-only server you need to expose, the way we front Linear, Stripe, and Notion internally.

The MCP Gateway is in public beta on every plan, the free tier included. Spin up a free Zuplo project and wrap your first token-only server today.