MCP Scope Challenge: Step-Up Authentication Guide
Alright, guys, buckle up! We're diving deep into the world of MCP (Model Context Protocol) and how we're tackling scope challenges with step-up authentication. This is all about making sure your users get the right access, right when they need it. We're going to break down the new MCP specification (2025-11-25) and how we're implementing it in FastMCP and mcp-proxy. Let's get started!
Overview
The MCP specification (2025-11-25) introduces scope challenge handling, which is basically a fancy way of saying we can now ask users for more permissions (OAuth scopes) when they try to access something that requires it. Think of it like needing a higher security clearance to enter a specific area β that's step-up authentication in action.
This document will walk you through the changes needed in FastMCP and mcp-proxy to support this awesome new feature. We'll cover everything from HTTP status codes to header formats and even look at different scope management strategies. So, grab your coffee and let's dive in!
MCP Specification Requirements
According to the MCP Spec Section: Scope Challenge Handling, here's what we need to keep in mind:
HTTP Status Codes
- 403 Forbidden: This is what we'll use when a user doesn't have enough scope.
- 401 Unauthorized: This is for when authorization is totally missing or the token is invalid.
WWW-Authenticate Header Format (403 Response)
When we send back a 403, we need to include a WWW-Authenticate header that looks something like this:
WWW-Authenticate: Bearer error="insufficient_scope",
scope="required_scope1 required_scope2",
resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
error_description="Additional permission required"
Let's break down what each part means:
error="insufficient_scope"- This is a fixed error code that tells the client exactly what's wrong: they don't have the right scopes.scope="..."- A space-separated list of the scopes needed to access the resource. This is crucial for the client to know what permissions to request.resource_metadata- A URI pointing to the OAuth protected resource metadata. This helps the client understand more about the resource they're trying to access.
Optional Parameter:
error_description- A human-readable description of the error. This is super helpful for developers (and sometimes even users) to understand what's going on.
Scope Management Strategies
When it comes to listing the required scopes, we have a few options:
- Minimum approach: Only include the newly required scopes. This keeps the request minimal but might be confusing if the user already has some scopes.
- Recommended approach: Include existing + new scopes. This gives the client a complete picture of all the scopes needed.
- Extended approach: Include existing + new + related scopes. This can be helpful for providing context but might be overkill in some cases.
Current Implementation Analysis
Before we start making changes, let's take a look at where we're currently at with FastMCP and mcp-proxy.
FastMCP Current State
Location: src/FastMCP.ts
// Line 878: Tool definition
interface Tool<T> {
canAccess?: (auth: T) => boolean; // Current signature
// ... other properties
}
// Line 1261: Tools filtered at session creation
const allowedTools = auth
? this.#tools.filter((tool) =>
tool.canAccess ? tool.canAccess(auth) : true,
)
: this.#tools;
// Line 1756: Tool execution handler
this.#server.setRequestHandler(CallToolRequestSchema, async (request) => {
const tool = tools.find((tool) => tool.name === request.params.name);
if (!tool) {
throw new McpError(
ErrorCode.MethodNotFound,
`Unknown tool: ${request.params.name}`,
);
}
// NO canAccess check here - tool already filtered out!
// ...
});
Current Behavior:
- Tools that fail
canAccessare hidden from tool lists. This means the user doesn't even know they exist if they don't have the right permissions. - No error is thrown when a client tries to use a tool they can't access. This is bad because the client gets no feedback about what's going wrong.
- Client never knows the tool exists or what scopes are needed. This is the worst part β the client is completely in the dark!
mcp-proxy Current State
Location: node_modules/mcp-proxy/src/authentication.ts
// Lines 15-36
getUnauthorizedResponse(): { body: string; headers: Record<string, string> } {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
// Add WWW-Authenticate header if OAuth config is available
if (this.config.oauth?.protectedResource?.resource) {
headers["WWW-Authenticate"] = `Bearer resource_metadata="${this.config.oauth.protectedResource.resource}/.well-known/oauth-protected-resource"`;
}
return {
body: JSON.stringify({
error: {
code: 401, // Always 401
message: "Unauthorized: Invalid or missing API key",
},
id: null,
jsonrpc: "2.0",
}),
headers,
};
}
Location: node_modules/mcp-proxy/src/startHTTPServer.ts
// Lines 61-70: Helper function for WWW-Authenticate header
const getWWWAuthenticateHeader = (
oauth?: AuthConfig["oauth"],
): string | undefined => {
if (!oauth?.protectedResource?.resource) {
return undefined;
}
return `Bearer resource_metadata="${oauth.protectedResource.resource}/.well-known/oauth-protected-resource"`;
};
// Lines 248-278: Authentication handling (stateless mode)
if (stateless && authenticate) {
try {
const authResult = await authenticate(req);
if (!authResult || ...) {
// Returns 401 only
res.setHeader("WWW-Authenticate", wwwAuthHeader);
res.writeHead(401).end(...);
}
}
}
Current Limitations:
- Only supports 401 responses. We need to handle 403s for scope challenges.
WWW-Authenticateheader only includesresource_metadata. We need to adderror,scope, anderror_description.- No support for
error,scope, orerror_descriptionparameters. These are required for scope challenge handling. - No 403 response handling. We need to be able to send back a 403 when the user doesn't have enough scope.
Required Changes
Okay, so now we know what's missing. Let's talk about the changes we need to make to FastMCP and mcp-proxy to support scope challenge handling.
1. FastMCP Changes
1.1 Enhance canAccess API
File: src/FastMCP.ts:878
// Current
interface Tool<T> {
canAccess?: (auth: T) => boolean;
}
// Proposed
interface CanAccessResult {
allowed: boolean;
requiredScopes?: string[];
errorDescription?: string;
}
interface Tool<T> {
// Support both for backward compatibility
canAccess?: (auth: T) => boolean | CanAccessResult;
}
What we're doing here is making the canAccess function more flexible. Instead of just returning a boolean, it can now return a CanAccessResult object that includes the required scopes and an error description. This gives us much more control over the scope challenge process.
1.2 Add Scope Challenge Error Type
File: New class in src/FastMCP.ts
export class InsufficientScopeError extends McpError {
constructor(
public toolName: string,
public requiredScopes: string[],
public errorDescription?: string,
) {
super(
ErrorCode.InvalidRequest, // Or add new ErrorCode.InsufficientScope
`Insufficient scope for tool '${toolName}': requires scopes [${requiredScopes.join(', ')}]`,
);
this.name = "InsufficientScopeError";
}
toJSON() {
return {
code: -32001, // Custom JSON-RPC error code
message: this.message,
data: {
error: "insufficient_scope",
requiredScopes: this.requiredScopes,
toolName: this.toolName,
errorDescription: this.errorDescription,
},
};
}
}
This new InsufficientScopeError class will be thrown when a user tries to access a tool without the required scopes. It includes all the information needed to generate the correct 403 response, including the tool name, required scopes, and error description.
1.3 Add Runtime Access Check in Tool Execution
File: src/FastMCP.ts:1756
this.#server.setRequestHandler(CallToolRequestSchema, async (request) => {
const tool = tools.find((tool) => tool.name === request.params.name);
if (!tool) {
throw new McpError(
ErrorCode.MethodNotFound,
`Unknown tool: ${request.params.name}`,
);
}
// NEW: Runtime access check with scope challenge support
if (tool.canAccess && this.#auth) {
const accessResult = tool.canAccess(this.#auth);
// Handle both boolean and CanAccessResult returns
const allowed = typeof accessResult === 'boolean'
? accessResult
: accessResult.allowed;
if (!allowed) {
// Extract scope information if available
const requiredScopes = typeof accessResult === 'object' && accessResult.requiredScopes
? accessResult.requiredScopes
: [];
const errorDescription = typeof accessResult === 'object' && accessResult.errorDescription
? accessResult.errorDescription
: `Tool '${request.params.name}' requires additional permissions`;
throw new InsufficientScopeError(
request.params.name,
requiredScopes,
errorDescription,
);
}
}
// Continue with existing tool execution logic...
});
This is where the magic happens! We're adding a runtime access check to the tool execution handler. This check will call the canAccess function and, if it returns false or a CanAccessResult with allowed: false, it will throw the InsufficientScopeError. This ensures that the user has the required scopes before the tool is executed.
1.4 Add OAuth Config to Session
File: src/FastMCP.ts
// Add to FastMCP options
interface FastMCPOptions {
// ... existing options
oauth?: {
enabled: boolean;
protectedResource?: {
resource?: string;
};
};
}
We're adding an oauth configuration option to FastMCP. This allows us to specify whether OAuth is enabled and to provide the resource URI, which is needed for the resource_metadata parameter in the WWW-Authenticate header.
2. mcp-proxy Changes
2.1 Enhance AuthenticationMiddleware
File: node_modules/mcp-proxy/src/authentication.ts
export interface AuthConfig {
apiKey?: string;
oauth?: {
protectedResource?: {
resource?: string;
};
};
}
export class AuthenticationMiddleware {
constructor(private config: AuthConfig = {}) {}
// Existing method - keep for 401 responses
getUnauthorizedResponse(): { body: string; headers: Record<string, string> } {
// ... existing implementation
}
// NEW: Generate scope challenge response for 403
getScopeChallengeResponse(
requiredScopes: string[],
errorDescription?: string,
requestId?: unknown,
): { body: string; headers: Record<string, string>; statusCode: number } {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
// Build WWW-Authenticate header with all required parameters
if (this.config.oauth?.protectedResource?.resource) {
const parts = [
'Bearer',
'error="insufficient_scope"',
`scope="${requiredScopes.join(' ')}"`,
`resource_metadata="${this.config.oauth.protectedResource.resource}/.well-known/oauth-protected-resource"`,
];
if (errorDescription) {
// Escape quotes in description
const escaped = errorDescription.replace(/"/g, '\\"');
parts.push(`error_description="${escaped}"`);
}
headers["WWW-Authenticate"] = parts.join(', ');
}
return {
statusCode: 403,
headers,
body: JSON.stringify({
jsonrpc: "2.0",
id: requestId ?? null,
error: {
code: -32001, // Custom error code for insufficient scope
message: errorDescription || "Insufficient scope",
data: {
error: "insufficient_scope",
required_scopes: requiredScopes,
},
},
}),
};
}
validateRequest(req: IncomingMessage): boolean {
// ... existing implementation
}
}
We're adding a new getScopeChallengeResponse method to the AuthenticationMiddleware. This method will generate the 403 response with the WWW-Authenticate header, including all the required parameters: error, scope, resource_metadata, and error_description. It also handles escaping quotes in the error description to prevent header injection attacks.
2.2 Update Error Handling in startHTTPServer
File: node_modules/mcp-proxy/src/startHTTPServer.ts
// Add helper to detect scope challenge errors
const isScopeChallengeError = (error: unknown): error is {
name: string;
data: {
error: string;
requiredScopes: string[];
errorDescription?: string;
};
} => {
return (
typeof error === 'object' &&
error !== null &&
'name' in error &&
error.name === 'InsufficientScopeError' &&
'data' in error &&
typeof error.data === 'object' &&
error.data !== null &&
'error' in error.data &&
error.data.error === 'insufficient_scope'
);
};
// Update handleStreamRequest to catch scope challenges
const handleStreamRequest = async <T extends ServerLike>({
// ... existing params
authMiddleware, // NEW: Pass authMiddleware instance
}: {
// ... existing params
authMiddleware: AuthenticationMiddleware; // NEW
}) => {
// ... existing code
try {
// ... existing request handling
await transport.handleRequest(req, res, body);
return true;
} catch (error) {
// NEW: Check for scope challenge errors
if (isScopeChallengeError(error)) {
const response = authMiddleware.getScopeChallengeResponse(
error.data.requiredScopes,
error.data.errorDescription,
(body as { id?: unknown })?.id,
);
res.writeHead(response.statusCode, response.headers);
res.end(response.body);
return true;
}
// ... existing error handling
console.error("[mcp-proxy] error handling request", error);
res.setHeader("Content-Type", "application/json");
res.writeHead(500).end(createJsonRpcErrorResponse(-32603, "Internal Server Error"));
}
return true;
};
We're updating the handleStreamRequest function to catch InsufficientScopeError exceptions. When it catches one, it calls the getScopeChallengeResponse method on the AuthenticationMiddleware to generate the 403 response and send it back to the client.
2.3 Update startHTTPServer Function Signature
File: node_modules/mcp-proxy/src/startHTTPServer.ts:683
export const startHTTPServer = async <T extends ServerLike>({
// ... existing params
}: {
// ... existing params
}): Promise<SSEServer> => {
// ... existing setup
const authMiddleware = new AuthenticationMiddleware({ apiKey, oauth });
const httpServer = http.createServer(async (req, res) => {
// ... existing CORS and auth checks
if (
streamEndpoint &&
(await handleStreamRequest({
activeTransports: activeStreamTransports,
authenticate,
createServer,
enableJsonResponse,
endpoint: streamEndpoint,
eventStore,
oauth,
onClose,
onConnect,
req,
res,
stateless,
authMiddleware, // NEW: Pass authMiddleware
}))
) {
return;
}
// ... rest of handler
});
// ... rest of function
};
Finally, we're updating the startHTTPServer function to pass the AuthenticationMiddleware instance to the handleStreamRequest function. This allows the handleStreamRequest function to use the AuthenticationMiddleware to generate the 403 response.
Error Flow
To give you a clear picture of how everything works together, here's a diagram of the error flow:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 1. Client calls tool via MCP β
βββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 2. mcp-proxy receives request β
β - Authenticates user (401 if invalid token) β
β - Forwards to FastMCP session β
βββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 3. FastMCP CallToolRequestSchema handler β
β - Finds tool β
β - Calls tool.canAccess(auth) β
β - Returns { allowed: false, requiredScopes: [...] } β
β - Throws InsufficientScopeError β
βββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 4. Error propagates to mcp-proxy β
β - Catches InsufficientScopeError β
β - Calls authMiddleware.getScopeChallengeResponse() β
β - Returns 403 with WWW-Authenticate header β
βββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 5. Client receives 403 response β
β HTTP/1.1 403 Forbidden β
β WWW-Authenticate: Bearer error="insufficient_scope", β
β scope="files:write admin:read", β
β resource_metadata="...", β
β error_description="..." β
β β
β Body: { β
β "jsonrpc": "2.0", β
β "id": 1, β
β "error": { β
β "code": -32001, β
β "message": "Insufficient scope", β
β "data": { β
β "error": "insufficient_scope", β
β "required_scopes": ["files:write", "admin:read"] β
β } β
β } β
β } β
βββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 6. Client initiates re-authorization β
β - Parses WWW-Authenticate header β
β - Requests additional scopes from OAuth provider β
β - Retries tool call with new token β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Example Usage
Here's an example of how you might use the new canAccess API in a FastMCP server:
// In a FastMCP server
const mcp = new FastMCP({
name: "File Manager",
oauth: {
enabled: true,
protectedResource: {
resource: "mcp://file-manager",
},
},
});
mcp.tool({
name: "write_file",
description: "Write content to a file",
parameters: z.object({
path: z.string(),
content: z.string(),
}),
// Scope-aware access control
canAccess: (auth) => {
const userScopes = auth.scopes || [];
if (userScopes.includes("files:write")) {
return true; // Backward compatible boolean
}
// Return scope challenge
return {
allowed: false,
requiredScopes: ["files:write"],
errorDescription: "Writing files requires the 'files:write' scope",
};
},
execute: async ({ path, content }) => {
// Execute only if canAccess passed
await fs.writeFile(path, content);
return `File written: ${path}`;
},
});
In this example, the write_file tool requires the files:write scope. If the user doesn't have this scope, the canAccess function returns a CanAccessResult that tells the client what scope is needed and why.
Testing Strategy
To make sure everything is working correctly, we need a comprehensive testing strategy.
Unit Tests
- FastMCP canAccess Tests
- Test backward compatibility (boolean return)
- Test new object return format
- Test scope challenge error throwing
- InsufficientScopeError Tests
- Test error construction
- Test JSON serialization
- Test error metadata
- mcp-proxy AuthenticationMiddleware Tests
- Test
getScopeChallengeResponseheader format - Test error description escaping
- Test backward compatibility with 401 responses
- Test
Integration Tests
- End-to-End Scope Challenge
- Client calls tool without required scope
- Receives 403 with proper
WWW-Authenticateheader - Re-authenticates with required scope
- Successfully calls tool
- WWW-Authenticate Header Format
- Validate header conforms to RFC 9728
- Test with/without
error_description - Test scope list formatting
- Error Propagation
- Verify
InsufficientScopeErrorpropagates through mcp-proxy - Verify 403 status code (not 401)
- Verify JSON-RPC error format
- Verify
Migration Guide
If you're already using FastMCP and mcp-proxy, here's how to migrate to the new scope challenge handling.
For FastMCP Server Developers
Before:
canAccess: (auth) => auth.scopes?.includes("admin")
After (backward compatible):
// Option 1: Keep existing boolean (tool hidden if false)
canAccess: (auth) => auth.scopes?.includes("admin")
// Option 2: Use scope challenge (client gets 403 with scope info)
canAccess: (auth) => {
if (auth.scopes?.includes("admin")) {
return true;
}
return {
allowed: false,
requiredScopes: ["admin"],
errorDescription: "Admin access required",
};
}
For MCP Client Developers
Before:
- Tools were filtered out silently
- No way to know what scopes were needed
After:
- Receive 403 response with
WWW-Authenticateheader - Parse
scopeparameter to know what's needed - Re-authenticate with additional scopes
- Retry request