From 8ece9975dec6af4431538df594ef541299af071c Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Tue, 11 Aug 2026 00:22:26 +0000 Subject: [PATCH 1/3] feat(ext-web-bundler): add build-time asset scanning and static-frontend strategy Introduces AssetDirectoryScanner, StaticFrontendStrategy, and WebBundlerBuild for build-time asset discovery, plus config/docs updates for frontend-type resolution. Co-Authored-By: Claude Sonnet 5 --- .../docs/asset-sources.md | 9 ++- .../flash-ext-web-bundler/docs/build-time.md | 43 ++++++++++++ .../docs/configuration.md | 12 +++- .../docs/frontend-selection.md | 24 ++++++- .../flash-ext-web-bundler/docs/modes.md | 9 +++ .../ext/webbundler/AssetDirectoryScanner.java | 60 ++++++++++++++++ .../webbundler/FilesystemAssetsSource.java | 38 +---------- .../flash/ext/webbundler/FrontendType.java | 14 +++- .../ext/webbundler/FrontendTypeResolver.java | 1 + .../webbundler/StaticFrontendStrategy.java | 21 ++++++ .../flash/ext/webbundler/WebBundlerBuild.java | 53 +++++++++++++++ .../ext/webbundler/WebBundlerConfig.java | 35 +++++++--- .../ext/webbundler/WebBundlerExtension.java | 5 +- .../ext/webbundler/WebBundlerBuildTest.java | 68 +++++++++++++++++++ .../ext/webbundler/WebBundlerConfigTest.java | 18 +++++ .../WebBundlerExtensionIntegrationTest.java | 43 ++++++++++++ 16 files changed, 399 insertions(+), 54 deletions(-) create mode 100644 flash-extensions/flash-ext-web-bundler/docs/build-time.md create mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetDirectoryScanner.java create mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/StaticFrontendStrategy.java create mode 100644 flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerBuild.java create mode 100644 flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerBuildTest.java diff --git a/flash-extensions/flash-ext-web-bundler/docs/asset-sources.md b/flash-extensions/flash-ext-web-bundler/docs/asset-sources.md index 216778e..a0a82bf 100644 --- a/flash-extensions/flash-ext-web-bundler/docs/asset-sources.md +++ b/flash-extensions/flash-ext-web-bundler/docs/asset-sources.md @@ -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: diff --git a/flash-extensions/flash-ext-web-bundler/docs/build-time.md b/flash-extensions/flash-ext-web-bundler/docs/build-time.md new file mode 100644 index 0000000..8276599 --- /dev/null +++ b/flash-extensions/flash-ext-web-bundler/docs/build-time.md @@ -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 + + org.codehaus.mojo + exec-maven-plugin + + + web-bundler-manifest + process-classes + java + + dev.relism.flash.ext.webbundler.WebBundlerBuild + + ${project.build.outputDirectory}/web/dist + + + + + +``` + +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")`. diff --git a/flash-extensions/flash-ext-web-bundler/docs/configuration.md b/flash-extensions/flash-ext-web-bundler/docs/configuration.md index 60b1be8..1c8b8f6 100644 --- a/flash-extensions/flash-ext-web-bundler/docs/configuration.md +++ b/flash-extensions/flash-ext-web-bundler/docs/configuration.md @@ -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. diff --git a/flash-extensions/flash-ext-web-bundler/docs/frontend-selection.md b/flash-extensions/flash-ext-web-bundler/docs/frontend-selection.md index 4712bce..e72fdb4 100644 --- a/flash-extensions/flash-ext-web-bundler/docs/frontend-selection.md +++ b/flash-extensions/flash-ext-web-bundler/docs/frontend-selection.md @@ -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: diff --git a/flash-extensions/flash-ext-web-bundler/docs/modes.md b/flash-extensions/flash-ext-web-bundler/docs/modes.md index adeeebc..11723b1 100644 --- a/flash-extensions/flash-ext-web-bundler/docs/modes.md +++ b/flash-extensions/flash-ext-web-bundler/docs/modes.md @@ -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. diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetDirectoryScanner.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetDirectoryScanner.java new file mode 100644 index 0000000..088198b --- /dev/null +++ b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetDirectoryScanner.java @@ -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 scan(Path root) { + Map 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 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; + } + } +} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FilesystemAssetsSource.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FilesystemAssetsSource.java index 1530378..769c07b 100644 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FilesystemAssetsSource.java +++ b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FilesystemAssetsSource.java @@ -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 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 byRoute = new HashMap<>(); - for (Map.Entry 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; - } - } } diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendType.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendType.java index c39d37d..c980410 100644 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendType.java +++ b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendType.java @@ -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; + } } diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendTypeResolver.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendTypeResolver.java index e596fae..05b39da 100644 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendTypeResolver.java +++ b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendTypeResolver.java @@ -8,6 +8,7 @@ final class FrontendTypeResolver { FrontendTypeResolver() { register(new ViteFrontendStrategy()); + register(new StaticFrontendStrategy()); } void register(FrontendStrategy strategy) { diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/StaticFrontendStrategy.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/StaticFrontendStrategy.java new file mode 100644 index 0000000..ed34e83 --- /dev/null +++ b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/StaticFrontendStrategy.java @@ -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 devCommand(WebBundlerConfig config, PackageManagerAdapter adapter) { + throw new UnsupportedOperationException("STATIC frontend type has no dev command"); + } + + @Override + public List buildCommand(WebBundlerConfig config, PackageManagerAdapter adapter) { + throw new UnsupportedOperationException("STATIC frontend type has no build command"); + } +} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerBuild.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerBuild.java new file mode 100644 index 0000000..678323c --- /dev/null +++ b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerBuild.java @@ -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 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() + " "); + System.exit(1); + } + generateManifest(Path.of(args[0])); + } +} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerConfig.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerConfig.java index 5a881df..73415ef 100644 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerConfig.java +++ b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerConfig.java @@ -92,15 +92,17 @@ public final class WebBundlerConfig { Objects.requireNonNull(webRoot, "webRoot"); Objects.requireNonNull(assetsSource, "assetsSource"); Objects.requireNonNull(indexFile, "indexFile"); - if (devPort <= 0 || devPort > 65535) { - throw new IllegalArgumentException("WebBundlerConfig: devPort must be in range 1..65535"); - } - if (watchList.isEmpty()) { - throw new IllegalArgumentException("WebBundlerConfig: watchList must not be empty"); - } - for (String path : watchList) { - if (path == null || path.isBlank()) { - throw new IllegalArgumentException("WebBundlerConfig: watchList contains blank entries"); + if (frontendType.requiresOrchestration()) { + if (devPort <= 0 || devPort > 65535) { + throw new IllegalArgumentException("WebBundlerConfig: devPort must be in range 1..65535"); + } + if (watchList.isEmpty()) { + throw new IllegalArgumentException("WebBundlerConfig: watchList must not be empty"); + } + for (String path : watchList) { + if (path == null || path.isBlank()) { + throw new IllegalArgumentException("WebBundlerConfig: watchList contains blank entries"); + } } } if (!basePath.startsWith("/")) { @@ -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 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 defaultWatchList(PackageManager manager) { return List.of( "package.json", diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerExtension.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerExtension.java index fc8d274..b92ed76 100644 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerExtension.java +++ b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerExtension.java @@ -51,7 +51,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( @@ -79,7 +79,8 @@ public final class WebBundlerExtension implements FlashExtension { @Override public void routes(FlashRegistrar app, FlashContext ctx) { 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; } diff --git a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerBuildTest.java b/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerBuildTest.java new file mode 100644 index 0000000..a8ffe8a --- /dev/null +++ b/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerBuildTest.java @@ -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"), "built"); + 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)); + } +} diff --git a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerConfigTest.java b/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerConfigTest.java index 6be98d8..1e3542c 100644 --- a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerConfigTest.java +++ b/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerConfigTest.java @@ -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()); + } } diff --git a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerExtensionIntegrationTest.java b/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerExtensionIntegrationTest.java index a09fec6..df7c443 100644 --- a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerExtensionIntegrationTest.java +++ b/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerExtensionIntegrationTest.java @@ -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"), "static"); + 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 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 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")); + } + } -- 2.54.0 From d7f36a7aeaa19a7e82144ac962f5f8caf66f4a15 Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Tue, 11 Aug 2026 00:22:40 +0000 Subject: [PATCH 2/3] feat(ext-mcp): add MCP (Model Context Protocol) server extension Streamable HTTP transport (JSON-RPC 2.0 over POST), one-class-per-tool/resource/prompt API mirroring RequestHandler, boot-time-precompiled schema/list payloads for a zero-alloc hot path, and optional OAuth2 protection built on flash-ext-oidc (lazy-loaded, RFC 8707 audience binding, RFC 9728 Protected Resource Metadata). Registers the module in the root and flash-extensions POMs and adds the ext-mcp commit scope to AGENTS.md. Co-Authored-By: Claude Sonnet 5 --- AGENTS.md | 2 +- README.md | 2 + flash-extensions/flash-ext-mcp/docs/README.md | 56 ++++ .../flash-ext-mcp/docs/jackson-interop.md | 51 ++++ .../flash-ext-mcp/docs/security.md | 89 +++++++ .../docs/tools-resources-prompts.md | 107 ++++++++ .../flash-ext-mcp/docs/transport.md | 45 ++++ flash-extensions/flash-ext-mcp/pom.xml | 43 +++ .../dev/relism/flash/ext/mcp/Content.java | 8 + .../flash/ext/mcp/JsonRpcErrorCode.java | 13 + .../dev/relism/flash/ext/mcp/McpConfig.java | 122 +++++++++ .../flash/ext/mcp/McpContentWriter.java | 54 ++++ .../relism/flash/ext/mcp/McpDispatcher.java | 244 ++++++++++++++++++ .../relism/flash/ext/mcp/McpExtension.java | 118 +++++++++ .../dev/relism/flash/ext/mcp/McpJson.java | 59 +++++ .../flash/ext/mcp/McpOidcIntegration.java | 56 ++++ .../flash/ext/mcp/McpPackageScanner.java | 167 ++++++++++++ .../dev/relism/flash/ext/mcp/McpPrompt.java | 60 +++++ .../flash/ext/mcp/McpProtocolException.java | 24 ++ .../dev/relism/flash/ext/mcp/McpRegistry.java | 176 +++++++++++++ .../dev/relism/flash/ext/mcp/McpResource.java | 66 +++++ .../flash/ext/mcp/McpResourceMetadata.java | 18 ++ .../dev/relism/flash/ext/mcp/McpSecurity.java | 17 ++ .../dev/relism/flash/ext/mcp/McpTool.java | 74 ++++++ .../flash/ext/mcp/McpTransportGuards.java | 62 +++++ .../java/dev/relism/flash/ext/mcp/Prompt.java | 32 +++ .../dev/relism/flash/ext/mcp/PromptArg.java | 11 + .../relism/flash/ext/mcp/PromptArguments.java | 21 ++ .../relism/flash/ext/mcp/PromptMessage.java | 15 ++ .../dev/relism/flash/ext/mcp/Resource.java | 31 +++ .../flash/ext/mcp/ResourceContents.java | 8 + .../dev/relism/flash/ext/mcp/TextContent.java | 4 + .../flash/ext/mcp/TextResourceContents.java | 9 + .../java/dev/relism/flash/ext/mcp/Tool.java | 40 +++ .../dev/relism/flash/ext/mcp/ToolArg.java | 12 + .../dev/relism/flash/ext/mcp/ToolArgType.java | 11 + .../relism/flash/ext/mcp/ToolArguments.java | 48 ++++ .../relism/flash/ext/mcp/ToolResponse.java | 32 +++ .../flash/ext/mcp/FakeOidcProvider.java | 94 +++++++ .../ext/mcp/McpExtensionIntegrationTest.java | 143 ++++++++++ .../ext/mcp/McpExtensionSecurityTest.java | 131 ++++++++++ .../relism/flash/ext/mcp/McpRegistryTest.java | 57 ++++ .../flash/ext/mcp/ToolArgumentsTest.java | 48 ++++ .../flash/ext/mcp/fixtures/EchoTool.java | 18 ++ .../flash/ext/mcp/fixtures/FailingTool.java | 15 ++ .../ext/mcp/fixtures/GreetingResource.java | 15 ++ .../ext/mcp/fixtures/SummarizePrompt.java | 18 ++ flash-extensions/pom.xml | 1 + pom.xml | 5 + 49 files changed, 2551 insertions(+), 1 deletion(-) create mode 100644 flash-extensions/flash-ext-mcp/docs/README.md create mode 100644 flash-extensions/flash-ext-mcp/docs/jackson-interop.md create mode 100644 flash-extensions/flash-ext-mcp/docs/security.md create mode 100644 flash-extensions/flash-ext-mcp/docs/tools-resources-prompts.md create mode 100644 flash-extensions/flash-ext-mcp/docs/transport.md create mode 100644 flash-extensions/flash-ext-mcp/pom.xml create mode 100644 flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/Content.java create mode 100644 flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/JsonRpcErrorCode.java create mode 100644 flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpConfig.java create mode 100644 flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpContentWriter.java create mode 100644 flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpDispatcher.java create mode 100644 flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpExtension.java create mode 100644 flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpJson.java create mode 100644 flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpOidcIntegration.java create mode 100644 flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpPackageScanner.java create mode 100644 flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpPrompt.java create mode 100644 flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpProtocolException.java create mode 100644 flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpRegistry.java create mode 100644 flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpResource.java create mode 100644 flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpResourceMetadata.java create mode 100644 flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpSecurity.java create mode 100644 flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpTool.java create mode 100644 flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpTransportGuards.java create mode 100644 flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/Prompt.java create mode 100644 flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/PromptArg.java create mode 100644 flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/PromptArguments.java create mode 100644 flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/PromptMessage.java create mode 100644 flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/Resource.java create mode 100644 flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/ResourceContents.java create mode 100644 flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/TextContent.java create mode 100644 flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/TextResourceContents.java create mode 100644 flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/Tool.java create mode 100644 flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/ToolArg.java create mode 100644 flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/ToolArgType.java create mode 100644 flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/ToolArguments.java create mode 100644 flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/ToolResponse.java create mode 100644 flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/FakeOidcProvider.java create mode 100644 flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpExtensionIntegrationTest.java create mode 100644 flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpExtensionSecurityTest.java create mode 100644 flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpRegistryTest.java create mode 100644 flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/ToolArgumentsTest.java create mode 100644 flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/fixtures/EchoTool.java create mode 100644 flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/fixtures/FailingTool.java create mode 100644 flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/fixtures/GreetingResource.java create mode 100644 flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/fixtures/SummarizePrompt.java diff --git a/AGENTS.md b/AGENTS.md index b37c568..985077f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,7 @@ Format: `(): ` 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: ``` diff --git a/README.md b/README.md index d317d8a..0fd4860 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -142,6 +143,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) diff --git a/flash-extensions/flash-ext-mcp/docs/README.md b/flash-extensions/flash-ext-mcp/docs/README.md new file mode 100644 index 0000000..4b30265 --- /dev/null +++ b/flash-extensions/flash-ext-mcp/docs/README.md @@ -0,0 +1,56 @@ +# 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 +- [`jackson-interop.md`](jackson-interop.md) — why this extension does not depend on `flash-ext-jackson` diff --git a/flash-extensions/flash-ext-mcp/docs/jackson-interop.md b/flash-extensions/flash-ext-mcp/docs/jackson-interop.md new file mode 100644 index 0000000..dddee59 --- /dev/null +++ b/flash-extensions/flash-ext-mcp/docs/jackson-interop.md @@ -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)` 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. diff --git a/flash-extensions/flash-ext-mcp/docs/security.md b/flash-extensions/flash-ext-mcp/docs/security.md new file mode 100644 index 0000000..5a23e3d --- /dev/null +++ b/flash-extensions/flash-ext-mcp/docs/security.md @@ -0,0 +1,89 @@ +# Security + +## `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 `true` 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 + +When oidc is available and `security() != NONE`: + +1. The MCP route is wrapped with `flash-ext-oidc`'s own `OidcMiddleware.protect()` — the same + Bearer-token/JWKS validation path used everywhere else in Flash5. No JWT parsing or JWKS + handling is reimplemented here. +2. If `McpConfig.resourceIdentifier(...)` is set, an additional audience guard runs after + `protect()`: it reads the validated claims from `ClaimsHolder` and rejects (`403`) any token + whose `aud` claim does not include the configured resource identifier — **RFC 8707 Resource + Indicators / audience binding**. This is genuinely new behavior, not something + `flash-ext-oidc` does on its own: `OidcMiddleware` 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. If `resourceIdentifier(...)` is left unset, only standard bearer validation runs — no + audience binding. Fine for a first integration; RFC 8707 becomes meaningful once you have + more than one resource server sharing the same authorization server. + +## RFC 9728 Protected Resource Metadata + +If both `resourceIdentifier(...)` and `authorizationServerIssuer(...)` are set (and the endpoint +ends up protected), `flash-ext-mcp` publishes a Protected Resource Metadata document at +`/.well-known/oauth-protected-resource{rootPath}`: + +```json +{ "resource": "https://mcp.example.com/mcp", "authorization_servers": ["https://auth.example.com/realms/myrealm"] } +``` + +This lets a spec-compliant MCP client discover which authorization server to use without +out-of-band configuration. `authorizationServerIssuer` has to be supplied explicitly because +`flash-ext-oidc` does not expose its resolved issuer/discovery metadata through `FlashContext` — +only `OidcMiddleware` and `JwtValidator` are registered there. Passing it separately avoids +reaching into `flash-ext-oidc` internals for a value the app owner already has at hand (it's the +same issuer they configured `OidcExtension` with). + +Without an issuer configured, bearer validation still works exactly the same — the client just +needs the authorization server configured out-of-band instead of discovering it automatically. + +## 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. diff --git a/flash-extensions/flash-ext-mcp/docs/tools-resources-prompts.md b/flash-extensions/flash-ext-mcp/docs/tools-resources-prompts.md new file mode 100644 index 0000000..fed2c37 --- /dev/null +++ b/flash-extensions/flash-ext-mcp/docs/tools-resources-prompts.md @@ -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 require(Class 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. diff --git a/flash-extensions/flash-ext-mcp/docs/transport.md b/flash-extensions/flash-ext-mcp/docs/transport.md new file mode 100644 index 0000000..65b9d78 --- /dev/null +++ b/flash-extensions/flash-ext-mcp/docs/transport.md @@ -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. diff --git a/flash-extensions/flash-ext-mcp/pom.xml b/flash-extensions/flash-ext-mcp/pom.xml new file mode 100644 index 0000000..e5effe7 --- /dev/null +++ b/flash-extensions/flash-ext-mcp/pom.xml @@ -0,0 +1,43 @@ + + + 4.0.0 + + + dev.relism + flash-extensions + 2.1.0-SNAPSHOT + + + flash-ext-mcp + + + + dev.relism + flash + + + dev.relism + flash-ext-oidc + true + + + com.fasterxml.jackson.core + jackson-databind + + + org.projectlombok + lombok + + + org.slf4j + slf4j-api + + + org.junit.jupiter + junit-jupiter + + + + diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/Content.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/Content.java new file mode 100644 index 0000000..910a392 --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/Content.java @@ -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 {} diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/JsonRpcErrorCode.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/JsonRpcErrorCode.java new file mode 100644 index 0000000..5776b3f --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/JsonRpcErrorCode.java @@ -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; +} diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpConfig.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpConfig.java new file mode 100644 index 0000000..3aecedb --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpConfig.java @@ -0,0 +1,122 @@ +package dev.relism.flash.ext.mcp; + +import java.util.ArrayList; +import java.util.List; + +/** + * Immutable configuration for {@link McpExtension}. + * + *
{@code
+ * McpConfig.builder("my-mcp-server")
+ *     .version("1.0.0")
+ *     .rootPath("/mcp")
+ *     .toolsPackage("com.example.tools")
+ *     .security(McpSecurity.REQUIRED)
+ *     .resourceIdentifier("https://mcp.example.com/mcp")
+ *     .build();
+ * }
+ */ +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 allowedOrigins; + + 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); + } + + 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 allowedOrigins() { return allowedOrigins; } + + 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 allowedOrigins = new ArrayList<>(); + + 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; } + + /** + * Resource identifier used for RFC 8707 audience binding: tokens whose {@code aud} claim + * does not include this value are rejected. Optional — if unset, only standard bearer + * validation (signature/issuer/expiry) is enforced, not audience binding. + */ + public Builder resourceIdentifier(String resourceIdentifier) { this.resourceIdentifier = resourceIdentifier; return this; } + + /** + * Authorization server issuer URL, used to publish an RFC 9728 Protected Resource + * Metadata document at {@code /.well-known/oauth-protected-resource{rootPath}} so MCP + * clients can discover it automatically. Requires {@link #resourceIdentifier(String)} + * to also be set. Optional — without it, bearer validation still works, clients just + * need the authorization server configured out-of-band. + */ + 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; } + + 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; + } + } +} diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpContentWriter.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpContentWriter.java new file mode 100644 index 0000000..94d6692 --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpContentWriter.java @@ -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 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(); + } +} diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpDispatcher.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpDispatcher.java new file mode 100644 index 0000000..f53d45b --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpDispatcher.java @@ -0,0 +1,244 @@ +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}. + * + *

