Expose your command tree with `__schema`
You have an ax-go CLI and you want an agent (or an MCP client) to discover it
without a human hand-writing an integration. This guide shows how to read the
__schema output, report a real version in it, emit an MCP-compatible
description, and serve the whole CLI as an MCP server.
This is a recipe, not a lesson. It assumes you already have a command built on
ax.Execute — if not, work through
Build your first agent-ready CLI first.
Read the schema you already have
Section titled “Read the schema you already have”ax.Execute injects a __schema command for you. Run it:
yourcli __schemaThe JSON on stdout describes the tool and its command tree:
{"schema_version":"1.0.0","tool":"yourcli","version":"0.1.0","mode_detection":"--format flag > AGENT_MODE env > TTY detection","command":{"use":"yourcli","short":"...","flags":[{"name":"dry-run","type":"bool","default":"false","usage":"..."},{"name":"format","type":"string","usage":"..."},{"name":"idempotency-key","type":"string","usage":"..."}]}}The --format, --dry-run, and --idempotency-key flags appear even though you
never declared them — ax-go adds them to every command.
Report a real version
Section titled “Report a real version”If version reads 0.0.0-unknown, no build version reached the binary. Wire one
in with a package variable and a link-time flag:
var version string // set by -ldflags "-X main.version=..."
func main() { resolved := ax.ResolveVersion(version)
root := newRootCommand() // your existing command tree
os.Exit(ax.Execute(context.Background(), root, ax.WithVersion(resolved)))}Build with the version injected:
go build -ldflags "-X main.version=$(git describe --tags --always --dirty)" -o yourcli .yourcli __schema # "version" now reflects the buildax.ResolveVersion guarantees a usable value — it falls back to Go build
metadata and never returns the bare dev or unknown. Pass the same resolved
value to ax.WithVersion, ax.WithLoggerLabels, and mcp.WithVersion so
__schema.version, ax.Error.version, and the logger version label all agree.
Emit MCP tool descriptions
Section titled “Emit MCP tool descriptions”To get the same command tree shaped as MCP tool definitions, add --as=mcp:
yourcli __schema --as=mcp{"tools":[{"name":"yourcli","description":"...","inputSchema":{"properties":{"format":{"type":"string","description":"..."}},"type":"object"}}]}This is a one-shot description — useful for registering your CLI with an MCP client or inspecting what it would expose.
Serve the CLI as an MCP server
Section titled “Serve the CLI as an MCP server”To serve live over MCP, mount the reserved mcp-server command. Unlike
__schema, this one is opt-in — you add it explicitly:
import "github.com/rshade/ax-go/mcp"
// inside your command setup, after building root:root.AddCommand(mcp.NewCommand(root, mcp.WithVersion(resolved)))Run it over stdio (the default transport):
yourcli mcp-serverEvery visible, runnable command becomes a callable MCP tool. The rules are covered in Excluding commands below. To serve over HTTP instead:
yourcli mcp-server --transport=http --addr=127.0.0.1:8080Excluding commands
Section titled “Excluding commands”__schema --as=mcp and mcp-server share one walk of your command tree, so
they always list the same tools. That walk applies these rules:
| Command | Effect on the tool list |
|---|---|
Hidden: true |
Dropped with its whole subtree |
Reserved: __schema, mcp-server, completion, help |
Dropped with its whole subtree |
Pure group with no Run or RunE |
Dropped on its own; children stay tools |
Marked with mcp.Exclude |
Dropped on its own; children stay tools |
A group command only prints usage, which is prose rather than a machine
payload, so it never becomes a tool. A command named help is reserved even
when you define it yourself; rename it if you want it callable.
Use mcp.Exclude for a command an agent should not call, such as an
interactive TUI root or a long-running server that would block every later
tool call:
import "github.com/rshade/ax-go/mcp"
mcp.Exclude(root) // the TUI root; its subcommands stay toolsmcp.Exclude(serve) // a blocking server commandAn excluded command still appears in --help and in __schema --as=ax.
Calling its tool name returns the same unknown-tool error as any name the
server never registered, and the command does not run. Exclusion is a Cobra
annotation (github.com/rshade/ax-go/mcp/exclude set to true); any other
value is ignored. To hide a command from humans too, set Hidden: true
instead.
Declare prompts and resources
Section titled “Declare prompts and resources”Tools tell an agent what it can call. A prompt tells it how to use your CLI for a task, and a resource gives it stable reference material it can look up again. Declare both on the command they describe:
if err := ax.DeclarePrompt(root, ax.Prompt{ Name: "triage-spike", Title: "Triage a cost spike", Description: "Find and explain the top cost driver in a window.", Arguments: []ax.PromptArgument{ {Name: "window", Description: "lookback window, e.g. 7d", Required: true}, }, Template: "Run `yourcli report --since={{window}}`, then `yourcli explain` on the top line item.",}); err != nil { return err}
if err := ax.DeclareResource(root, ax.Resource{ URI: "yourcli://docs/pricing-model", Name: "pricing-model", MIMEType: "text/markdown",}); err != nil { return err}yourcli __schema shows each declaration on its command, under prompts and
resources. yourcli __schema --as=mcp collects every declaration into
top-level arrays, in command-tree order:
{"tools":[...],"prompts":[{"name":"triage-spike","title":"Triage a cost spike","description":"...","arguments":[{"name":"window","description":"lookback window, e.g. 7d","required":true}],"template":"Run `yourcli report --since={{window}}`, then `yourcli explain` on the top line item."}],"resources":[{"uri":"yourcli://docs/pricing-model","name":"pricing-model","mimeType":"text/markdown"}]}The rules are checked when you declare, and a bad declaration returns an
*ax.Error with error_code invalid_schema_declaration (exit 2). Its
context names the field and a stable reason, and actionable_fix says
what to change:
- A prompt needs a name and a template. Names (prompt and argument) use only
letters, digits,
_,., and-, and argument names are unique within a prompt. - Every
{{name}}in a template must name a declared argument. Other brace text, such as{{ name }}with spaces, is left as literal text. - A resource needs a name and a URI. The URI must carry a scheme
(
yourcli://docs/xorurn:yourcli:x), parse, stay within 2048 bytes, and contain no spaces or control characters. A MIME type may contain spaces, as intext/plain; charset=utf-8, but no control characters. - A prompt name or resource URI already declared on the same command is rejected, and all text must be valid UTF-8.
A resource can carry static Content (at most 1 MiB, valid UTF-8) that
mcp-server returns from resources/read. __schema never projects it, so a
long document does not bloat discovery. Content is fixed text captured when you
declare it, never a callback, so a resource cannot expose live state or run
records. A resource declared without content is still listed and reads as an
empty body. ax-go never inlines a truncated body, because an agent cannot tell a
fragment from the whole. Keep prompt templates short (at most 64 KiB), because
__schema includes them; put long reference text in resource content.
Declarations under a hidden command are dropped, like the command itself. The
root command always keeps its own, even when it is hidden, exactly as
__schema keeps a hidden root. A group command or a command marked with
mcp.Exclude keeps its declarations, because those rules only decide which
commands become tools.
__schema refuses to emit a silently reduced contract. It fails with a
validation_error (exit 2) when two commands declare the same prompt name or
resource URI, naming both commands, and when a declaration annotation was
written by hand and does not decode cleanly. Declare only through
ax.DeclarePrompt and ax.DeclareResource.
Serve them, and tell the agent where to look
Section titled “Serve them, and tell the agent where to look”mcp-server registers every declared prompt and resource it would list in
__schema --as=mcp, in the same order, and advertises a capability only when
something is declared, so a CLI that declares nothing sends the handshake it
always did. Pass mcp.WithInstructions to put a short string in the
initialize result:
root.AddCommand(mcp.NewCommand(root, mcp.WithVersion(version), mcp.WithInstructions("Read yourcli://docs/pricing-model before calling any tool."),))Instructions must be valid UTF-8 and at most 8 KiB, or the server fails at
startup with a validation_error (exit 2). In Claude Code the instructions
reach the agent’s context when it connects, the model can read a resource itself,
and a prompt becomes a /mcp__<server>__<prompt> slash command the user runs.
prompts/get replaces each declared {{name}} with the argument’s value,
leaves other brace text alone, and rejects a missing required argument or an
undeclared one with an invalid-params error. An absent optional argument
renders as empty text. The server only renders text; it never runs it.
When the server starts, it applies the same duplicate and corrupt-annotation
checks as __schema, so it never serves a set __schema would refuse.
Keep the schema stable in CI
Section titled “Keep the schema stable in CI”__schema is part of your public contract: an agent that learned your CLI from
it will break if the shape changes silently. Pin the output with a golden-file
test so any change to the command tree, flags, or types has to be reviewed
deliberately rather than slipping through.
Related
Section titled “Related”- Tutorial: Build your first agent-ready CLI
- Guide: Test a command built on ax-go — run the same flags this page documents through a real test.
- Explanation: Why Agentic Experience? — why self-description matters to an agent.