Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/omit-error-on-missing-credentials-challenge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modelcontextprotocol/server': patch
'@modelcontextprotocol/express': patch
---

`requireBearerAuth` no longer sends `error="invalid_token"` when a request presents no bearer credentials — no `Authorization` header, or an unsupported scheme such as Basic or Digest — and the `WWW-Authenticate` challenge already includes `scope` or `resource_metadata` (RFC 6750 §3.1). The JSON body omits the error pair in that case. A malformed Bearer header and a presented token that fails verification still include `invalid_token`, and a challenge with neither parameter still includes `invalid_token`.
4 changes: 2 additions & 2 deletions docs/serving/authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const node = toNodeHandler(createMcpHandler(buildServer));
app.all('/mcp', auth, (req, res) => void node(req, res, req.body));
```

A request with a missing, malformed, or expired token gets `401` with the OAuth error code `invalid_token`. A valid token missing one of `requiredScopes` gets `403` with `insufficient_scope`. Both responses carry a `WWW-Authenticate: Bearer …` challenge whose `resource_metadata` parameter is the URL you passed — that challenge is what starts a client's OAuth flow.
A malformed Bearer header or an expired token gets `401` with the OAuth error code `invalid_token`. A request that sends no `Authorization` header, or an unsupported scheme such as Basic or Digest, also gets `401`. When that challenge already includes `scope` or `resource_metadata`, it leaves out `error` and `error_description` (RFC 6750 §3.1) and the JSON body is `{}`. When neither parameter is configured, the challenge still includes `invalid_token`. A valid token missing one of `requiredScopes` gets `403` with `insufficient_scope`. The 401 and 403 challenges carry `resource_metadata` when you passed a URL — that parameter is what starts a client's OAuth flow.

::: info Coming from v1?
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.
Expand Down Expand Up @@ -174,7 +174,7 @@ The callback runs before the primitive's input schema is validated or transforme

- `requireBearerAuth` from `@modelcontextprotocol/server` is the same gate for web-standard `fetch` hosts; the Express middleware adapts the same core.
- `requireBearerAuth` plus a `verifyAccessToken` you write turn an Express-mounted MCP route into an OAuth resource server; the SDK never issues tokens.
- 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.
- A malformed Bearer header or an expired token gets `401 invalid_token`. A request with no `Authorization` header, or an unsupported scheme such as Basic or Digest, gets `401`, and that challenge omits the error code when `scope` or `resource_metadata` is present. A token missing a `requiredScopes` entry gets `403 insufficient_scope`. These responses carry a `WWW-Authenticate: Bearer` challenge.
- `mcpAuthMetadataRouter` publishes the RFC 9728 document that challenge points at, plus a mirror of the AS metadata.
- Verified auth flows `req.auth` → `ctx.http.authInfo`; per-operation callbacks can trigger HTTP `403` scope step-up before invocation, advertising the metadata URL the gate stamped onto `AuthInfo`.
- The v1 Authorization Server helpers are frozen in `@modelcontextprotocol/server-legacy/auth`.
4 changes: 3 additions & 1 deletion examples/bearer-auth-web/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ const unauth = await fetch(url, {
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping' })
});
check.equal(unauth.status, 401);
check.equal(extractWWWAuthenticateParams(unauth).error, 'invalid_token');
const challenge = extractWWWAuthenticateParams(unauth);
check.equal(challenge.error, undefined);
check.equal(challenge.scope, 'mcp');

// Authenticated → 200 and the tool sees the authInfo. Bearer auth is
// HTTP-layer and era-agnostic; the client honours `--legacy` via `era`.
Expand Down
16 changes: 8 additions & 8 deletions packages/middleware/express/src/auth/bearerAuth.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { VerifyBearerTokenOptions } from '@modelcontextprotocol/server';
import { bearerAuthChallengeResponse, OAuthError, OAuthErrorCode, verifyBearerToken } from '@modelcontextprotocol/server';
import { bearerAuthChallengeResponse, verifyBearerToken } from '@modelcontextprotocol/server';
import type { RequestHandler } from 'express';

/**
Expand All @@ -19,9 +19,10 @@ export type BearerAuthMiddlewareOptions = VerifyBearerTokenOptions;
* `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.
* On failure the middleware sends the status, JSON body, and
* `WWW-Authenticate` challenge produced by `bearerAuthChallengeResponse`,
* including `resource_metadata` when configured so clients can discover the
* Authorization Server.
*/
export function requireBearerAuth(options: BearerAuthMiddlewareOptions): RequestHandler {
// Destructure at creation so a plain-JS caller passing undefined or
Expand All @@ -33,15 +34,14 @@ export function requireBearerAuth(options: BearerAuthMiddlewareOptions): Request
req.auth = await verifyBearerToken(req.headers.authorization, resolved);
next();
} catch (error) {
// The core Response supplies status and challenge; the body is
// derived directly rather than parsed back out of the Response.
// Status, challenge, and body all come from the core response so
// a challenge that omits its error code does not regain one here.
const response = bearerAuthChallengeResponse(error, resolved);
const challenge = response.headers.get('WWW-Authenticate');
if (challenge !== null) {
res.set('WWW-Authenticate', challenge);
}
const body = error instanceof OAuthError ? error : new OAuthError(OAuthErrorCode.ServerError, 'Internal Server Error');
res.status(response.status).json(body.toResponseObject());
res.status(response.status).json(await response.json());
}
};
}
51 changes: 47 additions & 4 deletions packages/middleware/express/test/auth/resourceServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,57 @@ describe('requireBearerAuth middleware', () => {
expect(res.status).toHaveBeenCalledWith(401);
expect(res.set).toHaveBeenCalledWith(
'WWW-Authenticate',
expect.stringMatching(
/^Bearer error="invalid_token".*resource_metadata="https:\/\/api\.example\.com\/\.well-known\/oauth-protected-resource"$/
)
'Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"'
);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'invalid_token' }));
expect(res.json).toHaveBeenCalledWith({});
expect(next).not.toHaveBeenCalled();
});

