-
Notifications
You must be signed in to change notification settings - Fork 41
Description
MCP Scope Challenge (Step-Up Authentication) Implementation
Overview
The MCP specification (2025-11-25) now includes support for scope challenge handling, which enables step-up authentication when a client attempts to access a resource that requires additional OAuth scopes beyond what they currently possess.
This document outlines the changes required in FastMCP and mcp-proxy to support this feature.
MCP Specification Requirements
Per MCP Spec Section: Scope Challenge Handling:
HTTP Status Codes
- 403 Forbidden: Used for insufficient scope errors
- 401 Unauthorized: Used when authorization is required or token is invalid
WWW-Authenticate Header Format (403 Response)
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"
Required Parameters:
error="insufficient_scope"- Fixed error codescope="..."- Space-separated list of required scopesresource_metadata- URI to OAuth protected resource metadata
Optional Parameters:
error_description- Human-readable description
Scope Management Strategies
- Minimum approach: Include only newly required scopes
- Recommended approach: Include existing + new scopes
- Extended approach: Include existing + new + related scopes
Current Implementation Analysis
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 - No error is thrown when a client tries to use a tool they can't access
- Client never knows the tool exists or what scopes are needed
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
- WWW-Authenticate header only includes
resource_metadata - No support for
error,scope, orerror_descriptionparameters - No 403 response handling
Required Changes
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;
}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,
},
};
}
}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...
});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;
};
};
}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
}
}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;
};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
};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
// 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}`;
},
});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 getScopeChallengeResponse header format
- Test error description escaping
- Test backward compatibility with 401 responses
Integration Tests
-
End-to-End Scope Challenge
- Client calls tool without required scope
- Receives 403 with proper WWW-Authenticate header
- 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 InsufficientScopeError propagates through mcp-proxy
- Verify 403 status code (not 401)
- Verify JSON-RPC error format
Migration Guide
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-Authenticate header
- Parse
scopeparameter to know what's needed - Re-authenticate with additional scopes
- Retry request