Skip to content

Is scopesRequired supposed to be enforced on per-tool authRequired gates, or is the per-tool layer intentionally audience-only? #3120

@Jkaiser001

Description

@Jkaiser001

Prerequisites

Question

First of all, thanks for the project — we're using it as the server for a
multi-database MCP setup and it's been really useful. Before I assume I'm
looking at a bug, I wanted to ask whether I'm reading the configuration
contract correctly.

I have two generic authServices. mcp-auth gates the MCP endpoint
(mcpEnabled: true) and userdata-role is attached to a specific tool via
authRequired. Each service declares a distinct scopesRequired. My reading
is that calling userdata_probe should require a token whose scope claim
contains mcp:userdata:read, in addition to whatever the MCP endpoint
requires.

However, when I mint a JWT with the correct aud but with a scope claim
that does not include mcp:userdata:read, the tool still runs and
returns HTTP 200. The server-side DEBUG log prints "tool invocation authorized" with no "insufficient scopes: missing ..." line. Replacing
the aud with a wrong value does fail (HTTP 401 + audience-check log),
which confirms the per-tool auth path is executing — only the scope check
seems to be skipped.

Reading internal/auth/generic/generic.go on main (2026-04-23), the
per-tool path goes through GetClaimsFromHeader at L174, which validates
signature and aud inline but never references a.ScopesRequired. The
sister function validateClaims at L362 does implement a scope block
(L384-L398), but GetClaimsFromHeader doesn't call it or replicate it.

My questions:

  1. Is per-tool scopesRequired expected to be enforced, and I'm hitting a
    bug? Or is audience-only the intended design at the per-tool layer?
  2. If it's intentional, would a doc PR clarifying that be welcome?
  3. If it's a bug, would a patch mirroring the scope block from
    validateClaims:384-398 into GetClaimsFromHeader be acceptable? I'm
    happy to send a PR with a unit test if so.

Environment

  • Toolbox image: us-central1-docker.pkg.dev/database-toolbox/toolbox/toolbox:1.0.0
  • Source read: main at HEAD, 2026-04-23
  • Auth: local JWT mock issuing RS256 tokens against a self-hosted JWKS
  • OS: Linux container, Darwin host

Code

tools.yaml (minimal reproduction)

authServices:
  mcp-auth:
    type: generic
    mcpEnabled: true
    audience: local-aud
    authorizationServer: http://localhost:9000
    scopesRequired: [mcp:read]

  userdata-role:
    type: generic
    audience: local-aud
    authorizationServer: http://localhost:9000
    scopesRequired: [mcp:userdata:read]   # <-- I expected this to gate userdata tools

sources:
  db_userdata:
    kind: mysql
    # ... your mysql config ...

tools:
  userdata_probe:
    kind: mysql-sql
    source: db_userdata
    authRequired: [userdata-role]
    statement: "SELECT 1 AS ok"

Request — token with aud=local-aud and scope="mcp:read mcp:reports:read" (no mcp:userdata:read)

curl -X POST http://127.0.0.1:5000/mcp/validation \
  -H "Authorization: Bearer $TOKEN" \
  -H "userdata-role_token: $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
       "params":{"name":"userdata_probe","arguments":{}}}'

Response (HTTP 200 — tool ran)

{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text",
 "text":"{\"ok\":1}"}]}}

Relevant source — internal/auth/generic/generic.go on main

The per-tool path, which does not touch a.ScopesRequired:

// L174
func (a AuthService) GetClaimsFromHeader(ctx context.Context, h http.Header) (map[string]any, error) {
    if a.McpEnabled { return nil, nil }
    tokenString := h.Get(a.Name + "_token")
    if tokenString == "" { return nil, nil }

    // parse + verify signature (L182)
    // extract claims (L188)
    // inline audience check (L200-L214)

    return claims, nil // L225 — no scope validation anywhere
}

The scope block that validateClaims (L362) implements and which I'd expect
the per-tool path to reuse:

// generic.go L384-L398 (inside validateClaims)
if len(a.ScopesRequired) > 0 {
    tokenScopes := strings.Split(scopeStr, " ")
    scopeMap := map[string]bool{}
    for _, s := range tokenScopes { scopeMap[s] = true }
    for _, requiredScope := range a.ScopesRequired {
        if !scopeMap[requiredScope] {
            logger.WarnContext(ctx, "insufficient scopes: missing %s", requiredScope)
            return &MCPAuthError{Code: http.StatusForbidden, Message: "insufficient scopes", ScopesRequired: a.ScopesRequired}
        }
    }
}

Candidate patch for GetClaimsFromHeader (if this turns out to be a bug)

Placed just after the existing audience check, before return claims, nil:

if len(a.ScopesRequired) > 0 {
    scopeStr, _ := claims["scope"].(string)
    have := map[string]bool{}
    for _, s := range strings.Split(scopeStr, " ") {
        have[s] = true
    }
    for _, need := range a.ScopesRequired {
        if !have[need] {
            return nil, fmt.Errorf("insufficient scopes: missing %s", need)
        }
    }
}

Additional Details

Server-side DEBUG logs from the request above (--log-level=DEBUG):

DEBUG "toolset name: validation"
DEBUG "method is: tools/call"
DEBUG "tool name: userdata_probe"
DEBUG "tool invocation authorized"
INFO  "POST /mcp/validation => HTTP 200"

There's no "insufficient scopes: missing mcp:userdata:read" line, i.e. the
log statement at generic.go:394 is never reached on this code path.

Contrast — same request, but token minted with aud: "wrong-aud"

DEBUG "toolset name: validation"
DEBUG "method is: tools/call"
DEBUG "tool name: userdata_probe"
DEBUG "audience validation failed: expected local-aud, got [wrong-aud]"
DEBUG "error processing message: unauthorized Tool call: Please make sure you specify correct auth headers"
WARN  "POST /mcp/validation => HTTP 401"

This confirms GetClaimsFromHeader is running — the audience branch fires
and rejects — so the absence of the scope-failure log in the first case is
specifically about the scope check not being performed, not about the whole
auth path being skipped.

Related file references (all on main at 2026-04-23):

  • internal/auth/generic/generic.go:174GetClaimsFromHeader
  • internal/auth/generic/generic.go:200-L214 — inline audience check
  • internal/auth/generic/generic.go:362validateClaims
  • internal/auth/generic/generic.go:384-L398 — scope block that's present
    in validateClaims but not in GetClaimsFromHeader

Thanks in advance for taking a look — if one of the three options above is
the preferred path forward, I can pick it up from there.

Metadata

Metadata

Assignees

No one assigned

    Labels

    type: questionRequest for information or clarification.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions