Every Milo CLI can run as an MCP (Model Context Protocol) server, exposing eligible registered commands as tools that AI agents can discover and invoke. Milo implements the MCP 2025-11-25 specification.
Commands registered withsurfaces that omit "mcp"are neither advertised
bytools/list nor callable through tools/call. This is appropriate for
long-running server commands; for example,
@cli.command("serve", surfaces=("cli",)). The same command remains available
to terminal and programmatic callers.
Quick start
Single CLI as MCP server
myapp --mcp
The server prints a startup banner to stderr with available tools and example requests, then listens on stdin/stdout for JSON-RPC messages.
Register with an AI host
Claude Code, Cursor, and other MCP hosts can connect directly:
claude mcp add --transport stdio myapp -- \
uv run python /absolute/path/to/examples/taskman/app.py --mcp
Gateway for multiple CLIs
If you have several Milo CLIs, register them once and run a single gateway:
# Register each CLI
myapp --mcp-install
taskman --mcp-install
# Run the gateway
uv run python -m milo.gateway --mcp
The gateway discovers all registered CLIs and exposes their tools under namespaced names (e.g. taskman.add, myapp.deploy).
Running as an MCP server
myapp --mcp
This starts a JSON-RPC server on stdin/stdout. The server writes a startup banner to stderr:
MCP server ready — myapp
Protocol: 2025-11-25
Tools: 3 (add, list, stats)
Transport: stdin/stdout (JSON-RPC, one request per line)
Send requests as JSON, for example:
{"jsonrpc":"2.0","id":1,"method":"initialize"}
{"jsonrpc":"2.0","id":2,"method":"tools/list"}
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"add","arguments":{"title":"..."}}}
Or pipe from a file:
cat requests.jsonl | myapp --mcp
Press Ctrl+C to stop.
Protocol support
Milo implements the MCP 2025-11-25 specification and keeps the
initialization handshake for current clients. It also exposes
server/discoverso clients preparing for stateless MCP revisions can
detect the supported protocol version before deciding whether to use the
legacy handshake.
When a request includes explicit per-request MCP metadata with an
unsupported protocol version, Milo returns JSON-RPC error-32004with
the supported versions instead of silently treating the request as
2025-11-25.
Compatibility matrix
| Scenario | Expected behavior |
|---|---|
| Legacy MCP client → Milo server | Client sendsinitialize, then notifications/initialized, then normal requests. Milo responds as MCP 2025-11-25. |
| Probe-first client → Milo server | Client sendsserver/discover. Milo returns supportedVersions: ["2025-11-25"]; the client can then use the legacy handshake. |
Client sends unsupported_metaprotocol version |
Milo returns JSON-RPC-32004 with data.supported and data.requestedrepair fields. |
| Milo gateway → legacy child CLI | Gateway probesserver/discover, falls back to initialize on method-not-found, and records child protocol mode as legacy. |
| Milo gateway → stateless-only child CLI | Gateway uses the discovered protocol version and includes per-request_metaon child calls. |
The server handles these methods:
server/discover
{"jsonrpc": "2.0", "id": 1, "method": "server/discover"}
Returns supported protocol versions, server info, capabilities, and instructions:
{
"supportedVersions": ["2025-11-25"],
"capabilities": {"tools": {}},
"serverInfo": {
"name": "myapp",
"version": "1.0.0",
"title": "My CLI application"
},
"instructions": "My CLI application"
}
initialize
{"jsonrpc": "2.0", "id": 1, "method": "initialize"}
Returns protocol version, server info (with title), and capabilities:
{
"protocolVersion": "2025-11-25",
"capabilities": {"tools": {}},
"serverInfo": {
"name": "myapp",
"version": "1.0.0",
"title": "My CLI application"
},
"instructions": "My CLI application"
}
notifications/initialized
{"jsonrpc": "2.0", "method": "notifications/initialized"}
Client confirmation after initialize. No response is sent (per MCP spec).
tools/list
{"jsonrpc": "2.0", "id": 2, "method": "tools/list"}
Returns commands whose surfaces include "mcp"as tools with full schemas:
{
"tools": [
{
"name": "greet",
"title": "Greet",
"description": "Say hello",
"inputSchema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"loud": {"type": "boolean"}
},
"required": ["name"]
},
"outputSchema": {
"type": "string"
}
},
{
"name": "site.build",
"title": "Build the documentation site",
"description": "Build the site",
"inputSchema": { "..." : "..." }
}
]
}
Each tool includes:
| Field | Source | Description |
|---|---|---|
name |
Command name | Dot-notation for groups:site.build, site.config.show |
title |
Handler docstring first line, or title-cased name | Human-readable display name |
description |
@cli.command(description=...) |
Short description |
inputSchema |
Parameter type annotations | JSON Schema for arguments |
outputSchema |
Return type annotation | JSON Schema for the return value (when available) |
tools/call
{
"jsonrpc": "2.0", "id": 3,
"method": "tools/call",
"params": {
"name": "greet",
"arguments": {"name": "Alice", "loud": true}
}
}
Dispatches to the command handler and returns the result as MCP content:
{
"content": [{"type": "text", "text": "HELLO, ALICE!"}]
}
When a handler returns structured data (dict, list, number, bool), the response also includes structuredContent:
{
"content": [{"type": "text", "text": "{\n \"id\": 1,\n \"status\": \"done\"\n}"}],
"structuredContent": {"id": 1, "status": "done"}
}
This lets MCP clients consume typed data directly instead of parsing text.
Before middleware reaches a handler, Milo validates tool arguments against the
sameinputSchema returned by tools/list. Required and unexpected arguments,
primitive types, enums, string and array lengths, regex patterns, uniqueness,
and inclusive or exclusive numeric bounds are enforced. String-sourced
numbers, booleans, JSON arrays, and JSON objects are coerced when valid.
Failures returnisError: true with M-INP-004 through M-INP-007, the
affectedargument, a machine-readable reason and constraint, a repair
suggestion, and the advertised schema. The handler is never called with a
value that fails these checks.
MCP Apps UI resources
Milo implements the stable MCP Apps 2026-01-26 extension as an optional layer over normal tools and resources. A linked command must still return useful text and structured data for clients that do not support embedded UIs.
from milo import (
CLI,
MCPAppCSP,
MCPAppResourceMeta,
MCPAppToolMeta,
)
cli = CLI(name="weather")
@cli.ui_resource(
"ui://weather/forecast",
name="Weather forecast",
meta=MCPAppResourceMeta(
csp=MCPAppCSP(connect_domains=("https://api.weather.example",)),
prefers_border=True,
),
)
def forecast_view() -> str:
return "<!doctype html><html><body><main id='forecast'></main></body></html>"
@cli.command("forecast", ui=MCPAppToolMeta("ui://weather/forecast"))
def forecast(city: str) -> dict[str, str]:
return {"city": city, "condition": "sunny"}
ui_resource() reserves the ui://scheme and the exact
text/html;profile=mcp-appMIME profile. Its handler returns a valid HTML5
document asstr, or bytes that Milo serializes as a deterministic base64
blob. Normal resource()registrations cannot claim the reserved scheme.
Capability negotiation
The host opts in duringinitialize:
{
"capabilities": {
"extensions": {
"io.modelcontextprotocol/ui": {
"mimeTypes": ["text/html;profile=mcp-app"]
}
}
}
}
When negotiated, Milo returns the same extension capability, adds nested
_meta.ui.resourceUrimetadata to linked tools, and exposes UI resources from
resources/list and resources/read. Without negotiation, model-visible tools
remain ordinary text/structured tools, UI metadata is omitted, and UI resources
are not advertised. Reading aui://resource without negotiation returns the
structuredM-UI-003error.
Milo emits only the stable nested_meta.uiform. The deprecated flat
_meta["ui/resourceUri"]key is intentionally unsupported.
Visibility and link integrity
MCPAppToolMeta defaults to visibility=("model", "app"). Use
visibility=("app",)for implementation tools that an embedded app knows by
name but the model must not see. Milo omits app-only tools from its model-facing
tools/list; hosts remain responsible for origin-sensitive app call policy
because coretools/calldoes not identify the iframe caller to the server.
Every advertised tool link must resolve to a resource registered on the same
server. Missing links fail discovery withM-UI-002 and include tooland
resourceUrirepair fields instead of advertising a broken UI.
Security metadata and ownership
MCPAppResourceMetaserializes CSP domains, requested browser permissions, an
optional host-specific domain, and the border preference deterministically.
Milo transports this declaration but does not render HTML, create iframes,
grant permissions, validate a host-specific domain, or enforce browser CSP.
Those are MCP Apps host responsibilities. Resource handlers should return
static, reviewable HTML and avoid embedding secrets.
Gateway namespacing and lifecycle
The Milo gateway acts as an MCP Apps-capable client to every child CLI, then
exposes UI metadata only when the upstream host negotiated the extension. A
child link such asui://weather/dashboardis rewritten deterministically:
ui://milo-gateway/weather/ui%3A%2F%2Fweather%2Fdashboard
Both tools/list._meta.ui.resourceUri and resources/list[].uriuse that
gateway URI.resources/readroutes it to the owning child with the original
URI, then rewrites the returned content URI back to the gateway URI. MIME,
security metadata, text or blob content, and structured tool results pass
through unchanged.
The encoded child name makes identical child URIs collision-safe. Duplicate tool, resource, or prompt entries from one child use deterministic first-wins discovery and emit a gateway warning. Malformed UI resources are omitted, and a tool's broken UI link is removed instead of advertising an unresolvable URI.
Without upstream negotiation, UI resources and_meta.uiare omitted while
the tool's text and structured fallback remains available. Unknown gateway UI
URIs returnM-UI-002; unnegotiated reads return M-UI-003; and child
disconnect, timeout, parse, or unavailable errors returnM-UI-004with
child, reason, and resource URI repair fields.
Verify conformance before registration
Runmilo verify app.pybefore an MCP host opens a linked UI. Three stable
check identities isolate the broken view:
| Check | Contract |
|---|---|
mcp_apps_in_process |
Discovery and negotiated capabilities agree; tool links resolve; listed resources have valid URI, MIME/profile, metadata, and readable text/base64 payloads |
mcp_apps_gateway |
A real single-child gateway projection rewrites each link and preserves resource/tool metadata |
mcp_apps_transport |
The same capability, list, link, and resource-read checks pass over subprocess JSON-RPC |
These failures exit 1 and include the next repair action. Schema documentation warnings still exit 0. Milo validates transport shape only: it does not parse, sanitize, render, or otherwise interpret the application HTML.
See the runnable dependency-free interactive MCP Apps example.
Schema generation
Schemas are generated automatically from function type annotations.
Input schemas
Generated from handler parameters viafunction_to_schema():
| Python | JSON Schema |
|---|---|
str |
"string" |
int |
"integer" |
float |
"number" |
bool |
"boolean" |
list[str] |
"array"with string items |
dict |
"object" |
X | None |
unwrapped to base type, not required |
Context parameters (ctx: Context) are excluded from schemas.
Output schemas
Generated from handler return type annotations viareturn_to_schema(). If a handler declares -> dict or -> list[str], the corresponding JSON Schema appears as outputSchema in tools/list.
@cli.command("stats", description="Get task statistics")
def stats() -> dict:
return {"total": 10, "done": 7}
This produces "outputSchema": {"type": "object"}in the tool definition.
Registry and gateway
For projects with multiple Milo CLIs, the registry and gateway let you expose all of them through a single MCP connection.
Registering a CLI
myapp --mcp-install
This writes the CLI's name, command, description, and version to Milo's
platform registry:~/.milo/registry.jsonon Unix or
%LOCALAPPDATA%\milo\registry.jsonon Windows. The registry is a simple JSON
file:
{
"version": 1,
"clis": {
"taskman": {
"command": ["python", "examples/taskman/app.py", "--mcp"],
"description": "A simple task manager",
"version": "0.1.0"
}
}
}
To remove a CLI:
myapp --mcp-uninstall
Running the gateway
The gateway is a meta-MCP server that discovers and proxies all registered CLIs:
uv run python -m milo.gateway --mcp
On startup, the gateway:
- Reads
registry.jsonfrom Milo's platform data directory - Spawns each registered CLI and negotiates supported child capabilities
- Discovers tools, resources, and prompts in parallel
- Namespaces tools as
cli_name.tool_nameand rewrites MCP Apps resource links - Listens on stdin/stdout for MCP requests
milo gateway ready
Protocol: 2025-11-25
CLIs: 2 (taskman, ghub)
Tools: 8
Available: taskman.add, taskman.list, taskman.done, ghub.repo.list, ...
When an agent callstaskman.add, the gateway:
- Looks up
taskmanin the routing table - Spawns
taskman --mcp - Sends an
initialize+tools/callrequest with the original tool name (add) - Returns the result to the agent
Listing registered CLIs
uv run python -m milo.gateway --list
Connecting the gateway to an AI host
Register the gateway once:
claude mcp add --transport stdio milo -- uv run python -m milo.gateway --mcp
Now every CLI registered via --mcp-install is discoverable through the single milo MCP server. Tools are namespaced: taskman.add, ghub.repo.list, etc.
Hidden commands
Commands markedhidden=True, including commands beneath hidden groups, are
excluded fromtools/list and rejected by tools/callwith structured
M-CMD-001repair data. The gateway routes only names returned by discovery,
so hidden tools cannot be reached through a namespaced gateway call either.
Commands whosesurfaces omit "mcp"follow the same list/call enforcement
without being hidden from their other selected surfaces.
Lazy commands and MCP
Lazy commands with pre-computed schemas appear intools/list without importing their handler modules. The import only happens on tools/call. This keeps MCP startup fast even with heavy dependencies.
cli.lazy_command(
"deploy",
"myapp.deploy:run_deploy",
description="Deploy to production",
schema={
"type": "object",
"properties": {"target": {"type": "string"}},
"required": ["target"],
},
)
The outputSchema and titlefields are also deferred for lazy commands — they only resolve when the handler is first imported.
Tip
Combine with--llms-txtto give AI agents both an MCP tool interface and a human-readable discovery document.