Edit Page

MCP Server

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 →

This 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/call_api/how_to_call 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"] } }
      }
    }]
  }'

Each variable the pipeline reads is declared as a parameter of its own, so list_apis shows status beside jsonMode and how_to_call composes ?status=A. Since 9.9.0 that is how RESTHeart binds them: any query parameter it does not reserve for itself becomes the $var of the same name — see Passing Variables for the full rules.

A parameter’s type is a JSON Schema type, or — since 9.9.0 — a BSON one by MongoDB’s $type alias: date, objectId, long, decimal. The catalog publishes it as the Extended JSON an agent sends: "type": "date" becomes an object described as {"$date": <epoch millis>}, which binds the $var to a date. Declared string, a variable compared with a date field matches nothing.

The older shape, one avars object holding them all (?avars={"status":"A"}), still works and is still accepted wherever the flat one is. Two cases keep it: a variable whose name collides with a query parameter RESTHeart reads itself — page, sort, filter and the like, which would never reach the pipeline — and a service older than 9.9.0. A colliding variable is declared under avars for that reason, and the resource says so among its warnings.

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"
      }
    }]
  }'
Important
An exposed change stream is neither executable nor subscribable by the agent. MCP has no streaming primitive: a tool answers once, so call_api refuses a stream, and resources/subscribe accepts collections and aggregations, not streams. What the agent gets from list_apis/how_to_call is the stream’s descriptor — a wss:// URL for the websocket transport, or an https:// one with Accept: text/event-stream for sse — to hand to the user for an external client: websocat wss://host/mydb/inventory/_streams/lowStock, a curl -N on the SSE URL, or a script in the browser. The agent itself never opens it.

To follow changes from inside MCP, subscribe to the collection the stream watches (or to an aggregation over it) with resources/subscribe: the agent receives notifications/resources/updated whenever the collection changes, and re-reads.

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 the tools. 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 call_api when…​ Use how_to_call when…​

You just want the data loaded into context

The action is a write, or anything resources/read does not cover

You are writing code for the user that will make the request itself

The host UI has a resource picker

You want the API’s own status, headers and body back

You need to inspect the actual request

The client exposes tools only

The resources primitive is optional for a client, and many do without it: the subagents of an agent host typically get the server’s tools and nothing else. So call_api is the one road every client has, reads included — a read is the resource’s read action (query for a collection, get for a document, execute for an aggregation), and it returns the same data resources/read would. The server’s tool descriptions and its initialize instructions say so.

Both resources/read and call_api run on the server, as the session: the agent never needs a network path to the API.

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/call_api 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 call_api-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.

Expect most clients not to show it. Listing resources and listing resource templates are two different MCP methods, and a client that calls only the first shows a catalog missing every parametric resource — the agent then has no reason to think it exists. list_apis always carries it, call_api always executes it, and the server says so in the instructions it sends at initialize.

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 — subscribe to the collection they watch instead), databases (a container, not itself document-shaped), and GraphQL apps stay list_apis/call_api-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" } }
  }
]
Important
@user._id identifies a caller authenticated against MongoDB users. An agent that connects with a Bearer token — a JWT or a personal access token, which is how most MCP clients authenticate — has no _id: the account carries the token’s claims, and the identity is @user.sub. A filter naming a property the account does not carry is unresolved, so since 9.8.2 it matches no document, and before 9.8.2 it matched every document without an owner. See Identifying the caller, and write {"$or": [{"owner": "@user._id"}, {"owner": "@user.sub"}]} when both kinds of caller must be served.
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.

What the catalog announces

list_apis, resources/list, resources/templates/list and how_to_call are composed per caller: two callers hitting the same server get different catalogs.

To decide what to announce, RESTHeart reads the caller’s own permissions: for each MCP-enabled resource it asks whether some call could satisfy one of them, and announces the resource as soon as the answer is yes.

That matters because a permission may decide on something a call carries:

{
  "roles": ["agent"],
  "predicate": "path('/ledger') and method(POST) and equals(%{q,ticket}, 'golden')",
  "priority": 100
}

There is no ticket while a catalog is being composed — there is no call yet — so that part of the rule is left open and /ledger is announced. The call itself is authorized as usual: without the ticket it answers 403, with it, it goes through. Reading the rule as a refusal would hide the resource from the very caller entitled to it.

The action left open says what it depends on: depends_on lists the arguments the rule reads from the call — here ["ticket"] — and its note reads decided by the arguments ticket when the call is made. When the rule that could apply writes fields into the body with mergeRequest, the action lists them as server_sets and its body_schema no longer requires them: the collection’s schema describes the stored document, not what the caller sends.