Per the MCP specification, a {@code tools/call} failure is a normal JSON-RPC + * result 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. + */ +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); + + ToolArguments args = new ToolArguments(params.path("arguments")); + ToolResponse result; + try { + result = tool.instance().call(args); + } catch (Exception e) { + result = ToolResponse.error("Tool \"" + name + "\" failed: " + e.getMessage()); + } + 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(); + } +} diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpExtension.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpExtension.java new file mode 100644 index 0000000..e38e24a --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpExtension.java @@ -0,0 +1,118 @@ +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)}. + * + *

{@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
+ * FlashApp.create(8080)
+ *     .install(new OidcExtension(oidcConfig))
+ *     .install(new McpExtension(McpConfig.builder("my-mcp-server")
+ *         .toolsPackage("com.example.tools")
+ *         .security(McpSecurity.REQUIRED)
+ *         .resourceIdentifier("https://mcp.example.com/mcp")
+ *         .authorizationServerIssuer("https://auth.example.com/realms/myrealm")
+ *         .build()))
+ *     .start();
+ * }
+ * + *

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; + } + + /** + * Everything — scanning, binding, security resolution, route registration — happens here + * rather than in {@link #provide}, because binding a tool calls its {@code onInit()}, which + * may call {@code require()} on services other extensions registered lazily via + * {@code ctx.supply()}. Per {@link FlashExtension}'s contract, {@code require()} is only + * safe once {@code routes()} runs, after every extension's {@code provide()} phase has + * completed and {@code FlashContext.resolveAll()} has run. + */ + @Override + public void routes(FlashRegistrar app, FlashContext ctx) { + McpRegistry registry = McpRegistry.scan(config.toolsPackage(), ctx); + McpDispatcher dispatcher = new McpDispatcher(registry, config.name(), config.version(), config.instructions()); + + List chain = new ArrayList<>(3); + chain.add(McpTransportGuards.httpExceptionGuard()); + chain.add(McpTransportGuards.originGuard(config.allowedOrigins())); + + Middleware security = resolveSecurity(ctx); + if (security != null) chain.add(security); + + app.post(config.rootPath(), (req, res) -> { dispatcher.handle(req, res); return null; }, + chain.toArray(Middleware[]::new)); + + registerResourceMetadata(app, security != null); + } + + private Middleware resolveSecurity(FlashContext ctx) { + if (config.security() == McpSecurity.NONE) return null; + + Middleware oidcSecurity; + try { + oidcSecurity = McpOidcIntegration.resolve(ctx, config); + } catch (NoClassDefFoundError e) { + oidcSecurity = null; // flash-ext-oidc not on the classpath at all + } + if (oidcSecurity != null) return oidcSecurity; + + 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; + } + + private void registerResourceMetadata(FlashRegistrar app, boolean secured) { + if (!secured) return; + String resourceId = config.resourceIdentifier(); + String issuer = config.authorizationServerIssuer(); + if (resourceId == null || resourceId.isBlank() || issuer == null || issuer.isBlank()) return; + + String body = McpResourceMetadata.build(resourceId, issuer); + String path = "/.well-known/oauth-protected-resource" + config.rootPath(); + app.get(path, (req, res) -> { + res.type(ContentType.JSON); + return body; + }); + } +} diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpJson.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpJson.java new file mode 100644 index 0000000..426fc01 --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpJson.java @@ -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). + * + *

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; + } +} diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpOidcIntegration.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpOidcIntegration.java new file mode 100644 index 0000000..3986176 --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpOidcIntegration.java @@ -0,0 +1,56 @@ +package dev.relism.flash.ext.mcp; + +import dev.relism.flash.ext.oidc.ClaimsHolder; +import dev.relism.flash.ext.oidc.OidcMiddleware; +import dev.relism.flash.exceptions.HttpException; +import dev.relism.flash.extension.FlashContext; +import dev.relism.flash.routing.Middleware; + +import java.util.Map; +import java.util.Optional; + +/** + * Lazy, isolated bridge to {@code flash-ext-oidc}. + * + *

References to OIDC types only ever resolve when {@link #resolve} is actually invoked — + * never at {@link McpExtension} class-load time — because they live in this separate nested + * class. The caller wraps the invocation in {@code catch (NoClassDefFoundError)}, exactly like + * {@code OidcExtension}'s own lazy bridge to {@code flash-ext-openapi}. This is what lets + * {@code flash-ext-mcp} run standalone (MCP-only, no OAuth2) when {@code flash-ext-oidc} is not + * even on the classpath. + */ +final class McpOidcIntegration { + + private McpOidcIntegration() {} + + /** Returns the security {@link Middleware} to apply, or {@code null} if oidc is not installed. */ + static Middleware resolve(FlashContext ctx, McpConfig config) { + Optional oidc = ctx.find(OidcMiddleware.class); + if (oidc.isEmpty()) return null; + + Middleware protect = oidc.get().protect(); + String resourceId = config.resourceIdentifier(); + if (resourceId == null || resourceId.isBlank()) return protect; + + return Middleware.of(protect, audienceGuard(resourceId)); + } + + /** RFC 8707 audience binding: rejects tokens whose {@code aud} claim doesn't include ours. */ + private static Middleware audienceGuard(String resourceIdentifier) { + return next -> (req, res) -> { + Map claims = ClaimsHolder.get(); + if (claims != null && !audienceMatches(claims.get("aud"), resourceIdentifier)) { + 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; + } +} diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpPackageScanner.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpPackageScanner.java new file mode 100644 index 0000000..5961321 --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpPackageScanner.java @@ -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). + * + *

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. + * + *

Fail-fast: 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> tools, + List> resources, + List> 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> tools = new ArrayList<>(); + List> resources = new ArrayList<>(); + List> prompts = new ArrayList<>(); + List errors = new ArrayList<>(); + boolean packageFound = false; + + try { + Enumeration 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> tools, + List> resources, + List> prompts, + List 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> tools, + List> resources, + List> prompts, + List errors) { + String prefix = resourcePath + "/"; + Enumeration 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> tools, + List> resources, + List> prompts, + List 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) cls); + return; + } + if (McpResource.class.isAssignableFrom(cls) && cls.isAnnotationPresent(Resource.class)) { + assertNoArgConstructor(cls, errors); + resources.add((Class) cls); + return; + } + if (McpPrompt.class.isAssignableFrom(cls) && cls.isAnnotationPresent(Prompt.class)) { + assertNoArgConstructor(cls, errors); + prompts.add((Class) 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 errors) { + try { cls.getDeclaredConstructor(); } + catch (NoSuchMethodException e) { errors.add(cls.getName() + " — missing public no-arg constructor"); } + } +} diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpPrompt.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpPrompt.java new file mode 100644 index 0000000..568a993 --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpPrompt.java @@ -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. + * + *

