Skip to content

Unit Backends: one governed tool over many private backends

A Unit Backend bundles one or more private sub-backends (MCP or REST) behind a single JavaScript glue module and exposes only its own, curated tools. The sub-backends never appear in the tool manifest — the model sees a handful of purpose-built tools, while the unit does the multi-step work, the composition, and the response shaping inside ToolMesh.

ToolMesh has two composition mechanisms, and they are not the same:

ComposesLives in
Composite toolother tools of the same backenda DADL file
Unit Backendother backends (MCP or REST)a unit.yaml + a JS module

A Unit is the right tool when one model-facing call should orchestrate several backend calls, join their results, or hide a verbose API behind a compact answer.

Wrapping is only worth it if the unit does something the raw tools plus the model cannot do cheaply. Three wins recur — and all three apply even with a single sub-backend:

  1. Multi-step capture. A clean tool that hides a mandatory discover-then-query dance.
  2. Cross-tool composition. One digest assembled from several calls.
  3. Response reduction. Big, nested or tabular payloads collapsed to the few fields that matter — real context-window savings.

The worked example below leans on exactly these.

A unit is a directory under TOOLMESH_UNITS_DIR (default /app/config/units), scanned as a direct child:

config/units/netdata/unit.yaml
unit: netdata
implementation: ./netdata.js
expose:
meta_signals: [netdata] # which _meta.* branches the Output Gate may pass through
audit: full # full | compact | none
tools: [health_summary] # optional: promote these to direct top-level MCP tools
backends: # private dependencies — same syntax as backends.yaml
- name: netdata
transport: http
url: http://your-parent:19999/mcp
api_key_env: netdata_mcp_api_key # injected as Authorization: Bearer <key>

The unit.yaml root must be a mapping (unit: / implementation: / expose: / backends:). Only the entries under backends: are a list.

expose.tools is optional and mirrors expose_tools from backends.yaml: the listed describe() tool names are additionally promoted to direct top-level MCP tools. Promotion always uses the full <unit>_<tool> name, never a bare alias — unit tool names like search or roll are too generic for the root level. Either way, every unit tool stays reachable through discover_tools and execute_code; an entry that names no describe() tool is logged once at load time and skipped.

The implementation is a JavaScript module that provides a top-level describe() (the tool surface) plus one function per declared tool. Inside, private sub-backends are reachable as api.<name>.<tool>(args):

function describe() {
return {
tools: [{
name: "health_summary",
access: "read",
description: "Connected nodes plus all raised alerts, in one digest."
}]
};
}
async function health_summary() {
const nodes = unwrap(await api.netdata.list_nodes({}));
const alerts = unwrap(await api.netdata.list_raised_alerts({ cardinality_limit: 200 }));
// ...parse, merge, trim...
return { content: [{ type: "text", text: JSON.stringify(/* compact digest */) }],
_meta: { netdata: { tool: "health_summary" } } };
}

The glue runs per call in a locked-down goja runtime:

  • Allowed: api.<sub>.<tool>(...), the params object, pure JS (Math, Date, JSON, await).
  • Forbidden: fetch, require, import, fs, process, eval, timers.
  • Limits: call depth 16, max 20,000 api.* calls per invocation (runaway guard).

Gotcha worth knowing. Inside the sandbox, api.<sub>.<tool>() returns the raw MCP ToolResult{ content: [{ type: "text", text: "<json>" }], isError, metadata } — not the parsed payload. Parse content[0].text yourself (the unwrap() helper above). The execute_code meta-tool auto-unwraps, so the same code can pass an inline test yet return empty inside a unit. Always verify unit logic through the loaded unit.

Netdata’s monitoring agent ships a built-in MCP server (free, open source, http://HOST:19999/mcp on any Agent or Parent — no Netdata Cloud account required). It exposes ~13 powerful but low-level tools. We register that MCP as a private sub-backend and expose a small curated surface:

Unit toolWhat it doesRaw equivalent
metricper-group summary (avg/min/max/anomaly%) for any contextget_metrics_detailsquery_metrics + parse a deep nested object
compare_nodesA↔B or now↔then delta/ratio for one contextsame two-call dance, twice
health_summarynodes + raised alerts in one digestlist_nodes + list_raised_alerts + parse two tables
anomalies_nowML-flagged anomalies, ranked & trimmedfind_anomalous_metrics + parse an 11-column table
what_changedmetrics that shifted vs. a baseline windowfind_correlated_metrics with hand-set baseline windows

Querying system.cpu across five nodes:

  • Raw query_metrics returns a nested summary / result / view / db object — ~7.9 KB — and requires you to pass explicit dimensions first (so it is really two calls), plus it silently warns when you average dimensions together.
  • metric auto-discovers the dimensions, picks a sane aggregation, and returns ~0.8 KB:
{
"context": "system.cpu",
"units": "Total CPU utilization",
"grouped_by": ["node"],
"groups": [
{ "name": "fermat", "avg": 4.25, "max": 4.25, "anomaly_rate_pct": 0 },
{ "name": "dumfries", "avg": 1.40 },
{ "name": "nomad-node-1", "avg": 1.20 },
{ "name": "nomad-node-0", "avg": 1.07 },
{ "name": "picard", "avg": 0.29 }
]
}

That is a ~90% smaller answer, the two-call workflow collapsed into one, and the aggregation pitfall handled for the model. health_summary shows the composition win — two tabular dumps become one digest — and anomalies_now the reduction win: an 11-column table over tens of thousands of analysed series becomes a short ranked list.

You can register the same Netdata MCP twice: once as the unit (netdata, curated) and once as a raw passthrough (netdata_raw, the full toolset for ad-hoc use). The model then sees both netdata_* and netdata_raw_*.

Because one backend name (netdata) is a prefix of the other (netdata_raw), ToolMesh routes by longest matching prefixnetdata_raw_query_metrics always reaches the passthrough, netdata_metric always reaches the unit. Pick names that do not collide if you are unsure.

A unit is a backend like any other, so the full pipeline applies:

  • Access classification per tool via describe() (read / write / admin / dangerous) — wrap sensitive calls (live processes, logs) as dangerous.
  • _meta signals the unit emits are gated by expose.meta_signals and consumed by the Output Gate.
  • Audit records every api.* call with the unit as parent context.
  • OpenFGA permissions are granted on the unit; that transitively authorises its private dependencies.

This example stays entirely on Netdata’s open-source Agent (GPLv3): the built-in MCP, the on-agent ML/anomaly detection, and a Parent’s multi-node aggregation all work without Netdata Cloud. The unit builds its own analysis layer on that data API — it does not touch or replicate the separately-licensed Netdata Cloud UI. Connecting at arm’s length over MCP/HTTP keeps ToolMesh (Apache-2.0) clean, since the Agent is GPL, not AGPL.