Skip to content
Blog
Articles4 min read

One token model for a REST API and an MCP server

JED ships a REST API and an MCP server for AI clients. I put both behind the same personal access tokens and the same handlers, so there's one set of rules to get right.

Admin screen for creating a personal access token with scope checkboxes

When I added an API to JED, I knew I'd also want an MCP server so an AI client could read and update a site's content. The easy path would have been to build them separately. A REST layer here, an MCP layer there, each with its own auth and its own idea of what a user may do.

I didn't want two sets of rules to keep in sync. So in JED, the REST API under /api/v1 and the MCP server at /api/mcp share one list of operations, one set of handlers, and one kind of token.

One registry, two doors

Every operation lives in a single registry. Each entry has an id, an HTTP method and path, an MCP tool name, the scopes it needs, and a Zod schema for its input. get_settings is GET /api/v1/settings over REST and the get_settings tool over MCP. Both call the same handler.

That means a rule I add to a handler applies everywhere at once. The OpenAPI spec at /api/v1/openapi.json is generated from the same registry, so the docs can't drift from the code either.

Tokens that can't outrank their owner

Admins create personal access tokens in the admin panel. Each token gets a name, an expiry between 1 and 365 days, and a set of scopes. The scopes are the same permission codes the rest of the app uses, like content.read or settings.write, and you can only pick ones you already have.

On every request, the token's scopes are checked against the owner's current permissions. If someone loses their admin role, their tokens lose admin access on the next call. Only a SHA-256 hash of the token is stored, and the full token is shown once, right after you create it.

A REST call looks like this:

curl https://client-site.com/api/v1/content \
  -H "Authorization: Bearer $TOKEN"

The MCP config uses the same token in a header:

{
  "mcpServers": {
    "jed": {
      "url": "https://client-site.com/api/mcp",
      "headers": { "Authorization": "Bearer YOUR_TOKEN_HERE" }
    }
  }
}

Running as the person, not as the server

The part I cared most about is what happens after the token checks out. The server doesn't switch to a key that skips the rules. It signs a short-lived session for the token's owner, valid for five minutes, and runs the request through a normal Supabase client as that person. Row level security applies exactly as it would if they were clicking around the admin.

So the API can't read anything the owner couldn't read in the browser. If I get a handler wrong, the database still refuses what the policies don't allow.

Extra care for AI clients

An AI client can retry a call, misunderstand a request, or chain several tools together. For MCP, every tool that changes data needs two extra arguments:

{
  "name": "save_settings",
  "arguments": {
    "contactEmail": "hello@example.com",
    "idempotencyKey": "settings-contact-oct",
    "confirm": true
  }
}

confirm: true gives a person a clear point to approve the change before the client sends it. The idempotency key means a retried call returns the first result instead of making the change twice. REST writes need the same key, sent as an Idempotency-Key header. Keys are kept for 24 hours per token.

Everything goes in the audit log

Every authenticated call is written to the audit log with the token that made it. Calls that change data get an extra entry, and a write refused for missing scope is logged as api.denied. The API and MCP screen also shows call counts and the last-used time for each token, which makes it easy to spot ones nobody needs anymore.

Why one model

Two systems with similar rules eventually disagree, and the disagreement is usually where the security bug lives. One registry, one token type, and one path to the database give me a single place to look when something goes wrong. That matters more to me than any one feature of either API.

  • JED
  • API
  • MCP
  • Security