Tools & MCP

Register Python functions as MCP tools for AI agents alongside HTTP routes

Page actions AI-ready formats and sharing
Open LLM text
Share with AI
Ask Claude Ask ChatGPT Ask Gemini Ask Copilot

What it is

MCP (Model Context Protocol) lets AI agents call your app's Python functions the same way htmx calls your routes. Register a function with@app.tool()and it serves two callers from one codebase: your HTTP handlers (which call it directly) and MCP clients over JSON-RPC.

Reach for this when you want an LLM agent to act on the same data your HTML UI exposes.

from chirp import App

app = App()

@app.tool("search_inventory", description="Search inventory by keyword")
async def search_inventory(query: str, limit: int = 10) -> list[dict]:
    return await db.search(query, limit=limit)

That function is now callable from:

  • Your HTTP handlers — call it directly, like any other function.
  • MCP clients — over JSON-RPC at/mcp.

Registering tools

Use the@app.tool()decorator during setup. The first argument is the tool name;descriptionis sent to MCP clients so agents know what each tool does.

@app.tool("add_note", description="Add a note with an optional tag.")
def add_note(text: str, tag: str | None = None) -> dict:
    note = {"id": next_id(), "text": text, "tag": tag}
    store.append(note)
    return note

@app.tool("list_notes", description="List all notes.")
def list_notes() -> list[dict]:
    return list(store)

Both sync and async handlers work.

Chirp generates JSON Schema from your type annotations, so MCP clients get a typed parameter list for free. Parameters namedrequestare excluded (the same convention as route handlers).

The MCP endpoint

When at least one tool is registered, Chirp mounts a JSON-RPC endpoint at/mcp. It speaks MCP protocol version2026-07-28(stateless Streamable HTTP core) with thetoolscapability.

Stateless transport. There is no handshake or server-side session. Each POSTis independent: protocol version, client identity, and capabilities ride in per-requestparams._meta(reserved keys under io.modelcontextprotocol/…). Optional server/discoveradvertises supported versions. Legacyinitialize / notifications/initializedremain accept-and-noop for older clients — they do not create session state.

Standard MCP2025-06-18clients (Cursor, Claude Code, …). These clients negotiate duringinitialize via params.protocolVersionand then send MCP-Protocol-Version: 2025-06-18on follow-up requests. Chirp echoes the requested version, does not attachchirp/legacyOfframp, and does not require SEP-2243Mcp-Method / Mcp-Namerouting headers — method and tool name stay in the JSON-RPC body. This is the path Orrery and most IDE MCP hosts use today.

Streamable HTTP routing headers (SEP-2243). Modern clients that advertise protocol2026-07-28must also send routing headers that agree with the JSON-RPC body:

Header Required when Must match
MCP-Protocol-Version Modern path (see below) params._metaprotocol version
Mcp-Method Modern path JSON-RPCmethod
Mcp-Name tools/call (also resources/read, prompts/get) params.name (or params.uri)

Enforcement is gated: if neither theMCP-Protocol-Versionheader nor params._meta protocol version is present — or only the legacy 2024-11-05 version is advertised — Chirp stays on the legacy offramp path and does not require these headers. Once either advertises a modern (non-legacy) version, missing or mismatched headers return HTTP 400 with JSON-RPC error HeaderMismatch (-32020). Mcp-Namemay use a Base64 sentinel form =?base64?<data>?=when the name is not header-safe.

  1. 1

    Discover (optional)

    Ask the server for supported versions and capabilities.

    curl -X POST http://localhost:8000/mcp \
      -H 'Content-Type: application/json' \
      -H 'MCP-Protocol-Version: 2026-07-28' \
      -H 'Mcp-Method: server/discover' \
      -d '{"jsonrpc":"2.0","method":"server/discover","id":1,"params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"curl","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}'
    
  2. 2

    List tools

    Fetch the registered tools and their input schemas. No prior call required.

    curl -X POST http://localhost:8000/mcp \
      -H 'Content-Type: application/json' \
      -H 'MCP-Protocol-Version: 2026-07-28' \
      -H 'Mcp-Method: tools/list' \
      -d '{"jsonrpc":"2.0","method":"tools/list","id":2,"params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'
    
  3. 3

    Call a tool

    Dispatch a tool by name with arguments. IncludeMcp-Name for tools/call.

    curl -X POST http://localhost:8000/mcp \
      -H 'Content-Type: application/json' \
      -H 'MCP-Protocol-Version: 2026-07-28' \
      -H 'Mcp-Method: tools/call' \
      -H 'Mcp-Name: add_note' \
      -d '{"jsonrpc":"2.0","method":"tools/call","id":3,"params":{"name":"add_note","arguments":{"text":"Hello"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'
    

Real-time tool activity

