Skip to content

​ Feat: Scope Challenge Handling #49

@gazzadownunder

Description

@gazzadownunder

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 code
  • scope="..." - Space-separated list of required scopes
  • resource_metadata - URI to OAuth protected resource metadata

Optional Parameters:

  • error_description - Human-readable description

Scope Management Strategies

  1. Minimum approach: Include only newly required scopes
  2. Recommended approach: Include existing + new scopes
  3. 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 canAccess are 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, or error_description parameters
  • 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

  1. FastMCP canAccess Tests

    • Test backward compatibility (boolean return)
    • Test new object return format
    • Test scope challenge error throwing
  2. InsufficientScopeError Tests

    • Test error construction
    • Test JSON serialization
    • Test error metadata
  3. mcp-proxy AuthenticationMiddleware Tests

    • Test getScopeChallengeResponse header format
    • Test error description escaping
    • Test backward compatibility with 401 responses

Integration Tests

  1. 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
  2. WWW-Authenticate Header Format

    • Validate header conforms to RFC 9728
    • Test with/without error_description
    • Test scope list formatting
  3. 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 scope parameter to know what's needed
  • Re-authenticate with additional scopes
  • Retry request

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions