Compare commits

4 Commits
Author SHA1 Message Date
Relism be7df3987c Merge pull request 'Feature/core/deterministic boot graph' (#6) from feature/core/deterministic-boot-graph into master
CI / Build & Test (push) Canceled after 5s
Publish Snapshot / Deploy Snapshot (push) Canceled after 3s
Reviewed-on: #6
2026-08-12 18:39:01 +00:00
Zakaria El Orche 891ef99b8e refactor(core): make boot and middleware ordering deterministic
CI / Build & Test (pull_request) Canceled after 23s
CI / Build & Test (push) Failing after 4m51s
2026-08-12 16:42:49 +00:00
Zakaria El OrcheandClaude Sonnet 5 d7f36a7aea feat(ext-mcp): add MCP (Model Context Protocol) server extension
CI / Build & Test (push) Failing after 4m57s
Streamable HTTP transport (JSON-RPC 2.0 over POST), one-class-per-tool/resource/prompt
API mirroring RequestHandler, boot-time-precompiled schema/list payloads for a zero-alloc
hot path, and optional OAuth2 protection built on flash-ext-oidc (lazy-loaded, RFC 8707
audience binding, RFC 9728 Protected Resource Metadata). Registers the module in the
root and flash-extensions POMs and adds the ext-mcp commit scope to AGENTS.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 00:22:40 +00:00
Zakaria El OrcheandClaude Sonnet 5 8ece9975de feat(ext-web-bundler): add build-time asset scanning and static-frontend strategy
Introduces AssetDirectoryScanner, StaticFrontendStrategy, and WebBundlerBuild for
build-time asset discovery, plus config/docs updates for frontend-type resolution.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 00:22:26 +00:00
103 changed files with 4248 additions and 462 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ Format: `<type>(<scope>): <short description>`
Allowed scopes: `core`, `ext-jackson`, `ext-openapi`, `ext-oidc`, `ext-routeviewer`, Allowed scopes: `core`, `ext-jackson`, `ext-openapi`, `ext-oidc`, `ext-routeviewer`,
`ext-view-core`, `ext-view-jte`, `ext-view-thymeleaf`, `ext-limiter`, `ext-web-bundler`, `ext-view-core`, `ext-view-jte`, `ext-view-thymeleaf`, `ext-limiter`, `ext-web-bundler`,
`ext-data-core`, `ext-data-jdbc`, `ext-data-hibernate`, `release`, `deps`, `ci`. `ext-mcp`, `ext-data-core`, `ext-data-jdbc`, `ext-data-hibernate`, `release`, `deps`, `ci`.
Examples: Examples:
``` ```
+18 -17
View File
@@ -10,6 +10,7 @@ A high-performance HTTP/1.1 server library for Java 21, built around virtual thr
| `flash-extensions/flash-ext-jackson` | Jackson JSON integration | | `flash-extensions/flash-ext-jackson` | Jackson JSON integration |
| `flash-extensions/flash-ext-openapi` | OpenAPI 3.0 spec + Swagger UI | | `flash-extensions/flash-ext-openapi` | OpenAPI 3.0 spec + Swagger UI |
| `flash-extensions/flash-ext-oidc` | OIDC Authorization Code + PKCE flow | | `flash-extensions/flash-ext-oidc` | OIDC Authorization Code + PKCE flow |
| `flash-extensions/flash-ext-mcp` | MCP (Model Context Protocol) server — Streamable HTTP, optional OAuth2 via flash-ext-oidc |
| `flash-extensions/flash-ext-view-core` | Minimal shared SSR runtime primitives | | `flash-extensions/flash-ext-view-core` | Minimal shared SSR runtime primitives |
| `flash-extensions/flash-ext-view-jte` | Opinionated jte SSR extension | | `flash-extensions/flash-ext-view-jte` | Opinionated jte SSR extension |
| `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf SSR extension | | `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf SSR extension |
@@ -64,24 +65,24 @@ app.get("/users/{id}", (req, res) -> {
### Class-based handlers ### Class-based handlers
Extend `RequestHandler` (or a subclass like `JacksonHandler`) and annotate with `@Route`: Extend `RequestHandler`, annotate it, then scan its package. Dependencies are cached in
`onInit()` after Flash has resolved its complete boot-time service graph:
```java ```java
@Route(method = HttpMethod.GET, path = "/api/users") @GET("/api/users")
public class ListUsers extends JacksonHandler { public class ListUsers extends RequestHandler {
@Override private UserService users;
public Object handle(Request req, Response res) throws Exception {
return json(res, List.of("alice", "bob")); @Override protected void onInit() { users = require(UserService.class); }
} @Override public Object handle(Request req, Response res) { return users.list(); }
} }
// Register: app.scan("dev.example.api");
app.register(new ListUsers());
``` ```
### Middleware ### Middleware
Apply middleware via `.with()` on the `RouteHandle` returned by any registration call: Apply middleware at registration. Flash composes the final chain at boot:
```java ```java
Middleware authCheck = next -> (req, res) -> { Middleware authCheck = next -> (req, res) -> {
@@ -90,14 +91,13 @@ Middleware authCheck = next -> (req, res) -> {
return next.handle(req, res); return next.handle(req, res);
}; };
app.get("/secure", (req, res) -> "secret data") app.get("/secure", (req, res) -> "secret data", authCheck);
.with(authCheck);
``` ```
Multiple middlewares are composed outermost-first (left-to-right in the call): Multiple middlewares are composed outermost-first (left-to-right in the call):
```java ```java
app.get("/admin", handler).with(logging, auth, rateLimit); app.get("/admin", handler, logging, auth, rateLimit);
// execution order: logging → auth → rateLimit → handler // execution order: logging → auth → rateLimit → handler
``` ```
@@ -119,22 +119,22 @@ processors, services):
```java ```java
app.mount("/api", scope -> { app.mount("/api", scope -> {
scope.get("/health", (req, res) -> "ok"); // → GET /api/health scope.get("/health", (req, res) -> "ok"); // → GET /api/health
scope.register(new UserHandler()); // @Route(path="/users") → GET /api/users
scope.scan("dev.example.api"); scope.scan("dev.example.api");
}); });
``` ```
## Extensions ## Extensions
Extensions are installed before route registration. Each extension receives the `FlashRegistrar` Extensions have one declarative `configure` method. They declare services, processors and route
and `FlashContext` — it can register routes, expose services, and register annotation processors. callbacks; Flash resolves the complete graph, materialises routes, compiles both routers, then
opens listeners. Extension install order never makes a service “not ready”.
```java ```java
FlashApp.create(8080) FlashApp.create(8080)
.install(new JacksonExtension()) .install(new JacksonExtension())
.install(new OpenApiExtension("/openapi", "My API", "1.0.0")) .install(new OpenApiExtension("/openapi", "My API", "1.0.0"))
.install(new OidcExtension(oidcConfig)) .install(new OidcExtension(oidcConfig))
.register(new MyHandler()) .scan("dev.example.handlers")
.start(); .start();
``` ```
@@ -142,6 +142,7 @@ See extension-specific READMEs for full details:
- [`flash-ext-jackson`](flash-extensions/flash-ext-jackson/README.md) - [`flash-ext-jackson`](flash-extensions/flash-ext-jackson/README.md)
- [`flash-ext-openapi`](flash-extensions/flash-ext-openapi/README.md) - [`flash-ext-openapi`](flash-extensions/flash-ext-openapi/README.md)
- [`flash-ext-oidc`](flash-extensions/flash-ext-oidc/README.md) - [`flash-ext-oidc`](flash-extensions/flash-ext-oidc/README.md)
- [`flash-ext-mcp`](flash-extensions/flash-ext-mcp/docs/README.md)
- [`flash-ext-view-jte`](flash-extensions/flash-ext-view-jte/README.md) - [`flash-ext-view-jte`](flash-extensions/flash-ext-view-jte/README.md)
- [`flash-ext-view-thymeleaf`](flash-extensions/flash-ext-view-thymeleaf/README.md) - [`flash-ext-view-thymeleaf`](flash-extensions/flash-ext-view-thymeleaf/README.md)
@@ -4,9 +4,11 @@ import dev.relism.flash.ext.data.core.Tx;
import dev.relism.flash.ext.data.core.TxDefinition; import dev.relism.flash.ext.data.core.TxDefinition;
import dev.relism.flash.ext.data.core.TxManager; import dev.relism.flash.ext.data.core.TxManager;
import dev.relism.flash.ext.data.core.TransactionPropagation; import dev.relism.flash.ext.data.core.TransactionPropagation;
import dev.relism.flash.extension.ExtensionPhase; import dev.relism.flash.extension.FlashRegistrar;
import dev.relism.flash.extension.FlashContext; import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashExtension; import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.routing.MiddlewareKey;
import dev.relism.flash.routing.MiddlewareNode;
import dev.relism.flash.routing.Middleware; import dev.relism.flash.routing.Middleware;
import jakarta.transaction.Transactional; import jakarta.transaction.Transactional;
@@ -14,6 +16,7 @@ import java.util.List;
import java.util.Objects; import java.util.Objects;
public final class DataExtension implements FlashExtension { public final class DataExtension implements FlashExtension {
private static final MiddlewareKey TRANSACTION = MiddlewareKey.of("flash.data.transaction");
private final TxManager txManager; private final TxManager txManager;
private final Tx tx; private final Tx tx;
@@ -23,7 +26,7 @@ public final class DataExtension implements FlashExtension {
} }
@Override @Override
public void provide(FlashContext ctx) { public void configure(FlashRegistrar<?> app, FlashContext ctx) {
ctx.provide(Tx.class, tx); ctx.provide(Tx.class, tx);
ctx.provide(TxManager.class, txManager); ctx.provide(TxManager.class, txManager);
ctx.addAnnotationProcessor(handlerClass -> { ctx.addAnnotationProcessor(handlerClass -> {
@@ -36,15 +39,10 @@ public final class DataExtension implements FlashExtension {
Middleware middleware = next -> (req, res) -> { Middleware middleware = next -> (req, res) -> {
return tx.call(definition, () -> next.handle(req, res)); return tx.call(definition, () -> next.handle(req, res));
}; };
return List.of(middleware); return List.of(MiddlewareNode.of(TRANSACTION, middleware));
}); });
} }
@Override
public int priority() {
return ExtensionPhase.EARLY.value;
}
private TransactionPropagation mapTxType(Transactional.TxType txType) { private TransactionPropagation mapTxType(Transactional.TxType txType) {
return switch (txType) { return switch (txType) {
case REQUIRED -> TransactionPropagation.REQUIRED; case REQUIRED -> TransactionPropagation.REQUIRED;
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.json.JsonMapper; import com.fasterxml.jackson.databind.json.JsonMapper;
import dev.relism.flash.extension.FlashContext; import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashRegistrar;
import dev.relism.flash.extension.FlashExtension; import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.routing.Middleware; import dev.relism.flash.routing.Middleware;
@@ -12,7 +13,8 @@ import dev.relism.flash.routing.Middleware;
* *
* <p>Exposes a {@link Json} utility instance in the {@link FlashContext} under * <p>Exposes a {@link Json} utility instance in the {@link FlashContext} under
* {@code Json.class}. Any handler or extension can retrieve it via {@code ctx.require(Json.class)} * {@code Json.class}. Any handler or extension can retrieve it via {@code ctx.require(Json.class)}
* inside {@code onInit()} (class-based) or inside {@link FlashExtension#routes} (extensions). * inside {@code onInit()} (class-based) or from a {@link FlashContext#onReady(Runnable)}
* callback (extensions).
* *
* <p>The raw {@link ObjectMapper} is also registered under {@code ObjectMapper.class} * <p>The raw {@link ObjectMapper} is also registered under {@code ObjectMapper.class}
* for extensions that need direct mapper access (e.g. OpenAPI schema generation). * for extensions that need direct mapper access (e.g. OpenAPI schema generation).
@@ -83,7 +85,7 @@ public class JacksonExtension implements FlashExtension {
} }
@Override @Override
public void provide(FlashContext ctx) { public void configure(FlashRegistrar<?> app, FlashContext ctx) {
Json json = new Json(mapper); Json json = new Json(mapper);
ctx.provide(Json.class, json); ctx.provide(Json.class, json);
ctx.provide(ObjectMapper.class, mapper); ctx.provide(ObjectMapper.class, mapper);
@@ -19,12 +19,13 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
class JacksonExtensionTest { class JacksonExtensionTest {
@Test @Test
void provide_registers_json_mapper_and_middleware() { void configure_registers_json_mapper_and_middleware() {
FlashContext ctx = new FlashContext(); FlashContext ctx = new FlashContext();
ObjectMapper mapper = new ObjectMapper(); ObjectMapper mapper = new ObjectMapper();
JacksonExtension ext = new JacksonExtension(mapper); JacksonExtension ext = new JacksonExtension(mapper);
ext.provide(ctx); ext.configure(null, ctx);
ctx.complete();
assertNotNull(ctx.require(Json.class)); assertNotNull(ctx.require(Json.class));
assertNotNull(ctx.require(JacksonMiddleware.class)); assertNotNull(ctx.require(JacksonMiddleware.class));
@@ -5,12 +5,13 @@ import dev.relism.flash.ext.openapi.OpenApiContributorRegistry;
import dev.relism.flash.ext.openapi.OpenApiOperationContribution; import dev.relism.flash.ext.openapi.OpenApiOperationContribution;
import dev.relism.flash.ext.openapi.OpenApiResponseContribution; import dev.relism.flash.ext.openapi.OpenApiResponseContribution;
import dev.relism.flash.extension.AnnotationProcessor; import dev.relism.flash.extension.AnnotationProcessor;
import dev.relism.flash.extension.ExtensionPhase; import dev.relism.flash.extension.FlashRegistrar;
import dev.relism.flash.extension.FlashContext; import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashExtension; import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.extension.FlashRegistrar;
import dev.relism.flash.http.HttpStatus; import dev.relism.flash.http.HttpStatus;
import dev.relism.flash.routing.Middleware; import dev.relism.flash.routing.Middleware;
import dev.relism.flash.routing.MiddlewareKey;
import dev.relism.flash.routing.MiddlewareNode;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.List; import java.util.List;
@@ -44,21 +45,16 @@ import java.util.Map;
* app.install(new LimiterExtension( * app.install(new LimiterExtension(
* new LimiterConfig().registerResolver("auth_user", req -> ClaimsHolder.user().sub()))); * new LimiterConfig().registerResolver("auth_user", req -> ClaimsHolder.user().sub())));
* *
* // inside FlashExtension.routes() or after install(): * // inside a FlashContext.onReady(...) callback:
* Guard guard = ctx.require(Guard.class); * Guard guard = ctx.require(Guard.class);
* app.get("/api/search", handler, guard.limit("ip", 50, 1, TimeUnit.SECONDS)); * app.get("/api/search", handler, guard.limit("ip", 50, 1, TimeUnit.SECONDS));
* }</pre> * }</pre>
*/ */
public final class LimiterExtension implements FlashExtension { public final class LimiterExtension implements FlashExtension {
private static final MiddlewareKey LIMIT = MiddlewareKey.of("flash.limiter.limit");
private final LimiterConfig config; private final LimiterConfig config;
/**
* Rate limiting runs before authentication — cheaper check rejects over-limit
* requests before any token validation occurs.
*/
@Override public int priority() { return ExtensionPhase.EARLY.value; }
/** Installs with default config (only the built-in {@code "ip"} resolver). */ /** Installs with default config (only the built-in {@code "ip"} resolver). */
public LimiterExtension() { public LimiterExtension() {
this(new LimiterConfig()); this(new LimiterConfig());
@@ -70,7 +66,7 @@ public final class LimiterExtension implements FlashExtension {
} }
@Override @Override
public void provide(FlashContext ctx) { public void configure(FlashRegistrar<?> app, FlashContext ctx) {
BucketStore store = new BucketStore(); BucketStore store = new BucketStore();
Guard guard = new Guard(config, store); Guard guard = new Guard(config, store);
@@ -90,17 +86,15 @@ public final class LimiterExtension implements FlashExtension {
ann.strategy().create() ann.strategy().create()
); );
return List.of(buildMiddleware(resolver, cfg, store)); return List.of(MiddlewareNode.of(LIMIT, buildMiddleware(resolver, cfg, store)));
});
ctx.onReady(() -> {
try {
OpenApiIntegration.register(ctx);
} catch (NoClassDefFoundError ignored) {
// flash-ext-openapi not available — OpenAPI integration disabled
}
}); });
}
@Override
public void routes(FlashRegistrar<?> app, FlashContext ctx) {
try {
OpenApiIntegration.register(ctx);
} catch (NoClassDefFoundError ignored) {
// flash-ext-openapi not available — OpenAPI integration disabled
}
} }
// ── Package-private helper — shared with Guard ──────────────────────────── // ── Package-private helper — shared with Guard ────────────────────────────
@@ -41,7 +41,8 @@ class LimiterOpenApiInteropTest {
OpenApiContributorRegistry registry = new OpenApiContributorRegistry(); OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
ctx.provide(OpenApiContributorRegistry.class, registry); ctx.provide(OpenApiContributorRegistry.class, registry);
new LimiterExtension().routes(null, ctx); new LimiterExtension().configure(null, ctx);
ctx.complete();
assertEquals(1, registry.contributors().size()); assertEquals(1, registry.contributors().size());
} }
@@ -51,7 +52,8 @@ class LimiterOpenApiInteropTest {
FlashContext ctx = new FlashContext(); FlashContext ctx = new FlashContext();
OpenApiContributorRegistry registry = new OpenApiContributorRegistry(); OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
ctx.provide(OpenApiContributorRegistry.class, registry); ctx.provide(OpenApiContributorRegistry.class, registry);
new LimiterExtension().routes(null, ctx); new LimiterExtension().configure(null, ctx);
ctx.complete();
OpenApiContributor contributor = registry.contributors().getFirst(); OpenApiContributor contributor = registry.contributors().getFirst();
OpenApiOperationContribution operation = contributor.operationFor(LimitedHandler.class); OpenApiOperationContribution operation = contributor.operationFor(LimitedHandler.class);
@@ -73,7 +75,8 @@ class LimiterOpenApiInteropTest {
FlashContext ctx = new FlashContext(); FlashContext ctx = new FlashContext();
OpenApiContributorRegistry registry = new OpenApiContributorRegistry(); OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
ctx.provide(OpenApiContributorRegistry.class, registry); ctx.provide(OpenApiContributorRegistry.class, registry);
new LimiterExtension().routes(null, ctx); new LimiterExtension().configure(null, ctx);
ctx.complete();
OpenApiContributor contributor = registry.contributors().getFirst(); OpenApiContributor contributor = registry.contributors().getFirst();
OpenApiOperationContribution operation = contributor.operationFor(PlainHandler.class); OpenApiOperationContribution operation = contributor.operationFor(PlainHandler.class);
@@ -0,0 +1,58 @@
# flash-ext-mcp
`flash-ext-mcp` turns a Flash5 app into an [MCP](https://modelcontextprotocol.io) (Model Context
Protocol) server: JSON-RPC 2.0 over the Streamable HTTP transport, tools/resources/prompts
declared as plain classes and discovered at boot, optional OAuth2 protection built on
`flash-ext-oidc`.
## Quick Start
```java
FlashApp.create(8080)
.install(new McpExtension(McpConfig.builder("my-mcp-server")
.toolsPackage("com.example.tools")
.build()))
.start();
```
```java
@Tool(name = "get_weather", description = "Get current weather for a city",
args = @ToolArg(name = "city", description = "City name", required = true))
public class GetWeatherTool extends McpTool {
private WeatherService weatherService;
@Override
protected void onInit() {
weatherService = require(WeatherService.class);
}
@Override
public ToolResponse call(ToolArguments args) {
return ToolResponse.success(new TextContent(weatherService.fetch(args.getString("city"))));
}
}
```
## Operating Model
- **One class per tool/resource/prompt** — mirrors `RequestHandler`: a no-arg constructor,
`onInit()` to cache services from `FlashContext`, one hot-path method
(`call`/`read`/`render`). No CDI, no field injection, no reflection on the hot path.
- **Boot-time precompilation** — `tools/list`/`resources/list`/`prompts/list` JSON payloads
(including JSON Schema) are built once at boot and spliced verbatim into responses. See
`tools-resources-prompts.md`.
- **Transport**: Streamable HTTP, `POST`-only, stateless in this revision — see `transport.md`
for exactly what that means and why.
- **Security**: optional, policy-driven OAuth2 via `flash-ext-oidc` — see `security.md`.
- **JSON**: this extension owns its JSON handling independently of `flash-ext-jackson` — see
`jackson-interop.md` for why, and how a future opt-in reuse could work.
## Documents
- [`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,51 @@
# Why no `flash-ext-jackson` interop (yet)
## The decision
`flash-ext-mcp` does not depend on, or integrate with, `flash-ext-jackson`. It brings its own
JSON handling (`jackson-databind`/`jackson-core` as a plain library dependency, wrapped by the
internal `McpJson` utility) and never touches `flash-ext-jackson`'s `Json`/`JacksonMiddleware`/
shared `ObjectMapper`, even if the host app has `flash-ext-jackson` installed. This was a
deliberate choice, discussed and made explicitly — not an oversight — and is written down here
so it isn't accidentally "fixed" later without re-litigating the trade-off.
## Why
`flash-ext-jackson`'s `Json` class is built around full databinding:
`mapper.readValue(bytes, SomeDto.class)` / `mapper.writeValueAsBytes(obj)` — reflection-driven
property matching in both directions. The MCP JSON-RPC envelope has a **fixed, known shape**
(`{jsonrpc, id, method, params}` in, `{jsonrpc, id, result|error}` out) defined by a spec, not by
application DTOs. Given that, hand-writing it with `JsonGenerator` directly is both simpler and
strictly cheaper than round-tripping through databinding: no property-name matching, no
reflection, no intermediate POJO graph for the parts of the response this extension controls
(the envelope itself, `tools/list`/`resources/list`/`prompts/list` — precompiled once at boot,
see `tools-resources-prompts.md` — and the fixed `TextContent`/`TextResourceContents`/
`PromptMessage` shapes). `ToolArguments`/`PromptArguments` read the incoming `arguments` object
as a `JsonNode` tree, not as a databound class, for the same reason — a JSON-RPC tool call's
arguments aren't a DTO with getters/setters, they're a dynamic, per-tool-defined bag of values.
This mirrors how `flash-ext-oidc` already handles its own internal JSON needs (`json-smart` for
token-endpoint responses) independently of `flash-ext-jackson` — extensions with protocol-level
JSON needs that are shaped by a spec, not by user code, own that JSON handling themselves rather
than routing it through the app's general-purpose JSON extension.
## What this means practically
- Installing `flash-ext-mcp` never requires installing `flash-ext-jackson`. A pure MCP server
with no other JSON REST routes has zero unrelated dependencies to configure.
- If the host app *does* have `flash-ext-jackson` installed for its own REST routes, that
`ObjectMapper`'s configuration (custom modules, date formatting, naming strategy, etc.) is
**not** consulted by `flash-ext-mcp` — the two JSON paths are entirely independent today.
## What a future opt-in reuse could look like
Nothing here rules out a later, additive convenience layer: `McpExtension.routes()` could check
`ctx.find(ObjectMapper.class)` (populated by `JacksonExtension.provide()`) and, if present, use
that shared mapper as the backing for an escape hatch such as `ToolArguments.as(Class<T>)` or
for a tool that wants to `ToolResponse.success(someRecord)` and have it serialized with the
app's own conventions — falling back to a locally-constructed default `ObjectMapper` when
`flash-ext-jackson` isn't installed, the same "prefer shared, degrade to sane default" shape
already used for `McpSecurity.AUTO`. That would be purely additive on top of the
`JsonGenerator`-based envelope/content writing described above, not a replacement for it — the
fixed-shape protocol plumbing has no reason to ever go through databinding, regardless of what
convenience layer gets added around it.
@@ -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`.
@@ -0,0 +1,170 @@
# 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
installed (`ctx.find(OidcMiddleware.class)`), resolved once at boot in `McpExtension.routes()`:
| Policy | `flash-ext-oidc` installed | `flash-ext-oidc` absent |
|---|---|---|
| `REQUIRED` | protected | **boot fails** (`IllegalStateException`) |
| `AUTO` (default) | protected | runs unprotected, logs a warning |
| `NONE` | never protected, even if oidc is installed elsewhere in the app | runs unprotected |
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
friction you don't want yet.
## Why `flash-ext-oidc` is an *optional* Maven dependency, concretely
Maven's `<optional>true</optional>` only affects **transitive** propagation: consumers of
`flash-ext-mcp` don't get `flash-ext-oidc` pulled in automatically unless they add it themselves.
Within `flash-ext-mcp` itself, `flash-ext-oidc`'s classes are on the compile/test classpath as
normal — this extension can (and does) reference `OidcMiddleware`/`ClaimsHolder` directly in
source.
That reference is isolated in its own class, `McpOidcIntegration`, invoked only from inside a
`catch (NoClassDefFoundError)` block. A bare class-literal like `OidcMiddleware.class` (which
`ctx.find(OidcMiddleware.class)` needs) forces the JVM to resolve that type the moment it's
evaluated — if `flash-ext-oidc` is not on the *runtime* classpath at all (a genuinely
MCP-only install, no OAuth2 anywhere in the app), the first such reference throws
`NoClassDefFoundError`. Keeping that reference inside a separate, lazily-loaded class means
`McpExtension` itself loads and works fine standalone; only the attempt to actually use OIDC
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 — zero-config by default
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(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
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"] }
```
`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.
### `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
`flash-ext-oidc`'s middleware throws `HttpException.unauthorized()`/`forbidden()` on auth
failure. Flash5's core does **not** special-case `HttpException` in the default exception
handler — the out-of-the-box `AbstractRouter` default always returns a generic `500`, regardless
of the thrown exception's embedded status code; only an app that explicitly calls
`FlashApp#onException(...)` (or installs something that does) gets `HttpException.status()`
honored.
To keep the MCP endpoint correct regardless of what the rest of the app configures,
`McpTransportGuards.httpExceptionGuard()` wraps the whole route and translates `HttpException`
into the right HTTP status itself, rather than letting it fall through to the app's (possibly
unconfigured) global handler. This is scoped entirely to the MCP route — it does not touch or
override the app's `onException` for any other route.
@@ -0,0 +1,107 @@
# Tools, Resources, Prompts
## One class per feature
Every tool, resource, and prompt is its own class — the same shape as a Flash `RequestHandler`,
minus the HTTP-specific bits:
```java
public abstract class McpTool {
protected void onInit() {} // cache services here, once, at boot
protected <T> T require(Class<T> type) { ... } // FlashContext lookup
public abstract ToolResponse call(ToolArguments args) throws Exception; // hot path
}
```
`McpResource` (`read()`) and `McpPrompt` (`render(PromptArguments)`) follow the exact same
shape. There is deliberately no CDI-style `@Inject` and no method-per-tool bean class — Flash5
handlers are classes, and MCP features follow that convention.
## Declaring metadata
Metadata (name, description, input schema) lives entirely in the annotation, not in reflected
method signatures — the whole JSON Schema is known at scan time and compiled once:
```java
@Tool(
name = "get_weather",
description = "Get current weather for a city",
args = {
@ToolArg(name = "city", description = "City name", required = true),
@ToolArg(name = "days", type = ToolArgType.INTEGER, description = "Forecast horizon")
}
)
public class GetWeatherTool extends McpTool {
@Override
public ToolResponse call(ToolArguments args) {
String city = args.getString("city");
int days = args.getInt("days", 1);
...
}
}
```
`ToolArgType` maps directly to JSON Schema primitive types: `STRING`, `INTEGER`, `NUMBER`,
`BOOLEAN`, `OBJECT`, `ARRAY`. Nested object/array schemas beyond the primitive type keyword are
not modeled in this revision — declare those tools with a looser `OBJECT`/`ARRAY` type and parse
the raw shape via `ToolArguments.raw(name)`.
`ToolArguments`/`PromptArguments` are thin typed accessors over the already-parsed JSON — no
databinding, no reflection, no intermediate DTO:
```java
args.getString("city");
args.getInt("days", 1);
args.getBoolean("metric", true);
args.raw("filters"); // escape hatch: JsonNode for nested/array arguments
```
## Discovery
`McpConfig.toolsPackage("com.example.tools")` scans that package (and subpackages) for concrete
`McpTool`/`McpResource`/`McpPrompt` subclasses carrying `@Tool`/`@Resource`/`@Prompt`. Same
fail-fast contract as `FlashApp.scan()`: missing package, missing no-arg constructor, or a class
that fails to load aborts startup immediately with a clear message. Duplicate names/URIs also
fail fast at boot.
## Resources and Prompts
```java
@Resource(uri = "config://app-settings", description = "Application settings", mimeType = "application/json")
public class AppSettingsResource extends McpResource {
@Override
public ResourceContents read() {
return TextResourceContents.of(uri(), "application/json", settingsJson());
}
}
@Prompt(name = "summarize", args = @PromptArg(name = "text", required = true))
public class SummarizePrompt extends McpPrompt {
@Override
public PromptMessage render(PromptArguments args) {
return PromptMessage.withUserRole(new TextContent("Summarize: " + args.getString("text")));
}
}
```
`McpResource.uri()` returns the URI declared on `@Resource`, cached at bind time — no repeated
annotation lookups on the hot path.
## Content types
`Content` and `ResourceContents` are `sealed`, currently permitting only `TextContent` and
`TextResourceContents` respectively. This is a deliberate v1 scope cut, not an oversight — image
content, embedded resources, and blob resources are extension points for a future revision
(extend the `permits` clause and `McpContentWriter`).
## Tool failures vs. protocol errors
A `McpTool.call(...)` that throws is caught by the dispatcher and turned into
`ToolResponse.error(message)` — per the MCP specification this is a normal JSON-RPC *result*
with `isError: true`, not a JSON-RPC error, so the calling model can see and react to it. Prefer
returning `ToolResponse.error(...)` explicitly when you can produce a better message than the
raw exception text.
`McpResource.read()`/`McpPrompt.render(...)` failures, by contrast, surface as JSON-RPC errors
(`-32603 Internal error`) — the specification does not define a soft-failure content convention
for those two.
@@ -0,0 +1,45 @@
# Transport
`flash-ext-mcp` implements the **Streamable HTTP** transport from the MCP specification
(revision `2025-11-25`). `stdio` is out of scope — Flash5 is an HTTP framework, and a
subprocess-stdio transport doesn't fit its model.
## What this revision implements
- A single `POST {rootPath}` endpoint (default `/mcp`) accepting one JSON-RPC 2.0 message per
request and responding with a plain JSON object — the "standard JSON object response" mode the
specification allows as an alternative to opening a Server-Sent Events stream per request.
- `Origin` header validation (DNS-rebinding protection), configurable via
`McpConfig.allowedOrigins(...)`.
- Full JSON-RPC lifecycle: `initialize`, `notifications/initialized` (and any other
`notifications/*`/id-less message — answered with a bare `202 Accepted`, no body, per
JSON-RPC's notification semantics), `ping`, `tools/list`, `tools/call`, `resources/list`,
`resources/read`, `prompts/list`, `prompts/get`.
## What this revision deliberately does not implement
- **No `Mcp-Session-Id` / session state.** The specification says a server "MAY assign a session
ID at initialization time" — it is optional, not mandatory. This server is stateless: every
`POST` is handled independently, with no server-side session store. `initialize` does not need
to precede other calls for the server to function (there's no session to be "not initialized"
yet), which is a looser contract than a session-aware server would enforce — acceptable for a
static, boot-time-defined tool/resource/prompt catalog.
- **No Server-Sent Events stream.** `GET {rootPath}` (used by session-aware servers to open a
standing SSE stream for server-initiated pushes) is not registered — MCP clients that only
speak the request/response half of Streamable HTTP work unaffected; clients that require a
standing SSE connection are not supported by this revision.
Both are real, intentional scope cuts for a first version — not just to keep the surface area
small: a static, precompiled tool catalog (see `tools-resources-prompts.md`) has no
`listChanged` events to push and no long-running server-initiated messages to stream, so the
stateful half of the transport buys little for the common case this extension targets. Sessions
and SSE are natural extension points if a future revision needs server push (e.g. dynamic tool
registration, elicitation, or sampling requests initiated by the server).
## Why `POST`, not the new `QUERY` HTTP method
Flash5's core recently gained `HttpMethod.QUERY` (safe, idempotent, carries a body — a good
semantic fit for JSON-RPC-over-HTTP in general). It is **not** used here: the MCP Streamable
HTTP specification mandates `POST` for the client-to-server message path. Real MCP clients send
`POST`; using `QUERY` instead would break interoperability with every existing client for a
semantic nicety this extension doesn't need standalone.
+43
View File
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId>
<version>2.1.0-SNAPSHOT</version>
</parent>
<artifactId>flash-ext-mcp</artifactId>
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash</artifactId>
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-oidc</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,8 @@
package dev.relism.flash.ext.mcp;
/**
* MCP tool/prompt content block. {@code sealed} to the variants this extension currently
* writes on the wire — extend the permits clause (and {@link McpContentWriter}) to add
* {@code ImageContent}, {@code EmbeddedResource}, etc. in a future revision.
*/
public sealed interface Content permits TextContent {}
@@ -0,0 +1,13 @@
package dev.relism.flash.ext.mcp;
/** Standard JSON-RPC 2.0 error codes used by the MCP transport. */
final class JsonRpcErrorCode {
private JsonRpcErrorCode() {}
static final int PARSE_ERROR = -32700;
static final int INVALID_REQUEST = -32600;
static final int METHOD_NOT_FOUND = -32601;
static final int INVALID_PARAMS = -32602;
static final int INTERNAL_ERROR = -32603;
}
@@ -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) {}
@@ -0,0 +1,156 @@
package dev.relism.flash.ext.mcp;
import java.util.ArrayList;
import java.util.List;
/**
* Immutable configuration for {@link McpExtension}.
*
* <pre>{@code
* McpConfig.builder("my-mcp-server")
* .version("1.0.0")
* .rootPath("/mcp")
* .toolsPackage("com.example.tools")
* .security(McpSecurity.REQUIRED)
* .scopesSupported("openid", "profile", "email")
* .build();
* }</pre>
*/
public final class McpConfig {
private final String name;
private final String version;
private final String instructions;
private final String rootPath;
private final String toolsPackage;
private final McpSecurity security;
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;
this.version = b.version;
this.instructions = b.instructions;
this.rootPath = b.rootPath;
this.toolsPackage = b.toolsPackage;
this.security = b.security;
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; }
String version() { return version; }
String instructions() { return instructions; }
String rootPath() { return rootPath; }
String toolsPackage() { return toolsPackage; }
McpSecurity security() { return security; }
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); }
public static final class Builder {
private final String name;
private String version = "1.0.0";
private String instructions;
private String rootPath = "/mcp";
private String toolsPackage;
private McpSecurity security = McpSecurity.AUTO;
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())
throw new IllegalArgumentException("McpConfig server name cannot be blank");
this.name = name;
}
/** Server version reported in {@code initialize}'s {@code serverInfo}. Default {@code "1.0.0"}. */
public Builder version(String version) { this.version = version; return this; }
/** Free-text instructions surfaced to the client at {@code initialize} time. */
public Builder instructions(String instructions) { this.instructions = instructions; return this; }
/** HTTP path for the Streamable HTTP endpoint. Default {@code "/mcp"}. */
public Builder rootPath(String rootPath) { this.rootPath = normalize(rootPath); return this; }
/** Package scanned for {@link Tool @Tool}/{@link Resource @Resource}/{@link Prompt @Prompt} classes. Required. */
public Builder toolsPackage(String toolsPackage) { this.toolsPackage = toolsPackage; return this; }
/** OAuth2 requirement policy. Default {@link McpSecurity#AUTO}. */
public Builder security(McpSecurity security) { this.security = security; return this; }
/**
* 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, 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; }
/**
* Origins allowed to call the MCP endpoint (DNS-rebinding protection, per the Streamable
* HTTP transport spec). If never set, {@code Origin} validation is skipped and a warning
* is logged at boot.
*/
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(
"McpConfig.toolsPackage(...) is required — declare at least one @Tool/@Resource/@Prompt class");
return new McpConfig(this);
}
private static String normalize(String path) {
if (path == null || path.isBlank()) throw new IllegalArgumentException("rootPath cannot be blank");
return path.startsWith("/") ? path : "/" + path;
}
}
}
@@ -0,0 +1,54 @@
package dev.relism.flash.ext.mcp;
import com.fasterxml.jackson.core.JsonGenerator;
import java.io.IOException;
import java.util.List;
import java.util.Locale;
/**
* Direct {@link JsonGenerator} writers for the fixed, known shapes of {@link Content},
* {@link ResourceContents} and {@link PromptMessage} — no databinding, one {@code switch}
* per call, matching the fixed wire shape defined by the MCP specification.
*/
final class McpContentWriter {
private McpContentWriter() {}
static void writeContentArray(JsonGenerator gen, List<Content> items) throws IOException {
gen.writeStartArray();
for (Content c : items) writeContent(gen, c);
gen.writeEndArray();
}
static void writeContent(JsonGenerator gen, Content content) throws IOException {
if (content instanceof TextContent tc) {
gen.writeStartObject();
gen.writeStringField("type", "text");
gen.writeStringField("text", tc.text());
gen.writeEndObject();
return;
}
throw new IllegalStateException("Unhandled Content variant: " + content.getClass());
}
static void writeResourceContents(JsonGenerator gen, ResourceContents contents) throws IOException {
if (contents instanceof TextResourceContents trc) {
gen.writeStartObject();
gen.writeStringField("uri", trc.uri());
gen.writeStringField("mimeType", trc.mimeType());
gen.writeStringField("text", trc.text());
gen.writeEndObject();
return;
}
throw new IllegalStateException("Unhandled ResourceContents variant: " + contents.getClass());
}
static void writePromptMessage(JsonGenerator gen, PromptMessage message) throws IOException {
gen.writeStartObject();
gen.writeStringField("role", message.role().name().toLowerCase(Locale.ROOT));
gen.writeFieldName("content");
writeContent(gen, message.content());
gen.writeEndObject();
}
}
@@ -0,0 +1,253 @@
package dev.relism.flash.ext.mcp;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonNode;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.Response;
import java.io.IOException;
/**
* JSON-RPC 2.0 dispatcher for the MCP Streamable HTTP endpoint — one instance per
* {@link McpExtension}, built once at boot from a resolved {@link McpRegistry}.
*
* <p>Per the MCP specification, a {@code tools/call} failure is a normal JSON-RPC
* <em>result</em> with {@code isError: true} (see {@link ToolResponse#error}), not a JSON-RPC
* 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. 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 {
/** Protocol revision this dispatcher implements. */
static final String PROTOCOL_VERSION = "2025-11-25";
private final McpRegistry registry;
private final String serverName;
private final String serverVersion;
private final String instructions;
McpDispatcher(McpRegistry registry, String serverName, String serverVersion, String instructions) {
this.registry = registry;
this.serverName = serverName;
this.serverVersion = serverVersion;
this.instructions = instructions;
}
void handle(Request req, Response res) {
byte[] body = req.body().bytes();
JsonNode root;
try {
root = McpJson.parse(body);
} catch (IOException e) {
writeError(res, 400, null, JsonRpcErrorCode.PARSE_ERROR, "Parse error: " + e.getMessage());
return;
}
if (root == null || !root.isObject()) {
writeError(res, 400, null, JsonRpcErrorCode.INVALID_REQUEST, "Request must be a JSON object");
return;
}
JsonNode idNode = root.get("id");
boolean isNotification = idNode == null;
String method = root.path("method").asText(null);
JsonNode params = root.path("params");
if (method == null || method.isBlank()) {
if (isNotification) { res.status(202); return; }
writeError(res, 400, idNode, JsonRpcErrorCode.INVALID_REQUEST, "Missing \"method\"");
return;
}
try {
switch (method) {
case "initialize" -> handleInitialize(res, idNode);
case "notifications/initialized", "notifications/cancelled" -> res.status(202);
case "ping" -> handlePing(res, idNode);
case "tools/list" -> handleToolsList(res, idNode);
case "tools/call" -> handleToolsCall(res, idNode, params);
case "resources/list" -> handleResourcesList(res, idNode);
case "resources/read" -> handleResourcesRead(res, idNode, params);
case "prompts/list" -> handlePromptsList(res, idNode);
case "prompts/get" -> handlePromptsGet(res, idNode, params);
default -> {
if (isNotification) { res.status(202); return; }
throw McpProtocolException.methodNotFound(method);
}
}
} catch (McpProtocolException e) {
writeError(res, 200, idNode, e.code, e.getMessage());
} catch (Exception e) {
writeError(res, 200, idNode, JsonRpcErrorCode.INTERNAL_ERROR, "Internal error: " + e.getMessage());
}
}
// ── Method handlers ──────────────────────────────────────────────────────
private void handleInitialize(Response res, JsonNode id) {
writeResult(res, id, gen -> {
gen.writeStartObject();
gen.writeStringField("protocolVersion", PROTOCOL_VERSION);
gen.writeObjectFieldStart("capabilities");
if (registry.hasTools()) writeEmptyCapability(gen, "tools");
if (registry.hasResources()) writeEmptyCapability(gen, "resources");
if (registry.hasPrompts()) writeEmptyCapability(gen, "prompts");
gen.writeEndObject();
gen.writeObjectFieldStart("serverInfo");
gen.writeStringField("name", serverName);
gen.writeStringField("version", serverVersion);
gen.writeEndObject();
if (instructions != null && !instructions.isBlank())
gen.writeStringField("instructions", instructions);
gen.writeEndObject();
});
}
private static void writeEmptyCapability(JsonGenerator gen, String field) throws IOException {
gen.writeObjectFieldStart(field);
gen.writeBooleanField("listChanged", false);
gen.writeEndObject();
}
private void handlePing(Response res, JsonNode id) {
writeResult(res, id, gen -> { gen.writeStartObject(); gen.writeEndObject(); });
}
private void handleToolsList(Response res, JsonNode id) {
writeResult(res, id, gen -> {
gen.writeStartObject();
gen.writeFieldName("tools");
gen.writeRawValue(registry.toolsListJson());
gen.writeEndObject();
});
}
private void handleToolsCall(Response res, JsonNode id, JsonNode params) {
String name = params.path("name").asText(null);
if (name == null || name.isBlank())
throw McpProtocolException.invalidParams("\"name\" is required");
McpRegistry.RegisteredTool tool = registry.tool(name);
if (tool == null)
throw McpProtocolException.invalidParams("Unknown tool: " + name);
ToolResponse result;
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 -> {
gen.writeStartObject();
gen.writeBooleanField("isError", finalResult.isError());
gen.writeFieldName("content");
McpContentWriter.writeContentArray(gen, finalResult.content());
gen.writeEndObject();
});
}
private void handleResourcesList(Response res, JsonNode id) {
writeResult(res, id, gen -> {
gen.writeStartObject();
gen.writeFieldName("resources");
gen.writeRawValue(registry.resourcesListJson());
gen.writeEndObject();
});
}
private void handleResourcesRead(Response res, JsonNode id, JsonNode params) throws Exception {
String uri = params.path("uri").asText(null);
if (uri == null || uri.isBlank())
throw McpProtocolException.invalidParams("\"uri\" is required");
McpRegistry.RegisteredResource resource = registry.resource(uri);
if (resource == null)
throw McpProtocolException.invalidParams("Unknown resource: " + uri);
ResourceContents contents = resource.instance().read();
writeResult(res, id, gen -> {
gen.writeStartObject();
gen.writeArrayFieldStart("contents");
McpContentWriter.writeResourceContents(gen, contents);
gen.writeEndArray();
gen.writeEndObject();
});
}
private void handlePromptsList(Response res, JsonNode id) {
writeResult(res, id, gen -> {
gen.writeStartObject();
gen.writeFieldName("prompts");
gen.writeRawValue(registry.promptsListJson());
gen.writeEndObject();
});
}
private void handlePromptsGet(Response res, JsonNode id, JsonNode params) throws Exception {
String name = params.path("name").asText(null);
if (name == null || name.isBlank())
throw McpProtocolException.invalidParams("\"name\" is required");
McpRegistry.RegisteredPrompt prompt = registry.prompt(name);
if (prompt == null)
throw McpProtocolException.invalidParams("Unknown prompt: " + name);
PromptArguments args = new PromptArguments(params.path("arguments"));
PromptMessage message = prompt.instance().render(args);
writeResult(res, id, gen -> {
gen.writeStartObject();
gen.writeArrayFieldStart("messages");
McpContentWriter.writePromptMessage(gen, message);
gen.writeEndArray();
gen.writeEndObject();
});
}
// ── Envelope writers ─────────────────────────────────────────────────────
private void writeResult(Response res, JsonNode id, McpJson.JsonWriter resultWriter) {
String body = McpJson.buildString(gen -> {
gen.writeStartObject();
gen.writeStringField("jsonrpc", "2.0");
gen.writeFieldName("id");
writeId(gen, id);
gen.writeFieldName("result");
resultWriter.write(gen);
gen.writeEndObject();
});
res.status(200).type(ContentType.JSON).body(body);
}
private void writeError(Response res, int httpStatus, JsonNode id, int code, String message) {
String body = McpJson.buildString(gen -> {
gen.writeStartObject();
gen.writeStringField("jsonrpc", "2.0");
gen.writeFieldName("id");
writeId(gen, id);
gen.writeObjectFieldStart("error");
gen.writeNumberField("code", code);
gen.writeStringField("message", message);
gen.writeEndObject();
gen.writeEndObject();
});
res.status(httpStatus).type(ContentType.JSON).body(body);
}
private static void writeId(JsonGenerator gen, JsonNode id) throws IOException {
if (id == null || id.isNull() || id.isMissingNode()) { gen.writeNull(); return; }
if (id.isTextual()) gen.writeString(id.asText());
else if (id.isIntegralNumber()) gen.writeNumber(id.asLong());
else if (id.isFloatingPointNumber()) gen.writeNumber(id.asDouble());
else gen.writeNull();
}
}
@@ -0,0 +1,119 @@
package dev.relism.flash.ext.mcp;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.extension.FlashRegistrar;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.routing.Middleware;
import lombok.extern.slf4j.Slf4j;
import java.util.ArrayList;
import java.util.List;
/**
* MCP (Model Context Protocol) server extension. Streamable HTTP transport — a single
* {@code POST} JSON-RPC endpoint, stateless in this revision (no session, no SSE stream; see
* {@code docs/transport.md}) — dispatch precompiled at boot from classes annotated with
* {@link Tool @Tool}/{@link Resource @Resource}/{@link Prompt @Prompt} under
* {@link McpConfig#toolsPackage(String)}.
*
* <pre>{@code
* // Standalone, no OAuth2
* FlashApp.create(8080)
* .install(new McpExtension(McpConfig.builder("my-mcp-server")
* .toolsPackage("com.example.tools")
* .build()))
* .start();
*
* // 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)
* .build()))
* .start();
* }</pre>
*
* <p>One server per {@code McpExtension} instance — install multiple instances (distinct
* {@code rootPath}, distinct {@code toolsPackage}) for multiple MCP servers on one app,
* mirroring the {@code OidcExtension} multi-tenant pattern. See {@code docs/security.md} for
* the full OAuth2 resolution rules.
*/
@Slf4j
public class McpExtension implements FlashExtension {
private final McpConfig config;
public McpExtension(McpConfig config) {
this.config = config;
}
@Override
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()));
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, secured);
}
private McpOidcIntegration.Resolved resolveSecurity(FlashContext ctx) {
if (config.security() == McpSecurity.NONE) return null;
McpOidcIntegration.Resolved resolved;
try {
resolved = McpOidcIntegration.resolve(ctx, config);
} catch (NoClassDefFoundError e) {
resolved = null; // flash-ext-oidc not on the classpath at all
}
if (resolved != null) return resolved;
if (config.security() == McpSecurity.REQUIRED) {
throw new IllegalStateException(
"McpSecurity.REQUIRED but flash-ext-oidc is not installed for MCP server \"" + config.name() +
"\" — install an OidcExtension before this McpExtension, or relax security to " +
"McpSecurity.AUTO/NONE if this server is meant to be public.");
}
log.warn("[flash-ext-mcp] MCP server \"{}\" is running WITHOUT OAuth2 protection — " +
"flash-ext-oidc is not installed and McpSecurity.AUTO degrades to unprotected. " +
"Install flash-ext-oidc or set McpSecurity.REQUIRED to make this a hard failure instead.",
config.name());
return null;
}
/**
* 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 McpResourceMetadata.build(
secured.resourceIdentifier().apply(req), secured.issuer(), config.scopesSupported());
});
}
}
@@ -0,0 +1,59 @@
package dev.relism.flash.ext.mcp;
import com.fasterxml.jackson.core.JsonEncoding;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
/**
* Internal JSON access shared by the whole extension. Deliberately tree/streaming only —
* no {@code readValue(bytes, Class)} databinding anywhere in this extension. Request bodies
* are parsed once into a {@link JsonNode} (no reflection, no property matching against a
* target class); responses are written directly with {@link JsonGenerator} against the
* envelope's fixed, known shape (also no reflection).
*
* <p>Not wired to {@code flash-ext-jackson} on purpose: the MCP JSON-RPC envelope is internal
* protocol plumbing, not a user-facing serialization concern, so this extension owns its
* mapper independently — same reasoning {@code flash-ext-oidc} applies to its own JSON needs
* (see {@code json-smart} there). See {@code docs/jackson-interop.md} for the full rationale
* and how a future opt-in reuse of a shared {@code ObjectMapper} could work.
*/
final class McpJson {
private static final ObjectMapper MAPPER = new ObjectMapper();
private McpJson() {}
static JsonNode parse(byte[] body) throws IOException {
return MAPPER.readTree(body);
}
static JsonGenerator generator(OutputStream out) throws IOException {
return MAPPER.getFactory().createGenerator(out, JsonEncoding.UTF8);
}
/** Builds a small JSON document in one shot; used only for boot-time precompilation. */
static byte[] build(JsonWriter writer) {
ByteArrayOutputStream buf = new ByteArrayOutputStream(256);
try (JsonGenerator gen = generator(buf)) {
writer.write(gen);
} catch (IOException e) {
throw new IllegalStateException("Failed to build MCP JSON fragment", e);
}
return buf.toByteArray();
}
static String buildString(JsonWriter writer) {
return new String(build(writer), StandardCharsets.UTF_8);
}
@FunctionalInterface
interface JsonWriter {
void write(JsonGenerator gen) throws IOException;
}
}
@@ -0,0 +1,179 @@
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}/{@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() {}
/** 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;
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();
Middleware protect = oidcMw.protect(resourceMetadataPath);
Middleware secured = Middleware.of(protect, audienceGuard(resourceId));
return new Resolved(secured, issuer, resourceId);
}
/**
* 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();
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);
};
}
private static boolean audienceMatches(Object aud, String expected) {
if (aud instanceof String s) return s.equals(expected);
if (aud instanceof Iterable<?> it) {
for (Object o : it) if (expected.equals(String.valueOf(o))) return true;
}
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);
}
}
@@ -0,0 +1,167 @@
package dev.relism.flash.ext.mcp;
import dev.relism.flash.exceptions.InitializationException;
import java.io.File;
import java.lang.reflect.Modifier;
import java.net.URL;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.List;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
/**
* Minimal classpath scanner used by {@link McpConfig#toolsPackage(String)}. Finds
* {@link McpTool}/{@link McpResource}/{@link McpPrompt} subclasses carrying the matching
* annotation ({@link Tool @Tool}, {@link Resource @Resource}, {@link Prompt @Prompt}).
* Supports both exploded directories (development) and fat JARs (deployment).
*
* <p>Deliberately not shared with {@code dev.relism.flash.extension.PackageScanner}: that
* scanner is package-private and hardcoded to {@code RequestHandler}/{@code WebSocketEndpoint}.
* The directory/JAR walking logic below intentionally mirrors it — same fail-fast contract,
* same anonymous-class filtering.
*
* <p><b>Fail-fast:</b> if the package does not exist, contains no matching class, or a class
* cannot be loaded, an {@link InitializationException} is thrown immediately at boot.
*/
final class McpPackageScanner {
private McpPackageScanner() {}
record ScanResult(List<Class<? extends McpTool>> tools,
List<Class<? extends McpResource>> resources,
List<Class<? extends McpPrompt>> prompts) {}
static ScanResult scan(String packageName) {
if (packageName == null || packageName.isBlank())
throw new InitializationException("McpConfig.toolsPackage() called with null or blank package name");
String resourcePath = packageName.replace('.', '/');
ClassLoader cl = Thread.currentThread().getContextClassLoader();
List<Class<? extends McpTool>> tools = new ArrayList<>();
List<Class<? extends McpResource>> resources = new ArrayList<>();
List<Class<? extends McpPrompt>> prompts = new ArrayList<>();
List<String> errors = new ArrayList<>();
boolean packageFound = false;
try {
Enumeration<URL> urls = cl.getResources(resourcePath);
while (urls.hasMoreElements()) {
packageFound = true;
URL url = urls.nextElement();
String protocol = url.getProtocol();
if ("file".equals(protocol)) {
scanDirectory(new File(url.toURI()), packageName, cl, tools, resources, prompts, errors);
} else if ("jar".equals(protocol)) {
String jarPath = url.getPath();
String filePart = jarPath.substring(jarPath.indexOf("file:") + 5, jarPath.indexOf('!'));
try (JarFile jar = new JarFile(filePart)) {
scanJar(jar, resourcePath, cl, tools, resources, prompts, errors);
}
}
}
} catch (InitializationException e) {
throw e;
} catch (Exception e) {
throw new InitializationException("Failed to scan MCP package: " + packageName, e);
}
if (!packageFound)
throw new InitializationException(
"McpConfig.toolsPackage(\"" + packageName + "\") — package not found on classpath. " +
"Verify the package name and ensure the module is on the classpath.");
if (!errors.isEmpty())
throw new InitializationException(
"McpConfig.toolsPackage(\"" + packageName + "\") — failed to load " + errors.size() + " class(es):\n • " +
String.join("\n • ", errors));
if (tools.isEmpty() && resources.isEmpty() && prompts.isEmpty())
throw new InitializationException(
"McpConfig.toolsPackage(\"" + packageName + "\") — no @Tool/@Resource/@Prompt classes found. " +
"Ensure classes extend McpTool/McpResource/McpPrompt, carry the matching annotation, " +
"are not abstract, and have a public no-arg constructor.");
return new ScanResult(List.copyOf(tools), List.copyOf(resources), List.copyOf(prompts));
}
private static void scanDirectory(File dir, String packageName, ClassLoader cl,
List<Class<? extends McpTool>> tools,
List<Class<? extends McpResource>> resources,
List<Class<? extends McpPrompt>> prompts,
List<String> errors) {
File[] files = dir.listFiles();
if (files == null) return;
for (File file : files) {
if (file.isDirectory()) {
scanDirectory(file, packageName + '.' + file.getName(), cl, tools, resources, prompts, errors);
} else if (file.getName().endsWith(".class") && !isAnonymous(file.getName())) {
String className = packageName + '.' + file.getName().replace(".class", "");
tryLoad(className, cl, tools, resources, prompts, errors);
}
}
}
private static void scanJar(JarFile jar, String resourcePath, ClassLoader cl,
List<Class<? extends McpTool>> tools,
List<Class<? extends McpResource>> resources,
List<Class<? extends McpPrompt>> prompts,
List<String> errors) {
String prefix = resourcePath + "/";
Enumeration<JarEntry> entries = jar.entries();
while (entries.hasMoreElements()) {
String name = entries.nextElement().getName();
if (name.startsWith(prefix) && name.endsWith(".class") && !isAnonymous(name)) {
String className = name.replace('/', '.').replace(".class", "");
tryLoad(className, cl, tools, resources, prompts, errors);
}
}
}
private static boolean isAnonymous(String fileName) {
int dollar = fileName.lastIndexOf('$');
if (dollar < 0) return false;
int next = dollar + 1;
while (next < fileName.length() && fileName.charAt(next) == '$') next++;
return next < fileName.length() && Character.isDigit(fileName.charAt(next));
}
@SuppressWarnings("unchecked")
private static void tryLoad(String className, ClassLoader cl,
List<Class<? extends McpTool>> tools,
List<Class<? extends McpResource>> resources,
List<Class<? extends McpPrompt>> prompts,
List<String> errors) {
try {
Class<?> cls = cl.loadClass(className);
if (Modifier.isAbstract(cls.getModifiers())) return;
if (McpTool.class.isAssignableFrom(cls) && cls.isAnnotationPresent(Tool.class)) {
assertNoArgConstructor(cls, errors);
tools.add((Class<? extends McpTool>) cls);
return;
}
if (McpResource.class.isAssignableFrom(cls) && cls.isAnnotationPresent(Resource.class)) {
assertNoArgConstructor(cls, errors);
resources.add((Class<? extends McpResource>) cls);
return;
}
if (McpPrompt.class.isAssignableFrom(cls) && cls.isAnnotationPresent(Prompt.class)) {
assertNoArgConstructor(cls, errors);
prompts.add((Class<? extends McpPrompt>) cls);
}
} catch (ClassNotFoundException e) {
errors.add(className + " — class not found: " + e.getMessage());
} catch (NoClassDefFoundError e) {
errors.add(className + " — missing dependency: " + e.getMessage());
} catch (LinkageError e) {
errors.add(className + " — linkage error: " + e.getMessage());
}
}
private static void assertNoArgConstructor(Class<?> cls, List<String> errors) {
try { cls.getDeclaredConstructor(); }
catch (NoSuchMethodException e) { errors.add(cls.getName() + " — missing public no-arg constructor"); }
}
}
@@ -0,0 +1,60 @@
package dev.relism.flash.ext.mcp;
import dev.relism.flash.extension.FlashContext;
import java.util.Optional;
/**
* Base class for a single MCP prompt template — one class per prompt, mirroring {@link McpTool}.
* Declare metadata with {@link Prompt @Prompt}, cache services in {@link #onInit()}, implement
* {@link #render(PromptArguments)} for the hot path.
*
* <pre>{@code
* @Prompt(name = "summarize", args = @PromptArg(name = "text", required = true))
* public class SummarizePrompt extends McpPrompt {
* @Override public PromptMessage render(PromptArguments args) {
* return PromptMessage.withUserRole(new TextContent("Summarize: " + args.getString("text")));
* }
* }
* }</pre>
*/
public abstract class McpPrompt {
private FlashContext ctx;
/**
* Called once by the framework after instantiation, before the first {@code prompts/get}.
* <b>Infrastructure method</b> — do not call from user code.
*/
public final void bind(FlashContext ctx) {
this.ctx = ctx;
onInit();
}
protected void onInit() {}
protected <T> T require(Class<T> type) {
checkBound();
return ctx.require(type);
}
protected <T> Optional<T> find(Class<T> type) {
checkBound();
return ctx.find(type);
}
protected <T> Optional<T> optional(Class<T> type) {
checkBound();
return ctx.optional(type);
}
private void checkBound() {
if (ctx == null)
throw new IllegalStateException(
getClass().getSimpleName() + " has not been bound to a FlashContext — " +
"register via McpConfig.toolsPackage(), not by instantiating directly");
}
/** Invoked on every matching {@code prompts/get} request (hot path). */
public abstract PromptMessage render(PromptArguments args) throws Exception;
}
@@ -0,0 +1,24 @@
package dev.relism.flash.ext.mcp;
/** Internal signal carrying a JSON-RPC error code, caught by {@link McpDispatcher} to build the error response. */
final class McpProtocolException extends RuntimeException {
final int code;
private McpProtocolException(int code, String message) {
super(message);
this.code = code;
}
static McpProtocolException invalidRequest(String message) {
return new McpProtocolException(JsonRpcErrorCode.INVALID_REQUEST, message);
}
static McpProtocolException methodNotFound(String method) {
return new McpProtocolException(JsonRpcErrorCode.METHOD_NOT_FOUND, "Method not found: " + method);
}
static McpProtocolException invalidParams(String message) {
return new McpProtocolException(JsonRpcErrorCode.INVALID_PARAMS, message);
}
}
@@ -0,0 +1,205 @@
package dev.relism.flash.ext.mcp;
import com.fasterxml.jackson.core.JsonGenerator;
import dev.relism.flash.exceptions.InitializationException;
import dev.relism.flash.extension.FlashContext;
import java.io.IOException;
import java.lang.reflect.Constructor;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Boot-time-built registry of tools/resources/prompts for one MCP server.
*
* <p>Everything static about the catalog — the {@code tools/list}/{@code resources/list}/
* {@code prompts/list} JSON payloads — is assembled exactly once here via
* {@link McpJson#buildString}, then spliced verbatim into responses at request time
* ({@link McpDispatcher}) with {@link JsonGenerator#writeRawValue(String)}: no
* re-serialization, no reflection, no databinding, and no per-request byte[]→String
* conversion on the hot path — the string is already sitting in memory, built once at boot.
*/
final class McpRegistry {
private static final String EMPTY_ARRAY = "[]";
/** {@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) {}
private final Map<String, RegisteredTool> tools = new LinkedHashMap<>();
private final Map<String, RegisteredResource> resources = new LinkedHashMap<>();
private final Map<String, RegisteredPrompt> prompts = new LinkedHashMap<>();
private String toolsListJson = EMPTY_ARRAY;
private String resourcesListJson = EMPTY_ARRAY;
private String promptsListJson = EMPTY_ARRAY;
private McpRegistry() {}
/**
* @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();
for (Class<? extends McpTool> cls : found.tools()) {
Tool ann = cls.getAnnotation(Tool.class);
McpTool instance = instantiate(cls);
instance.bind(ctx);
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()) {
Resource ann = cls.getAnnotation(Resource.class);
McpResource instance = instantiate(cls);
instance.bind(ctx);
if (registry.resources.putIfAbsent(ann.uri(), new RegisteredResource(ann.uri(), instance)) != null)
throw new InitializationException("Duplicate MCP resource uri: \"" + ann.uri() + "\"");
}
for (Class<? extends McpPrompt> cls : found.prompts()) {
Prompt ann = cls.getAnnotation(Prompt.class);
McpPrompt instance = instantiate(cls);
instance.bind(ctx);
if (registry.prompts.putIfAbsent(ann.name(), new RegisteredPrompt(ann.name(), instance)) != null)
throw new InitializationException("Duplicate MCP prompt name: \"" + ann.name() + "\"");
}
if (!found.tools().isEmpty())
registry.toolsListJson = McpJson.buildString(gen -> writeToolsArray(gen, found.tools()));
if (!found.resources().isEmpty())
registry.resourcesListJson = McpJson.buildString(gen -> writeResourcesArray(gen, found.resources()));
if (!found.prompts().isEmpty())
registry.promptsListJson = McpJson.buildString(gen -> writePromptsArray(gen, found.prompts()));
return registry;
}
boolean hasTools() { return !tools.isEmpty(); }
boolean hasResources() { return !resources.isEmpty(); }
boolean hasPrompts() { return !prompts.isEmpty(); }
String toolsListJson() { return toolsListJson; }
String resourcesListJson() { return resourcesListJson; }
String promptsListJson() { return promptsListJson; }
RegisteredTool tool(String name) { return tools.get(name); }
RegisteredResource resource(String uri) { return resources.get(uri); }
RegisteredPrompt prompt(String name) { return prompts.get(name); }
// ── Boot-time JSON Schema / descriptor precompilation ───────────────────────
private static void writeToolsArray(JsonGenerator gen, List<Class<? extends McpTool>> classes) throws IOException {
gen.writeStartArray();
for (Class<? extends McpTool> cls : classes) writeTool(gen, cls.getAnnotation(Tool.class));
gen.writeEndArray();
}
private static void writeTool(JsonGenerator gen, Tool ann) throws IOException {
gen.writeStartObject();
gen.writeStringField("name", ann.name());
if (!ann.title().isBlank()) gen.writeStringField("title", ann.title());
if (!ann.description().isBlank()) gen.writeStringField("description", ann.description());
gen.writeFieldName("inputSchema");
writeInputSchema(gen, ann.args());
gen.writeEndObject();
}
private static void writeInputSchema(JsonGenerator gen, ToolArg[] args) throws IOException {
gen.writeStartObject();
gen.writeStringField("type", "object");
gen.writeObjectFieldStart("properties");
for (ToolArg arg : args) {
gen.writeObjectFieldStart(arg.name());
gen.writeStringField("type", arg.type().jsonSchemaType());
if (!arg.description().isBlank()) gen.writeStringField("description", arg.description());
gen.writeEndObject();
}
gen.writeEndObject();
if (hasRequired(args)) {
gen.writeArrayFieldStart("required");
for (ToolArg arg : args) if (arg.required()) gen.writeString(arg.name());
gen.writeEndArray();
}
gen.writeEndObject();
}
private static boolean hasRequired(ToolArg[] args) {
for (ToolArg arg : args) if (arg.required()) return true;
return false;
}
private static void writeResourcesArray(JsonGenerator gen, List<Class<? extends McpResource>> classes) throws IOException {
gen.writeStartArray();
for (Class<? extends McpResource> cls : classes) {
Resource ann = cls.getAnnotation(Resource.class);
gen.writeStartObject();
gen.writeStringField("uri", ann.uri());
gen.writeStringField("name", !ann.name().isBlank() ? ann.name() : ann.uri());
if (!ann.description().isBlank()) gen.writeStringField("description", ann.description());
gen.writeStringField("mimeType", ann.mimeType());
gen.writeEndObject();
}
gen.writeEndArray();
}
private static void writePromptsArray(JsonGenerator gen, List<Class<? extends McpPrompt>> classes) throws IOException {
gen.writeStartArray();
for (Class<? extends McpPrompt> cls : classes) {
Prompt ann = cls.getAnnotation(Prompt.class);
gen.writeStartObject();
gen.writeStringField("name", ann.name());
if (!ann.description().isBlank()) gen.writeStringField("description", ann.description());
gen.writeArrayFieldStart("arguments");
for (PromptArg arg : ann.args()) {
gen.writeStartObject();
gen.writeStringField("name", arg.name());
if (!arg.description().isBlank()) gen.writeStringField("description", arg.description());
gen.writeBooleanField("required", arg.required());
gen.writeEndObject();
}
gen.writeEndArray();
gen.writeEndObject();
}
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();
return ctor.newInstance();
} catch (Exception e) {
throw new InitializationException(
"Failed to instantiate " + cls.getName() +
" — ensure it has a public no-arg constructor", e);
}
}
}
@@ -0,0 +1,66 @@
package dev.relism.flash.ext.mcp;
import dev.relism.flash.extension.FlashContext;
import java.util.Optional;
/**
* Base class for a single MCP resource — one class per resource, mirroring {@link McpTool}.
* Declare metadata with {@link Resource @Resource}, cache services in {@link #onInit()},
* implement {@link #read()} for the hot path.
*
* <pre>{@code
* @Resource(uri = "config://app-settings", description = "Application settings", mimeType = "application/json")
* public class AppSettingsResource extends McpResource {
* @Override public ResourceContents read() {
* return TextResourceContents.of(uri(), "application/json", settingsJson());
* }
* }
* }</pre>
*/
public abstract class McpResource {
private FlashContext ctx;
private String uri;
/**
* Called once by the framework after instantiation, before the first {@code resources/read}.
* <b>Infrastructure method</b> — do not call from user code.
*/
public final void bind(FlashContext ctx) {
this.ctx = ctx;
Resource ann = getClass().getAnnotation(Resource.class);
this.uri = ann != null ? ann.uri() : null;
onInit();
}
protected void onInit() {}
protected <T> T require(Class<T> type) {
checkBound();
return ctx.require(type);
}
protected <T> Optional<T> find(Class<T> type) {
checkBound();
return ctx.find(type);
}
protected <T> Optional<T> optional(Class<T> type) {
checkBound();
return ctx.optional(type);
}
/** URI declared via {@link Resource @Resource}, cached at bind time. */
protected final String uri() { return uri; }
private void checkBound() {
if (ctx == null)
throw new IllegalStateException(
getClass().getSimpleName() + " has not been bound to a FlashContext — " +
"register via McpConfig.toolsPackage(), not by instantiating directly");
}
/** Invoked on every matching {@code resources/read} request (hot path). */
public abstract ResourceContents read() throws Exception;
}
@@ -0,0 +1,26 @@
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() {}
/** {@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();
});
}
}
@@ -0,0 +1,17 @@
package dev.relism.flash.ext.mcp;
/**
* OAuth2 requirement policy for the MCP endpoint, resolved against whether
* {@code flash-ext-oidc} is installed ({@code ctx.find(OidcMiddleware.class)}).
*/
public enum McpSecurity {
/** Fail fast at boot if {@code flash-ext-oidc} is not installed — never expose an unprotected MCP endpoint. */
REQUIRED,
/** Protect the endpoint if {@code flash-ext-oidc} is installed; otherwise run unprotected and log a warning. */
AUTO,
/** Never protect the endpoint, even if {@code flash-ext-oidc} is installed elsewhere in the app. */
NONE
}
@@ -0,0 +1,74 @@
package dev.relism.flash.ext.mcp;
import dev.relism.flash.extension.FlashContext;
import java.util.Optional;
/**
* Base class for a single MCP tool — one class per tool, mirroring
* {@link dev.relism.flash.models.RequestHandler}: declare metadata with {@link Tool @Tool},
* cache services in {@link #onInit()}, implement {@link #call(ToolArguments)} for the hot path.
*
* <p>Discovered via {@link McpConfig#toolsPackage(String)} — instantiated with its public
* no-arg constructor and bound once at boot, before the first {@code tools/call} request.
*
* <pre>{@code
* @Tool(name = "get_weather", description = "Get current weather for a city",
* args = @ToolArg(name = "city", required = true))
* public class GetWeatherTool extends McpTool {
* private WeatherService weatherService;
*
* @Override protected void onInit() {
* weatherService = require(WeatherService.class);
* }
*
* @Override public ToolResponse call(ToolArguments args) {
* return ToolResponse.success(new TextContent(weatherService.fetch(args.getString("city"))));
* }
* }
* }</pre>
*/
public abstract class McpTool {
private FlashContext ctx;
/**
* Called once by the framework after instantiation, before the first {@code tools/call}.
* <b>Infrastructure method</b> — do not call from user code.
*/
public final void bind(FlashContext ctx) {
this.ctx = ctx;
onInit();
}
/** Override to cache services at boot time. See {@link #require}/{@link #find}. */
protected void onInit() {}
protected <T> T require(Class<T> type) {
checkBound();
return ctx.require(type);
}
protected <T> Optional<T> find(Class<T> type) {
checkBound();
return ctx.find(type);
}
protected <T> Optional<T> optional(Class<T> type) {
checkBound();
return ctx.optional(type);
}
private void checkBound() {
if (ctx == null)
throw new IllegalStateException(
getClass().getSimpleName() + " has not been bound to a FlashContext — " +
"register via McpConfig.toolsPackage(), not by instantiating directly");
}
/**
* Invoked on every matching {@code tools/call} request (hot path). {@code args} is a thin
* accessor over the already-parsed JSON arguments — no databinding.
*/
public abstract ToolResponse call(ToolArguments args) throws Exception;
}
@@ -0,0 +1,62 @@
package dev.relism.flash.ext.mcp;
import dev.relism.flash.exceptions.HttpException;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.routing.Middleware;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
/** Transport-level guards for the MCP Streamable HTTP endpoint. */
@Slf4j
final class McpTransportGuards {
private McpTransportGuards() {}
/**
* Validates the {@code Origin} header per the Streamable HTTP transport's DNS-rebinding
* protection requirement. Non-browser clients that omit {@code Origin} entirely are always
* allowed through — only a <em>present but disallowed</em> value is rejected.
*
* <p>If {@code allowedOrigins} is empty, validation is skipped and a boot-time warning is
* logged — same graceful-degradation shape as {@link McpSecurity#AUTO}.
*/
static Middleware originGuard(List<String> allowedOrigins) {
if (allowedOrigins.isEmpty()) {
log.warn("[flash-ext-mcp] No allowedOrigins configured — Origin header validation " +
"(DNS-rebinding protection) is DISABLED. Configure McpConfig.allowedOrigins(...) for production use.");
return next -> next::handle;
}
return next -> (req, res) -> {
String origin = req.header("Origin");
if (origin != null && !allowedOrigins.contains(origin)) {
throw HttpException.forbidden();
}
return next.handle(req, res);
};
}
/**
* Safety net around the whole MCP route: translates {@link HttpException} (thrown by
* {@link #originGuard} or by {@code flash-ext-oidc}'s middleware) into a proper HTTP status
* directly, instead of relying on the app's global exception handler — which defaults to a
* generic 500 for every exception type unless the app owner overrides it (see
* {@code AbstractRouter}'s default {@code exceptionHandler}). Keeps the MCP endpoint
* correct out of the box regardless of what the rest of the app configures.
*/
static Middleware httpExceptionGuard() {
return next -> (req, res) -> {
try {
return next.handle(req, res);
} catch (HttpException e) {
String body = McpJson.buildString(gen -> {
gen.writeStartObject();
gen.writeStringField("error", e.getMessage());
gen.writeEndObject();
});
res.status(e.status()).type(ContentType.JSON).body(body);
return null;
}
};
}
}
@@ -0,0 +1,32 @@
package dev.relism.flash.ext.mcp;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Marks a {@link McpPrompt} subclass as an MCP prompt template and declares its metadata,
* discovered by {@link McpConfig#toolsPackage(String)}.
*
* <pre>{@code
* @Prompt(name = "summarize", args = @PromptArg(name = "text", required = true))
* public class SummarizePrompt extends McpPrompt {
* @Override
* public PromptMessage render(PromptArguments args) {
* return PromptMessage.withUserRole(new TextContent("Summarize: " + args.getString("text")));
* }
* }
* }</pre>
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Prompt {
/** Unique prompt name (used by clients in {@code prompts/get}). */
String name();
String description() default "";
/** Arguments accepted by the prompt template — always strings per the MCP specification. */
PromptArg[] args() default {};
}
@@ -0,0 +1,11 @@
package dev.relism.flash.ext.mcp;
/**
* Declares one argument of a {@link Prompt}. Per the MCP specification, prompt arguments are
* always strings. Used inside {@link Prompt#args()}.
*/
public @interface PromptArg {
String name();
String description() default "";
boolean required() default false;
}
@@ -0,0 +1,21 @@
package dev.relism.flash.ext.mcp;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.MissingNode;
/**
* Typed accessor over a {@code prompts/get} request's {@code arguments} object.
* Per the MCP specification, prompt arguments are always strings.
*/
public final class PromptArguments {
private final JsonNode node;
PromptArguments(JsonNode node) {
this.node = node != null ? node : MissingNode.getInstance();
}
public boolean has(String name) { return node.has(name); }
public String getString(String name) { return node.path(name).asText(null); }
public String getString(String name, String defaultValue) { return node.path(name).asText(defaultValue); }
}
@@ -0,0 +1,15 @@
package dev.relism.flash.ext.mcp;
/** A single message returned by a {@link McpPrompt}. */
public record PromptMessage(Role role, Content content) {
public enum Role { USER, ASSISTANT }
public static PromptMessage withUserRole(Content content) {
return new PromptMessage(Role.USER, content);
}
public static PromptMessage withAssistantRole(Content content) {
return new PromptMessage(Role.ASSISTANT, content);
}
}
@@ -0,0 +1,31 @@
package dev.relism.flash.ext.mcp;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Marks a {@link McpResource} subclass as an MCP resource and declares its metadata, discovered
* by {@link McpConfig#toolsPackage(String)}.
*
* <pre>{@code
* @Resource(uri = "config://app-settings", description = "Application settings")
* public class AppSettingsResource extends McpResource {
* @Override
* public ResourceContents read() {
* return TextResourceContents.of(uri(), "application/json", settingsJson());
* }
* }
* }</pre>
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Resource {
/** Unique resource URI (used by clients in {@code resources/read}). */
String uri();
String name() default "";
String description() default "";
String mimeType() default "text/plain";
}
@@ -0,0 +1,8 @@
package dev.relism.flash.ext.mcp;
/**
* MCP resource contents. {@code sealed} to the variants this extension currently writes on
* the wire — extend the permits clause (and {@link McpContentWriter}) to add
* {@code BlobResourceContents} in a future revision.
*/
public sealed interface ResourceContents permits TextResourceContents {}
@@ -0,0 +1,4 @@
package dev.relism.flash.ext.mcp;
/** Plain-text content block ({@code type: "text"} on the wire). */
public record TextContent(String text) implements Content {}
@@ -0,0 +1,9 @@
package dev.relism.flash.ext.mcp;
/** Text resource contents returned from {@code resources/read}. */
public record TextResourceContents(String uri, String mimeType, String text) implements ResourceContents {
public static TextResourceContents of(String uri, String mimeType, String text) {
return new TextResourceContents(uri, mimeType, text);
}
}
@@ -0,0 +1,40 @@
package dev.relism.flash.ext.mcp;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Marks a {@link McpTool} subclass as an MCP tool and declares its metadata, discovered by
* {@link McpConfig#toolsPackage(String)}.
*
* <pre>{@code
* @Tool(
* name = "get_weather",
* description = "Get current weather for a city",
* args = @ToolArg(name = "city", description = "City name", required = true)
* )
* public class GetWeatherTool extends McpTool {
* @Override
* public ToolResponse call(ToolArguments args) {
* return ToolResponse.success(new TextContent(weatherService.fetch(args.getString("city"))));
* }
* }
* }</pre>
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Tool {
/** Unique tool name (used by clients in {@code tools/call}). */
String name();
/** Human/model-readable description of what the tool does. */
String description() default "";
/** Optional display title, distinct from {@link #name()}. */
String title() default "";
/** Input arguments — assembled into the tool's JSON Schema {@code inputSchema} once at boot. */
ToolArg[] args() default {};
}
@@ -0,0 +1,12 @@
package dev.relism.flash.ext.mcp;
/**
* Declares one input argument of a {@link Tool}. Used inside {@link Tool#args()} — the whole
* input JSON Schema is assembled once at scan time from these, never at call time.
*/
public @interface ToolArg {
String name();
ToolArgType type() default ToolArgType.STRING;
String description() default "";
boolean required() default false;
}
@@ -0,0 +1,11 @@
package dev.relism.flash.ext.mcp;
/** JSON Schema primitive types available for {@link ToolArg#type()}. */
public enum ToolArgType {
STRING, INTEGER, NUMBER, BOOLEAN, OBJECT, ARRAY;
/** JSON Schema {@code "type"} keyword value. */
String jsonSchemaType() {
return name().toLowerCase(java.util.Locale.ROOT);
}
}
@@ -0,0 +1,48 @@
package dev.relism.flash.ext.mcp;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.MissingNode;
/**
* Typed accessor over a {@code tools/call} request's {@code arguments} object.
*
* <p>Wraps the already-parsed {@link JsonNode} directly — no POJO databinding, no reflection,
* no intermediate copy. Same spirit as {@code QueryParams}/{@code PathParams} in Flash core:
* a thin typed view over data that already exists in memory.
*
* <pre>{@code
* public ToolResponse call(ToolArguments args) {
* String city = args.getString("city");
* int days = args.getInt("days", 1);
* ...
* }
* }</pre>
*/
public final class ToolArguments {
private final JsonNode node;
ToolArguments(JsonNode node) {
this.node = node != null ? node : MissingNode.getInstance();
}
public boolean has(String name) { return node.has(name); }
public String getString(String name) { return node.path(name).asText(null); }
public String getString(String name, String defaultValue) { return node.path(name).asText(defaultValue); }
public int getInt(String name) { return node.path(name).asInt(); }
public int getInt(String name, int defaultValue) { return node.path(name).asInt(defaultValue); }
public long getLong(String name) { return node.path(name).asLong(); }
public long getLong(String name, long defaultValue) { return node.path(name).asLong(defaultValue); }
public double getDouble(String name) { return node.path(name).asDouble(); }
public double getDouble(String name, double defaultValue) { return node.path(name).asDouble(defaultValue); }
public boolean getBoolean(String name) { return node.path(name).asBoolean(); }
public boolean getBoolean(String name, boolean defaultValue) { return node.path(name).asBoolean(defaultValue); }
/** Escape hatch for nested/array arguments not covered by the typed accessors above. */
public JsonNode raw(String name) { return node.path(name); }
}
@@ -0,0 +1,32 @@
package dev.relism.flash.ext.mcp;
import java.util.List;
/** Result of a {@link McpTool#call(ToolArguments)} invocation. */
public final class ToolResponse {
private final List<Content> content;
private final boolean isError;
private ToolResponse(List<Content> content, boolean isError) {
this.content = content;
this.isError = isError;
}
/** Successful tool result carrying one or more content blocks. */
public static ToolResponse success(Content... content) {
return new ToolResponse(List.of(content), false);
}
/**
* Tool-level failure — per the MCP specification this is still a normal JSON-RPC
* <em>result</em> (not a JSON-RPC error) with {@code isError: true}, so the model can see
* and react to it.
*/
public static ToolResponse error(String message) {
return new ToolResponse(List.of(new TextContent(message)), true);
}
List<Content> content() { return content; }
boolean isError() { return isError; }
}
@@ -0,0 +1,109 @@
package dev.relism.flash.ext.mcp;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.crypto.RSASSASigner;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.KeyUse;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import com.sun.net.httpserver.HttpServer;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
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;
/**
* Minimal, self-contained fake OIDC provider for tests: real discovery document, real JWKS
* endpoint, real RS256-signed tokens — no network dependency beyond localhost, no mocking
* framework. Exercises {@code flash-ext-oidc}'s actual discovery + JWKS + JWT validation path.
*/
final class FakeOidcProvider implements AutoCloseable {
private final HttpServer server;
private final String issuer;
private final RSAKey rsaKey;
FakeOidcProvider() throws Exception {
KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA");
gen.initialize(2048);
KeyPair kp = gen.generateKeyPair();
this.rsaKey = new RSAKey.Builder((RSAPublicKey) kp.getPublic())
.privateKey((RSAPrivateKey) kp.getPrivate())
.keyUse(KeyUse.SIGNATURE)
.algorithm(JWSAlgorithm.RS256)
.keyID(UUID.randomUUID().toString())
.build();
this.server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
this.issuer = "http://127.0.0.1:" + server.getAddress().getPort();
server.createContext("/.well-known/openid-configuration", ex -> respond(ex, discoveryDocument()));
server.createContext("/jwks", ex -> respond(ex, new JWKSet(rsaKey.toPublicJWK()).toJSONObject().toString()));
server.setExecutor(null);
server.start();
}
String issuer() { return issuer; }
/** 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.Builder builder = new JWTClaimsSet.Builder()
.issuer(issuer)
.subject(subject)
.audience(audience)
.issueTime(Date.from(Instant.now()))
.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(), builder.build());
jwt.sign(new RSASSASigner(rsaKey));
return jwt.serialize();
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
private static final String[] NO_ROLES = new String[0];
private String discoveryDocument() {
return "{"
+ "\"issuer\":\"" + issuer + "\","
+ "\"authorization_endpoint\":\"" + issuer + "/auth\","
+ "\"token_endpoint\":\"" + issuer + "/token\","
+ "\"jwks_uri\":\"" + issuer + "/jwks\""
+ "}";
}
private static void respond(com.sun.net.httpserver.HttpExchange ex, String body) throws java.io.IOException {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
ex.getResponseHeaders().add("Content-Type", "application/json");
ex.sendResponseHeaders(200, bytes.length);
try (OutputStream os = ex.getResponseBody()) { os.write(bytes); }
}
@Override
public void close() { server.stop(0); }
}
@@ -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();
}
}
}
@@ -0,0 +1,143 @@
package dev.relism.flash.ext.mcp;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.relism.flash.extension.FlashApp;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
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.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** End-to-end JSON-RPC lifecycle over the real Streamable HTTP endpoint — no OAuth2 involved. */
class McpExtensionIntegrationTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
private FlashApp app;
private String mcpUrl;
private HttpClient client;
@BeforeEach
void setUp() throws Exception {
int port;
try (ServerSocket s = new ServerSocket(0)) {
port = s.getLocalPort();
}
mcpUrl = "http://127.0.0.1:" + port + "/mcp";
client = HttpClient.newHttpClient();
McpConfig config = McpConfig.builder("test-server")
.version("9.9.9")
.toolsPackage("dev.relism.flash.ext.mcp.fixtures")
.security(McpSecurity.NONE)
.build();
app = FlashApp.create(port);
app.install(new McpExtension(config));
app.start();
}
@AfterEach
void tearDown() {
if (app != null) app.stop();
}
@Test
void initialize_returnsProtocolVersionCapabilitiesAndServerInfo() throws Exception {
JsonNode result = call(1, "initialize", "{}").get("result");
assertTrue(result.has("protocolVersion"));
assertEquals("test-server", result.get("serverInfo").get("name").asText());
assertEquals("9.9.9", result.get("serverInfo").get("version").asText());
assertTrue(result.get("capabilities").has("tools"));
assertTrue(result.get("capabilities").has("resources"));
assertTrue(result.get("capabilities").has("prompts"));
}
@Test
void toolsList_containsRegisteredTools() throws Exception {
JsonNode tools = call(2, "tools/list", "{}").get("result").get("tools");
assertEquals(2, tools.size());
}
@Test
void toolsCall_echo_returnsContent() throws Exception {
JsonNode result = call(3, "tools/call", "{\"name\":\"echo\",\"arguments\":{\"text\":\"hi there\"}}").get("result");
assertFalse(result.get("isError").asBoolean());
assertEquals("hi there", result.get("content").get(0).get("text").asText());
}
@Test
void toolsCall_failingTool_returnsIsErrorResultNotProtocolError() throws Exception {
JsonNode response = call(4, "tools/call", "{\"name\":\"boom\",\"arguments\":{}}");
assertFalse(response.has("error"));
JsonNode result = response.get("result");
assertTrue(result.get("isError").asBoolean());
assertTrue(result.get("content").get(0).get("text").asText().contains("kaboom"));
}
@Test
void toolsCall_unknownTool_returnsJsonRpcInvalidParamsError() throws Exception {
JsonNode response = call(5, "tools/call", "{\"name\":\"nope\",\"arguments\":{}}");
assertEquals(-32602, response.get("error").get("code").asInt());
}
@Test
void resourcesRead_returnsTextContents() throws Exception {
JsonNode result = call(6, "resources/read", "{\"uri\":\"greeting://hello\"}").get("result");
assertEquals("hello world", result.get("contents").get(0).get("text").asText());
}
@Test
void promptsGet_rendersMessage() throws Exception {
JsonNode result = call(7, "prompts/get", "{\"name\":\"summarize\",\"arguments\":{\"text\":\"foo\"}}").get("result");
assertEquals("Summarize: foo", result.get("messages").get(0).get("content").get("text").asText());
}
@Test
void notification_returns202WithEmptyBody() throws Exception {
String body = "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}";
HttpResponse<String> resp = post(body);
assertEquals(202, resp.statusCode());
}
@Test
void malformedJson_returns400ParseError() throws Exception {
HttpResponse<String> resp = post("not json");
assertEquals(400, resp.statusCode());
JsonNode json = MAPPER.readTree(resp.body());
assertEquals(-32700, json.get("error").get("code").asInt());
}
@Test
void unknownMethod_returnsJsonRpcMethodNotFound() throws Exception {
JsonNode response = call(8, "not/a/method", "{}");
assertEquals(-32601, response.get("error").get("code").asInt());
}
// ── Helpers ──────────────────────────────────────────────────────────────
private JsonNode call(int id, String method, String paramsJson) throws Exception {
String body = "{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"method\":\"" + method + "\",\"params\":" + paramsJson + "}";
HttpResponse<String> resp = post(body);
assertEquals(200, resp.statusCode());
return MAPPER.readTree(resp.body());
}
private HttpResponse<String> post(String body) throws Exception {
HttpRequest req = HttpRequest.newBuilder(URI.create(mcpUrl))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
return client.send(req, HttpResponse.BodyHandlers.ofString());
}
}
@@ -0,0 +1,194 @@
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;
/**
* Exercises the actual OAuth2 resolution rules against a real {@code flash-ext-oidc}
* installation backed by {@link FakeOidcProvider} — real discovery, real JWKS, real RS256
* tokens — plus the fail-fast/degrade behavior when oidc is absent.
*/
class McpExtensionSecurityTest {
private static final String TOOLS_PACKAGE = "dev.relism.flash.ext.mcp.fixtures";
private FlashApp app;
private FakeOidcProvider provider;
@AfterEach
void tearDown() {
if (app != null) app.stop();
if (provider != null) provider.close();
}
@Test
void required_withoutOidc_throwsAtBoot() throws Exception {
int port = freePort();
app = FlashApp.create(port);
app.install(new McpExtension(McpConfig.builder("secure-server")
.toolsPackage(TOOLS_PACKAGE)
.security(McpSecurity.REQUIRED)
.build()));
assertThrows(IllegalStateException.class, () -> app.start());
}
@Test
void auto_withoutOidc_degradesToPublic() throws Exception {
int port = freePort();
app = FlashApp.create(port);
app.install(new McpExtension(McpConfig.builder("auto-server")
.toolsPackage(TOOLS_PACKAGE)
.security(McpSecurity.AUTO)
.build()));
app.start();
HttpResponse<String> resp = post(port, initializeBody(), null);
assertEquals(200, resp.statusCode());
}
@Test
void required_withOidc_rejectsMissingToken() throws Exception {
int port = bootSecuredApp(null);
HttpResponse<String> resp = post(port, initializeBody(), null);
assertEquals(401, resp.statusCode());
}
@Test
void required_withOidc_rejectsWrongAudience() throws Exception {
int port = bootSecuredApp("https://mcp.example.com/mcp");
String token = provider.signToken("user-1", "https://someone-else.example.com/resource");
HttpResponse<String> resp = post(port, initializeBody(), token);
assertEquals(403, resp.statusCode());
}
@Test
void required_withOidc_acceptsValidAudience() throws Exception {
String resourceId = "https://mcp.example.com/mcp";
int port = bootSecuredApp(resourceId);
String token = provider.signToken("user-1", resourceId);
HttpResponse<String> resp = post(port, initializeBody(), token);
assertEquals(200, resp.statusCode());
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 {
provider = new FakeOidcProvider();
int port = freePort();
OidcConfig oidcConfig = OidcConfig.builder(
provider.issuer(), "mcp-client", "secret", "/auth/callback")
.build();
var mcpBuilder = McpConfig.builder("secure-server")
.toolsPackage(TOOLS_PACKAGE)
.security(McpSecurity.REQUIRED);
if (resourceIdentifier != null) mcpBuilder.resourceIdentifier(resourceIdentifier);
app = FlashApp.create(port);
app.install(new OidcExtension(oidcConfig));
app.install(new McpExtension(mcpBuilder.build()));
app.start();
return port;
}
private static String initializeBody() {
return "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}";
}
private static int freePort() throws Exception {
try (ServerSocket s = new ServerSocket(0)) {
return s.getLocalPort();
}
}
private static HttpResponse<String> post(int port, String body, String bearerToken) throws Exception {
HttpRequest.Builder req = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/mcp"))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body));
if (bearerToken != null) req.header("Authorization", "Bearer " + bearerToken);
return HttpClient.newHttpClient().send(req.build(), HttpResponse.BodyHandlers.ofString());
}
}
@@ -0,0 +1,57 @@
package dev.relism.flash.ext.mcp;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.relism.flash.exceptions.InitializationException;
import dev.relism.flash.extension.FlashContext;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class McpRegistryTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
@Test
void scan_findsAndPrecompilesToolsResourcesPrompts() throws Exception {
McpRegistry registry = McpRegistry.scan("dev.relism.flash.ext.mcp.fixtures", new FlashContext(), false, "realm_access.roles");
assertTrue(registry.hasTools());
assertTrue(registry.hasResources());
assertTrue(registry.hasPrompts());
JsonNode tools = MAPPER.readTree(registry.toolsListJson());
assertEquals(2, tools.size()); // echo + boom
JsonNode echo = findByField(tools, "name", "echo");
assertEquals("Echoes the given text", echo.get("description").asText());
assertEquals("object", echo.get("inputSchema").get("type").asText());
assertEquals("string", echo.get("inputSchema").get("properties").get("text").get("type").asText());
assertEquals("text", echo.get("inputSchema").get("required").get(0).asText());
JsonNode resources = MAPPER.readTree(registry.resourcesListJson());
assertEquals(1, resources.size());
assertEquals("greeting://hello", resources.get(0).get("uri").asText());
JsonNode prompts = MAPPER.readTree(registry.promptsListJson());
assertEquals(1, prompts.size());
assertEquals("summarize", prompts.get(0).get("name").asText());
assertTrue(prompts.get(0).get("arguments").get(0).get("required").asBoolean());
assertEquals("echo", registry.tool("echo").name());
assertEquals("greeting://hello", registry.resource("greeting://hello").uri());
assertEquals("summarize", registry.prompt("summarize").name());
}
@Test
void scan_emptyPackage_throwsInitializationException() {
assertThrows(InitializationException.class,
() -> McpRegistry.scan("dev.relism.flash.ext.mcp.doesnotexist", new FlashContext(), false, "realm_access.roles"));
}
private static JsonNode findByField(JsonNode array, String field, String value) {
for (JsonNode n : array) if (value.equals(n.path(field).asText())) return n;
throw new AssertionError("No entry with " + field + "=" + value);
}
}
@@ -0,0 +1,48 @@
package dev.relism.flash.ext.mcp;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ToolArgumentsTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
private ToolArguments of(String json) throws Exception {
return new ToolArguments(MAPPER.readTree(json));
}
@Test
void readsTypedFields() throws Exception {
ToolArguments args = of("{\"city\":\"Rome\",\"days\":3,\"temp\":21.5,\"metric\":true}");
assertEquals("Rome", args.getString("city"));
assertEquals(3, args.getInt("days"));
assertEquals(21.5, args.getDouble("temp"));
assertTrue(args.getBoolean("metric"));
assertTrue(args.has("city"));
assertFalse(args.has("missing"));
}
@Test
void missingFieldsFallBackToDefaults() throws Exception {
ToolArguments args = of("{}");
assertNull(args.getString("missing"));
assertEquals("fallback", args.getString("missing", "fallback"));
assertEquals(0, args.getInt("missing"));
assertEquals(42, args.getInt("missing", 42));
assertFalse(args.getBoolean("missing"));
}
@Test
void nullArgumentsNodeBehavesAsEmpty() {
ToolArguments args = new ToolArguments(null);
assertFalse(args.has("anything"));
assertNull(args.getString("anything"));
}
}
@@ -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"));
}
}
@@ -0,0 +1,18 @@
package dev.relism.flash.ext.mcp.fixtures;
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.ToolArg;
import dev.relism.flash.ext.mcp.ToolArguments;
import dev.relism.flash.ext.mcp.ToolResponse;
@Tool(name = "echo", description = "Echoes the given text",
args = @ToolArg(name = "text", description = "Text to echo", required = true))
public class EchoTool extends McpTool {
@Override
public ToolResponse call(ToolArguments args) {
return ToolResponse.success(new TextContent(args.getString("text")));
}
}
@@ -0,0 +1,15 @@
package dev.relism.flash.ext.mcp.fixtures;
import dev.relism.flash.ext.mcp.McpTool;
import dev.relism.flash.ext.mcp.Tool;
import dev.relism.flash.ext.mcp.ToolArguments;
import dev.relism.flash.ext.mcp.ToolResponse;
@Tool(name = "boom", description = "Always fails")
public class FailingTool extends McpTool {
@Override
public ToolResponse call(ToolArguments args) {
throw new IllegalStateException("kaboom");
}
}
@@ -0,0 +1,15 @@
package dev.relism.flash.ext.mcp.fixtures;
import dev.relism.flash.ext.mcp.McpResource;
import dev.relism.flash.ext.mcp.Resource;
import dev.relism.flash.ext.mcp.ResourceContents;
import dev.relism.flash.ext.mcp.TextResourceContents;
@Resource(uri = "greeting://hello", description = "A greeting", mimeType = "text/plain")
public class GreetingResource extends McpResource {
@Override
public ResourceContents read() {
return TextResourceContents.of(uri(), "text/plain", "hello world");
}
}
@@ -0,0 +1,18 @@
package dev.relism.flash.ext.mcp.fixtures;
import dev.relism.flash.ext.mcp.McpPrompt;
import dev.relism.flash.ext.mcp.Prompt;
import dev.relism.flash.ext.mcp.PromptArg;
import dev.relism.flash.ext.mcp.PromptArguments;
import dev.relism.flash.ext.mcp.PromptMessage;
import dev.relism.flash.ext.mcp.TextContent;
@Prompt(name = "summarize", description = "Summarizes the given text",
args = @PromptArg(name = "text", required = true))
public class SummarizePrompt extends McpPrompt {
@Override
public PromptMessage render(PromptArguments args) {
return PromptMessage.withUserRole(new TextContent("Summarize: " + args.getString("text")));
}
}
@@ -7,6 +7,8 @@ import dev.relism.flash.ext.openapi.OpenApiResponseContribution;
import dev.relism.flash.extension.FlashContext; import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashExtension; import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.extension.FlashRegistrar; import dev.relism.flash.extension.FlashRegistrar;
import dev.relism.flash.routing.MiddlewareKey;
import dev.relism.flash.routing.MiddlewareNode;
import dev.relism.flash.models.Request; import dev.relism.flash.models.Request;
import javax.net.ssl.SSLContext; import javax.net.ssl.SSLContext;
@@ -54,6 +56,7 @@ import java.util.*;
* }</pre> * }</pre>
*/ */
public class OidcExtension implements FlashExtension { public class OidcExtension implements FlashExtension {
private static final MiddlewareKey POLICY = MiddlewareKey.of("flash.oidc.policy");
private final OidcConfig config; private final OidcConfig config;
@@ -71,7 +74,7 @@ public class OidcExtension implements FlashExtension {
// ── Phase 1: services ───────────────────────────────────────────────────── // ── Phase 1: services ─────────────────────────────────────────────────────
@Override @Override
public void provide(FlashContext ctx) { public void configure(FlashRegistrar<?> app, FlashContext ctx) {
HttpClient http = buildHttpClient(config); HttpClient http = buildHttpClient(config);
// Discover provider endpoints (blocking; fail fast at startup). // Discover provider endpoints (blocking; fail fast at startup).
@@ -91,14 +94,12 @@ public class OidcExtension implements FlashExtension {
ctx.addAnnotationProcessor(handlerClass -> { ctx.addAnnotationProcessor(handlerClass -> {
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(handlerClass); OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(handlerClass);
return policy != null ? List.of(oidcMw.policyMiddleware(policy)) : List.of(); return policy != null ? List.of(MiddlewareNode.of(POLICY, oidcMw.policyMiddleware(policy))) : List.of();
}); });
ctx.onReady(() -> registerRoutes(app, ctx));
} }
// ── Phase 2: routes ─────────────────────────────────────────────────────── private void registerRoutes(FlashRegistrar<?> app, FlashContext ctx) {
@Override
public void routes(FlashRegistrar<?> app, FlashContext ctx) {
String prefix = config.routePrefix(); String prefix = config.routePrefix();
// ── GET {prefix}/login ──────────────────────────────────────────────── // ── GET {prefix}/login ────────────────────────────────────────────────
@@ -252,7 +253,7 @@ public class OidcExtension implements FlashExtension {
private String absoluteSelf(Request req, String uri) { private String absoluteSelf(Request req, String uri) {
if (!uri.startsWith("/")) return uri; if (!uri.startsWith("/")) return uri;
return config.selfScheme() + "://" + req.header("Host") + uri; return OidcMiddleware.selfOrigin(req, config.selfScheme()) + uri;
} }
private static String enc(String v) { private static String enc(String v) {
@@ -65,8 +65,21 @@ public class OidcMiddleware {
* to the login page on failure; API clients receive 401. * to the login page on failure; API clients receive 401.
*/ */
public Middleware protect() { public Middleware protect() {
return protect(null);
}
/**
* Like {@link #protect()}, but a 401 challenge also carries {@code resource_metadata}
* (RFC 9728 §5.1), resolved against this request's own scheme/host exactly like
* {@link OidcExtension}'s redirect URIs. {@code resourceMetadataPath} is an absolute path
* (e.g. {@code "/.well-known/oauth-protected-resource/mcp"}); pass {@code null} for plain
* challenges. Used by {@code flash-ext-mcp} to make its Protected Resource Metadata
* document discoverable straight from the {@code WWW-Authenticate} header, per the MCP
* Authorization spec.
*/
public Middleware protect(String resourceMetadataPath) {
return next -> (req, res) -> { return next -> (req, res) -> {
Map<String, Object> claims = resolve(req, res); Map<String, Object> claims = resolve(req, res, resourceMetadataPath);
if (claims == null) return null; // redirect already written if (claims == null) return null; // redirect already written
ClaimsHolder.set(claims); ClaimsHolder.set(claims);
try { try {
@@ -77,6 +90,12 @@ public class OidcMiddleware {
}; };
} }
/** OIDC issuer this middleware validates tokens against — the {@code iss} claim it enforces. */
public String issuer() { return config.issuer(); }
/** Scheme used to build this app's own absolute URLs — see {@link OidcConfig#selfScheme()}. */
public String selfScheme() { return config.selfScheme(); }
/** /**
* Silently populates {@link ClaimsHolder} if a valid bearer token or session cookie * Silently populates {@link ClaimsHolder} if a valid bearer token or session cookie
* is present, but never rejects or redirects unauthenticated requests. Use this on * is present, but never rejects or redirects unauthenticated requests. Use this on
@@ -195,13 +214,17 @@ public class OidcMiddleware {
* {@code res}. Throws {@link HttpException} 401/403 for API clients. * {@code res}. Throws {@link HttpException} 401/403 for API clients.
*/ */
private Map<String, Object> resolve(Request req, Response res) { private Map<String, Object> resolve(Request req, Response res) {
return resolve(req, res, null);
}
private Map<String, Object> resolve(Request req, Response res, String resourceMetadataPath) {
// 1. Bearer token // 1. Bearer token
String bearerToken = extractBearerToken(req.header("Authorization")); String bearerToken = extractBearerToken(req.header("Authorization"));
if (bearerToken != null) { if (bearerToken != null) {
try { try {
return validator.validate(bearerToken); return validator.validate(bearerToken);
} catch (HttpException e) { } catch (HttpException e) {
res.header("WWW-Authenticate", invalidTokenChallenge()); res.header("WWW-Authenticate", invalidTokenChallenge(req, resourceMetadataPath));
throw e; throw e;
} }
} }
@@ -233,7 +256,7 @@ public class OidcMiddleware {
// 3. No valid credentials // 3. No valid credentials
String accept = req.header("Accept"); String accept = req.header("Accept");
if (accept != null && accept.contains("application/json")) { if (accept != null && accept.contains("application/json")) {
res.header("WWW-Authenticate", bearerChallenge()); res.header("WWW-Authenticate", bearerChallenge(req, resourceMetadataPath));
throw HttpException.unauthorized(); throw HttpException.unauthorized();
} }
@@ -300,11 +323,21 @@ public class OidcMiddleware {
} }
String bearerChallenge() { String bearerChallenge() {
return BEARER + " realm=\"" + quoted(config.schemeName()) + "\""; return bearerChallenge(null, null);
}
private String bearerChallenge(Request req, String resourceMetadataPath) {
String base = BEARER + " realm=\"" + quoted(config.schemeName()) + "\"";
if (resourceMetadataPath == null) return base;
return base + ", resource_metadata=\"" + quoted(absoluteSelf(req, resourceMetadataPath)) + "\"";
} }
String invalidTokenChallenge() { String invalidTokenChallenge() {
return bearerChallenge() + ", error=\"invalid_token\""; return invalidTokenChallenge(null, null);
}
private String invalidTokenChallenge(Request req, String resourceMetadataPath) {
return bearerChallenge(req, resourceMetadataPath) + ", error=\"invalid_token\"";
} }
String insufficientScopeChallenge(String[] requiredScopes) { String insufficientScopeChallenge(String[] requiredScopes) {
@@ -312,6 +345,28 @@ public class OidcMiddleware {
+ quoted(spaceDelimited(requiredScopes)) + "\""; + quoted(spaceDelimited(requiredScopes)) + "\"";
} }
private String absoluteSelf(Request req, String path) {
if (!path.startsWith("/")) return path;
return selfOrigin(req, config.selfScheme()) + path;
}
/**
* {@code scheme://host} clients actually reach this app on — the basis for every absolute
* URL it publishes about itself (OAuth2 {@code redirect_uri}, the RFC 9728 resource
* identifier and the {@code resource_metadata} challenge). Behind a reverse proxy the
* request's own {@code Host} is the upstream address the proxy dialled, so
* {@code X-Forwarded-Host}/{@code -Proto} win whenever present: without them the app would
* name an address no client can resolve, and OAuth2 discovery fails with no error anyone
* can trace back to here. Trusted unconditionally — a caller able to reach this app without
* passing the proxy can do worse than spoof a self URL.
*/
public static String selfOrigin(Request req, String fallbackScheme) {
String forwardedHost = req.header("X-Forwarded-Host");
if (forwardedHost == null) return fallbackScheme + "://" + req.header("Host");
String forwardedProto = req.header("X-Forwarded-Proto");
return (forwardedProto != null ? forwardedProto : fallbackScheme) + "://" + forwardedHost;
}
private static String spaceDelimited(String[] values) { private static String spaceDelimited(String[] values) {
if (values == null || values.length == 0) return ""; if (values == null || values.length == 0) return "";
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
@@ -104,6 +104,7 @@ class OidcOpenApiInteropTest {
FlashContext ctx = new FlashContext(); FlashContext ctx = new FlashContext();
OpenApiContributorRegistry registry = new OpenApiContributorRegistry(); OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
ctx.provide(OpenApiContributorRegistry.class, registry); ctx.provide(OpenApiContributorRegistry.class, registry);
ctx.complete();
OidcConfig config = OidcConfig.builder("https://issuer", "c", "s", "/cb").build(); OidcConfig config = OidcConfig.builder("https://issuer", "c", "s", "/cb").build();
OidcProviderMetadata meta = new OidcProviderMetadata("a", "t", "u", "j", "e"); OidcProviderMetadata meta = new OidcProviderMetadata("a", "t", "u", "j", "e");
@@ -4,8 +4,8 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper; import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
import dev.relism.flash.extension.FlashContext; import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.extension.FlashRegistrar; import dev.relism.flash.extension.FlashRegistrar;
import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.extension.RouteEvent; import dev.relism.flash.extension.RouteEvent;
import dev.relism.flash.http.ContentType; import dev.relism.flash.http.ContentType;
import dev.relism.flash.http.HttpMethod; import dev.relism.flash.http.HttpMethod;
@@ -65,7 +65,7 @@ public class OpenApiExtension implements FlashExtension {
// ── FlashExtension ──────────────────────────────────────────────────────── // ── FlashExtension ────────────────────────────────────────────────────────
@Override @Override
public void provide(FlashContext ctx) { public void configure(FlashRegistrar<?> app, FlashContext ctx) {
OpenApiBuilder builder = new OpenApiBuilder().title(title).version(version).description(description); OpenApiBuilder builder = new OpenApiBuilder().title(title).version(version).description(description);
OpenApiContributorRegistry registry = new OpenApiContributorRegistry(); OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
@@ -76,22 +76,20 @@ public class OpenApiExtension implements FlashExtension {
// Collect operation metadata from final compiled routes. // Collect operation metadata from final compiled routes.
// This guarantees full runtime paths (namespaces/prefixes/rewrites) in the spec. // This guarantees full runtime paths (namespaces/prefixes/rewrites) in the spec.
ctx.addRouteListener(event -> addOperationFromEvent(builder, event)); ctx.addRouteListener(event -> addOperationFromEvent(builder, event));
} ctx.onReady(() -> {
ObjectMapper jsonMapper = ctx.find(ObjectMapper.class).orElseGet(() -> JsonMapper.builder().build());
@Override YAMLMapper yamlMapper = new YAMLMapper();
public void routes(FlashRegistrar<?> app, FlashContext ctx) { OpenApiBuilder resolvedBuilder = ctx.require(OpenApiBuilder.class);
ObjectMapper jsonMapper = ctx.find(ObjectMapper.class).orElseGet(() -> JsonMapper.builder().build());
YAMLMapper yamlMapper = new YAMLMapper();
OpenApiBuilder builder = ctx.require(OpenApiBuilder.class);
String jsonPath = basePath + ".json"; String jsonPath = basePath + ".json";
String yamlPath = basePath + ".yaml"; String yamlPath = basePath + ".yaml";
String swaggerPath = basePath + "/swagger"; String swaggerPath = basePath + "/swagger";
String swaggerHtml = buildSwaggerHtml(jsonPath); String swaggerHtml = buildSwaggerHtml(jsonPath);
app.get(jsonPath, (req, res) -> { res.type(ContentType.JSON); return jsonMapper.writeValueAsString(builder.build()); }); app.get(jsonPath, (req, res) -> { res.type(ContentType.JSON); return jsonMapper.writeValueAsString(resolvedBuilder.build()); });
app.get(yamlPath, (req, res) -> { res.type(YAML_CONTENT_TYPE); return yamlMapper.writeValueAsString(builder.build()); }); app.get(yamlPath, (req, res) -> { res.type(YAML_CONTENT_TYPE); return yamlMapper.writeValueAsString(resolvedBuilder.build()); });
app.get(swaggerPath, (req, res) -> { res.type(ContentType.TEXT_HTML); return swaggerHtml; }); app.get(swaggerPath, (req, res) -> { res.type(ContentType.TEXT_HTML); return swaggerHtml; });
});
} }
// ── Swagger UI HTML ─────────────────────────────────────────────────────── // ── Swagger UI HTML ───────────────────────────────────────────────────────
@@ -11,7 +11,7 @@ import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.models.RequestHandler; import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.models.Response; import dev.relism.flash.models.Response;
import dev.relism.flash.routing.GET; import dev.relism.flash.routing.GET;
import dev.relism.flash.routing.Middleware; import dev.relism.flash.routing.MiddlewareNode;
import dev.relism.flash.websocket.WebSocketEndpoint; import dev.relism.flash.websocket.WebSocketEndpoint;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -41,12 +41,10 @@ class OpenApiExtensionTest {
void provide_collects_operations_and_routes_serve_json_yaml_swagger() throws Exception { void provide_collects_operations_and_routes_serve_json_yaml_swagger() throws Exception {
FlashContext ctx = new FlashContext(); FlashContext ctx = new FlashContext();
OpenApiExtension ext = new OpenApiExtension("/docs", "My API", "2.0.0", "desc"); OpenApiExtension ext = new OpenApiExtension("/docs", "My API", "2.0.0", "desc");
ext.provide(ctx);
emitRoute(ctx, HttpMethod.GET, "/health", "/", HealthHandler.class);
TestRegistrar app = new TestRegistrar(ctx); TestRegistrar app = new TestRegistrar(ctx);
ext.routes(app, ctx); ext.configure(app, ctx);
emitRoute(ctx, HttpMethod.GET, "/health", "/", HealthHandler.class);
ctx.complete();
assertNotNull(app.route(HttpMethod.GET, "/docs.json")); assertNotNull(app.route(HttpMethod.GET, "/docs.json"));
assertNotNull(app.route(HttpMethod.GET, "/docs.yaml")); assertNotNull(app.route(HttpMethod.GET, "/docs.yaml"));
@@ -77,9 +75,9 @@ class OpenApiExtensionTest {
ObjectMapper mapper = new ObjectMapper(); ObjectMapper mapper = new ObjectMapper();
ctx.provide(ObjectMapper.class, mapper); ctx.provide(ObjectMapper.class, mapper);
ext.provide(ctx);
TestRegistrar app = new TestRegistrar(ctx); TestRegistrar app = new TestRegistrar(ctx);
ext.routes(app, ctx); ext.configure(app, ctx);
ctx.complete();
Response jsonRes = new Response(200, ContentType.NONE); Response jsonRes = new Response(200, ContentType.NONE);
Object jsonBody = app.route(HttpMethod.GET, "/openapi.json").handle(null, jsonRes); Object jsonBody = app.route(HttpMethod.GET, "/openapi.json").handle(null, jsonRes);
@@ -99,10 +97,11 @@ class OpenApiExtensionTest {
void collects_full_runtime_path_from_route_event() { void collects_full_runtime_path_from_route_event() {
FlashContext ctx = new FlashContext(); FlashContext ctx = new FlashContext();
OpenApiExtension ext = new OpenApiExtension(); OpenApiExtension ext = new OpenApiExtension();
ext.provide(ctx); ext.configure(new TestRegistrar(ctx), ctx);
emitRoute(ctx, HttpMethod.GET, "/api/v1/users", "/api/v1", ScopedUsersHandler.class); emitRoute(ctx, HttpMethod.GET, "/api/v1/users", "/api/v1", ScopedUsersHandler.class);
ctx.complete();
OpenApiBuilder builder = ctx.require(OpenApiBuilder.class); OpenApiBuilder builder = ctx.require(OpenApiBuilder.class);
Map<String, Object> spec = builder.build(); Map<String, Object> spec = builder.build();
Map<String, Object> paths = cast(spec.get("paths")); Map<String, Object> paths = cast(spec.get("paths"));
@@ -114,10 +113,11 @@ class OpenApiExtensionTest {
void normalizes_double_slash_paths_from_events() { void normalizes_double_slash_paths_from_events() {
FlashContext ctx = new FlashContext(); FlashContext ctx = new FlashContext();
OpenApiExtension ext = new OpenApiExtension(); OpenApiExtension ext = new OpenApiExtension();
ext.provide(ctx); ext.configure(new TestRegistrar(ctx), ctx);
emitRoute(ctx, HttpMethod.GET, "//blogs", "/", ScopedUsersHandler.class); emitRoute(ctx, HttpMethod.GET, "//blogs", "/", ScopedUsersHandler.class);
ctx.complete();
OpenApiBuilder builder = ctx.require(OpenApiBuilder.class); OpenApiBuilder builder = ctx.require(OpenApiBuilder.class);
Map<String, Object> spec = builder.build(); Map<String, Object> spec = builder.build();
Map<String, Object> paths = cast(spec.get("paths")); Map<String, Object> paths = cast(spec.get("paths"));
@@ -150,7 +150,7 @@ class OpenApiExtensionTest {
private static final class TestRegistrar extends FlashRegistrar<TestRegistrar> { private static final class TestRegistrar extends FlashRegistrar<TestRegistrar> {
private final FlashContext ctx; private final FlashContext ctx;
private final Map<String, RequestHandler> routes = new HashMap<>(); private final Map<String, RequestHandler> routes = new HashMap<>();
private final List<Middleware> middlewares = new ArrayList<>(); private final List<MiddlewareNode> middlewares = new ArrayList<>();
private TestRegistrar(FlashContext ctx) { private TestRegistrar(FlashContext ctx) {
this.ctx = ctx; this.ctx = ctx;
@@ -162,7 +162,7 @@ class OpenApiExtensionTest {
} }
@Override @Override
protected void addRoute(HttpMethod method, String path, RequestHandler handler, List<Middleware> mw) { protected void addRoute(HttpMethod method, String path, RequestHandler handler, List<MiddlewareNode> mw) {
routes.put(method.name() + " " + path, handler); routes.put(method.name() + " " + path, handler);
} }
@@ -172,7 +172,7 @@ class OpenApiExtensionTest {
} }
@Override @Override
protected void addMiddleware(Middleware mw) { protected void addMiddleware(MiddlewareNode mw) {
middlewares.add(mw); middlewares.add(mw);
} }
@@ -1,10 +1,9 @@
package dev.relism.flash.ext.routeviewer; package dev.relism.flash.ext.routeviewer;
import dev.relism.flash.ext.routeviewer.model.RouteGraph; import dev.relism.flash.ext.routeviewer.model.RouteGraph;
import dev.relism.flash.extension.ExtensionPhase;
import dev.relism.flash.extension.FlashContext; import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.extension.FlashRegistrar; import dev.relism.flash.extension.FlashRegistrar;
import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.http.ContentType; import dev.relism.flash.http.ContentType;
/** /**
@@ -41,9 +40,6 @@ public class RouteViewerExtension implements FlashExtension {
private final String path; private final String path;
private final RouteGraph graph = new RouteGraph(); private final RouteGraph graph = new RouteGraph();
/** Observability — runs last so the viewer sees the complete middleware chain. */
@Override public int priority() { return ExtensionPhase.LATE.value; }
/** Installs the viewer at {@value #DEFAULT_PATH}. */ /** Installs the viewer at {@value #DEFAULT_PATH}. */
public RouteViewerExtension() { this(DEFAULT_PATH); } public RouteViewerExtension() { this(DEFAULT_PATH); }
@@ -54,16 +50,14 @@ public class RouteViewerExtension implements FlashExtension {
public RouteViewerExtension(String path) { this.path = path; } public RouteViewerExtension(String path) { this.path = path; }
@Override @Override
public void provide(FlashContext ctx) { public void configure(FlashRegistrar<?> app, FlashContext ctx) {
// Register listener before any routes compile — captures everything. // Register listener before any routes compile — captures everything.
ctx.addRouteListener(graph::add); ctx.addRouteListener(graph::add);
} ctx.onReady(() -> {
app.get(path, new RouteViewerHandler()::handle);
@Override app.get(path + "/app.js", new RouteViewerStaticHandler("routeviewer/app.js", ContentType.TEXT_JAVASCRIPT)::handle);
public void routes(FlashRegistrar<?> app, FlashContext ctx) { app.get(path + "/app.css", new RouteViewerStaticHandler("routeviewer/app.css", ContentType.TEXT_CSS)::handle);
app.get(path, new RouteViewerHandler()::handle); app.get(path + "/data", new RouteViewerDataHandler(graph)::handle);
app.get(path + "/app.js", new RouteViewerStaticHandler("routeviewer/app.js", ContentType.TEXT_JAVASCRIPT)::handle); });
app.get(path + "/app.css", new RouteViewerStaticHandler("routeviewer/app.css", ContentType.TEXT_CSS)::handle);
app.get(path + "/data", new RouteViewerDataHandler(graph)::handle);
} }
} }
@@ -1,9 +1,11 @@
package dev.relism.flash.ext.view.core; package dev.relism.flash.ext.view.core;
import dev.relism.flash.extension.FlashContext; import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashRegistrar;
import dev.relism.flash.extension.FlashExtension; import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.models.Request; import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestHandler; import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.routing.MiddlewareNode;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -30,7 +32,7 @@ public abstract class BaseViewExtension<TTarget> implements FlashExtension {
} }
@Override @Override
public final void provide(FlashContext ctx) { public void configure(FlashRegistrar<?> app, FlashContext ctx) {
ViewRuntimeBridge<TTarget> runtime = createRuntime(List.copyOf(globals)); ViewRuntimeBridge<TTarget> runtime = createRuntime(List.copyOf(globals));
ctx.provide(ViewRuntimeBridge.class, runtime); ctx.provide(ViewRuntimeBridge.class, runtime);
ctx.addAnnotationProcessor(handlerClass -> { ctx.addAnnotationProcessor(handlerClass -> {
@@ -57,15 +57,17 @@ public final class JteExtension extends BaseViewExtension<JteTarget> {
} }
@Override @Override
public void routes(FlashRegistrar<?> app, FlashContext ctx) { public void configure(FlashRegistrar<?> app, FlashContext ctx) {
if (!settings.serveStatics()) return; super.configure(app, ctx);
JteStaticServing staticServing = JteStaticServing.load(settings); ctx.onReady(() -> {
if (staticServing == null) return; if (!settings.serveStatics()) return;
JteStaticServing staticServing = JteStaticServing.load(settings);
String wildcard = settings.staticPrefix() + "/**"; if (staticServing == null) return;
StaticJteHandler handler = new StaticJteHandler(staticServing); String wildcard = settings.staticPrefix() + "/**";
app.get(wildcard, handler::handle); StaticJteHandler handler = new StaticJteHandler(staticServing);
app.head(wildcard, (req, res) -> { staticServing.serve(req, res, true); return null; }); app.get(wildcard, handler::handle);
app.head(wildcard, (req, res) -> { staticServing.serve(req, res, true); return null; });
});
} }
@Override @Override
@@ -93,7 +93,9 @@ class JteExtensionTest {
void routes_register_static_wildcard_when_enabled() { void routes_register_static_wildcard_when_enabled() {
JteExtension ext = new JteExtension(cfg -> cfg.staticPrefix("/assets")); JteExtension ext = new JteExtension(cfg -> cfg.staticPrefix("/assets"));
TestRegistrar app = new TestRegistrar(); TestRegistrar app = new TestRegistrar();
ext.routes(app, new dev.relism.flash.extension.FlashContext()); dev.relism.flash.extension.FlashContext ctx = new dev.relism.flash.extension.FlashContext();
ext.configure(app, ctx);
ctx.complete();
assertTrue(app.routes.containsKey("GET /assets/**")); assertTrue(app.routes.containsKey("GET /assets/**"));
assertTrue(app.routes.containsKey("HEAD /assets/**")); assertTrue(app.routes.containsKey("HEAD /assets/**"));
} }
@@ -102,7 +104,9 @@ class JteExtensionTest {
void routes_do_not_register_static_when_disabled() { void routes_do_not_register_static_when_disabled() {
JteExtension ext = new JteExtension().serveStatics(false); JteExtension ext = new JteExtension().serveStatics(false);
TestRegistrar app = new TestRegistrar(); TestRegistrar app = new TestRegistrar();
ext.routes(app, new dev.relism.flash.extension.FlashContext()); dev.relism.flash.extension.FlashContext ctx = new dev.relism.flash.extension.FlashContext();
ext.configure(app, ctx);
ctx.complete();
assertTrue(app.routes.isEmpty()); assertTrue(app.routes.isEmpty());
} }
@@ -112,7 +116,9 @@ class JteExtensionTest {
.staticPrefix("/assets") .staticPrefix("/assets")
.largeFileThresholdBytes(1)); .largeFileThresholdBytes(1));
TestRegistrar app = new TestRegistrar(); TestRegistrar app = new TestRegistrar();
ext.routes(app, new dev.relism.flash.extension.FlashContext()); dev.relism.flash.extension.FlashContext ctx = new dev.relism.flash.extension.FlashContext();
ext.configure(app, ctx);
ctx.complete();
Request req = request("/assets/sample.css", null, null, null); Request req = request("/assets/sample.css", null, null, null);
Response res = new Response(200, dev.relism.flash.http.ContentType.TEXT_PLAIN); Response res = new Response(200, dev.relism.flash.http.ContentType.TEXT_PLAIN);
@@ -161,7 +167,7 @@ class JteExtensionTest {
private static final class TestRegistrar extends dev.relism.flash.extension.FlashRegistrar<TestRegistrar> { private static final class TestRegistrar extends dev.relism.flash.extension.FlashRegistrar<TestRegistrar> {
private final Map<String, RequestHandler> routes = new HashMap<>(); private final Map<String, RequestHandler> routes = new HashMap<>();
private final List<dev.relism.flash.routing.Middleware> mws = new ArrayList<>(); private final List<dev.relism.flash.routing.MiddlewareNode> mws = new ArrayList<>();
@Override @Override
public dev.relism.flash.extension.FlashContext ctx() { public dev.relism.flash.extension.FlashContext ctx() {
@@ -169,7 +175,7 @@ class JteExtensionTest {
} }
@Override @Override
protected void addRoute(dev.relism.flash.http.HttpMethod method, String path, RequestHandler handler, List<dev.relism.flash.routing.Middleware> mw) { protected void addRoute(dev.relism.flash.http.HttpMethod method, String path, RequestHandler handler, List<dev.relism.flash.routing.MiddlewareNode> mw) {
routes.put(method.name() + " " + path, handler); routes.put(method.name() + " " + path, handler);
} }
@@ -179,7 +185,7 @@ class JteExtensionTest {
} }
@Override @Override
protected void addMiddleware(dev.relism.flash.routing.Middleware mw) { protected void addMiddleware(dev.relism.flash.routing.MiddlewareNode mw) {
mws.add(mw); mws.add(mw);
} }
} }
@@ -13,7 +13,14 @@ Builder shortcuts:
- `.assetsFromClasspath("web/dist")` - `.assetsFromClasspath("web/dist")`
Classpath source requires `asset-manifest.json` generated at build time. Classpath source requires `asset-manifest.json` generated at build time.
The developer does not maintain this file manually. The developer does not maintain this file manually — generate it with `WebBundlerBuild`
(`dev.relism.flash.ext.webbundler.WebBundlerBuild`), which scans a prebuilt directory (a Vite
`dist/` or a `STATIC` asset folder) and writes the manifest into it, ready to be picked up as a
classpath resource once that directory lands under `target/classes`. See `build-time.md`.
`WebBundlerBuild` reuses the exact same etag/mimeType/immutable computation `FilesystemAssetsSource`
uses at runtime, so a file served from disk in dev and the same file served from the classpath in
prod get identical cache semantics.
Production startup is fail-fast if: Production startup is fail-fast if:
@@ -0,0 +1,43 @@
# Build-Time Manifest Generation
`WebBundlerBuild` turns an already-built directory into the `asset-manifest.json` that
`ClasspathAssetsSource` needs (see `asset-sources.md`). It does not run a frontend build itself —
it only scans a directory that already contains the final files:
- `VITE`: point it at whatever `dist/` the existing frontend build tooling already produces.
- `STATIC`: point it directly at the static asset folder — there's no separate build step.
It's meant to run once per build, from the consumer project's own build, not from the running
application (`ClasspathAssetsSource` is explicitly unsupported in DEV — see `dev-lifecycle.md`).
## Wiring it into a Maven build
No dedicated Flash5 Maven plugin — `WebBundlerBuild` is a plain class with a `main`, invoked via
the standard `exec-maven-plugin`, bound to run before the resources are packaged:
```xml
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<executions>
<execution>
<id>web-bundler-manifest</id>
<phase>process-classes</phase>
<goals><goal>java</goal></goals>
<configuration>
<mainClass>dev.relism.flash.ext.webbundler.WebBundlerBuild</mainClass>
<arguments>
<argument>${project.build.outputDirectory}/web/dist</argument>
</arguments>
</configuration>
</execution>
</executions>
</plugin>
```
This assumes the built frontend (`web/dist/`, or a static folder) is already copied under
`target/classes/web/dist` by that point — e.g. via `maven-resources-plugin`'s `copy-resources`
goal, or by running the frontend build with an output directory that points there directly. Once
the manifest is written alongside those files, they're just classpath resources: a plain `mvn
package` (or `maven-shade-plugin` for a fat jar) picks them up with no further configuration, and
the app can then be configured with `.assetsFromClasspath("web/dist")`.
@@ -6,7 +6,7 @@ Key fields:
- `runtimeMode`: `PROD`, `ENV`, `AUTODETECT` - `runtimeMode`: `PROD`, `ENV`, `AUTODETECT`
- `operationMode`: `ORCHESTRATE_ONLY`, `MANAGED` - `operationMode`: `ORCHESTRATE_ONLY`, `MANAGED`
- `frontendType`: currently `VITE` - `frontendType`: `VITE`, `STATIC` — see `frontend-selection.md`
- `packageManager`: `NPM`, `PNPM`, `YARN`, `BUN` - `packageManager`: `NPM`, `PNPM`, `YARN`, `BUN`
- `installPolicy`: `AUTO_IF_LOCK_HASH_CHANGED`, `NEVER` - `installPolicy`: `AUTO_IF_LOCK_HASH_CHANGED`, `NEVER`
- `loggingMode`: `MERGED`, `SEPARATE`, `QUIET`, `VERBOSE` - `loggingMode`: `MERGED`, `SEPARATE`, `QUIET`, `VERBOSE`
@@ -25,6 +25,12 @@ Or by direct source object:
Validation is fail-fast: Validation is fail-fast:
- invalid `devPort` - invalid `devPort` (only when `frontendType` requires orchestration — skipped for `STATIC`)
- blank/invalid watch entries - blank/invalid watch entries (same — skipped for `STATIC`)
- invalid `basePath` - invalid `basePath`
`frontendType(...)` has side effects on other defaults, same pattern as `packageManager(...)`
resetting `watchList`: it also resets `operationMode` (`MANAGED` for `STATIC`, `ORCHESTRATE_ONLY`
otherwise) and `assetsSource` (`webRoot` itself for `STATIC`, `webRoot/dist` otherwise). Call
`.frontendType(...)` before any explicit `.operationMode(...)`/`.assetsSource(...)`/`.assetsFrom*(...)`
override, or the later call wins.
@@ -4,7 +4,29 @@ Frontend integration is explicit through `frontendType`.
- No heuristic detection in v1. - No heuristic detection in v1.
- Deterministic mapping: `FrontendType -> FrontendStrategy`. - Deterministic mapping: `FrontendType -> FrontendStrategy`.
- Current built-in strategy: `VITE`. - Built-in strategies: `VITE`, `STATIC`.
## VITE
Orchestrates a dev server process in DEV, serves a prebuilt directory in PROD. See `dev-lifecycle.md`.
## STATIC
For files served as-is — no dev server, no package manager, no build step, no watch loop.
`STATIC` never orchestrates, in DEV or PROD: it always loads `assetsSource` directly and serves it,
the same code path `VITE` only uses in PROD. Editing a file during a running dev session requires a
restart to be picked up (assets are preloaded once, same as `VITE`'s prod serving — no hot reload).
Setting `.frontendType(FrontendType.STATIC)` also switches two other defaults (see `configuration.md`):
`operationMode` becomes `MANAGED` and `assetsSource` defaults to the `webRoot` itself instead of a
`dist` subdirectory — a minimal STATIC config is just:
```java
WebBundlerConfig.builder()
.frontendType(FrontendType.STATIC)
.webRoot(Path.of("public"))
.build()
```
Extension points: Extension points:
@@ -10,3 +10,12 @@
- `ORCHESTRATE_ONLY`: only orchestrates dev tooling. - `ORCHESTRATE_ONLY`: only orchestrates dev tooling.
- `MANAGED`: enables production serving + SPA fallback routes. - `MANAGED`: enables production serving + SPA fallback routes.
`FrontendType.STATIC` defaults `operationMode` to `MANAGED` (see `frontend-selection.md`) — `STATIC`
has no dev tooling to orchestrate, so `ORCHESTRATE_ONLY` would make the extension a no-op for it.
## Orchestration
Whether DEV mode spawns a dev-server process at all is a separate axis from Runtime Mode: it also
depends on `frontendType`. `VITE` orchestrates in DEV; `STATIC` never does, in DEV or PROD — it
always loads and serves `assetsSource` directly, the same path `VITE` only takes in PROD.
@@ -0,0 +1,60 @@
package dev.relism.flash.ext.webbundler;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Walks a directory and computes the same etag/mimeType/immutable metadata a served asset needs.
* Shared by {@link FilesystemAssetsSource} (runtime, dev/filesystem prod) and {@link WebBundlerBuild}
* (build-time classpath manifest) so both agree on cache semantics for the same file.
*/
final class AssetDirectoryScanner {
private AssetDirectoryScanner() {
}
record ScannedAsset(String canonicalPath, byte[] raw, byte[] br, byte[] gz, String etag, String mimeType, boolean immutable) {
}
static List<ScannedAsset> scan(Path root) {
Map<String, Builder> builders = new HashMap<>();
try (var walk = Files.walk(root)) {
walk.filter(Files::isRegularFile).forEach(file -> {
String rel = "/" + root.relativize(file).toString().replace('\\', '/');
String canonical = AssetIo.stripBrGzSuffix(rel);
Builder b = builders.computeIfAbsent(canonical, Builder::new);
byte[] bytes = AssetIo.read(file);
if (rel.endsWith(".br")) b.br = bytes;
else if (rel.endsWith(".gz")) b.gz = bytes;
else b.raw = bytes;
});
} catch (IOException e) {
throw new IllegalStateException("Failed to scan assets from " + root, e);
}
List<ScannedAsset> result = new ArrayList<>();
for (Builder b : builders.values()) {
if (b.raw == null) continue;
String etag = AssetIo.quotedSha1(b.raw);
String mime = MimeTypes.byPath(b.canonicalPath);
boolean immutable = AssetIo.isFingerprinted(b.canonicalPath);
result.add(new ScannedAsset(b.canonicalPath, b.raw, b.br, b.gz, etag, mime, immutable));
}
return result;
}
private static final class Builder {
private final String canonicalPath;
private byte[] raw;
private byte[] br;
private byte[] gz;
private Builder(String canonicalPath) {
this.canonicalPath = canonicalPath;
}
}
}
@@ -1,6 +1,5 @@
package dev.relism.flash.ext.webbundler; package dev.relism.flash.ext.webbundler;
import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.HashMap; import java.util.HashMap;
@@ -25,30 +24,10 @@ public final class FilesystemAssetsSource implements AssetsSource {
throw new IllegalStateException("distDir does not exist: " + root); throw new IllegalStateException("distDir does not exist: " + root);
} }
Map<String, AssetEntryBuilder> builders = new HashMap<>();
try (var walk = Files.walk(root)) {
walk.filter(Files::isRegularFile).forEach(file -> {
String rel = "/" + root.relativize(file).toString().replace('\\', '/');
String canonical = AssetIo.stripBrGzSuffix(rel);
String routePath = AssetPaths.joinBase(request.basePath(), canonical);
AssetEntryBuilder b = builders.computeIfAbsent(routePath, k -> new AssetEntryBuilder(canonical));
byte[] bytes = AssetIo.read(file);
if (rel.endsWith(".br")) b.br = bytes;
else if (rel.endsWith(".gz")) b.gz = bytes;
else b.raw = bytes;
});
} catch (IOException e) {
throw new IllegalStateException("Failed to preload assets from " + root, e);
}
Map<String, AssetEntry> byRoute = new HashMap<>(); Map<String, AssetEntry> byRoute = new HashMap<>();
for (Map.Entry<String, AssetEntryBuilder> e : builders.entrySet()) { for (AssetDirectoryScanner.ScannedAsset asset : AssetDirectoryScanner.scan(root)) {
AssetEntryBuilder b = e.getValue(); String routePath = AssetPaths.joinBase(request.basePath(), asset.canonicalPath());
if (b.raw == null) continue; byRoute.put(routePath, new AssetEntry(asset.raw(), asset.br(), asset.gz(), asset.etag(), asset.mimeType(), asset.immutable()));
String etag = AssetIo.quotedSha1(b.raw);
String mime = MimeTypes.byPath(b.canonicalPath);
boolean immutable = AssetIo.isFingerprinted(b.canonicalPath);
byRoute.put(e.getKey(), new AssetEntry(b.raw, b.br, b.gz, etag, mime, immutable));
} }
String indexRoute = AssetPaths.joinBase(request.basePath(), "/" + request.indexFile()); String indexRoute = AssetPaths.joinBase(request.basePath(), "/" + request.indexFile());
@@ -58,15 +37,4 @@ public final class FilesystemAssetsSource implements AssetsSource {
} }
return new AssetCatalog(byRoute, index); return new AssetCatalog(byRoute, index);
} }
private static final class AssetEntryBuilder {
private final String canonicalPath;
private byte[] raw;
private byte[] br;
private byte[] gz;
private AssetEntryBuilder(String canonicalPath) {
this.canonicalPath = canonicalPath;
}
}
} }
@@ -1,5 +1,17 @@
package dev.relism.flash.ext.webbundler; package dev.relism.flash.ext.webbundler;
public enum FrontendType { public enum FrontendType {
VITE VITE(true),
STATIC(false);
private final boolean requiresOrchestration;
FrontendType(boolean requiresOrchestration) {
this.requiresOrchestration = requiresOrchestration;
}
/** Whether this frontend type needs a dev-server process, package manager, and watch loop. */
boolean requiresOrchestration() {
return requiresOrchestration;
}
} }
@@ -8,6 +8,7 @@ final class FrontendTypeResolver {
FrontendTypeResolver() { FrontendTypeResolver() {
register(new ViteFrontendStrategy()); register(new ViteFrontendStrategy());
register(new StaticFrontendStrategy());
} }
void register(FrontendStrategy strategy) { void register(FrontendStrategy strategy) {
@@ -0,0 +1,21 @@
package dev.relism.flash.ext.webbundler;
import java.util.List;
/** No dev server, no build step — assets are served as-is. Both methods below are unreachable: call sites are gated by {@link FrontendType#requiresOrchestration()}. */
final class StaticFrontendStrategy implements FrontendStrategy {
@Override
public FrontendType type() {
return FrontendType.STATIC;
}
@Override
public List<String> devCommand(WebBundlerConfig config, PackageManagerAdapter adapter) {
throw new UnsupportedOperationException("STATIC frontend type has no dev command");
}
@Override
public List<String> buildCommand(WebBundlerConfig config, PackageManagerAdapter adapter) {
throw new UnsupportedOperationException("STATIC frontend type has no build command");
}
}
@@ -0,0 +1,53 @@
package dev.relism.flash.ext.webbundler;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
/**
* Build-time counterpart to {@link ClasspathAssetsSource}: scans a prebuilt directory (a Vite
* {@code dist/} or a static asset folder) and writes the {@code asset-manifest.json} that
* classpath-based production serving requires. Meant to run from a consumer's build (e.g. via
* exec-maven-plugin's {@code exec:java}), not from the running application — see {@code docs/build-time.md}.
*/
public final class WebBundlerBuild {
private static final ObjectMapper JSON = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT);
private WebBundlerBuild() {
}
/** Scans {@code distDir} and writes {@code distDir/asset-manifest.json} for classpath serving. */
public static void generateManifest(Path distDir) {
if (!Files.isDirectory(distDir)) {
throw new IllegalArgumentException("Not a directory: " + distDir);
}
List<ClasspathAssetManifest.Entry> entries = AssetDirectoryScanner.scan(distDir).stream()
.map(asset -> new ClasspathAssetManifest.Entry(
asset.canonicalPath(),
AssetIo.stripLeadingSlash(asset.canonicalPath()),
asset.mimeType(),
asset.etag(),
asset.immutable()))
.toList();
if (entries.isEmpty()) {
throw new IllegalStateException("No assets found under " + distDir);
}
try {
JSON.writeValue(distDir.resolve("asset-manifest.json").toFile(), new ClasspathAssetManifest(entries));
} catch (IOException e) {
throw new IllegalStateException("Failed to write asset-manifest.json in " + distDir, e);
}
}
public static void main(String[] args) {
if (args.length != 1) {
System.err.println("Usage: java " + WebBundlerBuild.class.getName() + " <distDir>");
System.exit(1);
}
generateManifest(Path.of(args[0]));
}
}
@@ -92,15 +92,17 @@ public final class WebBundlerConfig {
Objects.requireNonNull(webRoot, "webRoot"); Objects.requireNonNull(webRoot, "webRoot");
Objects.requireNonNull(assetsSource, "assetsSource"); Objects.requireNonNull(assetsSource, "assetsSource");
Objects.requireNonNull(indexFile, "indexFile"); Objects.requireNonNull(indexFile, "indexFile");
if (devPort <= 0 || devPort > 65535) { if (frontendType.requiresOrchestration()) {
throw new IllegalArgumentException("WebBundlerConfig: devPort must be in range 1..65535"); if (devPort <= 0 || devPort > 65535) {
} throw new IllegalArgumentException("WebBundlerConfig: devPort must be in range 1..65535");
if (watchList.isEmpty()) { }
throw new IllegalArgumentException("WebBundlerConfig: watchList must not be empty"); if (watchList.isEmpty()) {
} throw new IllegalArgumentException("WebBundlerConfig: watchList must not be empty");
for (String path : watchList) { }
if (path == null || path.isBlank()) { for (String path : watchList) {
throw new IllegalArgumentException("WebBundlerConfig: watchList contains blank entries"); if (path == null || path.isBlank()) {
throw new IllegalArgumentException("WebBundlerConfig: watchList contains blank entries");
}
} }
} }
if (!basePath.startsWith("/")) { if (!basePath.startsWith("/")) {
@@ -135,7 +137,7 @@ public final class WebBundlerConfig {
private String basePath = "/"; private String basePath = "/";
private String devHost = "127.0.0.1"; private String devHost = "127.0.0.1";
private int devPort = 5173; private int devPort = 5173;
private AssetsSource assetsSource = FilesystemAssetsSource.of(Path.of("dist")); private AssetsSource assetsSource = defaultAssetsSource(FrontendType.VITE);
private String indexFile = "index.html"; private String indexFile = "index.html";
private List<String> watchList = defaultWatchList(PackageManager.NPM); private List<String> watchList = defaultWatchList(PackageManager.NPM);
private String devCommand; private String devCommand;
@@ -146,7 +148,12 @@ public final class WebBundlerConfig {
public Builder runtimeMode(RuntimeMode runtimeMode) { this.runtimeMode = runtimeMode; return this; } public Builder runtimeMode(RuntimeMode runtimeMode) { this.runtimeMode = runtimeMode; return this; }
public Builder operationMode(OperationMode operationMode) { this.operationMode = operationMode; return this; } public Builder operationMode(OperationMode operationMode) { this.operationMode = operationMode; return this; }
public Builder frontendType(FrontendType frontendType) { this.frontendType = frontendType; return this; } public Builder frontendType(FrontendType frontendType) {
this.frontendType = frontendType;
this.assetsSource = defaultAssetsSource(frontendType);
this.operationMode = frontendType.requiresOrchestration() ? OperationMode.ORCHESTRATE_ONLY : OperationMode.MANAGED;
return this;
}
public Builder packageManager(PackageManager packageManager) { public Builder packageManager(PackageManager packageManager) {
this.packageManager = packageManager; this.packageManager = packageManager;
this.watchList = defaultWatchList(packageManager); this.watchList = defaultWatchList(packageManager);
@@ -188,6 +195,12 @@ public final class WebBundlerConfig {
return new WebBundlerConfig(this); return new WebBundlerConfig(this);
} }
private static AssetsSource defaultAssetsSource(FrontendType type) {
return type == FrontendType.STATIC
? FilesystemAssetsSource.of(Path.of("."))
: FilesystemAssetsSource.of(Path.of("dist"));
}
private static List<String> defaultWatchList(PackageManager manager) { private static List<String> defaultWatchList(PackageManager manager) {
return List.of( return List.of(
"package.json", "package.json",
@@ -1,9 +1,8 @@
package dev.relism.flash.ext.webbundler; package dev.relism.flash.ext.webbundler;
import dev.relism.flash.extension.ExtensionPhase;
import dev.relism.flash.extension.FlashContext; import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.extension.FlashRegistrar; import dev.relism.flash.extension.FlashRegistrar;
import dev.relism.flash.extension.FlashExtension;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -24,11 +23,6 @@ public final class WebBundlerExtension implements FlashExtension {
private ScheduledExecutorService watchLoop; private ScheduledExecutorService watchLoop;
private Thread shutdownHook; private Thread shutdownHook;
@Override
public int priority() {
return ExtensionPhase.LATE.value;
}
public WebBundlerExtension() { public WebBundlerExtension() {
this(WebBundlerConfig.builder().build()); this(WebBundlerConfig.builder().build());
} }
@@ -38,7 +32,7 @@ public final class WebBundlerExtension implements FlashExtension {
} }
@Override @Override
public void provide(FlashContext ctx) { public void configure(FlashRegistrar<?> app, FlashContext ctx) {
RuntimeEnvironment environment = modeResolver.resolve(config); RuntimeEnvironment environment = modeResolver.resolve(config);
PackageManagerAdapter pmAdapter = new PackageManagerAdapter(config); PackageManagerAdapter pmAdapter = new PackageManagerAdapter(config);
FrontendStrategy strategy = frontendTypeResolver.resolve(config.frontendType()); FrontendStrategy strategy = frontendTypeResolver.resolve(config.frontendType());
@@ -51,7 +45,7 @@ public final class WebBundlerExtension implements FlashExtension {
SpaFallbackPolicy fallbackPolicy = null; SpaFallbackPolicy fallbackPolicy = null;
try { try {
if (environment == RuntimeEnvironment.DEV) { if (environment == RuntimeEnvironment.DEV && config.frontendType().requiresOrchestration()) {
bootstrapDev(strategy, pmAdapter, orchestrator, watchList); bootstrapDev(strategy, pmAdapter, orchestrator, watchList);
} else { } else {
AssetCatalog catalog = config.assetsSource().load(new AssetLoadRequest( AssetCatalog catalog = config.assetsSource().load(new AssetLoadRequest(
@@ -74,12 +68,10 @@ public final class WebBundlerExtension implements FlashExtension {
shutdownResources(orchestrator); shutdownResources(orchestrator);
throw ex; throw ex;
} }
} ctx.onReady(() -> {
@Override
public void routes(FlashRegistrar<?> app, FlashContext ctx) {
WebBundlerRuntime runtime = ctx.require(WebBundlerRuntime.class); WebBundlerRuntime runtime = ctx.require(WebBundlerRuntime.class);
if (runtime.environment() == RuntimeEnvironment.DEV || config.operationMode() == OperationMode.ORCHESTRATE_ONLY) { boolean orchestrated = runtime.environment() == RuntimeEnvironment.DEV && config.frontendType().requiresOrchestration();
if (orchestrated || config.operationMode() == OperationMode.ORCHESTRATE_ONLY) {
return; return;
} }
@@ -100,6 +92,7 @@ public final class WebBundlerExtension implements FlashExtension {
res.body(new byte[0]); res.body(new byte[0]);
return null; return null;
}); });
});
} }
private void bootstrapDev( private void bootstrapDev(
@@ -0,0 +1,68 @@
package dev.relism.flash.ext.webbundler;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import static org.junit.jupiter.api.Assertions.*;
class WebBundlerBuildTest {
private Path root;
@AfterEach
void tearDown() throws IOException {
if (root == null || !Files.exists(root)) return;
try (var walk = Files.walk(root)) {
walk.sorted(Comparator.reverseOrder()).forEach(p -> {
try {
Files.delete(p);
} catch (IOException ignored) {
}
});
}
}
/**
* {@link ClasspathAssetsSource} resolves manifest + resources via the classloader, so the
* scanned directory needs to actually be on the test classpath — using the directory the test
* class itself was loaded from (Maven: {@code target/test-classes}) keeps this portable across
* runners instead of hardcoding a build-tool-specific path.
*/
@Test
void generateManifest_isConsumableByClasspathAssetsSource() throws Exception {
Path testClasses = Path.of(WebBundlerBuildTest.class.getProtectionDomain().getCodeSource().getLocation().toURI());
root = testClasses.resolve("web-bundler-build-test-" + System.nanoTime());
Files.createDirectories(root);
Files.writeString(root.resolve("index.html"), "<html>built</html>");
Files.writeString(root.resolve("app.a1b2c3d4.js"), "console.log('built')");
WebBundlerBuild.generateManifest(root);
assertTrue(Files.exists(root.resolve("asset-manifest.json")));
String rootPrefix = testClasses.relativize(root).toString().replace('\\', '/');
ClasspathAssetsSource source = ClasspathAssetsSource.of(rootPrefix);
AssetCatalog catalog = source.load(new AssetLoadRequest("/", "index.html", RuntimeEnvironment.PROD, Path.of(".")));
assertNotNull(catalog.index());
assertTrue(new String(catalog.index().raw()).contains("built"));
AssetEntry js = catalog.find("/app.a1b2c3d4.js");
assertNotNull(js);
assertTrue(js.immutable());
assertEquals("text/javascript", js.mimeType());
assertFalse(catalog.index().immutable());
}
@Test
void generateManifest_emptyDirectory_throws() throws Exception {
Path testClasses = Path.of(WebBundlerBuildTest.class.getProtectionDomain().getCodeSource().getLocation().toURI());
root = testClasses.resolve("web-bundler-build-empty-" + System.nanoTime());
Files.createDirectories(root);
assertThrows(IllegalStateException.class, () -> WebBundlerBuild.generateManifest(root));
}
}
@@ -55,4 +55,22 @@ class WebBundlerConfigTest {
.build(); .build();
assertTrue(cfg.assetsSource() instanceof ClasspathAssetsSource); assertTrue(cfg.assetsSource() instanceof ClasspathAssetsSource);
} }
@Test
void staticFrontend_defaultsToManagedAndFilesystemSource() {
WebBundlerConfig cfg = WebBundlerConfig.builder()
.frontendType(FrontendType.STATIC)
.build();
assertEquals(OperationMode.MANAGED, cfg.operationMode());
assertTrue(cfg.assetsSource() instanceof FilesystemAssetsSource);
}
@Test
void staticFrontend_skipsOrchestrationValidation() {
assertDoesNotThrow(() -> WebBundlerConfig.builder()
.frontendType(FrontendType.STATIC)
.devPort(0)
.watchList(List.of())
.build());
}
} }
@@ -76,4 +76,47 @@ class WebBundlerExtensionIntegrationTest {
assertTrue(fallback.body().contains("spa")); assertTrue(fallback.body().contains("spa"));
} }
@Test
void staticFrontend_servesAssetsWithoutOrchestration() throws Exception {
Path webRoot = tempDir.resolve("public");
Files.createDirectories(webRoot);
Files.writeString(webRoot.resolve("index.html"), "<html>static</html>");
Files.writeString(webRoot.resolve("style.css"), "body{color:red}");
int port;
try (ServerSocket s = new ServerSocket(0)) {
port = s.getLocalPort();
}
// PROD is deterministic in a test JVM (Flash.DEV depends on env/system-property detection
// that can't be forced per-test); STATIC's actual guarantee — that it never orchestrates,
// in DEV or PROD — is enforced structurally by the same requiresOrchestration() gate in
// both WebBundlerExtension.provide() and .routes(), not by this test.
WebBundlerConfig config = WebBundlerConfig.builder()
.runtimeMode(RuntimeMode.PROD)
.frontendType(FrontendType.STATIC)
.webRoot(webRoot)
.build();
app = FlashApp.create(port);
app.install(new WebBundlerExtension(config));
app.get("/api/ping", (req, res) -> "pong");
app.start();
HttpClient client = HttpClient.newHttpClient();
HttpResponse<String> backend = client.send(
HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/api/ping")).GET().build(),
HttpResponse.BodyHandlers.ofString()
);
assertEquals(200, backend.statusCode());
assertEquals("pong", backend.body());
HttpResponse<String> asset = client.send(
HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/style.css")).GET().build(),
HttpResponse.BodyHandlers.ofString()
);
assertEquals(200, asset.statusCode());
assertTrue(asset.body().contains("color:red"));
}
} }
+1
View File
@@ -23,6 +23,7 @@
<module>flash-ext-view-thymeleaf</module> <module>flash-ext-view-thymeleaf</module>
<module>flash-ext-limiter</module> <module>flash-ext-limiter</module>
<module>flash-ext-web-bundler</module> <module>flash-ext-web-bundler</module>
<module>flash-ext-mcp</module>
<module>flash-ext-data-core</module> <module>flash-ext-data-core</module>
<module>flash-ext-data-jdbc</module> <module>flash-ext-data-jdbc</module>
<module>flash-ext-data-hibernate</module> <module>flash-ext-data-hibernate</module>
@@ -1,7 +1,7 @@
package dev.relism.flash.extension; package dev.relism.flash.extension;
import dev.relism.flash.models.RequestHandler; import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.routing.Middleware; import dev.relism.flash.routing.MiddlewareNode;
import java.util.List; import java.util.List;
@@ -18,5 +18,5 @@ import java.util.List;
*/ */
@FunctionalInterface @FunctionalInterface
public interface AnnotationProcessor { public interface AnnotationProcessor {
List<Middleware> process(Class<? extends RequestHandler> handlerClass); List<MiddlewareNode> process(Class<? extends RequestHandler> handlerClass);
} }
@@ -1,32 +0,0 @@
package dev.relism.flash.extension;
/**
* Semantic execution phases for {@link FlashExtension#priority()}.
*
* <p>Phase determines the order in which annotation-processor middlewares are injected into
* the chain. Lower value = runs earlier (outermost wrapper = first at request time).
*
* <pre>
* Request ──► EARLY middlewares ──► DEFAULT middlewares ──► LATE middlewares ──► handler
* </pre>
*
* <p>Within the same phase, extensions execute in install order (sort is stable).
* Raw integers are valid for fine-grained ordering within a phase
* (e.g. {@code ExtensionPhase.EARLY.value + 10}).
*/
public enum ExtensionPhase {
/** Security guards, rate limiting — must short-circuit before expensive processing. */
EARLY(100),
/** Normal application extensions. Default when {@link FlashExtension#priority()} is not overridden. */
DEFAULT(500),
/** Observability, logging, diagnostics — must observe after all business logic. */
LATE(900);
/** The integer priority value used for sorting. */
public final int value;
ExtensionPhase(int value) { this.value = value; }
}
@@ -9,6 +9,8 @@ import dev.relism.flash.models.SimpleHandler;
import dev.relism.flash.routing.AbstractRouter; import dev.relism.flash.routing.AbstractRouter;
import dev.relism.flash.routing.AbstractWsRouter; import dev.relism.flash.routing.AbstractWsRouter;
import dev.relism.flash.routing.Middleware; import dev.relism.flash.routing.Middleware;
import dev.relism.flash.routing.MiddlewareGraph;
import dev.relism.flash.routing.MiddlewareNode;
import dev.relism.flash.routing.routers.fastpathrouter.FastPathRouterImpl; import dev.relism.flash.routing.routers.fastpathrouter.FastPathRouterImpl;
import dev.relism.flash.routing.routers.fastpathrouter.FastPathWsRouterImpl; import dev.relism.flash.routing.routers.fastpathrouter.FastPathWsRouterImpl;
import dev.relism.flash.websocket.WebSocketEndpoint; import dev.relism.flash.websocket.WebSocketEndpoint;
@@ -19,7 +21,6 @@ import lombok.extern.slf4j.Slf4j;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer; import java.util.function.Consumer;
@@ -41,10 +42,9 @@ import java.util.function.Consumer;
* *
* <h3>Startup sequence (both {@link #start()} and {@link #startAndBlock()})</h3> * <h3>Startup sequence (both {@link #start()} and {@link #startAndBlock()})</h3>
* <ol> * <ol>
* <li>Extensions sorted by {@link FlashExtension#priority()} — lower first.</li> * <li>All {@link FlashExtension#configure} declarations.</li>
* <li>All {@link FlashExtension#provide} — register services, processors, listeners.</li>
* <li>{@link FlashContext#resolveAll()} — topo-sort, cycle detection.</li> * <li>{@link FlashContext#resolveAll()} — topo-sort, cycle detection.</li>
* <li>All {@link FlashExtension#routes} — routes registered, services available.</li> * <li>Ready callbacks register routes with resolved services.</li>
* <li>Compile all routes into one flat FSM — zero prefix scanning at runtime.</li> * <li>Compile all routes into one flat FSM — zero prefix scanning at runtime.</li>
* <li>Accept loop started.</li> * <li>Accept loop started.</li>
* </ol> * </ol>
@@ -62,7 +62,7 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
private final ServerHandle server; private final ServerHandle server;
private final FlashContext ctx = new FlashContext(); private final FlashContext ctx = new FlashContext();
private final List<FlashExtension> extensions = new ArrayList<>(); private final List<FlashExtension> extensions = new ArrayList<>();
private final List<Middleware> globalMiddlewares = new ArrayList<>(); private final List<MiddlewareNode> globalMiddlewares = new ArrayList<>();
private final List<RouteDefinition> deferredRoutes = new ArrayList<>(); private final List<RouteDefinition> deferredRoutes = new ArrayList<>();
private final List<WsRouteDefinition> deferredWsRoutes = new ArrayList<>(); private final List<WsRouteDefinition> deferredWsRoutes = new ArrayList<>();
@@ -131,10 +131,7 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
// ── Extensions ──────────────────────────────────────────────────────────── // ── Extensions ────────────────────────────────────────────────────────────
/** /**
* Registers an extension for two-phase installation at startup. * Registers a declarative extension contribution.
* Install order is irrelevant — all {@link FlashExtension#provide} calls complete
* before any {@link FlashExtension#routes} call begins.
* Extensions are sorted by {@link FlashExtension#priority()} before execution.
*/ */
public FlashApp install(FlashExtension ext) { public FlashApp install(FlashExtension ext) {
extensions.add(ext); extensions.add(ext);
@@ -174,7 +171,7 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
// ── FlashRegistrar impl ─────────────────────────────────────────────────── // ── FlashRegistrar impl ───────────────────────────────────────────────────
@Override @Override
protected void addRoute(HttpMethod method, String path, RequestHandler handler, List<Middleware> mw) { protected void addRoute(HttpMethod method, String path, RequestHandler handler, List<MiddlewareNode> mw) {
deferredRoutes.add(new RouteDefinition( deferredRoutes.add(new RouteDefinition(
method, path, handler, List.of(), mw, method, path, handler, List.of(), mw,
!(handler instanceof SimpleHandler), ctx, "/")); !(handler instanceof SimpleHandler), ctx, "/"));
@@ -186,27 +183,25 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
} }
@Override @Override
protected void addMiddleware(Middleware mw) { globalMiddlewares.add(mw); } protected void addMiddleware(MiddlewareNode mw) { globalMiddlewares.add(mw); }
// ── Boot ───────────────────────────────────────────────────────────────── // ── Boot ─────────────────────────────────────────────────────────────────
private void boot() { private void boot() {
extensions.sort(Comparator.comparingInt(FlashExtension::priority)); extensions.forEach(e -> e.configure(this, ctx));
extensions.forEach(e -> e.provide(ctx)); ctx.complete();
ctx.resolveAll();
extensions.forEach(e -> e.routes(this, ctx));
compile(); compile();
compileWs(); compileWs();
router.compile();
wsRouter.compile();
} }
// ── Compilation ────────────────────────────────────────────────────────── // ── Compilation ──────────────────────────────────────────────────────────
private static final Middleware[] EMPTY_MW = new Middleware[0];
/** Middleware chain order per route: Global → Scope → Annotation → Explicit. */ /** Middleware chain order per route: Global → Scope → Annotation → Explicit. */
private void compile() { private void compile() {
for (RouteDefinition def : deferredRoutes) { for (RouteDefinition def : deferredRoutes) {
List<Middleware> injected; List<MiddlewareNode> injected;
if (def.classBasedHandler()) { if (def.classBasedHandler()) {
injected = def.ctx().processors().stream() injected = def.ctx().processors().stream()
.flatMap(p -> p.process(def.handler().getClass()).stream()) .flatMap(p -> p.process(def.handler().getClass()).stream())
@@ -215,7 +210,8 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
} else { } else {
injected = List.of(); injected = List.of();
} }
Middleware[] all = concat(globalMiddlewares, def.scopeMiddlewares(), injected, def.explicitMiddlewares()); Middleware[] all = MiddlewareGraph.order(def.method() + " " + def.path(),
concat(globalMiddlewares, def.scopeMiddlewares(), injected, def.explicitMiddlewares()));
emitEvent(def, all); emitEvent(def, all);
router.doRegister(def.method(), def.path(), def.handler(), all); router.doRegister(def.method(), def.path(), def.handler(), all);
} }
@@ -248,16 +244,12 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
listeners.forEach(l -> l.onRoute(event)); listeners.forEach(l -> l.onRoute(event));
} }
private static Middleware[] concat(List<Middleware> global, List<Middleware> scope, private static List<MiddlewareNode> concat(List<MiddlewareNode> global, List<MiddlewareNode> scope,
List<Middleware> injected, List<Middleware> explicit) { List<MiddlewareNode> injected, List<MiddlewareNode> explicit) {
int total = global.size() + scope.size() + injected.size() + explicit.size(); int total = global.size() + scope.size() + injected.size() + explicit.size();
if (total == 0) return EMPTY_MW; if (total == 0) return List.of();
Middleware[] all = new Middleware[total]; List<MiddlewareNode> all = new ArrayList<>(total);
int i = 0; all.addAll(global); all.addAll(scope); all.addAll(injected); all.addAll(explicit);
for (Middleware m : global) all[i++] = m;
for (Middleware m : scope) all[i++] = m;
for (Middleware m : injected) all[i++] = m;
for (Middleware m : explicit) all[i++] = m;
return all; return all;
} }
} }
@@ -1,176 +1,160 @@
package dev.relism.flash.extension; package dev.relism.flash.extension;
import java.util.*; import java.util.*;
import java.util.function.Function;
import java.util.function.Supplier; import java.util.function.Supplier;
/** /** Deterministic boot-time service graph, frozen before handlers are initialised. */
* Central service registry and boot-time hook coordinator. public final class FlashContext {
* private enum State { DECLARING, RESOLVING, READY }
* <p>Every handler, extension, and scope shares one (or a child of one) {@code FlashContext}.
* Three capabilities:
* <ol>
* <li><b>Service registry</b> — {@link #provide}/{@link #supply}/{@link #require}/{@link #find}.</li>
* <li><b>Annotation processors</b> — middleware injection from handler annotations at boot.</li>
* <li><b>Route listeners</b> — boot-time observation of the route graph.</li>
* </ol>
*
* <h3>Eager vs lazy registration</h3>
* <ul>
* <li>{@link #provide(Class, Object)} — instance is already constructed, registered immediately.</li>
* <li>{@link #supply(Class, Supplier)} — factory is registered; it runs once, at
* {@link #resolveAll()} time (called by {@link FlashApp#start()}) after all
* {@link FlashExtension#provide} phases complete. The factory may call
* {@link #require} for its own dependencies — the runtime resolves in topological
* order automatically and reports circular dependencies with the full cycle path.</li>
* </ul>
*
* <p>A child context (via {@link #child()}) inherits parent services and processors.
* Services provided on the child are scoped and invisible to the parent.
*/
public class FlashContext {
private final FlashContext parent; private final FlashContext parent;
private final Map<Class<?>, Object> registry = new LinkedHashMap<>(); private final Map<Class<?>, Binding<?>> bindings = new LinkedHashMap<>();
private final Map<Class<?>, Supplier<?>> pending = new LinkedHashMap<>(); private final List<AnnotationProcessor> processors = new ArrayList<>();
private final List<AnnotationProcessor> processors = new ArrayList<>(); private final List<RouteListener> routeListeners = new ArrayList<>();
private final List<RouteListener> routeListeners = new ArrayList<>(); private final List<Runnable> readyCallbacks = new ArrayList<>();
private final List<FlashContext> children = new ArrayList<>();
// Lazy caches — nulled whenever the corresponding list is mutated. private final Deque<Class<?>> resolutionPath = new ArrayDeque<>();
private State state = State.DECLARING;
private List<AnnotationProcessor> cachedProcessors; private List<AnnotationProcessor> cachedProcessors;
private List<RouteListener> cachedListeners; private List<RouteListener> cachedListeners;
// DFS stack — tracks in-progress resolutions to detect circular dependencies. public FlashContext() { parent = null; }
private final LinkedHashSet<Class<?>> resolutionStack = new LinkedHashSet<>();
public FlashContext() { this.parent = null; }
private FlashContext(FlashContext parent) { this.parent = parent; } private FlashContext(FlashContext parent) { this.parent = parent; }
/** Creates a child context that inherits this context's services and processors. */ public FlashContext child() {
public FlashContext child() { return new FlashContext(this); } requireDeclaring();
FlashContext child = new FlashContext(this);
children.add(child);
return child;
}
// ── Service registry ───────────────────────────────────────────────────── /** Binds an already-created singleton. Duplicate bindings are always an error. */
/** Registers an already-constructed {@code instance} under {@code type}. */
public <T> void provide(Class<T> type, T instance) { public <T> void provide(Class<T> type, T instance) {
registry.put(type, instance); declare(type, new Binding<>(type, List.of(), ignored -> Objects.requireNonNull(instance, "instance")));
} }
/** /** Declares a no-dependency boot factory. */
* Registers a lazy factory for {@code type}. The factory runs once at
* {@link #resolveAll()} time (or on the first {@link #require} call for this type)
* and may call {@link #require} for its own dependencies — topological order
* is resolved automatically.
*
* <pre>{@code
* ctx.supply(JwtValidator.class, () ->
* new JwtValidator(ctx.require(OidcProviderMetadata.class).jwksUri()));
* }</pre>
*/
public <T> void supply(Class<T> type, Supplier<T> factory) { public <T> void supply(Class<T> type, Supplier<T> factory) {
pending.put(type, factory); declare(type, new Binding<>(type, List.of(), ignored -> factory.get()));
} }
/** /** Declares a boot factory and its complete dependency set. */
* Returns the service for {@code type}. Checks own scope first, then parent chain. public <T> void supply(Class<T> type, ServiceFactory<T> factory, Class<?>... dependencies) {
* Lazy-registered types are resolved on first access. Circular dependencies throw Objects.requireNonNull(factory, "factory");
* {@link IllegalStateException} with the full cycle path. declare(type, new Binding<>(type, List.of(dependencies), factory));
* }
* @throws IllegalStateException if the service is not found anywhere in the context chain
*/ /** One-dependency factory with no application-side context lookup. */
public <A, T> void supply(Class<T> type, Class<A> dependency, Function<A, T> factory) {
supply(type, ignored -> factory.apply(require(dependency)), dependency);
}
/** Registers work materialised after all services are resolved. */
public void onReady(Runnable callback) { requireDeclaring(); readyCallbacks.add(Objects.requireNonNull(callback)); }
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public <T> T require(Class<T> type) { public <T> T require(Class<T> type) {
Object val = registry.get(type); if (state == State.DECLARING)
if (val != null) return (T) val; throw new IllegalStateException("Service graph is still being declared; use FlashContext.onReady(...)");
if (pending.containsKey(type)) return resolve(type); Binding<?> binding = bindings.get(type);
if (binding != null) {
verifyDeclaredDependency(type);
return (T) resolve((Binding<Object>) binding);
}
if (parent != null) return parent.require(type); if (parent != null) return parent.require(type);
throw new IllegalStateException( throw new IllegalStateException("No provider declared for " + type.getName() + dependencyTrace());
"Service not found: " + type.getSimpleName() +
" — register it via FlashContext.provide()/supply() or install the required extension");
} }
/** Returns the service for {@code type}, or empty if not found in this scope or any parent. */
@SuppressWarnings("unchecked")
public <T> Optional<T> find(Class<T> type) { public <T> Optional<T> find(Class<T> type) {
Object val = registry.get(type); if (state == State.DECLARING)
if (val != null) return Optional.of((T) val); throw new IllegalStateException("Service graph is still being declared; use FlashContext.onReady(...)");
if (pending.containsKey(type)) return Optional.of(resolve(type)); if (bindings.containsKey(type)) return Optional.of(require(type));
return parent != null ? parent.find(type) : Optional.empty(); return parent == null ? Optional.empty() : parent.find(type);
} }
/** Alias for {@link #find} — prefer when semantics are "this may or may not exist". */
public <T> Optional<T> optional(Class<T> type) { return find(type); } public <T> Optional<T> optional(Class<T> type) { return find(type); }
/**
* Eagerly resolves all pending lazy suppliers in topological order.
* Called once by {@link FlashApp#start()} after all {@link FlashExtension#provide}
* phases complete. Any circular dependency is reported with the full cycle path.
*/
void resolveAll() {
new ArrayList<>(pending.keySet()).forEach(this::resolve);
}
@SuppressWarnings("unchecked")
private <T> T resolve(Class<?> type) {
Object already = registry.get(type);
if (already != null) return (T) already; // resolved during an earlier DFS branch
if (!resolutionStack.add(type)) {
// type is already on the current DFS path → circular dependency
List<Class<?>> cycle = new ArrayList<>(resolutionStack);
cycle.add(type);
StringBuilder msg = new StringBuilder("Circular dependency: ");
for (int i = 0; i < cycle.size(); i++) {
if (i > 0) msg.append("");
msg.append(cycle.get(i).getSimpleName());
}
throw new IllegalStateException(msg.toString());
}
Supplier<?> factory = pending.get(type);
Object instance = factory.get(); // recursive require() calls happen here
registry.put(type, instance);
pending.remove(type);
resolutionStack.remove(type);
return (T) instance;
}
// ── Annotation processors ────────────────────────────────────────────────
/** Registers an {@link AnnotationProcessor}. Processors run once per class-based handler at boot. */
public void addAnnotationProcessor(AnnotationProcessor processor) { public void addAnnotationProcessor(AnnotationProcessor processor) {
processors.add(processor); requireDeclaring(); processors.add(Objects.requireNonNull(processor)); cachedProcessors = null;
cachedProcessors = null; }
public void addRouteListener(RouteListener listener) {
requireDeclaring(); routeListeners.add(Objects.requireNonNull(listener)); cachedListeners = null;
} }
/** All processors visible from this context: parent-first, then own. Cached after first call. */
List<AnnotationProcessor> processors() { List<AnnotationProcessor> processors() {
if (cachedProcessors != null) return cachedProcessors; if (cachedProcessors != null) return cachedProcessors;
if (parent == null) return cachedProcessors = List.copyOf(processors); List<AnnotationProcessor> all = parent == null ? new ArrayList<>() : new ArrayList<>(parent.processors());
List<AnnotationProcessor> p = parent.processors(); all.addAll(processors); return cachedProcessors = List.copyOf(all);
if (processors.isEmpty()) return cachedProcessors = p;
List<AnnotationProcessor> merged = new ArrayList<>(p.size() + processors.size());
merged.addAll(p);
merged.addAll(processors);
return cachedProcessors = List.copyOf(merged);
} }
// ── Route listeners ──────────────────────────────────────────────────────
/** Registers a boot-time {@link RouteListener}. Zero overhead on the request hot-path. */
public void addRouteListener(RouteListener listener) {
routeListeners.add(listener);
cachedListeners = null;
}
/** All route listeners visible from this context: parent-first, then own. Cached after first call. */
List<RouteListener> routeListeners() { List<RouteListener> routeListeners() {
if (cachedListeners != null) return cachedListeners; if (cachedListeners != null) return cachedListeners;
if (parent == null) return cachedListeners = List.copyOf(routeListeners); List<RouteListener> all = parent == null ? new ArrayList<>() : new ArrayList<>(parent.routeListeners());
List<RouteListener> p = parent.routeListeners(); all.addAll(routeListeners); return cachedListeners = List.copyOf(all);
if (routeListeners.isEmpty()) return cachedListeners = p;
List<RouteListener> merged = new ArrayList<>(p.size() + routeListeners.size());
merged.addAll(p);
merged.addAll(routeListeners);
return cachedListeners = List.copyOf(merged);
} }
}
void resolveAll() {
if (state != State.DECLARING) throw new IllegalStateException("Service graph has already been closed");
state = State.RESOLVING;
for (Binding<?> binding : bindings.values()) resolveUnchecked(binding);
for (FlashContext child : children) child.resolveAll();
state = State.READY;
}
void runReadyCallbacks() {
if (state != State.READY) throw new IllegalStateException("Service graph is not ready");
for (Runnable callback : List.copyOf(readyCallbacks)) callback.run();
readyCallbacks.clear();
for (FlashContext child : children) child.runReadyCallbacks();
}
/** Completes graph resolution and runs all deferred materialisation callbacks once. */
public void complete() {
resolveAll();
runReadyCallbacks();
}
private <T> void declare(Class<T> type, Binding<T> binding) {
requireDeclaring(); Objects.requireNonNull(type, "type");
if (bindings.putIfAbsent(type, binding) != null)
throw new IllegalStateException("Duplicate provider declared for " + type.getName());
}
private void requireDeclaring() {
if (state != State.DECLARING) throw new IllegalStateException("Flash service declarations are closed");
}
@SuppressWarnings("unchecked") private void resolveUnchecked(Binding<?> binding) { resolve((Binding<Object>) binding); }
private <T> T resolve(Binding<T> binding) {
if (binding.instance != null) return binding.instance;
if (binding.resolving) throw cycle(binding.type);
binding.resolving = true; resolutionPath.addLast(binding.type);
try {
for (Class<?> dependency : binding.dependencies) require(dependency);
return binding.instance = Objects.requireNonNull(binding.factory.create(this),
() -> "Provider returned null for " + binding.type.getName());
} finally {
resolutionPath.removeLast(); binding.resolving = false;
}
}
private IllegalStateException cycle(Class<?> type) {
StringBuilder out = new StringBuilder("Circular service dependency: ");
for (Class<?> node : resolutionPath) out.append(node.getSimpleName()).append(" -> ");
return new IllegalStateException(out.append(type.getSimpleName()).toString());
}
private String dependencyTrace() {
return resolutionPath.isEmpty() ? "" : " (required while creating " + resolutionPath.peekLast().getName() + ')';
}
private void verifyDeclaredDependency(Class<?> type) {
if (resolutionPath.isEmpty()) return;
Class<?> owner = resolutionPath.peekLast();
Binding<?> binding = bindings.get(owner);
if (binding != null && !binding.dependencies.contains(type))
throw new IllegalStateException(owner.getName() + " requested undeclared dependency " + type.getName());
}
@FunctionalInterface public interface ServiceFactory<T> { T create(FlashContext services); }
private static final class Binding<T> {
final Class<T> type; final List<Class<?>> dependencies; final ServiceFactory<T> factory;
T instance; boolean resolving;
Binding(Class<T> type, List<Class<?>> dependencies, ServiceFactory<T> factory) {
this.type = type; this.dependencies = dependencies; this.factory = factory;
}
}
}
@@ -1,70 +1,17 @@
package dev.relism.flash.extension; package dev.relism.flash.extension;
/** /**
* Two-phase contract for all Flash extensions. * One declarative contribution to a Flash application.
* *
* <p>Extension lifecycle inside {@link FlashApp#start()}: * <p>Extensions never control lifecycle ordering. During {@link #configure}, they declare
* <ol> * services, processors, listeners and ready callbacks. Flash closes declarations, validates and
* <li>Extensions are sorted by {@link #priority()} — lower value runs first.</li> * resolves the complete service graph, then executes ready callbacks to materialise routes.
* <li><b>Provide phase</b> — {@link #provide(FlashContext)} is called for <em>all</em>
* installed extensions. Use this phase to register services, annotation processors,
* and route listeners. Never call {@link FlashContext#require} here.</li>
* <li>Context resolution — {@link FlashContext#resolveAll()} performs topological
* resolution of lazy suppliers. Circular or missing dependencies fail here with
* a clear message before any request is served.</li>
* <li><b>Routes phase</b> — {@link #routes(FlashRegistrar, FlashContext)} is called for
* all extensions. All services are resolved; {@link FlashContext#require} is safe.</li>
* </ol>
*
* <h3>Priority and middleware ordering</h3>
* {@link #priority()} controls the order annotation processors are registered, which
* determines the annotation-layer middleware chain position:
* <pre>
* Request ──► EARLY processors' mw ──► DEFAULT processors' mw ──► LATE processors' mw ──► handler
* </pre>
* Use {@link ExtensionPhase} constants for semantic ordering:
* <pre>{@code
* @Override public int priority() { return ExtensionPhase.EARLY.value; }
* }</pre>
*
* <h3>Example</h3>
* <pre>{@code
* public class MetricsExtension implements FlashExtension {
*
* @Override public int priority() { return ExtensionPhase.LATE.value; }
*
* @Override
* public void provide(FlashContext ctx) {
* ctx.provide(MetricsRegistry.class, new PromMetricsRegistry());
* }
*
* @Override
* public void routes(FlashRegistrar<?> app, FlashContext ctx) {
* app.get("/metrics", (req, res) -> ctx.require(MetricsRegistry.class).scrape());
* }
* }
* }</pre>
*/ */
@FunctionalInterface
public interface FlashExtension { public interface FlashExtension {
/** /**
* Phase 1 — register services and processors. * Declares this extension's contribution. {@code ctx.require(...)} is intentionally illegal
* Safe: {@link FlashContext#provide}, {@link FlashContext#supply}, * here: work needing resolved services belongs in {@link FlashContext#onReady(Runnable)}.
* {@link FlashContext#addAnnotationProcessor}, {@link FlashContext#addRouteListener}.
* Unsafe: {@link FlashContext#require} (services not yet resolved).
*/ */
default void provide(FlashContext ctx) {} void configure(FlashRegistrar<?> app, FlashContext ctx);
/**
* Phase 2 — register routes. All services are fully resolved.
* {@link FlashContext#require} is safe here.
*/
default void routes(FlashRegistrar<?> app, FlashContext ctx) {}
/**
* Execution priority. Lower = earlier in the annotation middleware chain.
* Tie-breaking: same value → install order (sort is stable).
* Default: {@link ExtensionPhase#DEFAULT} (500).
*/
default int priority() { return ExtensionPhase.DEFAULT.value; }
} }
@@ -6,11 +6,14 @@ import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.models.SimpleHandler; import dev.relism.flash.models.SimpleHandler;
import dev.relism.flash.websocket.WebSocketEndpoint; import dev.relism.flash.websocket.WebSocketEndpoint;
import dev.relism.flash.routing.Middleware; import dev.relism.flash.routing.Middleware;
import dev.relism.flash.routing.MiddlewareKey;
import dev.relism.flash.routing.MiddlewareNode;
import dev.relism.flash.routing.Route; import dev.relism.flash.routing.Route;
import dev.relism.flash.routing.Routes; import dev.relism.flash.routing.Routes;
import dev.relism.flash.routing.Ws; import dev.relism.flash.routing.Ws;
import java.util.List; import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
/** /**
* Common route-registration surface shared by {@link FlashApp} and {@link FlashScope}. * Common route-registration surface shared by {@link FlashApp} and {@link FlashScope}.
@@ -24,14 +27,15 @@ import java.util.List;
* app.get("/admin", handler, oidc.requireRole("admin"), rateLimiter) * app.get("/admin", handler, oidc.requireRole("admin"), rateLimiter)
* }</pre> * }</pre>
* *
* <p>{@link FlashExtension#routes} receives a {@code FlashRegistrar<?>} for route * <p>Extensions receive a {@link FlashApp} during their single configure declaration.
* registration. Extension installation ({@code install()}) is only available on * Extension installation ({@code install()}) is only available on
* {@link FlashApp} — scoped install is intentionally unsupported. * {@link FlashApp} — scoped install is intentionally unsupported.
* *
* @param <SELF> concrete registrar type — enables fluent chaining without casting * @param <SELF> concrete registrar type — enables fluent chaining without casting
*/ */
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public abstract class FlashRegistrar<SELF extends FlashRegistrar<SELF>> { public abstract class FlashRegistrar<SELF extends FlashRegistrar<SELF>> {
private static final AtomicLong INLINE_KEYS = new AtomicLong();
// ── HTTP method registration ────────────────────────────────────────────── // ── HTTP method registration ──────────────────────────────────────────────
@@ -47,6 +51,18 @@ public abstract class FlashRegistrar<SELF extends FlashRegistrar<SELF>> {
public final SELF purge (String path, SimpleHandler.FunctionalHandler h, Middleware... mw) { return route(HttpMethod.PURGE, path, h, mw); } public final SELF purge (String path, SimpleHandler.FunctionalHandler h, Middleware... mw) { return route(HttpMethod.PURGE, path, h, mw); }
public final SELF query (String path, SimpleHandler.FunctionalHandler h, Middleware... mw) { return route(HttpMethod.QUERY, path, h, mw); } public final SELF query (String path, SimpleHandler.FunctionalHandler h, Middleware... mw) { return route(HttpMethod.QUERY, path, h, mw); }
public final SELF getWith (String path, SimpleHandler.FunctionalHandler h, MiddlewareNode... mw) { return route(HttpMethod.GET, path, h, mw); }
public final SELF postWith (String path, SimpleHandler.FunctionalHandler h, MiddlewareNode... mw) { return route(HttpMethod.POST, path, h, mw); }
public final SELF putWith (String path, SimpleHandler.FunctionalHandler h, MiddlewareNode... mw) { return route(HttpMethod.PUT, path, h, mw); }
public final SELF deleteWith (String path, SimpleHandler.FunctionalHandler h, MiddlewareNode... mw) { return route(HttpMethod.DELETE, path, h, mw); }
public final SELF patchWith (String path, SimpleHandler.FunctionalHandler h, MiddlewareNode... mw) { return route(HttpMethod.PATCH, path, h, mw); }
public final SELF optionsWith(String path, SimpleHandler.FunctionalHandler h, MiddlewareNode... mw) { return route(HttpMethod.OPTIONS, path, h, mw); }
public final SELF headWith (String path, SimpleHandler.FunctionalHandler h, MiddlewareNode... mw) { return route(HttpMethod.HEAD, path, h, mw); }
public final SELF traceWith (String path, SimpleHandler.FunctionalHandler h, MiddlewareNode... mw) { return route(HttpMethod.TRACE, path, h, mw); }
public final SELF connectWith(String path, SimpleHandler.FunctionalHandler h, MiddlewareNode... mw) { return route(HttpMethod.CONNECT, path, h, mw); }
public final SELF purgeWith (String path, SimpleHandler.FunctionalHandler h, MiddlewareNode... mw) { return route(HttpMethod.PURGE, path, h, mw); }
public final SELF queryWith (String path, SimpleHandler.FunctionalHandler h, MiddlewareNode... mw) { return route(HttpMethod.QUERY, path, h, mw); }
// ── Middleware ──────────────────────────────────────────────────────────── // ── Middleware ────────────────────────────────────────────────────────────
/** /**
@@ -56,7 +72,13 @@ public abstract class FlashRegistrar<SELF extends FlashRegistrar<SELF>> {
* Order-independent: middleware is resolved at {@link FlashApp#start()}. * Order-independent: middleware is resolved at {@link FlashApp#start()}.
*/ */
public final SELF use(Middleware... middlewares) { public final SELF use(Middleware... middlewares) {
for (Middleware m : middlewares) addMiddleware(m); for (Middleware m : middlewares) addMiddleware(inline(m));
return (SELF) this;
}
/** Adds named middleware nodes whose ordering constraints are compiled at boot. */
public final SELF use(MiddlewareNode... middlewares) {
for (MiddlewareNode m : middlewares) addMiddleware(m);
return (SELF) this; return (SELF) this;
} }
@@ -93,18 +115,29 @@ public abstract class FlashRegistrar<SELF extends FlashRegistrar<SELF>> {
* Subclasses may prepend a namespace prefix and inject scope middlewares before storing. * Subclasses may prepend a namespace prefix and inject scope middlewares before storing.
*/ */
protected abstract void addRoute(HttpMethod method, String path, protected abstract void addRoute(HttpMethod method, String path,
RequestHandler handler, List<Middleware> mw); RequestHandler handler, List<MiddlewareNode> mw);
protected abstract void addWsRoute(String path, WebSocketEndpoint endpoint); protected abstract void addWsRoute(String path, WebSocketEndpoint endpoint);
/** Registers a middleware in this registrar's own scope (global or scope-level). */ /** Registers a middleware in this registrar's own scope (global or scope-level). */
protected abstract void addMiddleware(Middleware mw); protected abstract void addMiddleware(MiddlewareNode mw);
private SELF route(HttpMethod method, String path, SimpleHandler.FunctionalHandler h, Middleware[] mw) { private SELF route(HttpMethod method, String path, SimpleHandler.FunctionalHandler h, Middleware[] mw) {
MiddlewareNode[] nodes = new MiddlewareNode[mw.length];
for (int i = 0; i < mw.length; i++) nodes[i] = inline(mw[i]);
addRoute(method, path, new SimpleHandler(h), mw.length == 0 ? List.of() : List.of(nodes));
return (SELF) this;
}
private SELF route(HttpMethod method, String path, SimpleHandler.FunctionalHandler h, MiddlewareNode[] mw) {
addRoute(method, path, new SimpleHandler(h), mw.length == 0 ? List.of() : List.of(mw)); addRoute(method, path, new SimpleHandler(h), mw.length == 0 ? List.of() : List.of(mw));
return (SELF) this; return (SELF) this;
} }
private static MiddlewareNode inline(Middleware middleware) {
return MiddlewareNode.of(MiddlewareKey.of("flash.inline." + INLINE_KEYS.incrementAndGet()), middleware);
}
protected static RequestHandler instantiate(Class<?> cls) { protected static RequestHandler instantiate(Class<?> cls) {
try { return (RequestHandler) cls.getDeclaredConstructor().newInstance(); } try { return (RequestHandler) cls.getDeclaredConstructor().newInstance(); }
catch (Exception e) { catch (Exception e) {
@@ -3,7 +3,7 @@ package dev.relism.flash.extension;
import dev.relism.flash.http.HttpMethod; import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.models.RequestHandler; import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.models.SimpleHandler; import dev.relism.flash.models.SimpleHandler;
import dev.relism.flash.routing.Middleware; import dev.relism.flash.routing.MiddlewareNode;
import dev.relism.flash.routing.PathUtils; import dev.relism.flash.routing.PathUtils;
import dev.relism.flash.websocket.WebSocketEndpoint; import dev.relism.flash.websocket.WebSocketEndpoint;
@@ -28,7 +28,7 @@ public final class FlashScope extends FlashRegistrar<FlashScope> {
private final String namespace; private final String namespace;
private final FlashContext ctx; private final FlashContext ctx;
private final List<Middleware> scopeMiddlewares = new ArrayList<>(); private final List<MiddlewareNode> scopeMiddlewares = new ArrayList<>();
private final List<RouteDefinition> deferredRoutes = new ArrayList<>(); private final List<RouteDefinition> deferredRoutes = new ArrayList<>();
private final List<WsRouteDefinition> deferredWsRoutes = new ArrayList<>(); private final List<WsRouteDefinition> deferredWsRoutes = new ArrayList<>();
@@ -44,8 +44,8 @@ public final class FlashScope extends FlashRegistrar<FlashScope> {
// ── FlashRegistrar impl ─────────────────────────────────────────────────── // ── FlashRegistrar impl ───────────────────────────────────────────────────
@Override @Override
protected void addRoute(HttpMethod method, String path, RequestHandler handler, List<Middleware> mw) { protected void addRoute(HttpMethod method, String path, RequestHandler handler, List<MiddlewareNode> mw) {
List<Middleware> scopeMw = scopeMiddlewares.isEmpty() ? List.of() : List.copyOf(scopeMiddlewares); List<MiddlewareNode> scopeMw = scopeMiddlewares.isEmpty() ? List.of() : List.copyOf(scopeMiddlewares);
deferredRoutes.add(new RouteDefinition( deferredRoutes.add(new RouteDefinition(
method, ns(path), handler, scopeMw, mw, method, ns(path), handler, scopeMw, mw,
!(handler instanceof SimpleHandler), ctx, namespace)); !(handler instanceof SimpleHandler), ctx, namespace));
@@ -57,7 +57,7 @@ public final class FlashScope extends FlashRegistrar<FlashScope> {
} }
@Override @Override
protected void addMiddleware(Middleware mw) { scopeMiddlewares.add(mw); } protected void addMiddleware(MiddlewareNode mw) { scopeMiddlewares.add(mw); }
// ── Internal (called by FlashApp.mount) ─────────────────────────────────── // ── Internal (called by FlashApp.mount) ───────────────────────────────────
@@ -2,7 +2,7 @@ package dev.relism.flash.extension;
import dev.relism.flash.http.HttpMethod; import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.models.RequestHandler; import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.routing.Middleware; import dev.relism.flash.routing.MiddlewareNode;
import java.util.List; import java.util.List;
@@ -26,8 +26,8 @@ record RouteDefinition(
HttpMethod method, HttpMethod method,
String path, String path,
RequestHandler handler, RequestHandler handler,
List<Middleware> scopeMiddlewares, List<MiddlewareNode> scopeMiddlewares,
List<Middleware> explicitMiddlewares, List<MiddlewareNode> explicitMiddlewares,
boolean classBasedHandler, boolean classBasedHandler,
FlashContext ctx, FlashContext ctx,
String namespace String namespace
@@ -32,6 +32,9 @@ import java.nio.charset.StandardCharsets;
*/ */
public abstract class AbstractRouter { public abstract class AbstractRouter {
/** Eagerly validates and compiles this route graph before traffic is accepted. */
public void compile() {}
// Pre-encoded prod JSON error bodies — zero allocation on error paths. // Pre-encoded prod JSON error bodies — zero allocation on error paths.
private static final byte[] JSON_404 = "{\"error\":\"Not Found\",\"status\":404}" private static final byte[] JSON_404 = "{\"error\":\"Not Found\",\"status\":404}"
.getBytes(StandardCharsets.UTF_8); .getBytes(StandardCharsets.UTF_8);
@@ -8,6 +8,9 @@ import dev.relism.flash.websocket.WebSocketHandler;
public abstract class AbstractWsRouter { public abstract class AbstractWsRouter {
/** Eagerly validates and compiles this WebSocket route graph before traffic is accepted. */
public void compile() {}
public final AbstractWsRouter register(HttpMethod method, String path, WebSocketHandler handler) { public final AbstractWsRouter register(HttpMethod method, String path, WebSocketHandler handler) {
return addRoute(method, PathUtils.sanitize(path), handler); return addRoute(method, PathUtils.sanitize(path), handler);
} }
@@ -0,0 +1,55 @@
package dev.relism.flash.routing;
import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.models.SimpleHandler;
import java.util.*;
/** Boot-only DAG compiler for a route's middleware nodes. */
public final class MiddlewareGraph {
private MiddlewareGraph() {}
public static Middleware[] order(String route, List<MiddlewareNode> nodes) {
if (nodes.isEmpty()) return new Middleware[0];
Map<MiddlewareKey, Integer> index = new LinkedHashMap<>();
for (int i = 0; i < nodes.size(); i++) {
MiddlewareKey key = nodes.get(i).key();
if (index.putIfAbsent(key, i) != null)
throw new IllegalStateException("Duplicate middleware " + key.value() + " on " + route);
}
List<Set<Integer>> outgoing = new ArrayList<>(nodes.size());
int[] incoming = new int[nodes.size()];
for (int i = 0; i < nodes.size(); i++) outgoing.add(new LinkedHashSet<>());
for (int source = 0; source < nodes.size(); source++) {
for (MiddlewareNode.Constraint c : nodes.get(source).constraints()) {
Integer target = index.get(c.target());
if (target == null) {
if (c.required()) throw new IllegalStateException("Middleware " + nodes.get(source).key().value()
+ " on " + route + " requires " + c.target().value() + " to be present");
continue;
}
int from = c.relation() == MiddlewareNode.Relation.AFTER ? target : source;
int to = c.relation() == MiddlewareNode.Relation.AFTER ? source : target;
if (outgoing.get(from).add(to)) incoming[to]++;
}
}
PriorityQueue<Integer> ready = new PriorityQueue<>();
for (int i = 0; i < incoming.length; i++) if (incoming[i] == 0) ready.add(i);
Middleware[] ordered = new Middleware[nodes.size()];
int out = 0;
while (!ready.isEmpty()) {
int current = ready.remove();
ordered[out++] = nodes.get(current).middleware();
for (int next : outgoing.get(current)) if (--incoming[next] == 0) ready.add(next);
}
if (out != nodes.size()) throw new IllegalStateException("Middleware ordering cycle on " + route);
return ordered;
}
/** Pre-composes a sorted chain once at boot. */
public static RequestHandler compose(RequestHandler handler, Middleware[] ordered) {
RequestHandler current = handler;
for (int i = ordered.length - 1; i >= 0; i--) current = new SimpleHandler(ordered[i].wrap(current));
return current;
}
}
@@ -0,0 +1,11 @@
package dev.relism.flash.routing;
import java.util.Objects;
/** Stable boot-time identity of a middleware node. Never consulted while handling a request. */
public record MiddlewareKey(String value) {
public MiddlewareKey {
if (value == null || value.isBlank()) throw new IllegalArgumentException("Middleware key cannot be blank");
}
public static MiddlewareKey of(String value) { return new MiddlewareKey(value); }
}
@@ -0,0 +1,35 @@
package dev.relism.flash.routing;
import java.util.*;
/**
* A named middleware contribution and its ordering constraints.
*
* <p>Constraints are resolved only while Flash compiles a route. The resulting handler chain
* contains no keys, graphs, ordering checks or additional request-path allocations.
*/
public final class MiddlewareNode {
private final MiddlewareKey key;
private final Middleware middleware;
private final List<Constraint> constraints = new ArrayList<>();
private MiddlewareNode(MiddlewareKey key, Middleware middleware) {
this.key = Objects.requireNonNull(key, "key");
this.middleware = Objects.requireNonNull(middleware, "middleware");
}
public static MiddlewareNode of(MiddlewareKey key, Middleware middleware) { return new MiddlewareNode(key, middleware); }
public MiddlewareNode after(MiddlewareKey key) { constraints.add(new Constraint(key, Relation.AFTER, true)); return this; }
public MiddlewareNode afterIfPresent(MiddlewareKey key) { constraints.add(new Constraint(key, Relation.AFTER, false)); return this; }
public MiddlewareNode before(MiddlewareKey key) { constraints.add(new Constraint(key, Relation.BEFORE, true)); return this; }
public MiddlewareNode beforeIfPresent(MiddlewareKey key) { constraints.add(new Constraint(key, Relation.BEFORE, false)); return this; }
public MiddlewareKey key() { return key; }
Middleware middleware() { return middleware; }
List<Constraint> constraints() { return List.copyOf(constraints); }
enum Relation { BEFORE, AFTER }
record Constraint(MiddlewareKey target, Relation relation, boolean required) {
Constraint { Objects.requireNonNull(target, "target"); }
}
}
@@ -92,4 +92,7 @@ public class FastPathRouterImpl extends AbstractRouter {
} }
} }
} }
@Override
public void compile() { ensureCompiled(); }
} }
@@ -56,6 +56,9 @@ public final class FastPathWsRouterImpl extends AbstractWsRouter {
} }
} }
@Override
public void compile() { ensureCompiled(); }
private static final class Context { private static final class Context {
private static final ThreadLocal<MatchResult<WebSocketHandler>> RESULT = private static final ThreadLocal<MatchResult<WebSocketHandler>> RESULT =
ThreadLocal.withInitial(() -> new MatchResult<>(32, 128)); ThreadLocal.withInitial(() -> new MatchResult<>(32, 128));

Some files were not shown because too many files have changed in this diff Show More