MCP Server Tutorial β Expose a Collection and an Aggregation
RESTHeart Cloud|
Note
|
The MCP server is available starting from RESTHeart v9.9. |
π§ Configuration
β‘ Setup Guide
To run the examples on this page, you need a RESTHeart instance.
Option 1: Use RESTHeart Cloud (Recommended)
The fastest way to get started is with RESTHeart Cloud. Create a free service in minutes:
-
Sign up at cloud.restheart.com
-
Create a free API service
-
Set up your root user following the Root User Setup guide
-
Use the configuration panel above to set your service URL and credentials
|
Tip
|
All code examples on this page will automatically use your configured RESTHeart Cloud credentials. |
Option 2: Run RESTHeart Locally
If you prefer local development, follow the Setup Guide to install RESTHeart on your machine.
|
Note
|
Local instances run at http://localhost:8080 with default credentials admin:secret
|
This tutorial takes a plain MongoDB collection and makes it discoverable and callable by an AI agent: enable a collection, add an aggregation, connect a real MCP client, and watch it find both and use them β no code written on either side.
See the MCP Server overview for the full feature reference this tutorial pulls from.
What You’ll Learn
-
Opt a collection into MCP with a metadata
PATCH -
Call
list_apis/call_api/how_to_calldirectly over JSON-RPC to see exactly what an agent sees -
Connect a real MCP client (Claude Code) and let it discover the resource on its own
-
Add an aggregation with a
$varparameter and expose it the same way -
Subscribe to a collection and get told when it changes
Prerequisites
-
RESTHeart 9.9+ running locally (the MCP server is included by default β see the overview page if you need to build it from source)
-
A tool for making HTTP requests (cURL is used below)
-
Optional, for the last section: Claude Code or any other MCP-capable client
|
Note
|
In all examples below:
The interactive examples on this page can automatically substitute the RESTHeart URL and credentials. |
1. Create and seed a collection
curl -X PUT [RESTHEART-URL]/inventory -u admin:secret
curl -X POST [RESTHEART-URL]/inventory -u admin:secret -H 'Content-Type: application/json' \
-d '{ "item": "notebook", "qty": 50, "status": "A" }'
curl -X POST [RESTHEART-URL]/inventory -u admin:secret -H 'Content-Type: application/json' \
-d '{ "item": "notebook", "qty": 25, "status": "D" }'
curl -X POST [RESTHEART-URL]/inventory -u admin:secret -H 'Content-Type: application/json' \
-d '{ "item": "journal", "qty": 10, "status": "A" }'
2. Opt it into MCP
curl -X PATCH [RESTHEART-URL]/inventory -u admin:secret -H 'Content-Type: application/json' \
-d '{
"mcp": {
"enabled": true,
"description": "Product inventory."
}
}'
That’s the whole opt-in. No restart, no extra plugin β the next list_apis call (once the catalog cache picks it up) shows it.
3. See what an agent sees, over raw JSON-RPC
An MCP client talks to /mcp in JSON-RPC over HTTP. This section does the same calls a real client would, so you can see the exact protocol β skip to section 4 if you just want to plug in a real client.
First, a session handshake β initialize, capturing the Mcp-Session-Id response header:
curl -i -X POST [RESTHEART-URL]/mcp -u admin:secret \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"tutorial","version":"1.0"}}}'
Copy the Mcp-Session-Id header from the response and use it below ([SESSION-ID]):
curl -X POST [RESTHEART-URL]/mcp -u admin:secret \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-H 'Mcp-Session-Id: [SESSION-ID]' -H 'Mcp-Protocol-Version: 2025-03-26' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_apis","arguments":{}}}'
The response is one text/event-stream frame wrapping the actual JSON-RPC result β inventory shows up in resources:
{ "resources": [
{ "uri": "http://localhost:8080/inventory", "kind": "collection", "description": "Product inventory." }
]}
|
Note
|
A tool call is answered as an event stream; resources/list, resources/read and the other single-answer methods come back as plain application/json. That is why the Accept header above lists both β a client has to handle either.
|
Now query it for low-quantity items β call_api runs the action on the server, as your session, and returns what the API answered:
curl -X POST [RESTHEART-URL]/mcp -u admin:secret \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-H 'Mcp-Session-Id: [SESSION-ID]' -H 'Mcp-Protocol-Version: 2025-03-26' \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"call_api","arguments":{"resource":"[RESTHEART-URL]/inventory","action":"query","args":{"filter":{"qty":{"$lt":30}}}}}}'
{ "status": 200,
"headers": { "Content-Type": "application/json", "X-Powered-By": "restheart.org" },
"body": [ { "_id": { "$oid": "66f1..." }, "item": "stapler", "qty": 12 } ] }
These are the real, live-queried documents, read through RESTHeart’s whole request pipeline β the ACL, every interceptor β with no token and no request for you to send. (For a plain read like this, resources/read is the shorter channel; call_api is the one that also covers writes.) Try one:
curl -X POST [RESTHEART-URL]/mcp -u admin:secret \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-H 'Mcp-Session-Id: [SESSION-ID]' -H 'Mcp-Protocol-Version: 2025-03-26' \
-d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"call_api","arguments":{"resource":"[RESTHEART-URL]/inventory","action":"create","args":{"body":{"item":"tape","qty":3}}}}}'
{ "status": 201,
"headers": { "Location": "http://localhost:8080/inventory/66f1...", "Content-Type": "application/json" } }
A non-2xx status comes back the same way β 409 for a document whose _id already exists, 403 for something your session’s roles may not do β with the API’s own body, for the agent to read and decide. Nothing is a tool error except a call that cannot run at all: unknown resource, unknown action, arguments that fail the action’s params or body_schema.
To see the request behind an action β for code you are writing yourself, not for the agent to run β ask how_to_call with the same arguments:
curl -X POST [RESTHEART-URL]/mcp -u admin:secret \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-H 'Mcp-Session-Id: [SESSION-ID]' -H 'Mcp-Protocol-Version: 2025-03-26' \
-d '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"how_to_call","arguments":{"resource":"[RESTHEART-URL]/inventory","action":"query","args":{"filter":{"qty":{"$lt":30}}}}}}'
{ "transport": "http", "method": "GET",
"url": "http://localhost:8080/inventory?filter=%7B%22qty%22%3A%7B%22%24lt%22%3A30%7D%7D",
"headers": { "Authorization": "Bearer <your-credential>" } }
It composes and never executes, and it carries no credential β the placeholder is where the user’s own API key or token goes in the code that will make this request.
When the action has something to say about itself, both tools say it. The case that matters most is a write to a collection that declares constraints: every write then runs in a transaction and a 409 can mean three different things β "retryable": true is a write conflict to send again unchanged; "constraint": "<name>" is a violated rule, with message and violations, that no retry will change; neither is a document whose _id already exists. list_apis spells it out in the action’s description, and how_to_call repeats it as notes in the descriptor:
{ "transport": "http", "method": "POST",
"url": "http://localhost:8080/market_events",
"headers": { "Authorization": "Bearer <your-credential>", "Content-Type": "application/json" },
"body": { "_id": "accept:offer:trader1:1", "offerId": "offer:trader1:1", "type": "trade" },
"notes": "409 Conflict means one of three things, and the body says which. \"retryable\": true β two writes collided and this one was NOT applied: send exactly the same request again. \"constraint\": \"<name>\" β the write broke one of this collection's rules (noNegativeHoldings, noOverCommitment): \"message\" says why and \"violations\" lists the documents; do not retry, the answer will not change. Neither β a document with this _id already exists." }
[[4-connect-a-real-client]] === 4. Connect a real client
Point Claude Code at your running instance:
claude mcp add --transport http restheart [RESTHEART-URL]/mcp \
--header "Authorization: Bearer <your-token>"
Restart the session so it picks up the new server, then ask it directly β no need to name tools or endpoints yourself:
> what APIs do you have access to on restheart?
> query the inventory collection for items with qty under 30
It calls list_apis, finds inventory, and calls call_api β the same steps you just did by hand. The agent never makes a request of its own: the action runs on RESTHeart, as the session you connected with, which is what makes this work from any host, sandboxed ones included.
For Claude Desktop, which needs a public HTTPS endpoint and signs users in through OAuth rather than a token you paste, see Connect Claude Desktop.
5. Add an aggregation
Expose a $var-parameterized aggregation the same way β declare status in mcp.params since an agent can’t guess its enum from the pipeline alone:
curl -X PATCH [RESTHEART-URL]/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"] } }
}
}]
}'
Ask your connected client:
> what's the total quantity of each item with status A in inventory?
It discovers byStatus under inventory’s `list_apis context, sees status is one of A/D under the avars param, and calls how_to_call with {"avars": {"status": "A"}} β the same avars bundling the overview explains.
6. Get told when it changes
Notifications arrive on a stream the client opens itself, with a GET on /mcp. This tutorial runs as admin, who may do anything; giving an agent its own account means granting it GET on /mcp too, not only POST β otherwise subscriptions succeed and never fire.
Open the stream with the same session and leave it running:
curl -N [RESTHEART-URL]/mcp -u admin:secret \
-H 'Accept: text/event-stream' -H 'Mcp-Session-Id: [SESSION-ID]'
In another shell, subscribe:
curl -X POST [RESTHEART-URL]/mcp -u admin:secret \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-H 'Mcp-Session-Id: [SESSION-ID]' \
-d '{"jsonrpc":"2.0","id":5,"method":"resources/subscribe","params":{"uri":"[RESTHEART-URL]/inventory"}}'
Now write a document:
curl -X POST [RESTHEART-URL]/inventory -u admin:secret \
-H 'Content-Type: application/json' -d '{"sku":"widget","qty":3}'
The open stream prints:
{ "jsonrpc": "2.0", "method": "notifications/resources/updated",
"params": { "uri": "http://localhost:8080/inventory" } }
There is no payload: the notification means "read it again". Subscribe to inventory/_aggrs/byStatus instead and you get the same signal for the aggregation, watched through the collection it reads from.
Next steps
-
Full MCP Server reference β change streams, GraphQL apps, configuration
-
Securing it β this tutorial ran as
admin; here is how to give an agent its own account and let the ACL decide what it can see and read -
McpAwareβ howlist_apis/call_api/how_to_callwork under the hood, and how to expose your own plugin’s data the same way -
examples/mcp-mongodbβ a worked example with all four kinds (collection, aggregation, stream, GraphQL app) loaded in one shot -
Vector Search β combine MCP with
$vectorScan/$vectorSearchto let an agent search semantically, not just filter