Skip to content

Commit d64230d

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 d64230d

File tree

14 files changed

+591
-174
lines changed

14 files changed

+591
-174
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.

examples/server/src/elicitationUrlExample.ts

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,8 @@
99

1010
import { randomUUID } from 'node:crypto';
1111

12-
import {
13-
createProtectedResourceMetadataRouter,
14-
getOAuthProtectedResourceMetadataUrl,
15-
requireBearerAuth,
16-
setupAuthServer
17-
} from '@modelcontextprotocol/examples-shared';
18-
import { createMcpExpressApp } from '@modelcontextprotocol/express';
12+
import { createProtectedResourceMetadataRouter, demoTokenVerifier, setupAuthServer } from '@modelcontextprotocol/examples-shared';
13+
import { createMcpExpressApp, getOAuthProtectedResourceMetadataUrl, requireBearerAuth } from '@modelcontextprotocol/express';
1914
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
2015
import type { CallToolResult, ElicitRequestURLParams, ElicitResult } from '@modelcontextprotocol/server';
2116
import { isInitializeRequest, McpServer, UrlElicitationRequiredError } from '@modelcontextprotocol/server';
@@ -235,18 +230,17 @@ let authMiddleware = null;
235230
const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}/mcp`);
236231
const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`);
237232

238-
setupAuthServer({ authServerUrl, mcpServerUrl, strictResource: true, demoMode: true });
233+
setupAuthServer({ authServerUrl, mcpServerUrl, demoMode: true });
239234

240235
// Add protected resource metadata route to the MCP server
241236
// This allows clients to discover the auth server
242237
// Pass the resource path so metadata is served at /.well-known/oauth-protected-resource/mcp
243238
app.use(createProtectedResourceMetadataRouter('/mcp'));
244239

245240
authMiddleware = requireBearerAuth({
241+
verifier: demoTokenVerifier,
246242
requiredScopes: [],
247-
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl),
248-
strictResource: true,
249-
expectedResource: mcpServerUrl
243+
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl)
250244
});
251245

252246
/**

examples/server/src/simpleStreamableHttp.ts

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,7 @@
11
import { randomUUID } from 'node:crypto';
22

3-
import {
4-
createProtectedResourceMetadataRouter,
5-
getOAuthProtectedResourceMetadataUrl,
6-
requireBearerAuth,
7-
setupAuthServer
8-
} from '@modelcontextprotocol/examples-shared';
9-
import { createMcpExpressApp } from '@modelcontextprotocol/express';
3+
import { createProtectedResourceMetadataRouter, demoTokenVerifier, setupAuthServer } from '@modelcontextprotocol/examples-shared';
4+
import { createMcpExpressApp, getOAuthProtectedResourceMetadataUrl, requireBearerAuth } from '@modelcontextprotocol/express';
105
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
116
import type {
127
CallToolResult,
@@ -25,7 +20,6 @@ import { InMemoryEventStore } from './inMemoryEventStore.js';
2520

2621
// Check for OAuth flag
2722
const useOAuth = process.argv.includes('--oauth');
28-
const strictOAuth = process.argv.includes('--oauth-strict');
2923
const dangerousLoggingEnabled = process.argv.includes('--dangerous-logging-enabled');
3024

3125
// Create shared task store for demonstration
@@ -624,18 +618,17 @@ if (useOAuth) {
624618
const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}/mcp`);
625619
const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`);
626620

627-
setupAuthServer({ authServerUrl, mcpServerUrl, strictResource: strictOAuth, demoMode: true, dangerousLoggingEnabled });
621+
setupAuthServer({ authServerUrl, mcpServerUrl, demoMode: true, dangerousLoggingEnabled });
628622

629623
// Add protected resource metadata route to the MCP server
630624
// This allows clients to discover the auth server
631625
// Pass the resource path so metadata is served at /.well-known/oauth-protected-resource/mcp
632626
app.use(createProtectedResourceMetadataRouter('/mcp'));
633627

634628
authMiddleware = requireBearerAuth({
629+
verifier: demoTokenVerifier,
635630
requiredScopes: [],
636-
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl),
637-
strictResource: strictOAuth,
638-
expectedResource: mcpServerUrl
631+
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl)
639632
});
640633
}
641634

@@ -651,8 +644,8 @@ const mcpPostHandler = async (req: Request, res: Response) => {
651644
console.log('Request body:', req.body);
652645
}
653646

654-
if (useOAuth && req.app.locals.auth) {
655-
console.log('Authenticated user:', req.app.locals.auth);
647+
if (useOAuth && req.auth) {
648+
console.log('Authenticated user:', req.auth);
656649
}
657650
try {
658651
let transport: NodeStreamableHTTPServerTransport;
@@ -742,8 +735,8 @@ const mcpGetHandler = async (req: Request, res: Response) => {
742735
return;
743736
}
744737

745-
if (useOAuth && req.app.locals.auth) {
746-
console.log('Authenticated SSE connection from user:', req.app.locals.auth);
738+
if (useOAuth && req.auth) {
739+
console.log('Authenticated SSE connection from user:', req.auth);
747740
}
748741

749742
// Check for Last-Event-ID header for resumability

examples/shared/src/authMiddleware.ts

Lines changed: 0 additions & 95 deletions
This file was deleted.

examples/shared/src/authServer.ts

Lines changed: 14 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
* See: https://www.better-auth.com/docs/plugins/mcp
1010
*/
1111

