@hyper.authio/mcp
v0.5.0
Published
Protect an MCP server with Authio, on top of @hyper.authio/server: token validation, protected resource metadata (RFC 9728), WWW-Authenticate challenges with step-up, per-tool authorization and connected-account token exchange.
Maintainers
Readme
@hyper.authio/mcp
Protect an MCP server with Authio.
Your MCP server is the resource server of the flow: it never signs anyone in. MCP clients (Claude Code, the MCP Inspector, …) get a token from Authio and call your server with it. This package gives the server everything it needs on its side, following the MCP authorization spec:
| Piece | What it does |
| --- | --- |
| Protected resource metadata (RFC 9728) | GET /.well-known/oauth-protected-resource/<path> names your server, the realm's issuer as its authorization server and the scopes it uses. Authio itself does not publish it: the MCP server is where it belongs. |
| 401 that starts OAuth | No token → 401 with WWW-Authenticate: Bearer resource_metadata="…" (plus scope="…" when requiredScopes names any) and no error code, which makes an MCP client discover Authio. Invalid, expired, revoked or misaddressed token → 401 invalid_token. Missing required scope → 403 insufficient_scope. Authio unreachable → 503, Authio refusing the server's own client → 500, neither with a challenge. |
| Token validation | All of it is @hyper.authio/server's: signature against the realm's keys with an algorithm allowlist, iss, aud = your resource identifier (RFC 8707), exp, header typ at+jwt (RFC 9068), payload typ, nested JWE (RSA-OAEP, RSA-OAEP-256), opaque tokens by introspection, and, with introspection: 'activeCheck', an introspection check (RFC 7662) that refuses a revoked token or an ended session. |
| The caller | AuthioUser: the kind of caller (user, client, consumer, account_credential), scopes, roles and permissions from the seven *_resource_access claims, groups, organization, acr/amr, delegation and impersonation. |
| Per-tool authorization | requires on each tool: scopes, roles, permissions (from chosen sources), caller kinds, organization, authentication level or a recent sign-in. Checked at the HTTP level (403 insufficient_scope or 401 insufficient_user_authentication, the challenges that let the client step up) and in the tool. |
| Step-up (RFC 9470) | A tool that needs a stronger or more recent sign-in answers 401 insufficient_user_authentication with acr_values and max_age (RFC 9470 §3). |
| Connected accounts (RFC 8693) | Trades the caller's Authio token for the provider token (GitHub, Google Drive, …) of an account they connected in Authio, with every refusal typed. |
| Client authentication | Introspection and the exchange authenticate as the server's own client with its tokenEndpointAuthMethod — client_secret_basic, client_secret_post, private_key_jwt (RFC 7523) or none — through @hyper.authio/client. |
Built on the official @modelcontextprotocol/sdk (its
McpServer.registerTool and extra.authInfo) and on the Authio Node.js SDK 0.9. The option names are the ones every
Authio library shares (conformance/options.md), and the shared conformance vectors run through this package's own
pipeline (conformance/).
Configure it the way the resource server and its client are registered. The package assumes nothing the Authio console configures: each setting is an option, and an option left out is off, as in NextAuth — no scope required, no introspection, nothing decrypted. What stays fixed is what the MCP authorization spec requires (see What the MCP spec fixes).
Install
npm install @hyper.authio/mcp @modelcontextprotocol/sdk expressNode.js 20.19+. ESM only. express is only needed for the @hyper.authio/mcp/express helpers.
Usage
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js'
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
import { createAuthio, requireAuthenticationLevel, requireRole, requireScope } from '@hyper.authio/mcp'
import { authioMetadataRouter, requireAuthioToken } from '@hyper.authio/mcp/express'
const authio = await createAuthio({
authority: 'https://auth.example.com', // the base Authio publishes in its issuer, without the realm
realm: 'my-realm', // or issuer: 'https://auth.example.com/realms/my-realm'
resource: 'https://mcp.example.com/mcp', // your MCP endpoint = the resource server identifier
clientId: 'mcp-server', // the resource server's introspection client (confidential)
tokenEndpointAuthMethod: 'client_secret_basic', // as the client is registered; see Client authentication
clientSecret: process.env.MCP_CLIENT_SECRET,
requiredScopes: ['mcp:tools'], // every request must carry them (left out: none required)
introspection: 'activeCheck', // confirm each token with Authio (left out: local validation only)
scopesSupported: ['repos:read'], // advertised besides requiredScopes
})
/** The tools, registered with the rules their caller must satisfy. */
function createServer() {
const server = new McpServer({ name: 'my-server', version: '1.0.0' })
const tools = authio.protect(server)
tools.registerTool('whoami', { description: 'Who is calling' }, (extra) => ({
content: [{ type: 'text', text: `${extra.user.kind} ${extra.user.subject} via ${extra.user.clientId}` }],
}))
tools.registerTool(
'list_repositories',
{ description: 'Lists repositories', requires: requireScope('repos:read') },
async (extra) => ({ content: [{ type: 'text', text: '…' }] }),
)
tools.registerTool(
'approve_invoice',
{
description: 'Approves an invoice',
requires: [requireRole('billing-admin', { sources: ['realm', 'client'] }), requireAuthenticationLevel(2, { maxAge: 300 })],
},
async (extra) => ({ content: [{ type: 'text', text: 'approved' }] }),
)
return server
}
const app = createMcpExpressApp() // parses the JSON body, which the per-tool check reads
app.use(authioMetadataRouter(authio)) // RFC 9728 metadata + CORS for browser clients
app.post('/mcp', requireAuthioToken(authio), async (req, res) => {
const server = createServer()
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
res.on('close', () => void transport.close())
await server.connect(transport)
await transport.handleRequest(req, res, req.body)
})
app.listen(3333)createAuthio reads the realm's discovery document at start-up, so a wrong issuer, authority or realm fails right
away with a sentence instead of every token being refused later.
Protected resource metadata
authioMetadataRouter(authio) serves, at /.well-known/oauth-protected-resource/mcp for the resource above, the
document @hyper.authio/server builds (through authioProtectedResourceMetadata of @hyper.authio/express):
{
"resource": "https://mcp.example.com/mcp",
"authorization_servers": ["https://auth.example.com/realms/my-realm"],
"bearer_methods_supported": ["header"],
"scopes_supported": ["mcp:tools", "repos:read"]
}scopes_supported lists requiredScopes and scopesSupported, and is left out when both are empty.
resourceName and resourceDocumentation add resource_name and resource_documentation. The same document is
authio.metadata, and its address authio.resourceMetadataUrl, for a server that serves it itself.
What the MCP spec fixes
Three things are not options, because the MCP authorization spec requires them rather than any client or resource server setting in Authio:
| Fixed | Why |
| --- | --- |
| authorization_servers is [issuer], the realm's issuer | The spec requires the protected resource metadata to name the authorization server an MCP client must use. For an Authio-protected server that is the realm that issues its tokens; any other value would send the client to a server whose tokens are refused (iss must be the realm). |
| bearer_methods_supported is ["header"] | The spec requires MCP clients to send the token in the Authorization: Bearer header and forbids it in the query string; the package reads only the header. Authio has no other method to configure (no DPoP, no mTLS). |
| Every 401 carries resource_metadata | The spec requires the server to point the client at its protected resource metadata through WWW-Authenticate when it answers 401; that is how the client discovers Authio. |
Challenges
| Request | Answer |
| --- | --- |
| No Authorization: Bearer header | 401, WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp", scope="mcp:tools"; scope only when requiredScopes names any |
| A token Authio does not vouch for | 401, Bearer error="invalid_token", error_description="…", resource_metadata="…", scope="mcp:tools" (same rule for scope) |
| Without the scopes requiredScopes names | 403, Bearer error="insufficient_scope", error_description="…", scope="…", resource_metadata="…" |
| tools/call of a tool whose requireScope fails | 403 insufficient_scope; scope lists the scopes the caller holds, the required ones and the missing ones, so signing in again for it keeps what the client could already do |
| tools/call of a tool that needs a stronger or recent sign-in | 401, Bearer error="insufficient_user_authentication", error_description="…", acr_values="2", max_age=300, resource_metadata="…" |
| tools/call of a tool whose role, permission, kind or organization rule fails | 403 without a bearer challenge |
| A token Authio could not be asked about: no answer, a timeout or a 5xx from introspection or the signing keys | 503 without a challenge |
| A token Authio could not be asked about because it refused the server's introspection client (invalid_client, unauthorized_client, another 4xx) | 500 without a challenge; the log names the client to check |
The step-up answers 401, as RFC 9470 §3 has it (403 up to 0.4.0); insufficient_scope stays 403 (RFC 6750
§3.1). A 500 or 503 carries no WWW-Authenticate, so an MCP client does not sign in again, in a loop, for a fault
no new token fixes (401 invalid_token up to 0.4.1); with problemDetails, its body names no configuration
(toRefusalProblemDetails). The table is conformance/challenges.json of authio-node, which test/conformance.test.ts runs through
requireAuthioToken.
requireAuthioToken(authio, { problemDetails: true }) adds an RFC 9457 body. A request that passes carries
req.auth (the AuthInfo the MCP SDK hands each tool as extra.authInfo) and req.authio ({ user, token }), so the
guards of @hyper.authio/express work after it too.
Per-tool authorization
authio.protect(server).registerTool(name, config, callback) takes what McpServer.registerTool takes, plus
requires: one rule or several, all required. The rules are @hyper.authio/server's, re-exported here:
| Rule | Requires |
| --- | --- |
| requireScope(scopes, match?) | Granted scopes (any by default, or all). |
| requireRole(roles, { match?, sources? }) | Roles, from any source or only from some *_resource_access claims (user, realm, client, plan, consumer, secret, system). |
| requirePermission(resource, method, { sources? }) | A permission granted by a role, with Authio's grant rule. |
| requireCallerKind(kinds) | user, client, consumer, account_credential. |
| requireOrganization(alias?), requireGroup, requireClaim, requireClient | An organization context, a group, a claim (value), a client. |
| requireAuthenticationLevel(level, { maxAge? }), requireAuthenticationMethod(methods), requireRecentAuthentication(maxAge) | A sign-in strong or recent enough (step-up). |
| allOf, anyOf | Combinations. |
The callback receives the caller as extra.user (an AuthioUser), already checked. A caller who does not satisfy
the rules is refused twice over:
- by
requireAuthioToken, before the MCP server sees the request, with the status and challenge above — an MCP client that supports step-up signs in again for it. It reads the parsed JSON body (express.json()orcreateMcpExpressAppfirst) and knows a tool once a server has registered it throughauthio.protect(in a stateless server, the first request builds one; a server that wants the check from the very first request builds itsMcpServerbefore callingauthio.authorizeRequest(user, body), asexamples/src/without-express.tsdoes); - by the tool itself, which answers a tool error saying what is missing, whatever the transport.
Outside protect, callerOf(extra.authInfo) reads the caller from any tool, and authorize(user, ...rules) checks
rules anywhere.
import { callerOf, authorize, requirePermission } from '@hyper.authio/mcp'
server.registerTool('report', { description: 'A report' }, (extra) => {
const user = callerOf(extra.authInfo)
const refusal = user && authorize(user, requirePermission('report', 'read', { sources: ['plan'] }))
// …
})Step-up
tools.registerTool(
'transfer_funds',
{ description: 'Moves money', requires: requireAuthenticationLevel(2, { maxAge: 300 }) },
async (extra) => ({ content: [{ type: 'text', text: `signed in at ${extra.user.authenticatedAt?.toISOString()}` }] }),
)A caller who signed in with one factor, or more than five minutes ago, gets 401 with
Bearer error="insufficient_user_authentication", acr_values="2", max_age=300, resource_metadata="…". The client
signs in again asking Authio for those (createStepUpAuthorizationUrl of @hyper.authio/client does it for a client
built on it). requireAuthenticationMethod('otp') asks for the level the method implies, and
requireRecentAuthentication(300) for a recent sign-in alone.
Token validation options
| Option | Left out | |
| --- | --- | --- |
| issuer, or authority + realm | | Where Authio is. |
| resource | | Required. The MCP endpoint's URL; added to audiences, published in the metadata, trailing slash trimmed. |
| audiences | | Further accepted audiences. |
| requiredScopes | No scope required | Every request must carry them all; they go into the 401 challenge's scope and into scopes_supported. Name the scopes of the resource server the server insists on, such as ['mcp:tools']. |
| scopesSupported, resourceName, resourceDocumentation | | The rest of the metadata. |
| introspection | none: local validation only | activeCheck confirms every token with Authio (cached per jti), so revocations and ended sessions count at once; introspectionOnly validates by introspection alone, for a client whose access token format is Reference or whose tokens are encrypted with the platform key. Both need clientId. |
| introspectionCacheDuration | 30 | Seconds an active answer is kept, never past exp. |
| clientEncryptionKey, clientEncryptionKeyPath | Nothing decrypted | The private key Authio encrypts this server's access tokens to, so nested JWE tokens are decrypted and validated locally. |
| encryptionAlgorithms, encryptionMethods | RSA-OAEP, RSA-OAEP-256; the CBC-HS methods | The JWE alg and enc accepted, as registered in Authio (AES-GCM may be listed; RSA1_5 cannot, Node cannot decrypt it). |
| accessTokenSignedResponseAlg | The asymmetric algorithms (RS/PS/ES 256/384/512), never HS* | The algorithm, or several, the client signs access tokens with in Authio. HS256/384/512 are verified with the clientSecret of clientId (at least 32, 48 or 64 bytes), so only the client's own server validates them locally; any other server uses introspection: 'introspectionOnly'. |
| clockSkew | 30 | Seconds. |
| rejectNonAccessTokens | true | Refuses an ID or refresh token. |
| roleClaims, groupClaims | ['role'], ['groups'] | Claims from claim mappers. |
| requireHttps, discoveryCacheDuration, httpTimeout, fetch, clock | | How Authio is reached. |
Not using Express? authio.authenticate(authorizationHeader) answers { ok: true, user, authInfo } or the refusal
to send (status, wwwAuthenticate); authio.authorizeRequest(user, body) checks a tools/call; authio.metadata
is the document to serve; authio.tokenVerifier is an OAuthTokenVerifier for the MCP SDK's own requireBearerAuth.
authio.verifier is the @hyper.authio/server verifier itself.
Client authentication
Introspection and the connected-account exchange are calls the server makes as its own OAuth client
(authio.client, an AuthioClient of @hyper.authio/client). Both authenticate with tokenEndpointAuthMethod, the
client's Token endpoint auth method in the console, which Authio matches exactly: another method is refused with
invalid_client, and the request answers 500 without a challenge, with the client to check in the log. Left out, it is
client_secret_basic with a clientSecret, private_key_jwt with only a private key, and none with neither.
// client_secret_basic: id and secret in an HTTP Basic header
await createAuthio({ /* … */ clientId: 'mcp-server', tokenEndpointAuthMethod: 'client_secret_basic', clientSecret })
// client_secret_post: id and secret in the form body
await createAuthio({ /* … */ clientId: 'mcp-server', tokenEndpointAuthMethod: 'client_secret_post', clientSecret })
// private_key_jwt (RFC 7523): a fresh signed assertion for every call, no secret travels
await createAuthio({
/* … */
clientId: 'mcp-server',
tokenEndpointAuthMethod: 'private_key_jwt',
clientPrivateKeyPath: './keys/mcp-server.key', // or clientPrivateKey: a PEM, a JWK or a CryptoKey
clientKeyId: 'mcp-server-2026', // optional: the kid in the client's JWK Set
// clientSigningAlgorithm: 'PS256', optional: RS/PS 256/384/512 for RSA, the curve's ES for EC
})clientSecret also takes a function returning the current secret, so a rotated secret is picked up without a restart.
Create a key pair for private_key_jwt, keep the private key with the server, and register the public key on the
client in the Authio console (Security step, as a JWK Set or a JWKS URL):
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out mcp-server.key # RSA → RS256
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out mcp-server.key # or EC P-256 → ES256Connected accounts
const exchange = await authio.exchangeConnection({ subjectToken: extra.user.token!, provider: 'github' })
if (exchange.status !== 'granted') {
return { content: [{ type: 'text', text: describeConnectionRefusal(exchange, 'GitHub') }], isError: true }
}
await fetch('https://api.github.com/user', { headers: { authorization: `Bearer ${exchange.accessToken}` } })| status | Meaning |
| --- | --- |
| granted | accessToken is the provider's token (never its refresh token); expiresIn, externalAccount, connectionId. |
| required | The caller has not connected the account; connectUri is where they do it. |
| selection_required | Several accounts; call again with connectionId set to one of connections[].id. |
| refused | Anything else, with Authio's error, errorCode and errorDescription. |
What to register in the Authio console
- Client for the MCP client (public): PKCE,
authorization_code, the redirect URIs of the MCP clients you use (Claude Code:http://localhost:<callback-port>/callback; MCP Inspector:http://localhost:6274/oauth/callback). Switch off "Require OpenID Connect" or ask foropenidtoo — MCP clients ask only for your scopes. A client that publishes a Client ID Metadata Document needs no registration when its host is allowed in the realm; see How MCP clients get a client ID for that and for dynamic registration. - Client for this server (confidential) with the token exchange grant, authenticating with one of the
methods above; pass the same method as
tokenEndpointAuthMethod. - Resource server: identifier =
resource, its scopes (such asmcp:tools, then listed inrequiredScopes, and your tools' scopes), introspection client = the client of step 2 (withintrospection: 'activeCheck'or'introspectionOnly'), and the client of step 1 under Authorized clients (otherwiseinvalid_targetat authorize). To have its tokens encrypted, register an encryption key and give its private half asclientEncryptionKey. - Connection provider (for connected accounts): its alias is the
provideryou pass toexchangeConnection. - The account's plan must include the MCP integration (and connected accounts, for step 4).
Connect from Claude Code with:
claude mcp add --transport http --client-id <client of step 1> --callback-port 8765 my-server https://mcp.example.com/mcpHow MCP clients get a client ID
An MCP client needs a client_id in the realm before it can run the authorization code flow. Authio offers three
ways, and the resource server must accept each one separately:
| | Pre-registered client | Client ID Metadata Document (CIMD) | Dynamic client registration (DCR) |
| --- | --- | --- | --- |
| Who creates the client | You, in the console | Authio, from the client's document on first use | The MCP client, by calling the registration endpoint |
| client_id | Chosen when you create it | The https URL of the document | Issued by Authio |
| Realm setting | None | Accept clients by metadata document (CIMD), plus allowed hosts | Dynamic client registration (DCR) (realm Edit > Access) |
| Resource server setting | Authorized clients | Accept CIMD clients (allowMetadataDocumentClients) | Accept dynamically registered clients (allowDynamicallyRegisteredClients) |
| Discovery advertises | — | client_id_metadata_document_supported: true | registration_endpoint |
Prefer CIMD or a pre-registered client. The MCP spec of 2025-06-18 says authorization servers SHOULD support dynamic registration; the 2025-11-25 revision downgrades it to MAY and recommends CIMD. Turn DCR on only for MCP clients that can do neither (older MCP Inspector versions, some IDE plugins), and keep it behind an initial access token.
Dynamic client registration
DCR (RFC 7591) is off by default and turned on per realm, in the
console or through the dynamicClientRegistration object of PUT /api/v1/admin/realms/{realm}. While it is off — or
the realm is missing or inactive — POST /realms/{realm}/protocol/openid-connect/register answers 404 and the
realm's metadata (/.well-known/oauth-authorization-server/realms/{realm} and the OpenID configuration) has no
registration_endpoint. MCP clients that rely on DCR read that member, so they only try once it is on.
| Policy | Default | Meaning |
| --- | --- | --- |
| enabled | false | Opens the endpoint and publishes registration_endpoint. |
| access | InitialAccessToken | InitialAccessToken requires a credential on every registration; Open lets anyone register. |
| allowedScopes | openid profile email offline_access | A request naming no scope gets all of them; a scope outside is refused. |
| allowedGrantTypes | authorization_code refresh_token | May also allow client_credentials and device_code; a request naming none gets authorization_code. |
| allowedAuthMethods | none | May also allow client_secret_basic, client_secret_post; omitted in the request: client_secret_basic if allowed, else the first allowed. |
| redirectHosts | empty (any https host) | *.example.com covers subdomains, not the apex. |
| allowLoopbackRedirects | true | http/https on 127.0.0.1, [::1] or localhost, any port. |
With InitialAccessToken, send Authorization: Bearer <API key or personal access token> of the account that owns the
realm, with realm:write (the permission needed to create clients through the Admin API). A credential restricted to
another realm or to a single client does not work.
curl -X POST https://authio.api.hyperio.tech/realms/acme/protocol/openid-connect/register \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer pat_...' \
-d '{"client_name":"My MCP client","redirect_uris":["http://127.0.0.1:33418/callback"],"grant_types":["authorization_code","refresh_token"],"response_types":["code"],"token_endpoint_auth_method":"none","scope":"openid offline_access"}'A 201 returns client_id, client_id_issued_at, client_name, redirect_uris, grant_types, response_types,
token_endpoint_auth_method and scope (plus client_secret and client_secret_expires_at: 0 for a confidential
client). Errors: 400 invalid_client_metadata, 400 invalid_redirect_uri (http off loopback, host not listed,
loopback disabled, fragment or user info), 401 invalid_token. RFC 7592 is not supported — there is no
registration_access_token; the client is managed in the console like any other, marked Dynamically registered,
with PKCE S256 always required for public clients, the consent screen always shown and Require OpenID Connect
off.
Then switch on Accept dynamically registered clients on this server's resource server; without it, such a client
asking for a token with resource= gets invalid_target.
Examples
Runnable servers live in examples/: one per client authentication method, per-tool scopes and
step-up, the caller's identity, connected accounts with a call to GitHub's API on the caller's behalf, and the core on
plain node:http without the Express helpers.
Upgrading
- From 0.3:
clientAuthenticationis nowtokenEndpointAuthMethod(and the typeAuthioClientAuthenticationisAuthioTokenEndpointAuthMethod);validAlgorithmsis nowaccessTokenSignedResponseAlg;requiredScopesandintrospectionare off when left out, so a server that relied onmcp:toolsandactiveCheckpasses them explicitly;DEFAULT_REQUIRED_SCOPEis gone. - From 0.2: 0.3.0 is built on the Authio Node.js SDK and renames options to the names every Authio library shares.
See CHANGELOG.md for the full list of breaking changes.
License
MIT
