Edit Page

McpAware

RESTHeart Cloud
Prefer not to run it yourself? RESTHeart Cloud is this, hosted, with sign-up, payments and an MCP server already on. Free to start. Get started →
Note
McpAware is available starting from RESTHeart v9.9.

McpAware is the interface any plugin implements to become discoverable and callable by an AI agent over the Model Context Protocol — no MCP-specific transport code, no hand-written tool definitions. It lives in restheart-commons, so any Service, Interceptor, or other plugin, in any module, can implement it.

The actual /mcp endpoint — the server that discovers every McpAware plugin and speaks MCP’s JSON-RPC-over-HTTP transport to agents — ships as part of restheart-ai. If you’re looking to expose your MongoDB collections or GraphQL apps rather than write a plugin, see MCP for MongoDB & GraphQL instead; this page is for plugin developers.

How it works

The server does the calling for the agent. Three tools do all the work: list_apis (what exists, and how each action is shaped), call_api (execute an action) and how_to_call (the HTTP request behind an action, described for code the agent writes for the user). An agent that wants something done calls call_api; the MCP server composes the request from your plugin’s own description and runs it through RESTHeart’s whole handler chain, in-process, as the MCP session’s own identity: authentication, authorizers, every interceptor, your plugin’s handle(). Your plugin sees an ordinary request.

sequenceDiagram
  participant Agent
  participant MCP as RESTHeart /mcp
  participant Chain as Handler chain
  participant Plugin as Your McpAware plugin

  Agent->>MCP: list_apis()
  MCP-->>Agent: catalog (uri, kind, description)
  Agent->>MCP: call_api(resource, action, args)
  MCP->>Chain: the composed request, in-process, as the session
  Chain->>Plugin: handle(request)
  Plugin-->>Chain: response
  Chain-->>MCP: status, headers, body
  MCP-->>Agent: { status, headers, body }

There is one executable road, and no credential in it: the agent never holds a token, never needs a network path to your endpoint (an agent running in a sandbox with no egress works exactly like one on the user’s machine), and RESTHeart’s existing ACL applies exactly as it does to any other client. An action the session’s roles can’t perform still fails with a normal 403 — a result the agent reads, with the body, not a tool error.

how_to_call remains for the other job: when the agent is writing code for the user — a frontend, a script, an integration — it asks for the request behind an action and gets method, URL, headers and body, with a <your-credential> placeholder where the user’s own credential goes. It composes and never executes.

Mode A — a fixed, code-baked resource

If your plugin is a single endpoint with a stable shape, override defaultMcpConfig(). PingService (restheart-core) is the real example:

public class PingService implements ByteArrayService, McpAware {
    @Override
    public Map<String, Object> defaultMcpConfig() {
        return Map.of(
            "description", "Liveness probe. Returns a greeting message along with RESTHeart version and build time.",
            "actions", Map.of(
                "ping", Map.of(
                    "method", "GET",
                    "description", "Returns a greeting message, client IP, host, RESTHeart version and build time.")));
    }
}

The default describeMcp(ctx) implementation turns this into one McpResource, at ctx.baseUrl() + ctx.pluginUri(), merged with anything an operator overrides via the plugin’s own mcp-config configuration key.

Mode B — many dynamic resources

If your plugin’s resources come from somewhere else (a database, a registry) and change at runtime, override describeMcp(McpContext ctx) directly and build McpResource instances yourself. This is how MongoService and GraphQLService do it — see MCP for MongoDB & GraphQL for what they actually expose.

@Override
public List<McpResource> describeMcp(McpContext ctx) {
    var resources = new ArrayList<McpResource>();
    // ... discover what to expose, e.g. by querying MongoDB, a registry, etc.
    resources.add(McpResource.builder()
        .uri(ctx.baseUrl() + "/my-resource")
        .kind("my-kind")
        .description("...")
        .action("doSomething", a -> a.method("POST").bodySchema(Map.of(...)))
        .build());
    return resources;
}

McpContext carries principal, baseUrl, pluginName, pluginUri, and the plugin’s own resolved pluginConfiguration — everything needed to build absolute URIs without depending on HttpServerExchange or any other transport-level type.

Calling a resource: args.body

Whether executed by call_api or described by how_to_call, a call is the same thing: resource, action, args. For an action backed by a body_schema, the actual body goes under an args.body key — not as top-level args:

{ "resource": "https://host/my-resource", "action": "doSomething",
  "args": { "body": { "field": "value" } } }

Any other top-level args entries become the query string instead — this applies uniformly across HTTP, SSE, and WebSocket transports.

Declared params are validated before anything runs: a missing required one, or one of the wrong type, is refused with the property named. The body is not, and deliberately so. A body_schema describes the document the resource stores, and what a caller sends is not that document: a deployment fills fields in on the way — RESTHeart’s own mongo.mergeRequest stamps the author of a write and the time of it — so a schema that rightly requires them would reject a request that is right to omit them. That is a refusal the REST endpoint does not make, because its own checker runs after those fields are set.

The body is the service’s to judge, and it judges it. call_api carries the answer back with its status and its message, the same one the REST call would give:

{ "status": 400,
  "body": { "message": "#/give/qty: 9 is not less or equal to 3" } }

Publish body_schema all the same: it is how an agent learns the shape it should aim at, and the closer it is the fewer round trips it costs.

