Skip to content

ext/auth: AcceptedScopes OR + includeGrantedScopes opt-in on tool-scope middleware - #811

Merged
panyam merged 2 commits into
mainfrom
feat/742-743-tool-scope-middleware-ergonomics
Jun 18, 2026
Merged

ext/auth: AcceptedScopes OR + includeGrantedScopes opt-in on tool-scope middleware#811
panyam merged 2 commits into
mainfrom
feat/742-743-tool-scope-middleware-ergonomics

Conversation

@panyam

@panyam panyam commented Jun 18, 2026

Copy link
Copy Markdown
Owner

What changes

Two ergonomic improvements to ext/auth.NewToolScopeMiddleware, bundled because they touch the same signature and share test scaffolding. Closes issues 742 and 743.

  • ToolDef.AcceptedScopes (issue 742) — opt-in OR escape hatch on top of RequiredScopes' AND semantics, supporting hierarchies like a repo scope satisfying a tool nominally requiring repo:read. Gate-only: never appears in the 403 challenge, so re-auth guidance stays least-privilege.
  • WithIncludeGrantedScopes(bool) (issue 743) — server-side defense against non-mcpkit clients that overwrite scopes on every challenge. When true, the 403 advertises the union of (caller's granted scopes ∪ required scopes). Default off.

Reviewer's guide

Read in this order:

  1. core/tool.go — the new AcceptedScopes field on ToolDef. Doc-block spells the gate-only contract and two-state nil-vs-empty semantics — both deliberate, both tested.
  2. ext/auth/scope_middleware.go — the new shape. NewToolScopeMiddleware becomes variadic-options; scopeGateSatisfied factors out the AND-vs-OR branching; the challenge-construction path branches on cfg.includeGrantedScopes.
  3. ext/auth/scope_middleware_test.go — six new acceptance tests covering both features and their interaction. All five pre-existing tests still pass unchanged (back-compat gate).
  4. core/auth.go + core/scopes_test.go — new core.GetScopes(ctx) sibling to core.HasScope. Three tests.
  5. ext/auth/scopes.go + ext/auth/scopes_test.go — new auth.UnionScopes(a, b) helper. Lives in ext/auth (not core) because the only in-tree caller is the middleware's challenge path. Five tests.
  6. core/typed_tool.go — wires WithToolAcceptedScopes mirror of WithToolRequiredScopes.

Skim: the doc-comment additions on existing surface (no behavior change).

Decision log

  1. Two helpers split across core and ext/auth rather than both in core. GetScopes is a sibling of HasScope (already in core) — handlers shouldn't need to import ext/auth just to inspect their own claims. UnionScopes had no cross-cutting caller in mcpkit — it lives where it's actually used. Heads off the awkward "mcpkit/core duplicates oneauth/core.UnionScopes" footnote.

  2. AcceptedScopes is gate-only — it never appears in the 403 challenge. Re-auth guidance stays least-privilege so clients don't escalate by requesting tolerated alternatives. Aligns with the upstream TypeScript SDK PR feat(server): add request-time OAuth scope challenges modelcontextprotocol/typescript-sdk#1624 (same choice cross-SDK). Flipping this post-merge would be a wire-format change.

  3. AcceptedScopes is two-state — nil and explicit []string{} both fall back to AND-on-RequiredScopes. Prevents the footgun where allocating an empty slice silently disables enforcement. Explicit test covers this.

  4. Variadic options on NewToolScopeMiddleware rather than a struct argument. Matches mcpkit precedent (server.WithMiddleware, every core.WithTool*). All five existing callsite tests compile unchanged.

  5. RequireAnyScope handler helper NOT added despite ext/auth: AcceptedScopes OR-hierarchy on ToolDef for tool-scope middleware #742's "optionally" mention. The static OR case is handled by AcceptedScopes declaratively (with correct WWW-Authenticate semantics for free). Dynamic OR in a handler body is two core.HasScope calls in three lines. File a follow-up if a real use surfaces.

Risk / blast radius

  • Affects: callers of auth.NewToolScopeMiddleware. Verified zero in-tree call sites outside the test file via grep -rn NewToolScopeMiddleware.
  • Back-compat: existing five tests run unchanged. The variadic-opts signature is additive.
  • Pressure-test areas:
    • Empty-slice vs nil discrimination on AcceptedScopes (Go footgun zone).
    • Gate-only invariant of AcceptedScopes vs union-in-challenge invariant of IncludeGrantedScopes — these are orthogonal and tested together.

Before / after — 403 challenge shape

Configuration Token has Challenge
RequiredScopes=["docs:write"] ["docs:read"] scope="docs:write" (unchanged)
+ AcceptedScopes=["docs:write","docs"] ["unrelated"] scope="docs:write" (accepted set hidden)
+ WithIncludeGrantedScopes(true) ["docs:read"] scope="docs:read docs:write"

Out of scope (deliberately deferred)

  • IntrospectionConfig.RequiredScopes / JWTValidator.RequiredScopes global gates — stay AND, per issue 742.
  • WWWAuth401 granted-scope union — unauthenticated path has no granted scopes to union with, per issue 743.
  • RequireAnyScope handler helper — file follow-up if a real dynamic-OR-in-handler case surfaces.

panyam added 2 commits June 17, 2026 17:53
…n tool-scope middleware (#742, #743)

Two ergonomic improvements to NewToolScopeMiddleware, bundled because they
touch the same signature and share test scaffolding.

#742 - AcceptedScopes OR-hierarchy:

- core.ToolDef.AcceptedScopes []string: opt-in OR escape hatch on top of
  RequiredScopes' AND semantics. Non-empty AcceptedScopes flips the gate to
  "any of these is held," supporting hierarchies like a `repo` scope
  satisfying a tool nominally requiring `repo:read`.
- core.WithToolAcceptedScopes typed-tool option mirrors WithToolRequiredScopes.
- Gate-only contract: AcceptedScopes participates in the satisfaction check
  but NEVER appears in the 403 WWW-Authenticate challenge. Re-auth guidance
  stays least-privilege - the challenge advertises RequiredScopes alone so
  clients don't escalate by requesting tolerated alternatives.
- Two-state: nil and explicit empty slice both fall back to the AND-on-
  RequiredScopes default. Allocating []string{} cannot silently disable
  enforcement.

#743 - WithIncludeGrantedScopes opt-in:

- NewToolScopeMiddleware gains variadic ToolScopeOption args. Existing
  callsites compile unchanged (no opts).
- WithIncludeGrantedScopes(true): the 403 challenge advertises the union of
  caller's currently-granted scopes and the tool's required scopes.
  Server-side defense against non-mcpkit clients that overwrite scopes on
  every challenge instead of accumulating. Mirrors the upstream TypeScript
  SDK PR modelcontextprotocol/typescript-sdk#1657 fix client-side.
- Default off - matches SEP-2350 per-operation semantics and keeps the
  challenge minimal for mcpkit-on-mcpkit deployments where clients
  accumulate correctly.

Shared helpers:

- core.GetScopes(ctx) - sibling of core.HasScope. Returns the caller's
  granted scope set or nil for absent/empty claims (consistent with HasScope
  semantics).
- auth.UnionScopes(a, b) - set-union helper preserving first-seen order.
  Lives in ext/auth because the only in-tree caller is the middleware's
  challenge-construction path; not speculative core surface.

Test plan:

- 3 new core/scopes_test.go cases for GetScopes (absent/present/empty).
- 5 new ext/auth/scopes_test.go cases for UnionScopes (nil-nil, disjoint,
  overlap dedup, nil-first allocates fresh, nil-second allocates fresh).
- 6 new ext/auth/scope_middleware_test.go cases:
  - AcceptedScopesHierarchyPasses
  - AcceptedScopesEmptyFallsBackToAND
  - AcceptedScopesDeniedAdvertisesRequiredOnly (gate-only contract)
  - IncludeGrantedScopesOffByDefault (back-compat)
  - IncludeGrantedScopesUnionsInChallenge
  - IncludeGrantedScopesEmptyGrantedSameAsOff
- All 5 existing middleware tests pass unchanged (no assertion weakening).
- make test + make test-auth + make tidy-all all green.

Closes #742.
Closes #743.
@panyam
panyam merged commit b6eb7b2 into main Jun 18, 2026
5 checks passed
@panyam
panyam deleted the feat/742-743-tool-scope-middleware-ergonomics branch June 18, 2026 04:30
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