Skip to content

MCP for AI assistants

Toise is LLM-first: its primary consumer is an AI assistant querying on an operator's behalf. A native Model Context Protocol (MCP) server is part of the backend, not an integration bolted on later — it is the path the assistant actually takes to reach Toise. It reads the same in-memory projection and event log as GraphQL and the debug UI, so every surface reports the same world (ADR 0011).

Transports

Transport Address Use
Streamable HTTP http://<listen>/mcp (default 127.0.0.1:8080/mcp) web-based MCP clients, remote assistants
stdio toise-server --mcp-stdio Claude Desktop and other local clients that launch a subprocess

--mcp-stdio makes the process a pure local MCP server: it disables the HTTP and OTLP servers and just reads the given data directory.

The tools

The assistant sees twelve typed tools. Each carries a rich description and examples so the model picks the right one, and each returns structured, name-bearing results — ids carry human labels and types — so a single call answers the question without a second lookup.

Tool What it does
describe_schema() a natural-language description of the entity and relation types currently in the graph, to bootstrap the model's understanding
describe_type(type) zoom on one type: observed attribute keys with examples, empirical relation shapes and peers — or, for a relation type, endpoint shapes and failure-propagation direction
find_entities(type, match, limit, verbosity) entities matching a type / attribute filter, with total/truncated
get_entity(entity_id, verbosity) a full entity with its attributes, plus any operator annotations, plus a canonical group — other entities that high-confidence same_as edges assert are the same real thing (ADR 0020; a read-time view, the entities are not merged)
annotate_entity(entity_id, annotations) attach operator notes (an overlay, not producer truth) to an entity — merge key/values, an empty value removes a key; the one write tool, so it needs a write-capable token
get_neighbors(entity_id, relation_type, max_depth, limit, verbosity) traverse relations up to max_depth (capped at 5); each neighbor carries the relation type, direction, and hop distance that reached it, with total/truncated like the other list tools
find_path(from_id, to_id, relation_type, max_depth) the shortest relation path between two entities; reachable: false is a first-class answer, never an error. A high-confidence same_as connects a path (shown as a same_as hop); a below-threshold belief does not (ADR 0020)
impact_of(entity_id, max_depth) the blast radius of a failure: everything the entity takes down, following each relation type's dependency direction, nearest first. High-confidence same_as aliases of the entity are folded into the origin (ADR 0020), so querying any facet of a machine yields the same radius
entity_history(entity_id, since, until, ...) an entity's timeline from the event log (bi-temporal), heartbeats excluded by default, bounded by limit, with a per-type digest
recent_changes(window, kind, change_type, ...) recent qualified changes across the graph — same budget and digest contract; window defaults to 1h
graph_diff(window | from/to, limit) the folded net difference between two instants: created / deleted / changed / transient (flapping) entities and relations, churn collapsed away
telemetry_keys(entity_id) the OTel attributes that locate this entity's metrics and logs in observability backends — its own keys plus those of the entity that owns it, each with its flattened metric-label spelling and usage caveats

Pivoting to telemetry. telemetry_keys walks one hop to the entity that owns the one you asked about and inherits its keys — a listener gains its host's host.id through runs_on, a route gains its device's through has_route. Observation and peer relations are not followed: what monitors an entity, or what it depends on, describes something else, and inheriting through them would hand back another machine's telemetry as if it were the subject's. An empty result therefore means no key exists, not that none was found — some types legitimately have none, an address or a route being a graph fact with no measurements of its own.

Where the join then holds depends on how the telemetry reached the backend, and the tool says so rather than implying a uniform guarantee:

Path Round trip
OTLP rail into a metrics/logs backend guaranteed — the keys travel as resource or per-datapoint attributes
Prometheus-family backend fed by a collector holds where that collector flattens resource attributes into labels — a deployment choice, not a property of the wire
A producer's own scrape endpoint not guaranteed — there is no resource there, so resource-borne keys are absent

A remote target the producer polled — a database, an SNMP device — has no resource of its own, so its identity rides each datapoint instead. Those keys are absent from any label set derived by flattening resource attributes, and the tool notes it on the key itself.

Time travel. Every graph-reading tool takes as_of (RFC 3339): the answer is the graph as it was at that instant, rebuilt from the event log — "show me db-07's dependencies as they were last Tuesday" is one call. An as_of older than the retention horizon is refused explicitly (those events are pruned). The audit reading — what Toise knew at an instant — stays on entity_history's as_known_at.

