Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ Requires Python 3.14+

## Quick Start

> **Coding agents**: jump to [`docs/agent-quickstart.md`](./docs/agent-quickstart.md)
> for a 5-minute walkthrough from `@cli.command` to a verified Claude MCP tool call.
> See also [`docs/testing.md`](./docs/testing.md) for the test template.

### AI-Native CLI

| Function | Description |
Expand Down
1 change: 1 addition & 0 deletions changelog.d/+agent-vibe-coding.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Agent-first improvements: structured MCP validation errors with argument/constraint context, `form_schema()` introspection helper, `llms.txt` required/optional/default markers, `docs/agent-quickstart.md`, `docs/testing.md`, and `examples/greet/` test template.
182 changes: 182 additions & 0 deletions docs/agent-quickstart.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
# Agent Quickstart

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.

## Prerequisites

- Python 3.14+ (the project uses free-threading on 3.14t; 3.14 GIL builds also work).
- `uv` installed (`curl -LsSf https://astral.sh/uv/install.sh | sh`).
- This repo cloned or `milo-cli` installed (`uv add milo-cli`).

## Step 1 — Write the function

```python
# 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-parameter `description` in the schema.
- Return value is serialized to JSON and returned as MCP `structuredContent`.
- Add `annotations={"readOnlyHint": True}` etc. in the `@cli.command` decorator
to set MCP behavioral hints. See `AGENTS.md`.

## Step 2 — Run the CLI

```bash
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 listing
```

If `--help` lists your command, `@cli.command` is wired correctly.

## Step 3 — Verify the MCP tool schema

```bash
uv run python my_cli/app.py --llms-txt
```

Look 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.

## Step 4 — Register with Claude

Use the `claude` CLI (part of Claude Code) to register your CLI as an MCP server:

```bash
claude mcp add my_cli -- uv run python /absolute/path/to/my_cli/app.py --mcp
```

The 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):

```bash
uv run python /absolute/path/to/my_cli/app.py --mcp-install
claude mcp add milo -- uv run python -m milo.gateway --mcp
```

The gateway namespaces tools: your `greet` becomes `my_cli.greet`.

## Step 5 — Verify from inside Claude

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.greet` tool to greet "Bob"

Expected result: Claude calls the tool, the tool returns `"Hello, Bob!"`,
Claude echoes it back.

## When things go wrong

| 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`. |

## Error data contract (important for agents)

When a tool call fails, the response includes an `errorData` dict you can
parse to repair the call without guessing:

```json
{
"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})`:

```json
{
"errorData": {
"errorCode": "M-INP-001",
"argument": "env",
"constraint": {"minLength": 1},
"example": "x",
"suggestion": "..."
}
}
```

Parse these fields. Don't rely on the error message string.

## Test your CLI

Copy `examples/greet/tests/test_greet.py` next to your `app.py`, rename the
imports, and edit the assertions. The three test layers (schema, direct
dispatch, MCP dispatch) cover the common regression surface. See
[`testing.md`](./testing.md) for the full testing story.

```bash
uv run pytest my_cli/tests/ -v
```

## Next

- Rich schema constraints: `Annotated[str, MinLen(1), MaxLen(100)]`. See `AGENTS.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`](../AGENTS.md).
87 changes: 87 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Testing a milo CLI

A milo CLI has three test layers, each short. The template at
[`examples/greet/tests/test_greet.py`](../examples/greet/tests/test_greet.py)
shows all three. Copy the file next to your own CLI and edit the assertions —
the structure is the same for every CLI.

## Layer 1 — Schema

The JSON Schema generated from your function's type hints must match what MCP
clients will see. Most schema drift is caught here.

```python
from milo.schema import function_to_schema

def test_schema_matches_signature():
schema = function_to_schema(my_command)
assert schema["required"] == ["env"]
assert schema["properties"]["env"]["type"] == "string"
```

See also `form_schema(*specs)` if your CLI drives an interactive form — it
returns a JSON Schema describing the form without running the TUI.

