Skip to content

Commit 0225166

Browse files
feat(server): runtime-neutral requireBearerAuth for web-standard hosts
Bearer authentication existed only as Express middleware, so Cloudflare Workers, Deno, Bun, and Hono hosts had to hand-roll header parsing, scope and expiry enforcement, and the WWW-Authenticate challenge. The core moves to @modelcontextprotocol/server as verifyBearerToken (raw header value in, AuthInfo out, OAuthError thrown), bearerAuthChallengeResponse (error to 401/403/500/400 challenge Response), and requireBearerAuth (a fetch-style gate resolving to AuthInfo or the ready challenge Response), with OAuthTokenVerifier now defined here. The Express middleware becomes a thin adapter over the same core with byte-identical behavior, proven by its untouched test suite; it re-exports OAuthTokenVerifier as before. The extraction also hardens the previously untested edges: challenge header values are quoted-string sanitized so a verifier message with control or non-ASCII characters can no longer make the Response constructor throw, the web gate takes the first Authorization value where Fetch's Headers.get comma-joins duplicates (matching Node), and malformed options crash at creation time rather than on the first request.
1 parent ddf6cc1 commit 0225166

12 files changed

Lines changed: 590 additions & 128 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@modelcontextprotocol/server': minor
3+
'@modelcontextprotocol/express': patch
4+
---
5+
6+
Add runtime-neutral Bearer authentication to `@modelcontextprotocol/server`:
7+
`requireBearerAuth` gates web-standard `fetch(request)` hosts (Cloudflare
8+
Workers, Deno, Bun, Hono), built on the exported `verifyBearerToken` and
9+
`bearerAuthChallengeResponse` pieces, with `OAuthTokenVerifier` now defined
10+
here. The Express middleware adapts the same core and is unchanged in
11+
behavior; `@modelcontextprotocol/express` re-exports `OAuthTokenVerifier` as
12+
before.

docs/migration/upgrade-to-v2.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,8 @@ The codemod ([`@modelcontextprotocol/codemod`](https://github.com/modelcontextpr
6464
mechanically applies every rename whose mapping is fixed. The mappings are the
6565
**source of truth** — they live in the codemod package and are not reproduced here:
6666

67-
| Mapping | Source file |
68-
| ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
67+
| Mapping | Source file |
68+
| ----------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
6969
| `@modelcontextprotocol/sdk/...` import paths → v2 packages | [`mappings/importMap.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts) |
7070
| Symbol renames (`McpError``ProtocolError`, `JSONRPCError``JSONRPCErrorResponse`, …) | [`mappings/symbolMap.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/codemod/src/migrations/v1-to-v2/mappings/symbolMap.ts) |
7171
| `setRequestHandler(Schema, …)``setRequestHandler('method/string', …)` | [`mappings/schemaToMethodMap.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts) |
@@ -395,7 +395,9 @@ A few transports need a decision the codemod can't make:
395395
import path changes.
396396
- **Server auth split.** Resource Server helpers (`requireBearerAuth`,
397397
`mcpAuthMetadataRouter`, `getOAuthProtectedResourceMetadataUrl`, `OAuthTokenVerifier`)
398-
→ `@modelcontextprotocol/express`. Authorization Server helpers (`mcpAuthRouter`,
398+
→ `@modelcontextprotocol/express`; the runtime-neutral core (`requireBearerAuth`
399+
for web-standard `fetch` hosts, `verifyBearerToken`, `bearerAuthChallengeResponse`,
400+
`OAuthTokenVerifier`) is also exported from `@modelcontextprotocol/server`. Authorization Server helpers (`mcpAuthRouter`,
399401
`OAuthServerProvider`, `ProxyOAuthServerProvider`, `allowedMethods`,
400402
`authenticateClient`, `metadataHandler`, `createOAuthMetadata`,
401403
`authorizationHandler` / `tokenHandler` / `revocationHandler` /
@@ -1036,7 +1038,8 @@ if (error instanceof OAuthError && error.code === OAuthErrorCode.InvalidClient)
10361038
```
10371039
10381040
⚠ **Token verifiers must throw the v2 `OAuthError`.** `requireBearerAuth` (from
1039-
`@modelcontextprotocol/express`) classifies the error your
1041+
`@modelcontextprotocol/express`, or from `@modelcontextprotocol/server` on
1042+
web-standard hosts) classifies the error your
10401043
`OAuthTokenVerifier.verifyAccessToken()` throws: a v2
10411044
`OAuthError(OAuthErrorCode.InvalidToken)` produces the proper `401` +
10421045
`WWW-Authenticate` challenge, while the legacy `InvalidTokenError` (from

docs/serving/authorization.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
shape: how-to
33
description: 'Require a bearer token on a server you run: verification, protected-resource metadata, and per-tool scopes.'
44
---
5+
56
# Require authorization
67

78
Protecting a server you run → this page. Signing a user in from a client you build → [Authenticate a user with OAuth](../clients/oauth.md). No user present → [Authenticate without a user](../clients/machine-auth.md).
@@ -42,6 +43,25 @@ A request with a missing, malformed, or expired token gets `401` with the OAuth
4243
The Authorization Server helpers (`mcpAuthRouter`, `ProxyOAuthServerProvider`, …) are frozen in `@modelcontextprotocol/server-legacy/auth`. Use a dedicated identity provider for new servers; this page only covers the resource-server half.
4344
:::
4445

46+
## Require a bearer token on a web-standard host
47+
48+
On hosts whose HTTP surface is a `fetch(request)` handler — Cloudflare Workers, Deno, Bun, Hono — the gate is `requireBearerAuth` from `@modelcontextprotocol/server`: no framework, only web-standard `Request` and `Response`.
49+
50+
```ts source="../../examples/guides/serving/authorization.web.examples.ts#requireBearerAuth_webStandard"
51+
const gate = requireBearerAuth({ verifier, requiredScopes: ['mcp'] });
52+
const handler = createMcpHandler(buildServer);
53+
54+
export default {
55+
async fetch(request: Request): Promise<Response> {
56+
const auth = await gate(request);
57+
if (auth instanceof Response) return auth;
58+
return handler.fetch(request, { authInfo: auth });
59+
}
60+
};
61+
```
62+
63+
The gate resolves to the verified `AuthInfo` — pass it to the handler as `{ authInfo }` and handlers read it as `ctx.http.authInfo` — or to the ready-to-return challenge `Response`. Status codes, error bodies, and the `WWW-Authenticate` challenge (including `resourceMetadataUrl`) are identical to the Express middleware: both are adapters over one core, so a verifier written for one serves the other unchanged.
64+
4565
## Verify tokens your way
4666

4767
`verifyAccessToken` is the one function you supply: take the raw token string, return an `AuthInfo`. Local JWT verification, [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662) introspection, or a call to your identity provider all fit behind it.
@@ -107,6 +127,7 @@ Responding `403 insufficient_scope` at the HTTP layer instead triggers the clien
107127

108128
## Recap
109129

130+
- `requireBearerAuth` from `@modelcontextprotocol/server` is the same gate for web-standard `fetch` hosts; the Express middleware adapts the same core.
110131
- `requireBearerAuth` plus a `verifyAccessToken` you write turn an Express-mounted MCP route into an OAuth resource server; the SDK never issues tokens.
111132
- Missing, invalid, or expired tokens get `401 invalid_token`; a token missing a `requiredScopes` entry gets `403 insufficient_scope`; both carry a `WWW-Authenticate: Bearer` challenge.
112133
- `mcpAuthMetadataRouter` publishes the RFC 9728 document that challenge points at, plus a mirror of the AS metadata.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// docs: typecheck-only
2+
/**
3+
* Companion example for the web-standard section of
4+
* `docs/serving/authorization.md`.
5+
*
6+
* Lives beside `authorization.examples.ts` in its own module because both
7+
* packages export a `requireBearerAuth`: the Express one is demonstrated
8+
* there, the web-standard one from `@modelcontextprotocol/server` here. Like
9+
* its sibling, this file only typechecks:
10+
*
11+
* pnpm --filter @modelcontextprotocol/examples typecheck
12+
*
13+
* @module
14+
*/
15+
import type { AuthInfo, OAuthTokenVerifier } from '@modelcontextprotocol/server';
16+
import { createMcpHandler, McpServer, OAuthError, OAuthErrorCode, requireBearerAuth } from '@modelcontextprotocol/server';
17+
18+
declare function verifyJwt(token: string): Promise<{ sub: string; scopes: string[]; exp: number }>;
19+
20+
const verifier: OAuthTokenVerifier = {
21+
async verifyAccessToken(token): Promise<AuthInfo> {
22+
const payload = await verifyJwt(token).catch(() => {
23+
throw new OAuthError(OAuthErrorCode.InvalidToken, 'unknown token');
24+
});
25+
return { token, clientId: payload.sub, scopes: payload.scopes, expiresAt: payload.exp };
26+
}
27+
};
28+
29+
function buildServer(): McpServer {
30+
return new McpServer({ name: 'protected-server', version: '1.0.0' });
31+
}
32+
33+
//#region requireBearerAuth_webStandard
34+
const gate = requireBearerAuth({ verifier, requiredScopes: ['mcp'] });
35+
const handler = createMcpHandler(buildServer);
36+
37+
export default {
38+
async fetch(request: Request): Promise<Response> {
39+
const auth = await gate(request);
40+
if (auth instanceof Response) return auth;
41+
return handler.fetch(request, { authInfo: auth });
42+
}
43+
};
44+
//#endregion requireBearerAuth_webStandard

packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,10 @@ export interface ImportMapping {
3434
}
3535

3636
/**
37-
* Resource-server auth helpers whose maintained v2 home is `@modelcontextprotocol/express`;
38-
* the server-legacy/auth copy they route to by default is a frozen v1 snapshot, so import
37+
* Resource-server auth helpers whose maintained v2 home is `@modelcontextprotocol/express`
38+
* (with the runtime-neutral core — `requireBearerAuth` for web-standard hosts and
39+
* `OAuthTokenVerifier` — also exported from `@modelcontextprotocol/server`); the
40+
* server-legacy/auth copy they route to by default is a frozen v1 snapshot, so import
3941
* and re-export sites get a marker prompting a deliberate re-point.
4042
*/
4143
export const RS_ONLY_AUTH_SYMBOLS: ReadonlySet<string> = new Set([
@@ -141,7 +143,8 @@ export const IMPORT_MAP: Record<string, ImportMapping> = {
141143
'@modelcontextprotocol/sdk/server/auth/provider.js': {
142144
target: '@modelcontextprotocol/server-legacy/auth',
143145
status: 'moved',
144-
migrationHint: 'Legacy OAuth AS provider. For RS-only auth, see requireBearerAuth from @modelcontextprotocol/express.'
146+
migrationHint:
147+
'Legacy OAuth AS provider. For RS-only auth, see requireBearerAuth from @modelcontextprotocol/express (or, on web-standard hosts, from @modelcontextprotocol/server).'
145148
},
146149
'@modelcontextprotocol/sdk/server/auth/router.js': {
147150
target: '@modelcontextprotocol/server-legacy/auth',
@@ -151,7 +154,8 @@ export const IMPORT_MAP: Record<string, ImportMapping> = {
151154
'@modelcontextprotocol/sdk/server/auth/middleware.js': {
152155
target: '@modelcontextprotocol/server-legacy/auth',
153156
status: 'moved',
154-
migrationHint: 'Legacy OAuth AS middleware. For bearer-only auth, see requireBearerAuth from @modelcontextprotocol/express.'
157+
migrationHint:
158+
'Legacy OAuth AS middleware. For bearer-only auth, see requireBearerAuth from @modelcontextprotocol/express (or, on web-standard hosts, from @modelcontextprotocol/server).'
155159
},
156160
'@modelcontextprotocol/sdk/server/auth/errors.js': {
157161
target: '@modelcontextprotocol/server-legacy/auth',
Lines changed: 24 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -1,120 +1,47 @@
1-
import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server';
1+
import type { BearerAuthOptions } from '@modelcontextprotocol/server';
2+
import { bearerAuthChallengeResponse, OAuthError, OAuthErrorCode, verifyBearerToken } from '@modelcontextprotocol/server';
23
import type { RequestHandler } from 'express';
34

4-
import type { OAuthTokenVerifier } from './types';
5-
65
/**
76
* Options for {@link requireBearerAuth}.
87
*/
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-
}
8+
export type BearerAuthMiddlewareOptions = BearerAuthOptions;
469

4710
/**
4811
* Express middleware that requires a valid Bearer token in the `Authorization`
4912
* header.
5013
*
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`.
14+
* The Express adapter over the runtime-neutral core in
15+
* `@modelcontextprotocol/server` (`verifyBearerToken` /
16+
* `bearerAuthChallengeResponse` — or `requireBearerAuth` from that package for
17+
* web-standard `fetch(request)` hosts). The token is validated via the
18+
* supplied `OAuthTokenVerifier` and the resulting `AuthInfo` is attached to
19+
* `req.auth`. The MCP Streamable HTTP transport reads `req.auth` and surfaces
20+
* it to handlers as `ctx.http.authInfo`.
5521
*
5622
* On failure the middleware sends a JSON OAuth error body and a
5723
* `WWW-Authenticate: Bearer …` challenge that includes the configured
5824
* `resource_metadata` URL so clients can discover the Authorization Server.
5925
*/
60-
export function requireBearerAuth({ verifier, requiredScopes = [], resourceMetadataUrl }: BearerAuthMiddlewareOptions): RequestHandler {
26+
export function requireBearerAuth(options: BearerAuthMiddlewareOptions): RequestHandler {
27+
// Destructure at creation so a plain-JS caller passing undefined or
28+
// malformed options crashes at startup, not on the first request.
29+
const { verifier, requiredScopes = [], resourceMetadataUrl } = options;
30+
const resolved = { verifier, requiredScopes, resourceMetadataUrl };
6131
return async (req, res, next) => {
6232
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;
33+
req.auth = await verifyBearerToken(req.headers.authorization, resolved);
9134
next();
9235
} 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());
36+
// The core Response supplies status and challenge; the body is
37+
// derived directly rather than parsed back out of the Response.
38+
const response = bearerAuthChallengeResponse(error, resolved);
39+
const challenge = response.headers.get('WWW-Authenticate');
40+
if (challenge !== null) {
41+
res.set('WWW-Authenticate', challenge);
11742
}
43+
const body = error instanceof OAuthError ? error : new OAuthError(OAuthErrorCode.ServerError, 'Internal Server Error');
44+
res.status(response.status).json(body.toResponseObject());
11845
}
11946
};
12047
}

packages/middleware/express/src/auth/types.ts

Lines changed: 5 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,12 @@
11
import type { AuthInfo } from '@modelcontextprotocol/server';
22

33
/**
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.
4+
* Re-exported from `@modelcontextprotocol/server`, where the runtime-neutral
5+
* Bearer authentication core lives — implement it once and use it with this
6+
* package's Express middleware or with `requireBearerAuth` from the server
7+
* package on web-standard hosts.
128
*/
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-
* Note: `requireBearerAuth` rejects tokens whose `AuthInfo.expiresAt` is unset
23-
* (matches v1 behavior). Ensure your verifier populates it (e.g. from RFC 7662
24-
* introspection `exp` or the JWT `exp` claim).
25-
*/
26-
verifyAccessToken(token: string): Promise<AuthInfo>;
27-
}
9+
export type { OAuthTokenVerifier } from '@modelcontextprotocol/server';
2810

2911
declare module 'express-serve-static-core' {
3012
interface Request {

0 commit comments

Comments
 (0)