v2 backcompat: server legacy package (#2206)

Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com>
This commit is contained in:
Konstantin Konstantinov 2026-06-01 17:33:32 +03:00 • committed by GitHub
commit e03bca90c1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 5797 additions and 71 deletions

View file

@ -0,0 +1,22 @@
import type { OAuthClientInformationFull } from '@modelcontextprotocol/core';
/**
* Stores information about registered OAuth clients for this server.
*/
export interface OAuthRegisteredClientsStore {
/**
* Returns information about a registered client, based on its ID.
*/
getClient(clientId: string): OAuthClientInformationFull | undefined | Promise<OAuthClientInformationFull | undefined>;
/**
* Registers a new client with the server. The client ID and secret will be automatically generated by the library. A modified version of the client information can be returned to reflect specific values enforced by the server.
*
* NOTE: Implementations should NOT delete expired client secrets in-place. Auth middleware provided by this library will automatically check the `client_secret_expires_at` field and reject requests with expired secrets. Any custom logic for authenticating clients should check the `client_secret_expires_at` field as well.
*
* If unimplemented, dynamic client registration is unsupported.
*/
registerClient?(
client: Omit<OAuthClientInformationFull, 'client_id' | 'client_id_issued_at'>
): OAuthClientInformationFull | Promise<OAuthClientInformationFull>;
}

View file

@ -0,0 +1,212 @@
import type { OAuthErrorResponse } from '@modelcontextprotocol/core';
/**
* Base class for all OAuth errors
*/
export class OAuthError extends Error {
static errorCode: string;
constructor(
message: string,
public readonly errorUri?: string
) {
super(message);
this.name = this.constructor.name;
}
/**
* Converts the error to a standard OAuth error response object
*/
toResponseObject(): OAuthErrorResponse {
const response: OAuthErrorResponse = {
error: this.errorCode,
error_description: this.message
};
if (this.errorUri) {
response.error_uri = this.errorUri;
}
return response;
}
get errorCode(): string {
return (this.constructor as typeof OAuthError).errorCode;
}
}
/**
* Invalid request error - The request is missing a required parameter,
* includes an invalid parameter value, includes a parameter more than once,
* or is otherwise malformed.
*/
export class InvalidRequestError extends OAuthError {
static override errorCode = 'invalid_request';
}
/**
* Invalid client error - Client authentication failed (e.g., unknown client, no client
* authentication included, or unsupported authentication method).
*/
export class InvalidClientError extends OAuthError {
static override errorCode = 'invalid_client';
}
/**
* Invalid grant error - The provided authorization grant or refresh token is
* invalid, expired, revoked, does not match the redirection URI used in the
* authorization request, or was issued to another client.
*/
export class InvalidGrantError extends OAuthError {
static override errorCode = 'invalid_grant';
}
/**
* Unauthorized client error - The authenticated client is not authorized to use
* this authorization grant type.
*/
export class UnauthorizedClientError extends OAuthError {
static override errorCode = 'unauthorized_client';
}
/**
* Unsupported grant type error - The authorization grant type is not supported
* by the authorization server.
*/
export class UnsupportedGrantTypeError extends OAuthError {
static override errorCode = 'unsupported_grant_type';
}
/**
* Invalid scope error - The requested scope is invalid, unknown, malformed, or
* exceeds the scope granted by the resource owner.
*/
export class InvalidScopeError extends OAuthError {
static override errorCode = 'invalid_scope';
}
/**
* Access denied error - The resource owner or authorization server denied the request.
*/
export class AccessDeniedError extends OAuthError {
static override errorCode = 'access_denied';
}
/**
* Server error - The authorization server encountered an unexpected condition
* that prevented it from fulfilling the request.
*/
export class ServerError extends OAuthError {
static override errorCode = 'server_error';
}
/**
* Temporarily unavailable error - The authorization server is currently unable to
* handle the request due to a temporary overloading or maintenance of the server.
*/
export class TemporarilyUnavailableError extends OAuthError {
static override errorCode = 'temporarily_unavailable';
}
/**
* Unsupported response type error - The authorization server does not support
* obtaining an authorization code using this method.
*/
export class UnsupportedResponseTypeError extends OAuthError {
static override errorCode = 'unsupported_response_type';
}
/**
* Unsupported token type error - The authorization server does not support
* the requested token type.
*/
export class UnsupportedTokenTypeError extends OAuthError {
static override errorCode = 'unsupported_token_type';
}
/**
* Invalid token error - The access token provided is expired, revoked, malformed,
* or invalid for other reasons.
*/
export class InvalidTokenError extends OAuthError {
static override errorCode = 'invalid_token';
}
/**
* Method not allowed error - The HTTP method used is not allowed for this endpoint.
* (Custom, non-standard error)
*/
export class MethodNotAllowedError extends OAuthError {
static override errorCode = 'method_not_allowed';
}
/**
* Too many requests error - Rate limit exceeded.
* (Custom, non-standard error based on RFC 6585)
*/
export class TooManyRequestsError extends OAuthError {
static override errorCode = 'too_many_requests';
}
/**
* Invalid client metadata error - The client metadata is invalid.
* (Custom error for dynamic client registration - RFC 7591)
*/
export class InvalidClientMetadataError extends OAuthError {
static override errorCode = 'invalid_client_metadata';
}
/**
* Insufficient scope error - The request requires higher privileges than provided by the access token.
*/
export class InsufficientScopeError extends OAuthError {
static override errorCode = 'insufficient_scope';
}
/**
* Invalid target error - The requested resource is invalid, missing, unknown, or malformed.
* (Custom error for resource indicators - RFC 8707)
*/
export class InvalidTargetError extends OAuthError {
static override errorCode = 'invalid_target';
}
/**
* A utility class for defining one-off error codes
*/
export class CustomOAuthError extends OAuthError {
constructor(
private readonly customErrorCode: string,
message: string,
errorUri?: string
) {
super(message, errorUri);
}
override get errorCode(): string {
return this.customErrorCode;
}
}
/**
* A full list of all OAuthErrors, enabling parsing from error responses
*/
export const OAUTH_ERRORS = {
[InvalidRequestError.errorCode]: InvalidRequestError,
[InvalidClientError.errorCode]: InvalidClientError,
[InvalidGrantError.errorCode]: InvalidGrantError,
[UnauthorizedClientError.errorCode]: UnauthorizedClientError,
[UnsupportedGrantTypeError.errorCode]: UnsupportedGrantTypeError,
[InvalidScopeError.errorCode]: InvalidScopeError,
[AccessDeniedError.errorCode]: AccessDeniedError,
[ServerError.errorCode]: ServerError,
[TemporarilyUnavailableError.errorCode]: TemporarilyUnavailableError,
[UnsupportedResponseTypeError.errorCode]: UnsupportedResponseTypeError,
[UnsupportedTokenTypeError.errorCode]: UnsupportedTokenTypeError,
[InvalidTokenError.errorCode]: InvalidTokenError,
[MethodNotAllowedError.errorCode]: MethodNotAllowedError,
[TooManyRequestsError.errorCode]: TooManyRequestsError,
[InvalidClientMetadataError.errorCode]: InvalidClientMetadataError,
[InsufficientScopeError.errorCode]: InsufficientScopeError,
[InvalidTargetError.errorCode]: InvalidTargetError
} as const;

View file

@ -0,0 +1,201 @@
import type { RequestHandler } from 'express';
import express from 'express';
import type { Options as RateLimitOptions } from 'express-rate-limit';
import { rateLimit } from 'express-rate-limit';
import * as z from 'zod/v4';
import { InvalidClientError, InvalidRequestError, OAuthError, ServerError, TooManyRequestsError } from '../errors.js';
import { allowedMethods } from '../middleware/allowedMethods.js';
import type { OAuthServerProvider } from '../provider.js';
export type AuthorizationHandlerOptions = {
provider: OAuthServerProvider;
/**
* Rate limiting configuration for the authorization endpoint.
* Set to false to disable rate limiting for this endpoint.
*/
rateLimit?: Partial<RateLimitOptions> | false;
};
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
/**
* Validates a requested redirect_uri against a registered one.
*
* Per RFC 8252 §7.3 (OAuth 2.0 for Native Apps), authorization servers MUST
* allow any port for loopback redirect URIs (localhost, 127.0.0.1, [::1]) to
* accommodate native clients that obtain an ephemeral port from the OS. For
* non-loopback URIs, exact match is required.
*
* @see https://datatracker.ietf.org/doc/html/rfc8252#section-7.3
*/
export function redirectUriMatches(requested: string, registered: string): boolean {
if (requested === registered) {
return true;
}
let req: URL, reg: URL;
try {
req = new URL(requested);
reg = new URL(registered);
} catch {
return false;
}
// Port relaxation only applies when both URIs target a loopback host.
if (!LOOPBACK_HOSTS.has(req.hostname) || !LOOPBACK_HOSTS.has(reg.hostname)) {
return false;
}
// RFC 8252 relaxes the port only — scheme, host, path, and query must
// still match exactly. Note: hostname must match exactly too (the RFC
// does not allow localhost↔127.0.0.1 cross-matching).
return req.protocol === reg.protocol && req.hostname === reg.hostname && req.pathname === reg.pathname && req.search === reg.search;
}
// Parameters that must be validated in order to issue redirects.
const ClientAuthorizationParamsSchema = z.object({
client_id: z.string(),
redirect_uri: z
.string()
.optional()
.refine(value => value === undefined || URL.canParse(value), { message: 'redirect_uri must be a valid URL' })
});
// Parameters that must be validated for a successful authorization request. Failure can be reported to the redirect URI.
const RequestAuthorizationParamsSchema = z.object({
response_type: z.literal('code'),
code_challenge: z.string(),
code_challenge_method: z.literal('S256'),
scope: z.string().optional(),
state: z.string().optional(),
resource: z.string().url().optional()
});
export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: AuthorizationHandlerOptions): RequestHandler {
// Create a router to apply middleware
const router = express.Router();
router.use(allowedMethods(['GET', 'POST']));
router.use(express.urlencoded({ extended: false }));
// Apply rate limiting unless explicitly disabled
if (rateLimitConfig !== false) {
router.use(
rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per windowMs
standardHeaders: true,
legacyHeaders: false,
message: new TooManyRequestsError('You have exceeded the rate limit for authorization requests').toResponseObject(),
...rateLimitConfig
})
);
}
router.all('/', async (req, res) => {
res.setHeader('Cache-Control', 'no-store');
// In the authorization flow, errors are split into two categories:
// 1. Pre-redirect errors (direct response with 400)
// 2. Post-redirect errors (redirect with error parameters)
// Phase 1: Validate client_id and redirect_uri. Any errors here must be direct responses.
let client_id, redirect_uri, client;
try {
const result = ClientAuthorizationParamsSchema.safeParse(req.method === 'POST' ? req.body : req.query);
if (!result.success) {
throw new InvalidRequestError(result.error.message);
}
client_id = result.data.client_id;
redirect_uri = result.data.redirect_uri;
client = await provider.clientsStore.getClient(client_id);
if (!client) {
throw new InvalidClientError('Invalid client_id');
}
if (redirect_uri !== undefined) {
const requested = redirect_uri;
if (!client.redirect_uris.some(registered => redirectUriMatches(requested, registered))) {
throw new InvalidRequestError('Unregistered redirect_uri');
}
} else if (client.redirect_uris.length === 1) {
redirect_uri = client.redirect_uris[0];
} else {
throw new InvalidRequestError('redirect_uri must be specified when client has multiple registered URIs');
}
} catch (error) {
// Pre-redirect errors - return direct response
//
// These don't need to be JSON encoded, as they'll be displayed in a user
// agent, but OTOH they all represent exceptional situations (arguably,
// "programmer error"), so presenting a nice HTML page doesn't help the
// user anyway.
if (error instanceof OAuthError) {
const status = error instanceof ServerError ? 500 : 400;
res.status(status).json(error.toResponseObject());
} else {
const serverError = new ServerError('Internal Server Error');
res.status(500).json(serverError.toResponseObject());
}
return;
}
// Phase 2: Validate other parameters. Any errors here should go into redirect responses.
let state;
try {
// Parse and validate authorization parameters
const parseResult = RequestAuthorizationParamsSchema.safeParse(req.method === 'POST' ? req.body : req.query);
if (!parseResult.success) {
throw new InvalidRequestError(parseResult.error.message);
}
const { scope, code_challenge, resource } = parseResult.data;
state = parseResult.data.state;
// Validate scopes
let requestedScopes: string[] = [];
if (scope !== undefined) {
requestedScopes = scope.split(' ');
}
// All validation passed, proceed with authorization
await provider.authorize(
client,
{
state,
scopes: requestedScopes,
redirectUri: redirect_uri!,
codeChallenge: code_challenge,
resource: resource ? new URL(resource) : undefined
},
res
);
} catch (error) {
// Post-redirect errors - redirect with error parameters
if (error instanceof OAuthError) {
res.redirect(302, createErrorRedirect(redirect_uri!, error, state));
} else {
const serverError = new ServerError('Internal Server Error');
res.redirect(302, createErrorRedirect(redirect_uri!, serverError, state));
}
}
});
return router;
}
/**
* Helper function to create redirect URL with error parameters
*/
function createErrorRedirect(redirectUri: string, error: OAuthError, state?: string): string {
const errorUrl = new URL(redirectUri);
errorUrl.searchParams.set('error', error.errorCode);
errorUrl.searchParams.set('error_description', error.message);
if (error.errorUri) {
errorUrl.searchParams.set('error_uri', error.errorUri);
}
if (state) {
errorUrl.searchParams.set('state', state);
}
return errorUrl.href;
}