Executing an action: call_api

call_api answers with what the API answered:

{ "status": 201,
  "headers": { "Location": "https://host/my-resource/66f1...", "Content-Type": "application/json" },
  "body": { "...": "parsed, when the API returned JSON" } }

A non-2xx status is a result, not a tool error: the agent reads body for the reason, exactly as a REST client would. Only a failure to execute at all — unknown resource or action, invalid arguments, a stream that has no single response — is reported as a tool error. Streams (SSE, WebSocket) are not executable this way, and a stream is not itself subscribable: an agent follows changes by subscribing to the resource the stream watches (a subscribable one, see below) with resources/subscribe, or asks how_to_call for the stream’s descriptor to use from its own code.

The request runs as the MCP session’s own account, whatever authenticated it — a password, a JWT, a Personal Access Token: with a PAT the session, and every call_api it makes, carries the roles on the key, not the wider roles of the user it belongs to. Nothing is minted for the agent, so nothing can leak and nothing needs revoking.

Overriding execute()

You almost never should. McpAware.execute(ctx, resource, action, args) returns Optional.empty() by default, which means "use the default": the MCP server composes the request from your description and runs it through the handler chain, and that is the right implementation for nearly every plugin, reads and writes alike.

Override it only when your plugin is the whole semantics of the operation — no interceptor and no data-level ACL would apply to it on the REST path. A direct implementation is never a cheap optimization: it has to redo by hand whatever the chain would have done, and keep doing so as the chain evolves. A plugin that owns some actions and not others answers for its own and returns empty for the rest.

Letting agents read your data directly: readable actions

For a plugin whose data agents should be able to read directly (loaded straight into context, or picked from a host UI’s resource list), give the resources primitive a read side too: mark a safe, idempotent, GET-shaped action readable. That is all. resources/read runs it through execute() — by default through RESTHeart’s handler chain, in-process, as the session — and returns what your plugin’s own handle() answered, as the resource’s content:

resources.add(McpResource.builder()
    .uri(ctx.baseUrl() + "/my-resource")
    .kind("my-kind")
    .action("get", a -> a.method("GET").readable(true))
    .build());

A read is the GET it stands for: a 2xx body is the content, a 404 is -32002 (resource not found), a 400 is -32602 (invalid params), and a 403 from the ACL is -32003 — a code RESTHeart adds for "this session may not read it", so an agent does not mistake a refusal for a missing resource. Nothing is replicated for reads: the ACL’s own filters and projections, and every interceptor, apply because the request is the same one REST would see.

Declare the resource subscribable as well if changes to it can be watched, and clients can call resources/subscribe on it; the framework refuses a subscription to any resource that does not declare it, rather than accepting one that would never fire.

resources/read always means "here is real data" — a resource with no readable action is simply never registered with the resources primitive at all (resources/list/resources/templates/list); it stays discoverable through list_apis, and callable through call_api, only. There is no "context" fallback to fall back to. If your default action needs an argument with no sensible default, the framework surfaces it as a Resource Template instead of a plain resource, so a client knows to fill it in before reading — see the "Reading data directly" section of MCP for MongoDB & GraphQL for a concrete example (aggregations with a required $var).

Partitioned deployments: ctx.scope()

Note
available from RESTHeart v9.9.

One RESTHeart process can serve several isolated callers, each seeing only its own slice of the catalogue. ctx.scope() tells you which slice you are describing.

It is an opaque token: a name for which partition, never a description of what a partition is. What it means is yours to decide — MongoService reads it as a database name, a plugin backed by something else maps it onto whatever its own content is organised by.

If your plugin’s content is the same for every caller, ignore it. Ignoring it is already correct: the same resources are contributed to every partition, each carrying that partition’s own base URL. There is nothing to write.

If your plugin’s content depends on the caller, you must filter by it. RESTHeart cannot do this for you: a plugin that returns identical resources in every partition looks exactly like one that forgot to check.

@Override
public List<McpResource> describeMcp(McpContext ctx) {
    var all = myResources();

    if (ctx.unpartitioned()) {
        return all;   // nothing to filter — this deployment partitions nothing
    }

    return all.stream()
            .filter(r -> ctx.scope().equals(tenantOf(r)))
            .toList();
}

Most deployments never partition anything. With no McpScopeProvider registered every request resolves to McpScopeProvider.UNPARTITIONED and ctx.unpartitioned() is always true, so the branch above is all you need to stay correct in both cases.

Enabling and disabling

Any McpAware plugin is exposed by default; set mcp: false in its own configuration to opt out entirely — the same override mechanism as enabled/uri:

myPlugin:
  mcp: false

Staying fresh: the catalog cache

list_apis, call_api and how_to_call read from a cache, not from every McpAware implementation’s describeMcp() on every call — refreshed at most every catalog-ttl-seconds. Change what your plugin exposes and an agent may see the old state for up to that long; there’s no live invalidation watching your plugin’s own data source. When a cache entry expires, already-connected agents get a notifications/tools/list_changed push telling them to refetch.

mcpService:
  catalog-ttl-seconds: 30   # default 300

This is a deliberate trade-off: instant, source-specific invalidation would mean different wiring per McpAware implementation (a MongoDB write, a config reload, …​); a single TTL is one uniform mechanism for all of them, at the cost of a bounded staleness window.