refactor(core): make boot and middleware ordering deterministic
This commit is contained in:
+23
@@ -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) {}
|
||||
+43
-9
@@ -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(
|
||||
|
||||
+15
-6
@@ -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 -> {
|
||||
|
||||
+31
-30
@@ -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());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+138
-15
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+32
-3
@@ -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();
|
||||
|
||||
+9
-1
@@ -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
-4
@@ -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 + "\","
|
||||
|
||||
+150
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
@@ -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 {
|
||||
|
||||
+2
-2
@@ -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) {
|
||||
|
||||
+20
@@ -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"));
|
||||
}
|
||||
}
|
||||
+18
@@ -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"));
|
||||
}
|
||||
}
|
||||
+17
@@ -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"));
|
||||
}
|
||||
}
|
||||
+18
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user