Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/express-resource-server-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/express': minor
---

Add OAuth Resource-Server glue to the Express adapter: `requireBearerAuth` middleware (token verification + RFC 6750 `WWW-Authenticate` challenges), `mcpAuthMetadataRouter` (serves RFC 9728 Protected Resource Metadata and mirrors RFC 8414 AS metadata at the resource origin), the `getOAuthProtectedResourceMetadataUrl` helper, and the `OAuthTokenVerifier` interface. These restore the v1 `src/server/auth` Resource-Server pieces as first-class v2 API so MCP servers can plug into an external Authorization Server with a few lines of Express wiring.
9 changes: 7 additions & 2 deletions packages/middleware/express/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,19 +41,24 @@
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {},
"dependencies": {
"cors": "catalog:runtimeServerOnly"
},
"peerDependencies": {
"@modelcontextprotocol/server": "workspace:^",
"express": "catalog:runtimeServerOnly"
"express": "^4.18.0 || ^5.0.0"
},
"devDependencies": {
"@modelcontextprotocol/server": "workspace:^",
"@modelcontextprotocol/tsconfig": "workspace:^",
"@modelcontextprotocol/vitest-config": "workspace:^",
"@modelcontextprotocol/eslint-config": "workspace:^",
"@eslint/js": "catalog:devTools",
"@types/cors": "catalog:devTools",
"@types/express": "catalog:devTools",
"@types/express-serve-static-core": "catalog:devTools",
"@types/supertest": "catalog:devTools",
"supertest": "catalog:devTools",
"@typescript/native-preview": "catalog:devTools",
"eslint": "catalog:devTools",
"eslint-config-prettier": "catalog:devTools",
Expand Down
120 changes: 120 additions & 0 deletions packages/middleware/express/src/auth/bearerAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server';
import type { RequestHandler } from 'express';

import type { OAuthTokenVerifier } from './types.js';

/**
* Options for {@link requireBearerAuth}.
*/
export interface BearerAuthMiddlewareOptions {
/**
* A verifier used to validate access tokens.
*/
verifier: OAuthTokenVerifier;

/**
* Optional scopes that the token must have. When any are missing the
* middleware responds with `403 insufficient_scope`.
*/
requiredScopes?: string[];

/**
* Optional Protected Resource Metadata URL to advertise in the
* `WWW-Authenticate` header on 401/403 responses, per
* {@link https://datatracker.ietf.org/doc/html/rfc9728 | RFC 9728}.
*
* Typically built with `getOAuthProtectedResourceMetadataUrl`.
*/
resourceMetadataUrl?: string;
}

function buildWwwAuthenticateHeader(
errorCode: string,
description: string,
requiredScopes: string[],
resourceMetadataUrl: string | undefined
): string {
let header = `Bearer error="${errorCode}", error_description="${description}"`;
if (requiredScopes.length > 0) {
header += `, scope="${requiredScopes.join(' ')}"`;
}
if (resourceMetadataUrl) {
header += `, resource_metadata="${resourceMetadataUrl}"`;
}
return header;
}

/**
* Express middleware that requires a valid Bearer token in the `Authorization`
* header.
*
* The token is validated via the supplied {@link OAuthTokenVerifier} and the
* resulting `AuthInfo` (from `@modelcontextprotocol/server`) is attached
* to `req.auth`. The MCP Streamable HTTP transport reads `req.auth` and
* surfaces it to handlers as `ctx.http.authInfo`.
*
* On failure the middleware sends a JSON OAuth error body and a
* `WWW-Authenticate: Bearer …` challenge that includes the configured
* `resource_metadata` URL so clients can discover the Authorization Server.
*/
export function requireBearerAuth({ verifier, requiredScopes = [], resourceMetadataUrl }: BearerAuthMiddlewareOptions): RequestHandler {
return async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader) {
throw new OAuthError(OAuthErrorCode.InvalidToken, 'Missing Authorization header');
}

const [type, token] = authHeader.split(' ');
if (type?.toLowerCase() !== 'bearer' || !token) {
throw new OAuthError(OAuthErrorCode.InvalidToken, "Invalid Authorization header format, expected 'Bearer TOKEN'");
}

const authInfo = await verifier.verifyAccessToken(token);

// Check if token has the required scopes (if any)
if (requiredScopes.length > 0) {
const hasAllScopes = requiredScopes.every(scope => authInfo.scopes.includes(scope));
if (!hasAllScopes) {
throw new OAuthError(OAuthErrorCode.InsufficientScope, 'Insufficient scope');
}
}

// Check if the token is set to expire or if it is expired
if (typeof authInfo.expiresAt !== 'number' || Number.isNaN(authInfo.expiresAt)) {
throw new OAuthError(OAuthErrorCode.InvalidToken, 'Token has no expiration time');
} else if (authInfo.expiresAt < Date.now() / 1000) {
throw new OAuthError(OAuthErrorCode.InvalidToken, 'Token has expired');
}

req.auth = authInfo;
next();
} catch (error) {
if (error instanceof OAuthError) {
const challenge = buildWwwAuthenticateHeader(error.code, error.message, requiredScopes, resourceMetadataUrl);
switch (error.code) {
case OAuthErrorCode.InvalidToken: {
res.set('WWW-Authenticate', challenge);
res.status(401).json(error.toResponseObject());
break;
}
case OAuthErrorCode.InsufficientScope: {
res.set('WWW-Authenticate', challenge);
res.status(403).json(error.toResponseObject());
break;
}
case OAuthErrorCode.ServerError: {
res.status(500).json(error.toResponseObject());
break;
}
default: {
res.status(400).json(error.toResponseObject());
}
}
} else {
const serverError = new OAuthError(OAuthErrorCode.ServerError, 'Internal Server Error');
res.status(500).json(serverError.toResponseObject());
}
}
};
}
153 changes: 153 additions & 0 deletions packages/middleware/express/src/auth/metadataRouter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import type { OAuthMetadata, OAuthProtectedResourceMetadata } from '@modelcontextprotocol/server';
import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server';
import cors from 'cors';
import type { RequestHandler, Router } from 'express';
import express from 'express';

