Quickstart

Build a typed CLI command that also works as an MCP tool.

3 min read 686 words

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

0/5 complete
  • 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?

flowchart LR F["Typed Python function"] --> C["CLI command"] F --> S["JSON Schema"] S --> M["MCP tools/list"] F --> L["llms.txt entry"] C --> T["Generated tests"] M --> T

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.

Next Steps