View file

@ -0,0 +1,21 @@
import type { OAuthMetadata, OAuthProtectedResourceMetadata } from '@modelcontextprotocol/core';
import cors from 'cors';
import type { RequestHandler } from 'express';
import express from 'express';
import { allowedMethods } from '../middleware/allowedMethods.js';
export function metadataHandler(metadata: OAuthMetadata | OAuthProtectedResourceMetadata): RequestHandler {
// Nested router so we can configure middleware and restrict HTTP method
const router = express.Router();
// Configure CORS to allow any origin, to make accessible to web-based MCP clients
router.use(cors());
router.use(allowedMethods(['GET', 'OPTIONS']));
router.get('/', (req, res) => {
res.status(200).json(metadata);
});
return router;
}

View file

@ -0,0 +1,124 @@
import crypto from 'node:crypto';
import type { OAuthClientInformationFull } from '@modelcontextprotocol/core';
import { OAuthClientMetadataSchema } from '@modelcontextprotocol/core';
import cors from 'cors';
import type { RequestHandler } from 'express';
import express from 'express';
import type { Options as RateLimitOptions } from 'express-rate-limit';
import { rateLimit } from 'express-rate-limit';
import type { OAuthRegisteredClientsStore } from '../clients.js';
import { InvalidClientMetadataError, OAuthError, ServerError, TooManyRequestsError } from '../errors.js';
import { allowedMethods } from '../middleware/allowedMethods.js';
export type ClientRegistrationHandlerOptions = {
/**
* A store used to save information about dynamically registered OAuth clients.
*/
clientsStore: OAuthRegisteredClientsStore;
/**
* The number of seconds after which to expire issued client secrets, or 0 to prevent expiration of client secrets (not recommended).
*
* If not set, defaults to 30 days.
*/
clientSecretExpirySeconds?: number;
/**
* Rate limiting configuration for the client registration endpoint.
* Set to false to disable rate limiting for this endpoint.
* Registration endpoints are particularly sensitive to abuse and should be rate limited.
*/
rateLimit?: Partial<RateLimitOptions> | false;
/**
* Whether to generate a client ID before calling the client registration endpoint.
*
* If not set, defaults to true.
*/
clientIdGeneration?: boolean;
};
const DEFAULT_CLIENT_SECRET_EXPIRY_SECONDS = 30 * 24 * 60 * 60; // 30 days
export function clientRegistrationHandler({
clientsStore,
clientSecretExpirySeconds = DEFAULT_CLIENT_SECRET_EXPIRY_SECONDS,
rateLimit: rateLimitConfig,
clientIdGeneration = true
}: ClientRegistrationHandlerOptions): RequestHandler {
if (!clientsStore.registerClient) {
throw new Error('Client registration store does not support registering clients');
}
// Nested router so we can configure middleware and restrict HTTP method
const router = express.Router();
// Configure CORS to allow any origin, to make accessible to web-based MCP clients
router.use(cors());
router.use(allowedMethods(['POST']));
router.use(express.json());
// Apply rate limiting unless explicitly disabled - stricter limits for registration
if (rateLimitConfig !== false) {
router.use(
rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 20, // 20 requests per hour - stricter as registration is sensitive
standardHeaders: true,
legacyHeaders: false,
message: new TooManyRequestsError('You have exceeded the rate limit for client registration requests').toResponseObject(),
...rateLimitConfig
})
);
}
router.post('/', async (req, res) => {
res.setHeader('Cache-Control', 'no-store');
try {
const parseResult = OAuthClientMetadataSchema.safeParse(req.body);
if (!parseResult.success) {
throw new InvalidClientMetadataError(parseResult.error.message);
}
const clientMetadata = parseResult.data;
const isPublicClient = clientMetadata.token_endpoint_auth_method === 'none';
// Generate client credentials
const clientSecret = isPublicClient ? undefined : crypto.randomBytes(32).toString('hex');
const clientIdIssuedAt = Math.floor(Date.now() / 1000);
// Calculate client secret expiry time
const clientsDoExpire = clientSecretExpirySeconds > 0;
const secretExpiryTime = clientsDoExpire ? clientIdIssuedAt + clientSecretExpirySeconds : 0;
const clientSecretExpiresAt = isPublicClient ? undefined : secretExpiryTime;
let clientInfo: Omit<OAuthClientInformationFull, 'client_id'> & { client_id?: string } = {
...clientMetadata,
client_secret: clientSecret,
client_secret_expires_at: clientSecretExpiresAt
};
if (clientIdGeneration) {
clientInfo.client_id = crypto.randomUUID();
clientInfo.client_id_issued_at = clientIdIssuedAt;
}
clientInfo = await clientsStore.registerClient!(clientInfo);
res.status(201).json(clientInfo);
} catch (error) {
if (error instanceof OAuthError) {
const status = error instanceof ServerError ? 500 : 400;
res.status(status).json(error.toResponseObject());
} else {
const serverError = new ServerError('Internal Server Error');
res.status(500).json(serverError.toResponseObject());
}
}
});
return router;
}

