feat(ext-mcp): let applications put middleware on the MCP route, and document the auth split
McpExtension built its middleware chain entirely internally, so a consumer had no way to add rate limiting, audit logging or tracing to /mcp — routine on every other Flash route. McpConfig.middleware(...) appends to the chain after the transport guards and after whatever McpSecurity resolved to, so it composes with OAuth2 protection instead of replacing it, and never satisfies REQUIRED. Docs: flash-ext-auth-core and flash-ext-auth-oidc both get a docs/ directory — oidc had none at all, and its module README documented types that no longer exist. Includes a migration table from flash-ext-oidc.
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
import dev.relism.flash.routing.Middleware;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@@ -28,6 +30,7 @@ public final class McpConfig {
|
||||
private final String authorizationServerIssuer;
|
||||
private final List<String> allowedOrigins;
|
||||
private final List<String> scopesSupported;
|
||||
private final List<Middleware> middleware;
|
||||
|
||||
private McpConfig(Builder b) {
|
||||
this.name = b.name;
|
||||
@@ -40,6 +43,7 @@ public final class McpConfig {
|
||||
this.authorizationServerIssuer = b.authorizationServerIssuer;
|
||||
this.allowedOrigins = List.copyOf(b.allowedOrigins);
|
||||
this.scopesSupported = List.copyOf(b.scopesSupported);
|
||||
this.middleware = List.copyOf(b.middleware);
|
||||
}
|
||||
|
||||
String name() { return name; }
|
||||
@@ -52,6 +56,7 @@ public final class McpConfig {
|
||||
String authorizationServerIssuer() { return authorizationServerIssuer; }
|
||||
List<String> allowedOrigins() { return allowedOrigins; }
|
||||
List<String> scopesSupported() { return scopesSupported; }
|
||||
List<Middleware> middleware() { return middleware; }
|
||||
|
||||
public static Builder builder(String name) { return new Builder(name); }
|
||||
|
||||
@@ -66,6 +71,7 @@ public final class McpConfig {
|
||||
private String authorizationServerIssuer;
|
||||
private final List<String> allowedOrigins = new ArrayList<>();
|
||||
private final List<String> scopesSupported = new ArrayList<>();
|
||||
private final List<Middleware> middleware = new ArrayList<>();
|
||||
|
||||
private Builder(String name) {
|
||||
if (name == null || name.isBlank())
|
||||
@@ -128,6 +134,22 @@ public final class McpConfig {
|
||||
*/
|
||||
public Builder scopesSupported(String... scopes) { this.scopesSupported.addAll(List.of(scopes)); return this; }
|
||||
|
||||
/**
|
||||
* Middleware to run on the MCP route, in the order given, after the transport guards and
|
||||
* after whatever {@link McpSecurity} resolved to. Rate limiting, audit logging, tracing —
|
||||
* anything that is routine on every other Flash route and had no way in here.
|
||||
*
|
||||
* <p>It runs on an authenticated request when OAuth2 protection is active, and is the only
|
||||
* thing standing in front of the endpoint when it is not: {@link McpSecurity#NONE} plus a
|
||||
* middleware of your own is how an app that authenticates some other way guards
|
||||
* {@code /mcp}. It never satisfies {@link McpSecurity#REQUIRED}, which still asks for a
|
||||
* real authorization server.
|
||||
*/
|
||||
public Builder middleware(Middleware... middleware) {
|
||||
this.middleware.addAll(List.of(middleware));
|
||||
return this;
|
||||
}
|
||||
|
||||
public McpConfig build() {
|
||||
if (toolsPackage == null || toolsPackage.isBlank())
|
||||
throw new IllegalStateException(
|
||||
|
||||
+2
-1
@@ -65,10 +65,11 @@ public class McpExtension implements FlashExtension {
|
||||
secured == null ? null : secured.rolesClaimPath());
|
||||
McpDispatcher dispatcher = new McpDispatcher(registry, config.name(), config.version(), config.instructions());
|
||||
|
||||
List<Middleware> chain = new ArrayList<>(3);
|
||||
List<Middleware> chain = new ArrayList<>(3 + config.middleware().size());
|
||||
chain.add(McpTransportGuards.httpExceptionGuard());
|
||||
chain.add(McpTransportGuards.originGuard(config.allowedOrigins()));
|
||||
if (secured != null) chain.add(secured.security());
|
||||
chain.addAll(config.middleware());
|
||||
|
||||
app.post(config.rootPath(), (req, res) -> { dispatcher.handle(req, res); return null; },
|
||||
chain.toArray(Middleware[]::new));
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
import dev.relism.flash.exceptions.HttpException;
|
||||
import dev.relism.flash.testing.FlashTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* Application middleware on the MCP route. Until this existed a consumer had no way to put rate
|
||||
* limiting, audit logging or tracing in front of {@code /mcp} — the chain was assembled entirely
|
||||
* inside the extension.
|
||||
*/
|
||||
class McpConfigMiddlewareTest {
|
||||
|
||||
private static final AtomicInteger CALLS = new AtomicInteger();
|
||||
|
||||
@RegisterExtension
|
||||
static FlashTest mcp = FlashTest.of(app -> app.install(new McpExtension(
|
||||
McpConfig.builder("middleware-server")
|
||||
.version("1.0.0")
|
||||
.toolsPackage("dev.relism.flash.ext.mcp.fixtures")
|
||||
.security(McpSecurity.NONE)
|
||||
.middleware(
|
||||
next -> (req, res) -> {
|
||||
CALLS.incrementAndGet();
|
||||
return next.handle(req, res);
|
||||
},
|
||||
next -> (req, res) -> {
|
||||
if ("deny".equals(req.header("X-Test-Gate"))) throw HttpException.forbidden();
|
||||
return next.handle(req, res);
|
||||
})
|
||||
.build())));
|
||||
|
||||
@Test
|
||||
void appMiddlewareRunsOnTheMcpRoute() {
|
||||
int before = CALLS.get();
|
||||
mcp.request()
|
||||
.json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}")
|
||||
.post("/mcp")
|
||||
.expectStatus(200);
|
||||
assertEquals(before + 1, CALLS.get());
|
||||
}
|
||||
|
||||
/**
|
||||
* The point of the hook: with {@link McpSecurity#NONE}, an app's own guard is the only thing
|
||||
* in front of the endpoint — which is how an app that does not authenticate with OAuth2
|
||||
* protects {@code /mcp} at all.
|
||||
*/
|
||||
@Test
|
||||
void appMiddlewareCanRejectTheRequest() {
|
||||
mcp.request()
|
||||
.header("X-Test-Gate", "deny")
|
||||
.json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}")
|
||||
.post("/mcp")
|
||||
.expectStatus(403);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user