This doc gets a coding agent (Claude, DORI, Copilot, etc.) from zero to "my CLI is an MCP tool that Claude is calling" in five minutes. It is written for the agent: copy each block, run it, verify the output, move on.
If something in this doc no longer works, that's the bug — open an issue.
- Python 3.14+ (the project uses free-threading on 3.14t; 3.14 GIL builds also work).
uvinstalled (curl -LsSf https://astral.sh/uv/install.sh | sh).- This repo cloned or
milo-cliinstalled (uv add milo-cli).
uv run milo new my_cliProduces app.py, tests/test_app.py, conftest.py, and a README.md — the
same shape this doc walks through. Scaffold names must be lowercase with
underscores (my_cli, not My-CLI). If the directory exists, the command
refuses to overwrite; pick another name or delete the old one.
# my_cli/app.py
from milo import CLI
cli = CLI(name="my_cli", description="What it does", version="0.1")
@cli.command("greet", description="Say hello")
def greet(name: str, loud: bool = False) -> str:
"""Greet someone.
Args:
name: Person to greet.
loud: SHOUT if true.
"""
message = f"Hello, {name}!"
return message.upper() if loud else message
if __name__ == "__main__":
cli.run()Rules you can rely on:
- Type hints become the JSON Schema for the MCP tool — no separate schema file.
- Parameters without a default are required; parameters with defaults are optional.
- The docstring's
Args:section becomes per-parameterdescriptionin the schema. - Structured return values are serialized to JSON and returned as MCP
structuredContent; string results are returned as text content. - Add
annotations={"readOnlyHint": True}etc. in the@cli.commanddecorator to set MCP behavioral hints. SeeAGENTS.md.
uv run python my_cli/app.py greet --name Alice
# → Hello, Alice!
uv run python my_cli/app.py greet --name Alice --loud
# → HELLO, ALICE!
uv run python my_cli/app.py --help
# → usage and command listingIf --help lists your command, @cli.command is wired correctly.
uv run python my_cli/app.py --llms-txtLook for these lines:
**greet**: Say hello
Parameters: `--name` (string, **required**), `--loud` (boolean, optional, default: False)
If --name shows **required** and --loud shows the default, the JSON Schema
is correct. If not, check that type hints are on both parameters.
Use the claude CLI (part of Claude Code) to register your CLI as an MCP server:
claude mcp add my_cli -- uv run python /absolute/path/to/my_cli/app.py --mcpThe flag after -- tells milo to speak JSON-RPC on stdin/stdout instead of
parsing argv. Nothing else changes about your code.
Alternative — register in the milo gateway (useful when you have several CLIs and want a single MCP entrypoint):
uv run python /absolute/path/to/my_cli/app.py --mcp-install
claude mcp add milo -- uv run python -m milo.gateway --mcpThe gateway namespaces tools: your greet becomes my_cli.greet.
In a fresh Claude Code session, run:
/mcp
You should see my_cli listed with greet as a tool. Call it:
Use the
my_cli.greettool to greet "Bob"
Expected result: Claude calls the tool, the tool returns "Hello, Bob!",
Claude echoes it back.
Before registering with Claude (or any time you break something), run:
uv run milo verify my_cli/app.pyAll seven checks should pass:
✓ imports: loaded app.py
✓ cli_located: found CLI instance (name='my_cli')
✓ commands_registered: 1 command(s) registered
✓ schemas_generate: 1 schema(s) generated; all params documented
✓ mcp_list_tools: 1 tool(s) listed with valid inputSchema
✓ mcp_discover: server/discover advertises 2025-11-25
✓ mcp_transport: subprocess discovery and handshake succeeded; 1 tool(s) over JSON-RPC
A ⚠ schemas_generate row listing parameter 'X' has no description means a
typed parameter is missing an Args: entry (or Annotated[..., Description(...)]).
A ✗ row is a failure — read the details and fix before continuing.
milo verify exits 0 on warnings, nonzero on failures. Wire it into CI.
| Symptom | Likely cause | Fix |
|---|---|---|
Tool doesn't appear after claude mcp add |
MCP server process failed to start | Run uv run python app.py --mcp manually; watch stderr. Any Python import error is fatal. |
Tool appears but call returns isError: True with argument: "name" |
Required arg was not supplied by the caller | Claude sometimes calls without all args — the error payload tells you which is missing. |
Tool returns isError: True with no errorData.argument |
User code raised a plain exception | Raise milo.MiloError(ErrorCode.INP_*, "…", argument="name", constraint={…}) so error data is structured. |
print() breaks the protocol |
MCP uses stdout for JSON-RPC; any other stdout write corrupts the stream | Use the provided Context (ctx.info, ctx.error) or write to stderr. |
| Schema is missing a parameter | Parameter is typed as Context (or named ctx) |
Correct — these are injected at dispatch time and intentionally excluded from the schema. See function_to_schema in src/milo/schema.py. |
| Non-serializable return type | Return value can't be JSON-encoded | Return dict, list, str, int, float, bool, None, or a @dataclass. |
Client gets JSON-RPC -32004 |
The request declared an unsupported MCP protocol version in _meta |
Retry with one of error.data.supported, or use the legacy initialize handshake for 2025-11-25. |
When a tool call fails, the response includes an errorData dict you can
parse to repair the call without guessing:
{
"content": [{"type": "text", "text": "Error: ..."}],
"isError": true,
"errorData": {
"tool": "greet",
"argument": "name",
"reason": "missing_required_argument",
"suggestion": "Provide 'name'.",
"schema": {"type": "object", "properties": {...}, "required": ["name"]}
}
}For validation failures raised via MiloError(argument="env", constraint={"minLength": 1}):
{
"errorData": {
"errorCode": "M-INP-001",
"argument": "env",
"constraint": {"minLength": 1},
"example": "x",
"suggestion": "..."
}
}Parse these fields. Don't rely on the error message string.
Copy examples/greet/tests/test_greet.py next to your app.py, rename the
imports, and edit the assertions. The command-level layers (schema, direct
dispatch, MCP dispatch) cover the common regression surface; milo new also
adds a milo verify test for the full agent-facing CLI. See
testing.md for the full testing story.
uv run pytest my_cli/tests/ -v
uv run milo verify my_cli/app.py- Rich schema constraints:
Annotated[str, MinLen(1), MaxLen(100)]. SeeAGENTS.md. - Streaming progress: yield
Progress(step, total, status)from a generator command. - Tool annotations:
@cli.command("deploy", annotations={"destructiveHint": True}). - Groups and subcommands:
cli.group("db")+@db.command("migrate"). - Middleware:
cli.before_command(hook)/cli.after_command(hook).
For the architecture and design constraints you must respect when extending
milo itself, read AGENTS.md.