MCP Integration
Ze includes an MCP (Model Context Protocol) server that lets AI assistants control BGP operations. The server runs inside the daemon and wraps the same command dispatcher used by the CLI and web interface.
Starting the MCP Server
CLI flag:
ze start --mcp 9718
ze --mcp 9718 config.conf
Config file:
environment {
mcp {
enabled true;
server main {
ip 127.0.0.1;
port 9718;
}
}
}
A server entry that omits port binds 8080, the default the YANG declares. A
server entry that omits ip binds 127.0.0.1. An mcp block with no server
entry at all binds 127.0.0.1:8080.
Environment variable:
export ze_mcp_listen=127.0.0.1:9718
# or simply:
export ze_mcp_enabled=true # defaults to 127.0.0.1:8080
Precedence: CLI > environment variable > config file.
The MCP server defaults to loopback. bind-remote true enables a configured
non-loopback address, with authentication required. See
remote-access.md for native TLS and tunnel deployments.
Authentication
MCP supports four authentication modes selected by environment.mcp.auth-mode:
| Mode | Use case | Config |
|---|---|---|
none |
Loopback dev / tunnel-only deployments | ✕ |
bearer |
Single shared secret, one trusted caller | token leaf |
bearer-list |
Per-identity tokens, many callers, per-identity scopes | identity[] list |
oauth |
OAuth 2.1 resource server, external AS manages identities | oauth container + TLS |
Identity is established per request: every POST presents its own credential and every POST is checked. There is no session identifier that can serve as a second bearer credential. OAuth tokens are validated locally, so revocation at the authorization server is not an immediate per-token revocation mechanism in Ze.
auth-mode none is not a bypass. It is an authenticator that accepts every
caller as an anonymous identity, so even an unauthenticated deployment runs the
same uniform code path.
bearer (legacy single token)
environment {
mcp {
enabled true;
auth-mode bearer;
token my-secret-token;
server main { ip 127.0.0.1; port 9718; }
}
}
Env var ze.mcp.token and CLI flag --mcp-token still work. The token leaf
is ze:sensitive -- masked in show config output. A token set without an
explicit auth-mode infers bearer for operators upgrading from pre-Phase-2
configs.
bearer-list (per-identity tokens)
environment {
mcp {
enabled true;
auth-mode bearer-list;
identity alice { token alice-token; scope [ mcp.read mcp.write ]; }
identity bob { token bob-token; scope [ mcp.read ]; }
server main { ip 127.0.0.1; port 9718; }
}
}
Each identity's token is compared constant-time. The matching entry's name and scopes become the authenticated identity for that one request. Changing an identity, its token or its scopes requires a daemon restart.
oauth (OAuth 2.1 resource server)
environment {
mcp {
enabled true;
bind-remote true;
auth-mode oauth;
oauth {
authorization-server https://auth.example/;
audience https://mcp.example/mcp;
required-scopes [ mcp.admin ];
}
tls {
cert /etc/ze/mcp.pem;
key /etc/ze/mcp.key;
}
server main { ip 0.0.0.0; port 443; }
}
}
Tokens are validated locally: RS256 / RS384 / RS512 / ES256 / ES384 signatures
are verified against JWKS fetched from the authorization server's RFC 8414
metadata document. HS* (HMAC) and alg: none are always rejected.
iss and aud match the configured identifiers exactly, including case,
ports, path escaping, query, and trailing slash. Ze does not normalize
Unicode or URL spellings. The exp / nbf checks allow 60 s leeway, and
the token must carry every required scope.
Discovery uses HTTPS for both the authorization-server metadata and jwks_uri,
including every redirect. Certificates are checked against the system trust
store. The metadata response must be a 200 application/json object with an
issuer exactly equal to authorization-server. If discovery or key loading
fails, the MCP listener does not start. A JWKS that includes encryption keys
must label every key with use, and Ze uses only signing keys to verify tokens.
The JWKS cache expires after 15 minutes. A request with a known key still triggers refresh after expiry, and a failed refresh cannot authorize a token with expired keys. Refresh attempts are limited to one per 30 seconds, and concurrent misses wait for the active refresh.
Ze reads the plain JSON issuer and jwks_uri fields. It does not consume
signed metadata, use token introspection, or act as an authorization server.
The discovery suffix is fixed at /.well-known/oauth-authorization-server,
inserted before the issuer's path. An OpenID Connect-only server that publishes
only the older appended discovery path cannot be used with this configuration.
ze config validate rejects internally inconsistent configurations (oauth
without TLS on a remote bind, oauth without authorization-server, bind-remote
without auth, etc.) before the daemon starts. See
rules/exact-or-reject.md for the
contract.
RFC 9728 metadata: when auth-mode oauth, the server publishes a document
listing the authorization server(s) and supported scopes. Its path is the
well-known suffix inserted between the host and the path of the resource
identifier (audience): an audience of
https://mcp.example/mcp publishes at
https://mcp.example/.well-known/oauth-protected-resource/mcp, and
https://mcp.example/ at https://mcp.example/.well-known/oauth-protected-resource.
The 401 challenge names that URL in resource_metadata, so clients discover
the AS through it. Set audience to the URL clients use for the MCP endpoint:
a client checks the resource field against its own URL (RFC 9728 Section 3.3).
The resource identifier must use HTTPS and contain no fragment. If it includes
a query, the metadata URL retains that query. Escaped path segments remain
distinct: /tenant%2Fadmin does not become /tenant/admin. The published
resource value keeps the configured spelling and never comes from the request
Host header. Ze publishes plain JSON metadata and does not advertise resource
response-signing keys or algorithms.
Constant-time comparison
Bearer tokens (both bearer and bearer-list) use subtle.ConstantTimeCompare
so response timing does not reveal which entry matched (or whether any did).
The bearer-list scan visits every entry regardless of early match.
Configuration reload
MCP listen addresses can migrate on a configuration reload. Authentication,
OAuth discovery settings and TLS material are fixed when the listener starts.
A reload that changes them, even a token rotation within the same auth mode,
is rejected with a restart-required error before listener migration. Changing
the enabled leaf on a running listener also requires a restart.
A token supplied by a flag or environment variable keeps its startup precedence over the config-file token. Removing the entire MCP block leaves the existing listener and its credentials in place.
Checking OAuth over HTTP
With the OAuth example above running and its certificate trusted, a conformant
request without credentials must return HTTP 401 and a WWW-Authenticate
challenge containing the configured metadata URL:
curl --silent --show-error --include https://mcp.example/mcp \
-H 'Content-Type: application/json' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: tools/list' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{
"io.modelcontextprotocol/protocolVersion":"2026-07-28",
"io.modelcontextprotocol/clientCapabilities":{}}}}'
curl --silent --show-error --include \
https://mcp.example/.well-known/oauth-protected-resource/mcp
The metadata GET must return HTTP 200 with application/json, the exact
configured audience in resource, and the configured issuer in
authorization_servers. It requires no token. Adding a different query to
the metadata URL must return HTTP 404.
For an authenticated check, the authorization server must issue a signed token
with the exact issuer and audience, an unexpired exp, a subject, and every
configured required scope. Repeat the POST with
--header @/path/to/private-authorization-header, where the mode-0600 file
contains Authorization: Bearer <token>. Keeping the credential in that file
avoids placing it in the shell history or process arguments. The response must
be HTTP 200 with a tools result. A token expired beyond the 60-second clock
leeway, a signature from an unknown key or a different audience must return
HTTP 401 without echoing token bytes
in the response or audit actor.
For key rotation, publish the replacement signing key before issuing tokens
that name its kid; an unknown key can trigger a fetch once the 30-second
minimum interval has elapsed. To check expiry failure, remove a previously
accepted key and make the JWKS endpoint unavailable. Once the 15-minute cache
lifetime ends, even a still-unexpired token using that key must receive HTTP
401. Restoring a valid JWKS permits a later rate-limited refresh to recover.
Protocol
The MCP server speaks JSON-RPC 2.0 over HTTP at protocol revision
2026-07-28, and accepts no other revision. Each message is its own HTTP POST
to /mcp. The profile is stateless: there is no initialize handshake, no
session, no Mcp-Session-Id, no GET stream, and no server-initiated request.
A typical exchange is:
- (Optional)
server/discoverto learn the supported versions and capabilities tools/listto discover available toolstools/callwith a tool name and arguments
Every POST carries three standard headers plus a _meta block inside
params:
| Header | Required | Must equal |
|---|---|---|
MCP-Protocol-Version |
Always | params._meta["io.modelcontextprotocol/protocolVersion"] |
Mcp-Method |
Always | the body's method |
Mcp-Name |
tools/call, resources/read, prompts/get |
params.name (tools/call, prompts/get) or params.uri (resources/read) |
params._meta key |
Required | Purpose |
|---|---|---|
io.modelcontextprotocol/protocolVersion |
✓ | The revision this request speaks |
io.modelcontextprotocol/clientCapabilities |
✓ | What the client supports. Send {} for none |
io.modelcontextprotocol/clientInfo |
✕ | Client name and version, for logs and display only |
A minimal conformant request:
curl -s http://127.0.0.1:9718/mcp \
-H 'Content-Type: application/json' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: tools/list' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{
"io.modelcontextprotocol/protocolVersion":"2026-07-28",
"io.modelcontextprotocol/clientCapabilities":{}}}}'
Capabilities are declared per request, not once per session. A client declares
task support with the io.modelcontextprotocol/tasks identifier under
clientCapabilities.extensions, and only there. The bare tasks member that
MCP 2025-11-25 used is no longer accepted. That spelling opted into a model
where the client asked for each task individually.
The declaration does two things. It gates the three tasks/* methods, which are
refused with -32021 without it. And it governs whether the server can return a
task handle at all. A client that has not declared the extension still gets its
answer, synchronously, as an ordinary resultType: "complete" result. That is
the only gate. resources/list and resources/read are served to everyone,
because resources is a server capability and a conformant client has no way
to declare it.
Every successful result carries resultType: "complete" and
_meta["io.modelcontextprotocol/serverInfo"].
Errors
| Code | HTTP | When |
|---|---|---|
-32020 |
400 | A required header is missing, or disagrees with the body |
-32021 |
400 | The request needs a capability its _meta did not declare. data.requiredCapabilities names it |
-32022 |
400 | The declared version is not one this server implements. data.supported lists what is |
-32602 |
400 | A required params._meta field is missing or malformed |
-32601 |
404 | Unknown method. The 404 lets a client tell a modern server from a legacy one that does not host the endpoint |
| n/a | 405 | GET or DELETE to /mcp |
Only -32601 and -32021 carry a mandated HTTP status at dispatch time, plus
the three pre-dispatch rejections above (-32020, -32022, and a malformed
_meta) which are all 400. Every other JSON-RPC error, including a -32602 for
an unknown tool or a bad taskId, rides a 200 as JSON-RPC intends.
A legacy client that POSTs initialize receives a -32020, which names the
protocol version this server does speak. Header validation runs before dispatch,
and the legacy request carries none of the required headers.
server/discover
server/discover is the one call a client can make to learn what the server
speaks, before it commits to anything else. It returns supportedVersions,
capabilities, and an instructions string, with serverInfo in the result's
_meta.
capabilities.extensions names two extensions, each with an empty settings
object: io.modelcontextprotocol/ui, the MCP Apps extension, and
io.modelcontextprotocol/tasks, the Tasks extension. Advertising the second is
not bookkeeping. A client is entitled to reject a resultType it does not
recognize, and it recognizes task only because this map says the extension is
supported.
Result caching
Four methods return caching hints, so a client can hold a result instead of re-fetching it every turn:
| Method | ttlMs |
cacheScope |
|---|---|---|
tools/list |
60000 (60 s) |
private |
server/discover |
60000 (60 s) |
private |
resources/list |
3600000 (1 h) |
private |
resources/read |
3600000 (1 h) |
private |
tools/call and the three tasks/* methods (tasks/get, tasks/update,
tasks/cancel) carry no hints. Their results are not cacheable.
There is nothing to configure. The two lifetimes match how the underlying data
changes. Ze rebuilds the tool list from the command registry on every call, and
the UI assets are compiled into the binary. cacheScope is always private,
which tells shared gateways and caching proxies never to serve one caller's
response to another.
Known limitation: a reload takes up to 60 seconds to reach clients
Ze has no push invalidation for the tool list, so ttlMs is the only signal a
client gets. For up to 60 seconds after a config reload, a client can still
offer a command that the reload removed.
That window is a supported mode, not a defect. The protocol explicitly allows a
server to provide ttlMs without a promise of change notifications. The client
then relies on TTL freshness alone.
The window is also self-correcting. A call to a command that no longer exists returns an error. The protocol names such an error as a reason to re-fetch early. The stale entry is therefore usually gone after one failed call, not after the full minute.
If you need a removed command to disappear from a client immediately, restart the client's MCP connection after the reload.
Tools
All MCP tools are auto-generated from the YANG command registry at
tools/list time. Each command group (e.g. rib, show config, metrics)
becomes a tool with an action enum listing its subcommands. When a new
YANG command is registered, it appears as an MCP tool automatically without
code changes.
Run tools/list against a live daemon to see the current tool inventory.
Two handcrafted tools (ze_execute, ze_reference) provide escape-hatch and
discovery capabilities alongside the generated tools.
ze_execute
Run any command the CLI supports. Use ze_reference to discover available commands.
| Parameter | Type | Required | Description |
|---|---|---|---|
command |
string | Conditional: yes, unless the request declared form-mode elicitation | Full command string |
The published inputSchema says the same thing, and says it per request. The
required array names command for a client that declared no form-mode
elicitation. The array is absent for a client that did declare it.
A generated tool's typed parameters carry the two texts their YANG leaf
declares: the property's title is the leaf's ze:help summary and its
description is the leaf's description explanation. A text the leaf does not
declare writes no key, and neither is derived from the other.
Ze cannot advertise a single answer here, because the two clients get different behaviour. A schema-validating host would otherwise refuse to make the very call that reaches the prompt.
So a call that omits command is not always an error. When the request declared
form-mode elicitation, Ze answers with the Multi Round-Trip interim result
and asks for one:
{
"resultType": "input_required",
"inputRequests": {
"ze_execute_command": {
"method": "elicitation/create",
"params": { "mode": "form", "message": "Which ze command should be run? ..." }
}
}
}
The client then retries the same tools/call, under a new id, with
params.inputResponses carrying the answer. A client that declared no
elicitation capability is never prompted. The same is true of a client that
declared url mode only, which Ze does not implement. Both get a tool error
naming the missing argument. See MCP Elicitation.
ze_reference
No parameters. Returns the full machine-readable reference for this daemon
(CLI commands, daemon API endpoints with dispatch keys, plugins, address
families, config services) as JSON. This is the same data as ze help ai --json,
assembled from internal/component/aihelp so the CLI and MCP never diverge.
An MCP client sees this tool in tools/list on connect, so it can discover the
instance's capabilities without out-of-band documentation.
AI Help Reference
ze help ai generates a machine-readable reference from the running binary.
All data comes from the plugin registry, YANG schemas, and RPC registrations,
so it is never out of date.
| Command | Content |
|---|---|
ze help ai |
Summary with counts and quick start |
ze help ai --json |
Machine-readable JSON with commands, RPCs, notifications, plugins, families, services. An RPC carries its one-line summary under short-help and its long explanation under description, and omits description when the module declares no description. Its input and output leaves, and a notification's leaves, carry the same two keys from the leaf's own ze:help and description |
ze help ai cli |
CLI subcommands (ze bgp, ze config, ...) |
ze help ai api |
Daemon API commands with parameters (YANG RPCs) |
ze help ai mcp |
MCP tools with parameters and examples |
ze help ai dispatch |
Dispatch keys for daemon commands |
ze help ai all |
Everything |
The legacy flag form (ze help --ai --api) is still accepted as a hidden alias.
Agent Tooling
Offline commands for agent-driven config validation and repair:
| Command | Purpose |
|---|---|
ze cli -c "validate config <file> | json" |
Structured diagnostics with stable codes |
ze explain [--json] <code> |
Explain a diagnostic code |
ze config fix --plan <file> |
Plan-only repair candidates |
ze skills list [--json] |
List bundled agent skills |
ze skills get <name> [--full] |
Load version-matched skill content |
These commands do not require a running daemon. Agents use them to validate and fix config before committing.
Testing
le test mcp is an MCP client for functional tests. It reads commands from
stdin and sends them to the MCP endpoint.
# Start daemon with MCP
ze --mcp 8080 config.conf &
# Send commands
echo 'wait-established
send bgp * update text origin igp next-hop 1.1.1.1 nlri ipv4/unicast add 10.0.0.0/24' | le test mcp --port 8080
Every message it sends is its own POST to /mcp, with the required headers and
_meta block. There is no handshake to complete and no session to create.
Flags:
| Flag | Purpose |
|---|---|
--port <port> |
MCP server port (required) |
--token <token> |
Bearer token, sent on every request |
--timeout <duration> |
Connection timeout (default 10s) |
--tasks |
Declare the extension in every request's _meta.clientCapabilities. io.modelcontextprotocol/tasks |
There is no --resources flag. resources is a ServerCapabilities member,
not one of the five ClientCapabilities members. A conformant client therefore
never declares resources, and the server serves resources to every caller.
Special stdin directives:
| Directive | Description |
|---|---|
# comment |
Ignored |
<command> |
Run via ze_execute |
@tool_name {json} |
Call a specific MCP tool with JSON arguments |
wait <duration> |
Pause (for example, wait 2s) |
wait-established |
Poll until a BGP peer is Established |
wait-peers |
Poll until at least one peer exists |
wait-tool <name> |
Poll tools/list until the named tool appears |
task-call <tool> [<json>] |
Ordinary tools/call the server must answer with resultType: "task". Prints the taskId. There is no client-side opt-in: the server decides from ze:task-support |
call-sync <tool> [<json>] |
Ordinary tools/call the server must answer synchronously (resultType: "complete", no taskId). Prints the result text |
task-get <id> |
Get task status |
task-result <id> |
Print the result a terminal task carries. Reads it off tasks/get, since tasks/result no longer exists |
task-update <id> [<json>] |
Call tasks/update with optional inputResponses. Requires an empty acknowledgement |
task-cancel <id> |
Cancel a task. Requires an empty acknowledgement |
task-wait <id> <state> |
Poll until task reaches state |
Deliberately-malformed requests, for conformance tests. Each probe-* directive
queues one deviation. The next probe applies every queued deviation, prints
one result line, then clears the queue. A probe with nothing queued sends a
fully conformant request, which is how a test asserts the success shape.
| Directive | Description |
|---|---|
probe-header <name> <value|-> |
Set a request header verbatim, with no sentinel encoding. - omits it |
probe-meta <key> <value|-> |
Set a params._meta field. - omits it. The short keys protocolVersion, clientInfo and clientCapabilities expand to their. io.modelcontextprotocol/ |
probe-method <verb> |
HTTP verb for the next probe (default POST), for asserting the 405 on GET and DELETE |
probe-body <json|-> |
Send this exact request body. - sends an empty body |
probe <method> [<json params>] |
Send one request and print probe status=<http> code=<jsonrpc|ok|none> [data=<json>] [result=<json>] message=<text> |
MCP-Protocol-Version is derived from the _meta protocolVersion value.
probe-meta protocolVersion 2025-06-18 therefore sends a consistent pair, which
tests version rejection (-32022). probe-header MCP-Protocol-Version 2025-06-18 sends a header/body mismatch, which tests -32020.
$LAST substitutes the most recent directive output (for example, the taskId
from task-call). task-update and task-cancel deliberately do not update
it. Both return an empty acknowledgement rather than an identifier, so $LAST
keeps naming the taskId that task-call produced.
Example using typed tools:
wait-established
@ze_announce {"family":"ipv4/unicast","origin":"igp","next-hop":"1.1.1.1","prefixes":["10.0.0.0/24"]}
@ze_peers {}