refactor(core): make boot and middleware ordering deterministic
CI / Build & Test (push) Failing after 4m51s
CI / Build & Test (pull_request) Canceled after 23s

This commit is contained in:
Zakaria El Orche
2026-08-12 16:42:49 +00:00
parent d7f36a7aea
commit 891ef99b8e
51 changed files with 1395 additions and 504 deletions
@@ -53,4 +53,6 @@ public class GetWeatherTool extends McpTool {
- [`tools-resources-prompts.md`](tools-resources-prompts.md) — defining tools, resources, prompts
- [`transport.md`](transport.md) — Streamable HTTP scope, session/SSE limitations, Origin validation
- [`security.md`](security.md) — `McpSecurity` policy, OAuth2 resolution, RFC 9728 / RFC 8707
- [`keycloak.md`](keycloak.md) — Keycloak-specific setup cookbook: Dynamic Client Registration,
the RFC 8707 audience mapper gotcha, and how to verify/debug it
- [`jackson-interop.md`](jackson-interop.md) — why this extension does not depend on `flash-ext-jackson`
@@ -0,0 +1,107 @@
# Keycloak cookbook
`security.md` covers the OAuth2 mechanics `McpOidcIntegration` implements against any
`flash-ext-oidc`-compatible provider. This is the Keycloak-specific setup: the exact Admin
Console configuration for a working MCP OAuth2 flow with open Dynamic Client Registration
(DCR) — no pre-registered clients, any MCP client self-registers on first connect.
## 1. Allow Dynamic Client Registration
MCP clients (Claude Desktop, Claude.ai, MCP Inspector, others) don't share one static OAuth
client — each has its own `redirect_uri` and none know your realm in advance. They self-register
on first connect via `POST {issuer}/clients-registrations/openid-connect` (the
`registration_endpoint` from the AS metadata document, reached via the RFC 9728 Protected
Resource Metadata document `McpExtension` publishes).
**Clients → Client registration**: remove the **Trusted Hosts** policy — it rejects anonymous
registration from hosts not on an explicit allowlist (`403` / `"Host not trusted"`), which
doesn't scale to arbitrary future agents. This does not weaken end-user authentication — DCR
only grants an app a `client_id`; every user still authenticates against Keycloak's real login
screen regardless of which client asked. Lighter hygiene policies (**Max Clients Limit**,
**Consent Required**) can stay, they don't interfere.
## 2. RFC 8707 audience: mapper on `basic`, not a custom scope
`McpOidcIntegration` rejects (403) any token whose `aud` doesn't include the MCP endpoint's
canonical URL. Keycloak doesn't add this by default. The obvious fix — a custom client scope
with an Audience mapper, marked Default, added to Allowed Client Scopes — **does not work**:
clients created via the `openid-connect` DCR endpoint only ever get scopes they explicitly
request, and most MCP clients (including MCP Inspector) don't request anything beyond what a
server tells them to via `scopes_supported` (step 3). Default-scope auto-attachment, which is
how a normal manually-created client would pick up a custom Default scope, doesn't apply to
DCR-created clients at all.
`basic` is the one built-in scope Keycloak attaches to every client unconditionally, regardless
of what it registered with. Put the audience mapper there:
1. **Client scopes → `basic`****Mappers****Add mapper****By configuration**
**Audience**.
2. **Included Custom Audience** = the exact value your server expects — check
`GET {parent-of-rootPath}/.well-known/oauth-protected-resource{rootPath}` on the running
server for the `resource` field it publishes (auto-derived from the request's
forwarded/`Host` headers — see `security.md`). Leave **Included Client Audience** empty (that
targets another Keycloak client, not a resource URL).
3. **Add to access token** = ON.
4. **Save.**
This is unconditional and works regardless of client cooperation — keep it even after step 3
below gets other claims flowing normally, since audience binding is a hard spec requirement
that shouldn't depend on a client bothering to request the right scope.
## 3. Other claims (username, email...): `scopes_supported` + Allowed Client Scopes
`OidcUser.username()`/`.email()`/`.name()` read `preferred_username`/`email`/`name` — normally
from the `profile`/`email` client scopes, which DCR clients don't get either, same root cause.
Unlike audience, this **is** fixable the "normal" way, because it doesn't need to survive a
completely uncooperative client:
`McpConfig.scopesSupported("openid", "profile", "email")` publishes those scopes in the PRM
document. MCP clients that read it (confirmed for MCP Inspector) echo them back in their DCR
registration request — `"scope": "openid profile email offline_access"` (`offline_access` is
Inspector's own addition, for refresh tokens). For that request to actually succeed, **Allowed
Client Scopes** needs, exactly:
- **`openid` listed explicitly.** The one genuinely non-obvious step: `openid` is not covered by
**Allow Default Scopes** (On by default) the way other realm-Default scopes are, even though
every OIDC request includes it. Until it's listed here, registration fails with a generic
`403 insufficient_scope` / `"Not permitted to use specified clientScope"` regardless of
whether everything else is configured correctly.
- **`offline_access` listed explicitly** — it's Optional, not Default, so `ALLOW_DEFAULT_SCOPES`
doesn't cover it either.
- **`profile`/`email` — do not list them here.** Mark them **Default** on the **Client scopes**
page (Assigned Type column) instead, and leave **Allow Default Scopes** = On. Adding an
already-Default scope to this list explicitly gets rejected on save
(`"Client scopes not allowed: [...]"`) — the list is for *additional* Optional scopes only.
With that, a real client's token comes back with `preferred_username`/`email` populated
normally.
### Fallback for anything else
For a claim not covered by `openid profile email` (a custom attribute, a role) — or for a client
that ignores `scopes_supported` entirely — add a **User Property** mapper to `basic` too
(Property `username` → Token Claim Name `preferred_username`, or whatever's needed), same as the
audience mapper in step 2. Unconditional, works regardless of client cooperation, costs one
mapper per claim, once, at the realm level — not per tool.
## Verifying without a full OAuth round-trip
**Clients → (any client) → Client scopes → Evaluate**: pick a user, run it — Default scopes
(including `basic`) apply automatically and won't appear in the "Select scope parameters"
picker, which only lists Optional ones — and check the **Generated Access Token** preview.
Confirms mappers work without a browser + real MCP client round-trip each time.
## If a real client still gets rejected
`McpOidcIntegration.audienceGuard` logs the actual mismatch at `WARN`:
```
[flash-ext-mcp] Rejecting token (RFC 8707): aud=<token's actual aud> does not include expected
resource identifier "<what this server expects>" — ...
```
`aud=null` → the `basic` mapper produced nothing (most common cause: **Included Custom
Audience** left blank — the mapper saves fine and silently does nothing without it). A non-null
`aud` that still doesn't match → compare byte-for-byte — the expected side is derived from the
request's own forwarded/`Host` headers, so scheme/host/trailing-slash mismatches show up here
directly, as does a proxy hop that drops `X-Forwarded-Host`.
+108 -27
View File
@@ -1,5 +1,10 @@
# Security
Provider-specific setup steps (not generic OAuth2 mechanics) live in separate cookbooks —
[`keycloak.md`](keycloak.md) for Keycloak: enabling Dynamic Client Registration, why the RFC 8707
audience mapper needs to go on the built-in `basic` scope instead of a custom one, and the exact
Allowed Client Scopes configuration `scopes_supported` needs to actually work.
## `McpSecurity`
`McpConfig.security(...)` controls how the MCP endpoint reacts to `flash-ext-oidc` being
@@ -34,44 +39,120 @@ MCP-only install, no OAuth2 anywhere in the app), the first such reference throw
fails, and only when there's something to fail. This mirrors `OidcExtension`'s own lazy bridge to
`flash-ext-openapi` — same technique, same reason.
## OAuth2 resolution details
## OAuth2 resolution details — zero-config by default
When oidc is available and `security() != NONE`:
When oidc is available and `security() != NONE`, `McpOidcIntegration` (an isolated,
lazily-loaded bridge — see its javadoc) derives everything an MCP OAuth2 resource server needs
straight from the installed `OidcMiddleware`, with no additional `McpConfig` calls required:
1. The MCP route is wrapped with `flash-ext-oidc`'s own `OidcMiddleware.protect()` — the same
Bearer-token/JWKS validation path used everywhere else in Flash5. No JWT parsing or JWKS
handling is reimplemented here.
2. If `McpConfig.resourceIdentifier(...)` is set, an additional audience guard runs after
`protect()`: it reads the validated claims from `ClaimsHolder` and rejects (`403`) any token
whose `aud` claim does not include the configured resource identifier — **RFC 8707 Resource
Indicators / audience binding**. This is genuinely new behavior, not something
`flash-ext-oidc` does on its own: `OidcMiddleware` validates `aud` against its own
`clientId` for ID tokens, but deliberately does not enforce audience on access tokens (it
varies by provider) — the MCP extension adds that check on top, scoped to its own resource
identifier.
3. If `resourceIdentifier(...)` is left unset, only standard bearer validation runs — no
audience binding. Fine for a first integration; RFC 8707 becomes meaningful once you have
more than one resource server sharing the same authorization server.
1. The MCP route is wrapped with `flash-ext-oidc`'s own `OidcMiddleware.protect(resourceMetadataPath)`
— the same Bearer-token/JWKS validation path used everywhere else in Flash5, plus a
`resource_metadata` challenge parameter (see below). No JWT parsing or JWKS handling is
reimplemented here.
2. An audience guard always runs after `protect(...)`: it reads the validated claims from
`ClaimsHolder` and rejects (`403`) any token whose `aud` claim does not include the resource
identifier — **RFC 8707 Resource Indicators / audience binding**, enforced unconditionally,
not opt-in. `OidcMiddleware` itself validates `aud` against its own `clientId` for ID
tokens, but deliberately does not enforce audience on access tokens (it varies by provider)
— the MCP extension adds that check on top, scoped to its own resource identifier.
3. The resource identifier is the canonical URI of the MCP endpoint, resolved **per request** by
`OidcMiddleware#selfOrigin` + `rootPath` — the same scheme/host resolution `OidcExtension`
uses for its own redirect URIs: `X-Forwarded-Host`/`X-Forwarded-Proto` when the request came
through a reverse proxy, otherwise `{selfScheme()}://{Host header}`. Behind a proxy the
`Host` alone is the upstream address the proxy dialled, which would publish a resource
identifier no client can reach. `McpConfig.resourceIdentifier(...)` still overrides it
outright for a proxy that forwards neither header.
4. The authorization server issuer is read from `OidcMiddleware#issuer()` unless
`McpConfig.authorizationServerIssuer(...)` overrides it.
## RFC 9728 Protected Resource Metadata
If both `resourceIdentifier(...)` and `authorizationServerIssuer(...)` are set (and the endpoint
ends up protected), `flash-ext-mcp` publishes a Protected Resource Metadata document at
`/.well-known/oauth-protected-resource{rootPath}`:
Whenever the endpoint ends up protected, `flash-ext-mcp` publishes a Protected Resource Metadata
document at `/.well-known/oauth-protected-resource{rootPath}` — no explicit `resourceIdentifier`/
`authorizationServerIssuer` configuration required, both are auto-derived as described above:
```json
{ "resource": "https://mcp.example.com/mcp", "authorization_servers": ["https://auth.example.com/realms/myrealm"] }
```
This lets a spec-compliant MCP client discover which authorization server to use without
out-of-band configuration. `authorizationServerIssuer` has to be supplied explicitly because
`flash-ext-oidc` does not expose its resolved issuer/discovery metadata through `FlashContext`
only `OidcMiddleware` and `JwtValidator` are registered there. Passing it separately avoids
reaching into `flash-ext-oidc` internals for a value the app owner already has at hand (it's the
same issuer they configured `OidcExtension` with).
`resource` is computed per request from the incoming request's forwarded/`Host` headers (see
above), so the document is correct without hardcoding the server's own public URL.
Without an issuer configured, bearer validation still works exactly the same — the client just
needs the authorization server configured out-of-band instead of discovering it automatically.
### `scopes_supported`
Optional per RFC 9728, omitted from the document entirely unless set via
`McpConfig.scopesSupported("openid", "profile", "email")`:
```json
{ "resource": "...", "authorization_servers": ["..."], "scopes_supported": ["openid", "profile", "email"] }
```
This is pure advertisement — token validation doesn't change based on it — but it matters in
practice: a client that ignores it and requests no scope at all (many do — see `keycloak.md`)
only gets back whatever the authorization server treats as always-included regardless of
request, which for Keycloak is just its built-in `basic` scope. A client that *does* read
`scopes_supported` and echoes it back in its authorization/token requests gets a token with the
claims those scopes actually provide (`profile``preferred_username`/`name`, etc.), without
needing every one of those claims hand-mapped onto `basic`. Set it to whatever scopes your
`McpTool`s actually read off `ClaimsHolder`/`OidcUser` — there's no way to auto-derive this list,
it depends entirely on what your tools do with the claims.
## `WWW-Authenticate: resource_metadata` (RFC 9728 §5.1)
The MCP Authorization spec **requires** a `401` to carry `resource_metadata` in
`WWW-Authenticate`, pointing at the Protected Resource Metadata document above — this is how a
spec-compliant client discovers the authorization server without out-of-band configuration.
`OidcMiddleware.protect(String resourceMetadataPath)` (an overload added specifically for this)
builds that challenge automatically:
```
WWW-Authenticate: Bearer realm="...", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp"
```
The plain `OidcMiddleware.protect()` (no argument), used by every other Flash5 app, is
unaffected — this parameter is additive and MCP-specific.
## Per-tool `@RolesAllowed`/`@ScopesAllowed`
`McpTool` subclasses can carry `flash-ext-oidc`'s `@RolesAllowed`/`@ScopesAllowed`:
```java
@Tool(name = "delete_route", description = "Delete a route")
@RolesAllowed("admin")
public class DeleteRouteTool extends McpTool {
@Override public ToolResponse call(ToolArguments args) { ... }
}
```
This does **not** reuse `flash-ext-oidc`'s per-route middleware mechanism (`ctx.addAnnotationProcessor`,
the thing that makes these annotations work on a `RequestHandler`) — it can't: every tool shares
one HTTP route (`POST {rootPath}`), already wrapped by whatever `McpSecurity` resolved above, so
there is no per-tool route to attach a different middleware chain to. Instead,
`McpOidcIntegration.compileToolPolicy` reads the annotations once at boot (`McpRegistry.scan`)
and compiles them into a closure (`McpAuthPolicy`) that `McpDispatcher` runs *after* the
route-wide auth has already succeeded and *before* invoking the specific tool named in the
`tools/call` request — narrowing what's already-authenticated, not replacing it. A denial is a
normal `isError: true` tool result (see `ToolResponse.error`), not an HTTP-level rejection — the
model sees why, the same as any other tool failure.
Roles are read via `OidcUser#hasRole` against `McpConfig.rolesClaimPath(...)` (default
`"realm_access.roles"`, matching `OidcConfig`'s own default — set this explicitly if the two
diverge; there's no way to read `OidcConfig`'s actual configured value from here). Scopes use
`OidcUser#hasScope`'s built-in default claim paths (`scope`/`scp`), no extra config needed.
`@ScopesAllowed(match = ScopesAllowed.Match.ANY)` and multi-role `@RolesAllowed({"admin",
"editor"})` (OR semantics) both work exactly as they do on a `RequestHandler`.
**`@Authenticated` alone has no effect and fails boot.** Once oidc is active for a server, every
tool call is already authenticated — there's no per-tool public/authenticated split the way
there is for HTTP routes, so a bare `@Authenticated` on a tool can't mean anything and would
silently do nothing if allowed to compile. Boot fails instead, with a message pointing at
`@RolesAllowed`/`@ScopesAllowed` as the actual narrowing mechanism.
**Annotating a tool without active OAuth2 also fails boot**, not silently at request time: if
`@RolesAllowed`/`@ScopesAllowed`/`@Authenticated` shows up on a tool while `McpSecurity` resolved
to unprotected (`NONE`, or `AUTO` with no oidc installed), that's very likely a forgotten
`OidcExtension` install or a `McpSecurity.NONE` left over from local dev — `IllegalStateException`
at `app.start()`.
## The `HttpException` safety net
@@ -0,0 +1,23 @@
package dev.relism.flash.ext.mcp;
import java.util.function.Supplier;
/**
* Compiled per-tool authorization requirement, built once at boot by {@link McpOidcIntegration}
* from {@code @RolesAllowed}/{@code @ScopesAllowed} on an {@link McpTool} subclass — {@code null}
* on {@link McpRegistry.RegisteredTool} means no restriction beyond whatever {@link McpSecurity}
* already enforces route-wide.
*
* <p>{@code check} is a closure, not a raw role/scope list — this is what lets this record (and
* its only caller, {@link McpDispatcher}) stay free of any compile-time reference to a {@code
* flash-ext-oidc} type, preserving the same classload isolation {@link McpOidcIntegration}'s
* javadoc describes for the rest of the OIDC bridge. Only the plain-JDK {@link Supplier}
* signature crosses the boundary; the closure itself, built once inside {@code
* McpOidcIntegration}, is the only place that ever touches {@code OidcUser}/{@code ClaimsHolder}.
*
* <p>Returns {@code null} from {@link #check()}{@code .get()} when authorized, or a
* human-readable denial reason otherwise — invoked once per {@code tools/call} against an
* annotated tool, never allocated on that path (the closure and its captured role/scope arrays
* are built exactly once, at boot).
*/
record McpAuthPolicy(Supplier<String> check) {}
@@ -12,7 +12,7 @@ import java.util.List;
* .rootPath("/mcp")
* .toolsPackage("com.example.tools")
* .security(McpSecurity.REQUIRED)
* .resourceIdentifier("https://mcp.example.com/mcp")
* .scopesSupported("openid", "profile", "email")
* .build();
* }</pre>
*/
@@ -27,6 +27,8 @@ public final class McpConfig {
private final String resourceIdentifier;
private final String authorizationServerIssuer;
private final List<String> allowedOrigins;
private final List<String> scopesSupported;
private final String rolesClaimPath;
private McpConfig(Builder b) {
this.name = b.name;
@@ -38,6 +40,8 @@ public final class McpConfig {
this.resourceIdentifier = b.resourceIdentifier;
this.authorizationServerIssuer = b.authorizationServerIssuer;
this.allowedOrigins = List.copyOf(b.allowedOrigins);
this.scopesSupported = List.copyOf(b.scopesSupported);
this.rolesClaimPath = b.rolesClaimPath;
}
String name() { return name; }
@@ -49,6 +53,8 @@ public final class McpConfig {
String resourceIdentifier() { return resourceIdentifier; }
String authorizationServerIssuer() { return authorizationServerIssuer; }
List<String> allowedOrigins() { return allowedOrigins; }
List<String> scopesSupported() { return scopesSupported; }
String rolesClaimPath() { return rolesClaimPath; }
public static Builder builder(String name) { return new Builder(name); }
@@ -62,6 +68,8 @@ public final class McpConfig {
private String resourceIdentifier;
private String authorizationServerIssuer;
private final List<String> allowedOrigins = new ArrayList<>();
private final List<String> scopesSupported = new ArrayList<>();
private String rolesClaimPath = "realm_access.roles";
private Builder(String name) {
if (name == null || name.isBlank())
@@ -85,18 +93,22 @@ public final class McpConfig {
public Builder security(McpSecurity security) { this.security = security; return this; }
/**
* Resource identifier used for RFC 8707 audience binding: tokens whose {@code aud} claim
* does not include this value are rejected. Optional — if unset, only standard bearer
* validation (signature/issuer/expiry) is enforced, not audience binding.
* Canonical URI of this MCP endpoint, used for RFC 8707 audience binding: tokens whose
* {@code aud} claim does not include this value are rejected. Optional — when
* {@code flash-ext-oidc} is installed, this is auto-derived per request from the
* forwarded/{@code Host} headers (same resolution {@code OidcExtension} uses for its own
* redirect URIs) and audience binding is enforced unconditionally. Set this explicitly
* only to override that guess — a reverse proxy that forwards neither
* {@code X-Forwarded-Host} nor {@code X-Forwarded-Proto}.
*/
public Builder resourceIdentifier(String resourceIdentifier) { this.resourceIdentifier = resourceIdentifier; return this; }
/**
* Authorization server issuer URL, used to publish an RFC 9728 Protected Resource
* Metadata document at {@code /.well-known/oauth-protected-resource{rootPath}} so MCP
* clients can discover it automatically. Requires {@link #resourceIdentifier(String)}
* to also be set. Optional — without it, bearer validation still works, clients just
* need the authorization server configured out-of-band.
* Authorization server issuer URL, published in the RFC 9728 Protected Resource
* Metadata document at {@code /.well-known/oauth-protected-resource{rootPath}}. Optional
* — when {@code flash-ext-oidc} is installed, this is auto-derived from its configured
* issuer. Set this explicitly only to override that (e.g. publishing a different issuer
* than the one actually validating tokens).
*/
public Builder authorizationServerIssuer(String issuer) { this.authorizationServerIssuer = issuer; return this; }
@@ -107,6 +119,28 @@ public final class McpConfig {
*/
public Builder allowedOrigins(String... origins) { this.allowedOrigins.addAll(List.of(origins)); return this; }
/**
* OAuth2 scopes this server expects clients to request, published as {@code
* scopes_supported} in the RFC 9728 Protected Resource Metadata document. Optional per
* the spec — omitted from the document entirely if never set. A spec-compliant client
* reads this to know what to put in its authorization/token requests instead of
* requesting nothing; see {@code docs/keycloak.md}'s "same story for any other claim"
* section for why this matters in practice (a client that requests no scope only gets
* whatever your authorization server treats as always-included, e.g. Keycloak's `basic`).
* Purely advertisement — this server still validates whatever token it actually receives
* the same way regardless of what a client requested.
*/
public Builder scopesSupported(String... scopes) { this.scopesSupported.addAll(List.of(scopes)); return this; }
/**
* Claim path used to resolve roles for {@code @RolesAllowed} on an {@link McpTool} —
* same dot-path syntax and default (Keycloak's {@code realm_access.roles}) as {@code
* OidcConfig#rolesClaimPath()}. Set this only if the two configs diverge; there is no
* way to auto-derive it from the installed {@code OidcExtension} (see {@code
* docs/security.md}'s {@code @RolesAllowed}/{@code @ScopesAllowed} section for why).
*/
public Builder rolesClaimPath(String rolesClaimPath) { this.rolesClaimPath = rolesClaimPath; return this; }
public McpConfig build() {
if (toolsPackage == null || toolsPackage.isBlank())
throw new IllegalStateException(
@@ -17,7 +17,11 @@ import java.io.IOException;
* error — the model needs to see it. Everything else that goes wrong (bad params, unknown
* tool/resource/prompt name, resource/prompt handler exceptions) is a JSON-RPC error object,
* always returned with HTTP 200: the HTTP request itself succeeded, only the RPC did not. Only
* malformed HTTP-level input (unparsable JSON, not a JSON object) gets HTTP 400.
* malformed HTTP-level input (unparsable JSON, not a JSON object) gets HTTP 400. A
* {@code @RolesAllowed}/{@code @ScopesAllowed} denial (see {@link McpAuthPolicy}) is the same
* category — {@code isError: true}, tool never invoked — not a transport-level rejection; the
* route-wide 401/403 for "not authenticated at all" already happened earlier, in the {@code
* OidcMiddleware}/audience-guard middleware chain, before this dispatcher ever runs.
*/
final class McpDispatcher {
@@ -132,12 +136,17 @@ final class McpDispatcher {
if (tool == null)
throw McpProtocolException.invalidParams("Unknown tool: " + name);
ToolArguments args = new ToolArguments(params.path("arguments"));
ToolResponse result;
try {
result = tool.instance().call(args);
} catch (Exception e) {
result = ToolResponse.error("Tool \"" + name + "\" failed: " + e.getMessage());
String denied = tool.policy() != null ? tool.policy().check().get() : null;
if (denied != null) {
result = ToolResponse.error("Tool \"" + name + "\" denied: " + denied);
} else {
ToolArguments args = new ToolArguments(params.path("arguments"));
try {
result = tool.instance().call(args);
} catch (Exception e) {
result = ToolResponse.error("Tool \"" + name + "\" failed: " + e.getMessage());
}
}
ToolResponse finalResult = result;
writeResult(res, id, gen -> {
@@ -25,14 +25,14 @@ import java.util.List;
* .build()))
* .start();
*
* // With flash-ext-oidc as the OAuth2 resource server
* // With flash-ext-oidc as the OAuth2 resource server — zero extra config: issuer, canonical
* // resource identifier, RFC 8707 audience binding and RFC 9728 metadata are all derived from
* // the installed OidcExtension.
* FlashApp.create(8080)
* .install(new OidcExtension(oidcConfig))
* .install(new McpExtension(McpConfig.builder("my-mcp-server")
* .toolsPackage("com.example.tools")
* .security(McpSecurity.REQUIRED)
* .resourceIdentifier("https://mcp.example.com/mcp")
* .authorizationServerIssuer("https://auth.example.com/realms/myrealm")
* .build()))
* .start();
* }</pre>
@@ -51,42 +51,40 @@ public class McpExtension implements FlashExtension {
this.config = config;
}
/**
* Everything — scanning, binding, security resolution, route registration — happens here
* rather than in {@link #provide}, because binding a tool calls its {@code onInit()}, which
* may call {@code require()} on services other extensions registered lazily via
* {@code ctx.supply()}. Per {@link FlashExtension}'s contract, {@code require()} is only
* safe once {@code routes()} runs, after every extension's {@code provide()} phase has
* completed and {@code FlashContext.resolveAll()} has run.
*/
@Override
public void routes(FlashRegistrar<?> app, FlashContext ctx) {
McpRegistry registry = McpRegistry.scan(config.toolsPackage(), ctx);
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
ctx.onReady(() -> registerRoutes(app, ctx));
}
private void registerRoutes(FlashRegistrar<?> app, FlashContext ctx) {
// Resolved before scanning so McpRegistry knows, per tool, whether @RolesAllowed/
// @ScopesAllowed are backed by real OAuth2 protection or a boot-time misconfiguration
// (see McpOidcIntegration#compileToolPolicy) — must run first, not after.
McpOidcIntegration.Resolved secured = resolveSecurity(ctx);
McpRegistry registry = McpRegistry.scan(config.toolsPackage(), ctx, secured != null, config.rolesClaimPath());
McpDispatcher dispatcher = new McpDispatcher(registry, config.name(), config.version(), config.instructions());
List<Middleware> chain = new ArrayList<>(3);
chain.add(McpTransportGuards.httpExceptionGuard());
chain.add(McpTransportGuards.originGuard(config.allowedOrigins()));
Middleware security = resolveSecurity(ctx);
if (security != null) chain.add(security);
if (secured != null) chain.add(secured.security());
app.post(config.rootPath(), (req, res) -> { dispatcher.handle(req, res); return null; },
chain.toArray(Middleware[]::new));
registerResourceMetadata(app, security != null);
registerResourceMetadata(app, secured);
}
private Middleware resolveSecurity(FlashContext ctx) {
private McpOidcIntegration.Resolved resolveSecurity(FlashContext ctx) {
if (config.security() == McpSecurity.NONE) return null;
Middleware oidcSecurity;
McpOidcIntegration.Resolved resolved;
try {
oidcSecurity = McpOidcIntegration.resolve(ctx, config);
resolved = McpOidcIntegration.resolve(ctx, config);
} catch (NoClassDefFoundError e) {
oidcSecurity = null; // flash-ext-oidc not on the classpath at all
resolved = null; // flash-ext-oidc not on the classpath at all
}
if (oidcSecurity != null) return oidcSecurity;
if (resolved != null) return resolved;
if (config.security() == McpSecurity.REQUIRED) {
throw new IllegalStateException(
@@ -102,17 +100,20 @@ public class McpExtension implements FlashExtension {
return null;
}
private void registerResourceMetadata(FlashRegistrar<?> app, boolean secured) {
if (!secured) return;
String resourceId = config.resourceIdentifier();
String issuer = config.authorizationServerIssuer();
if (resourceId == null || resourceId.isBlank() || issuer == null || issuer.isBlank()) return;
String body = McpResourceMetadata.build(resourceId, issuer);
/**
* RFC 9728 Protected Resource Metadata, built once security is resolved — no longer
* conditioned on {@code resourceIdentifier}/{@code authorizationServerIssuer} being set
* explicitly, since {@link McpOidcIntegration#resolve} now derives both by default. The
* {@code resource} field is computed per request (it depends on that request's own
* forwarded/{@code Host} headers) via {@link McpOidcIntegration.Resolved#resourceIdentifier()}.
*/
private void registerResourceMetadata(FlashRegistrar<?> app, McpOidcIntegration.Resolved secured) {
if (secured == null) return;
String path = "/.well-known/oauth-protected-resource" + config.rootPath();
app.get(path, (req, res) -> {
res.type(ContentType.JSON);
return body;
return McpResourceMetadata.build(
secured.resourceIdentifier().apply(req), secured.issuer(), config.scopesSupported());
});
}
}
@@ -1,45 +1,84 @@
package dev.relism.flash.ext.mcp;
import dev.relism.flash.ext.oidc.Authenticated;
import dev.relism.flash.ext.oidc.ClaimsHolder;
import dev.relism.flash.ext.oidc.OidcMiddleware;
import dev.relism.flash.ext.oidc.OidcUser;
import dev.relism.flash.ext.oidc.RolesAllowed;
import dev.relism.flash.ext.oidc.ScopesAllowed;
import dev.relism.flash.exceptions.HttpException;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.models.Request;
import dev.relism.flash.routing.Middleware;
import lombok.extern.slf4j.Slf4j;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.function.Supplier;
/**
* Lazy, isolated bridge to {@code flash-ext-oidc}.
*
* <p>References to OIDC types only ever resolve when {@link #resolve} is actually invoked —
* never at {@link McpExtension} class-load time — because they live in this separate nested
* class. The caller wraps the invocation in {@code catch (NoClassDefFoundError)}, exactly like
* {@code OidcExtension}'s own lazy bridge to {@code flash-ext-openapi}. This is what lets
* {@code flash-ext-mcp} run standalone (MCP-only, no OAuth2) when {@code flash-ext-oidc} is not
* even on the classpath.
* <p>References to OIDC types only ever resolve when {@link #resolve}/{@link #compileToolPolicy}
* are actually invoked — never at {@link McpExtension} class-load time — because they live in
* this separate nested class. The caller wraps the invocation in {@code catch
* (NoClassDefFoundError)}, exactly like {@code OidcExtension}'s own lazy bridge to {@code
* flash-ext-openapi}. This is what lets {@code flash-ext-mcp} run standalone (MCP-only, no
* OAuth2) when {@code flash-ext-oidc} is not even on the classpath. {@link Resolved}/{@link
* McpAuthPolicy} carry only oidc-free types back out ({@link Middleware}, {@link String}, a
* {@link Function}, a {@link Supplier}) so no other class in this package ever has to reference
* an OIDC type.
*
* <p>Zero-config by design: when {@code flash-ext-oidc} is installed, everything an MCP OAuth2
* resource server needs — issuer, canonical resource identifier, RFC 8707 audience binding, and
* a spec-compliant {@code WWW-Authenticate} challenge (RFC 9728 §5.1) — is derived straight from
* the installed {@link OidcMiddleware}, with no additional {@link McpConfig} calls.
* {@link McpConfig#resourceIdentifier(String)}/{@link McpConfig#authorizationServerIssuer(String)}
* remain as explicit overrides for the rare case where that guess is wrong.
*/
@Slf4j
final class McpOidcIntegration {
private static final String[] NO_VALUES = new String[0];
private McpOidcIntegration() {}
/** Returns the security {@link Middleware} to apply, or {@code null} if oidc is not installed. */
static Middleware resolve(FlashContext ctx, McpConfig config) {
/** Everything {@link McpExtension} needs once oidc security is resolved. */
record Resolved(Middleware security, String issuer, Function<Request, String> resourceIdentifier) {}
/** Returns the resolved security bundle, or {@code null} if oidc is not installed. */
static Resolved resolve(FlashContext ctx, McpConfig config) {
Optional<OidcMiddleware> oidc = ctx.find(OidcMiddleware.class);
if (oidc.isEmpty()) return null;
Middleware protect = oidc.get().protect();
String resourceId = config.resourceIdentifier();
if (resourceId == null || resourceId.isBlank()) return protect;
OidcMiddleware oidcMw = oidc.get();
String resourceMetadataPath = "/.well-known/oauth-protected-resource" + config.rootPath();
String issuer = config.authorizationServerIssuer() != null
? config.authorizationServerIssuer() : oidcMw.issuer();
Function<Request, String> resourceId = req -> config.resourceIdentifier() != null
? config.resourceIdentifier()
: OidcMiddleware.selfOrigin(req, oidcMw.selfScheme()) + config.rootPath();
return Middleware.of(protect, audienceGuard(resourceId));
Middleware protect = oidcMw.protect(resourceMetadataPath);
Middleware secured = Middleware.of(protect, audienceGuard(resourceId));
return new Resolved(secured, issuer, resourceId);
}
/** RFC 8707 audience binding: rejects tokens whose {@code aud} claim doesn't include ours. */
private static Middleware audienceGuard(String resourceIdentifier) {
/**
* RFC 8707 audience binding, unconditionally enforced once oidc is protecting the MCP
* route — no longer opt-in behind an explicit {@code resourceIdentifier(...)} call.
*/
private static Middleware audienceGuard(Function<Request, String> resourceIdentifier) {
return next -> (req, res) -> {
Map<String, Object> claims = ClaimsHolder.get();
if (claims != null && !audienceMatches(claims.get("aud"), resourceIdentifier)) {
String expected = resourceIdentifier.apply(req);
if (claims != null && !audienceMatches(claims.get("aud"), expected)) {
log.warn("[flash-ext-mcp] Rejecting token (RFC 8707): aud={} does not include expected " +
"resource identifier \"{}\" — the authorization server must include this exact " +
"value in the access token's aud claim (e.g. an Audience protocol mapper in " +
"Keycloak) for this MCP server to accept it.", claims.get("aud"), expected);
throw HttpException.forbidden();
}
return next.handle(req, res);
@@ -53,4 +92,88 @@ final class McpOidcIntegration {
}
return false;
}
/**
* Compiles {@code @RolesAllowed}/{@code @ScopesAllowed} on a tool class into a {@link
* McpAuthPolicy}, or returns {@code null} if the tool carries none of the three OIDC
* annotations. Called once per tool at boot ({@link McpRegistry#scan}), never on the
* request hot path — the {@link Supplier} it returns is what runs per {@code tools/call},
* closing over the already-normalized role/scope arrays so the hot path itself allocates
* nothing beyond what {@link OidcUser#hasRole}/{@link OidcUser#hasScope} already do.
*
* <p>Fails fast at boot, not silently at request time, for the two ways this can be
* misconfigured: the annotation present without OAuth2 actually protecting this MCP server
* ({@code oidcActive == false}), and {@code @Authenticated} — which has no per-tool meaning
* here (see below) — used at all.
*/
static McpAuthPolicy compileToolPolicy(Class<? extends McpTool> toolClass, boolean oidcActive,
String rolesClaimPath) {
Authenticated auth = toolClass.getAnnotation(Authenticated.class);
RolesAllowed roles = toolClass.getAnnotation(RolesAllowed.class);
ScopesAllowed scopes = toolClass.getAnnotation(ScopesAllowed.class);
if (auth == null && roles == null && scopes == null) return null;
if (!oidcActive) {
throw new IllegalStateException(
"MCP tool \"" + toolClass.getSimpleName() + "\" declares @Authenticated/@RolesAllowed/" +
"@ScopesAllowed, but this MCP server has no active OAuth2 protection — flash-ext-oidc " +
"is not installed for it, or McpSecurity is NONE. These annotations require " +
"McpSecurity.AUTO/REQUIRED with an OidcExtension installed; install one, or remove the " +
"annotation from " + toolClass.getSimpleName() + ".");
}
if (auth != null) {
throw new IllegalStateException(
"MCP tool \"" + toolClass.getSimpleName() + "\" is annotated @Authenticated, which has " +
"no effect on an McpTool: the whole MCP endpoint is already all-or-nothing " +
"authenticated once oidc is active (McpSecurity.AUTO/REQUIRED) — unlike a RequestHandler " +
"route, there is no per-tool public/authenticated split to opt into. Remove it, or use " +
"@RolesAllowed/@ScopesAllowed to narrow further.");
}
String[] requiredRoles = roles != null ? normalizeRequired("RolesAllowed", roles.value()) : NO_VALUES;
String[] requiredScopes = scopes != null ? normalizeRequired("ScopesAllowed", scopes.value()) : NO_VALUES;
ScopesAllowed.Match scopeMatch = scopes != null ? scopes.match() : ScopesAllowed.Match.ALL;
Supplier<String> check = () -> {
OidcUser user = ClaimsHolder.user();
if (user == null) return "not authenticated";
if (requiredRoles.length > 0 && !hasAnyRole(user, rolesClaimPath, requiredRoles))
return "missing required role (any of: " + String.join(", ", requiredRoles) + ")";
if (requiredScopes.length > 0 && !hasScopes(user, requiredScopes, scopeMatch))
return "missing required scope (" + scopeMatch + " of: " + String.join(", ", requiredScopes) + ")";
return null;
};
return new McpAuthPolicy(check);
}
private static boolean hasAnyRole(OidcUser user, String claimPath, String[] roles) {
for (String role : roles) if (user.hasRole(claimPath, role)) return true;
return false;
}
private static boolean hasScopes(OidcUser user, String[] scopes, ScopesAllowed.Match match) {
if (match == ScopesAllowed.Match.ALL) {
for (String scope : scopes) if (!user.hasScope(scope)) return false;
return true;
}
for (String scope : scopes) if (user.hasScope(scope)) return true;
return false;
}
/** Mirrors {@code OidcAuthPolicy}'s own normalization — trim, dedupe, require non-blank. */
private static String[] normalizeRequired(String annotationName, String[] values) {
if (values == null || values.length == 0)
throw new IllegalStateException("@" + annotationName + " requires at least one value");
LinkedHashSet<String> normalized = new LinkedHashSet<>(values.length);
for (String raw : values) {
if (raw == null) continue;
String trimmed = raw.trim();
if (!trimmed.isEmpty()) normalized.add(trimmed);
}
if (normalized.isEmpty())
throw new IllegalStateException("@" + annotationName + " requires at least one non-empty value");
return normalized.toArray(String[]::new);
}
}
@@ -24,7 +24,8 @@ final class McpRegistry {
private static final String EMPTY_ARRAY = "[]";
record RegisteredTool(String name, McpTool instance) {}
/** {@code policy} is {@code null} unless the tool carries @RolesAllowed/@ScopesAllowed. */
record RegisteredTool(String name, McpTool instance, McpAuthPolicy policy) {}
record RegisteredResource(String uri, McpResource instance) {}
record RegisteredPrompt(String name, McpPrompt instance) {}
@@ -38,7 +39,16 @@ final class McpRegistry {
private McpRegistry() {}
static McpRegistry scan(String packageName, FlashContext ctx) {
/**
* @param oidcActive whether this MCP server's route is actually OAuth2-protected right
* now (see {@link McpOidcIntegration#resolve}) — gates whether
* {@code @RolesAllowed}/{@code @ScopesAllowed} on a tool are honored or
* rejected at boot as a misconfiguration; see
* {@link McpOidcIntegration#compileToolPolicy}.
* @param rolesClaimPath claim path forwarded to {@code @RolesAllowed} checks; see
* {@link McpConfig#rolesClaimPath(String)}.
*/
static McpRegistry scan(String packageName, FlashContext ctx, boolean oidcActive, String rolesClaimPath) {
McpPackageScanner.ScanResult found = McpPackageScanner.scan(packageName);
McpRegistry registry = new McpRegistry();
@@ -46,7 +56,8 @@ final class McpRegistry {
Tool ann = cls.getAnnotation(Tool.class);
McpTool instance = instantiate(cls);
instance.bind(ctx);
if (registry.tools.putIfAbsent(ann.name(), new RegisteredTool(ann.name(), instance)) != null)
McpAuthPolicy policy = compileToolPolicy(cls, oidcActive, rolesClaimPath);
if (registry.tools.putIfAbsent(ann.name(), new RegisteredTool(ann.name(), instance, policy)) != null)
throw new InitializationException("Duplicate MCP tool name: \"" + ann.name() + "\"");
}
for (Class<? extends McpResource> cls : found.resources()) {
@@ -163,6 +174,24 @@ final class McpRegistry {
gen.writeEndArray();
}
/**
* Isolated the same way {@link McpOidcIntegration#resolve} is — {@code
* NoClassDefFoundError} here means {@code flash-ext-oidc} genuinely isn't on the runtime
* classpath, in which case a tool couldn't have been compiled against
* {@code @RolesAllowed}/{@code @ScopesAllowed} in the first place, so there's nothing to
* check (and nothing lost: {@code oidcActive} is only ever {@code true} once {@link
* McpOidcIntegration#resolve} has already succeeded once this boot, which proves those
* types resolve fine).
*/
private static McpAuthPolicy compileToolPolicy(Class<? extends McpTool> cls, boolean oidcActive,
String rolesClaimPath) {
try {
return McpOidcIntegration.compileToolPolicy(cls, oidcActive, rolesClaimPath);
} catch (NoClassDefFoundError e) {
return null;
}
}
private static <T> T instantiate(Class<T> cls) {
try {
Constructor<T> ctor = cls.getDeclaredConstructor();
@@ -1,17 +1,25 @@
package dev.relism.flash.ext.mcp;
import java.util.List;
/** RFC 9728 OAuth 2.0 Protected Resource Metadata document, built once at boot. */
final class McpResourceMetadata {
private McpResourceMetadata() {}
static String build(String resourceIdentifier, String authorizationServerIssuer) {
/** {@code scopesSupported} is optional per RFC 9728 — omitted from the document if empty. */
static String build(String resourceIdentifier, String authorizationServerIssuer, List<String> scopesSupported) {
return McpJson.buildString(gen -> {
gen.writeStartObject();
gen.writeStringField("resource", resourceIdentifier);
gen.writeArrayFieldStart("authorization_servers");
gen.writeString(authorizationServerIssuer);
gen.writeEndArray();
if (!scopesSupported.isEmpty()) {
gen.writeArrayFieldStart("scopes_supported");
for (String scope : scopesSupported) gen.writeString(scope);
gen.writeEndArray();
}
gen.writeEndObject();
});
}
@@ -19,6 +19,8 @@ import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.time.Instant;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
@@ -56,16 +58,27 @@ final class FakeOidcProvider implements AutoCloseable {
/** Mints a valid RS256 access token — bearer-validation only, no full authorization-code round-trip needed. */
String signToken(String subject, String audience) {
return signToken(subject, audience, null, NO_ROLES);
}
/**
* Same as {@link #signToken(String, String)}, plus a {@code scope} claim (space-delimited,
* matching {@link dev.relism.flash.ext.oidc.OidcUser#hasScope}'s default claim path) and a
* Keycloak-shaped {@code realm_access.roles} claim (matching {@code McpConfig}'s default
* {@code rolesClaimPath}) when {@code roles} is non-empty.
*/
String signToken(String subject, String audience, String scope, String... roles) {
try {
JWTClaimsSet claims = new JWTClaimsSet.Builder()
JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder()
.issuer(issuer)
.subject(subject)
.audience(audience)
.issueTime(Date.from(Instant.now()))
.expirationTime(Date.from(Instant.now().plusSeconds(300)))
.build();
.expirationTime(Date.from(Instant.now().plusSeconds(300)));
if (scope != null) builder.claim("scope", scope);
if (roles.length > 0) builder.claim("realm_access", Map.of("roles", List.of(roles)));
SignedJWT jwt = new SignedJWT(
new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaKey.getKeyID()).build(), claims);
new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaKey.getKeyID()).build(), builder.build());
jwt.sign(new RSASSASigner(rsaKey));
return jwt.serialize();
} catch (Exception e) {
@@ -73,6 +86,8 @@ final class FakeOidcProvider implements AutoCloseable {
}
}
private static final String[] NO_ROLES = new String[0];
private String discoveryDocument() {
return "{"
+ "\"issuer\":\"" + issuer + "\","
@@ -0,0 +1,150 @@
package dev.relism.flash.ext.mcp;
import dev.relism.flash.ext.oidc.OidcConfig;
import dev.relism.flash.ext.oidc.OidcExtension;
import dev.relism.flash.extension.FlashApp;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.net.ServerSocket;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* {@code @RolesAllowed}/{@code @ScopesAllowed} on an {@link McpTool} — see
* {@link McpOidcIntegration#compileToolPolicy}. Same real-discovery/real-JWKS/real-RS256-token
* approach as {@link McpExtensionSecurityTest}, against {@code fixtures.secured}'s tools.
*/
class McpAuthPolicyTest {
private static final String SECURED_TOOLS = "dev.relism.flash.ext.mcp.authfixtures.secured";
private static final String AUTHENTICATED_ONLY_TOOLS = "dev.relism.flash.ext.mcp.authfixtures.authenticatedonly";
private FlashApp app;
private FakeOidcProvider provider;
@AfterEach
void tearDown() {
if (app != null) app.stop();
if (provider != null) provider.close();
}
@Test
void rolesAllowed_deniesWithoutRole_allowsWithRole() throws Exception {
int port = bootSecuredApp(SECURED_TOOLS);
String resourceId = "http://127.0.0.1:" + port + "/mcp";
String noRole = provider.signToken("user-1", resourceId, null);
HttpResponse<String> denied = callTool(port, "admin_only", noRole);
assertEquals(200, denied.statusCode());
assertTrue(denied.body().contains("\"isError\":true"), denied.body());
assertTrue(denied.body().contains("missing required role"), denied.body());
String withRole = provider.signToken("user-1", resourceId, null, "admin");
HttpResponse<String> allowed = callTool(port, "admin_only", withRole);
assertEquals(200, allowed.statusCode());
assertTrue(allowed.body().contains("\"isError\":false"), allowed.body());
assertTrue(allowed.body().contains("ok"), allowed.body());
}
@Test
void scopesAllowed_deniesWithoutScope_allowsWithScope() throws Exception {
int port = bootSecuredApp(SECURED_TOOLS);
String resourceId = "http://127.0.0.1:" + port + "/mcp";
String noScope = provider.signToken("user-1", resourceId, "read");
HttpResponse<String> denied = callTool(port, "write_only", noScope);
assertEquals(200, denied.statusCode());
assertTrue(denied.body().contains("\"isError\":true"), denied.body());
assertTrue(denied.body().contains("missing required scope"), denied.body());
String withScope = provider.signToken("user-1", resourceId, "read write");
HttpResponse<String> allowed = callTool(port, "write_only", withScope);
assertEquals(200, allowed.statusCode());
assertTrue(allowed.body().contains("\"isError\":false"), allowed.body());
assertTrue(allowed.body().contains("written"), allowed.body());
}
@Test
void unannotatedTool_unaffectedByOtherToolsPolicies() throws Exception {
int port = bootSecuredApp(SECURED_TOOLS);
String resourceId = "http://127.0.0.1:" + port + "/mcp";
String plain = provider.signToken("user-1", resourceId, null);
HttpResponse<String> resp = callTool(port, "open", plain);
assertEquals(200, resp.statusCode());
assertTrue(resp.body().contains("\"isError\":false"), resp.body());
assertTrue(resp.body().contains("open"), resp.body());
}
@Test
void toolAnnotated_butSecurityNone_failsAtBoot() throws Exception {
provider = new FakeOidcProvider();
int port = freePort();
app = FlashApp.create(port);
app.install(new OidcExtension(OidcConfig.builder(
provider.issuer(), "mcp-client", "secret", "/auth/callback").build()));
app.install(new McpExtension(McpConfig.builder("secure-server")
.toolsPackage(SECURED_TOOLS)
.security(McpSecurity.NONE)
.build()));
IllegalStateException e = assertThrows(IllegalStateException.class, () -> app.start());
assertTrue(e.getMessage().contains("no active OAuth2 protection"), e.getMessage());
}
@Test
void bareAuthenticated_hasNoEffect_failsAtBoot() throws Exception {
provider = new FakeOidcProvider();
int port = freePort();
app = FlashApp.create(port);
app.install(new OidcExtension(OidcConfig.builder(
provider.issuer(), "mcp-client", "secret", "/auth/callback").build()));
app.install(new McpExtension(McpConfig.builder("secure-server")
.toolsPackage(AUTHENTICATED_ONLY_TOOLS)
.security(McpSecurity.REQUIRED)
.build()));
IllegalStateException e = assertThrows(IllegalStateException.class, () -> app.start());
assertTrue(e.getMessage().contains("no effect"), e.getMessage());
}
// ── Helpers ──────────────────────────────────────────────────────────────
private int bootSecuredApp(String toolsPackage) throws Exception {
provider = new FakeOidcProvider();
int port = freePort();
app = FlashApp.create(port);
app.install(new OidcExtension(OidcConfig.builder(
provider.issuer(), "mcp-client", "secret", "/auth/callback").build()));
app.install(new McpExtension(McpConfig.builder("secure-server")
.toolsPackage(toolsPackage)
.security(McpSecurity.REQUIRED)
.build()));
app.start();
return port;
}
private static HttpResponse<String> callTool(int port, String toolName, String token) throws Exception {
String body = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"" + toolName + "\"}}";
HttpRequest.Builder req = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/mcp"))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("Authorization", "Bearer " + token)
.POST(HttpRequest.BodyPublishers.ofString(body));
return HttpClient.newHttpClient().send(req.build(), HttpResponse.BodyHandlers.ofString());
}
private static int freePort() throws Exception {
try (ServerSocket s = new ServerSocket(0)) {
return s.getLocalPort();
}
}
}
@@ -88,6 +88,69 @@ class McpExtensionSecurityTest {
assertTrue(resp.body().contains("\"protocolVersion\""));
}
@Test
void required_withOidc_noExplicitResourceIdentifier_derivesFromRequestAndEnforcesAudience() throws Exception {
int port = bootSecuredApp(null);
String derivedResourceId = "http://127.0.0.1:" + port + "/mcp";
String matching = provider.signToken("user-1", derivedResourceId);
assertEquals(200, post(port, initializeBody(), matching).statusCode());
String mismatched = provider.signToken("user-1", "https://someone-else.example.com/resource");
assertEquals(403, post(port, initializeBody(), mismatched).statusCode());
}
@Test
void required_withOidc_missingToken_challengeIncludesResourceMetadata() throws Exception {
int port = bootSecuredApp(null);
HttpResponse<String> resp = post(port, initializeBody(), null);
assertEquals(401, resp.statusCode());
String challenge = resp.headers().firstValue("WWW-Authenticate").orElse("");
assertTrue(challenge.contains(
"resource_metadata=\"http://127.0.0.1:" + port + "/.well-known/oauth-protected-resource/mcp\""),
"WWW-Authenticate: " + challenge);
}
@Test
void required_withOidc_noExplicitConfig_publishesProtectedResourceMetadata() throws Exception {
int port = bootSecuredApp(null);
HttpResponse<String> resp = HttpClient.newHttpClient().send(
HttpRequest.newBuilder(URI.create(
"http://127.0.0.1:" + port + "/.well-known/oauth-protected-resource/mcp")).GET().build(),
HttpResponse.BodyHandlers.ofString());
assertEquals(200, resp.statusCode());
assertTrue(resp.body().contains("\"resource\":\"http://127.0.0.1:" + port + "/mcp\""), resp.body());
assertTrue(resp.body().contains("\"authorization_servers\":[\"" + provider.issuer() + "\"]"), resp.body());
assertTrue(!resp.body().contains("scopes_supported"), "scopes_supported must be omitted when unset: " + resp.body());
}
@Test
void scopesSupported_published_inProtectedResourceMetadata() throws Exception {
provider = new FakeOidcProvider();
int port = freePort();
app = FlashApp.create(port);
app.install(new OidcExtension(OidcConfig.builder(
provider.issuer(), "mcp-client", "secret", "/auth/callback").build()));
app.install(new McpExtension(McpConfig.builder("secure-server")
.toolsPackage(TOOLS_PACKAGE)
.security(McpSecurity.REQUIRED)
.scopesSupported("openid", "profile", "email")
.build()));
app.start();
HttpResponse<String> resp = HttpClient.newHttpClient().send(
HttpRequest.newBuilder(URI.create(
"http://127.0.0.1:" + port + "/.well-known/oauth-protected-resource/mcp")).GET().build(),
HttpResponse.BodyHandlers.ofString());
assertEquals(200, resp.statusCode());
assertTrue(resp.body().contains("\"scopes_supported\":[\"openid\",\"profile\",\"email\"]"), resp.body());
}
// ── Helpers ──────────────────────────────────────────────────────────────
private int bootSecuredApp(String resourceIdentifier) throws Exception {
@@ -16,7 +16,7 @@ class McpRegistryTest {
@Test
void scan_findsAndPrecompilesToolsResourcesPrompts() throws Exception {
McpRegistry registry = McpRegistry.scan("dev.relism.flash.ext.mcp.fixtures", new FlashContext());
McpRegistry registry = McpRegistry.scan("dev.relism.flash.ext.mcp.fixtures", new FlashContext(), false, "realm_access.roles");
assertTrue(registry.hasTools());
assertTrue(registry.hasResources());
@@ -47,7 +47,7 @@ class McpRegistryTest {
@Test
void scan_emptyPackage_throwsInitializationException() {
assertThrows(InitializationException.class,
() -> McpRegistry.scan("dev.relism.flash.ext.mcp.doesnotexist", new FlashContext()));
() -> McpRegistry.scan("dev.relism.flash.ext.mcp.doesnotexist", new FlashContext(), false, "realm_access.roles"));
}
private static JsonNode findByField(JsonNode array, String field, String value) {
@@ -0,0 +1,20 @@
package dev.relism.flash.ext.mcp.authfixtures.authenticatedonly;
import dev.relism.flash.ext.mcp.McpTool;
import dev.relism.flash.ext.mcp.TextContent;
import dev.relism.flash.ext.mcp.Tool;
import dev.relism.flash.ext.mcp.ToolArguments;
import dev.relism.flash.ext.mcp.ToolResponse;
import dev.relism.flash.ext.oidc.Authenticated;
/** Deliberately misconfigured fixture: bare @Authenticated has no effect on an McpTool — see
* McpOidcIntegration#compileToolPolicy. Boot must fail with a clear message, not silently no-op. */
@Tool(name = "pointless", description = "Exists only to prove @Authenticated alone fails boot")
@Authenticated
public class PointlessAuthTool extends McpTool {
@Override
public ToolResponse call(ToolArguments args) {
return ToolResponse.success(new TextContent("unreachable"));
}
}
@@ -0,0 +1,18 @@
package dev.relism.flash.ext.mcp.authfixtures.secured;
import dev.relism.flash.ext.mcp.McpTool;
import dev.relism.flash.ext.mcp.TextContent;
import dev.relism.flash.ext.mcp.Tool;
import dev.relism.flash.ext.mcp.ToolArguments;
import dev.relism.flash.ext.mcp.ToolResponse;
import dev.relism.flash.ext.oidc.RolesAllowed;
@Tool(name = "admin_only", description = "Only callable with the admin role")
@RolesAllowed("admin")
public class AdminOnlyTool extends McpTool {
@Override
public ToolResponse call(ToolArguments args) {
return ToolResponse.success(new TextContent("ok"));
}
}
@@ -0,0 +1,17 @@
package dev.relism.flash.ext.mcp.authfixtures.secured;
import dev.relism.flash.ext.mcp.McpTool;
import dev.relism.flash.ext.mcp.TextContent;
import dev.relism.flash.ext.mcp.Tool;
import dev.relism.flash.ext.mcp.ToolArguments;
import dev.relism.flash.ext.mcp.ToolResponse;
/** No role/scope annotation — any authenticated caller, confirms unrelated tools are unaffected. */
@Tool(name = "open", description = "Callable by anyone already authenticated")
public class OpenTool extends McpTool {
@Override
public ToolResponse call(ToolArguments args) {
return ToolResponse.success(new TextContent("open"));
}
}
@@ -0,0 +1,18 @@
package dev.relism.flash.ext.mcp.authfixtures.secured;
import dev.relism.flash.ext.mcp.McpTool;
import dev.relism.flash.ext.mcp.TextContent;
import dev.relism.flash.ext.mcp.Tool;
import dev.relism.flash.ext.mcp.ToolArguments;
import dev.relism.flash.ext.mcp.ToolResponse;
import dev.relism.flash.ext.oidc.ScopesAllowed;
@Tool(name = "write_only", description = "Only callable with the write scope")
@ScopesAllowed("write")
public class WriteScopeTool extends McpTool {
@Override
public ToolResponse call(ToolArguments args) {
return ToolResponse.success(new TextContent("written"));
}
}