MCP authorization — protect an MCP server with Helix¶
The Model Context Protocol (MCP) lets an AI agent call tools on a server. The MCP authorization spec builds directly on OAuth 2.1: an MCP server is just an OAuth resource server, and the agent is an OAuth client. Helix is the authorization server — so any MCP client that follows the spec can discover Helix, get a token, and call your MCP server, with no Helix-specific code on either side.
This page shows the full handshake, the four RFCs that make it work, and a runnable end-to-end demo you can execute against a local Helix.
This is the agent story, applied to tools
An MCP server is just an API an agent calls, so everything in Agent authorization applies unchanged: the token carries the caller's nhi identity and actor chain, its authority is the intersection of every bound, and it is audience-bound to the one MCP server it was minted for. MCP auth is the discovery + challenge protocol that gets that token into the agent's hands.
The handshake¶
The whole point of the spec is that the agent needs zero prior configuration — it discovers everything from a 401. Five steps:
AGENT (MCP client) MCP SERVER (resource) HELIX (authorization server)
│ │ │
│ 1. POST /mcp (no token) │ │
│ ─────────────────────────────────▶ │
│ 401 + WWW-Authenticate: │ │
│ Bearer resource_metadata="…" │ │
│ ◀───────────────────────────────── │
│ │ │
│ 2. GET /.well-known/oauth-protected-resource (RFC 9728) │
│ ─────────────────────────────────▶ │
│ { resource, authorization_servers:[HELIX] } │
│ ◀───────────────────────────────── │
│ │ │
│ 3. GET /.well-known/openid-configuration (RFC 8414) │
│ ─────────────────────────────────────────────────────────────────────▶
│ { token_endpoint, … } │
│ ◀─────────────────────────────────────────────────────────────────────
│ │ │
│ 4. POST token_endpoint grant_type=client_credentials │
│ resource=<this MCP server> (RFC 8707) │
│ ─────────────────────────────────────────────────────────────────────▶
│ access_token (aud = <this MCP server>, nhi=true, act chain) │
│ ◀─────────────────────────────────────────────────────────────────────
│ │ │
│ 5. POST /mcp Authorization: Bearer <token> │
│ ─────────────────────────────────▶ │
│ 200 tools/call result │ (verifies signature, iss, │
│ ◀───────────────────────────────── exp, and aud == this server) │
| Step | Spec | What happens |
|---|---|---|
| 1 | MCP auth / RFC 9728 | The unauthenticated call is refused with a WWW-Authenticate: Bearer resource_metadata="…" challenge pointing at the server's metadata. |
| 2 | RFC 9728 | The client fetches Protected Resource Metadata, learning the resource id and which authorization server (Helix) to use. |
| 3 | RFC 8414 | Standard OAuth/OIDC discovery on Helix yields the token endpoint (and, for interactive agents, the authorization + registration endpoints). |
| 4 | RFC 8707 | The client requests a token, passing resource=<the MCP server>. Helix binds the token's aud to exactly that server. |
| 5 | RFC 9068 | The server validates the JWT: signature via Helix's JWKS, issuer, expiry, and — critically — that aud is this server. |
Why audience binding matters¶
Step 4 is the security crux. Because the token's aud is pinned to one MCP server (RFC 8707 resource indicators), a token minted for payments-mcp cannot be replayed against email-mcp, even though both trust the same Helix. A leaked or over-broad token can only ever hit the one resource it was scoped to. The demo below proves this with a negative test: a token bound to a different resource is rejected 401.
Machine tokens carry the realm issuer (RFC 9068)
Helix stamps every client-credentials access token with iss = the realm issuer it advertises in discovery — not the client id — so a standards-compliant MCP server validates it with no Helix-specific rules. Combined with the JWKS signature check and the aud binding, that is the complete trust anchor.
In the console¶
The admin console has a dedicated MCP authorization screen (under Integration) that gathers everything on this page into one place for a realm. It's a read-only + generator surface — MCP callers are managed as ordinary agents, not here — with four parts:
- Overview — how many of your agents are MCP-capable, how many are active, and how many are expiring soon.
- Authorization-server endpoints — the realm's issuer, discovery, token, and Dynamic Client Registration URLs, each copyable, to paste into an MCP server or client.
- MCP-capable agents — the agents holding an
mcp/mcp:*scope or role (the identities allowed to call MCP servers), with a link through to the Agents screen to manage them. - Protect your MCP server — enter your MCP server's URL and it generates the exact RFC 9728 Protected Resource Metadata document, the
401WWW-Authenticatechallenge, and a DCR registration command for you to copy.
Use it as the starting point when wiring up a new MCP server; the sections below explain what each generated artifact means.
Registering the agent's client¶
The agent authenticates to Helix as an OAuth client. Two ways to get one:
- Pre-registered — create a confidential client (or a service account) in the console and hand the agent its
client_id/client_secret. Bind it to an Agent so its tokens carry thenhi.*identity and its least-privilege roles. - Dynamic Client Registration (RFC 7591) — for agents that self-onboard, Helix exposes
POST /connect/register. The agent registers itself, then runs the handshake above. Governance still applies: DCR clients appear in the console and can be reviewed, expired, and revoked like any other.
Runnable demo¶
A zero-dependency demo lives in helix-mcp-demo/ next to the auth server. It contains a minimal MCP resource server, an MCP client, and the two tests, all in plain Node (no npm install).
cd platform/microservices/authorization/helix-mcp-demo
./run-demo.sh # seeds a demo agent+client, starts the server, runs the handshake + negative test
What each file does:
| File | Role |
|---|---|
mcp-server.js |
The MCP resource server — publishes RFC 9728 metadata, issues the 401 challenge, and verifies tokens (signature, iss, exp, aud). |
mcp-client.js |
The agent — runs all five handshake steps and prints each one. |
test-aud-binding.js |
Negative test — proves a token bound to a different resource is rejected. |
setup.sql |
Seeds the demo agent (mcp-demo-agent) and its bound client. |
A successful run ends with the tool call returning the caller's non-human identity:
{
"ok": true,
"result": "Hello from the Helix-protected MCP server 👋",
"caller": {
"nhi": true,
"agent_name": "mcp-demo-agent",
"agent_owner": "alice@acme.example",
"audience": "http://localhost:9800",
"realm_roles": ["mcp:tools"]
}
}
Building your own MCP server¶
The server side is small. To Helix-protect any MCP (or plain HTTP) server:
- Publish
GET /.well-known/oauth-protected-resourcereturning at leastresource(your server's URL) andauthorization_servers(your Helix realm issuer). See RFC 9728 for the full field set. - Challenge unauthenticated calls with
401andWWW-Authenticate: Bearer resource_metadata="<that URL>". - Verify each bearer token: fetch Helix's JWKS (from discovery), check the RS256 signature, then assert
iss= your realm issuer,expis in the future, andaudcontains your server's URL. Optionally require a scope.
mcp-server.js is ~120 lines of exactly this and is a fine starting point. Everything else — who the agent is, what it may do, how long the token lives, how to revoke it — is the standard Helix agent model.