Back to Blog

MCP server OAuth2 in Spring: the DCR proxy pattern

A Spring Authorization Server that adds Dynamic Client Registration in front of an IdP that can't, keeping a Spring AI MCP server connectable to AI clients

Lukas GrigisSoftware Architect22 min read
0:00 / 0:00
oauth2mcpai-agentsspring-bootspring-aikeycloaksecurityjava
A Spring Authorization Server stands in front of an enterprise IdP, adding DCR so a Spring AI MCP server stays connectable to autonomous clients
On this page

Most MCP auth tutorials assume your IdP can register clients. Does yours?

The MCP authorization spec turns your server into an OAuth2 resource server and assumes the client self-registers with no human in the loop. That second part is where many enterprise identity providers stop. Microsoft Entra ID has no Dynamic Client Registration endpoint at all, and Spring's own Authorization Server only does a gated OIDC variant, not the open registration the spec leaned on. DCR is the part many enterprise IdPs don't offer.

The cleaner model puts a small Spring Authorization Server in front of the real IdP. The broker exposes DCR to the MCP client, federates the human login upstream to the IdP that can't register clients, then mints its own scoped tokens. The tokens your MCP server trusts come from the broker, not the upstream IdP.

This post builds that broker. The companion repo is spring-oauth2-mcp, and the whole thing runs with one command. Here is the one line to hold onto, because everything else is in service of it:

Federated identity in, fine-grained authorization out, and the scopes never come from what the client asks for.

One caveat before the code, because the spec moved while this pattern was being written. The 2025-11-25 revision downgraded DCR from SHOULD to MAY, calling it "included for backwards compatibility with earlier versions of the MCP authorization spec," and put Client ID Metadata Documents (CIMD) above it as the SHOULD-level mechanism for parties with no prior relationship. So the broker isn't a DCR purist. It's the bridge for the messy middle: most enterprise IdPs do neither open DCR nor CIMD, and most shipped clients still try DCR first. First, though, the one mechanism the whole thing turns on.

Dynamic Client Registration in 60 seconds, and why the spec just moved the goalposts

DCR (RFC 7591, July 2015) is one unauthenticated request. A client POSTs its metadata to the registration endpoint, gets back a client_id, runs auth-code plus PKCE, and calls tools with the resulting bearer token. No operator provisions anything by hand. In this repo's headless test, that first step is exactly this:

typescript|test/e2e.ts
const res = await fetch(`${AS}/oauth2/register`, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({
    client_name: `e2e-${Date.now()}`,
    redirect_uris: [REDIRECT],
    grant_types: ["authorization_code", "refresh_token"],
    response_types: ["code"],
    token_endpoint_auth_method: "none", // public client + PKCE, like the MCP Inspector
  }),
})

Three RFCs sit under the MCP flow. RFC 7591 is DCR. RFC 9728 (Protected Resource Metadata, April 2025, the newest of the three) is how a 401 tells the client where to find the authorization server. RFC 8707 (Resource Indicators, February 2020) is the resource parameter that audience-binds the token to the server the client meant to call.

Now the goalpost move. The 2025-06-18 spec made DCR a SHOULD. The 2025-11-25 revision (current as I write this in mid-2026) rewrote it: DCR is now a MAY, kept for backwards compatibility, and CIMD is the SHOULD-level mechanism listed above it. RFC 9728 stays a MUST. The RFC 8707 resource parameter stays a MUST. So the discovery and audience-binding parts didn't soften. Registration did.

Worth flagging, because it cuts against the pattern: CIMD rests on an IETF draft (draft-ietf-oauth-client-id-metadata-document, at -01 as of mid-2026), not yet an RFC. And in mid-2026, clients like Claude Code, Cursor, and the MCP Inspector still attempt DCR first. That's the design decision behind this broker. It's a DCR broker today, with a deliberate CIMD seam. It implements DCR because that's what shipped clients send, and the code is structured so CIMD can be slotted in later: advertise client_id_metadata_document_supported, then fetch and validate the client-hosted metadata document. None of that is wired yet. And CIMD isn't free when that day comes. The moment the broker fetches a client-supplied client_id URL, it inherits the spec's own warnings: an authorization server reaching an attacker-chosen URL is an SSRF vector against internal endpoints, and pinning trust to localhost redirect URIs invites impersonation. The seam would need SSRF egress controls and a redirect-URI trust policy before it became a code path.

