# Unit Backends: one governed tool over many private backends

> A Unit Backend bundles private MCP/REST sub-backends behind one JavaScript glue and exposes only its own curated tools. Worked example: wrapping Netdata's MCP so an LLM gets compact answers instead of raw dumps.

Canonical: https://www.toolmesh.io/en/units/

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.

## Unit Backend vs. Composite Tool

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

| | Composes | Lives in |
|---|---|---|
| **Composite tool** | other tools of the **same** backend | a DADL file |
| **Unit Backend** | other **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.

## When a Unit earns its place

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.

## Anatomy

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

```yaml
# 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)`:

```javascript
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" } } };
}
```

### Sandbox

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*.

## Worked example: Netdata

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 tool | What it does | Raw equivalent |
|---|---|---|
| `metric` | per-group summary (avg/min/max/anomaly%) for any context | `get_metrics_details` → `query_metrics` + parse a deep nested object |
| `compare_nodes` | A↔B or now↔then delta/ratio for one context | same two-call dance, twice |
| `health_summary` | nodes + raised alerts in one digest | `list_nodes` + `list_raised_alerts` + parse two tables |
| `anomalies_now` | ML-flagged anomalies, ranked & trimmed | `find_anomalous_metrics` + parse an 11-column table |
| `what_changed` | metrics that shifted vs. a baseline window | `find_correlated_metrics` with hand-set baseline windows |

### The payoff, measured

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

```json
{
  "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.

## Running raw and curated side by side

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 prefix** — `netdata_raw_query_metrics` always reaches the passthrough, `netdata_metric` always reaches the unit. Pick names that do not collide if you are unsure.

## Governance

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.

## A note on licensing

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.