{@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")));
+ *     }
+ * }
+ * }
+ */ +public abstract class McpPrompt { + + private FlashContext ctx; + + /** + * Called once by the framework after instantiation, before the first {@code prompts/get}. + * Infrastructure method — do not call from user code. + */ + public final void bind(FlashContext ctx) { + this.ctx = ctx; + onInit(); + } + + protected void onInit() {} + + protected T require(Class type) { + checkBound(); + return ctx.require(type); + } + + protected Optional find(Class type) { + checkBound(); + return ctx.find(type); + } + + protected Optional optional(Class 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; +} diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpProtocolException.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpProtocolException.java new file mode 100644 index 0000000..6405456 --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpProtocolException.java @@ -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); + } +} diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpRegistry.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpRegistry.java new file mode 100644 index 0000000..d3a0bb7 --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpRegistry.java @@ -0,0 +1,176 @@ +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. + * + *

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 = "[]"; + + record RegisteredTool(String name, McpTool instance) {} + record RegisteredResource(String uri, McpResource instance) {} + record RegisteredPrompt(String name, McpPrompt instance) {} + + private final Map tools = new LinkedHashMap<>(); + private final Map resources = new LinkedHashMap<>(); + private final Map prompts = new LinkedHashMap<>(); + + private String toolsListJson = EMPTY_ARRAY; + private String resourcesListJson = EMPTY_ARRAY; + private String promptsListJson = EMPTY_ARRAY; + + private McpRegistry() {} + + static McpRegistry scan(String packageName, FlashContext ctx) { + McpPackageScanner.ScanResult found = McpPackageScanner.scan(packageName); + McpRegistry registry = new McpRegistry(); + + for (Class cls : found.tools()) { + Tool ann = cls.getAnnotation(Tool.class); + McpTool instance = instantiate(cls); + instance.bind(ctx); + if (registry.tools.putIfAbsent(ann.name(), new RegisteredTool(ann.name(), instance)) != null) + throw new InitializationException("Duplicate MCP tool name: \"" + ann.name() + "\""); + } + for (Class 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 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> classes) throws IOException { + gen.writeStartArray(); + for (Class 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> classes) throws IOException { + gen.writeStartArray(); + for (Class 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> classes) throws IOException { + gen.writeStartArray(); + for (Class 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(); + } + + private static T instantiate(Class cls) { + try { + Constructor 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); + } + } +} diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpResource.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpResource.java new file mode 100644 index 0000000..f214f89 --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpResource.java @@ -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. + * + *

{@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());
+ *     }
+ * }
+ * }
+ */ +public abstract class McpResource { + + private FlashContext ctx; + private String uri; + + /** + * Called once by the framework after instantiation, before the first {@code resources/read}. + * Infrastructure method — 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 require(Class type) { + checkBound(); + return ctx.require(type); + } + + protected Optional find(Class type) { + checkBound(); + return ctx.find(type); + } + + protected Optional optional(Class 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; +} diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpResourceMetadata.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpResourceMetadata.java new file mode 100644 index 0000000..c3c9fac --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpResourceMetadata.java @@ -0,0 +1,18 @@ +package dev.relism.flash.ext.mcp; + +/** RFC 9728 OAuth 2.0 Protected Resource Metadata document, built once at boot. */ +final class McpResourceMetadata { + + private McpResourceMetadata() {} + + static String build(String resourceIdentifier, String authorizationServerIssuer) { + return McpJson.buildString(gen -> { + gen.writeStartObject(); + gen.writeStringField("resource", resourceIdentifier); + gen.writeArrayFieldStart("authorization_servers"); + gen.writeString(authorizationServerIssuer); + gen.writeEndArray(); + gen.writeEndObject(); + }); + } +} diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpSecurity.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpSecurity.java new file mode 100644 index 0000000..211ea5d --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpSecurity.java @@ -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 +} diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpTool.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpTool.java new file mode 100644 index 0000000..e5b0e8e --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpTool.java @@ -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. + * + *

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. + * + *

{@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"))));
+ *     }
+ * }
+ * }
+ */ +public abstract class McpTool { + + private FlashContext ctx; + + /** + * Called once by the framework after instantiation, before the first {@code tools/call}. + * Infrastructure method — 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 require(Class type) { + checkBound(); + return ctx.require(type); + } + + protected Optional find(Class type) { + checkBound(); + return ctx.find(type); + } + + protected Optional optional(Class 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; +} diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpTransportGuards.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpTransportGuards.java new file mode 100644 index 0000000..94ab5a0 --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpTransportGuards.java @@ -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 present but disallowed value is rejected. + * + *

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 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; + } + }; + } +} diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/Prompt.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/Prompt.java new file mode 100644 index 0000000..8814763 --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/Prompt.java @@ -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)}. + * + *

{@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")));
+ *     }
+ * }
+ * }
+ */ +@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 {}; +} diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/PromptArg.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/PromptArg.java new file mode 100644 index 0000000..81c896e --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/PromptArg.java @@ -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; +} diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/PromptArguments.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/PromptArguments.java new file mode 100644 index 0000000..0da0dc6 --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/PromptArguments.java @@ -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); } +} diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/PromptMessage.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/PromptMessage.java new file mode 100644 index 0000000..c453984 --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/PromptMessage.java @@ -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); + } +} diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/Resource.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/Resource.java new file mode 100644 index 0000000..49bad3f --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/Resource.java @@ -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)}. + * + *
{@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());
+ *     }
+ * }
+ * }
+ */ +@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"; +} diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/ResourceContents.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/ResourceContents.java new file mode 100644 index 0000000..e76d547 --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/ResourceContents.java @@ -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 {} diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/TextContent.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/TextContent.java new file mode 100644 index 0000000..825ab91 --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/TextContent.java @@ -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 {} diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/TextResourceContents.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/TextResourceContents.java new file mode 100644 index 0000000..01ecff3 --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/TextResourceContents.java @@ -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); + } +} diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/Tool.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/Tool.java new file mode 100644 index 0000000..49f48f4 --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/Tool.java @@ -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)}. + * + *
{@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"))));
+ *     }
+ * }
+ * }
+ */ +@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 {}; +} diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/ToolArg.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/ToolArg.java new file mode 100644 index 0000000..0a800f2 --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/ToolArg.java @@ -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; +} diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/ToolArgType.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/ToolArgType.java new file mode 100644 index 0000000..0124396 --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/ToolArgType.java @@ -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); + } +} diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/ToolArguments.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/ToolArguments.java new file mode 100644 index 0000000..25e8f53 --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/ToolArguments.java @@ -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. + * + *

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. + * + *