// Dev-only escape hatch: allow http:// issuer URLs (e.g., for local testing).
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.');
}

function checkIssuerUrl(issuer: URL): void {
// RFC 8414 technically does not permit a localhost HTTPS exemption, but it is necessary for local 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}`);
}
}

/**
* Express middleware that rejects HTTP methods not in the supplied allow-list
* with a 405 Method Not Allowed and an OAuth-style error body. Used by
* {@link metadataHandler} to restrict metadata endpoints to GET/OPTIONS.
*/
export function allowedMethods(allowed: string[]): RequestHandler {
return (req, res, next) => {
if (allowed.includes(req.method)) {
next();
return;
}
const error = new OAuthError(OAuthErrorCode.MethodNotAllowed, `The method ${req.method} is not allowed for this endpoint`);
res.status(405).set('Allow', allowed.join(', ')).json(error.toResponseObject());
};
}

/**
* Builds a small Express router that serves the given OAuth metadata document
* at `/` as JSON, with permissive CORS and a GET/OPTIONS method allow-list.
*
* Used by {@link mcpAuthMetadataRouter} for both the Authorization Server and
* Protected Resource metadata endpoints.
*/
export function metadataHandler(metadata: OAuthMetadata | OAuthProtectedResourceMetadata): RequestHandler {
const router = express.Router();
// Metadata documents must be fetchable from web-based MCP clients on any origin.
router.use(cors());
router.use(allowedMethods(['GET', 'OPTIONS']));
router.get('/', (_req, res) => {
res.status(200).json(metadata);
});
return router;
}