View file

@ -0,0 +1,82 @@
import { OAuthTokenRevocationRequestSchema } from '@modelcontextprotocol/core';
import cors from 'cors';
import type { RequestHandler } from 'express';
import express from 'express';
import type { Options as RateLimitOptions } from 'express-rate-limit';
import { rateLimit } from 'express-rate-limit';
import { InvalidRequestError, OAuthError, ServerError, TooManyRequestsError } from '../errors.js';
import { allowedMethods } from '../middleware/allowedMethods.js';
import { authenticateClient } from '../middleware/clientAuth.js';
import type { OAuthServerProvider } from '../provider.js';
export type RevocationHandlerOptions = {
provider: OAuthServerProvider;
/**
* Rate limiting configuration for the token revocation endpoint.
* Set to false to disable rate limiting for this endpoint.
*/
rateLimit?: Partial<RateLimitOptions> | false;
};
export function revocationHandler({ provider, rateLimit: rateLimitConfig }: RevocationHandlerOptions): RequestHandler {
if (!provider.revokeToken) {
throw new Error('Auth provider does not support revoking tokens');
}
// Nested router so we can configure middleware and restrict HTTP method
const router = express.Router();
// Configure CORS to allow any origin, to make accessible to web-based MCP clients
router.use(cors());
router.use(allowedMethods(['POST']));
router.use(express.urlencoded({ extended: false }));
// Apply rate limiting unless explicitly disabled
if (rateLimitConfig !== false) {
router.use(
rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 50, // 50 requests per windowMs
standardHeaders: true,
legacyHeaders: false,
message: new TooManyRequestsError('You have exceeded the rate limit for token revocation requests').toResponseObject(),
...rateLimitConfig
})
);
}
// Authenticate and extract client details
router.use(authenticateClient({ clientsStore: provider.clientsStore }));
router.post('/', async (req, res) => {
res.setHeader('Cache-Control', 'no-store');
try {
const parseResult = OAuthTokenRevocationRequestSchema.safeParse(req.body);
if (!parseResult.success) {
throw new InvalidRequestError(parseResult.error.message);
}
const client = req.client;
if (!client) {
// This should never happen
throw new ServerError('Internal Server Error');
}
await provider.revokeToken!(client, parseResult.data);
res.status(200).json({});
} catch (error) {
if (error instanceof OAuthError) {
const status = error instanceof ServerError ? 500 : 400;
res.status(status).json(error.toResponseObject());
} else {
const serverError = new ServerError('Internal Server Error');
res.status(500).json(serverError.toResponseObject());
}
}
});
return router;
}

