The Idea
Humans interact with your app through HTML forms and htmx. AI agents interact through the same functions via MCP (Model Context Protocol). One codebase, two interfaces.
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)
This function is now callable from:
- HTTP routes (call it directly in your handlers)
- MCP clients via JSON-RPC at
/mcp
Registering Tools
Use the@app.tool()decorator during setup:
@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. The descriptionis sent to MCP clients so agents understand what each tool does.
Schema Generation
Chirp auto-generates JSON Schema from your function's type annotations:
str->"string",int->"integer",float->"number",bool->"boolean"list[str]->"array"with"items": {"type": "string"}X | None-> optional parameter (not inrequired)- Parameters with defaults are optional
- Parameters named
requestare excluded (same convention as route handlers)
The MCP Endpoint
Chirp automatically mounts a JSON-RPC endpoint at/mcp. It speaks the MCP v1 protocol:
# Initialize handshake
curl -X POST http://localhost:8000/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{}}'
# List available tools
curl -X POST http://localhost:8000/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","method":"tools/list","id":2,"params":{}}'
# Call a tool
curl -X POST http://localhost:8000/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","method":"tools/call","id":3,"params":{"name":"add_note","arguments":{"text":"Hello"}}}'
Inspecting Registered Tools
After the app is frozen (first request orapp.run()), inspect the registry:
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)
The app.tools property returns the frozen ToolRegistry. It's read-only at runtime.
Real-Time Tool Activity
Every tool call emits aToolCallEvent through app.tool_events. Subscribe from an SSE route to build live dashboards:
from chirp import EventStream, Fragment
@app.route("/activity/feed", referenced=True)
def activity_feed():
async def stream():
async for event in app.tool_events.subscribe():
yield Fragment("dashboard.html", "activity_row", event=event)
return EventStream(stream())
Each ToolCallEventis a frozen dataclass with:
tool_name-- which tool was calledarguments-- the arguments passedresult-- what it returnedtimestamp-- when it was calledcall_id-- unique 12-char hex identifier
Template for Activity Rows
{% 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 %}
Thread Safety
The tools system is designed for Python 3.14 free-threading:
ToolDefis a frozen dataclass (immutable, safe to share)ToolRegistryis built once at freeze time, never mutatedToolEventBususes athreading.Lockto protect subscriber queues- Each subscriber gets its own
asyncio.Queue(no shared mutable state)
Next Steps
- See the tools example (
examples/standalone/tools/) for a complete working app - Return Values -- All return types including EventStream
- Server-Sent Events -- SSE patterns for real-time feeds