MCP Server
RESTHeart CloudThis is the connectivity half of restheart-ai — see the module overview for how it fits together with vector search.
restheart-ai ships the native Model Context Protocol server itself (/mcp), plus ready-made McpAware implementations for mongo and graphql. Any collection, aggregation, change stream, or GraphQL app you opt in becomes discoverable and callable by an AI agent — no hand-written tool definitions, no wrapper service, no separate integration code.
|
Note
|
The MCP server is available starting from RESTHeart v9.9. |
|
Warning
|
RESTHeart 9.9 has not been released yet — the MCP server currently only exists in development snapshot builds. Until 9.9 is out, build RESTHeart yourself from the 9.x branch: git clone https://github.com/SoftInstigate/restheart.git && cd restheart && git checkout 9.x && ./mvnw clean package. The restheart-ai.jar you need is produced at core/target/plugins/restheart-ai.jar.
|
|
Tip
|
mongo/graphql are just two plugins implementing the framework’s McpAware interface — see that page for how list_apis/how_to_call/get_token actually work, the args.body calling convention, and the catalog cache, or for exposing your own custom plugin the same way.
|
|
Tip
|
To try it from a real client, see Connect Claude Desktop — a local instance exposed over HTTPS with OAuth sign-in. |
|
Tip
|
examples/mcp-mongodb on GitHub is a complete worked example — a setup.sh script that loads all four kinds below (collection, aggregation, change stream, GraphQL app) into a running RESTHeart in one shot.
|
Enabled by default, opt-in per resource
The mongo and graphql plugins — the same ones serving your REST and GraphQL APIs — both expose their data through MCP automatically. Set mcp: false on either to turn it off entirely:
mongo:
mcp: false # no MongoDB resource ever appears in the catalog, regardless of mcp.enabled below
graphql:
mcp: false # same, for GraphQL apps
But nothing is actually visible in the catalog until you say so on the resource itself: every collection, aggregation, stream, and GraphQL app needs its own mcp block with enabled: true and a description. ACL and MCP visibility are two independent switches — a resource with no mcp block simply doesn’t exist for an agent, no matter what permissions apply to it.
1. Expose a collection
Add an mcp block to a collection’s existing metadata (the same PATCH you’d use for jsonSchema or aggrs):
curl -X PATCH http://localhost:8080/mydb/inventory \
-u admin:secret -H 'Content-Type: application/json' \
-d '{
"mcp": {
"enabled": true,
"description": "Product inventory.",
"examples": [
{ "description": "Find low-stock items", "action": "query", "args": { "filter": { "qty": { "$lt": 10 } } } }
]
}
}'
An agent now sees it via list_apis(), and its full context — query/get/create/update/delete actions, body_schema derived automatically from the collection’s jsonSchema if one is set, your curated examples — via list_apis(resource: "http://localhost:8080/mydb/inventory").
2. Expose an aggregation
Add an mcp block to one entry in the collection’s aggrs array. $var references in the pipeline are discovered automatically — declare only the ones whose type an agent can’t guess from the pipeline alone:
curl -X PATCH http://localhost:8080/mydb/inventory \
-u admin:secret -H 'Content-Type: application/json' \
-d '{
"aggrs": [{
"uri": "byStatus",
"stages": [
{ "$match": { "status": { "$var": "status" } } },
{ "$group": { "_id": "$item", "total": { "$sum": "$qty" } } }
],
"mcp": {
"enabled": true,
"description": "Total quantity by item, for a given status.",
"params": { "status": { "type": "string", "enum": ["A", "D"] } }
}
}]
}'
RESTHeart binds every $var through a single avars query parameter (?avars={"status":"A"}), so that’s exactly the shape how_to_call composes — the agent never needs to know this convention, it just sees a status property under the avars param in list_apis’s output. Since 9.9.0, a bare `?status=A also works — any query parameter not otherwise reserved by RESTHeart is bound the same way as an entry in avars, and avars wins only when it already carries that name — see Passing Variables.
|
Tip
|
mcp.pipeline_summary lets you override the auto-generated one-line summary of the pipeline ($match → $group) — useful once a pipeline uses $lookup/$facet/$vectorSearch and the auto-generated summary stops being meaningful.
|
3. Expose a change stream
Same pattern, on one entry in streams. A change stream is a subscription, not a request/response call, so RESTHeart declares both transports it actually serves the endpoint on:
curl -X PATCH http://localhost:8080/mydb/inventory \
-u admin:secret -H 'Content-Type: application/json' \
-d '{
"streams": [{
"uri": "lowStock",
"stages": [{ "$match": { "fullDocument.qty": { "$lt": 10 } } }],
"mcp": {
"enabled": true,
"description": "Low-stock alerts.",
"event_type": "Update events where qty < 10"
}
}]
}'
how_to_call returns a wss:// descriptor for the websocket transport and an https:// one with Accept: text/event-stream for sse — pick whichever the agent’s runtime can actually open.
4. Expose a GraphQL app
Add a top-level mcp block to the app’s own document in your gql-apps collection. Every GraphQL app shares one fixed action, execute (POST with the standard {query, variables, operationName} body) — what’s specific to your app is which Query fields exist, listed automatically from its SDL:
curl -X PATCH http://localhost:8080/gql-apps/warehouse \
-u admin:secret -H 'Content-Type: application/json' \
-d '{
"mcp": {
"enabled": true,
"description": "Query the warehouse via GraphQL.",
"examples": [
{ "description": "Find low-stock items", "args": { "body": { "query": "{ lowStock { sku qty } }" } } }
]
}
}'
RESTHeart’s GraphQL API is read-only — only Query fields are ever listed; a Mutation type in your SDL, if you have one, is never surfaced.
Reading data directly: the resources primitive
Everything exposed above is also reachable through MCP’s native resources primitive (resources/list, resources/read), not just through list_apis/how_to_call. This gives an agent — or a host UI like Claude Desktop/Cursor with a resource picker — a second, more direct channel to read data:
Use resources/read when… |
Use how_to_call when… |
|---|---|
You just want the data loaded into context |
You need to generate/inspect the actual request, or the action is a write |
The host UI has a resource picker |
The host has no built-in HTTP execution |
resources/read always returns real data, or a clear error — never a description of the resource. A read that cannot be served answers with a JSON-RPC error (-32002 unknown resource, -32602 bad parameters, -32603 internal), not with a successful result whose text happens to start with "Error:" — so a client branches on error as it does everywhere else. A resource with nothing readable to return (a database, a change stream, a GraphQL app, or an aggregation AggregationPipelineSecurityChecker didn’t clear — see below) simply doesn’t appear under resources/list at all; it stays reachable through list_apis/how_to_call only. There is exactly one way to get real data through this primitive and one generic way to invoke anything (reads and writes alike) through tools — never two different answers to "read this" depending on which channel you used.
Collections
Reading a collection’s bare URI (https://host/mydb/inventory) returns its first page of documents with default pagination — the same shape as GET. Add the usual query parameters to filter or page through it: ?filter={"qty":{"$lt":10}}&sort=-qty&pagesize=20. A single document is read at …/inventory/<id>.
Aggregations
An aggregation is readable this way only if AggregationPipelineSecurityChecker clears its raw, un-interpolated pipeline (the default blacklist blocks $out, $merge, $lookup, $graphLookup, $unionWith) — a pipeline it rejects stays how_to_call-only, same as any other non-readable resource.
If the pipeline references a $var with no default value outside a conditional $ifvar/$ifarg stage, it’s a genuinely required parameter — byStatus above needs status — and the resource shows up under resources/templates/list instead of resources/list, as …/inventory/_aggrs/byStatus{?status}. Fill in the required variable(s) to read it: …/inventory/_aggrs/byStatus?status=A. A $var with a default value, or one only referenced inside an $ifvar/$ifarg guard, is optional and doesn’t force this — the aggregation is a plain, directly-readable resource instead.
As with the real REST endpoint, a flat query parameter like ?status=A binds the $var of the same name — see Passing Variables for the full rules (JSON-shaped values, combining with avars, etc.).
Not (yet) readable this way
Change streams (they’re a live subscription, not a request/response read), databases (a container, not itself document-shaped), and GraphQL apps stay list_apis/how_to_call-only for now.
Securing it
Reaching /mcp and reading data through it are two separate permissions.
A read through the resources primitive is authorized exactly as the equivalent REST GET: the same permission matches, and the same readFilter and projectResponse apply. Whatever an ACL already does to your REST API, it does to MCP — you do not write MCP-specific rules.
Grant the endpoint itself, then the data:
[
{
"_id": "agentCanReachMcp",
"roles": ["agent"],
"predicate": "path-prefix('/mcp') and method(POST, GET, DELETE)",
"priority": 100
},
{
"_id": "agentCanReadInventory",
"roles": ["agent"],
"predicate": "path-prefix('/inventory') and method(GET)",
"priority": 100,
"mongo": { "readFilter": { "owner": "@user._id" } }
}
]
|
Warning
|
/mcp needs POST, GET and DELETE. POST carries the JSON-RPC messages, but GET opens the stream the server delivers notifications on and DELETE closes the session. With POST alone a resources/subscribe succeeds and the client then waits forever for notifications it can never receive.
|
Use path-prefix, not path. One collection answers at several paths — /inventory, /inventory/_size, /inventory/<id> and /inventory/_aggrs/<name> — and an exact path('/inventory') covers only the first, so _size and the aggregations come back 403.
Remember that path-prefix matches whole segments: path-prefix('/inv') does not match /inventory.
The catalog shows only what you can read
list_apis, resources/list, resources/templates/list and how_to_call are all filtered by the caller’s ACL: a resource whose read would be refused is not listed, not described, and not composable. Two callers hitting the same server get different catalogs.
Filtering is in addition to enforcement, not instead of it — a read is still authorized when it happens.
Subscribing to changes
Subscribe to a collection or an aggregation over one, and the server sends notifications/resources/updated when it changes. The notification carries only the URI you subscribed to, no payload: read the resource again to get the new state.
Notifications are delivered on a stream the client opens with a GET on /mcp, so the caller needs GET on /mcp as well as POST — see Securing it. Without it the subscription succeeds and nothing ever arrives.
{ "jsonrpc": "2.0", "id": 1, "method": "resources/subscribe",
"params": { "uri": "https://host/inventory" } }
Subscribing to an aggregation watches the collection it reads from, so you can attach an aggregation that summarises your data and be told to re-read it whenever the underlying collection changes.
list_apis marks what you can subscribe to:
{ "uri": "https://host/inventory", "kind": "collection", "subscribable": true }
Anything else — a change stream, a GraphQL app, a service — is refused with a JSON-RPC error explaining why. Change streams are their own live channel: connect to them over WebSocket or SSE instead.
|
Tip
|
A busy collection does not become a burst of messages. The first change is notified immediately, further ones are collapsed into at most one notification per subscription-notify-interval-seconds (default 5).
|
Serving several isolated callers: scopes
|
Note
|
available from RESTHeart v9.9. |
By default one instance serves one catalogue: every MCP-enabled resource on it, to every caller its ACL allows. That is the right shape for a single deployment and it needs no configuration.
A process that serves several isolated callers needs more than the ACL filter above. The ACL decides what a caller may read; it does not stop the catalogue from being built out of every database on the instance in the first place. Scopes partition the catalogue itself: each scope gets its own MCP server, its own resource registry, and its own notifications.
There is no switch to turn this on. Register a provider and the instance is partitioned; register none and nothing changes:
@RegisterPlugin(name = "myScopeProvider", description = "Resolves the MCP scope from the request")
public class MyScopeProvider implements Provider<McpScopeProvider> {
@Override
public McpScopeProvider get(PluginRecord<?> caller) {
return request -> {
var host = request.getExchange().getHostName();
var label = host.indexOf('.') > 0 ? host.substring(0, host.indexOf('.')) : null;
return label == null ? McpScopeProvider.UNRESOLVED : label;
};
}
}
RESTHeart finds it by the type it provides, so the plugin name is yours to choose.
A scope is an opaque token naming which partition, and each service maps it onto its own namespace — MongoService reads it as a database name. Resolve it from something present on every request whatever the credential: the hostname is, an authentication detail may not be.
|
Note
|
If your mongo-mounts use the parametric form — what: /{host[0]}/{*} — the scope is what binds {host[0]}. Such a mount used to be skipped when the catalogue was built, so the instance advertised nothing over MCP; with a scope it resolves like any other mount and the catalogue fills in. Without one it is still skipped, since there is nothing to bind the placeholder to.
|
Three answers, and the third matters:
| Return | Meaning |
|---|---|
|
No partitioning for this request — the whole catalogue, exactly as an unpartitioned instance behaves. This is what the default provider returns for everything. |
any other value |
This request belongs to that partition, and sees only its resources. |
|
The scope should have been determined and could not. The request is refused with |
A provider that throws is refused too, with 500: that is not the caller’s fault. Both refuse; neither falls back to serving the whole catalogue.
Returning UNPARTITIONED when you meant UNRESOLVED hands that caller the whole instance’s catalogue.
On a partitioned instance GraphQLService reads its app definitions from the scope’s own database rather than from the configured graphql.db, so each caller gets its own apps. The per-request override-gql-apps-db cannot serve here: the catalogue is built outside any request, so there is no exchange to read an override from.
Plugins that hold caller-specific data must filter by the scope themselves — see McpAware. RESTHeart cannot tell a plugin that ignores the scope from one whose content genuinely is the same everywhere.
Configuration reference
| Plugin | Purpose |
|---|---|
|
|
|
|
|
|
|
|
|
|
Everything else is metadata, not server config: mcp.enabled, mcp.description, mcp.params, mcp.examples, mcp.event_type, mcp.pipeline_summary all live on the resource itself (a collection, an aggrs/streams entry, or a gql-apps document), set through RESTHeart’s normal REST API.