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/how_to_call/get_tokendirectly 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 ask how to query it for low-quantity items:
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":"how_to_call","arguments":{"resource":"[RESTHEART-URL]/inventory","action":"query","args":{"filter":{"qty":{"$lt":30}}}}}}'
how_to_call composes the descriptor β it does not execute it:
{ "transport": "http", "method": "GET",
"url": "http://localhost:8080/inventory?filter=%7B%22qty%22%3A%7B%22%24lt%22%3A30%7D%7D",
"headers": { "Authorization": "Bearer <token_from_get_token>" } }
The descriptor carries no credential, only that placeholder β which is what makes it stable and worth keeping.
When the action has something to say about itself, it says it here too, as notes. 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, and the descriptor for create, update and delete spells them out β "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. An agent reads that at the moment it is about to send, which is where it is needed.
{ "transport": "http", "method": "POST",
"url": "http://localhost:8080/market_events",
"headers": { "Authorization": "Bearer <token_from_get_token>", "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." }
Ask for a token when you are about to send the request:
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":"get_token","arguments":{}}}'
{ "access_token": "eyJhbGciOiJIUzI1NiIs...", "token_type": "Bearer",
"expires_in": 60, "username": "admin", "roles": ["admin"] }
Substitute access_token for the placeholder and run that url yourself: you get the real, live-queried documents β the agent makes this exact request directly against RESTHeart, the MCP server was never in the data path. Mind expires_in: the token lasts a minute, so fetch it right before use rather than in advance.
[[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, calls how_to_call, fetches a token with get_token, and executes the composed request β the same steps you just did by hand.
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/how_to_call/get_tokenwork 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