Edit Page

MCP Server Tutorial β€” Expose a Collection and an Aggregation

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
The MCP server is available starting from RESTHeart v9.9.

πŸ”§ Configuration

β–Ό
Sets localhost:8080 with admin:secret
Values are saved in your browser

⚑ 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:

  1. Sign up at cloud.restheart.com

  2. Create a free API service

  3. Set up your root user following the Root User Setup guide

  4. 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_call directly 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 $var parameter 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:

  • [RESTHEART-URL] - Replace with your RESTHeart server URL (e.g., http://localhost:8080)

  • [BASIC-AUTH] - Replace with your Base64-encoded credentials (e.g., YWRtaW46c2VjcmV0 for admin:secret)

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 β€” how list_apis/call_api/how_to_call work 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/$vectorSearch to let an agent search semantically, not just filter