/**
* Options for {@link mcpAuthMetadataRouter}.
*/
export interface AuthMetadataOptions {
/**
* Authorization Server metadata (RFC 8414) for the AS this MCP server
* relies on. Served at `/.well-known/oauth-authorization-server` so
* legacy clients that probe the resource origin still discover the AS.
*/
oauthMetadata: OAuthMetadata;

/**
* The public URL of this MCP server, used as the `resource` value in the
* Protected Resource Metadata document. Any path component is reflected
* in the well-known route per RFC 9728.
*/
resourceServerUrl: URL;

/**
* Optional documentation URL advertised as `resource_documentation`.
*/
serviceDocumentationUrl?: URL;

/**
* Optional list of scopes this MCP server understands, advertised as
* `scopes_supported`.
*/
scopesSupported?: string[];

/**
* Optional human-readable name advertised as `resource_name`.
*/
resourceName?: string;
}

/**
* Builds an Express router that serves the two OAuth discovery documents an
* MCP server acting purely as a Resource Server needs to expose:
*
* - `/.well-known/oauth-protected-resource[/<path>]` — RFC 9728 Protected
* Resource Metadata, derived from the supplied options.
* - `/.well-known/oauth-authorization-server` — RFC 8414 Authorization
* Server Metadata, passed through verbatim from {@link AuthMetadataOptions.oauthMetadata}.
*
* Mount this router at the application root:
*
* ```ts
* app.use(mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl }));
* ```
*
* Pair with `requireBearerAuth` on your `/mcp` route and pass
* `getOAuthProtectedResourceMetadataUrl` as its `resourceMetadataUrl`
* so unauthenticated clients can discover the AS from the 401 challenge.
*/
export function mcpAuthMetadataRouter(options: AuthMetadataOptions): 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-aware URL per RFC 9728 §3.1.
const rsPath = new URL(options.resourceServerUrl.href).pathname;
router.use(`/.well-known/oauth-protected-resource${rsPath === '/' ? '' : rsPath}`, metadataHandler(protectedResourceMetadata));

// Mirror the AS metadata at this origin for clients that look here first.
router.use('/.well-known/oauth-authorization-server', metadataHandler(options.oauthMetadata));

return router;
}

/**
* Builds the RFC 9728 Protected Resource Metadata URL for a given MCP server
* URL by inserting `/.well-known/oauth-protected-resource` ahead of the path.
*
* @example
* ```ts
* getOAuthProtectedResourceMetadataUrl(new URL('https://api.example.com/mcp'))
* // → '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;
}
33 changes: 33 additions & 0 deletions packages/middleware/express/src/auth/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { AuthInfo } from '@modelcontextprotocol/server';

/**
* Minimal token-verifier interface for MCP servers acting as an OAuth 2.0
* Resource Server. Implementations introspect or locally validate an access
* token and return the resulting {@link AuthInfo}, which is then attached to
* the Express request and surfaced to MCP request handlers via
* `ctx.http.authInfo`.
*
* This is intentionally narrower than a full OAuth Authorization Server
* provider — it only covers the verification step a Resource Server needs.
*/
export interface OAuthTokenVerifier {
/**
* Verifies an access token and returns information about it.
*
* Implementations should throw an `OAuthError` (from `@modelcontextprotocol/server`)
* with `OAuthErrorCode.InvalidToken` when
* the token is unknown, revoked, or otherwise invalid; `requireBearerAuth`
* maps that to a 401 with a `WWW-Authenticate` challenge.
*/
verifyAccessToken(token: string): Promise<AuthInfo>;
}

declare module 'express-serve-static-core' {
interface Request {
/**
* Information about the validated access token, populated by
* `requireBearerAuth`.
*/
auth?: AuthInfo;
}
}
7 changes: 7 additions & 0 deletions packages/middleware/express/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,9 @@
export * from './express.js';
export * from './middleware/hostHeaderValidation.js';

// OAuth Resource-Server glue: bearer-token middleware + PRM/AS metadata router.
export type { BearerAuthMiddlewareOptions } from './auth/bearerAuth.js';
export { requireBearerAuth } from './auth/bearerAuth.js';
export type { AuthMetadataOptions } from './auth/metadataRouter.js';
export { allowedMethods, getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter, metadataHandler } from './auth/metadataRouter.js';
export type { OAuthTokenVerifier } from './auth/types.js';
Loading
Loading