it('keeps invalid_token when the Authorization header is missing and no other challenge parameter is set', async () => {
const { req, res, next } = createMockReqResNext(undefined);
const middleware = requireBearerAuth({ verifier: mockVerifier });
await middleware(req, res, next);

expect(res.status).toHaveBeenCalledWith(401);
expect(res.set).toHaveBeenCalledWith(
'WWW-Authenticate',
'Bearer error="invalid_token", error_description="Missing Authorization header"'
);
expect(res.json).toHaveBeenCalledWith({
error: 'invalid_token',
error_description: 'Missing Authorization header'
});
expect(next).not.toHaveBeenCalled();
});

it('omits invalid_token for Basic when resource_metadata is set, and keeps it for an empty Bearer token', async () => {
const middleware = requireBearerAuth({
verifier: mockVerifier,
resourceMetadataUrl: 'https://api.example.com/.well-known/oauth-protected-resource'
});

const basic = createMockReqResNext('Basic dXNlcjpwYXNz');
await middleware(basic.req, basic.res, basic.next);
expect(basic.res.status).toHaveBeenCalledWith(401);
expect(basic.res.set).toHaveBeenCalledWith(
'WWW-Authenticate',
'Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"'
);
expect(basic.res.json).toHaveBeenCalledWith({});
expect(mockVerifyAccessToken).not.toHaveBeenCalled();

const emptyBearer = createMockReqResNext('Bearer');
await middleware(emptyBearer.req, emptyBearer.res, emptyBearer.next);
expect(emptyBearer.res.set).toHaveBeenCalledWith(
'WWW-Authenticate',
'Bearer error="invalid_token", error_description="Invalid Authorization header format, expected \'Bearer TOKEN\'", resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"'
);
expect(emptyBearer.res.json).toHaveBeenCalledWith({
error: 'invalid_token',
error_description: "Invalid Authorization header format, expected 'Bearer TOKEN'"
});
});

