Summary
Add MCP (Model Context Protocol) server support to the existing ai Maven module. RESTHeart becomes an MCP platform: any plugin can expose itself to AI agents through a uniform mechanism. The MCP server has no special knowledge of MongoDB, GraphQL, or any specific RESTHeart subsystem — it operates entirely on an abstraction (McpAware) that plugins implement.
The MCP server provides exactly two tools:
mcp_discover — discovers available resources (catalog or per-resource context).
how_to_call — composes a request descriptor for invoking a resource. Composes, does not execute.
Plugins participate in MCP by:
- Implementing
McpAware (mandatory if mcp = true is declared — boot fails otherwise).
- Setting
mcp = true in @RegisterPlugin (overridable to false via configuration; false always wins).
McpAware has only default methods. A plugin can implement it without overriding anything and still work — the default describeMcp() builds the resource from defaultMcpConfig() (a code-baked default the plugin can override) deep-merged with operator-supplied mcp-config from the plugin's configuration. Plugins with dynamic resource needs (e.g. MongoService, GraphQLService) override describeMcp() directly to compute resources at runtime.
The transport is Streamable HTTP (MCP spec 2025-03-26) at /mcp. OAuth is delegated to RESTHeart's existing OAuth 2.1 stack.
This issue is the foundation. Two follow-up issues build on top:
- v2: MongoDB API as MCP —
MongoService and GraphQLService implement McpAware to expose collections, aggregations, change streams, GraphQL apps as MCP resources, deriving descriptions from existing collection/app metadata.
- v3: MCP
resources primitive — adds resources/list, resources/read, resources/subscribe on top of the same model.
Design principles
-
No API duplication. RESTHeart's existing endpoints are the source of truth. The MCP server describes them and helps agents construct requests; it never reimplements them.
-
One tool, one model. how_to_call composes requests for any resource regardless of kind. No per-resource tools.
-
MCP framework, not MongoDB framework. The MCP server has no MongoDB-specific code. It calls McpAware.describeMcp() on participating plugins and operates on the abstract McpResource model. MongoDB integration is just one consumer of this framework.
-
Opt-in at plugin level, override at operator level. Plugins declare participation via @RegisterPlugin(mcp = true). Operators can flip it via configuration (mcp: true|false in plugins-args). mcp = false always wins — operators have final say.
-
Tool-agnostic descriptors. Composed requests describe what to send (transport, URL, headers, body, message format) — never which CLI to use. The agent picks curl, httpie, wscat, native fetch, generated code, whatever its host supports.
-
Automate everything possible. The MCP server derives whatever it can: transports from RESTHeart configuration, validation from JSON Schema (for body_schema declarations), examples rendered through how_to_call itself.
Plugin participation
The opt-in flag
A new optional property on @RegisterPlugin:
@RegisterPlugin(
name = "order-processor",
description = "Triggers order fulfillment", // RESTHeart-level, not MCP
defaultURI = "/orders/_process",
mcp = true // default: false
)
public class OrderProcessor implements JsonService { ... }
The flag is purely on/off. It contains no MCP description content — that lives in configuration or in a custom McpAware implementation.
Operator override
Like defaultURI is overridden by uri in configuration, the mcp flag is overridden by configuration:
plugins-args:
order-processor:
uri: "/orders/_process"
mcp: false # operator can disable even if annotation says true
Precedence: mcp = false always wins. If either the annotation or the configuration sets mcp: false, the plugin is not exposed via MCP. This gives operators final control regardless of what the plugin author chose.
Hard rule: mcp = true requires implements McpAware
If @RegisterPlugin(mcp = true) is declared on a class that does not implement McpAware, the framework fails the boot with a clear error. There is no "configuration-only" path. Participating in MCP is a deliberate Java-level decision; the framework refuses to silently work around a misdeclared annotation.
The configuration-side override (mcp: false in YAML) still applies as the final on/off switch.
Two participation modes
Mode A — Minimal effort with code-baked default
The plugin implements McpAware and overrides only defaultMcpConfig(). The default describeMcp() provided by the interface uses this code-baked default (deep-merged with whatever mcp-config the operator supplies in YAML; operator wins on conflict) to build the resource. No imperative MCP logic in the plugin.
@RegisterPlugin(name = "ping", defaultURI = "/ping", mcp = true)
public class PingService implements JsonService, McpAware {
@Override
public Map<String, Object> defaultMcpConfig() {
return Map.of(
"description", "Liveness probe. Returns 200 OK when the server is up.",
"actions", Map.of(
"ping", Map.of("method", "GET")
)
);
}
@Override
public void handle(JsonRequest req, JsonResponse res) {
res.setContent(Map.of("status", "ok"));
}
}
The operator sees a working MCP description out of the box — no YAML required. They can still override the description in their language, add examples, or disable the plugin entirely:
plugins-args:
ping:
mcp-config:
description: "Sonda di liveness." # overrides default
examples: # adds to the default (deep merge)
- description: "Health check"
action: ping
This is the recommended pattern for single-resource plugins with a sensible default. A plugin can also leave defaultMcpConfig() returning null (the interface default) — in that case, the operator's mcp-config is mandatory; without it the resource is registered with an empty description and a warning is logged.
Mode B — Custom (programmatic)
The plugin overrides describeMcp() directly. Used when:
- The plugin contributes multiple resources dynamically (one per MongoDB collection, one per GraphQL app, etc.).
- The plugin's MCP description depends on runtime state (e.g. reading metadata from a database).
- The plugin needs ACL-aware filtering of which resources to expose.
@RegisterPlugin(name = "task-runner", defaultURI = "/tasks", mcp = true)
public class TaskRunnerService implements JsonService, McpAware {
private final TaskRegistry registry;
@Override
public List<McpResource> describeMcp(McpContext ctx) {
return registry.allTasks().stream()
.filter(task -> task.canBeAccessedBy(ctx.principal()))
.map(task -> McpResource.builder()
.uri(ctx.baseUrl() + "/tasks/" + task.name())
.kind("service")
.description(task.description())
.transport(Transport.HTTP)
.action("run", a -> a
.method("POST")
.bodySchema(task.inputSchema()))
.build())
.toList();
}
}
A custom describeMcp() is responsible for its own configuration handling. defaultMcpConfig() is not automatically consulted — overriding describeMcp() means taking full control of resource construction. A custom implementation may still call defaultMcpConfig() and ctx.pluginConfiguration() to reuse the merge behaviour, but there is no contractual guarantee that it will.
If mcp = false (annotation or configuration), describeMcp() is not consulted — the on/off flag is final.
Core abstractions
McpAware interface
package org.restheart.ai.mcp;
public interface McpAware {
/**
* Returns the MCP resources this plugin contributes to the catalog.
* Called by the MCP server at boot and on cache invalidation.
*
* The default implementation builds a single McpResource from the
* deep-merge of:
*
* defaultMcpConfig() + operator-supplied mcp-config from YAML
*
* (operator wins on conflict). This covers the common case of a
* single-resource plugin with a code-baked default.
*
* Override this method when:
* - the plugin contributes multiple resources (one per database
* collection, GraphQL app, registered task, etc.)
* - the resource list is dynamic (depends on runtime state)
* - per-principal ACL filtering is needed (use ctx.principal())
*
* A custom override is responsible for its own configuration handling.
* defaultMcpConfig() is NOT automatically merged in — there is no
* contractual guarantee that an override will use it.
*/
default List<McpResource> describeMcp(McpContext ctx) {
// Implementation detail: the framework merges defaultMcpConfig()
// with ctx.pluginConfiguration().get("mcp-config") and constructs
// a single McpResource. See McpResourceParser for the parsing logic.
return McpResourceParser.fromMerged(
defaultMcpConfig(),
ctx.pluginConfiguration()
).buildSingle(ctx);
}
/**
* Optional default MCP configuration baked into the plugin code. Used
* exclusively by the default describeMcp() implementation to build a
* resource without requiring operator YAML.
*
* The framework deep-merges this with the operator-supplied `mcp-config`
* from the plugin's configuration (operator wins on conflict).
*
* Use cases:
* - Built-in plugins like /ping that should "just work" without
* requiring the operator to write any mcp-config in YAML.
* - Plugins that have a sensible default and want operator overrides
* to be additive or selective.
*
* Default returns null — meaning no code-baked default. If the
* operator also supplies no mcp-config, the resource is registered
* with an empty description and the framework logs a warning.
*
* Plugins that override describeMcp() are NOT required to consult
* this method.
*/
default Map<String, Object> defaultMcpConfig() { return null; }
/**
* Optional hook: called by the plugin (or RESTHeart core) when the
* implementation knows its resources have changed (e.g. metadata write,
* configuration reload). The MCP server invalidates its cache and
* emits notifications/tools/list_changed if needed.
*
* Default implementation is a no-op (caching relies on TTL).
*/
default void registerInvalidationHook(InvalidationHook hook) {}
}
McpContext
Passed to describeMcp. Carries:
principal() — the authenticated principal of the MCP session (for ACL filtering)
baseUrl() — the public base URL of the RESTHeart instance (for constructing absolute URIs)
pluginName() — the name of the plugin being asked
pluginConfiguration() — the resolved configuration map for the plugin (so custom McpAware implementations can also read mcp-config if they want)
McpResource
A POJO that mirrors the uniform mcp description schema (see "Resource schema" below). Built via a fluent builder:
McpResource.builder()
.uri(ctx.baseUrl() + "/orders/_process")
.kind("service")
.description("Triggers order fulfillment.")
.transport(Transport.HTTP)
.action("process", a -> a
.method("POST")
.pathTemplate("/{orderId}")
.param("orderId", "string", true))
.build();
Same JSON shape as the operator-supplied YAML mcp-config. Whether the McpResource is built by McpResourceParser (default describeMcp() path) or by a custom describeMcp() implementation, the rest of the MCP server sees the same model.
McpResourceParser (internal helper)
The framework's helper that turns a deep-merged config map into an McpResource. Used by the default describeMcp() and available for custom implementations that want to delegate parts of their work to the same logic.
public final class McpResourceParser {
/**
* Deep-merges code-baked defaults with operator overrides (operator
* wins on conflict; either argument may be null) and returns a parser
* ready to build the McpResource.
*/
public static McpResourceParser fromMerged(
Map<String,Object> codeDefaults,
Map<String,Object> operatorConfig
) { ... }
/** Builds a single McpResource using ctx.baseUrl() + plugin URI for the resource URI. */
public McpResource buildSingle(McpContext ctx) { ... }
}
This is an implementation detail of the framework — plugins normally don't reference it. It is exposed primarily so custom McpAware implementations can opt into the same merge behaviour for parts of their resource construction if they wish.
The uniform resource schema
Every McpResource follows the same shape — whether produced by the default describeMcp() (via McpResourceParser), by MongoService, by GraphQLService, or by any custom plugin:
{
"uri": "https://...",
"kind": "service | database | collection | aggregation | change-stream | graphql-app",
"description": "...",
"transports": [
{ "name": "http", "actions": ["process", "cancel", "status"] },
{ "name": "websocket", "actions": ["subscribe"], "url_scheme": "wss" },
{ "name": "sse", "actions": ["subscribe"], "media_type": "text/event-stream" }
],
"actions": {
"<actionName>": {
"method": "GET | POST | PATCH | PUT | DELETE",
"path_template": "/...{var}...",
"params": { "<paramName>": { "type": "...", "description": "...", "required": true|false, "enum": [...], "default": ... } },
"body_schema": { /* optional JSON Schema for the body */ },
"description": "..."
}
},
"auth": { /* OAuth + current principal effective permissions */ },
"examples": [
{
"description": "...",
"action": "<actionName>",
"args": { "...": "..." }
}
]
}
The kind field is purely informational for agents (service is the v1 default; v2 introduces the MongoDB-specific kinds). The MCP server treats all kinds uniformly.
The transports array is automatically derived by the framework from the plugin's interfaces. A JsonService plugin gets [{name: "http", ...}]. A WebSocketService plugin gets [{name: "websocket", ...}]. A plugin implementing both gets both. Custom McpAware implementations can override this when they know better (e.g. MongoService will declare [websocket, sse] for change-stream resources).
Tools
mcp_discover — discovery
{
"name": "mcp_discover",
"description": "Lists or describes MCP-enabled resources exposed by RESTHeart. Without arguments, returns the catalog (URIs, kinds, short descriptions). With a resource URI, returns full context: kind, supported transports, actions with parameter types, auth requirements, examples. Call this before how_to_call to learn what you can do with a resource.",
"inputSchema": {
"type": "object",
"properties": {
"resource": { "type": "string", "format": "uri", "description": "Optional. Omit for the full catalog." }
}
}
}
Catalog response (no argument):
{
"resources": [
{ "uri": "https://cloud.restheart.com/orders/_process", "kind": "service", "description": "Order fulfillment." },
{ "uri": "https://cloud.restheart.com/healthcheck", "kind": "service", "description": "System health probe." }
]
}
Resource-specific response — the full McpResource JSON for that URI.
The catalog is filtered by the authenticated principal: each plugin's describeMcp(ctx) decides whether to return its resources for the current ctx.principal().
how_to_call — request composer
{
"name": "how_to_call",
"description": "Returns a request descriptor (transport, URL, headers, body) for invoking a known MCP resource. The tool COMPOSES the request — it does NOT execute it. After receiving the response, choose any client appropriate to the descriptor's transport and your host environment (HTTP libraries, WebSocket libraries, OS shells with curl/httpie/wscat, generated code in any language). The MCP server does not prescribe the tool.\n\nDispatch by action — the set of valid actions for a given resource is declared in the resource's mcp_discover output. Validate args against the declared params and body_schema before calling.",
"inputSchema": {
"type": "object",
"properties": {
"resource": { "type": "string", "format": "uri", "description": "Resource URI." },
"action": { "type": "string", "description": "Action name as declared in the resource's actions map." },
"args": { "type": "object", "description": "Action arguments — values for params and body declared by the resource." },
"transport": { "type": "string", "description": "Optional transport preference (e.g. websocket vs sse for streams). If omitted, the resource's default transport is used." },
"token": { "type": "string", "description": "Optional access token. If omitted, a `<token>` placeholder is embedded." }
},
"required": ["resource", "action"]
}
}
Validation:
- Resource exists and the principal can access it → otherwise structured error.
- Action exists in the resource's
actions map → otherwise error listing valid actions.
- Args' types match the action's
params declarations.
- Body (if any) validates against the action's
body_schema.
Descriptor format — uniform, transport-agnostic:
// HTTP descriptor
{
"transport": "http",
"method": "POST",
"url": "https://...",
"headers": { "Authorization": "Bearer <token>", "Content-Type": "application/json" },
"body": { ... },
"validation": { "params_check": "passed", "body_check": "passed" },
"expected_response": { "status": 200 }
}
// WebSocket descriptor
{
"transport": "websocket",
"url": "wss://...",
"headers": { "Authorization": "Bearer <token>" },
"message_format": { "description": "...", "example_event": { ... } }
}
// SSE descriptor
{
"transport": "sse",
"method": "GET",
"url": "https://...",
"headers": { "Authorization": "Bearer <token>", "Accept": "text/event-stream" },
"message_format": { "description": "..." }
}
The agent picks the client based on transport. The descriptor is everything needed; nothing is prescribed about how to issue the request.
Transport (Streamable HTTP, MCP 2025-03-26)
Single endpoint /mcp per the Streamable HTTP transport spec. SSE is used internally as the streaming mechanism for POST responses and the GET notification stream — not a separate transport, no /sse endpoint.
- POST — JSON-RPC:
initialize, tools/list, tools/call, notifications. Response: application/json or text/event-stream.
- GET — persistent SSE notification stream (
notifications/tools/list_changed when a plugin's resources change).
- DELETE — terminate session.
Mcp-Session-Id header for sessions; Last-Event-ID for resumability; Origin validation per spec.
Authorization: delegated to RESTHeart's OAuth 2.1 stack
RESTHeart already implements OAuth 2.1 with PKCE and the well-known metadata endpoints (RFC 8414, RFC 9728) explicitly designed for MCP clients. The MCP service reuses them without writing new OAuth code:
| Existing endpoint |
MCP role |
POST /token, GET/POST /authorize |
PKCE flow |
GET /.well-known/oauth-authorization-server (RFC 8414) |
Authorization Server Metadata |
GET /.well-known/oauth-protected-resource (RFC 9728) |
Protected Resource Metadata |
jwtTokenManager |
Validates Bearer tokens — secure = true on McpService is enough |
MCP authorization flow. Unauthenticated POST /mcp → 401 with WWW-Authenticate: Bearer resource_metadata=".well-known/oauth-protected-resource". Client fetches Protected Resource Metadata → discovers RESTHeart as authorization server → PKCE flow → Bearer token → authenticated POST /mcp.
Operator setup (standard RESTHeart OAuth, no MCP-specific config):
oauthAuthorizationServerMetadataService: { enabled: true }
oauthAuthorizationService:
enabled: true
login-url: https://myapp.example.com/login
allowed-redirect-uris: [https://claude.ai/api/mcp/auth_callback, "http://localhost:*"]
oauthProtectedResourceMetadataService: { enabled: true }
Architecture (ai module)
org.restheart.ai.mcp/
McpService.java # /mcp — POST + GET + DELETE
McpSessionManager.java
McpStreamManager.java # GET SSE; event IDs; resumability
McpManifestBuilder.java # tools/list (mcp_discover + how_to_call)
McpAwareRegistry.java # Discovers McpAware plugins; enforces mcp=true ⇒ implements McpAware
api/
McpAware.java # Public interface for plugins
McpContext.java # Principal + baseUrl + pluginName + pluginConfiguration
McpResource.java # POJO + builder — uniform resource schema
InvalidationHook.java # Optional cache-invalidation hook
internal/
McpResourceParser.java # Deep-merges defaultMcpConfig() with operator config; builds McpResource
ConfigDeepMerger.java # Merge utility (operator wins; arrays per-key strategy)
tools/
DiscoverTool.java # mcp_discover — catalog + per-resource
HowToCallTool.java # how_to_call — descriptor composer
transport/
TransportDeriver.java # Plugin interfaces → available transports
DescriptorRenderer.java # Action invocation → uniform descriptor
validation/
ParamValidator.java # args vs action.params
BodyValidator.java # body vs action.body_schema (delegates to existing JSON Schema validator)
config/
McpConfiguration.java
Plugin components (one-line summaries)
McpService — single /mcp endpoint. Validates Origin. Dispatches POST/GET/DELETE.
McpSessionManager — Mcp-Session-Id lifecycle.
McpStreamManager — per-session SSE GET stream; event IDs; resumability buffer; keep-alive pings.
McpManifestBuilder — exactly two tools: mcp_discover, how_to_call. Cached.
McpAwareRegistry — at boot, scans all registered plugins. For each plugin:
- resolve effective
mcp flag: false always wins between annotation and configuration.
- if
mcp = false → not registered, period.
- if
mcp = true and plugin does not implement McpAware → fail boot with a clear error message naming the plugin and pointing to the docs (the annotation is a misdeclaration).
- if
mcp = true and plugin implements McpAware → register the implementation. The default describeMcp() will use defaultMcpConfig() deep-merged with the operator's mcp-config; a custom describeMcp() is invoked as-is.
- On
describeMcp(ctx) returning an empty resource (no description, no actions) and the plugin uses the default path → log a warning ("MCP enabled but neither defaultMcpConfig() nor mcp-config in YAML supplies a description").
DiscoverTool — handles mcp_discover. Without args → calls describeMcp(ctx) on every registered McpAware, flattens results, returns catalog. With resource URI → finds the contributing plugin, returns the full resource context.
HowToCallTool — handles how_to_call. Locates the McpResource, validates action and args against the action's declarations, calls DescriptorRenderer to build the transport-specific descriptor, embeds Bearer <token> placeholder when no token provided.
TransportDeriver — given a plugin's implemented interfaces (JsonService, WebSocketService, etc.), returns the default transports array. McpAware implementations can override.
DescriptorRenderer — assembles the descriptor from the action declaration and the supplied args. Renders URL with path-template substitution and query-string encoding for HTTP. Includes message_format for WebSocket/SSE actions.
ParamValidator — checks types, required, enum constraints.
BodyValidator — delegates to RESTHeart's existing JSON Schema validation.
Authorization is not a plugin component — handled entirely by existing RESTHeart services.
MCP capabilities at initialize
{ "capabilities": { "tools": { "listChanged": true } } }
(No resources capability in v1 — see v3 follow-up issue.)
Example: Mode A with no code-baked default (operator-only description)
A simple "echo" service. The plugin author opts in to MCP but doesn't bake any default — leaving the description entirely to the operator.
@RegisterPlugin(
name = "echo",
description = "Echoes the request body",
defaultURI = "/echo",
mcp = true
)
public class EchoService implements JsonService, McpAware {
// No defaultMcpConfig() override — operator must supply mcp-config in YAML.
// No describeMcp() override — uses the default merge logic.
@Override
public void handle(JsonRequest request, JsonResponse response) {
response.setContent(request.getContent());
}
}
The operator supplies the description:
plugins-args:
echo:
uri: "/echo"
mcp: true
mcp-config:
description: "Echoes the request body. Useful for testing MCP integration."
actions:
echo:
method: POST
body-schema:
type: object
description: "Any JSON object."
examples:
- description: "Echo a hello message"
action: echo
args: { body: { message: "hello" } }
mcp_discover() returns this resource in the catalog. how_to_call(resource: ".../echo", action: "echo", args: { body: {...} }) returns:
{
"transport": "http",
"method": "POST",
"url": "https://cloud.restheart.com/echo",
"headers": { "Authorization": "Bearer <token>", "Content-Type": "application/json" },
"body": { "message": "hello" }
}
If the operator omits mcp-config, the resource appears in the catalog with an empty description and a warning is logged at boot — the plugin participates but has nothing to say about itself.
Example: Mode A with code-baked default (the common case)
A ping service that ships with a sensible MCP description out of the box. Operators don't need to write any mcp-config to make it work; they can override pieces if they want.
@RegisterPlugin(
name = "ping",
description = "Liveness probe",
defaultURI = "/ping",
mcp = true
)
public class PingService implements JsonService, McpAware {
@Override
public Map<String, Object> defaultMcpConfig() {
return Map.of(
"description", "Liveness probe. Returns 200 OK with {status: ok} when the server is up.",
"actions", Map.of(
"ping", Map.of("method", "GET")
),
"examples", List.of(
Map.of("description", "Health check", "action", "ping")
)
);
}
@Override
public void handle(JsonRequest request, JsonResponse response) {
response.setContent(Map.of("status", "ok"));
}
}
With no operator configuration, mcp_discover already returns:
{
"uri": "https://cloud.restheart.com/ping",
"kind": "service",
"description": "Liveness probe. Returns 200 OK with {status: ok} when the server is up.",
"transports": [{ "name": "http", "actions": ["ping"] }],
"actions": { "ping": { "method": "GET" } },
"examples": [{ "description": "Health check", "action": "ping" }]
}
The operator can refine the description (translate it, tighten it) and add custom examples without recompiling:
plugins-args:
ping:
mcp-config:
description: "Sonda di liveness — restituisce 200 quando il server è attivo."
examples:
- description: "Probe per il monitoring esterno"
action: ping
The deep merge yields the Italian description plus both the code-baked example and the operator's example.
Example: Mode B (custom describeMcp for dynamic resources)
@RegisterPlugin(name = "task-runner", defaultURI = "/tasks", mcp = true)
public class TaskRunnerService implements JsonService, McpAware {
private final TaskRegistry registry;
@Override
public List<McpResource> describeMcp(McpContext ctx) {
return registry.allTasks().stream()
.filter(task -> task.canBeAccessedBy(ctx.principal()))
.map(task -> McpResource.builder()
.uri(ctx.baseUrl() + "/tasks/" + task.name())
.kind("service")
.description(task.description())
.transport(Transport.HTTP)
.action("run", a -> a
.method("POST")
.bodySchema(task.inputSchema()))
.build())
.toList();
}
}
The MCP server treats each task as a separate resource. The plugin author owns the logic; the MCP framework only needs the McpAware contract.
Configuration
plugins-args:
mcp-service:
enabled: true
uri: "/mcp"
secure: true
server-name: "restheart-mcp"
server-version: "1.0.0"
discover:
cache-ttl-seconds: 60
render-examples: true
session: { enabled: true, expiry-seconds: 3600 }
stream: { resumability-buffer-size: 100, keep-alive-interval-seconds: 30 }
how-to-call:
validate-bodies: true
embed-token-placeholder: true
backwards-compat:
http-sse-transport: false # deprecated 2024-11-05 transport, off by default
OAuth configuration is not in the MCP plugin section — it's the standard RESTHeart OAuth services configuration shared platform-wide.
Security
secure = true by default. jwtTokenManager validates Bearer tokens; existing authorizers enforce ACL. No new auth code.
- Unauthenticated →
401 with proper WWW-Authenticate header.
Origin validated on all connections (DNS rebinding protection per MCP spec).
mcp_discover returns only resources contributed by McpAware implementations for the current principal — each implementation is responsible for its own ACL filtering. The default describeMcp() (used by Mode A plugins) does not filter — Mode A resources are visible to any authenticated agent. Plugins that need ACL-aware visibility must implement Mode B (override describeMcp).
- Unknown/malformed URIs → JSON-RPC error
-32002.
- The
mcp = false precedence rule applies to both annotation and configuration sources: if either is false, the plugin is silently excluded from the MCP catalog.
Testing
Unit tests
| Test class |
Covers |
McpAwareRegistryTest |
mcp = true/false resolution from annotation + config; false always wins; mcp = true on a plugin that doesn't implement McpAware → boot fails with descriptive error; mcp = true + McpAware impl → registered |
McpResourceParserTest |
YAML mcp-config parsed into McpResource; missing fields → defaults; malformed → error; fromMerged deep-merges code-baked defaults with operator overrides (operator wins; null arguments handled); arrays follow per-key strategy (see open question) |
DefaultDescribeMcpTest |
McpAware default describeMcp() calls McpResourceParser.fromMerged with defaultMcpConfig() and ctx.pluginConfiguration().get("mcp-config"); plugin overriding only defaultMcpConfig() produces the expected resource without operator config; both null → empty resource + warning |
McpResourceParserTest |
actions / params / body-schema / examples parsing |
TransportDeriverTest |
JsonService → http; WebSocketService → websocket; combinations |
DescriptorRendererTest |
path-template substitution; query-string encoding for HTTP; WebSocket descriptor with message_format |
ParamValidatorTest |
types, required, enum, default |
BodyValidatorTest |
delegates to existing JSON Schema validator |
DiscoverToolTest |
catalog flattening across plugins; per-resource lookup |
HowToCallToolTest |
action lookup; validation; descriptor rendering; mismatched action → error |
McpManifestBuilderTest |
exactly two tools listed |
McpSessionManagerTest |
session lifecycle |
Integration tests
| Test |
Scenario |
McpDiscoverModeAOperatorOnlyIT |
Plugin implementing McpAware (no overrides), mcp = true, mcp-config in YAML → resource appears with operator-supplied description. With no mcp-config → empty description + warning logged |
McpDiscoverModeACodeBakedIT |
Plugin overriding only defaultMcpConfig() (e.g. PingService) → resource appears with code-baked description, no YAML required. Add operator mcp-config overrides → deep-merged values (operator wins) appear in catalog |
McpDiscoverModeBCustomIT |
Plugin overriding describeMcp() returning multiple resources → all appear in catalog. Filter by principal → only allowed resources returned |
McpHardRuleIT |
Plugin annotated @RegisterPlugin(mcp = true) that does not implement McpAware → boot fails with a clear error message. Same plugin with mcp = false in config → boots normally (config override) |
McpFlagPrecedenceIT |
annotation mcp=true + config mcp=false → not exposed. Both true → exposed. Both false → not exposed |
McpHowToCallIT |
Mode A plugin: how_to_call returns valid HTTP descriptor; agent issues it against RESTHeart → success. Invalid args → validation error. Disabled action → error |
McpHowToCallMultiTransportIT |
Custom plugin implementing both JsonService and WebSocketService → both transports advertised; how_to_call with each transport returns appropriate descriptor |
McpProtectedResourceIT |
Unauthenticated → 401 + WWW-Authenticate. Discover well-known. Complete PKCE. Bearer → 200 |
McpInvalidationIT |
Plugin's InvalidationHook fires → MCP server emits notifications/tools/list_changed → client refetches catalog and sees changes |
McpSessionIT |
POST without/unknown session ID → 400/404. DELETE /mcp → subsequent POST → 404 |
McpResumabilityIT |
GET stream → events → disconnect → reconnect with Last-Event-ID → replay |
Native image compatibility
The framework discovers McpAware implementations through RESTHeart's existing plugin registration mechanism (no new classpath scanning). GraalVM reflection entries for McpResource serialization classes go in core/src/main/resources/META-INF/native-image/. Custom McpAware implementations follow the same native-image rules as any other RESTHeart plugin.
Implementation plan
| Phase |
Deliverable |
| 1 |
McpAware (with defaultMcpConfig() + default describeMcp() delegating to merge) + McpContext + McpResource + builder; InvalidationHook |
| 2 |
McpResourceParser + ConfigDeepMerger (constructor + fromMerged factory) + McpResourceParserTest + DefaultDescribeMcpTest |
| 3 |
TransportDeriver + DescriptorRenderer + ParamValidator + BodyValidator + tests |
| 4 |
McpAwareRegistry (annotation/config flag resolution; boot failure when mcp = true but McpAware not implemented) + McpAwareRegistryTest + McpHardRuleIT |
| 5 |
DiscoverTool + HowToCallTool + McpManifestBuilder + tests |
| 6 |
McpSessionManager + McpService POST handler (initialize, tools/list, tools/call) + McpSessionIT |
| 7 |
McpStreamManager + GET handler + keep-alive + resumability + McpResumabilityIT |
| 8 |
InvalidationHook wiring + McpInvalidationIT |
| 9 |
OAuth integration verification: McpProtectedResourceIT |
| 10 |
Demo plugin (Mode A operator-only) + demo plugin (Mode A code-baked, e.g. PingService) + demo plugin (Mode B custom) + integration tests |
| 11 |
examples/mcp-plugin/ directory with all three demo styles + documentation |
| 12 |
Native image config |
Open questions
@RegisterPlugin(mcp = true) with no mcp-config and no McpAware impl — log a warning and skip, or fail boot? Proposal: warn and skip with empty description (operator likely forgot the config).
- ACL semantics for Mode A — the default
describeMcp() does not filter by principal. Mode A resources are visible to any authenticated agent (the plugin's own ACL still applies on actual invocation through how_to_call/REST). If finer-grained MCP visibility is needed, the plugin must override describeMcp() (Mode B). Acceptable?
McpResource.kind enumeration — fixed enum (service, database, collection, aggregation, change-stream, graphql-app) or open string? Proposal: open string, with a recommended-values list. Custom plugins may want their own kinds (workflow, report, etc.).
- Cache invalidation granularity —
notifications/tools/list_changed fires only when the catalog changes (resources added/removed). Should description-only changes also fire it? Proposal: yes, simpler model.
- Deep-merge semantics for
defaultMcpConfig() + operator config — scalars and objects are deep-merged with operator winning on conflict. For arrays (e.g. examples, actions[].params) two strategies are possible: (a) operator array fully replaces default array; (b) operator array is concatenated to default array. Proposal: (b) concatenation for examples (operator adds curated examples to defaults), (a) replacement for actions and params (operator wants full control over the action surface). Open for discussion.
- Stateless mode for RESTHeart Cloud HA — sessions are in-process. Multi-node delivery for
notifications/tools/list_changed needs a shared backend; MongoDB change streams are the natural candidate. Deferred to v1.x.
Non-goals for v1
| Sub-issue |
Rationale |
| MongoDB API as MCP |
v2 — see follow-up. MongoService and GraphQLService will implement McpAware to expose collections, aggregations, change streams, GraphQL apps. Reads metadata from each entity (collection metadata, gql-apps documents) |
MCP resources primitive |
v3 — see follow-up. Native resources/list / resources/read / resources/subscribe on top of the same McpResource model |
MCP prompts support |
Separate primitive; orthogonal |
| Backwards-compat HTTP+SSE transport (2024-11-05) |
Most current clients support Streamable HTTP |
| Stateless/multi-node session delivery |
Needed for HA; v1.x candidate |
Related
Summary
Add MCP (Model Context Protocol) server support to the existing
aiMaven module. RESTHeart becomes an MCP platform: any plugin can expose itself to AI agents through a uniform mechanism. The MCP server has no special knowledge of MongoDB, GraphQL, or any specific RESTHeart subsystem — it operates entirely on an abstraction (McpAware) that plugins implement.The MCP server provides exactly two tools:
mcp_discover— discovers available resources (catalog or per-resource context).how_to_call— composes a request descriptor for invoking a resource. Composes, does not execute.Plugins participate in MCP by:
McpAware(mandatory ifmcp = trueis declared — boot fails otherwise).mcp = truein@RegisterPlugin(overridable tofalsevia configuration;falsealways wins).McpAwarehas only default methods. A plugin can implement it without overriding anything and still work — the defaultdescribeMcp()builds the resource fromdefaultMcpConfig()(a code-baked default the plugin can override) deep-merged with operator-suppliedmcp-configfrom the plugin's configuration. Plugins with dynamic resource needs (e.g.MongoService,GraphQLService) overridedescribeMcp()directly to compute resources at runtime.The transport is Streamable HTTP (MCP spec 2025-03-26) at
/mcp. OAuth is delegated to RESTHeart's existing OAuth 2.1 stack.This issue is the foundation. Two follow-up issues build on top:
MongoServiceandGraphQLServiceimplementMcpAwareto expose collections, aggregations, change streams, GraphQL apps as MCP resources, deriving descriptions from existing collection/app metadata.resourcesprimitive — addsresources/list,resources/read,resources/subscribeon top of the same model.Design principles
No API duplication. RESTHeart's existing endpoints are the source of truth. The MCP server describes them and helps agents construct requests; it never reimplements them.
One tool, one model.
how_to_callcomposes requests for any resource regardless of kind. No per-resource tools.MCP framework, not MongoDB framework. The MCP server has no MongoDB-specific code. It calls
McpAware.describeMcp()on participating plugins and operates on the abstractMcpResourcemodel. MongoDB integration is just one consumer of this framework.Opt-in at plugin level, override at operator level. Plugins declare participation via
@RegisterPlugin(mcp = true). Operators can flip it via configuration (mcp: true|falseinplugins-args).mcp = falsealways wins — operators have final say.Tool-agnostic descriptors. Composed requests describe what to send (transport, URL, headers, body, message format) — never which CLI to use. The agent picks
curl,httpie,wscat, native fetch, generated code, whatever its host supports.Automate everything possible. The MCP server derives whatever it can: transports from RESTHeart configuration, validation from JSON Schema (for
body_schemadeclarations), examples rendered throughhow_to_callitself.Plugin participation
The opt-in flag
A new optional property on
@RegisterPlugin:The flag is purely on/off. It contains no MCP description content — that lives in configuration or in a custom
McpAwareimplementation.Operator override
Like
defaultURIis overridden byuriin configuration, themcpflag is overridden by configuration:Precedence:
mcp = falsealways wins. If either the annotation or the configuration setsmcp: false, the plugin is not exposed via MCP. This gives operators final control regardless of what the plugin author chose.Hard rule:
mcp = truerequiresimplements McpAwareIf
@RegisterPlugin(mcp = true)is declared on a class that does not implementMcpAware, the framework fails the boot with a clear error. There is no "configuration-only" path. Participating in MCP is a deliberate Java-level decision; the framework refuses to silently work around a misdeclared annotation.The configuration-side override (
mcp: falsein YAML) still applies as the final on/off switch.Two participation modes
Mode A — Minimal effort with code-baked default
The plugin implements
McpAwareand overrides onlydefaultMcpConfig(). The defaultdescribeMcp()provided by the interface uses this code-baked default (deep-merged with whatevermcp-configthe operator supplies in YAML; operator wins on conflict) to build the resource. No imperative MCP logic in the plugin.The operator sees a working MCP description out of the box — no YAML required. They can still override the description in their language, add examples, or disable the plugin entirely:
This is the recommended pattern for single-resource plugins with a sensible default. A plugin can also leave
defaultMcpConfig()returningnull(the interface default) — in that case, the operator'smcp-configis mandatory; without it the resource is registered with an empty description and a warning is logged.Mode B — Custom (programmatic)
The plugin overrides
describeMcp()directly. Used when:A custom
describeMcp()is responsible for its own configuration handling.defaultMcpConfig()is not automatically consulted — overridingdescribeMcp()means taking full control of resource construction. A custom implementation may still calldefaultMcpConfig()andctx.pluginConfiguration()to reuse the merge behaviour, but there is no contractual guarantee that it will.If
mcp = false(annotation or configuration),describeMcp()is not consulted — the on/off flag is final.Core abstractions
McpAwareinterfaceMcpContextPassed to
describeMcp. Carries:principal()— the authenticated principal of the MCP session (for ACL filtering)baseUrl()— the public base URL of the RESTHeart instance (for constructing absolute URIs)pluginName()— the name of the plugin being askedpluginConfiguration()— the resolved configuration map for the plugin (so customMcpAwareimplementations can also readmcp-configif they want)McpResourceA POJO that mirrors the uniform
mcpdescription schema (see "Resource schema" below). Built via a fluent builder:Same JSON shape as the operator-supplied YAML
mcp-config. Whether theMcpResourceis built byMcpResourceParser(defaultdescribeMcp()path) or by a customdescribeMcp()implementation, the rest of the MCP server sees the same model.McpResourceParser(internal helper)The framework's helper that turns a deep-merged config map into an
McpResource. Used by the defaultdescribeMcp()and available for custom implementations that want to delegate parts of their work to the same logic.This is an implementation detail of the framework — plugins normally don't reference it. It is exposed primarily so custom
McpAwareimplementations can opt into the same merge behaviour for parts of their resource construction if they wish.The uniform resource schema
Every
McpResourcefollows the same shape — whether produced by the defaultdescribeMcp()(viaMcpResourceParser), byMongoService, byGraphQLService, or by any custom plugin:{ "uri": "https://...", "kind": "service | database | collection | aggregation | change-stream | graphql-app", "description": "...", "transports": [ { "name": "http", "actions": ["process", "cancel", "status"] }, { "name": "websocket", "actions": ["subscribe"], "url_scheme": "wss" }, { "name": "sse", "actions": ["subscribe"], "media_type": "text/event-stream" } ], "actions": { "<actionName>": { "method": "GET | POST | PATCH | PUT | DELETE", "path_template": "/...{var}...", "params": { "<paramName>": { "type": "...", "description": "...", "required": true|false, "enum": [...], "default": ... } }, "body_schema": { /* optional JSON Schema for the body */ }, "description": "..." } }, "auth": { /* OAuth + current principal effective permissions */ }, "examples": [ { "description": "...", "action": "<actionName>", "args": { "...": "..." } } ] }The
kindfield is purely informational for agents (serviceis the v1 default; v2 introduces the MongoDB-specific kinds). The MCP server treats all kinds uniformly.The
transportsarray is automatically derived by the framework from the plugin's interfaces. AJsonServiceplugin gets[{name: "http", ...}]. AWebSocketServiceplugin gets[{name: "websocket", ...}]. A plugin implementing both gets both. CustomMcpAwareimplementations can override this when they know better (e.g.MongoServicewill declare[websocket, sse]for change-stream resources).Tools
mcp_discover— discovery{ "name": "mcp_discover", "description": "Lists or describes MCP-enabled resources exposed by RESTHeart. Without arguments, returns the catalog (URIs, kinds, short descriptions). With a resource URI, returns full context: kind, supported transports, actions with parameter types, auth requirements, examples. Call this before how_to_call to learn what you can do with a resource.", "inputSchema": { "type": "object", "properties": { "resource": { "type": "string", "format": "uri", "description": "Optional. Omit for the full catalog." } } } }Catalog response (no argument):
{ "resources": [ { "uri": "https://cloud.restheart.com/orders/_process", "kind": "service", "description": "Order fulfillment." }, { "uri": "https://cloud.restheart.com/healthcheck", "kind": "service", "description": "System health probe." } ] }Resource-specific response — the full
McpResourceJSON for that URI.The catalog is filtered by the authenticated principal: each plugin's
describeMcp(ctx)decides whether to return its resources for the currentctx.principal().how_to_call— request composer{ "name": "how_to_call", "description": "Returns a request descriptor (transport, URL, headers, body) for invoking a known MCP resource. The tool COMPOSES the request — it does NOT execute it. After receiving the response, choose any client appropriate to the descriptor's transport and your host environment (HTTP libraries, WebSocket libraries, OS shells with curl/httpie/wscat, generated code in any language). The MCP server does not prescribe the tool.\n\nDispatch by action — the set of valid actions for a given resource is declared in the resource's mcp_discover output. Validate args against the declared params and body_schema before calling.", "inputSchema": { "type": "object", "properties": { "resource": { "type": "string", "format": "uri", "description": "Resource URI." }, "action": { "type": "string", "description": "Action name as declared in the resource's actions map." }, "args": { "type": "object", "description": "Action arguments — values for params and body declared by the resource." }, "transport": { "type": "string", "description": "Optional transport preference (e.g. websocket vs sse for streams). If omitted, the resource's default transport is used." }, "token": { "type": "string", "description": "Optional access token. If omitted, a `<token>` placeholder is embedded." } }, "required": ["resource", "action"] } }Validation:
actionsmap → otherwise error listing valid actions.paramsdeclarations.body_schema.Descriptor format — uniform, transport-agnostic:
The agent picks the client based on
transport. The descriptor is everything needed; nothing is prescribed about how to issue the request.Transport (Streamable HTTP, MCP 2025-03-26)
Single endpoint
/mcpper the Streamable HTTP transport spec. SSE is used internally as the streaming mechanism for POST responses and the GET notification stream — not a separate transport, no/sseendpoint.initialize,tools/list,tools/call, notifications. Response:application/jsonortext/event-stream.notifications/tools/list_changedwhen a plugin's resources change).Mcp-Session-Idheader for sessions;Last-Event-IDfor resumability;Originvalidation per spec.Authorization: delegated to RESTHeart's OAuth 2.1 stack
RESTHeart already implements OAuth 2.1 with PKCE and the well-known metadata endpoints (RFC 8414, RFC 9728) explicitly designed for MCP clients. The MCP service reuses them without writing new OAuth code:
POST /token,GET/POST /authorizeGET /.well-known/oauth-authorization-server(RFC 8414)GET /.well-known/oauth-protected-resource(RFC 9728)jwtTokenManagersecure = trueonMcpServiceis enoughMCP authorization flow. Unauthenticated
POST /mcp→401withWWW-Authenticate: Bearer resource_metadata=".well-known/oauth-protected-resource". Client fetches Protected Resource Metadata → discovers RESTHeart as authorization server → PKCE flow → Bearer token → authenticatedPOST /mcp.Operator setup (standard RESTHeart OAuth, no MCP-specific config):
Architecture (
aimodule)Plugin components (one-line summaries)
McpService— single/mcpendpoint. ValidatesOrigin. Dispatches POST/GET/DELETE.McpSessionManager—Mcp-Session-Idlifecycle.McpStreamManager— per-session SSE GET stream; event IDs; resumability buffer; keep-alive pings.McpManifestBuilder— exactly two tools:mcp_discover,how_to_call. Cached.McpAwareRegistry— at boot, scans all registered plugins. For each plugin:mcpflag:falsealways wins between annotation and configuration.mcp = false→ not registered, period.mcp = trueand plugin does not implementMcpAware→ fail boot with a clear error message naming the plugin and pointing to the docs (the annotation is a misdeclaration).mcp = trueand plugin implementsMcpAware→ register the implementation. The defaultdescribeMcp()will usedefaultMcpConfig()deep-merged with the operator'smcp-config; a customdescribeMcp()is invoked as-is.describeMcp(ctx)returning an empty resource (no description, no actions) and the plugin uses the default path → log a warning ("MCP enabled but neither defaultMcpConfig() nor mcp-config in YAML supplies a description").DiscoverTool— handlesmcp_discover. Without args → callsdescribeMcp(ctx)on every registeredMcpAware, flattens results, returns catalog. WithresourceURI → finds the contributing plugin, returns the full resource context.HowToCallTool— handleshow_to_call. Locates theMcpResource, validates action and args against the action's declarations, callsDescriptorRendererto build the transport-specific descriptor, embedsBearer <token>placeholder when no token provided.TransportDeriver— given a plugin's implemented interfaces (JsonService,WebSocketService, etc.), returns the defaulttransportsarray.McpAwareimplementations can override.DescriptorRenderer— assembles the descriptor from the action declaration and the supplied args. Renders URL with path-template substitution and query-string encoding for HTTP. Includesmessage_formatfor WebSocket/SSE actions.ParamValidator— checks types, required, enum constraints.BodyValidator— delegates to RESTHeart's existing JSON Schema validation.Authorization is not a plugin component — handled entirely by existing RESTHeart services.
MCP capabilities at
initialize{ "capabilities": { "tools": { "listChanged": true } } }(No
resourcescapability in v1 — see v3 follow-up issue.)Example: Mode A with no code-baked default (operator-only description)
A simple "echo" service. The plugin author opts in to MCP but doesn't bake any default — leaving the description entirely to the operator.
The operator supplies the description:
mcp_discover()returns this resource in the catalog.how_to_call(resource: ".../echo", action: "echo", args: { body: {...} })returns:{ "transport": "http", "method": "POST", "url": "https://cloud.restheart.com/echo", "headers": { "Authorization": "Bearer <token>", "Content-Type": "application/json" }, "body": { "message": "hello" } }If the operator omits
mcp-config, the resource appears in the catalog with an empty description and a warning is logged at boot — the plugin participates but has nothing to say about itself.Example: Mode A with code-baked default (the common case)
A
pingservice that ships with a sensible MCP description out of the box. Operators don't need to write anymcp-configto make it work; they can override pieces if they want.With no operator configuration,
mcp_discoveralready returns:{ "uri": "https://cloud.restheart.com/ping", "kind": "service", "description": "Liveness probe. Returns 200 OK with {status: ok} when the server is up.", "transports": [{ "name": "http", "actions": ["ping"] }], "actions": { "ping": { "method": "GET" } }, "examples": [{ "description": "Health check", "action": "ping" }] }The operator can refine the description (translate it, tighten it) and add custom examples without recompiling:
The deep merge yields the Italian description plus both the code-baked example and the operator's example.
Example: Mode B (custom describeMcp for dynamic resources)
The MCP server treats each task as a separate resource. The plugin author owns the logic; the MCP framework only needs the
McpAwarecontract.Configuration
OAuth configuration is not in the MCP plugin section — it's the standard RESTHeart OAuth services configuration shared platform-wide.
Security
secure = trueby default.jwtTokenManagervalidates Bearer tokens; existing authorizers enforce ACL. No new auth code.401with properWWW-Authenticateheader.Originvalidated on all connections (DNS rebinding protection per MCP spec).mcp_discoverreturns only resources contributed byMcpAwareimplementations for the current principal — each implementation is responsible for its own ACL filtering. The defaultdescribeMcp()(used by Mode A plugins) does not filter — Mode A resources are visible to any authenticated agent. Plugins that need ACL-aware visibility must implement Mode B (overridedescribeMcp).-32002.mcp = falseprecedence rule applies to both annotation and configuration sources: if either isfalse, the plugin is silently excluded from the MCP catalog.Testing
Unit tests
McpAwareRegistryTestmcp = true/falseresolution from annotation + config;falsealways wins;mcp = trueon a plugin that doesn't implementMcpAware→ boot fails with descriptive error;mcp = true+McpAwareimpl → registeredMcpResourceParserTestmcp-configparsed intoMcpResource; missing fields → defaults; malformed → error;fromMergeddeep-merges code-baked defaults with operator overrides (operator wins; null arguments handled); arrays follow per-key strategy (see open question)DefaultDescribeMcpTestMcpAwaredefaultdescribeMcp()callsMcpResourceParser.fromMergedwithdefaultMcpConfig()andctx.pluginConfiguration().get("mcp-config"); plugin overriding onlydefaultMcpConfig()produces the expected resource without operator config; both null → empty resource + warningMcpResourceParserTestTransportDeriverTestJsonService→ http;WebSocketService→ websocket; combinationsDescriptorRendererTestmessage_formatParamValidatorTestBodyValidatorTestDiscoverToolTestHowToCallToolTestMcpManifestBuilderTestMcpSessionManagerTestIntegration tests
McpDiscoverModeAOperatorOnlyITMcpAware(no overrides),mcp = true,mcp-configin YAML → resource appears with operator-supplied description. With nomcp-config→ empty description + warning loggedMcpDiscoverModeACodeBakedITdefaultMcpConfig()(e.g.PingService) → resource appears with code-baked description, no YAML required. Add operatormcp-configoverrides → deep-merged values (operator wins) appear in catalogMcpDiscoverModeBCustomITdescribeMcp()returning multiple resources → all appear in catalog. Filter by principal → only allowed resources returnedMcpHardRuleIT@RegisterPlugin(mcp = true)that does not implementMcpAware→ boot fails with a clear error message. Same plugin withmcp = falsein config → boots normally (config override)McpFlagPrecedenceITmcp=true+ configmcp=false→ not exposed. Both true → exposed. Both false → not exposedMcpHowToCallIThow_to_callreturns valid HTTP descriptor; agent issues it against RESTHeart → success. Invalid args → validation error. Disabled action → errorMcpHowToCallMultiTransportITJsonServiceandWebSocketService→ both transports advertised;how_to_callwith each transport returns appropriate descriptorMcpProtectedResourceIT401+WWW-Authenticate. Discover well-known. Complete PKCE. Bearer →200McpInvalidationITInvalidationHookfires → MCP server emitsnotifications/tools/list_changed→ client refetches catalog and sees changesMcpSessionITDELETE /mcp→ subsequent POST → 404McpResumabilityITLast-Event-ID→ replayNative image compatibility
The framework discovers
McpAwareimplementations through RESTHeart's existing plugin registration mechanism (no new classpath scanning). GraalVM reflection entries forMcpResourceserialization classes go incore/src/main/resources/META-INF/native-image/. CustomMcpAwareimplementations follow the same native-image rules as any other RESTHeart plugin.Implementation plan
McpAware(withdefaultMcpConfig()+ defaultdescribeMcp()delegating to merge) +McpContext+McpResource+ builder;InvalidationHookMcpResourceParser+ConfigDeepMerger(constructor +fromMergedfactory) +McpResourceParserTest+DefaultDescribeMcpTestTransportDeriver+DescriptorRenderer+ParamValidator+BodyValidator+ testsMcpAwareRegistry(annotation/config flag resolution; boot failure whenmcp = truebutMcpAwarenot implemented) +McpAwareRegistryTest+McpHardRuleITDiscoverTool+HowToCallTool+McpManifestBuilder+ testsMcpSessionManager+McpServicePOST handler (initialize,tools/list,tools/call) +McpSessionITMcpStreamManager+ GET handler + keep-alive + resumability +McpResumabilityITInvalidationHookwiring +McpInvalidationITMcpProtectedResourceITPingService) + demo plugin (Mode B custom) + integration testsexamples/mcp-plugin/directory with all three demo styles + documentationOpen questions
@RegisterPlugin(mcp = true)with nomcp-configand noMcpAwareimpl — log a warning and skip, or fail boot? Proposal: warn and skip with empty description (operator likely forgot the config).describeMcp()does not filter by principal. Mode A resources are visible to any authenticated agent (the plugin's own ACL still applies on actual invocation throughhow_to_call/REST). If finer-grained MCP visibility is needed, the plugin must overridedescribeMcp()(Mode B). Acceptable?McpResource.kindenumeration — fixed enum (service,database,collection,aggregation,change-stream,graphql-app) or open string? Proposal: open string, with a recommended-values list. Custom plugins may want their own kinds (workflow,report, etc.).notifications/tools/list_changedfires only when the catalog changes (resources added/removed). Should description-only changes also fire it? Proposal: yes, simpler model.defaultMcpConfig()+ operator config — scalars and objects are deep-merged with operator winning on conflict. For arrays (e.g.examples,actions[].params) two strategies are possible: (a) operator array fully replaces default array; (b) operator array is concatenated to default array. Proposal: (b) concatenation forexamples(operator adds curated examples to defaults), (a) replacement foractionsandparams(operator wants full control over the action surface). Open for discussion.notifications/tools/list_changedneeds a shared backend; MongoDB change streams are the natural candidate. Deferred to v1.x.Non-goals for v1
MongoServiceandGraphQLServicewill implementMcpAwareto expose collections, aggregations, change streams, GraphQL apps. Reads metadata from each entity (collection metadata, gql-apps documents)resourcesprimitiveresources/list/resources/read/resources/subscribeon top of the sameMcpResourcemodelpromptssupportRelated
aimodule (existing) — vector index management; architectural homerestheart-docs/security/oauth.mdrestheart-docs/plugins/