What we're building: two services, a real IdP, and a one-command demo

Three moving parts, two of them Spring. The authorization server (:9000) is the broker. The mcp-server (:9200) is a Spring AI 2.0 MCP server exposing four toy tools: a whoami identity probe plus three operations over an in-memory notes store. Keycloak (:80 through Traefik) is the upstream IdP, here playing a locked-down enterprise IdP like Entra ID that has no open registration endpoint. Keycloak can actually do DCR itself; the demo doesn't lean on that, because the point is for the broker to own registration.

Two users carry the whole demo. alice has the realm role user, which maps to note:read and note:write. bob has the realm role admin, which adds note:admin. The allow/deny matrix across those two is the proof.

The four tools are the scope surface, one tool per scope on purpose:

ToolRequired scopeWho can call it
whoamiany authenticated calleralice, bob
list_notesnote:readalice, bob
create_notenote:writealice, bob
purge_notesnote:admin (destructive)bob only

One command runs it:

bash|run the whole demo
mise run demo

That builds both modules, starts Traefik, Postgres, and Keycloak, provisions the demo realm, starts both services, runs the discovery checks, then performs the allowed and denied tool calls for alice and bob live.

A word on versions, because pre-1.0 honesty matters here. The stack is Spring Boot 4.1.0, Spring AI 2.0.0, Java 25, BouncyCastle 1.84, Keycloak 26.6, Traefik 3.7, Postgres 18. The MCP security artifacts from org.springaicommunity are 0.1.13, which is pre-1.0. Spring Security 7.1.0, the MCP SDK 2.0.0, and Spring Authorization Server come in transitively managed by Boot 4.1.0, not pinned in the pom. This is a reference to learn from, not something I'd point at production, and there's a security notice in the repo that says so.

The broker: a Spring Authorization Server doing three jobs at once

The broker is three things at once, and naming all three up front makes the rest read cleanly. It's an OAuth2 authorization server (it issues tokens to MCP clients). It's an OAuth2 client (it federates the human login to Keycloak). And it's a DCR plus scope layer (the part the broker owns instead of the upstream IdP).

The load-bearing line is one attribute. The authorization-server chain enables .dynamicClientRegistration(true) through the mcpAuthorizationServer() configurer. That one attribute turns the register endpoint on:

java|authorization-server/.../configuration/WebSecurityConfiguration.java
.with(
    mcpAuthorizationServer(), mcp -> mcp
        .dynamicClientRegistration(true) 
        .authorizationServer(authzServer -> {
            // Advertise ES256 in the provider metadata (Spring defaults to RS256).
            authzServer.oidc(oidc -> oidc.providerConfigurationEndpoint(endpoint ->
                endpoint.providerConfigurationCustomizer(cfg ->
                    cfg.idTokenSigningAlgorithms(algs -> {
                        algs.clear();
                        algs.add(SignatureAlgorithm.ES256.getName());
                    }))));
            http.securityMatcher(authzServer.getEndpointsMatcher());
        })
)

There are three security chains: actuator, authorization-server, and login. The login chain is the federation seam. It runs oauth2Login against the upstream registration id, which is where the human actually authenticates against Keycloak:

java|authorization-server/.../configuration/WebSecurityConfiguration.java
.oauth2Login(login -> login.loginPage(upstream.authorizationRequestUri()))

Who authenticates is a config choice, not a rewrite. The upstream registration id lives in app.upstream.registration-id, bound by a tiny UpstreamProperties record, so the upstream IdP stays a property rather than a hardcoded "keycloak". The broker stays the DCR plus scope layer either way. That's the whole DCR-proxy thesis in one sentence: the upstream IdP doesn't expose open DCR to your MCP clients, so the Spring AS sits in front, adds DCR, and delegates the human authentication upstream.

The registered DCR clients, their authorizations, and consents persist to Postgres through JDBC stores (Liquibase manages the tables). I'll come back to watching DCR rows pile up in the proof section. The federation gets you a logged-in human. Now the interesting part: turning that human into scopes.

Roles in, scopes out, and the scope claim never comes from the client

Start with the threat. If you let the client's requested scopes flow onto the minted token, a roleless caller mints note:read just by asking for it. The fix is to derive scopes from the caller's upstream roles and ignore what the client requested.

RoleScopeMapper.scopesFor() is the contract. It expands realm roles into a fixed scope set: the user role grants note:read and note:write, the admin role adds note:admin. The scopes are an API contract, declared static final. Only the role names are configurable, bound from app.security.roles.*:

java|authorization-server/.../security/RoleScopeMapper.java
public static final Set<String> USER_SCOPES = Set.of("note:read", "note:write");
public static final Set<String> ADMIN_SCOPES = Set.of("note:admin");

public Set<String> scopesFor(Set<String> roles) {
    Set<String> scopes = new TreeSet<>();
    if (roles.contains(userRole) || roles.contains(adminRole)) {
        scopes.addAll(USER_SCOPES); 
    }
    if (roles.contains(adminRole)) {
        scopes.addAll(ADMIN_SCOPES);
    }
    return scopes;
}

The fail-closed line is the one that matters. The token customizer stamps an empty scope claim when no mappable role is present, with the comment explaining exactly why:

java|authorization-server/.../configuration/ScopeMappingConfiguration.java
final Set<String> granted = roleScopeMapper.scopesFor(roles);
if (granted.isEmpty()) {
    // FAIL CLOSED: if we left the claim alone, Spring Authorization Server would stamp the
    // client's REQUESTED scopes: a roleless principal could mint a note:read token just by
    // asking (DCR registers requested scopes verbatim). No mappable roles ⇒ no scopes.
    context.getClaims().claim(OAuth2ParameterNames.SCOPE, Set.of()); 
    return;
}
// Scope derives from the upstream roles, never the client request. Stamp it directly.
context.getClaims().claim(OAuth2ParameterNames.SCOPE, granted);

Below the headline, the other branch is just as deliberate. When roles do map, the role-derived set is stamped onto the scope claim directly, and the customizer never reads what the client requested. Roles are the only input, so the role set is the hard ceiling, and no requested scope can widen it.

Now the failure I want you to see before the fix, because it's the kind that denies everything silently. Keycloak's default realm-roles mapper writes the nested realm_access.roles to the access token. But the broker reads the ID token, because it's acting as an OIDC client. Without a custom realm-roles to flat-roles ID-token mapper, the broker sees no roles, maps an empty scope set, and (failing closed) denies every tool for both users. The Keycloak protocol mapper is the contract between the IdP and ScopeMappingConfiguration:

typescript|support/keycloak/setup-keycloak.ts
// Keycloak's default mapper only writes the nested realm_access.roles to the ACCESS token, but the
// authorization server is an OIDC client reading the ID token, so without this it sees no roles and
// (failing closed) denies every tool for both alice and bob.
config: {
  "claim.name": "roles",
  "id.token.claim": "true", 
  "access.token.claim": "true",
  // ...plus jsonType.label, multivalued, and the userinfo/introspection flags
}

The same family of token customizers carries the human identity onto the minted token. AccessTokenClaimsConfiguration.copyIdentity() copies preferred_username, email, and name off the upstream OidcUser, so whoami and a note's author show the real person, not a subject UUID:

java|authorization-server/.../configuration/AccessTokenClaimsConfiguration.java
claim(context, "preferred_username", user.getPreferredUsername()); 
claim(context, "email", user.getEmail());
claim(context, "name", user.getFullName());

That carries the user's identity the way the gateway in Token exchange with Spring Cloud Gateway carries it downstream, though this broker derives scopes from roles rather than running an RFC 8693 exchange. The claims are right. Now the token has to be signed in a way the resource server will accept, and that's where I lost an afternoon.

ES256 takes three coordinated pieces, or every token is silently rejected

Spring Authorization Server defaults to RS256. Switching to ES256 is not one switch. It's three pieces across two config classes, and dropping any one mints RS256 with no matching key, so every downstream token is rejected. That was the afternoon.

