MCP Server

Expose CLI commands as MCP tools, register CLIs for discovery, and run a unified gateway.

11 min read 2149 words

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.

flowchart LR Agent[AI Agent] -->|JSON-RPC| Gateway[Milo Gateway] Gateway -->|tools/call| CLI1[taskman --mcp] Gateway -->|tools/call| CLI2[ghub --mcp] Gateway -->|tools/call| CLI3[deploy --mcp]

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.

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:

  1. Readsregistry.jsonfrom Milo's platform data directory
  2. Spawns each registered CLI and negotiates supported child capabilities
  3. Discovers tools, resources, and prompts in parallel
  4. Namespaces tools ascli_name.tool_nameand rewrites MCP Apps resource links
  5. 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:

  1. Looks uptaskmanin the routing table
  2. Spawnstaskman --mcp
  3. Sends aninitialize + tools/call request with the original tool name (add)
  4. 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.