Every Milo CLI can run as an MCP (Model Context Protocol) server, exposing all registered commands as tools that AI agents can discover and invoke. Milo implements the MCP 2025-11-25 specification.
Quick start
Single CLI as MCP server
myapp --mcp
The server prints a startup banner to stderr with available tools and example requests, then listens on stdin/stdout for JSON-RPC messages.
Register with an AI host
Claude Code, Cursor, and other MCP hosts can connect directly:
claude mcp add myapp -- uv run python examples/taskman/app.py --mcp
Gateway for multiple CLIs
If you have several Milo CLIs, register them once and run a single gateway:
# Register each CLI
myapp --mcp-install
taskman --mcp-install
# Run the gateway
uv run python -m milo.gateway --mcp
The gateway discovers all registered CLIs and exposes their tools under namespaced names (e.g. taskman.add, myapp.deploy).
Running as an MCP server
myapp --mcp
This starts a JSON-RPC server on stdin/stdout. The server writes a startup banner to stderr:
MCP server ready — myapp
Protocol: 2025-11-25
Tools: 3 (add, list, stats)
Transport: stdin/stdout (JSON-RPC, one request per line)
Send requests as JSON, for example:
{"jsonrpc":"2.0","id":1,"method":"initialize"}
{"jsonrpc":"2.0","id":2,"method":"tools/list"}
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"add","arguments":{"title":"..."}}}
Or pipe from a file:
cat requests.jsonl | myapp --mcp
Press Ctrl+C to stop.
Protocol support
Milo implements the MCP 2025-11-25 specification. The server handles these methods:
initialize
{"jsonrpc": "2.0", "id": 1, "method": "initialize"}
Returns protocol version, server info (with title), and capabilities:
{
"protocolVersion": "2025-11-25",
"capabilities": {"tools": {}},
"serverInfo": {
"name": "myapp",
"version": "1.0.0",
"title": "My CLI application"
},
"instructions": "My CLI application"
}
notifications/initialized
{"jsonrpc": "2.0", "method": "notifications/initialized"}
Client confirmation after initialize. No response is sent (per MCP spec).
tools/list
{"jsonrpc": "2.0", "id": 2, "method": "tools/list"}
Returns all registered commands as MCP tools with full schemas:
{
"tools": [
{
"name": "greet",
"title": "Greet",
"description": "Say hello",
"inputSchema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"loud": {"type": "boolean"}
},
"required": ["name"]
},
"outputSchema": {
"type": "string"
}
},
{
"name": "site.build",
"title": "Build the documentation site",
"description": "Build the site",
"inputSchema": { "..." : "..." }
}
]
}
Each tool includes:
| Field | Source | Description |
|---|---|---|
name |
Command name | Dot-notation for groups:site.build, site.config.show |
title |
Handler docstring first line, or title-cased name | Human-readable display name |
description |
@cli.command(description=...) |
Short description |
inputSchema |
Parameter type annotations | JSON Schema for arguments |
outputSchema |
Return type annotation | JSON Schema for the return value (when available) |
tools/call
{
"jsonrpc": "2.0", "id": 3,
"method": "tools/call",
"params": {
"name": "greet",
"arguments": {"name": "Alice", "loud": true}
}
}
Dispatches to the command handler and returns the result as MCP content:
{
"content": [{"type": "text", "text": "HELLO, ALICE!"}]
}
When a handler returns structured data (dict, list, number, bool), the response also includes structuredContent:
{
"content": [{"type": "text", "text": "{\n \"id\": 1,\n \"status\": \"done\"\n}"}],
"structuredContent": {"id": 1, "status": "done"}
}
This lets MCP clients consume typed data directly instead of parsing text.
Schema generation
Schemas are generated automatically from function type annotations.
Input schemas
Generated from handler parameters viafunction_to_schema():
| Python | JSON Schema |
|---|---|
str |
"string" |
int |
"integer" |
float |
"number" |
bool |
"boolean" |
list[str] |
"array"with string items |
dict |
"object" |
X | None |
unwrapped to base type, not required |
Context parameters (ctx: Context) are excluded from schemas.
Output schemas
Generated from handler return type annotations viareturn_to_schema(). If a handler declares -> dict or -> list[str], the corresponding JSON Schema appears as outputSchema in tools/list.
@cli.command("stats", description="Get task statistics")
def stats() -> dict:
return {"total": 10, "done": 7}
This produces "outputSchema": {"type": "object"}in the tool definition.
Registry and gateway
For projects with multiple Milo CLIs, the registry and gateway let you expose all of them through a single MCP connection.
Registering a CLI
myapp --mcp-install
This writes an entry to ~/.milo/registry.jsonwith the CLI's name, command, description, and version. The registry is a simple JSON file:
{
"version": 1,
"clis": {
"taskman": {
"command": ["python", "examples/taskman/app.py", "--mcp"],
"description": "A simple task manager",
"version": "0.1.0"
}
}
}
To remove a CLI:
myapp --mcp-uninstall
Running the gateway
The gateway is a meta-MCP server that discovers and proxies all registered CLIs:
uv run python -m milo.gateway --mcp
On startup, the gateway:
- Reads
~/.milo/registry.json - Spawns each registered CLI and calls
tools/listto discover its tools - Namespaces all tools as
cli_name.tool_name - Listens on stdin/stdout for MCP requests
milo gateway ready
Protocol: 2025-11-25
CLIs: 2 (taskman, ghub)
Tools: 8
Available: taskman.add, taskman.list, taskman.done, ghub.repo.list, ...
When an agent callstaskman.add, the gateway:
- Looks up
taskmanin the routing table - Spawns
taskman --mcp - Sends an
initialize+tools/callrequest with the original tool name (add) - Returns the result to the agent
Listing registered CLIs
uv run python -m milo.gateway --list
Connecting the gateway to an AI host
Register the gateway once:
claude mcp add milo -- uv run python -m milo.gateway --mcp
Now every CLI registered via --mcp-install is discoverable through the single milo MCP server. Tools are namespaced: taskman.add, ghub.repo.list, etc.
Hidden commands
Commands markedhidden=True are excluded from tools/list but can still be called via tools/callif you know the name.
Lazy commands and MCP
Lazy commands with pre-computed schemas appear intools/list without importing their handler modules. The import only happens on tools/call. This keeps MCP startup fast even with heavy dependencies.
cli.lazy_command(
"deploy",
module="myapp.deploy",
handler="run_deploy",
description="Deploy to production",
schema={
"type": "object",
"properties": {"target": {"type": "string"}},
"required": ["target"],
},
)
The outputSchema and titlefields are also deferred for lazy commands — they only resolve when the handler is first imported.
Tip
Combine with--llms-txtto give AI agents both an MCP tool interface and a human-readable discovery document.