Skip to content

Commit d1e0ddf

Browse files
feat(compat): restore Resource-Server auth glue in @modelcontextprotocol/express
Adds first-class (not deprecated) OAuth Resource-Server helpers to the Express adapter, restoring the v1 src/server/auth pieces that an MCP server needs when it delegates to an external Authorization Server: - requireBearerAuth: Express middleware that validates a Bearer token via a pluggable OAuthTokenVerifier, attaches AuthInfo to req.auth, and on failure emits RFC 6750 WWW-Authenticate challenges (with optional resource_metadata pointer per RFC 9728). - mcpAuthMetadataRouter: serves RFC 9728 Protected Resource Metadata at /.well-known/oauth-protected-resource[/<path>] and mirrors the AS metadata at /.well-known/oauth-authorization-server, with permissive CORS and a GET/OPTIONS allow-list. - getOAuthProtectedResourceMetadataUrl: builds the path-aware PRM URL for a given server URL. - OAuthTokenVerifier interface, plus metadataHandler / allowedMethods building blocks. Adapted to v2's single OAuthError + OAuthErrorCode (no per-code subclasses) and to types re-exported via @modelcontextprotocol/server. Adds cors as a runtime dependency and supertest as a dev dependency for the integration tests.
1 parent 9ed62fe commit d1e0ddf

File tree

8 files changed

+556
-3
lines changed

8 files changed

+556
-3
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@modelcontextprotocol/express': minor
3+
---
4+
5+
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.