The unfiltered catalog gives each resource’s actions with the arguments each one requires: its required params, its depends_on, and body for a write. A signed write reads "create": ["ticket", "body"].

The opposite holds for anything the catalog can work out. A condition on who is asking is decided exactly:

{
  "roles": ["customer"],
  "predicate": "path-prefix('/orders') and equals(@user.plan, 'gold')",
  "priority": 100
}

/orders is announced to a customer on the gold plan and to nobody else — which no list of roles could express, since every customer holds the same role.

The same rule decides a resource’s actions. A resource is described with the actions the caller’s own rules could allow, and with no others: a role holding only GET on a collection is not offered its writes, and one whose permission does not grant mongo.allowManagementRequests is not offered drop. An action the rules might allow is offered, with what the catalog knows about it — create on a collection whose write rule reads a query parameter says that whether it goes through depends on what the call carries.

This does not bring back the defect that filtering actions once caused. That was a two-valued probe: it answered "refused" to a rule deciding on an argument it did not have, and dropped the write for the very caller entitled to it. The analysis answers "undetermined" there, and undetermined is offered.

Withholding is not blocking, here as anywhere else in the catalog: a caller who knows an action can still ask for it, and the ACL refuses it then.

Important

The catalog is discovery, not security. It decides what an agent is told about. It neither grants nor denies anything, in either direction:

  • a resource in the catalog may still answer 403 — the call is authorized when it is made, exactly as the equivalent REST call would be;

  • a resource absent from the catalog can still be called. call_api and resources/read execute whatever URI they are given, and the ACL decides. An agent that learns a URI some other way — a colleague, a log, a guess — reaches it as it would over REST.

The channels the catalog governs are list_apis, resources/list, resources/templates/list and how_to_call. Nothing else consults it.

So hiding a resource protects its name and description, which is often what you want, since a description is prose written to explain what the data is. It does not protect the data. Only permissions do that.

What the catalog works out, and where it needs a hand

Security is the ACL, and only the ACL. Permissions, vetoers and allowers are what is enforced; nothing on this page changes that. What follows is about a catalog being accurate, not about a boundary holding.

To read a permission, the catalog looks at its predicate one atom at a time, and there are three kinds:

Kind Examples What the catalog does with it

Determined — it already has a value

path('/orders'), method(GET), regex('/t-[^/]+/orders'), equals(@user.dept, 'sales'), @roles, %u

Evaluates it. This is what makes a policy decide a catalog exactly.

Belongs to the call — there is no call yet

qparams-contain(page), equals(%{q,ticket}, 'golden'), bson-request-contains(title), @qparams['id'], anything reading the body

Leaves it open, so the resource is announced. The call itself is authorized when it happens.

Unreadable — nothing says which of the two it is

a predicate a plugin registered without declaring anything: is-premium(); a variable of a custom VarResolver that declares nothing: @billing.tier

Leaves it open, so the resource is announced — the same as the row above, and for the same reason: never hide what might be allowed.

There is a fourth case, one step up from an atom: a caller for whom there is no rule to read at all. It happens when access is granted by an Authorizer written in Java rather than by a permission — a hosting platform letting an administrator onto its own node, for instance. No rule to read is not the same as no rule that matches: the caller reached /mcp, so something allowed them, and the analysis has nothing to go on. It rules nothing out, so it hides nothing, and the whole catalog is announced. Use the two keys below on anything that should stay quiet anyway.

The first two kinds need nothing from you. Every predicate, exchange attribute and variable RESTHeart ships declares itself, so a policy written with them — which is almost every policy — produces an accurate catalog on its own.

The third is where a hand helps, and it costs precision, not safety: an atom nobody can read makes the resource visible to everyone the rest of the rule admits, so path-prefix('/orders') and is-premium() announces /orders to every customer instead of the premium ones. Three places to say what it is, best first:

  1. In the plugin that registers it, once, correctly for every permission that ever uses it. A PredicateBuilder implements EvaluationScope; a VarResolver overrides scope():

    public static class Builder implements PredicateBuilder, EvaluationScope {
        @Override
        public Scope scope() {
            return Scope.LISTING;  // reads only the session; CALL if it reads the request
        }
    }

    Scope.ARGUMENTS is the third value, for a predicate that simply compares what it is given, as equals and in do.

  2. On the permission, when the predicate comes from something you cannot change:

    {
      "roles": ["customer"],
      "predicate": "path-prefix('/orders') and is-premium()",
      "mcp": { "resolve": { "is-premium": "listing" } }
    }

    The same block takes "publish": "never", which keeps a deliberately wide permission — path-prefix('/') for an operations role — from announcing the whole service to it.

  3. On the resource, when there is no predicate at all to declare: access decided by an Authorizer written in Java, whose logic is code and has nothing to read. That is what the two keys below are for.

