A ctlplne studio product

MCP server

What it is

MCP (Model Context Protocol) is the open standard that lets AI applications call external tools. A language model on its own can only produce text; MCP gives it a disciplined way to do things: an AI client connects to an MCP server, asks what tools it offers, and invokes them with structured arguments. Think of MCP as a USB-C port for AI — one standard plug, so any compliant client can talk to any compliant server without bespoke integration code.

probectl ships an MCP server so AI clients — Claude Desktop, an agent framework, your own tool-using app — can query probectl directly, in the client's own "call a tool" idiom: your AI asking your self-hosted platform about your network. It exposes a small catalog of read-and-propose tools scoped by tenant, RBAC, and ABAC over two transports (a transport is simply the channel the messages travel over):

  • stdio — local; the client spawns the probectl binary and talks over stdin/stdout (how Claude Desktop runs it).
  • HTTP — network-reachable; TLS-only and bearer-authenticated.

Under the hood it's a thin, dependency-free JSON-RPC 2.0 server speaking MCP revision 2024-11-05. JSON-RPC is the minimal remote-procedure-call convention MCP builds on: each message is a small JSON document naming a method and its params, and each reply carries the matching id — that is the entire wire format. The tools are mostly read-only; the one write-ish tool is proposal-only and can never act on its own (details below).

Security model: tenant first, then RBAC, then ABAC

%%{init: {'theme':'base','themeVariables':{'background':'#0d1117','primaryColor':'#161b22','primaryTextColor':'#e6edf3','primaryBorderColor':'#3b82f6','lineColor':'#8b949e','secondaryColor':'#21262d','tertiaryColor':'#0d1117','clusterBkg':'#161b22','clusterBorder':'#30363d','fontFamily':'ui-monospace, SFMono-Regular, Menlo, monospace'},'flowchart':{'curve':'basis','nodeSpacing':55,'rankSpacing':55,'padding':12}}}%%
flowchart LR
  C["MCP client"] -->|stdio or HTTPS| T["Transport<br/>(authenticate → principal)"]
  T --> S["MCP server<br/>tenant FIRST, then RBAC, then ABAC,<br/>then rate-limit, then egress gate"]
  S -->|"tools/list"| L["only the tools the<br/>caller may use"]
  S -->|"tools/call"| B["Backend (tenant-scoped)<br/>stores + query engine + RCA"]
  B --> S

Two words of vocabulary first. A tenant is one organization's hard-isolated slice of a probectl deployment — its agents, telemetry, incidents, and users; cross-tenant leakage is the platform's highest-severity failure. RBAC (role-based access control) is the permission system inside a tenant: which of that tenant's users may read tests, read incidents, or run AI queries. ABAC (attribute-based access control) is the final deny-override: a tenant policy may narrow an RBAC grant using subject attributes such as department. The order in the heading is the point — the tenant selects the building, RBAC decides which rooms the badge normally opens, and ABAC may mark a room off-limits for that particular badge holder.

An MCP caller is bound to a single tenant — the token it presents determines which one. Every call enforces the boundary at the MCP layer (internal/ai/mcp/server.go), in order:

  1. Tenant first. A principal with no tenant is rejected. No tool takes a tenant argument, so a call cannot express "another tenant's data" (the same by-construction property as the query layer — see docs/ai-query.md).
  2. Then RBAC. tools/list returns only the tools the caller's permissions allow — an out-of-scope caller doesn't even see a tool it can't use. tools/call re-checks the tool's permission (out of scope → forbidden, never data).
  3. Then ABAC. The server loads policies within that same tenant and applies their deny overrides to both discovery and invocation. A denied tool is hidden from tools/list and refused by tools/call. If policies cannot be loaded and there is no previously loaded, known-good cache entry, authorization fails closed with a temporary-unavailable error rather than silently using RBAC alone.
  4. Then rate-limit and fairness-admit. Tool calls are rate-limited per tenant (default 120/minute, PROBECTL_MCP_RATE_PER_MIN), and query tools enter the shared per-tenant fairness gate before touching a store, so one tenant can't exhaust the server. A denial from either the rate limiter or the fairness gate is returned as the JSON-RPC error -32003 (rate limit exceeded), and over HTTP it also carries status 429 with a Retry-After header — distinct from a genuine tool failure (an isError tool result), so a client's backoff keys cleanly on the code / status.
  5. Then the egress gate. Returning tool output to an external AI client is tenant data leaving the platform, so each tools/call passes the shared egress gate — per-tenant consent, redaction, audit (its own section below).
  6. Then the backend runs through the tenant-scoped stores + the semantic query engine, which enforce tenant → RBAC again. That's defense in depth: a tool can't return another tenant's data even if a layer above had a bug.

