NPM package Build status NPM downloads License

mcp-oauth-server

Self-hosted OAuth 2.1 Authorization Server for MCP servers, as Express middleware in TypeScript. Implements the MCP Authorization Spec so you can protect your MCP server with OAuth without depending on a hosted auth vendor - and with no runtime dependency on the MCP SDK.

Originally forked from the (now sunset) OAuth 2.1 Authorization Server implementation in @modelcontextprotocol/typescript-sdk.

When to use this package

  • You are building a remote MCP server and need an OAuth authorization server that MCP clients (Claude, ChatGPT, VS Code, ...) can register against and obtain tokens from - with your own login/consent screen and your own storage.
  • You want the authorization server and the protected MCP server (resource server) in one Express app, or split across services sharing an OAuthServerModel.
  • You need the client registration mechanisms MCP clients actually use: Client ID Metadata Documents and Dynamic Client Registration.

If you only need to validate tokens issued by an external identity provider, you don't need the full server - use requireBearerAuth with a custom verifier.

Table of Contents

Installation

npm install mcp-oauth-server@latest --save-exact

Features

  • MCP Authorization Spec compliant: Aligns with the MCP Authorization Spec
  • Grant types: Configurable via grantTypes - authorization_code, refresh_token, client_credentials, and device authorization (urn:ietf:params:oauth:grant-type:device_code, RFC 8628)
  • Compatibility: Works with MCP clients that omit a resource indicator (RFC 8707) or requested scopes when needed (strictResource)
  • Flexible storage: In-memory model for development (MemoryOAuthServerModel) or your own OAuthServerModel for production

Not supported:

  • Token introspection (RFC 7662) - validate access tokens via OAuthServer.verifyAccessToken (and requireBearerAuth) instead.
  • private_key_jwt client authentication for CIMD clients - CIMD clients are treated as public clients (token_endpoint_auth_method: 'none').
  • OpenID Connect Discovery 1.0 - RFC 8414 metadata satisfies the MCP spec's discovery requirement on its own.

OAuth client credentials (machine-to-machine)

Use this grant when no end user is present (services, CI, daemons). MCP documents this as the OAuth Client Credentials extension (io.modelcontextprotocol/oauth-client-credentials). See the official guide: OAuth Client Credentials.

Server

  1. Include 'client_credentials' in grantTypes.
  2. Clients registered for this flow must include client_credentials in grant_types (via dynamic registration or your model).
import { OAuthServer } from 'mcp-oauth-server';

const oauthServer = new OAuthServer({
    authorizationUrl: new URL('https://example.com/consent'),
    scopesSupported: ['mcp:tools'],
    grantTypes: ['authorization_code', 'refresh_token', 'client_credentials'],
});

Client (conceptually)

POST to the token endpoint with grant_type=client_credentials and authenticate the client (for example client_id / client_secret per RFC 6749 §4.4). MCP clients using @modelcontextprotocol/client can use ClientCredentialsProvider as described in the extension docs above.

Tokens minted for this grant typically have no userId on AuthInfo - authorize by clientId and scopes where appropriate.

Device authorization (RFC 8628)

Use the device authorization grant for clients that cannot easily run a browser redirect (CLIs, TVs, constrained devices).

Server

  1. Set deviceAuthorizationUrl to the URL where the user enters or confirms the user code (your UX page).
  2. Add the device grant type to grantTypes (use the exported constant so you do not typo the URN):
import { OAuthServer, DEVICE_AUTHORIZATION_GRANT_TYPE } from 'mcp-oauth-server';

const oauthServer = new OAuthServer({
    authorizationUrl: new URL('https://example.com/consent'),
    scopesSupported: ['mcp:tools'],
    grantTypes: ['authorization_code', 'refresh_token', DEVICE_AUTHORIZATION_GRANT_TYPE],
    deviceAuthorizationUrl: new URL('https://example.com/device'),
});
  1. Implement the device-related methods on OAuthServerModel (saveDeviceAuthorization, getDeviceAuthorizationByDeviceCode, getDeviceAuthorizationByUserCode, deleteDeviceAuthorization) - see MemoryOAuthServerModel for a reference.

