Instrument a FastMCP server in five minutes
In this tutorial you build a two-tool FastMCP server, add one line that records every tool call, call the tools, and read what was recorded. You need Python 3.11 or newer and uv.
-
Create a project and install the package with the
instrumentationextra, which adds the database sink:Terminal window uv init weather && cd weatheruv add "fastmcp-feedback[instrumentation]" -
Create
weather.pywith a server, two tools and the instrumentation:from fastmcp import FastMCPfrom fastmcp_feedback.instrumentation import DatabaseSink, JsonLinesSink, instrumentapp = FastMCP("Weather")@app.tooldef forecast(city: str) -> dict:"""Tomorrow's high for a city."""if city == "Atlantis":return {"ok": False, "error": "no such city"}return {"city": city, "high_c": 21}@app.tooldef alerts(region: str) -> list[str]:"""Active weather alerts for a region."""raise RuntimeError(f"alert feed for {region} is down")mw = instrument(app, [JsonLinesSink(), # one JSON line per record on stderrDatabaseSink("sqlite+aiosqlite:///calls.db", create_tables=True),])instrument(app, sinks)adds anInstrumentationMiddlewareto the server and returns it. Every tool on the server is now recorded, including tools added later. -
Add a few calls to the bottom of the same file.
Client(app)connects to the server in-process, so there is nothing to start:import asynciofrom fastmcp import Clientasync def main():async with Client(app) as client:await client.call_tool("forecast", {"city": "Lisbon"})await client.call_tool("forecast", {"city": "Atlantis"})try:await client.call_tool("alerts", {"region": "north"})except Exception as exc:print("client saw:", exc)await mw.aclose() # flush queued records, close the sinksif __name__ == "__main__":asyncio.run(main()) -
Run it:
Terminal window uv run weather.pyAmong FastMCP’s own log output, stderr gets one line per call. Shortened, they look like this:
{"record": "tool_call", "tool": "forecast", "outcome": "ok", "duration_ms": 1.4, "error_type": null, ...}{"record": "tool_call", "tool": "forecast", "outcome": "soft_error", "error_type": "SoftError", "error_message": "ok=False: no such city", ...}{"record": "tool_call", "tool": "alerts", "outcome": "error", "error_type": "RuntimeError", "error_message": "alert feed for north is down", ...}The second call did not raise, but its result said it failed, so it is recorded as a
soft_error. The third raised, so it is anerror. The client received exactly what it would have without the middleware. -
Read the same records from SQLite:
import sqlite3rows = sqlite3.connect("calls.db").execute("SELECT tool, outcome, error_message FROM ffb_tool_calls ORDER BY started_at")for tool, outcome, message in rows:print(f"{tool:10} {outcome:11} {message or ''}")forecast okforecast soft_error ok=False: no such cityalerts error alert feed for north is down
What you built
Section titled “What you built”You have a server whose every tool call becomes a row in ffb_tool_calls, with
its duration, its outcome (ok, soft_error or error) and a redacted error
message. Recording happens after the tool returns, on a background task, so a
slow or broken sink cannot slow down or fail a call.
To serve the tools to a real client, call app.run() instead of main();
nothing about the instrumentation changes.
- Collect feedback linked to the calls behind it
adds a
submit_feedbacktool that attaches these records to each report. - Store records in Postgres moves
calls.dbto a real database. - Instrumentation middleware reference lists every option.