The practical consequence: the AI never gains powers of its own. Whatever it may see is exactly what the human who minted its token may see — the model inherits one person's view of one tenant; there is no service-account superview to steal. A confused or compromised model is bounded by the same walls as its owner.

Tools (initial catalog)

A tool, in MCP terms, is one named operation the server advertises: a name, a human-readable description the model reads to decide when to call it, a machine-readable input schema, and — in probectl — the RBAC permission a caller must hold. The catalog is deliberately small and legible: eight tools.

Tool Permission Description
list_tests test.read List at most 200 tenant tests, with limit + truncated metadata.
get_path test.read Most recently discovered path to a target (hops, per-hop loss/latency).
get_bgp_events events.read Recent BGP/routing events for a prefix.
query_flows events.read Network flow / service-map records (eBPF).
get_incident incident.read One incident with its full cross-plane timeline.
correlate_incident incident.read Which planes contributed to an incident, plus the signal timeline.
explain_degradation ai.query RCA on a natural-language question → a cited, RBAC-scoped root cause.
propose_remediation remediation.propose Propose-only. Files a proposed suggestion a human must approve.

Each tool advertises a documented JSON-Schema input (internal/ai/mcp/tools.go), which is the stable contract — JSON Schema is the standard way to declare "this tool takes a target string, that one a required id", so the model knows exactly what shape to produce and the server can reject anything else. Tools whose backing store isn't wired in a deployment (e.g. flows/BGP without ClickHouse) return an empty result with a note rather than failing — so a client gets a clean "nothing here" instead of an error.

Every tool result is encoded through a 1 MiB ceiling after redaction, including the MCP text and structuredContent representations together. Oversized results fail closed instead of being materialized on the wire. list_tests also uses the shared query-fairness gate and fetches only one 201-row sentinel page so its 200-row truncation receipt is exact without loading the full tenant catalog.

About propose_remediation. This is the one tool that writes anything, and it is built so it cannot be dangerous. It only ever creates a state=proposed suggestion — a reroute suggestion, a traffic-shift suggestion, a ticket, or a trustctl renewal request — that a human must approve through the authenticated UI. Think of it as a suggestion box bolted to the wall: the AI can drop a note in, but the box has no wires running to the network. The MCP path can never approve or execute; probectl never executes autonomously (see docs/remediation.md). So the worst an injected prompt can do through this tool is file a suggestion someone then has to look at. TestProposeRemediationToolIsProposalOnly (internal/ai/mcp/mcp_test.go) pins that guarantee — including a structural check that the catalog contains no approve/execute/apply tool at all. Note: the proposal backend is the commercially licensed guarded-remediation feature, attached at the editions seam — it is live only on the HTTP transport of a licensed server. On the lightweight stdio transport (and on an unlicensed deployment) the tool is inert: calling it returns a clear "remediation is not enabled" error result instead of acting. That inertness is wiring, not policy — the stdio process is simply never handed a remediation backend to call, so there is nothing a clever prompt could switch back on.

If an attached license dynamically ages past its grace period, the HTTP propose_remediation tool returns an error result before its proposal delegate runs. Existing proposal review remains available over REST; MCP exposes no review or decision tool. This shares the same attach-seam write capability as REST, so a long-running process cannot retain an MCP-only write path.

Transports and auth

