Merge pull request 'Feature/core/deterministic boot graph' (#6) from feature/core/deterministic-boot-graph into master
Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
@@ -38,7 +38,7 @@ Format: `<type>(<scope>): <short description>`
|
||||
|
||||
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-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:
|
||||
```
|
||||
|
||||
@@ -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-openapi` | OpenAPI 3.0 spec + Swagger UI |
|
||||
| `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-jte` | Opinionated jte 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
|
||||
|
||||
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
|
||||
@Route(method = HttpMethod.GET, path = "/api/users")
|
||||
public class ListUsers extends JacksonHandler {
|
||||
@Override
|
||||
public Object handle(Request req, Response res) throws Exception {
|
||||
return json(res, List.of("alice", "bob"));
|
||||
}
|
||||
@GET("/api/users")
|
||||
public class ListUsers extends RequestHandler {
|
||||
private UserService users;
|
||||
|
||||
@Override protected void onInit() { users = require(UserService.class); }
|
||||
@Override public Object handle(Request req, Response res) { return users.list(); }
|
||||
}
|
||||
|
||||
// Register:
|
||||
app.register(new ListUsers());
|
||||
app.scan("dev.example.api");
|
||||
```
|
||||
|
||||
### 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
|
||||
Middleware authCheck = next -> (req, res) -> {
|
||||
@@ -90,14 +91,13 @@ Middleware authCheck = next -> (req, res) -> {
|
||||
return next.handle(req, res);
|
||||
};
|
||||
|
||||
app.get("/secure", (req, res) -> "secret data")
|
||||
.with(authCheck);
|
||||
app.get("/secure", (req, res) -> "secret data", authCheck);
|
||||
```
|
||||
|
||||
Multiple middlewares are composed outermost-first (left-to-right in the call):
|
||||
|
||||
```java
|
||||
app.get("/admin", handler).with(logging, auth, rateLimit);
|
||||
app.get("/admin", handler, logging, auth, rateLimit);
|
||||
// execution order: logging → auth → rateLimit → handler
|
||||
```
|
||||
|
||||
@@ -119,22 +119,22 @@ processors, services):
|
||||
```java
|
||||
app.mount("/api", scope -> {
|
||||
scope.get("/health", (req, res) -> "ok"); // → GET /api/health
|
||||
scope.register(new UserHandler()); // @Route(path="/users") → GET /api/users
|
||||
scope.scan("dev.example.api");
|
||||
});
|
||||
```
|
||||
|
||||
## Extensions
|
||||
|
||||
Extensions are installed before route registration. Each extension receives the `FlashRegistrar`
|
||||
and `FlashContext` — it can register routes, expose services, and register annotation processors.
|
||||
Extensions have one declarative `configure` method. They declare services, processors and route
|
||||
callbacks; Flash resolves the complete graph, materialises routes, compiles both routers, then
|
||||
opens listeners. Extension install order never makes a service “not ready”.
|
||||
|
||||
```java
|
||||
FlashApp.create(8080)
|
||||
.install(new JacksonExtension())
|
||||
.install(new OpenApiExtension("/openapi", "My API", "1.0.0"))
|
||||
.install(new OidcExtension(oidcConfig))
|
||||
.register(new MyHandler())
|
||||
.scan("dev.example.handlers")
|
||||
.start();
|
||||
```
|
||||
|
||||
@@ -142,6 +142,7 @@ See extension-specific READMEs for full details:
|
||||
- [`flash-ext-jackson`](flash-extensions/flash-ext-jackson/README.md)
|
||||
- [`flash-ext-openapi`](flash-extensions/flash-ext-openapi/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-thymeleaf`](flash-extensions/flash-ext-view-thymeleaf/README.md)
|
||||
|
||||
|
||||
+6
-8
@@ -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.TxManager;
|
||||
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.FlashExtension;
|
||||
import dev.relism.flash.routing.MiddlewareKey;
|
||||
import dev.relism.flash.routing.MiddlewareNode;
|
||||
import dev.relism.flash.routing.Middleware;
|
||||
import jakarta.transaction.Transactional;
|
||||
|
||||
@@ -14,6 +16,7 @@ import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class DataExtension implements FlashExtension {
|
||||
private static final MiddlewareKey TRANSACTION = MiddlewareKey.of("flash.data.transaction");
|
||||
private final TxManager txManager;
|
||||
private final Tx tx;
|
||||
|
||||
@@ -23,7 +26,7 @@ public final class DataExtension implements FlashExtension {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void provide(FlashContext ctx) {
|
||||
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||
ctx.provide(Tx.class, tx);
|
||||
ctx.provide(TxManager.class, txManager);
|
||||
ctx.addAnnotationProcessor(handlerClass -> {
|
||||
@@ -36,15 +39,10 @@ public final class DataExtension implements FlashExtension {
|
||||
Middleware middleware = next -> (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) {
|
||||
return switch (txType) {
|
||||
case REQUIRED -> TransactionPropagation.REQUIRED;
|
||||
|
||||
+4
-2
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.databind.json.JsonMapper;
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
import dev.relism.flash.extension.FlashRegistrar;
|
||||
import dev.relism.flash.extension.FlashExtension;
|
||||
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
|
||||
* {@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}
|
||||
* for extensions that need direct mapper access (e.g. OpenAPI schema generation).
|
||||
@@ -83,7 +85,7 @@ public class JacksonExtension implements FlashExtension {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void provide(FlashContext ctx) {
|
||||
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||
Json json = new Json(mapper);
|
||||
ctx.provide(Json.class, json);
|
||||
ctx.provide(ObjectMapper.class, mapper);
|
||||
|
||||
+3
-2
@@ -19,12 +19,13 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
class JacksonExtensionTest {
|
||||
|
||||
@Test
|
||||
void provide_registers_json_mapper_and_middleware() {
|
||||
void configure_registers_json_mapper_and_middleware() {
|
||||
FlashContext ctx = new FlashContext();
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
JacksonExtension ext = new JacksonExtension(mapper);
|
||||
|
||||
ext.provide(ctx);
|
||||
ext.configure(null, ctx);
|
||||
ctx.complete();
|
||||
|
||||
assertNotNull(ctx.require(Json.class));
|
||||
assertNotNull(ctx.require(JacksonMiddleware.class));
|
||||
|
||||
+9
-15
@@ -5,12 +5,13 @@ import dev.relism.flash.ext.openapi.OpenApiContributorRegistry;
|
||||
import dev.relism.flash.ext.openapi.OpenApiOperationContribution;
|
||||
import dev.relism.flash.ext.openapi.OpenApiResponseContribution;
|
||||
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.FlashExtension;
|
||||
import dev.relism.flash.extension.FlashRegistrar;
|
||||
import dev.relism.flash.http.HttpStatus;
|
||||
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.util.List;
|
||||
@@ -44,21 +45,16 @@ import java.util.Map;
|
||||
* app.install(new LimiterExtension(
|
||||
* 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);
|
||||
* app.get("/api/search", handler, guard.limit("ip", 50, 1, TimeUnit.SECONDS));
|
||||
* }</pre>
|
||||
*/
|
||||
public final class LimiterExtension implements FlashExtension {
|
||||
private static final MiddlewareKey LIMIT = MiddlewareKey.of("flash.limiter.limit");
|
||||
|
||||
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). */
|
||||
public LimiterExtension() {
|
||||
this(new LimiterConfig());
|
||||
@@ -70,7 +66,7 @@ public final class LimiterExtension implements FlashExtension {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void provide(FlashContext ctx) {
|
||||
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||
BucketStore store = new BucketStore();
|
||||
Guard guard = new Guard(config, store);
|
||||
|
||||
@@ -90,17 +86,15 @@ public final class LimiterExtension implements FlashExtension {
|
||||
ann.strategy().create()
|
||||
);
|
||||
|
||||
return List.of(buildMiddleware(resolver, cfg, store));
|
||||
return List.of(MiddlewareNode.of(LIMIT, buildMiddleware(resolver, cfg, store)));
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void routes(FlashRegistrar<?> app, FlashContext ctx) {
|
||||
ctx.onReady(() -> {
|
||||
try {
|
||||
OpenApiIntegration.register(ctx);
|
||||
} catch (NoClassDefFoundError ignored) {
|
||||
// flash-ext-openapi not available — OpenAPI integration disabled
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Package-private helper — shared with Guard ────────────────────────────
|
||||
|
||||
+6
-3
@@ -41,7 +41,8 @@ class LimiterOpenApiInteropTest {
|
||||
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
|
||||
ctx.provide(OpenApiContributorRegistry.class, registry);
|
||||
|
||||
new LimiterExtension().routes(null, ctx);
|
||||
new LimiterExtension().configure(null, ctx);
|
||||
ctx.complete();
|
||||
|
||||
assertEquals(1, registry.contributors().size());
|
||||
}
|
||||
@@ -51,7 +52,8 @@ class LimiterOpenApiInteropTest {
|
||||
FlashContext ctx = new FlashContext();
|
||||
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
|
||||
ctx.provide(OpenApiContributorRegistry.class, registry);
|
||||
new LimiterExtension().routes(null, ctx);
|
||||
new LimiterExtension().configure(null, ctx);
|
||||
ctx.complete();
|
||||
|
||||
OpenApiContributor contributor = registry.contributors().getFirst();
|
||||
OpenApiOperationContribution operation = contributor.operationFor(LimitedHandler.class);
|
||||
@@ -73,7 +75,8 @@ class LimiterOpenApiInteropTest {
|
||||
FlashContext ctx = new FlashContext();
|
||||
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
|
||||
ctx.provide(OpenApiContributorRegistry.class, registry);
|
||||
new LimiterExtension().routes(null, ctx);
|
||||
new LimiterExtension().configure(null, ctx);
|
||||
ctx.complete();
|
||||
|
||||
OpenApiContributor contributor = registry.contributors().getFirst();
|
||||
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.
|
||||
@@ -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 {}
|
||||
+13
@@ -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;
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Compiled per-tool authorization requirement, built once at boot by {@link McpOidcIntegration}
|
||||
* from {@code @RolesAllowed}/{@code @ScopesAllowed} on an {@link McpTool} subclass — {@code null}
|
||||
* on {@link McpRegistry.RegisteredTool} means no restriction beyond whatever {@link McpSecurity}
|
||||
* already enforces route-wide.
|
||||
*
|
||||
* <p>{@code check} is a closure, not a raw role/scope list — this is what lets this record (and
|
||||
* its only caller, {@link McpDispatcher}) stay free of any compile-time reference to a {@code
|
||||
* flash-ext-oidc} type, preserving the same classload isolation {@link McpOidcIntegration}'s
|
||||
* javadoc describes for the rest of the OIDC bridge. Only the plain-JDK {@link Supplier}
|
||||
* signature crosses the boundary; the closure itself, built once inside {@code
|
||||
* McpOidcIntegration}, is the only place that ever touches {@code OidcUser}/{@code ClaimsHolder}.
|
||||
*
|
||||
* <p>Returns {@code null} from {@link #check()}{@code .get()} when authorized, or a
|
||||
* human-readable denial reason otherwise — invoked once per {@code tools/call} against an
|
||||
* annotated tool, never allocated on that path (the closure and its captured role/scope arrays
|
||||
* are built exactly once, at boot).
|
||||
*/
|
||||
record McpAuthPolicy(Supplier<String> check) {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
@@ -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();
|
||||
}
|
||||
}
|
||||
+253
@@ -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();
|
||||
}
|
||||
}
|
||||
+119
@@ -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;
|
||||
}
|
||||
}
|
||||
+179
@@ -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);
|
||||
}
|
||||
}
|
||||
+167
@@ -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;
|
||||
}
|
||||
+24
@@ -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);
|
||||
}
|
||||
}
|
||||
+205
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+66
@@ -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;
|
||||
}
|
||||
+26
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
+17
@@ -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;
|
||||
}
|
||||
+62
@@ -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;
|
||||
}
|
||||
+21
@@ -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); }
|
||||
}
|
||||
+15
@@ -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";
|
||||
}
|
||||
+8
@@ -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 {}
|
||||
+9
@@ -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;
|
||||
}
|
||||
+11
@@ -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);
|
||||
}
|
||||
}
|
||||
+48
@@ -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); }
|
||||
}
|
||||
+32
@@ -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; }
|
||||
}
|
||||
+109
@@ -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); }
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
import dev.relism.flash.ext.oidc.OidcConfig;
|
||||
import dev.relism.flash.ext.oidc.OidcExtension;
|
||||
import dev.relism.flash.extension.FlashApp;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.net.ServerSocket;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* {@code @RolesAllowed}/{@code @ScopesAllowed} on an {@link McpTool} — see
|
||||
* {@link McpOidcIntegration#compileToolPolicy}. Same real-discovery/real-JWKS/real-RS256-token
|
||||
* approach as {@link McpExtensionSecurityTest}, against {@code fixtures.secured}'s tools.
|
||||
*/
|
||||
class McpAuthPolicyTest {
|
||||
|
||||
private static final String SECURED_TOOLS = "dev.relism.flash.ext.mcp.authfixtures.secured";
|
||||
private static final String AUTHENTICATED_ONLY_TOOLS = "dev.relism.flash.ext.mcp.authfixtures.authenticatedonly";
|
||||
|
||||
private FlashApp app;
|
||||
private FakeOidcProvider provider;
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (app != null) app.stop();
|
||||
if (provider != null) provider.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rolesAllowed_deniesWithoutRole_allowsWithRole() throws Exception {
|
||||
int port = bootSecuredApp(SECURED_TOOLS);
|
||||
String resourceId = "http://127.0.0.1:" + port + "/mcp";
|
||||
|
||||
String noRole = provider.signToken("user-1", resourceId, null);
|
||||
HttpResponse<String> denied = callTool(port, "admin_only", noRole);
|
||||
assertEquals(200, denied.statusCode());
|
||||
assertTrue(denied.body().contains("\"isError\":true"), denied.body());
|
||||
assertTrue(denied.body().contains("missing required role"), denied.body());
|
||||
|
||||
String withRole = provider.signToken("user-1", resourceId, null, "admin");
|
||||
HttpResponse<String> allowed = callTool(port, "admin_only", withRole);
|
||||
assertEquals(200, allowed.statusCode());
|
||||
assertTrue(allowed.body().contains("\"isError\":false"), allowed.body());
|
||||
assertTrue(allowed.body().contains("ok"), allowed.body());
|
||||
}
|
||||
|
||||
@Test
|
||||
void scopesAllowed_deniesWithoutScope_allowsWithScope() throws Exception {
|
||||
int port = bootSecuredApp(SECURED_TOOLS);
|
||||
String resourceId = "http://127.0.0.1:" + port + "/mcp";
|
||||
|
||||
String noScope = provider.signToken("user-1", resourceId, "read");
|
||||
HttpResponse<String> denied = callTool(port, "write_only", noScope);
|
||||
assertEquals(200, denied.statusCode());
|
||||
assertTrue(denied.body().contains("\"isError\":true"), denied.body());
|
||||
assertTrue(denied.body().contains("missing required scope"), denied.body());
|
||||
|
||||
String withScope = provider.signToken("user-1", resourceId, "read write");
|
||||
HttpResponse<String> allowed = callTool(port, "write_only", withScope);
|
||||
assertEquals(200, allowed.statusCode());
|
||||
assertTrue(allowed.body().contains("\"isError\":false"), allowed.body());
|
||||
assertTrue(allowed.body().contains("written"), allowed.body());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unannotatedTool_unaffectedByOtherToolsPolicies() throws Exception {
|
||||
int port = bootSecuredApp(SECURED_TOOLS);
|
||||
String resourceId = "http://127.0.0.1:" + port + "/mcp";
|
||||
|
||||
String plain = provider.signToken("user-1", resourceId, null);
|
||||
HttpResponse<String> resp = callTool(port, "open", plain);
|
||||
assertEquals(200, resp.statusCode());
|
||||
assertTrue(resp.body().contains("\"isError\":false"), resp.body());
|
||||
assertTrue(resp.body().contains("open"), resp.body());
|
||||
}
|
||||
|
||||
@Test
|
||||
void toolAnnotated_butSecurityNone_failsAtBoot() throws Exception {
|
||||
provider = new FakeOidcProvider();
|
||||
int port = freePort();
|
||||
app = FlashApp.create(port);
|
||||
app.install(new OidcExtension(OidcConfig.builder(
|
||||
provider.issuer(), "mcp-client", "secret", "/auth/callback").build()));
|
||||
app.install(new McpExtension(McpConfig.builder("secure-server")
|
||||
.toolsPackage(SECURED_TOOLS)
|
||||
.security(McpSecurity.NONE)
|
||||
.build()));
|
||||
|
||||
IllegalStateException e = assertThrows(IllegalStateException.class, () -> app.start());
|
||||
assertTrue(e.getMessage().contains("no active OAuth2 protection"), e.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void bareAuthenticated_hasNoEffect_failsAtBoot() throws Exception {
|
||||
provider = new FakeOidcProvider();
|
||||
int port = freePort();
|
||||
app = FlashApp.create(port);
|
||||
app.install(new OidcExtension(OidcConfig.builder(
|
||||
provider.issuer(), "mcp-client", "secret", "/auth/callback").build()));
|
||||
app.install(new McpExtension(McpConfig.builder("secure-server")
|
||||
.toolsPackage(AUTHENTICATED_ONLY_TOOLS)
|
||||
.security(McpSecurity.REQUIRED)
|
||||
.build()));
|
||||
|
||||
IllegalStateException e = assertThrows(IllegalStateException.class, () -> app.start());
|
||||
assertTrue(e.getMessage().contains("no effect"), e.getMessage());
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private int bootSecuredApp(String toolsPackage) throws Exception {
|
||||
provider = new FakeOidcProvider();
|
||||
int port = freePort();
|
||||
|
||||
app = FlashApp.create(port);
|
||||
app.install(new OidcExtension(OidcConfig.builder(
|
||||
provider.issuer(), "mcp-client", "secret", "/auth/callback").build()));
|
||||
app.install(new McpExtension(McpConfig.builder("secure-server")
|
||||
.toolsPackage(toolsPackage)
|
||||
.security(McpSecurity.REQUIRED)
|
||||
.build()));
|
||||
app.start();
|
||||
return port;
|
||||
}
|
||||
|
||||
private static HttpResponse<String> callTool(int port, String toolName, String token) throws Exception {
|
||||
String body = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"" + toolName + "\"}}";
|
||||
HttpRequest.Builder req = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/mcp"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body));
|
||||
return HttpClient.newHttpClient().send(req.build(), HttpResponse.BodyHandlers.ofString());
|
||||
}
|
||||
|
||||
private static int freePort() throws Exception {
|
||||
try (ServerSocket s = new ServerSocket(0)) {
|
||||
return s.getLocalPort();
|
||||
}
|
||||
}
|
||||
}
|
||||
+143
@@ -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());
|
||||
}
|
||||
}
|
||||
+194
@@ -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());
|
||||
}
|
||||
}
|
||||
+57
@@ -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);
|
||||
}
|
||||
}
|
||||
+48
@@ -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"));
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package dev.relism.flash.ext.mcp.authfixtures.authenticatedonly;
|
||||
|
||||
import dev.relism.flash.ext.mcp.McpTool;
|
||||
import dev.relism.flash.ext.mcp.TextContent;
|
||||
import dev.relism.flash.ext.mcp.Tool;
|
||||
import dev.relism.flash.ext.mcp.ToolArguments;
|
||||
import dev.relism.flash.ext.mcp.ToolResponse;
|
||||
import dev.relism.flash.ext.oidc.Authenticated;
|
||||
|
||||
/** Deliberately misconfigured fixture: bare @Authenticated has no effect on an McpTool — see
|
||||
* McpOidcIntegration#compileToolPolicy. Boot must fail with a clear message, not silently no-op. */
|
||||
@Tool(name = "pointless", description = "Exists only to prove @Authenticated alone fails boot")
|
||||
@Authenticated
|
||||
public class PointlessAuthTool extends McpTool {
|
||||
|
||||
@Override
|
||||
public ToolResponse call(ToolArguments args) {
|
||||
return ToolResponse.success(new TextContent("unreachable"));
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package dev.relism.flash.ext.mcp.authfixtures.secured;
|
||||
|
||||
import dev.relism.flash.ext.mcp.McpTool;
|
||||
import dev.relism.flash.ext.mcp.TextContent;
|
||||
import dev.relism.flash.ext.mcp.Tool;
|
||||
import dev.relism.flash.ext.mcp.ToolArguments;
|
||||
import dev.relism.flash.ext.mcp.ToolResponse;
|
||||
import dev.relism.flash.ext.oidc.RolesAllowed;
|
||||
|
||||
@Tool(name = "admin_only", description = "Only callable with the admin role")
|
||||
@RolesAllowed("admin")
|
||||
public class AdminOnlyTool extends McpTool {
|
||||
|
||||
@Override
|
||||
public ToolResponse call(ToolArguments args) {
|
||||
return ToolResponse.success(new TextContent("ok"));
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package dev.relism.flash.ext.mcp.authfixtures.secured;
|
||||
|
||||
import dev.relism.flash.ext.mcp.McpTool;
|
||||
import dev.relism.flash.ext.mcp.TextContent;
|
||||
import dev.relism.flash.ext.mcp.Tool;
|
||||
import dev.relism.flash.ext.mcp.ToolArguments;
|
||||
import dev.relism.flash.ext.mcp.ToolResponse;
|
||||
|
||||
/** No role/scope annotation — any authenticated caller, confirms unrelated tools are unaffected. */
|
||||
@Tool(name = "open", description = "Callable by anyone already authenticated")
|
||||
public class OpenTool extends McpTool {
|
||||
|
||||
@Override
|
||||
public ToolResponse call(ToolArguments args) {
|
||||
return ToolResponse.success(new TextContent("open"));
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package dev.relism.flash.ext.mcp.authfixtures.secured;
|
||||
|
||||
import dev.relism.flash.ext.mcp.McpTool;
|
||||
import dev.relism.flash.ext.mcp.TextContent;
|
||||
import dev.relism.flash.ext.mcp.Tool;
|
||||
import dev.relism.flash.ext.mcp.ToolArguments;
|
||||
import dev.relism.flash.ext.mcp.ToolResponse;
|
||||
import dev.relism.flash.ext.oidc.ScopesAllowed;
|
||||
|
||||
@Tool(name = "write_only", description = "Only callable with the write scope")
|
||||
@ScopesAllowed("write")
|
||||
public class WriteScopeTool extends McpTool {
|
||||
|
||||
@Override
|
||||
public ToolResponse call(ToolArguments args) {
|
||||
return ToolResponse.success(new TextContent("written"));
|
||||
}
|
||||
}
|
||||
+18
@@ -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")));
|
||||
}
|
||||
}
|
||||
+15
@@ -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");
|
||||
}
|
||||
}
|
||||
+15
@@ -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");
|
||||
}
|
||||
}
|
||||
+18
@@ -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")));
|
||||
}
|
||||
}
|
||||
+8
-7
@@ -7,6 +7,8 @@ import dev.relism.flash.ext.openapi.OpenApiResponseContribution;
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
import dev.relism.flash.extension.FlashExtension;
|
||||
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 javax.net.ssl.SSLContext;
|
||||
@@ -54,6 +56,7 @@ import java.util.*;
|
||||
* }</pre>
|
||||
*/
|
||||
public class OidcExtension implements FlashExtension {
|
||||
private static final MiddlewareKey POLICY = MiddlewareKey.of("flash.oidc.policy");
|
||||
|
||||
private final OidcConfig config;
|
||||
|
||||
@@ -71,7 +74,7 @@ public class OidcExtension implements FlashExtension {
|
||||
// ── Phase 1: services ─────────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public void provide(FlashContext ctx) {
|
||||
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||
HttpClient http = buildHttpClient(config);
|
||||
|
||||
// Discover provider endpoints (blocking; fail fast at startup).
|
||||
@@ -91,14 +94,12 @@ public class OidcExtension implements FlashExtension {
|
||||
|
||||
ctx.addAnnotationProcessor(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 ───────────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public void routes(FlashRegistrar<?> app, FlashContext ctx) {
|
||||
private void registerRoutes(FlashRegistrar<?> app, FlashContext ctx) {
|
||||
String prefix = config.routePrefix();
|
||||
|
||||
// ── GET {prefix}/login ────────────────────────────────────────────────
|
||||
@@ -252,7 +253,7 @@ public class OidcExtension implements FlashExtension {
|
||||
|
||||
private String absoluteSelf(Request req, String 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) {
|
||||
|
||||
+60
-5
@@ -65,8 +65,21 @@ public class OidcMiddleware {
|
||||
* to the login page on failure; API clients receive 401.
|
||||
*/
|
||||
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) -> {
|
||||
Map<String, Object> claims = resolve(req, res);
|
||||
Map<String, Object> claims = resolve(req, res, resourceMetadataPath);
|
||||
if (claims == null) return null; // redirect already written
|
||||
ClaimsHolder.set(claims);
|
||||
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
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
String bearerToken = extractBearerToken(req.header("Authorization"));
|
||||
if (bearerToken != null) {
|
||||
try {
|
||||
return validator.validate(bearerToken);
|
||||
} catch (HttpException e) {
|
||||
res.header("WWW-Authenticate", invalidTokenChallenge());
|
||||
res.header("WWW-Authenticate", invalidTokenChallenge(req, resourceMetadataPath));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -233,7 +256,7 @@ public class OidcMiddleware {
|
||||
// 3. No valid credentials
|
||||
String accept = req.header("Accept");
|
||||
if (accept != null && accept.contains("application/json")) {
|
||||
res.header("WWW-Authenticate", bearerChallenge());
|
||||
res.header("WWW-Authenticate", bearerChallenge(req, resourceMetadataPath));
|
||||
throw HttpException.unauthorized();
|
||||
}
|
||||
|
||||
@@ -300,11 +323,21 @@ public class OidcMiddleware {
|
||||
}
|
||||
|
||||
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() {
|
||||
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) {
|
||||
@@ -312,6 +345,28 @@ public class OidcMiddleware {
|
||||
+ 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) {
|
||||
if (values == null || values.length == 0) return "";
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
+1
@@ -104,6 +104,7 @@ class OidcOpenApiInteropTest {
|
||||
FlashContext ctx = new FlashContext();
|
||||
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
|
||||
ctx.provide(OpenApiContributorRegistry.class, registry);
|
||||
ctx.complete();
|
||||
|
||||
OidcConfig config = OidcConfig.builder("https://issuer", "c", "s", "/cb").build();
|
||||
OidcProviderMetadata meta = new OidcProviderMetadata("a", "t", "u", "j", "e");
|
||||
|
||||
+7
-9
@@ -4,8 +4,8 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.json.JsonMapper;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
import dev.relism.flash.extension.FlashExtension;
|
||||
import dev.relism.flash.extension.FlashRegistrar;
|
||||
import dev.relism.flash.extension.FlashExtension;
|
||||
import dev.relism.flash.extension.RouteEvent;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
@@ -65,7 +65,7 @@ public class OpenApiExtension implements FlashExtension {
|
||||
// ── FlashExtension ────────────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public void provide(FlashContext ctx) {
|
||||
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||
OpenApiBuilder builder = new OpenApiBuilder().title(title).version(version).description(description);
|
||||
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
|
||||
|
||||
@@ -76,22 +76,20 @@ public class OpenApiExtension implements FlashExtension {
|
||||
// Collect operation metadata from final compiled routes.
|
||||
// This guarantees full runtime paths (namespaces/prefixes/rewrites) in the spec.
|
||||
ctx.addRouteListener(event -> addOperationFromEvent(builder, event));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void routes(FlashRegistrar<?> app, FlashContext ctx) {
|
||||
ctx.onReady(() -> {
|
||||
ObjectMapper jsonMapper = ctx.find(ObjectMapper.class).orElseGet(() -> JsonMapper.builder().build());
|
||||
YAMLMapper yamlMapper = new YAMLMapper();
|
||||
OpenApiBuilder builder = ctx.require(OpenApiBuilder.class);
|
||||
OpenApiBuilder resolvedBuilder = ctx.require(OpenApiBuilder.class);
|
||||
|
||||
String jsonPath = basePath + ".json";
|
||||
String yamlPath = basePath + ".yaml";
|
||||
String swaggerPath = basePath + "/swagger";
|
||||
String swaggerHtml = buildSwaggerHtml(jsonPath);
|
||||
|
||||
app.get(jsonPath, (req, res) -> { res.type(ContentType.JSON); return jsonMapper.writeValueAsString(builder.build()); });
|
||||
app.get(yamlPath, (req, res) -> { res.type(YAML_CONTENT_TYPE); return yamlMapper.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(resolvedBuilder.build()); });
|
||||
app.get(swaggerPath, (req, res) -> { res.type(ContentType.TEXT_HTML); return swaggerHtml; });
|
||||
});
|
||||
}
|
||||
|
||||
// ── Swagger UI HTML ───────────────────────────────────────────────────────
|
||||
|
||||
+13
-13
@@ -11,7 +11,7 @@ import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.flash.models.RequestHandler;
|
||||
import dev.relism.flash.models.Response;
|
||||
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 org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -41,12 +41,10 @@ class OpenApiExtensionTest {
|
||||
void provide_collects_operations_and_routes_serve_json_yaml_swagger() throws Exception {
|
||||
FlashContext ctx = new FlashContext();
|
||||
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);
|
||||
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.yaml"));
|
||||
@@ -77,9 +75,9 @@ class OpenApiExtensionTest {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
ctx.provide(ObjectMapper.class, mapper);
|
||||
|
||||
ext.provide(ctx);
|
||||
TestRegistrar app = new TestRegistrar(ctx);
|
||||
ext.routes(app, ctx);
|
||||
ext.configure(app, ctx);
|
||||
ctx.complete();
|
||||
|
||||
Response jsonRes = new Response(200, ContentType.NONE);
|
||||
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() {
|
||||
FlashContext ctx = new FlashContext();
|
||||
OpenApiExtension ext = new OpenApiExtension();
|
||||
ext.provide(ctx);
|
||||
ext.configure(new TestRegistrar(ctx), ctx);
|
||||
|
||||
emitRoute(ctx, HttpMethod.GET, "/api/v1/users", "/api/v1", ScopedUsersHandler.class);
|
||||
|
||||
ctx.complete();
|
||||
OpenApiBuilder builder = ctx.require(OpenApiBuilder.class);
|
||||
Map<String, Object> spec = builder.build();
|
||||
Map<String, Object> paths = cast(spec.get("paths"));
|
||||
@@ -114,10 +113,11 @@ class OpenApiExtensionTest {
|
||||
void normalizes_double_slash_paths_from_events() {
|
||||
FlashContext ctx = new FlashContext();
|
||||
OpenApiExtension ext = new OpenApiExtension();
|
||||
ext.provide(ctx);
|
||||
ext.configure(new TestRegistrar(ctx), ctx);
|
||||
|
||||
emitRoute(ctx, HttpMethod.GET, "//blogs", "/", ScopedUsersHandler.class);
|
||||
|
||||
ctx.complete();
|
||||
OpenApiBuilder builder = ctx.require(OpenApiBuilder.class);
|
||||
Map<String, Object> spec = builder.build();
|
||||
Map<String, Object> paths = cast(spec.get("paths"));
|
||||
@@ -150,7 +150,7 @@ class OpenApiExtensionTest {
|
||||
private static final class TestRegistrar extends FlashRegistrar<TestRegistrar> {
|
||||
private final FlashContext ctx;
|
||||
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) {
|
||||
this.ctx = ctx;
|
||||
@@ -162,7 +162,7 @@ class OpenApiExtensionTest {
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ class OpenApiExtensionTest {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void addMiddleware(Middleware mw) {
|
||||
protected void addMiddleware(MiddlewareNode mw) {
|
||||
middlewares.add(mw);
|
||||
}
|
||||
|
||||
|
||||
+4
-10
@@ -1,10 +1,9 @@
|
||||
package dev.relism.flash.ext.routeviewer;
|
||||
|
||||
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.FlashExtension;
|
||||
import dev.relism.flash.extension.FlashRegistrar;
|
||||
import dev.relism.flash.extension.FlashExtension;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
|
||||
/**
|
||||
@@ -41,9 +40,6 @@ public class RouteViewerExtension implements FlashExtension {
|
||||
private final String path;
|
||||
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}. */
|
||||
public RouteViewerExtension() { this(DEFAULT_PATH); }
|
||||
|
||||
@@ -54,16 +50,14 @@ public class RouteViewerExtension implements FlashExtension {
|
||||
public RouteViewerExtension(String path) { this.path = path; }
|
||||
|
||||
@Override
|
||||
public void provide(FlashContext ctx) {
|
||||
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||
// Register listener before any routes compile — captures everything.
|
||||
ctx.addRouteListener(graph::add);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void routes(FlashRegistrar<?> app, FlashContext ctx) {
|
||||
ctx.onReady(() -> {
|
||||
app.get(path, new RouteViewerHandler()::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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -1,9 +1,11 @@
|
||||
package dev.relism.flash.ext.view.core;
|
||||
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
import dev.relism.flash.extension.FlashRegistrar;
|
||||
import dev.relism.flash.extension.FlashExtension;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.models.RequestHandler;
|
||||
import dev.relism.flash.routing.MiddlewareNode;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
@@ -30,7 +32,7 @@ public abstract class BaseViewExtension<TTarget> implements FlashExtension {
|
||||
}
|
||||
|
||||
@Override
|
||||
public final void provide(FlashContext ctx) {
|
||||
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||
ViewRuntimeBridge<TTarget> runtime = createRuntime(List.copyOf(globals));
|
||||
ctx.provide(ViewRuntimeBridge.class, runtime);
|
||||
ctx.addAnnotationProcessor(handlerClass -> {
|
||||
|
||||
+4
-2
@@ -57,15 +57,17 @@ public final class JteExtension extends BaseViewExtension<JteTarget> {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void routes(FlashRegistrar<?> app, FlashContext ctx) {
|
||||
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||
super.configure(app, ctx);
|
||||
ctx.onReady(() -> {
|
||||
if (!settings.serveStatics()) return;
|
||||
JteStaticServing staticServing = JteStaticServing.load(settings);
|
||||
if (staticServing == null) return;
|
||||
|
||||
String wildcard = settings.staticPrefix() + "/**";
|
||||
StaticJteHandler handler = new StaticJteHandler(staticServing);
|
||||
app.get(wildcard, handler::handle);
|
||||
app.head(wildcard, (req, res) -> { staticServing.serve(req, res, true); return null; });
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+12
-6
@@ -93,7 +93,9 @@ class JteExtensionTest {
|
||||
void routes_register_static_wildcard_when_enabled() {
|
||||
JteExtension ext = new JteExtension(cfg -> cfg.staticPrefix("/assets"));
|
||||
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("HEAD /assets/**"));
|
||||
}
|
||||
@@ -102,7 +104,9 @@ class JteExtensionTest {
|
||||
void routes_do_not_register_static_when_disabled() {
|
||||
JteExtension ext = new JteExtension().serveStatics(false);
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -112,7 +116,9 @@ class JteExtensionTest {
|
||||
.staticPrefix("/assets")
|
||||
.largeFileThresholdBytes(1));
|
||||
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);
|
||||
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 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
|
||||
public dev.relism.flash.extension.FlashContext ctx() {
|
||||
@@ -169,7 +175,7 @@ class JteExtensionTest {
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@@ -179,7 +185,7 @@ class JteExtensionTest {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void addMiddleware(dev.relism.flash.routing.Middleware mw) {
|
||||
protected void addMiddleware(dev.relism.flash.routing.MiddlewareNode mw) {
|
||||
mws.add(mw);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,14 @@ Builder shortcuts:
|
||||
- `.assetsFromClasspath("web/dist")`
|
||||
|
||||
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:
|
||||
|
||||
|
||||
@@ -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`
|
||||
- `operationMode`: `ORCHESTRATE_ONLY`, `MANAGED`
|
||||
- `frontendType`: currently `VITE`
|
||||
- `frontendType`: `VITE`, `STATIC` — see `frontend-selection.md`
|
||||
- `packageManager`: `NPM`, `PNPM`, `YARN`, `BUN`
|
||||
- `installPolicy`: `AUTO_IF_LOCK_HASH_CHANGED`, `NEVER`
|
||||
- `loggingMode`: `MERGED`, `SEPARATE`, `QUIET`, `VERBOSE`
|
||||
@@ -25,6 +25,12 @@ Or by direct source object:
|
||||
|
||||
Validation is fail-fast:
|
||||
|
||||
- invalid `devPort`
|
||||
- blank/invalid watch entries
|
||||
- invalid `devPort` (only when `frontendType` requires orchestration — skipped for `STATIC`)
|
||||
- blank/invalid watch entries (same — skipped for `STATIC`)
|
||||
- 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.
|
||||
- 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:
|
||||
|
||||
|
||||
@@ -10,3 +10,12 @@
|
||||
|
||||
- `ORCHESTRATE_ONLY`: only orchestrates dev tooling.
|
||||
- `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.
|
||||
|
||||
+60
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-35
@@ -1,6 +1,5 @@
|
||||
package dev.relism.flash.ext.webbundler;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
@@ -25,30 +24,10 @@ public final class FilesystemAssetsSource implements AssetsSource {
|
||||
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<>();
|
||||
for (Map.Entry<String, AssetEntryBuilder> e : builders.entrySet()) {
|
||||
AssetEntryBuilder b = e.getValue();
|
||||
if (b.raw == null) continue;
|
||||
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));
|
||||
for (AssetDirectoryScanner.ScannedAsset asset : AssetDirectoryScanner.scan(root)) {
|
||||
String routePath = AssetPaths.joinBase(request.basePath(), asset.canonicalPath());
|
||||
byRoute.put(routePath, new AssetEntry(asset.raw(), asset.br(), asset.gz(), asset.etag(), asset.mimeType(), asset.immutable()));
|
||||
}
|
||||
|
||||
String indexRoute = AssetPaths.joinBase(request.basePath(), "/" + request.indexFile());
|
||||
@@ -58,15 +37,4 @@ public final class FilesystemAssetsSource implements AssetsSource {
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-1
@@ -1,5 +1,17 @@
|
||||
package dev.relism.flash.ext.webbundler;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -8,6 +8,7 @@ final class FrontendTypeResolver {
|
||||
|
||||
FrontendTypeResolver() {
|
||||
register(new ViteFrontendStrategy());
|
||||
register(new StaticFrontendStrategy());
|
||||
}
|
||||
|
||||
void register(FrontendStrategy strategy) {
|
||||
|
||||
+21
@@ -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");
|
||||
}
|
||||
}
|
||||
+53
@@ -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]));
|
||||
}
|
||||
}
|
||||
+15
-2
@@ -92,6 +92,7 @@ public final class WebBundlerConfig {
|
||||
Objects.requireNonNull(webRoot, "webRoot");
|
||||
Objects.requireNonNull(assetsSource, "assetsSource");
|
||||
Objects.requireNonNull(indexFile, "indexFile");
|
||||
if (frontendType.requiresOrchestration()) {
|
||||
if (devPort <= 0 || devPort > 65535) {
|
||||
throw new IllegalArgumentException("WebBundlerConfig: devPort must be in range 1..65535");
|
||||
}
|
||||
@@ -103,6 +104,7 @@ public final class WebBundlerConfig {
|
||||
throw new IllegalArgumentException("WebBundlerConfig: watchList contains blank entries");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!basePath.startsWith("/")) {
|
||||
throw new IllegalArgumentException("WebBundlerConfig: basePath must start with '/'");
|
||||
}
|
||||
@@ -135,7 +137,7 @@ public final class WebBundlerConfig {
|
||||
private String basePath = "/";
|
||||
private String devHost = "127.0.0.1";
|
||||
private int devPort = 5173;
|
||||
private AssetsSource assetsSource = FilesystemAssetsSource.of(Path.of("dist"));
|
||||
private AssetsSource assetsSource = defaultAssetsSource(FrontendType.VITE);
|
||||
private String indexFile = "index.html";
|
||||
private List<String> watchList = defaultWatchList(PackageManager.NPM);
|
||||
private String devCommand;
|
||||
@@ -146,7 +148,12 @@ public final class WebBundlerConfig {
|
||||
|
||||
public Builder runtimeMode(RuntimeMode runtimeMode) { this.runtimeMode = runtimeMode; 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) {
|
||||
this.packageManager = packageManager;
|
||||
this.watchList = defaultWatchList(packageManager);
|
||||
@@ -188,6 +195,12 @@ public final class WebBundlerConfig {
|
||||
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) {
|
||||
return List.of(
|
||||
"package.json",
|
||||
|
||||
+7
-14
@@ -1,9 +1,8 @@
|
||||
package dev.relism.flash.ext.webbundler;
|
||||
|
||||
import dev.relism.flash.extension.ExtensionPhase;
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
import dev.relism.flash.extension.FlashExtension;
|
||||
import dev.relism.flash.extension.FlashRegistrar;
|
||||
import dev.relism.flash.extension.FlashExtension;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@@ -24,11 +23,6 @@ public final class WebBundlerExtension implements FlashExtension {
|
||||
private ScheduledExecutorService watchLoop;
|
||||
private Thread shutdownHook;
|
||||
|
||||
@Override
|
||||
public int priority() {
|
||||
return ExtensionPhase.LATE.value;
|
||||
}
|
||||
|
||||
public WebBundlerExtension() {
|
||||
this(WebBundlerConfig.builder().build());
|
||||
}
|
||||
@@ -38,7 +32,7 @@ public final class WebBundlerExtension implements FlashExtension {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void provide(FlashContext ctx) {
|
||||
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||
RuntimeEnvironment environment = modeResolver.resolve(config);
|
||||
PackageManagerAdapter pmAdapter = new PackageManagerAdapter(config);
|
||||
FrontendStrategy strategy = frontendTypeResolver.resolve(config.frontendType());
|
||||
@@ -51,7 +45,7 @@ public final class WebBundlerExtension implements FlashExtension {
|
||||
SpaFallbackPolicy fallbackPolicy = null;
|
||||
|
||||
try {
|
||||
if (environment == RuntimeEnvironment.DEV) {
|
||||
if (environment == RuntimeEnvironment.DEV && config.frontendType().requiresOrchestration()) {
|
||||
bootstrapDev(strategy, pmAdapter, orchestrator, watchList);
|
||||
} else {
|
||||
AssetCatalog catalog = config.assetsSource().load(new AssetLoadRequest(
|
||||
@@ -74,12 +68,10 @@ public final class WebBundlerExtension implements FlashExtension {
|
||||
shutdownResources(orchestrator);
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void routes(FlashRegistrar<?> app, FlashContext ctx) {
|
||||
ctx.onReady(() -> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -100,6 +92,7 @@ public final class WebBundlerExtension implements FlashExtension {
|
||||
res.body(new byte[0]);
|
||||
return null;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void bootstrapDev(
|
||||
|
||||
+68
@@ -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));
|
||||
}
|
||||
}
|
||||
+18
@@ -55,4 +55,22 @@ class WebBundlerConfigTest {
|
||||
.build();
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
+43
@@ -76,4 +76,47 @@ class WebBundlerExtensionIntegrationTest {
|
||||
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"));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<module>flash-ext-view-thymeleaf</module>
|
||||
<module>flash-ext-limiter</module>
|
||||
<module>flash-ext-web-bundler</module>
|
||||
<module>flash-ext-mcp</module>
|
||||
<module>flash-ext-data-core</module>
|
||||
<module>flash-ext-data-jdbc</module>
|
||||
<module>flash-ext-data-hibernate</module>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package dev.relism.flash.extension;
|
||||
|
||||
import dev.relism.flash.models.RequestHandler;
|
||||
import dev.relism.flash.routing.Middleware;
|
||||
import dev.relism.flash.routing.MiddlewareNode;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -18,5 +18,5 @@ import java.util.List;
|
||||
*/
|
||||
@FunctionalInterface
|
||||
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.AbstractWsRouter;
|
||||
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.FastPathWsRouterImpl;
|
||||
import dev.relism.flash.websocket.WebSocketEndpoint;
|
||||
@@ -19,7 +21,6 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Consumer;
|
||||
@@ -41,10 +42,9 @@ import java.util.function.Consumer;
|
||||
*
|
||||
* <h3>Startup sequence (both {@link #start()} and {@link #startAndBlock()})</h3>
|
||||
* <ol>
|
||||
* <li>Extensions sorted by {@link FlashExtension#priority()} — lower first.</li>
|
||||
* <li>All {@link FlashExtension#provide} — register services, processors, listeners.</li>
|
||||
* <li>All {@link FlashExtension#configure} declarations.</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>Accept loop started.</li>
|
||||
* </ol>
|
||||
@@ -62,7 +62,7 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
|
||||
private final ServerHandle server;
|
||||
private final FlashContext ctx = new FlashContext();
|
||||
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<WsRouteDefinition> deferredWsRoutes = new ArrayList<>();
|
||||
|
||||
@@ -131,10 +131,7 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
|
||||
// ── Extensions ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Registers an extension for two-phase installation at startup.
|
||||
* 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.
|
||||
* Registers a declarative extension contribution.
|
||||
*/
|
||||
public FlashApp install(FlashExtension ext) {
|
||||
extensions.add(ext);
|
||||
@@ -174,7 +171,7 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
|
||||
// ── FlashRegistrar impl ───────────────────────────────────────────────────
|
||||
|
||||
@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(
|
||||
method, path, handler, List.of(), mw,
|
||||
!(handler instanceof SimpleHandler), ctx, "/"));
|
||||
@@ -186,27 +183,25 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void addMiddleware(Middleware mw) { globalMiddlewares.add(mw); }
|
||||
protected void addMiddleware(MiddlewareNode mw) { globalMiddlewares.add(mw); }
|
||||
|
||||
// ── Boot ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private void boot() {
|
||||
extensions.sort(Comparator.comparingInt(FlashExtension::priority));
|
||||
extensions.forEach(e -> e.provide(ctx));
|
||||
ctx.resolveAll();
|
||||
extensions.forEach(e -> e.routes(this, ctx));
|
||||
extensions.forEach(e -> e.configure(this, ctx));
|
||||
ctx.complete();
|
||||
compile();
|
||||
compileWs();
|
||||
router.compile();
|
||||
wsRouter.compile();
|
||||
}
|
||||
|
||||
// ── Compilation ──────────────────────────────────────────────────────────
|
||||
|
||||
private static final Middleware[] EMPTY_MW = new Middleware[0];
|
||||
|
||||
/** Middleware chain order per route: Global → Scope → Annotation → Explicit. */
|
||||
private void compile() {
|
||||
for (RouteDefinition def : deferredRoutes) {
|
||||
List<Middleware> injected;
|
||||
List<MiddlewareNode> injected;
|
||||
if (def.classBasedHandler()) {
|
||||
injected = def.ctx().processors().stream()
|
||||
.flatMap(p -> p.process(def.handler().getClass()).stream())
|
||||
@@ -215,7 +210,8 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
|
||||
} else {
|
||||
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);
|
||||
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));
|
||||
}
|
||||
|
||||
private static Middleware[] concat(List<Middleware> global, List<Middleware> scope,
|
||||
List<Middleware> injected, List<Middleware> explicit) {
|
||||
private static List<MiddlewareNode> concat(List<MiddlewareNode> global, List<MiddlewareNode> scope,
|
||||
List<MiddlewareNode> injected, List<MiddlewareNode> explicit) {
|
||||
int total = global.size() + scope.size() + injected.size() + explicit.size();
|
||||
if (total == 0) return EMPTY_MW;
|
||||
Middleware[] all = new Middleware[total];
|
||||
int i = 0;
|
||||
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;
|
||||
if (total == 0) return List.of();
|
||||
List<MiddlewareNode> all = new ArrayList<>(total);
|
||||
all.addAll(global); all.addAll(scope); all.addAll(injected); all.addAll(explicit);
|
||||
return all;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,176 +1,160 @@
|
||||
package dev.relism.flash.extension;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Central service registry and boot-time hook coordinator.
|
||||
*
|
||||
* <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 {
|
||||
/** Deterministic boot-time service graph, frozen before handlers are initialised. */
|
||||
public final class FlashContext {
|
||||
private enum State { DECLARING, RESOLVING, READY }
|
||||
|
||||
private final FlashContext parent;
|
||||
private final Map<Class<?>, Object> registry = new LinkedHashMap<>();
|
||||
private final Map<Class<?>, Supplier<?>> pending = new LinkedHashMap<>();
|
||||
private final Map<Class<?>, Binding<?>> bindings = new LinkedHashMap<>();
|
||||
private final List<AnnotationProcessor> processors = new ArrayList<>();
|
||||
private final List<RouteListener> routeListeners = new ArrayList<>();
|
||||
|
||||
// Lazy caches — nulled whenever the corresponding list is mutated.
|
||||
private final List<Runnable> readyCallbacks = new ArrayList<>();
|
||||
private final List<FlashContext> children = new ArrayList<>();
|
||||
private final Deque<Class<?>> resolutionPath = new ArrayDeque<>();
|
||||
private State state = State.DECLARING;
|
||||
private List<AnnotationProcessor> cachedProcessors;
|
||||
private List<RouteListener> cachedListeners;
|
||||
|
||||
// DFS stack — tracks in-progress resolutions to detect circular dependencies.
|
||||
private final LinkedHashSet<Class<?>> resolutionStack = new LinkedHashSet<>();
|
||||
|
||||
public FlashContext() { this.parent = null; }
|
||||
public FlashContext() { parent = null; }
|
||||
private FlashContext(FlashContext parent) { this.parent = parent; }
|
||||
|
||||
/** Creates a child context that inherits this context's services and processors. */
|
||||
public FlashContext child() { return new FlashContext(this); }
|
||||
public FlashContext child() {
|
||||
requireDeclaring();
|
||||
FlashContext child = new FlashContext(this);
|
||||
children.add(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
// ── Service registry ─────────────────────────────────────────────────────
|
||||
|
||||
/** Registers an already-constructed {@code instance} under {@code type}. */
|
||||
/** Binds an already-created singleton. Duplicate bindings are always an error. */
|
||||
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")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>
|
||||
*/
|
||||
/** Declares a no-dependency boot 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()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the service for {@code type}. Checks own scope first, then parent chain.
|
||||
* Lazy-registered types are resolved on first access. Circular dependencies throw
|
||||
* {@link IllegalStateException} with the full cycle path.
|
||||
*
|
||||
* @throws IllegalStateException if the service is not found anywhere in the context chain
|
||||
*/
|
||||
/** Declares a boot factory and its complete dependency set. */
|
||||
public <T> void supply(Class<T> type, ServiceFactory<T> factory, Class<?>... dependencies) {
|
||||
Objects.requireNonNull(factory, "factory");
|
||||
declare(type, new Binding<>(type, List.of(dependencies), factory));
|
||||
}
|
||||
|
||||
/** 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")
|
||||
public <T> T require(Class<T> type) {
|
||||
Object val = registry.get(type);
|
||||
if (val != null) return (T) val;
|
||||
if (pending.containsKey(type)) return resolve(type);
|
||||
if (state == State.DECLARING)
|
||||
throw new IllegalStateException("Service graph is still being declared; use FlashContext.onReady(...)");
|
||||
Binding<?> binding = bindings.get(type);
|
||||
if (binding != null) {
|
||||
verifyDeclaredDependency(type);
|
||||
return (T) resolve((Binding<Object>) binding);
|
||||
}
|
||||
if (parent != null) return parent.require(type);
|
||||
throw new IllegalStateException(
|
||||
"Service not found: " + type.getSimpleName() +
|
||||
" — register it via FlashContext.provide()/supply() or install the required extension");
|
||||
throw new IllegalStateException("No provider declared for " + type.getName() + dependencyTrace());
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
Object val = registry.get(type);
|
||||
if (val != null) return Optional.of((T) val);
|
||||
if (pending.containsKey(type)) return Optional.of(resolve(type));
|
||||
return parent != null ? parent.find(type) : Optional.empty();
|
||||
if (state == State.DECLARING)
|
||||
throw new IllegalStateException("Service graph is still being declared; use FlashContext.onReady(...)");
|
||||
if (bindings.containsKey(type)) return Optional.of(require(type));
|
||||
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); }
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
processors.add(processor);
|
||||
cachedProcessors = null;
|
||||
requireDeclaring(); processors.add(Objects.requireNonNull(processor)); 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() {
|
||||
if (cachedProcessors != null) return cachedProcessors;
|
||||
if (parent == null) return cachedProcessors = List.copyOf(processors);
|
||||
List<AnnotationProcessor> p = parent.processors();
|
||||
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);
|
||||
List<AnnotationProcessor> all = parent == null ? new ArrayList<>() : new ArrayList<>(parent.processors());
|
||||
all.addAll(processors); return cachedProcessors = List.copyOf(all);
|
||||
}
|
||||
|
||||
// ── 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() {
|
||||
if (cachedListeners != null) return cachedListeners;
|
||||
if (parent == null) return cachedListeners = List.copyOf(routeListeners);
|
||||
List<RouteListener> p = parent.routeListeners();
|
||||
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);
|
||||
List<RouteListener> all = parent == null ? new ArrayList<>() : new ArrayList<>(parent.routeListeners());
|
||||
all.addAll(routeListeners); return cachedListeners = List.copyOf(all);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* Two-phase contract for all Flash extensions.
|
||||
* One declarative contribution to a Flash application.
|
||||
*
|
||||
* <p>Extension lifecycle inside {@link FlashApp#start()}:
|
||||
* <ol>
|
||||
* <li>Extensions are sorted by {@link #priority()} — lower value runs first.</li>
|
||||
* <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>
|
||||
* <p>Extensions never control lifecycle ordering. During {@link #configure}, they declare
|
||||
* services, processors, listeners and ready callbacks. Flash closes declarations, validates and
|
||||
* resolves the complete service graph, then executes ready callbacks to materialise routes.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface FlashExtension {
|
||||
|
||||
/**
|
||||
* Phase 1 — register services and processors.
|
||||
* Safe: {@link FlashContext#provide}, {@link FlashContext#supply},
|
||||
* {@link FlashContext#addAnnotationProcessor}, {@link FlashContext#addRouteListener}.
|
||||
* Unsafe: {@link FlashContext#require} (services not yet resolved).
|
||||
* Declares this extension's contribution. {@code ctx.require(...)} is intentionally illegal
|
||||
* here: work needing resolved services belongs in {@link FlashContext#onReady(Runnable)}.
|
||||
*/
|
||||
default void provide(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; }
|
||||
void configure(FlashRegistrar<?> app, FlashContext ctx);
|
||||
}
|
||||
|
||||
@@ -6,11 +6,14 @@ import dev.relism.flash.models.RequestHandler;
|
||||
import dev.relism.flash.models.SimpleHandler;
|
||||
import dev.relism.flash.websocket.WebSocketEndpoint;
|
||||
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.Routes;
|
||||
import dev.relism.flash.routing.Ws;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* 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)
|
||||
* }</pre>
|
||||
*
|
||||
* <p>{@link FlashExtension#routes} receives a {@code FlashRegistrar<?>} for route
|
||||
* registration. Extension installation ({@code install()}) is only available on
|
||||
* <p>Extensions receive a {@link FlashApp} during their single configure declaration.
|
||||
* Extension installation ({@code install()}) is only available on
|
||||
* {@link FlashApp} — scoped install is intentionally unsupported.
|
||||
*
|
||||
* @param <SELF> concrete registrar type — enables fluent chaining without casting
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public abstract class FlashRegistrar<SELF extends FlashRegistrar<SELF>> {
|
||||
private static final AtomicLong INLINE_KEYS = new AtomicLong();
|
||||
|
||||
// ── 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 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 ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -56,7 +72,13 @@ public abstract class FlashRegistrar<SELF extends FlashRegistrar<SELF>> {
|
||||
* Order-independent: middleware is resolved at {@link FlashApp#start()}.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -93,18 +115,29 @@ public abstract class FlashRegistrar<SELF extends FlashRegistrar<SELF>> {
|
||||
* Subclasses may prepend a namespace prefix and inject scope middlewares before storing.
|
||||
*/
|
||||
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);
|
||||
|
||||
/** 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) {
|
||||
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));
|
||||
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) {
|
||||
try { return (RequestHandler) cls.getDeclaredConstructor().newInstance(); }
|
||||
catch (Exception e) {
|
||||
|
||||
@@ -3,7 +3,7 @@ package dev.relism.flash.extension;
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.flash.models.RequestHandler;
|
||||
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.websocket.WebSocketEndpoint;
|
||||
|
||||
@@ -28,7 +28,7 @@ public final class FlashScope extends FlashRegistrar<FlashScope> {
|
||||
|
||||
private final String namespace;
|
||||
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<WsRouteDefinition> deferredWsRoutes = new ArrayList<>();
|
||||
|
||||
@@ -44,8 +44,8 @@ public final class FlashScope extends FlashRegistrar<FlashScope> {
|
||||
// ── FlashRegistrar impl ───────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
protected void addRoute(HttpMethod method, String path, RequestHandler handler, List<Middleware> mw) {
|
||||
List<Middleware> scopeMw = scopeMiddlewares.isEmpty() ? List.of() : List.copyOf(scopeMiddlewares);
|
||||
protected void addRoute(HttpMethod method, String path, RequestHandler handler, List<MiddlewareNode> mw) {
|
||||
List<MiddlewareNode> scopeMw = scopeMiddlewares.isEmpty() ? List.of() : List.copyOf(scopeMiddlewares);
|
||||
deferredRoutes.add(new RouteDefinition(
|
||||
method, ns(path), handler, scopeMw, mw,
|
||||
!(handler instanceof SimpleHandler), ctx, namespace));
|
||||
@@ -57,7 +57,7 @@ public final class FlashScope extends FlashRegistrar<FlashScope> {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void addMiddleware(Middleware mw) { scopeMiddlewares.add(mw); }
|
||||
protected void addMiddleware(MiddlewareNode mw) { scopeMiddlewares.add(mw); }
|
||||
|
||||
// ── Internal (called by FlashApp.mount) ───────────────────────────────────
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ package dev.relism.flash.extension;
|
||||
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.flash.models.RequestHandler;
|
||||
import dev.relism.flash.routing.Middleware;
|
||||
import dev.relism.flash.routing.MiddlewareNode;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -26,8 +26,8 @@ record RouteDefinition(
|
||||
HttpMethod method,
|
||||
String path,
|
||||
RequestHandler handler,
|
||||
List<Middleware> scopeMiddlewares,
|
||||
List<Middleware> explicitMiddlewares,
|
||||
List<MiddlewareNode> scopeMiddlewares,
|
||||
List<MiddlewareNode> explicitMiddlewares,
|
||||
boolean classBasedHandler,
|
||||
FlashContext ctx,
|
||||
String namespace
|
||||
|
||||
@@ -32,6 +32,9 @@ import java.nio.charset.StandardCharsets;
|
||||
*/
|
||||
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.
|
||||
private static final byte[] JSON_404 = "{\"error\":\"Not Found\",\"status\":404}"
|
||||
.getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
@@ -8,6 +8,9 @@ import dev.relism.flash.websocket.WebSocketHandler;
|
||||
|
||||
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) {
|
||||
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"); }
|
||||
}
|
||||
}
|
||||
+3
@@ -92,4 +92,7 @@ public class FastPathRouterImpl extends AbstractRouter {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void compile() { ensureCompiled(); }
|
||||
}
|
||||
|
||||
+3
@@ -56,6 +56,9 @@ public final class FastPathWsRouterImpl extends AbstractWsRouter {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void compile() { ensureCompiled(); }
|
||||
|
||||
private static final class Context {
|
||||
private static final ThreadLocal<MatchResult<WebSocketHandler>> RESULT =
|
||||
ThreadLocal.withInitial(() -> new MatchResult<>(32, 128));
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user