Skip to content

feat(auth): ask for a missing OAuth scope with a 403 insufficient_scope challenge - #268

Open
mattzcarey wants to merge 1 commit into
fix/explain-api-refusalsfrom
feat/insufficient-scope-challenge
Open

mattzcarey wants to merge 1 commit into
fix/explain-api-refusalsfrom
feat/insufficient-scope-challenge

Conversation

@mattzcarey

@mattzcarey mattzcarey commented Oct 7, 2026 •

Copy link
Copy Markdown
Member

Third of five stacked PRs, on #267. Replaces #261 (same change, ported onto the Forge artifacts: endpoint tools pick the scope from their own mcp-tools.json entry's path template and permissions; execute uses spec.json).

When Cloudflare refuses a call because the OAuth connection lacks a scope, answer with the spec's step-up challenge (MCP scope challenge handling) instead of a tool error. Claude, Claude Code, the MCP Inspector and the SDK client then re-authorize for the scope and retry the call. Replaces #256.

HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer realm="OAuth", error="insufficient_scope", scope="user:read account:read dns.read",
  resource_metadata="https://mcp.cloudflare.com/.well-known/oauth-protected-resource/mcp",
  error_description="This operation needs the dns.read scope"
sequenceDiagram
  participant C as MCP client
  participant S as MCP server
  participant CF as Cloudflare API
  C->>S: tools/call execute (GET /zones/x/dns_records)
  S->>CF: GET /zones/x/dns_records
  CF-->>S: 403 10000 Authentication error
  Note over S: grant lacks dns.read and dns.write<br/>every request was a read or refused
  S-->>C: 403 insufficient_scope, scope="user:read account:read dns.read"
  C->>C: re-authorize with dns.read added
  C->>S: same tools/call, new token
Loading

When the server challenges. All of these must hold. Otherwise the call gets the explanation from #267 as a tool error.

  • The connection is an OAuth connection this server issued. API tokens can't step up.
  • Cloudflare actually refused with a 403. There's no up-front check from the spec: mapping permission names to scopes is good enough to explain a refusal, but not to block a call that might have worked. That's also why this doesn't use the SDK's scopeChallenge, which runs before the tool does.
  • The grant holds none of the scopes the endpoint accepts. A connection that already holds one is never challenged, so a wrong pick can't loop.
  • One scope can be named. scopeToRequest picks a read scope for GET/HEAD and a write scope otherwise. Next it takes the account or zone variant the path implies (access.read or zone-access.read). Then it takes the alternative that matches the path best (workers-scripts.read rather than workers-tail.read). A tie names no scope.
  • Retrying is safe:
    • An endpoint tool sends exactly one request.
    • execute needs the refusal to be what made the code fail, and every request the code sent to be a GET/HEAD or refused. The sandbox wraps fetch before the code runs. If an earlier write went through, the error says the code wasn't offered for an automatic retry and asks for only the remaining steps to be run.

What it names. The baseline user:read account:read plus the missing scope. Earlier step-ups aren't repeated: the spec makes accumulating scopes the client's job, and repeating them would bring back the long authorize URLs from #253.

Trust. The execute sandbox reports the missing scope, so it's untrusted. The host accepts only a catalog scope the connection lacks. Code that lies gains nothing, since it could trigger the same challenge by calling an endpoint that needs the scope.

HTTP plumbing.

  • For OAuth connections, 2025-era POSTs are served as JSON through our own stateless transport (enableJsonResponse). The SDK fallback opens an SSE stream before the tool runs, which would commit a 200 too early. 2026-07-28 requests are already JSON.
  • WWW-Authenticate is added to Access-Control-Expose-Headers.

ChatGPT. ChatGPT reads challenges from the tool result's _meta["mcp/www_authenticate"], not from an HTTP 403. Tools always add the challenge there. With /mcp?scopeChallenge=tool, that result is sent as-is instead of being swapped for the 403.

…pe challenge

Ported onto the Forge artifacts: endpoint tools pick the scope from their
mcp-tools.json entry's template and permissions (explainRefusalFor);
execute keeps the spec.json lookup.
mattzcarey added a commit that referenced this pull request Oct 9, 2026
Combines #267 and #268, ported from the Forge stack onto main.

- 401/403 tool errors name the method, path and status, and say whether the
  connection lacks a scope, already holds one, or needs an API token.
- The scheduled handler keeps x-api-token-group in spec.json and copies it
  into each mcp-tools.json entry as `permissions`.
- execute looks a refused path up in the cached mcp-tools.json, not the
  ~25 MB spec.json.
- An OAuth connection missing the scope gets a 403 insufficient_scope
  challenge when retrying the call is safe; ?scopeChallenge=tool keeps the
  challenge in the tool result's _meta instead.
mattzcarey added a commit that referenced this pull request Oct 12, 2026
Combines #267 and #268, ported from the Forge stack onto main.

- 401/403 tool errors name the method, path and status, and say whether the
  connection lacks a scope, already holds one, or needs an API token.
- The scheduled handler keeps x-api-token-group in spec.json and copies it
  into each mcp-tools.json entry as `permissions`.
- execute looks a refused path up in the cached mcp-tools.json, not the
  ~25 MB spec.json.
- An OAuth connection missing the scope gets a 403 insufficient_scope
  challenge when retrying the call is safe; ?scopeChallenge=tool keeps the
  challenge in the tool result's _meta instead.
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