Tokens. A bearer token is a secret string that is the authentication: whoever presents ("bears") it on a request is treated as its owner, with no further challenge. Treat one like a visitor badge that grants exactly one employee's access — it opens the doors that person's badge opens, in that person's building, and nothing else. In probectl, a control-plane bearer token (table mcp_tokens) maps to a tenant plus the owning user's effective RBAC and tenant-scoped ABAC subject attributes. As with sessions, only the token's hash is stored (never the token itself), so a database leak yields no usable badges. The lookup happens before the tenant is known, but only through an exact-hash SECURITY DEFINER function; direct application-role table access without a tenant GUC returns zero rows. The reserved mfa subject attribute is always derived by the control plane after mutable directory attributes are copied; an MCP bearer token carries no interactive MFA proof, so a SCIM-supplied mfa=true cannot turn it into one. Mint one with:

probectl-control mcp-token --user <user-uuid> [--tenant <id>] [--name laptop]

The secret is printed once. The token acts as that user — it carries exactly that user's permissions, no more.

stdio (local — e.g. Claude Desktop)

stdio (standard input/output) is the pair of text pipes every process is born with. On this transport there is no network socket at all: the client spawns the binary as a child process and the two exchange JSON-RPC through those pipes. The token comes from PROBECTL_MCP_TOKEN. Logs go to stderr so stdout stays a clean JSON-RPC channel.

The local-trust model is worth being explicit about. The binary authenticates the token before it serves anything: mcp-stdio resolves PROBECTL_MCP_TOKEN against the mcp_tokens store and refuses to start on a missing or invalid token (runMCPStdio in cmd/probectl-control/mcp.go). What stdio deliberately trusts is the local invoking process: anyone who can spawn the binary with that env var is the principal the token names — workstation process isolation is the boundary, exactly like any local CLI credential (think kubectl's kubeconfig). That trust is reasonable precisely because nothing listens: there is no port to scan and no remote surface — an attacker would already need to run code as you, on your machine, at which point they hold the badge anyway. Tenant scoping and RBAC still apply to every call; the transport grants no extra privilege.

PROBECTL_MCP_TOKEN=<token> PROBECTL_DATABASE_URL=... probectl-control mcp-stdio

Worked end-to-end (every value real except the secrets):

# 1. mint a token for the user the AI client should act as (prints ONCE — copy it):
PROBECTL_DATABASE_URL='postgres://probectl:probectl@localhost:5432/probectl?sslmode=disable' \
  ./bin/probectl-control mcp-token --user 7b1e6c9a-0000-4000-8000-000000000001 --name laptop-claude
# → prints the token secret once; only its hash is stored.
// 2. Claude Desktop → Settings → Developer → Edit Config (claude_desktop_config.json):
{
  "mcpServers": {
    "probectl": {
      "command": "/usr/local/bin/probectl-control",
      "args": ["mcp-stdio"],
      "env": {
        "PROBECTL_MCP_TOKEN": "<the value mcp-token printed>",
        "PROBECTL_DATABASE_URL": "postgres://probectl:probectl@localhost:5432/probectl?sslmode=disable"
      }
    }
  }
}

The same command/args/env triple works in any MCP client that launches a local stdio server (Claude Code, IDE integrations, agent frameworks). Restart the client; the eight probectl tools appear in its tool list. The token acts as that user — what they can't see, the AI can't see.

HTTP (network-reachable)

The HTTP transport puts the same server behind a URL, for clients that can't spawn a local process. The moment a listener is reachable over the network, anyone on the path could read or impersonate plaintext traffic — so this transport is enabled by config and TLS-only and bearer-authenticated — never plaintext when network-reachable (the platform's TLS-everywhere guardrail; TLS is the encryption-and-identity layer under HTTPS). Set PROBECTL_MCP_HTTP_ADDR together with PROBECTL_MCP_TLS_CERT_FILE and PROBECTL_MCP_TLS_KEY_FILE; setting the address without the TLS files fails config validation on purpose, so the endpoint can't come up anonymous — a half-configured server refuses to start rather than start insecure. Then POST a JSON-RPC request with Authorization: Bearer <token>. See configuration.md for the PROBECTL_MCP_* keys.

Worked example — enable the bridge, then prove it answers:

# control plane env (alongside the usual keys):
PROBECTL_MCP_HTTP_ADDR=:9444
PROBECTL_MCP_TLS_CERT_FILE=./certs/tls.crt
PROBECTL_MCP_TLS_KEY_FILE=./certs/tls.key