The operations on a resource itself

A collection publishes more than its documents' actions. The data management API — reading and changing its properties, dropping it, listing and managing its indexes — and the bulk writes over /<coll>/* are published like anything else, because they are legitimate operations and the ACL is what decides them.

Each one says what it acts on, which a name alone does not: delete is DELETE /<coll>/{id} and removes one document, while drop is DELETE /<coll> and removes the collection with everything in it. An action on the resource itself carries "target": "resource"; acting on an element is the ordinary case and stays unsaid.

Each also names the switch its permission must grant, because there are always two conditions and both are necessary — a permission matching the method and the path, and the switch:

Action Needs

properties, set_properties, drop, indexes, create_index, delete_index

mongo.allowManagementRequests

update_many

mongo.allowBulkPatch

delete_many

mongo.allowBulkDelete

Those switches are off unless written, so by default none of these is offered to anybody — which is why a catalog that lists them for a role is worth a second look, not a shrug.

Tip
drop needs the collection’s current ETag, which properties returns, and declares it as a parameter carried in the If-Match header. Without it the answer is 409, which an agent has no way to interpret.

Declaring it on the resource

Two keys of a resource’s mcp block override the reading of the permissions altogether. Both decide disclosure, never access — an agent that knows the URI can call a resource neither key announces.

hide_from_roles keeps the resource out of the catalog of anyone holding one of those roles, whatever the permissions say. It is for a resource the ACL does allow and whose very name should not be announced — remember a description is prose written to explain what the data is.

{
  "mcp": {
    "enabled": true,
    "description": "Performance reviews pending approval.",
    "hide_from_roles": ["employee"]
  }
}

show_if replaces the reading of the permissions with a condition of your own. Use it when the decision lives somewhere the catalog cannot read — an Authorizer written in Java, for instance, which has no predicate to analyse.

{
  "mcp": {
    "enabled": true,
    "description": "Orders, for customers on the gold plan.",
    "show_if": "equals(@user.plan, 'gold')"
  }
}

hide_from_roles is decided first and wins over everything, show_if included.

Warning
hide_from_roles is not a permission. It keeps a name out of a listing; it does not stop the read. If the role must not read the data, write that in the ACL — the two are not interchangeable, and only one of them is enforced.
Important
show_if may only read what a catalog has: the caller’s session and the resource itself. A condition reading the call — qparams-contain(…​), %{q,…​}, @qparams[…​], the request body — has no meaning while a listing is being composed, and the resource is not announced at all, with a warning in the log naming the atom at fault.

When the catalog shows more, or less, than you meant

The analysis errs in one direction only: it never hides what a permission could allow, and when it cannot tell, it announces.

  • A resource missing for a caller is missing because that caller’s permissions do not allow it, or because its show_if does not hold. The fix is in the permissions.

  • A resource announced where it should not be is the usual surprise, and the fix is one of the two keys above: hide_from_roles, or a show_if that says who it is for.

  • An action not offered is almost always a switch the permission does not grant: allowManagementRequests, allowBulkPatch, allowBulkDelete. The action’s requires names it.

On RESTHeart Cloud the MCP page shows the catalog of several identities side by side and edits both keys in place.

Tip
Do not restate your ACL here. The catalog already reads it; a condition duplicating it is a second rule in a second place, and the two drift apart without anything saying so. Write one only for what the reading of the permissions cannot reach.

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

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]}. Without one such a mount is skipped when the catalogue is built, since there is nothing to bind the placeholder to, and the instance advertises nothing over MCP.

Three answers, and the third matters:

Return Meaning

McpScopeProvider.UNPARTITIONED

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.

McpScopeProvider.UNRESOLVED

The scope should have been determined and could not. The request is refused with 400 and a JSON body naming the reason — a request that does not carry what the provider needs is the caller’s to fix.

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

mongo

mcp: false hides every MongoDB resource from MCP regardless of per-resource mcp.enabled. Default: exposed.

graphql

mcp: false does the same, for GraphQL apps.

mcpService

catalog-ttl-seconds — see the catalog cache. Default 300.

mcpService

subscription-notify-interval-seconds — at most one resources/updated per subscribed resource per interval. Default 5.

mcpService

public-base-url — the externally visible URL resource URIs are built from. Default http://localhost:8080; set it to your real public URL behind a proxy. Comment it out to disable the resources primitive entirely (list_apis/call_api/how_to_call keep working).

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.