View file

@ -0,0 +1,157 @@
import cors from 'cors';
import type { RequestHandler } from 'express';
import express from 'express';
import type { Options as RateLimitOptions } from 'express-rate-limit';
import { rateLimit } from 'express-rate-limit';
import { verifyChallenge } from 'pkce-challenge';
import * as z from 'zod/v4';
import {
InvalidGrantError,
InvalidRequestError,
OAuthError,
ServerError,
TooManyRequestsError,
UnsupportedGrantTypeError
} from '../errors.js';
import { allowedMethods } from '../middleware/allowedMethods.js';
import { authenticateClient } from '../middleware/clientAuth.js';
import type { OAuthServerProvider } from '../provider.js';
export type TokenHandlerOptions = {
provider: OAuthServerProvider;
/**
* Rate limiting configuration for the token endpoint.
* Set to false to disable rate limiting for this endpoint.
*/
rateLimit?: Partial<RateLimitOptions> | false;
};
const TokenRequestSchema = z.object({
grant_type: z.string()
});
const AuthorizationCodeGrantSchema = z.object({
code: z.string(),
code_verifier: z.string(),
redirect_uri: z.string().optional(),
resource: z.string().url().optional()
});
const RefreshTokenGrantSchema = z.object({
refresh_token: z.string(),
scope: z.string().optional(),
resource: z.string().url().optional()
});
export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHandlerOptions): RequestHandler {
// Nested router so we can configure middleware and restrict HTTP method
const router = express.Router();
// Configure CORS to allow any origin, to make accessible to web-based MCP clients
router.use(cors());
router.use(allowedMethods(['POST']));
router.use(express.urlencoded({ extended: false }));
// Apply rate limiting unless explicitly disabled
if (rateLimitConfig !== false) {
router.use(
rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 50, // 50 requests per windowMs
standardHeaders: true,
legacyHeaders: false,
message: new TooManyRequestsError('You have exceeded the rate limit for token requests').toResponseObject(),
...rateLimitConfig
})
);
}
// Authenticate and extract client details
router.use(authenticateClient({ clientsStore: provider.clientsStore }));
router.post('/', async (req, res) => {
res.setHeader('Cache-Control', 'no-store');
try {
const parseResult = TokenRequestSchema.safeParse(req.body);
if (!parseResult.success) {
throw new InvalidRequestError(parseResult.error.message);
}
const { grant_type } = parseResult.data;
const client = req.client;
if (!client) {
// This should never happen
throw new ServerError('Internal Server Error');
}
switch (grant_type) {
case 'authorization_code': {
const parseResult = AuthorizationCodeGrantSchema.safeParse(req.body);
if (!parseResult.success) {
throw new InvalidRequestError(parseResult.error.message);
}
const { code, code_verifier, redirect_uri, resource } = parseResult.data;
const skipLocalPkceValidation = provider.skipLocalPkceValidation;
// Perform local PKCE validation unless explicitly skipped
// (e.g. to validate code_verifier in upstream server)
if (!skipLocalPkceValidation) {
const codeChallenge = await provider.challengeForAuthorizationCode(client, code);
if (!(await verifyChallenge(code_verifier, codeChallenge))) {
throw new InvalidGrantError('code_verifier does not match the challenge');
}
}
// Passes the code_verifier to the provider if PKCE validation didn't occur locally
const tokens = await provider.exchangeAuthorizationCode(
client,
code,
skipLocalPkceValidation ? code_verifier : undefined,
redirect_uri,
resource ? new URL(resource) : undefined
);
res.status(200).json(tokens);
break;
}
case 'refresh_token': {
const parseResult = RefreshTokenGrantSchema.safeParse(req.body);
if (!parseResult.success) {
throw new InvalidRequestError(parseResult.error.message);
}
const { refresh_token, scope, resource } = parseResult.data;
const scopes = scope?.split(' ');
const tokens = await provider.exchangeRefreshToken(
client,
refresh_token,
scopes,
resource ? new URL(resource) : undefined
);
res.status(200).json(tokens);
break;
}
default: {
throw new UnsupportedGrantTypeError('The grant type is not supported by this authorization server.');
}
}
} catch (error) {
if (error instanceof OAuthError) {
const status = error instanceof ServerError ? 500 : 400;
res.status(status).json(error.toResponseObject());
} else {
const serverError = new ServerError('Internal Server Error');
res.status(500).json(serverError.toResponseObject());
}
}
});
return router;
}

View file

@ -0,0 +1,37 @@
// Core router
export type { AuthMetadataOptions, AuthRouterOptions } from './router.js';
export { createOAuthMetadata, getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter, mcpAuthRouter } from './router.js';
// Provider interfaces
export type { AuthorizationParams, OAuthServerProvider, OAuthTokenVerifier } from './provider.js';
// Proxy provider
export type { ProxyEndpoints, ProxyOptions } from './providers/proxyProvider.js';
export { ProxyOAuthServerProvider } from './providers/proxyProvider.js';
// Client store
export type { OAuthRegisteredClientsStore } from './clients.js';
// Handlers
export type { AuthorizationHandlerOptions } from './handlers/authorize.js';
export { authorizationHandler, redirectUriMatches } from './handlers/authorize.js';
export { metadataHandler } from './handlers/metadata.js';
export type { ClientRegistrationHandlerOptions } from './handlers/register.js';
export { clientRegistrationHandler } from './handlers/register.js';
export type { RevocationHandlerOptions } from './handlers/revoke.js';
export { revocationHandler } from './handlers/revoke.js';
export type { TokenHandlerOptions } from './handlers/token.js';
export { tokenHandler } from './handlers/token.js';
// Middleware
export { allowedMethods } from './middleware/allowedMethods.js';
export type { BearerAuthMiddlewareOptions } from './middleware/bearerAuth.js';
export { requireBearerAuth } from './middleware/bearerAuth.js';
export type { ClientAuthenticationMiddlewareOptions } from './middleware/clientAuth.js';
export { authenticateClient } from './middleware/clientAuth.js';
// Error classes
export * from './errors.js';
// Types
export type { AuthInfo } from './types.js';

View file

@ -0,0 +1,21 @@
import type { RequestHandler } from 'express';
import { MethodNotAllowedError } from '../errors.js';
/**
* Middleware to handle unsupported HTTP methods with a 405 Method Not Allowed response.
*
* @param allowedMethods Array of allowed HTTP methods for this endpoint (e.g., ['GET', 'POST'])
* @returns Express middleware that returns a 405 error if method not in allowed list
*/
export function allowedMethods(allowedMethods: string[]): RequestHandler {
return (req, res, next) => {
if (allowedMethods.includes(req.method)) {
next();
return;
}
const error = new MethodNotAllowedError(`The method ${req.method} is not allowed for this endpoint`);
res.status(405).set('Allow', allowedMethods.join(', ')).json(error.toResponseObject());
};
}