# list the tools (token from `mcp-token`; --cacert trusts the quickstart CA):
curl -sS --cacert ./certs/ca.crt \
  -H "Authorization: Bearer <token>" -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \
  https://localhost:9444/

A consent-gated call behaves honestly rather than silently: invoking explain_degradation against a remote model before the tenant has consented returns a tool error carrying the exact denial text from ai-egress.md — nothing is sent, and the denial is the signal to run that page's enablement chain.

Methods

Standard MCP, nothing custom: initialize (the opening handshake, where client and server agree on the protocol revision and capabilities), tools/list, tools/call, ping, and the notifications/initialized notification — a notification being a JSON-RPC message sent without an id, which expects, and gets, no reply. A tool result carries both a text rendering and structuredContent (the same data as a machine-readable object). The error split matters to a model: a tool-level failure comes back as an isError result (so the model can read the message and recover — say, retry with a valid argument), while protocol/auth failures are JSON-RPC errors (the conversation itself is malformed or unauthorized). A tenant-policy dependency failure is the server-defined JSON-RPC error -32004 with the stable message authorization policy is temporarily unavailable; clients may retry, but the server does not run or advertise protected tools until it has a safely loaded policy set.

Egress is data leaving the platform's boundary. An MCP caller is an external AI client — typically the front end of a model probectl doesn't operate — so returning tool output means tenant telemetry is leaving the platform. Every tools/call therefore rides the same egress gate as the remote RCA model and the authoring model (internal/ai.EgressGate, built by the control plane's one gate constructor — the same consent source, redaction policy, and audit sink on every surface; see docs/ai-egress.md). Picture a mailroom every outgoing parcel must pass through: it first checks the sender has opted in to shipping at all, then blacks out the sensitive lines, then logs the parcel — three checks, every time:

  • Consent (default deny). The tenant must have opted in via tenant_governance.ai_remote_egress — the same per-tenant consent that gates remote-model RCA; silence means no. Without it, tools/call returns an isError result explaining the requirement, the tool never runs, and the denial is audited. (tools/list and initialize still work — discovery isn't egress.)
  • Redaction. A result is rendered to JSON once, masked by the redaction policy (secrets always; IPs/PII by default; hostnames + custom patterns per config), and the redacted form is what reaches the client — both the text and the structuredContent. Masking runs on the JSON encoding with tenant-scoped, keyed-HMAC tokens — the same tenant + value gets the same mask — so the document stays valid and the model can still tell "this address here is that address there" without ever seeing it. A token from one tenant does not build a useful dictionary for another tenant.
  • Audit. Every call — allowed or denied, and why — lands in the tenant's tamper-evident audit stream as mcp.tool_call (actor, tool, outcome), plus an ai.remote_egress event (surface = mcp) on each allowed call that returns data. Both appends are fail-closed. The call audit must commit before the tool runs; the egress audit must commit before the bounded, redacted result is returned. A missing or failed audit sink yields JSON-RPC -32004 with no tool result, so a database outage cannot silently turn an audited egress path into an unaudited one.

Crucially, the egress gate is a required constructor argument of mcp.New — there is no gate-less constructor, and a nil gate denies every tool call (fail closed). A gate-less MCP server simply can't exist, so no future transport can bypass consent/redaction/audit. The mailroom is load-bearing: you can't build the building without it.

What it deliberately does not do

Three absences, each deliberate:

  • No tenant argument, anywhere. Isolation is by construction, not by a parameter a caller could set.
  • No autonomous action. The only write tool is proposal-only and human-gated; the server never executes a remediation.
  • No anonymous network exposure. The HTTP transport refuses to start without TLS, and every call is bearer-authenticated and consent-gated.

See also

  • docs/ai-query.md — the semantic query engine the tools read through.
  • docs/ai-rca.md — the RCA that explain_degradation invokes.
  • docs/ai-egress.md — the consent/redaction/audit gate every tool call rides.
  • docs/remediation.md — the human-gated proposal workflow propose_remediation files into.
  • docs/configuration.md — the PROBECTL_MCP_* keys.
Rendered live from github.com/ctlplne/probectl — found a mistake? edit this page.