The auth router exposes POST /device (under your AS base path) when the device grant and deviceAuthorizationUrl are configured. Metadata lists device_authorization_endpoint accordingly.

Approving or denying a login

Wire approveDeviceAuthorizationHandler and denyDeviceAuthorizationHandler on routes you choose; they accept user_code (and resolve the authenticated user via getUser) so the user can approve or reject the device login out-of-band.

Client ID Metadata Documents (CIMD)

Client ID Metadata Documents let clients use an HTTPS URL as their client_id. The authorization server fetches a JSON metadata document from that URL (client_id, client_name, redirect_uris, ...) instead of requiring registration. The draft MCP Authorization spec recommends CIMD and deprecates Dynamic Client Registration in its favor.

Support is opt-in because it makes the server issue outbound HTTPS requests to client-supplied URLs:

const oauthServer = new OAuthServer({
    // enable with defaults
    clientIdMetadataDocuments: true,

    // or configure it
    clientIdMetadataDocuments: {
        // Trust policy: reject metadata URLs outside your allowlist
        validateClientIdUrl: (url) => trustedDomains.includes(url.hostname),
        defaultCacheTtlSeconds: 300,
        maxCacheTtlSeconds: 3600,
        fetchTimeoutMs: 5000,
        fetch: myCustomFetch, // defaults to the Node built-in fetch
    },
    // ...
});

When enabled, the server metadata advertises client_id_metadata_document_supported: true and OAuthServer.getClient resolves URL-formatted client ids by fetching and validating the document: the document's client_id must equal the URL exactly, and client_name and at least one redirect_uris entry are required.

Fetched documents are cached through your OAuthServerModel (saveClientIdMetadataDocument / getClientIdMetadataDocument, implemented by MemoryOAuthServerModel out of the box) respecting Cache-Control headers, so multiple server instances sharing a model share the cache. Enabling CIMD on a custom model requires implementing both methods.

Security notes

  • CIMD clients are public clients - documents demanding any token_endpoint_auth_method other than none are rejected (private_key_jwt is not supported).
  • Non-HTTPS URLs, loopback/IP-literal hosts, redirects, and documents over 10 KB are always rejected. These checks do not cover DNS rebinding or internal hostnames - use validateClientIdUrl (or network egress filtering) if the server can reach internal services.
  • CIMD cannot prevent localhost redirect URI impersonation by itself; consent screens should display the redirect URI hostname to the user.

Quick Start

A minimal MCP authorization server protecting an MCP endpoint:

import express from 'express';
import { OAuthServer, mcpAuthRouter, requireBearerAuth, getOAuthProtectedResourceMetadataUrl } from 'mcp-oauth-server';

const mcpServerUrl = new URL('https://example.com/mcp');

const oauthServer = new OAuthServer({
    issuerUrl: new URL('https://example.com'),
    authorizationUrl: new URL('https://example.com/consent'), // your login/consent page
    scopesSupported: ['mcp:tools'],
    clientIdMetadataDocuments: true,
});

const app = express();

// OAuth endpoints: /.well-known metadata, /authorize, /token, /register, /revoke
app.use(mcpAuthRouter({ provider: oauthServer, resourceServerUrl: mcpServerUrl }));

// Your protected MCP endpoint
app.post(
    '/mcp',
    requireBearerAuth({
        verifier: oauthServer,
        requiredScopes: ['mcp:tools'],
        resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl),
        resource: mcpServerUrl,
    }),
    (req, res) => {
        // req.auth is the validated token: clientId, userId, scopes
    },
);

app.listen(3000);

Your consent page completes the flow by calling authenticateHandler with the authenticated user id.

A complete runnable example with a memory-backed authorization server and consent screen lives in ./example.

Run the demo

  1. Start the server:

    pnpm example:server
    
  2. In another terminal, authenticate with the server:

    pnpm example:client
    