ES256 is the better greenfield default anyway. A P-256 signature is 64 bytes against RSA-2048's 256, at a security level matching RSA-3072, and it's what WebAuthn, COSE, and newer identity standards already lean on. RS256 isn't broken, it's just heavier.

Here are the three pieces. Piece one is the EC P-256 key in the JWKS (a committed dev PEM parsed via BouncyCastle, or an ephemeral key if you don't supply one). Piece two forces the per-token JWS header to ES256, because JwtGenerator defaults to RS256. Both live in JwksConfiguration, and the class javadoc names all three:

java|authorization-server/.../configuration/JwksConfiguration.java
/** Piece 1: a single EC P-256 key in the JWKS (served at /oauth2/jwks for the resource server). */
@Bean
JWKSource<SecurityContext> jwkSource(JwksProperties properties) throws Exception {
    final var pem = properties.signingKeyPem();
    ECKey ecKey = pem.isBlank() ? generateEphemeralKey() : loadFromPem(pem);
    return new ImmutableJWKSet<>(new JWKSet(ecKey));
}

/** Piece 2: force the JWS header to ES256. Without it JwtGenerator defaults to RS256 (no key). */
@Bean
OAuth2TokenCustomizer<JwtEncodingContext> es256JwsHeaderCustomizer() {
    return context -> context.getJwsHeader().algorithm(SignatureAlgorithm.ES256); 
}

Piece three is advertising ES256 in the OIDC provider metadata, by clearing the default idTokenSigningAlgorithms and setting ES256. That one lives in WebSecurityConfiguration (you saw it in the DCR snippet above). Two files, one outcome. That split is exactly why it's easy to get two of three right and ship a server that mints tokens nobody accepts.

The symptom is the nasty part. There's no error at mint time. The token fails validation at the resource server, silently, with a signature or algorithm mismatch. The bug isn't where the failure shows up. If you've ever needed a local-JWKS-validatable JWT for another resource server, this is the same plumbing I walked through for the broker in OAuth2-secured RabbitMQ over AMQP 0.9.1, where the broker pulls the issuer's JWKS and checks the signature itself.

A correctly signed, correctly scoped token is still only half the contract. The resource server has to refuse the ones that aren't for it.

The MCP server: a clean resource server that gates every tool

The mcp-server stays simple on purpose. It's a Spring AI 2.0.0 MCP server over Streamable HTTP on WebMVC at /mcp, secured as an OAuth2 resource server through org.springaicommunity's mcp-server-security.

Discovery is a handshake. A 401 carries a WWW-Authenticate header pointing at /.well-known/oauth-protected-resource/mcp (RFC 9728), which points the client at the broker. From there the client runs DCR and the auth-code flow.

Token validation runs all three checks the architecture promises: signature via JWKS, issuer, and audience. All three are genuinely wired, so I'll say so plainly. mcpServerOAuth2() resolves the issuer's JWKS eagerly at boot through OIDC discovery, checks the issuer, and validates the audience. The audience check is config-driven and on in this demo:

java|mcp-server/.../configuration/McpServerSecurityConfiguration.java
.with(
    mcpServerOAuth2(),
    mcp -> mcp.authorizationServer(properties.issuerUri())
        .validateAudienceClaim(properties.validateAudience()) 
)

That audience is the RFC 8707 link. The client sends a resource indicator on /authorize, the broker stamps it into aud, and the resource server rejects any token whose aud is for something else. That closes the confused-deputy hole on the resource side. The MCP spec makes this audience check a MUST, so treat validateAudienceClaim as on-always: the flag exists only to accept clients that omit resource, and turning it off reopens the hole. Two caveats even with it on. The demo stamps whatever resource the client asks for, so a production broker should require it and reject an unrecognized one with invalid_target. And bindAudience only sets aud when the client sends a resource, so a missing one falls back to Spring's default of the client id. The other half of confused-deputy, per-client consent, is the first item in what I'd change before shipping.

Now the worst kind of bug, the silent no-op. @EnableMethodSecurity is load-bearing. Without it, every @PreAuthorize gate silently does nothing and any authenticated caller can call every tool. It fails open, and it fails without a sound:

java|mcp-server/.../configuration/McpServerSecurityConfiguration.java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@EnableConfigurationProperties(McpServerSecurityProperties.class)
class McpServerSecurityConfiguration {

With method security on, the gates do their job. Each @McpTool carries a @PreAuthorize for the scope it needs; whoami only needs isAuthenticated():

java|mcp-server/.../tool/NoteTool.java
@McpTool(name = "list_notes", description = "List all notes. Requires the note:read scope.")
@PreAuthorize("hasAuthority('SCOPE_note:read')") 
public List<Note> listNotes() {
    return List.copyOf(notes);
}

@McpTool(name = "purge_notes", description = "Delete ALL notes. Admin-only, requires the note:admin scope.")
@PreAuthorize("hasAuthority('SCOPE_note:admin')")
public String purgeNotes() { /* ... */ }

When a scope is missing, the AccessDeniedException comes back as an MCP tool error (isError=true, HTTP 200). A missing or invalid token is rejected earlier with a 401. Beyond that the surface stays minimal: stateless sessions, so there's no cookie and no CSRF to defend, plus a Jackson Throwable mix-in that strips stackTrace and cause so a transport error never leaks the server's stack on the wire.

One MCP-specific gotcha worth a sentence: keep tool bodies synchronous. No @Async, no executor, no streaming, or the SecurityContext (and with it @PreAuthorize) is lost.

So far I've claimed two things: the gates work, and identity propagates. The only way to trust either is to force them to fail.

Prove it: the failure I forced, and the identity I asserted

The production happy path avoids these failures, so the only way to prove the avoidance is real is to force them, on two layers: offline and live.

Offline, with no web server or JWKS in play, method-security slice tests (DebugToolSecurityTest, NoteToolSecurityTest) would fail under the silent @EnableMethodSecurity bypass, and RoleScopeMapperTest proves an unknown role maps to an empty set. Run them with:

bash|offline method-security + mapping tests
mise run test

Live and headless, test/e2e.ts drives the whole chain per user with nothing but Node's fetch, no browser: DCR, then auth-code plus PKCE with a resource indicator, then the federated Keycloak login and consent, then an ES256 token, then Streamable-HTTP tool calls. Run it with:

bash|the full flow, headless
mise run test:e2e

The output shape is the proof. Per user, it prints the DCR-issued client_id, the role-derived token scopes, the allow/deny matrix, the propagated identity, and a PASS line:

text|mise run test:e2e (per user)
  DCR → authorization server issued client_id: ...

=== alice (user) ===
  token scopes: [note:read, note:write]
  ✓ ALLOW whoami
  ✓ ALLOW list_notes
  ✓ ALLOW create_note
  ✗ DENY  purge_notes  (Access Denied)
  identity: username=alice email=alice@example.com note.author=alice
  PASS: alice (user)
...
ALL E2E CHECKS PASSED ✅

Here's the part most demos skip: the e2e doesn't just assert allow and deny, it asserts that the human identity propagated end to end. whoami.username, whoami.email (alice@example.com, bob@example.com), and create_note.author must equal the real user, not the subject UUID. It's the regression guard for identity-claim propagation:

typescript|test/e2e.ts
const idChecks: Array<[string, unknown, unknown]> = [
  ["whoami.username", who?.username, user],
  ["whoami.email", who?.email, `${user}@example.com`], 
  ["create_note.author", note?.author, user],
]

And you can watch DCR happen for real. Every registration is a row in Postgres:

sql|watch DCR rows accumulate
SELECT client_id, client_name, scopes FROM oauth2_registered_client;

Run the e2e a few times and the rows pile up. Which is a fine segue, because those accumulating rows are also the first thing I'd change before shipping this.

Swapping the IdP: config for the names, code for the claim shape

The design exists so the upstream IdP is a swap, not a rewrite. Let me be precise about which is which.

There are two swap seams. First, the role names are config (app.security.roles.*). Second, the scopes are a fixed static final API contract, so those don't move. But there's a third thing that isn't config: if a new IdP delivers its roles under a different claim shape than the flat roles claim this demo reads, the claim reader in ScopeMappingConfiguration needs a small code change. Config for the role names, a code change for the claim shape.

Now the blast radius. For Keycloak, swapping is a property change. For Entra ID it's the property change, plus a claim-reader tweak (its roles and groups have their own shape), plus accepting that Entra has no DCR endpoint at all. That last one is the whole reason the proxy exists, and it bites harder there, not less.

The honest spec verdict is the cleanest argument for the pattern. Even Spring's own Authorization Server doesn't give MCP the anonymous DCR the pre-November-2025 spec assumed. It implements OIDC DCR (not full open RFC 7591), disabled by default, at /connect/register, requiring a single-use initial access token that carries the client.create scope. That friction is exactly what an autonomous MCP client can't satisfy. So the broker pattern stands whether your upstream is Entra, Okta, or a locked-down Spring AS.

This is a reference to learn from, not something you'd deploy. Here's the gap between the two.

What I'd change before shipping this

This is a demo optimized for clarity. Here's where it breaks, in rough order of how much it would keep me up at night.

Per-client consent is the gap that should bother you most, and the MCP 2025-11-25 spec makes it a MUST. The broker federates every login through a single static upstream client (named keycloak in the demo) to Keycloak, so the consent screen is bound to that one client, not to the MCP client that registered through DCR. Once a user has a live Keycloak session and has consented to it, an attacker-registered DCR client can ride that still-valid upstream consent with no fresh prompt. That's the confused-deputy problem the spec names: a proxy with a static upstream client id MUST get the user's consent for each dynamically registered client before forwarding it upstream. PKCE and redirect-URI matching don't help, because the flow is mechanically valid; the missing step is a per-client yes. The fix is to re-enable the broker's own consent page for DCR clients, so each registered client earns its own explicit grant. One gotcha if you do: the broker serves only JSON and redirects today, so its content-security policy is a strict default-src 'self' with no inline allowance. A consent page is HTML, so turning it back on means relaxing that policy for inline script and style, or shipping a consent template that uses neither.

Open DCR is the next risk. Anyone can POST to the register endpoint unauthenticated and stand up a client with arbitrary self-asserted metadata, including a redirect_uri they control (RFC 7591 §5). PKCE blocks code interception and exact redirect-URI matching blocks open-redirect abuse of an honest client, but neither touches what open DCR actually adds: an unauthenticated party registering a client at all. A public deployment needs an RFC 7591 initial-access-token (or gateway auth) gate on the register endpoint, a host allowlist for the registered redirect_uri, rate-limiting, and stale-client pruning. The rows piling up in Postgres are the visible symptom, not the real risk.

Committed secrets. The ES256 PEM and the keycloak-dev-secret client secret are plaintext in application.yml and the Keycloak config. Use a real key and a secrets manager.

Keycloak runs start-dev with SSL disabled and the master realm's sslRequired relaxed over HTTP. The upstream keycloak client uses fullScopeAllowed=true, so every realm role flows in (scope it down). consentRequired=true is on so the user sees Keycloak's consent screen, and the test passwords are trivial.

The refresh-token policy is stamped on every client, including DCR ones. OAuth2StoreConfiguration adds REFRESH_TOKEN only for the authorization_code grant, not client_credentials (RFC 6749 §4.4). These are public, secretless clients (token_endpoint_auth_method: none), and RFC 9700 §2.2.2 requires their refresh tokens to be sender-constrained or rotated, so reuseRefreshTokens(false) is the floor, not a nicety. Access lives 30 minutes, refresh 7 days:

java|authorization-server/.../configuration/OAuth2StoreConfiguration.java
// Demo constants (the broker no longer binds these from yaml). Externalize to tune per environment.
private static final Duration ACCESS_TOKEN_TTL = Duration.ofMinutes(30);
private static final Duration REFRESH_TOKEN_TTL = Duration.ofDays(7);

final var tokenSettings = TokenSettings.builder()
    .accessTokenTimeToLive(ACCESS_TOKEN_TTL)
    .refreshTokenTimeToLive(REFRESH_TOKEN_TTL)
    .reuseRefreshTokens(false) 
    .build();
// REFRESH_TOKEN only for authorization_code, not for client_credentials (RFC 6749 §4.4).

And there's the plumbing war story, the same one that cost me a separate afternoon on the RabbitMQ post. With no HTTP client library on the classpath, Spring's RestClient falls back to the JDK HttpClient, which attempts an h2c (cleartext HTTP/2) upgrade on its first request. Against plain-HTTP Keycloak-behind-Traefik, that upgrade hangs, and the whole federated token exchange stalls with no error to read. Adding Apache HttpComponents 5 forces HTTP/1.1 and fixes it:

xml|authorization-server/pom.xml
<dependency>
  <groupId>org.apache.httpcomponents.client5</groupId>
  <artifactId>httpclient5</artifactId>
</dependency>

One dependency, no code. I spent more time on this than I want to admit, and the symptom points nowhere near the cause.

The broker is the bridge for the messy middle

The MCP spec wants self-registering clients. Your enterprise IdP won't oblige. A small Spring Authorization Server in front of it closes the gap: federated identity in, role-derived scopes out, ES256 tokens audience-bound to the resource that asked. The scopes never come from what the client requested, which is the one property that keeps an autonomous client from granting itself anything.

Where the spec stands today, DCR is the backwards-compat MAY path, CIMD is the SHOULD-level mechanism for parties with no prior relationship, and most enterprise IdPs do neither. The broker is the pragmatic bridge until the installed base catches up: DCR today, with the CIMD seam left open rather than a DCR-only dead end. The spec will move again. The broker is the seam that lets you move with it.

This post is the fourth surface in the Spring Boot and OAuth2 guide, after the HTTP edge in Token exchange with Spring Cloud Gateway and the messaging path in RabbitMQ over AMQP 0.9.1 and its AMQP 1.0 follow-up. The full source is spring-oauth2-mcp. Clone it, run mise run demo, and open an issue if something is unclear or broken.

Standards and sources

Every spec claim above links to its primary source, collected here. Plain-English notes on each, and how they map to the code, live in the repo's docs/STANDARDS.md.

Frequently asked questions

What is Dynamic Client Registration (DCR) in the MCP authorization spec?

DCR (RFC 7591) lets an OAuth2 client register itself with no human in the loop: it POSTs its metadata to a registration endpoint and gets back a client_id, then runs the normal auth-code plus PKCE flow. The MCP authorization spec leaned on it so an AI client could connect to a new server it had never seen before, without an operator pre-provisioning credentials. The catch is that most enterprise identity providers don't expose an open registration endpoint.

Why can't enterprise IdPs like Entra ID register MCP clients?

Microsoft Entra ID has no open DCR endpoint at all, and even Spring's own Authorization Server only implements OIDC DCR gated behind a single-use client.create initial access token, not the open anonymous registration RFC 7591 describes. An autonomous MCP client can't satisfy that friction, so a small broker that exposes open registration in front of the locked-down IdP is the bridge.

Did the MCP spec drop DCR?

The 2025-11-25 revision downgraded DCR from SHOULD to MAY, describing it as included for backwards compatibility with earlier versions of the spec, and listed Client ID Metadata Documents (CIMD) above it as the SHOULD-level mechanism for parties with no prior relationship. DCR is not gone: a large installed base of shipped clients still attempts it first in mid-2026, and CIMD still rests on a draft, not an RFC. A broker that runs DCR today and keeps a seam open for CIMD is the pragmatic move.

How do you mint ES256 tokens in Spring Authorization Server?

It takes three coordinated pieces, because Spring Authorization Server defaults to RS256: an EC P-256 key in the JWKS, a token customizer that forces the JWS header to ES256 (JwtGenerator defaults to RS256), and ES256 advertised in the OIDC provider metadata. Miss any one and the server mints RS256 with no matching key, and every downstream token is silently rejected at the resource server.

How does the broker stop a client from granting itself scopes?

The scope claim never comes from what the client requested. A token customizer expands the caller's upstream realm roles into a fixed scope set and, when no mappable role is present, stamps an empty scope claim instead of leaving the requested scopes in place. Without that fail-closed branch, Spring Authorization Server stamps the client's requested scopes, so a roleless caller could mint note:read just by asking.

Resources

Share this article

Enjoyed this article?

Stay in the Loop

Get notified when I publish new articles. No spam, unsubscribe anytime.

More Posts