12+
import type { OAuthTokenVerifier } from '@modelcontextprotocol/express';
13+
import type { AuthInfo } from '@modelcontextprotocol/server';
14+
import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server';
1215
import { toNodeHandler } from 'better-auth/node';
1316
import { oAuthDiscoveryMetadata, oAuthProtectedResourceMetadata } from 'better-auth/plugins';
1417
import cors from 'cors';
@@ -21,7 +24,6 @@ import { createDemoAuth, DEMO_USER_CREDENTIALS } from './auth.js';
2124
export interface SetupAuthServerOptions {
2225
authServerUrl: URL;
2326
mcpServerUrl: URL;
24-
strictResource?: boolean;
2527
/**
2628
* Examples should be used for **demo** only and not for production purposes, however this mode disables some logging and other features.
2729
*/
@@ -284,60 +286,29 @@ export function createProtectedResourceMetadataRouter(resourcePath = '/mcp'): Ro
284286
}
285287

286288
/**
287-
* Verifies an access token using better-auth's getMcpSession.
288-
* This can be used by MCP servers to validate tokens.
289+
* Demo {@link OAuthTokenVerifier} backed by better-auth's `getMcpSession`.
290+
* Pass this to `requireBearerAuth({ verifier: demoTokenVerifier, ... })` from
291+
* `@modelcontextprotocol/express` to validate Bearer tokens against the demo
292+
* Authorization Server started by `setupAuthServer`.
289293
*/
290-
export async function verifyAccessToken(
291-
token: string,
292-
options?: { strictResource?: boolean; expectedResource?: URL }
293-
): Promise<{
294-
token: string;
295-
clientId: string;
296-
scopes: string[];
297-
expiresAt: number;
298-
}> {
299-
const auth = getAuth();
294+
export const demoTokenVerifier: OAuthTokenVerifier = {
295+
async verifyAccessToken(token: string): Promise<AuthInfo> {
296+
const auth = getAuth();
300297

301-
try {
302-
// Create a mock request with the Authorization header
303298
const headers = new Headers();
304299
headers.set('Authorization', `Bearer ${token}`);
305300

306-
// Use better-auth's getMcpSession API
307301
// eslint-disable-next-line @typescript-eslint/no-explicit-any
308-
const session = await (auth.api as any).getMcpSession({
309-
headers
310-
});
311-
302+
const session = await (auth.api as any).getMcpSession({ headers });
312303
if (!session) {
313-
throw new Error('Invalid token');
304+
throw new OAuthError(OAuthErrorCode.InvalidToken, 'Invalid token');
314305
}
315306

316-
// OAuthAccessToken has:
317-
// - accessToken, refreshToken: string
318-
// - accessTokenExpiresAt, refreshTokenExpiresAt: Date
319-
// - clientId, userId: string
320-
// - scopes: string (space-separated)
321307
const scopes = typeof session.scopes === 'string' ? session.scopes.split(' ') : ['openid'];
322308
const expiresAt = session.accessTokenExpiresAt
323309
? Math.floor(new Date(session.accessTokenExpiresAt).getTime() / 1000)
324310
: Math.floor(Date.now() / 1000) + 3600;
325311

326-
// Note: better-auth's OAuthAccessToken doesn't have a resource field
327-
// Resource validation would need to be done at a different layer
328-
if (options?.strictResource && options.expectedResource) {
329-
// For now, we skip resource validation as it's not in the session
330-
// In production, you'd store and validate this separately
331-
console.warn('[Auth] Resource validation requested but not available in better-auth session');
332-
}
333-
334-
return {
335-
token,
336-
clientId: session.clientId,
337-
scopes,
338-
expiresAt
339-
};
340-
} catch (error) {
341-
throw new Error(`Token verification failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
312+
return { token, clientId: session.clientId, scopes, expiresAt };
342313
}
343-
}
314+
};

examples/shared/src/index.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,6 @@
22
export type { CreateDemoAuthOptions, DemoAuth } from './auth.js';
33
export { createDemoAuth } from './auth.js';
44

5-
// Auth middleware
6-
export type { RequireBearerAuthOptions } from './authMiddleware.js';
7-
export { getOAuthProtectedResourceMetadataUrl, requireBearerAuth } from './authMiddleware.js';
8-
9-
// Auth server setup
5+
// Auth server setup + demo token verifier (pass to `requireBearerAuth` from @modelcontextprotocol/express)
106
export type { SetupAuthServerOptions } from './authServer.js';
11-
export { createProtectedResourceMetadataRouter, getAuth, setupAuthServer, verifyAccessToken } from './authServer.js';
7+
export { createProtectedResourceMetadataRouter, demoTokenVerifier, getAuth, setupAuthServer } from './authServer.js';

examples/shared/tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"paths": {
99
"*": ["./*"],
1010
"@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"],
11+
"@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"],
1112
"@modelcontextprotocol/express": ["./node_modules/@modelcontextprotocol/express/src/index.ts"],
1213
"@modelcontextprotocol/core": [
1314
"./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/index.ts"

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",

0 commit comments

Comments
 (0)