The example covers mounting the OAuth router, a simple consent screen, and confirming authorization.

API Reference

OAuthServer

OAuth 2.1 server instance passed to mcpAuthRouter.

import { OAuthServer } from 'mcp-oauth-server';

const oauthServer = new OAuthServer({
    issuerUrl: new URL('http://localhost:3000'),
    authorizationUrl: new URL('http://localhost:3000/consent'),
    scopesSupported: ['mcp:tools'],
    grantTypes: ['authorization_code', 'refresh_token', 'client_credentials'],
});

Options

  • model: (optional) Storage backend. Default: MemoryOAuthServerModel.
  • issuerUrl: (required when using mcpAuthRouter) Authorization server issuer identifier (HTTPS in production; localhost is allowed for development). Used as the metadata issuer and appended as the RFC 9207 iss parameter on authorization responses.
  • authorizationUrl: (required) Redirect URL for interactive authorization (consent). Required when authorization_code is enabled.
  • resourceServerUrl: (optional) MCP resource server URL; used for resource validation and metadata when set.
  • scopesSupported: (optional) Supported scopes; if the client omits scope, the server may default to these supported scopes.
  • accessTokenLifetime: (optional) Access token lifetime in seconds. Default: 3600.
  • refreshTokenLifetime: (optional) Refresh token lifetime in seconds. Default: 1209600 (14 days).
  • clientSecretLifetime: (optional) Client secret expiry in seconds, or 0 for no expiry. Default: 7776000 (90 days). Public clients (token_endpoint_auth_method: 'none') have no secret.
  • authorizationCodeLifetime: (optional) Authorization code lifetime in seconds. Default: 300.
  • strictResource: (optional) Validate the RFC 8707 resource parameter on authorize requests. Default: true.
  • modifyAuthorizationRedirectUrl: (optional) Mutate the consent redirect URL (e.g. add client display hints as query parameters).
  • errorHandler: (optional) Hook for logging or handling errors inside OAuth flows.
  • dynamicClientRegistration: (optional) Enable RFC 7591 /register. Default: true. Construction fails if enabled and model.registerClient is missing. Note: the MCP spec deprecates Dynamic Client Registration in favor of CIMD; keep it enabled for backwards compatibility with clients that do not support CIMD.
  • clientIdMetadataDocuments: (optional) Enable Client ID Metadata Documents - pass true or a ClientIdMetadataDocumentOptions object. Default: false.
  • grantTypes: (optional) Enabled grants. Default: ['authorization_code', 'refresh_token']. Add 'client_credentials' and/or DEVICE_AUTHORIZATION_GRANT_TYPE as needed.
  • deviceAuthorizationUrl: (optional) Page URL where the user enters the user code (RFC 8628). Required together with the device grant on grantTypes.
  • deviceAuthorizationLifetime: (optional) Device code lifetime in seconds. Default: 900.
  • devicePollIntervalSeconds: (optional) Minimum poll interval returned to clients while authorization is pending. Default: 5.

OAuthServerModel

Storage interface for clients, codes, tokens, and (when enabled) device authorization records.

import type { OAuthServerModel, AccessToken } from 'mcp-oauth-server';

export class PostgresModel implements OAuthServerModel {
    async getClient(clientId: string) {
        return await this.db.loadClient(clientId);
    }

    async saveAccessToken(accessToken: AccessToken): Promise<void> {
        await this.db.saveAccessToken(accessToken);
    }

    async getAccessToken(token: string): Promise<AccessToken | undefined> {
        return await this.db.getAccessToken(token);
    }

    // Implement the remaining methods required for your enabled grant types:
    // authorization codes, refresh tokens, revocation, optional registration,
    // and device authorization helpers when using the device grant.
}