Budgets. The timeline tools exclude entity.unchanged heartbeats unless asked (include_heartbeats), bound their output (limit, default 50, max 200), and report a digest — total, truncated, heartbeats_excluded, counts per change type — so the model can narrow instead of paging blind. Every tool call runs under a 30-second budget.

Verbosity. The entity-returning tools (find_entities, get_entity, get_neighbors) take an optional verbosity: compact returns just the id, type and label of each entity — cheap to scan a large set — and full (the default) adds the identity and descriptive attributes. Scan compact, then re-fetch the one entity you care about in full.

Annotations (the one write). Eleven tools read; annotate_entity writes. It attaches operator notes — owner, runbook link, a remark — as an overlay kept in a per-tenant sidecar, never producer truth and never part of the event log, so it is not replayed and does not appear in history. Merge key/values; an empty value removes a key. They surface back on get_entity (and on Entity.annotations in GraphQL). Because it writes, it requires a write-capable bearer token (full or tenant-scoped); a read-only token is refused. With auth disabled (trusted network) any caller may annotate.

Errors are plain, user-friendly messages (e.g. "max_depth 7 exceeds the maximum of 5"), never stack traces.

Resources

Beyond the tools, Toise exposes a few resources — read-only context a client can fetch by URI and pin into the conversation, under the toise:// scheme:

Resource What it is
toise://schema the current graph schema (entity/relation types, counts, a natural-language summary) as JSON — the same data as describe_schema, but pinnable
toise://guide a short markdown orientation: what Toise models, the bi-temporal log, the tool catalog, a suggested first move
toise://entity/{id} a resource template — a single entity (identity, attributes, annotations) by its logical id, the same data as get_entity

Resources never diverge from their tool twins — the schema and entity resources call the same handlers as describe_schema and get_entity.

Prompts

Toise also ships reusable prompts — user-invocable templates that seed a conversation with a well-shaped operator task and steer the assistant toward the right tools, so an analyst gets a good investigation without knowing the catalog:

Prompt Arguments Seeds
investigate_incident entity (required), window triage a suspected cause: state, recent change, blast radius, telemetry
blast_radius entity (required) what a failure of this entity takes down, and how
explain_entity entity (required) a full briefing on one entity: what it is, how it connects, its history, its telemetry
whats_changed window triage recent change across the graph (newest/most significant first)

The tool, resource, and prompt surface is pinned by the same golden contract (internal/mcp, tool_contract.golden): adding or renaming any of them is a deliberate change that fails the build until the golden is regenerated.

Connect Claude Desktop (stdio)

Add the toise entry to your claude_desktop_config.json under mcpServers, replacing the absolute paths with yours:

{
  "mcpServers": {
    "toise": {
      "command": "/usr/local/bin/toise-server",
      "args": [
        "--mcp-stdio",
        "--data-dir",
        "/var/lib/toise/toise-data"
      ]
    }
  }
}

Claude Desktop launches toise-server as a subprocess and speaks MCP over stdio. A copy of this config ships at docs/demo/claude-desktop-config.json.

Connect an HTTP MCP client

Point any Streamable-HTTP MCP client at a running server:

http://127.0.0.1:8080/mcp

The server must be running normally (not in --mcp-stdio mode) so the HTTP listener is up.

A live session

A real run against a live 60-host fabric (~318 entities, ~320 relations). Each answer is the assistant's natural-language synthesis of a tool's structured result:

"What is this Toise instance tracking?"describe_schema()

Your instance tracks 318 entities across 8 types — 60 hosts with their interfaces, addresses, routes and service listeners, plus 8 PostgreSQL databases, 6 network switches, and the agent observing them — connected by 320 relations. A small, well-wired 60-machine fleet.

"What is connected to host-0000?"get_neighbors(entity_id, max_depth: 1)

host-0000 has 2 direct neighbours: its eth0 interface (oper_state=up) and the agent that monitors it. One hop further would surface its address, route, and listener.

"What structural changes happened in the last 2 minutes?"recent_changes(window: "2m", kind: "structural")

A wave of relation.added events — switch adjacencies, has_interface edges — each stamped with both event_time (reality) and recorded_at (ingestion). Nothing broken; the map is assembling.

Reproduce it:

make build
./bin/toise-server --data-dir ./live-data &
./bin/toise-probe --hosts 60 --interval 60s --heartbeat 6s

More example operator questions, each mapped to the tool calls they should make, are in docs/demo/llm-prompts.md.

Why it matters

  • The model takes the same path a real assistant takes to reach Toise — MCP is part of the backend, not glue.
  • Tool results are name-bearing and structured, so one call answers the question.
  • It runs on live data flowing through the real OTLP path, on the one read model shared with GraphQL and the debug UI.