This guide starts with Milo's core contract: one typed Python function becomes a human CLI command, an MCP tool with a JSON Schema, and an llms.txt entry.
What You'll Learn
- Scaffold a minimal Milo CLI
- Run the command from argv
- Inspect the generated help and llms.txt output
- Run the generated contract tests
- Verify the CLI as an agent-facing MCP server
Create a CLI
uv run milo new my_cli
cd my_cli
The scaffold creates this small project:
my_cli/
app.py
conftest.py
README.md
tests/
__init__.py
test_app.py
The generated app.pycontains one typed command:
from milo import CLI
cli = CLI(name="my_cli", description="What it does", version="0.1")
@cli.command("greet", description="Return a greeting")
def greet(name: str, loud: bool = False) -> str:
"""Greet someone by name.
Args:
name: The person to greet.
loud: If true, SHOUT.
"""
message = f"Hello, {name}!"
return message.upper() if loud else message
if __name__ == "__main__":
cli.run()
The function signature is the contract. name: strbecomes a required CLI
option and MCP schema field.loud: bool = Falsebecomes an optional flag with a
default.
Run It
uv run python app.py greet --name Alice
Expected output:
Hello, Alice!
Boolean defaults become flags:
uv run python app.py greet --name Alice --loud
Expected output:
HELLO, ALICE!
Inspect Help
uv run python app.py --help
uv run python app.py greet --help
Milo generates help from the registered command, type annotations, defaults, and
docstrings. Keep parameter descriptions in theArgs:section so humans and
agents see the same contract.
Inspect llms.txt
uv run python app.py --llms-txt
Look for the generated command entry:
**greet**: Return a greeting
Parameters: `--name` (string, **required**), `--loud` (boolean, optional, default: False)
The llms.txt output is a readable catalog. MCP clients use the JSON Schema from
tools/list; both are generated from the same Python function.
Test the Contract
uv run pytest tests/ -v
The generated tests cover four layers:
| Layer | What it protects |
|---|---|
| Schema | function_to_schema(greet)matches the function signature |
| Direct dispatch | cli.invoke([...])parses argv and returns the expected value |
| MCP dispatch | _call_tool(cli, {...}) returns content or structured errorData |
| Verify | milo verify app.pypasses import, schema, tools/list, and transport checks |
Verify for Agents
uv run milo verify app.py
A healthy scaffold reports six passing checks:
✓ 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_transport: subprocess handshake succeeded; 1 tool(s) over JSON-RPC
Warnings tell you what to improve, such as missing parameter descriptions. Failures mean the CLI is not safe to register as an MCP tool yet.
Register with an MCP Host
Claude Code and other MCP hosts can launch your CLI over stdin/stdout:
claude mcp add my_cli -- uv run python /absolute/path/to/my_cli/app.py --mcp
MCP uses stdout for JSON-RPC. Do not write progress logs with print()from
library or handler code that may run under--mcp; use Contextoutput helpers
or stderr boundary code instead.
What Just Happened?
Milo keeps command resolution, schema generation, programmatic calls, and MCP dispatch tied to one definition. That is the part to preserve as your CLI grows.