View file

@ -0,0 +1,100 @@
import type { RequestHandler } from 'express';
import { InsufficientScopeError, InvalidTokenError, OAuthError, ServerError } from '../errors.js';
import type { OAuthTokenVerifier } from '../provider.js';
import type { AuthInfo } from '../types.js';
export type BearerAuthMiddlewareOptions = {
/**
* A provider used to verify tokens.
*/
verifier: OAuthTokenVerifier;
/**
* Optional scopes that the token must have.
*/
requiredScopes?: string[];
/**
* Optional resource metadata URL to include in WWW-Authenticate header.
*/
resourceMetadataUrl?: string;
};
declare module 'express-serve-static-core' {
interface Request {
/**
* Information about the validated access token, if the `requireBearerAuth` middleware was used.
*/
auth?: AuthInfo;
}
}
/**
* Middleware that requires a valid Bearer token in the Authorization header.
*
* This will validate the token with the auth provider and add the resulting auth info to the request object.
*
* If resourceMetadataUrl is provided, it will be included in the WWW-Authenticate header
* for 401 responses as per the OAuth 2.0 Protected Resource Metadata spec.
*/
export function requireBearerAuth({ verifier, requiredScopes = [], resourceMetadataUrl }: BearerAuthMiddlewareOptions): RequestHandler {
const buildWwwAuthHeader = (errorCode: string, message: string): string => {
let header = `Bearer error="${errorCode}", error_description="${message}"`;
if (requiredScopes.length > 0) {
header += `, scope="${requiredScopes.join(' ')}"`;
}
if (resourceMetadataUrl) {
header += `, resource_metadata="${resourceMetadataUrl}"`;
}
return header;
};
return async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader) {
throw new InvalidTokenError('Missing Authorization header');
}
const [type, token] = authHeader.split(' ') as [string | undefined, string | undefined];
if (!type || type.toLowerCase() !== 'bearer' || !token) {
throw new InvalidTokenError("Invalid Authorization header format, expected 'Bearer TOKEN'");
}
const authInfo = await verifier.verifyAccessToken(token);
if (requiredScopes.length > 0) {
const hasAllScopes = requiredScopes.every(scope => authInfo.scopes.includes(scope));
if (!hasAllScopes) {
throw new InsufficientScopeError('Insufficient scope');
}
}
if (typeof authInfo.expiresAt !== 'number' || Number.isNaN(authInfo.expiresAt)) {
throw new InvalidTokenError('Token has no expiration time');
} else if (authInfo.expiresAt < Date.now() / 1000) {
throw new InvalidTokenError('Token has expired');
}
req.auth = authInfo;
next();
} catch (error) {
if (error instanceof InvalidTokenError) {
res.set('WWW-Authenticate', buildWwwAuthHeader(error.errorCode, error.message));
res.status(401).json(error.toResponseObject());
} else if (error instanceof InsufficientScopeError) {
res.set('WWW-Authenticate', buildWwwAuthHeader(error.errorCode, error.message));
res.status(403).json(error.toResponseObject());
} else if (error instanceof ServerError) {
res.status(500).json(error.toResponseObject());
} else if (error instanceof OAuthError) {
res.status(400).json(error.toResponseObject());
} else {
const serverError = new ServerError('Internal Server Error');
res.status(500).json(serverError.toResponseObject());
}
}
};
}

View file

@ -0,0 +1,65 @@
import type { OAuthClientInformationFull } from '@modelcontextprotocol/core';
import type { RequestHandler } from 'express';
import * as z from 'zod/v4';
import type { OAuthRegisteredClientsStore } from '../clients.js';
import { InvalidClientError, InvalidRequestError, OAuthError, ServerError } from '../errors.js';
export type ClientAuthenticationMiddlewareOptions = {
/**
* A store used to read information about registered OAuth clients.
*/
clientsStore: OAuthRegisteredClientsStore;
};
const ClientAuthenticatedRequestSchema = z.object({
client_id: z.string(),
client_secret: z.string().optional()
});
declare module 'express-serve-static-core' {
interface Request {
/**
* The authenticated client for this request, if the `authenticateClient` middleware was used.
*/
client?: OAuthClientInformationFull;
}
}
export function authenticateClient({ clientsStore }: ClientAuthenticationMiddlewareOptions): RequestHandler {
return async (req, res, next) => {
try {
const result = ClientAuthenticatedRequestSchema.safeParse(req.body);
if (!result.success) {
throw new InvalidRequestError(String(result.error));
}
const { client_id, client_secret } = result.data;
const client = await clientsStore.getClient(client_id);
if (!client) {
throw new InvalidClientError('Invalid client_id');
}
if (client.client_secret) {
if (!client_secret) {
throw new InvalidClientError('Client secret is required');
}
if (client.client_secret !== client_secret) {
throw new InvalidClientError('Invalid client_secret');
}
if (client.client_secret_expires_at && client.client_secret_expires_at < Math.floor(Date.now() / 1000)) {
throw new InvalidClientError('Client secret has expired');
}
}
req.client = client;
next();
} catch (error) {
if (error instanceof OAuthError) {
const status = error instanceof ServerError ? 500 : 400;
res.status(status).json(error.toResponseObject());
} else {
const serverError = new ServerError('Internal Server Error');
res.status(500).json(serverError.toResponseObject());
}
}
};
}

View file

@ -0,0 +1,84 @@
import type { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '@modelcontextprotocol/core';
import type { Response } from 'express';
import type { OAuthRegisteredClientsStore } from './clients.js';
import type { AuthInfo } from './types.js';
export type AuthorizationParams = {
state?: string;
scopes?: string[];
codeChallenge: string;
redirectUri: string;
resource?: URL;
};
/**
* Implements an end-to-end OAuth server.
*/
export interface OAuthServerProvider {
/**
* A store used to read information about registered OAuth clients.
*/
get clientsStore(): OAuthRegisteredClientsStore;
/**
* Begins the authorization flow, which can either be implemented by this server itself or via redirection to a separate authorization server.
*
* This server must eventually issue a redirect with an authorization response or an error response to the given redirect URI. Per OAuth 2.1:
* - In the successful case, the redirect MUST include the `code` and `state` (if present) query parameters.
* - In the error case, the redirect MUST include the `error` query parameter, and MAY include an optional `error_description` query parameter.
*/
authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void>;
/**
* Returns the `codeChallenge` that was used when the indicated authorization began.
*/
challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise<string>;
/**
* Exchanges an authorization code for an access token.
*/
exchangeAuthorizationCode(
client: OAuthClientInformationFull,
authorizationCode: string,
codeVerifier?: string,
redirectUri?: string,
resource?: URL
): Promise<OAuthTokens>;
/**
* Exchanges a refresh token for an access token.
*/
exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[], resource?: URL): Promise<OAuthTokens>;
/**
* Verifies an access token and returns information about it.
*/
verifyAccessToken(token: string): Promise<AuthInfo>;
/**
* Revokes an access or refresh token. If unimplemented, token revocation is not supported (not recommended).
*
* If the given token is invalid or already revoked, this method should do nothing.
*/
revokeToken?(client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest): Promise<void>;
/**
* Whether to skip local PKCE validation.
*
* If true, the server will not perform PKCE validation locally and will pass the code_verifier to the upstream server.
*
* NOTE: This should only be true if the upstream server is performing the actual PKCE validation.
*/
skipLocalPkceValidation?: boolean;
}
/**
* Slim implementation useful for token verification
*/
export interface OAuthTokenVerifier {
/**
* Verifies an access token and returns information about it.
*/
verifyAccessToken(token: string): Promise<AuthInfo>;
}

