McpAware
RESTHeart Cloud|
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 never calls your plugin’s data for the agent — it only describes how to. Three tools do all the work: list_apis (what exists), how_to_call (how to invoke it) and get_token (a credential to invoke it with). The agent takes the descriptor how_to_call returns and makes the real HTTP request itself, directly against your plugin’s own endpoint.
sequenceDiagram
participant Agent
participant MCP as RESTHeart /mcp
participant Plugin as Your McpAware plugin
Agent->>MCP: list_apis()
MCP-->>Agent: catalog (uri, kind, description)
Agent->>MCP: how_to_call(resource, action, args)
MCP-->>Agent: { method, url, headers, body }
Note over Agent: descriptor carries a placeholder,<br/>no credential — reusable
Agent->>MCP: get_token()
MCP-->>Agent: { access_token, expires_in: 60 }
Agent->>Plugin: the real request, directly
Plugin-->>Agent: real data
The split is deliberate: a descriptor answers "how do I call this" and is worth keeping, while a token is worth seconds. Were the token baked into the descriptor, its clock would start when the agent asked how to call something — and an agent that reasons, or asks its user, before actually calling would find it already expired.
This means RESTHeart’s existing ACL applies exactly as it does to any other client — an action the agent’s credentials can’t perform still fails with a normal 403, the same way it would for a human hitting the same endpoint.
Your describeMcp does not filter by principal, and should not: describe everything your plugin exposes. The MCP server filters the catalog itself, per caller, hiding from list_apis, resources/list and how_to_call anything whose read that caller’s ACL would refuse. Discovery therefore follows the same boundary as access, and you get that without writing any authorization code of your own.
Filtering by scope is the one exception, and it is yours to do — see ctx.scope().
Implementing McpAware
Two ways to implement it, depending on whether your plugin exposes one fixed thing or many dynamic ones.
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
how_to_call composes a descriptor, it never executes it. 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.
|
Tip
|
Call how_to_call once per action shape, not once per request. The descriptor for creating a document in a collection is the same every time apart from the body — keep it and change only what varies. A repeated write is then two steps, not three: get_token, then send.
|
If the action has a body_schema and the body does not satisfy it, how_to_call refuses to compose the request and reports which property failed and why, by JSON pointer:
#/give/qty: 9 is not less or equal to 3
For a schema whose top level is a oneOf, each alternative is reported separately — alternative 'offer' does not match: … — so it is clear which shape you were aiming at rather than being handed the union of every alternative’s requirements.
Filling in the credential: get_token
The descriptor’s Authorization header holds the literal placeholder Bearer <token_from_get_token> — it never carries a credential, which is what makes it stable and reusable. The agent calls get_token when it is about to send the request and substitutes the value:
{ "access_token": "eyJhbGciOiJIUzI1NiIs...", "token_type": "Bearer",
"expires_in": 60, "username": "andrea@example.com", "roles": ["cli"] }
Same field names as RESTHeart’s own /token endpoint, so an agent that has seen either recognises this one. The token carries the identity and roles of the MCP session and nothing more — it can do exactly what that session can do — and expires within expires_in seconds. That short life is the point: the agent never handles a password or a long-lived API key, and a token that leaks is worthless almost immediately.
Read access_token from the response and put it in the descriptor’s Authorization header as Bearer <token>.
Requires the jwtIssuer provider (enabled by default) and an authenticated session; the tool returns an explanatory error rather than a weaker credential when either is missing.
|
Note
|
A Personal Access Token works here too: authenticate the MCP session with Authorization: Bearer <PAT> and the session — and every token get_token then issues — carries the roles on the key, not the wider roles of the user it belongs to.
|
Letting agents read your data directly: readResource()
list_apis/how_to_call only ever describe a request — 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), implement the resources primitive’s read side too. Mark a safe, idempotent, GET-shaped action readable, and override readResource() to actually produce the data, in-process — no HttpServerExchange, no self-directed HTTP call:
resources.add(McpResource.builder()
.uri(ctx.baseUrl() + "/my-resource")
.kind("my-kind")
.action("get", a -> a.method("GET").readable(true))
.build());
@Override
public Optional<McpReadResult> readResource(McpContext ctx, String resource, String action, Map<String, Object> args) {
// talk to your own engine directly, exactly as your handle() method already does
return Optional.of(new McpReadResult(myData));
}
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/how_to_call 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/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.