MCP Server for LLM Agents
The Fuzzball CLI can run a Model Context Protocol (MCP) server that exposes cluster operations as tools an LLM agent can call. This lets an assistant such as Claude Desktop or an agent runtime observe your workflows, author and run new ones, transfer artifacts through the object cache, and search the cluster’s own documentation – all through the same authenticated connection you already use.
Model Context Protocol is an open standard for
connecting LLM applications to external tools and data. fuzzball mcp serve speaks MCP
over standard input/output (stdio), so it is launched as a subprocess by an MCP client
rather than run interactively.
- A reachable, running Fuzzball cluster.
- A
fuzzballCLI that is logged in to that cluster. The MCP server inherits authentication from your active context – runfuzzball loginfirst. No credentials are ever passed on the command line.
The MCP server acts as you. Any tool the agent calls runs with your identity and permissions on the cluster. Grant only the capability tiers the agent actually needs (see Capability tiers below).
The fuzzball mcp command group holds the MCP tooling; serve runs the server:
$ fuzzball mcp serveStarted with no flags, the server registers read-only tools only – the safe
default. It reads and writes MCP JSON-RPC messages on stdout, and sends all diagnostic
logging to stderr, so you normally do not run it by hand. Instead, an MCP client is
configured to launch it. Most clients accept a command plus arguments; point them at
your fuzzball binary with the mcp serve arguments and whichever capability flags you
want to grant:
{
"command": "fuzzball",
"args": ["mcp", "serve", "--allow-write", "--allow-exec"]
}
Because authentication is inherited from the active context, the client does not need any tokens or secrets in its configuration.
Every client launches the same stdio subprocess – your fuzzball binary with the
mcp serve arguments – so setup has the same shape everywhere: a command (fuzzball)
and its arguments (mcp serve plus any capability flags). Two things apply to all of
them:
- Run
fuzzball loginfirst. The server inherits that logged-in context; the client needs no tokens. fuzzballmust be on the client’sPATH, or give the absolute path to the binary as the command.
Add the --allow-write / --allow-exec / --allow-destructive flags to the arguments
only for the tiers you want to grant (see Capability tiers).
Register the server, granting whichever tiers you need:
$ claude mcp add fuzzball -- fuzzball mcp serve --allow-write --allow-execclaude mcp list shows connection status, and /mcp inside a session reconnects after a
rebuild. Add -s user (before --) to make it available in all your projects, or
-s project to share it via a committed .mcp.json.
Add a server to ~/.codex/config.toml (or a project .codex/config.toml):
[mcp_servers.fuzzball]
command = "fuzzball"
args = ["mcp", "serve", "--allow-write", "--allow-exec"]
Or register it non-interactively:
$ codex mcp add fuzzball -- fuzzball mcp serve --allow-write --allow-execAdd a local server under the mcp object in opencode.json:
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"fuzzball": {
"type": "local",
"command": ["fuzzball", "mcp", "serve", "--allow-write", "--allow-exec"],
"enabled": true
}
}
}
Pi does not support MCP natively, so an MCP extension for Pi is a prerequisite – install
one before continuing. Once the extension is in place, configure the Fuzzball server
through it as a stdio server with the command fuzzball and the arguments mcp serve
(plus any --allow-* flags).
Edit claude_desktop_config.json (Settings -> Developer -> Edit Config) and add the
server under mcpServers:
{
"mcpServers": {
"fuzzball": {
"command": "fuzzball",
"args": ["mcp", "serve", "--allow-write", "--allow-exec"]
}
}
}
Restart Claude Desktop after editing.
Add the server under mcpServers in ~/.cursor/mcp.json (global) or .cursor/mcp.json
(project):
{
"mcpServers": {
"fuzzball": {
"command": "fuzzball",
"args": ["mcp", "serve", "--allow-write", "--allow-exec"]
}
}
}
Tools are grouped into tiers by how much power they hand the agent. Read-only tools are always available; the more powerful tiers are opted in with launch flags, in ascending order of risk:
| Flag | Tier | What it unlocks |
|---|---|---|
| (none) | Read | Observe workflows, nodes, volumes, objects, secrets (metadata only), catalog entries, and identity; search and fetch docs; validate a Fuzzfile without running it. |
--allow-write | Write | State changes that are not arbitrary compute: upload artifacts to the object cache (object_put) and download them to a local file (object_download). Reading object content inline (object_get) stays in the read tier. |
--allow-exec | Exec | Run arbitrary cluster compute: start a workflow, run a one-off command, exec into a running job, or start a catalog workflow. |
--allow-destructive | Destructive | Cancel a workflow (workflow_stop) and delete object-cache entries (object_delete). |
Flags are additive, so grant exactly the tiers you need:
# Read-only server (safe default)
$ fuzzball mcp serve
# Allow the agent to run workflows and stage/retrieve artifacts
$ fuzzball mcp serve --allow-write --allow-exec
# Full access, including cancel/delete
$ fuzzball mcp serve --allow-write --allow-exec --allow-destructiveEach tool also advertises the standard MCP annotations (readOnlyHint,
destructiveHint, and so on) so a well-behaved client can warn you before invoking a
mutating tool.
The tool set is built around a compose -> run -> examine -> iterate loop. Rather than listing every tool, the tables below group them by tier so you can see what each launch flag grants.
| Area | Tools |
|---|---|
| Workflows | workflow_list, workflow_describe (pass include_spec to get the workflow’s specification as JSON; omitted by default because it is large and costs an extra call), workflow_log, workflow_events, workflow_endpoints, workflow_score, workflow_wait |
| Authoring pre-flight | workflow_validate (check a Fuzzfile without spending compute) |
| Compute & clusters | node_list, node_show, cluster_resources, cluster_list, cluster_get |
| Scheduler | queue_show (your queued allocations), workflow_why (typed reason a workflow is pending) |
| Storage & data | volume_list, volume_info, object_list, object_describe, object_get (retrieve content inline, bounded — see below), secret_list, secret_get (metadata only – secret values are never exposed) |
| Identity & config | context_show, organization_get, organization_member_list, group_list, group_get |
| Catalog | catalog_list, catalog_get, catalog_render |
| Documentation | docs_search (returns {"results": [...], "total": N}; no matches is an empty array, not a sentence), docs_get (also returns a worked example Fuzzfile verbatim when given its .fz href) |
List tools return a JSON object, never a bare array:
{
"clusters": [ { "id": "...", "name": "..." } ],
"total": 1
}
total always matches the length of the array, so a query that matches nothing returns
{"clusters": [], "total": 0} rather than an empty object. That distinction matters to an
agent: “no results” and “the request was rejected” must not look the same.
Two controls keep replies inside a model’s context budget:
fields– an optional array naming the top-level keys to keep on each item, for example["id", "name"]. Naming a field that does not exist returns an error listing the valid names. Either spelling works –create_timeandcreateTimeselect the same key – and the reply always uses thelowerCamelCaseform.Omitting
fieldsreturns every field on most tools.node_listis the exception: it returns a documented summary by default, because a node’s fullresourcereport – NUMA topology, socket maps and per-device plugin inventory – runs to kilobytes per node and is rarely what you asked the question for. Name it explicitly (fields: ["id", "resource"]) to get it. Anything a default omits is still reachable this way.Separately, a few tools drop a field entirely before replying (
catalog_listremoveskeyArt, which is thumbnail image data). That is a different thing from a default: asking for one of those is an error saying so, rather than a page of empty objects.A size cap. If a reply would exceed the limit, the tool returns as many items as fit and adds
"truncated": true,"omitted": <count>, and atruncationNotesuggesting how to narrow the request. Responses are never silently cut short.
Output keys are lowerCamelCase (createTime, workflowId). Tool arguments stay
snake_case (max_results, order_by).
Most list tools accept an AIP-160 filter. Use = for an
exact match and : for a substring, so name = "prod" and name:"pro" differ. A bare
word with no comparator is rejected: the server’s parser accepts it but produces no
condition, so the call would return the whole collection and look like a successful filter.
Filter field names are snake_case (display_name, create_time) even though the
response keys are lowerCamelCase. Each tool’s filter description lists the fields it
accepts, because the accepted set is per-tool and narrower than what the tool returns –
volume_list, for instance, filters only on name, display_name and description.
cluster_list takes no filter or order_by: the control plane returns every registered
cluster and ignores both, so match on the returned name yourself.
object_get returns content inline and is bounded by max_inline_bytes (64 KB by
default). An object over the limit is not truncated — it comes back as a refusal
carrying "tooLarge": true, the object’s size, and the maxInlineBytes that applied, so
the reason is unambiguous rather than looking like empty content.
Two ways forward: raise max_inline_bytes if the object is only somewhat over, or use
object_download to write it to a local file. object_download is write-tier, so it is
only present when the operator started the server with --allow-write; on a read-only
server, raising the limit is the only route.
Every tool that reports a workflow’s status – workflow_list, workflow_describe,
workflow_endpoints, workflow_wait, workflow_stop – uses the same six words, and
they match what fuzzball workflow list prints:
pending · started · finished · errored · canceled · down
The underlying API enum is stage-oriented (STAGE_STATUS_*) and never reaches the agent.
Two of those spellings are worth noting: a failed workflow is errored, not failed
(failed is job-level vocabulary), and a running one is started. down is an
autoscaled service scaled to zero — not terminal, but nothing is listening.
When filtering, workflow_list additionally accepts running as an alias for started
and failed as an alias for errored, plus the shorthand active for “pending or
started” — so “what is running right now?” is a single call.
One tool reports a different vocabulary, deliberately. workflow_why explains queued
allocations, whose status is a separate and larger set (CREATING, RUNNING,
FINISHED, CANCELLED, PREEMPTED and others) — note CANCELLED, with two Ls, against
a workflow’s canceled. Those values are passed through as the scheduler reports them
rather than mapped onto the six words above, because they are not the same thing and
flattening them would lose the distinction. When workflow_why says a job is waiting for
a dependency to reach a status, that status is an allocation status.
Every tool that starts a workflow — run, workflow_start, catalog_start — returns the
new workflow’s id under workflowId, so the value can be handed to workflow_describe,
workflow_log or workflow_wait without checking which tool produced it. Note that
workflow_stop is asynchronous: the status it reads back is usually still started, and
the workflow settles to canceled shortly after.
| Tool | Purpose |
|---|---|
object_put | Upload a local artifact to the object cache. |
object_download | Write an object-cache artifact to a local file (for large or binary objects, or anything past object_get’s inline limit). |
| Tool | Purpose |
|---|---|
workflow_start | Submit a Fuzzfile (as a string or a path). |
run | Run a structured one-off command (image, command, resources, environment, mounts). Pass dry_run to get the generated Fuzzfile back validated instead of started — see below. |
workflow_exec | Run a command inside an existing job’s container. |
catalog_start | Run a workflow catalog entry directly with parameter values. |
dry_run returns the Fuzzfile run would have submitted, having validated it, and starts
nothing. It replies with {"workflow": "<yaml>", "warnings": [...]} — the YAML as a string
so it stays paste-able, and any warnings the validator returned.
Validation is done by the server, using the same call workflow_validate makes, so a
dry run rejects anything a real start would reject rather than only what parses. A value
like memory: "not-a-size" is a validation failure, not a preview. That means dry_run
needs a reachable cluster; it is not an offline formatter.
When cluster_id is set, the schedulability half is evaluated against that cluster,
matching what starting the workflow would do — so a dry run cannot bless a workflow the
target federate would refuse, or refuse one it would accept.
| Tool | Purpose |
|---|---|
workflow_stop | Cancel a running workflow. |
object_delete | Delete an object-cache reference and all of its versions. |
Both destructive tools additionally require confirm: true. Granting the flag makes
the tool available; confirming states the intent for a specific call, so an agent
cannot reach an irreversible operation through a malformed or half-built argument
list. workflow_stop’s separate force parameter escalates a graceful cancel to an
immediate stop — it is not a confirmation and does not substitute for one.
Beyond tools, the server publishes guided prompts for common tasks
(write_workflow, run_command, inspect_workflow, research_loop) that a client
surfaces as ready-made starting points. research_loop drives an iterative
experiment loop: it frames a measurable goal with you, then authors a workflow and
runs it repeatedly, improving toward that goal.
Workflow outputs must egress to the object cache. The agent has no tool for reading files out of a storage volume, so a workflow that writes results only to a volume produces nothing the agent can retrieve. Any workflow authored for an agent should egress its results to the object cache, whereobject_getcan fetch them. This guidance is also baked into the relevant tool descriptions and prompts so the agent follows it automatically.
The documentation tools (docs_search, docs_get) serve the connected cluster’s own
documentation site. The base URL is derived from your active context automatically. If
your cluster publishes its docs at a different location, override it with the --docs-url
flag or the FUZZBALL_DOCS_URL environment variable:
$ fuzzball mcp serve --docs-url https://docs.example.com/The worked example Fuzzfiles are part of that site,
so they need no separate tool: pass an example’s .fz href to docs_get and it comes back
verbatim, ready to adapt and validate.
docs_get reads the rendered page, so the text an agent sees matches what you see. Where a
page presents alternatives in tabs – Kubernetes Operator or Docker Compose, for instance –
each tab is returned under its own ### Tab: <name> heading, so the agent follows one set of
instructions rather than a blend of both.