it('responds 401 when the verifier throws InvalidToken', async () => {
mockVerifyAccessToken.mockRejectedValue(new OAuthError(OAuthErrorCode.InvalidToken, 'unknown token'));

Expand Down
49 changes: 38 additions & 11 deletions packages/server/src/server/middleware/bearerAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,16 +116,29 @@ export function buildWwwAuthenticateHeader(
errorCode: string,
description: string,
requiredScopes: readonly string[],
resourceMetadataUrl: string | undefined
resourceMetadataUrl: string | undefined,
omitError = false
): string {
let header = `Bearer error="${headerQuotedValue(errorCode)}", error_description="${headerQuotedValue(description)}"`;
// RFC 6750 §3.1: omit the error pair only when another auth-param remains.
const parts: string[] = [];
const hasOtherParam = requiredScopes.length > 0 || Boolean(resourceMetadataUrl);
if (!omitError || !hasOtherParam) {
parts.push(`error="${headerQuotedValue(errorCode)}"`, `error_description="${headerQuotedValue(description)}"`);
}
if (requiredScopes.length > 0) {
header += `, scope="${headerQuotedValue(requiredScopes.join(' '))}"`;
parts.push(`scope="${headerQuotedValue(requiredScopes.join(' '))}"`);
}
if (resourceMetadataUrl) {
header += `, resource_metadata="${headerQuotedValue(resourceMetadataUrl)}"`;
parts.push(`resource_metadata="${headerQuotedValue(resourceMetadataUrl)}"`);
}
return `Bearer ${parts.join(', ')}`;
}

/** No bearer credentials were presented: the header is absent, or its scheme is not Bearer. */
class MissingAuthorizationError extends OAuthError {
constructor(message = 'Missing Authorization header') {
super(OAuthErrorCode.InvalidToken, message);
}
return header;
}

/**
Expand All @@ -149,12 +162,17 @@ export async function verifyBearerToken(
const { verifier, requiredScopes = [], expectedResource } = options;

if (!authorizationHeader) {
throw new OAuthError(OAuthErrorCode.InvalidToken, 'Missing Authorization header');
throw new MissingAuthorizationError();
}

const [type, token] = authorizationHeader.split(' ');
if (type?.toLowerCase() !== 'bearer' || !token) {
throw new OAuthError(OAuthErrorCode.InvalidToken, "Invalid Authorization header format, expected 'Bearer TOKEN'");
// Unsupported schemes presented no bearer token; a Bearer header with no token stays invalid_token.
const invalidAuthorizationHeader = "Invalid Authorization header format, expected 'Bearer TOKEN'";
if (type?.toLowerCase() !== 'bearer') {
throw new MissingAuthorizationError(invalidAuthorizationHeader);
}
if (!token) {
throw new OAuthError(OAuthErrorCode.InvalidToken, invalidAuthorizationHeader);
}

const authInfo = await verifier.verifyAccessToken(token);
Expand Down Expand Up @@ -198,7 +216,12 @@ export async function verifyBearerToken(
* challenge, with `resource_metadata` when configured so clients can discover
* the Authorization Server), `500` for `server_error`, `400` for anything
* else. A non-`OAuthError` value answers `500 server_error`. The body is the
* OAuth error JSON.
* OAuth error JSON. A `401` for a request that presented no bearer
* credentials (no `Authorization` header, or a scheme other than Bearer)
* omits `error` and `error_description` from the challenge and the body
* when `scope` or `resource_metadata` is present (RFC 6750 §3.1). A
* malformed Bearer header keeps `invalid_token`. With neither parameter,
* the challenge keeps `invalid_token`.
*/
export function bearerAuthChallengeResponse(
error: unknown,
Expand All @@ -213,8 +236,12 @@ export function bearerAuthChallengeResponse(

switch (error.code) {
case OAuthErrorCode.InvalidToken: {
const challenge = buildWwwAuthenticateHeader(error.code, error.message, requiredScopes, resourceMetadataUrl);
return Response.json(error.toResponseObject(), { status: 401, headers: { 'WWW-Authenticate': challenge } });
const omitError = error instanceof MissingAuthorizationError && (requiredScopes.length > 0 || Boolean(resourceMetadataUrl));
const challenge = buildWwwAuthenticateHeader(error.code, error.message, requiredScopes, resourceMetadataUrl, omitError);
return Response.json(omitError ? {} : error.toResponseObject(), {
status: 401,
headers: { 'WWW-Authenticate': challenge }
});
}
case OAuthErrorCode.InsufficientScope: {
const challenge = buildWwwAuthenticateHeader(error.code, error.message, requiredScopes, resourceMetadataUrl);
Expand Down
Loading
Loading