## Layer 2 — Direct dispatch

Verify that the function runs correctly when invoked through the CLI argv parser.
Use `cli.invoke(argv)` — it returns an `InvokeResult` with `output`, `exit_code`,
`result`, `stderr`, and `exception`. This is the test your CI should rely on most.

```python
def test_greet_argv():
result = cli.invoke(["greet", "--name", "Alice"])
assert result.exit_code == 0
assert "Hello, Alice!" in result.output
```

For direct calls that bypass argv parsing, use `cli.call_raw(name, **kwargs)`.

## Layer 3 — MCP dispatch

Verify that the JSON-RPC `tools/call` path returns what agents expect, including
error data when a required argument is missing.

```python
from milo.mcp import _call_tool

def test_mcp_dispatch():
result = _call_tool(cli, {"name": "greet", "arguments": {"name": "Agent"}})
assert result["content"][0]["text"] == "Hello, Agent!"
assert "isError" not in result

def test_mcp_missing_arg_has_argument_context():
result = _call_tool(cli, {"name": "greet", "arguments": {}})
assert result["isError"] is True
assert result["errorData"]["argument"] == "name"
```

`errorData` is the structured diagnostic — when your handler raises a
`MiloError` with `argument=` and `constraint=` kwargs, those surface in the
response so agents can repair the call automatically.

## When to use `assert_renders` / `assert_state` / `assert_saga`

For interactive apps (forms, wizards, TUIs), use the helpers in
[`milo.testing`](../src/milo/testing/). They let you feed actions to a reducer
and snapshot-test the rendered output. These are for *interactive* state, not
CLI dispatch — the three layers above cover dispatch.

## Running tests

```bash
# Full suite
make test

# A single example
uv run pytest examples/greet/tests/ -v

# With coverage (project enforces 80% floor)
make test-cov
```

## Free-threading (Python 3.14t)

Milo runs its test suite with `PYTHON_GIL=0` on 3.14t builds so threading bugs
surface in CI. If your CLI adds mutable global state, add a test that exercises
concurrent calls — see `tests/test_freethreading.py` for patterns.
43 changes: 43 additions & 0 deletions examples/greet/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# greet — minimal milo CLI template

The smallest milo CLI that exercises all three protocols: CLI dispatch, MCP tool,
and llms.txt. Use this as the starting point for a new CLI — copy the folder,
rename, edit `app.py`.

## Run

```bash
# CLI
uv run python examples/greet/app.py greet --name Alice
uv run python examples/greet/app.py greet --name Alice --loud

# llms.txt (agent-readable catalog)
uv run python examples/greet/app.py --llms-txt

# MCP server (stdin/stdout JSON-RPC, for Claude et al.)
uv run python examples/greet/app.py --mcp
```

## Test

```bash
uv run pytest examples/greet/tests/ -v
```

The test file at `tests/test_greet.py` is a template covering the three layers:

- **Schema** — `function_to_schema(greet)` matches the signature.
- **Direct dispatch** — `cli.invoke([...])` returns the expected output.
- **MCP dispatch** — `_call_tool(cli, {...})` returns the expected response, and
a missing required arg returns structured error data with `argument` context.

Copy this file next to your own `app.py` and edit the assertions.

## How this gets discovered by Claude

```bash
uv run python examples/greet/app.py --mcp-install
```

This registers the CLI with the milo gateway. See `docs/agent-quickstart.md` for
the end-to-end walkthrough.
31 changes: 31 additions & 0 deletions examples/greet/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""greet — a minimal milo CLI that ships as CLI, MCP tool, and llms.txt.

The simplest possible template for agents: one typed function, three protocols.

Run modes:
uv run python examples/greet/app.py greet --name Alice
uv run python examples/greet/app.py --llms-txt
uv run python examples/greet/app.py --mcp
"""

from __future__ import annotations

from milo import CLI

cli = CLI(name="greet", description="Say hello to someone", version="1.0")


@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()
Empty file.
Loading
Loading