Skip to content

feat(auth): implement generalized authentication layer and scope-based authorization#1729

Open
sairajm wants to merge 4 commits intomodelcontextprotocol:mainfrom
sairajm:main
Open

feat(auth): implement generalized authentication layer and scope-based authorization#1729
sairajm wants to merge 4 commits intomodelcontextprotocol:mainfrom
sairajm:main

Conversation

@sairajm
Copy link

@sairajm sairajm commented Mar 22, 2026

This PR introduces a robust, generalized authentication and authorization framework for the MCP TypeScript SDK. It enables servers to verify identities (e.g., via Bearer tokens) and enforce granular scope-based access control for tools, resources, and prompts.

Additionally, this PR includes critical fixes to the core protocol's asynchronous message loop to ensure that authentication and authorization errors (401/403) propagate correctly through the stack without being masked as generic internal errors.

Motivation and Context

The MCP TypeScript SDK lacked a standardized, built-in mechanism for handling authentication and authorization at the server level. This resulted in developers having to implement ad-hoc security logic that often conflicted with the internal protocol state, leading to inconsistent error reporting (e.g., returning 500 Internal Server Error instead of 401/403).

This change introduces a unified
Authenticator
layer and scope-based authorization, while also fixing a critical asynchronous propagation bug in the core
Protocol
class that previously prevented security errors from correctly surfacing to the transport layer.

How Has This Been Tested?

I implemented a comprehensive integration test suite in packages/server/test/server/auth_integration.test.ts that validates the following scenarios using the WebStandardStreamableHTTPServerTransport:

401 Unauthorized: Verified that requests without a token or with an invalid token return a 401 status with appropriate headers.
403 Forbidden: Verified that validly authenticated users are blocked with a 403 status when attempting to access tools/resources for which they lack the required scopes.
Success Cases: Verified that public tools remain accessible and that private tools are accessible when the correct scopes are provided.
Transport Modes: Confirmed these behaviors work across both standard JSON responses and SSE streaming modes.

Breaking Changes

Yes, this PR introduces internal breaking changes to the Protocol base class:

Protocol._onrequest signature has been changed from void to Promise<void>. Subclasses overriding this method must now be updated to be async.
Protocol.onmessage is now an asynchronous handler.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

  • Pluggable Auth: The implementation uses an Authenticator interface, allowing developers to bring their own authentication logic (JWT, API Keys, etc.).
  • Automatic Scoping: Authorization is baked into the high-level McpServer registration methods, making it simple to secure specific tools or resources by simply passing a scopes array.
  • Core Protocol Stability: The fix to the _onrequest promise chain improves the overall reliability of the SDK's error handling by ensuring all request-processing errors are properly awaited and propagated.

@sairajm sairajm requested a review from a team as a code owner March 22, 2026 16:32
@changeset-bot
Copy link

changeset-bot bot commented Mar 22, 2026

🦋 Changeset detected

Latest commit: 2f985be

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 5 packages
Name Type
@modelcontextprotocol/core Minor
@modelcontextprotocol/server Minor
@modelcontextprotocol/node Major
@modelcontextprotocol/express Major
@modelcontextprotocol/hono Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 22, 2026

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@1729

@modelcontextprotocol/server

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server@1729

@modelcontextprotocol/express

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/express@1729

@modelcontextprotocol/hono

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/hono@1729

@modelcontextprotocol/node

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/node@1729

commit: 2f985be

Comment on lines +718 to +720
this._onerror(new Error(`Unknown message type: ${JSON.stringify(message)}`));
}
} catch (error) {
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 +23 to +37
const server = new McpServer({
name: "my-authenticated-server",
version: "1.0.0",
}, {
authenticator: new BearerTokenAuthenticator({
validate: async (token) => {
// Validate the token (e.g., verify with an OAuth provider)
if (token === "valid-token") {
return {
name: "john_doe",
scopes: ["read:resources", "execute:tools"]
};
}
return undefined; // Invalid token
}
Copy link

Choose a reason for hiding this comment

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

🔴 Documentation in docs/auth.md and docs/server.md shows new BearerTokenAuthenticator({ validate: async (token) => { ... } }), but the actual constructor in bearer.ts takes a plain function: constructor(private readonly _validator: BearerTokenValidator). Any developer following the docs will get a runtime error. The correct usage is new BearerTokenAuthenticator(async (token) => { ... }) as shown in the integration test.

Extended reasoning...

Bug Analysis

The BearerTokenAuthenticator class in packages/server/src/server/auth/bearer.ts defines its constructor as:

constructor(private readonly _validator: BearerTokenValidator) {}

where BearerTokenValidator is typed as (token: string) => Promise<AuthInfo | undefined> — a plain async function.

Documentation Mismatch

However, the documentation in two locations shows an object-based API:

  • docs/auth.md (line 27): new BearerTokenAuthenticator({ validate: async (token) => { ... } })
  • docs/server.md (line 455): Same object-wrapping pattern

Additionally, the JSDoc examples in packages/middleware/express/src/middleware/auth.ts and packages/middleware/hono/src/middleware/auth.ts repeat the same incorrect pattern.

Step-by-Step Proof

  1. A developer reads docs/auth.md and writes: new BearerTokenAuthenticator({ validate: async (token) => { ... } })
  2. The constructor stores { validate: fn } (an object) as this._validator
  3. When authenticate() is called, it extracts the Bearer token and calls this._validator(token)
  4. Since this._validator is actually { validate: fn } (an object, not a function), calling it as a function throws TypeError: this._validator is not a function

Confirmation

The integration test in auth_integration.test.ts (line 41) and the unit test in auth.test.ts (line 7) both correctly use the function-based API: new BearerTokenAuthenticator(async (token) => { ... }), confirming the docs are wrong.

Impact

Any developer following the official documentation to set up authentication will encounter a runtime TypeError when the first request with a Bearer token arrives. This is the primary documentation for a new security feature, so incorrect examples will affect most adopters.

Fix

Update all four locations to pass a plain function instead of an object:

// Before (wrong)
new BearerTokenAuthenticator({ validate: async (token) => { ... } })

// After (correct)
new BearerTokenAuthenticator(async (token) => { ... })

Copy link

Choose a reason for hiding this comment

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

The docs (docs/auth.md and docs/server.md) and the Express middleware JSDoc now correctly show the function-based API — nice fix. However, the Hono middleware JSDoc example in packages/middleware/hono/src/middleware/auth.ts (lines 27-29) still uses the incorrect object-based pattern:

const authenticator = new BearerTokenAuthenticator({
  validate: async (token) => ({ name: 'user', scopes: ['read'] })
});

This should be:

const authenticator = new BearerTokenAuthenticator(
  async (token) => ({ token, clientId: 'user', scopes: ['read'] })
);

Comment on lines +707 to +720
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) {
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);
        } ...

Comment on lines +687 to 690
scopes: string[] | undefined,
readCallback: ReadResourceCallback
): RegisteredResource {
const registeredResource: RegisteredResource = {
Copy link

Choose a reason for hiding this comment

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

🔴 _createRegisteredResource accepts a scopes parameter but never assigns it to the registeredResource object initializer, so resource.scopes is always undefined. This silently bypasses the authorization check in the resources/read handler (if (resource.scopes && resource.scopes.length > 0)), leaving static resources with scopes completely unprotected. Fix: add scopes, to the object initializer, matching the pattern in _createRegisteredResourceTemplate.

Extended reasoning...

The Bug

The _createRegisteredResource method (line ~687) was updated by this PR to accept a scopes: string[] | undefined parameter, but the registeredResource object initializer only includes name, title, metadata, readCallback, and enabled — it omits scopes. The parameter is accepted and silently discarded.

Comparison with Sibling Methods

_createRegisteredResourceTemplate (line ~719) correctly includes scopes, in its object initializer. Similarly, _createRegisteredPrompt and _createRegisteredTool both correctly store scopes in their respective registered objects. _createRegisteredResource is the only one that drops it.

Step-by-Step Proof

  1. A developer registers a static resource with scopes: server.registerResource("secure", "secure://data", { scopes: ["admin"] }, callback)
  2. registerResource extracts scopes from config and passes it to _createRegisteredResource(name, title, uri, metadata, scopes, readCallback)
  3. Inside _createRegisteredResource, the object initializer creates registeredResource without scopes — it is { name, title, metadata, readCallback, enabled: true }
  4. registeredResource.scopes is undefined
  5. When a client calls resources/read, the authorization check evaluates resource.scopes && resource.scopes.length > 0 — this is false because resource.scopes is undefined
  6. The authorization check is entirely skipped, and the resource callback executes without any scope verification

Why Existing Code Does Not Prevent It

The update() handler correctly handles updates.scopes (line ~704 in the diff), but it only applies to subsequent updates — the initial value from registration is never stored. TypeScript does not catch this because the RegisteredResource type has scopes as an optional field (scopes?: string[]), so the compiler allows the initializer to omit it.

Impact

This is a security-relevant bug. Any static resource registered with scopes will be silently left unprotected. The developer believes the resource is secured (they passed scopes: ["admin"]), but any authenticated user can read it regardless of their scopes. Resource templates are correctly protected, making this inconsistency particularly insidious — testing with templates would pass while static resources remain exposed.

Fix

Add scopes, to the registeredResource object initializer in _createRegisteredResource, between metadata and readCallback:

const registeredResource: RegisteredResource = {
    name,
    title,
    metadata,
    scopes,
    readCallback,
    enabled: true,
};

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant