Collect feedback linked to the calls behind it
A report that says “the forecast tool is broken” is more useful with the calls that came before it attached. In this tutorial you add the five feedback tools to an instrumented server, file a report the way a model would, and read back the calls it was linked to.
It builds on Instrument a FastMCP server in five minutes; the code below is complete on its own, so you can start here too.
-
Install the package with the
instrumentationextra, if you have not already:Terminal window uv add "fastmcp-feedback[instrumentation]" -
Create
support.py. It is the weather server again, plus an identity resolver and the feedback tools:from fastmcp import FastMCPfrom fastmcp_feedback import add_feedback_toolsfrom fastmcp_feedback.instrumentation import DatabaseSink, 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}def who(context):"""Who is calling. A real server reads its auth state here."""return {"user_sub": "demo-user"}mw = instrument(app,[DatabaseSink("sqlite+aiosqlite:///calls.db", create_tables=True)],identity_resolver=who,)add_feedback_tools(app, database_url="sqlite:///feedback.db", instrumentation=mw)add_feedback_toolsregisterssubmit_feedback,list_feedback,get_feedback_statistics,update_feedback_statusanddelete_feedback. Passinginstrumentation=mwmakessubmit_feedbacklink each new report to the calls before it.The identity resolver matters here. Clients on MCP protocol 2026-07-28, including FastMCP 4’s own
Client, do not keep a session, so calls are matched to a report by user instead. How correlation works explains why. -
Add a session that uses the tool, hits the failure, and reports it. Note that
submit_feedbacktakes a singlerequestobject: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"})result = await client.call_tool("submit_feedback", {"request": {"type": "bug","title": "forecast fails for some cities","description": "Atlantis returns ok=False with 'no such city'.","submitter": "tutorial-model",}})report = result.structured_contentprint(report)for call in await mw.feedback_context(report["feedback_id"]):print(call["position"], call["tool"], call["outcome"], call["rule"],call["error_message"] or "")await mw.aclose()if __name__ == "__main__":asyncio.run(main()) -
Run it:
Terminal window uv run support.py{'success': True, 'feedback_id': '1', 'message': 'Feedback submitted successfully', 'linked_calls': 2}0 forecast ok user_window1 forecast soft_error user_window ok=False: no such cityThe report was stored in
feedback.dband linked to the twoforecastcalls, in the order they ran, each with its outcome.rulesays why each call matched:user_windowmeans the sameuser_subwithin the last 15 minutes. -
List the feedback the way a maintainer would, with the same client:
async def review():async with Client(app) as client:page = await client.call_tool("list_feedback", {"status_filter": "open"})for item in page.structured_content["feedback"]:print(item["id"], item["type"], item["status"], item["title"])await client.call_tool("update_feedback_status",{"feedback_id": "1", "new_status": "in_progress"},)await mw.aclose()asyncio.run(review())1 bug open forecast fails for some cities
What you built
Section titled “What you built”Your server now accepts bug reports, feature requests, improvements and
questions through MCP, stores them in feedback.db, and keeps a link from each
report to the calls behind it in calls.db (table ffb_feedback_call_links).
Each report is also recorded as a feedback.submitted event, which is what an
embedding sink uses to find similar reports later.
- Add identity and enrichment shows a real identity resolver.
- Feedback tools reference has every tool’s arguments and response.
- Link feedback from your own tools
uses
capture_recent_callsandlink_feedbackdirectly.