View file

@ -0,0 +1,233 @@
import type { FetchLike, OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '@modelcontextprotocol/core';
import { OAuthClientInformationFullSchema, OAuthTokensSchema } from '@modelcontextprotocol/core';
import type { Response } from 'express';
import type { OAuthRegisteredClientsStore } from '../clients.js';
import { ServerError } from '../errors.js';
import type { AuthorizationParams, OAuthServerProvider } from '../provider.js';
import type { AuthInfo } from '../types.js';
export type ProxyEndpoints = {
authorizationUrl: string;
tokenUrl: string;
revocationUrl?: string;
registrationUrl?: string;
};
export type ProxyOptions = {
/**
* Individual endpoint URLs for proxying specific OAuth operations
*/
endpoints: ProxyEndpoints;
/**
* Function to verify access tokens and return auth info
*/
verifyAccessToken: (token: string) => Promise<AuthInfo>;
/**
* Function to fetch client information from the upstream server
*/
getClient: (clientId: string) => Promise<OAuthClientInformationFull | undefined>;
/**
* Custom fetch implementation used for all network requests.
*/
fetch?: FetchLike;
};
/**
* Implements an OAuth server that proxies requests to another OAuth server.
*/
export class ProxyOAuthServerProvider implements OAuthServerProvider {
protected readonly _endpoints: ProxyEndpoints;
protected readonly _verifyAccessToken: (token: string) => Promise<AuthInfo>;
protected readonly _getClient: (clientId: string) => Promise<OAuthClientInformationFull | undefined>;
protected readonly _fetch?: FetchLike;
skipLocalPkceValidation = true;
revokeToken?: (client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest) => Promise<void>;
constructor(options: ProxyOptions) {
this._endpoints = options.endpoints;
this._verifyAccessToken = options.verifyAccessToken;
this._getClient = options.getClient;
this._fetch = options.fetch;
if (options.endpoints?.revocationUrl) {
this.revokeToken = async (client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest) => {
const revocationUrl = this._endpoints.revocationUrl;
if (!revocationUrl) {
throw new Error('No revocation endpoint configured');
}
const params = new URLSearchParams();
params.set('token', request.token);
params.set('client_id', client.client_id);
if (client.client_secret) {
params.set('client_secret', client.client_secret);
}
if (request.token_type_hint) {
params.set('token_type_hint', request.token_type_hint);
}
const response = await (this._fetch ?? fetch)(revocationUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: params.toString()
});
await response.body?.cancel();
if (!response.ok) {
throw new ServerError(`Token revocation failed: ${response.status}`);
}
};
}
}
get clientsStore(): OAuthRegisteredClientsStore {
const registrationUrl = this._endpoints.registrationUrl;
return {
getClient: this._getClient,
...(registrationUrl && {
registerClient: async (client: OAuthClientInformationFull) => {
const response = await (this._fetch ?? fetch)(registrationUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(client)
});
if (!response.ok) {
await response.body?.cancel();
throw new ServerError(`Client registration failed: ${response.status}`);
}
const data = await response.json();
return OAuthClientInformationFullSchema.parse(data);
}
})
};
}
async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void> {
// Start with required OAuth parameters
const targetUrl = new URL(this._endpoints.authorizationUrl);
const searchParams = new URLSearchParams({
client_id: client.client_id,
response_type: 'code',
redirect_uri: params.redirectUri,
code_challenge: params.codeChallenge,
code_challenge_method: 'S256'
});
// Add optional standard OAuth parameters
if (params.state) searchParams.set('state', params.state);
if (params.scopes?.length) searchParams.set('scope', params.scopes.join(' '));
if (params.resource) searchParams.set('resource', params.resource.href);
targetUrl.search = searchParams.toString();
res.redirect(targetUrl.toString());
}
async challengeForAuthorizationCode(_client: OAuthClientInformationFull, _authorizationCode: string): Promise<string> {
// In a proxy setup, we don't store the code challenge ourselves
// Instead, we proxy the token request and let the upstream server validate it
return '';
}
async exchangeAuthorizationCode(
client: OAuthClientInformationFull,
authorizationCode: string,
codeVerifier?: string,
redirectUri?: string,
resource?: URL
): Promise<OAuthTokens> {
const params = new URLSearchParams({
grant_type: 'authorization_code',
client_id: client.client_id,
code: authorizationCode
});
if (client.client_secret) {
params.append('client_secret', client.client_secret);
}
if (codeVerifier) {
params.append('code_verifier', codeVerifier);
}
if (redirectUri) {
params.append('redirect_uri', redirectUri);
}
if (resource) {
params.append('resource', resource.href);
}
const response = await (this._fetch ?? fetch)(this._endpoints.tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: params.toString()
});
if (!response.ok) {
await response.body?.cancel();
throw new ServerError(`Token exchange failed: ${response.status}`);
}
const data = await response.json();
return OAuthTokensSchema.parse(data);
}
async exchangeRefreshToken(
client: OAuthClientInformationFull,
refreshToken: string,
scopes?: string[],
resource?: URL
): Promise<OAuthTokens> {
const params = new URLSearchParams({
grant_type: 'refresh_token',
client_id: client.client_id,
refresh_token: refreshToken
});
if (client.client_secret) {
params.set('client_secret', client.client_secret);
}
if (scopes?.length) {
params.set('scope', scopes.join(' '));
}
if (resource) {
params.set('resource', resource.href);
}
const response = await (this._fetch ?? fetch)(this._endpoints.tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: params.toString()
});
if (!response.ok) {
await response.body?.cancel();
throw new ServerError(`Token refresh failed: ${response.status}`);
}
const data = await response.json();
return OAuthTokensSchema.parse(data);
}
async verifyAccessToken(token: string): Promise<AuthInfo> {
return this._verifyAccessToken(token);
}
}

View file