{@code
+ * public ToolResponse call(ToolArguments args) {
+ *     String city = args.getString("city");
+ *     int days    = args.getInt("days", 1);
+ *     ...
+ * }
+ * }
+ */ +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); } +} diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/ToolResponse.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/ToolResponse.java new file mode 100644 index 0000000..8551408 --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/ToolResponse.java @@ -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; + private final boolean isError; + + private ToolResponse(List 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 + * result (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() { return content; } + boolean isError() { return isError; } +} diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/FakeOidcProvider.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/FakeOidcProvider.java new file mode 100644 index 0000000..3b14af1 --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/FakeOidcProvider.java @@ -0,0 +1,94 @@ +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.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) { + try { + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer(issuer) + .subject(subject) + .audience(audience) + .issueTime(Date.from(Instant.now())) + .expirationTime(Date.from(Instant.now().plusSeconds(300))) + .build(); + SignedJWT jwt = new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaKey.getKeyID()).build(), claims); + jwt.sign(new RSASSASigner(rsaKey)); + return jwt.serialize(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + 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); } +} diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpExtensionIntegrationTest.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpExtensionIntegrationTest.java new file mode 100644 index 0000000..051af49 --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpExtensionIntegrationTest.java @@ -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 resp = post(body); + assertEquals(202, resp.statusCode()); + } + + @Test + void malformedJson_returns400ParseError() throws Exception { + HttpResponse 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 resp = post(body); + assertEquals(200, resp.statusCode()); + return MAPPER.readTree(resp.body()); + } + + private HttpResponse 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()); + } +} diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpExtensionSecurityTest.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpExtensionSecurityTest.java new file mode 100644 index 0000000..416e4ed --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpExtensionSecurityTest.java @@ -0,0 +1,131 @@ +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 resp = post(port, initializeBody(), null); + assertEquals(200, resp.statusCode()); + } + + @Test + void required_withOidc_rejectsMissingToken() throws Exception { + int port = bootSecuredApp(null); + + HttpResponse 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 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 resp = post(port, initializeBody(), token); + assertEquals(200, resp.statusCode()); + assertTrue(resp.body().contains("\"protocolVersion\"")); + } + + // ── 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 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()); + } +} diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpRegistryTest.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpRegistryTest.java new file mode 100644 index 0000000..20503a1 --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpRegistryTest.java @@ -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()); + + 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())); + } + + 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); + } +} diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/ToolArgumentsTest.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/ToolArgumentsTest.java new file mode 100644 index 0000000..c39720d --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/ToolArgumentsTest.java @@ -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")); + } +} diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/fixtures/EchoTool.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/fixtures/EchoTool.java new file mode 100644 index 0000000..32611eb --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/fixtures/EchoTool.java @@ -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"))); + } +} diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/fixtures/FailingTool.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/fixtures/FailingTool.java new file mode 100644 index 0000000..e845097 --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/fixtures/FailingTool.java @@ -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"); + } +} diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/fixtures/GreetingResource.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/fixtures/GreetingResource.java new file mode 100644 index 0000000..792c28e --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/fixtures/GreetingResource.java @@ -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"); + } +} diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/fixtures/SummarizePrompt.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/fixtures/SummarizePrompt.java new file mode 100644 index 0000000..d7ba230 --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/fixtures/SummarizePrompt.java @@ -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"))); + } +} diff --git a/flash-extensions/pom.xml b/flash-extensions/pom.xml index a47182b..7c3b859 100644 --- a/flash-extensions/pom.xml +++ b/flash-extensions/pom.xml @@ -23,6 +23,7 @@ flash-ext-view-thymeleaf flash-ext-limiter flash-ext-web-bundler + flash-ext-mcp flash-ext-data-core flash-ext-data-jdbc flash-ext-data-hibernate diff --git a/pom.xml b/pom.xml index 428ee7f..1cad048 100644 --- a/pom.xml +++ b/pom.xml @@ -97,6 +97,11 @@ flash-ext-web-bundler ${project.version} + + dev.relism + flash-ext-mcp + ${project.version} + dev.relism fpr-core -- 2.54.0 From 891ef99b8e15894bf03b83daf06f764b23e5c0a7 Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Wed, 12 Aug 2026 16:42:49 +0000 Subject: [PATCH 3/3] refactor(core): make boot and middleware ordering deterministic --- README.md | 33 ++- .../relism/flash/ext/data/DataExtension.java | 14 +- .../flash/ext/jackson/JacksonExtension.java | 6 +- .../ext/jackson/JacksonExtensionTest.java | 5 +- .../flash/ext/limiter/LimiterExtension.java | 34 +-- .../limiter/LimiterOpenApiInteropTest.java | 9 +- flash-extensions/flash-ext-mcp/docs/README.md | 2 + .../flash-ext-mcp/docs/keycloak.md | 107 +++++++ .../flash-ext-mcp/docs/security.md | 135 +++++++-- .../relism/flash/ext/mcp/McpAuthPolicy.java | 23 ++ .../dev/relism/flash/ext/mcp/McpConfig.java | 52 +++- .../relism/flash/ext/mcp/McpDispatcher.java | 21 +- .../relism/flash/ext/mcp/McpExtension.java | 61 ++-- .../flash/ext/mcp/McpOidcIntegration.java | 153 +++++++++- .../dev/relism/flash/ext/mcp/McpRegistry.java | 35 ++- .../flash/ext/mcp/McpResourceMetadata.java | 10 +- .../flash/ext/mcp/FakeOidcProvider.java | 23 +- .../flash/ext/mcp/McpAuthPolicyTest.java | 150 ++++++++++ .../ext/mcp/McpExtensionSecurityTest.java | 63 +++++ .../relism/flash/ext/mcp/McpRegistryTest.java | 4 +- .../authenticatedonly/PointlessAuthTool.java | 20 ++ .../authfixtures/secured/AdminOnlyTool.java | 18 ++ .../mcp/authfixtures/secured/OpenTool.java | 17 ++ .../authfixtures/secured/WriteScopeTool.java | 18 ++ .../relism/flash/ext/oidc/OidcExtension.java | 15 +- .../relism/flash/ext/oidc/OidcMiddleware.java | 65 ++++- .../ext/oidc/OidcOpenApiInteropTest.java | 1 + .../flash/ext/openapi/OpenApiExtension.java | 22 +- .../ext/openapi/OpenApiExtensionTest.java | 26 +- .../ext/routeviewer/RouteViewerExtension.java | 22 +- .../ext/view/core/BaseViewExtension.java | 4 +- .../flash/ext/view/jte/JteExtension.java | 20 +- .../flash/ext/view/jte/JteExtensionTest.java | 18 +- .../ext/webbundler/WebBundlerExtension.java | 16 +- .../flash/extension/AnnotationProcessor.java | 4 +- .../flash/extension/ExtensionPhase.java | 32 --- .../dev/relism/flash/extension/FlashApp.java | 48 ++-- .../relism/flash/extension/FlashContext.java | 264 ++++++++---------- .../flash/extension/FlashExtension.java | 69 +---- .../flash/extension/FlashRegistrar.java | 43 ++- .../relism/flash/extension/FlashScope.java | 10 +- .../flash/extension/RouteDefinition.java | 6 +- .../relism/flash/routing/AbstractRouter.java | 3 + .../flash/routing/AbstractWsRouter.java | 3 + .../relism/flash/routing/MiddlewareGraph.java | 55 ++++ .../relism/flash/routing/MiddlewareKey.java | 11 + .../relism/flash/routing/MiddlewareNode.java | 35 +++ .../fastpathrouter/FastPathRouterImpl.java | 3 + .../fastpathrouter/FastPathWsRouterImpl.java | 3 + .../flash/extension/FlashContextTest.java | 46 +++ .../flash/routing/MiddlewareGraphTest.java | 42 +++ 51 files changed, 1395 insertions(+), 504 deletions(-) create mode 100644 flash-extensions/flash-ext-mcp/docs/keycloak.md create mode 100644 flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpAuthPolicy.java create mode 100644 flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpAuthPolicyTest.java create mode 100644 flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/authenticatedonly/PointlessAuthTool.java create mode 100644 flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/AdminOnlyTool.java create mode 100644 flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/OpenTool.java create mode 100644 flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/WriteScopeTool.java delete mode 100644 flash/src/main/java/dev/relism/flash/extension/ExtensionPhase.java create mode 100644 flash/src/main/java/dev/relism/flash/routing/MiddlewareGraph.java create mode 100644 flash/src/main/java/dev/relism/flash/routing/MiddlewareKey.java create mode 100644 flash/src/main/java/dev/relism/flash/routing/MiddlewareNode.java create mode 100644 flash/src/test/java/dev/relism/flash/extension/FlashContextTest.java create mode 100644 flash/src/test/java/dev/relism/flash/routing/MiddlewareGraphTest.java diff --git a/README.md b/README.md index 0fd4860..721c3eb 100644 --- a/README.md +++ b/README.md @@ -65,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) -> { @@ -91,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 ``` @@ -120,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(); ``` diff --git a/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/DataExtension.java b/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/DataExtension.java index 60ad68d..63f6e72 100644 --- a/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/DataExtension.java +++ b/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/DataExtension.java @@ -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; diff --git a/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/flash/ext/jackson/JacksonExtension.java b/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/flash/ext/jackson/JacksonExtension.java index 206f2f8..dc9a4c9 100644 --- a/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/flash/ext/jackson/JacksonExtension.java +++ b/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/flash/ext/jackson/JacksonExtension.java @@ -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; * *

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). * *

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); diff --git a/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/flash/ext/jackson/JacksonExtensionTest.java b/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/flash/ext/jackson/JacksonExtensionTest.java index 1518d08..aced4b8 100644 --- a/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/flash/ext/jackson/JacksonExtensionTest.java +++ b/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/flash/ext/jackson/JacksonExtensionTest.java @@ -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)); diff --git a/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimiterExtension.java b/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimiterExtension.java index 7e2bed6..9f09e85 100644 --- a/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimiterExtension.java +++ b/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimiterExtension.java @@ -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)); * } */ 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))); + }); + ctx.onReady(() -> { + try { + OpenApiIntegration.register(ctx); + } catch (NoClassDefFoundError ignored) { + // flash-ext-openapi not available — OpenAPI integration disabled + } }); - } - - @Override - public void routes(FlashRegistrar app, FlashContext ctx) { - try { - OpenApiIntegration.register(ctx); - } catch (NoClassDefFoundError ignored) { - // flash-ext-openapi not available — OpenAPI integration disabled - } } // ── Package-private helper — shared with Guard ──────────────────────────── diff --git a/flash-extensions/flash-ext-limiter/src/test/java/dev/relism/flash/ext/limiter/LimiterOpenApiInteropTest.java b/flash-extensions/flash-ext-limiter/src/test/java/dev/relism/flash/ext/limiter/LimiterOpenApiInteropTest.java index 168b4fc..f556696 100644 --- a/flash-extensions/flash-ext-limiter/src/test/java/dev/relism/flash/ext/limiter/LimiterOpenApiInteropTest.java +++ b/flash-extensions/flash-ext-limiter/src/test/java/dev/relism/flash/ext/limiter/LimiterOpenApiInteropTest.java @@ -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); diff --git a/flash-extensions/flash-ext-mcp/docs/README.md b/flash-extensions/flash-ext-mcp/docs/README.md index 4b30265..643ce0b 100644 --- a/flash-extensions/flash-ext-mcp/docs/README.md +++ b/flash-extensions/flash-ext-mcp/docs/README.md @@ -53,4 +53,6 @@ public class GetWeatherTool extends McpTool { - [`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` diff --git a/flash-extensions/flash-ext-mcp/docs/keycloak.md b/flash-extensions/flash-ext-mcp/docs/keycloak.md new file mode 100644 index 0000000..6326541 --- /dev/null +++ b/flash-extensions/flash-ext-mcp/docs/keycloak.md @@ -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= does not include expected +resource identifier "" — ... +``` + +`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`. diff --git a/flash-extensions/flash-ext-mcp/docs/security.md b/flash-extensions/flash-ext-mcp/docs/security.md index 5a23e3d..43e3903 100644 --- a/flash-extensions/flash-ext-mcp/docs/security.md +++ b/flash-extensions/flash-ext-mcp/docs/security.md @@ -1,5 +1,10 @@ # 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 @@ -34,44 +39,120 @@ MCP-only install, no OAuth2 anywhere in the app), the first such reference throw 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 +## OAuth2 resolution details — zero-config by default -When oidc is available and `security() != NONE`: +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()` — the same - Bearer-token/JWKS validation path used everywhere else in Flash5. No JWT parsing or JWKS - handling is reimplemented here. -2. If `McpConfig.resourceIdentifier(...)` is set, an additional audience guard runs after - `protect()`: it reads the validated claims from `ClaimsHolder` and rejects (`403`) any token - whose `aud` claim does not include the configured resource identifier — **RFC 8707 Resource - Indicators / audience binding**. This is genuinely new behavior, not something - `flash-ext-oidc` does on its own: `OidcMiddleware` 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. If `resourceIdentifier(...)` is left unset, only standard bearer validation runs — no - audience binding. Fine for a first integration; RFC 8707 becomes meaningful once you have - more than one resource server sharing the same authorization server. +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 -If both `resourceIdentifier(...)` and `authorizationServerIssuer(...)` are set (and the endpoint -ends up protected), `flash-ext-mcp` publishes a Protected Resource Metadata document at -`/.well-known/oauth-protected-resource{rootPath}`: +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"] } ``` -This lets a spec-compliant MCP client discover which authorization server to use without -out-of-band configuration. `authorizationServerIssuer` has to be supplied explicitly because -`flash-ext-oidc` does not expose its resolved issuer/discovery metadata through `FlashContext` — -only `OidcMiddleware` and `JwtValidator` are registered there. Passing it separately avoids -reaching into `flash-ext-oidc` internals for a value the app owner already has at hand (it's the -same issuer they configured `OidcExtension` with). +`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. -Without an issuer configured, bearer validation still works exactly the same — the client just -needs the authorization server configured out-of-band instead of discovering it automatically. +### `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 diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpAuthPolicy.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpAuthPolicy.java new file mode 100644 index 0000000..088d35e --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpAuthPolicy.java @@ -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. + * + *

{@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}. + * + *

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 check) {} diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpConfig.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpConfig.java index 3aecedb..da6315f 100644 --- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpConfig.java +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpConfig.java @@ -12,7 +12,7 @@ import java.util.List; * .rootPath("/mcp") * .toolsPackage("com.example.tools") * .security(McpSecurity.REQUIRED) - * .resourceIdentifier("https://mcp.example.com/mcp") + * .scopesSupported("openid", "profile", "email") * .build(); * } */ @@ -27,6 +27,8 @@ public final class McpConfig { private final String resourceIdentifier; private final String authorizationServerIssuer; private final List allowedOrigins; + private final List scopesSupported; + private final String rolesClaimPath; private McpConfig(Builder b) { this.name = b.name; @@ -38,6 +40,8 @@ public final class McpConfig { this.resourceIdentifier = b.resourceIdentifier; this.authorizationServerIssuer = b.authorizationServerIssuer; this.allowedOrigins = List.copyOf(b.allowedOrigins); + this.scopesSupported = List.copyOf(b.scopesSupported); + this.rolesClaimPath = b.rolesClaimPath; } String name() { return name; } @@ -49,6 +53,8 @@ public final class McpConfig { String resourceIdentifier() { return resourceIdentifier; } String authorizationServerIssuer() { return authorizationServerIssuer; } List allowedOrigins() { return allowedOrigins; } + List scopesSupported() { return scopesSupported; } + String rolesClaimPath() { return rolesClaimPath; } public static Builder builder(String name) { return new Builder(name); } @@ -62,6 +68,8 @@ public final class McpConfig { private String resourceIdentifier; private String authorizationServerIssuer; private final List allowedOrigins = new ArrayList<>(); + private final List scopesSupported = new ArrayList<>(); + private String rolesClaimPath = "realm_access.roles"; private Builder(String name) { if (name == null || name.isBlank()) @@ -85,18 +93,22 @@ public final class McpConfig { public Builder security(McpSecurity security) { this.security = security; return this; } /** - * Resource identifier used for RFC 8707 audience binding: tokens whose {@code aud} claim - * does not include this value are rejected. Optional — if unset, only standard bearer - * validation (signature/issuer/expiry) is enforced, not audience binding. + * Canonical URI of this MCP endpoint, used for RFC 8707 audience binding: tokens whose + * {@code aud} claim does not include this value are rejected. Optional — when + * {@code flash-ext-oidc} is installed, this is auto-derived per request from the + * forwarded/{@code Host} headers (same resolution {@code OidcExtension} uses for its own + * redirect URIs) and audience binding is enforced unconditionally. Set this explicitly + * only to override that guess — a reverse proxy that forwards neither + * {@code X-Forwarded-Host} nor {@code X-Forwarded-Proto}. */ public Builder resourceIdentifier(String resourceIdentifier) { this.resourceIdentifier = resourceIdentifier; return this; } /** - * Authorization server issuer URL, used to publish an RFC 9728 Protected Resource - * Metadata document at {@code /.well-known/oauth-protected-resource{rootPath}} so MCP - * clients can discover it automatically. Requires {@link #resourceIdentifier(String)} - * to also be set. Optional — without it, bearer validation still works, clients just - * need the authorization server configured out-of-band. + * Authorization server issuer URL, published in the RFC 9728 Protected Resource + * Metadata document at {@code /.well-known/oauth-protected-resource{rootPath}}. Optional + * — when {@code flash-ext-oidc} is installed, this is auto-derived from its configured + * issuer. Set this explicitly only to override that (e.g. publishing a different issuer + * than the one actually validating tokens). */ public Builder authorizationServerIssuer(String issuer) { this.authorizationServerIssuer = issuer; return this; } @@ -107,6 +119,28 @@ public final class McpConfig { */ public Builder allowedOrigins(String... origins) { this.allowedOrigins.addAll(List.of(origins)); return this; } + /** + * OAuth2 scopes this server expects clients to request, published as {@code + * scopes_supported} in the RFC 9728 Protected Resource Metadata document. Optional per + * the spec — omitted from the document entirely if never set. A spec-compliant client + * reads this to know what to put in its authorization/token requests instead of + * requesting nothing; see {@code docs/keycloak.md}'s "same story for any other claim" + * section for why this matters in practice (a client that requests no scope only gets + * whatever your authorization server treats as always-included, e.g. Keycloak's `basic`). + * Purely advertisement — this server still validates whatever token it actually receives + * the same way regardless of what a client requested. + */ + public Builder scopesSupported(String... scopes) { this.scopesSupported.addAll(List.of(scopes)); return this; } + + /** + * Claim path used to resolve roles for {@code @RolesAllowed} on an {@link McpTool} — + * same dot-path syntax and default (Keycloak's {@code realm_access.roles}) as {@code + * OidcConfig#rolesClaimPath()}. Set this only if the two configs diverge; there is no + * way to auto-derive it from the installed {@code OidcExtension} (see {@code + * docs/security.md}'s {@code @RolesAllowed}/{@code @ScopesAllowed} section for why). + */ + public Builder rolesClaimPath(String rolesClaimPath) { this.rolesClaimPath = rolesClaimPath; return this; } + public McpConfig build() { if (toolsPackage == null || toolsPackage.isBlank()) throw new IllegalStateException( diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpDispatcher.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpDispatcher.java index f53d45b..86271d1 100644 --- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpDispatcher.java +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpDispatcher.java @@ -17,7 +17,11 @@ import java.io.IOException; * error — the model needs to see it. Everything else that goes wrong (bad params, unknown * tool/resource/prompt name, resource/prompt handler exceptions) is a JSON-RPC error object, * always returned with HTTP 200: the HTTP request itself succeeded, only the RPC did not. Only - * malformed HTTP-level input (unparsable JSON, not a JSON object) gets HTTP 400. + * malformed HTTP-level input (unparsable JSON, not a JSON object) gets HTTP 400. A + * {@code @RolesAllowed}/{@code @ScopesAllowed} denial (see {@link McpAuthPolicy}) is the same + * category — {@code isError: true}, tool never invoked — not a transport-level rejection; the + * route-wide 401/403 for "not authenticated at all" already happened earlier, in the {@code + * OidcMiddleware}/audience-guard middleware chain, before this dispatcher ever runs. */ final class McpDispatcher { @@ -132,12 +136,17 @@ final class McpDispatcher { if (tool == null) throw McpProtocolException.invalidParams("Unknown tool: " + name); - ToolArguments args = new ToolArguments(params.path("arguments")); ToolResponse result; - try { - result = tool.instance().call(args); - } catch (Exception e) { - result = ToolResponse.error("Tool \"" + name + "\" failed: " + e.getMessage()); + String denied = tool.policy() != null ? tool.policy().check().get() : null; + if (denied != null) { + result = ToolResponse.error("Tool \"" + name + "\" denied: " + denied); + } else { + ToolArguments args = new ToolArguments(params.path("arguments")); + try { + result = tool.instance().call(args); + } catch (Exception e) { + result = ToolResponse.error("Tool \"" + name + "\" failed: " + e.getMessage()); + } } ToolResponse finalResult = result; writeResult(res, id, gen -> { diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpExtension.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpExtension.java index e38e24a..9a3dd3e 100644 --- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpExtension.java +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpExtension.java @@ -25,14 +25,14 @@ import java.util.List; * .build())) * .start(); * - * // With flash-ext-oidc as the OAuth2 resource server + * // With flash-ext-oidc as the OAuth2 resource server — zero extra config: issuer, canonical + * // resource identifier, RFC 8707 audience binding and RFC 9728 metadata are all derived from + * // the installed OidcExtension. * FlashApp.create(8080) * .install(new OidcExtension(oidcConfig)) * .install(new McpExtension(McpConfig.builder("my-mcp-server") * .toolsPackage("com.example.tools") * .security(McpSecurity.REQUIRED) - * .resourceIdentifier("https://mcp.example.com/mcp") - * .authorizationServerIssuer("https://auth.example.com/realms/myrealm") * .build())) * .start(); * } @@ -51,42 +51,40 @@ public class McpExtension implements FlashExtension { this.config = config; } - /** - * Everything — scanning, binding, security resolution, route registration — happens here - * rather than in {@link #provide}, because binding a tool calls its {@code onInit()}, which - * may call {@code require()} on services other extensions registered lazily via - * {@code ctx.supply()}. Per {@link FlashExtension}'s contract, {@code require()} is only - * safe once {@code routes()} runs, after every extension's {@code provide()} phase has - * completed and {@code FlashContext.resolveAll()} has run. - */ @Override - public void routes(FlashRegistrar app, FlashContext ctx) { - McpRegistry registry = McpRegistry.scan(config.toolsPackage(), ctx); + public void configure(FlashRegistrar app, FlashContext ctx) { + ctx.onReady(() -> registerRoutes(app, ctx)); + } + + private void registerRoutes(FlashRegistrar app, FlashContext ctx) { + // Resolved before scanning so McpRegistry knows, per tool, whether @RolesAllowed/ + // @ScopesAllowed are backed by real OAuth2 protection or a boot-time misconfiguration + // (see McpOidcIntegration#compileToolPolicy) — must run first, not after. + McpOidcIntegration.Resolved secured = resolveSecurity(ctx); + McpRegistry registry = McpRegistry.scan(config.toolsPackage(), ctx, secured != null, config.rolesClaimPath()); McpDispatcher dispatcher = new McpDispatcher(registry, config.name(), config.version(), config.instructions()); List chain = new ArrayList<>(3); chain.add(McpTransportGuards.httpExceptionGuard()); chain.add(McpTransportGuards.originGuard(config.allowedOrigins())); - - Middleware security = resolveSecurity(ctx); - if (security != null) chain.add(security); + if (secured != null) chain.add(secured.security()); app.post(config.rootPath(), (req, res) -> { dispatcher.handle(req, res); return null; }, chain.toArray(Middleware[]::new)); - registerResourceMetadata(app, security != null); + registerResourceMetadata(app, secured); } - private Middleware resolveSecurity(FlashContext ctx) { + private McpOidcIntegration.Resolved resolveSecurity(FlashContext ctx) { if (config.security() == McpSecurity.NONE) return null; - Middleware oidcSecurity; + McpOidcIntegration.Resolved resolved; try { - oidcSecurity = McpOidcIntegration.resolve(ctx, config); + resolved = McpOidcIntegration.resolve(ctx, config); } catch (NoClassDefFoundError e) { - oidcSecurity = null; // flash-ext-oidc not on the classpath at all + resolved = null; // flash-ext-oidc not on the classpath at all } - if (oidcSecurity != null) return oidcSecurity; + if (resolved != null) return resolved; if (config.security() == McpSecurity.REQUIRED) { throw new IllegalStateException( @@ -102,17 +100,20 @@ public class McpExtension implements FlashExtension { return null; } - private void registerResourceMetadata(FlashRegistrar app, boolean secured) { - if (!secured) return; - String resourceId = config.resourceIdentifier(); - String issuer = config.authorizationServerIssuer(); - if (resourceId == null || resourceId.isBlank() || issuer == null || issuer.isBlank()) return; - - String body = McpResourceMetadata.build(resourceId, issuer); + /** + * RFC 9728 Protected Resource Metadata, built once security is resolved — no longer + * conditioned on {@code resourceIdentifier}/{@code authorizationServerIssuer} being set + * explicitly, since {@link McpOidcIntegration#resolve} now derives both by default. The + * {@code resource} field is computed per request (it depends on that request's own + * forwarded/{@code Host} headers) via {@link McpOidcIntegration.Resolved#resourceIdentifier()}. + */ + private void registerResourceMetadata(FlashRegistrar app, McpOidcIntegration.Resolved secured) { + if (secured == null) return; String path = "/.well-known/oauth-protected-resource" + config.rootPath(); app.get(path, (req, res) -> { res.type(ContentType.JSON); - return body; + return McpResourceMetadata.build( + secured.resourceIdentifier().apply(req), secured.issuer(), config.scopesSupported()); }); } } diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpOidcIntegration.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpOidcIntegration.java index 3986176..a12a3b4 100644 --- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpOidcIntegration.java +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpOidcIntegration.java @@ -1,45 +1,84 @@ package dev.relism.flash.ext.mcp; +import dev.relism.flash.ext.oidc.Authenticated; import dev.relism.flash.ext.oidc.ClaimsHolder; import dev.relism.flash.ext.oidc.OidcMiddleware; +import dev.relism.flash.ext.oidc.OidcUser; +import dev.relism.flash.ext.oidc.RolesAllowed; +import dev.relism.flash.ext.oidc.ScopesAllowed; import dev.relism.flash.exceptions.HttpException; import dev.relism.flash.extension.FlashContext; +import dev.relism.flash.models.Request; import dev.relism.flash.routing.Middleware; +import lombok.extern.slf4j.Slf4j; +import java.util.LinkedHashSet; import java.util.Map; import java.util.Optional; +import java.util.function.Function; +import java.util.function.Supplier; /** * Lazy, isolated bridge to {@code flash-ext-oidc}. * - *

References to OIDC types only ever resolve when {@link #resolve} is actually invoked — - * never at {@link McpExtension} class-load time — because they live in this separate nested - * class. The caller wraps the invocation in {@code catch (NoClassDefFoundError)}, exactly like - * {@code OidcExtension}'s own lazy bridge to {@code flash-ext-openapi}. This is what lets - * {@code flash-ext-mcp} run standalone (MCP-only, no OAuth2) when {@code flash-ext-oidc} is not - * even on the classpath. + *

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. + * + *

Zero-config by design: when {@code flash-ext-oidc} is installed, everything an MCP OAuth2 + * resource server needs — issuer, canonical resource identifier, RFC 8707 audience binding, and + * a spec-compliant {@code WWW-Authenticate} challenge (RFC 9728 §5.1) — is derived straight from + * the installed {@link OidcMiddleware}, with no additional {@link McpConfig} calls. + * {@link McpConfig#resourceIdentifier(String)}/{@link McpConfig#authorizationServerIssuer(String)} + * remain as explicit overrides for the rare case where that guess is wrong. */ +@Slf4j final class McpOidcIntegration { + private static final String[] NO_VALUES = new String[0]; + private McpOidcIntegration() {} - /** Returns the security {@link Middleware} to apply, or {@code null} if oidc is not installed. */ - static Middleware resolve(FlashContext ctx, McpConfig config) { + /** Everything {@link McpExtension} needs once oidc security is resolved. */ + record Resolved(Middleware security, String issuer, Function resourceIdentifier) {} + + /** Returns the resolved security bundle, or {@code null} if oidc is not installed. */ + static Resolved resolve(FlashContext ctx, McpConfig config) { Optional oidc = ctx.find(OidcMiddleware.class); if (oidc.isEmpty()) return null; - Middleware protect = oidc.get().protect(); - String resourceId = config.resourceIdentifier(); - if (resourceId == null || resourceId.isBlank()) return protect; + OidcMiddleware oidcMw = oidc.get(); + String resourceMetadataPath = "/.well-known/oauth-protected-resource" + config.rootPath(); + String issuer = config.authorizationServerIssuer() != null + ? config.authorizationServerIssuer() : oidcMw.issuer(); + Function resourceId = req -> config.resourceIdentifier() != null + ? config.resourceIdentifier() + : OidcMiddleware.selfOrigin(req, oidcMw.selfScheme()) + config.rootPath(); - return Middleware.of(protect, audienceGuard(resourceId)); + Middleware protect = oidcMw.protect(resourceMetadataPath); + Middleware secured = Middleware.of(protect, audienceGuard(resourceId)); + return new Resolved(secured, issuer, resourceId); } - /** RFC 8707 audience binding: rejects tokens whose {@code aud} claim doesn't include ours. */ - private static Middleware audienceGuard(String resourceIdentifier) { + /** + * RFC 8707 audience binding, unconditionally enforced once oidc is protecting the MCP + * route — no longer opt-in behind an explicit {@code resourceIdentifier(...)} call. + */ + private static Middleware audienceGuard(Function resourceIdentifier) { return next -> (req, res) -> { Map claims = ClaimsHolder.get(); - if (claims != null && !audienceMatches(claims.get("aud"), resourceIdentifier)) { + String expected = resourceIdentifier.apply(req); + if (claims != null && !audienceMatches(claims.get("aud"), expected)) { + log.warn("[flash-ext-mcp] Rejecting token (RFC 8707): aud={} does not include expected " + + "resource identifier \"{}\" — the authorization server must include this exact " + + "value in the access token's aud claim (e.g. an Audience protocol mapper in " + + "Keycloak) for this MCP server to accept it.", claims.get("aud"), expected); throw HttpException.forbidden(); } return next.handle(req, res); @@ -53,4 +92,88 @@ final class McpOidcIntegration { } return false; } + + /** + * Compiles {@code @RolesAllowed}/{@code @ScopesAllowed} on a tool class into a {@link + * McpAuthPolicy}, or returns {@code null} if the tool carries none of the three OIDC + * annotations. Called once per tool at boot ({@link McpRegistry#scan}), never on the + * request hot path — the {@link Supplier} it returns is what runs per {@code tools/call}, + * closing over the already-normalized role/scope arrays so the hot path itself allocates + * nothing beyond what {@link OidcUser#hasRole}/{@link OidcUser#hasScope} already do. + * + *

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 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 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 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); + } } diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpRegistry.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpRegistry.java index d3a0bb7..dc653fe 100644 --- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpRegistry.java +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpRegistry.java @@ -24,7 +24,8 @@ final class McpRegistry { private static final String EMPTY_ARRAY = "[]"; - record RegisteredTool(String name, McpTool instance) {} + /** {@code policy} is {@code null} unless the tool carries @RolesAllowed/@ScopesAllowed. */ + record RegisteredTool(String name, McpTool instance, McpAuthPolicy policy) {} record RegisteredResource(String uri, McpResource instance) {} record RegisteredPrompt(String name, McpPrompt instance) {} @@ -38,7 +39,16 @@ final class McpRegistry { private McpRegistry() {} - static McpRegistry scan(String packageName, FlashContext ctx) { + /** + * @param oidcActive whether this MCP server's route is actually OAuth2-protected right + * now (see {@link McpOidcIntegration#resolve}) — gates whether + * {@code @RolesAllowed}/{@code @ScopesAllowed} on a tool are honored or + * rejected at boot as a misconfiguration; see + * {@link McpOidcIntegration#compileToolPolicy}. + * @param rolesClaimPath claim path forwarded to {@code @RolesAllowed} checks; see + * {@link McpConfig#rolesClaimPath(String)}. + */ + static McpRegistry scan(String packageName, FlashContext ctx, boolean oidcActive, String rolesClaimPath) { McpPackageScanner.ScanResult found = McpPackageScanner.scan(packageName); McpRegistry registry = new McpRegistry(); @@ -46,7 +56,8 @@ final class McpRegistry { Tool ann = cls.getAnnotation(Tool.class); McpTool instance = instantiate(cls); instance.bind(ctx); - if (registry.tools.putIfAbsent(ann.name(), new RegisteredTool(ann.name(), instance)) != null) + McpAuthPolicy policy = compileToolPolicy(cls, oidcActive, rolesClaimPath); + if (registry.tools.putIfAbsent(ann.name(), new RegisteredTool(ann.name(), instance, policy)) != null) throw new InitializationException("Duplicate MCP tool name: \"" + ann.name() + "\""); } for (Class cls : found.resources()) { @@ -163,6 +174,24 @@ final class McpRegistry { gen.writeEndArray(); } + /** + * Isolated the same way {@link McpOidcIntegration#resolve} is — {@code + * NoClassDefFoundError} here means {@code flash-ext-oidc} genuinely isn't on the runtime + * classpath, in which case a tool couldn't have been compiled against + * {@code @RolesAllowed}/{@code @ScopesAllowed} in the first place, so there's nothing to + * check (and nothing lost: {@code oidcActive} is only ever {@code true} once {@link + * McpOidcIntegration#resolve} has already succeeded once this boot, which proves those + * types resolve fine). + */ + private static McpAuthPolicy compileToolPolicy(Class cls, boolean oidcActive, + String rolesClaimPath) { + try { + return McpOidcIntegration.compileToolPolicy(cls, oidcActive, rolesClaimPath); + } catch (NoClassDefFoundError e) { + return null; + } + } + private static T instantiate(Class cls) { try { Constructor ctor = cls.getDeclaredConstructor(); diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpResourceMetadata.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpResourceMetadata.java index c3c9fac..55a7eb9 100644 --- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpResourceMetadata.java +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpResourceMetadata.java @@ -1,17 +1,25 @@ package dev.relism.flash.ext.mcp; +import java.util.List; + /** RFC 9728 OAuth 2.0 Protected Resource Metadata document, built once at boot. */ final class McpResourceMetadata { private McpResourceMetadata() {} - static String build(String resourceIdentifier, String authorizationServerIssuer) { + /** {@code scopesSupported} is optional per RFC 9728 — omitted from the document if empty. */ + static String build(String resourceIdentifier, String authorizationServerIssuer, List 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(); }); } diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/FakeOidcProvider.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/FakeOidcProvider.java index 3b14af1..8f0bc37 100644 --- a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/FakeOidcProvider.java +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/FakeOidcProvider.java @@ -19,6 +19,8 @@ import java.security.interfaces.RSAPrivateKey; import java.security.interfaces.RSAPublicKey; import java.time.Instant; import java.util.Date; +import java.util.List; +import java.util.Map; import java.util.UUID; /** @@ -56,16 +58,27 @@ final class FakeOidcProvider implements AutoCloseable { /** Mints a valid RS256 access token — bearer-validation only, no full authorization-code round-trip needed. */ String signToken(String subject, String audience) { + return signToken(subject, audience, null, NO_ROLES); + } + + /** + * Same as {@link #signToken(String, String)}, plus a {@code scope} claim (space-delimited, + * matching {@link dev.relism.flash.ext.oidc.OidcUser#hasScope}'s default claim path) and a + * Keycloak-shaped {@code realm_access.roles} claim (matching {@code McpConfig}'s default + * {@code rolesClaimPath}) when {@code roles} is non-empty. + */ + String signToken(String subject, String audience, String scope, String... roles) { try { - JWTClaimsSet claims = new JWTClaimsSet.Builder() + JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder() .issuer(issuer) .subject(subject) .audience(audience) .issueTime(Date.from(Instant.now())) - .expirationTime(Date.from(Instant.now().plusSeconds(300))) - .build(); + .expirationTime(Date.from(Instant.now().plusSeconds(300))); + if (scope != null) builder.claim("scope", scope); + if (roles.length > 0) builder.claim("realm_access", Map.of("roles", List.of(roles))); SignedJWT jwt = new SignedJWT( - new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaKey.getKeyID()).build(), claims); + new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaKey.getKeyID()).build(), builder.build()); jwt.sign(new RSASSASigner(rsaKey)); return jwt.serialize(); } catch (Exception e) { @@ -73,6 +86,8 @@ final class FakeOidcProvider implements AutoCloseable { } } + private static final String[] NO_ROLES = new String[0]; + private String discoveryDocument() { return "{" + "\"issuer\":\"" + issuer + "\"," diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpAuthPolicyTest.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpAuthPolicyTest.java new file mode 100644 index 0000000..fb31b9c --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpAuthPolicyTest.java @@ -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 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 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 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 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 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 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(); + } + } +} diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpExtensionSecurityTest.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpExtensionSecurityTest.java index 416e4ed..ee040ad 100644 --- a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpExtensionSecurityTest.java +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpExtensionSecurityTest.java @@ -88,6 +88,69 @@ class McpExtensionSecurityTest { assertTrue(resp.body().contains("\"protocolVersion\"")); } + @Test + void required_withOidc_noExplicitResourceIdentifier_derivesFromRequestAndEnforcesAudience() throws Exception { + int port = bootSecuredApp(null); + String derivedResourceId = "http://127.0.0.1:" + port + "/mcp"; + + String matching = provider.signToken("user-1", derivedResourceId); + assertEquals(200, post(port, initializeBody(), matching).statusCode()); + + String mismatched = provider.signToken("user-1", "https://someone-else.example.com/resource"); + assertEquals(403, post(port, initializeBody(), mismatched).statusCode()); + } + + @Test + void required_withOidc_missingToken_challengeIncludesResourceMetadata() throws Exception { + int port = bootSecuredApp(null); + + HttpResponse 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 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 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 { diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpRegistryTest.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpRegistryTest.java index 20503a1..a3faa29 100644 --- a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpRegistryTest.java +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpRegistryTest.java @@ -16,7 +16,7 @@ class McpRegistryTest { @Test void scan_findsAndPrecompilesToolsResourcesPrompts() throws Exception { - McpRegistry registry = McpRegistry.scan("dev.relism.flash.ext.mcp.fixtures", new FlashContext()); + McpRegistry registry = McpRegistry.scan("dev.relism.flash.ext.mcp.fixtures", new FlashContext(), false, "realm_access.roles"); assertTrue(registry.hasTools()); assertTrue(registry.hasResources()); @@ -47,7 +47,7 @@ class McpRegistryTest { @Test void scan_emptyPackage_throwsInitializationException() { assertThrows(InitializationException.class, - () -> McpRegistry.scan("dev.relism.flash.ext.mcp.doesnotexist", new FlashContext())); + () -> McpRegistry.scan("dev.relism.flash.ext.mcp.doesnotexist", new FlashContext(), false, "realm_access.roles")); } private static JsonNode findByField(JsonNode array, String field, String value) { diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/authenticatedonly/PointlessAuthTool.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/authenticatedonly/PointlessAuthTool.java new file mode 100644 index 0000000..9d9a622 --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/authenticatedonly/PointlessAuthTool.java @@ -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")); + } +} diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/AdminOnlyTool.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/AdminOnlyTool.java new file mode 100644 index 0000000..1bd96f5 --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/AdminOnlyTool.java @@ -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")); + } +} diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/OpenTool.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/OpenTool.java new file mode 100644 index 0000000..8040710 --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/OpenTool.java @@ -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")); + } +} diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/WriteScopeTool.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/WriteScopeTool.java new file mode 100644 index 0000000..14b390f --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/WriteScopeTool.java @@ -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")); + } +} diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcExtension.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcExtension.java index f7f59a8..10502f7 100644 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcExtension.java +++ b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcExtension.java @@ -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.*; * } */ 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) { diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcMiddleware.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcMiddleware.java index 78ef6c8..6afe313 100644 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcMiddleware.java +++ b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcMiddleware.java @@ -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 claims = resolve(req, res); + Map 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 resolve(Request req, Response res) { + return resolve(req, res, null); + } + + private Map 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(); diff --git a/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcOpenApiInteropTest.java b/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcOpenApiInteropTest.java index d84bcf3..41b8bdf 100644 --- a/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcOpenApiInteropTest.java +++ b/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcOpenApiInteropTest.java @@ -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"); diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiExtension.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiExtension.java index 92a1bb5..aff5f09 100644 --- a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiExtension.java +++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiExtension.java @@ -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) { - ObjectMapper jsonMapper = ctx.find(ObjectMapper.class).orElseGet(() -> JsonMapper.builder().build()); - YAMLMapper yamlMapper = new YAMLMapper(); - OpenApiBuilder builder = ctx.require(OpenApiBuilder.class); + ctx.onReady(() -> { + ObjectMapper jsonMapper = ctx.find(ObjectMapper.class).orElseGet(() -> JsonMapper.builder().build()); + YAMLMapper yamlMapper = new YAMLMapper(); + 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(swaggerPath, (req, res) -> { res.type(ContentType.TEXT_HTML); return swaggerHtml; }); + 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 ─────────────────────────────────────────────────────── diff --git a/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiExtensionTest.java b/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiExtensionTest.java index a92ddc2..b5e4fbd 100644 --- a/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiExtensionTest.java +++ b/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiExtensionTest.java @@ -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 spec = builder.build(); Map 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 spec = builder.build(); Map paths = cast(spec.get("paths")); @@ -150,7 +150,7 @@ class OpenApiExtensionTest { private static final class TestRegistrar extends FlashRegistrar { private final FlashContext ctx; private final Map routes = new HashMap<>(); - private final List middlewares = new ArrayList<>(); + private final List 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 mw) { + protected void addRoute(HttpMethod method, String path, RequestHandler handler, List 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); } diff --git a/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/RouteViewerExtension.java b/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/RouteViewerExtension.java index 9630519..5e728ee 100644 --- a/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/RouteViewerExtension.java +++ b/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/RouteViewerExtension.java @@ -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) { - 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); + 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); + }); } } diff --git a/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/BaseViewExtension.java b/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/BaseViewExtension.java index 1d83b9c..356d222 100644 --- a/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/BaseViewExtension.java +++ b/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/BaseViewExtension.java @@ -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 implements FlashExtension { } @Override - public final void provide(FlashContext ctx) { + public void configure(FlashRegistrar app, FlashContext ctx) { ViewRuntimeBridge runtime = createRuntime(List.copyOf(globals)); ctx.provide(ViewRuntimeBridge.class, runtime); ctx.addAnnotationProcessor(handlerClass -> { diff --git a/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteExtension.java b/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteExtension.java index d7cbf7f..832bac2 100644 --- a/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteExtension.java +++ b/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteExtension.java @@ -57,15 +57,17 @@ public final class JteExtension extends BaseViewExtension { } @Override - public void routes(FlashRegistrar app, FlashContext ctx) { - 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; }); + 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 diff --git a/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/flash/ext/view/jte/JteExtensionTest.java b/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/flash/ext/view/jte/JteExtensionTest.java index f3997f5..fc774d5 100644 --- a/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/flash/ext/view/jte/JteExtensionTest.java +++ b/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/flash/ext/view/jte/JteExtensionTest.java @@ -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 { private final Map routes = new HashMap<>(); - private final List mws = new ArrayList<>(); + private final List 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 mw) { + protected void addRoute(dev.relism.flash.http.HttpMethod method, String path, RequestHandler handler, List 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); } } diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerExtension.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerExtension.java index b92ed76..3fc1c7c 100644 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerExtension.java +++ b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerExtension.java @@ -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()); @@ -74,10 +68,7 @@ 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); boolean orchestrated = runtime.environment() == RuntimeEnvironment.DEV && config.frontendType().requiresOrchestration(); if (orchestrated || config.operationMode() == OperationMode.ORCHESTRATE_ONLY) { @@ -101,6 +92,7 @@ public final class WebBundlerExtension implements FlashExtension { res.body(new byte[0]); return null; }); + }); } private void bootstrapDev( diff --git a/flash/src/main/java/dev/relism/flash/extension/AnnotationProcessor.java b/flash/src/main/java/dev/relism/flash/extension/AnnotationProcessor.java index d33ce4d..b5ddc26 100644 --- a/flash/src/main/java/dev/relism/flash/extension/AnnotationProcessor.java +++ b/flash/src/main/java/dev/relism/flash/extension/AnnotationProcessor.java @@ -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 process(Class handlerClass); + List process(Class handlerClass); } diff --git a/flash/src/main/java/dev/relism/flash/extension/ExtensionPhase.java b/flash/src/main/java/dev/relism/flash/extension/ExtensionPhase.java deleted file mode 100644 index 39e5e07..0000000 --- a/flash/src/main/java/dev/relism/flash/extension/ExtensionPhase.java +++ /dev/null @@ -1,32 +0,0 @@ -package dev.relism.flash.extension; - -/** - * Semantic execution phases for {@link FlashExtension#priority()}. - * - *

Phase determines the order in which annotation-processor middlewares are injected into - * the chain. Lower value = runs earlier (outermost wrapper = first at request time). - * - *

- * Request ──► EARLY middlewares ──► DEFAULT middlewares ──► LATE middlewares ──► handler
- * 
- * - *

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; } -} diff --git a/flash/src/main/java/dev/relism/flash/extension/FlashApp.java b/flash/src/main/java/dev/relism/flash/extension/FlashApp.java index dfe4673..61a83a8 100644 --- a/flash/src/main/java/dev/relism/flash/extension/FlashApp.java +++ b/flash/src/main/java/dev/relism/flash/extension/FlashApp.java @@ -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; * *

Startup sequence (both {@link #start()} and {@link #startAndBlock()})

*
    - *
  1. Extensions sorted by {@link FlashExtension#priority()} — lower first.
  2. - *
  3. All {@link FlashExtension#provide} — register services, processors, listeners.
  4. + *
  5. All {@link FlashExtension#configure} declarations.
  6. *
  7. {@link FlashContext#resolveAll()} — topo-sort, cycle detection.
  8. - *
  9. All {@link FlashExtension#routes} — routes registered, services available.
  10. + *
  11. Ready callbacks register routes with resolved services.
  12. *
  13. Compile all routes into one flat FSM — zero prefix scanning at runtime.
  14. *
  15. Accept loop started.
  16. *
@@ -62,7 +62,7 @@ public final class FlashApp extends FlashRegistrar { private final ServerHandle server; private final FlashContext ctx = new FlashContext(); private final List extensions = new ArrayList<>(); - private final List globalMiddlewares = new ArrayList<>(); + private final List globalMiddlewares = new ArrayList<>(); private final List deferredRoutes = new ArrayList<>(); private final List deferredWsRoutes = new ArrayList<>(); @@ -131,10 +131,7 @@ public final class FlashApp extends FlashRegistrar { // ── 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 { // ── FlashRegistrar impl ─────────────────────────────────────────────────── @Override - protected void addRoute(HttpMethod method, String path, RequestHandler handler, List mw) { + protected void addRoute(HttpMethod method, String path, RequestHandler handler, List 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 { } @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 injected; + List 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 { } 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 { listeners.forEach(l -> l.onRoute(event)); } - private static Middleware[] concat(List global, List scope, - List injected, List explicit) { + private static List concat(List global, List scope, + List injected, List 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 all = new ArrayList<>(total); + all.addAll(global); all.addAll(scope); all.addAll(injected); all.addAll(explicit); return all; } } diff --git a/flash/src/main/java/dev/relism/flash/extension/FlashContext.java b/flash/src/main/java/dev/relism/flash/extension/FlashContext.java index ed55a30..36bed75 100644 --- a/flash/src/main/java/dev/relism/flash/extension/FlashContext.java +++ b/flash/src/main/java/dev/relism/flash/extension/FlashContext.java @@ -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. - * - *

Every handler, extension, and scope shares one (or a child of one) {@code FlashContext}. - * Three capabilities: - *

    - *
  1. Service registry — {@link #provide}/{@link #supply}/{@link #require}/{@link #find}.
  2. - *
  3. Annotation processors — middleware injection from handler annotations at boot.
  4. - *
  5. Route listeners — boot-time observation of the route graph.
  6. - *
- * - *

Eager vs lazy registration

- *
    - *
  • {@link #provide(Class, Object)} — instance is already constructed, registered immediately.
  • - *
  • {@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.
  • - *
- * - *

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, Object> registry = new LinkedHashMap<>(); - private final Map, Supplier> pending = new LinkedHashMap<>(); - private final List processors = new ArrayList<>(); - private final List routeListeners = new ArrayList<>(); - - // Lazy caches — nulled whenever the corresponding list is mutated. + private final Map, Binding> bindings = new LinkedHashMap<>(); + private final List processors = new ArrayList<>(); + private final List routeListeners = new ArrayList<>(); + private final List readyCallbacks = new ArrayList<>(); + private final List children = new ArrayList<>(); + private final Deque> resolutionPath = new ArrayDeque<>(); + private State state = State.DECLARING; private List cachedProcessors; - private List cachedListeners; + private List cachedListeners; - // DFS stack — tracks in-progress resolutions to detect circular dependencies. - private final LinkedHashSet> 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 void provide(Class 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. - * - *

{@code
-     * ctx.supply(JwtValidator.class, () ->
-     *     new JwtValidator(ctx.require(OidcProviderMetadata.class).jwksUri()));
-     * }
- */ + /** Declares a no-dependency boot factory. */ public void supply(Class type, Supplier 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 void supply(Class type, ServiceFactory 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 void supply(Class type, Class dependency, Function 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 require(Class 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) 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 Optional find(Class 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 Optional optional(Class 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 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> 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 processors() { if (cachedProcessors != null) return cachedProcessors; - if (parent == null) return cachedProcessors = List.copyOf(processors); - List p = parent.processors(); - if (processors.isEmpty()) return cachedProcessors = p; - List merged = new ArrayList<>(p.size() + processors.size()); - merged.addAll(p); - merged.addAll(processors); - return cachedProcessors = List.copyOf(merged); + List 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 routeListeners() { if (cachedListeners != null) return cachedListeners; - if (parent == null) return cachedListeners = List.copyOf(routeListeners); - List p = parent.routeListeners(); - if (routeListeners.isEmpty()) return cachedListeners = p; - List merged = new ArrayList<>(p.size() + routeListeners.size()); - merged.addAll(p); - merged.addAll(routeListeners); - return cachedListeners = List.copyOf(merged); + List all = parent == null ? new ArrayList<>() : new ArrayList<>(parent.routeListeners()); + all.addAll(routeListeners); return cachedListeners = List.copyOf(all); } -} \ No newline at end of file + + 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 void declare(Class type, Binding 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) binding); } + private T resolve(Binding 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 create(FlashContext services); } + private static final class Binding { + final Class type; final List> dependencies; final ServiceFactory factory; + T instance; boolean resolving; + Binding(Class type, List> dependencies, ServiceFactory factory) { + this.type = type; this.dependencies = dependencies; this.factory = factory; + } + } +} diff --git a/flash/src/main/java/dev/relism/flash/extension/FlashExtension.java b/flash/src/main/java/dev/relism/flash/extension/FlashExtension.java index e4765a2..d37d45a 100644 --- a/flash/src/main/java/dev/relism/flash/extension/FlashExtension.java +++ b/flash/src/main/java/dev/relism/flash/extension/FlashExtension.java @@ -1,70 +1,17 @@ package dev.relism.flash.extension; /** - * Two-phase contract for all Flash extensions. + * One declarative contribution to a Flash application. * - *

Extension lifecycle inside {@link FlashApp#start()}: - *

    - *
  1. Extensions are sorted by {@link #priority()} — lower value runs first.
  2. - *
  3. Provide phase — {@link #provide(FlashContext)} is called for all - * installed extensions. Use this phase to register services, annotation processors, - * and route listeners. Never call {@link FlashContext#require} here.
  4. - *
  5. 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.
  6. - *
  7. Routes phase — {@link #routes(FlashRegistrar, FlashContext)} is called for - * all extensions. All services are resolved; {@link FlashContext#require} is safe.
  8. - *
- * - *

Priority and middleware ordering

- * {@link #priority()} controls the order annotation processors are registered, which - * determines the annotation-layer middleware chain position: - *
- * Request ──► EARLY processors' mw ──► DEFAULT processors' mw ──► LATE processors' mw ──► handler
- * 
- * Use {@link ExtensionPhase} constants for semantic ordering: - *
{@code
- * @Override public int priority() { return ExtensionPhase.EARLY.value; }
- * }
- * - *

Example

- *
{@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());
- *     }
- * }
- * }
+ *

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); } diff --git a/flash/src/main/java/dev/relism/flash/extension/FlashRegistrar.java b/flash/src/main/java/dev/relism/flash/extension/FlashRegistrar.java index 0818bb3..bd83f87 100644 --- a/flash/src/main/java/dev/relism/flash/extension/FlashRegistrar.java +++ b/flash/src/main/java/dev/relism/flash/extension/FlashRegistrar.java @@ -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) * } * - *

{@link FlashExtension#routes} receives a {@code FlashRegistrar} for route - * registration. Extension installation ({@code install()}) is only available on + *

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 concrete registrar type — enables fluent chaining without casting */ @SuppressWarnings("unchecked") public abstract class FlashRegistrar> { + private static final AtomicLong INLINE_KEYS = new AtomicLong(); // ── HTTP method registration ────────────────────────────────────────────── @@ -47,6 +51,18 @@ public abstract class FlashRegistrar> { 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> { * 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> { * Subclasses may prepend a namespace prefix and inject scope middlewares before storing. */ protected abstract void addRoute(HttpMethod method, String path, - RequestHandler handler, List mw); + RequestHandler handler, List 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) { diff --git a/flash/src/main/java/dev/relism/flash/extension/FlashScope.java b/flash/src/main/java/dev/relism/flash/extension/FlashScope.java index 071142b..f3a117a 100644 --- a/flash/src/main/java/dev/relism/flash/extension/FlashScope.java +++ b/flash/src/main/java/dev/relism/flash/extension/FlashScope.java @@ -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 { private final String namespace; private final FlashContext ctx; - private final List scopeMiddlewares = new ArrayList<>(); + private final List scopeMiddlewares = new ArrayList<>(); private final List deferredRoutes = new ArrayList<>(); private final List deferredWsRoutes = new ArrayList<>(); @@ -44,8 +44,8 @@ public final class FlashScope extends FlashRegistrar { // ── FlashRegistrar impl ─────────────────────────────────────────────────── @Override - protected void addRoute(HttpMethod method, String path, RequestHandler handler, List mw) { - List scopeMw = scopeMiddlewares.isEmpty() ? List.of() : List.copyOf(scopeMiddlewares); + protected void addRoute(HttpMethod method, String path, RequestHandler handler, List mw) { + List 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 { } @Override - protected void addMiddleware(Middleware mw) { scopeMiddlewares.add(mw); } + protected void addMiddleware(MiddlewareNode mw) { scopeMiddlewares.add(mw); } // ── Internal (called by FlashApp.mount) ─────────────────────────────────── diff --git a/flash/src/main/java/dev/relism/flash/extension/RouteDefinition.java b/flash/src/main/java/dev/relism/flash/extension/RouteDefinition.java index 90db000..11527ca 100644 --- a/flash/src/main/java/dev/relism/flash/extension/RouteDefinition.java +++ b/flash/src/main/java/dev/relism/flash/extension/RouteDefinition.java @@ -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 scopeMiddlewares, - List explicitMiddlewares, + List scopeMiddlewares, + List explicitMiddlewares, boolean classBasedHandler, FlashContext ctx, String namespace diff --git a/flash/src/main/java/dev/relism/flash/routing/AbstractRouter.java b/flash/src/main/java/dev/relism/flash/routing/AbstractRouter.java index a54db16..1ccd6d9 100644 --- a/flash/src/main/java/dev/relism/flash/routing/AbstractRouter.java +++ b/flash/src/main/java/dev/relism/flash/routing/AbstractRouter.java @@ -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); diff --git a/flash/src/main/java/dev/relism/flash/routing/AbstractWsRouter.java b/flash/src/main/java/dev/relism/flash/routing/AbstractWsRouter.java index a4bb146..6b78755 100644 --- a/flash/src/main/java/dev/relism/flash/routing/AbstractWsRouter.java +++ b/flash/src/main/java/dev/relism/flash/routing/AbstractWsRouter.java @@ -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); } diff --git a/flash/src/main/java/dev/relism/flash/routing/MiddlewareGraph.java b/flash/src/main/java/dev/relism/flash/routing/MiddlewareGraph.java new file mode 100644 index 0000000..99f887d --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/routing/MiddlewareGraph.java @@ -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 nodes) { + if (nodes.isEmpty()) return new Middleware[0]; + Map 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> 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 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; + } +} diff --git a/flash/src/main/java/dev/relism/flash/routing/MiddlewareKey.java b/flash/src/main/java/dev/relism/flash/routing/MiddlewareKey.java new file mode 100644 index 0000000..e85cc4e --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/routing/MiddlewareKey.java @@ -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); } +} diff --git a/flash/src/main/java/dev/relism/flash/routing/MiddlewareNode.java b/flash/src/main/java/dev/relism/flash/routing/MiddlewareNode.java new file mode 100644 index 0000000..a05b3c0 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/routing/MiddlewareNode.java @@ -0,0 +1,35 @@ +package dev.relism.flash.routing; + +import java.util.*; + +/** + * A named middleware contribution and its ordering constraints. + * + *

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 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 constraints() { return List.copyOf(constraints); } + + enum Relation { BEFORE, AFTER } + record Constraint(MiddlewareKey target, Relation relation, boolean required) { + Constraint { Objects.requireNonNull(target, "target"); } + } +} diff --git a/flash/src/main/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathRouterImpl.java b/flash/src/main/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathRouterImpl.java index 7697a9e..6041521 100644 --- a/flash/src/main/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathRouterImpl.java +++ b/flash/src/main/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathRouterImpl.java @@ -92,4 +92,7 @@ public class FastPathRouterImpl extends AbstractRouter { } } } + + @Override + public void compile() { ensureCompiled(); } } diff --git a/flash/src/main/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathWsRouterImpl.java b/flash/src/main/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathWsRouterImpl.java index fcd7442..339fe4d 100644 --- a/flash/src/main/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathWsRouterImpl.java +++ b/flash/src/main/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathWsRouterImpl.java @@ -56,6 +56,9 @@ public final class FastPathWsRouterImpl extends AbstractWsRouter { } } + @Override + public void compile() { ensureCompiled(); } + private static final class Context { private static final ThreadLocal> RESULT = ThreadLocal.withInitial(() -> new MatchResult<>(32, 128)); diff --git a/flash/src/test/java/dev/relism/flash/extension/FlashContextTest.java b/flash/src/test/java/dev/relism/flash/extension/FlashContextTest.java new file mode 100644 index 0000000..ff1662e --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/extension/FlashContextTest.java @@ -0,0 +1,46 @@ +package dev.relism.flash.extension; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class FlashContextTest { + + @Test + void resolvesDeclaredGraphRegardlessOfDeclarationOrder() { + FlashContext ctx = new FlashContext(); + ctx.supply(Service.class, Dependency.class, Service::new); + ctx.provide(Dependency.class, new Dependency()); + + ctx.resolveAll(); + + assertNotNull(ctx.require(Service.class).dependency); + } + + @Test + void rejectsCircularDeclaredGraphBeforeReadyCallbacks() { + FlashContext ctx = new FlashContext(); + ctx.supply(Left.class, services -> new Left(), Right.class); + ctx.supply(Right.class, services -> new Right(), Left.class); + + IllegalStateException error = assertThrows(IllegalStateException.class, ctx::resolveAll); + + assertEquals("Circular service dependency: Left -> Right -> Left", error.getMessage()); + } + + @Test + void rejectsAFactoryLookupThatWasNotDeclared() { + FlashContext ctx = new FlashContext(); + ctx.provide(Dependency.class, new Dependency()); + ctx.supply(Service.class, services -> new Service(services.require(Dependency.class))); + + IllegalStateException error = assertThrows(IllegalStateException.class, ctx::resolveAll); + + assertTrue(error.getMessage().contains("undeclared dependency")); + } + + private static final class Dependency {} + private static final class Service { final Dependency dependency; Service(Dependency dependency) { this.dependency = dependency; } } + private static final class Left {} + private static final class Right {} +} diff --git a/flash/src/test/java/dev/relism/flash/routing/MiddlewareGraphTest.java b/flash/src/test/java/dev/relism/flash/routing/MiddlewareGraphTest.java new file mode 100644 index 0000000..7d74b79 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/routing/MiddlewareGraphTest.java @@ -0,0 +1,42 @@ +package dev.relism.flash.routing; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class MiddlewareGraphTest { + private static final Middleware NOOP = next -> next::handle; + + @Test + void ordersNodesFromConstraintsNotRegistrationOrder() { + MiddlewareKey auth = MiddlewareKey.of("auth"); + MiddlewareKey audit = MiddlewareKey.of("audit"); + Middleware authMiddleware = next -> next::handle; + Middleware auditMiddleware = next -> next::handle; + MiddlewareNode auditNode = MiddlewareNode.of(audit, auditMiddleware).after(auth); + MiddlewareNode authNode = MiddlewareNode.of(auth, authMiddleware); + + Middleware[] ordered = MiddlewareGraph.order("GET /", List.of(auditNode, authNode)); + + assertSame(authMiddleware, ordered[0]); + assertSame(auditMiddleware, ordered[1]); + } + + @Test + void rejectsAbsentRequiredPredecessor() { + MiddlewareNode audit = MiddlewareNode.of(MiddlewareKey.of("audit"), NOOP) + .after(MiddlewareKey.of("auth")); + + assertThrows(IllegalStateException.class, () -> MiddlewareGraph.order("GET /", List.of(audit))); + } + + @Test + void acceptsAbsentOptionalPredecessor() { + MiddlewareNode audit = MiddlewareNode.of(MiddlewareKey.of("audit"), NOOP) + .afterIfPresent(MiddlewareKey.of("auth")); + + assertEquals(1, MiddlewareGraph.order("GET /", List.of(audit)).length); + } +} -- 2.54.0