Authorization¶
Mocapi ships an OAuth2 resource-server module for the Streamable HTTP transport. The MCP 2026-07-28 authorization specification requires servers to:
- Validate bearer JWTs on every request (signature, expiry, audience).
- Respond with
401 WWW-Authenticate: Bearer ... resource_metadata="..."when a token is missing or invalid. - Publish an RFC 9728 protected-resource metadata document at
/.well-known/oauth-protected-resourceadvertising the accepted authorization servers.
mocapi-oauth2 wires all three, leaning on Spring Boot's oauth2-resource-server starter for the heavy lifting and filling in the MCP-specific gaps Spring doesn't address.
Getting Started¶
Add the starter to your build — it pulls mocapi-streamable-http-spring-boot-starter and spring-boot-starter-oauth2-resource-server transitively.
<dependency>
<groupId>com.callibrity.mocapi</groupId>
<artifactId>mocapi-oauth2</artifactId>
<version>${mocapi.version}</version>
</dependency>
Configure your identity provider and the resource you're protecting:
Minimum configuration — for the common single-audience case, two standard Spring Boot properties are enough:
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://idp.example.com
audiences:
- https://mcp.example.com
When spring.security.oauth2.resourceserver.jwt.audiences has exactly one element and mocapi.oauth2.resource is unset, mocapi auto-derives the protected-resource metadata's resource field from that single audience. For Auth0, Okta, Keycloak, and most other IdPs this is the normal case — clients get tokens for one logical resource, and there's no need to duplicate the identifier across two properties.
Full configuration — set mocapi.oauth2.* when you want to enrich the metadata document or work around the auto-derivation:
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://idp.example.com
audiences:
- https://mcp.example.com
mocapi:
server-title: My MCP Server # already used by server/discover;
# reused as OAuth2 resource_name
oauth2:
resource: https://mcp.example.com # optional — defaults to audiences[0]
# when audiences has exactly one entry.
# Must be a member of audiences.
scopes: # optional — advertised in metadata
- mcp.read
- mcp.write
resource-documentation: https://docs.example.com # optional — developer docs URL
resource-policy-uri: https://example.com/policy # optional — policy doc (token handling)
resource-tos-uri: https://example.com/tos # optional — terms of service
The metadata's resource_name field is sourced from mocapi.server-title (falling back to mocapi.server-name) — the same human-readable label the MCP server/discover response advertises. Having one property feed both avoids a configuration drift where the OAuth2 metadata names a different server than the MCP discovery surface.
spring.security.oauth2.resourceserver.jwt.issuer-uri and .audiences are the standard Spring Boot properties; mocapi does not duplicate them. The mocapi.oauth2.* properties cover the MCP-specific metadata document.
Recommended: set jwk-set-uri directly¶
If you only set issuer-uri, Spring Boot performs an HTTP call to the IdP's /.well-known/openid-configuration at startup (or on first request, via SupplierJwtDecoder) to discover the signing keys URL. Setting jwk-set-uri explicitly skips that discovery hop:
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://example.auth0.com/
jwk-set-uri: https://example.auth0.com/.well-known/jwks.json # recommended
audiences:
- https://demo-api.example.com
Cuts one network dependency from the boot path and makes startup more robust in restricted network environments. Most IdPs publish their JWKS at <issuer>.well-known/jwks.json — Auth0, Okta, Keycloak, Entra ID all follow that pattern.
Startup-time invariant¶
Mocapi validates at startup that mocapi.oauth2.resource (whether explicitly set or auto-derived) is a member of spring.security.oauth2.resourceserver.jwt.audiences. If they don't agree, the app refuses to start with a descriptive error. The rationale: clients following the RFC 9728 metadata document request tokens bound to the advertised resource identifier; if that identifier isn't in the server's accepted audiences, every token the client obtains would be rejected during validation. Catching that at startup is much cheaper than a silently-broken deployment where every MCP request returns 401.
That's the whole setup. Starting your Spring Boot application brings up:
- Bearer token validation on
${mocapi.endpoint:/mcp}/**(signature, expiry, audience) — Spring Boot auto-wires this from thejwt.*properties. - A
401 WWW-Authenticate: Bearer ... resource_metadata="..."challenge on missing or invalid tokens — Spring Security 7.0's built-in entry point handles this natively. - A
GET /.well-known/oauth-protected-resourceendpoint serving the RFC 9728 metadata document — mocapi wires Spring's filter with your configured fields.
What mocapi adds vs. what Spring provides¶
| Capability | Provided by |
|---|---|
JwtDecoder (JWKS fetch, signature verify) |
Spring Boot auto-config (jwt.issuer-uri) |
aud claim validation |
Spring Boot auto-config (jwt.audiences) |
JwtAuthenticationConverter (claims → authorities) |
Spring Boot auto-config |
Default SecurityFilterChain with oauth2ResourceServer() |
Spring Boot auto-config |
401 WWW-Authenticate: Bearer ... resource_metadata="..." |
Spring Security 7.0 BearerTokenAuthenticationEntryPoint |
/.well-known/oauth-protected-resource endpoint |
mocapi wires Spring's OAuth2ProtectedResourceMetadataFilter |
Metadata document content (resource, authorization_servers, scopes_supported, etc.) |
mocapi, from mocapi.oauth2.* with fallback to jwt.issuer-uri |
SecurityFilterChain scoped to the MCP endpoint + metadata path |
mocapi |
Filter chain architecture¶
mocapi-oauth2 registers two SecurityFilterChain beans, not one.
Each chain owns a distinct URL space, has its own authorization policy,
and exposes its own customizer SPI.
| Bean name | @Order |
Matches | Policy | Customizer SPI |
|---|---|---|---|---|
mcpMetadataFilterChain |
HIGHEST_PRECEDENCE |
/.well-known/oauth-protected-resource |
permitAll (RFC 9728 §3) |
McpMetadataFilterChainCustomizer |
mcpFilterChain |
HIGHEST_PRECEDENCE + 10 |
${mocapi.endpoint:/mcp} and ${mocapi.endpoint:/mcp}/** |
authenticated, or all of mocapi.oauth2.required-scopes when set |
McpFilterChainCustomizer |
CSRF is disabled on both (MCP is stateless bearer-token, not cookie auth).
Both chains wire the same McpTokenStrategy into Spring's
oauth2ResourceServer DSL — the metadata chain uses it only to satisfy
the DSL (which refuses to build without a bearer-token format declared);
the MCP chain uses it to actually validate incoming tokens.
The chains are split because they have genuinely different
responsibilities. The metadata chain serves a public discovery document
— clients fetch it before they have a token to find out which
authorization server to use. Requiring auth there would be a chicken-
and-egg violation of RFC 9728. Keeping it on its own chain with its own
customizer surface means tweaks to the MCP auth policy (like "require
scope mcp.write") can't accidentally lock the metadata document.
Requiring a scope to reach the server (resource-level)¶
To require that every caller present a given scope just to talk to the MCP
endpoint, set mocapi.oauth2.required-scopes:
mocapi:
oauth2:
scopes: [mcp.read, mcp.write] # advertised in the metadata document
required-scopes: [mcp.read] # enforced on every /mcp request
The property is optional. Leave it unset and the endpoint requires only
an authenticated token — the default. Set it and all listed scopes must be
present (AND semantics, matching @RequiresScope). Values are bare scope
names; the SCOPE_ authority prefix is applied for you.
A valid token missing a required scope gets:
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope",
error_description="...", scope="mcp.read",
resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"
That challenge is defined by
RFC 6750 §3.1 and is the
step-up breadcrumb the
MCP authorization spec
expects: it tells the client which scope to go ask the authorization server
for, and the resource_metadata parameter
(RFC 9728) points at the document
listing what this server supports. Spring Security's
BearerTokenAccessDeniedHandler produces it; mocapi only supplies the rule.
A request with no token still gets 401, not 403 — missing credentials
is an authentication failure, not a scope problem.
List every required scope in mocapi.oauth2.scopes too. That property is
what populates scopes_supported in the metadata document, so a scope you
enforce but don't advertise leaves clients no way to discover what to request
— they just see a 403. Mocapi logs a warning at startup if it spots one.
Why this is a property and not a customizer¶
Earlier versions of this guide suggested doing it in an
McpFilterChainCustomizer with auth.anyRequest().hasAuthority(...).
That does not work, in two different ways, so don't reach for it:
HttpSecurity.authorizeHttpRequestsreuses a single rule registry across calls, andanyRequest()refuses to be configured twice — mocapi already called it, so a second call fails the context at startup withCan't configure anyRequest after itself.- Switching to
requestMatchers(...)to dodge that is worse: rules are matched in registration order, first match wins, and mocapi'sanyRequest()rule is registered first, so a later rule is never consulted. You get no enforcement and no error — the dangerous outcome.
Resource-level scopes therefore have to be expressed where mocapi builds
that single rule, which is what required-scopes does.
Customizing the MCP chain¶
McpFilterChainCustomizer mutates the HttpSecurity for mcpFilterChain
after mocapi has applied its defaults (securityMatcher, authorization rule,
CSRF disabled, oauth2ResourceServer). Use it for concerns that sit
alongside the authorization rule rather than replacing it — CORS on the MCP
endpoint, an extra servlet filter, a rate limiter:
@Bean
McpFilterChainCustomizer corsForMcp() {
return http -> http.cors(Customizer.withDefaults());
}
Multiple McpFilterChainCustomizer beans compose in Spring's natural
order. They run after mocapi's defaults, so user configuration layers on
top of the built-ins. The one thing to avoid is authorizeHttpRequests — see
the section above for why, and use required-scopes instead.
Customizing the metadata chain¶
McpMetadataFilterChainCustomizer targets the metadata chain. Typical
uses: permit CORS so browser-based MCP clients can fetch the metadata
document, add security headers, or front the endpoint with a rate
limiter.
@Bean
McpMetadataFilterChainCustomizer metadataCors() {
return http -> http.cors(cors -> cors.configurationSource(request -> {
var config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://app.example.com"));
config.setAllowedMethods(List.of("GET", "HEAD"));
return config;
}));
}
Don't touch authorizeHttpRequests on this chain. RFC 9728 §3
requires the metadata document to be fetchable without authentication,
and mocapi freezes the policy at permitAll for that reason. This
customizer is for HTTP-layer concerns (CORS, headers, logging, rate
limiting) — not for auth policy.
Swapping the token strategy¶
McpTokenStrategy is the SPI that configures Spring's
oauth2ResourceServer DSL with a bearer-token format. Mocapi ships two
implementations, selected automatically by @ConditionalOnBean:
JwtMcpTokenStrategy— activates when Spring Boot wired aJwtDecoder(i.e.spring.security.oauth2.resourceserver.jwt.*is set).OpaqueTokenMcpTokenStrategy— activates when Spring Boot wired anOpaqueTokenIntrospector(i.e.spring.security.oauth2.resourceserver.opaquetoken.*is set). Internally wraps the introspector in an audience-checking delegate so the MCP-mandatedaudvalidation still runs.
To replace both with a custom strategy — for a hypothetical future
token format, or to plug in a mock for testing — register a @Primary
bean:
@Bean
@Primary
McpTokenStrategy customStrategy() {
return rs -> rs.jwt(jwt -> jwt.decoder(myCustomDecoder()));
}
The same instance is applied to both the metadata and MCP chains, so there's exactly one place to swap validation behavior.
Customizing the metadata document¶
The RFC 9728 metadata document served at
/.well-known/oauth-protected-resource is assembled by a list of
McpMetadataCustomizer beans. Mocapi ships five baseline customizers,
each responsible for one facet:
| Customizer | Field(s) it sets | Source |
|---|---|---|
ResourceMetadataCustomizer |
resource |
mocapi.oauth2.resource, or the single jwt.audiences entry when unset |
AuthorizationServersMetadataCustomizer |
authorization_servers |
mocapi.oauth2.authorization-servers, or jwt.issuer-uri fallback |
ScopesSupportedMetadataCustomizer |
scopes_supported |
mocapi.oauth2.scopes |
ResourceNameMetadataCustomizer |
resource_name |
Implementation.title(), falling back to Implementation.name() (i.e. mocapi.server-title / mocapi.server-name) |
ClaimsMetadataCustomizer |
resource_documentation, resource_policy_uri, resource_tos_uri |
the matching mocapi.oauth2.* properties; only emits a claim when the property is set |
Adding a custom claim¶
Register a @Bean McpMetadataCustomizer. Default @Order runs it after
the five baseline customizers, so you see (and can overwrite) whatever
they set.
@Bean
McpMetadataCustomizer tlsBoundTokenAdvertisement() {
return builder -> builder.tlsClientCertificateBoundAccessTokens(true);
}
Overriding a baseline facet¶
Two approaches, pick whichever fits:
- Register a later-
@OrderMcpMetadataCustomizerthat mutates the field you want to change. Mocapi's baselines use@Order(HIGHEST_PRECEDENCE), so any customizer with a later order (including the default) runs after them and wins:
@Bean
@Order(0)
McpMetadataCustomizer overrideResourceName() {
return builder -> builder.resourceName("My Custom Name");
}
- Register a
@Primaryreplacement for the specific baseline bean type (e.g.@Primary ResourceNameMetadataCustomizer). The autoconfig uses@ConditionalOnMissingBeanon each baseline, so your bean replaces it entirely and mocapi's default never registers:
@Bean
@Primary
ResourceNameMetadataCustomizer myResourceName() {
return new ResourceNameMetadataCustomizer(/* ... */) {
@Override
public void customize(OAuth2ProtectedResourceMetadata.Builder builder) {
builder.resourceName("My Custom Name");
}
};
}
Use this when you want to take over a facet completely, including its property-reading constructor logic.
Opaque tokens¶
Some IdPs issue opaque (non-JWT) access tokens and validate them via RFC 7662 introspection. Mocapi auto-detects the mode from which Spring Boot resource-server properties are configured: set spring.security.oauth2.resourceserver.opaquetoken.* instead of jwt.*.
spring:
security:
oauth2:
resourceserver:
opaquetoken:
introspection-uri: https://idp.example.com/introspect
client-id: mcp-client
client-secret: ${INTROSPECTION_SECRET}
jwt:
audiences:
- mcp.example.com # still required — enforced via introspection response
The jwt.audiences property is reused in opaque mode — mocapi wraps Spring's OpaqueTokenIntrospector to enforce the aud claim on the introspection response, since Spring's opaque path does not include an audience validator out of the box. The MCP spec still requires audience checking, so this wrapper is not optional.
JWT and opaque modes are mutually exclusive; configure one or the other. If both are somehow configured, JWT wins (matching Spring Boot's own precedence).
Per-handler authorization¶
mocapi-oauth2 validates tokens on the way in. Gating individual handlers
on the resulting Authentication is a second concern, covered by the
mocapi-spring-security-guards module.
Which layer do I want? The two are complementary, and they behave differently on denial:
mocapi.oauth2.required-scopes |
@RequiresScope on a handler |
|
|---|---|---|
| Granularity | The whole MCP endpoint | One tool / prompt / resource |
| Enforced by | Spring Security filter chain, before dispatch | Guard SPI, inside dispatch |
| Denial shape | 403 + insufficient_scope challenge |
Handler hidden from */list; -32010 Forbidden on call |
| Tells the client what to ask for | Yes — that's the point | No, deliberately |
| Works on stdio | No (HTTP-only) | Yes |
Use required-scopes for "you need scope X to use this server at all," where
naming the missing scope is helpful and safe. Use @RequiresScope for
per-tool rules, where mocapi's visibility ≡ invocation model means an
unentitled caller never sees the tool — so there's nothing to step up to,
and naming the scope would leak the existence of a hidden handler. The filter
chain also physically cannot do per-tool rules: every tool arrives at the same
/mcp URL, and the filter runs before mocapi knows which one is being called.
See ADR-0029.
Add the guards module alongside mocapi-oauth2:
<dependency>
<groupId>com.callibrity.mocapi</groupId>
<artifactId>mocapi-spring-security-guards</artifactId>
<version>${mocapi.version}</version>
</dependency>
Then annotate handler methods:
@McpTool(name = "tenant_admin_op")
@RequiresScope("admin:write") // AND — all listed scopes required
@RequiresRole({"TENANT_ADMIN", "OPS"}) // OR — any listed role grants access
public void tenantAdminOp(...) { ... }
@RequiresScope values match granted authorities with the SCOPE_ prefix
Spring Security's JWT / opaque-token converters produce (so admin:write
matches SCOPE_admin:write). @RequiresRole accepts bare (ADMIN) or
prefixed (ROLE_ADMIN) values; both normalize to the same granted
authority. Both annotations may coexist on the same method — the Guard
SPI's AND evaluation means every attached guard must allow.
Denied calls do not reach the handler: tools/list (and the matching
prompts/list, resources/list, resources/templates/list) hides the
handler entirely, and tools/call returns JSON-RPC -32010 with
Forbidden: <reason> where the reason comes from the first denying
guard (unauthenticated, missing scope(s): ..., or
insufficient role). Deny reasons are only returned at call time —
list time simply omits the handler so the decision doesn't leak.
Unlike Spring Security's @PreAuthorize, guards gate list operations as
well as call operations — clients doing tools/list never see handlers
they aren't entitled to invoke. And guard denials surface as the
protocol-right -32010 Forbidden shape (ADR-0023) instead of the generic
-32603 Internal error an AOP-thrown AccessDeniedException would
produce. See docs/guards.md for the underlying Guard SPI,
and the mocapi-spring-security-guards module for the annotation
sources if you want to write your own Guard implementation.
Stdio transport¶
OAuth2 is transport-bearer-token-specific and applies only to the Streamable HTTP transport. Stdio (subprocess-launched) MCP servers authenticate the subprocess via its launch context; there are no bearer tokens to validate. mocapi-oauth2 depends on mocapi-streamable-http-spring-boot-starter and is not compatible with stdio-only deployments.
What's not supported¶
Deliberately declined, with rationale (reopen at the linked ADR if your threat model needs one):
- DPoP (RFC 9449) and mTLS token binding — see ADR-0022.
- Signed metadata (
application/resource-metadata+jwt) — Spring serves the RFC 9728 document as JSON only. ADR-0022. - Per-tool
insufficient_scopestep-up — declined because it conflicts with mocapi'svisibility ≡ invocationmodel (a scope-gated tool is hidden, not challenged); ADR-0029. Resource-levelinsufficient_scopeis supported — see Requiring a scope above.
Current limitation, with a workaround:
- Multiple issuer-uris (federation).
mocapi.oauth2.authorization-serversaccepts a list and advertises them all in the metadata document, but Spring Boot's auto-wiredJwtDecodervalidates against a single issuer. To accept tokens from more than one issuer today, register your ownJwtDecoderbean (e.g. aJwtIssuerAuthenticationManagerResolver-backed decoder) — mocapi wires whateverJwtDecoderis present onto both filter chains.
The forward view for these lives in the roadmap; nothing here is a dated commitment.