BetterIAM

Dynamic registration and MCP

Let MCP hosts and other self-configuring clients discover your authorization server, register with RFC 7591, and call your protected APIs.

The Model Context Protocol (MCP) lets AI assistants and IDEs (the MCP host) call tools that your product exposes through an MCP server. When that server holds customer data, the assistant has to act as a particular person, with that person's permission, just like any other app. MCP uses OAuth for this.

The problem it solves. Normally an administrator registers every OAuth client by hand and copies its client ID into the app. That cannot work for MCP: thousands of people run their own copy of a desktop assistant, and each copy connects to servers it has never seen. Two standards let such a client configure itself:

  • Protected resource metadata (RFC 9728): the MCP server publishes a small JSON document that says which authorization server issues its tokens. A client that gets a 401 reads it and knows where to go.
  • Dynamic client registration (RFC 7591): the client registers itself with the authorization server over HTTP and receives a client ID, within limits you set.

After that the client runs the normal authorization code flow, the person signs in to Better IAM and approves the assistant, and the assistant calls the MCP server with a token meant only for it. Better IAM supplies both halves:

  • On the OAuth/OIDC provider, registration turns on dynamic registration, limited to one and to the scopes and APIs you allow.
  • In front of the MCP server (your API), createResourceGuard serves the protected resource metadata, verifies access tokens, and answers failures with the challenge MCP hosts expect.

The same pieces work for any self-configuring OAuth client, not only MCP.

Who configures it. Your team decides the registration policy in code and puts the guard in front of the MCP server. Tenant administrators can hand out registration tokens when they want tighter control than open registration, and they manage the registered clients like any other. The people using an assistant only sign in and approve it.

How an MCP host connects

The host starts with nothing but the MCP server's URL. Each step below discovers the next piece, so no one has to configure the host by hand:

The consent screen shows the registered client_name. Consenting binds the client to the person's IAM , so signing out or deactivation revokes it like any other consent.

Turn on dynamic registration

Declare the MCP server as a resource server and configure registration:

oauth.ts
import { createOAuthProvider } from 'better-iam/oauth';

const issuer = createOAuthProvider({
  ...options,
  resourceServers: { 'https://mcp.example.com/mcp': { scopes: ['mcp:tools'] } },
  registration: {
    // Optional: accept registrations without a token (what most MCP hosts do), for the tenant the host serves.
    anonymous: ({ headers }) =>
      tenantForHost(headers.host)
        ? {
            tenantId: tenantForHost(headers.host)!,
            scopes: ['openid', 'offline_access', 'mcp:tools'],
            resources: ['https://mcp.example.com/mcp'],
            maxClients: 500,
          }
        : undefined,
  },
});

The discovered registration_endpoint ({issuer}/reg) accepts registrations in two ways. Choose by how much you trust the clients:

Open registration, for public MCP servers. Most MCP hosts register without any token. The anonymous hook decides what such a request gets: it receives the request's headers and ip and returns the policy (above: the tenant that owns the host name the request came to), or undefined to decline. Anonymous registrations are capped per tenant by maxClients (default 100), so an open endpoint cannot be used to create unlimited clients.

A registration request looks like this:

POST /oidc/reg
Content-Type: application/json

{
  "client_name": "Acme Assistant",
  "redirect_uris": ["http://127.0.0.1:33418/callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "token_endpoint_auth_method": "none",
  "scope": "openid offline_access mcp:tools"
}

A missing, invalid, expired, or used-up registration token, a declined anonymous request, or a suspended tenant answers 401 with error: invalid_token.

Registration policy

The anonymous hook returns, and a registration token stores, the same limits:

Prop

Type

createRegistrationToken also takes name (required, to recognize the token later) and expiresIn in seconds (default 7 days, between one minute and one year). It requires iam:oauth:clients:create, returns the token once, and stores only its hash.

What a registration may contain

A self-registered client is never more powerful than a hand-registered one. In short:

  • The tenant comes from the token or the hook. A tenant_id in the request is ignored.
  • Only the authorization_code and refresh_token grants are allowed, always with PKCE, so a self-registered client always acts for a person who consented.
  • Clients are public (token_endpoint_auth_method: "none") unless the policy sets allowConfidential.
  • Redirect URIs use HTTPS, loopback HTTP on any port (how desktop MCP hosts receive the code, per RFC 8252), or a reverse-domain custom scheme.
  • Nothing the provider would have to fetch can be registered (jwks_uri, backchannel_logout_uri, and similar), and scopes stay within the allowance.

The complete rules, the registration-token administration methods (listRegistrationTokens, revokeRegistrationToken), the stored schema, and every registration error are on Dynamic client registration. Registered clients appear in listClients with registeredVia (the token ID or anonymous) and are changed or removed with updateClient and revokeClient, like any other client. Clients cannot edit their own registration: registration_client_uri is not offered.

Protect the MCP server

createResourceGuard is everything the MCP server needs in front of its routes: it serves the protected resource metadata document, verifies access tokens, and answers failures in the form MCP hosts understand.

mcp/server.ts
import { createResourceGuard } from 'better-iam/oauth';

const guard = createResourceGuard({
  resource: 'https://mcp.example.com/mcp',
  authorizationServers: ['https://id.example.com/oidc'],
  scopes: ['mcp:tools'],
  requiredScopes: ['mcp:tools'],
  resourceName: 'Acme MCP',
});

async function handle(request: Request): Promise<Response> {
  const { response, token } = await guard.check(request); // also serves /.well-known/oauth-protected-resource/mcp
  if (response) return response;
  return runMcp(request, token); // token.subject, token.tenantId, token.clientId, token.scopes
}

guard.check(request, { scopes }) returns either { response }, which you send back as is, or { token } for an authorized request. Pass scopes for routes that need more than requiredScopes.

RequestAnswer
GET the metadata URLThe metadata document, readable from any origin, cached for an hour.
No Authorization header401 with WWW-Authenticate: Bearer resource_metadata="…", scope="…" and no error code, as the bearer-token standard (RFC 6750) requires. This is what starts discovery in the host.
Invalid, expired, or wrong-audience token401 invalid_token.
Missing scopes403 insufficient_scope, naming the scopes needed.
Valid token{ token } with the verified token fields.

Bearer and DPoP-bound tokens are both accepted.

Prop

Type

The guard also exposes metadataUrl and its verifier.

Lower-level pieces

If your framework already has its own auth middleware, use the parts:

  • protectedResourceMetadata(options) returns the RFC 9728 document as an object.
  • protectedResourceMetadataUrl(resource) returns where it lives: /.well-known/oauth-protected-resource inserted before the resource's path, for example https://mcp.example.com/.well-known/oauth-protected-resource/mcp.
  • createProtectedResourceHandler(options) serves the document (GET, HEAD, and CORS preflight) and returns undefined for every other request, so it can sit in front of your routing.
  • The verifier's challenge(error, realm, { resourceMetadata, scopes }) builds a WWW-Authenticate value that points clients at the metadata.

Refresh tokens for MCP clients

Refresh tokens follow OAuth 2.1 (the consolidated revision of OAuth 2.0 that MCP builds on) for these requests, so an assistant stays connected without asking again every 15 minutes. An authorization code without the openid scope yields a refresh token whenever the client is registered for the refresh_token grant, with no offline_access or prompt=consent needed. OpenID requests keep the OIDC rule: they need offline_access. Refresh tokens rotate on every use and end with the consenting session.

Endpoints

EndpointServed byPath
Protected resource metadata (RFC 9728)createResourceGuard in front of the MCP serverGET /.well-known/oauth-protected-resource{resource path} on the MCP server's origin
Authorization server metadata (RFC 8414)The providerGET /.well-known/oauth-authorization-server{issuer path}
Registration (RFC 7591)The provider, when registration is setPOST {issuer}/reg
AuthorizationThe providerGET {issuer}/auth
TokenThe providerPOST {issuer}/token

Errors

WhereAnswerWhen
Registration endpoint401 invalid_tokenMissing, invalid, expired, or used-up registration token; declined anonymous request; inactive tenant.
Registration endpoint400 invalid_client_metadataMetadata outside the policy. See Dynamic client registration.
guard.check401 invalid_tokenInvalid, expired, or wrong-audience token (INVALID_TOKEN).
guard.check403 insufficient_scopeA required scope is missing (INSUFFICIENT_SCOPE).
createRegistrationTokenINVALID_INPUTMissing name, a lifetime outside one minute to one year, or unsupported scopes, resources, or limit.

Next steps

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page