# Unit-Backends: ein governtes Tool über viele private Backends

> Ein Unit-Backend bündelt private MCP-/REST-Sub-Backends hinter einem JavaScript-Glue und exponiert nur eigene, kuratierte Tools. Durchgängiges Beispiel: der Netdata-MCP, damit ein LLM kompakte Antworten statt Roh-Dumps bekommt.

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

Ein **Unit-Backend** bündelt ein oder mehrere *private* Sub-Backends (MCP oder REST) hinter einem einzigen JavaScript-Glue-Modul und exponiert nach außen nur eigene, kuratierte Tools. Die Sub-Backends tauchen nie im Tool-Manifest auf — das Modell sieht eine Handvoll zweckgebauter Tools, während die Unit die Mehrschritt-Arbeit, die Komposition und die Antwort-Verdichtung innerhalb von ToolMesh erledigt.

## Unit-Backend vs. Composite-Tool

ToolMesh hat zwei Kompositions-Mechanismen, und sie sind nicht dasselbe:

| | Komponiert | Lebt in |
|---|---|---|
| **Composite-Tool** | andere Tools **desselben** Backends | einer DADL-Datei |
| **Unit-Backend** | andere **Backends** (MCP oder REST) | einer `unit.yaml` + einem JS-Modul |

Eine Unit ist die richtige Wahl, wenn *ein* modellseitiger Aufruf mehrere Backend-Aufrufe orchestrieren, deren Ergebnisse zusammenführen oder eine geschwätzige API hinter einer kompakten Antwort verbergen soll.

## Wann sich eine Unit lohnt

Wrapping lohnt nur, wenn die Unit etwas tut, das die Roh-Tools plus das Modell nicht günstig hinbekommen. Drei Gewinne treten immer wieder auf — und alle drei greifen schon bei einem **einzigen** Sub-Backend:

1. **Mehrschritt-Kapselung.** Ein sauberes Tool, das eine zwingende „Erst-entdecken-dann-abfragen"-Kette verbirgt.
2. **Tool-übergreifende Komposition.** Ein Digest, aus mehreren Aufrufen zusammengesetzt.
3. **Antwort-Verdichtung.** Große, verschachtelte oder tabellarische Payloads auf die wenigen relevanten Felder reduziert — echte Einsparung im Kontextfenster.

Das Beispiel unten stützt sich genau darauf.

## Aufbau

Eine Unit ist ein Verzeichnis unter `TOOLMESH_UNITS_DIR` (Default `/app/config/units`), das als direktes Unterverzeichnis gescannt wird:

```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>
```

> Der `unit.yaml`-Root muss ein Mapping sein (`unit:` / `implementation:` / `expose:` / `backends:`). Nur die Einträge *unter* `backends:` sind eine Liste.

`expose.tools` ist optional und spiegelt `expose_tools` aus `backends.yaml`: Die gelisteten `describe()`-Tool-Namen werden zusätzlich als direkte Top-Level-MCP-Tools promotet. Promotet wird immer unter dem vollen `<unit>_<tool>`-Namen, nie als nackter Alias — Unit-Tool-Namen wie `search` oder `roll` sind für die Root-Ebene zu generisch. Erreichbar bleibt jedes Unit-Tool ohnehin über `discover_tools` und `execute_code`; ein Eintrag ohne passendes `describe()`-Tool wird beim Laden einmal geloggt und übersprungen.

Die Implementierung ist ein JavaScript-Modul mit einer top-level `describe()`-Funktion (die Tool-Oberfläche) plus einer Funktion je deklariertem Tool. Intern sind private Sub-Backends als `api.<name>.<tool>(args)` erreichbar:

```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

Der Glue läuft pro Aufruf in einer abgeschotteten goja-Runtime:

- **Erlaubt:** `api.<sub>.<tool>(...)`, das `params`-Objekt, reines JS (`Math`, `Date`, `JSON`, `await`).
- **Verboten:** `fetch`, `require`, `import`, `fs`, `process`, `eval`, Timer.
- **Limits:** Aufruftiefe 16, max. 20.000 `api.*`-Aufrufe pro Invocation (Runaway-Bremse).

> **Wissenswerte Falle.** Innerhalb der Sandbox liefert `api.<sub>.<tool>()` den *rohen* MCP-`ToolResult` — `{ content: [{ type: "text", text: "<json>" }], isError, metadata }` — nicht das geparste Payload. `content[0].text` selbst parsen (der `unwrap()`-Helfer oben). Das `execute_code`-Meta-Tool packt automatisch aus, derselbe Code kann also einen Inline-Test bestehen und in der Unit trotzdem leer zurückkommen. Unit-Logik daher immer *durch die geladene Unit* verifizieren.

## Durchgängiges Beispiel: Netdata

Netdatas Monitoring-Agent bringt einen eingebauten MCP-Server mit (kostenlos, Open Source, `http://HOST:19999/mcp` auf jedem Agent oder Parent — kein Netdata-Cloud-Konto nötig). Er exponiert ~13 mächtige, aber low-level Tools. Wir registrieren diesen MCP als privates Sub-Backend und exponieren eine kleine, kuratierte Oberfläche:

| Unit-Tool | Was es tut | Roh-Entsprechung |
|---|---|---|
| `metric` | Per-Gruppe-Summary (avg/min/max/anomaly%) für beliebige Context | `get_metrics_details` → `query_metrics` + verschachteltes Objekt parsen |
| `compare_nodes` | A↔B oder jetzt↔vorhin: Delta/Ratio für eine Context | dieselbe Zwei-Call-Kette, zweimal |
| `health_summary` | Nodes + offene Alerts in einem Digest | `list_nodes` + `list_raised_alerts` + zwei Tabellen parsen |
| `anomalies_now` | ML-markierte Anomalien, gerankt & getrimmt | `find_anomalous_metrics` + 11-Spalten-Tabelle parsen |
| `what_changed` | Metriken, die sich ggü. einem Baseline-Fenster verschoben haben | `find_correlated_metrics` mit von Hand gesetzten Baseline-Fenstern |

### Der Gewinn, gemessen

Abfrage von `system.cpu` über fünf Nodes:

- **Roh-`query_metrics`** liefert ein verschachteltes `summary` / `result` / `view` / `db`-Objekt — **~7,9 KB** — und *verlangt* vorher explizite Dimensionen (es sind also faktisch zwei Calls), plus eine stille Warnung, wenn man Dimensionen mittelt.
- **`metric`** entdeckt die Dimensionen automatisch, wählt eine sinnvolle Aggregation und liefert **~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 }
  ]
}
```

Das ist eine **~90 % kleinere** Antwort, der Zwei-Call-Workflow auf einen reduziert, und die Aggregations-Falle für das Modell abgefangen. `health_summary` zeigt den Kompositions-Gewinn — zwei Tabellen-Dumps werden ein Digest — und `anomalies_now` den Verdichtungs-Gewinn: eine 11-Spalten-Tabelle über zehntausende analysierte Zeitreihen wird eine kurze, gerankte Liste.

## Roh und kuratiert nebeneinander

Man kann denselben Netdata-MCP zweimal registrieren: einmal als **Unit** (`netdata`, kuratiert) und einmal als **Roh-Passthrough** (`netdata_raw`, der volle Werkzeugkasten für ad-hoc). Das Modell sieht dann sowohl `netdata_*` als auch `netdata_raw_*`.

Weil ein Backend-Name (`netdata`) ein Präfix des anderen ist (`netdata_raw`), routet ToolMesh nach dem **längsten passenden Präfix** — `netdata_raw_query_metrics` erreicht immer das Passthrough, `netdata_metric` immer die Unit. Im Zweifel kollisionsfreie Namen wählen.

## Governance

Eine Unit ist ein Backend wie jedes andere, also greift die volle Pipeline:

- **Access-Klassifikation** pro Tool über `describe()` (`read` / `write` / `admin` / `dangerous`) — sensible Aufrufe (Live-Prozesse, Logs) als `dangerous` kapseln.
- **`_meta`-Signale**, die die Unit emittiert, werden über `expose.meta_signals` gegatet und vom Output Gate konsumiert.
- **Audit** protokolliert jeden `api.*`-Aufruf mit der Unit als Eltern-Kontext.
- **OpenFGA**-Berechtigungen werden auf die Unit vergeben; das autorisiert transitiv ihre privaten Abhängigkeiten.

## Hinweis zur Lizenzierung

Dieses Beispiel bleibt vollständig auf Netdatas Open-Source-**Agent** (GPLv3): der eingebaute MCP, die ML-/Anomalie-Erkennung auf dem Agent und die Multi-Node-Aggregation eines Parent funktionieren ohne Netdata Cloud. Die Unit baut *ihre eigene* Analyse-Schicht auf dieser Daten-API — sie berührt oder repliziert nicht die separat lizenzierte Netdata-Cloud-UI. Die Kopplung über MCP/HTTP auf Distanz hält ToolMesh (Apache-2.0) sauber, da der Agent GPL ist, nicht AGPL.
