Authorization model¶
Authorization in mocapi is split across two layers that compose at runtime. Neither layer knows about the other; both are present in any production deployment.
- Transport-layer authentication (
mocapi-oauth2). Validates a bearer token on every HTTP request, populates Spring Security'sSecurityContextHolderwith anAuthentication, and serves the RFC 9728 protected-resource metadata document. - Handler-layer authorization (Guard SPI in
mocapi-server, plus themocapi-spring-security-guardsreference implementation). Decides, per handler invocation, whether the currentAuthenticationis allowed to see and call the handler. Visibility and invocation are unified — if a guard denies, the handler is hidden fromtools/listand rejected bytools/call.
See ADR-0012 for the Guard SPI decision and ADR-0013 for the OAuth2 module and reference guards.
Layer 1: transport-layer authentication (mocapi-oauth2)¶
mocapi-oauth2 registers two SecurityFilterChain beans, scoped to
disjoint URL spaces. One serves the public discovery document; the other
authenticates MCP traffic.
| Bean | @Order |
URL pattern | Policy | Customizer SPI |
|---|---|---|---|---|
mcpMetadataFilterChain |
HIGHEST_PRECEDENCE |
/.well-known/oauth-protected-resource |
permitAll |
McpMetadataFilterChainCustomizer |
mcpFilterChain |
HIGHEST_PRECEDENCE + 10 |
${mocapi.endpoint:/mcp} and below |
authenticated, or all of mocapi.oauth2.required-scopes |
McpFilterChainCustomizer |
Both chains disable CSRF (MCP is stateless bearer-token, not cookie auth)
and wire the same McpTokenStrategy into Spring's oauth2ResourceServer
DSL.
The chains are split because their responsibilities genuinely differ. The
metadata document must be fetchable without a token — clients read it
to learn which authorization server to ask for a token. Forcing
authentication on metadata would be a chicken-and-egg violation of RFC
9728 §3. Keeping policy on its own chain prevents an MCP-chain edit
(say, requireScope("mcp.write")) from accidentally locking metadata.
Token strategy¶
McpTokenStrategy is the SPI that tells Spring how to validate bearer
tokens. Mocapi auto-selects the implementation based on which Spring Boot
properties were configured:
JwtMcpTokenStrategy— whenspring.security.oauth2.resourceserver.jwt.*is set. JWKS fetch, signature verify, audience validation are all Spring's own; mocapi only wires them onto both chains.OpaqueTokenMcpTokenStrategy— whenspring.security.oauth2.resourceserver.opaquetoken.*is set. Wraps Spring'sOpaqueTokenIntrospectorto enforce theaudcheck on the introspection response (Spring's opaque path doesn't ship one out of the box; the MCP spec requires it).
A user can replace both with a @Primary bean for testing or a
hypothetical future format.
Metadata customizers¶
The RFC 9728 document at /.well-known/oauth-protected-resource is
assembled by a list of McpMetadataCustomizer beans, one per facet
(resource, authorization_servers, scopes_supported, resource_name,
documentation/policy/ToS URIs). Mocapi ships five baselines, each
@ConditionalOnMissingBean — users can replace any of them outright with
@Primary, or add a later-@Order customizer to mutate or extend the
output (e.g. advertise mTLS-bound tokens).
Challenges: 401 and insufficient_scope¶
A request to /mcp with a missing or invalid token is rejected by
Spring's BearerTokenAuthenticationEntryPoint — the default on the
chain McpFilterChains.createMcpFilterChain builds — with 401 and a
WWW-Authenticate: Bearer challenge. As of Spring Security 7 that
challenge always carries resource_metadata, the absolute
/.well-known/oauth-protected-resource URL, computed from the request.
That is the client's auto-discovery breadcrumb: challenge → metadata
document → authorization server. Mocapi contributes no code here; it
inherits the behavior and pins it with an autoconfiguration test.
The challenge does not carry a scope parameter. Spring adds one
only for a BearerTokenError that names a scope (i.e. on
insufficient_scope); at 401 time no handler has been selected, so no
required scope is known, and the only available set — the advertised
mocapi.oauth2.scopes — is already published as scopes_supported in
the metadata document the challenge points at. Declined as duplicate in
ADR-0029.
403 insufficient_scope is available at resource level, via
mocapi.oauth2.required-scopes. When that list is non-empty,
createMcpFilterChain requires all of its scopes on the endpoint's
authorization rule and Spring's BearerTokenAccessDeniedHandler emits
the RFC 6750 §3.1 challenge for a token that lacks one. When it is empty
— the default — the rule is plain authenticated(), byte-for-byte the
behavior before the property existed.
Two implementation constraints make this a property rather than
something a user bolts on with McpFilterChainCustomizer:
HttpSecurity.authorizeHttpRequestsreuses one rule registry across calls, andAbstractRequestMatcherRegistry.anyRequest()asserts it has not already been configured. mocapi calls it once, so a customizer calling it again fails the context at startup.- Rules are evaluated in registration order, first match wins
(
RequestMatcherDelegatingAuthorizationManager). mocapi'sanyRequest()rule is registered before customizers run, so a customizer'srequestMatchers(...)rule is never reached — silent non-enforcement.
So the endpoint's authorization rule has exactly one owner
(McpFilterChains.authorizeMcpEndpoint), and resource-level scopes are
expressed through it. The AND is Spring's hasAllAuthorities, backed by
AllAuthoritiesAuthorizationManager, which asserts a non-empty authority
list — so the empty case is branched to authenticated() rather than
composed. That shape was chosen over AuthorizationManagers.allOf, which
grants when handed zero managers: composing an empty scope list there
would have permitted everyone and bypassed authentication entirely. Tests
pin both ends — an unauthenticated request still gets 401 with no required
scopes configured, and McpFilterChains sits at 100% branch coverage.
Per-tool scope denials deliberately do not produce a 403 step-up: they are Guard-layer decisions below JSON-RPC dispatch, where the filter chain cannot see which tool was called, and surfacing them would leak a hidden handler's existence and scope requirement — see "Visibility ≡ invocation" below and ADR-0029.
Layer 2: handler-layer authorization (Guard SPI)¶
The Guard SPI lives in com.callibrity.mocapi.server.guards. Three
types, no framework coupling:
@FunctionalInterface
public interface Guard { GuardDecision check(); }
public sealed interface GuardDecision {
record Allow() implements GuardDecision {}
record Deny(String reason) implements GuardDecision {}
}
Guards attach via the per-handler customizer SPI. At handler-build time, a
customizer inspects the method (typically for an annotation) and calls
config.guard(...) to register the runtime check. The guard closure
captures whatever annotation state it needs; the runtime call is a single
method invocation with no reflection.
Multiple guards on the same handler evaluate with AND semantics, in
@Order of the contributing customizers. The first Deny short-circuits.
Visibility ≡ invocation¶
The same guard list is consulted in two places:
*/list—tools/list,prompts/list,resources/list,resources/templates/list. Denied handlers are filtered out before pagination and never appear in the response. Deny reasons are not surfaced — list time is a discovery surface and should not leak why a handler was hidden.tools/call(and prompt/resource equivalents). After lookup, guards are evaluated. ADenythrowsJsonRpcExceptionwith code-32010 Forbiddenand message"Forbidden: <reason>"— the call never reaches the invoker chain, so interceptors afterAUTHORIZATION(input validation, the reflective method call) don't run.
A denial does not return CallToolResult.isError=true. That shape is
for tool-level errors the model can reasonably recover from; auth failures
are infrastructure-level and the protocol-correct shape is JSON-RPC
-32010 (ADR-0023).
Reference implementation: mocapi-spring-security-guards¶
This module reads two annotations off handler methods at startup:
@McpTool(name = "tenant_admin_op")
@RequiresScope("admin:write") // AND across listed scopes
@RequiresRole({"TENANT_ADMIN", "OPS"}) // OR across listed roles
public void tenantAdminOp(...) { ... }
Two customizers attach a ScopeGuard and/or RoleGuard when those
annotations are present. Each guard reads
SecurityContextHolder.getContext().getAuthentication() at call time —
so the same Authentication populated by mcpFilterChain is what the
guard inspects. Both annotations may coexist; the combined effect is the
SPI's natural AND semantics.
@RequiresScope matches granted authorities with the SCOPE_ prefix
Spring Security's JWT and opaque-token converters produce.
@RequiresRole accepts bare or ROLE_-prefixed values.
Other entitlement models — tenant checks, rate limits, mTLS subject matching — are user or third-party concerns. Mocapi does not bake any of them into core; the SPI is the seam.
server/discover bypasses guards¶
The server/discover JSON-RPC method does not flow through the
per-handler invoker chain — it is dispatched directly by the protocol
layer. Guards are not consulted. There is no handshake and no capability
negotiation in MCP 2026-07-28: server/discover is an ordinary,
stateless request that advertises the server's supported protocol
versions and static capabilities and is answerable at any time. Keeping
it guard-free lets a client probe those versions and capabilities even
against a locked-down server.
Per-handler guards take effect only on tools/call, prompts/get,
resources/read, resources/templates/read, and the matching */list
operations.
End-to-end flow¶
An authenticated tools/call on a Streamable HTTP deployment with
mocapi-oauth2 and mocapi-spring-security-guards both present:
Client ──POST /mcp + Authorization: Bearer eyJ…──▶ Servlet container
│
mcpMetadataFilterChain │ (URL doesn't match — skipped)
mcpFilterChain ▼
┌─ BearerTokenAuthenticationFilter
│ ├─ JwtDecoder: signature, exp, aud
│ ├─ JwtAuthenticationConverter: claims → authorities
│ └─ SecurityContextHolder.set(Authentication)
│ (on failure: 401 WWW-Authenticate + resource_metadata)
▼
StreamableHttpController.handleCall
│
│ spawns virtual thread; context-propagation
│ carries SecurityContext, Observation, MDC across
▼
JSON-RPC dispatch → tools/call
│
▼
Handler lookup by tool name
│
▼
AUTHORIZATION stratum
│ Guards.evaluate(handler.guards())
│ ├─ ScopeGuard reads SecurityContextHolder
│ └─ RoleGuard reads SecurityContextHolder
│ Allow ───► continue
│ Deny ───► throw JsonRpcException(-32010, reason)
▼
VALIDATION stratum (input schema, Jakarta)
│
▼
INVOCATION (reflective call into user code)
Every stratum outside AUTHORIZATION (CORRELATION/MDC, OBSERVATION/Micrometer,
AUDIT) wraps the guard evaluation, so a denial is logged, observed, and
audited as outcome=forbidden even though the user method never ran. See
observability-stack.md for the stratum order.
Why split into two layers¶
Authentication answers "who is this caller?" and is uniformly applied
across every request that reaches the MCP endpoint. Authorization answers
"may this caller do this thing?" and is per-handler. Conflating them
into Spring Security's authorizeHttpRequests would force every per-tool
rule to be expressed as a URL pattern, which doesn't work — tools/call
is one URL serving N tools.
The split also matches the deployment story. mocapi-oauth2 is mandatory
for HTTP-bearer deployments; mocapi-spring-security-guards is optional
and can be replaced or augmented by user-supplied Guard implementations
without touching the OAuth2 wiring.
Related¶
- Authorization guide — user-facing configuration recipes for OAuth2 + guards.
- Guards guide — annotation usage.
docs/guards.md— Guard SPI details.- ADR-0012 / ADR-0013.
- ADR-0029 — which
SHOULD-level challenges mocapi emits,
required-scopes, and why per-tool step-up is declined. observability-stack.md— where AUTHORIZATION sits in the interceptor strata.