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.
Argument Contract
Every dispatch surface uses the command'sfunction_to_schema()result before
the handler runs. Milo rejects missing and unexpected arguments, type errors,
enum mismatches,Annotatedlength and item limits, patterns, uniqueness, and
numeric bounds. Programmatic and MCP calls also coerce string-sourced integers,
numbers, booleans, JSON arrays, and JSON objects. Defaults and injected
Contextparameters retain their normal Python behavior.
Unknown keyword arguments are errors rather than being silently discarded.
Programmatic calls raiseInputError; CLI dispatch exits nonzero; MCP returns
structurederrorData. Middleware may repair or inject arguments before the
shared validation step, but invalid values never reach the command handler.
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.
Programmatic hosts may pass a realContextthrough the reserved
cli.call(..., ctx=host_context) or call_raw(..., ctx=host_context)keyword.
That context can own output, interaction, and confirmation policy. MCP command
arguments cannot injectctx; attempts return structured M-INP-005repair
data because agent-visible inputs remain defined only by the command schema.
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.