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 has no `Authorization` header 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 presented token that fails verification is unchanged, 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 or expired token gets `401` with the OAuth error code `invalid_token`. A request that sends no `Authorization` header 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 or expired token gets `401 invalid_token`. A request with no `Authorization` header 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());
}
};
}
23 changes: 19 additions & 4 deletions packages/middleware/express/test/auth/resourceServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,26 @@ 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();
});

Expand Down
38 changes: 29 additions & 9 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 `Authorization` credentials were sent. Distinct from a token the server rejected. */
class MissingAuthorizationError extends OAuthError {
constructor() {
super(OAuthErrorCode.InvalidToken, 'Missing Authorization header');
}
return header;
}

/**
Expand All @@ -149,7 +162,7 @@ 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(' ');
Expand Down Expand Up @@ -198,7 +211,10 @@ 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 sent no credentials omits
* `error` and `error_description` from the challenge and the body when
* `scope` or `resource_metadata` is present (RFC 6750 §3.1); with neither,
* the challenge keeps `invalid_token`.
*/
export function bearerAuthChallengeResponse(
error: unknown,
Expand All @@ -213,8 +229,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
62 changes: 61 additions & 1 deletion packages/server/test/server/bearerAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,67 @@ describe('requireBearerAuth (web-standard)', () => {
expect(result).toBeInstanceOf(Response);
const response = result as Response;
expect(response.status).toBe(401);
expect(response.headers.get('WWW-Authenticate')).toMatch(/resource_metadata="https:\/\/api\.example\.com/);
expect(response.headers.get('WWW-Authenticate')).toBe(
'Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"'
);
expect(await response.json()).toEqual({});
});

it('omits the error code when no credentials were sent and scope or resource_metadata remains', async () => {
const metadata = 'https://mcp.example/.well-known/oauth-protected-resource/mcp';
const request = () => new Request('https://mcp.example/mcp');

const withBoth = requireBearerAuth({
verifier: verifierReturning(validAuthInfo),
requiredScopes: ['a:read'],
resourceMetadataUrl: metadata
});
const both = (await withBoth(request())) as Response;
expect(both.status).toBe(401);
expect(both.headers.get('WWW-Authenticate')).toBe(`Bearer scope="a:read", resource_metadata="${metadata}"`);
expect(await both.json()).toEqual({});

const withScope = requireBearerAuth({ verifier: verifierReturning(validAuthInfo), requiredScopes: ['a:read'] });
const scopeOnly = (await withScope(request())) as Response;
expect(scopeOnly.headers.get('WWW-Authenticate')).toBe('Bearer scope="a:read"');
expect(await scopeOnly.json()).toEqual({});
});

it('keeps invalid_token when a request with no credentials has no other challenge parameter', async () => {
const gate = requireBearerAuth({ verifier: verifierReturning(validAuthInfo) });
const response = (await gate(new Request('https://mcp.example/mcp'))) as Response;
expect(response.status).toBe(401);
expect(response.headers.get('WWW-Authenticate')).toBe(
'Bearer error="invalid_token", error_description="Missing Authorization header"'
);
expect(await response.json()).toEqual({
error: 'invalid_token',
error_description: 'Missing Authorization header'
});
});

it('keeps invalid_token when a token was sent and rejected, even if other challenge parameters are set', async () => {
const metadata = 'https://mcp.example/.well-known/oauth-protected-resource/mcp';
const gate = requireBearerAuth({
verifier: verifierThrowing(new OAuthError(OAuthErrorCode.InvalidToken, 'Token has expired')),
requiredScopes: ['a:read'],
resourceMetadataUrl: metadata
});
const response = (await gate(new Request('https://mcp.example/mcp', { headers: { authorization: 'Bearer expired' } }))) as Response;
expect(response.headers.get('WWW-Authenticate')).toBe(
`Bearer error="invalid_token", error_description="Token has expired", scope="a:read", resource_metadata="${metadata}"`
);
expect(await response.json()).toEqual({ error: 'invalid_token', error_description: 'Token has expired' });

const malformed = requireBearerAuth({
verifier: verifierReturning(validAuthInfo),
resourceMetadataUrl: metadata
});
const formatResponse = (await malformed(
new Request('https://mcp.example/mcp', { headers: { authorization: 'Basic abc' } })
)) as Response;
expect(formatResponse.headers.get('WWW-Authenticate')).toContain('error="invalid_token"');
expect(await formatResponse.json()).toMatchObject({ error: 'invalid_token' });
});

it('resolves to a 403 Response when scopes are missing', async () => {
Expand Down
2 changes: 1 addition & 1 deletion test/e2e/requirements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1643,7 +1643,7 @@ export const REQUIREMENTS: Record<string, Requirement> = {
'hosting:auth:missing-401': {
source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#error-handling',
behavior:
"A request without an Authorization header is rejected with 401; the WWW-Authenticate header carries resource_metadata (one of the spec's two permitted discovery mechanisms).",
"A request without an Authorization header is rejected with 401; the WWW-Authenticate header carries resource_metadata (one of the spec's two permitted discovery mechanisms) and omits an error code, because that parameter is already present (RFC 6750 §3.1).",
transports: ['streamableHttp'],
note: 'These exercise the HTTP hosting/auth layer (mostly over real Express); the matrix transport arg is ignored, so they run as a single streamableHttp-labelled cell to avoid duplicate runs.'
},
Expand Down
16 changes: 4 additions & 12 deletions test/e2e/scenarios/hosting-express.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,13 +88,9 @@ verifies('hosting:auth:missing-401', async (_args: TestArgs) => {
expect(res.status).toBe(401);

const wwwAuth = res.headers.get('www-authenticate');
expect(wwwAuth).toBeTruthy();
expect(wwwAuth).toMatch(/^Bearer\b/i);
expect(wwwAuth).toContain('error="invalid_token"');
expect(wwwAuth).toContain(`resource_metadata="${RESOURCE_METADATA_URL}"`);
expect(wwwAuth).toBe(`Bearer resource_metadata="${RESOURCE_METADATA_URL}"`);

const body = (await res.json()) as { error?: string };
expect(body.error).toBe('invalid_token');
expect(await res.json()).toEqual({});
});

verifies('hosting:auth:invalid-401', async (_args: TestArgs) => {
Expand Down Expand Up @@ -374,12 +370,8 @@ verifies('hosting:auth:query-token-ignored', async (_args: TestArgs) => {
});

expect(queryRes.status).toBe(401);
const wwwAuth = queryRes.headers.get('www-authenticate');
expect(wwwAuth).toBeTruthy();
expect(wwwAuth).toMatch(/^Bearer\b/i);
expect(wwwAuth).toContain('error="invalid_token"');
const queryBody = (await queryRes.json()) as { error?: string };
expect(queryBody.error).toBe('invalid_token');
expect(queryRes.headers.get('www-authenticate')).toBe(`Bearer resource_metadata="${RESOURCE_METADATA_URL}"`);
expect(await queryRes.json()).toEqual({});

// Control: the same token in the Authorization header authenticates, proving only the query-string placement was ignored.
const headerRes = await fetch(new URL('/mcp', host.baseUrl), {
Expand Down
Loading