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:
- 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?
- If it's intentional, would a doc PR clarifying that be welcome?
- 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:174 — GetClaimsFromHeader
internal/auth/generic/generic.go:200-L214 — inline audience check
internal/auth/generic/generic.go:362 — validateClaims
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.
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
genericauthServices.mcp-authgates the MCP endpoint(
mcpEnabled: true) anduserdata-roleis attached to a specific tool viaauthRequired. Each service declares a distinctscopesRequired. My readingis that calling
userdata_probeshould require a token whosescopeclaimcontains
mcp:userdata:read, in addition to whatever the MCP endpointrequires.
However, when I mint a JWT with the correct
audbut with ascopeclaimthat does not include
mcp:userdata:read, the tool still runs andreturns
HTTP 200. The server-side DEBUG log prints"tool invocation authorized"with no"insufficient scopes: missing ..."line. Replacingthe
audwith 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.goonmain(2026-04-23), theper-tool path goes through
GetClaimsFromHeaderat L174, which validatessignature and
audinline but never referencesa.ScopesRequired. Thesister function
validateClaimsat L362 does implement a scope block(L384-L398), but
GetClaimsFromHeaderdoesn't call it or replicate it.My questions:
scopesRequiredexpected to be enforced, and I'm hitting abug? Or is audience-only the intended design at the per-tool layer?
validateClaims:384-398intoGetClaimsFromHeaderbe acceptable? I'mhappy to send a PR with a unit test if so.
Environment
us-central1-docker.pkg.dev/database-toolbox/toolbox/toolbox:1.0.0mainat HEAD, 2026-04-23Code
tools.yaml(minimal reproduction)Request — token with
aud=local-audandscope="mcp:read mcp:reports:read"(nomcp:userdata:read)Response (HTTP 200 — tool ran)
{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text", "text":"{\"ok\":1}"}]}}Relevant source —
internal/auth/generic/generic.goonmainThe per-tool path, which does not touch
a.ScopesRequired:The scope block that
validateClaims(L362) implements and which I'd expectthe per-tool path to reuse:
Candidate patch for
GetClaimsFromHeader(if this turns out to be a bug)Placed just after the existing audience check, before
return claims, nil:Additional Details
Server-side DEBUG logs from the request above (
--log-level=DEBUG):There's no
"insufficient scopes: missing mcp:userdata:read"line, i.e. thelog statement at
generic.go:394is never reached on this code path.Contrast — same request, but token minted with
aud: "wrong-aud"This confirms
GetClaimsFromHeaderis running — the audience branch firesand 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
mainat 2026-04-23):internal/auth/generic/generic.go:174—GetClaimsFromHeaderinternal/auth/generic/generic.go:200-L214— inline audience checkinternal/auth/generic/generic.go:362—validateClaimsinternal/auth/generic/generic.go:384-L398— scope block that's presentin
validateClaimsbut not inGetClaimsFromHeaderThanks 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.