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
11 changes: 11 additions & 0 deletions .changeset/add-mcp-auth-and-authorization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@modelcontextprotocol/core': minor
'@modelcontextprotocol/server': minor
---

Implement generalized authentication and authorization layer for MCP servers.

- Added `Authenticator` and `BearerTokenAuthenticator` to `@modelcontextprotocol/server`.
- Integrated scope-based authorization checks into `McpServer` for tools, resources, and prompts.
- Fixed asynchronous error propagation in the core `Protocol` class to support proper 401/403 HTTP status mapping in transports.
- Updated `WebStandardStreamableHTTPServerTransport` to correctly map authentication and authorization failures to their respective HTTP status codes.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ Next steps:

- Local SDK docs:
- [docs/server.md](docs/server.md) – building MCP servers, transports, tools/resources/prompts, sampling, elicitation, tasks, and deployment patterns.
- [docs/auth.md](docs/auth.md) – implementing authentication and authorization in MCP servers.
- [docs/client.md](docs/client.md) – building MCP clients: connecting, tools, resources, prompts, server-initiated requests, and error handling
- [docs/faq.md](docs/faq.md) – frequently asked questions and troubleshooting
- External references:
Expand Down
110 changes: 110 additions & 0 deletions docs/auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Authentication and Authorization

The MCP TypeScript SDK provides optional, opt-in support for authentication (AuthN) and authorization (AuthZ). This enables you to protect your MCP server resources, tools, and prompts using industry-standard schemes like OAuth 2.1 Bearer tokens.

## Key Concepts

- **Authenticator**: Responsible for extracting and validating authentication information from an incoming request.
- **AuthInfo**: A structure containing information about the authenticated entity (e.g., user name, active scopes).
- **Authorizer**: Used by the MCP server to verify if the authenticated entity has the required scopes to access a specific resource, tool, or prompt.
- **Scopes**: Optional strings associated with registered items that define the required permissions.

## Implementing Authentication

To enable authentication, provide an `authenticator` in the `ServerOptions` when creating your server.

### Using Bearer Token Authentication

The SDK includes a `BearerTokenAuthenticator` for validating OAuth 2.1 Bearer tokens.

```typescript
import { McpServer, BearerTokenAuthenticator } from "@modelcontextprotocol/server";

const server = new McpServer({
name: "my-authenticated-server",
version: "1.0.0",
}, {
authenticator: new BearerTokenAuthenticator(async (token) => {
// Validate the token (e.g., verify with an OAuth provider)
if (token === "valid-token") {
return {
token,
clientId: "john_doe",
scopes: ["read:resources", "execute:tools"]
};
}
return undefined; // Invalid token
})
});
```

## Implementing Authorization

Authorization is enforced using the `scopes` property when registering tools, resources, or prompts.

### Scoped Tools

```typescript
server.tool(
"secure_tool",
{
description: "A tool that requires specific scopes",
scopes: ["execute:tools"]
},
async (args) => {
return { content: [{ type: "text", text: "Success!" }] };
}
);
```

### Scoped Resources

```typescript
server.resource(
"secure_resource",
"secure://data",
{ scopes: ["read:resources"] },
async (uri) => {
return { contents: [{ uri: uri.href, text: "Top secret data" }] };
}
);
```

## Middleware Support

For framework-specific integrations, use the provided middleware to pre-authenticate requests.

### Express Middleware

```typescript
import express from "express";
import { auth } from "@modelcontextprotocol/express";

const app = express();
app.use(auth({ authenticator }));

app.post("/mcp", (req, res) => {
// req.auth is now populated
transport.handleRequest(req, res);
});
```

### Hono Middleware

```typescript
import { Hono } from "hono";
import { auth } from "@modelcontextprotocol/hono";

const app = new Hono();
app.use("/mcp/*", auth({ authenticator }));

app.all("/mcp", async (c) => {
const authInfo = c.get("mcpAuthInfo");
return transport.handleRequest(c.req.raw, { authInfo });
});
```

## Error Handling

- **401 Unauthorized**: Returned when authentication is required but missing or invalid. Includes `WWW-Authenticate: Bearer` header.
- **403 Forbidden**: Returned when the authenticated entity lacks the required scopes.
20 changes: 20 additions & 0 deletions docs/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Building a server takes three steps:
1. Create an {@linkcode @modelcontextprotocol/server!server/mcp.McpServer | McpServer} and register your [tools, resources, and prompts](#tools-resources-and-prompts).
2. Create a transport — [Streamable HTTP](#streamable-http) for remote servers or [stdio](#stdio) for local, process‑spawned integrations.
3. Wire the transport into your HTTP framework (or use stdio directly) and call `server.connect(transport)`.
1. (Optional) Configure [authentication and authorization](#authentication-and-authorization) to protect your server.

The sections below cover each of these. For a feature‑rich starting point, see [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleStreamableHttp.ts) — remove what you don't need and register your own tools, resources, and prompts. For stateless or JSON‑response‑mode alternatives, see the examples linked in [Transports](#transports) below.

Expand Down Expand Up @@ -444,6 +445,25 @@ Task-based execution enables "call-now, fetch-later" patterns for long-running o
> [!WARNING]
> The tasks API is experimental and may change without notice.

## Authentication and Authorization

The MCP TypeScript SDK provides optional, opt-in support for authentication (AuthN) and authorization (AuthZ). For a comprehensive guide, see the [Authentication and Authorization guide](./auth.md).

Quick example:

```ts
const server = new McpServer({ name: 'my-server', version: '1.0.0' }, {
authenticator: new BearerTokenAuthenticator(async (token) => {
if (token === 'secret') return { token, clientId: 'admin', scopes: ['all'] };
return undefined;
})
});

server.tool('secure-tool', { scopes: ['all'] }, async (args) => {
return { content: [{ type: 'text', text: 'Success' }] };
});
```

## Deployment

### DNS rebinding protection
Expand Down
44 changes: 31 additions & 13 deletions packages/core/src/shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -704,16 +704,24 @@ export abstract class Protocol<ContextT extends BaseContext> {
};

const _onmessage = this._transport?.onmessage;
this._transport.onmessage = (message, extra) => {
_onmessage?.(message, extra);
if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) {
this._onresponse(message);
} else if (isJSONRPCRequest(message)) {
this._onrequest(message, extra);
} else if (isJSONRPCNotification(message)) {
this._onnotification(message);
} else {
this._onerror(new Error(`Unknown message type: ${JSON.stringify(message)}`));
this._transport.onmessage = async (message, extra) => {
try {
if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) {
await _onmessage?.(message, extra);
this._onresponse(message);
} else if (isJSONRPCRequest(message)) {
await this._onrequest(message, extra);
} else if (isJSONRPCNotification(message)) {
await this._onnotification(message);
} else {
await _onmessage?.(message, extra);
this._onerror(new Error(`Unknown message type: ${JSON.stringify(message)}`));
}
} catch (error) {
Comment on lines +718 to +720
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Auth errors are detected by string-matching error.message.includes("Unauthorized") / error.message.includes("Forbidden") in 5+ locations (protocol.ts, mcp.ts, streamableHttp.ts) instead of using dedicated error codes. Any ProtocolError whose message coincidentally contains these words (e.g., "Forbidden characters in input") would be incorrectly escalated to HTTP 401/403. The ProtocolErrorCode enum already supports custom codes (e.g., UrlElicitationRequired = -32_042) -- consider adding Unauthorized/Forbidden codes instead of string matching.

Extended reasoning...

What the bug is

This PR introduces authentication and authorization error handling by throwing ProtocolError instances with ProtocolErrorCode.InvalidRequest and messages like "Unauthorized" or "Forbidden". These errors are then identified downstream via error.message.includes("Unauthorized") or error.message.includes("Forbidden") in at least 5 locations:

  1. protocol.ts onmessage catch block (~line 718)
  2. protocol.ts _onrequest error handler (~line 887)
  3. protocol.ts .catch handler (~line 915)
  4. mcp.ts tools/call catch block (~line 228-233)
  5. streamableHttp.ts in multiple catch blocks that map to HTTP 401/403

Why this is fragile

The string matching operates on the error message, not on a structured error code. This means any ProtocolError whose message happens to contain the substring "Unauthorized" or "Forbidden" will be incorrectly treated as an auth error. For example, consider a tool handler that throws:

throw new ProtocolError(ProtocolErrorCode.InvalidParams, "Forbidden characters in input");

Step-by-step proof of false positive

  1. A user registers a tool via McpServer.registerTool() that validates input and throws new ProtocolError(ProtocolErrorCode.InvalidParams, "Forbidden characters in input").
  2. The tools/call handler in mcp.ts catches this error. In the PR’s version, the catch block checks error.message.includes("Forbidden") — this matches, so the error is re-thrown instead of being wrapped in a CallToolResult error.
  3. The re-thrown error propagates up to protocol.ts _onrequest, where the error handler again checks error.message.includes("Forbidden") and re-throws it instead of sending a normal JSON-RPC error response.
  4. In streamableHttp.ts, the outer catch block maps any ProtocolError containing "Forbidden" to an HTTP 403 response.
  5. The client receives a 403 Forbidden HTTP response for what was actually a simple input validation error.

Why existing code doesn’t prevent this

The ProtocolErrorCode used for auth errors is InvalidRequest — the same generic code used for many other error conditions. The only distinguishing factor is the message string. There is no structural difference between an auth error and any other ProtocolError that happens to use a message containing these words.

Impact

This is a public SDK where users write custom tool/resource/prompt handlers that can throw ProtocolError with arbitrary messages. A false positive would cause:

  • Normal validation errors being mapped to HTTP 401/403 instead of proper JSON-RPC error responses
  • Client-side auth retry logic being triggered unnecessarily
  • Confusing error behavior that would be very difficult to debug

Additionally, the mcp.ts catch block at lines ~228-233 is dead code: both branches of the if statement do the same thing (throw error), suggesting this pattern was applied mechanically without careful consideration.

How to fix

Add dedicated error codes to ProtocolErrorCode, e.g.:

Unauthorized = -32_043,
Forbidden = -32_044,

Then check error.code instead of error.message in all catch blocks. This follows the existing pattern used by UrlElicitationRequired and ResourceNotFound.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dedicated error codes were added (Unauthorized = 401, Forbidden = 403 in ProtocolErrorCode) and some locations were updated to use code-based checking (mcp.ts catch block and streamableHttp.ts SSE/outer catch blocks now check error.code). However, protocol.ts itself — the file where this comment was posted — still uses error.message.includes("Unauthorized") / error.message.includes("Forbidden") in all 3 catch blocks (lines ~720, ~890, ~918). The JSON response mode in streamableHttp.ts (lines ~735-740) also still uses string matching.

These remaining string-match locations are still vulnerable to the false-positive scenario described in the original comment. They should be updated to check error.code === ProtocolErrorCode.Unauthorized / error.code === ProtocolErrorCode.Forbidden to match the pattern already applied in mcp.ts and the SSE mode of streamableHttp.ts.

Comment on lines +707 to +720
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The PR moves the _onmessage?.(message, extra) call from being invoked unconditionally for all message types to only being called in the response and unknown-message branches. Code that set transport.onmessage before protocol.connect() to observe all incoming messages will silently stop seeing JSON-RPC requests and notifications. Restore await _onmessage?.(message, extra) at the top of the handler, before the if/else chain, to maintain the original behavior consistent with how onclose and onerror preserve their pre-existing handlers.

Extended reasoning...

What the bug is

In the original code, _onmessage (the previously-registered transport onmessage handler) was called unconditionally at the top of the new onmessage wrapper, before the if/else chain that dispatches by message type:

this._transport.onmessage = (message, extra) => {
    _onmessage?.(message, extra);  // Called for ALL messages
    if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) {
        this._onresponse(message);
    } else if (isJSONRPCRequest(message)) {
        ...

The PR restructures this so _onmessage is only called inside the response branch and the unknown-message else branch. It is completely skipped for isJSONRPCRequest and isJSONRPCNotification messages.

Why this is an inconsistency

The connect() method preserves pre-existing handlers for all three transport callbacks: onclose, onerror, and onmessage. For onclose and onerror, the old handler is still called unconditionally. But with this PR, onmessage only calls the old handler for some message types. This inconsistency suggests the omission is unintentional rather than a deliberate design choice.

Step-by-step proof

  1. External code sets transport.onmessage = (msg) => console.log(msg) before calling protocol.connect(transport).
  2. connect() captures this as _onmessage.
  3. A client sends a JSON-RPC request (e.g., tools/call). The transport receives it and calls the new onmessage wrapper.
  4. The wrapper enters the isJSONRPCRequest branch and calls await this._onrequest(message, extra) — but never calls _onmessage.
  5. The pre-existing handler never sees the request message. The same applies to notifications.
  6. Only response messages and unknown messages trigger _onmessage.

Existing test coverage

There is a test at protocol.test.ts:212 ("should not overwrite existing hooks when connecting transports") that verifies callback preservation. However, it passes an empty string as the message, which falls into the unknown-message else branch where _onmessage IS still called — so the test does not catch this regression.

Impact

The practical impact is low: no production code in the repository sets transport.onmessage before connect(), and the Protocol docs state it assumes ownership of the transport. However, this is a behavioral contract change that could silently break external code relying on pre-connect message observation, and the inconsistency with onclose/onerror handling makes the code confusing.

How to fix

Restore await _onmessage?.(message, extra) at the top of the handler, before the if/else chain:

this._transport.onmessage = async (message, extra) => {
    try {
        await _onmessage?.(message, extra);  // Restore: called for ALL messages
        if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) {
            this._onresponse(message);
        } else if (isJSONRPCRequest(message)) {
            await this._onrequest(message, extra);
        } ...

if (error instanceof ProtocolError && (error.code === ProtocolErrorCode.Unauthorized || error.code === ProtocolErrorCode.Forbidden)) {
throw error;
}
this._onerror(error instanceof Error ? error : new Error(String(error)));
}
};

Expand Down Expand Up @@ -758,7 +766,7 @@ export abstract class Protocol<ContextT extends BaseContext> {
.catch(error => this._onerror(new Error(`Uncaught error in notification handler: ${error}`)));
}

private _onrequest(request: JSONRPCRequest, extra?: MessageExtraInfo): void {
protected async _onrequest(request: JSONRPCRequest, extra?: MessageExtraInfo): Promise<void> {
const handler = this._requestHandlers.get(request.method) ?? this.fallbackRequestHandler;

// Capture the current transport at request time to ensure responses go to the correct client
Expand Down Expand Up @@ -838,7 +846,7 @@ export abstract class Protocol<ContextT extends BaseContext> {
const ctx = this.buildContext(baseCtx, extra);

// Starting with Promise.resolve() puts any synchronous errors into the monad as well.
Promise.resolve()
return Promise.resolve()
.then(() => {
// If this request asked for task creation, check capability first
if (taskCreationParams) {
Expand Down Expand Up @@ -879,6 +887,10 @@ export abstract class Protocol<ContextT extends BaseContext> {
return;
}

if (error instanceof ProtocolError && (error.message.includes('Unauthorized') || error.message.includes('Forbidden'))) {
throw error;
}

const errorResponse: JSONRPCErrorResponse = {
jsonrpc: '2.0',
id: request.id,
Expand All @@ -903,7 +915,13 @@ export abstract class Protocol<ContextT extends BaseContext> {
: capturedTransport?.send(errorResponse));
}
)
.catch(error => this._onerror(new Error(`Failed to send response: ${error}`)))
.catch(error => {
if (error instanceof ProtocolError && (error.message.includes('Unauthorized') || error.message.includes('Forbidden'))) {
throw error;
}
// Do not report as protocol error if it's already an auth error we're escaping
this._onerror(new Error(`Failed to send response: ${error}`));
})
.finally(() => {
this._requestHandlerAbortControllers.delete(request.id);
});
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/shared/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export interface Transport {
*
* The {@linkcode MessageExtraInfo.requestInfo | requestInfo} can be used to get the original request information (headers, etc.)
*/
onmessage?: <T extends JSONRPCMessage>(message: T, extra?: MessageExtraInfo) => void;
onmessage?: <T extends JSONRPCMessage>(message: T, extra?: MessageExtraInfo) => void | Promise<void>;

/**
* The session ID generated for this connection.
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,8 @@ export enum ProtocolErrorCode {
InternalError = -32_603,

// MCP-specific error codes
Unauthorized = 401,
Forbidden = 403,
ResourceNotFound = -32_002,
UrlElicitationRequired = -32_042
}
Expand Down
2 changes: 2 additions & 0 deletions packages/middleware/express/src/express.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type { Express } from 'express';
import express from 'express';

import { hostHeaderValidation, localhostHostValidation } from './middleware/hostHeaderValidation.js';
export { auth } from './middleware/auth.js';
export type { AuthMiddlewareOptions } from './middleware/auth.js';

/**
* Options for creating an MCP Express application.
Expand Down
1 change: 1 addition & 0 deletions packages/middleware/express/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './express.js';
export { auth } from './middleware/auth.js';
export * from './middleware/hostHeaderValidation.js';
58 changes: 58 additions & 0 deletions packages/middleware/express/src/middleware/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { Request, Response, NextFunction, RequestHandler } from 'express';
import { Authenticator, AuthInfo } from '@modelcontextprotocol/server';

/**
* Options for the MCP Express authentication middleware.
*/
export interface AuthMiddlewareOptions {
/**
* The authenticator to use for validating requests.
*/
authenticator: Authenticator;
}

/**
* Creates an Express middleware for MCP authentication.
*
* This middleware extracts authentication information from the request using the provided authenticator
* and attaches it to the request object as `req.auth`. The MCP Express transport will then
* pick up this information automatically.
*
* @param options - Middleware options
* @returns An Express middleware function
*
* @example
* ```ts
* const authenticator = new BearerTokenAuthenticator((token) => Promise.resolve({ token, clientId: 'user', scopes: ['read'] }));
* app.use(auth({ authenticator }));
* ```
*/
export function auth(options: AuthMiddlewareOptions): RequestHandler {
return async (req: Request & { auth?: AuthInfo }, res: Response, next: NextFunction) => {
try {
const headers: Record<string, string> = {};
for (const [key, value] of Object.entries(req.headers)) {
if (typeof value === 'string') {
headers[key] = value;
} else if (Array.isArray(value)) {
headers[key] = value.join(', ');
}
}

const authInfo = await options.authenticator.authenticate({
method: req.method,
headers,
});
if (authInfo) {
req.auth = authInfo;
}
next();
} catch (error) {
// If authentication fails, we let the MCP server handle it later,
// or the developer can choose to reject here.
// By default, we just proceed to allow the MCP server to decide (e.g., if auth is optional).
console.error('[MCP Express Auth Middleware] Authentication failed:', error);
next();
}
};
}
2 changes: 2 additions & 0 deletions packages/middleware/hono/src/hono.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type { Context } from 'hono';
import { Hono } from 'hono';

import { hostHeaderValidation, localhostHostValidation } from './middleware/hostHeaderValidation.js';
export { auth } from './middleware/auth.js';
export type { AuthMiddlewareOptions } from './middleware/auth.js';

/**
* Options for creating an MCP Hono application.
Expand Down
1 change: 1 addition & 0 deletions packages/middleware/hono/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './hono.js';
export { auth } from './middleware/auth.js';
export * from './middleware/hostHeaderValidation.js';
Loading
Loading