@ -0,0 +1,246 @@
import type { OAuthMetadata, OAuthProtectedResourceMetadata } from '@modelcontextprotocol/core';
import type { RequestHandler } from 'express';
import express from 'express';
import type { AuthorizationHandlerOptions } from './handlers/authorize.js';
import { authorizationHandler } from './handlers/authorize.js';
import { metadataHandler } from './handlers/metadata.js';
import type { ClientRegistrationHandlerOptions } from './handlers/register.js';
import { clientRegistrationHandler } from './handlers/register.js';
import type { RevocationHandlerOptions } from './handlers/revoke.js';
import { revocationHandler } from './handlers/revoke.js';
import type { TokenHandlerOptions } from './handlers/token.js';
import { tokenHandler } from './handlers/token.js';
import type { OAuthServerProvider } from './provider.js';
// Check for dev mode flag that allows HTTP issuer URLs (for development/testing only)
const allowInsecureIssuerUrl =
process.env.MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL === 'true' || process.env.MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL === '1';
if (allowInsecureIssuerUrl) {
// eslint-disable-next-line no-console
console.warn('MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL is enabled - HTTP issuer URLs are allowed. Do not use in production.');
}
export type AuthRouterOptions = {
/**
* A provider implementing the actual authorization logic for this router.
*/
provider: OAuthServerProvider;
/**
* The authorization server's issuer identifier, which is a URL that uses the "https" scheme and has no query or fragment components.
*/
issuerUrl: URL;
/**
* The base URL of the authorization server to use for the metadata endpoints.
*
* If not provided, the issuer URL will be used as the base URL.
*/
baseUrl?: URL;
/**
* An optional URL of a page containing human-readable information that developers might want or need to know when using the authorization server.
*/
serviceDocumentationUrl?: URL;
/**
* An optional list of scopes supported by this authorization server
*/
scopesSupported?: string[];
/**
* The resource name to be displayed in protected resource metadata
*/
resourceName?: string;
/**
* The URL of the protected resource (RS) whose metadata we advertise.
* If not provided, falls back to `baseUrl` and then to `issuerUrl` (AS=RS).
*/
resourceServerUrl?: URL;
// Individual options per route
authorizationOptions?: Omit<AuthorizationHandlerOptions, 'provider'>;
clientRegistrationOptions?: Omit<ClientRegistrationHandlerOptions, 'clientsStore'>;
revocationOptions?: Omit<RevocationHandlerOptions, 'provider'>;
tokenOptions?: Omit<TokenHandlerOptions, 'provider'>;
};
const checkIssuerUrl = (issuer: URL): void => {
// Technically RFC 8414 does not permit a localhost HTTPS exemption, but this will be necessary for ease of testing
if (issuer.protocol !== 'https:' && issuer.hostname !== 'localhost' && issuer.hostname !== '127.0.0.1' && !allowInsecureIssuerUrl) {
throw new Error('Issuer URL must be HTTPS');
}
if (issuer.hash) {
throw new Error(`Issuer URL must not have a fragment: ${issuer}`);
}
if (issuer.search) {
throw new Error(`Issuer URL must not have a query string: ${issuer}`);
}
};
export const createOAuthMetadata = (options: {
provider: OAuthServerProvider;
issuerUrl: URL;
baseUrl?: URL;
serviceDocumentationUrl?: URL;
scopesSupported?: string[];
}): OAuthMetadata => {
const issuer = options.issuerUrl;
const baseUrl = options.baseUrl;
checkIssuerUrl(issuer);
const authorization_endpoint = '/authorize';
const token_endpoint = '/token';
const registration_endpoint = options.provider.clientsStore.registerClient ? '/register' : undefined;
const revocation_endpoint = options.provider.revokeToken ? '/revoke' : undefined;
const metadata: OAuthMetadata = {
issuer: issuer.href,
service_documentation: options.serviceDocumentationUrl?.href,
authorization_endpoint: new URL(authorization_endpoint, baseUrl || issuer).href,
response_types_supported: ['code'],
code_challenge_methods_supported: ['S256'],
token_endpoint: new URL(token_endpoint, baseUrl || issuer).href,
token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
grant_types_supported: ['authorization_code', 'refresh_token'],
scopes_supported: options.scopesSupported,
revocation_endpoint: revocation_endpoint ? new URL(revocation_endpoint, baseUrl || issuer).href : undefined,
revocation_endpoint_auth_methods_supported: revocation_endpoint ? ['client_secret_post'] : undefined,
registration_endpoint: registration_endpoint ? new URL(registration_endpoint, baseUrl || issuer).href : undefined
};
return metadata;
};
/**
* Installs standard MCP authorization server endpoints, including dynamic client registration and token revocation (if supported).
* Also advertises standard authorization server metadata, for easier discovery of supported configurations by clients.
* Note: if your MCP server is only a resource server and not an authorization server, use mcpAuthMetadataRouter instead.
*
* By default, rate limiting is applied to all endpoints to prevent abuse.
*
* This router MUST be installed at the application root, like so:
*
* const app = express();
* app.use(mcpAuthRouter(...));
*/
export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler {
const oauthMetadata = createOAuthMetadata(options);
const router = express.Router();
router.use(
new URL(oauthMetadata.authorization_endpoint).pathname,
authorizationHandler({ provider: options.provider, ...options.authorizationOptions })
);
router.use(new URL(oauthMetadata.token_endpoint).pathname, tokenHandler({ provider: options.provider, ...options.tokenOptions }));
router.use(
mcpAuthMetadataRouter({
oauthMetadata,
// Prefer explicit RS; otherwise fall back to AS baseUrl, then to issuer (back-compat)
resourceServerUrl: options.resourceServerUrl ?? options.baseUrl ?? new URL(oauthMetadata.issuer),
serviceDocumentationUrl: options.serviceDocumentationUrl,
scopesSupported: options.scopesSupported,
resourceName: options.resourceName
})
);
if (oauthMetadata.registration_endpoint) {
router.use(
new URL(oauthMetadata.registration_endpoint).pathname,
clientRegistrationHandler({
clientsStore: options.provider.clientsStore,
...options.clientRegistrationOptions
})
);
}
if (oauthMetadata.revocation_endpoint) {
router.use(
new URL(oauthMetadata.revocation_endpoint).pathname,
revocationHandler({ provider: options.provider, ...options.revocationOptions })
);
}
return router;
}
export type AuthMetadataOptions = {
/**
* OAuth Metadata as would be returned from the authorization server
* this MCP server relies on
*/
oauthMetadata: OAuthMetadata;
/**
* The url of the MCP server, for use in protected resource metadata
*/
resourceServerUrl: URL;
/**
* The url for documentation for the MCP server
*/
serviceDocumentationUrl?: URL;
/**
* An optional list of scopes supported by this MCP server
*/
scopesSupported?: string[];
/**
* An optional resource name to display in resource metadata
*/
resourceName?: string;
};
export function mcpAuthMetadataRouter(options: AuthMetadataOptions): express.Router {
checkIssuerUrl(new URL(options.oauthMetadata.issuer));
const router = express.Router();
const protectedResourceMetadata: OAuthProtectedResourceMetadata = {
resource: options.resourceServerUrl.href,
authorization_servers: [options.oauthMetadata.issuer],
scopes_supported: options.scopesSupported,
resource_name: options.resourceName,
resource_documentation: options.serviceDocumentationUrl?.href
};
// Serve PRM at the path-specific URL per RFC 9728
const rsPath = new URL(options.resourceServerUrl.href).pathname;
router.use(`/.well-known/oauth-protected-resource${rsPath === '/' ? '' : rsPath}`, metadataHandler(protectedResourceMetadata));
// Always add this for OAuth Authorization Server metadata per RFC 8414
router.use('/.well-known/oauth-authorization-server', metadataHandler(options.oauthMetadata));
return router;
}
/**
* Helper function to construct the OAuth 2.0 Protected Resource Metadata URL
* from a given server URL. This replaces the path with the standard metadata endpoint.
*
* @param serverUrl - The base URL of the protected resource server
* @returns The URL for the OAuth protected resource metadata endpoint
*
* @example
* getOAuthProtectedResourceMetadataUrl(new URL('https://api.example.com/mcp'))
* // Returns: 'https://api.example.com/.well-known/oauth-protected-resource/mcp'
*/
export function getOAuthProtectedResourceMetadataUrl(serverUrl: URL): string {
const u = new URL(serverUrl.href);
const rsPath = u.pathname && u.pathname !== '/' ? u.pathname : '';
return new URL(`/.well-known/oauth-protected-resource${rsPath}`, u).href;
}

View file

@ -0,0 +1 @@
export type { AuthInfo } from '@modelcontextprotocol/core';

View file

@ -0,0 +1,2 @@
export * from './auth/index.js';
export * from './sse/index.js';

View file

@ -0,0 +1 @@
export { SSEServerTransport, type SSEServerTransportOptions } from './sse.js';

View file

@ -0,0 +1,195 @@
import { randomUUID } from 'node:crypto';
import type { IncomingMessage, ServerResponse } from 'node:http';
import { TLSSocket } from 'node:tls';
import type { AuthInfo, JSONRPCMessage, MessageExtraInfo, Transport, TransportSendOptions } from '@modelcontextprotocol/core';
import { JSONRPCMessageSchema } from '@modelcontextprotocol/core';
import contentType from 'content-type';
import getRawBody from 'raw-body';
const MAXIMUM_MESSAGE_SIZE = '4mb';
/**
* Configuration options for SSEServerTransport.
* @deprecated Use StreamableHTTPServerTransport instead.
*/
export interface SSEServerTransportOptions {
/**
* @deprecated Use the host-header-validation middleware from @modelcontextprotocol/express instead.
*/
allowedHosts?: string[];
/**
* @deprecated Use the host-header-validation middleware from @modelcontextprotocol/express instead.
*/
allowedOrigins?: string[];
/**
* @deprecated Use the host-header-validation middleware from @modelcontextprotocol/express instead.
*/
enableDnsRebindingProtection?: boolean;
}
/**
* Server transport for SSE: this will send messages over an SSE connection and receive messages from HTTP POST requests.
*
* This transport is only available in Node.js environments.
* @deprecated Use StreamableHTTPServerTransport from @modelcontextprotocol/server instead.
*/
export class SSEServerTransport implements Transport {
private _sseResponse?: ServerResponse;
private _sessionId: string;
private _options: SSEServerTransportOptions;
onclose?: () => void;
onerror?: ((error: Error) => void) | undefined;
onmessage?: (<T extends JSONRPCMessage>(message: T, extra?: MessageExtraInfo) => void) | undefined;
constructor(
private _endpoint: string,
private res: ServerResponse,
options?: SSEServerTransportOptions
) {
this._sessionId = randomUUID();
this._options = options || { enableDnsRebindingProtection: false };
}
private validateRequestHeaders(req: IncomingMessage): string | undefined {
if (!this._options.enableDnsRebindingProtection) {
return undefined;
}
if (this._options.allowedHosts && this._options.allowedHosts.length > 0) {
const hostHeader = req.headers.host;
if (!hostHeader || !this._options.allowedHosts.includes(hostHeader)) {
return `Invalid Host header: ${hostHeader}`;
}
}
if (this._options.allowedOrigins && this._options.allowedOrigins.length > 0) {
const originHeader = req.headers.origin;
if (originHeader && !this._options.allowedOrigins.includes(originHeader)) {
return `Invalid Origin header: ${originHeader}`;
}
}
return undefined;
}
async start(): Promise<void> {
if (this._sseResponse) {
throw new Error('SSEServerTransport already started! If using Server class, note that connect() calls start() automatically.');
}
this.res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive'
});
const dummyBase = 'http://localhost';
const endpointUrl = new URL(this._endpoint, dummyBase);
endpointUrl.searchParams.set('sessionId', this._sessionId);
const relativeUrlWithSession = endpointUrl.pathname + endpointUrl.search + endpointUrl.hash;
this.res.write(`event: endpoint\ndata: ${relativeUrlWithSession}\n\n`);
this._sseResponse = this.res;
this.res.on('close', () => {
this._sseResponse = undefined;
this.onclose?.();
});
}
async handlePostMessage(req: IncomingMessage & { auth?: AuthInfo }, res: ServerResponse, parsedBody?: unknown): Promise<void> {
if (!this._sseResponse) {
const message = 'SSE connection not established';
res.writeHead(500).end(message);
throw new Error(message);
}
const validationError = this.validateRequestHeaders(req);
if (validationError) {
res.writeHead(403).end(validationError);
this.onerror?.(new Error(validationError));
return;
}
const authInfo: AuthInfo | undefined = req.auth;
const host = req.headers.host;
const protocol = req.socket instanceof TLSSocket ? 'https' : 'http';
const fullUrl = host && req.url ? new URL(req.url, `${protocol}://${host}`) : undefined;
const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
if (typeof value === 'string') {
headers.set(key, value);
} else if (Array.isArray(value)) {
for (const v of value) {
headers.append(key, v);
}
}
}
const request = fullUrl ? new Request(fullUrl.toString(), { method: req.method ?? 'POST', headers }) : undefined;
let body: string | unknown;
try {
const ct = contentType.parse(req.headers['content-type'] ?? '');
if (ct.type !== 'application/json') {
throw new Error(`Unsupported content-type: ${ct.type}`);
}
body =
parsedBody ??
(await getRawBody(req, {
limit: MAXIMUM_MESSAGE_SIZE,
encoding: ct.parameters.charset ?? 'utf8'
}));
} catch (error) {
res.writeHead(400).end(String(error));
this.onerror?.(error as Error);
return;
}
try {
await this.handleMessage(typeof body === 'string' ? JSON.parse(body) : body, { request, authInfo });
} catch {
res.writeHead(400).end(`Invalid message: ${body}`);
return;
}
res.writeHead(202).end('Accepted');
}
async handleMessage(message: unknown, extra?: MessageExtraInfo): Promise<void> {
let parsedMessage: JSONRPCMessage;
try {
parsedMessage = JSONRPCMessageSchema.parse(message);
} catch (error) {
this.onerror?.(error as Error);
throw error;
}
this.onmessage?.(parsedMessage, extra);
}
async close(): Promise<void> {
this._sseResponse?.end();
this._sseResponse = undefined;
this.onclose?.();
}
async send(message: JSONRPCMessage, _options?: TransportSendOptions): Promise<void> {
if (!this._sseResponse) {
throw new Error('Not connected');
}
this._sseResponse.write(`event: message\ndata: ${JSON.stringify(message)}\n\n`);
}
get sessionId(): string {
return this._sessionId;
}
}