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.
|
||||
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-extensions</artifactId>
|
||||
<version>2.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-ext-mcp</artifactId>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-oidc</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,8 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
/**
|
||||
* MCP tool/prompt content block. {@code sealed} to the variants this extension currently
|
||||
* writes on the wire — extend the permits clause (and {@link McpContentWriter}) to add
|
||||
* {@code ImageContent}, {@code EmbeddedResource}, etc. in a future revision.
|
||||
*/
|
||||
public sealed interface Content permits TextContent {}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
/** Standard JSON-RPC 2.0 error codes used by the MCP transport. */
|
||||
final class JsonRpcErrorCode {
|
||||
|
||||
private JsonRpcErrorCode() {}
|
||||
|
||||
static final int PARSE_ERROR = -32700;
|
||||
static final int INVALID_REQUEST = -32600;
|
||||
static final int METHOD_NOT_FOUND = -32601;
|
||||
static final int INVALID_PARAMS = -32602;
|
||||
static final int INTERNAL_ERROR = -32603;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Immutable configuration for {@link McpExtension}.
|
||||
*
|
||||
* <pre>{@code
|
||||
* McpConfig.builder("my-mcp-server")
|
||||
* .version("1.0.0")
|
||||
* .rootPath("/mcp")
|
||||
* .toolsPackage("com.example.tools")
|
||||
* .security(McpSecurity.REQUIRED)
|
||||
* .resourceIdentifier("https://mcp.example.com/mcp")
|
||||
* .build();
|
||||
* }</pre>
|
||||
*/
|
||||
public final class McpConfig {
|
||||
|
||||
private final String name;
|
||||
private final String version;
|
||||
private final String instructions;
|
||||
private final String rootPath;
|
||||
private final String toolsPackage;
|
||||
private final McpSecurity security;
|
||||
private final String resourceIdentifier;
|
||||
private final String authorizationServerIssuer;
|
||||
private final List<String> allowedOrigins;
|
||||
|
||||
private 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<String> 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<String> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Direct {@link JsonGenerator} writers for the fixed, known shapes of {@link Content},
|
||||
* {@link ResourceContents} and {@link PromptMessage} — no databinding, one {@code switch}
|
||||
* per call, matching the fixed wire shape defined by the MCP specification.
|
||||
*/
|
||||
final class McpContentWriter {
|
||||
|
||||
private McpContentWriter() {}
|
||||
|
||||
static void writeContentArray(JsonGenerator gen, List<Content> items) throws IOException {
|
||||
gen.writeStartArray();
|
||||
for (Content c : items) writeContent(gen, c);
|
||||
gen.writeEndArray();
|
||||
}
|
||||
|
||||
static void writeContent(JsonGenerator gen, Content content) throws IOException {
|
||||
if (content instanceof TextContent tc) {
|
||||
gen.writeStartObject();
|
||||
gen.writeStringField("type", "text");
|
||||
gen.writeStringField("text", tc.text());
|
||||
gen.writeEndObject();
|
||||
return;
|
||||
}
|
||||
throw new IllegalStateException("Unhandled Content variant: " + content.getClass());
|
||||
}
|
||||
|
||||
static void writeResourceContents(JsonGenerator gen, ResourceContents contents) throws IOException {
|
||||
if (contents instanceof TextResourceContents trc) {
|
||||
gen.writeStartObject();
|
||||
gen.writeStringField("uri", trc.uri());
|
||||
gen.writeStringField("mimeType", trc.mimeType());
|
||||
gen.writeStringField("text", trc.text());
|
||||
gen.writeEndObject();
|
||||
return;
|
||||
}
|
||||
throw new IllegalStateException("Unhandled ResourceContents variant: " + contents.getClass());
|
||||
}
|
||||
|
||||
static void writePromptMessage(JsonGenerator gen, PromptMessage message) throws IOException {
|
||||
gen.writeStartObject();
|
||||
gen.writeStringField("role", message.role().name().toLowerCase(Locale.ROOT));
|
||||
gen.writeFieldName("content");
|
||||
writeContent(gen, message.content());
|
||||
gen.writeEndObject();
|
||||
}
|
||||
}
|
||||
+244
@@ -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}.
|
||||
*
|
||||
* <p>Per the MCP specification, a {@code tools/call} failure is a normal JSON-RPC
|
||||
* <em>result</em> with {@code isError: true} (see {@link ToolResponse#error}), not a JSON-RPC
|
||||
* error — the model needs to see it. Everything else that goes wrong (bad params, unknown
|
||||
* tool/resource/prompt name, resource/prompt handler exceptions) is a JSON-RPC error object,
|
||||
* always returned with HTTP 200: the HTTP request itself succeeded, only the RPC did not. Only
|
||||
* malformed HTTP-level input (unparsable JSON, not a JSON object) gets HTTP 400.
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
+118
@@ -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)}.
|
||||
*
|
||||
* <pre>{@code
|
||||
* // Standalone, no OAuth2
|
||||
* FlashApp.create(8080)
|
||||
* .install(new McpExtension(McpConfig.builder("my-mcp-server")
|
||||
* .toolsPackage("com.example.tools")
|
||||
* .build()))
|
||||
* .start();
|
||||
*
|
||||
* // With flash-ext-oidc as the OAuth2 resource server
|
||||
* 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();
|
||||
* }</pre>
|
||||
*
|
||||
* <p>One server per {@code McpExtension} instance — install multiple instances (distinct
|
||||
* {@code rootPath}, distinct {@code toolsPackage}) for multiple MCP servers on one app,
|
||||
* mirroring the {@code OidcExtension} multi-tenant pattern. See {@code docs/security.md} for
|
||||
* the full OAuth2 resolution rules.
|
||||
*/
|
||||
@Slf4j
|
||||
public class McpExtension implements FlashExtension {
|
||||
|
||||
private final McpConfig config;
|
||||
|
||||
public McpExtension(McpConfig config) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Middleware> 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonEncoding;
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* Internal JSON access shared by the whole extension. Deliberately tree/streaming only —
|
||||
* no {@code readValue(bytes, Class)} databinding anywhere in this extension. Request bodies
|
||||
* are parsed once into a {@link JsonNode} (no reflection, no property matching against a
|
||||
* target class); responses are written directly with {@link JsonGenerator} against the
|
||||
* envelope's fixed, known shape (also no reflection).
|
||||
*
|
||||
* <p>Not wired to {@code flash-ext-jackson} on purpose: the MCP JSON-RPC envelope is internal
|
||||
* protocol plumbing, not a user-facing serialization concern, so this extension owns its
|
||||
* mapper independently — same reasoning {@code flash-ext-oidc} applies to its own JSON needs
|
||||
* (see {@code json-smart} there). See {@code docs/jackson-interop.md} for the full rationale
|
||||
* and how a future opt-in reuse of a shared {@code ObjectMapper} could work.
|
||||
*/
|
||||
final class McpJson {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private McpJson() {}
|
||||
|
||||
static JsonNode parse(byte[] body) throws IOException {
|
||||
return MAPPER.readTree(body);
|
||||
}
|
||||
|
||||
static JsonGenerator generator(OutputStream out) throws IOException {
|
||||
return MAPPER.getFactory().createGenerator(out, JsonEncoding.UTF8);
|
||||
}
|
||||
|
||||
/** Builds a small JSON document in one shot; used only for boot-time precompilation. */
|
||||
static byte[] build(JsonWriter writer) {
|
||||
ByteArrayOutputStream buf = new ByteArrayOutputStream(256);
|
||||
try (JsonGenerator gen = generator(buf)) {
|
||||
writer.write(gen);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Failed to build MCP JSON fragment", e);
|
||||
}
|
||||
return buf.toByteArray();
|
||||
}
|
||||
|
||||
static String buildString(JsonWriter writer) {
|
||||
return new String(build(writer), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface JsonWriter {
|
||||
void write(JsonGenerator gen) throws IOException;
|
||||
}
|
||||
}
|
||||
+56
@@ -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}.
|
||||
*
|
||||
* <p>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<OidcMiddleware> 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<String, Object> 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;
|
||||
}
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
import dev.relism.flash.exceptions.InitializationException;
|
||||
|
||||
import java.io.File;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.net.URL;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Enumeration;
|
||||
import java.util.List;
|
||||
import java.util.jar.JarEntry;
|
||||
import java.util.jar.JarFile;
|
||||
|
||||
/**
|
||||
* Minimal classpath scanner used by {@link McpConfig#toolsPackage(String)}. Finds
|
||||
* {@link McpTool}/{@link McpResource}/{@link McpPrompt} subclasses carrying the matching
|
||||
* annotation ({@link Tool @Tool}, {@link Resource @Resource}, {@link Prompt @Prompt}).
|
||||
* Supports both exploded directories (development) and fat JARs (deployment).
|
||||
*
|
||||
* <p>Deliberately not shared with {@code dev.relism.flash.extension.PackageScanner}: that
|
||||
* scanner is package-private and hardcoded to {@code RequestHandler}/{@code WebSocketEndpoint}.
|
||||
* The directory/JAR walking logic below intentionally mirrors it — same fail-fast contract,
|
||||
* same anonymous-class filtering.
|
||||
*
|
||||
* <p><b>Fail-fast:</b> if the package does not exist, contains no matching class, or a class
|
||||
* cannot be loaded, an {@link InitializationException} is thrown immediately at boot.
|
||||
*/
|
||||
final class McpPackageScanner {
|
||||
|
||||
private McpPackageScanner() {}
|
||||
|
||||
record ScanResult(List<Class<? extends McpTool>> tools,
|
||||
List<Class<? extends McpResource>> resources,
|
||||
List<Class<? extends McpPrompt>> prompts) {}
|
||||
|
||||
static ScanResult scan(String packageName) {
|
||||
if (packageName == null || packageName.isBlank())
|
||||
throw new InitializationException("McpConfig.toolsPackage() called with null or blank package name");
|
||||
|
||||
String resourcePath = packageName.replace('.', '/');
|
||||
ClassLoader cl = Thread.currentThread().getContextClassLoader();
|
||||
List<Class<? extends McpTool>> tools = new ArrayList<>();
|
||||
List<Class<? extends McpResource>> resources = new ArrayList<>();
|
||||
List<Class<? extends McpPrompt>> prompts = new ArrayList<>();
|
||||
List<String> errors = new ArrayList<>();
|
||||
boolean packageFound = false;
|
||||
|
||||
try {
|
||||
Enumeration<URL> urls = cl.getResources(resourcePath);
|
||||
while (urls.hasMoreElements()) {
|
||||
packageFound = true;
|
||||
URL url = urls.nextElement();
|
||||
String protocol = url.getProtocol();
|
||||
if ("file".equals(protocol)) {
|
||||
scanDirectory(new File(url.toURI()), packageName, cl, tools, resources, prompts, errors);
|
||||
} else if ("jar".equals(protocol)) {
|
||||
String jarPath = url.getPath();
|
||||
String filePart = jarPath.substring(jarPath.indexOf("file:") + 5, jarPath.indexOf('!'));
|
||||
try (JarFile jar = new JarFile(filePart)) {
|
||||
scanJar(jar, resourcePath, cl, tools, resources, prompts, errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (InitializationException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new InitializationException("Failed to scan MCP package: " + packageName, e);
|
||||
}
|
||||
|
||||
if (!packageFound)
|
||||
throw new InitializationException(
|
||||
"McpConfig.toolsPackage(\"" + packageName + "\") — package not found on classpath. " +
|
||||
"Verify the package name and ensure the module is on the classpath.");
|
||||
|
||||
if (!errors.isEmpty())
|
||||
throw new InitializationException(
|
||||
"McpConfig.toolsPackage(\"" + packageName + "\") — failed to load " + errors.size() + " class(es):\n • " +
|
||||
String.join("\n • ", errors));
|
||||
|
||||
if (tools.isEmpty() && resources.isEmpty() && prompts.isEmpty())
|
||||
throw new InitializationException(
|
||||
"McpConfig.toolsPackage(\"" + packageName + "\") — no @Tool/@Resource/@Prompt classes found. " +
|
||||
"Ensure classes extend McpTool/McpResource/McpPrompt, carry the matching annotation, " +
|
||||
"are not abstract, and have a public no-arg constructor.");
|
||||
|
||||
return new ScanResult(List.copyOf(tools), List.copyOf(resources), List.copyOf(prompts));
|
||||
}
|
||||
|
||||
private static void scanDirectory(File dir, String packageName, ClassLoader cl,
|
||||
List<Class<? extends McpTool>> tools,
|
||||
List<Class<? extends McpResource>> resources,
|
||||
List<Class<? extends McpPrompt>> prompts,
|
||||
List<String> errors) {
|
||||
File[] files = dir.listFiles();
|
||||
if (files == null) return;
|
||||
for (File file : files) {
|
||||
if (file.isDirectory()) {
|
||||
scanDirectory(file, packageName + '.' + file.getName(), cl, tools, resources, prompts, errors);
|
||||
} else if (file.getName().endsWith(".class") && !isAnonymous(file.getName())) {
|
||||
String className = packageName + '.' + file.getName().replace(".class", "");
|
||||
tryLoad(className, cl, tools, resources, prompts, errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void scanJar(JarFile jar, String resourcePath, ClassLoader cl,
|
||||
List<Class<? extends McpTool>> tools,
|
||||
List<Class<? extends McpResource>> resources,
|
||||
List<Class<? extends McpPrompt>> prompts,
|
||||
List<String> errors) {
|
||||
String prefix = resourcePath + "/";
|
||||
Enumeration<JarEntry> entries = jar.entries();
|
||||
while (entries.hasMoreElements()) {
|
||||
String name = entries.nextElement().getName();
|
||||
if (name.startsWith(prefix) && name.endsWith(".class") && !isAnonymous(name)) {
|
||||
String className = name.replace('/', '.').replace(".class", "");
|
||||
tryLoad(className, cl, tools, resources, prompts, errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isAnonymous(String fileName) {
|
||||
int dollar = fileName.lastIndexOf('$');
|
||||
if (dollar < 0) return false;
|
||||
int next = dollar + 1;
|
||||
while (next < fileName.length() && fileName.charAt(next) == '$') next++;
|
||||
return next < fileName.length() && Character.isDigit(fileName.charAt(next));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void tryLoad(String className, ClassLoader cl,
|
||||
List<Class<? extends McpTool>> tools,
|
||||
List<Class<? extends McpResource>> resources,
|
||||
List<Class<? extends McpPrompt>> prompts,
|
||||
List<String> errors) {
|
||||
try {
|
||||
Class<?> cls = cl.loadClass(className);
|
||||
if (Modifier.isAbstract(cls.getModifiers())) return;
|
||||
|
||||
if (McpTool.class.isAssignableFrom(cls) && cls.isAnnotationPresent(Tool.class)) {
|
||||
assertNoArgConstructor(cls, errors);
|
||||
tools.add((Class<? extends McpTool>) cls);
|
||||
return;
|
||||
}
|
||||
if (McpResource.class.isAssignableFrom(cls) && cls.isAnnotationPresent(Resource.class)) {
|
||||
assertNoArgConstructor(cls, errors);
|
||||
resources.add((Class<? extends McpResource>) cls);
|
||||
return;
|
||||
}
|
||||
if (McpPrompt.class.isAssignableFrom(cls) && cls.isAnnotationPresent(Prompt.class)) {
|
||||
assertNoArgConstructor(cls, errors);
|
||||
prompts.add((Class<? extends McpPrompt>) cls);
|
||||
}
|
||||
} catch (ClassNotFoundException e) {
|
||||
errors.add(className + " — class not found: " + e.getMessage());
|
||||
} catch (NoClassDefFoundError e) {
|
||||
errors.add(className + " — missing dependency: " + e.getMessage());
|
||||
} catch (LinkageError e) {
|
||||
errors.add(className + " — linkage error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertNoArgConstructor(Class<?> cls, List<String> errors) {
|
||||
try { cls.getDeclaredConstructor(); }
|
||||
catch (NoSuchMethodException e) { errors.add(cls.getName() + " — missing public no-arg constructor"); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Base class for a single MCP prompt template — one class per prompt, mirroring {@link McpTool}.
|
||||
* Declare metadata with {@link Prompt @Prompt}, cache services in {@link #onInit()}, implement
|
||||
* {@link #render(PromptArguments)} for the hot path.
|
||||
*
|
||||
* <pre>{@code
|
||||
* @Prompt(name = "summarize", args = @PromptArg(name = "text", required = true))
|
||||
* public class SummarizePrompt extends McpPrompt {
|
||||
* @Override public PromptMessage render(PromptArguments args) {
|
||||
* return PromptMessage.withUserRole(new TextContent("Summarize: " + args.getString("text")));
|
||||
* }
|
||||
* }
|
||||
* }</pre>
|
||||
*/
|
||||
public abstract class McpPrompt {
|
||||
|
||||
private FlashContext ctx;
|
||||
|
||||
/**
|
||||
* Called once by the framework after instantiation, before the first {@code prompts/get}.
|
||||
* <b>Infrastructure method</b> — do not call from user code.
|
||||
*/
|
||||
public final void bind(FlashContext ctx) {
|
||||
this.ctx = ctx;
|
||||
onInit();
|
||||
}
|
||||
|
||||
protected void onInit() {}
|
||||
|
||||
protected <T> T require(Class<T> type) {
|
||||
checkBound();
|
||||
return ctx.require(type);
|
||||
}
|
||||
|
||||
protected <T> Optional<T> find(Class<T> type) {
|
||||
checkBound();
|
||||
return ctx.find(type);
|
||||
}
|
||||
|
||||
protected <T> Optional<T> optional(Class<T> type) {
|
||||
checkBound();
|
||||
return ctx.optional(type);
|
||||
}
|
||||
|
||||
private void checkBound() {
|
||||
if (ctx == null)
|
||||
throw new IllegalStateException(
|
||||
getClass().getSimpleName() + " has not been bound to a FlashContext — " +
|
||||
"register via McpConfig.toolsPackage(), not by instantiating directly");
|
||||
}
|
||||
|
||||
/** Invoked on every matching {@code prompts/get} request (hot path). */
|
||||
public abstract PromptMessage render(PromptArguments args) throws Exception;
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
/** Internal signal carrying a JSON-RPC error code, caught by {@link McpDispatcher} to build the error response. */
|
||||
final class McpProtocolException extends RuntimeException {
|
||||
|
||||
final int code;
|
||||
|
||||
private McpProtocolException(int code, String message) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
static McpProtocolException invalidRequest(String message) {
|
||||
return new McpProtocolException(JsonRpcErrorCode.INVALID_REQUEST, message);
|
||||
}
|
||||
|
||||
static McpProtocolException methodNotFound(String method) {
|
||||
return new McpProtocolException(JsonRpcErrorCode.METHOD_NOT_FOUND, "Method not found: " + method);
|
||||
}
|
||||
|
||||
static McpProtocolException invalidParams(String message) {
|
||||
return new McpProtocolException(JsonRpcErrorCode.INVALID_PARAMS, message);
|
||||
}
|
||||
}
|
||||
+176
@@ -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.
|
||||
*
|
||||
* <p>Everything static about the catalog — the {@code tools/list}/{@code resources/list}/
|
||||
* {@code prompts/list} JSON payloads — is assembled exactly once here via
|
||||
* {@link McpJson#buildString}, then spliced verbatim into responses at request time
|
||||
* ({@link McpDispatcher}) with {@link JsonGenerator#writeRawValue(String)}: no
|
||||
* re-serialization, no reflection, no databinding, and no per-request byte[]→String
|
||||
* conversion on the hot path — the string is already sitting in memory, built once at boot.
|
||||
*/
|
||||
final class McpRegistry {
|
||||
|
||||
private static final String EMPTY_ARRAY = "[]";
|
||||
|
||||
record RegisteredTool(String name, McpTool instance) {}
|
||||
record RegisteredResource(String uri, McpResource instance) {}
|
||||
record RegisteredPrompt(String name, McpPrompt instance) {}
|
||||
|
||||
private final Map<String, RegisteredTool> tools = new LinkedHashMap<>();
|
||||
private final Map<String, RegisteredResource> resources = new LinkedHashMap<>();
|
||||
private final Map<String, RegisteredPrompt> prompts = new LinkedHashMap<>();
|
||||
|
||||
private String toolsListJson = EMPTY_ARRAY;
|
||||
private String resourcesListJson = EMPTY_ARRAY;
|
||||
private String promptsListJson = EMPTY_ARRAY;
|
||||
|
||||
private McpRegistry() {}
|
||||
|
||||
static McpRegistry scan(String packageName, FlashContext ctx) {
|
||||
McpPackageScanner.ScanResult found = McpPackageScanner.scan(packageName);
|
||||
McpRegistry registry = new McpRegistry();
|
||||
|
||||
for (Class<? extends McpTool> cls : found.tools()) {
|
||||
Tool ann = cls.getAnnotation(Tool.class);
|
||||
McpTool instance = instantiate(cls);
|
||||
instance.bind(ctx);
|
||||
if (registry.tools.putIfAbsent(ann.name(), new RegisteredTool(ann.name(), instance)) != null)
|
||||
throw new InitializationException("Duplicate MCP tool name: \"" + ann.name() + "\"");
|
||||
}
|
||||
for (Class<? extends McpResource> cls : found.resources()) {
|
||||
Resource ann = cls.getAnnotation(Resource.class);
|
||||
McpResource instance = instantiate(cls);
|
||||
instance.bind(ctx);
|
||||
if (registry.resources.putIfAbsent(ann.uri(), new RegisteredResource(ann.uri(), instance)) != null)
|
||||
throw new InitializationException("Duplicate MCP resource uri: \"" + ann.uri() + "\"");
|
||||
}
|
||||
for (Class<? extends McpPrompt> cls : found.prompts()) {
|
||||
Prompt ann = cls.getAnnotation(Prompt.class);
|
||||
McpPrompt instance = instantiate(cls);
|
||||
instance.bind(ctx);
|
||||
if (registry.prompts.putIfAbsent(ann.name(), new RegisteredPrompt(ann.name(), instance)) != null)
|
||||
throw new InitializationException("Duplicate MCP prompt name: \"" + ann.name() + "\"");
|
||||
}
|
||||
|
||||
if (!found.tools().isEmpty())
|
||||
registry.toolsListJson = McpJson.buildString(gen -> writeToolsArray(gen, found.tools()));
|
||||
if (!found.resources().isEmpty())
|
||||
registry.resourcesListJson = McpJson.buildString(gen -> writeResourcesArray(gen, found.resources()));
|
||||
if (!found.prompts().isEmpty())
|
||||
registry.promptsListJson = McpJson.buildString(gen -> writePromptsArray(gen, found.prompts()));
|
||||
|
||||
return registry;
|
||||
}
|
||||
|
||||
boolean hasTools() { return !tools.isEmpty(); }
|
||||
boolean hasResources() { return !resources.isEmpty(); }
|
||||
boolean hasPrompts() { return !prompts.isEmpty(); }
|
||||
|
||||
String toolsListJson() { return toolsListJson; }
|
||||
String resourcesListJson() { return resourcesListJson; }
|
||||
String promptsListJson() { return promptsListJson; }
|
||||
|
||||
RegisteredTool tool(String name) { return tools.get(name); }
|
||||
RegisteredResource resource(String uri) { return resources.get(uri); }
|
||||
RegisteredPrompt prompt(String name) { return prompts.get(name); }
|
||||
|
||||
// ── Boot-time JSON Schema / descriptor precompilation ───────────────────────
|
||||
|
||||
private static void writeToolsArray(JsonGenerator gen, List<Class<? extends McpTool>> classes) throws IOException {
|
||||
gen.writeStartArray();
|
||||
for (Class<? extends McpTool> cls : classes) writeTool(gen, cls.getAnnotation(Tool.class));
|
||||
gen.writeEndArray();
|
||||
}
|
||||
|
||||
private static void writeTool(JsonGenerator gen, Tool ann) throws IOException {
|
||||
gen.writeStartObject();
|
||||
gen.writeStringField("name", ann.name());
|
||||
if (!ann.title().isBlank()) gen.writeStringField("title", ann.title());
|
||||
if (!ann.description().isBlank()) gen.writeStringField("description", ann.description());
|
||||
gen.writeFieldName("inputSchema");
|
||||
writeInputSchema(gen, ann.args());
|
||||
gen.writeEndObject();
|
||||
}
|
||||
|
||||
private static void writeInputSchema(JsonGenerator gen, ToolArg[] args) throws IOException {
|
||||
gen.writeStartObject();
|
||||
gen.writeStringField("type", "object");
|
||||
gen.writeObjectFieldStart("properties");
|
||||
for (ToolArg arg : args) {
|
||||
gen.writeObjectFieldStart(arg.name());
|
||||
gen.writeStringField("type", arg.type().jsonSchemaType());
|
||||
if (!arg.description().isBlank()) gen.writeStringField("description", arg.description());
|
||||
gen.writeEndObject();
|
||||
}
|
||||
gen.writeEndObject();
|
||||
if (hasRequired(args)) {
|
||||
gen.writeArrayFieldStart("required");
|
||||
for (ToolArg arg : args) if (arg.required()) gen.writeString(arg.name());
|
||||
gen.writeEndArray();
|
||||
}
|
||||
gen.writeEndObject();
|
||||
}
|
||||
|
||||
private static boolean hasRequired(ToolArg[] args) {
|
||||
for (ToolArg arg : args) if (arg.required()) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void writeResourcesArray(JsonGenerator gen, List<Class<? extends McpResource>> classes) throws IOException {
|
||||
gen.writeStartArray();
|
||||
for (Class<? extends McpResource> cls : classes) {
|
||||
Resource ann = cls.getAnnotation(Resource.class);
|
||||
gen.writeStartObject();
|
||||
gen.writeStringField("uri", ann.uri());
|
||||
gen.writeStringField("name", !ann.name().isBlank() ? ann.name() : ann.uri());
|
||||
if (!ann.description().isBlank()) gen.writeStringField("description", ann.description());
|
||||
gen.writeStringField("mimeType", ann.mimeType());
|
||||
gen.writeEndObject();
|
||||
}
|
||||
gen.writeEndArray();
|
||||
}
|
||||
|
||||
private static void writePromptsArray(JsonGenerator gen, List<Class<? extends McpPrompt>> classes) throws IOException {
|
||||
gen.writeStartArray();
|
||||
for (Class<? extends McpPrompt> cls : classes) {
|
||||
Prompt ann = cls.getAnnotation(Prompt.class);
|
||||
gen.writeStartObject();
|
||||
gen.writeStringField("name", ann.name());
|
||||
if (!ann.description().isBlank()) gen.writeStringField("description", ann.description());
|
||||
gen.writeArrayFieldStart("arguments");
|
||||
for (PromptArg arg : ann.args()) {
|
||||
gen.writeStartObject();
|
||||
gen.writeStringField("name", arg.name());
|
||||
if (!arg.description().isBlank()) gen.writeStringField("description", arg.description());
|
||||
gen.writeBooleanField("required", arg.required());
|
||||
gen.writeEndObject();
|
||||
}
|
||||
gen.writeEndArray();
|
||||
gen.writeEndObject();
|
||||
}
|
||||
gen.writeEndArray();
|
||||
}
|
||||
|
||||
private static <T> T instantiate(Class<T> cls) {
|
||||
try {
|
||||
Constructor<T> ctor = cls.getDeclaredConstructor();
|
||||
return ctor.newInstance();
|
||||
} catch (Exception e) {
|
||||
throw new InitializationException(
|
||||
"Failed to instantiate " + cls.getName() +
|
||||
" — ensure it has a public no-arg constructor", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Base class for a single MCP resource — one class per resource, mirroring {@link McpTool}.
|
||||
* Declare metadata with {@link Resource @Resource}, cache services in {@link #onInit()},
|
||||
* implement {@link #read()} for the hot path.
|
||||
*
|
||||
* <pre>{@code
|
||||
* @Resource(uri = "config://app-settings", description = "Application settings", mimeType = "application/json")
|
||||
* public class AppSettingsResource extends McpResource {
|
||||
* @Override public ResourceContents read() {
|
||||
* return TextResourceContents.of(uri(), "application/json", settingsJson());
|
||||
* }
|
||||
* }
|
||||
* }</pre>
|
||||
*/
|
||||
public abstract class McpResource {
|
||||
|
||||
private FlashContext ctx;
|
||||
private String uri;
|
||||
|
||||
/**
|
||||
* Called once by the framework after instantiation, before the first {@code resources/read}.
|
||||
* <b>Infrastructure method</b> — do not call from user code.
|
||||
*/
|
||||
public final void bind(FlashContext ctx) {
|
||||
this.ctx = ctx;
|
||||
Resource ann = getClass().getAnnotation(Resource.class);
|
||||
this.uri = ann != null ? ann.uri() : null;
|
||||
onInit();
|
||||
}
|
||||
|
||||
protected void onInit() {}
|
||||
|
||||
protected <T> T require(Class<T> type) {
|
||||
checkBound();
|
||||
return ctx.require(type);
|
||||
}
|
||||
|
||||
protected <T> Optional<T> find(Class<T> type) {
|
||||
checkBound();
|
||||
return ctx.find(type);
|
||||
}
|
||||
|
||||
protected <T> Optional<T> optional(Class<T> type) {
|
||||
checkBound();
|
||||
return ctx.optional(type);
|
||||
}
|
||||
|
||||
/** URI declared via {@link Resource @Resource}, cached at bind time. */
|
||||
protected final String uri() { return uri; }
|
||||
|
||||
private void checkBound() {
|
||||
if (ctx == null)
|
||||
throw new IllegalStateException(
|
||||
getClass().getSimpleName() + " has not been bound to a FlashContext — " +
|
||||
"register via McpConfig.toolsPackage(), not by instantiating directly");
|
||||
}
|
||||
|
||||
/** Invoked on every matching {@code resources/read} request (hot path). */
|
||||
public abstract ResourceContents read() throws Exception;
|
||||
}
|
||||
+18
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
/**
|
||||
* OAuth2 requirement policy for the MCP endpoint, resolved against whether
|
||||
* {@code flash-ext-oidc} is installed ({@code ctx.find(OidcMiddleware.class)}).
|
||||
*/
|
||||
public enum McpSecurity {
|
||||
|
||||
/** Fail fast at boot if {@code flash-ext-oidc} is not installed — never expose an unprotected MCP endpoint. */
|
||||
REQUIRED,
|
||||
|
||||
/** Protect the endpoint if {@code flash-ext-oidc} is installed; otherwise run unprotected and log a warning. */
|
||||
AUTO,
|
||||
|
||||
/** Never protect the endpoint, even if {@code flash-ext-oidc} is installed elsewhere in the app. */
|
||||
NONE
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Base class for a single MCP tool — one class per tool, mirroring
|
||||
* {@link dev.relism.flash.models.RequestHandler}: declare metadata with {@link Tool @Tool},
|
||||
* cache services in {@link #onInit()}, implement {@link #call(ToolArguments)} for the hot path.
|
||||
*
|
||||
* <p>Discovered via {@link McpConfig#toolsPackage(String)} — instantiated with its public
|
||||
* no-arg constructor and bound once at boot, before the first {@code tools/call} request.
|
||||
*
|
||||
* <pre>{@code
|
||||
* @Tool(name = "get_weather", description = "Get current weather for a city",
|
||||
* args = @ToolArg(name = "city", required = true))
|
||||
* public class GetWeatherTool extends McpTool {
|
||||
* private WeatherService weatherService;
|
||||
*
|
||||
* @Override protected void onInit() {
|
||||
* weatherService = require(WeatherService.class);
|
||||
* }
|
||||
*
|
||||
* @Override public ToolResponse call(ToolArguments args) {
|
||||
* return ToolResponse.success(new TextContent(weatherService.fetch(args.getString("city"))));
|
||||
* }
|
||||
* }
|
||||
* }</pre>
|
||||
*/
|
||||
public abstract class McpTool {
|
||||
|
||||
private FlashContext ctx;
|
||||
|
||||
/**
|
||||
* Called once by the framework after instantiation, before the first {@code tools/call}.
|
||||
* <b>Infrastructure method</b> — do not call from user code.
|
||||
*/
|
||||
public final void bind(FlashContext ctx) {
|
||||
this.ctx = ctx;
|
||||
onInit();
|
||||
}
|
||||
|
||||
/** Override to cache services at boot time. See {@link #require}/{@link #find}. */
|
||||
protected void onInit() {}
|
||||
|
||||
protected <T> T require(Class<T> type) {
|
||||
checkBound();
|
||||
return ctx.require(type);
|
||||
}
|
||||
|
||||
protected <T> Optional<T> find(Class<T> type) {
|
||||
checkBound();
|
||||
return ctx.find(type);
|
||||
}
|
||||
|
||||
protected <T> Optional<T> optional(Class<T> type) {
|
||||
checkBound();
|
||||
return ctx.optional(type);
|
||||
}
|
||||
|
||||
private void checkBound() {
|
||||
if (ctx == null)
|
||||
throw new IllegalStateException(
|
||||
getClass().getSimpleName() + " has not been bound to a FlashContext — " +
|
||||
"register via McpConfig.toolsPackage(), not by instantiating directly");
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoked on every matching {@code tools/call} request (hot path). {@code args} is a thin
|
||||
* accessor over the already-parsed JSON arguments — no databinding.
|
||||
*/
|
||||
public abstract ToolResponse call(ToolArguments args) throws Exception;
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
import dev.relism.flash.exceptions.HttpException;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.routing.Middleware;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Transport-level guards for the MCP Streamable HTTP endpoint. */
|
||||
@Slf4j
|
||||
final class McpTransportGuards {
|
||||
|
||||
private McpTransportGuards() {}
|
||||
|
||||
/**
|
||||
* Validates the {@code Origin} header per the Streamable HTTP transport's DNS-rebinding
|
||||
* protection requirement. Non-browser clients that omit {@code Origin} entirely are always
|
||||
* allowed through — only a <em>present but disallowed</em> value is rejected.
|
||||
*
|
||||
* <p>If {@code allowedOrigins} is empty, validation is skipped and a boot-time warning is
|
||||
* logged — same graceful-degradation shape as {@link McpSecurity#AUTO}.
|
||||
*/
|
||||
static Middleware originGuard(List<String> allowedOrigins) {
|
||||
if (allowedOrigins.isEmpty()) {
|
||||
log.warn("[flash-ext-mcp] No allowedOrigins configured — Origin header validation " +
|
||||
"(DNS-rebinding protection) is DISABLED. Configure McpConfig.allowedOrigins(...) for production use.");
|
||||
return next -> next::handle;
|
||||
}
|
||||
return next -> (req, res) -> {
|
||||
String origin = req.header("Origin");
|
||||
if (origin != null && !allowedOrigins.contains(origin)) {
|
||||
throw HttpException.forbidden();
|
||||
}
|
||||
return next.handle(req, res);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Safety net around the whole MCP route: translates {@link HttpException} (thrown by
|
||||
* {@link #originGuard} or by {@code flash-ext-oidc}'s middleware) into a proper HTTP status
|
||||
* directly, instead of relying on the app's global exception handler — which defaults to a
|
||||
* generic 500 for every exception type unless the app owner overrides it (see
|
||||
* {@code AbstractRouter}'s default {@code exceptionHandler}). Keeps the MCP endpoint
|
||||
* correct out of the box regardless of what the rest of the app configures.
|
||||
*/
|
||||
static Middleware httpExceptionGuard() {
|
||||
return next -> (req, res) -> {
|
||||
try {
|
||||
return next.handle(req, res);
|
||||
} catch (HttpException e) {
|
||||
String body = McpJson.buildString(gen -> {
|
||||
gen.writeStartObject();
|
||||
gen.writeStringField("error", e.getMessage());
|
||||
gen.writeEndObject();
|
||||
});
|
||||
res.status(e.status()).type(ContentType.JSON).body(body);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* Marks a {@link McpPrompt} subclass as an MCP prompt template and declares its metadata,
|
||||
* discovered by {@link McpConfig#toolsPackage(String)}.
|
||||
*
|
||||
* <pre>{@code
|
||||
* @Prompt(name = "summarize", args = @PromptArg(name = "text", required = true))
|
||||
* public class SummarizePrompt extends McpPrompt {
|
||||
* @Override
|
||||
* public PromptMessage render(PromptArguments args) {
|
||||
* return PromptMessage.withUserRole(new TextContent("Summarize: " + args.getString("text")));
|
||||
* }
|
||||
* }
|
||||
* }</pre>
|
||||
*/
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE)
|
||||
public @interface Prompt {
|
||||
/** Unique prompt name (used by clients in {@code prompts/get}). */
|
||||
String name();
|
||||
|
||||
String description() default "";
|
||||
|
||||
/** Arguments accepted by the prompt template — always strings per the MCP specification. */
|
||||
PromptArg[] args() default {};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
/**
|
||||
* Declares one argument of a {@link Prompt}. Per the MCP specification, prompt arguments are
|
||||
* always strings. Used inside {@link Prompt#args()}.
|
||||
*/
|
||||
public @interface PromptArg {
|
||||
String name();
|
||||
String description() default "";
|
||||
boolean required() default false;
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.MissingNode;
|
||||
|
||||
/**
|
||||
* Typed accessor over a {@code prompts/get} request's {@code arguments} object.
|
||||
* Per the MCP specification, prompt arguments are always strings.
|
||||
*/
|
||||
public final class PromptArguments {
|
||||
|
||||
private final JsonNode node;
|
||||
|
||||
PromptArguments(JsonNode node) {
|
||||
this.node = node != null ? node : MissingNode.getInstance();
|
||||
}
|
||||
|
||||
public boolean has(String name) { return node.has(name); }
|
||||
public String getString(String name) { return node.path(name).asText(null); }
|
||||
public String getString(String name, String defaultValue) { return node.path(name).asText(defaultValue); }
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
/** A single message returned by a {@link McpPrompt}. */
|
||||
public record PromptMessage(Role role, Content content) {
|
||||
|
||||
public enum Role { USER, ASSISTANT }
|
||||
|
||||
public static PromptMessage withUserRole(Content content) {
|
||||
return new PromptMessage(Role.USER, content);
|
||||
}
|
||||
|
||||
public static PromptMessage withAssistantRole(Content content) {
|
||||
return new PromptMessage(Role.ASSISTANT, content);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* Marks a {@link McpResource} subclass as an MCP resource and declares its metadata, discovered
|
||||
* by {@link McpConfig#toolsPackage(String)}.
|
||||
*
|
||||
* <pre>{@code
|
||||
* @Resource(uri = "config://app-settings", description = "Application settings")
|
||||
* public class AppSettingsResource extends McpResource {
|
||||
* @Override
|
||||
* public ResourceContents read() {
|
||||
* return TextResourceContents.of(uri(), "application/json", settingsJson());
|
||||
* }
|
||||
* }
|
||||
* }</pre>
|
||||
*/
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE)
|
||||
public @interface Resource {
|
||||
/** Unique resource URI (used by clients in {@code resources/read}). */
|
||||
String uri();
|
||||
|
||||
String name() default "";
|
||||
String description() default "";
|
||||
String mimeType() default "text/plain";
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
/**
|
||||
* MCP resource contents. {@code sealed} to the variants this extension currently writes on
|
||||
* the wire — extend the permits clause (and {@link McpContentWriter}) to add
|
||||
* {@code BlobResourceContents} in a future revision.
|
||||
*/
|
||||
public sealed interface ResourceContents permits TextResourceContents {}
|
||||
@@ -0,0 +1,4 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
/** Plain-text content block ({@code type: "text"} on the wire). */
|
||||
public record TextContent(String text) implements Content {}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
/** Text resource contents returned from {@code resources/read}. */
|
||||
public record TextResourceContents(String uri, String mimeType, String text) implements ResourceContents {
|
||||
|
||||
public static TextResourceContents of(String uri, String mimeType, String text) {
|
||||
return new TextResourceContents(uri, mimeType, text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* Marks a {@link McpTool} subclass as an MCP tool and declares its metadata, discovered by
|
||||
* {@link McpConfig#toolsPackage(String)}.
|
||||
*
|
||||
* <pre>{@code
|
||||
* @Tool(
|
||||
* name = "get_weather",
|
||||
* description = "Get current weather for a city",
|
||||
* args = @ToolArg(name = "city", description = "City name", required = true)
|
||||
* )
|
||||
* public class GetWeatherTool extends McpTool {
|
||||
* @Override
|
||||
* public ToolResponse call(ToolArguments args) {
|
||||
* return ToolResponse.success(new TextContent(weatherService.fetch(args.getString("city"))));
|
||||
* }
|
||||
* }
|
||||
* }</pre>
|
||||
*/
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE)
|
||||
public @interface Tool {
|
||||
/** Unique tool name (used by clients in {@code tools/call}). */
|
||||
String name();
|
||||
|
||||
/** Human/model-readable description of what the tool does. */
|
||||
String description() default "";
|
||||
|
||||
/** Optional display title, distinct from {@link #name()}. */
|
||||
String title() default "";
|
||||
|
||||
/** Input arguments — assembled into the tool's JSON Schema {@code inputSchema} once at boot. */
|
||||
ToolArg[] args() default {};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
/**
|
||||
* Declares one input argument of a {@link Tool}. Used inside {@link Tool#args()} — the whole
|
||||
* input JSON Schema is assembled once at scan time from these, never at call time.
|
||||
*/
|
||||
public @interface ToolArg {
|
||||
String name();
|
||||
ToolArgType type() default ToolArgType.STRING;
|
||||
String description() default "";
|
||||
boolean required() default false;
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
/** JSON Schema primitive types available for {@link ToolArg#type()}. */
|
||||
public enum ToolArgType {
|
||||
STRING, INTEGER, NUMBER, BOOLEAN, OBJECT, ARRAY;
|
||||
|
||||
/** JSON Schema {@code "type"} keyword value. */
|
||||
String jsonSchemaType() {
|
||||
return name().toLowerCase(java.util.Locale.ROOT);
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.MissingNode;
|
||||
|
||||
/**
|
||||
* Typed accessor over a {@code tools/call} request's {@code arguments} object.
|
||||
*
|
||||
* <p>Wraps the already-parsed {@link JsonNode} directly — no POJO databinding, no reflection,
|
||||
* no intermediate copy. Same spirit as {@code QueryParams}/{@code PathParams} in Flash core:
|
||||
* a thin typed view over data that already exists in memory.
|
||||
*
|
||||
* <pre>{@code
|
||||
* public ToolResponse call(ToolArguments args) {
|
||||
* String city = args.getString("city");
|
||||
* int days = args.getInt("days", 1);
|
||||
* ...
|
||||
* }
|
||||
* }</pre>
|
||||
*/
|
||||
public final class ToolArguments {
|
||||
|
||||
private final JsonNode node;
|
||||
|
||||
ToolArguments(JsonNode node) {
|
||||
this.node = node != null ? node : MissingNode.getInstance();
|
||||
}
|
||||
|
||||
public boolean has(String name) { return node.has(name); }
|
||||
|
||||
public String getString(String name) { return node.path(name).asText(null); }
|
||||
public String getString(String name, String defaultValue) { return node.path(name).asText(defaultValue); }
|
||||
|
||||
public int getInt(String name) { return node.path(name).asInt(); }
|
||||
public int getInt(String name, int defaultValue) { return node.path(name).asInt(defaultValue); }
|
||||
|
||||
public long getLong(String name) { return node.path(name).asLong(); }
|
||||
public long getLong(String name, long defaultValue) { return node.path(name).asLong(defaultValue); }
|
||||
|
||||
public double getDouble(String name) { return node.path(name).asDouble(); }
|
||||
public double getDouble(String name, double defaultValue) { return node.path(name).asDouble(defaultValue); }
|
||||
|
||||
public boolean getBoolean(String name) { return node.path(name).asBoolean(); }
|
||||
public boolean getBoolean(String name, boolean defaultValue) { return node.path(name).asBoolean(defaultValue); }
|
||||
|
||||
/** Escape hatch for nested/array arguments not covered by the typed accessors above. */
|
||||
public JsonNode raw(String name) { return node.path(name); }
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Result of a {@link McpTool#call(ToolArguments)} invocation. */
|
||||
public final class ToolResponse {
|
||||
|
||||
private final List<Content> content;
|
||||
private final boolean isError;
|
||||
|
||||
private ToolResponse(List<Content> content, boolean isError) {
|
||||
this.content = content;
|
||||
this.isError = isError;
|
||||
}
|
||||
|
||||
/** Successful tool result carrying one or more content blocks. */
|
||||
public static ToolResponse success(Content... content) {
|
||||
return new ToolResponse(List.of(content), false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool-level failure — per the MCP specification this is still a normal JSON-RPC
|
||||
* <em>result</em> (not a JSON-RPC error) with {@code isError: true}, so the model can see
|
||||
* and react to it.
|
||||
*/
|
||||
public static ToolResponse error(String message) {
|
||||
return new ToolResponse(List.of(new TextContent(message)), true);
|
||||
}
|
||||
|
||||
List<Content> content() { return content; }
|
||||
boolean isError() { return isError; }
|
||||
}
|
||||
+94
@@ -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); }
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.relism.flash.extension.FlashApp;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.net.ServerSocket;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** End-to-end JSON-RPC lifecycle over the real Streamable HTTP endpoint — no OAuth2 involved. */
|
||||
class McpExtensionIntegrationTest {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private FlashApp app;
|
||||
private String mcpUrl;
|
||||
private HttpClient client;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
int port;
|
||||
try (ServerSocket s = new ServerSocket(0)) {
|
||||
port = s.getLocalPort();
|
||||
}
|
||||
mcpUrl = "http://127.0.0.1:" + port + "/mcp";
|
||||
client = HttpClient.newHttpClient();
|
||||
|
||||
McpConfig config = McpConfig.builder("test-server")
|
||||
.version("9.9.9")
|
||||
.toolsPackage("dev.relism.flash.ext.mcp.fixtures")
|
||||
.security(McpSecurity.NONE)
|
||||
.build();
|
||||
|
||||
app = FlashApp.create(port);
|
||||
app.install(new McpExtension(config));
|
||||
app.start();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (app != null) app.stop();
|
||||
}
|
||||
|
||||
@Test
|
||||
void initialize_returnsProtocolVersionCapabilitiesAndServerInfo() throws Exception {
|
||||
JsonNode result = call(1, "initialize", "{}").get("result");
|
||||
|
||||
assertTrue(result.has("protocolVersion"));
|
||||
assertEquals("test-server", result.get("serverInfo").get("name").asText());
|
||||
assertEquals("9.9.9", result.get("serverInfo").get("version").asText());
|
||||
assertTrue(result.get("capabilities").has("tools"));
|
||||
assertTrue(result.get("capabilities").has("resources"));
|
||||
assertTrue(result.get("capabilities").has("prompts"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void toolsList_containsRegisteredTools() throws Exception {
|
||||
JsonNode tools = call(2, "tools/list", "{}").get("result").get("tools");
|
||||
assertEquals(2, tools.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void toolsCall_echo_returnsContent() throws Exception {
|
||||
JsonNode result = call(3, "tools/call", "{\"name\":\"echo\",\"arguments\":{\"text\":\"hi there\"}}").get("result");
|
||||
assertFalse(result.get("isError").asBoolean());
|
||||
assertEquals("hi there", result.get("content").get(0).get("text").asText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void toolsCall_failingTool_returnsIsErrorResultNotProtocolError() throws Exception {
|
||||
JsonNode response = call(4, "tools/call", "{\"name\":\"boom\",\"arguments\":{}}");
|
||||
assertFalse(response.has("error"));
|
||||
JsonNode result = response.get("result");
|
||||
assertTrue(result.get("isError").asBoolean());
|
||||
assertTrue(result.get("content").get(0).get("text").asText().contains("kaboom"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void toolsCall_unknownTool_returnsJsonRpcInvalidParamsError() throws Exception {
|
||||
JsonNode response = call(5, "tools/call", "{\"name\":\"nope\",\"arguments\":{}}");
|
||||
assertEquals(-32602, response.get("error").get("code").asInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resourcesRead_returnsTextContents() throws Exception {
|
||||
JsonNode result = call(6, "resources/read", "{\"uri\":\"greeting://hello\"}").get("result");
|
||||
assertEquals("hello world", result.get("contents").get(0).get("text").asText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void promptsGet_rendersMessage() throws Exception {
|
||||
JsonNode result = call(7, "prompts/get", "{\"name\":\"summarize\",\"arguments\":{\"text\":\"foo\"}}").get("result");
|
||||
assertEquals("Summarize: foo", result.get("messages").get(0).get("content").get("text").asText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void notification_returns202WithEmptyBody() throws Exception {
|
||||
String body = "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}";
|
||||
HttpResponse<String> resp = post(body);
|
||||
assertEquals(202, resp.statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedJson_returns400ParseError() throws Exception {
|
||||
HttpResponse<String> resp = post("not json");
|
||||
assertEquals(400, resp.statusCode());
|
||||
JsonNode json = MAPPER.readTree(resp.body());
|
||||
assertEquals(-32700, json.get("error").get("code").asInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownMethod_returnsJsonRpcMethodNotFound() throws Exception {
|
||||
JsonNode response = call(8, "not/a/method", "{}");
|
||||
assertEquals(-32601, response.get("error").get("code").asInt());
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private JsonNode call(int id, String method, String paramsJson) throws Exception {
|
||||
String body = "{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"method\":\"" + method + "\",\"params\":" + paramsJson + "}";
|
||||
HttpResponse<String> resp = post(body);
|
||||
assertEquals(200, resp.statusCode());
|
||||
return MAPPER.readTree(resp.body());
|
||||
}
|
||||
|
||||
private HttpResponse<String> post(String body) throws Exception {
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create(mcpUrl))
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||
.build();
|
||||
return client.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
}
|
||||
}
|
||||
+131
@@ -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<String> resp = post(port, initializeBody(), null);
|
||||
assertEquals(200, resp.statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void required_withOidc_rejectsMissingToken() throws Exception {
|
||||
int port = bootSecuredApp(null);
|
||||
|
||||
HttpResponse<String> resp = post(port, initializeBody(), null);
|
||||
assertEquals(401, resp.statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void required_withOidc_rejectsWrongAudience() throws Exception {
|
||||
int port = bootSecuredApp("https://mcp.example.com/mcp");
|
||||
String token = provider.signToken("user-1", "https://someone-else.example.com/resource");
|
||||
|
||||
HttpResponse<String> resp = post(port, initializeBody(), token);
|
||||
assertEquals(403, resp.statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void required_withOidc_acceptsValidAudience() throws Exception {
|
||||
String resourceId = "https://mcp.example.com/mcp";
|
||||
int port = bootSecuredApp(resourceId);
|
||||
String token = provider.signToken("user-1", resourceId);
|
||||
|
||||
HttpResponse<String> resp = post(port, initializeBody(), token);
|
||||
assertEquals(200, resp.statusCode());
|
||||
assertTrue(resp.body().contains("\"protocolVersion\""));
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private int bootSecuredApp(String resourceIdentifier) throws Exception {
|
||||
provider = new FakeOidcProvider();
|
||||
int port = freePort();
|
||||
|
||||
OidcConfig oidcConfig = OidcConfig.builder(
|
||||
provider.issuer(), "mcp-client", "secret", "/auth/callback")
|
||||
.build();
|
||||
|
||||
var mcpBuilder = McpConfig.builder("secure-server")
|
||||
.toolsPackage(TOOLS_PACKAGE)
|
||||
.security(McpSecurity.REQUIRED);
|
||||
if (resourceIdentifier != null) mcpBuilder.resourceIdentifier(resourceIdentifier);
|
||||
|
||||
app = FlashApp.create(port);
|
||||
app.install(new OidcExtension(oidcConfig));
|
||||
app.install(new McpExtension(mcpBuilder.build()));
|
||||
app.start();
|
||||
return port;
|
||||
}
|
||||
|
||||
private static String initializeBody() {
|
||||
return "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}";
|
||||
}
|
||||
|
||||
private static int freePort() throws Exception {
|
||||
try (ServerSocket s = new ServerSocket(0)) {
|
||||
return s.getLocalPort();
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpResponse<String> post(int port, String body, String bearerToken) throws Exception {
|
||||
HttpRequest.Builder req = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/mcp"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body));
|
||||
if (bearerToken != null) req.header("Authorization", "Bearer " + bearerToken);
|
||||
return HttpClient.newHttpClient().send(req.build(), HttpResponse.BodyHandlers.ofString());
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.relism.flash.exceptions.InitializationException;
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class McpRegistryTest {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void scan_findsAndPrecompilesToolsResourcesPrompts() throws Exception {
|
||||
McpRegistry registry = McpRegistry.scan("dev.relism.flash.ext.mcp.fixtures", new FlashContext());
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class ToolArgumentsTest {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private ToolArguments of(String json) throws Exception {
|
||||
return new ToolArguments(MAPPER.readTree(json));
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsTypedFields() throws Exception {
|
||||
ToolArguments args = of("{\"city\":\"Rome\",\"days\":3,\"temp\":21.5,\"metric\":true}");
|
||||
|
||||
assertEquals("Rome", args.getString("city"));
|
||||
assertEquals(3, args.getInt("days"));
|
||||
assertEquals(21.5, args.getDouble("temp"));
|
||||
assertTrue(args.getBoolean("metric"));
|
||||
assertTrue(args.has("city"));
|
||||
assertFalse(args.has("missing"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingFieldsFallBackToDefaults() throws Exception {
|
||||
ToolArguments args = of("{}");
|
||||
|
||||
assertNull(args.getString("missing"));
|
||||
assertEquals("fallback", args.getString("missing", "fallback"));
|
||||
assertEquals(0, args.getInt("missing"));
|
||||
assertEquals(42, args.getInt("missing", 42));
|
||||
assertFalse(args.getBoolean("missing"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullArgumentsNodeBehavesAsEmpty() {
|
||||
ToolArguments args = new ToolArguments(null);
|
||||
assertFalse(args.has("anything"));
|
||||
assertNull(args.getString("anything"));
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package dev.relism.flash.ext.mcp.fixtures;
|
||||
|
||||
import dev.relism.flash.ext.mcp.McpTool;
|
||||
import dev.relism.flash.ext.mcp.TextContent;
|
||||
import dev.relism.flash.ext.mcp.Tool;
|
||||
import dev.relism.flash.ext.mcp.ToolArg;
|
||||
import dev.relism.flash.ext.mcp.ToolArguments;
|
||||
import dev.relism.flash.ext.mcp.ToolResponse;
|
||||
|
||||
@Tool(name = "echo", description = "Echoes the given text",
|
||||
args = @ToolArg(name = "text", description = "Text to echo", required = true))
|
||||
public class EchoTool extends McpTool {
|
||||
|
||||
@Override
|
||||
public ToolResponse call(ToolArguments args) {
|
||||
return ToolResponse.success(new TextContent(args.getString("text")));
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package dev.relism.flash.ext.mcp.fixtures;
|
||||
|
||||
import dev.relism.flash.ext.mcp.McpTool;
|
||||
import dev.relism.flash.ext.mcp.Tool;
|
||||
import dev.relism.flash.ext.mcp.ToolArguments;
|
||||
import dev.relism.flash.ext.mcp.ToolResponse;
|
||||
|
||||
@Tool(name = "boom", description = "Always fails")
|
||||
public class FailingTool extends McpTool {
|
||||
|
||||
@Override
|
||||
public ToolResponse call(ToolArguments args) {
|
||||
throw new IllegalStateException("kaboom");
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package dev.relism.flash.ext.mcp.fixtures;
|
||||
|
||||
import dev.relism.flash.ext.mcp.McpResource;
|
||||
import dev.relism.flash.ext.mcp.Resource;
|
||||
import dev.relism.flash.ext.mcp.ResourceContents;
|
||||
import dev.relism.flash.ext.mcp.TextResourceContents;
|
||||
|
||||
@Resource(uri = "greeting://hello", description = "A greeting", mimeType = "text/plain")
|
||||
public class GreetingResource extends McpResource {
|
||||
|
||||
@Override
|
||||
public ResourceContents read() {
|
||||
return TextResourceContents.of(uri(), "text/plain", "hello world");
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package dev.relism.flash.ext.mcp.fixtures;
|
||||
|
||||
import dev.relism.flash.ext.mcp.McpPrompt;
|
||||
import dev.relism.flash.ext.mcp.Prompt;
|
||||
import dev.relism.flash.ext.mcp.PromptArg;
|
||||
import dev.relism.flash.ext.mcp.PromptArguments;
|
||||
import dev.relism.flash.ext.mcp.PromptMessage;
|
||||
import dev.relism.flash.ext.mcp.TextContent;
|
||||
|
||||
@Prompt(name = "summarize", description = "Summarizes the given text",
|
||||
args = @PromptArg(name = "text", required = true))
|
||||
public class SummarizePrompt extends McpPrompt {
|
||||
|
||||
@Override
|
||||
public PromptMessage render(PromptArguments args) {
|
||||
return PromptMessage.withUserRole(new TextContent("Summarize: " + args.getString("text")));
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@
|
||||
<module>flash-ext-view-thymeleaf</module>
|
||||
<module>flash-ext-limiter</module>
|
||||
<module>flash-ext-web-bundler</module>
|
||||
<module>flash-ext-mcp</module>
|
||||
<module>flash-ext-data-core</module>
|
||||
<module>flash-ext-data-jdbc</module>
|
||||
<module>flash-ext-data-hibernate</module>
|
||||
|
||||
Reference in New Issue
Block a user