npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

Readme

@hyper.authio/mcp

NPM Version

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 express

Node.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() or createMcpExpressApp first) and knows a tool once a server has registered it through authio.protect (in a stateless server, the first request builds one; a server that wants the check from the very first request builds its McpServer before calling authio.authorizeRequest(user, body), as examples/src/without-express.ts does);
  • 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 → ES256

Connected 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

  1. 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 for openid too — 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.
  2. Client for this server (confidential) with the token exchange grant, authenticating with one of the methods above; pass the same method as tokenEndpointAuthMethod.
  3. Resource server: identifier = resource, its scopes (such as mcp:tools, then listed in requiredScopes, and your tools' scopes), introspection client = the client of step 2 (with introspection: 'activeCheck' or 'introspectionOnly'), and the client of step 1 under Authorized clients (otherwise invalid_target at authorize). To have its tokens encrypted, register an encryption key and give its private half as clientEncryptionKey.
  4. Connection provider (for connected accounts): its alias is the provider you pass to exchangeConnection.
  5. 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/mcp

How 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: clientAuthentication is now tokenEndpointAuthMethod (and the type AuthioClientAuthentication is AuthioTokenEndpointAuthMethod); validAlgorithms is now accessTokenSignedResponseAlg; requiredScopes and introspection are off when left out, so a server that relied on mcp:tools and activeCheck passes them explicitly; DEFAULT_REQUIRED_SCOPE is 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