Milo command handlers are resolved through one command registry. Human CLI
dispatch, programmatic dispatch, and MCPtools/callshould agree on command
names, argument behavior, context injection, and returned values.
One Command, Several Surfaces
from milo import CLI, Context
cli = CLI(name="contract")
@cli.command("deploy", description="Deploy a service")
def deploy(environment: str, service: str, version: str = "latest",
ctx: Context = None) -> dict[str, str]:
return {"environment": environment, "service": service, "version": version}
| Surface | Example | Primary use |
|---|---|---|
cli.run() |
python app.py deploy --environment staging --service api |
Real process CLI |
cli.invoke(argv) |
cli.invoke(["deploy", "--environment", "staging", "--service", "api"]) |
Tests and in-process argv dispatch |
cli.call(name, **kwargs) |
cli.call("deploy", environment="staging", service="api") |
Programmatic public dispatch |
cli.call_raw(name, **kwargs) |
cli.call_raw("deploy", environment="staging", service="api") |
Internal/raw dispatch used by MCP |
MCPtools/call |
{"name": "deploy", "arguments": {...}} |
Agent tool invocation |
cli.invoke() returns an InvokeResult with output, stderr, exit_code,
result, and exception. cli.call() and call_raw()return the handler's
plain value or raise.
Groups
Grouped commands use spaces in argv and dot notation in programmatic/MCP calls.
site = cli.group("site", description="Site commands")
@site.command("build", description="Build the site")
def build(output: str = "_site", clean: bool = False) -> dict[str, str | bool]:
return {"output": output, "clean": clean}
| Surface | Name |
|---|---|
| CLI argv | site build --output public --clean |
| Programmatic | cli.call("site.build", output="public", clean=True) |
| MCP | {"name": "site.build", "arguments": {"output": "public", "clean": true}} |
Context Injection
Contextparameters are injected by dispatch and excluded from argparse and MCP
schemas. A handler can usectx.log(), ctx.error(), ctx.progress(), or
ctx.run_app() without exposing ctxas a user argument.
Return Values
| Return value | CLI default output | MCPtools/call |
|---|---|---|
str |
Printed as text | content[0].text |
dict / list |
Formatted by--format |
Text plusstructuredContent |
int / float / bool |
Formatted by--format |
Text plusstructuredContent |
None |
No structured value | Text content from serialization |
Generator yieldingProgress |
Progress output, then final value | notifications/progress, then final value |
Use JSON-serializable return values for commands that agents or--format json
will consume.
Error Behavior
cli.invoke() captures exceptions in InvokeResult.exceptionand sets a
nonzeroexit_code. Programmatic calls raise exceptions. MCP tools/call
returns a tool error response:
{
"content": [{"type": "text", "text": "Error: ..."}],
"isError": true,
"errorData": {
"tool": "deploy",
"argument": "environment",
"reason": "missing_required_argument",
"schema": {"type": "object", "properties": {"environment": {"type": "string"}}}
}
}
Agents should repair calls from errorData, not by parsing the human-readable
error text.
Contract Tests
Use the four-layer testing pattern:
function_to_schema(handler)for schema truth.cli.invoke([...])for argv parsing and formatted output.cli.call(...)orcli.call_raw(...)for plain value dispatch._call_tool(cli, {"name": ..., "arguments": ...})for MCP behavior and structurederrorData.
See Testing for examples.