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:
Zakaria El Orche
2026-09-10 19:12:44 +00:00
parent 9d39e24ccb
commit 829b9bf348
11 changed files with 590 additions and 39 deletions
@@ -16,6 +16,24 @@ installed (`ctx.find(OidcMiddleware.class)`), resolved once at boot in `McpExten
| `AUTO` (default) | protected | runs unprotected, logs a warning |
| `NONE` | never protected, even if oidc is installed elsewhere in the app | runs unprotected |
### Guarding `/mcp` without OAuth2
`McpSecurity` only ever answers "is `flash-ext-auth-oidc` installed". An app that authenticates
some other way sets `McpSecurity.NONE` and supplies its own guard:
```java
McpConfig.builder("my-server")
.toolsPackage("com.example.mcp")
.security(McpSecurity.NONE)
.middleware(myAuthMiddleware.protect())
.build();
```
`McpConfig.middleware(...)` runs after the transport guards and after whatever `McpSecurity`
resolved to, so it composes with OAuth2 protection rather than replacing it — the same hook is how
you add rate limiting, audit logging or tracing to the endpoint. It never satisfies `REQUIRED`,
which still asks for a real authorization server.
Use `REQUIRED` for anything you intend to run in production reachable over the network — it
turns "someone forgot to wire up OAuth2" into a startup crash instead of a silently open
endpoint. `AUTO` is meant for local development, where spinning up a real identity provider is
@@ -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(
@@ -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));
@@ -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);
}
}