Compare commits

...
Author SHA1 Message Date
rsp2k 3abd5c6311 Merge feature/soft-errors: classify error results returned as data
Tests / test (==2.12.2) (push) Canceled after 0s
Tests / test (==2.14.3) (push) Canceled after 0s
Tests / test (>=3.3,<4) (push) Canceled after 0s
Tests / test (>=4,<5) (push) Canceled after 0s
Tests / canary (push) Canceled after 0s
2026-09-27 19:34:30 -06:00
rsp2k 472071730e Release 2026.09.27.4: soft-error classification
Tests / test (==2.12.2) (push) Canceled after 0s
Tests / test (==2.14.3) (push) Canceled after 0s
Tests / test (>=3.3,<4) (push) Canceled after 0s
Tests / test (>=4,<5) (push) Canceled after 0s
Tests / canary (push) Canceled after 0s
2026-09-27 19:34:30 -06:00
rsp2k 9ddd929a13 Classify error results that tools return instead of raising 2026-09-27 19:34:30 -06:00
rsp2k 052862f4fc Point README examples at the in-repo examples/ directory
Tests / test (==2.12.2) (push) Canceled after 0s
Tests / test (==2.14.3) (push) Canceled after 0s
Tests / test (>=3.3,<4) (push) Canceled after 0s
Tests / test (>=4,<5) (push) Canceled after 0s
Tests / canary (push) Canceled after 0s
The showcase repo is private and fastmcp-feedback-showcase was never
published, so both the link and the uvx command failed for everyone else.
Tested the new clone-and-run instructions against the now-public repo.
2026-09-27 12:31:18 -06:00
rsp2k 975c24c766 Merge feature/correlation: link feedback to preceding tool calls
Tests / test (==2.12.2) (push) Canceled after 0s
Tests / test (==2.14.3) (push) Canceled after 0s
Tests / test (>=3.3,<4) (push) Canceled after 0s
Tests / test (>=4,<5) (push) Canceled after 0s
Tests / canary (push) Canceled after 0s
2026-09-27 12:26:00 -06:00
rsp2k a530634eb9 Release 2026.09.27.3: feedback-to-call correlation
Tests / test (==2.12.2) (push) Canceled after 0s
Tests / test (==2.14.3) (push) Canceled after 0s
Tests / test (>=3.3,<4) (push) Canceled after 0s
Tests / test (>=4,<5) (push) Canceled after 0s
Tests / canary (push) Canceled after 0s
Changelog entry and version bump for linking feedback items to the tool
calls that preceded them. README documents the instrumentation parameter
of add_feedback_tools and the new feedback_call_links table that Alembic
hosts need to migrate.
2026-09-27 12:26:00 -06:00
rsp2k 4b16764faa Attribute per-call session ids to the stateless MCP protocol
Tests / test (==2.12.2) (push) Canceled after 0s
Tests / test (==2.14.3) (push) Canceled after 0s
Tests / test (>=3.3,<4) (push) Canceled after 0s
Tests / test (>=4,<5) (push) Canceled after 0s
Tests / canary (push) Canceled after 0s
Tested against real clients: a FastMCP 4.0.10 server keeps ctx.session_id
stable for clients that open sessions (curl on 2025-06-18, the FastMCP 3.3
Client, Claude Code over stdio). Claude Code and FastMCP 4's Client speak
MCP 2026-07-28, which has no HTTP sessions, so FastMCP generates an id per
request. Not a FastMCP bug; correct the comments and README note.
2026-09-27 10:01:44 -06:00
rsp2k 58e531019d Add feedback-to-call correlation (unreleased)
Tests / test (==2.12.2) (push) Canceled after 0s
Tests / test (==2.14.3) (push) Canceled after 0s
Tests / test (>=3.3,<4) (push) Canceled after 0s
Tests / test (>=4,<5) (push) Canceled after 0s
Tests / canary (push) Canceled after 0s
capture_recent_calls() picks the calls that led up to a feedback item:
same client session first, then the same user within a window, each link
recording which rule matched. It reads an in-memory buffer (so calls still
queued count) and falls back to DatabaseSink for other workers and
restarts. link_feedback() stores links against any feedback id in a
prefixed feedback_call_links table; feedback_context() reads them back.
add_feedback_tools(instrumentation=mw) makes submit_feedback link
automatically and report linked_calls.

session_key() works around FastMCP 4.0 returning a new ctx.session_id on
every call: it prefers the HTTP mcp-session-id header, then one key per
process for stdio, then ctx.session_id.

Also fixes a race where the writer and link_feedback both ran create_all
with create_tables=True.

468 tests pass on FastMCP 2.12.2, 2.14.3, 3.3.1, 3.4.7; 467 + 1 skip on
4.0.10. Still to do before release: execute the new README examples,
CHANGELOG, version bump.
2026-09-27 09:12:10 -06:00
rsp2k 00fae0b46b Merge fix/json-sql-null: store absent payloads as SQL NULL
Tests / test (==2.12.2) (push) Canceled after 0s
Tests / test (==2.14.3) (push) Canceled after 0s
Tests / test (>=3.3,<4) (push) Canceled after 0s
Tests / test (>=4,<5) (push) Canceled after 0s
Tests / canary (push) Canceled after 0s
2026-09-27 08:58:34 -06:00
rsp2k 10b1eea86c Version 2026.09.27.2
Tests / test (==2.12.2) (push) Canceled after 0s
Tests / test (==2.14.3) (push) Canceled after 0s
Tests / test (>=3.3,<4) (push) Canceled after 0s
Tests / test (>=4,<5) (push) Canceled after 0s
Tests / canary (push) Canceled after 0s
2026-09-27 08:58:34 -06:00
rsp2k 39d775a0cd Store absent JSON payloads as SQL NULL
DatabaseSink wrote the JSON literal null for missing args/result, so
WHERE args IS NULL matched nothing. Use none_as_null on the JSON and JSONB
column types. DDL is unchanged; existing rows keep JSON null.

Reported by blender-mcp from production.
2026-09-27 08:57:06 -06:00
rsp2k a052ca3b4b Merge feature/instrumentation: per-call instrumentation middleware
Tests / test (==2.12.2) (push) Canceled after 0s
Tests / test (==2.14.3) (push) Canceled after 0s
Tests / test (>=3.3,<4) (push) Canceled after 0s
Tests / test (>=4,<5) (push) Canceled after 0s
Tests / canary (push) Canceled after 0s
2026-09-27 08:37:09 -06:00
rsp2k 2e1278d81f Add per-call instrumentation middleware
Tests / test (==2.12.2) (push) Canceled after 0s
Tests / test (==2.14.3) (push) Canceled after 0s
Tests / test (>=3.3,<4) (push) Canceled after 0s
Tests / test (>=4,<5) (push) Canceled after 0s
Tests / canary (push) Canceled after 0s
InstrumentationMiddleware (and instrument(app)) records every tool call on
a FastMCP server: tool, timing, outcome and original error type, session
and request ids, argument and result sizes, plus identity and enrichment
hook output. Modes off/meta/full; full adds redacted args and results.

Recording can't slow or break a call. Records reach sinks through a
bounded queue drained by a background task and are dropped, counted, when
it fills. Sink and hook failures are logged, never raised, and async hooks
are cut off after hook_timeout.

Redactor strips secrets by key at any depth and by value pattern from
args, results, error text and hook output. DatabaseSink runs on async
SQLAlchemy, takes a host AsyncEngine, writes to a prefixed table and never
creates it unless asked; build_metadata() plugs into Alembic env.py.

Verified on FastMCP 2.12.2, 2.14.3, 3.3.1, 3.4.7 and 4.0.10. CI matrix
gains 3.x. Version 2026.09.27.1.
2026-09-27 08:37:09 -06:00
21 changed files with 3047 additions and 20 deletions
+3 -3
View File
@@ -20,7 +20,7 @@ jobs:
strategy:
fail-fast: false
matrix:
fastmcp: ['==2.12.2', '==2.14.3', '>=4,<5']
fastmcp: ['==2.12.2', '==2.14.3', '>=3.3,<4', '>=4,<5']
steps:
- uses: actions/checkout@v4
@@ -36,7 +36,7 @@ jobs:
uv pip show fastmcp | grep -i '^version'
- name: Lint
run: uv run --no-sync ruff check src/ examples/ tests/_compat.py
run: uv run --no-sync ruff check src/ examples/ tests/_compat.py tests/unit/test_instrumentation.py
- name: Test
run: uv run --no-sync pytest -q --no-cov
@@ -58,7 +58,7 @@ jobs:
- name: Install newest FastMCP, ignoring our ceiling
run: |
uv venv
uv pip install fastmcp sqlalchemy pydantic \
uv pip install fastmcp "sqlalchemy[asyncio]" aiosqlite pydantic \
pytest pytest-asyncio pytest-cov pytest-mock pytest-html
uv pip install --no-deps -e .
uv pip show fastmcp | grep -i '^version'
+111
View File
@@ -5,6 +5,117 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Calendar Versioning](https://calver.org/) (YYYY.MM.DD).
## [2026.09.27.4] - 2026-09-27
Count errors that tools return as data instead of raising.
### Added
- Records have an `outcome`: `ok`, `soft_error` (the tool returned a result
flagged as a failure) or `error` (the tool raised). Soft errors get
`error_type="SoftError"` and an `error_message` built from the result, such
as `status=failed: disk full`, redacted and truncated to `max_error_chars`.
- `soft_error_classifier(statuses=..., status_keys=..., flag_keys=...,
extra_statuses=...)` builds a classifier that flags dict results where `ok`
or `success` is `False`, where `status` or `state` is `error`, `failed` or
`failure`, or that the tool marked `isError` without raising.
`DEFAULT_RESULT_CLASSIFIER` is `soft_error_classifier()`.
- `InstrumentationMiddleware(..., result_classifier=...)` takes any
`(tool, result) -> str | None`; `None` turns classification off. A
classifier that raises is logged and the call is recorded as `ok`.
- `feedback_context()` rows include `outcome`.
### Changed
- `ok` is now `outcome == "ok"`, so it is also false for soft errors. Existing
`WHERE NOT ok` queries and error rates will rise; filter on
`outcome = 'error'` for exceptions only. `CallLink.ok` follows.
- The package's own feedback tools answer `{"success": False, ...}` on failure,
so those calls are now recorded as soft errors.
### Migration
- New nullable, indexed `outcome` column (`String(16)`) on
`{prefix}tool_calls`. Hosts that manage the schema with Alembic need a
migration adding it. `create_tables=True` does not alter a table that
already exists, so add the column by hand there
(`ALTER TABLE ffb_tool_calls ADD COLUMN outcome VARCHAR(16)` plus an index).
Until the column exists, `DatabaseSink` writes fail and those records are
dropped (logged). Rows from earlier versions keep NULL.
## [2026.09.27.3] - 2026-09-27
Link a feedback item to the tool calls that preceded it.
### Added
- `InstrumentationMiddleware.capture_recent_calls(ctx, n=20)` returns the
calls that led up to now. It matches the same client session first
(`session` rule), then fills remaining slots with calls by the same
`user_sub` inside `window` (`user_window` rule, 15 minutes by default), and
each link records which rule matched.
- Calls are kept in an in-memory buffer as they happen, so calls still
waiting in the sink queue count. With a `DatabaseSink` the lookup falls back
to the database, which covers calls from other workers and from before a
restart.
- `link_feedback(ref, links)` stores links against a feedback id of any
format (`bug-7Q2X`, `42`) in a new `{prefix}feedback_call_links` table.
`build_metadata()` includes it; hosts that manage the schema with Alembic
need a new migration.
- `feedback_context(ref)` returns the linked calls in order with tool,
outcome, error type and matching rule, filling in calls not yet written
from memory.
- `add_feedback_tools(..., instrumentation=mw)`: `submit_feedback` links the
preceding calls automatically and returns `linked_calls` in its response.
- `session_key(ctx)` gives a per-client key that stays stable across calls:
the HTTP `mcp-session-id` header when present, then one key per process for
stdio, then `ctx.session_id`.
### Fixed
- With `create_tables=True`, the background writer and `link_feedback` could
both run `create_all` at once and fail with "table already exists". Table
creation is now serialized by a lock.
### Note
- Clients on MCP protocol 2026-07-28 (Claude Code, FastMCP 4's `Client`)
open no HTTP sessions, so their calls link by user only. HTTP servers should
configure an `identity_resolver`. Stdio servers link by session with every
client.
## [2026.09.27.2] - 2026-09-27
### Fixed
- `DatabaseSink` stored missing `args`/`result` (every `meta` mode row) as the
JSON literal `null`, so `WHERE args IS NULL` matched nothing. They are now
SQL `NULL`. No schema change; rows written by 2026.09.27.1 keep JSON `null`
and can be backfilled with
`UPDATE ffb_tool_calls SET args = NULL WHERE args = 'null'::jsonb` (same for
`result`). Reported from production by blender-mcp.
## [2026.09.27.1] - 2026-09-27
Per-call instrumentation for any FastMCP server.
### Added
- `fastmcp_feedback.instrumentation`: `InstrumentationMiddleware` and
`instrument(app)` record every tool call on the server (tool, timing,
outcome, original error type, session and request ids, argument and result
sizes). Modes `off`, `meta` (default) and `full`, set by argument or
`FEEDBACK_INSTRUMENTATION_MODE`; `meta_only_tools` keeps chosen tools at
`meta`.
- Recording never slows or breaks a call: records reach sinks from a
background task through a bounded queue and are dropped (and counted) when it
is full. Sink and hook errors are logged, never raised; async hooks are cut
off after `hook_timeout`.
- `Redactor`: secrets removed from arguments, results, error text and hook
output, by key at any depth and by value pattern (JWT, bearer, `sk-`,
GitHub, Slack, AWS, PyPI and `bmcp_` tokens, `password=...` in text).
- Sinks: `JsonLinesSink` (stderr), `CallbackSink`, `MemorySink`, and
`DatabaseSink` on async SQLAlchemy. `DatabaseSink` accepts the host's
`AsyncEngine`, writes to a prefixed `ffb_tool_calls` table, never creates
tables unless asked, and `build_metadata()` plugs the table into a host's
Alembic `env.py`.
- `identity_resolver` and `enricher` hooks fill `user_sub`, `caller_kind`,
`client_id`, `server_version` and free-form `identity`/`extra` fields.
- `instrumentation` extra: `sqlalchemy[asyncio]` and `aiosqlite`.
- CI covers FastMCP 3.x as well as 2.12, 2.14 and 4.x.
## [2026.09.27] - 2026-09-27
FastMCP 4 support, and a composition bug that affected every earlier release.
+246 -8
View File
@@ -1,17 +1,19 @@
# FastMCP Feedback
Production-ready feedback collection system for [FastMCP](https://gofastmcp.com) servers.
Instrumentation and QA feedback for [FastMCP](https://gofastmcp.com) servers.
Add structured feedback collection (bug reports, feature requests, general feedback) to any FastMCP server with a single line of code.
Record every tool call your server handles, and let people and models report problems with it, each with a line or two of code.
## Features
- **Per-Call Instrumentation** - Middleware records every tool call (timing, outcome, identity, payload sizes) without slowing or breaking it
- **Secret Redaction** - Tokens and passwords removed from recorded arguments, results and errors by default
- **One-Line Integration** - Add feedback tools to any FastMCP server instantly
- **Modular Mixins** - Selective tool registration for fine-grained control
- **Multi-Database Support** - SQLite (dev), PostgreSQL, MySQL (production)
- **Privacy-Compliant Analytics** - Optional usage insights without sensitive data
- **Type-Safe** - Full Pydantic validation and type hints
- **Well-Tested** - 97%+ test coverage
- **Well-Tested** - 98% coverage, run against FastMCP 2.12, 2.14, 3.x and 4.x
## Installation
@@ -114,6 +116,233 @@ Analytics are off unless you pass `FeedbackInsights(enabled=True)` or set
`FEEDBACK_INSIGHTS_ENABLED=true`. When enabled they record tool usage and
submission metadata (type, lengths, timing), never feedback text or contact details.
## Instrumentation
Record every tool call on your server:
```python
from fastmcp import FastMCP
from fastmcp_feedback.instrumentation import instrument
app = FastMCP("My Server")
instrument(app) # one JSON line per tool call on stderr
```
Each record has the tool name, start time, duration, outcome and error type,
session and request ids, argument and result sizes, and whatever your hooks
add. The recording happens in middleware, so it covers every tool on the server,
not just the feedback ones.
It never slows or breaks a tool call. Records go to the sinks from a background
task through a bounded queue, and when the queue is full they are dropped and
counted instead of waited on. Errors in a sink or a hook are logged and swallowed.
### Modes
| Mode | Records |
|------|---------|
| `off` | Nothing |
| `meta` (default) | Tool, timing, outcome, identity, payload sizes |
| `full` | Also the arguments and result, redacted |
Set it with `mode=` or the `FEEDBACK_INSTRUMENTATION_MODE` environment variable.
`meta_only_tools={"run_code", "create_token"}` keeps chosen tools at `meta`
even in `full` mode.
### Redaction
Secrets are removed from arguments, results, error messages and hook output
before anything reaches a sink. It matches keys at any depth (`password`,
`token`, `authorization`, `client_secret`, `*_api_key`, ...) and secret-shaped
values anywhere in text (JWTs, `Bearer ...`, `sk-...`, GitHub, Slack, AWS and
PyPI tokens, `password=...` in error text). Add your own:
```python
from fastmcp_feedback.instrumentation import Redactor, instrument
instrument(
app,
mode="full",
redactor=Redactor(extra_keys=["license"], extra_patterns=[r"acme_[A-Za-z0-9]{32}"]),
)
```
### Sinks
```python
from fastmcp_feedback.instrumentation import CallbackSink, DatabaseSink, JsonLinesSink, instrument
instrument(app, [
JsonLinesSink(), # stderr
DatabaseSink("sqlite+aiosqlite:///calls.db", create_tables=True),
CallbackSink(lambda records: print(len(records))), # sync or async
])
```
`DatabaseSink` needs the `instrumentation` extra
(`uv add "fastmcp-feedback[instrumentation]"`), plus `asyncpg` for Postgres.
### Using your own database and migrations
Pass the `AsyncEngine` you already have. Records go to a `ffb_tool_calls`
table (the prefix is configurable), and nothing is created at startup unless you
ask. To manage the table with Alembic, add it to your `env.py`:
```python
from fastmcp_feedback.instrumentation import build_metadata
target_metadata = [Base.metadata, build_metadata(prefix="ffb_")]
```
```python
from fastmcp_feedback.instrumentation import DatabaseSink, instrument
sink = DatabaseSink(engine, prefix="ffb_") # your engine is never disposed
instrument(app, [sink])
```
### Errors returned as data
Many tools report failure in their result instead of raising, for example
`{"status": "failed", "error": "disk full"}` or `{"success": False, ...}`. A
result classifier catches these, so error rates count them. Each record has an
`outcome`:
| `outcome` | `ok` | Meaning |
|-----------|------|---------|
| `ok` | true | The call succeeded |
| `soft_error` | false | The tool returned a result the classifier flagged |
| `error` | false | The tool raised |
Soft errors get `error_type="SoftError"` and an `error_message` such as
`status=failed: disk full`, redacted and truncated like exception text.
The default classifier flags a dict result when `ok` or `success` is exactly
`False`, when `status` or `state` is `error`, `failed` or `failure` (any case),
or when the tool marked the result as an MCP error (`isError`) without raising.
The message comes from the first of `error`, `message`, `detail` and `reason`.
Add your own status values, or pass any `(tool, result) -> str | None`:
```python
from fastmcp_feedback.instrumentation import instrument, soft_error_classifier
# blender-mcp answers {"status": "unknown_target", ...} for a missing object
instrument(app, result_classifier=soft_error_classifier(extra_statuses=("unknown_target",)))
```
```python
from fastmcp_feedback.instrumentation import instrument
def classify(tool, result):
"""None means ok; a string marks a soft error and is its message."""
if tool == "search" and isinstance(result, dict) and result.get("hits") == []:
return "no hits"
return None
instrument(app, result_classifier=classify)
```
`result_classifier=None` turns classification off. The classifier only sees
calls that did not raise, gets the same payload the record stores (structured
content when present), and if it raises, the error is logged and the call is
recorded as `ok`. It never changes what the client receives.
**Upgrading:** soft errors set `ok` to false, so existing `WHERE NOT ok` counts
include them from this release on. Filter on `outcome = 'error'` for exceptions
only. The `outcome` column is new in 2026.09.27.4; it is nullable, and rows
written by earlier versions have NULL there. Hosts that manage the schema with
Alembic need a migration adding it (with its index) to `ffb_tool_calls`, and a
table made earlier by `create_tables=True` needs
`ALTER TABLE ffb_tool_calls ADD COLUMN outcome VARCHAR(16)`, since existing
tables are not altered. Until the column exists, database writes fail and
those records are dropped (logged).
### Identity and enrichment hooks
```python
from fastmcp_feedback.instrumentation import instrument
def who(context):
"""Who made this call. May be async."""
user = context.fastmcp_context # read your auth state from here
return {"user_sub": "user-123", "caller_kind": "pat:7", "client_id": "cli"}
async def enrich(tool, args, result, context):
"""Extra indexed fields. May be async."""
return {"job_id": (result or {}).get("job_id"), "server_version": "1.4.0"}
instrument(app, identity_resolver=who, enricher=enrich)
```
`user_sub`, `caller_kind` and `client_id` become columns; other identity keys
go in `identity`. Enricher output goes in `extra`, except `server_version`,
which has its own column. Async hooks that take longer than `hook_timeout`
(0.25 s by default) are skipped for that call.
### Linking feedback to the calls behind it
When a model reports "the render tool hung", the report is far more useful with
the calls that led up to it attached. Pass the middleware to
`add_feedback_tools` and every `submit_feedback` does this for you:
```python
from fastmcp_feedback import add_feedback_tools
from fastmcp_feedback.instrumentation import DatabaseSink, instrument
mw = instrument(app, [DatabaseSink("sqlite+aiosqlite:///calls.db", create_tables=True)])
add_feedback_tools(app, database_url="sqlite:///feedback.db", instrumentation=mw)
# submit_feedback now answers {"feedback_id": ..., "linked_calls": 3, ...}
```
Read the linked calls back, in order, with their outcomes:
```python
for call in await mw.feedback_context(feedback_id):
print(call["position"], call["tool"], call["outcome"], call["error_type"], call["rule"])
```
If you have your own feedback tools, use the same pieces directly. Feedback ids
can be anything (`bug-7Q2X`, `42`):
```python
from fastmcp import Context
@app.tool
async def report_problem(summary: str, ctx: Context) -> dict:
ref = save_my_feedback(summary) # your storage, your id
links = await mw.capture_recent_calls(ctx, n=20)
await mw.link_feedback(ref, links)
return {"id": ref, "linked_calls": len(links)}
```
Calls are matched in two ways, and each link records which one (`rule`):
- `session`: the same MCP client session. Over HTTP this is the
`mcp-session-id`; over stdio it is the server process, which serves one client.
- `user_window`: the same `user_sub` within the last 15 minutes (`window=`),
for clients that open a new session per call (personal access tokens, CI).
This needs an `identity_resolver`.
Session matches come first, and user matches fill any remaining slots. Calls
still waiting in the queue are included, and with a `DatabaseSink`, so are
calls from other workers or from before a restart.
Links live in a `ffb_feedback_call_links` table next to `ffb_tool_calls`.
`build_metadata()` includes it, so hosts that manage the schema with Alembic
need one new migration before calling `link_feedback`.
**Stateless clients:** clients on MCP protocol 2026-07-28, including Claude
Code and FastMCP 4's own `Client`, do not open sessions over HTTP. Their calls
link by user only, so configure an `identity_resolver` for HTTP servers. Stdio
servers link by session with every client.
### Middleware order and shutdown
FastMCP runs the first middleware added as the outermost. Call `instrument()`
before adding other middleware to include their time and record calls they
reject; call it last to time only the tool. On shutdown, `await mw.aclose()`
flushes queued records and closes the sinks.
## Advanced Usage
### Mixin Architecture
@@ -175,13 +404,13 @@ removed in FastMCP 4.
## Compatibility
Tested against FastMCP 2.12, 2.14 and 4.0 on every commit. The dependency is
Tested against FastMCP 2.12, 2.14, 3.x and 4.x on every commit. The dependency is
declared as `fastmcp>=2.12.2,<5`, and the ceiling moves up once a new major
version passes the suite.
## API Reference
### `add_feedback_tools(mcp, database_url=None, insights=None, prefix="", separator="_")`
### `add_feedback_tools(mcp, database_url=None, insights=None, prefix="", separator="_", instrumentation=None)`
Add all five feedback tools to a FastMCP server.
@@ -191,6 +420,9 @@ Add all five feedback tools to a FastMCP server.
- `insights`: a `FeedbackInsights` instance. Defaults to one with analytics disabled
- `prefix`: prepended to every tool name
- `separator`: placed between `prefix` and the tool name
- `instrumentation`: the middleware returned by `instrument()`. When given,
`submit_feedback` links the calls that preceded it to the new item and
returns `linked_calls` (see [Linking feedback to the calls behind it](#linking-feedback-to-the-calls-behind-it))
### Feedback Types
@@ -214,10 +446,17 @@ Full documentation: https://fastmcp-feedback.l.supported.systems
## Examples
See the [fastmcp-feedback-showcase](https://git.supported.systems/fastmcp-feedback/fastmcp-example) repository for comprehensive examples demonstrating all capabilities:
The [`examples/`](https://git.supported.systems/fastmcp-feedback/fastmcp-feedback/src/branch/main/examples)
directory has runnable servers, each with a walkthrough:
- `simple_integration.py`: the one-line integration
- `mcp_server.py`: a production-style server configured from environment variables
- `demo_server.py`: every feature, including server composition and sample data
```bash
uvx fastmcp-feedback-showcase # Run full demo
git clone https://git.supported.systems/fastmcp-feedback/fastmcp-feedback.git
cd fastmcp-feedback
uv run python examples/simple_integration.py
```
## Contributing
@@ -265,5 +504,4 @@ MIT License - see [LICENSE](LICENSE) for details.
- **Documentation**: https://fastmcp-feedback.l.supported.systems
- **Repository**: https://git.supported.systems/fastmcp-feedback/fastmcp-feedback
- **Issues**: https://git.supported.systems/fastmcp-feedback/fastmcp-feedback/issues
- **Showcase**: https://git.supported.systems/fastmcp-feedback/fastmcp-example
- **FastMCP**: https://gofastmcp.com
+7 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "fastmcp-feedback"
version = "2026.09.27" # YYYYMMDD.patch versioning - update for each release
version = "2026.09.27.4" # YYYYMMDD.patch versioning - update for each release
description = "Production-ready feedback collection system for FastMCP servers"
readme = "README.md"
license = "MIT"
@@ -50,8 +50,12 @@ dependencies = [
[project.optional-dependencies]
postgresql = ["psycopg2-binary>=2.9.0"]
# Async database sink for per-call instrumentation. Postgres hosts add asyncpg.
instrumentation = ["sqlalchemy[asyncio]>=2.0.43", "aiosqlite>=0.22.1"]
mysql = ["pymysql>=1.1.0"]
dev = [
"sqlalchemy[asyncio]>=2.0.43",
"aiosqlite>=0.22.1",
"pytest>=8.2.0",
"pytest-asyncio>=0.23.0",
"pytest-cov>=5.0.0",
@@ -61,6 +65,8 @@ dev = [
"mypy>=1.7.0",
]
test = [
"sqlalchemy[asyncio]>=2.0.43",
"aiosqlite>=0.22.1",
"pytest>=8.2.0",
"pytest-asyncio>=0.23.0",
"pytest-cov>=5.0.0",
+1 -1
View File
@@ -44,7 +44,7 @@ Quick Start:
import logging
# Version information - CalVer YYYY.MM.DD
__version__ = "2026.09.27"
__version__ = "2026.09.27.4"
__author__ = "Ryan Malloy"
__email__ = "ryan@supported.systems"
@@ -0,0 +1,48 @@
"""Per-call instrumentation for any FastMCP server.
from fastmcp_feedback.instrumentation import instrument, DatabaseSink
mw = instrument(app) # every tool call as JSON lines on stderr
mw = instrument(app, [DatabaseSink(engine)], identity_resolver=who)
mw = instrument(app, result_classifier=soft_error_classifier(extra_statuses=("unknown_target",)))
"""
from .classify import (
DEFAULT_RESULT_CLASSIFIER,
ResultClassifier,
soft_error_classifier,
)
from .dispatch import RecordDispatcher
from .middleware import MODE_ENV_VAR, MODES, InstrumentationMiddleware, instrument
from .record import ToolCallRecord
from .redaction import (
DEFAULT_SECRET_KEYS,
DEFAULT_SECRET_PATTERNS,
REDACTED,
Redactor,
)
from .sinks import CallbackSink, JsonLinesSink, MemorySink, Sink
from .storage import DEFAULT_TABLE_PREFIX, DatabaseSink, build_metadata
__all__ = [
"CallbackSink",
"DatabaseSink",
"DEFAULT_RESULT_CLASSIFIER",
"DEFAULT_SECRET_KEYS",
"DEFAULT_SECRET_PATTERNS",
"DEFAULT_TABLE_PREFIX",
"InstrumentationMiddleware",
"JsonLinesSink",
"MemorySink",
"MODE_ENV_VAR",
"MODES",
"RecordDispatcher",
"REDACTED",
"Redactor",
"ResultClassifier",
"Sink",
"ToolCallRecord",
"build_metadata",
"instrument",
"soft_error_classifier",
]
@@ -0,0 +1,97 @@
"""Spotting tools that report failure as data instead of raising.
A result classifier is ``(tool, result) -> str | None``. ``result`` is the
recorded payload: the structured content when there is one, otherwise the
content items. None means the call succeeded; a string marks it as a soft
error and becomes the record's ``error_message`` (after redaction).
A result the tool marked as an MCP error (``isError``) without raising reaches
the classifier as a dict with ``"isError": True``, alongside the payload's own
keys or with the payload under ``"content"``.
"""
from collections.abc import Callable, Iterable
from typing import Any
ResultClassifier = Callable[[str, Any], str | None]
MESSAGE_KEYS = ("error", "message", "detail", "reason")
ERROR_FLAG_KEYS = ("isError", "is_error")
def _message(result: dict, depth: int = 0) -> str | None:
for key in MESSAGE_KEYS:
value = result.get(key)
if value is None or value == "":
continue
if isinstance(value, dict):
# {"error": {"code": 404, "message": "..."}}
if depth == 0 and (inner := _message(value, 1)):
return inner
continue
if isinstance(value, list | tuple):
continue
return str(value)
return None
def _content_text(content: Any) -> str | None:
if isinstance(content, str):
return content
if not isinstance(content, list | tuple):
return None
texts = [
item if isinstance(item, str) else item.get("text")
for item in content
if isinstance(item, str | dict)
]
return " ".join(t for t in texts if t) or None
def _describe(label: str, detail: str | None) -> str:
return f"{label}: {detail}" if detail else label
def soft_error_classifier(
*,
statuses: Iterable[str] = ("error", "failed", "failure"),
status_keys: Iterable[str] = ("status", "state"),
flag_keys: Iterable[str] = ("ok", "success"),
extra_statuses: Iterable[str] = (),
) -> ResultClassifier:
"""Build a classifier for dict results that describe their own failure.
A dict result is a soft error when any of ``flag_keys`` is exactly False,
when any of ``status_keys`` holds one of ``statuses`` or
``extra_statuses`` (case-insensitive), or when the tool marked it
``isError``. The message is the first of ``error``, ``message``,
``detail`` and ``reason`` present, e.g. ``status=failed: disk full``.
>>> classify = soft_error_classifier(extra_statuses=("unknown_target",))
>>> classify("render", {"status": "unknown_target", "error": "no such object"})
'status=unknown_target: no such object'
"""
bad = frozenset(s.lower() for s in (*statuses, *extra_statuses))
status_keys = tuple(status_keys)
flag_keys = tuple(flag_keys)
def classify(tool: str, result: Any) -> str | None:
if not isinstance(result, dict):
return None
for key in status_keys:
value = result.get(key)
if isinstance(value, str) and value.lower() in bad:
return _describe(f"{key}={value}", _message(result))
for key in flag_keys:
if key in result and result[key] is False:
return _describe(f"{key}=False", _message(result))
for key in ERROR_FLAG_KEYS:
if result.get(key) is True:
detail = _message(result) or _content_text(result.get("content"))
return _describe("isError", detail)
return None
return classify
DEFAULT_RESULT_CLASSIFIER = soft_error_classifier()
@@ -0,0 +1,106 @@
"""Linking feedback to the tool calls that came before it."""
from collections.abc import Iterable
from dataclasses import asdict, dataclass
from datetime import UTC, datetime
from typing import Any
RULE_SESSION = "session"
RULE_USER_WINDOW = "user_window"
@dataclass
class CallLink:
"""A tool call linked to a piece of feedback, and why it matched."""
call_id: str
rule: str
tool: str
started_at: datetime
ok: bool
position: int = 0
def to_dict(self) -> dict[str, Any]:
data = asdict(self)
data["started_at"] = self.started_at.isoformat()
return data
@dataclass
class Candidate:
"""The fields correlation needs, from a buffered record or a database row."""
id: str
tool: str
started_at: datetime
ok: bool
session_id: str | None
user_sub: str | None
class ResolverContext:
"""What an identity resolver receives when called during correlation.
Outside a tool call there is no MiddlewareContext, so this carries only the
``fastmcp_context`` attribute, which is what resolvers normally read.
"""
def __init__(self, fastmcp_context: Any):
self.fastmcp_context = fastmcp_context
self.message = None
def as_utc(value: datetime) -> datetime:
# SQLite drops the timezone from DateTime(timezone=True) columns.
return value.replace(tzinfo=UTC) if value.tzinfo is None else value
def select_calls(
candidates: Iterable[Candidate],
*,
session_id: str | None,
user_sub: str | None,
since: datetime,
limit: int,
) -> list[CallLink]:
"""Pick up to ``limit`` recent calls for a feedback item.
Calls from the same MCP session come first. If there are fewer than
``limit``, the rest are filled with the same user's calls since ``since``,
which covers clients that open a new session per call (PATs, CI). The
result is in chronological order, numbered from 0.
"""
unique: dict[str, Candidate] = {}
for c in candidates:
unique.setdefault(c.id, c)
newest_first = sorted(
unique.values(), key=lambda c: as_utc(c.started_at), reverse=True
)
chosen: list[tuple[Candidate, str]] = []
if session_id:
chosen += [
(c, RULE_SESSION) for c in newest_first if c.session_id == session_id
][:limit]
if user_sub and len(chosen) < limit:
taken = {c.id for c, _ in chosen}
chosen += [
(c, RULE_USER_WINDOW)
for c in newest_first
if c.id not in taken
and c.user_sub == user_sub
and as_utc(c.started_at) >= since
][: limit - len(chosen)]
chosen.sort(key=lambda pair: as_utc(pair[0].started_at))
return [
CallLink(
call_id=c.id,
rule=rule,
tool=c.tool,
started_at=as_utc(c.started_at),
ok=c.ok,
position=i,
)
for i, (c, rule) in enumerate(chosen)
]
@@ -0,0 +1,117 @@
"""Background delivery of records to sinks, off the tool-call path."""
import asyncio
import contextlib
import logging
from collections.abc import Sequence
from .record import ToolCallRecord
from .sinks import Sink
logger = logging.getLogger(__name__)
class RecordDispatcher:
"""Queue records and hand them to sinks from a background task.
``submit`` never blocks and never raises: when the queue is full the record
is dropped and counted, so a slow or broken sink cannot slow down or fail
a tool call. Sink errors are logged and counted, never propagated.
"""
def __init__(
self,
sinks: Sequence[Sink],
*,
max_queue: int = 1000,
batch_size: int = 100,
sink_timeout: float = 10.0,
):
self.sinks = list(sinks)
self.max_queue = max_queue
self.batch_size = batch_size
self.sink_timeout = sink_timeout
self.dropped = 0
self.sink_errors = 0
self._queue: asyncio.Queue[ToolCallRecord] | None = None
self._task: asyncio.Task | None = None
self._loop: asyncio.AbstractEventLoop | None = None
def submit(self, record: ToolCallRecord) -> bool:
"""Queue a record for delivery. Returns False if it was dropped."""
try:
loop = asyncio.get_running_loop()
except RuntimeError:
self._drop("no running event loop")
return False
# A queue belongs to one loop; tests and some hosts run several.
if self._queue is None or self._loop is not loop:
self._loop = loop
self._queue = asyncio.Queue(maxsize=self.max_queue)
self._task = loop.create_task(self._run(self._queue))
try:
self._queue.put_nowait(record)
except asyncio.QueueFull:
self._drop("queue full")
return False
return True
def _drop(self, reason: str) -> None:
self.dropped += 1
if self.dropped == 1 or self.dropped % 1000 == 0:
logger.warning(
"Dropped tool-call record (%s); %d dropped so far", reason, self.dropped
)
async def _run(self, queue: asyncio.Queue) -> None:
while True:
batch = [await queue.get()]
while len(batch) < self.batch_size:
try:
batch.append(queue.get_nowait())
except asyncio.QueueEmpty:
break
try:
await self._deliver(batch)
finally:
for _ in batch:
queue.task_done()
async def _deliver(self, batch: list[ToolCallRecord]) -> None:
for sink in self.sinks:
try:
await asyncio.wait_for(sink.write(batch), self.sink_timeout)
except Exception:
self.sink_errors += 1
logger.warning(
"Sink %s failed to write %d records",
type(sink).__name__,
len(batch),
exc_info=True,
)
async def flush(self) -> None:
"""Wait until every queued record has been handed to the sinks."""
if self._queue is not None and self._loop is asyncio.get_running_loop():
await self._queue.join()
async def aclose(self) -> None:
"""Flush, stop the background task and close the sinks."""
await self.flush()
if self._task is not None:
self._task.cancel()
with contextlib.suppress(asyncio.CancelledError, RuntimeError):
await self._task
self._task = None
self._queue = None
for sink in self.sinks:
close = getattr(sink, "aclose", None)
if close is not None:
try:
await close()
except Exception:
logger.warning(
"Sink %s failed to close", type(sink).__name__, exc_info=True
)
@@ -0,0 +1,463 @@
"""FastMCP middleware that records every tool call."""
import asyncio
import inspect
import json
import logging
import os
import time
import uuid
from collections import deque
from collections.abc import Awaitable, Callable, Iterable, Sequence
from datetime import UTC, datetime, timedelta
from typing import Any
from fastmcp import FastMCP
from fastmcp.server.middleware import Middleware, MiddlewareContext
from .classify import DEFAULT_RESULT_CLASSIFIER, ResultClassifier
from .correlation import (
CallLink,
Candidate,
ResolverContext,
as_utc,
select_calls,
)
from .dispatch import RecordDispatcher
from .record import ToolCallRecord
from .redaction import Redactor
from .sinks import JsonLinesSink, Sink
from .storage import DatabaseSink
logger = logging.getLogger(__name__)
MODES = ("off", "meta", "full")
MODE_ENV_VAR = "FEEDBACK_INSTRUMENTATION_MODE"
IdentityResolver = Callable[
[MiddlewareContext], dict[str, Any] | Awaitable[dict[str, Any]] | None
]
Enricher = Callable[
[str, Any, Any, MiddlewareContext],
dict[str, Any] | Awaitable[dict[str, Any]] | None,
]
# Keys an identity resolver can return that get their own columns.
_IDENTITY_COLUMNS = ("user_sub", "caller_kind", "client_id")
def _attr(obj: Any, name: str) -> Any:
try:
value = getattr(obj, name, None)
except Exception:
return None
return str(value) if value is not None else None
# stdio serves exactly one client per process, so the process is the session.
_STDIO_SESSION = f"stdio:{os.getpid()}:{uuid.uuid4().hex[:8]}"
def session_key(ctx: Any) -> str | None:
"""A per-client key that stays the same across that client's calls.
Clients on MCP protocol 2026-07-28 (Claude Code, FastMCP 4's Client) do
not open sessions, and FastMCP then generates a new ``ctx.session_id`` for
every request. So stable signals come first: the HTTP ``mcp-session-id``
header (older protocol clients), then one key per process for stdio, then
``ctx.session_id``. With a stateless HTTP client there is no session, and
correlation relies on the same-user rule.
"""
if ctx is None:
return None
try:
from fastmcp.server.dependencies import get_http_headers
header = (get_http_headers(include_all=True) or {}).get("mcp-session-id")
if header:
return header
except Exception:
pass
if _attr(ctx, "transport") == "stdio":
return _STDIO_SESSION
return _attr(ctx, "session_id")
def _result_payload(result: Any) -> Any:
structured = getattr(result, "structured_content", None)
if structured is not None:
return structured
content = getattr(result, "content", None)
if content is not None:
return [
getattr(item, "text", None) or {"type": getattr(item, "type", None)}
for item in content
]
return result
def _json_size(value: Any) -> int | None:
try:
return len(json.dumps(value, default=str).encode())
except Exception:
return None
class InstrumentationMiddleware(Middleware):
"""Record every tool call without ever slowing or breaking it.
Modes:
``off``: pass calls straight through.
``meta`` (default): tool, timing, outcome, identity and payload sizes.
``full``: also the arguments and result, redacted.
The mode defaults to the ``FEEDBACK_INSTRUMENTATION_MODE`` environment
variable, then ``meta``. Records go to the sinks from a background task
through a bounded queue; when it fills, records are dropped, not waited on.
Args:
sinks: where records go. Defaults to JSON lines on stderr.
mode: see above.
redactor: secret redaction for args, results, error text and extras.
meta_only_tools: tools that are never recorded above ``meta``.
identity_resolver: ``(context) -> dict`` returning ``user_sub``,
``caller_kind`` and ``client_id`` (stored as columns) plus any
other keys (stored in ``identity``). May be async.
enricher: ``(tool, args, result, context) -> dict`` of extra fields,
stored in ``extra``. A ``server_version`` key fills that column.
May be async.
server_version: default for the ``server_version`` column.
hook_timeout: seconds an async hook may take before it is skipped.
recent_calls: how many recent records to keep in memory for
correlation, including ones still waiting to reach the sinks.
correlation_timeout: seconds a database lookup during correlation may
take before it is skipped.
result_classifier: ``(tool, result) -> str | None`` run on results
of calls that did not raise. A string marks the call as a soft
error (``outcome="soft_error"``, ``ok=False``) and becomes the
error message. Defaults to ``DEFAULT_RESULT_CLASSIFIER``; None
turns it off. If it raises, the call is recorded as ok.
"""
def __init__(
self,
sinks: Sequence[Sink] | None = None,
*,
mode: str | None = None,
redactor: Redactor | None = None,
meta_only_tools: Iterable[str] = (),
identity_resolver: IdentityResolver | None = None,
enricher: Enricher | None = None,
server_version: str | None = None,
hook_timeout: float = 0.25,
max_queue: int = 1000,
batch_size: int = 100,
sink_timeout: float = 10.0,
max_error_chars: int = 500,
recent_calls: int = 2000,
correlation_timeout: float = 1.0,
result_classifier: ResultClassifier | None = DEFAULT_RESULT_CLASSIFIER,
):
mode = (mode or os.getenv(MODE_ENV_VAR) or "meta").lower()
if mode not in MODES:
raise ValueError(f"mode must be one of {MODES}, got {mode!r}")
self.mode = mode
self.redactor = redactor or Redactor()
self.meta_only_tools = frozenset(meta_only_tools)
self.identity_resolver = identity_resolver
self.enricher = enricher
self.server_version = server_version
self.hook_timeout = hook_timeout
self.max_error_chars = max_error_chars
self.correlation_timeout = correlation_timeout
self.result_classifier = result_classifier
self.recent: deque[ToolCallRecord] = deque(maxlen=recent_calls)
self.dispatcher = RecordDispatcher(
sinks if sinks is not None else [JsonLinesSink()],
max_queue=max_queue,
batch_size=batch_size,
sink_timeout=sink_timeout,
)
async def on_call_tool(self, context: MiddlewareContext, call_next):
if self.mode == "off":
return await call_next(context)
started_at = datetime.now(UTC)
start = time.perf_counter()
result = None
error: BaseException | None = None
try:
result = await call_next(context)
return result
except BaseException as exc:
error = exc
raise
finally:
duration_ms = (time.perf_counter() - start) * 1000
try:
record = await self._build_record(
context, started_at, duration_ms, result, error
)
self.recent.append(record)
self.dispatcher.submit(record)
except Exception:
logger.warning("Failed to record tool call", exc_info=True)
async def _call_hook(self, hook: Callable | None, *args: Any) -> dict[str, Any]:
if hook is None:
return {}
try:
out = hook(*args)
if inspect.isawaitable(out):
out = await asyncio.wait_for(out, self.hook_timeout)
return dict(out) if out else {}
except Exception:
logger.warning("Instrumentation hook %r failed", hook, exc_info=True)
return {}
def _classify(self, tool: str, result: Any, payload: Any) -> str | None:
if getattr(result, "is_error", False) is True:
# FastMCP 3.4+ keeps isError on the ToolResult, not in the payload.
if isinstance(payload, dict):
payload = {**payload, "isError": True}
else:
payload = {"isError": True, "content": payload}
try:
message = self.result_classifier(tool, payload)
except Exception:
logger.warning(
"Result classifier %r failed", self.result_classifier, exc_info=True
)
return None
return None if message is None else str(message)
async def _build_record(
self,
context: MiddlewareContext,
started_at: datetime,
duration_ms: float,
result: Any,
error: BaseException | None,
) -> ToolCallRecord:
message = context.message
tool = getattr(message, "name", None) or "unknown"
args = getattr(message, "arguments", None) or {}
ctx = context.fastmcp_context
payload = _result_payload(result) if result is not None else None
identity = await self._call_hook(self.identity_resolver, context)
extra = await self._call_hook(self.enricher, tool, args, payload, context)
identity = self.redactor.redact(identity)
extra = self.redactor.redact(extra)
columns = {k: identity.pop(k) for k in _IDENTITY_COLUMNS if k in identity}
server_version = extra.pop("server_version", None) or self.server_version
mode = "meta" if tool in self.meta_only_tools else self.mode
full = mode == "full"
outcome = "ok"
error_type = error_message = None
if error is not None:
# FastMCP wraps tool exceptions in ToolError; report the original.
cause = error.__cause__ or error
outcome = "error"
error_type = type(cause).__name__
error_message = self.redactor.redact_text(str(cause))[
: self.max_error_chars
]
elif self.result_classifier is not None and payload is not None:
soft = self._classify(tool, result, payload)
if soft is not None:
outcome = "soft_error"
error_type = "SoftError"
error_message = (
self.redactor.redact_text(soft)[: self.max_error_chars] or None
)
return ToolCallRecord(
tool=tool,
started_at=started_at,
duration_ms=duration_ms,
ok=outcome == "ok",
outcome=outcome,
mode=mode,
error_type=error_type,
error_message=error_message,
session_id=session_key(ctx),
request_id=_attr(ctx, "request_id"),
client_id=columns.get("client_id") or _attr(ctx, "client_id"),
user_sub=columns.get("user_sub"),
caller_kind=columns.get("caller_kind"),
identity=identity,
server_version=server_version,
args_size=_json_size(args),
result_size=_json_size(payload) if payload is not None else None,
args=self.redactor.redact(args) if full else None,
result=self.redactor.redact(payload)
if full and payload is not None
else None,
extra=extra,
)
@property
def database_sink(self) -> DatabaseSink | None:
"""The first DatabaseSink among the sinks, if any."""
return next(
(s for s in self.dispatcher.sinks if isinstance(s, DatabaseSink)), None
)
async def capture_recent_calls(
self,
ctx: Any = None,
n: int = 20,
*,
user_sub: str | None = None,
window: timedelta = timedelta(minutes=15),
) -> list[CallLink]:
"""The calls that led up to now, for attaching to a feedback item.
Call from inside a tool. Returns up to ``n`` calls in chronological
order: first from the current MCP session, then, if that yields fewer
than ``n``, the same user's calls within ``window``. Each link says
which rule matched. Never raises; returns [] on any failure.
Args:
ctx: the FastMCP Context. Looked up automatically when omitted.
n: maximum number of calls.
user_sub: the caller's user id. When omitted, the identity
resolver is asked, given an object whose ``fastmcp_context``
is ``ctx``.
window: how far back the same-user fallback reaches.
"""
try:
if ctx is None:
ctx = _current_context()
session_id = session_key(ctx)
if user_sub is None and self.identity_resolver is not None:
identity = await self._call_hook(
self.identity_resolver, ResolverContext(ctx)
)
user_sub = identity.get("user_sub")
since = datetime.now(UTC) - window
candidates = [
Candidate(r.id, r.tool, r.started_at, r.ok, r.session_id, r.user_sub)
for r in self.recent
]
links = select_calls(
candidates,
session_id=session_id,
user_sub=user_sub,
since=since,
limit=n,
)
sink = self.database_sink
if sink is not None and len(links) < n:
# Other workers, or calls from before a restart.
rows = await asyncio.wait_for(
sink.recent_calls(
session_id=session_id, user_sub=user_sub, since=since, limit=n
),
self.correlation_timeout,
)
candidates += [
Candidate(
r["id"],
r["tool"],
as_utc(r["started_at"]),
r["ok"],
r["session_id"],
r["user_sub"],
)
for r in rows
]
links = select_calls(
candidates,
session_id=session_id,
user_sub=user_sub,
since=since,
limit=n,
)
return links
except Exception:
logger.warning("Failed to capture recent calls", exc_info=True)
return []
async def link_feedback(self, feedback_ref: str, links: Sequence[CallLink]) -> bool:
"""Store ``links`` against a feedback id of any format (``bug-XXXX``, 42).
Needs a DatabaseSink. Replaces earlier links for the same id. Returns
False, without raising, if they could not be stored.
"""
sink = self.database_sink
if sink is None:
logger.warning("link_feedback needs a DatabaseSink; links not stored")
return False
linked_at = datetime.now(UTC)
try:
await sink.replace_links(
str(feedback_ref),
[
{
"feedback_ref": str(feedback_ref),
"call_id": link.call_id,
"position": link.position,
"rule": link.rule,
"linked_at": linked_at,
}
for link in links
],
)
return True
except Exception:
logger.warning("Failed to link feedback %s", feedback_ref, exc_info=True)
return False
async def feedback_context(self, feedback_ref: str) -> list[dict[str, Any]]:
"""The calls linked to a feedback id, in order, with their details.
Calls not yet written to the database are filled in from memory.
"""
sink = self.database_sink
if sink is None:
return []
rows = await sink.linked_calls(str(feedback_ref))
in_memory = {r.id: r for r in self.recent}
for row in rows:
if row["tool"] is None and row["call_id"] in in_memory:
record = in_memory[row["call_id"]]
for key in row:
if key not in ("call_id", "position", "rule"):
row[key] = getattr(record, key, None)
if row["started_at"] is not None:
row["started_at"] = as_utc(row["started_at"])
return rows
async def flush(self) -> None:
"""Wait until queued records have reached the sinks."""
await self.dispatcher.flush()
async def aclose(self) -> None:
"""Flush and close the sinks. Call on server shutdown."""
await self.dispatcher.aclose()
def _current_context() -> Any:
try:
from fastmcp.server.dependencies import get_context
return get_context()
except Exception:
return None
def instrument(app: FastMCP, sinks: Sequence[Sink] | None = None, **kwargs: Any):
"""Add an InstrumentationMiddleware to ``app`` and return it.
>>> mw = instrument(app) # JSON lines to stderr
>>> mw = instrument(app, [DatabaseSink(engine)], mode="full")
"""
middleware = InstrumentationMiddleware(sinks, **kwargs)
app.add_middleware(middleware)
return middleware
@@ -0,0 +1,55 @@
"""The per-call record produced by InstrumentationMiddleware."""
import uuid
from dataclasses import asdict, dataclass, field
from datetime import datetime
from typing import Any
@dataclass
class ToolCallRecord:
"""One MCP tool call, as seen by the instrumentation middleware.
``args`` and ``result`` are only populated in ``full`` mode, and are
already redacted when they are. Sizes are measured before redaction.
``outcome`` is ``ok``, ``error`` (the tool raised) or ``soft_error`` (it
returned a result the classifier flagged). ``ok`` is ``outcome == "ok"``;
when ``outcome`` is not given it is derived from ``ok``.
"""
tool: str
started_at: datetime
duration_ms: float
ok: bool
mode: str
outcome: str | None = None
id: str = field(default_factory=lambda: str(uuid.uuid4()))
error_type: str | None = None
error_message: str | None = None
session_id: str | None = None
request_id: str | None = None
client_id: str | None = None
user_sub: str | None = None
caller_kind: str | None = None
identity: dict[str, Any] = field(default_factory=dict)
server_version: str | None = None
args_size: int | None = None
result_size: int | None = None
args: Any = None
result: Any = None
extra: dict[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
if self.outcome is None:
self.outcome = "ok" if self.ok else "error"
def to_row(self) -> dict[str, Any]:
"""Column values for the tool_calls table."""
return asdict(self)
def to_dict(self) -> dict[str, Any]:
"""JSON-friendly form, with the timestamp as ISO 8601."""
data = asdict(self)
data["started_at"] = self.started_at.isoformat()
return data
@@ -0,0 +1,125 @@
"""Secret redaction for recorded tool arguments, results and error text."""
import re
from collections.abc import Iterable
from typing import Any
REDACTED = "[REDACTED]"
TRUNCATED = "[TRUNCATED]"
# Matched after lowercasing and turning "-" into "_", so "Authorization",
# "X-Api-Key" style variants are covered too.
DEFAULT_SECRET_KEYS = frozenset(
{
"token",
"access_token",
"refresh_token",
"id_token",
"auth_token",
"session_token",
"csrf_token",
"bearer",
"authorization",
"proxy_authorization",
"cookie",
"set_cookie",
"password",
"passwd",
"passphrase",
"secret",
"client_secret",
"api_key",
"apikey",
"x_api_key",
"private_key",
}
)
# github_token, db_password, stripe_api_key, ...
DEFAULT_SECRET_SUFFIXES = (
"_token",
"_secret",
"_password",
"_api_key",
"_apikey",
"_private_key",
)
# Values that are secrets wherever they appear, including inside free text.
DEFAULT_SECRET_PATTERNS = (
r"\bbmcp_[A-Za-z0-9_-]{8,}", # blender-mcp personal access tokens
r"\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}", # JWT
r"(?i)\bbearer\s+[A-Za-z0-9._~+/=-]{8,}",
r"\bsk-[A-Za-z0-9_-]{16,}", # OpenAI/Anthropic-style API keys
r"\bgh[pousr]_[A-Za-z0-9]{20,}", # GitHub tokens
r"\bgithub_pat_[A-Za-z0-9_]{20,}",
r"\bxox[abposr]-[A-Za-z0-9-]{10,}", # Slack
r"\bAKIA[0-9A-Z]{16}\b", # AWS access key id
r"\bpypi-[A-Za-z0-9_-]{16,}",
)
# "password=hunter2", "api_key: abc" in error messages and logs. The key is
# kept so the text still says what was removed.
_KEY_VALUE_TEXT = re.compile(
r"(?i)\b(password|passwd|secret|token|api[_-]?key|authorization)(\s*[=:]\s*)[^\s,;&'\"]+"
)
def _normalize_key(key: Any) -> str:
return str(key).lower().replace("-", "_")
class Redactor:
"""Replace secrets in nested JSON-like data with a placeholder.
Dict values are redacted when their key looks like a secret, at any depth.
String values anywhere are scanned for secret-shaped substrings. The input
is never modified; a redacted copy is returned.
"""
def __init__(
self,
keys: Iterable[str] = DEFAULT_SECRET_KEYS,
patterns: Iterable[str] = DEFAULT_SECRET_PATTERNS,
*,
extra_keys: Iterable[str] = (),
extra_patterns: Iterable[str] = (),
suffixes: Iterable[str] = DEFAULT_SECRET_SUFFIXES,
replacement: str = REDACTED,
max_depth: int = 32,
):
self.keys = frozenset(_normalize_key(k) for k in (*keys, *extra_keys))
self.suffixes = tuple(_normalize_key(s) for s in suffixes)
self.patterns = tuple(re.compile(p) for p in (*patterns, *extra_patterns))
self.replacement = replacement
self.max_depth = max_depth
def is_secret_key(self, key: Any) -> bool:
normalized = _normalize_key(key)
return normalized in self.keys or normalized.endswith(self.suffixes)
def redact_text(self, text: str) -> str:
for pattern in self.patterns:
text = pattern.sub(self.replacement, text)
return _KEY_VALUE_TEXT.sub(
lambda m: f"{m.group(1)}{m.group(2)}{self.replacement}", text
)
def redact(self, value: Any) -> Any:
return self._redact(value, 0)
def _redact(self, value: Any, depth: int) -> Any:
if depth > self.max_depth:
return TRUNCATED
if isinstance(value, str):
return self.redact_text(value)
if isinstance(value, dict):
return {
k: self.replacement
if self.is_secret_key(k) and v is not None
else self._redact(v, depth + 1)
for k, v in value.items()
}
if isinstance(value, list | tuple | set | frozenset):
return [self._redact(v, depth + 1) for v in value]
return value
@@ -0,0 +1,55 @@
"""Destinations for tool-call records."""
import inspect
import json
import sys
from collections.abc import Callable, Sequence
from typing import IO, Any, Protocol, runtime_checkable
from .record import ToolCallRecord
@runtime_checkable
class Sink(Protocol):
"""Anything with ``async write(records)``. ``async aclose()`` is optional."""
async def write(self, records: Sequence[ToolCallRecord]) -> None: ...
class JsonLinesSink:
"""One JSON object per record, to stderr by default."""
def __init__(self, stream: IO[str] | None = None):
self._stream = stream
async def write(self, records: Sequence[ToolCallRecord]) -> None:
# Resolved per write so a stderr swapped by the host or a test is honored.
stream = self._stream or sys.stderr
for record in records:
stream.write(json.dumps(record.to_dict(), default=str) + "\n")
stream.flush()
class CallbackSink:
"""Pass each batch to a function, sync or async.
A sync callback runs on the event loop, so keep it quick.
"""
def __init__(self, callback: Callable[[Sequence[ToolCallRecord]], Any]):
self.callback = callback
async def write(self, records: Sequence[ToolCallRecord]) -> None:
result = self.callback(records)
if inspect.isawaitable(result):
await result
class MemorySink:
"""Keep records in a list. Meant for tests."""
def __init__(self):
self.records: list[ToolCallRecord] = []
async def write(self, records: Sequence[ToolCallRecord]) -> None:
self.records.extend(records)
@@ -0,0 +1,217 @@
"""Async SQLAlchemy storage for tool-call records and feedback links.
Hosts that manage their own schema include ``build_metadata(prefix)`` in their
Alembic ``env.py`` (``target_metadata``) and pass their existing AsyncEngine to
``DatabaseSink``. Nothing is created unless ``create_tables=True``.
"""
import asyncio
from collections.abc import Sequence
from datetime import datetime
from typing import Any
from sqlalchemy import (
JSON,
Boolean,
Column,
DateTime,
Float,
Integer,
MetaData,
String,
Table,
Text,
and_,
delete,
or_,
select,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from .record import ToolCallRecord
DEFAULT_TABLE_PREFIX = "ffb_"
# none_as_null: a missing payload is SQL NULL, so "WHERE args IS NULL" works.
_JSON = JSON(none_as_null=True).with_variant(JSONB(none_as_null=True), "postgresql")
def build_metadata(
prefix: str = DEFAULT_TABLE_PREFIX, metadata: MetaData | None = None
) -> MetaData:
"""Return MetaData holding ``{prefix}tool_calls`` and ``{prefix}feedback_call_links``.
Pass an existing MetaData to add the tables to it instead.
"""
metadata = metadata if metadata is not None else MetaData()
calls = f"{prefix}tool_calls"
links = f"{prefix}feedback_call_links"
if calls not in metadata.tables:
Table(
calls,
metadata,
Column("id", String(36), primary_key=True),
Column("started_at", DateTime(timezone=True), nullable=False, index=True),
Column("duration_ms", Float, nullable=False),
Column("tool", String(255), nullable=False, index=True),
Column("mode", String(8), nullable=False),
Column("ok", Boolean, nullable=False, index=True),
# ok, soft_error or error. NULL on rows from before 2026.09.27.4.
Column("outcome", String(16), index=True),
Column("error_type", String(255)),
Column("error_message", Text),
Column("session_id", String(255), index=True),
Column("request_id", String(255)),
Column("client_id", String(255)),
Column("user_sub", String(255), index=True),
Column("caller_kind", String(64)),
Column("identity", _JSON),
Column("server_version", String(64), index=True),
Column("args_size", Integer),
Column("result_size", Integer),
Column("args", _JSON),
Column("result", _JSON),
Column("extra", _JSON),
)
if links not in metadata.tables:
# No foreign key to tool_calls: a link can be written before its call
# record has left the queue, and hosts may keep calls elsewhere.
Table(
links,
metadata,
Column("feedback_ref", String(255), primary_key=True),
Column("call_id", String(36), primary_key=True, index=True),
Column("position", Integer, nullable=False),
Column("rule", String(32), nullable=False),
Column("linked_at", DateTime(timezone=True), nullable=False),
)
return metadata
class DatabaseSink:
"""Write records to ``{prefix}tool_calls`` through an async engine.
Args:
engine: an AsyncEngine the host already owns, or a URL such as
``sqlite+aiosqlite:///calls.db`` or ``postgresql+asyncpg://...``.
An engine built from a URL is disposed on ``aclose()``; a passed-in
engine is left alone.
prefix: table name prefix, so the tables cannot collide with the host's.
create_tables: create the tables on first use. Off by default; hosts
with Alembic should generate a migration from ``build_metadata``.
"""
def __init__(
self,
engine: AsyncEngine | str,
*,
prefix: str = DEFAULT_TABLE_PREFIX,
create_tables: bool = False,
):
self._owns_engine = isinstance(engine, str)
self.engine = create_async_engine(engine) if isinstance(engine, str) else engine
self.metadata = build_metadata(prefix)
self.table = self.metadata.tables[f"{prefix}tool_calls"]
self.links = self.metadata.tables[f"{prefix}feedback_call_links"]
self.create_tables = create_tables
self._tables_ready = False
# The background writer and link_feedback can both reach _ready() at
# once; without the lock both run create_all and one fails.
self._tables_lock = asyncio.Lock()
async def ensure_tables(self) -> None:
"""Create the tables if missing. Only called when create_tables=True."""
async with self.engine.begin() as conn:
await conn.run_sync(self.metadata.create_all)
self._tables_ready = True
async def _ready(self) -> None:
if self.create_tables and not self._tables_ready:
async with self._tables_lock:
if not self._tables_ready:
await self.ensure_tables()
async def write(self, records: Sequence[ToolCallRecord]) -> None:
if not records:
return
await self._ready()
async with self.engine.begin() as conn:
await conn.execute(self.table.insert(), [r.to_row() for r in records])
async def recent_calls(
self,
*,
session_id: str | None,
user_sub: str | None,
since: datetime,
limit: int,
) -> list[dict[str, Any]]:
"""Calls from this session, or from this user since ``since``, newest first."""
t = self.table
conditions = []
if session_id:
conditions.append(t.c.session_id == session_id)
if user_sub:
conditions.append(and_(t.c.user_sub == user_sub, t.c.started_at >= since))
if not conditions:
return []
await self._ready()
query = (
select(
t.c.id, t.c.tool, t.c.started_at, t.c.ok, t.c.session_id, t.c.user_sub
)
.where(or_(*conditions))
.order_by(t.c.started_at.desc())
.limit(limit)
)
async with self.engine.connect() as conn:
return [dict(row) for row in (await conn.execute(query)).mappings()]
async def replace_links(
self, feedback_ref: str, links: Sequence[dict[str, Any]]
) -> None:
"""Set the calls linked to ``feedback_ref``, replacing any earlier set."""
await self._ready()
async with self.engine.begin() as conn:
await conn.execute(
delete(self.links).where(self.links.c.feedback_ref == feedback_ref)
)
if links:
await conn.execute(self.links.insert(), list(links))
async def linked_calls(self, feedback_ref: str) -> list[dict[str, Any]]:
"""Links for ``feedback_ref`` in order, joined to their call records.
Call columns are None for calls not (yet) in this database.
"""
await self._ready()
link, call = self.links, self.table
query = (
select(
link.c.call_id,
link.c.position,
link.c.rule,
call.c.tool,
call.c.started_at,
call.c.duration_ms,
call.c.ok,
call.c.outcome,
call.c.error_type,
call.c.error_message,
call.c.session_id,
call.c.user_sub,
call.c.caller_kind,
call.c.server_version,
call.c.extra,
)
.select_from(link.outerjoin(call, link.c.call_id == call.c.id))
.where(link.c.feedback_ref == feedback_ref)
.order_by(link.c.position)
)
async with self.engine.connect() as conn:
return [dict(row) for row in (await conn.execute(query)).mappings()]
async def aclose(self) -> None:
if self._owns_engine:
await self.engine.dispose()
+35 -4
View File
@@ -1,15 +1,37 @@
"""Submission mixin for feedback collection."""
from datetime import UTC, datetime
from typing import Any
from typing import TYPE_CHECKING, Any
from ..insights import record_feedback_submission
from ..models import SubmitFeedbackRequest, create_feedback_from_request
from .base import BaseFeedbackMixin, logger, mcp_tool
from .base import (
BaseFeedbackMixin,
FeedbackDatabase,
FeedbackInsights,
logger,
mcp_tool,
)
if TYPE_CHECKING:
from ..instrumentation import InstrumentationMiddleware
class SubmissionMixin(BaseFeedbackMixin):
"""Mixin for feedback submission functionality."""
"""Mixin for feedback submission functionality.
Pass an InstrumentationMiddleware to link each submission to the tool
calls that led up to it.
"""
def __init__(
self,
database: FeedbackDatabase,
insights: FeedbackInsights | None = None,
instrumentation: "InstrumentationMiddleware | None" = None,
):
super().__init__(database, insights)
self.instrumentation = instrumentation
@mcp_tool(
description="Submit feedback about your MCP experience - Use this tool when you encounter issues, have suggestions for improvement, or want to report positive experiences. AI models should proactively use this to help improve future MCP sessions. PRIVACY: Only include information you've already shared via previous MCP calls. Ask human permission before including any new sensitive data."
@@ -62,14 +84,23 @@ class SubmissionMixin(BaseFeedbackMixin):
source="mcp_tool",
)
linked_calls = 0
if self.instrumentation is not None:
links = await self.instrumentation.capture_recent_calls()
if await self.instrumentation.link_feedback(feedback_id, links):
linked_calls = len(links)
self._record_tool_usage("submit_feedback", start_time, True)
logger.info(f"Feedback submitted successfully: {feedback_id}")
return {
response = {
"success": True,
"feedback_id": feedback_id,
"message": "Feedback submitted successfully",
}
if self.instrumentation is not None:
response["linked_calls"] = linked_calls
return response
except Exception as e:
logger.error(f"Failed to submit feedback: {e}")
+5 -1
View File
@@ -17,6 +17,7 @@ def add_feedback_tools(
insights: FeedbackInsights | None = None,
prefix: str = "",
separator: str = "_",
instrumentation=None,
) -> None:
"""Add all feedback tools to a FastMCP server.
@@ -29,6 +30,9 @@ def add_feedback_tools(
insights: Optional insights instance. Creates default if not provided.
prefix: Optional prefix for tool names.
separator: Separator between prefix and tool name.
instrumentation: an InstrumentationMiddleware (see ``instrument``).
When given, each submission is linked to the tool calls that led
up to it, and the response reports ``linked_calls``.
"""
try:
# Initialize database
@@ -39,7 +43,7 @@ def add_feedback_tools(
insights = FeedbackInsights()
# Create mixin instances
submission_mixin = SubmissionMixin(database, insights)
submission_mixin = SubmissionMixin(database, insights, instrumentation)
retrieval_mixin = RetrievalMixin(database, insights)
management_mixin = ManagementMixin(database, insights)
+22
View File
@@ -0,0 +1,22 @@
"""A stdio MCP server used by test_correlation to check session linking end to end."""
from fastmcp import Context, FastMCP
from fastmcp_feedback.instrumentation import MemorySink, instrument
app = FastMCP("stdio-correlation")
mw = instrument(app, [MemorySink()])
@app.tool
def echo(text: str) -> str:
return text
@app.tool
async def report(ctx: Context) -> list:
return [[link.tool, link.rule] for link in await mw.capture_recent_calls(ctx)]
if __name__ == "__main__":
app.run(show_banner=False)
+416
View File
@@ -0,0 +1,416 @@
"""Tests for linking feedback to the tool calls that preceded it."""
import asyncio
import contextlib
import json
import sys
from datetime import UTC, datetime, timedelta
from pathlib import Path
import fastmcp
import pytest
from fastmcp import Client, Context, FastMCP
from fastmcp.client.transports import PythonStdioTransport
from fastmcp.exceptions import ToolError
from fastmcp_feedback import add_feedback_tools
from fastmcp_feedback.instrumentation import (
DatabaseSink,
MemorySink,
instrument,
)
from fastmcp_feedback.instrumentation import middleware as mw_module
from fastmcp_feedback.instrumentation.correlation import (
RULE_SESSION,
RULE_USER_WINDOW,
Candidate,
select_calls,
)
NOW = datetime(2026, 9, 27, 12, 0, tzinfo=UTC)
# FastMCP 4's Client speaks MCP 2026-07-28, which has no sessions, so each
# in-memory call gets a new ctx.session_id and there is no header or stdio
# signal to fall back on. Calls from one test client link by user instead.
FASTMCP_4 = int(fastmcp.__version__.split(".")[0]) >= 4
SAME_CLIENT_RULE = RULE_USER_WINDOW if FASTMCP_4 else RULE_SESSION
@pytest.fixture
async def db(tmp_path):
"""Make DatabaseSinks whose engines are always disposed, even on failure.
An undisposed aiosqlite engine keeps a worker thread alive and the test
process never exits.
"""
made = []
def factory(name="calls", cls=DatabaseSink):
sink = cls(f"sqlite+aiosqlite:///{tmp_path}/{name}.db", create_tables=True)
made.append(sink)
return sink
yield factory
for sink in made:
await sink.engine.dispose()
def cand(id, minutes_ago, session="s1", user="u1", tool="t", ok=True):
return Candidate(id, tool, NOW - timedelta(minutes=minutes_ago), ok, session, user)
class TestSelectCalls:
def test_session_first_then_user_window_chronological(self):
links = select_calls(
[cand("a", 1), cand("b", 3, session="s2"), cand("c", 2)],
session_id="s1",
user_sub="u1",
since=NOW - timedelta(minutes=15),
limit=10,
)
assert [(link.call_id, link.rule, link.position) for link in links] == [
("b", RULE_USER_WINDOW, 0),
("c", RULE_SESSION, 1),
("a", RULE_SESSION, 2),
]
def test_session_matches_take_priority_over_newer_user_calls(self):
links = select_calls(
[cand("old", 10), cand("new", 1, session="s2")],
session_id="s1",
user_sub="u1",
since=NOW - timedelta(minutes=15),
limit=1,
)
assert [(link.call_id, link.rule) for link in links] == [("old", RULE_SESSION)]
def test_user_window_excludes_old_calls_and_other_users(self):
links = select_calls(
[
cand("recent", 5, session="s2"),
cand("stale", 30, session="s2"),
cand("other", 1, session="s3", user="u2"),
],
session_id="s9",
user_sub="u1",
since=NOW - timedelta(minutes=15),
limit=10,
)
assert [link.call_id for link in links] == ["recent"]
def test_limit_keeps_the_most_recent(self):
links = select_calls(
[cand(str(i), i) for i in range(10)],
session_id="s1",
user_sub=None,
since=NOW,
limit=3,
)
assert [link.call_id for link in links] == ["2", "1", "0"]
def test_duplicates_counted_once_and_naive_times_treated_as_utc(self):
naive = Candidate(
"a",
"t",
(NOW - timedelta(minutes=1)).replace(tzinfo=None),
True,
"s1",
"u1",
)
links = select_calls(
[naive, cand("a", 1)], session_id="s1", user_sub=None, since=NOW, limit=5
)
assert len(links) == 1 and links[0].started_at.tzinfo is not None
def test_no_session_or_user_means_no_links(self):
assert (
select_calls(
[cand("a", 1)], session_id=None, user_sub=None, since=NOW, limit=5
)
== []
)
def build(sinks=None, user="u1", **kwargs):
app = FastMCP("correlated")
@app.tool
def echo(text: str) -> str:
return text
@app.tool
def render(scene: str) -> str:
raise RuntimeError(f"render of {scene} hung")
captured = {}
@app.tool
async def report(ctx: Context) -> list:
links = await mw.capture_recent_calls(ctx)
captured["links"] = links
return [link.call_id for link in links]
identity = {"user": user}
mw = instrument(
app,
sinks if sinks is not None else [MemorySink()],
identity_resolver=lambda c: {"user_sub": identity["user"]},
**kwargs,
)
return app, mw, captured, identity
class TestCaptureRecentCalls:
async def test_same_session_calls_including_failures(self):
app, mw, captured, _ = build()
async with Client(app) as client:
await client.call_tool("echo", {"text": "a"})
with contextlib.suppress(ToolError):
await client.call_tool("render", {"scene": "castle"})
await client.call_tool("report", {})
links = captured["links"]
assert [(link.tool, link.ok, link.rule) for link in links] == [
("echo", True, SAME_CLIENT_RULE),
("render", False, SAME_CLIENT_RULE),
]
async def test_new_session_per_call_falls_back_to_user_window(self):
"""PAT/CI clients open a new MCP session for every call."""
app, mw, captured, _ = build()
for text in ("one", "two"):
async with Client(app) as client:
await client.call_tool("echo", {"text": text})
async with Client(app) as client:
await client.call_tool("report", {})
assert [link.rule for link in captured["links"]] == [RULE_USER_WINDOW] * 2
async def test_other_users_calls_are_not_linked(self):
app, mw, captured, identity = build()
identity["user"] = "someone-else"
async with Client(app) as client:
await client.call_tool("echo", {"text": "not mine"})
identity["user"] = "u1"
async with Client(app) as client:
await client.call_tool("report", {})
assert captured["links"] == []
async def test_calls_still_queued_are_found(self):
release = asyncio.Event()
class Stalled:
async def write(self, records):
await release.wait()
app, mw, captured, _ = build(sinks=[Stalled()])
async with Client(app) as client:
await client.call_tool("echo", {"text": "queued"})
await client.call_tool("report", {})
release.set()
assert [link.tool for link in captured["links"]] == ["echo"]
async def test_database_fallback_after_restart(self, db):
app1, mw1, _, _ = build(sinks=[db("restart")])
async with Client(app1) as client:
await client.call_tool("echo", {"text": "before restart"})
await mw1.aclose()
# A new process: empty in-memory buffer, same database.
app2, mw2, captured, _ = build(sinks=[db("restart")])
async with Client(app2) as client:
await client.call_tool("report", {})
await mw2.aclose()
[link] = captured["links"]
assert link.tool == "echo" and link.rule == RULE_USER_WINDOW
@pytest.mark.skipif(
FASTMCP_4, reason="FastMCP 4 Client is sessionless (MCP 2026-07-28)"
)
async def test_failing_resolver_still_links_by_session(self):
app, mw, captured, _ = build()
mw.identity_resolver = lambda c: 1 / 0
async with Client(app) as client:
await client.call_tool("echo", {"text": "a"})
await client.call_tool("report", {})
assert [link.rule for link in captured["links"]] == [RULE_SESSION]
async def test_slow_database_is_skipped(self, db):
class SlowDb(DatabaseSink):
async def recent_calls(self, **kwargs):
await asyncio.sleep(5)
return []
sink = db("slow", SlowDb)
app, mw, captured, _ = build(sinks=[sink], correlation_timeout=0.05)
async with Client(app) as client:
await client.call_tool("echo", {"text": "a"})
await client.call_tool("report", {})
await mw.aclose()
# The lookup timed out, so capture returned [] rather than hanging.
assert captured["links"] == []
async def test_outside_a_tool_call_returns_empty(self):
_, mw, _, _ = build()
mw.identity_resolver = None
assert await mw.capture_recent_calls() == []
class TestLinksAndContext:
async def test_link_and_read_back_with_external_ids(self, db):
sink = db("links")
app, mw, captured, _ = build(sinks=[sink])
async with Client(app) as client:
await client.call_tool("echo", {"text": "a"})
with contextlib.suppress(ToolError):
await client.call_tool("render", {"scene": "castle"})
await client.call_tool("report", {})
assert await mw.link_feedback("bug-7Q2X", captured["links"])
await mw.flush()
context = await mw.feedback_context("bug-7Q2X")
await mw.aclose()
assert [(c["position"], c["tool"], c["ok"], c["rule"]) for c in context] == [
(0, "echo", True, SAME_CLIENT_RULE),
(1, "render", False, SAME_CLIENT_RULE),
]
assert context[1]["error_type"] == "RuntimeError"
async def test_relinking_replaces_links(self, db):
sink = db("relink")
app, mw, captured, _ = build(sinks=[sink])
async with Client(app) as client:
await client.call_tool("echo", {"text": "a"})
await client.call_tool("report", {})
links = captured["links"]
await mw.link_feedback("fb-1", links)
await mw.link_feedback("fb-1", links)
assert len(await mw.feedback_context("fb-1")) == 1
await mw.aclose()
async def test_context_fills_unflushed_calls_from_memory(self, db):
release = asyncio.Event()
class StalledDb(DatabaseSink):
async def write(self, records):
await release.wait()
sink = db("unflushed", StalledDb)
app, mw, captured, _ = build(sinks=[sink])
async with Client(app) as client:
await client.call_tool("echo", {"text": "a"})
await client.call_tool("report", {})
await mw.link_feedback("fb-2", captured["links"])
[row] = await mw.feedback_context("fb-2")
release.set()
await mw.aclose()
assert row["tool"] == "echo" and row["ok"] is True
async def test_link_without_database_sink_is_false(self):
_, mw, _, _ = build()
assert await mw.link_feedback("x", []) is False
assert await mw.feedback_context("x") == []
class TestSubmitFeedbackAutoLinks:
async def test_submission_links_preceding_calls(self, db):
app = FastMCP("app")
@app.tool
def render(scene: str) -> str:
raise RuntimeError("hung")
sink = db("submit")
mw = instrument(app, [sink], identity_resolver=lambda c: {"user_sub": "u1"})
add_feedback_tools(app, database_url="sqlite:///:memory:", instrumentation=mw)
async with Client(app) as client:
with contextlib.suppress(ToolError):
await client.call_tool("render", {"scene": "castle"})
result = await client.call_tool(
"submit_feedback",
{
"request": {
"type": "bug",
"title": "render hangs",
"description": "the render tool hung",
"submitter": "model",
}
},
)
assert result.data["linked_calls"] == 1
await mw.flush()
[row] = await mw.feedback_context(result.data["feedback_id"])
await mw.aclose()
assert (row["tool"], row["ok"], row["error_type"]) == (
"render",
False,
"RuntimeError",
)
async def test_without_instrumentation_response_unchanged(self):
app = FastMCP("plain")
add_feedback_tools(app, database_url="sqlite:///:memory:")
async with Client(app) as client:
result = await client.call_tool(
"submit_feedback",
{
"request": {
"type": "bug",
"title": "t",
"description": "no instrumentation",
"submitter": "m",
}
},
)
assert "linked_calls" not in result.data
class FakeContext:
def __init__(self, transport=None, session_id="ctx-session"):
self.transport = transport
self.session_id = session_id
class TestSessionKey:
def test_http_session_header_wins(self, monkeypatch):
import fastmcp.server.dependencies as deps
monkeypatch.setattr(
deps,
"get_http_headers",
lambda include_all=False: {"mcp-session-id": "hdr-1"},
)
assert (
mw_module.session_key(FakeContext(transport="streamable-http")) == "hdr-1"
)
def test_stdio_uses_one_key_per_process(self):
a = mw_module.session_key(FakeContext(transport="stdio", session_id="x"))
b = mw_module.session_key(FakeContext(transport="stdio", session_id="y"))
assert a == b and a.startswith("stdio:")
def test_falls_back_to_ctx_session_id(self):
assert mw_module.session_key(FakeContext()) == "ctx-session"
def test_no_context(self):
assert mw_module.session_key(None) is None
class TestStdioEndToEnd:
async def test_same_stdio_client_links_by_session(self):
"""On a real stdio transport, one client's calls link by session on every FastMCP version."""
server = (
Path(__file__).parent.parent / "fixtures" / "stdio_correlation_server.py"
)
transport = PythonStdioTransport(str(server), python_cmd=sys.executable)
async with Client(transport) as client:
await client.call_tool("echo", {"text": "a"})
await client.call_tool("echo", {"text": "b"})
result = await client.call_tool("report", {})
pairs = json.loads(result.content[0].text)
assert pairs == [["echo", RULE_SESSION], ["echo", RULE_SESSION]]
+484
View File
@@ -0,0 +1,484 @@
"""Tests for per-call instrumentation: redaction, middleware, dispatch, sinks.
These run against real FastMCP servers through fastmcp.Client, so they
exercise the same middleware path on every supported FastMCP version.
"""
import asyncio
import io
import json
import time
import pytest
from fastmcp import Client, FastMCP
from fastmcp.exceptions import ToolError
from sqlalchemy import MetaData, inspect, select
from sqlalchemy.ext.asyncio import create_async_engine
from fastmcp_feedback import add_feedback_tools
from fastmcp_feedback.instrumentation import (
REDACTED,
CallbackSink,
DatabaseSink,
InstrumentationMiddleware,
JsonLinesSink,
MemorySink,
RecordDispatcher,
Redactor,
ToolCallRecord,
build_metadata,
instrument,
)
BMCP_TOKEN = "bmcp_" + "A1b2C3d4E5f6G7h8I9j0"
JWT = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTYifQ.c2lnbmF0dXJlLXZhbHVl"
def make_app(sinks=None, **kwargs):
sink = MemorySink()
app = FastMCP("instrumented")
@app.tool
def echo(text: str) -> dict:
return {"echo": text}
@app.tool
def create_token(label: str) -> dict:
return {"label": label, "access_token": BMCP_TOKEN, "note": f"use {BMCP_TOKEN}"}
@app.tool
def login(user: str, password: str) -> str:
return f"welcome {user}"
@app.tool
def fail(secret: str) -> str:
raise ValueError(f"bad input secret={secret}")
mw = instrument(app, sinks if sinks is not None else [sink], **kwargs)
return app, sink, mw
async def call(app, tool, args=None):
async with Client(app) as client:
return await client.call_tool(tool, args or {})
class TestRedactor:
def test_secret_keys_at_any_depth(self):
data = {"a": {"b": [{"password": "hunter2", "keep": 1}]}, "Authorization": "x"}
out = Redactor().redact(data)
assert out["a"]["b"][0] == {"password": REDACTED, "keep": 1}
assert out["Authorization"] == REDACTED
def test_suffix_and_dash_variants(self):
out = Redactor().redact(
{
"github_token": "t",
"DB-Password": "p",
"X-Api-Key": "k",
"stripe_api_key": "s",
}
)
assert set(out.values()) == {REDACTED}
def test_non_secret_keys_untouched(self):
data = {"max_tokens": 100, "token_count": 3, "tool": "echo", "tokens": [1, 2]}
assert Redactor().redact(data) == data
def test_secret_values_inside_text(self):
text = f"pat {BMCP_TOKEN}, jwt {JWT}, header Bearer abcdefghijklmnop, key sk-{'x' * 20}"
out = Redactor().redact_text(text)
for secret in (BMCP_TOKEN, JWT, "abcdefghijklmnop", "sk-"):
assert secret not in out
assert out.count(REDACTED) == 4
def test_key_value_text_keeps_the_key(self):
out = Redactor().redact_text("failed: password=hunter2 api_key: abc123, ok")
assert out == f"failed: password={REDACTED} api_key: {REDACTED}, ok"
def test_input_not_mutated(self):
data = {"password": "hunter2", "nested": {"token": "t"}}
Redactor().redact(data)
assert data == {"password": "hunter2", "nested": {"token": "t"}}
def test_none_secret_value_left_as_none(self):
assert Redactor().redact({"password": None}) == {"password": None}
def test_extra_keys_and_patterns(self):
r = Redactor(extra_keys=["license"], extra_patterns=[r"ACME-\d{6}"])
assert r.redact({"license": "L", "msg": "code ACME-123456"}) == {
"license": REDACTED,
"msg": f"code {REDACTED}",
}
def test_depth_limit(self):
deep = current = {}
for _ in range(50):
current["n"] = {}
current = current["n"]
assert "[TRUNCATED]" in json.dumps(Redactor(max_depth=5).redact(deep))
class TestMiddlewareRecording:
async def test_success_in_meta_mode(self):
app, sink, mw = make_app()
await call(app, "echo", {"text": "hello"})
await mw.flush()
[rec] = sink.records
assert rec.tool == "echo" and rec.ok and rec.mode == "meta"
assert rec.duration_ms > 0
assert rec.args_size == len(json.dumps({"text": "hello"}))
assert rec.result_size and rec.result_size > 0
assert rec.args is None and rec.result is None
assert rec.session_id and rec.request_id
async def test_full_mode_redacts_args_and_results(self):
app, sink, mw = make_app(mode="full")
await call(app, "create_token", {"label": "ci"})
await call(app, "login", {"user": "ryan", "password": "hunter2"})
await mw.flush()
token_rec, login_rec = sink.records
dumped = json.dumps([token_rec.to_dict(), login_rec.to_dict()])
assert BMCP_TOKEN not in dumped and "hunter2" not in dumped
assert token_rec.result["access_token"] == REDACTED
assert token_rec.result["note"] == f"use {REDACTED}"
assert token_rec.result["label"] == "ci"
assert login_rec.args == {"user": "ryan", "password": REDACTED}
async def test_failed_call_recorded_and_error_unchanged(self):
app, sink, mw = make_app()
with pytest.raises(ToolError, match="bad input"):
await call(app, "fail", {"secret": "hunter2"})
await mw.flush()
[rec] = sink.records
assert not rec.ok
assert rec.error_type == "ValueError"
assert "hunter2" not in rec.error_message
assert f"secret={REDACTED}" in rec.error_message
async def test_meta_only_tools_override_full_mode(self):
app, sink, mw = make_app(mode="full", meta_only_tools={"create_token"})
await call(app, "create_token", {"label": "ci"})
await call(app, "echo", {"text": "hi"})
await mw.flush()
token_rec, echo_rec = sink.records
assert token_rec.mode == "meta" and token_rec.result is None
assert token_rec.result_size > 0
assert echo_rec.mode == "full" and echo_rec.result == {"echo": "hi"}
async def test_off_mode_records_nothing(self):
app, sink, mw = make_app(mode="off")
await call(app, "echo", {"text": "hi"})
await mw.flush()
assert sink.records == []
async def test_mode_from_environment(self, monkeypatch):
monkeypatch.setenv("FEEDBACK_INSTRUMENTATION_MODE", "full")
assert InstrumentationMiddleware([]).mode == "full"
def test_invalid_mode_rejected(self):
with pytest.raises(ValueError, match="mode must be one of"):
InstrumentationMiddleware([], mode="everything")
class TestHooks:
async def test_identity_resolver_fills_columns(self):
def who(context):
assert context.fastmcp_context is not None
return {
"user_sub": "user-1",
"caller_kind": "pat:7",
"client_id": "blender-addon",
"org": "acme",
"token": BMCP_TOKEN,
}
app, sink, mw = make_app(identity_resolver=who)
await call(app, "echo", {"text": "hi"})
await mw.flush()
[rec] = sink.records
assert (rec.user_sub, rec.caller_kind, rec.client_id) == (
"user-1",
"pat:7",
"blender-addon",
)
assert rec.identity == {"org": "acme", "token": REDACTED}
async def test_async_enricher_gets_result_and_sets_extra(self):
seen = {}
async def enrich(tool, args, result, context):
seen.update(tool=tool, args=args, result=result)
return {"job_id": "job-42", "server_version": "2026.9.27", "api_key": "k"}
app, sink, mw = make_app(enricher=enrich, server_version="default")
await call(app, "echo", {"text": "hi"})
await mw.flush()
[rec] = sink.records
assert seen == {
"tool": "echo",
"args": {"text": "hi"},
"result": {"echo": "hi"},
}
assert rec.extra == {"job_id": "job-42", "api_key": REDACTED}
assert rec.server_version == "2026.9.27"
async def test_server_version_default(self):
app, sink, mw = make_app(server_version="1.0")
await call(app, "echo", {"text": "hi"})
await mw.flush()
assert sink.records[0].server_version == "1.0"
async def test_hook_exceptions_do_not_break_calls(self):
def boom(*args):
raise RuntimeError("hook bug")
app, sink, mw = make_app(identity_resolver=boom, enricher=boom)
result = await call(app, "echo", {"text": "hi"})
await mw.flush()
assert result.data == {"echo": "hi"}
[rec] = sink.records
assert rec.ok and rec.identity == {} and rec.extra == {}
async def test_slow_async_hook_is_cut_off(self):
async def slow(*args):
await asyncio.sleep(5)
return {"never": True}
app, sink, mw = make_app(enricher=slow, hook_timeout=0.05)
start = time.perf_counter()
await call(app, "echo", {"text": "hi"})
assert time.perf_counter() - start < 2
await mw.flush()
assert sink.records[0].extra == {}
class TestNeverBlocksOrBreaks:
async def test_failing_sink_does_not_break_calls_or_other_sinks(self):
class Broken:
async def write(self, records):
raise ConnectionError("database down")
good = MemorySink()
app, _, mw = make_app(sinks=[Broken(), good])
result = await call(app, "echo", {"text": "hi"})
await mw.flush()
assert result.data == {"echo": "hi"}
assert mw.dispatcher.sink_errors == 1
assert len(good.records) == 1
async def test_stalled_sink_drops_records_instead_of_waiting(self):
release = asyncio.Event()
class Stalled:
def __init__(self):
self.records = []
async def write(self, records):
await release.wait()
self.records.extend(records)
stalled = Stalled()
app, _, mw = make_app(sinks=[stalled], max_queue=2, batch_size=1)
start = time.perf_counter()
async with Client(app) as client:
for i in range(6):
await client.call_tool("echo", {"text": str(i)})
assert time.perf_counter() - start < 3
# One record is in the stalled write, two fit the queue, the rest drop.
assert mw.dispatcher.dropped == 3
release.set()
await mw.flush()
assert len(stalled.records) == 3
async def test_record_building_failure_does_not_break_calls(self, monkeypatch):
app, sink, mw = make_app()
async def explode(*args, **kwargs):
raise RuntimeError("bug in record building")
monkeypatch.setattr(mw, "_build_record", explode)
result = await call(app, "echo", {"text": "hi"})
assert result.data == {"echo": "hi"}
async def test_tool_error_passes_through_even_if_recording_fails(self, monkeypatch):
app, sink, mw = make_app()
async def explode(*args, **kwargs):
raise RuntimeError("bug in record building")
monkeypatch.setattr(mw, "_build_record", explode)
with pytest.raises(ToolError, match="bad input"):
await call(app, "fail", {"secret": "x"})
def test_submit_without_event_loop_drops(self):
dispatcher = RecordDispatcher([MemorySink()])
record = ToolCallRecord(
tool="t",
started_at=__import__("datetime").datetime.now(),
duration_ms=1,
ok=True,
mode="meta",
)
assert dispatcher.submit(record) is False
assert dispatcher.dropped == 1
class TestSinks:
async def test_json_lines_sink(self):
stream = io.StringIO()
app, _, mw = make_app(sinks=[JsonLinesSink(stream)])
await call(app, "echo", {"text": "hi"})
await call(app, "echo", {"text": "again"})
await mw.flush()
lines = [json.loads(line) for line in stream.getvalue().splitlines()]
assert [line["tool"] for line in lines] == ["echo", "echo"]
assert lines[0]["started_at"].endswith("+00:00")
async def test_callback_sink_sync_and_async(self):
got_sync, got_async = [], []
async def async_cb(records):
got_async.extend(records)
app, _, mw = make_app(
sinks=[CallbackSink(got_sync.extend), CallbackSink(async_cb)]
)
await call(app, "echo", {"text": "hi"})
await mw.flush()
assert len(got_sync) == len(got_async) == 1
async def test_aclose_closes_sinks(self):
closed = []
class Closable(MemorySink):
async def aclose(self):
closed.append(True)
app, _, mw = make_app(sinks=[Closable()])
await call(app, "echo", {"text": "hi"})
await mw.aclose()
assert closed == [True]
class TestDatabaseSink:
async def test_end_to_end_with_created_tables(self, tmp_path):
url = f"sqlite+aiosqlite:///{tmp_path}/calls.db"
db = DatabaseSink(url, create_tables=True)
app, _, mw = make_app(sinks=[db], mode="full", server_version="9.9")
await call(app, "create_token", {"label": "ci"})
with pytest.raises(ToolError):
await call(app, "fail", {"secret": "hunter2"})
await mw.flush()
async with db.engine.connect() as conn:
rows = (
(await conn.execute(select(db.table).order_by(db.table.c.started_at)))
.mappings()
.all()
)
await mw.aclose()
assert [r["tool"] for r in rows] == ["create_token", "fail"]
assert rows[0]["ok"] is True and rows[1]["ok"] is False
assert rows[0]["result"]["access_token"] == REDACTED
assert rows[1]["error_type"] == "ValueError"
assert rows[0]["server_version"] == "9.9"
assert "hunter2" not in json.dumps([dict(r) for r in rows], default=str)
async def test_prefix_and_host_owned_engine(self, tmp_path):
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/host.db")
db = DatabaseSink(engine, prefix="qa_", create_tables=True)
app, _, mw = make_app(sinks=[db])
await call(app, "echo", {"text": "hi"})
await mw.aclose()
# The host's engine is still usable: the sink did not dispose it.
async with engine.connect() as conn:
tables = await conn.run_sync(lambda c: inspect(c).get_table_names())
await engine.dispose()
assert sorted(tables) == ["qa_feedback_call_links", "qa_tool_calls"]
async def test_no_tables_created_by_default(self, tmp_path):
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/host.db")
app, _, mw = make_app(sinks=[DatabaseSink(engine)])
result = await call(app, "echo", {"text": "hi"})
await mw.flush()
async with engine.connect() as conn:
tables = await conn.run_sync(lambda c: inspect(c).get_table_names())
await engine.dispose()
# The insert fails (no table), is counted, and the call still worked.
assert result.data == {"echo": "hi"}
assert tables == []
assert mw.dispatcher.sink_errors == 1
async def test_absent_payloads_are_sql_null(self, tmp_path):
"""Meta mode stores no payloads; they must be SQL NULL, not JSON null."""
db = DatabaseSink(f"sqlite+aiosqlite:///{tmp_path}/n.db", create_tables=True)
app, _, mw = make_app(sinks=[db])
await call(app, "echo", {"text": "hi"})
await mw.flush()
t = db.table
async with db.engine.connect() as conn:
nulls = (
await conn.execute(select(t.c.args.is_(None), t.c.result.is_(None)))
).one()
await mw.aclose()
assert tuple(nulls) == (True, True)
def test_build_metadata_joins_host_metadata(self):
host = MetaData()
assert build_metadata("ffb_", host) is host
build_metadata("ffb_", host)
assert sorted(host.tables) == ["ffb_feedback_call_links", "ffb_tool_calls"]
class TestIntegration:
async def test_records_mounted_server_tools_by_prefixed_name(self):
main = FastMCP("main")
sub = FastMCP("sub")
@sub.tool
def ping() -> str:
return "pong"
main.mount(sub, "svc")
sink = MemorySink()
mw = instrument(main, [sink])
await call(main, "svc_ping")
await mw.flush()
assert [r.tool for r in sink.records] == ["svc_ping"]
async def test_records_feedback_tools(self):
app = FastMCP("with-feedback")
add_feedback_tools(app, database_url="sqlite:///:memory:")
sink = MemorySink()
mw = instrument(app, [sink])
await call(
app,
"submit_feedback",
{
"request": {
"type": "bug",
"title": "t",
"description": "instrumented",
"submitter": "me",
}
},
)
await mw.flush()
assert [r.tool for r in sink.records] == ["submit_feedback"]
+397
View File
@@ -0,0 +1,397 @@
"""Tests for classifying error results that tools return instead of raising.
Real FastMCP servers through fastmcp.Client, as in test_instrumentation.py.
"""
import contextlib
import logging
import pytest
from fastmcp import Client, FastMCP
from fastmcp.exceptions import ToolError
from mcp.types import CallToolResult, TextContent
from sqlalchemy import select
from fastmcp_feedback import add_feedback_tools
from fastmcp_feedback.instrumentation import (
DEFAULT_RESULT_CLASSIFIER,
REDACTED,
DatabaseSink,
MemorySink,
ToolCallRecord,
instrument,
soft_error_classifier,
)
from fastmcp_feedback.mixins import submission
BMCP_TOKEN = "bmcp_" + "A1b2C3d4E5f6G7h8I9j0"
try:
from fastmcp.tools.tool import ToolResult
except ImportError: # FastMCP 4
from fastmcp.tools import ToolResult
try:
ToolResult(content=[TextContent(type="text", text="x")], is_error=True)
TOOL_RESULT_IS_ERROR = True
except TypeError: # added in FastMCP 3.4
TOOL_RESULT_IS_ERROR = False
@pytest.fixture
async def db(tmp_path):
"""DatabaseSinks whose engines are always disposed, or the process hangs."""
made = []
def factory(name="calls"):
sink = DatabaseSink(
f"sqlite+aiosqlite:///{tmp_path}/{name}.db", create_tables=True
)
made.append(sink)
return sink
yield factory
for sink in made:
await sink.engine.dispose()
def make_app(sinks=None, **kwargs):
sink = MemorySink()
app = FastMCP("soft")
@app.tool
def render(status: str) -> dict:
return {"status": status, "error": f"render said {status}"}
@app.tool
def respond(payload: dict) -> dict:
return payload
@app.tool
def text(value: str) -> str:
return value
@app.tool
def items() -> list:
return [{"status": "failed"}]
@app.tool
def crash() -> dict:
raise RuntimeError("exploded")
@app.tool
def mcp_error() -> CallToolResult:
return CallToolResult(
content=[TextContent(type="text", text="target missing")], isError=True
)
mw = instrument(app, sinks if sinks is not None else [sink], **kwargs)
return app, sink, mw
async def run(app, mw, tool, args=None):
async with Client(app) as client:
with contextlib.suppress(ToolError):
await client.call_tool(tool, args or {})
await mw.flush()
async def record_for(tool, args=None, **kwargs) -> ToolCallRecord:
app, sink, mw = make_app(**kwargs)
await run(app, mw, tool, args)
[record] = sink.records
return record
class TestClassifier:
@pytest.mark.parametrize(
"result, expected",
[
({"status": "failed", "error": "disk full"}, "status=failed: disk full"),
({"state": "ERROR"}, "state=ERROR"),
({"ok": False, "message": "no scene"}, "ok=False: no scene"),
({"success": False, "error": "nope"}, "success=False: nope"),
({"success": False, "detail": "d", "reason": "r"}, "success=False: d"),
(
{"status": "failure", "error": {"code": 7, "message": "inner"}},
"status=failure: inner",
),
({"isError": True, "content": ["boom"]}, "isError: boom"),
(
{"is_error": True, "content": [{"type": "text", "text": "a"}]},
"isError: a",
),
],
)
def test_flagged(self, result, expected):
assert DEFAULT_RESULT_CLASSIFIER("t", result) == expected
@pytest.mark.parametrize(
"result",
[
{"status": "done"},
{"status": "unknown_target"},
{"ok": True},
{"ok": 0},
{"success": None},
{"result": False},
{"state": 3},
{"isError": False},
[{"status": "failed"}],
"failed",
None,
],
)
def test_not_flagged(self, result):
assert DEFAULT_RESULT_CLASSIFIER("t", result) is None
def test_extra_statuses(self):
classify = soft_error_classifier(extra_statuses=("unknown_target",))
assert (
classify("t", {"status": "unknown_target", "error": "no cube"})
== "status=unknown_target: no cube"
)
assert classify("t", {"status": "failed"}) == "status=failed"
def test_custom_keys_replace_defaults(self):
classify = soft_error_classifier(
statuses=("bad",), status_keys=("phase",), flag_keys=("passed",)
)
assert classify("t", {"phase": "bad"}) == "phase=bad"
assert classify("t", {"passed": False}) == "passed=False"
assert classify("t", {"status": "failed", "ok": False}) is None
class TestMiddleware:
async def test_status_failed_is_soft_error(self):
record = await record_for("render", {"status": "failed"})
assert (record.outcome, record.ok, record.error_type) == (
"soft_error",
False,
"SoftError",
)
assert record.error_message == "status=failed: render said failed"
@pytest.mark.parametrize("key", ["ok", "success"])
async def test_false_flags_are_soft_errors(self, key):
record = await record_for("respond", {"payload": {key: False, "error": "x"}})
assert (record.outcome, record.ok) == ("soft_error", False)
assert record.error_message == f"{key}=False: x"
async def test_message_is_redacted_and_truncated(self):
record = await record_for(
"respond",
{"payload": {"status": "error", "error": f"bad key {BMCP_TOKEN} " * 50}},
max_error_chars=60,
)
assert BMCP_TOKEN not in record.error_message
assert REDACTED in record.error_message
assert len(record.error_message) == 60
async def test_extra_statuses(self):
record = await record_for(
"render",
{"status": "unknown_target"},
result_classifier=soft_error_classifier(extra_statuses=("unknown_target",)),
)
assert record.outcome == "soft_error"
assert (
record.error_message == "status=unknown_target: render said unknown_target"
)
async def test_unknown_status_is_ok_by_default(self):
record = await record_for("render", {"status": "unknown_target"})
assert (record.outcome, record.ok, record.error_type) == ("ok", True, None)
@pytest.mark.parametrize(
"tool, args",
[
("text", {"value": "failed"}),
("items", {}),
("respond", {"payload": {"status": "done", "count": 3}}),
],
)
async def test_normal_results_stay_ok(self, tool, args):
record = await record_for(tool, args)
assert (record.outcome, record.ok, record.error_message) == ("ok", True, None)
async def test_none_disables(self):
record = await record_for(
"render", {"status": "failed"}, result_classifier=None
)
assert (record.outcome, record.ok, record.error_type) == ("ok", True, None)
async def test_custom_classifier(self):
seen = []
def classify(tool, result):
seen.append((tool, result))
return "empty" if result.get("count") == 0 else None
record = await record_for(
"respond", {"payload": {"count": 0}}, result_classifier=classify
)
assert seen == [("respond", {"count": 0})]
assert (record.outcome, record.error_message) == ("soft_error", "empty")
async def test_raising_classifier_records_ok(self, caplog):
def classify(tool, result):
raise KeyError("classifier bug")
app, sink, mw = make_app(result_classifier=classify)
with caplog.at_level(logging.WARNING):
async with Client(app) as client:
result = await client.call_tool("render", {"status": "failed"})
await mw.flush()
assert result.data["status"] == "failed"
[record] = sink.records
assert (record.outcome, record.ok, record.error_type) == ("ok", True, None)
assert "Result classifier" in caplog.text
async def test_classifier_not_run_on_exceptions(self):
calls = []
record = await record_for(
"crash", result_classifier=lambda t, r: calls.append(t) or "x"
)
assert calls == []
assert (record.outcome, record.ok, record.error_type) == (
"error",
False,
"RuntimeError",
)
assert record.error_message == "exploded"
async def test_mcp_error_result_without_raising(self):
"""A CallToolResult with isError, on every FastMCP version."""
record = await record_for("mcp_error")
assert (record.outcome, record.ok) == ("soft_error", False)
assert record.error_message == "isError: target missing"
@pytest.mark.skipif(not TOOL_RESULT_IS_ERROR, reason="ToolResult.is_error is 3.4+")
async def test_tool_result_is_error(self):
app = FastMCP("is_error")
@app.tool
def lookup():
return ToolResult(
content=[TextContent(type="text", text="not found")], is_error=True
)
sink = MemorySink()
mw = instrument(app, [sink])
async with Client(app) as client:
await client.call_tool("lookup", {}, raise_on_error=False)
await mw.flush()
[record] = sink.records
assert (record.outcome, record.error_message) == (
"soft_error",
"isError: not found",
)
def test_record_outcome_defaults_from_ok(self):
from datetime import UTC, datetime
now = datetime.now(UTC)
assert ToolCallRecord("t", now, 1.0, True, "meta").outcome == "ok"
assert ToolCallRecord("t", now, 1.0, False, "meta").outcome == "error"
class TestStorageAndCorrelation:
async def test_database_sink_writes_outcome(self, db):
sink = db()
app, _, mw = make_app(sinks=[sink])
async with Client(app) as client:
await client.call_tool("render", {"status": "done"})
await client.call_tool("render", {"status": "failed"})
with contextlib.suppress(ToolError):
await client.call_tool("crash", {})
await mw.flush()
t = sink.table
async with sink.engine.connect() as conn:
rows = (
await conn.execute(
select(t.c.outcome, t.c.ok, t.c.error_type).order_by(t.c.started_at)
)
).all()
assert [tuple(r) for r in rows] == [
("ok", True, None),
("soft_error", False, "SoftError"),
("error", False, "RuntimeError"),
]
async with sink.engine.connect() as conn:
[(failing,)] = (
await conn.execute(
select(t.c.tool).where(~t.c.ok, t.c.outcome != "error")
)
).all()
assert failing == "render"
async def test_links_and_feedback_context_show_soft_errors(self, db):
app, _, mw = make_app(
sinks=[db()], identity_resolver=lambda c: {"user_sub": "u1"}
)
captured = {}
@app.tool
async def report() -> int:
captured["links"] = await mw.capture_recent_calls()
await mw.link_feedback("bug-1", captured["links"])
return len(captured["links"])
async with Client(app) as client:
await client.call_tool("render", {"status": "failed"})
await client.call_tool("report", {})
links = captured["links"]
assert [(link.tool, link.ok) for link in links] == [("render", False)]
# Filled from memory or read from the database, depending on timing.
[row] = await mw.feedback_context("bug-1")
assert (row["outcome"], row["ok"], row["error_type"]) == (
"soft_error",
False,
"SoftError",
)
await mw.flush()
[row] = await mw.feedback_context("bug-1")
assert (row["tool"], row["outcome"], row["ok"]) == (
"render",
"soft_error",
False,
)
await mw.aclose()
async def test_submit_feedback_failure_is_soft_error(self, monkeypatch):
def broken(request):
raise RuntimeError("database down, password=hunter2")
monkeypatch.setattr(submission, "create_feedback_from_request", broken)
app = FastMCP("fb")
sink = MemorySink()
mw = instrument(app, [sink])
add_feedback_tools(app, database_url="sqlite:///:memory:", instrumentation=mw)
async with Client(app) as client:
result = await client.call_tool(
"submit_feedback",
{
"request": {
"type": "bug",
"title": "t",
"description": "d",
"submitter": "m",
}
},
)
await mw.flush()
assert result.data["success"] is False
[record] = sink.records
assert (record.tool, record.outcome, record.ok, record.error_type) == (
"submit_feedback",
"soft_error",
False,
"SoftError",
)
assert record.error_message == (
f"success=False: database down, password={REDACTED}"
)
Generated
+37 -2
View File
@@ -20,6 +20,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/79/6e45e778c4c3cab39e0937b007b720c15f76c50c6453d153282d0fcc3588/aiofile-3.12.3-py3-none-any.whl", hash = "sha256:5c1bcc9e929c50834608e8cc1a4cc1d7503eb60c15a535b779fd39e2f372c017", size = 22122, upload-time = "2026-08-04T22:59:25.838Z" },
]
[[package]]
name = "aiosqlite"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
@@ -544,7 +553,7 @@ wheels = [
[[package]]
name = "fastmcp-feedback"
version = "2026.9.27"
version = "2026.9.27.4"
source = { editable = "." }
dependencies = [
{ name = "fastmcp" },
@@ -554,6 +563,7 @@ dependencies = [
[package.optional-dependencies]
all = [
{ name = "aiosqlite" },
{ name = "mypy" },
{ name = "psycopg2-binary" },
{ name = "pymysql" },
@@ -563,8 +573,10 @@ all = [
{ name = "pytest-html" },
{ name = "pytest-mock" },
{ name = "ruff" },
{ name = "sqlalchemy", extra = ["asyncio"] },
]
dev = [
{ name = "aiosqlite" },
{ name = "mypy" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
@@ -572,6 +584,11 @@ dev = [
{ name = "pytest-html" },
{ name = "pytest-mock" },
{ name = "ruff" },
{ name = "sqlalchemy", extra = ["asyncio"] },
]
instrumentation = [
{ name = "aiosqlite" },
{ name = "sqlalchemy", extra = ["asyncio"] },
]
mysql = [
{ name = "pymysql" },
@@ -580,15 +597,20 @@ postgresql = [
{ name = "psycopg2-binary" },
]
test = [
{ name = "aiosqlite" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
{ name = "pytest-html" },
{ name = "pytest-mock" },
{ name = "sqlalchemy", extra = ["asyncio"] },
]
[package.metadata]
requires-dist = [
{ name = "aiosqlite", marker = "extra == 'dev'", specifier = ">=0.22.1" },
{ name = "aiosqlite", marker = "extra == 'instrumentation'", specifier = ">=0.22.1" },
{ name = "aiosqlite", marker = "extra == 'test'", specifier = ">=0.22.1" },
{ name = "fastmcp", specifier = ">=2.12.2,<5" },
{ name = "fastmcp-feedback", extras = ["postgresql", "mysql", "dev"], marker = "extra == 'all'" },
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.7.0" },
@@ -607,8 +629,11 @@ requires-dist = [
{ name = "pytest-mock", marker = "extra == 'test'", specifier = ">=3.12.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" },
{ name = "sqlalchemy", specifier = ">=2.0.43" },
{ name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'dev'", specifier = ">=2.0.43" },
{ name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'instrumentation'", specifier = ">=2.0.43" },
{ name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'test'", specifier = ">=2.0.43" },
]
provides-extras = ["postgresql", "mysql", "dev", "test", "all"]
provides-extras = ["postgresql", "instrumentation", "mysql", "dev", "test", "all"]
[[package]]
name = "fastmcp-slim"
@@ -671,6 +696,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/cb/48e964c452ca2b92175a9b2dca037a553036cb053ba69e284650ce755f13/greenlet-3.3.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e29f3018580e8412d6aaf5641bb7745d38c85228dacf51a73bd4e26ddf2a6a8e", size = 274908, upload-time = "2025-12-04T14:23:26.435Z" },
{ url = "https://files.pythonhosted.org/packages/28/da/38d7bff4d0277b594ec557f479d65272a893f1f2a716cad91efeb8680953/greenlet-3.3.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a687205fb22794e838f947e2194c0566d3812966b41c78709554aa883183fb62", size = 577113, upload-time = "2025-12-04T14:50:05.493Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f2/89c5eb0faddc3ff014f1c04467d67dee0d1d334ab81fadbf3744847f8a8a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4243050a88ba61842186cb9e63c7dfa677ec146160b0efd73b855a3d9c7fcf32", size = 590338, upload-time = "2025-12-04T14:57:41.136Z" },
{ url = "https://files.pythonhosted.org/packages/80/d7/db0a5085035d05134f8c089643da2b44cc9b80647c39e93129c5ef170d8f/greenlet-3.3.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:670d0f94cd302d81796e37299bcd04b95d62403883b24225c6b5271466612f45", size = 601098, upload-time = "2025-12-04T15:07:11.898Z" },
{ url = "https://files.pythonhosted.org/packages/dc/a6/e959a127b630a58e23529972dbc868c107f9d583b5a9f878fb858c46bc1a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb3a8ec3db4a3b0eb8a3c25436c2d49e3505821802074969db017b87bc6a948", size = 590206, upload-time = "2025-12-04T14:26:01.254Z" },
{ url = "https://files.pythonhosted.org/packages/48/60/29035719feb91798693023608447283b266b12efc576ed013dd9442364bb/greenlet-3.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2de5a0b09eab81fc6a382791b995b1ccf2b172a9fec934747a7a23d2ff291794", size = 1550668, upload-time = "2025-12-04T15:04:22.439Z" },
{ url = "https://files.pythonhosted.org/packages/0a/5f/783a23754b691bfa86bd72c3033aa107490deac9b2ef190837b860996c9f/greenlet-3.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4449a736606bd30f27f8e1ff4678ee193bc47f6ca810d705981cfffd6ce0d8c5", size = 1615483, upload-time = "2025-12-04T14:27:28.083Z" },
@@ -678,6 +704,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" },
{ url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" },
{ url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" },
{ url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297, upload-time = "2025-12-04T15:07:13.552Z" },
{ url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" },
{ url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" },
{ url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" },
@@ -685,6 +712,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" },
{ url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" },
{ url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" },
{ url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" },
{ url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" },
{ url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" },
{ url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" },
@@ -692,6 +720,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" },
{ url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" },
{ url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" },
{ url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388, upload-time = "2025-12-04T15:07:15.789Z" },
{ url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" },
{ url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" },
{ url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" },
@@ -699,6 +728,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" },
{ url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" },
{ url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" },
{ url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506, upload-time = "2025-12-04T15:07:16.906Z" },
{ url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" },
{ url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" },
{ url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" },
@@ -1936,6 +1966,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/e1/3ccb13c643399d22289c6a9786c1a91e3dcbb68bce4beb44926ac2c557bf/sqlalchemy-2.0.45-py3-none-any.whl", hash = "sha256:5225a288e4c8cc2308dbdd874edad6e7d0fd38eac1e9e5f23503425c8eee20d0", size = 1936672, upload-time = "2025-12-09T21:54:52.608Z" },
]
[package.optional-dependencies]
asyncio = [
{ name = "greenlet" },
]
[[package]]
name = "sse-starlette"
version = "3.1.2"