Skip to content

MCP Server with Resources and Tools for MongoDB Collections #615

@ujibang

Description

@ujibang

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:

  1. Implementing McpAware (mandatory if mcp = true is declared — boot fails otherwise).
  2. 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

  1. 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.

  2. One tool, one model. how_to_call composes requests for any resource regardless of kind. No per-resource tools.

  3. 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.

  4. 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.

  5. 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.

  6. 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 /mcp401 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.
  • McpSessionManagerMcp-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 McpAwarefail 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

  1. @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).
  2. 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?
  3. 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.).
  4. Cache invalidation granularitynotifications/tools/list_changed fires only when the catalog changes (resources added/removed). Should description-only changes also fire it? Proposal: yes, simpler model.
  5. 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.
  6. 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

Metadata

Metadata

Assignees

Type

No type
No fields configured for issues without a type.

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions