Dispatch Contract

How CLI, programmatic, and MCP command calls agree.

2 min read 483 words

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:

  1. function_to_schema(handler)for schema truth.
  2. cli.invoke([...])for argv parsing and formatted output.
  3. cli.call(...) or cli.call_raw(...)for plain value dispatch.
  4. _call_tool(cli, {"name": ..., "arguments": ...})for MCP behavior and structurederrorData.

See Testing for examples.