packages/middleware/express/package.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,19 +41,24 @@
4141
"test": "vitest run",
4242
"test:watch": "vitest"
4343
},
44-
"dependencies": {},
44+
"dependencies": {
45+
"cors": "catalog:runtimeServerOnly"
46+
},
4547
"peerDependencies": {
4648
"@modelcontextprotocol/server": "workspace:^",
47-
"express": "catalog:runtimeServerOnly"
49+
"express": "^4.18.0 || ^5.0.0"
4850
},
4951
"devDependencies": {
5052
"@modelcontextprotocol/server": "workspace:^",
5153
"@modelcontextprotocol/tsconfig": "workspace:^",
5254
"@modelcontextprotocol/vitest-config": "workspace:^",
5355
"@modelcontextprotocol/eslint-config": "workspace:^",
5456
"@eslint/js": "catalog:devTools",
57+
"@types/cors": "catalog:devTools",
5558
"@types/express": "catalog:devTools",
5659
"@types/express-serve-static-core": "catalog:devTools",
60+
"@types/supertest": "catalog:devTools",
61+
"supertest": "catalog:devTools",
5762
"@typescript/native-preview": "catalog:devTools",
5863
"eslint": "catalog:devTools",
5964
"eslint-config-prettier": "catalog:devTools",
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server';
2+
import type { RequestHandler } from 'express';
3+
4+
import type { OAuthTokenVerifier } from './types.js';
5+
6+
/**
7+
* Options for {@link requireBearerAuth}.
8+
*/
9+
export interface BearerAuthMiddlewareOptions {
10+
/**
11+
* A verifier used to validate access tokens.
12+
*/
13+
verifier: OAuthTokenVerifier;
14+
15+
/**
16+
* Optional scopes that the token must have. When any are missing the
17+
* middleware responds with `403 insufficient_scope`.
18+
*/
19+
requiredScopes?: string[];
20+
21+
/**
22+
* Optional Protected Resource Metadata URL to advertise in the
23+
* `WWW-Authenticate` header on 401/403 responses, per
24+
* {@link https://datatracker.ietf.org/doc/html/rfc9728 | RFC 9728}.
25+
*
26+
* Typically built with `getOAuthProtectedResourceMetadataUrl`.
27+
*/
28+
resourceMetadataUrl?: string;
29+
}
30+
31+
function buildWwwAuthenticateHeader(
32+
errorCode: string,
33+
description: string,
34+
requiredScopes: string[],
35+
resourceMetadataUrl: string | undefined
36+
): string {
37+
let header = `Bearer error="${errorCode}", error_description="${description}"`;
38+
if (requiredScopes.length > 0) {
39+
header += `, scope="${requiredScopes.join(' ')}"`;
40+
}
41+
if (resourceMetadataUrl) {
42+
header += `, resource_metadata="${resourceMetadataUrl}"`;
43+
}
44+
return header;
45+
}
46+
47+
/**
48+
* Express middleware that requires a valid Bearer token in the `Authorization`
49+
* header.
50+
*
51+
* The token is validated via the supplied {@link OAuthTokenVerifier} and the
52+
* resulting `AuthInfo` (from `@modelcontextprotocol/server`) is attached
53+
* to `req.auth`. The MCP Streamable HTTP transport reads `req.auth` and
54+
* surfaces it to handlers as `ctx.http.authInfo`.
55+
*
56+
* On failure the middleware sends a JSON OAuth error body and a
57+
* `WWW-Authenticate: Bearer …` challenge that includes the configured
58+
* `resource_metadata` URL so clients can discover the Authorization Server.
59+
*/
60+
export function requireBearerAuth({ verifier, requiredScopes = [], resourceMetadataUrl }: BearerAuthMiddlewareOptions): RequestHandler {
61+
return async (req, res, next) => {
62+
try {
63+
const authHeader = req.headers.authorization;
64+
if (!authHeader) {
65+
throw new OAuthError(OAuthErrorCode.InvalidToken, 'Missing Authorization header');
66+
}
67+
68+
const [type, token] = authHeader.split(' ');
69+
if (type?.toLowerCase() !== 'bearer' || !token) {
70+
throw new OAuthError(OAuthErrorCode.InvalidToken, "Invalid Authorization header format, expected 'Bearer TOKEN'");
71+
}
72+
73+
const authInfo = await verifier.verifyAccessToken(token);
74+
75+
// Check if token has the required scopes (if any)
76+
if (requiredScopes.length > 0) {
77+
const hasAllScopes = requiredScopes.every(scope => authInfo.scopes.includes(scope));
78+
if (!hasAllScopes) {
79+
throw new OAuthError(OAuthErrorCode.InsufficientScope, 'Insufficient scope');
80+
}
81+
}
82+
83+
// Check if the token is set to expire or if it is expired
84+
if (typeof authInfo.expiresAt !== 'number' || Number.isNaN(authInfo.expiresAt)) {
85+
throw new OAuthError(OAuthErrorCode.InvalidToken, 'Token has no expiration time');
86+
} else if (authInfo.expiresAt < Date.now() / 1000) {
87+
throw new OAuthError(OAuthErrorCode.InvalidToken, 'Token has expired');
88+
}
89+
90+
req.auth = authInfo;
91+
next();
92+
} catch (error) {
93+
if (error instanceof OAuthError) {
94+
const challenge = buildWwwAuthenticateHeader(error.code, error.message, requiredScopes, resourceMetadataUrl);
95+
switch (error.code) {
96+
case OAuthErrorCode.InvalidToken: {
97+
res.set('WWW-Authenticate', challenge);
98+
res.status(401).json(error.toResponseObject());
99+
break;
100+
}
101+
case OAuthErrorCode.InsufficientScope: {
102+
res.set('WWW-Authenticate', challenge);
103+
res.status(403).json(error.toResponseObject());
104+
break;
105+
}
106+
case OAuthErrorCode.ServerError: {
107+
res.status(500).json(error.toResponseObject());
108+
break;
109+
}
110+
default: {
111+
res.status(400).json(error.toResponseObject());
112+
}
113+
}
114+
} else {
115+
const serverError = new OAuthError(OAuthErrorCode.ServerError, 'Internal Server Error');
116+
res.status(500).json(serverError.toResponseObject());
117+
}
118+
}
119+
};
120+
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import type { OAuthMetadata, OAuthProtectedResourceMetadata } from '@modelcontextprotocol/server';
2+
import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server';
3+
import cors from 'cors';
4+
import type { RequestHandler, Router } from 'express';
5+
import express from 'express';
6+
7+
// Dev-only escape hatch: allow http:// issuer URLs (e.g., for local testing).
8+
const allowInsecureIssuerUrl =
9+
process.env.MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL === 'true' || process.env.MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL === '1';
10+
if (allowInsecureIssuerUrl) {
11+
// eslint-disable-next-line no-console
12+
console.warn('MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL is enabled - HTTP issuer URLs are allowed. Do not use in production.');
13+
}
14+
15+
function checkIssuerUrl(issuer: URL): void {
16+
// RFC 8414 technically does not permit a localhost HTTPS exemption, but it is necessary for local testing.
17+
if (issuer.protocol !== 'https:' && issuer.hostname !== 'localhost' && issuer.hostname !== '127.0.0.1' && !allowInsecureIssuerUrl) {
18+
throw new Error('Issuer URL must be HTTPS');
19+
}
20+
if (issuer.hash) {
21+
throw new Error(`Issuer URL must not have a fragment: ${issuer}`);
22+
}
23+
if (issuer.search) {
24+
throw new Error(`Issuer URL must not have a query string: ${issuer}`);
25+
}
26+
}
27+
28+
/**
29+
* Express middleware that rejects HTTP methods not in the supplied allow-list
30+
* with a 405 Method Not Allowed and an OAuth-style error body. Used by
31+
* {@link metadataHandler} to restrict metadata endpoints to GET/OPTIONS.
32+
*/
33+
export function allowedMethods(allowed: string[]): RequestHandler {
34+
return (req, res, next) => {
35+
if (allowed.includes(req.method)) {
36+
next();
37+
return;
38+
}
39+
const error = new OAuthError(OAuthErrorCode.MethodNotAllowed, `The method ${req.method} is not allowed for this endpoint`);
40+
res.status(405).set('Allow', allowed.join(', ')).json(error.toResponseObject());
41+
};
42+
}
43+
44+
/**
45+
* Builds a small Express router that serves the given OAuth metadata document
46+
* at `/` as JSON, with permissive CORS and a GET/OPTIONS method allow-list.
47+
*
48+
* Used by {@link mcpAuthMetadataRouter} for both the Authorization Server and
49+
* Protected Resource metadata endpoints.
50+
*/
51+
export function metadataHandler(metadata: OAuthMetadata | OAuthProtectedResourceMetadata): RequestHandler {
52+
const router = express.Router();
53+
// Metadata documents must be fetchable from web-based MCP clients on any origin.
54+
router.use(cors());
55+
router.use(allowedMethods(['GET', 'OPTIONS']));
56+
router.get('/', (_req, res) => {
57+
res.status(200).json(metadata);
58+
});
59+
return router;
60+
}
61+
62+
/**
63+
* Options for {@link mcpAuthMetadataRouter}.
64+
*/
65+
export interface AuthMetadataOptions {
66+
/**
67+
* Authorization Server metadata (RFC 8414) for the AS this MCP server
68+
* relies on. Served at `/.well-known/oauth-authorization-server` so
69+
* legacy clients that probe the resource origin still discover the AS.
70+
*/
71+
oauthMetadata: OAuthMetadata;
72+
73+
/**
74+
* The public URL of this MCP server, used as the `resource` value in the
75+
* Protected Resource Metadata document. Any path component is reflected
76+
* in the well-known route per RFC 9728.
77+
*/
78+
resourceServerUrl: URL;
79+
80+
/**
81+
* Optional documentation URL advertised as `resource_documentation`.
82+
*/
83+
serviceDocumentationUrl?: URL;
84+
85+
/**
86+
* Optional list of scopes this MCP server understands, advertised as
87+
* `scopes_supported`.
88+
*/
89+
scopesSupported?: string[];
90+
91+
/**
92+
* Optional human-readable name advertised as `resource_name`.
93+
*/
94+
resourceName?: string;
95+
}
96+
97+
/**
98+
* Builds an Express router that serves the two OAuth discovery documents an
99+
* MCP server acting purely as a Resource Server needs to expose:
100+
*
101+
* - `/.well-known/oauth-protected-resource[/<path>]` — RFC 9728 Protected
102+
* Resource Metadata, derived from the supplied options.
103+
* - `/.well-known/oauth-authorization-server` — RFC 8414 Authorization
104+
* Server Metadata, passed through verbatim from {@link AuthMetadataOptions.oauthMetadata}.
105+
*
106+
* Mount this router at the application root:
107+
*
108+
* ```ts
109+
* app.use(mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl }));
110+
* ```
111+
*
112+
* Pair with `requireBearerAuth` on your `/mcp` route and pass
113+
* `getOAuthProtectedResourceMetadataUrl` as its `resourceMetadataUrl`
114+
* so unauthenticated clients can discover the AS from the 401 challenge.
115+
*/
116+
export function mcpAuthMetadataRouter(options: AuthMetadataOptions): Router {
117+
checkIssuerUrl(new URL(options.oauthMetadata.issuer));
118+
119+
const router = express.Router();
120+
121+
const protectedResourceMetadata: OAuthProtectedResourceMetadata = {
122+
resource: options.resourceServerUrl.href,
123+
authorization_servers: [options.oauthMetadata.issuer],
124+
scopes_supported: options.scopesSupported,
125+
resource_name: options.resourceName,
126+
resource_documentation: options.serviceDocumentationUrl?.href
127+
};
128+
129+
// Serve PRM at the path-aware URL per RFC 9728 §3.1.
130+
const rsPath = new URL(options.resourceServerUrl.href).pathname;
131+
router.use(`/.well-known/oauth-protected-resource${rsPath === '/' ? '' : rsPath}`, metadataHandler(protectedResourceMetadata));
132+
133+
// Mirror the AS metadata at this origin for clients that look here first.
134+
router.use('/.well-known/oauth-authorization-server', metadataHandler(options.oauthMetadata));
135+
136+
return router;
137+
}
138+
139+
/**
140+
* Builds the RFC 9728 Protected Resource Metadata URL for a given MCP server
141+
* URL by inserting `/.well-known/oauth-protected-resource` ahead of the path.
142+
*
143+
* @example
144+
* ```ts
145+
* getOAuthProtectedResourceMetadataUrl(new URL('https://api.example.com/mcp'))
146+
* // → 'https://api.example.com/.well-known/oauth-protected-resource/mcp'
147+
* ```
148+
*/
149+
export function getOAuthProtectedResourceMetadataUrl(serverUrl: URL): string {
150+
const u = new URL(serverUrl.href);
151+
const rsPath = u.pathname && u.pathname !== '/' ? u.pathname : '';
152+
return new URL(`/.well-known/oauth-protected-resource${rsPath}`, u).href;
153+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { AuthInfo } from '@modelcontextprotocol/server';
2+
3+
/**
4+
* Minimal token-verifier interface for MCP servers acting as an OAuth 2.0
5+
* Resource Server. Implementations introspect or locally validate an access
6+
* token and return the resulting {@link AuthInfo}, which is then attached to
7+
* the Express request and surfaced to MCP request handlers via
8+
* `ctx.http.authInfo`.
9+
*
10+
* This is intentionally narrower than a full OAuth Authorization Server
11+
* provider — it only covers the verification step a Resource Server needs.
12+
*/
13+
export interface OAuthTokenVerifier {
14+
/**
15+
* Verifies an access token and returns information about it.
16+
*
17+
* Implementations should throw an `OAuthError` (from `@modelcontextprotocol/server`)
18+
* with `OAuthErrorCode.InvalidToken` when
19+
* the token is unknown, revoked, or otherwise invalid; `requireBearerAuth`
20+
* maps that to a 401 with a `WWW-Authenticate` challenge.
21+
*/
22+
verifyAccessToken(token: string): Promise<AuthInfo>;
23+
}
24+
25+
declare module 'express-serve-static-core' {
26+
interface Request {
27+
/**
28+
* Information about the validated access token, populated by
29+
* `requireBearerAuth`.
30+
*/
31+
auth?: AuthInfo;
32+
}
33+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,9 @@
11
export * from './express.js';
22
export * from './middleware/hostHeaderValidation.js';
3+
4+
// OAuth Resource-Server glue: bearer-token middleware + PRM/AS metadata router.
5+
export type { BearerAuthMiddlewareOptions } from './auth/bearerAuth.js';
6+
export { requireBearerAuth } from './auth/bearerAuth.js';
7+
export type { AuthMetadataOptions } from './auth/metadataRouter.js';
8+
export { allowedMethods, getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter, metadataHandler } from './auth/metadataRouter.js';
9+
export type { OAuthTokenVerifier } from './auth/types.js';

0 commit comments

Comments
 (0)