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).
Type-to-schema mapping
The schema is built at freeze time from each parameter's annotation:
str→"string",int→"integer",float→"number",bool→"boolean"list[str]→"array"with"items": {"type": "string"}(alsolist[int],list[float])X | None→ optional parameter (unwrapped toX, left out ofrequired)- Parameters with a default value are optional
- Parameters named
request(or annotatedRequest) are excluded - Unannotated parameters default to
"string"
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
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
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
Call a tool
Dispatch a tool by name with arguments. Include
Mcp-Namefortools/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 calledarguments— the arguments passedresult— what it returnedtimestamp— 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 %}
Inspecting the registry
After the app is frozen (first request orapp.run()), app.toolsreturns the
frozenToolRegistry. It is read-only at runtime; accessing it before freeze
raisesRuntimeError.
for tool_info in app.tools.list_tools():
print(f"{tool_info['name']}: {tool_info['description']}")
# Look up a specific tool
tool = app.tools.get("add_note")
if tool is not None:
print(tool.schema)
Thread safety
The tools system is built for Python 3.14 free-threading:
ToolDefis a frozen dataclass — immutable, safe to share across threads.ToolRegistryis built once at freeze time and never mutated.ToolEventBusguards its subscriber set with athreading.Lock.- Each subscriber gets its own
asyncio.Queue, so there is no shared mutable state on the broadcast path.
For the framework-wide model see the free-threading thread-safety model.
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.