feat(ext-mcp): add MCP (Model Context Protocol) server extension
CI / Build & Test (push) Failing after 4m57s
CI / Build & Test (push) Failing after 4m57s
Streamable HTTP transport (JSON-RPC 2.0 over POST), one-class-per-tool/resource/prompt API mirroring RequestHandler, boot-time-precompiled schema/list payloads for a zero-alloc hot path, and optional OAuth2 protection built on flash-ext-oidc (lazy-loaded, RFC 8707 audience binding, RFC 9728 Protected Resource Metadata). Registers the module in the root and flash-extensions POMs and adds the ext-mcp commit scope to AGENTS.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
8ece9975de
commit
d7f36a7aea
@@ -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`
|
||||
@@ -0,0 +1,51 @@
|
||||
# Why no `flash-ext-jackson` interop (yet)
|
||||
|
||||
## The decision
|
||||
|
||||
`flash-ext-mcp` does not depend on, or integrate with, `flash-ext-jackson`. It brings its own
|
||||
JSON handling (`jackson-databind`/`jackson-core` as a plain library dependency, wrapped by the
|
||||
internal `McpJson` utility) and never touches `flash-ext-jackson`'s `Json`/`JacksonMiddleware`/
|
||||
shared `ObjectMapper`, even if the host app has `flash-ext-jackson` installed. This was a
|
||||
deliberate choice, discussed and made explicitly — not an oversight — and is written down here
|
||||
so it isn't accidentally "fixed" later without re-litigating the trade-off.
|
||||
|
||||
## Why
|
||||
|
||||
`flash-ext-jackson`'s `Json` class is built around full databinding:
|
||||
`mapper.readValue(bytes, SomeDto.class)` / `mapper.writeValueAsBytes(obj)` — reflection-driven
|
||||
property matching in both directions. The MCP JSON-RPC envelope has a **fixed, known shape**
|
||||
(`{jsonrpc, id, method, params}` in, `{jsonrpc, id, result|error}` out) defined by a spec, not by
|
||||
application DTOs. Given that, hand-writing it with `JsonGenerator` directly is both simpler and
|
||||
strictly cheaper than round-tripping through databinding: no property-name matching, no
|
||||
reflection, no intermediate POJO graph for the parts of the response this extension controls
|
||||
(the envelope itself, `tools/list`/`resources/list`/`prompts/list` — precompiled once at boot,
|
||||
see `tools-resources-prompts.md` — and the fixed `TextContent`/`TextResourceContents`/
|
||||
`PromptMessage` shapes). `ToolArguments`/`PromptArguments` read the incoming `arguments` object
|
||||
as a `JsonNode` tree, not as a databound class, for the same reason — a JSON-RPC tool call's
|
||||
arguments aren't a DTO with getters/setters, they're a dynamic, per-tool-defined bag of values.
|
||||
|
||||
This mirrors how `flash-ext-oidc` already handles its own internal JSON needs (`json-smart` for
|
||||
token-endpoint responses) independently of `flash-ext-jackson` — extensions with protocol-level
|
||||
JSON needs that are shaped by a spec, not by user code, own that JSON handling themselves rather
|
||||
than routing it through the app's general-purpose JSON extension.
|
||||
|
||||
## What this means practically
|
||||
|
||||
- Installing `flash-ext-mcp` never requires installing `flash-ext-jackson`. A pure MCP server
|
||||
with no other JSON REST routes has zero unrelated dependencies to configure.
|
||||
- If the host app *does* have `flash-ext-jackson` installed for its own REST routes, that
|
||||
`ObjectMapper`'s configuration (custom modules, date formatting, naming strategy, etc.) is
|
||||
**not** consulted by `flash-ext-mcp` — the two JSON paths are entirely independent today.
|
||||
|
||||
## What a future opt-in reuse could look like
|
||||
|
||||
Nothing here rules out a later, additive convenience layer: `McpExtension.routes()` could check
|
||||
`ctx.find(ObjectMapper.class)` (populated by `JacksonExtension.provide()`) and, if present, use
|
||||
that shared mapper as the backing for an escape hatch such as `ToolArguments.as(Class<T>)` or
|
||||
for a tool that wants to `ToolResponse.success(someRecord)` and have it serialized with the
|
||||
app's own conventions — falling back to a locally-constructed default `ObjectMapper` when
|
||||
`flash-ext-jackson` isn't installed, the same "prefer shared, degrade to sane default" shape
|
||||
already used for `McpSecurity.AUTO`. That would be purely additive on top of the
|
||||
`JsonGenerator`-based envelope/content writing described above, not a replacement for it — the
|
||||
fixed-shape protocol plumbing has no reason to ever go through databinding, regardless of what
|
||||
convenience layer gets added around it.
|
||||
@@ -0,0 +1,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 `<optional>true</optional>` only affects **transitive** propagation: consumers of
|
||||
`flash-ext-mcp` don't get `flash-ext-oidc` pulled in automatically unless they add it themselves.
|
||||
Within `flash-ext-mcp` itself, `flash-ext-oidc`'s classes are on the compile/test classpath as
|
||||
normal — this extension can (and does) reference `OidcMiddleware`/`ClaimsHolder` directly in
|
||||
source.
|
||||
|
||||
That reference is isolated in its own class, `McpOidcIntegration`, invoked only from inside a
|
||||
`catch (NoClassDefFoundError)` block. A bare class-literal like `OidcMiddleware.class` (which
|
||||
`ctx.find(OidcMiddleware.class)` needs) forces the JVM to resolve that type the moment it's
|
||||
evaluated — if `flash-ext-oidc` is not on the *runtime* classpath at all (a genuinely
|
||||
MCP-only install, no OAuth2 anywhere in the app), the first such reference throws
|
||||
`NoClassDefFoundError`. Keeping that reference inside a separate, lazily-loaded class means
|
||||
`McpExtension` itself loads and works fine standalone; only the attempt to actually use OIDC
|
||||
fails, and only when there's something to fail. This mirrors `OidcExtension`'s own lazy bridge to
|
||||
`flash-ext-openapi` — same technique, same reason.
|
||||
|
||||
## OAuth2 resolution details
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,107 @@
|
||||
# Tools, Resources, Prompts
|
||||
|
||||
## One class per feature
|
||||
|
||||
Every tool, resource, and prompt is its own class — the same shape as a Flash `RequestHandler`,
|
||||
minus the HTTP-specific bits:
|
||||
|
||||
```java
|
||||
public abstract class McpTool {
|
||||
protected void onInit() {} // cache services here, once, at boot
|
||||
protected <T> T require(Class<T> type) { ... } // FlashContext lookup
|
||||
public abstract ToolResponse call(ToolArguments args) throws Exception; // hot path
|
||||
}
|
||||
```
|
||||
|
||||
`McpResource` (`read()`) and `McpPrompt` (`render(PromptArguments)`) follow the exact same
|
||||
shape. There is deliberately no CDI-style `@Inject` and no method-per-tool bean class — Flash5
|
||||
handlers are classes, and MCP features follow that convention.
|
||||
|
||||
## Declaring metadata
|
||||
|
||||
Metadata (name, description, input schema) lives entirely in the annotation, not in reflected
|
||||
method signatures — the whole JSON Schema is known at scan time and compiled once:
|
||||
|
||||
```java
|
||||
@Tool(
|
||||
name = "get_weather",
|
||||
description = "Get current weather for a city",
|
||||
args = {
|
||||
@ToolArg(name = "city", description = "City name", required = true),
|
||||
@ToolArg(name = "days", type = ToolArgType.INTEGER, description = "Forecast horizon")
|
||||
}
|
||||
)
|
||||
public class GetWeatherTool extends McpTool {
|
||||
@Override
|
||||
public ToolResponse call(ToolArguments args) {
|
||||
String city = args.getString("city");
|
||||
int days = args.getInt("days", 1);
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`ToolArgType` maps directly to JSON Schema primitive types: `STRING`, `INTEGER`, `NUMBER`,
|
||||
`BOOLEAN`, `OBJECT`, `ARRAY`. Nested object/array schemas beyond the primitive type keyword are
|
||||
not modeled in this revision — declare those tools with a looser `OBJECT`/`ARRAY` type and parse
|
||||
the raw shape via `ToolArguments.raw(name)`.
|
||||
|
||||
`ToolArguments`/`PromptArguments` are thin typed accessors over the already-parsed JSON — no
|
||||
databinding, no reflection, no intermediate DTO:
|
||||
|
||||
```java
|
||||
args.getString("city");
|
||||
args.getInt("days", 1);
|
||||
args.getBoolean("metric", true);
|
||||
args.raw("filters"); // escape hatch: JsonNode for nested/array arguments
|
||||
```
|
||||
|
||||
## Discovery
|
||||
|
||||
`McpConfig.toolsPackage("com.example.tools")` scans that package (and subpackages) for concrete
|
||||
`McpTool`/`McpResource`/`McpPrompt` subclasses carrying `@Tool`/`@Resource`/`@Prompt`. Same
|
||||
fail-fast contract as `FlashApp.scan()`: missing package, missing no-arg constructor, or a class
|
||||
that fails to load aborts startup immediately with a clear message. Duplicate names/URIs also
|
||||
fail fast at boot.
|
||||
|
||||
## Resources and Prompts
|
||||
|
||||
```java
|
||||
@Resource(uri = "config://app-settings", description = "Application settings", mimeType = "application/json")
|
||||
public class AppSettingsResource extends McpResource {
|
||||
@Override
|
||||
public ResourceContents read() {
|
||||
return TextResourceContents.of(uri(), "application/json", settingsJson());
|
||||
}
|
||||
}
|
||||
|
||||
@Prompt(name = "summarize", args = @PromptArg(name = "text", required = true))
|
||||
public class SummarizePrompt extends McpPrompt {
|
||||
@Override
|
||||
public PromptMessage render(PromptArguments args) {
|
||||
return PromptMessage.withUserRole(new TextContent("Summarize: " + args.getString("text")));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`McpResource.uri()` returns the URI declared on `@Resource`, cached at bind time — no repeated
|
||||
annotation lookups on the hot path.
|
||||
|
||||
## Content types
|
||||
|
||||
`Content` and `ResourceContents` are `sealed`, currently permitting only `TextContent` and
|
||||
`TextResourceContents` respectively. This is a deliberate v1 scope cut, not an oversight — image
|
||||
content, embedded resources, and blob resources are extension points for a future revision
|
||||
(extend the `permits` clause and `McpContentWriter`).
|
||||
|
||||
## Tool failures vs. protocol errors
|
||||
|
||||
A `McpTool.call(...)` that throws is caught by the dispatcher and turned into
|
||||
`ToolResponse.error(message)` — per the MCP specification this is a normal JSON-RPC *result*
|
||||
with `isError: true`, not a JSON-RPC error, so the calling model can see and react to it. Prefer
|
||||
returning `ToolResponse.error(...)` explicitly when you can produce a better message than the
|
||||
raw exception text.
|
||||
|
||||
`McpResource.read()`/`McpPrompt.render(...)` failures, by contrast, surface as JSON-RPC errors
|
||||
(`-32603 Internal error`) — the specification does not define a soft-failure content convention
|
||||
for those two.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Transport
|
||||
|
||||
`flash-ext-mcp` implements the **Streamable HTTP** transport from the MCP specification
|
||||
(revision `2025-11-25`). `stdio` is out of scope — Flash5 is an HTTP framework, and a
|
||||
subprocess-stdio transport doesn't fit its model.
|
||||
|
||||
## What this revision implements
|
||||
|
||||
- A single `POST {rootPath}` endpoint (default `/mcp`) accepting one JSON-RPC 2.0 message per
|
||||
request and responding with a plain JSON object — the "standard JSON object response" mode the
|
||||
specification allows as an alternative to opening a Server-Sent Events stream per request.
|
||||
- `Origin` header validation (DNS-rebinding protection), configurable via
|
||||
`McpConfig.allowedOrigins(...)`.
|
||||
- Full JSON-RPC lifecycle: `initialize`, `notifications/initialized` (and any other
|
||||
`notifications/*`/id-less message — answered with a bare `202 Accepted`, no body, per
|
||||
JSON-RPC's notification semantics), `ping`, `tools/list`, `tools/call`, `resources/list`,
|
||||
`resources/read`, `prompts/list`, `prompts/get`.
|
||||
|
||||
## What this revision deliberately does not implement
|
||||
|
||||
- **No `Mcp-Session-Id` / session state.** The specification says a server "MAY assign a session
|
||||
ID at initialization time" — it is optional, not mandatory. This server is stateless: every
|
||||
`POST` is handled independently, with no server-side session store. `initialize` does not need
|
||||
to precede other calls for the server to function (there's no session to be "not initialized"
|
||||
yet), which is a looser contract than a session-aware server would enforce — acceptable for a
|
||||
static, boot-time-defined tool/resource/prompt catalog.
|
||||
- **No Server-Sent Events stream.** `GET {rootPath}` (used by session-aware servers to open a
|
||||
standing SSE stream for server-initiated pushes) is not registered — MCP clients that only
|
||||
speak the request/response half of Streamable HTTP work unaffected; clients that require a
|
||||
standing SSE connection are not supported by this revision.
|
||||
|
||||
Both are real, intentional scope cuts for a first version — not just to keep the surface area
|
||||
small: a static, precompiled tool catalog (see `tools-resources-prompts.md`) has no
|
||||
`listChanged` events to push and no long-running server-initiated messages to stream, so the
|
||||
stateful half of the transport buys little for the common case this extension targets. Sessions
|
||||
and SSE are natural extension points if a future revision needs server push (e.g. dynamic tool
|
||||
registration, elicitation, or sampling requests initiated by the server).
|
||||
|
||||
## Why `POST`, not the new `QUERY` HTTP method
|
||||
|
||||
Flash5's core recently gained `HttpMethod.QUERY` (safe, idempotent, carries a body — a good
|
||||
semantic fit for JSON-RPC-over-HTTP in general). It is **not** used here: the MCP Streamable
|
||||
HTTP specification mandates `POST` for the client-to-server message path. Real MCP clients send
|
||||
`POST`; using `QUERY` instead would break interoperability with every existing client for a
|
||||
semantic nicety this extension doesn't need standalone.
|
||||
Reference in New Issue
Block a user