Methods

  • getClient: (required) Resolve a registered client by id.
  • registerClient: (required if dynamicClientRegistration is true) Persist dynamic registration.
  • Authorization code grant: saveAuthorizationCode, getAuthorizationCode, revokeAuthorizationCode when authorization_code is enabled.
  • Device grant: saveDeviceAuthorization, getDeviceAuthorizationByDeviceCode, getDeviceAuthorizationByUserCode, deleteDeviceAuthorization when the device grant is enabled.
  • CIMD: saveClientIdMetadataDocument, getClientIdMetadataDocument when clientIdMetadataDocuments is enabled.
  • Tokens: saveAccessToken, getAccessToken, revokeAccessToken, saveRefreshToken, getRefreshToken, revokeRefreshToken.

mcpAuthRouter

Express middleware that mounts OAuth authorization server routes and .well-known metadata.

import express from 'express';
import { mcpAuthRouter } from 'mcp-oauth-server';

const app = express();

app.use(
    mcpAuthRouter({
        provider: oauthServer,
        baseUrl: new URL('http://localhost:3000/oauth'),
        resourceServerUrl: new URL('http://localhost:3000/mcp'),
        scopesSupported: ['mcp:tools'],
    }),
);
  • provider: The OAuthServer instance. Its issuerUrl is used as the issuer identifier in the advertised metadata.
  • baseUrl: Optional AS URL base for OAuth endpoints (defaults to the provider's issuerUrl).
  • resourceServerUrl: Resource server URL for protected-resource metadata.

Endpoints (paths are relative to where you mount the router and to baseUrl / issuer pathname):

  • /.well-known/oauth-authorization-server and path-specific protected-resource metadata (RFC 8414 / RFC 9728)
  • /authorize when authorization_code is in grantTypes
  • /token - authorization code, refresh token, client credentials, and device code exchange (according to grantTypes)
  • /device when the device grant is enabled and deviceAuthorizationUrl is set
  • /register when dynamicClientRegistration is true
  • /revoke - token revocation (RFC 7009)

Install at the application root (see src/router.ts).

authenticateHandler

Handles user consent completion after your consent UI (authorization code flow).

import { authenticateHandler } from 'mcp-oauth-server';

app.post(
    '/confirm',
    authenticateUserMiddleware(),
    authenticateHandler({
        provider: oauthServer,
        getUser: async (req) => {
            return req.session?.userId;
        },
        rateLimit: {
            windowMs: 15 * 60 * 1000,
            max: 100,
        },
    }),
);

Options

  • provider: (required) OAuthServer instance.
  • getUser: (required) Returns the authenticated user id (string or promise).
  • rateLimit: (optional) express-rate-limit options, or false to disable.

requireBearerAuth

Validates Authorization: Bearer tokens for protected routes (for example your MCP HTTP endpoint).

import { getOAuthProtectedResourceMetadataUrl, requireBearerAuth } from 'mcp-oauth-server';

const mcpUrl = new URL('http://localhost:3000/mcp');

app.post(
    '/mcp',
    requireBearerAuth({
        verifier: oauthServer,
        requiredScopes: ['mcp:tools'],
        resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpUrl),
        resource: mcpUrl,
    }),
    async (req, res) => {
        const clientId = req.auth!.clientId;
        const userId = req.auth!.userId;
        // Handle MCP request…
    },
);
  • verifier: Token verifier, typically the OAuthServer instance.
  • requiredScopes: (optional) Scopes the token must include; missing scopes yield 403 with an insufficient_scope challenge.
  • resourceMetadataUrl: (optional) Protected resource metadata URL advertised in WWW-Authenticate challenges (RFC 9728).
  • resource: (optional) Canonical RFC 8707 resource identifier of this endpoint. When set, tokens must carry a matching resource (token audience validation); tokens without one are rejected with 401. OAuthServer.verifyAccessToken already enforces this when resourceServerUrl is configured - set it here when using a custom verifier or protecting multiple resources.

See src/middleware/bearerAuth.ts for details.

After successful authentication, req.auth contains:

  • token: Raw access token string
  • clientId: OAuth client id
  • scopes: Granted scopes
  • userId: Present for user-centric grants; absent for pure client-credentials tokens