Every successful tool call emits aToolCallEvent through app.tool_events. Bridge that bus into anEventStreamfor a live invocation log (or hand-roll the same pattern withFragmentyields).

from chirp.tools import mount_invocation_log, tool_event_stream

# One-liner: SSE route + packaged invocation_row fragment (default /invocations/live)
mount_invocation_log(app)

# Or compose the bridge yourself:
@app.route("/activity/feed", referenced=True)
def activity_feed():
    return tool_event_stream(app.tool_events, template="dashboard.html", block="activity_row")

mount_skills(...) wires mount_invocation_logby default for Orrery-style hosts; passinvocation_log_path=Noneto skip the live log.

EventStreamis one of Chirp's return types; for the wire format and connection lifecycle see Server-Sent Events.

EachToolCallEventis a frozen dataclass with:

  • tool_name— which tool was called
  • arguments— the arguments passed
  • result— what it returned
  • timestamp— when it was called (epoch seconds)
  • call_id— a unique 12-character hex identifier

Render an event in a template block like any other context value:

{% block activity_row %}
<tr>
  <td><code>{{ event.tool_name }}</code></td>
  <td>{{ event.arguments | format_args }}</td>
  <td>{{ event.call_id[:8] }}</td>
</tr>
{% endblock %}

Skill tools with machine scopes

Provisionalchirp.skillmounts signed tool handlers onto the same MCP registry. Gate a skill tool on machine-token scopes with @skill.tool(..., scopes=(...)) — Chirp calls enforce_auth(AuthSpec(scopes=...)) before the body. Declare each scope withapp.register_scopeso the auth_speccontract can validate names at startup:

from chirp.skill import Skill, use_skill

skill = Skill("hooks", version="1.0.0", private_key=private, key_id="hooks-1")

@skill.tool("dispatch", scopes=("webhook:write",))
def dispatch(payload: dict) -> dict:
    return payload

use_skill(app, skill)
app.register_scope("webhook:write")

A caller missing the scope gets a 403 from enforce_auth; the skill wrapper maps that toToolAuthError and MCP tools/callreturns a JSON-RPC error (-32603, message Forbidden) while emitting authz.scope.denied.

Milo MCP Apps named-block resources

chirp.ext.milois a provisional bridge for applications that already register commands with Milo 0.4.1. It is separate from the stableapp.tool()registry described above: it does not convert either registry, copy Milo schemas, or expose every Milo command automatically.

The caller attachesMCPAppToolMetawhen the Milo command is originally registered, registers the linkedui://resource, and opts the canonical dotted command ID into an exact Chirp allowlist.adapter.bind()then records one existing Chirp template, named block, and parameterless application context provider. Atapp.freeze(), Chirp verifies the public Milo command/resource link and publishes frozen binding metadata. On each MCP App resource read, the caller-owned@cli.ui_resourcehandler delegates to adapter.render_resource(operation_id), which invokes the context provider and renders that named block throughFragment / App.render:

@cli.ui_resource("ui://chirp/work-items/create", name="Create work item")
def create_work_item_resource() -> str:
    return adapter.render_resource("work-items.create")

adapter = use_milo(app, cli, allowlist=("work-items.create",))
adapter.bind(
    "work-items.create",
    template="work_items.html",
    block="create_tool",
    context=resource_context,
)

app.freeze()
print(adapter.bindings[0].resource_uri)
print(adapter.render_resource("work-items.create"))

Milo is already a bounded direct dependency of Chirp, so this preview needs no additional extra. The adapter never freezes or mutates the caller-owned Milo CLI, invokes the context provider during freeze, or manufactures a Chirp request/session. Application state captured by the provider remains application-owned and must be safe for concurrent reads. Missing blocks raise BlockNotFoundError; empty required UI output and non-mapping context raise ConfigurationError. Host CSP/sandbox/auth semantics for the read-only resource profile remain with issue #579.

The offline milo_mcp_appsexample proves browser page, htmx fragment, MCP tool structured result, and negotiated MCP App resource HTML from one template contract.

The shipping example

The runnable demo registers three tools, serves a notes UI, and streams tool calls into a live activity feed. The tool definitions:

@app.tool("add_note", description="Add a note with an optional tag.")
def add_note(text: str, tag: str | None = None) -> dict:
    global _next_id
    with _lock:
        note = {"id": _next_id, "text": text, "tag": tag}
        _next_id += 1
        _notes.append(note)
        return note


@app.tool("list_notes", description="List all notes.")
def list_notes() -> list[dict]:
    with _lock:
        return list(_notes)


@app.tool("search_notes", description="Search notes by text substring.")
def search_notes(query: str) -> list[dict]:
    with _lock:
        q = query.lower()
        return [n for n in _notes if q in n["text"].lower()]

Source: examples/standalone/tools/app.py.

Run the full example withpython app.py, open it in a browser, then call a tool withcurland watch the activity feed update in real time.

See also