Compare commits

...
Author SHA1 Message Date
rsp2k 4206e15293 Release 2026.10.04: payload size limit, dotted classifier keys
Tests / test (>=4,<5) (push) Canceled after 0s
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-postgres (==2.12.2) (push) Canceled after 0s
Tests / test-postgres (>=4,<5) (push) Canceled after 0s
Tests / canary (push) Canceled after 0s
2026-10-04 14:43:32 -06:00
rsp2k 8bf0801cf6 Test a preview that ends inside a secret 2026-10-04 14:42:16 -06:00
rsp2k 72d2845502 Cap stored payloads and event attrs; dotted paths in the classifier
max_payload_bytes (default 64 KiB, None for no limit) bounds the arguments
and results stored in mode full and every event's attrs. A larger value is
stored as {"_truncated": true, "size": ..., "preview": ...}, the preview
taken from the redacted value. Call payloads reuse the size already
measured for args_size and result_size; attrs keep their keys, with only
the largest values replaced.

soft_error_classifier takes message_keys, and message_keys and error_keys
accept dotted paths into nested dicts ("snapshot.error"). An exact key is
matched before splitting; a missing or non-dict step is absent.
2026-10-04 14:41:13 -06:00
rsp2k 69a09f4400 Merge fix/narrow-late-identity: second identity run only for an empty answer or a missing late key
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 / test-postgres (==2.12.2) (push) Canceled after 0s
Tests / test-postgres (>=4,<5) (push) Canceled after 0s
Tests / canary (push) Canceled after 0s
2026-10-02 00:11:38 -06:00
rsp2k b2e36f630a Release 2026.10.02.1: second identity run only for an empty answer or a missing late key
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 / test-postgres (==2.12.2) (push) Canceled after 0s
Tests / test-postgres (>=4,<5) (push) Canceled after 0s
Tests / canary (push) Canceled after 0s
2026-10-02 00:09:25 -06:00
rsp2k 6c16fdda03 Rerun the identity resolver only for an empty answer or a missing late key
A missing user_sub, caller_kind or client_id no longer triggers the second
run on its own, so a resolver that never returns one of them (mcspeak's has
no client_id) runs once per call again. The second run happens when the
first answer is empty (nothing, only None values, or a failed run) or lacks
a key in late_identity_keys, which stays empty by default.
2026-10-02 00:08:32 -06:00
rsp2k 2760854db9 Merge fix/late-identity: second identity run for tools that create the caller's identity
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 / test-postgres (==2.12.2) (push) Canceled after 0s
Tests / test-postgres (>=4,<5) (push) Canceled after 0s
Tests / canary (push) Canceled after 0s
2026-10-02 00:01:45 -06:00
rsp2k d0d2ee1b00 Release 2026.10.02: second identity run for tools that create the caller's identity
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 / test-postgres (==2.12.2) (push) Canceled after 0s
Tests / test-postgres (>=4,<5) (push) Canceled after 0s
Tests / canary (push) Canceled after 0s
2026-10-02 00:01:16 -06:00
rsp2k 6987d947d3 Run the identity resolver again after the tool when its first answer is incomplete
Since 2026.10.01.4 the resolver runs before the tool, so a tool that creates
the caller's identity (blender-mcp's register_client, which makes the bus
client the resolver looks up) recorded its own row without it. When the
first answer lacks user_sub, caller_kind or client_id, or any of the new
late_identity_keys, the resolver runs once more after the tool, alongside
the enricher, for calls that are kept. Its values fill the gaps; the first
answer wins on conflict, matching what events during the call saw.
2026-10-01 23:57:07 -06:00
rsp2k 74780fef02 Merge fix/consistency: statistics labels, event identity, quiet logging
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 / test-postgres (==2.12.2) (push) Canceled after 0s
Tests / test-postgres (>=4,<5) (push) Canceled after 0s
Tests / canary (push) Canceled after 0s
2026-10-01 22:49:12 -06:00
rsp2k 22f2c007e9 Drop the private dev docs hostname from the 2026.01.12 changelog entry
Tests / canary (push) Canceled after 0s
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 / test-postgres (==2.12.2) (push) Canceled after 0s
Tests / test-postgres (>=4,<5) (push) Canceled after 0s
2026-10-01 22:49:12 -06:00
rsp2k 7892a5d79f Release 2026.10.01.4: statistics labels, event identity, quiet logging
README and CONTRIBUTING rewritten where they described the old behavior
or code that does not exist: the example test and mixin recipe now run,
MySQL is marked untested, the two Postgres driver paths are spelled out,
and the coverage figure is the measured 95%.
2026-10-01 22:48:46 -06:00
rsp2k c6c928d528 Test statistics labels on SQLite and Postgres, event identity, quiet import 2026-10-01 22:38:21 -06:00
rsp2k 1213b1587e Statistics keyed by enum value, identity resolved before the tool, no logging config on import
get_feedback_statistics and get_database_stats grouped the raw columns,
which hold enum names (BUG, OPEN) on SQLite and Postgres, while
list_feedback returns values (bug, open). Both now count through
count_feedback_by and return values.

The identity resolver ran after the call, so record_event inside a tool
never had user_sub, caller_kind or client_id. It now runs once before
the tool; the call scope carries the result to record_event and the
call's own record reuses it.

Importing the package called logging.basicConfig at INFO. It now adds a
NullHandler to the fastmcp_feedback logger; setup_logging() stays as an
opt-in. PACKAGE_INFO is read from the installed metadata.

psycopg2-binary joins the dev and test extras so the feedback tools can
be tested on Postgres.
2026-10-01 22:36:30 -06:00
rsp2k 433a003d09 Point the docs links at the public site
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 / test-postgres (==2.12.2) (push) Canceled after 0s
Tests / test-postgres (>=4,<5) (push) Canceled after 0s
Tests / canary (push) Canceled after 0s
fastmcp-feedback.supported.systems replaces the private dev address in the
README and the PyPI Documentation URL. Version 2026.10.01.3.
2026-10-01 22:13:34 -06:00
rsp2k e4da62d0df Merge fix/lazy-storage-import: instrumentation imports without the database extras
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 / test-postgres (==2.12.2) (push) Canceled after 0s
Tests / test-postgres (>=4,<5) (push) Canceled after 0s
Tests / canary (push) Canceled after 0s
2026-10-01 20:56:05 -06:00
rsp2k 5e9a9ca037 Release 2026.10.01.2: instrumentation imports without the database extras
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 / test-postgres (==2.12.2) (push) Canceled after 0s
Tests / test-postgres (>=4,<5) (push) Canceled after 0s
Tests / canary (push) Canceled after 0s
2026-10-01 20:56:02 -06:00
rsp2k 76999c942c Model SQLAlchemy 2.0 and 2.1 in the base-install test 2026-10-01 20:53:34 -06:00
rsp2k 4995143f8f Import instrumentation without the database extras
storage, embeddings and backfill need sqlalchemy[asyncio] (greenlet). The
package __init__ now loads their names on first access and raises an
ImportError naming the extra when they are missing. The middleware finds a
DatabaseSink or EmbeddingSink through sys.modules instead of importing them.
Table prefix, embedding dim, sources and event text keys move to constants.py
so they import on a base install.
2026-10-01 20:52:33 -06:00
rsp2k 28816477ff Merge fix/explicit-success-wins: an explicit success flag beats error keys
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 / test-postgres (==2.12.2) (push) Canceled after 0s
Tests / test-postgres (>=4,<5) (push) Canceled after 0s
Tests / canary (push) Canceled after 0s
2026-10-01 18:05:52 -06:00
rsp2k c81dc3bf8c Release 2026.10.01.1: an explicit success flag beats error keys
Tests / test (==2.12.2) (push) Canceled after 0s
Tests / canary (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 / test-postgres (==2.12.2) (push) Canceled after 0s
Tests / test-postgres (>=4,<5) (push) Canceled after 0s
2026-10-01 18:05:50 -06:00
rsp2k 4922eaecbd Let an explicit success flag win over error keys
A result with ok or success exactly True skips the error-key check, so
{"ok": True, "error": "deprecated arg"} is ok and the string reads as a
warning. A failing status, a False flag and isError still flag it, and
truthy non-bools are not an explicit success.
2026-10-01 18:04:15 -06:00
rsp2k 36fed0267c Move the dev Postgres port to 55447
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 / test-postgres (==2.12.2) (push) Canceled after 0s
Tests / test-postgres (>=4,<5) (push) Canceled after 0s
Tests / canary (push) Canceled after 0s
55432 and 55433 are taken by other projects' containers on the dev host,
so make pg-up failed. The test database URL carries the port too.
2026-10-01 18:02:49 -06:00
rsp2k ae52e7a0f9 Merge feature/error-key-classifier: treat error-string results as soft errors
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 / test-postgres (==2.12.2) (push) Canceled after 0s
Tests / test-postgres (>=4,<5) (push) Canceled after 0s
Tests / canary (push) Canceled after 0s
2026-10-01 16:44:37 -06:00
rsp2k a69bf989fd Release 2026.10.01: treat error-string results as soft errors
Tests / test (>=3.3,<4) (push) Canceled after 0s
Tests / test (>=4,<5) (push) Canceled after 0s
Tests / test (==2.12.2) (push) Canceled after 0s
Tests / test (==2.14.3) (push) Canceled after 0s
Tests / test-postgres (==2.12.2) (push) Canceled after 0s
Tests / test-postgres (>=4,<5) (push) Canceled after 0s
Tests / canary (push) Canceled after 0s
2026-10-01 16:44:34 -06:00
rsp2k f4aef293a5 Document error_keys, unique event keys, and the call/event join
README: the error-string check, its default and the behavior change on
upgrade; event keys used for joining must be unique across restarts; the
SQLite and Postgres join from calls to events; ids are UUIDs, so order
by occurred_at.
2026-10-01 16:41:54 -06:00
rsp2k 146bbbff25 Treat results with a non-empty error string as soft errors
soft_error_classifier gains error_keys, default ("error",). A dict result
whose value there is a non-empty string is a soft error, after the
status, flag and isError checks. None, blank strings, numbers, booleans,
dicts and lists do not count. error_keys=() turns it off.
2026-10-01 16:40:24 -06:00
rsp2k 34d5130bd3 Merge feature/backfill: backfill embeddings, flatten list and dict text
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 / test-postgres (==2.12.2) (push) Canceled after 0s
Tests / test-postgres (>=4,<5) (push) Canceled after 0s
Tests / canary (push) Canceled after 0s
2026-09-30 11:24:06 -06:00
rsp2k 7378afae8e Release 2026.09.30.1: backfill embeddings, flatten list and dict text
Tests / test (>=3.3,<4) (push) Canceled after 0s
Tests / test (==2.12.2) (push) Canceled after 0s
Tests / test (==2.14.3) (push) Canceled after 0s
Tests / test (>=4,<5) (push) Canceled after 0s
Tests / test-postgres (==2.12.2) (push) Canceled after 0s
Tests / test-postgres (>=4,<5) (push) Canceled after 0s
Tests / canary (push) Canceled after 0s
2026-09-30 11:24:04 -06:00
rsp2k 9f3edc59d3 Backfill CLI: keep request logs quiet unless -v 2026-09-30 11:20:01 -06:00
rsp2k ac1d751045 Document backfilling embeddings and list/dict flattening 2026-09-30 11:17:21 -06:00
rsp2k 6dacdafd6e Backfill embeddings for stored records; flatten list and dict text
backfill_embeddings reads stored call errors (including rows from before the
outcome column) and events, rebuilds the records and sends them through the
live sink's extraction, redaction and truncation, so later records dedupe
against the backfilled rows. backfill_feedback does the same for feedback
kept in a host's own tables. A CLI wraps it:
python -m fastmcp_feedback.instrumentation.backfill.

List and tuple attrs are embedded one item per line instead of as a Python
repr; dicts as key: value lines sorted by key, since JSONB drops key order.
2026-09-30 11:13:12 -06:00
rsp2k 2bd3d9476c Add a tested Postgres Alpine + pgvector Dockerfile
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 / test-postgres (==2.12.2) (push) Canceled after 0s
Tests / test-postgres (>=4,<5) (push) Canceled after 0s
Tests / canary (push) Canceled after 0s
Consumers on postgres:NN-alpine can't switch to the Debian pgvector image
without reindexing every text index (musl vs glibc collation). This builds
pgvector into the same Alpine image instead. Verified on PG 16 and 17.
2026-09-30 00:36:41 -06:00
rsp2k b9652da288 Merge feature/embeddings: embed feedback, errors, events and LLM text
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 / test-postgres (==2.12.2) (push) Canceled after 0s
Tests / test-postgres (>=4,<5) (push) Canceled after 0s
Tests / canary (push) Canceled after 0s
2026-09-30 00:27:25 -06:00
rsp2k ecef7c4cbc Release 2026.09.30: embeddings and similarity search
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 / test-postgres (==2.12.2) (push) Canceled after 0s
Tests / test-postgres (>=4,<5) (push) Canceled after 0s
Tests / canary (push) Canceled after 0s
2026-09-30 00:27:24 -06:00
rsp2k c655949bbb Embed feedback, tool errors, event text and opt-in LLM text for similarity search
EmbeddingSink queues text from each batch on its own bounded queue and
upserts vectors into a new {prefix}embeddings table (pgvector on Postgres,
JSON elsewhere). mw.similar() and mw.similar_feedback() search it.
submit_feedback records a feedback.submitted event; record_llm_call keeps
prompt and completion only with capture_llm_text=True.
2026-09-30 00:22:14 -06:00
rsp2k 0bc53a8b3c Merge feature/postgres-dev-stack: run the database tests on Postgres + pgvector too
Tests / canary (push) Canceled after 0s
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 / test-postgres (==2.12.2) (push) Canceled after 0s
Tests / test-postgres (>=4,<5) (push) Canceled after 0s
2026-09-29 19:34:01 -06:00
rsp2k daec2d9e72 Run the DatabaseSink tests against Postgres + pgvector as well as SQLite
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 / test-postgres (==2.12.2) (push) Canceled after 0s
Tests / test-postgres (>=4,<5) (push) Canceled after 0s
Tests / canary (push) Canceled after 0s
The sink ships with a JSONB variant and is documented for asyncpg, but
the suite only ever exercised SQLite. The embeddings work will need
pgvector, so this adds a local Postgres stack and a way to run the
database tests on it, here and in CI.

- docker-compose.yml: ffb-postgres (pgvector/pgvector:pg16) on a private
  network, published on 127.0.0.1 only, with an init script that adds
  the vector extension to the test database and template1
- Makefile: pg-up/pg-down/pg-reset/pg-logs/pg-psql, test, test-pg,
  test-all, lint; .env is generated from .env.example with a random
  password, and FFB_TEST_DATABASE_URL from the environment wins over it
- tests: a db_url fixture parametrized over sqlite and postgres. The
  postgres leg skips unless FFB_TEST_DATABASE_URL is set, and creates a
  uniquely named database per test (with vector) that is dropped after.
  The DatabaseSink factories in the events, retention, correlation,
  soft-error and instrumentation tests now use it, so their 38 database
  tests run on both. The missing-events-table test accepts either
  backend's error. New tests check isolation and that vector works.
- asyncpg and pgvector added to the dev and test extras
- CI: a test-postgres job on FastMCP 2.12.2 and 4.x with a pgvector
  service container, failing if the Postgres cases skip
- CONTRIBUTING: how to run the suite against Postgres
2026-09-29 19:33:41 -06:00
rsp2k 3f3beda8e3 Merge fix/create-tables-race: tolerate concurrent table creation
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-29 19:29:20 -06:00
rsp2k b3ded1c267 Release 2026.09.29.1: tolerate concurrent table creation
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-29 19:29:20 -06:00
rsp2k ee46066383 Retry create_all once when another process creates the tables concurrently 2026-09-29 19:26:46 -06:00
rsp2k 9648440f20 Merge feature/events: record events that aren't 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-29 10:24:09 -06:00
rsp2k 99074c6fa8 Release 2026.09.29: events
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-29 10:24:09 -06:00
rsp2k d998256bc4 Make event attrs JSON-safe on every write path, not only record_event 2026-09-29 10:23:38 -06:00
rsp2k bf62650e05 Add events: record_event, record_llm_call and an events table
Events are things worth recording that are not tool calls, such as a
background job changing state or an LLM request made by a tool. They go
through the same queue as call records, are redacted the same way, link
to the running tool call when there is one, and can be recorded from any
thread. DatabaseSink writes them to {prefix}events and prunes them with
the same retention; events_for(key) reads them back.
2026-09-29 10:22:05 -06:00
rsp2k b34f5683a1 Merge feature/retention-sampling: retention, exclusion and sampling
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-28 22:13:07 -06:00
rsp2k 4bf2b6f906 Release 2026.09.28: retention, exclusion and sampling
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-28 22:13:07 -06:00
rsp2k 25a1ca5ecd Add per-tool exclusion and sampling, and retention pruning 2026-09-28 22:11:20 -06:00
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
rsp2k ddf23d10e4 Support FastMCP 4 and fix silent composition failure
Tests / test (==2.12.2) (push) Canceled after 0s
Tests / test (==2.14.3) (push) Canceled after 0s
Tests / test (>=4,<5) (push) Canceled after 0s
Tests / canary (push) Canceled after 0s
compose_feedback_services() called import_server() without awaiting it.
That method became async in FastMCP 2.14, so on the version this package
was built against it composed nothing while logging success. FastMCP 4
removed it outright. Use mount(), which is sync on 2.12 through 4.x.

The test suite could not start (pytest-html hook, plugin not installed),
so nothing caught this. Add pytest-html, bring examples/ over from the docs
repo since the integration tests import them, and fix the tests that had
drifted from the code. 407 tests pass on FastMCP 2.12.2, 2.14.3 and 4.0.10.

- Declare fastmcp>=2.12.2,<5; CI matrix covers each family plus a
  non-blocking canary on the newest FastMCP
- Add a composition test with real servers; the mocked ones could not
  see a missing await
- Rewrite the README against the actual API and execute its examples
- Ignore ruff UP042: StrEnum would change stored enum formatting
- Version 2026.09.27
2026-09-27 06:09:30 -06:00
rsp2k 736b122533 fix: Add missing re-exports to mixins/base.py
The mixin modules import shared types from base.py, but they
weren't being re-exported. Add __all__ with explicit re-exports for:
- Feedback, FeedbackStatus, FeedbackType (from models)
- feedback_to_dict (from models)
- mcp_tool (from fastmcp)

Bump version to 2026.01.12.1
2026-01-12 13:18:34 -07:00
70 changed files with 16043 additions and 358 deletions
+13
View File
@@ -0,0 +1,13 @@
# Copy to .env (make does this for you on first run) and adjust as needed.
COMPOSE_PROJECT_NAME=fastmcp-feedback-pkg
FFB_PG_IMAGE=pgvector/pgvector:pg16
FFB_PG_USER=ffb
FFB_PG_PASSWORD=change-me
FFB_PG_DB=ffb_test
# Published on 127.0.0.1 only. 55432 is commonly taken by other local stacks.
FFB_PG_PORT=55447
# Read by the test suite. Unset, the Postgres test cases skip.
# Each test gets its own database created on this server and dropped after.
FFB_TEST_DATABASE_URL=postgresql+asyncpg://ffb:change-me@127.0.0.1:55447/ffb_test
+118
View File
@@ -0,0 +1,118 @@
name: Tests
# fastmcp-feedback declares fastmcp>=2.12.2,<5, so every version family in
# that range gets the full suite. A user on PyPI gets the newest FastMCP the
# range allows, not what uv.lock pins, which is how 2026.01.12.1 shipped with
# composition broken on 2.14 and 4.x without anyone noticing.
on:
push:
pull_request:
workflow_dispatch:
schedule:
# Weekly, so a new FastMCP release is caught even when nobody pushes
- cron: '17 9 * * 1'
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
fastmcp: ['==2.12.2', '==2.14.3', '>=3.3,<4', '>=4,<5']
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with:
python-version: '3.13'
- name: Install against fastmcp${{ matrix.fastmcp }}
run: |
uv venv
uv pip install -e ".[dev]" "fastmcp${{ matrix.fastmcp }}"
uv pip show fastmcp | grep -i '^version'
- name: Lint
run: uv run --no-sync ruff check src/ examples/ tests/_compat.py tests/_databases.py tests/unit/test_test_databases.py tests/unit/test_instrumentation.py
- name: Test
run: uv run --no-sync pytest -q --no-cov
# The DatabaseSink tests again, on Postgres + pgvector instead of SQLite.
# Oldest and newest supported FastMCP; the SQLite legs above cover the rest.
# act_runner runs the job in a container, so the service is reached by name.
test-postgres:
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
fastmcp: ['==2.12.2', '>=4,<5']
services:
ffb-postgres:
image: pgvector/pgvector:pg16
env:
POSTGRES_USER: ffb
POSTGRES_PASSWORD: ffb-ci
POSTGRES_DB: ffb_test
options: >-
--health-cmd "pg_isready -U ffb -d ffb_test"
--health-interval 2s
--health-timeout 3s
--health-retries 30
env:
FFB_TEST_DATABASE_URL: postgresql+asyncpg://ffb:ffb-ci@ffb-postgres:5432/ffb_test
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with:
python-version: '3.13'
- name: Install against fastmcp${{ matrix.fastmcp }}
run: |
uv venv
uv pip install -e ".[dev]" "fastmcp${{ matrix.fastmcp }}"
uv pip show fastmcp | grep -i '^version'
- name: Test with the Postgres cases enabled
run: make test-pg
- name: Postgres cases ran rather than skipped
run: |
out=$(uv run --no-sync pytest -q --no-cov -m postgres -rs)
echo "$out" | tail -3
if echo "$out" | grep -q 'SKIPPED'; then
echo "Postgres cases skipped; is the service reachable?"; exit 1
fi
# Newest FastMCP with no ceiling. Allowed to fail: it exists to say
# "a new major version is out and here is what breaks" before users do.
canary:
runs-on: ubuntu-latest
timeout-minutes: 15
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with:
python-version: '3.13'
- name: Install newest FastMCP, ignoring our ceiling
run: |
uv venv
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'
- name: Test
run: uv run --no-sync pytest -q --no-cov
+593 -1
View File
@@ -5,6 +5,598 @@ 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.10.04] - 2026-10-04
Bounded payloads, and classifier keys that reach into nested results.
### Added
- `max_payload_bytes` on `InstrumentationMiddleware` and `instrument()`,
64 KiB by default, `None` for no limit. Arguments and results stored in
mode `full`, and every event's `attrs` (including `record_llm_call`'s),
are bounded by it. A larger value is stored as
`{"_truncated": true, "size": <bytes>, "preview": "<start of its JSON>"}`;
the preview is 1000 characters (a quarter of the limit when smaller) and
is taken after redaction. Call payloads reuse the size already measured
for `args_size` and `result_size`, so the limit adds no encoding; the
preview encodes only the start of the value. In `attrs`, only the largest
values are replaced, so the dict keeps its keys. `EmbeddingSink` embeds a
marker's preview. `fastmcp_feedback.instrumentation.payload` holds the
marker helpers (`TRUNCATED`, `is_truncated`).
- `message_keys` on `soft_error_classifier` (default `error`, `message`,
`detail`, `reason`, as before).
- Dotted paths in `message_keys` and `error_keys`: `"snapshot.error"` reads
`result["snapshot"]["error"]`. A key that literally contains a dot is
matched first; a missing step or a non-dict step counts as absent and
never raises. Default outputs are unchanged.
### Changed
- Stored payloads and event attrs over 64 KiB become markers. Earlier
releases stored them whole; `max_payload_bytes=None` keeps that.
## [2026.10.02.1] - 2026-10-02
The identity resolver's second run is opt-in again for any resolver that
answers.
### Changed
- The second resolver run after the tool now happens only when the first
answer was empty (no keys, only None values, or a run that failed or timed
out) or lacks a key in `late_identity_keys`. In 2026.10.02 a missing
`user_sub`, `caller_kind` or `client_id` also triggered it, so a resolver
that never returns one of them (mcspeak's returns no `client_id`) ran twice
on every kept call. `late_identity_keys` stays empty by default: a resolver
that answers anything runs once per call unless the server opts in.
blender-mcp's registration fix still needs
`late_identity_keys=["bus_client_uuid"]`, as in 2026.10.02.
## [2026.10.02] - 2026-10-02
A tool that creates its caller's identity records it on its own row again.
### Fixed
- Regression in 2026.10.01.4: with the identity resolver moved before the
tool, a call whose tool creates the identity the resolver looks up (the
call that registers a client, or logs in) recorded its own row without
it. blender-mcp's `register_client` lost `bus_client_uuid`, `bus_role` and
`parent_uuid` from its row's `identity` this way. When the first answer is
incomplete, missing `user_sub`, `caller_kind` or `client_id`, or any key in
the new `late_identity_keys`, the resolver now runs a second time after the
tool, concurrently with the enricher, for calls that are kept (not for
sampled-out ones). Its values fill the row's gaps and replace `None`; on
conflict the first answer wins, so the row matches the events recorded
during the call, which are not rewritten. Both runs keep `hook_timeout`,
redaction and never raising. A complete first answer still means one run.
### Added
- `late_identity_keys` on `InstrumentationMiddleware` and `instrument()`:
identity keys that may only exist once the tool has run. A resolver that
already returns all three columns before such a tool (blender-mcp's does)
needs it to get the second run, e.g.
`late_identity_keys=["bus_client_uuid"]`.
## [2026.10.01.4] - 2026-10-01
Statistics speak the same enum values as the rest of the API, events carry
the caller's identity, and importing the package no longer configures
logging.
### Changed
- `get_feedback_statistics` and `get_database_stats` key `by_type` and
`by_status` by enum value (`bug`, `in_progress`), the spelling
`list_feedback` returns and accepts as a filter. They grouped the raw
columns, which hold the enum NAMES on both SQLite (VARCHAR) and Postgres
(native enum types `feedbacktype`, `feedbackstatus`), so they returned
`BUG`, `IN_PROGRESS`. **Code reading the uppercase keys must switch to
lowercase.** Nothing stored changes. A row holding a value instead of a
name is counted under the same key; an unknown label is kept as-is.
- The identity resolver runs once per call **before** the tool, instead of
after it. The call's record reuses the result (still redacted, still
limited by `hook_timeout`, still never raising). An async resolver can
therefore delay the tool by up to `hook_timeout`, and it now also runs for
ok calls that sampling drops afterwards. It does not run in mode `off` or
for `exclude_tools`. The enricher still runs after the call.
- `PACKAGE_INFO` is read from the installed distribution's metadata. It
listed only the `postgresql` and `dev` extras and a classifier pyproject
did not have.
- psycopg2-binary is in the `dev` and `test` extras, so `make test-pg`
covers the feedback tools on Postgres too.
### Fixed
- `record_event` (and `record_llm_call`) inside a tool call never set
`user_sub`, `caller_kind` or `client_id`, because identity was resolved
only after the call. Inside a call they now come from the call's resolved
identity, matching its row; `client_id` falls back to the FastMCP context's
as for calls. Values passed explicitly win, and `record_event` takes
`caller_kind=` and `client_id=` as well as `user_sub=`. Outside a call,
including a task the call started that records after it returned, nothing
is filled in.
- Importing `fastmcp_feedback` called `logging.basicConfig` at INFO when the
root logger had no handlers, so every host process (the backfill CLI
included) printed an "initialized" line and had its root logger set up by
a library. The package now only adds a `NullHandler` to the
`fastmcp_feedback` logger and logs the version at DEBUG. **Servers that
relied on the package's INFO or WARNING lines must configure logging
themselves**; `fastmcp_feedback.setup_logging()` restores the old
behavior in one line.
- The backfill CLI logs a summary of its counts at INFO, so `-v` shows
something on a dry run.
### Documentation
- README: statistics keys, identity resolver timing, a Logging section, the
two Postgres driver paths (`[postgresql]` for the feedback tools,
`[instrumentation]` plus asyncpg for the sinks), MySQL marked untested,
and the measured coverage (95% lines and branches with the Postgres cases
enabled) in place of "98%".
- CONTRIBUTING: the example test, the mixin recipe and the commands now
match the package and were run; `make test`, `make test-pg` and
`make lint` replace commands that did not pass on this repository.
## [2026.10.01.3] - 2026-10-01
### Documentation
- The documentation site is public at https://fastmcp-feedback.supported.systems,
rewritten against this package with every example executed. The README
links and the PyPI "Documentation" URL point there instead of the private
dev address.
## [2026.10.01.2] - 2026-10-01
The instrumentation imports on a base install, without the database extras.
### Fixed
- `import fastmcp_feedback.instrumentation` failed on `pip install
fastmcp-feedback` without the `instrumentation` extra: the package imported
`storage.py` eagerly, which imports `sqlalchemy.ext.asyncio`, and SQLAlchemy
2.1 raises `ImportError` there when `greenlet` is missing. `instrument(app)`
with `JsonLinesSink`, `CallbackSink` or `MemorySink`, events, redaction and
result classification now need only the base dependencies.
- `DatabaseSink`, `EmbeddingVector`, `build_metadata`, `Embedder`,
`EmbeddingSink`, `EmbeddingText`, `OpenAIEmbedder`, `extract_texts`,
`backfill_embeddings` and `backfill_feedback` load on first access. Without
the extra, accessing one raises `ImportError` naming the missing package and
the extra to install (`[instrumentation]`, or `[instrumentation,embeddings]`
for the embedding names), chained to the original error. Every access
raises, not only the first, although SQLAlchemy itself reports a missing
`greenlet` only once. Import paths are unchanged.
- The middleware finds a `DatabaseSink` or `EmbeddingSink` among its sinks
without importing their modules, so `database_sink`, `embedding_sink`,
`capture_recent_calls`, `link_feedback`, `feedback_context`, `events_for`,
`similar` and `similar_feedback` return their usual empty results on a base
install.
- `DEFAULT_TABLE_PREFIX`, `DEFAULT_EMBEDDING_DIM`, `EMBEDDING_SOURCES` and
`DEFAULT_EVENT_TEXT_KEYS` moved to `instrumentation/constants.py` and import
without the extras; `storage` and `embeddings` still export them.
## [2026.10.01.1] - 2026-10-01
A result that says it succeeded is no longer flagged for its own error string.
### Changed
- Explicit success flag wins over error keys: when any of the classifier's
`flag_keys` (`ok`, `success`) is exactly `True`, the `error_keys` check is
skipped and the string is treated as a warning, so
`{"ok": True, "error": "deprecated arg"}` is recorded as `ok` again. Only
that check is skipped: a failing status (`{"success": True, "status":
"failed"}`), any flag key exactly `False` and `isError` still flag the
result. Truthy values that are not `True`, such as `1` or `"yes"`, are not
an explicit success. Results with no flag key are classified as in
2026.10.01.
## [2026.10.01] - 2026-10-01
Results that report failure only as an `error` string now count as soft
errors, and the docs cover joining events to calls.
### Changed
- `soft_error_classifier(..., error_keys=("error",))`: a dict result whose
value at any of `error_keys` is a non-empty string (after stripping) is a
soft error, with message `<key>: <string>`, redacted and truncated as
before. Checked after the status, flag and `isError` checks, so results
those already flag keep their message and are counted once. None, empty or
blank strings, numbers, booleans, dicts and lists do not count, so
`{"error": None}` and `{"error": 0.02}` stay ok. `error_keys=()` turns it
off. `DEFAULT_RESULT_CLASSIFIER` has it on.
- **Behavior change:** results such as `{"played": False, "error": "..."}`,
`{"error": "..."}` and `{"status": "unknown_target", "error": "..."}` were
recorded as `ok` and are now `soft_error`, so `NOT ok` and soft-error
counts can rise after upgrading. This includes the package's own
`list_feedback`, `get_feedback_statistics` and analytics tools, whose
failure results carry an `error` string and no flag key. Pass
`result_classifier=soft_error_classifier(error_keys=())` for the old
behavior.
### Docs
- `record_event` / `events_for`: a `key` used for joining must be unique for
at least the retention period and across restarts; a per-boot counter
collides. Use a UUID or a startup token plus a counter.
- The join from a call to its events through an enricher-written `extra`
field, for SQLite (`json_extract(c.extra, '$.speech_id') = e.key`) and
Postgres (`c.extra->>'speech_id' = e.key`).
- Schema notes: `ffb_tool_calls` has primary key `id` and no `call_id`
column; `ffb_events.call_id` holds a call's `id`. Ids are UUIDs, so order
events by `occurred_at`.
- The soft-error section documents `error_keys`, its default and the
upgrade note.
## [2026.09.30.1] - 2026-09-30
Embed what was stored before embedding was switched on, and embed list and
dict attrs as text rather than as Python reprs.
### Added
- `await backfill_embeddings(embedding_sink, *, sources=None, since=None,
until=None, limit=None, batch_size=500, dry_run=False)`. Reads the
`DatabaseSink`'s tables for calls with outcome `error` or `soft_error`
(and, for rows from before the `outcome` column, outcome NULL with an
`error_message`), `feedback.submitted` events, `llm.call` events with
prompt or completion, and other events with any of the sink's
`event_text_keys`. Each record goes through the live sink's extraction,
redactor and `max_chars`, so text, `text_hash` and `source_id` match and
later records dedupe against backfilled rows. Idempotent: a second run
embeds nothing. Keyset-paged by timestamp and id, `batch_size` rows per
query; embeds in chunks of the sink's `batch_size`. `limit` embeds at most
that many texts, oldest first. Returns counts per source type plus
`skipped` and `errors`; embed failures are counted, never raised. Raises
only for bad arguments or a missing table. `sources` defaults to the
sink's.
- `await backfill_feedback(embedding_sink, items, *, batch_size=500,
dry_run=False)` for feedback in the host's own tables, as
`(ref, title, description)` tuples; the same text a `feedback.submitted`
event with that ref gives.
- `python -m fastmcp_feedback.instrumentation.backfill --database-url ...
--embed-base-url ... [--model --dim --prefix --sources --since --until
--limit --batch-size --max-chars --event-text-keys --create-tables
--dry-run]`. The API key comes from `FFB_EMBED_API_KEY` or the variable
named by `--api-key-env`. Prints counts as JSON; exits 1 when any text
failed, 2 on a setup error.
### Changed
- **Embedded text of list, tuple and dict values.** Lists and tuples are
embedded one cleaned, non-empty item per line instead of as their repr
(`"['a', 'b']"`); dicts as `key: value` lines, sorted by key because
PostgreSQL JSONB does not keep key order and a backfill must rebuild the
same text. Nested values are flattened the same way, to a depth of 32 and
about 64k characters before `max_chars` applies. This applies to event
text keys, feedback title and description, and LLM prompt and completion.
Existing rows embedded from such values keep their old text; a backfill
run replaces them, since their text changed.
## [2026.09.30] - 2026-09-30
Embed feedback, tool errors, event text and (opt-in) LLM text, and search
them by meaning: "has anyone reported this before?"
### Added
- `EmbeddingSink(embedder, database_sink, *, sources=("feedback",
"call_error", "event", "llm"), event_text_keys=("error_excerpt", "error",
"message", "reason"), max_chars=4000, max_queue=1000, batch_size=32,
timeout=30.0, scan_limit=5000)`. `write` extracts text and puts it on the
sink's own bounded queue, returning at once, so a slow embedding backend
never reaches the dispatcher's `sink_timeout` or a tool call. A background
task embeds in batches and upserts through the `DatabaseSink`'s engine. A
full queue drops and counts (`dropped`); embedder and database failures are
logged and counted (`errors`), never raised. An unchanged text (same
`text_hash`) is not embedded again; a changed one replaces its row. Text is
redacted with the middleware's `Redactor` (adopted when the sink is passed
to `instrument`) and truncated before it leaves the process. `aclose()`
drains for up to `timeout`, then drops and counts the rest.
- `Embedder` protocol (`model`, `dim`, `async embed(texts)`) and
`OpenAIEmbedder(base_url, api_key=None, model="mxbai-embed-large", dim=1024,
batch_size=32, timeout=30.0)` for any OpenAI-compatible `/v1/embeddings`
endpoint. It uses `httpx`, or `httpx2` under FastMCP 4; no new dependency.
A vector of the wrong length raises `ValueError`.
- `await mw.similar(text, *, source_types=None, k=10, min_score=None)` and
`await mw.similar_feedback(feedback_ref, *, k=10, source_types=None,
min_score=None)`. Results carry `source_type`, `source_id`, `text`, `score`
(1 - cosine distance, clamped to [0, 1]) and `created_at`. PostgreSQL ranks
with `embedding <=> :q` on an HNSW index; other databases compare in Python
against the newest `scan_limit` rows. `similar_feedback` reuses the stored
vector and leaves the item itself out. Neither raises; failures log and
return [].
- **Feedback flows as an event.** With `instrumentation=`, `submit_feedback`
records `feedback.submitted` keyed by the feedback id, with `type`, `title`,
`description` and `submitter`. Hosts with their own feedback tools record
the same event with their own ref (`bug-7Q2X`).
- `InstrumentationMiddleware(capture_llm_text=False, max_llm_text_chars=None)`
and `record_llm_call(..., prompt=None, completion=None)`. Prompt and
completion are stored (redacted, cut to `8 * max_error_chars`, 4000 by
default) only when `capture_llm_text=True`.
- `embeddings` extra: `pgvector>=0.5.0`. Postgres hosts also need `asyncpg`.
- `build_metadata(prefix, metadata=None, embedding_dim=1024)` and
`DatabaseSink(embedding_dim=1024)`. `embedding_dim=None` in `build_metadata`
leaves the embeddings table out.
### Changed
- **New table `{prefix}embeddings`** (`id`, `source_type`, `source_id`,
`model`, `dim`, `text_hash`, `text`, `created_at`, `embedding`), unique on
(`source_type`, `source_id`, `model`). `embedding` is pgvector `vector(dim)`
with an HNSW `vector_cosine_ops` index on PostgreSQL, a JSON float list
elsewhere. `build_metadata()` now includes it, so Alembic hosts need a
migration; the README has the SQL, starting with
`CREATE EXTENSION IF NOT EXISTS vector`. With `create_tables=True` the table
is created when an `EmbeddingSink` first writes, not at startup, so hosts
that do not embed need neither pgvector nor the extension. On PostgreSQL
that step first runs `CREATE EXTENSION IF NOT EXISTS vector`; without the
privilege it logs a warning, keeps the sink running, and retries at most
once a minute.
- `DatabaseSink.prune()` also deletes embeddings older than the cutoff,
except those of feedback, and returns the total from all three tables.
- `record_llm_call` now drops `prompt` and `completion` keys passed in
`attrs` unless `capture_llm_text=True`. Earlier versions stored them.
### Documentation
- README: `DatabaseSink` needs the `instrumentation` extra even with
SQLAlchemy installed, since SQLAlchemy 2.1 no longer installs `greenlet`.
## [2026.09.29.1] - 2026-09-29
### Fixed
- `DatabaseSink(create_tables=True)`: two processes starting at the same
moment against a database without the tables both ran `create_all`, and one
failed (SQLite: `table ... already exists`; Postgres: a unique violation on
`pg_type_typname_nsp_index`). That process dropped its first batch and
counted a sink error. `ensure_tables()` now waits 50 to 250 ms after a
database error and runs `create_all` once more, which skips what the other
process created; the recovery is logged at INFO. A second failure is raised
as before. Measured on two-writer cold starts: SQLite 9 of 10 runs lost a
batch before, 0 of 10 after; Postgres 16 12 of 12 before, 0 of 12 after.
## [2026.09.29] - 2026-09-29
Record things that are not tool calls: background job transitions, LLM
requests a tool makes, anything with a dotted name.
### Added
- `mw.record_event(kind, *, key=None, attrs=None, user_sub=None,
call_id=None) -> bool`. Synchronous; never raises, never blocks. `attrs` is
redacted like call arguments and made JSON-safe (`NaN`/infinities become
strings, since Postgres JSONB rejects them). Returns False when the event
was not recorded: mode `off`, an empty or over-128-character `kind`, a `key`
over 255 characters, non-mapping `attrs`, a full queue, or no event loop.
Events share the call records' queue and drop-and-count policy.
- **Linking to the running call.** The middleware now picks a call's record id
before the tool runs and exposes it through a ContextVar, so an event
recorded inside a tool gets that call's id as `call_id` (and its session),
matching the stored `ffb_tool_calls` row. Outside a call, `call_id` is None;
a task a tool started that records after the tool returned counts as
outside.
- **Thread safety.** `record_event` works from any thread: off the server's
loop, the event is handed over with `call_soon_threadsafe`. The loop is
known from the first tool call (the dispatcher now binds to it as each call
starts), so sync tools running in FastMCP's worker threads link too. Before
any call or record, a thread with no loop of its own gets False and a
counted drop.
- `mw.record_llm_call(*, model, provider=None, duration_ms=None,
input_tokens=None, output_tokens=None, ok=None, error=None, key=None,
attrs=None) -> bool` records kind `llm.call` with plain attribute names:
`model`, `provider`, `duration_ms`, `input_tokens`, `output_tokens`, `ok`
(defaults to `error is None`), `error` (redacted, truncated to
`max_error_chars`) and `error_type` for exceptions. The README maps them to
OTel GenAI names.
- `await mw.events_for(key, *, kinds=None, limit=1000)`: a key's events, oldest
first (the newest `limit`), from the `DatabaseSink` plus events still in the
queue, deduplicated by id, `occurred_at` in UTC. Without a `DatabaseSink` it
answers from memory (`recent_events=2000` on the middleware).
- `EventRecord`, exported from `fastmcp_feedback.instrumentation`.
`MemorySink` has `.calls` and `.events`.
- Exclusion and sampling still apply to tool calls only. A sampled tool's call
that recorded an event is always kept (no `sample_rate`), so the event's
`call_id` has a row.
### Changed
- Sinks receive mixed batches of `ToolCallRecord` and `EventRecord` once
events are recorded. `record.record_type` is `tool_call` or `event`. A
`CallbackSink` callback that assumes every record is a call needs a check.
- `JsonLinesSink` lines carry `"record": "tool_call"` or `"record": "event"`
(a new field; existing fields unchanged).
- `DatabaseSink.write` stores calls and events in separate transactions,
calls first, so a database without the events table still stores calls.
- `DatabaseSink.prune()` also deletes events whose `occurred_at` is before
the cutoff, in `prune_batch` batches, and **returns the total rows deleted
from both tables**; each table's count is logged. It skips events on a
database that has no events table.
### Migration
- New table `{prefix}events` (`ffb_events` by default): `id`, `occurred_at`,
`kind`, `key`, `call_id`, `session_id`, `user_sub`, `caller_kind`,
`client_id`, `server_version`, `attrs` (JSON/JSONB), indexed on
`occurred_at`, `kind`, `key`, `call_id` and `user_sub`. Hosts that manage the
schema with Alembic need a migration creating it (`build_metadata()`
includes it). `create_tables=True` creates it on first use. Until it exists,
event writes fail and are dropped (logged); calls are unaffected. No change
to existing tables.
## [2026.09.28] - 2026-09-28
Keep the tool-call table small: leave out or sample busy tools, and prune old
rows.
### Added
- `InstrumentationMiddleware(..., exclude_tools=...)`: tools that are never
recorded and never kept for feedback links. The calls themselves are
untouched.
- `InstrumentationMiddleware(..., sample_rates={"tool": 0.01})`: record that
fraction of a tool's `ok` calls. **Failures are always recorded**: calls with
outcome `error` or `soft_error` are kept whatever the rate. Rates outside
(0, 1] raise `ValueError`. Calls sampled out skip the hooks and redaction and
are not kept for feedback links. `random=` takes a `() -> float` for tests.
- Records have a `sample_rate`: the configured rate on sampled-in `ok` rows,
None on everything recorded unconditionally (including failures of sampled
tools). `SUM(1.0 / COALESCE(sample_rate, 1))` estimates the true call count.
- `DatabaseSink(..., retention=timedelta(days=30), prune_interval=...,
prune_batch=5000)`. With `retention` set, the background writer prunes after
an insert at most once per `prune_interval` (one hour by default). A failing
prune is logged and never stops the insert.
- `DatabaseSink.prune(older_than=None) -> int` deletes older rows in batches
of `prune_batch`, one transaction each, on SQLite and Postgres. **Calls
linked to feedback are never pruned**, and links are never deleted.
### Migration
- New nullable `sample_rate` column (`Float`) 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 it by
hand there (`ALTER TABLE ffb_tool_calls ADD COLUMN sample_rate FLOAT`). Until
the column exists, `DatabaseSink` writes fail and those records are dropped
(logged). Hosts still on 2026.09.27.3 or earlier also need the `outcome`
column from 2026.09.27.4.
## [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.
### Fixed
- `compose_feedback_services()` composed nothing on FastMCP 2.14. It called
`import_server()`, which became async in 2.14, without awaiting it, then
logged success anyway. On FastMCP 4, where `import_server()` no longer
exists, it raised `AttributeError`. It now uses `mount()`, which is
synchronous and available on every supported version.
- The test suite could not start (a pytest-html hook with pytest-html not
installed), so it had not run since the package was split out. It runs
again: 407 tests.
### Changed
- Dependency is now `fastmcp>=2.12.2,<5`. Earlier releases had no ceiling,
so a fresh install picked up FastMCP 4 untested.
- `examples/` moved into this repository and ships in the sdist.
`create_demo_server()` accepts an optional `database_url`. Examples list
and call tools through `fastmcp.Client`, which works on 2.x and 4.x.
### Added
- CI (Gitea Actions) runs the suite against FastMCP 2.12.2, 2.14.3 and 4.x
on every push and weekly, plus a non-blocking job against the newest
FastMCP with no ceiling.
- A composition test using real servers instead of mocks.
### Documentation
- README rewritten against the code and every Python example executed.
Previous docs described things that do not exist: four tools (there are
five, including `delete_feedback`), `get_feedback_stats` (it is
`get_feedback_statistics`), `InsightsMixin`, an `enable_insights`
parameter, feedback types `bug_report`/`feature_request`/`general` (they
are `bug`/`feature`/`improvement`/`question`), and a
`new -> reviewing -> planned` workflow (it is
`open -> in_progress -> resolved -> closed`).
- The default database is in-memory SQLite (`sqlite:///:memory:`), not
`sqlite:///feedback.db` as previously documented. Pass `database_url` to
keep feedback across restarts.
## [2026.01.12.1] - 2026-01-12
### Fixed
- `ImportError` on install: `mixins/base.py` did not re-export the names
the other mixin modules import from it.
## [2026.01.12] - 2026-01-12
### Added
@@ -20,7 +612,7 @@ and this project adheres to [Calendar Versioning](https://calver.org/) (YYYY.MM.
- `create_feedback_server()` for server composition
- Comprehensive test suite (97%+ coverage)
- Full type hints and Pydantic validation
- Documentation site at https://fastmcp-feedback.l.supported.systems
- Documentation site (on a private development address at the time)
### Technical Details
- Built on FastMCP 2.12.2+
+162 -72
View File
@@ -1,6 +1,6 @@
# Contributing to FastMCP Feedback
Thank you for your interest in contributing! This guide will help you get started.
How to set up, test and submit changes.
## Development Setup
@@ -13,17 +13,16 @@ Thank you for your interest in contributing! This guide will help you get starte
### Getting Started
```bash
# 1. Fork the repository on git.supported.systems
# 1. Fork fastmcp-feedback/fastmcp-feedback on git.supported.systems
# 2. Clone your fork
git clone git@git.supported.systems:YOUR_USERNAME/fastmcp-feedback.git
cd fastmcp-feedback
# 3. Create virtual environment and install dependencies
uv venv
uv pip install -e ".[dev]"
# 3. Create .venv from uv.lock with the dev extra
uv sync --extra dev
# 4. Verify setup
uv run pytest
# 4. Verify setup (SQLite only; Postgres cases skip)
make test
```
### Development Workflow
@@ -35,17 +34,14 @@ git checkout -b feature/your-feature-name
# Make your changes...
# Run tests
uv run pytest
make test
# Run linting
uv run ruff check .
uv run ruff format .
# Run type checking (optional but appreciated)
uv run mypy src/fastmcp_feedback
make lint
uv run ruff format --check src/
# Commit with clear message
git commit -m "feat: Add support for custom validators"
git commit -m "Count feedback by submitter in get_feedback_statistics"
# Push and create PR
git push origin feature/your-feature-name
@@ -56,19 +52,21 @@ git push origin feature/your-feature-name
We use [Ruff](https://docs.astral.sh/ruff/) for linting and formatting:
```bash
# Check for issues
uv run ruff check .
# Lint src/, examples/ and the test files that are kept ruff-clean
make lint
# Auto-fix issues
uv run ruff check --fix .
# Format code
uv run ruff format .
# Format what you changed
uv run ruff format src/ path/to/your_test.py
```
Many of the older test files predate the ruff configuration and do not pass
it, so `ruff check .` over the whole repository reports errors that are not
yours. `src/` must stay clean, and new test files should be added to the
`lint` target in the Makefile.
### Style Guidelines
- **Line length**: 88 characters (Black default)
- **Line length**: 88 characters
- **Imports**: Sorted with isort (via Ruff)
- **Type hints**: Required for public APIs
- **Docstrings**: Google style for public functions
@@ -78,11 +76,11 @@ uv run ruff format .
### Running Tests
```bash
# Run all tests
# Run all tests (coverage is on by default, see pyproject.toml)
uv run pytest
# Run with coverage
uv run pytest --cov
# Without the coverage report, as the Makefile does
uv run pytest -q --no-cov
# Run specific test file
uv run pytest tests/unit/test_tools.py
@@ -90,13 +88,57 @@ uv run pytest tests/unit/test_tools.py
# Run specific test
uv run pytest -k "test_submit_feedback"
# Run only unit tests
uv run pytest -m unit
# Run only integration tests
uv run pytest -m integration
# Run one directory
uv run pytest tests/integration
```
### Running the tests against Postgres
The `DatabaseSink` tests run on SQLite by default. Each one also has a
`[postgres]` variant that runs against a real Postgres 16 with pgvector
when `FFB_TEST_DATABASE_URL` is set, and skips otherwise. The repo ships a
compose stack for it (Docker required):
```bash
make pg-up # start ffb-postgres and wait until healthy (creates .env on first run)
make test-pg # whole suite, Postgres cases enabled
make test # whole suite, SQLite only
make test-all # both
make pg-down # stop it; the data volume is kept (pg-reset deletes it)
```
The server listens on `127.0.0.1` only, on `FFB_PG_PORT` from `.env`
(55447 by default; change it if that port is taken). `make pg-psql` opens a
shell on it. Every Postgres test gets its own freshly created database with
the `vector` extension, dropped when the test ends, so tests never see each
other's rows. To write a test that runs on both backends, take the `db_url`
fixture from `tests/conftest.py`: `db_url("name")` returns an async URL for
an empty database, the same one for the same name within a test. The
feedback tools use a synchronous engine, so convert that URL's driver
(`sqlite`, `postgresql+psycopg2`) as the `feedback_db` fixture in
`tests/unit/test_consistency.py` does; psycopg2 comes with the dev extra.
To point the suite at another server, set the variable yourself; the user
needs permission to create and drop databases:
```bash
FFB_TEST_DATABASE_URL=postgresql+asyncpg://user:pass@host:5432/postgres uv run pytest -m postgres
```
### Embedding tests against a real endpoint
`tests/unit/test_embeddings.py` uses a deterministic fake embedder. One test
embeds through a real OpenAI-compatible endpoint when you point it at one, and
skips otherwise:
```bash
FFB_TEST_EMBED_URL=https://api.example.com/v1 FFB_TEST_EMBED_KEY=... \
uv run pytest tests/unit/test_embeddings.py -k live
```
`FFB_TEST_EMBED_MODEL` (default `mxbai-embed-large`) and `FFB_TEST_EMBED_DIM`
(default 1024) pick the model.
### Writing Tests
- Place unit tests in `tests/unit/`
@@ -104,44 +146,60 @@ uv run pytest -m integration
- Use descriptive test names: `test_submit_feedback_with_invalid_type_raises_error`
- Use fixtures from `tests/conftest.py`
Example test:
Tests are async without a marker (`asyncio_mode = "auto"`). Call tools the
way a client does, through `fastmcp.Client`. `submit_feedback` takes one
argument, `request`, holding the fields of `SubmitFeedbackRequest`; `type`
is one of `bug`, `feature`, `improvement` or `question`. Tools report
failure in the result (`success: False` and an `error`) rather than raising.
```python
import pytest
from fastmcp import Client, FastMCP
from fastmcp_feedback import add_feedback_tools
@pytest.mark.unit
async def test_submit_feedback_creates_record(app_with_feedback, db_session):
"""Submitting feedback should create a database record."""
result = await app_with_feedback.call_tool(
"submit_feedback",
type="bug_report",
title="Test Bug",
description="Description"
)
assert result["status"] == "success"
assert result["id"] is not None
async def test_submit_feedback_creates_record(tmp_path):
app = FastMCP("test")
add_feedback_tools(app, database_url=f"sqlite:///{tmp_path}/feedback.db")
async with Client(app) as client:
result = await client.call_tool(
"submit_feedback",
{
"request": {
"type": "bug",
"title": "Export fails",
"description": "Exporting a scene with no objects raises",
"submitter": "test",
}
},
)
listed = await client.call_tool("list_feedback", {})
stats = await client.call_tool("get_feedback_statistics", {})
assert result.structured_content["success"] is True
[item] = listed.structured_content["feedback"]
assert item["id"] == result.structured_content["feedback_id"]
assert (item["type"], item["status"]) == ("bug", "open")
assert stats.structured_content["by_type"] == {"bug": 1}
```
## Pull Request Guidelines
### Before Submitting
1. **Tests pass**: `uv run pytest`
2. **Linting passes**: `uv run ruff check .`
1. **Tests pass**: `make test`, and `make test-pg` when you touched database code
2. **Linting passes**: `make lint`
3. **New features have tests**
4. **Documentation updated** (if applicable)
### PR Title Format
Use [Conventional Commits](https://www.conventionalcommits.org/):
Say what changes, in the imperative, as the commit log does:
- `feat: Add custom validation support`
- `fix: Handle empty feedback list correctly`
- `docs: Update installation instructions`
- `test: Add edge case tests for insights`
- `refactor: Simplify database connection logic`
- `Key feedback statistics by enum value`
- `Resolve caller identity before the tool runs`
- `Import instrumentation without the database extras`
### PR Description Template
@@ -171,16 +229,20 @@ fastmcp-feedback/
│ ├── models.py # Pydantic models and SQLAlchemy ORM
│ ├── database.py # Database connection management
│ ├── feedback.py # Standalone server factory
│ ├── insights.py # Privacy-compliant analytics
│ └── mixins/ # Modular tool mixins
│ ├── base.py
│ ├── submission.py
│ ├── retrieval.py
│ └── management.py
│ ├── insights.py # In-process usage metrics
│ ├── mixins/ # The feedback tools, grouped
│ │ ├── base.py # BaseFeedbackMixin
│ │ ├── submission.py
│ │ ├── retrieval.py
│ │ └── management.py
│ └── instrumentation/ # Middleware, sinks, events, embeddings, backfill
├── tests/
│ ├── conftest.py # Shared fixtures
│ ├── conftest.py # Shared fixtures, including db_backend and db_url
│ ├── _databases.py # Per-test Postgres databases
│ ├── unit/ # Unit tests
│ └── integration/ # Integration tests
├── docker-compose.yml # Postgres + pgvector for make test-pg
├── Makefile
├── pyproject.toml # Package configuration
├── README.md
├── CONTRIBUTING.md # This file
@@ -192,28 +254,56 @@ fastmcp-feedback/
### Adding a New Mixin
1. Create `src/fastmcp_feedback/mixins/your_mixin.py`
2. Inherit from `BaseMixin`
3. Implement `register_tools(app, prefix="")`
4. Export from `src/fastmcp_feedback/mixins/__init__.py`
5. Add to main `__init__.py` exports
6. Write tests in `tests/unit/test_your_mixin.py`
7. Update documentation
A mixin subclasses `BaseFeedbackMixin`, which holds the `FeedbackDatabase`
(`self.database`) and the `FeedbackInsights` (`self.insights`). Its tools are
methods decorated with `mcp_tool` from `fastmcp.contrib.mcp_mixin`;
`register_tools(app, prefix=...)`, inherited from FastMCP's `MCPMixin`, adds
them to a server.
```python
from fastmcp import FastMCP
from fastmcp_feedback import FeedbackDatabase
from fastmcp_feedback.mixins.base import BaseFeedbackMixin, Feedback, mcp_tool
class CountMixin(BaseFeedbackMixin):
@mcp_tool(description="Count feedback items")
async def count_feedback(self) -> dict:
async with self.database.session() as session:
return {"count": session.query(Feedback).count()}
app = FastMCP("demo")
CountMixin(FeedbackDatabase("sqlite:///:memory:")).register_tools(app, prefix="admin")
# The tool is registered as admin_count_feedback.
```
Then:
1. Put it in `src/fastmcp_feedback/mixins/your_mixin.py`
2. Export it from `src/fastmcp_feedback/mixins/__init__.py` and the package `__init__.py`
3. Write tests in `tests/unit/`, calling the tool through `fastmcp.Client`
4. Document it in README.md and on the docs site
### Adding a New Tool
1. Add to appropriate mixin or create new one
2. Use `@app.tool()` decorator with clear description
3. Add Pydantic models for request/response if complex
4. Write unit tests
5. Add integration test
6. Document in README
1. Add a method to the mixin it belongs to, decorated with `mcp_tool` and a
description written for the model that will call it
2. Return a dict; report failures as `{"success": False, "error": ...}`
instead of raising
3. Return enum fields as their values (`bug`, `in_progress`), as
`feedback_to_dict` does; the database stores the names
4. Use a Pydantic model for structured input
5. Test it on both backends where it touches the database (`make test-pg`)
## Versioning
We use **calendar versioning** (CalVer): `YYYY.MM.DD`
Version bumps happen automatically on release. Contributors don't need to update version numbers.
A release bumps the version in `pyproject.toml` and
`src/fastmcp_feedback/__init__.py` and adds a CHANGELOG entry. Contributors
don't need to update version numbers.
## Getting Help
+65
View File
@@ -0,0 +1,65 @@
# Dev/test helpers. The Postgres stack exists only so the test suite can run
# against a real server; see CONTRIBUTING.md.
COMPOSE ?= docker compose
PYTEST ?= uv run --no-sync pytest
PYTEST_ARGS ?= -q --no-cov
# FFB_TEST_DATABASE_URL and friends come from .env when it exists. A value
# already in the environment (CI points it at a service container) wins.
ENV_TEST_DATABASE_URL := $(FFB_TEST_DATABASE_URL)
-include .env
ifneq ($(ENV_TEST_DATABASE_URL),)
FFB_TEST_DATABASE_URL := $(ENV_TEST_DATABASE_URL)
endif
.PHONY: help pg-up pg-down pg-logs pg-psql pg-reset test test-pg test-all lint
help:
@echo "pg-up start Postgres + pgvector and wait until healthy"
@echo "pg-down stop it (the data volume is kept)"
@echo "pg-reset stop it and delete the data volume"
@echo "pg-logs follow its logs"
@echo "pg-psql psql into the test database"
@echo "test test suite on SQLite only (Postgres cases skip)"
@echo "test-pg test suite with the Postgres cases enabled (needs pg-up)"
@echo "test-all both"
@echo "lint ruff"
# First run: copy the example and give it a random password.
.env:
@pw=$$(openssl rand -hex 16); sed "s/change-me/$$pw/g" .env.example > .env
@echo "wrote .env from .env.example"
pg-up: .env
$(COMPOSE) up -d --wait ffb-postgres
$(COMPOSE) logs --tail=20 ffb-postgres
pg-down:
$(COMPOSE) down
pg-reset:
$(COMPOSE) down -v
pg-logs:
$(COMPOSE) logs -f ffb-postgres
pg-psql:
$(COMPOSE) exec ffb-postgres psql -U "$(FFB_PG_USER)" -d "$(FFB_PG_DB)"
test:
env -u FFB_TEST_DATABASE_URL $(PYTEST) $(PYTEST_ARGS)
test-pg: .env
@test -n "$(FFB_TEST_DATABASE_URL)" || { echo "FFB_TEST_DATABASE_URL is not set (environment or .env)"; exit 1; }
@echo "$(PYTEST) $(PYTEST_ARGS) (Postgres cases enabled)"
@FFB_TEST_DATABASE_URL="$(FFB_TEST_DATABASE_URL)" $(PYTEST) $(PYTEST_ARGS)
test-all: test test-pg
lint:
uv run --no-sync ruff check src/ examples/ tests/_compat.py tests/_databases.py \
tests/unit/test_test_databases.py tests/unit/test_instrumentation.py \
tests/unit/test_embeddings.py tests/unit/test_backfill.py \
tests/unit/test_consistency.py tests/unit/test_late_identity.py \
tests/unit/test_payload_cap.py tests/unit/test_classifier_paths.py
+1015 -67
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
# Postgres + pgvector for running the test suite against a real server.
# Test-only: nothing here serves HTTP, so there is no caddy network.
# The port is published on 127.0.0.1 only, for pytest running on the host.
services:
ffb-postgres:
image: ${FFB_PG_IMAGE:-pgvector/pgvector:pg16}
restart: unless-stopped
environment:
POSTGRES_USER: ${FFB_PG_USER}
POSTGRES_PASSWORD: ${FFB_PG_PASSWORD}
POSTGRES_DB: ${FFB_PG_DB}
ports:
- "127.0.0.1:${FFB_PG_PORT:-55447}:5432"
volumes:
- ffb-postgres-data:/var/lib/postgresql/data
- ./docker/postgres-init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
interval: 2s
timeout: 3s
retries: 30
start_period: 5s
networks:
- internal
volumes:
ffb-postgres-data:
networks:
internal:
+13
View File
@@ -0,0 +1,13 @@
# Postgres on Alpine with pgvector built in. Keeps the musl C library, so an
# existing data directory's collation and text indexes are unaffected.
# with_llvm=no: the base image's Postgres was built with a specific clang for
# JIT bitcode that Alpine may no longer ship; pgvector itself builds with gcc.
ARG PG_MAJOR=16
FROM postgres:${PG_MAJOR}-alpine
ARG PGVECTOR_VERSION=v0.8.6
RUN apk add --no-cache --virtual .build-deps git build-base \
&& git clone --depth 1 --branch ${PGVECTOR_VERSION} https://github.com/pgvector/pgvector.git /tmp/pgvector \
&& make -C /tmp/pgvector OPTFLAGS="" with_llvm=no \
&& make -C /tmp/pgvector install with_llvm=no \
&& rm -rf /tmp/pgvector \
&& apk del .build-deps
+28
View File
@@ -0,0 +1,28 @@
# Postgres on Alpine with pgvector
For stacks that already run `postgres:NN-alpine` and want the `vector`
extension for fastmcp-feedback embeddings.
Why not switch to the `pgvector/pgvector` image: it's Debian (glibc), and the
Alpine images use musl. The two C libraries sort text differently under
locales such as `en_US.utf8`, so reusing an Alpine data directory under a
Debian image changes the order of every text index. The indexes need a full
`REINDEX` or they silently return wrong results. Building pgvector into the
same Alpine image keeps the data directory's collation unchanged.
```bash
docker build --build-arg PG_MAJOR=16 -t postgres-pgvector:16-alpine .
docker build --build-arg PG_MAJOR=17 -t postgres-pgvector:17-alpine .
```
Then point the stack's Postgres service at the new image (same major version
as the existing data directory) and run `CREATE EXTENSION IF NOT EXISTS
vector;` once, or let a migration do it.
Tested 2026-09-30 with pgvector v0.8.6 on postgres:16-alpine and
postgres:17-alpine: the extension loads, and an HNSW `vector_cosine_ops`
index is used for `ORDER BY embedding <=> ...` queries.
`with_llvm=no` skips the LLVM bitcode used for JIT inlining. The base images'
Postgres was built with a clang version Alpine no longer ships, so the bitcode
step fails; pgvector itself still builds with gcc and works normally.
+6
View File
@@ -0,0 +1,6 @@
-- Runs once, on first start with an empty volume.
-- The test fixtures create a fresh database per test from template1, so the
-- extension goes there too; every database created afterwards inherits it.
CREATE EXTENSION IF NOT EXISTS vector;
\connect template1
CREATE EXTENSION IF NOT EXISTS vector;
+318
View File
@@ -0,0 +1,318 @@
# FastMCP Feedback Examples
This directory contains comprehensive examples demonstrating all features of the FastMCP Feedback system.
## 🚀 Demo Server
### `demo_server.py`
**Complete demonstration server showcasing all features**
**Features Demonstrated:**
- ✅ Complete feedback collection workflow
- ✅ Privacy-compliant analytics (GDPR-ready)
- ✅ Multi-database support (SQLite, PostgreSQL, MySQL)
- ✅ Thread-safe operations with 97% test coverage
- ✅ Server composition patterns
- ✅ Performance monitoring and health checks
- ✅ Sample data for testing
**Available Tools:**
- `submit_feedback` - Submit bug reports, feature requests, improvements, questions
- `list_feedback` - Query and filter feedback with pagination
- `get_feedback_statistics` - Analytics and aggregate metrics
- `update_feedback_status` - Change status (open → in_progress → resolved)
- `delete_feedback` - Remove feedback items
- `get_system_info` - Version and dependency information
- `setup_sample_data` - Populate with realistic demo data
**Usage:**
```bash
# Run the demo server
python examples/demo_server.py
# Or with uv
uv run examples/demo_server.py
```
**Claude Desktop MCP Configuration:**
```json
{
"mcpServers": {
"fastmcp-feedback-demo": {
"command": "uv",
"args": ["run", "examples/demo_server.py"],
"env": {
"LOG_LEVEL": "INFO"
}
}
}
}
```
## 🏗️ Architecture Examples
### `simple_integration.py`
**Minimal integration example - 3 lines of code**
```python
from fastmcp import FastMCP
from fastmcp_feedback import add_feedback_tools
app = FastMCP("My App")
add_feedback_tools(app, database_url="sqlite:///feedback.db")
```
### `advanced_configuration.py`
**Production-ready configuration with all features**
```python
from fastmcp_feedback import (
add_feedback_tools,
FeedbackInsights,
FeedbackDatabase
)
# Configure privacy-compliant analytics
insights = FeedbackInsights(
enabled=True,
retention_days=90,
privacy_compliant=True
)
# Setup database with connection pooling
database = FeedbackDatabase(
"postgresql://user:pass@localhost/feedback",
pool_size=20,
max_overflow=10
)
# Add tools with custom prefix
add_feedback_tools(
app,
database=database,
insights=insights,
prefix="support_"
)
```
### `server_composition.py`
**Multi-server composition patterns**
```python
from fastmcp_feedback.feedback import (
create_submission_server,
create_management_server,
create_analytics_server
)
# Create specialized servers
public_server = create_submission_server("Public Portal")
admin_server = create_management_server("Admin Panel")
analytics = create_analytics_server("Dashboard")
# Compose into main application
main_app = FastMCP("Main Application")
await main_app.import_server(public_server, prefix="public")
await main_app.import_server(admin_server, prefix="admin")
await main_app.import_server(analytics, prefix="stats")
```
## 🎯 Usage Patterns
### Basic Feedback Workflow
1. **Submit Feedback:**
```python
# Submit a bug report
result = await submit_feedback({
"request": {
"type": "bug",
"title": "Login page not responsive on mobile",
"description": "The login form overflows on screens < 768px width",
"submitter": "user_123",
"contact_info": "user@example.com"
}
})
```
2. **List and Filter:**
```python
# Get all open bug reports
bugs = await list_feedback({
"type_filter": "bug",
"status_filter": "open",
"limit": 20
})
```
3. **Update Status:**
```python
# Mark as in progress
await update_feedback_status({
"feedback_id": "123",
"new_status": "in_progress",
"note": "Engineering team investigating"
})
```
4. **Get Analytics:**
```python
# View statistics
stats = await get_feedback_statistics()
# Returns: {"total_count": 45, "by_type": {"BUG": 12, "FEATURE": 20}, ...}
```
## 🔒 Privacy & Security Examples
### Privacy-Compliant Configuration
```python
insights = FeedbackInsights(
enabled=True, # Enable analytics
retention_days=90, # Auto-cleanup after 90 days
privacy_compliant=True, # Enforce GDPR compliance
anonymize_identifiers=True, # Hash user identifiers
exclude_contact_info=True # Don't store in metrics
)
```
### Security Features
```python
# Input validation with Pydantic
from fastmcp_feedback.models import SubmitFeedbackRequest
# SQL injection prevention via SQLAlchemy ORM
# Rate limiting support (extend mixins)
# Authentication ready (add middleware)
```
## 📊 Performance Examples
### Connection Pooling
```python
database = FeedbackDatabase(
"postgresql://localhost/feedback",
pool_size=20, # Base connections
max_overflow=10, # Burst capacity
pool_timeout=30, # Connection timeout
pool_recycle=3600 # Refresh every hour
)
```
### Bulk Operations
```python
# Bulk status updates
result = await bulk_update_status(
feedback_ids=["1", "2", "3", "4", "5"],
new_status="resolved"
)
```
### Performance Monitoring
```python
# Built-in health checks
health = await database.health_check()
if not health:
# Trigger alerts
pass
# Performance metrics
stats = await get_database_stats()
# Returns connection pool status, query times, etc.
```
## 🧪 Testing Examples
### Unit Testing with FastMCP Feedback
```python
import pytest
from fastmcp_feedback import FeedbackDatabase
@pytest.mark.asyncio
async def test_feedback_submission():
# Use in-memory database for testing
db = FeedbackDatabase("sqlite:///:memory:")
await db.initialize()
# Test feedback submission
# ... test code here
await db.close()
```
### Integration Testing
```python
# Test complete workflows
async def test_complete_feedback_lifecycle():
# Submit → List → Update → Verify → Analytics
pass
```
## 🚀 Production Deployment Examples
### Docker Configuration
```dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY pyproject.toml .
RUN pip install -e .
COPY src/ src/
COPY examples/ examples/
# Health check
HEALTHCHECK --interval=30s --timeout=10s \
CMD python -c "import asyncio; from examples.health_check import check; asyncio.run(check())"
CMD ["python", "examples/demo_server.py"]
```
### Environment Configuration
```bash
# Database
DATABASE_URL=postgresql://user:pass@db:5432/feedback
# Analytics
INSIGHTS_ENABLED=true
INSIGHTS_RETENTION_DAYS=90
# Performance
CONNECTION_POOL_SIZE=20
MAX_OVERFLOW=10
# Security
RATE_LIMIT_REQUESTS=100
RATE_LIMIT_WINDOW=3600
```
## 📚 Documentation
Each example includes:
- **Comprehensive comments** explaining every feature
- **Error handling** patterns
- **Performance considerations**
- **Security best practices**
- **Testing approaches**
- **Production deployment guidance**
## 🎯 Getting Started
1. **Start with the demo server** - `python examples/demo_server.py`
2. **Connect to Claude Desktop** using the MCP configuration above
3. **Try the tools** - submit feedback, query results, update status
4. **Explore the code** - see how each feature is implemented
5. **Customize for your needs** - adapt patterns to your use case
## 🤝 Contributing Examples
We welcome additional examples! Please:
- Include comprehensive comments
- Add error handling
- Provide usage instructions
- Update this README
- Add tests if applicable
---
**These examples demonstrate production-ready FastMCP Feedback integration with 97% test coverage and enterprise-grade reliability.**
+247
View File
@@ -0,0 +1,247 @@
# FastMCP Feedback Demo Server
**File:** `demo_server.py`
**Purpose:** Comprehensive demonstration of all FastMCP Feedback features
**Lines:** 396 lines of production-ready code
**Test Coverage:** Validated through 396+ comprehensive tests
## 🎯 Overview
The demo server is a complete, production-ready FastMCP server that showcases every feature of the FastMCP Feedback system. It's designed to demonstrate real-world usage patterns and serve as a reference implementation for developers.
## ✨ Features Demonstrated
### Core Feedback System
- **Complete CRUD Operations** - Submit, retrieve, update, delete feedback
- **Type Support** - Bug reports, feature requests, improvements, questions
- **Status Management** - Open → In Progress → Resolved → Closed workflow
- **Rich Metadata** - Timestamps, submitter info, contact details
### Advanced Capabilities
- **Privacy-Compliant Analytics** - GDPR-ready insights collection
- **Multi-Database Support** - SQLite for development, PostgreSQL for production
- **Thread-Safe Operations** - Concurrent request handling
- **Performance Monitoring** - Health checks and dependency validation
- **Server Composition** - Multiple specialized server types
### Data Integrity
- **Input Validation** - Pydantic models with comprehensive validation
- **Unicode Support** - Emojis, special characters, international text
- **Timezone Handling** - UTC-aware timestamp management
- **Whitespace Normalization** - Consistent data cleaning
## 🛠️ Available Tools (7 Total)
| Tool | Description | Use Case |
|------|-------------|----------|
| `submit_feedback` | Submit new feedback items | Users reporting bugs/requesting features |
| `list_feedback` | Query and filter feedback | Viewing feedback with pagination/filtering |
| `get_feedback_statistics` | Analytics and metrics | Dashboard data and reporting |
| `update_feedback_status` | Change feedback status | Admin workflow management |
| `delete_feedback` | Remove feedback items | Content moderation |
| `get_system_info` | Version/dependency info | System diagnostics |
| `setup_sample_data` | Populate demo data | Testing and demonstration |
## 📊 Sample Data
The demo server includes realistic sample data:
```python
sample_feedback = [
{
"type": "bug",
"title": "Memory leak in analytics processing",
"description": "Analytics worker consumes increasing memory over time...",
"submitter": "alice@example.com",
"contact_info": "alice@engineering.example.com"
},
{
"type": "feature",
"title": "Add export functionality for feedback data",
"description": "Users need ability to export feedback data as CSV or JSON...",
"submitter": "product_manager",
"contact_info": "pm@example.com"
}
# ... 3 more realistic examples
]
```
## 🚀 Usage
### Direct Execution
```bash
# Run with Python
python examples/demo_server.py
# Run with uv (recommended)
uv run examples/demo_server.py
```
### Claude Desktop Integration
```json
{
"mcpServers": {
"fastmcp-feedback-demo": {
"command": "uv",
"args": ["run", "examples/demo_server.py"],
"env": {
"DATABASE_URL": "sqlite:///feedback_demo.db",
"LOG_LEVEL": "INFO"
}
}
}
}
```
### Docker Deployment
```dockerfile
FROM python:3.11-slim
COPY . /app
WORKDIR /app
RUN pip install uv && uv sync
CMD ["uv", "run", "examples/demo_server.py"]
```
## 🔧 Configuration Options
### Environment Variables
- `DATABASE_URL` - Database connection string (default: temporary SQLite)
- `LOG_LEVEL` - Logging verbosity (DEBUG, INFO, WARNING, ERROR)
- `INSIGHTS_ENABLED` - Enable/disable analytics collection
- `RETENTION_DAYS` - Analytics data retention period
### Database Support
- **SQLite** - `sqlite:///path/to/database.db` (development)
- **PostgreSQL** - `postgresql://user:pass@host:port/db` (production)
- **In-Memory** - `sqlite:///:memory:` (testing)
## 📈 Performance Characteristics
### Throughput
- **Concurrent Requests** - Handles 100+ concurrent submissions
- **Database Performance** - Optimized queries with indexing
- **Memory Usage** - Efficient async/await patterns
### Scalability
- **Horizontal Scaling** - Stateless design supports load balancing
- **Database Scaling** - Connection pooling and query optimization
- **Analytics Scaling** - Configurable retention and aggregation
## 🧪 Testing Integration
The demo server is thoroughly tested through:
### Unit Tests
- Tool registration and configuration
- Sample data generation
- Error handling scenarios
### Integration Tests
- End-to-end workflow validation
- Database operations
- Server composition patterns
### Performance Tests
- High-volume operations
- Concurrent request handling
- Memory usage monitoring
## 🎓 Learning Examples
### Basic Feedback Submission
```python
# Submit a bug report
result = await submit_feedback({
"type": "bug",
"title": "Application crashes on startup",
"description": "Detailed error description...",
"submitter": "developer@company.com"
})
```
### Advanced Filtering
```python
# Get all open bug reports from last week
feedback = await list_feedback({
"type_filter": "bug",
"status_filter": "open",
"limit": 50
})
```
### Analytics Query
```python
# Get comprehensive statistics
stats = await get_feedback_statistics()
# Returns: total_count, by_type, by_status, recent_count
```
## 🔍 Architecture Insights
### Server Composition Pattern
```python
# The demo server demonstrates server composition:
main_server = FastMCP("Demo Server")
feedback_server = create_feedback_server("Feedback Service")
main_server.import_server(feedback_server, prefix="feedback_")
```
### Database Abstraction
```python
# Flexible database backend:
database = FeedbackDatabase(database_url)
await database.initialize() # Auto-creates tables
```
### Analytics Integration
```python
# Privacy-compliant analytics:
insights = FeedbackInsights(enabled=True, retention_days=90)
insights.record_metric("feedback_submitted", metadata)
```
## 📚 Related Documentation
- **[Simple Integration](simple_integration.md)** - Minimal 3-line integration
- **[Production Server](mcp_server.md)** - Environment-configured deployment
- **[Testing Guide](../tests/README.md)** - Comprehensive test documentation
- **[Architecture Overview](../docs/architecture.md)** - System design details
## 🤝 Contributing
When modifying the demo server:
1. **Maintain Compatibility** - Ensure all existing tools continue working
2. **Update Documentation** - Keep this file synchronized with code changes
3. **Add Tests** - Include test coverage for new features
4. **Version Compatibility** - Support multiple FastMCP versions where possible
## 🐛 Troubleshooting
### Common Issues
**Database Errors**
```bash
# Check database permissions
ls -la feedback_demo.db
# Reset database
rm feedback_demo.db && python examples/demo_server.py
```
**Import Errors**
```bash
# Ensure development installation
uv sync
# Check Python path
python -c "import fastmcp_feedback; print('OK')"
```
**Performance Issues**
```bash
# Enable debug logging
LOG_LEVEL=DEBUG python examples/demo_server.py
# Monitor memory usage
ps aux | grep demo_server
```
This demo server represents the gold standard for FastMCP Feedback integration, demonstrating every feature with production-ready code patterns and comprehensive error handling.
+402
View File
@@ -0,0 +1,402 @@
#!/usr/bin/env python3
"""
FastMCP Feedback Demo Server
A comprehensive demonstration server that showcases all features of the
FastMCP Feedback system including:
- Complete feedback collection workflow
- Privacy-compliant analytics
- Multi-database support
- Performance monitoring
- Server composition patterns
Usage:
# Run the demo server
python examples/demo_server.py
# Or use uv
uv run examples/demo_server.py
# Connect from Claude Desktop with this MCP server configuration:
# {
# "feedback-demo": {
# "command": "uv",
# "args": ["run", "examples/demo_server.py"]
# }
# }
"""
import asyncio
import logging
import sys
import tempfile
from datetime import UTC, datetime
from pathlib import Path
# Add the src directory to the path for development
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fastmcp import Client, FastMCP
from fastmcp_feedback import (
FeedbackDatabase,
FeedbackInsights,
add_feedback_tools,
check_dependencies,
get_version_info,
)
from fastmcp_feedback.feedback import (
create_analytics_server,
create_management_server,
create_submission_server,
)
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
handlers=[logging.StreamHandler(), logging.FileHandler("feedback_demo.log")],
)
logger = logging.getLogger(__name__)
def create_temp_database() -> str:
"""Create a temporary SQLite database for the demo."""
temp_dir = tempfile.mkdtemp(prefix="fastmcp_feedback_demo_")
db_path = Path(temp_dir) / "demo_feedback.db"
return f"sqlite:///{db_path}"
async def setup_demo_data(database: FeedbackDatabase) -> None:
"""Populate the database with sample feedback for demonstration."""
logger.info("Setting up demo data...")
sample_feedback = [
{
"type": "bug",
"title": "Memory leak in analytics processing",
"description": "Analytics worker consumes increasing memory over time, eventually causing OOM errors.",
"submitter": "alice@example.com",
"contact_info": "alice@engineering.example.com",
},
{
"type": "feature",
"title": "Add export functionality for feedback data",
"description": "Users need ability to export feedback data as CSV or JSON for external analysis.",
"submitter": "product_manager",
"contact_info": "pm@example.com",
},
{
"type": "improvement",
"title": "Optimize database query performance",
"description": "List feedback queries are slow with large datasets. Need pagination and indexing improvements.",
"submitter": "performance_team",
},
{
"type": "question",
"title": "Best practices for rate limiting?",
"description": "What are recommended rate limits for feedback submission to prevent abuse?",
"submitter": "security_team",
"contact_info": "security@example.com",
},
{
"type": "bug",
"title": "Unicode handling in feedback titles",
"description": "Emojis and special characters cause encoding errors in feedback titles. 🐛",
"submitter": "qa_engineer",
},
]
try:
await database.initialize()
# Import the model here to avoid circular imports
from fastmcp_feedback.models import Feedback, FeedbackStatus, FeedbackType
async with database.session() as session:
for item in sample_feedback:
feedback = Feedback(
type=FeedbackType(item["type"]),
status=FeedbackStatus.OPEN,
title=item["title"],
description=item["description"],
submitter=item["submitter"],
contact_info=item.get("contact_info"),
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
session.add(feedback)
session.commit()
logger.info(f"Added {len(sample_feedback)} sample feedback items")
except Exception as e:
logger.error(f"Failed to setup demo data: {e}")
raise
def create_demo_server(database_url: str | None = None) -> FastMCP:
"""Create the main demo server with all feedback tools.
Uses a fresh temporary SQLite database unless database_url is given.
"""
logger.info("🚀 Creating FastMCP Feedback Demo Server")
# Create the main server
server = FastMCP("FastMCP Feedback Demo")
server.description = (
"Comprehensive demonstration of FastMCP Feedback system featuring "
"complete feedback lifecycle, analytics, and multi-database support."
)
# Create temporary database for demo
database_url = database_url or create_temp_database()
logger.info(f"Using demo database: {database_url}")
# Configure insights for demo (privacy-compliant by design)
insights = FeedbackInsights(
enabled=True,
retention_days=30, # Short retention for demo
)
# Add all feedback tools to the main server
add_feedback_tools(
server,
database_url=database_url,
insights=insights,
prefix="", # No prefix for main tools
)
# Add version info tool
@server.tool(description="Get FastMCP Feedback version and dependency information")
def get_system_info() -> dict:
"""Get comprehensive system information."""
version_info = get_version_info()
dependency_status = check_dependencies()
return {
"system": "FastMCP Feedback Demo Server",
"status": "running",
"started_at": datetime.now(UTC).isoformat(),
"versions": version_info,
"dependencies": dependency_status,
"demo_features": [
"Complete feedback lifecycle",
"Privacy-compliant analytics",
"Multi-database support",
"Performance monitoring",
"Sample data included",
],
}
# Add demo data setup tool
@server.tool(description="Populate database with sample feedback data")
async def setup_sample_data() -> dict:
"""Setup sample feedback data for demonstration."""
try:
database = FeedbackDatabase(database_url)
await setup_demo_data(database)
await database.close()
return {
"success": True,
"message": "Sample data created successfully",
"items_added": 5,
"data_includes": [
"Bug reports with technical details",
"Feature requests from product team",
"Performance improvement suggestions",
"Security questions",
"Unicode handling issues",
],
}
except Exception as e:
logger.error(f"Failed to setup sample data: {e}")
return {"success": False, "error": str(e)}
return server
async def create_composed_demo_server() -> FastMCP:
"""Create a demo server using server composition patterns."""
logger.info("🏗️ Creating Composed Demo Server")
# Create main application server
main_server = FastMCP("Composed Feedback System")
main_server.description = "Demonstration of FastMCP server composition patterns"
# Create temporary databases for different services
public_db = create_temp_database()
admin_db = create_temp_database()
analytics_db = create_temp_database()
# Create specialized servers
public_server = create_submission_server(
"Public Feedback Portal",
database_url=public_db,
insights=FeedbackInsights(enabled=True, retention_days=90),
)
admin_server = create_management_server(
"Admin Management Panel",
database_url=admin_db,
insights=FeedbackInsights(enabled=True, retention_days=365),
)
analytics_server = create_analytics_server(
"Analytics Dashboard",
database_url=analytics_db,
insights=FeedbackInsights(enabled=True, retention_days=30),
)
# Compose servers with prefixes
main_server.mount(public_server, "public")
main_server.mount(admin_server, "admin")
main_server.mount(analytics_server, "analytics")
# Add composition info tool
@main_server.tool(description="Get information about server composition")
def get_composition_info() -> dict:
"""Get information about the composed server architecture."""
return {
"architecture": "Multi-server composition",
"servers": {
"public": {
"purpose": "Feedback submission for end users",
"tools": ["submit_feedback"],
"database": "isolated",
},
"admin": {
"purpose": "Management and moderation",
"tools": [
"update_feedback_status",
"delete_feedback",
"bulk_operations",
],
"database": "isolated",
},
"analytics": {
"purpose": "Reporting and insights",
"tools": ["generate_reports", "export_data", "usage_statistics"],
"database": "isolated",
},
},
"benefits": [
"Separation of concerns",
"Independent scaling",
"Role-based access control",
"Database isolation",
"Service modularity",
],
}
return main_server
async def run_demo_tests(server: FastMCP) -> dict:
"""Run basic functionality tests on the demo server."""
logger.info("🧪 Running demo functionality tests...")
try:
# Go through a client so this works the same on every FastMCP version
async with Client(server) as client:
tool_names = [tool.name for tool in await client.list_tools()]
system_info = None
if "get_system_info" in tool_names:
result = await client.call_tool("get_system_info", {})
system_info = result.structured_content
setup_result = None
if "setup_sample_data" in tool_names:
result = await client.call_tool("setup_sample_data", {})
setup_result = result.structured_content
return {
"test_status": "passed",
"tools_available": len(tool_names),
"tool_names": tool_names,
"system_info": system_info,
"sample_data_setup": setup_result,
"test_timestamp": datetime.now(UTC).isoformat(),
}
except Exception as e:
logger.error(f"Demo tests failed: {e}")
return {
"test_status": "failed",
"error": str(e),
"test_timestamp": datetime.now(UTC).isoformat(),
}
def print_demo_banner():
"""Print an informative banner for the demo server."""
banner = """
╔══════════════════════════════════════════════════════════════════╗
║ 🚀 FastMCP Feedback Demo Server ║
║ ║
║ Features Demonstrated: ║
║ • Complete feedback collection system ║
║ • Privacy-compliant analytics (GDPR-ready) ║
║ • Multi-database support (SQLite, PostgreSQL) ║
║ • Thread-safe operations with 97% test coverage ║
║ • Server composition patterns ║
║ • Performance monitoring and health checks ║
║ ║
║ Available Tools: ║
║ 📝 submit_feedback - Submit bug reports & feature requests║
║ 📋 list_feedback - Query and filter feedback ║
║ 📊 get_feedback_statistics - Analytics and metrics ║
║ 🔧 update_feedback_status - Change status (admin) ║
║ 🗑️ delete_feedback - Remove feedback items ║
║ ℹ️ get_system_info - Version and dependency info ║
║ 🎯 setup_sample_data - Populate with demo data ║
║ ║
║ Usage: Connect this as an MCP server in Claude Desktop ║
║ Configuration: See comments in this file ║
╚══════════════════════════════════════════════════════════════════╝
"""
print(banner)
async def main():
"""Main entry point for the demo server."""
print_demo_banner()
try:
# Check dependencies first
deps = check_dependencies()
if not deps["all_available"]:
logger.error("Missing required dependencies!")
for dep, info in deps["details"].items():
if not info["available"]:
logger.error(f" Missing: {dep} {info['required']}")
sys.exit(1)
logger.info("All dependencies satisfied ✅")
# Create the main demo server
server = create_demo_server()
# Run functionality tests
test_results = await run_demo_tests(server)
logger.info(f"Demo tests: {test_results['test_status']}")
if test_results["test_status"] == "passed":
logger.info(f"Server ready with {test_results['tools_available']} tools")
# Start the FastMCP server
logger.info("🎬 Starting FastMCP Feedback Demo Server...")
await server.run()
except KeyboardInterrupt:
logger.info("Demo server stopped by user")
except Exception as e:
logger.error(f"Demo server failed: {e}")
sys.exit(1)
if __name__ == "__main__":
asyncio.run(main())
+396
View File
@@ -0,0 +1,396 @@
# FastMCP Feedback Production Server
**File:** `mcp_server.py`
**Purpose:** Production-ready MCP server with environment configuration
**Lines:** 89 lines of deployment-focused code
**Target:** Production and staging environments
## 🎯 Overview
This is a production-ready MCP server launcher designed for real-world deployments. It demonstrates environment-based configuration, proper logging, and integration patterns suitable for staging and production environments.
## ✨ Production Features
### Environment Configuration
- **Database URL** - Configurable via `DATABASE_URL` environment variable
- **Insights Control** - Enable/disable analytics via `INSIGHTS_ENABLED`
- **Logging Levels** - Configurable via `LOG_LEVEL` (DEBUG, INFO, WARNING, ERROR)
- **Graceful Defaults** - Sensible fallbacks for missing configuration
### Security Considerations
- **No Hardcoded Secrets** - All sensitive data via environment variables
- **Input Validation** - Comprehensive Pydantic validation
- **Error Handling** - Graceful failure without exposing internals
- **Audit Logging** - Configurable logging for compliance
### Deployment Ready
- **Docker Compatible** - Environment variable based configuration
- **Kubernetes Ready** - ConfigMap and Secret integration
- **Load Balancer Friendly** - Stateless design
- **Health Checks** - Built-in system info endpoint
## 🚀 Usage
### Local Development
```bash
# Run with default settings
python examples/mcp_server.py
# Run with uv (recommended)
uv run examples/mcp_server.py
# Run with custom database
DATABASE_URL="postgresql://localhost/feedback" uv run examples/mcp_server.py
```
### Environment Variables
```bash
# Required/Optional Configuration
export DATABASE_URL="postgresql://user:pass@host:5432/feedback_prod"
export INSIGHTS_ENABLED="true"
export LOG_LEVEL="INFO"
export RETENTION_DAYS="90"
# Run the server
uv run examples/mcp_server.py
```
## 🔧 Configuration Options
### Database Configuration
```bash
# SQLite (development/testing)
DATABASE_URL="sqlite:///feedback.db"
# PostgreSQL (production)
DATABASE_URL="postgresql://user:password@host:5432/database"
# PostgreSQL with SSL (production)
DATABASE_URL="postgresql://user:pass@host:5432/db?sslmode=require"
# Environment-specific databases
DATABASE_URL_DEV="sqlite:///feedback_dev.db"
DATABASE_URL_STAGING="postgresql://staging_user:pass@staging.db/feedback"
DATABASE_URL_PROD="postgresql://prod_user:pass@prod.db/feedback"
```
### Analytics Configuration
```bash
# Enable analytics with retention
INSIGHTS_ENABLED="true"
RETENTION_DAYS="90"
# Disable analytics (GDPR compliance)
INSIGHTS_ENABLED="false"
# Different retention by environment
RETENTION_DAYS_DEV="7"
RETENTION_DAYS_STAGING="30"
RETENTION_DAYS_PROD="365"
```
### Logging Configuration
```bash
# Production logging
LOG_LEVEL="INFO"
LOG_FORMAT="json" # For log aggregation
# Development logging
LOG_LEVEL="DEBUG"
LOG_FORMAT="standard"
# Disable logging (not recommended)
LOG_LEVEL="CRITICAL"
```
## 🐳 Docker Deployment
### Dockerfile
```dockerfile
FROM python:3.11-slim
# Set working directory
WORKDIR /app
# Install uv for faster dependency management
RUN pip install uv
# Copy dependency files
COPY pyproject.toml uv.lock ./
# Install dependencies
RUN uv sync --frozen
# Copy application code
COPY . .
# Set environment variables
ENV DATABASE_URL="postgresql://postgres:password@db:5432/feedback"
ENV INSIGHTS_ENABLED="true"
ENV LOG_LEVEL="INFO"
# Expose port (if needed)
EXPOSE 8000
# Run the MCP server
CMD ["uv", "run", "examples/mcp_server.py"]
```
### Docker Compose
```yaml
version: '3.8'
services:
feedback-server:
build: .
environment:
- DATABASE_URL=postgresql://postgres:password@db:5432/feedback
- INSIGHTS_ENABLED=true
- LOG_LEVEL=INFO
- RETENTION_DAYS=90
depends_on:
- db
restart: unless-stopped
db:
image: postgres:15
environment:
- POSTGRES_DB=feedback
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=password
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
volumes:
postgres_data:
```
## ☸️ Kubernetes Deployment
### ConfigMap
```yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: feedback-config
data:
LOG_LEVEL: "INFO"
INSIGHTS_ENABLED: "true"
RETENTION_DAYS: "90"
```
### Secret
```yaml
apiVersion: v1
kind: Secret
metadata:
name: feedback-secrets
type: Opaque
stringData:
DATABASE_URL: "postgresql://user:password@postgres-service:5432/feedback"
```
### Deployment
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: feedback-server
spec:
replicas: 3
selector:
matchLabels:
app: feedback-server
template:
metadata:
labels:
app: feedback-server
spec:
containers:
- name: feedback-server
image: fastmcp-feedback:latest
ports:
- containerPort: 8000
envFrom:
- configMapRef:
name: feedback-config
- secretRef:
name: feedback-secrets
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
```
## 🎛️ Claude Desktop Configuration
### Development
```json
{
"mcpServers": {
"fastmcp-feedback-dev": {
"command": "uv",
"args": ["run", "examples/mcp_server.py"],
"env": {
"DATABASE_URL": "sqlite:///feedback_dev.db",
"INSIGHTS_ENABLED": "true",
"LOG_LEVEL": "DEBUG"
}
}
}
}
```
### Production
```json
{
"mcpServers": {
"fastmcp-feedback-prod": {
"command": "uv",
"args": ["run", "examples/mcp_server.py"],
"env": {
"DATABASE_URL": "postgresql://user:pass@prod.db/feedback",
"INSIGHTS_ENABLED": "true",
"LOG_LEVEL": "INFO",
"RETENTION_DAYS": "365"
}
}
}
}
```
## 📊 Monitoring & Observability
### Health Checks
```python
# Built-in health check endpoint
await get_system_info()
# Returns: version, dependencies, database_status, insights_status
```
### Logging Format
```json
{
"timestamp": "2024-01-15T10:30:00Z",
"level": "INFO",
"logger": "fastmcp_feedback.tools",
"message": "Feedback submitted successfully",
"feedback_id": "123",
"submitter_hash": "a1b2c3",
"type": "bug"
}
```
### Metrics Collection
- **Request Counts** - Total submissions, retrievals, updates
- **Error Rates** - Failed operations and reasons
- **Performance** - Response times and database query duration
- **Usage Patterns** - Popular feedback types, status distributions
## 🔍 Troubleshooting
### Common Issues
**Database Connection Errors**
```bash
# Check database connectivity
DATABASE_URL="postgresql://..." python -c "
import asyncio
from fastmcp_feedback import FeedbackDatabase
async def test():
db = FeedbackDatabase('$DATABASE_URL')
await db.initialize()
print('✅ Database connection successful')
asyncio.run(test())
"
```
**Environment Variable Issues**
```bash
# Verify environment variables
env | grep -E "(DATABASE_URL|INSIGHTS_ENABLED|LOG_LEVEL)"
# Test with explicit variables
DATABASE_URL="sqlite:///test.db" \
INSIGHTS_ENABLED="false" \
LOG_LEVEL="DEBUG" \
python examples/mcp_server.py
```
**Permission Issues**
```bash
# Check file permissions
ls -la feedback.db
# Check directory permissions
ls -ld $(dirname $(readlink -f feedback.db))
# Fix permissions
chmod 644 feedback.db
chmod 755 $(dirname $(readlink -f feedback.db))
```
### Performance Tuning
**Database Optimization**
```bash
# PostgreSQL connection pooling
DATABASE_URL="postgresql://user:pass@host/db?pool_size=20&max_overflow=30"
# SQLite optimization
DATABASE_URL="sqlite:///feedback.db?journal_mode=WAL&synchronous=NORMAL"
```
**Memory Optimization**
```bash
# Limit analytics retention
RETENTION_DAYS="30"
# Disable insights in memory-constrained environments
INSIGHTS_ENABLED="false"
```
## 🚀 Scaling Considerations
### Horizontal Scaling
- **Stateless Design** - No server-side session storage
- **Database Sharing** - Multiple instances can share database
- **Load Balancer Compatible** - Standard HTTP/JSON interfaces
### Database Scaling
- **Read Replicas** - Use read-only database URLs for list operations
- **Connection Pooling** - Configure appropriate pool sizes
- **Query Optimization** - Built-in indexing and pagination
### Caching Strategy
```python
# Redis caching for statistics (optional)
REDIS_URL="redis://localhost:6379/0"
CACHE_TTL="300" # 5 minutes
# Memcached alternative
MEMCACHED_SERVERS="localhost:11211"
```
## 📚 Related Documentation
- **[Demo Server](demo_server.md)** - Complete feature showcase
- **[Simple Integration](simple_integration.md)** - Minimal integration
- **[Architecture Guide](../docs/architecture.md)** - System design
- **[Security Guide](../docs/security.md)** - Security best practices
## 🤝 Contributing
When modifying the production server:
1. **Maintain Backward Compatibility** - Don't break existing environment variables
2. **Add Configuration Validation** - Validate new environment variables
3. **Update Documentation** - Keep this file synchronized with changes
4. **Test in Multiple Environments** - Docker, Kubernetes, bare metal
5. **Consider Security Implications** - Audit any configuration changes
This production server demonstrates enterprise-ready deployment patterns while maintaining the simplicity and power of the FastMCP Feedback system.
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""
FastMCP Feedback - MCP Server Launcher
This script provides a clean MCP server launcher that can be easily
configured in Claude Desktop or other MCP clients.
Usage:
# Direct execution
python examples/mcp_server.py
# With uv
uv run examples/mcp_server.py
# Claude Desktop configuration:
{
"mcpServers": {
"fastmcp-feedback": {
"command": "uv",
"args": ["run", "examples/mcp_server.py"],
"env": {
"DATABASE_URL": "sqlite:///feedback.db",
"INSIGHTS_ENABLED": "true",
"LOG_LEVEL": "INFO"
}
}
}
}
"""
import asyncio
import logging
import os
import sys
from pathlib import Path
# Add the src directory to the path for development
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fastmcp import Client, FastMCP
from fastmcp_feedback import FeedbackInsights, add_feedback_tools
# Configure logging based on environment
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
logging.basicConfig(
level=getattr(logging, log_level),
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
def create_production_server() -> FastMCP:
"""Create a production-ready FastMCP Feedback server."""
# Server configuration
server_name = os.getenv("SERVER_NAME", "FastMCP Feedback Server")
database_url = os.getenv("DATABASE_URL", "sqlite:///feedback.db")
# Create server
server = FastMCP(server_name)
server.description = "Production FastMCP Feedback collection system"
# Configure insights
insights_enabled = os.getenv("INSIGHTS_ENABLED", "true").lower() == "true"
retention_days = int(os.getenv("INSIGHTS_RETENTION_DAYS", "90"))
insights = FeedbackInsights(enabled=insights_enabled, retention_days=retention_days)
# Add feedback tools
tool_prefix = os.getenv("TOOL_PREFIX", "")
add_feedback_tools(
server, database_url=database_url, insights=insights, prefix=tool_prefix
)
logger.info(f"Server '{server_name}' configured")
logger.info(f"Database: {database_url}")
logger.info(f"Insights: {'enabled' if insights_enabled else 'disabled'}")
logger.info(f"Tool prefix: '{tool_prefix}' (empty = no prefix)")
return server
async def main():
"""Main entry point for the MCP server."""
try:
logger.info("🚀 Starting FastMCP Feedback Server")
# Create and start server
server = create_production_server()
# Show available tools
async with Client(server) as client:
tools = await client.list_tools()
logger.info(f"Server ready with {len(tools)} tools:")
for tool in tools:
logger.info(f" • {tool.name}")
# Start the server
await server.run()
except KeyboardInterrupt:
logger.info("Server stopped by user")
except Exception as e:
logger.error(f"Server failed: {e}")
sys.exit(1)
if __name__ == "__main__":
asyncio.run(main())
+274
View File
@@ -0,0 +1,274 @@
# Simple FastMCP Feedback Integration
**File:** `simple_integration.py`
**Purpose:** Minimal 3-line integration example
**Lines:** 48 lines of clean, documented code
**Complexity:** Beginner-friendly
## 🎯 Overview
This example demonstrates the absolute minimal integration of FastMCP Feedback into any FastMCP server. With just 3 lines of code, you get a complete, production-ready feedback system.
## ✨ Key Features
### Minimal Code
```python
# This is all you need - 3 lines of code!
app = FastMCP("Simple Feedback App")
add_feedback_tools(app, database_url="sqlite:///simple_feedback.db")
```
### Automatic Tool Registration
The integration automatically adds 5 production-ready tools:
- ✅ `submit_feedback` - Submit bug reports & feature requests
- ✅ `list_feedback` - Query and filter feedback
- ✅ `get_feedback_statistics` - Analytics and metrics
- ✅ `delete_feedback` - Remove feedback items
- ✅ `update_feedback_status` - Change status (admin)
### Zero Configuration
- **Database** - Automatically creates SQLite database
- **Schema** - Auto-generates tables on first run
- **Validation** - Built-in Pydantic input validation
- **Error Handling** - Graceful failure scenarios
## 🚀 Usage
### Direct Execution
```bash
# Run the example
python examples/simple_integration.py
# With uv (recommended)
uv run examples/simple_integration.py
```
### Expected Output
```
🚀 Simple FastMCP Feedback Integration
==================================================
✅ Feedback system added!
📊 Available tools:
• submit_feedback - Submit feedback to the system
• get_feedback_statistics - Get feedback statistics and analytics
• list_feedback - List feedback items with optional filtering
• delete_feedback - Delete feedback item
• update_feedback_status - Update feedback status
🎯 Total tools: 5
🎬 Starting server...
Connect this as an MCP server in Claude Desktop
```
## 📋 Integration Steps
### Step 1: Install Dependencies
```bash
# Using uv (recommended)
uv add fastmcp-feedback
# Using pip
pip install fastmcp-feedback
```
### Step 2: Add to Your Server
```python
from fastmcp import FastMCP
from fastmcp_feedback import add_feedback_tools
# Your existing FastMCP server
app = FastMCP("My Awesome App")
# Add your existing tools here...
# app.add_tool(my_custom_tool)
# Add complete feedback system in one line
add_feedback_tools(app, database_url="sqlite:///feedback.db")
```
### Step 3: Configure Claude Desktop
```json
{
"mcpServers": {
"my-app-with-feedback": {
"command": "python",
"args": ["my_server.py"]
}
}
}
```
## 🔧 Customization Options
### Database Configuration
```python
# SQLite (development)
add_feedback_tools(app, database_url="sqlite:///feedback.db")
# PostgreSQL (production)
add_feedback_tools(app, database_url="postgresql://user:pass@host/db")
# In-memory (testing)
add_feedback_tools(app, database_url="sqlite:///:memory:")
```
### Analytics Configuration
```python
from fastmcp_feedback import add_feedback_tools, FeedbackInsights
# Enable analytics with custom retention
insights = FeedbackInsights(enabled=True, retention_days=30)
add_feedback_tools(app, insights=insights)
```
### Tool Prefixing
```python
# Add with prefix to avoid naming conflicts
add_feedback_tools(app, prefix="support_")
# Creates: support_submit_feedback, support_list_feedback, etc.
```
## 📊 What You Get
### Complete CRUD Operations
```python
# Users can submit feedback
await submit_feedback({
"type": "bug",
"title": "App crashes on login",
"description": "Detailed error description",
"submitter": "user@example.com"
})
# Admins can manage feedback
await update_feedback_status({
"feedback_id": "123",
"new_status": "resolved"
})
# Analytics for insights
stats = await get_feedback_statistics()
# Returns: total_count, by_type, by_status, recent_count
```
### Input Validation
- **Type Validation** - Must be: bug, feature, improvement, question
- **Required Fields** - title, description, submitter cannot be empty
- **Length Limits** - title ≤ 255 chars, description ≤ 10,000 chars
- **Format Validation** - email validation for contact_info (optional)
### Database Schema
Automatically creates:
```sql
CREATE TABLE feedback (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type VARCHAR(50) NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL,
submitter VARCHAR(255) NOT NULL,
contact_info VARCHAR(255),
status VARCHAR(50) NOT NULL DEFAULT 'open',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
```
## 🎓 Learning Examples
### Basic Usage Pattern
```python
#!/usr/bin/env python3
from fastmcp import FastMCP
from fastmcp_feedback import add_feedback_tools
app = FastMCP("My App")
# Your custom tools
@app.tool()
def my_custom_feature():
return "Custom functionality"
# Add feedback system
add_feedback_tools(app)
# Run server
app.run()
```
### Error Handling
```python
try:
add_feedback_tools(app, database_url="invalid://url")
except Exception as e:
print(f"Failed to add feedback tools: {e}")
# Graceful fallback or alternative configuration
```
## 🔍 Under the Hood
### What `add_feedback_tools()` Does
1. **Creates Database Instance** - Initializes FeedbackDatabase with provided URL
2. **Sets Up Insights** - Configures optional analytics collection
3. **Registers Tools** - Adds 5 tools to your FastMCP server
4. **Validates Configuration** - Ensures database connectivity
5. **Logs Success** - Confirms successful integration
### Tool Implementation Pattern
```python
# Each tool is implemented as an async function
@app.tool(description="Submit feedback to the system")
async def submit_feedback(request: dict) -> dict:
# Validate input with Pydantic
validated_request = SubmitFeedbackRequest(**request)
# Store in database
feedback = await database.create_feedback(validated_request)
# Record analytics (if enabled)
insights.record_metric("feedback_submitted", metadata)
return {"success": True, "feedback_id": feedback.id}
```
## 🚀 Next Steps
### For More Features
If you need advanced capabilities, see:
- **[Demo Server](demo_server.md)** - Complete feature showcase
- **[Production Server](mcp_server.md)** - Environment configuration
- **[Architecture Guide](../docs/architecture.md)** - System design
### For Customization
```python
# Use individual mixins for fine-grained control
from fastmcp_feedback.mixins import SubmissionMixin, RetrievalMixin
submission = SubmissionMixin(database, insights)
submission.register_tools(app, prefix="feedback_")
```
### For Testing
```python
# Test your integration
import pytest
from fastmcp_feedback import add_feedback_tools
@pytest.mark.asyncio
async def test_feedback_integration():
app = FastMCP("Test App")
add_feedback_tools(app, database_url="sqlite:///:memory:")
tools = await app.get_tools()
assert "submit_feedback" in tools
```
## 🤝 Contributing
When modifying this example:
1. **Keep It Simple** - This is the "Hello World" of FastMCP Feedback
2. **Document Changes** - Update this file with any modifications
3. **Test Integration** - Ensure the 3-line pattern still works
4. **Preserve Clarity** - Maintain beginner-friendly explanations
This simple integration example proves that adding a complete, production-ready feedback system to any FastMCP server requires just 3 lines of code while providing enterprise-grade functionality.
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""
Simple FastMCP Feedback Integration Example
This demonstrates the absolute minimal integration - just 3 lines of code
to add a complete feedback system to any FastMCP server.
Usage:
python examples/simple_integration.py
"""
import asyncio
import sys
from pathlib import Path
# Add the src directory to the path for development
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fastmcp import Client, FastMCP
from fastmcp_feedback import add_feedback_tools
async def main():
"""Minimal FastMCP Feedback integration example."""
print("🚀 Simple FastMCP Feedback Integration")
print("=" * 50)
# This is all you need - 3 lines of code!
app = FastMCP("Simple Feedback App")
add_feedback_tools(app, database_url="sqlite:///simple_feedback.db")
print("✅ Feedback system added!")
print("📊 Available tools:")
# Show what tools were added
async with Client(app) as client:
tools = await client.list_tools()
for tool in tools:
print(f" • {tool.name} - {tool.description}")
print(f"\n🎯 Total tools: {len(tools)}")
print("\n🎬 Starting server...")
print(" Connect this as an MCP server in Claude Desktop")
# Start the server
await app.run()
if __name__ == "__main__":
asyncio.run(main())
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""
Test script to verify demo server functionality without running the full MCP server.
"""
import asyncio
import sys
from pathlib import Path
# Add the src directory to the path for development
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
sys.path.insert(0, str(Path(__file__).parent))
from demo_server import create_demo_server
from fastmcp import Client
async def test_demo_functionality():
"""Test the demo server tools without running the MCP server."""
print("🧪 Testing FastMCP Feedback Demo Functionality")
print("=" * 55)
try:
# Create the demo server
server = create_demo_server()
async with Client(server) as client:
# Get available tools
tools = {tool.name: tool for tool in await client.list_tools()}
print(f"✅ Server created with {len(tools)} tools")
# List available tools
print("\n📋 Available Tools:")
for name, tool in tools.items():
print(f" • {name}: {tool.description}")
# Test system info tool
if "get_system_info" in tools:
print("\n🔍 Testing system info...")
result = await client.call_tool("get_system_info", {})
info = result.structured_content
print(f" Version: {info['versions']['fastmcp_feedback']}")
print(f" Python: {info['versions']['python']}")
print(f" Features: {len(info['demo_features'])}")
# Test sample data setup
if "setup_sample_data" in tools:
print("\n📊 Testing sample data setup...")
result = await client.call_tool("setup_sample_data", {})
data_result = result.structured_content
if data_result["success"]:
print(f" ✅ Added {data_result['items_added']} sample items")
else:
print(f" ❌ Failed: {data_result['error']}")
# Test feedback listing
if "list_feedback" in tools:
print("\n📝 Testing feedback listing...")
result = await client.call_tool("list_feedback", {})
feedback_data = result.structured_content
print(f" Found {len(feedback_data['feedback'])} feedback items")
# Show first item details
if feedback_data["feedback"]:
first = feedback_data["feedback"][0]
print(f" First item: {first['type']} - {first['title']}")
# Test statistics
if "get_feedback_statistics" in tools:
print("\n📈 Testing statistics...")
result = await client.call_tool("get_feedback_statistics", {})
stats = result.structured_content
print(f" Total items: {stats['total_count']}")
print(f" By type: {stats['by_type']}")
print(f" By status: {stats['by_status']}")
print("\n🎉 All tests passed! Demo server is working correctly.")
return True
except Exception as e:
print(f"\n❌ Test failed: {e}")
import traceback
traceback.print_exc()
return False
if __name__ == "__main__":
success = asyncio.run(test_demo_functionality())
sys.exit(0 if success else 1)
+29 -4
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "fastmcp-feedback"
version = "2026.01.12" # YYYYMMDD versioning - update for each release
version = "2026.10.04" # YYYYMMDD.patch versioning - update for each release
description = "Production-ready feedback collection system for FastMCP servers"
readme = "README.md"
license = "MIT"
@@ -43,33 +43,50 @@ classifiers = [
]
requires-python = ">=3.11"
dependencies = [
"fastmcp>=2.12.2",
"fastmcp>=2.12.2,<5", # CI tests 2.12, 2.14 and 4.0; raise the ceiling once 5.x passes
"sqlalchemy>=2.0.43",
"pydantic>=2.11.7"
]
[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"]
# Vector column for EmbeddingSink on Postgres. Postgres hosts add asyncpg.
embeddings = ["pgvector>=0.5.0"]
mysql = ["pymysql>=1.1.0"]
dev = [
"sqlalchemy[asyncio]>=2.0.43",
"aiosqlite>=0.22.1",
"asyncpg>=0.31.0",
# The feedback tools' synchronous engine on Postgres, for test-pg.
"psycopg2-binary>=2.9.0",
"pgvector>=0.5.0",
"pytest>=8.2.0",
"pytest-asyncio>=0.23.0",
"pytest-cov>=5.0.0",
"pytest-mock>=3.12.0",
"pytest-html>=4.2.0",
"ruff>=0.1.0",
"mypy>=1.7.0",
]
test = [
"sqlalchemy[asyncio]>=2.0.43",
"aiosqlite>=0.22.1",
"asyncpg>=0.31.0",
"psycopg2-binary>=2.9.0",
"pgvector>=0.5.0",
"pytest>=8.2.0",
"pytest-asyncio>=0.23.0",
"pytest-cov>=5.0.0",
"pytest-mock>=3.12.0",
"pytest-html>=4.2.0",
]
all = ["fastmcp-feedback[postgresql,mysql,dev]"]
[project.urls]
Homepage = "https://git.supported.systems/fastmcp-feedback/fastmcp-feedback"
Documentation = "https://fastmcp-feedback.l.supported.systems"
Documentation = "https://fastmcp-feedback.supported.systems"
Repository = "https://git.supported.systems/fastmcp-feedback/fastmcp-feedback.git"
Issues = "https://git.supported.systems/fastmcp-feedback/fastmcp-feedback/issues"
Changelog = "https://git.supported.systems/fastmcp-feedback/fastmcp-feedback/blob/main/CHANGELOG.md"
@@ -81,6 +98,7 @@ packages = ["src/fastmcp_feedback"]
include = [
"/src",
"/tests",
"/examples",
"README.md",
"LICENSE",
"CHANGELOG.md",
@@ -110,6 +128,7 @@ markers = [
"integration: Integration tests for component interaction",
"slow: Slow running tests",
"database: Tests requiring database setup",
"postgres: Runs against the Postgres named by FFB_TEST_DATABASE_URL; skipped when unset",
]
filterwarnings = [
"ignore::DeprecationWarning",
@@ -142,7 +161,13 @@ src = ["src", "tests"]
[tool.ruff.lint]
select = ["E", "W", "F", "UP", "B", "SIM", "I", "N", "C4"]
ignore = ["E501", "B008"]
ignore = [
"E501",
"B008",
# StrEnum changes str()/format() output of FeedbackType/FeedbackStatus,
# which reach the database and API responses. Worth doing, not silently.
"UP042",
]
[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = ["ARG", "PLR2004"]
+66 -58
View File
@@ -1,20 +1,18 @@
"""FastMCP Feedback - Comprehensive feedback collection system for FastMCP servers.
"""FastMCP Feedback: feedback tools and call instrumentation for FastMCP servers.
This package provides a complete feedback collection and management system
that integrates seamlessly with FastMCP servers. It supports multiple
integration patterns, privacy-compliant analytics, and production-ready
database management.
Adds tools for submitting, listing and managing feedback (bug reports,
feature requests, improvements, questions) to a FastMCP server, stored in
SQLite or PostgreSQL through SQLAlchemy, with optional in-process usage
metrics. ``fastmcp_feedback.instrumentation`` records every tool call and
the events you report, so a feedback item can be traced back to the calls
that led to it.
Key Features:
- Modular mixin architecture for selective tool integration
- Privacy-compliant analytics with configurable data retention
- Support for SQLite (development) and PostgreSQL (production)
- Comprehensive error handling and logging
- Multiple server composition patterns
- Production-ready with connection pooling and health checks
The package does not configure logging. Its loggers live under
``fastmcp_feedback``; configure them in your server, or call
``setup_logging()`` for a basic stderr handler.
Quick Start:
Basic integration with existing FastMCP server:
Add the feedback tools to an existing server:
>>> from fastmcp import FastMCP
>>> from fastmcp_feedback import add_feedback_tools
@@ -22,19 +20,18 @@ Quick Start:
>>> app = FastMCP("My App")
>>> add_feedback_tools(app, database_url="sqlite:///feedback.db")
Create dedicated feedback server:
A dedicated feedback server:
>>> from fastmcp_feedback import create_feedback_server
>>>
>>> feedback_server = create_feedback_server("Feedback Service")
>>> # feedback_server now has all feedback tools ready
Selective tool integration using mixins:
Only some of the tools, through the mixins:
>>> from fastmcp_feedback import SubmissionMixin, RetrievalMixin
>>> from fastmcp_feedback import SubmissionMixin
>>> from fastmcp_feedback import FeedbackDatabase, FeedbackInsights
>>>
>>> db = FeedbackDatabase("postgresql://user:pass@host:5432/db")
>>> db = FeedbackDatabase("sqlite:///feedback.db")
>>> insights = FeedbackInsights(enabled=True, retention_days=90)
>>>
>>> submission = SubmissionMixin(db, insights)
@@ -44,7 +41,7 @@ Quick Start:
import logging
# Version information - CalVer YYYY.MM.DD
__version__ = "2026.01.12"
__version__ = "2026.10.04"
__author__ = "Ryan Malloy"
__email__ = "ryan@supported.systems"
@@ -142,38 +139,42 @@ from .tools import (
validate_tool_configuration,
)
# Package metadata
PACKAGE_INFO = {
"name": "fastmcp-feedback",
"version": __version__,
"description": "Comprehensive feedback collection system for FastMCP servers",
"author": __author__,
"email": __email__,
"keywords": ["fastmcp", "feedback", "mcp", "analytics", "database"],
"classifiers": [
"Development Status :: 5 - Production/Ready",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Database :: Database Engines/Servers",
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
],
"dependencies": ["fastmcp>=2.12.2", "sqlalchemy>=2.0.43", "pydantic>=2.11.7"],
"optional_dependencies": {
"postgresql": ["psycopg2-binary>=2.9.0"],
"dev": [
"pytest>=7.4.0",
"pytest-asyncio>=0.21.0",
"pytest-html>=3.2.0",
"pytest-cov>=4.1.0",
"ruff>=0.1.0",
],
},
}
def _package_info() -> dict:
"""Name, version, dependencies and extras from the installed metadata.
Read from the distribution rather than written out here, so it cannot
drift from pyproject.toml.
"""
import re
from importlib.metadata import PackageNotFoundError, metadata
info = {
"name": "fastmcp-feedback",
"version": __version__,
"author": __author__,
"email": __email__,
}
try:
meta = metadata("fastmcp-feedback")
except PackageNotFoundError:
return info
requires = meta.get_all("Requires-Dist") or []
extras = {}
for extra in meta.get_all("Provides-Extra") or []:
marker = re.compile(rf"""extra\s*==\s*['"]{re.escape(extra)}['"]""")
extras[extra] = [r.split(";")[0].strip() for r in requires if marker.search(r)]
info.update(
description=meta["Summary"],
keywords=[k.strip() for k in (meta["Keywords"] or "").split(",") if k],
classifiers=meta.get_all("Classifier") or [],
dependencies=[r for r in requires if "extra ==" not in r],
optional_dependencies=extras,
)
return info
PACKAGE_INFO = _package_info()
# Public API - these are the main exports users should use
__all__ = [
@@ -259,8 +260,8 @@ from fastmcp_feedback import create_feedback_server
main_app = FastMCP("Main Application")
feedback_service = create_feedback_server("Feedback Service")
# Import feedback service with prefix
main_app.import_server(feedback_service, prefix="feedback")
# Mount feedback service; its tools become feedback_<name>
main_app.mount(feedback_service, "feedback")
""",
"multi_tenant": """
from fastmcp import FastMCP
@@ -275,11 +276,18 @@ create_multi_tenant_tools(app, tenants)
""",
}
# Logging configuration
# Logging. A library leaves configuration to the application; the
# NullHandler keeps "No handlers could be found" noise away without
# printing anything.
logging.getLogger("fastmcp_feedback").addHandler(logging.NullHandler())
def setup_logging(level: str = "INFO") -> None:
"""Set up logging for FastMCP Feedback.
"""Send log records to stderr through ``logging.basicConfig``.
Opt-in: importing the package configures nothing. Call this when your
server has no logging set up and you want the package's messages.
``basicConfig`` does nothing if the root logger already has handlers.
Args:
level: Logging level (DEBUG, INFO, WARNING, ERROR).
@@ -293,9 +301,9 @@ def setup_logging(level: str = "INFO") -> None:
logger.info(f"FastMCP Feedback v{__version__} initialized")
# Auto-setup logging if not already configured
if not logging.getLogger().handlers:
setup_logging()
logging.getLogger("fastmcp_feedback").debug(
"FastMCP Feedback v%s initialized", __version__
)
def get_version_info() -> dict:
+37 -13
View File
@@ -3,14 +3,15 @@
import logging
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from enum import Enum
from typing import Any
from sqlalchemy import create_engine, text
from sqlalchemy import String, create_engine, func, select, text, type_coerce
from sqlalchemy.exc import OperationalError, SQLAlchemyError
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import QueuePool, StaticPool
from .models import Base
from .models import Base, Feedback, FeedbackStatus, FeedbackType
logger = logging.getLogger(__name__)
@@ -171,6 +172,38 @@ class FeedbackDatabase:
# Database utility functions
def enum_label(enum_cls: type[Enum], stored: Any) -> str:
"""The API spelling (``bug``, ``in_progress``) of a stored enum value.
SQLAlchemy stores enum members by NAME (``BUG``, ``IN_PROGRESS``): a
VARCHAR on SQLite, a native enum type on Postgres. The tools return
values. Anything that is neither a member, a name nor a value is
returned as a string rather than dropped.
"""
if isinstance(stored, enum_cls):
return str(stored.value)
stored = str(stored)
if stored in enum_cls.__members__:
return str(enum_cls[stored].value)
return stored
def count_feedback_by(session: Session, field: str) -> dict[str, int]:
"""Feedback counts per ``type`` or ``status``, keyed by enum value."""
column, enum_cls = {
"type": (Feedback.type, FeedbackType),
"status": (Feedback.status, FeedbackStatus),
}[field]
# type_coerce: read the raw stored label, so a row the enum cannot load
# is still counted instead of failing the whole query.
raw = type_coerce(column, String)
counts: dict[str, int] = {}
for stored, n in session.execute(select(raw, func.count()).group_by(raw)):
label = enum_label(enum_cls, stored)
counts[label] = counts.get(label, 0) + n
return counts
async def create_feedback_database(database_url: str | None = None) -> FeedbackDatabase:
"""Create and initialize a feedback database.
@@ -200,17 +233,8 @@ async def get_database_stats(db: FeedbackDatabase) -> dict[str, Any]:
total_result = session.execute(text("SELECT COUNT(*) FROM feedback"))
total_count = total_result.scalar()
# Get counts by type
type_result = session.execute(
text("SELECT type, COUNT(*) FROM feedback GROUP BY type")
)
by_type = dict(type_result.fetchall())
# Get counts by status
status_result = session.execute(
text("SELECT status, COUNT(*) FROM feedback GROUP BY status")
)
by_status = dict(status_result.fetchall())
by_type = count_feedback_by(session, "type")
by_status = count_feedback_by(session, "status")
return {
"total_count": total_count,
+4 -2
View File
@@ -260,8 +260,10 @@ def compose_feedback_services(
f"{service_name} Service", database_url, shared_insights
)
# Import service with prefix
main_server.import_server(service_server, prefix=service_name)
# mount() is sync on every supported FastMCP; the second positional
# arg is `prefix` on 2.x and `namespace` on 4.x. import_server() is
# gone in 4.x and was async (so silently never ran here) on 2.14.
main_server.mount(service_server, service_name)
logger.info(f"Composed {len(services)} feedback services into main server")
@@ -0,0 +1,144 @@
"""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",)))
mw.record_event("job.finished", key=job_id, attrs={"duration_s": 3600})
db = DatabaseSink(engine)
mw = instrument(app, [db, EmbeddingSink(OpenAIEmbedder(url, key), db)])
await mw.similar("render timed out")
await backfill_embeddings(mw.embedding_sink) # rows stored before embedding
"""
import importlib
from typing import TYPE_CHECKING, Any
from .classify import (
DEFAULT_RESULT_CLASSIFIER,
ResultClassifier,
soft_error_classifier,
)
from .constants import (
DEFAULT_EMBEDDING_DIM,
DEFAULT_EVENT_TEXT_KEYS,
DEFAULT_TABLE_PREFIX,
EMBEDDING_SOURCES,
)
from .dispatch import RecordDispatcher
from .events import EventRecord
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
if TYPE_CHECKING:
from .backfill import backfill_embeddings, backfill_feedback
from .embeddings import (
Embedder,
EmbeddingSink,
EmbeddingText,
OpenAIEmbedder,
extract_texts,
)
from .storage import DatabaseSink, EmbeddingVector, build_metadata
# Names whose modules need the async database stack (sqlalchemy[asyncio],
# which brings greenlet). Imported on first access, so instrument() and the
# plain sinks work on a base install.
_LAZY = {
"DatabaseSink": "storage",
"EmbeddingVector": "storage",
"build_metadata": "storage",
"Embedder": "embeddings",
"EmbeddingSink": "embeddings",
"EmbeddingText": "embeddings",
"OpenAIEmbedder": "embeddings",
"extract_texts": "embeddings",
"backfill_embeddings": "backfill",
"backfill_feedback": "backfill",
}
_EXTRA = {
"storage": "instrumentation",
"embeddings": "instrumentation,embeddings",
"backfill": "instrumentation,embeddings",
}
def _missing_module(exc: BaseException) -> str:
seen: BaseException | None = exc
while seen is not None:
if isinstance(seen, ImportError) and seen.name:
return seen.name
seen = seen.__cause__ or seen.__context__
return "a dependency"
def __getattr__(name: str) -> Any:
module = _LAZY.get(name)
if module is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
try:
# Checked first because SQLAlchemy reports a missing greenlet only on
# the first import of its asyncio module; a retry would then succeed
# and leave an engine that fails at runtime.
importlib.import_module("greenlet")
value = getattr(importlib.import_module(f".{module}", __name__), name)
except ImportError as exc:
extra = _EXTRA[module]
missing = _missing_module(exc)
raise ImportError(
f"fastmcp_feedback.instrumentation.{name} needs the async database "
f"stack, and {missing} is not installed. Install it with: "
f'uv add "fastmcp-feedback[{extra}]" '
f'(or pip install "fastmcp-feedback[{extra}]")'
) from exc
globals()[name] = value
return value
def __dir__() -> list[str]:
return sorted(set(globals()) | set(_LAZY))
__all__ = [
"CallbackSink",
"DatabaseSink",
"DEFAULT_EMBEDDING_DIM",
"DEFAULT_EVENT_TEXT_KEYS",
"DEFAULT_RESULT_CLASSIFIER",
"DEFAULT_SECRET_KEYS",
"DEFAULT_SECRET_PATTERNS",
"DEFAULT_TABLE_PREFIX",
"EMBEDDING_SOURCES",
"Embedder",
"EmbeddingSink",
"EmbeddingText",
"EmbeddingVector",
"EventRecord",
"InstrumentationMiddleware",
"JsonLinesSink",
"MemorySink",
"MODE_ENV_VAR",
"MODES",
"OpenAIEmbedder",
"RecordDispatcher",
"REDACTED",
"Redactor",
"ResultClassifier",
"Sink",
"ToolCallRecord",
"backfill_embeddings",
"backfill_feedback",
"build_metadata",
"extract_texts",
"instrument",
"soft_error_classifier",
]
@@ -0,0 +1,340 @@
"""Embed records that were stored before an EmbeddingSink was running.
``backfill_embeddings`` reads tool-call errors and events back out of the
DatabaseSink's tables, rebuilds the records, and sends them through the same
extraction, redaction and truncation as ``EmbeddingSink.write``. The texts,
hashes and source ids therefore match what the live sink would produce, so
records arriving later dedupe against backfilled rows, and a second run
embeds nothing new.
Command line: ``python -m fastmcp_feedback.instrumentation.backfill --help``.
"""
import asyncio
import logging
from collections.abc import AsyncIterator, Iterable
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import and_, inspect, or_, select, tuple_
from ..embeddings import EmbeddingSink, extract_texts
from ..events import EventRecord
from ..record import ToolCallRecord
from ..sinks import Record
from ..storage import EMBEDDING_SOURCES
logger = logging.getLogger(__name__)
FEEDBACK_KIND = "feedback.submitted"
LLM_KIND = "llm.call"
_CALL_COLUMNS = (
"id",
"tool",
"started_at",
"duration_ms",
"mode",
"ok",
"error_type",
"error_message",
)
def _utc(value: datetime | None) -> datetime | None:
if value is None or value.tzinfo is None:
return value if value is None else value.replace(tzinfo=UTC)
return value.astimezone(UTC)
class _Run:
"""Counts and limits for one backfill call."""
def __init__(
self,
sink: EmbeddingSink,
sources: Iterable[str],
limit: int | None,
dry_run: bool,
):
self.sink = sink
self.sources = tuple(sources)
self.limit = limit
self.dry_run = dry_run
self.counts = dict.fromkeys(self.sources, 0)
self.skipped = 0
self.errors = 0
self.sent = 0
self.have_table = True
@property
def done(self) -> bool:
return self.limit is not None and self.sent >= self.limit
def result(self) -> dict[str, int]:
return {**self.counts, "skipped": self.skipped, "errors": self.errors}
async def feed(self, records: list[Record]) -> None:
sink = self.sink
items = [
sink._prepare(i)
for i in extract_texts(
records, sources=self.sources, event_text_keys=sink.event_text_keys
)
]
# The last text for a source wins, as in the live sink.
latest = list({(i.source_type, i.source_id): i for i in items}.values())
if self.have_table:
todo, stored = await sink._unembedded(latest)
else:
todo, stored = latest, set()
self.skipped += len(latest) - len(todo)
if self.limit is not None:
todo = todo[: max(0, self.limit - self.sent)]
self.sent += len(todo)
if self.dry_run:
for item in todo:
self.counts[item.source_type] += 1
return
for start in range(0, len(todo), sink.batch_size):
chunk = todo[start : start + sink.batch_size]
try:
vectors = await asyncio.wait_for(
sink.embedder.embed([i.text for i in chunk]), sink.timeout
)
await sink._store(chunk, vectors, stored)
except Exception:
self.errors += len(chunk)
logger.warning(
"Backfill failed to embed %d texts with %s",
len(chunk),
sink.embedder.model,
exc_info=True,
)
continue
for item in chunk:
self.counts[item.source_type] += 1
async def _has_table(engine: Any, name: str) -> bool:
async with engine.connect() as conn:
return await conn.run_sync(lambda sync: inspect(sync).has_table(name))
async def _columns(engine: Any, name: str) -> set[str]:
async with engine.connect() as conn:
return await conn.run_sync(
lambda sync: {c["name"] for c in inspect(sync).get_columns(name)}
)
async def _pages(
engine: Any,
table: Any,
columns: list[Any],
ts: Any,
where: Any,
since: datetime | None,
until: datetime | None,
page_size: int,
) -> AsyncIterator[list[dict[str, Any]]]:
"""Rows matching ``where``, oldest first, ``page_size`` at a time.
Keyset paging on (timestamp, id), so rows written during the run neither
shift the pages nor get read twice.
"""
conditions = [where]
if since is not None:
conditions.append(ts >= since)
if until is not None:
conditions.append(ts < until)
last: tuple[Any, Any] | None = None
while True:
query = select(*columns).where(*conditions)
if last is not None:
query = query.where(tuple_(ts, table.c.id) > tuple_(*last))
query = query.order_by(ts, table.c.id).limit(page_size)
async with engine.connect() as conn:
rows = [dict(r) for r in (await conn.execute(query)).mappings()]
if not rows:
return
yield rows
if len(rows) < page_size:
return
last = (rows[-1][ts.name], rows[-1]["id"])
async def _backfill_calls(run: _Run, since, until, page_size) -> None:
db = run.sink.database_sink
t = db.table
have = await _columns(db.engine, t.name)
if "outcome" in have:
# Rows from before the outcome column was added have it NULL and
# were errors exactly when they carry an error message.
where = or_(
t.c.outcome.in_(("error", "soft_error")),
and_(t.c.outcome.is_(None), t.c.error_message.is_not(None)),
)
columns = [t.c[c] for c in (*_CALL_COLUMNS, "outcome")]
else:
where = t.c.error_message.is_not(None)
columns = [t.c[c] for c in _CALL_COLUMNS]
async for rows in _pages(
db.engine, t, columns, t.c.started_at, where, since, until, page_size
):
records: list[Record] = []
for row in rows:
row["outcome"] = row.get("outcome") or "error"
records.append(ToolCallRecord(**row))
await run.feed(records)
if run.done:
return
async def _backfill_events(run: _Run, since, until, page_size) -> None:
db = run.sink.database_sink
e = db.events
kinds = []
if "feedback" in run.sources:
kinds.append(e.c.kind == FEEDBACK_KIND)
if "llm" in run.sources:
kinds.append(e.c.kind == LLM_KIND)
if "event" in run.sources:
# Includes llm.call: one without prompt or completion is a plain event.
kinds.append(e.c.kind != FEEDBACK_KIND)
columns = [e.c.id, e.c.kind, e.c.key, e.c.occurred_at, e.c.attrs]
async for rows in _pages(
db.engine, e, columns, e.c.occurred_at, or_(*kinds), since, until, page_size
):
records: list[Record] = [
EventRecord(**{**row, "attrs": row["attrs"] or {}}) for row in rows
]
await run.feed(records)
if run.done:
return
async def _start(
sink: EmbeddingSink,
sources: Iterable[str] | None,
limit: int | None,
batch_size: int,
dry_run: bool,
) -> _Run:
chosen = tuple(sink.sources if sources is None else sources)
unknown = set(chosen) - set(EMBEDDING_SOURCES)
if unknown:
raise ValueError(
f"unknown sources {sorted(unknown)}; choose from {EMBEDDING_SOURCES}"
)
if batch_size < 1:
raise ValueError(f"batch_size must be at least 1, got {batch_size!r}")
if limit is not None and limit < 0:
raise ValueError(f"limit must not be negative, got {limit!r}")
run = _Run(sink, sorted(chosen, key=EMBEDDING_SOURCES.index), limit, dry_run)
db = sink.database_sink
if not dry_run:
await db.ensure_embeddings()
run.have_table = await _has_table(db.engine, db.embeddings.name)
if not run.have_table and not dry_run:
raise RuntimeError(
f"{db.embeddings.name} does not exist; create it with a migration "
"or pass create_tables=True to the DatabaseSink"
)
return run
async def backfill_embeddings(
embedding_sink: EmbeddingSink,
*,
sources: Iterable[str] | None = None,
since: datetime | None = None,
until: datetime | None = None,
limit: int | None = None,
batch_size: int = 500,
dry_run: bool = False,
) -> dict[str, int]:
"""Embed stored tool-call errors and events that have no embedding yet.
Reads the tables of ``embedding_sink.database_sink``:
- ``call_error``: calls with outcome ``error`` or ``soft_error``, and
calls with outcome NULL and an error message (rows written before the
outcome column existed).
- ``feedback``: ``feedback.submitted`` events.
- ``llm``: ``llm.call`` events with a prompt or completion.
- ``event``: other events with any of the sink's ``event_text_keys``.
Each text goes through the sink's extraction, redactor and ``max_chars``,
so it matches what the live sink makes of the same record. Texts already
stored with the same hash are skipped; a changed text replaces its row.
Args:
sources: which of the above; defaults to the sink's ``sources``.
since, until: only rows timestamped in [since, until). Naive
datetimes are taken as UTC.
limit: embed at most this many texts, oldest first. A later run
carries on, since what was embedded is then skipped.
batch_size: rows read per query. Texts are embedded in chunks of
the sink's ``batch_size``, each limited to its ``timeout``.
dry_run: count what would be embedded without calling the embedder
or writing anything.
Returns counts of texts embedded per source type, plus ``skipped``
(already embedded) and ``errors`` (texts whose embed or store failed;
logged, never raised). Raises ValueError for bad arguments and
RuntimeError when a table it needs does not exist.
"""
run = await _start(embedding_sink, sources, limit, batch_size, dry_run)
db = embedding_sink.database_sink
since, until = _utc(since), _utc(until)
if "call_error" in run.sources:
if not await _has_table(db.engine, db.table.name):
raise RuntimeError(f"{db.table.name} does not exist")
if not run.done:
await _backfill_calls(run, since, until, batch_size)
if set(run.sources) & {"feedback", "event", "llm"}:
if not await _has_table(db.engine, db.events.name):
raise RuntimeError(f"{db.events.name} does not exist")
if not run.done:
await _backfill_events(run, since, until, batch_size)
result = run.result()
logger.info("Backfill %s: %s", "dry run" if dry_run else "done", result)
return result
async def backfill_feedback(
embedding_sink: EmbeddingSink,
items: Iterable[tuple[str, str, str | None]],
*,
batch_size: int = 500,
dry_run: bool = False,
) -> dict[str, int]:
"""Embed feedback kept in the host's own tables, as (ref, title, description).
The text and source id are those of a ``feedback.submitted`` event with
key ``ref`` and those attrs, so feedback recorded later as an event
dedupes against these rows. Returns ``{"feedback", "skipped", "errors"}``
like ``backfill_embeddings``.
"""
run = await _start(embedding_sink, ("feedback",), None, batch_size, dry_run)
now = datetime.now(UTC)
page: list[Record] = []
for ref, title, description in items:
page.append(
EventRecord(
kind=FEEDBACK_KIND,
occurred_at=now,
key=str(ref),
attrs={"title": title, "description": description},
)
)
if len(page) >= batch_size:
await run.feed(page)
page = []
if page:
await run.feed(page)
return run.result()
__all__ = ["backfill_embeddings", "backfill_feedback"]
@@ -0,0 +1,5 @@
import sys
from .cli import main
sys.exit(main())
@@ -0,0 +1,143 @@
"""python -m fastmcp_feedback.instrumentation.backfill
Prints the counts as JSON. Exits 1 when any text failed to embed, 2 on a
setup error (bad arguments, missing table, unreachable database).
"""
import argparse
import asyncio
import json
import logging
import os
import sys
from datetime import datetime
from ..embeddings import DEFAULT_EVENT_TEXT_KEYS, EmbeddingSink, OpenAIEmbedder
from ..storage import DEFAULT_TABLE_PREFIX, EMBEDDING_SOURCES, DatabaseSink
from . import backfill_embeddings
API_KEY_ENV = "FFB_EMBED_API_KEY"
def _sources(value: str) -> tuple[str, ...]:
chosen = tuple(s.strip() for s in value.split(",") if s.strip())
unknown = set(chosen) - set(EMBEDDING_SOURCES)
if not chosen or unknown:
raise argparse.ArgumentTypeError(
f"choose from {','.join(EMBEDDING_SOURCES)}, comma separated"
)
return chosen
def _when(value: str) -> datetime:
try:
return datetime.fromisoformat(value)
except ValueError as exc:
raise argparse.ArgumentTypeError(f"not an ISO 8601 time: {value!r}") from exc
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="python -m fastmcp_feedback.instrumentation.backfill",
description=(
"Embed stored tool-call errors and events that have no embedding "
f"yet. The embedder's API key is read from ${API_KEY_ENV} (or the "
"variable named by --api-key-env)."
),
)
parser.add_argument(
"--database-url",
required=True,
help="async SQLAlchemy URL, e.g. postgresql+asyncpg://... or sqlite+aiosqlite:///calls.db",
)
parser.add_argument(
"--embed-base-url",
help="OpenAI-compatible base URL up to the version, e.g. https://api.example.com/v1 "
"(required unless --dry-run)",
)
parser.add_argument("--api-key-env", default=API_KEY_ENV, metavar="NAME")
parser.add_argument("--model", default="mxbai-embed-large")
parser.add_argument("--dim", type=int, default=1024)
parser.add_argument("--prefix", default=DEFAULT_TABLE_PREFIX)
parser.add_argument(
"--sources",
type=_sources,
default=EMBEDDING_SOURCES,
help=f"comma separated, default {','.join(EMBEDDING_SOURCES)}",
)
parser.add_argument(
"--event-text-keys",
default=",".join(DEFAULT_EVENT_TEXT_KEYS),
help="attrs making up an event text, comma separated",
)
parser.add_argument("--since", type=_when, help="ISO 8601; naive means UTC")
parser.add_argument("--until", type=_when, help="ISO 8601, exclusive")
parser.add_argument("--limit", type=int, help="embed at most this many texts")
parser.add_argument("--batch-size", type=int, default=500, help="rows per query")
parser.add_argument("--max-chars", type=int, default=4000)
parser.add_argument(
"--create-tables",
action="store_true",
help="create the embeddings table (and vector extension) if missing",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="count what would be embedded; call no embedder, write nothing",
)
parser.add_argument("-v", "--verbose", action="store_true")
return parser
async def _run(args: argparse.Namespace) -> dict[str, int]:
embedder = OpenAIEmbedder(
args.embed_base_url or "http://embedder.invalid/v1",
api_key=os.environ.get(args.api_key_env) or None,
model=args.model,
dim=args.dim,
)
db = DatabaseSink(
args.database_url,
prefix=args.prefix,
create_tables=args.create_tables and not args.dry_run,
embedding_dim=args.dim,
)
try:
sink = EmbeddingSink(
embedder,
db,
sources=args.sources,
event_text_keys=[k for k in args.event_text_keys.split(",") if k],
max_chars=args.max_chars,
)
return await backfill_embeddings(
sink,
since=args.since,
until=args.until,
limit=args.limit,
batch_size=args.batch_size,
dry_run=args.dry_run,
)
finally:
await db.aclose()
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if not args.dry_run and not args.embed_base_url:
parser.error("--embed-base-url is required unless --dry-run")
# The CLI owns logging; the package configures none. force: replace
# anything a host process (or a test runner) already set up.
logging.basicConfig(
level=logging.INFO if args.verbose else logging.WARNING,
format="%(levelname)s %(name)s: %(message)s",
force=True,
)
try:
counts = asyncio.run(_run(args))
except Exception as exc:
print(f"backfill failed: {type(exc).__name__}: {exc}", file=sys.stderr)
return 2
print(json.dumps({"dry_run": args.dry_run, **counts}))
return 1 if counts["errors"] else 0
@@ -0,0 +1,165 @@
"""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 lookup(result: dict, key: str) -> Any:
"""``result[key]``, where ``key`` may be a dotted path into nested dicts.
A key present exactly as written wins, so a literal ``"a.b"`` key is
found before ``result["a"]["b"]``. Otherwise ``"snapshot.error"`` walks
``result["snapshot"]["error"]``. A missing step, or a step that is not a
dict, gives None: the same as the key not being there. Never raises.
"""
if key in result:
return result[key]
if "." not in key:
return None
value: Any = result
for part in key.split("."):
if not isinstance(value, dict) or part not in value:
return None
value = value[part]
return value
def _message(
result: dict, depth: int = 0, keys: tuple[str, ...] = MESSAGE_KEYS
) -> str | None:
for key in keys:
value = lookup(result, key)
if value is None or value == "":
continue
if isinstance(value, dict):
# {"error": {"code": 404, "message": "..."}}
if depth == 0 and (inner := _message(value, 1, keys)):
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] = (),
error_keys: Iterable[str] = ("error",),
message_keys: Iterable[str] = MESSAGE_KEYS,
) -> 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 ``message_keys`` present
(``error``, ``message``, ``detail``, ``reason`` by default), e.g.
``status=failed: disk full``. A dict found there is searched one level
down with the same keys, as in ``{"error": {"message": "..."}}``.
Failing those, it is a soft error when any of ``error_keys`` holds a
non-empty string, as in ``{"played": False, "error": "output stalled"}``,
with that string as the message (``error: output stalled``). None, empty
or blank strings, numbers, booleans, dicts and lists there do not count,
so ``{"error": None}`` and ``{"error": 0.02}`` stay ok. ``error_keys=()``
turns this off.
A result that says it succeeded, with any of ``flag_keys`` exactly True,
skips only that last check: ``{"ok": True, "error": "deprecated arg"}``
is ok, the string being a warning. A failing status, a False flag and
``isError`` still flag it. Truthy non-bools such as ``1`` or ``"yes"``
are not an explicit success.
``message_keys`` and ``error_keys`` accept dotted paths into nested
dicts: ``"snapshot.error"`` reads ``result["snapshot"]["error"]``. A key
that literally contains a dot is matched first; a missing step or one
that is not a dict counts as the key not being there. The message label
for an ``error_keys`` hit is the path as written. ``status_keys`` and
``flag_keys`` are plain keys.
>>> classify = soft_error_classifier(extra_statuses=("unknown_target",))
>>> classify("render", {"status": "unknown_target", "error": "no such object"})
'status=unknown_target: no such object'
>>> classify("speak", {"played": False, "error": "output stalled"})
'error: output stalled'
>>> print(classify("speak", {"ok": True, "error": "deprecated arg"}))
None
>>> sitl = soft_error_classifier(
... status_keys=("status", "state", "reason"),
... extra_statuses=("died",),
... message_keys=("snapshot.error", "error", "message", "detail", "reason"),
... )
>>> sitl("wait_until", {"reason": "died", "snapshot": {"error": "SITL exited"}})
'reason=died: SITL exited'
"""
bad = frozenset(s.lower() for s in (*statuses, *extra_statuses))
status_keys = tuple(status_keys)
flag_keys = tuple(flag_keys)
error_keys = tuple(error_keys)
message_keys = tuple(message_keys)
def message(result: dict) -> str | None:
return _message(result, 0, message_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)
if any(result.get(key) is True for key in flag_keys):
return None
for key in error_keys:
value = lookup(result, key)
if isinstance(value, str) and value.strip():
return _describe(key, value.strip())
return None
return classify
DEFAULT_RESULT_CLASSIFIER = soft_error_classifier()
@@ -0,0 +1,12 @@
"""Defaults shared by the storage and embedding modules.
Kept apart from them so these import without the async database stack.
"""
DEFAULT_TABLE_PREFIX = "ffb_"
DEFAULT_EMBEDDING_DIM = 1024
# source_type values in {prefix}embeddings. Feedback rows survive pruning.
EMBEDDING_SOURCES = ("feedback", "call_error", "event", "llm")
FEEDBACK_SOURCE = "feedback"
# Attribute keys whose text an EmbeddingSink embeds from events.
DEFAULT_EVENT_TEXT_KEYS = ("error_excerpt", "error", "message", "reason")
@@ -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,182 @@
"""Background delivery of records to sinks, off the tool-call path."""
import asyncio
import contextlib
import logging
import threading
from collections.abc import Callable, Sequence
from .sinks import Record, 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[Record] | None = None
self._task: asyncio.Task | None = None
self._loop: asyncio.AbstractEventLoop | None = None
# submit_threadsafe can drop from any thread.
self._drop_lock = threading.Lock()
def submit(self, record: Record) -> 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
self._bind(loop)
try:
self._queue.put_nowait(record)
except asyncio.QueueFull:
self._drop("queue full")
return False
return True
def bind_loop(self) -> None:
"""Adopt the running loop, so other threads can submit before any record.
Called by the middleware as each tool call starts. Never raises.
"""
with contextlib.suppress(Exception):
self._bind(asyncio.get_running_loop())
def _bind(self, loop: asyncio.AbstractEventLoop) -> None:
# 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))
def submit_threadsafe(
self,
record: Record,
on_queued: Callable[[Record], None] | None = None,
) -> bool:
"""``submit`` that may be called from any thread. Never blocks or raises.
On the loop the dispatcher runs on, or on any loop when it has none
running, this is ``submit``. From another thread the record is handed
to the dispatcher's loop with ``call_soon_threadsafe``; True then means
it was handed over, and a queue that fills before it lands still drops
and counts it. With no running loop anywhere the record is dropped.
``on_queued(record)`` runs on the loop thread once the record is queued.
"""
try:
try:
running = asyncio.get_running_loop()
except RuntimeError:
running = None
home = self._loop
home_alive = home is not None and home.is_running()
if running is not None and (not home_alive or home is running):
return self._submit_and_notify(record, on_queued)
if not home_alive:
self._drop("no running event loop")
return False
queue = self._queue
if queue is not None and queue.full():
self._drop("queue full")
return False
home.call_soon_threadsafe(self._submit_and_notify, record, on_queued)
return True
except Exception:
# call_soon_threadsafe raises if the loop closed after the check.
self._drop("event loop unavailable")
return False
def _submit_and_notify(
self, record: Record, on_queued: Callable[[Record], None] | None
) -> bool:
if not self.submit(record):
return False
if on_queued is not None:
try:
on_queued(record)
except Exception:
logger.warning("on_queued callback failed", exc_info=True)
return True
def _drop(self, reason: str) -> None:
with self._drop_lock:
self.dropped += 1
dropped = self.dropped
if dropped == 1 or dropped % 1000 == 0:
logger.warning(
"Dropped instrumentation record (%s); %d dropped so far",
reason,
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[Record]) -> 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,674 @@
"""Embed feedback, tool errors, event text and LLM text for similarity search.
EmbeddingSink sits next to a DatabaseSink. It picks text out of each batch,
queues it on its own bounded queue and returns at once, so a slow embedding
backend never holds up the dispatcher. A background task embeds and stores
the vectors in ``{prefix}embeddings``.
"""
import asyncio
import contextlib
import hashlib
import logging
import math
import operator
import uuid
from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any, Protocol, runtime_checkable
from sqlalchemy import Float, and_, literal, not_, select, tuple_
from .constants import DEFAULT_EVENT_TEXT_KEYS, EMBEDDING_SOURCES
from .events import EventRecord
from .payload import is_truncated
from .record import ToolCallRecord
from .redaction import Redactor
from .sinks import Record
from .storage import EmbeddingVector
logger = logging.getLogger(__name__)
@runtime_checkable
class Embedder(Protocol):
"""Turns texts into vectors of length ``dim``, one per text, in order."""
model: str
dim: int
async def embed(self, texts: list[str]) -> list[list[float]]: ...
def _http_module() -> Any:
# FastMCP 2.x and 3.x depend on httpx, FastMCP 4 on its successor httpx2.
try:
import httpx
return httpx
except ImportError:
pass
try:
import httpx2
return httpx2
except ImportError as exc:
raise ImportError("OpenAIEmbedder needs httpx or httpx2") from exc
class OpenAIEmbedder:
"""An OpenAI-compatible ``POST {base_url}/embeddings`` endpoint.
Args:
base_url: up to and including the version, e.g.
``https://api.example.com/v1``.
api_key: sent as ``Authorization: Bearer ...`` when given.
model: the ``model`` field of each request.
dim: expected vector length. A response with any other length raises
ValueError, since it cannot be stored or compared.
batch_size: texts per request.
timeout: seconds per request.
transport: passed to the HTTP client. For tests.
"""
def __init__(
self,
base_url: str,
api_key: str | None = None,
model: str = "mxbai-embed-large",
dim: int = 1024,
batch_size: int = 32,
timeout: float = 30.0,
transport: Any = None,
):
if batch_size < 1:
raise ValueError(f"batch_size must be at least 1, got {batch_size!r}")
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.model = model
self.dim = dim
self.batch_size = batch_size
self.timeout = timeout
self.transport = transport
async def embed(self, texts: list[str]) -> list[list[float]]:
if not texts:
return []
http = _http_module()
headers = {"Content-Type": "application/json"}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
vectors: list[list[float]] = []
kwargs: dict[str, Any] = {"timeout": self.timeout}
if self.transport is not None:
kwargs["transport"] = self.transport
async with http.AsyncClient(**kwargs) as client:
for start in range(0, len(texts), self.batch_size):
chunk = texts[start : start + self.batch_size]
response = await client.post(
f"{self.base_url}/embeddings",
json={"model": self.model, "input": chunk},
headers=headers,
)
response.raise_for_status()
data = sorted(
response.json()["data"], key=lambda item: item.get("index", 0)
)
if len(data) != len(chunk):
raise ValueError(
f"asked for {len(chunk)} embeddings, got {len(data)}"
)
for item in data:
vector = [float(v) for v in item["embedding"]]
if len(vector) != self.dim:
raise ValueError(
f"{self.model} returned a {len(vector)}-dim vector, "
f"expected {self.dim}"
)
vectors.append(vector)
return vectors
@dataclass
class EmbeddingText:
"""One text waiting to be embedded, and where it came from."""
source_type: str
source_id: str
text: str
@property
def text_hash(self) -> str:
return hashlib.sha256(self.text.encode()).hexdigest()
# Flattening stops adding items past this many characters; max_chars cuts
# the result again later. Keeps a huge list from being joined in full.
_FLAT_CHARS = 65536
_FLAT_DEPTH = 32
def _clean(value: Any, depth: int = 0) -> str | None:
"""``value`` as stripped text, or None when there is none.
Lists and tuples become their items' texts, one per line. Dicts become
``key: value`` lines sorted by key: PostgreSQL JSONB does not keep key
order, and the text has to come out the same when a backfill rebuilds it
from a stored row. Other values go through ``str()``.
"""
if value is None:
return None
if is_truncated(value):
# A value over the middleware's max_payload_bytes: its start is all
# that was kept.
value = value.get("preview")
if isinstance(value, str):
text = value
elif isinstance(value, list | tuple | dict):
if depth >= _FLAT_DEPTH:
return None
if isinstance(value, dict):
pairs = sorted(value.items(), key=lambda kv: str(kv[0]))
parts = ((f"{k}: ", _clean(v, depth + 1)) for k, v in pairs)
else:
parts = (("", _clean(v, depth + 1)) for v in value)
lines: list[str] = []
size = 0
for label, part in parts:
if not part:
continue
lines.append(label + part)
size += len(lines[-1]) + 1
if size > _FLAT_CHARS:
break
text = "\n".join(lines)
else:
text = str(value)
text = text.strip()
return text or None
def extract_texts(
records: Iterable[Record],
*,
sources: Iterable[str] = EMBEDDING_SOURCES,
event_text_keys: Sequence[str] = DEFAULT_EVENT_TEXT_KEYS,
) -> list[EmbeddingText]:
"""The texts worth embedding in a batch of records, unredacted and untruncated.
``call_error``: calls with outcome ``error`` or ``soft_error``, as
``"{tool}: {error_type}: {error_message}"``, keyed by the call id.
``feedback``: ``feedback.submitted`` events, title and description,
keyed by the event's ``key`` (the feedback ref).
``llm``: ``llm.call`` events with ``prompt`` or ``completion`` attrs.
``event``: any other event with one of ``event_text_keys`` in its attrs,
those values joined by newlines, keyed by the event id. An ``llm.call``
without prompt or completion counts as a plain event, so a failed LLM
call's ``error`` is still embedded.
List and tuple values become one line per item, dicts ``key: value``
lines sorted by key, at any depth.
"""
wanted = frozenset(sources)
out: list[EmbeddingText] = []
for record in records:
if isinstance(record, ToolCallRecord):
if "call_error" in wanted and record.outcome in ("error", "soft_error"):
parts = [record.tool, record.error_type, _clean(record.error_message)]
out.append(
EmbeddingText(
"call_error", record.id, ": ".join(p for p in parts if p)
)
)
continue
if not isinstance(record, EventRecord):
continue
attrs = record.attrs or {}
if record.kind == "feedback.submitted":
if "feedback" in wanted:
body = "\n".join(
t
for t in (
_clean(attrs.get("title")),
_clean(attrs.get("description")),
)
if t
)
if body:
out.append(EmbeddingText("feedback", record.key or record.id, body))
continue
if record.kind == "llm.call":
prompt, completion = (
_clean(attrs.get("prompt")),
_clean(attrs.get("completion")),
)
if prompt or completion:
if "llm" in wanted:
lines = []
if prompt:
lines.append(f"prompt: {prompt}")
if completion:
lines.append(f"completion: {completion}")
out.append(EmbeddingText("llm", record.id, "\n".join(lines)))
continue
if "event" in wanted:
values = [_clean(attrs.get(k)) for k in event_text_keys]
body = "\n".join(v for v in values if v)
if body:
out.append(EmbeddingText("event", record.id, body))
return out
def _dot(a: Sequence[float], b: Sequence[float]) -> float:
sumprod = getattr(math, "sumprod", None) # 3.12+
if sumprod is not None:
return sumprod(a, b)
return sum(map(operator.mul, a, b))
def cosine_score(a: Sequence[float], b: Sequence[float]) -> float | None:
"""1 - cosine distance, clamped to [0, 1]. None for a zero vector."""
na, nb = math.sqrt(_dot(a, a)), math.sqrt(_dot(b, b))
if not na or not nb:
return None
return _clamp(_dot(a, b) / (na * nb))
def _clamp(score: float) -> float:
return min(1.0, max(0.0, score))
class EmbeddingSink:
"""Embed text from records and store the vectors through a DatabaseSink.
``write`` only queues, so it returns at once whatever the embedder is
doing. A background task embeds up to ``batch_size`` texts at a time and
upserts them into ``{prefix}embeddings`` on ``database_sink.engine``. A
full queue drops the text and counts it in ``dropped``; embedder and
database failures are logged and counted in ``errors``, never raised.
A text whose (source_type, source_id, model) row already holds the same
text is not embedded again; a changed text replaces the row.
Text is redacted with ``redactor`` (the middleware's, when this sink is
passed to ``instrument``) and cut to ``max_chars`` before it is embedded
or stored.
Args:
embedder: anything with ``model``, ``dim`` and ``async embed(texts)``.
database_sink: the DatabaseSink whose engine and tables to use. Its
``embedding_dim`` must equal ``embedder.dim``.
sources: which of ``feedback``, ``call_error``, ``event`` and ``llm``
to embed.
event_text_keys: attrs whose values make up an ``event`` text.
max_chars: longest text embedded.
max_queue: texts waiting to be embedded before new ones are dropped.
batch_size: texts per embed call.
timeout: seconds one embed call may take, and how long ``aclose``
waits for the queue to drain.
scan_limit: without pgvector, ``similar`` compares against the newest
this many rows.
"""
def __init__(
self,
embedder: Embedder,
database_sink: Any,
*,
sources: Iterable[str] = EMBEDDING_SOURCES,
event_text_keys: Sequence[str] = DEFAULT_EVENT_TEXT_KEYS,
max_chars: int = 4000,
max_queue: int = 1000,
batch_size: int = 32,
timeout: float = 30.0,
scan_limit: int = 5000,
redactor: Redactor | None = None,
):
self.sources = frozenset(sources)
unknown = self.sources - set(EMBEDDING_SOURCES)
if unknown:
raise ValueError(
f"unknown sources {sorted(unknown)}; choose from {EMBEDDING_SOURCES}"
)
if embedder.dim != database_sink.embedding_dim:
raise ValueError(
f"embedder {embedder.model!r} makes {embedder.dim}-dim vectors but "
f"the DatabaseSink's embeddings table holds "
f"{database_sink.embedding_dim}; pass embedding_dim={embedder.dim} "
"to DatabaseSink"
)
if max_chars < 1 or max_queue < 1 or batch_size < 1 or scan_limit < 1:
raise ValueError(
"max_chars, max_queue, batch_size and scan_limit must be positive"
)
self.embedder = embedder
self.database_sink = database_sink
self.table = database_sink.embeddings
self.event_text_keys = tuple(event_text_keys)
self.max_chars = max_chars
self.max_queue = max_queue
self.batch_size = batch_size
self.timeout = timeout
self.scan_limit = scan_limit
self.redactor = redactor
self._default_redactor = Redactor()
self.dropped = 0
self.errors = 0
self.embedded = 0
self.skipped = 0
self._queue: asyncio.Queue[EmbeddingText] | None = None
self._task: asyncio.Task | None = None
self._loop: asyncio.AbstractEventLoop | None = None
self._warned_scan = False
# Writing
async def write(self, records: Sequence[Record]) -> None:
try:
items = extract_texts(
records, sources=self.sources, event_text_keys=self.event_text_keys
)
except Exception:
self.errors += 1
logger.warning("Failed to extract texts to embed", exc_info=True)
return
for item in items:
self._enqueue(self._prepare(item))
def _prepare(self, item: EmbeddingText) -> EmbeddingText:
redactor = self.redactor or self._default_redactor
item.text = redactor.redact_text(item.text)[: self.max_chars]
return item
def _enqueue(self, item: EmbeddingText) -> None:
loop = asyncio.get_running_loop()
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(item)
except asyncio.QueueFull:
self.dropped += 1
if self.dropped == 1 or self.dropped % 1000 == 0:
logger.warning(
"Embedding queue full; %d texts dropped so far", 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._process(batch)
except Exception:
self.errors += 1
logger.warning(
"Failed to embed %d texts with %s",
len(batch),
self.embedder.model,
exc_info=True,
)
finally:
for _ in batch:
queue.task_done()
async def _process(self, batch: list[EmbeddingText]) -> None:
# The last text for a source wins within a batch.
latest = {(i.source_type, i.source_id): i for i in batch}
db = self.database_sink
await db._ready()
await db.ensure_embeddings()
todo, stored = await self._unembedded(list(latest.values()))
self.skipped += len(batch) - len(todo)
if not todo:
return
vectors = await asyncio.wait_for(
self.embedder.embed([i.text for i in todo]), self.timeout
)
await self._store(todo, vectors, stored)
self.embedded += len(todo)
async def _unembedded(
self, items: list[EmbeddingText]
) -> tuple[list[EmbeddingText], set[tuple[str, str]]]:
"""The items whose stored row is missing or holds another text, and
the keys that have a row (to be replaced). Items must be unique by
(source_type, source_id)."""
if not items:
return [], set()
t = self.table
keys = [(i.source_type, i.source_id) for i in items]
async with self.database_sink.engine.connect() as conn:
rows = (
await conn.execute(
select(t.c.source_type, t.c.source_id, t.c.text_hash).where(
t.c.model == self.embedder.model,
tuple_(t.c.source_type, t.c.source_id).in_(keys),
)
)
).all()
stored = {(r.source_type, r.source_id): r.text_hash for r in rows}
todo = [
i for i in items if stored.get((i.source_type, i.source_id)) != i.text_hash
]
return todo, set(stored)
async def _store(
self,
todo: list[EmbeddingText],
vectors: list[list[float]],
replace: set[tuple[str, str]],
) -> None:
db = self.database_sink
if len(vectors) != len(todo):
raise ValueError(
f"embedder returned {len(vectors)} vectors for {len(todo)}"
)
for vector in vectors:
if len(vector) != db.embedding_dim:
raise ValueError(
f"embedder returned a {len(vector)}-dim vector, "
f"expected {db.embedding_dim}"
)
now = datetime.now(UTC)
values = [
{
"id": str(uuid.uuid4()),
"source_type": i.source_type,
"source_id": i.source_id,
"model": self.embedder.model,
"dim": len(v),
"text_hash": i.text_hash,
"text": i.text,
"created_at": now,
"embedding": [float(x) for x in v],
}
for i, v in zip(todo, vectors, strict=True)
]
await self._upsert(values, replace=replace)
async def _upsert(self, values: list[dict[str, Any]], replace: set) -> None:
t = self.table
engine = self.database_sink.engine
dialect = engine.dialect.name
changing = ("text_hash", "text", "created_at", "embedding", "dim")
if dialect in ("postgresql", "sqlite"):
if dialect == "postgresql":
from sqlalchemy.dialects.postgresql import insert
else:
from sqlalchemy.dialects.sqlite import insert
stmt = insert(t)
stmt = stmt.on_conflict_do_update(
index_elements=[t.c.source_type, t.c.source_id, t.c.model],
set_={c: stmt.excluded[c] for c in changing},
)
async with engine.begin() as conn:
await conn.execute(stmt, values)
return
async with engine.begin() as conn:
for row in values:
key = (row["source_type"], row["source_id"])
if key in replace:
await conn.execute(
t.update()
.where(
t.c.source_type == row["source_type"],
t.c.source_id == row["source_id"],
t.c.model == row["model"],
)
.values({c: row[c] for c in changing})
)
else:
await conn.execute(t.insert(), [row])
async def flush(self) -> None:
"""Wait until every queued text has been embedded (or failed)."""
if self._queue is not None and self._loop is asyncio.get_running_loop():
await self._queue.join()
async def aclose(self) -> None:
"""Drain the queue for up to ``timeout`` seconds, then stop.
Texts still queued after that are dropped and counted. Disposes the
DatabaseSink's engine again if it owns it, since the dispatcher may
have closed that sink first.
"""
if self._queue is not None and self._loop is asyncio.get_running_loop():
try:
await asyncio.wait_for(self._queue.join(), self.timeout)
except TimeoutError:
left = self._queue.qsize()
self.dropped += left
logger.warning(
"Embedding queue did not drain in %ss; %d texts dropped",
self.timeout,
left,
)
if self._task is not None:
self._task.cancel()
with contextlib.suppress(asyncio.CancelledError, RuntimeError):
await self._task
self._task = None
self._queue = None
if getattr(self.database_sink, "_owns_engine", False):
await self.database_sink.engine.dispose()
# Searching
async def similar(
self,
text: str,
*,
source_types: Iterable[str] | None = None,
k: int = 10,
min_score: float | None = None,
) -> list[dict[str, Any]]:
"""Stored texts closest to ``text``. Raises on failure; see the middleware."""
text = (self.redactor or self._default_redactor).redact_text(text)[
: self.max_chars
]
[vector] = await asyncio.wait_for(self.embedder.embed([text]), self.timeout)
return await self.nearest(
vector, source_types=source_types, k=k, min_score=min_score
)
async def vector_for(self, source_type: str, source_id: str) -> list[float] | None:
"""The stored vector for a source under this embedder's model, if any."""
t = self.table
async with self.database_sink.engine.connect() as conn:
row = (
await conn.execute(
select(t.c.embedding).where(
t.c.source_type == source_type,
t.c.source_id == str(source_id),
t.c.model == self.embedder.model,
)
)
).first()
return None if row is None else list(row.embedding)
async def nearest(
self,
vector: Sequence[float],
*,
source_types: Iterable[str] | None = None,
k: int = 10,
min_score: float | None = None,
exclude: tuple[str, str] | None = None,
) -> list[dict[str, Any]]:
"""Rows closest to ``vector`` by cosine, best first, as dicts with
``source_type``, ``source_id``, ``text``, ``score`` and ``created_at``.
``score`` is 1 - cosine distance, clamped to [0, 1]. Only rows made by
this embedder's model are compared. ``exclude`` is a
(source_type, source_id) left out of the results.
"""
if k < 1:
return []
t = self.table
conditions = [t.c.model == self.embedder.model]
if source_types is not None:
conditions.append(t.c.source_type.in_(list(source_types)))
if exclude is not None:
conditions.append(
not_(and_(t.c.source_type == exclude[0], t.c.source_id == exclude[1]))
)
engine = self.database_sink.engine
cols = (t.c.source_type, t.c.source_id, t.c.text, t.c.created_at)
results: list[dict[str, Any]] = []
if engine.dialect.name == "postgresql":
distance = t.c.embedding.op("<=>", return_type=Float)(
literal(list(vector), type_=EmbeddingVector(len(vector)))
)
query = (
select(*cols, distance.label("distance"))
.where(*conditions)
.order_by(distance)
.limit(k)
)
async with engine.connect() as conn:
for row in (await conn.execute(query)).mappings():
row = dict(row)
distance_value = row.pop("distance")
if distance_value is None or math.isnan(distance_value):
continue
row["score"] = _clamp(1.0 - distance_value)
results.append(row)
else:
query = (
select(*cols, t.c.embedding)
.where(*conditions)
.order_by(t.c.created_at.desc())
.limit(self.scan_limit + 1)
)
async with engine.connect() as conn:
rows = [dict(r) for r in (await conn.execute(query)).mappings()]
if len(rows) > self.scan_limit:
rows = rows[: self.scan_limit]
if not self._warned_scan:
self._warned_scan = True
logger.info(
"Similarity search without pgvector compares only the "
"newest %d embeddings (scan_limit)",
self.scan_limit,
)
for row in rows:
score = cosine_score(vector, row.pop("embedding"))
if score is None:
continue
row["score"] = score
results.append(row)
results.sort(key=lambda r: r["score"], reverse=True)
results = results[:k]
if min_score is not None:
results = [r for r in results if r["score"] >= min_score]
for row in results:
created = row["created_at"]
if created is not None and created.tzinfo is None:
row["created_at"] = created.replace(tzinfo=UTC)
return results
@@ -0,0 +1,95 @@
"""Events: things worth recording that are not tool calls.
A background job changing state, an LLM request made on a tool's behalf. Each
event has a dotted ``kind``, an optional ``key`` to join on (a job id), and,
when recorded while a tool call is running, that call's id.
"""
import math
import uuid
from dataclasses import asdict, dataclass, field
from datetime import datetime
from typing import Any, ClassVar
# Column widths in {prefix}events. Longer values are refused rather than
# truncated, since a cut key would silently stop joining.
MAX_KIND_CHARS = 128
MAX_KEY_CHARS = 255
@dataclass
class EventRecord:
"""One event, as passed to sinks next to ``ToolCallRecord``.
``call_id`` is the id of the tool call that was running when the event was
recorded, None outside a call. ``attrs`` is already redacted.
"""
record_type: ClassVar[str] = "event"
kind: str
occurred_at: datetime
id: str = field(default_factory=lambda: str(uuid.uuid4()))
key: str | None = None
call_id: str | None = None
session_id: str | None = None
user_sub: str | None = None
caller_kind: str | None = None
client_id: str | None = None
server_version: str | None = None
attrs: dict[str, Any] = field(default_factory=dict)
def to_row(self) -> dict[str, Any]:
"""Column values for the events table, with ``attrs`` made JSON-safe."""
data = asdict(self)
data["attrs"] = jsonable(data["attrs"])
return data
def to_dict(self) -> dict[str, Any]:
"""JSON-friendly form, with the timestamp as ISO 8601."""
data = self.to_row()
data["occurred_at"] = self.occurred_at.isoformat()
return data
@dataclass
class CallScope:
"""The tool call in progress, visible to ``record_event`` through a ContextVar.
Tasks started inside a tool inherit a copy of the context and outlive the
call, so the scope is switched off when the call ends instead of relying
on the ContextVar reset alone.
``user_sub``, ``caller_kind`` and ``client_id`` are the call's identity,
resolved before the tool runs, so events recorded inside it carry them.
"""
id: str
session_id: str | None
active: bool = True
events: int = 0
user_sub: str | None = None
caller_kind: str | None = None
client_id: str | None = None
def jsonable(value: Any, depth: int = 0) -> Any:
"""A copy of ``value`` that any JSON column accepts.
Non-finite floats (a diverged residual) become strings, since Postgres
JSONB rejects NaN and one bad row would fail the whole batch. Other
unknown types become ``str(value)``.
"""
if depth > 32:
return "[TRUNCATED]"
if value is None or isinstance(value, bool | int | str):
return value
if isinstance(value, float):
return value if math.isfinite(value) else str(value)
if isinstance(value, dict):
return {str(k): jsonable(v, depth + 1) for k, v in value.items()}
if isinstance(value, list | tuple | set | frozenset):
return [jsonable(v, depth + 1) for v in value]
if isinstance(value, datetime):
return value.isoformat()
return str(value)
@@ -0,0 +1,952 @@
"""FastMCP middleware that records every tool call, and events you report."""
import asyncio
import contextlib
import contextvars
import inspect
import logging
import os
import random as _random
import sys
import time
import uuid
from collections import deque
from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any, NamedTuple
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 .events import (
MAX_KEY_CHARS,
MAX_KIND_CHARS,
CallScope,
EventRecord,
jsonable,
)
from .payload import DEFAULT_MAX_PAYLOAD_BYTES, cap, cap_attrs, json_size
from .record import ToolCallRecord
from .redaction import Redactor
from .sinks import JsonLinesSink, Sink
if TYPE_CHECKING:
from .embeddings import EmbeddingSink
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 _tool_name(context: MiddlewareContext) -> str:
return getattr(context.message, "name", None) or "unknown"
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
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. Runs before
the tool, so events recorded during the call get that identity;
it also runs for calls that sampling later drops, and an async
one can delay the tool by up to ``hook_timeout``; keep it cheap.
When that first answer is empty (nothing, or only None values,
including a run that failed or timed out) or lacks any of
``late_identity_keys``, it runs again after the tool, alongside
the enricher, and the call's own record takes the second
answer's values where the first had none. Any other answer means
one run, even without all three columns.
late_identity_keys: identity keys that may only exist once the tool
has run, such as an id a registration or login tool creates. A
first answer without one of them gets the second run. Empty by
default.
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.
exclude_tools: tools that are never recorded, nor kept for
correlation. The calls themselves are untouched.
sample_rates: tool name to the fraction of its ok calls recorded, in
(0, 1]. Failures (``error`` and ``soft_error``) are always
recorded. Sampled-in ok rows carry ``sample_rate``; calls
sampled out are not kept for correlation either.
random: ``() -> float`` in [0, 1) deciding sampling; a call is kept
when it returns less than the tool's rate. For tests.
recent_events: how many recent events to keep in memory for
``events_for``, including ones still waiting to reach the sinks.
capture_llm_text: store ``prompt`` and ``completion`` given to
``record_llm_call``. Off by default, since chat text can hold
anything a user typed; when off they are discarded, including
``prompt``/``completion`` keys passed in its ``attrs``.
max_llm_text_chars: longest prompt or completion kept. Defaults to
``8 * max_error_chars`` (4000).
max_payload_bytes: largest stored arguments, result (mode ``full``)
or event ``attrs``, in bytes of JSON; None for no limit. Larger
ones are stored as ``{"_truncated": true, "size": ..., "preview":
...}`` markers, after redaction; for attrs, only the largest
values are replaced. See ``fastmcp_feedback.instrumentation.payload``.
Exclusion and sampling apply to tool calls only; events are always
recorded. A sampled tool's call is kept whenever an event was recorded
during it, so the event's ``call_id`` always has a row.
"""
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,
late_identity_keys: Iterable[str] = (),
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,
exclude_tools: Iterable[str] = (),
sample_rates: Mapping[str, float] | None = None,
random: Callable[[], float] = _random.random,
recent_events: int = 2000,
capture_llm_text: bool = False,
max_llm_text_chars: int | None = None,
max_payload_bytes: int | None = DEFAULT_MAX_PAYLOAD_BYTES,
):
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.late_identity_keys = tuple(late_identity_keys)
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.exclude_tools = frozenset(exclude_tools)
self.sample_rates: dict[str, float] = {}
for tool, rate in (sample_rates or {}).items():
rate = float(rate)
if not 0 < rate <= 1: # also rejects NaN
raise ValueError(
f"sample rate for {tool!r} must be in (0, 1], got {rate!r}"
)
self.sample_rates[tool] = rate
self.random = random
self.recent: deque[ToolCallRecord] = deque(maxlen=recent_calls)
self.recent_events: deque[EventRecord] = deque(maxlen=recent_events)
# One per middleware, so events only link to calls this one records.
self._call_scope: contextvars.ContextVar[CallScope | None] = (
contextvars.ContextVar(f"ffb_call_scope_{id(self):x}", default=None)
)
self.capture_llm_text = capture_llm_text
if max_payload_bytes is not None and (
isinstance(max_payload_bytes, bool)
or not isinstance(max_payload_bytes, int)
or max_payload_bytes <= 0
):
raise ValueError(
f"max_payload_bytes must be a positive int or None, "
f"got {max_payload_bytes!r}"
)
self.max_payload_bytes = max_payload_bytes
self.max_llm_text_chars = (
max_llm_text_chars
if max_llm_text_chars is not None
else 8 * max_error_chars
)
sinks = list(sinks) if sinks is not None else [JsonLinesSink()]
for sink in sinks:
# Embedded text gets this middleware's redaction rules too.
if _is_sink(sink, "embeddings", "EmbeddingSink") and sink.redactor is None:
sink.redactor = self.redactor
self.dispatcher = RecordDispatcher(
sinks,
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)
# Sync tools run in worker threads; their events need the loop known.
self.dispatcher.bind_loop()
if _tool_name(context) in self.exclude_tools:
return await call_next(context)
# Resolved before the tool runs, so events recorded inside it carry
# the caller; the call's own record reuses it.
who = await self._resolve_identity(context)
result = None
error: BaseException | None = None
scope = token = None
try:
# The record id exists before the tool runs, so events recorded
# inside it carry the id the stored call row will have.
scope = CallScope(
id=str(uuid.uuid4()),
session_id=session_key(context.fastmcp_context),
user_sub=who.column("user_sub"),
caller_kind=who.column("caller_kind"),
client_id=who.column("client_id"),
)
token = self._call_scope.set(scope)
except Exception:
logger.warning("Failed to open call scope", exc_info=True)
started_at = datetime.now(UTC)
start = time.perf_counter()
try:
result = await call_next(context)
return result
except BaseException as exc:
error = exc
raise
finally:
duration_ms = (time.perf_counter() - start) * 1000
if scope is not None:
scope.active = False
if token is not None:
# ValueError when finishing in another context (a task group).
with contextlib.suppress(ValueError):
self._call_scope.reset(token)
try:
await self._record(
context, started_at, duration_ms, result, error, scope, who
)
except Exception:
logger.warning("Failed to record tool call", exc_info=True)
async def _record(
self,
context: MiddlewareContext,
started_at: datetime,
duration_ms: float,
result: Any,
error: BaseException | None,
scope: CallScope | None,
who: "_Identity",
) -> None:
tool = _tool_name(context)
payload = _result_payload(result) if result is not None else None
soft = None
if error is None and self.result_classifier is not None and payload is not None:
soft = self._classify(tool, result, payload)
rate = self.sample_rates.get(tool)
# Failures are always kept, and their rows carry no sample_rate. So are
# calls that recorded events, which would otherwise point at nothing.
if rate is not None and (
error is not None or soft is not None or (scope and scope.events)
):
rate = None
elif rate is not None and not self.random() < rate:
return
record = await self._build_record(
context, started_at, duration_ms, payload, error, soft, rate, who
)
if scope is not None:
record.id = scope.id
self.recent.append(record)
self.dispatcher.submit(record)
async def _resolve_identity(self, context: MiddlewareContext) -> "_Identity":
"""Run the identity resolver once. Never raises.
Returns the redacted result split into the three columns and the
rest, plus the FastMCP context's ``client_id`` as the fallback for a
resolver that gives none.
"""
fallback = None
try:
fallback = _attr(context.fastmcp_context, "client_id")
identity = await self._call_hook(self.identity_resolver, context)
identity = self.redactor.redact(identity)
columns = {k: identity.pop(k) for k in _IDENTITY_COLUMNS if k in identity}
return _Identity(columns, identity, fallback)
except Exception:
logger.warning("Failed to resolve identity", exc_info=True)
return _Identity({}, {}, fallback)
def _identity_incomplete(self, who: "_Identity") -> bool:
"""Whether the resolver's first answer lacks something it may know later."""
if self.identity_resolver is None:
return False
answered = any(
v is not None for v in (*who.columns.values(), *who.rest.values())
)
return not answered or any(
who.rest.get(k) is None and who.columns.get(k) is None
for k in self.late_identity_keys
)
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,
payload: Any,
error: BaseException | None,
soft: str | None,
sample_rate: float | None,
who: "_Identity",
) -> ToolCallRecord:
tool = _tool_name(context)
args = getattr(context.message, "arguments", None) or {}
ctx = context.fastmcp_context
enrich = self._call_hook(self.enricher, tool, args, payload, context)
if self._identity_incomplete(who):
# A tool can create the identity it runs under (a registration,
# a login). Ask again, concurrently with the enricher so the
# record is not built any later than the slower of the two.
later, extra = await asyncio.gather(self._resolve_identity(context), enrich)
who = who.filled_from(later)
else:
extra = await enrich
identity = dict(who.rest)
columns = {k: who.column(k) for k in _IDENTITY_COLUMNS}
extra = self.redactor.redact(extra)
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 soft is not None:
outcome = "soft_error"
error_type = "SoftError"
error_message = (
self.redactor.redact_text(soft)[: self.max_error_chars] or None
)
# Measured once: the size columns and the payload limit share it.
args_size = json_size(args)
result_size = json_size(payload) if payload is not None else 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"),
user_sub=columns.get("user_sub"),
caller_kind=columns.get("caller_kind"),
identity=identity,
server_version=server_version,
args_size=args_size,
result_size=result_size,
args=cap(self.redactor.redact(args), args_size, self.max_payload_bytes)
if full
else None,
result=cap(
self.redactor.redact(payload), result_size, self.max_payload_bytes
)
if full and payload is not None
else None,
extra=extra,
sample_rate=sample_rate,
)
def record_event(
self,
kind: str,
*,
key: Any = None,
attrs: Mapping[str, Any] | None = None,
user_sub: str | None = None,
call_id: str | None = None,
caller_kind: str | None = None,
client_id: str | None = None,
) -> bool:
"""Record something that is not a tool call. Never raises or blocks.
Safe from any thread. Inside a tool call the event gets that call's
id, session, and the ``user_sub``, ``caller_kind`` and ``client_id``
the identity resolver gave the call; each of those given here wins.
Outside a call (including a task the call started that is still
running after it returned) only what is passed is stored. ``attrs`` is redacted like
call arguments and made JSON-safe. ``kind`` must be a non-empty string
of at most 128 characters and ``key`` at most 255 once made a string.
A ``key`` used for joining must stay unique for the retention period
and across restarts, so not a per-boot counter: use a UUID or a
startup token plus a counter.
Returns False when the event was not recorded: mode ``off``, an invalid
``kind``, ``key`` or ``attrs``, a full queue, or no event loop to hand
it to (called from a thread before anything was recorded on the
server's loop). From another thread, True means handed to the loop.
"""
try:
if self.mode == "off":
return False
if not isinstance(kind, str) or not kind.strip():
logger.warning("record_event needs a non-empty str kind, got %r", kind)
return False
if len(kind) > MAX_KIND_CHARS:
logger.warning(
"Event kind longer than %d chars: %r", MAX_KIND_CHARS, kind
)
return False
if key is not None:
key = str(key)
if len(key) > MAX_KEY_CHARS:
logger.warning("Event key longer than %d chars", MAX_KEY_CHARS)
return False
if attrs is not None and not isinstance(attrs, Mapping):
logger.warning("Event attrs must be a mapping, got %s", type(attrs))
return False
session_id = None
scope = self._call_scope.get()
if scope is not None and scope.active:
scope.events += 1
session_id = scope.session_id
if call_id is None:
call_id = scope.id
if user_sub is None:
user_sub = scope.user_sub
if caller_kind is None:
caller_kind = scope.caller_kind
if client_id is None:
client_id = scope.client_id
record = EventRecord(
kind=kind,
occurred_at=datetime.now(UTC),
key=key,
call_id=None if call_id is None else str(call_id),
session_id=session_id,
user_sub=None if user_sub is None else str(user_sub),
caller_kind=None if caller_kind is None else str(caller_kind),
client_id=None if client_id is None else str(client_id),
server_version=self.server_version,
attrs=cap_attrs(
jsonable(self.redactor.redact(dict(attrs or {}))),
self.max_payload_bytes,
),
)
return self.dispatcher.submit_threadsafe(record, self.recent_events.append)
except Exception:
logger.warning("Failed to record event %r", kind, exc_info=True)
return False
def record_llm_call(
self,
*,
model: str,
provider: str | None = None,
duration_ms: float | None = None,
input_tokens: int | None = None,
output_tokens: int | None = None,
ok: bool | None = None,
error: str | BaseException | None = None,
key: Any = None,
attrs: Mapping[str, Any] | None = None,
prompt: str | None = None,
completion: str | None = None,
) -> bool:
"""Record one LLM request as an ``llm.call`` event. Never raises.
Attributes: ``model``, ``provider``, ``duration_ms``, ``input_tokens``,
``output_tokens`` (each only when given), ``ok``, and on failure
``error`` (redacted, truncated to ``max_error_chars``) plus
``error_type`` when ``error`` is an exception. ``ok`` defaults to
``error is None``. Extra ``attrs`` are kept, but these names win.
``prompt`` and ``completion`` are stored, redacted and truncated to
``max_llm_text_chars``, only with ``capture_llm_text=True``;
otherwise they are discarded.
"""
try:
fields: dict[str, Any] = dict(attrs or {})
fields.pop("prompt", None)
fields.pop("completion", None)
if self.capture_llm_text:
for name, value in (("prompt", prompt), ("completion", completion)):
if value is None and attrs is not None:
value = attrs.get(name)
if value is not None:
text = self.redactor.redact_text(str(value))
fields[name] = text[: self.max_llm_text_chars]
fields["model"] = None if model is None else str(model)
for name, value in (
("provider", provider),
("duration_ms", duration_ms),
("input_tokens", input_tokens),
("output_tokens", output_tokens),
):
if value is not None:
fields[name] = value
fields["ok"] = (error is None) if ok is None else bool(ok)
if error is not None:
if isinstance(error, BaseException):
fields["error_type"] = type(error).__name__
text = self.redactor.redact_text(str(error))
fields["error"] = text[: self.max_error_chars]
return self.record_event("llm.call", key=key, attrs=fields)
except Exception:
logger.warning("Failed to record LLM call", exc_info=True)
return False
async def events_for(
self,
key: Any,
*,
kinds: Iterable[str] | None = None,
limit: int = 1000,
) -> list[dict[str, Any]]:
"""Events for ``key``, oldest first: the newest ``limit`` of them.
Reads the DatabaseSink when there is one and adds events still only in
memory, so an event is visible as soon as ``record_event`` returns (on
the loop thread). Without a DatabaseSink, memory only. ``occurred_at``
is a UTC datetime. A database failure is logged and memory is used.
Events are ordered by ``occurred_at``; their ids are random UUIDs.
"""
key = str(key)
kinds = None if kinds is None else frozenset(kinds)
found: dict[str, dict[str, Any]] = {}
sink = self.database_sink
if sink is not None:
try:
for row in await sink.events_for(key, kinds=kinds, limit=limit):
found[row["id"]] = row
except Exception:
logger.warning("Failed to read events for %r", key, exc_info=True)
for record in list(self.recent_events):
if record.key == key and (kinds is None or record.kind in kinds):
found.setdefault(record.id, record.to_row())
rows = list(found.values())
for row in rows:
row["occurred_at"] = as_utc(row["occurred_at"])
rows.sort(key=lambda r: r["occurred_at"])
return rows[-limit:] if limit > 0 else []
@property
def embedding_sink(self) -> "EmbeddingSink | None":
"""The first EmbeddingSink among the sinks, if any."""
return _first_sink(self.dispatcher.sinks, "embeddings", "EmbeddingSink")
async def similar(
self,
text: str,
*,
source_types: Iterable[str] | None = None,
k: int = 10,
min_score: float | None = None,
) -> list[dict[str, Any]]:
"""Embedded texts most like ``text``, best first. Never raises.
Needs an EmbeddingSink. Each result has ``source_type``,
``source_id``, ``text``, ``score`` (1 - cosine distance, in [0, 1])
and ``created_at``. ``source_types`` limits the search to some of
``feedback``, ``call_error``, ``event`` and ``llm``; ``min_score``
drops weaker matches. Returns [] on any failure, logged.
"""
sink = self.embedding_sink
if sink is None:
logger.warning("similar() needs an EmbeddingSink")
return []
try:
return await sink.similar(
str(text),
source_types=None if source_types is None else list(source_types),
k=k,
min_score=min_score,
)
except Exception:
logger.warning("Similarity search failed", exc_info=True)
return []
async def similar_feedback(
self,
feedback_ref: Any,
*,
k: int = 10,
source_types: Iterable[str] | None = None,
min_score: float | None = None,
) -> list[dict[str, Any]]:
"""Texts most like an embedded feedback item, excluding the item itself.
Uses the feedback's stored vector, so nothing is embedded. Returns []
when the feedback is not embedded yet, and on any failure. Never raises.
"""
sink = self.embedding_sink
if sink is None:
logger.warning("similar_feedback() needs an EmbeddingSink")
return []
ref = str(feedback_ref)
try:
vector = await sink.vector_for("feedback", ref)
if vector is None:
return []
return await sink.nearest(
vector,
source_types=None if source_types is None else list(source_types),
k=k,
min_score=min_score,
exclude=("feedback", ref),
)
except Exception:
logger.warning("similar_feedback(%r) failed", ref, exc_info=True)
return []
@property
def database_sink(self) -> "DatabaseSink | None":
"""The first DatabaseSink among the sinks, if any."""
return _first_sink(self.dispatcher.sinks, "storage", "DatabaseSink")
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()
class _Identity(NamedTuple):
"""One answer from the identity resolver, redacted.
``columns`` holds whichever of user_sub, caller_kind and client_id it
returned, ``rest`` everything else, and ``fallback_client_id`` the
FastMCP context's client_id for when it returned none.
"""
columns: dict[str, Any]
rest: dict[str, Any]
fallback_client_id: str | None = None
def column(self, key: str) -> Any:
value = self.columns.get(key)
if key == "client_id" and not value:
return self.fallback_client_id
return value
def filled_from(self, later: "_Identity") -> "_Identity":
"""This answer, with gaps and None values filled from ``later``.
Values already here win, so the call's record agrees with the events
recorded during the call, which saw only this answer.
"""
def fill(first: dict[str, Any], second: dict[str, Any]) -> dict[str, Any]:
merged = dict(first)
for key, value in second.items():
if merged.get(key) is None:
merged[key] = value
return merged
return _Identity(
fill(self.columns, later.columns),
fill(self.rest, later.rest),
self.fallback_client_id or later.fallback_client_id,
)
def _is_sink(sink: Any, module: str, name: str) -> bool:
"""isinstance against a sink class without importing its module.
storage and embeddings need the async database stack. When the module was
never imported, no instance of its class can exist, so the answer is no.
"""
loaded = sys.modules.get(f"{__package__}.{module}")
cls = getattr(loaded, name, None) if loaded is not None else None
return cls is not None and isinstance(sink, cls)
def _first_sink(sinks: Iterable[Any], module: str, name: str) -> Any:
return next((s for s in sinks if _is_sink(s, module, name)), None)
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,108 @@
"""Size limits for stored payloads: call arguments and results, event attrs.
A value whose JSON encoding is larger than the limit is stored as a marker:
{"_truncated": true, "size": 812345, "preview": "{\"frames\": [[0.1, ..."}
``size`` is the value's encoded size in bytes, measured as for ``args_size``
(``json.dumps(value, default=str)``, non-ASCII escaped). ``preview`` is the
start of its JSON text, ``preview_chars(limit)`` characters: ``PREVIEW_CHARS``
(1000) under the default limit, a quarter of the limit under a smaller one,
so the marker itself fits. It is taken from the value after redaction, so a
secret cannot reach it. The keys and their meaning are stable.
"""
import json
from typing import Any
TRUNCATED = "_truncated"
PREVIEW_CHARS = 1000
DEFAULT_MAX_PAYLOAD_BYTES = 64 * 1024
# Incremental, so a preview of a large value encodes only its start.
_ENCODER = json.JSONEncoder(default=str, ensure_ascii=False)
def json_size(value: Any) -> int | None:
"""Bytes of ``value`` as JSON, or None when it cannot be encoded."""
try:
return len(json.dumps(value, default=str).encode())
except Exception:
return None
def is_truncated(value: Any) -> bool:
"""Whether ``value`` is a marker left by the size limit."""
return isinstance(value, dict) and value.get(TRUNCATED) is True
def preview(value: Any, chars: int = PREVIEW_CHARS) -> str:
"""The first ``chars`` characters of ``value``'s JSON text."""
if chars <= 0:
return ""
parts: list[str] = []
length = 0
try:
for chunk in _ENCODER.iterencode(value):
parts.append(chunk)
length += len(chunk)
if length >= chars:
break
except Exception:
return ""
return "".join(parts)[:chars]
def preview_chars(limit: int) -> int:
"""How much of a value's JSON text a marker keeps under ``limit``."""
return min(PREVIEW_CHARS, limit // 4)
def marker(value: Any, size: int | None, limit: int) -> dict[str, Any]:
"""The stand-in stored for a ``value`` over ``limit`` bytes."""
return {
TRUNCATED: True,
"size": size,
"preview": preview(value, preview_chars(limit)),
}
def cap(value: Any, size: int | None, limit: int | None) -> Any:
"""``value``, or its marker when ``size`` is over ``limit``.
``size`` is passed in because the caller has usually measured it already
(``args_size``, ``result_size``), so this costs no second encoding.
"""
if limit is None or size is None or size <= limit:
return value
return marker(value, size, limit)
def cap_attrs(attrs: dict[str, Any], limit: int | None) -> dict[str, Any]:
"""Event attrs within ``limit`` bytes, keeping the dict's keys.
The largest values are replaced by markers, one at a time, until the
whole fits, so small fields (``ok``, ``phase``, ``model``) stay readable.
If markers alone cannot get it under the limit (very many keys, or a
limit of a few dozen bytes), the whole dict becomes one marker, which
under such a tiny limit can itself be larger than the limit.
"""
if limit is None or not attrs:
return attrs
total = json_size(attrs)
if total is None or total <= limit:
return attrs
sizes = {key: json_size(value) or 0 for key, value in attrs.items()}
out = dict(attrs)
for key in sorted(sizes, key=sizes.__getitem__, reverse=True):
if total <= limit:
break
stand_in = marker(attrs[key], sizes[key], limit)
smaller = sizes[key] - (json_size(stand_in) or 0)
if smaller <= 0:
continue
out[key] = stand_in
total -= smaller
if total > limit:
return marker(attrs, json_size(attrs), limit)
return out
@@ -0,0 +1,62 @@
"""The per-call record produced by InstrumentationMiddleware."""
import uuid
from dataclasses import asdict, dataclass, field
from datetime import datetime
from typing import Any, ClassVar
@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``.
``sample_rate`` is set on ok calls of a sampled tool: each such row stands
for ``1 / sample_rate`` calls. It is None for everything recorded
unconditionally, including failures of sampled tools.
"""
record_type: ClassVar[str] = "tool_call"
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)
sample_rate: float | None = None
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,78 @@
"""Destinations for tool-call and event records."""
import inspect
import json
import sys
from collections.abc import Callable, Sequence
from typing import IO, Any, Protocol, runtime_checkable
from .events import EventRecord
from .record import ToolCallRecord
Record = ToolCallRecord | EventRecord
@runtime_checkable
class Sink(Protocol):
"""Anything with ``async write(records)``. ``async aclose()`` is optional.
A batch can mix ``ToolCallRecord`` and ``EventRecord``; tell them apart
with ``isinstance`` or ``record.record_type`` (``tool_call``, ``event``).
"""
async def write(self, records: Sequence[Record]) -> None: ...
class JsonLinesSink:
"""One JSON object per record, to stderr by default.
Each line has ``"record": "tool_call"`` or ``"record": "event"``.
"""
def __init__(self, stream: IO[str] | None = None):
self._stream = stream
async def write(self, records: Sequence[Record]) -> 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:
line = {"record": record.record_type, **record.to_dict()}
stream.write(json.dumps(line, 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[Record]], Any]):
self.callback = callback
async def write(self, records: Sequence[Record]) -> None:
result = self.callback(records)
if inspect.isawaitable(result):
await result
class MemorySink:
"""Keep records in a list. Meant for tests.
``records`` holds everything in arrival order; ``calls`` and ``events``
are the two kinds on their own.
"""
def __init__(self):
self.records: list[Record] = []
@property
def calls(self) -> list[ToolCallRecord]:
return [r for r in self.records if isinstance(r, ToolCallRecord)]
@property
def events(self) -> list[EventRecord]:
return [r for r in self.records if isinstance(r, EventRecord)]
async def write(self, records: Sequence[Record]) -> None:
self.records.extend(records)
@@ -0,0 +1,546 @@
"""Async SQLAlchemy storage for tool-call records, events, feedback links and
embeddings.
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
import logging
import random as _random
import time
from collections.abc import Sequence
from datetime import UTC, datetime, timedelta
from typing import Any
from sqlalchemy import (
JSON,
Boolean,
Column,
DateTime,
Float,
Index,
Integer,
MetaData,
String,
Table,
Text,
UniqueConstraint,
and_,
delete,
exists,
inspect,
or_,
select,
text,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.exc import DBAPIError
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from sqlalchemy.types import TypeDecorator
from .constants import (
DEFAULT_EMBEDDING_DIM,
DEFAULT_TABLE_PREFIX,
FEEDBACK_SOURCE,
)
from .constants import EMBEDDING_SOURCES as EMBEDDING_SOURCES # re-export
from .events import MAX_KEY_CHARS, MAX_KIND_CHARS, EventRecord
from .record import ToolCallRecord
from .sinks import Record
logger = logging.getLogger(__name__)
# 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")
class EmbeddingVector(TypeDecorator):
"""pgvector ``VECTOR(dim)`` on PostgreSQL, a JSON list of floats elsewhere.
pgvector is imported only when a PostgreSQL dialect needs the type, so
SQLite hosts never need it installed.
"""
impl = JSON
cache_ok = True
def __init__(self, dim: int = DEFAULT_EMBEDDING_DIM):
super().__init__()
self.dim = dim
def load_dialect_impl(self, dialect: Any) -> Any:
if dialect.name == "postgresql":
try:
from pgvector.sqlalchemy import VECTOR
except ImportError as exc:
raise ImportError(
"Embeddings on PostgreSQL need pgvector: install "
'"fastmcp-feedback[embeddings]"'
) from exc
return dialect.type_descriptor(VECTOR(self.dim))
return dialect.type_descriptor(JSON())
def process_bind_param(self, value: Any, dialect: Any) -> Any:
return None if value is None else [float(v) for v in value]
def process_result_value(self, value: Any, dialect: Any) -> Any:
return None if value is None else [float(v) for v in value]
def build_metadata(
prefix: str = DEFAULT_TABLE_PREFIX,
metadata: MetaData | None = None,
embedding_dim: int | None = DEFAULT_EMBEDDING_DIM,
) -> MetaData:
"""Return MetaData holding ``{prefix}tool_calls``, ``{prefix}events``,
``{prefix}feedback_call_links`` and ``{prefix}embeddings``.
Pass an existing MetaData to add the tables to it instead.
``embedding_dim`` sizes the vector column; None leaves the embeddings
table out, for hosts that do not embed and have no pgvector.
"""
metadata = metadata if metadata is not None else MetaData()
calls = f"{prefix}tool_calls"
events = f"{prefix}events"
links = f"{prefix}feedback_call_links"
embeddings = f"{prefix}embeddings"
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),
# Set on sampled-in ok rows; each stands for 1/sample_rate calls.
# NULL for rows recorded unconditionally and before 2026.09.28.
Column("sample_rate", Float),
)
if events not in metadata.tables:
# No foreign key to tool_calls for the same reasons as links: an event
# can be written before its call leaves the queue, and calls are pruned.
Table(
events,
metadata,
Column("id", String(36), primary_key=True),
Column("occurred_at", DateTime(timezone=True), nullable=False, index=True),
Column("kind", String(MAX_KIND_CHARS), nullable=False, index=True),
Column("key", String(MAX_KEY_CHARS), index=True),
Column("call_id", String(36), index=True),
Column("session_id", String(255)),
Column("user_sub", String(255), index=True),
Column("caller_kind", String(64)),
Column("client_id", String(255)),
Column("server_version", String(64)),
Column("attrs", _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),
)
if embedding_dim is not None and embeddings not in metadata.tables:
table = Table(
embeddings,
metadata,
Column("id", String(36), primary_key=True),
# feedback, call_error, event or llm
Column("source_type", String(16), nullable=False, index=True),
# The feedback ref, call id or event id the text came from.
Column("source_id", String(255), nullable=False, index=True),
Column("model", String(128), nullable=False),
Column("dim", Integer, nullable=False),
# sha256 hex of ``text``; an unchanged text is not embedded again.
Column("text_hash", String(64), nullable=False),
# What was embedded: redacted and truncated.
Column("text", Text, nullable=False),
Column("created_at", DateTime(timezone=True), nullable=False, index=True),
Column("embedding", EmbeddingVector(embedding_dim), nullable=False),
UniqueConstraint(
"source_type", "source_id", "model", name=f"{embeddings}_source_uq"
),
)
# Cosine HNSW index; other databases search by brute force.
Index(
f"{embeddings}_hnsw",
table.c.embedding,
postgresql_using="hnsw",
postgresql_ops={"embedding": "vector_cosine_ops"},
).ddl_if(dialect="postgresql")
return metadata
class DatabaseSink:
"""Write records to ``{prefix}tool_calls`` and ``{prefix}events``.
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``.
retention: delete tool-call and event rows older than this. When set, ``write``
runs ``prune()`` at most once per ``prune_interval``, after its
insert, in the background writer. None (the default) keeps rows
forever. Calls linked to feedback are never pruned.
prune_interval: minimum time between automatic prunes.
prune_batch: rows deleted per statement, so no single delete holds
locks for long.
embedding_dim: vector size of ``{prefix}embeddings``; must match the
embedder of any EmbeddingSink writing through this sink.
"""
def __init__(
self,
engine: AsyncEngine | str,
*,
prefix: str = DEFAULT_TABLE_PREFIX,
create_tables: bool = False,
retention: timedelta | None = None,
prune_interval: timedelta = timedelta(hours=1),
prune_batch: int = 5000,
embedding_dim: int = DEFAULT_EMBEDDING_DIM,
):
if retention is not None and retention <= timedelta(0):
raise ValueError(f"retention must be positive, got {retention!r}")
if prune_batch < 1:
raise ValueError(f"prune_batch must be at least 1, got {prune_batch!r}")
self._owns_engine = isinstance(engine, str)
self.engine = create_async_engine(engine) if isinstance(engine, str) else engine
self.metadata = build_metadata(prefix, embedding_dim=embedding_dim)
self.table = self.metadata.tables[f"{prefix}tool_calls"]
self.events = self.metadata.tables[f"{prefix}events"]
self.links = self.metadata.tables[f"{prefix}feedback_call_links"]
self.embeddings = self.metadata.tables[f"{prefix}embeddings"]
self.embedding_dim = embedding_dim
self.create_tables = create_tables
self._tables_ready = False
# The embeddings table (and on Postgres the vector extension) is only
# created once an EmbeddingSink uses this sink; see ensure_embeddings.
self._embeddings_ready = False
self._embeddings_failed_at: float | None = None
self._embeddings_lock = asyncio.Lock()
# 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()
self.retention = retention
self.prune_interval = prune_interval
self.prune_batch = prune_batch
# time.monotonic() of the last automatic prune; None until the first.
self._last_prune: float | None = None
async def ensure_tables(self) -> None:
"""Create the tables if missing. Only called when create_tables=True.
Another process starting against the same empty database can create a
table between our existence check and our CREATE, which fails ours
(SQLite: "already exists"; Postgres: DuplicateTable or a unique
violation in pg_type). So on a database error, wait a moment and run
create_all once more; it skips whatever the other process made. A
second failure is raised.
"""
try:
await self._create_all()
except DBAPIError as first:
await asyncio.sleep(_random.uniform(0.05, 0.25))
await self._create_all()
logger.info(
"table creation failed once (%s), retry succeeded; another "
"process was probably creating the same tables",
type(first).__name__,
)
self._tables_ready = True
async def _create_all(self) -> None:
# The embeddings table is left to ensure_embeddings, so hosts that do
# not embed never need pgvector or the vector extension.
tables = [t for t in self.metadata.sorted_tables if t is not self.embeddings]
async with self.engine.begin() as conn:
await conn.run_sync(self.metadata.create_all, tables=tables)
async def ensure_embeddings(self) -> bool:
"""Create ``{prefix}embeddings`` if missing. Only when create_tables=True.
On PostgreSQL this first runs ``CREATE EXTENSION IF NOT EXISTS vector``.
Never raises: a missing privilege, a missing pgvector package or any
other failure is logged, and False is returned. A failed attempt is
retried at most once a minute. Called by EmbeddingSink before it writes.
"""
if not self.create_tables or self._embeddings_ready:
return True
async with self._embeddings_lock:
if self._embeddings_ready:
return True
now = time.monotonic()
if (
self._embeddings_failed_at is not None
and now - self._embeddings_failed_at < 60
):
return False
try:
await self._ready()
if self.engine.dialect.name == "postgresql":
await self._create_vector_extension()
try:
await self._create_embeddings_table()
except DBAPIError:
# Another process creating it at the same moment.
await asyncio.sleep(_random.uniform(0.05, 0.25))
await self._create_embeddings_table()
except Exception as exc:
self._embeddings_failed_at = now
logger.warning(
"Could not create %s (%s: %s); embeddings are not stored until "
"it exists. Create it with a migration (see the README) or "
"fix the cause; retrying in a minute.",
self.embeddings.name,
type(exc).__name__,
str(exc).splitlines()[0] if str(exc) else "",
)
return False
self._embeddings_ready = True
return True
async def _create_vector_extension(self) -> None:
try:
async with self.engine.begin() as conn:
await conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
except DBAPIError as exc:
# Usually a missing privilege. The table step then says whether
# the extension exists anyway (a DBA may have created it).
logger.warning(
"CREATE EXTENSION vector failed (%s); a superuser or database "
"owner must run it once in this database",
str(exc.orig).splitlines()[0] if exc.orig else type(exc).__name__,
)
async def _create_embeddings_table(self) -> None:
async with self.engine.begin() as conn:
await conn.run_sync(
lambda sync: self.embeddings.create(sync, checkfirst=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[Record]) -> None:
calls = [r.to_row() for r in records if isinstance(r, ToolCallRecord)]
events = [r.to_row() for r in records if isinstance(r, EventRecord)]
if not calls and not events:
return
await self._ready()
# Separate transactions, calls first: a host that has not created the
# events table yet still gets its calls stored.
if calls:
async with self.engine.begin() as conn:
await conn.execute(self.table.insert(), calls)
if events:
async with self.engine.begin() as conn:
await conn.execute(self.events.insert(), events)
await self._auto_prune()
async def _auto_prune(self) -> None:
if self.retention is None:
return
now = time.monotonic()
if (
self._last_prune is not None
and now - self._last_prune < self.prune_interval.total_seconds()
):
return
# Set before running, so a prune that fails is not retried every write.
self._last_prune = now
try:
await self.prune()
except Exception:
logger.warning("Pruning %s failed", self.table.name, exc_info=True)
async def prune(self, older_than: datetime | None = None) -> int:
"""Delete tool-call, event and embedding rows from before ``older_than``.
Defaults to now minus ``retention``; with neither, deletes nothing.
Calls linked to feedback in ``{prefix}feedback_call_links`` are kept,
and links are never deleted. Events go by ``occurred_at``, whatever
call they belong to. Embeddings go by ``created_at``, except those of
feedback (``source_type="feedback"``), which are kept. Deletes
``prune_batch`` rows per transaction until none are left. Returns the
total rows deleted from all three tables and logs each table's count.
A table that does not exist yet (not migrated) is skipped.
"""
if older_than is None:
if self.retention is None:
return 0
older_than = datetime.now(UTC) - self.retention
await self._ready()
t, link = self.table, self.links
calls = await self._delete_batches(
t,
select(t.c.id).where(
t.c.started_at < older_than,
~exists().where(link.c.call_id == t.c.id),
),
)
events = 0
if await self._has_table(self.events.name):
e = self.events
events = await self._delete_batches(
e, select(e.c.id).where(e.c.occurred_at < older_than)
)
vectors = 0
if await self._has_table(self.embeddings.name):
v = self.embeddings
vectors = await self._delete_batches(
v,
select(v.c.id).where(
v.c.created_at < older_than, v.c.source_type != FEEDBACK_SOURCE
),
)
if calls or events or vectors:
logger.info(
"Pruned %d rows from %s, %d from %s and %d from %s",
calls,
t.name,
events,
self.events.name,
vectors,
self.embeddings.name,
)
return calls + events + vectors
async def _delete_batches(self, table: Any, ids_query: Any) -> int:
batch = ids_query.limit(self.prune_batch)
total = 0
while True:
async with self.engine.begin() as conn:
ids = (await conn.execute(batch)).scalars().all()
if ids:
await conn.execute(delete(table).where(table.c.id.in_(ids)))
total += len(ids)
if len(ids) < self.prune_batch:
return total
async def _has_table(self, name: str) -> bool:
async with self.engine.connect() as conn:
return await conn.run_sync(lambda sync: inspect(sync).has_table(name))
async def events_for(
self, key: str, *, kinds: Sequence[str] | None = None, limit: int = 1000
) -> list[dict[str, Any]]:
"""The newest ``limit`` events for ``key``, newest first."""
await self._ready()
e = self.events
query = select(e).where(e.c.key == key)
if kinds is not None:
query = query.where(e.c.kind.in_(list(kinds)))
query = query.order_by(e.c.occurred_at.desc()).limit(limit)
async with self.engine.connect() as conn:
return [dict(row) for row in (await conn.execute(query)).mappings()]
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()
+16 -1
View File
@@ -3,16 +3,31 @@
import logging
from datetime import UTC, datetime
from fastmcp.contrib.mcp_mixin import MCPMixin
from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool
from ..database import FeedbackDatabase
from ..insights import (
FeedbackInsights,
record_tool_usage,
)
from ..models import Feedback, FeedbackStatus, FeedbackType, feedback_to_dict
logger = logging.getLogger(__name__)
# Explicit re-exports for other mixin modules
__all__ = [
"BaseFeedbackMixin",
"Feedback",
"FeedbackDatabase",
"FeedbackInsights",
"FeedbackStatus",
"FeedbackType",
"feedback_to_dict",
"logger",
"mcp_tool",
"record_tool_usage",
]
class BaseFeedbackMixin(MCPMixin):
"""Base class for all feedback mixins with shared functionality."""
+9 -12
View File
@@ -5,6 +5,7 @@ from typing import Any
from sqlalchemy import text
from ..database import count_feedback_by
from ..insights import record_feedback_retrieval, record_statistics_view
from .base import BaseFeedbackMixin, Feedback, feedback_to_dict, logger, mcp_tool
@@ -93,7 +94,10 @@ class RetrievalMixin(BaseFeedbackMixin):
@mcp_tool(description="Get feedback statistics and analytics")
async def get_feedback_statistics(self) -> dict[str, Any]:
"""Get comprehensive feedback statistics.
"""Feedback counts: total, per type, per status, and the last 7 days.
``by_type`` and ``by_status`` are keyed by the same lowercase values
``list_feedback`` returns (``bug``, ``in_progress``).
Returns:
Dictionary with statistics.
@@ -106,17 +110,10 @@ class RetrievalMixin(BaseFeedbackMixin):
total_result = session.execute(text("SELECT COUNT(*) FROM feedback"))
total_count = total_result.scalar() or 0
# Count by type
type_result = session.execute(
text("SELECT type, COUNT(*) FROM feedback GROUP BY type")
)
by_type = dict(type_result.fetchall()) if total_count > 0 else {}
# Count by status
status_result = session.execute(
text("SELECT status, COUNT(*) FROM feedback GROUP BY status")
)
by_status = dict(status_result.fetchall()) if total_count > 0 else {}
# Keyed by enum value ("bug", "open"), as list_feedback
# returns them; the database stores the names.
by_type = count_feedback_by(session, "type")
by_status = count_feedback_by(session, "status")
# Recent count (last 7 days)
week_ago = datetime.now(UTC) - timedelta(days=7)
+46 -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 and record a ``feedback.submitted`` event for 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,34 @@ class SubmissionMixin(BaseFeedbackMixin):
source="mcp_tool",
)
linked_calls = 0
if self.instrumentation is not None:
# Feeds an EmbeddingSink, so similar reports can be found.
self.instrumentation.record_event(
"feedback.submitted",
key=feedback_id,
attrs={
"type": request.type,
"title": request.title,
"description": request.description,
"submitter": request.submitter,
},
)
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)
+10
View File
@@ -0,0 +1,10 @@
"""Helpers that paper over FastMCP API differences between 2.x and 4.x."""
from fastmcp import FastMCP
async def tool_map(server: FastMCP) -> dict:
"""Return {name: Tool}. 2.x has get_tools() -> dict, 4.x has list_tools() -> list."""
if hasattr(server, "get_tools"):
return await server.get_tools()
return {tool.name: tool for tool in await server.list_tools()}
+114
View File
@@ -0,0 +1,114 @@
"""Fresh databases for DatabaseSink tests: SQLite always, Postgres when asked.
Set FFB_TEST_DATABASE_URL to a ``postgresql+asyncpg://`` URL (``make test-pg``
does this from .env) and every test using the ``db_url`` fixture also runs
against that server. Each test gets its own uniquely named databases, created
on first use and dropped at teardown, so no two tests share tables.
"""
import asyncio
import os
import re
import threading
import uuid
from collections.abc import Coroutine
from typing import Any
from sqlalchemy import text
from sqlalchemy.engine import URL, make_url
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.pool import NullPool
ENV_VAR = "FFB_TEST_DATABASE_URL"
SKIP_REASON = f"{ENV_VAR} is not set; run `make pg-up` then `make test-pg`"
def postgres_url() -> URL | None:
raw = os.environ.get(ENV_VAR, "").strip()
if not raw:
return None
url = make_url(raw)
if not url.drivername.startswith("postgresql"):
raise ValueError(f"{ENV_VAR} must be a postgresql URL, got {url.drivername}")
return url
def run_blocking(coro: Coroutine[Any, Any, Any]) -> Any:
"""Run ``coro`` to completion on its own loop in a worker thread.
The sink factories in the tests are plain functions called from inside a
running event loop, so the admin work cannot await on that loop.
"""
box: dict[str, Any] = {}
def target() -> None:
try:
box["value"] = asyncio.run(coro)
except BaseException as exc: # re-raised in the caller's thread
box["error"] = exc
thread = threading.Thread(target=target)
thread.start()
thread.join()
if "error" in box:
raise box["error"]
return box.get("value")
class PostgresDatabases:
"""Create databases on one server for a single test, then drop them all."""
def __init__(self, url: URL):
self.url = url
self.run_id = uuid.uuid4().hex[:10]
self.by_label: dict[str, str] = {}
def _admin(self, database: str | None = None):
url = self.url if database is None else self.url.set(database=database)
return create_async_engine(
url, isolation_level="AUTOCOMMIT", poolclass=NullPool
)
def url_for(self, label: str) -> str:
"""URL of this test's database for ``label``, created on first request."""
if label not in self.by_label:
safe = re.sub(r"[^a-z0-9_]", "_", label.lower())
name = f"ffb_t_{self.run_id}_{safe}"[:63]
run_blocking(self._create(name))
self.by_label[label] = name
return self.url.set(database=self.by_label[label]).render_as_string(
hide_password=False
)
async def _create(self, name: str) -> None:
engine = self._admin()
try:
async with engine.connect() as conn:
await conn.execute(text(f'CREATE DATABASE "{name}"'))
finally:
await engine.dispose()
# The compose init script puts vector in template1 already; CI's
# service container has no init script, so make sure here.
engine = self._admin(name)
try:
async with engine.connect() as conn:
await conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
finally:
await engine.dispose()
def drop_all(self) -> None:
if self.by_label:
run_blocking(self._drop(list(self.by_label.values())))
self.by_label.clear()
async def _drop(self, names: list[str]) -> None:
engine = self._admin()
try:
async with engine.connect() as conn:
for name in names:
# FORCE: a sink a test forgot to close must not keep it alive.
await conn.execute(
text(f'DROP DATABASE IF EXISTS "{name}" WITH (FORCE)')
)
finally:
await engine.dispose()
+33
View File
@@ -16,6 +16,8 @@ from fastmcp_feedback.database import FeedbackDatabase
from fastmcp_feedback.insights import FeedbackInsights
from fastmcp_feedback.models import Base, Feedback, FeedbackType, FeedbackStatus
from ._databases import SKIP_REASON, PostgresDatabases, postgres_url
@pytest_asyncio.fixture
async def event_loop():
@@ -177,6 +179,37 @@ def mock_database(mock_session):
return db
# Database backends for DatabaseSink tests
@pytest.fixture(
params=["sqlite", pytest.param("postgres", marks=pytest.mark.postgres)]
)
def db_backend(request) -> str:
"""Which database a ``db_url`` test runs on. Postgres needs FFB_TEST_DATABASE_URL."""
if request.param == "postgres" and postgres_url() is None:
pytest.skip(SKIP_REASON)
return request.param
@pytest.fixture
def db_url(db_backend, tmp_path):
"""Factory: ``db_url(name)`` returns an async URL for an empty database.
The same name within one test returns the same database, so a test can
reopen it; different names and different tests never share one. SQLite
databases are files under tmp_path; Postgres ones are created on the
server named by FFB_TEST_DATABASE_URL and dropped after the test.
"""
if db_backend == "sqlite":
yield lambda name="calls": f"sqlite+aiosqlite:///{tmp_path}/{name}.db"
return
databases = PostgresDatabases(postgres_url())
try:
yield lambda name="calls": databases.url_for(name)
finally:
databases.drop_all()
# Environment helpers
@pytest.fixture(autouse=True)
+105
View File
@@ -0,0 +1,105 @@
"""Exercise the instrumentation as a base install would, then print JSON.
Run in a fresh interpreter. greenlet and aiosqlite are made unimportable
before anything else loads, as on ``pip install fastmcp-feedback`` without
the ``instrumentation`` extra. Argument: a directory for the feedback database.
"""
import sys
from importlib.metadata import version
# SQLAlchemy 2.1 refuses to import sqlalchemy.ext.asyncio without greenlet,
# once: a second import succeeds. 2.0 imports it and fails on first use.
# On 2.0 the module is blocked too, so both versions fail at import.
_blocked = ["greenlet", "aiosqlite"]
if tuple(int(p) for p in version("sqlalchemy").split(".")[:2]) < (2, 1):
_blocked.append("sqlalchemy.ext.asyncio")
for _name in _blocked:
sys.modules[_name] = None # import raises ModuleNotFoundError
import asyncio # noqa: E402
import io # noqa: E402
import json # noqa: E402
from pathlib import Path # noqa: E402
STORAGE = "fastmcp_feedback.instrumentation.storage"
EMBEDDINGS = "fastmcp_feedback.instrumentation.embeddings"
async def run(db_dir: Path) -> dict:
from fastmcp import Client, FastMCP
import fastmcp_feedback.instrumentation as inst
from fastmcp_feedback import add_feedback_tools
from fastmcp_feedback.instrumentation import (
DEFAULT_TABLE_PREFIX,
JsonLinesSink,
MemorySink,
instrument,
)
out: dict = {"prefix": DEFAULT_TABLE_PREFIX}
app = FastMCP("base-install")
@app.tool
def add(a: int, b: int) -> int:
return a + b
memory = MemorySink()
lines = io.StringIO()
mw = instrument(app, [memory, JsonLinesSink(lines)], mode="full")
add_feedback_tools(
app, database_url=f"sqlite:///{db_dir / 'fb.db'}", instrumentation=mw
)
async with Client(app) as client:
result = await client.call_tool("add", {"a": 2, "b": 3})
out["result"] = result.content[0].text
fb = await client.call_tool(
"submit_feedback",
{
"request": {
"type": "bug",
"title": "base install",
"description": "submitted without the extra",
"submitter": "worker",
}
},
)
out["feedback"] = json.loads(fb.content[0].text)
mw.record_event("job.done", key="j1", attrs={"n": 1})
await mw.flush()
out["tools"] = [r.tool for r in memory.records if hasattr(r, "tool")]
out["event_kinds"] = [r.kind for r in memory.records if hasattr(r, "kind")]
out["jsonl_lines"] = len(lines.getvalue().splitlines())
out["capture"] = await mw.capture_recent_calls()
out["similar"] = await mw.similar("anything")
out["similar_feedback"] = await mw.similar_feedback("bug-1")
out["feedback_context"] = await mw.feedback_context("bug-1")
out["link_feedback"] = await mw.link_feedback("bug-1", [])
out["events_for"] = len(await mw.events_for("j1"))
out["database_sink"] = mw.database_sink is None
out["embedding_sink"] = mw.embedding_sink is None
await mw.aclose()
errors = {}
for name in (
"DatabaseSink",
"EmbeddingSink",
"build_metadata",
"backfill_feedback",
):
for _attempt in range(2): # SQLAlchemy only complains the first time
try:
getattr(inst, name)
errors.setdefault(name, []).append(None)
except ImportError as exc:
errors.setdefault(name, []).append(str(exc))
out["errors"] = errors
out["loaded"] = sorted(m for m in (STORAGE, EMBEDDINGS) if m in sys.modules)
return out
if __name__ == "__main__":
print(json.dumps(asyncio.run(run(Path(sys.argv[1]))), default=str))
+39
View File
@@ -0,0 +1,39 @@
"""One writer process for the cross-process create_tables race test.
Usage: python create_tables_race_worker.py <db url> <trigger file> <events>
Builds a DatabaseSink with create_tables=True, waits until the trigger file
exists so every worker hits the empty database at the same moment, records
events, flushes, and prints {"sink_errors": n} as JSON. Prints "ready" once
imports and setup are done, so the parent can release all workers together.
"""
import asyncio
import json
import os
import sys
import time
from fastmcp_feedback.instrumentation import DatabaseSink, InstrumentationMiddleware
async def main(url: str, trigger: str, count: int) -> dict:
sink = DatabaseSink(url, create_tables=True)
mw = InstrumentationMiddleware([sink])
print("ready", flush=True)
deadline = time.monotonic() + 30
while not os.path.exists(trigger):
if time.monotonic() > deadline:
raise SystemExit("trigger file never appeared")
await asyncio.sleep(0.001)
for i in range(count):
mw.record_event("race.write", key=f"{os.getpid()}-{i}")
await mw.flush()
errors = mw.dispatcher.sink_errors
await mw.aclose() # also disposes the sink's engine
return {"sink_errors": errors}
if __name__ == "__main__":
url, trigger, count = sys.argv[1], sys.argv[2], int(sys.argv[3])
print(json.dumps(asyncio.run(main(url, trigger, count))), flush=True)
+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)
+23 -21
View File
@@ -11,11 +11,14 @@ import sys
from pathlib import Path
from unittest.mock import patch
from sqlalchemy import text
# Add the examples directory to the path for imports
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "examples"))
from fastmcp import FastMCP
from fastmcp_feedback import FeedbackDatabase, FeedbackInsights
from tests._compat import tool_map
@pytest.mark.asyncio
@@ -35,7 +38,7 @@ class TestDemoServerIntegration:
assert "demonstration" in server.description.lower()
# Get all tools
tools = await server.get_tools()
tools = await tool_map(server)
# Verify all expected tools are present
expected_tools = {
@@ -72,12 +75,12 @@ class TestDemoServerIntegration:
from fastmcp_feedback.models import Feedback
async with database.session() as session:
# Count feedback items
result = session.execute("SELECT COUNT(*) FROM feedback")
result = session.execute(text("SELECT COUNT(*) FROM feedback"))
count = result.scalar()
assert count >= 5, f"Expected at least 5 sample items, got {count}"
# Verify types are present
result = session.execute("SELECT DISTINCT type FROM feedback")
result = session.execute(text("SELECT DISTINCT type FROM feedback"))
types = [row[0] for row in result.fetchall()]
expected_types = {"BUG", "FEATURE", "IMPROVEMENT", "QUESTION"}
assert expected_types.issubset(set(types)), f"Missing feedback types: {expected_types - set(types)}"
@@ -89,21 +92,19 @@ class TestDemoServerIntegration:
"""Test actual execution of demo server tools."""
from demo_server import create_demo_server, create_temp_database, setup_demo_data
# Create server with temporary database
server = create_demo_server()
# Override database URL for testing
# Seed a temporary database, then point the server at it
db_url = create_temp_database()
database = FeedbackDatabase(db_url)
await setup_demo_data(database)
server = create_demo_server(database_url=db_url)
tools = await server.get_tools()
tools = await tool_map(server)
# Test system info tool
system_info_tool = tools["get_system_info"]
info_result = await system_info_tool.run({})
assert info_result.structured_content["success"] is True
assert "version" in info_result.structured_content
assert info_result.structured_content["status"] == "running"
assert "versions" in info_result.structured_content
assert "dependencies" in info_result.structured_content
# Test statistics tool (should work with sample data)
@@ -126,7 +127,7 @@ class TestDemoServerIntegration:
add_feedback_tools(app, database_url="sqlite:///:memory:")
# Verify tools were added
tools = await app.get_tools()
tools = await tool_map(app)
expected_tools = {
"submit_feedback",
"list_feedback",
@@ -176,7 +177,7 @@ class TestDemoServerIntegration:
from demo_server import create_demo_server
server = create_demo_server()
tools = await server.get_tools()
tools = await tool_map(server)
# Test submit_feedback with invalid data
submit_tool = tools["submit_feedback"]
@@ -199,7 +200,7 @@ class TestDemoServerIntegration:
from demo_server import create_demo_server, create_temp_database
server = create_demo_server()
tools = await server.get_tools()
tools = await tool_map(server)
# Submit feedback
submit_tool = tools["submit_feedback"]
@@ -225,7 +226,7 @@ class TestDemoServerIntegration:
assert our_feedback is not None, "Submitted feedback not found in list"
assert our_feedback["title"] == test_feedback["title"]
assert our_feedback["type"] == test_feedback["type"]
assert our_feedback["status"] == "OPEN"
assert our_feedback["status"] == "open"
# Update feedback status
update_tool = tools["update_feedback_status"]
@@ -239,7 +240,7 @@ class TestDemoServerIntegration:
list_result = await list_tool.run({})
feedback_list = list_result.structured_content["feedback"]
updated_feedback = next((f for f in feedback_list if f["id"] == feedback_id), None)
assert updated_feedback["status"] == "IN_PROGRESS"
assert updated_feedback["status"] == "in_progress"
# Get statistics (should include our feedback)
stats_tool = tools["get_feedback_statistics"]
@@ -247,9 +248,10 @@ class TestDemoServerIntegration:
stats = stats_result.structured_content
assert stats["total_count"] >= 1
assert "FEATURE" in stats["by_type"]
assert stats["by_type"]["FEATURE"] >= 1
assert "IN_PROGRESS" in stats["by_status"]
# Keyed by the values list_feedback returns, not the stored names.
assert "feature" in stats["by_type"]
assert stats["by_type"]["feature"] >= 1
assert "in_progress" in stats["by_status"]
# Delete feedback
delete_tool = tools["delete_feedback"]
@@ -272,7 +274,7 @@ class TestDemoServerDocumentation:
from demo_server import create_demo_server
server = create_demo_server()
tools = await server.get_tools()
tools = await tool_map(server)
# According to demo_server.md, should have exactly 7 tools
assert len(tools) == 7, f"Expected 7 tools as documented, got {len(tools)}"
@@ -296,7 +298,7 @@ class TestDemoServerDocumentation:
app = FastMCP("Test App")
add_feedback_tools(app, database_url="sqlite:///:memory:")
tools = await app.get_tools()
tools = await tool_map(app)
assert len(tools) == 5, f"Expected 5 tools as documented, got {len(tools)}"
# Verify documented tool names
@@ -318,7 +320,7 @@ class TestDemoServerPerformance:
from demo_server import create_demo_server
server = create_demo_server()
tools = await server.get_tools()
tools = await tool_map(server)
submit_tool = tools["submit_feedback"]
# Submit multiple feedback items concurrently
+22 -21
View File
@@ -10,6 +10,7 @@ from fastmcp_feedback.feedback import create_feedback_server
from fastmcp_feedback.models import SubmitFeedbackRequest, FeedbackStatus
from fastmcp_feedback.database import FeedbackDatabase
from fastmcp_feedback.insights import FeedbackInsights
from tests._compat import tool_map
@pytest.mark.integration
@@ -25,7 +26,7 @@ class TestCompleteWorkflow:
add_feedback_tools(server, database_url=memory_db_url, insights=insights)
# Get tools
tools = await server.get_tools()
tools = await tool_map(server)
submit_tool = tools["submit_feedback"]
list_tool = tools["list_feedback"]
update_tool = tools["update_feedback_status"]
@@ -60,10 +61,10 @@ class TestCompleteWorkflow:
stats = stats_result.structured_content
assert stats["total_count"] >= 1
assert "BUG" in stats["by_type"]
assert stats["by_type"]["BUG"] >= 1
assert "OPEN" in stats["by_status"]
assert stats["by_status"]["OPEN"] >= 1
assert "bug" in stats["by_type"]
assert stats["by_type"]["bug"] >= 1
assert "open" in stats["by_status"]
assert stats["by_status"]["open"] >= 1
# 4. Update feedback status
update_result = await update_tool.run({
@@ -93,8 +94,8 @@ class TestCompleteWorkflow:
final_stats_result = await stats_tool.run({})
final_stats = final_stats_result.structured_content
assert "RESOLVED" in final_stats["by_status"]
assert final_stats["by_status"]["RESOLVED"] >= 1
assert "resolved" in final_stats["by_status"]
assert final_stats["by_status"]["resolved"] >= 1
# 8. Verify insights were recorded
assert len(insights.get_metrics("feedback_submitted")) >= 1
@@ -106,7 +107,7 @@ class TestCompleteWorkflow:
server = FastMCP("Multi-Type Test Server")
add_feedback_tools(server, database_url=memory_db_url)
tools = await server.get_tools()
tools = await tool_map(server)
submit_tool = tools["submit_feedback"]
list_tool = tools["list_feedback"]
@@ -179,7 +180,7 @@ class TestCompleteWorkflow:
server = FastMCP("Error Test Server")
add_feedback_tools(server, database_url=memory_db_url)
tools = await server.get_tools()
tools = await tool_map(server)
submit_tool = tools["submit_feedback"]
update_tool = tools["update_feedback_status"]
delete_tool = tools["delete_feedback"]
@@ -233,7 +234,7 @@ class TestCompleteWorkflow:
insights = FeedbackInsights(enabled=True)
add_feedback_tools(server, database_url=memory_db_url, insights=insights)
tools = await server.get_tools()
tools = await tool_map(server)
submit_tool = tools["submit_feedback"]
list_tool = tools["list_feedback"]
@@ -285,7 +286,7 @@ class TestCompleteWorkflow:
server = FastMCP("Bulk Test Server")
add_feedback_tools(server, database_url=memory_db_url)
tools = await server.get_tools()
tools = await tool_map(server)
submit_tool = tools["submit_feedback"]
list_tool = tools["list_feedback"]
update_tool = tools["update_feedback_status"]
@@ -355,7 +356,7 @@ class TestServerCompositionWorkflow:
)
# Get tools from dedicated server
tools = await feedback_server.get_tools()
tools = await tool_map(feedback_server)
submit_tool = tools["submit_feedback"]
list_tool = tools["list_feedback"]
@@ -384,11 +385,11 @@ class TestServerCompositionWorkflow:
admin_feedback = create_feedback_server("Admin Feedback", database_url="sqlite:///:memory:")
# Import servers with different prefixes
await main_server.import_server(user_feedback, prefix="user")
await main_server.import_server(admin_feedback, prefix="admin")
main_server.mount(user_feedback, "user")
main_server.mount(admin_feedback, "admin")
# Get tools
tools = await main_server.get_tools()
tools = await tool_map(main_server)
user_submit = tools["user_submit_feedback"]
admin_submit = tools["admin_submit_feedback"]
@@ -439,7 +440,7 @@ class TestServerCompositionWorkflow:
add_feedback_tools(server, database_url=memory_db_url, prefix="public")
add_feedback_tools(server, database_url=memory_db_url, prefix="internal")
tools = await server.get_tools()
tools = await tool_map(server)
public_submit = tools["public_submit_feedback"]
internal_submit = tools["internal_submit_feedback"]
@@ -490,7 +491,7 @@ class TestPerformanceWorkflow:
insights = FeedbackInsights(enabled=True)
add_feedback_tools(server, database_url=memory_db_url, insights=insights)
tools = await server.get_tools()
tools = await tool_map(server)
submit_tool = tools["submit_feedback"]
list_tool = tools["list_feedback"]
stats_tool = tools["get_feedback_statistics"]
@@ -525,8 +526,8 @@ class TestPerformanceWorkflow:
stats_result = await stats_tool.run({})
stats = stats_result.structured_content
assert stats["total_count"] >= 100
assert "IMPROVEMENT" in stats["by_type"]
assert stats["by_type"]["IMPROVEMENT"] >= 100
assert "improvement" in stats["by_type"]
assert stats["by_type"]["improvement"] >= 100
# Insights should have recorded all submissions
submit_metrics = insights.get_metrics("feedback_submitted")
@@ -537,7 +538,7 @@ class TestPerformanceWorkflow:
server = FastMCP("Filter Performance Server")
add_feedback_tools(server, database_url=memory_db_url)
tools = await server.get_tools()
tools = await tool_map(server)
submit_tool = tools["submit_feedback"]
list_tool = tools["list_feedback"]
update_tool = tools["update_feedback_status"]
@@ -611,7 +612,7 @@ class TestPerformanceWorkflow:
server = FastMCP("Concurrent Mixed Server")
add_feedback_tools(server, database_url=memory_db_url)
tools = await server.get_tools()
tools = await tool_map(server)
submit_tool = tools["submit_feedback"]
list_tool = tools["list_feedback"]
update_tool = tools["update_feedback_status"]
+565
View File
@@ -0,0 +1,565 @@
"""Tests for backfill_embeddings, backfill_feedback, its CLI, and list/dict
text flattening.
DB tests take the ``db_url`` fixture, so they run on SQLite and, with
FFB_TEST_DATABASE_URL set, on Postgres with pgvector.
"""
import json
import os
import subprocess
import sys
from datetime import UTC, datetime, timedelta
import pytest
from sqlalchemy import func, select, update
from fastmcp_feedback.instrumentation import (
REDACTED,
DatabaseSink,
EmbeddingSink,
EventRecord,
Redactor,
backfill_embeddings,
backfill_feedback,
extract_texts,
)
from fastmcp_feedback.instrumentation.backfill.cli import main as cli_main
from .test_embeddings import DIM, SECRET, FakeEmbedder, call_record, event, rows
T0 = datetime(2026, 9, 1, 12, 0, tzinfo=UTC)
@pytest.fixture
async def db(db_url):
made = []
def factory(name="calls", **kwargs):
kwargs.setdefault("create_tables", True)
kwargs.setdefault("embedding_dim", DIM)
sink = DatabaseSink(db_url(name), **kwargs)
made.append(sink)
return sink
yield factory
for sink in made:
await sink.engine.dispose()
def at(minutes: float) -> datetime:
return T0 + timedelta(minutes=minutes)
def error_call(minute, tool="render", message="render timed out", **kw):
record = call_record(tool=tool, message=message, **kw)
record.started_at = at(minute)
return record
def ev(minute, kind, key=None, **attrs):
record = event(kind, key, **attrs)
record.occurred_at = at(minute)
return record
def mixed_records():
"""One of everything backfill should pick up, and some it should not."""
return [
error_call(1, message="render timed out after 30s"),
error_call(2, tool="find", outcome="soft_error", message="no object Cube"),
call_record(outcome="ok"),
ev(
3, "feedback.submitted", "bug-1", title="Render hangs", description="at 99%"
),
ev(
4, "llm.call", model="m", prompt="summarize the log", completion="it failed"
),
ev(5, "llm.call", model="m", ok=False, error="rate limited"),
ev(6, "llm.call", model="m", ok=True),
ev(7, "job.failed", "job-1", error_excerpt=["line one", "line two"]),
ev(8, "job.progress", "job-1", percent=50),
]
def keyed(stored):
return {(r["source_type"], r["source_id"]): r for r in stored}
async def count_rows(sink):
t = sink.embeddings
async with sink.engine.connect() as conn:
return (await conn.execute(select(func.count()).select_from(t))).scalar_one()
class TestFlattening:
def test_list_and_tuple_join_lines(self):
records = [
event("job.failed", error_excerpt=[" a ", "", None, "b"]),
event("job.failed", error=("x", 1, 2.5)),
]
texts = [t.text for t in extract_texts(records)]
assert texts == ["a\nb", "x\n1\n2.5"]
def test_dict_becomes_sorted_key_lines(self):
records = [event("job.failed", error={"zeta": "last", "alpha": 1, "mid": None})]
[item] = extract_texts(records)
assert item.text == "alpha: 1\nzeta: last"
def test_nested(self):
records = [
event(
"job.failed",
error_excerpt=[["a", "b"], {"k": ["c", "d"]}, ("e",)],
)
]
[item] = extract_texts(records)
assert item.text == "a\nb\nk: c\nd\ne"
assert "[" not in item.text and "{" not in item.text
def test_flattening_applies_to_feedback_and_llm(self):
records = [
event("feedback.submitted", "f1", title=["Two", "parts"], description=None),
event("llm.call", prompt=["p1", "p2"], completion={"text": "done"}),
]
by_type = {t.source_type: t.text for t in extract_texts(records)}
assert by_type["feedback"] == "Two\nparts"
assert by_type["llm"] == "prompt: p1\np2\ncompletion: text: done"
def test_empty_containers_are_no_text(self):
records = [event("job.failed", error_excerpt=[], error={}, message=[[""]])]
assert extract_texts(records) == []
def test_bounded(self):
deep: list = ["bottom"]
for _ in range(100):
deep = [deep]
huge = ["x" * 1000] * 10_000
records = [event("job.failed", error=deep), event("job.failed", message=huge)]
texts = extract_texts(records)
assert len(texts) == 1 # the deep one is cut off before any text
assert len(texts[0].text) < 70_000
def test_other_values_use_str(self):
[item] = extract_texts([event("job.failed", error=42)])
assert item.text == "42"
class TestBackfill:
async def test_everything_once(self, db):
store = db()
await store.write(mixed_records())
emb = EmbeddingSink(FakeEmbedder(), store)
counts = await backfill_embeddings(emb)
assert counts == {
"feedback": 1,
"call_error": 2,
"event": 2,
"llm": 1,
"skipped": 0,
"errors": 0,
}
stored = keyed(await rows(store))
texts = {k[0]: [] for k in stored}
for (source_type, _), row in stored.items():
texts[source_type].append(row["text"])
assert sorted(texts["call_error"]) == [
"find: TimeoutError: no object Cube",
"render: TimeoutError: render timed out after 30s",
]
assert texts["feedback"] == ["Render hangs\nat 99%"]
assert texts["llm"] == ["prompt: summarize the log\ncompletion: it failed"]
assert sorted(texts["event"]) == ["line one\nline two", "rate limited"]
assert ("feedback", "bug-1") in stored
async def test_pre_outcome_error_rows(self, db):
store = db()
old = error_call(1, message="boom from before outcome existed")
old_ok = call_record(outcome="ok")
await store.write([old, old_ok])
t = store.table
async with store.engine.begin() as conn:
await conn.execute(update(t).values(outcome=None))
emb = EmbeddingSink(FakeEmbedder(), store)
counts = await backfill_embeddings(emb)
assert counts["call_error"] == 1
[row] = await rows(store)
assert row["source_id"] == old.id
assert row["text"] == "render: TimeoutError: boom from before outcome existed"
async def test_same_text_hash_and_id_as_live_sink(self, db):
records = mixed_records() + [
ev(9, "job.failed", error={"zeta": 1, "alpha": {"b": 2, "a": [1, 2]}}),
]
backfilled, live = db("backfilled"), db("live")
await backfilled.write(records)
await backfill_embeddings(EmbeddingSink(FakeEmbedder(), backfilled))
live_emb = EmbeddingSink(FakeEmbedder(), live)
await live_emb.write(records)
await live_emb.flush()
assert live_emb.errors == 0
def shape(stored):
return sorted(
(r["source_type"], r["source_id"], r["text"], r["text_hash"])
for r in stored
)
assert shape(await rows(backfilled)) == shape(await rows(live))
assert len(await rows(live)) == 7
async def test_live_records_dedupe_against_backfill(self, db):
records = mixed_records() + [
ev(9, "job.failed", error={"zeta": 1, "alpha": {"b": 2, "a": [1, 2]}}),
]
store = db()
await store.write(records)
await backfill_embeddings(EmbeddingSink(FakeEmbedder(), store))
before = await count_rows(store)
embedder = FakeEmbedder()
live = EmbeddingSink(embedder, store)
# The records as they come back from the database, as a live sink
# in another process would have seen them before storing.
await live.write(records)
await live.flush()
assert embedder.calls == []
assert live.embedded == 0 and live.skipped == 7
assert await count_rows(store) == before
async def test_second_run_embeds_nothing(self, db):
store = db()
await store.write(mixed_records())
embedder = FakeEmbedder()
emb = EmbeddingSink(embedder, store)
await backfill_embeddings(emb)
calls = len(embedder.calls)
again = await backfill_embeddings(emb)
assert again == {
"feedback": 0,
"call_error": 0,
"event": 0,
"llm": 0,
"skipped": 6,
"errors": 0,
}
assert len(embedder.calls) == calls
async def test_changed_text_replaces(self, db):
store = db()
record = error_call(1, message="first")
await store.write([record])
emb = EmbeddingSink(FakeEmbedder(), store)
await backfill_embeddings(emb)
async with store.engine.begin() as conn:
await conn.execute(
update(store.table).values(error_message="second"),
)
counts = await backfill_embeddings(emb)
assert counts["call_error"] == 1
[row] = await rows(store)
assert row["text"] == "render: TimeoutError: second"
async def test_sources(self, db):
store = db()
await store.write(mixed_records())
emb = EmbeddingSink(FakeEmbedder(), store)
counts = await backfill_embeddings(emb, sources=["feedback", "llm"])
assert counts == {"feedback": 1, "llm": 1, "skipped": 0, "errors": 0}
assert {r["source_type"] for r in await rows(store)} == {"feedback", "llm"}
async def test_defaults_to_sink_sources(self, db):
store = db()
await store.write(mixed_records())
emb = EmbeddingSink(FakeEmbedder(), store, sources=["call_error"])
counts = await backfill_embeddings(emb)
assert counts == {"call_error": 2, "skipped": 0, "errors": 0}
async def test_since_until(self, db):
store = db()
await store.write([error_call(m, message=f"error {m}") for m in range(10)])
emb = EmbeddingSink(FakeEmbedder(), store)
counts = await backfill_embeddings(emb, since=at(3), until=at(6))
assert counts["call_error"] == 3
texts = sorted(r["text"] for r in await rows(store))
assert texts == [f"render: TimeoutError: error {m}" for m in (3, 4, 5)]
# A naive datetime is UTC.
naive = (T0 + timedelta(minutes=8)).replace(tzinfo=None)
counts = await backfill_embeddings(emb, since=naive)
assert counts["call_error"] == 2
async def test_limit_takes_oldest_and_resumes(self, db):
store = db()
await store.write([error_call(m, message=f"error {m}") for m in range(10)])
emb = EmbeddingSink(FakeEmbedder(), store)
counts = await backfill_embeddings(emb, limit=4, batch_size=3)
assert counts["call_error"] == 4
texts = sorted(r["text"] for r in await rows(store))
assert texts == [f"render: TimeoutError: error {m}" for m in range(4)]
counts = await backfill_embeddings(emb, limit=4, batch_size=3)
assert counts["call_error"] == 4 and counts["skipped"] == 4
counts = await backfill_embeddings(emb, limit=0)
assert counts["call_error"] == 0
async def test_pages_through_many_rows(self, db):
store = db()
# Identical timestamps too, so paging must break ties on id.
records = [error_call(m // 5, message=f"error {m}") for m in range(53)]
records += [ev(m // 5, "job.failed", error=f"job error {m}") for m in range(41)]
await store.write(records)
embedder = FakeEmbedder()
emb = EmbeddingSink(embedder, store, batch_size=8)
counts = await backfill_embeddings(emb, batch_size=7)
assert counts["call_error"] == 53 and counts["event"] == 41
assert await count_rows(store) == 94
assert len(embedder.seen) == 94 # nothing read twice
assert max(len(c) for c in embedder.calls) <= 8
async def test_embedder_errors_are_counted(self, db):
store = db()
await store.write([error_call(m, message=f"error {m}") for m in range(5)])
emb = EmbeddingSink(FakeEmbedder(fail=True), store, batch_size=2)
counts = await backfill_embeddings(emb)
assert counts["errors"] == 5 and counts["call_error"] == 0
assert await count_rows(store) == 0
emb.embedder.fail = False
counts = await backfill_embeddings(emb)
assert counts["call_error"] == 5 and counts["errors"] == 0
async def test_wrong_vector_count_is_counted(self, db):
class Short(FakeEmbedder):
async def embed(self, texts):
return (await super().embed(texts))[:-1]
store = db()
await store.write([error_call(1), error_call(2, message="other")])
counts = await backfill_embeddings(EmbeddingSink(Short(), store))
assert counts["errors"] == 2
async def test_redaction(self, db):
store = db()
leaked = error_call(1, message=f"auth failed with {SECRET}")
custom = ev(2, "job.failed", error="ticket ACME-1234 failed")
await store.write([leaked, custom])
embedder = FakeEmbedder()
emb = EmbeddingSink(
embedder, store, redactor=Redactor(extra_patterns=[r"ACME-\d+"])
)
await backfill_embeddings(emb)
texts = sorted(r["text"] for r in await rows(store))
assert SECRET not in "".join(embedder.seen + texts)
assert "ACME-1234" not in "".join(embedder.seen + texts)
assert f"render: TimeoutError: auth failed with {REDACTED}" in texts
assert f"ticket {REDACTED} failed" in texts
async def test_truncation(self, db):
store = db()
await store.write([error_call(1, message="x" * 500)])
emb = EmbeddingSink(FakeEmbedder(), store, max_chars=50)
await backfill_embeddings(emb)
[row] = await rows(store)
assert len(row["text"]) == 50
async def test_dry_run_writes_nothing(self, db):
store = db()
await store.write(mixed_records())
embedder = FakeEmbedder()
emb = EmbeddingSink(embedder, store)
counts = await backfill_embeddings(emb, dry_run=True)
assert counts["call_error"] == 2 and counts["feedback"] == 1
assert embedder.calls == []
assert await rows(store) == []
async def test_missing_tables_raise(self, db):
store = db(create_tables=False)
emb = EmbeddingSink(FakeEmbedder(), store)
with pytest.raises(RuntimeError, match="does not exist"):
await backfill_embeddings(emb)
with pytest.raises(RuntimeError, match="does not exist"):
await backfill_embeddings(emb, dry_run=True)
async def test_bad_arguments(self, db):
emb = EmbeddingSink(FakeEmbedder(), db())
with pytest.raises(ValueError, match="unknown sources"):
await backfill_embeddings(emb, sources=["nope"])
with pytest.raises(ValueError):
await backfill_embeddings(emb, batch_size=0)
with pytest.raises(ValueError):
await backfill_embeddings(emb, limit=-1)
class TestBackfillFeedback:
async def test_matches_feedback_event(self, db):
own, events = db("own"), db("events")
items = [
("bug-1", " Render hangs ", "Stuck at 99%"),
("bug-2", "No description", None),
("bug-3", "", ""),
]
counts = await backfill_feedback(EmbeddingSink(FakeEmbedder(), own), items)
assert counts == {"feedback": 2, "skipped": 0, "errors": 0}
live = EmbeddingSink(FakeEmbedder(), events)
await live.write(
[
EventRecord(
kind="feedback.submitted",
occurred_at=datetime.now(UTC),
key=ref,
attrs={"title": title, "description": desc},
)
for ref, title, desc in items
]
)
await live.flush()
def shape(stored):
return sorted((r["source_id"], r["text"], r["text_hash"]) for r in stored)
assert shape(await rows(own)) == shape(await rows(events))
assert shape(await rows(own))[0][1] == "Render hangs\nStuck at 99%"
async def test_idempotent_and_dedupes_with_events(self, db):
store = db()
emb = EmbeddingSink(FakeEmbedder(), store)
await backfill_feedback(emb, [("bug-1", "Render hangs", "at 99%")])
await store.write(
[
ev(
1,
"feedback.submitted",
"bug-1",
title="Render hangs",
description="at 99%",
)
]
)
counts = await backfill_embeddings(emb)
assert counts["feedback"] == 0 and counts["skipped"] == 1
counts = await backfill_feedback(
emb, iter([("bug-1", "Render hangs", "at 99%")]), batch_size=1
)
assert counts == {"feedback": 0, "skipped": 1, "errors": 0}
async def test_redacted(self, db):
store = db()
emb = EmbeddingSink(FakeEmbedder(), store)
await backfill_feedback(emb, [("bug-1", "Leak", f"my key is {SECRET}")])
[row] = await rows(store)
assert SECRET not in row["text"] and REDACTED in row["text"]
class TestCli:
async def seeded(self, db):
store = db()
await store.write(mixed_records())
await store.ensure_embeddings()
return store
async def test_dry_run_main(self, db, capsys):
store = await self.seeded(db)
url = store.engine.url.render_as_string(hide_password=False)
code = await _in_thread(
cli_main,
[
"--database-url",
url,
"--dim",
str(DIM),
"--dry-run",
"--sources",
"call_error,feedback",
],
)
assert code == 0
out = json.loads(capsys.readouterr().out.strip().splitlines()[-1])
assert out == {
"dry_run": True,
"feedback": 1,
"call_error": 2,
"skipped": 0,
"errors": 0,
}
assert await rows(store) == []
async def test_dry_run_subprocess(self, db):
store = await self.seeded(db)
url = store.engine.url.render_as_string(hide_password=False)
env = {k: v for k, v in os.environ.items() if k != "FFB_EMBED_API_KEY"}
proc = await _in_thread(
subprocess.run,
[
sys.executable,
"-m",
"fastmcp_feedback.instrumentation.backfill",
"--database-url",
url,
"--dim",
str(DIM),
"--dry-run",
],
capture_output=True,
text=True,
env=env,
timeout=60,
)
assert proc.returncode == 0, proc.stderr
out = json.loads(proc.stdout.strip().splitlines()[-1])
assert out["call_error"] == 2 and out["llm"] == 1 and out["event"] == 2
assert "RuntimeWarning" not in proc.stderr
async def test_needs_embed_url_without_dry_run(self, capsys):
with pytest.raises(SystemExit) as exc:
cli_main(["--database-url", "sqlite+aiosqlite:///x.db"])
assert exc.value.code == 2
assert "--embed-base-url" in capsys.readouterr().err
async def test_no_api_key_flag(self, capsys):
with pytest.raises(SystemExit):
cli_main(["--database-url", "sqlite+aiosqlite:///x.db", "--api-key", "k"])
async def test_setup_error_exit_code(self, tmp_path, capsys):
url = f"sqlite+aiosqlite:///{tmp_path / 'empty.db'}"
code = await _in_thread(cli_main, ["--database-url", url, "--dry-run"])
assert code == 2
assert "does not exist" in capsys.readouterr().err
async def test_api_key_from_env(self, db, monkeypatch):
seen = {}
class Recorder(FakeEmbedder):
def __init__(self, base_url, api_key=None, model="m", dim=DIM, **kw):
super().__init__(dim=dim, model=model)
seen["api_key"] = api_key
seen["base_url"] = base_url
store = await self.seeded(db)
url = store.engine.url.render_as_string(hide_password=False)
monkeypatch.setattr(
"fastmcp_feedback.instrumentation.backfill.cli.OpenAIEmbedder", Recorder
)
monkeypatch.setenv("MY_KEY", "secret-value")
code = await _in_thread(
cli_main,
[
"--database-url",
url,
"--dim",
str(DIM),
"--embed-base-url",
"https://embed.example/v1",
"--api-key-env",
"MY_KEY",
],
)
assert code == 0
assert seen == {
"api_key": "secret-value",
"base_url": "https://embed.example/v1",
}
assert await count_rows(store) == 6
async def _in_thread(fn, *args, **kwargs):
# The CLI runs its own event loop, which cannot start on the test's.
import asyncio
return await asyncio.to_thread(fn, *args, **kwargs)
+90
View File
@@ -0,0 +1,90 @@
"""The instrumentation works on a base install, without the database extras.
The worker runs in a fresh interpreter with greenlet and aiosqlite made
unimportable, which is what ``pip install fastmcp-feedback`` leaves out.
"""
import json
import subprocess
import sys
from pathlib import Path
import pytest
WORKER = Path(__file__).parent.parent / "fixtures" / "base_install_worker.py"
@pytest.fixture(scope="module")
def report(tmp_path_factory) -> dict:
proc = subprocess.run(
[sys.executable, str(WORKER), str(tmp_path_factory.mktemp("base"))],
capture_output=True,
text=True,
timeout=60,
)
assert proc.returncode == 0, proc.stderr[-4000:]
return json.loads(proc.stdout.strip().splitlines()[-1])
def test_tool_calls_are_recorded(report):
assert report["result"] == "5"
assert report["tools"] == ["add", "submit_feedback"]
assert report["jsonl_lines"] == 4 # two calls, two events
def test_events_are_recorded(report):
assert report["event_kinds"] == ["feedback.submitted", "job.done"]
assert report["events_for"] == 1
def test_feedback_tools_work_alongside(report):
assert report["feedback"]["success"] is True
assert report["feedback"]["linked_calls"] == 0
def test_database_helpers_return_empty_defaults(report):
assert report["capture"] == []
assert report["similar"] == []
assert report["similar_feedback"] == []
assert report["feedback_context"] == []
assert report["link_feedback"] is False
assert report["database_sink"] is True
assert report["embedding_sink"] is True
def test_constants_import_without_the_extra(report):
assert report["prefix"] == "ffb_"
def test_storage_modules_stay_unloaded(report):
assert report["loaded"] == []
@pytest.mark.parametrize(
("name", "extra"),
[
("DatabaseSink", "[instrumentation]"),
("build_metadata", "[instrumentation]"),
("EmbeddingSink", "[instrumentation,embeddings]"),
("backfill_feedback", "[instrumentation,embeddings]"),
],
)
def test_database_names_explain_the_missing_extra(report, name, extra):
# Asked twice: SQLAlchemy reports a missing greenlet only on first import.
for message in report["errors"][name]:
assert message is not None
assert "greenlet" in message
assert f'uv add "fastmcp-feedback{extra}"' in message
def test_lazy_names_resolve_with_the_extra():
import fastmcp_feedback.instrumentation as inst
from fastmcp_feedback.instrumentation import DatabaseSink, storage
assert DatabaseSink is storage.DatabaseSink
assert set(inst._LAZY) <= set(inst.__all__)
assert set(inst.__all__) <= set(dir(inst))
for name in inst.__all__:
assert getattr(inst, name) is not None
with pytest.raises(AttributeError):
_ = inst.not_a_name
+101
View File
@@ -0,0 +1,101 @@
"""soft_error_classifier: message_keys and dotted paths into nested results."""
import pytest
from fastmcp_feedback.instrumentation import soft_error_classifier
from fastmcp_feedback.instrumentation.classify import MESSAGE_KEYS, lookup
# mcardupilot: a wait that returned because the SITL died, the cause nested.
SITL = soft_error_classifier(
status_keys=("status", "state", "reason"),
extra_statuses=("died",),
message_keys=("snapshot.error", "error", "message", "detail", "reason"),
)
class TestMcardupilot:
def test_died_reads_the_nested_cause(self):
result = {"reason": "died", "snapshot": {"error": "SITL exited: segfault"}}
assert SITL("wait_until", result) == "reason=died: SITL exited: segfault"
def test_timeout_stays_ok(self):
result = {"reason": "timeout", "snapshot": {"error": None, "alt": 12.0}}
assert SITL("wait_until", result) is None
def test_reached_stays_ok(self):
assert SITL("wait_until", {"reason": "reached", "snapshot": {}}) is None
def test_died_without_a_cause_falls_back(self):
assert SITL("wait_until", {"reason": "died"}) == "reason=died: died"
def test_dead_session_state(self):
result = {"state": "died", "error": "lost heartbeat"}
assert SITL("snapshot", result) == "state=died: lost heartbeat"
def test_default_message_keys_cannot_reach_it(self):
plain = soft_error_classifier(
status_keys=("status", "state", "reason"), extra_statuses=("died",)
)
result = {"reason": "died", "snapshot": {"error": "SITL exited: segfault"}}
assert plain("wait_until", result) == "reason=died: died"
class TestLookup:
@pytest.mark.parametrize(
"result",
[
{},
{"snapshot": None},
{"snapshot": "a string"},
{"snapshot": ["error"]},
{"snapshot": 3},
{"snapshot": {}},
{"snapshot": {"other": 1}},
],
)
def test_missing_or_non_dict_step_is_absent(self, result):
assert lookup(result, "snapshot.error") is None
def test_deep_path(self):
assert lookup({"a": {"b": {"c": "x"}}}, "a.b.c") == "x"
def test_literal_dotted_key_wins(self):
result = {"snapshot.error": "literal", "snapshot": {"error": "nested"}}
assert lookup(result, "snapshot.error") == "literal"
def test_plain_key(self):
assert lookup({"error": "x"}, "error") == "x"
assert lookup({"error": None}, "error") is None
def test_odd_paths_never_raise(self):
for key in ("", ".", "a.", ".a", "a..b"):
lookup({"a": {"": {"b": 1}}}, key)
class TestOptions:
def test_default_message_keys(self):
assert MESSAGE_KEYS == ("error", "message", "detail", "reason")
def test_message_keys_order(self):
classify = soft_error_classifier(message_keys=("detail", "error"))
result = {"status": "failed", "error": "e", "detail": "d"}
assert classify("t", result) == "status=failed: d"
def test_dotted_error_keys(self):
classify = soft_error_classifier(error_keys=("result.error",))
assert classify("t", {"result": {"error": " broke "}}) == "result.error: broke"
assert classify("t", {"result": {"error": ""}}) is None
assert classify("t", {"result": "error"}) is None
def test_dotted_error_keys_respect_explicit_success(self):
classify = soft_error_classifier(error_keys=("result.error",))
assert classify("t", {"ok": True, "result": {"error": "warn"}}) is None
def test_nested_dict_message_searched_with_same_keys(self):
classify = soft_error_classifier(message_keys=("problem",))
result = {"status": "failed", "problem": {"problem": "inner"}}
assert classify("t", result) == "status=failed: inner"
def test_is_error_uses_message_keys(self):
classify = soft_error_classifier(message_keys=("why",))
assert classify("t", {"isError": True, "why": "nope"}) == "isError: nope"
+524
View File
@@ -0,0 +1,524 @@
"""Statistics labels, event identity and logging, as fixed in 2026.10.01.4.
Statistics run on SQLite and, with FFB_TEST_DATABASE_URL set, on Postgres
through the feedback tools' own synchronous engine (psycopg2).
"""
import asyncio
import contextlib
import json
import os
import subprocess
import sys
import tomllib
from pathlib import Path
import pytest
from fastmcp import Client, FastMCP
from fastmcp.exceptions import ToolError
from packaging.requirements import Requirement
from sqlalchemy import String, select, text, type_coerce
from sqlalchemy.engine import make_url
import fastmcp_feedback
from fastmcp_feedback import (
FeedbackDatabase,
ManagementMixin,
RetrievalMixin,
SubmissionMixin,
SubmitFeedbackRequest,
get_database_stats,
)
from fastmcp_feedback.database import enum_label
from fastmcp_feedback.instrumentation import REDACTED, MemorySink, instrument
from fastmcp_feedback.models import Feedback, FeedbackStatus, FeedbackType
ROOT = Path(__file__).resolve().parents[2]
# Statistics
@pytest.fixture
async def feedback_db(db_backend, db_url):
"""A FeedbackDatabase on an empty database of the current backend."""
url = make_url(db_url("feedback"))
if db_backend == "postgres":
pytest.importorskip("psycopg2")
url = url.set(drivername="postgresql+psycopg2")
else:
url = url.set(drivername="sqlite")
db = FeedbackDatabase(url.render_as_string(hide_password=False))
await db.initialize()
yield db
await db.close()
async def seed(db):
"""Two bugs (one in progress) and a feature, through the tools."""
submit = SubmissionMixin(db)
ids = []
for kind in ("bug", "bug", "feature"):
result = await submit.submit_feedback(
SubmitFeedbackRequest(
type=kind, title=f"a {kind}", description="d", submitter="t"
)
)
assert result["success"], result
ids.append(result["feedback_id"])
updated = await ManagementMixin(db).update_feedback_status(
ids[0], FeedbackStatus.IN_PROGRESS
)
assert updated["success"], updated
return ids
class TestStatisticsLabels:
async def test_database_stores_enum_names(self, feedback_db):
# Why the mapping exists: the columns hold names on both backends.
await seed(feedback_db)
async with feedback_db.session() as session:
stored = session.execute(
select(type_coerce(Feedback.type, String)).distinct()
).scalars()
assert set(stored) == {"BUG", "FEATURE"}
async def test_statistics_keyed_by_value(self, feedback_db):
await seed(feedback_db)
stats = await RetrievalMixin(feedback_db).get_feedback_statistics()
assert "error" not in stats, stats
assert stats["total_count"] == 3
assert stats["by_type"] == {"bug": 2, "feature": 1}
assert stats["by_status"] == {"open": 2, "in_progress": 1}
async def test_statistics_keys_match_list_feedback(self, feedback_db):
await seed(feedback_db)
retrieval = RetrievalMixin(feedback_db)
stats = await retrieval.get_feedback_statistics()
listed = (await retrieval.list_feedback(per_page=100))["feedback"]
assert set(stats["by_type"]) == {f["type"] for f in listed}
assert set(stats["by_status"]) == {f["status"] for f in listed}
async def test_filter_by_statistics_key(self, feedback_db):
await seed(feedback_db)
retrieval = RetrievalMixin(feedback_db)
stats = await retrieval.get_feedback_statistics()
for kind, count in stats["by_type"].items():
listed = await retrieval.list_feedback(type_filter=kind)
assert listed["total_count"] == count
async def test_database_stats_keyed_by_value(self, feedback_db):
await seed(feedback_db)
stats = await get_database_stats(feedback_db)
assert stats["by_type"] == {"bug": 2, "feature": 1}
assert stats["by_status"] == {"open": 2, "in_progress": 1}
async def test_empty_database(self, feedback_db):
stats = await RetrievalMixin(feedback_db).get_feedback_statistics()
assert stats["by_type"] == {} and stats["by_status"] == {}
class TestEnumLabel:
@pytest.mark.parametrize(
("enum_cls", "stored", "label"),
[
(FeedbackType, FeedbackType.BUG, "bug"),
(FeedbackType, "BUG", "bug"),
(FeedbackType, "bug", "bug"),
(FeedbackStatus, "IN_PROGRESS", "in_progress"),
(FeedbackStatus, "in_progress", "in_progress"),
(FeedbackStatus, "archived", "archived"),
],
)
def test_label(self, enum_cls, stored, label):
assert enum_label(enum_cls, stored) == label
async def test_rows_written_as_values_are_merged(self, tmp_path):
# A row holding the value instead of the name (written by hand, or by
# another tool) is counted under the same key, not dropped.
db = FeedbackDatabase(f"sqlite:///{tmp_path}/legacy.db")
await db.initialize()
try:
await seed(db)
async with db.session() as session:
session.execute(
text(
"INSERT INTO feedback (type, title, description, submitter,"
" status, created_at, updated_at) VALUES ('bug', 't', 'd',"
" 's', 'open', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
)
)
session.commit()
stats = await RetrievalMixin(db).get_feedback_statistics()
assert stats["by_type"] == {"bug": 3, "feature": 1}
assert stats["by_status"] == {"open": 3, "in_progress": 1}
finally:
await db.close()
# Event identity
def make_app(resolver=None, **kwargs):
sink = MemorySink()
app = FastMCP("identity")
holder = {"spawned": []}
@app.tool
async def submit(job_id: str) -> dict:
holder["mw"].record_event("job.submitted", key=job_id)
return {"job_id": job_id}
@app.tool
def submit_sync(job_id: str) -> dict:
holder["mw"].record_event("job.submitted", key=job_id)
return {"job_id": job_id}
@app.tool
async def submit_as(job_id: str) -> dict:
holder["mw"].record_event(
"job.submitted",
key=job_id,
user_sub="given-user",
caller_kind="given-kind",
client_id="given-client",
)
return {"job_id": job_id}
@app.tool
async def chat() -> str:
holder["mw"].record_llm_call(model="m", provider="p")
return "ok"
@app.tool
async def fails() -> str:
holder["mw"].record_event("job.failed")
raise RuntimeError("no")
@app.tool
async def spawn(job_id: str) -> dict:
"""Start a task that records after this call has returned."""
started = asyncio.Event()
async def watcher():
started.set()
await asyncio.sleep(0.05)
holder["mw"].record_event("job.started", key=job_id)
holder["spawned"].append(asyncio.get_running_loop().create_task(watcher()))
await started.wait()
return {"job_id": job_id}
mw = instrument(app, [sink], identity_resolver=resolver, **kwargs)
holder["mw"] = mw
return app, sink, mw, holder["spawned"]
async def call(app, mw, *calls):
async with Client(app) as client:
for tool, args in calls:
with contextlib.suppress(ToolError):
await client.call_tool(tool, args)
await mw.flush()
class Resolver:
"""Counts its invocations; returns a different user each time."""
def __init__(self, extra=None):
self.calls = 0
self.extra = extra or {}
def __call__(self, context):
self.calls += 1
return {
"user_sub": f"user-{self.calls}",
"caller_kind": "pat",
"client_id": "addon",
"org": "acme",
**self.extra,
}
class AsyncResolver(Resolver):
async def __call__(self, context):
await asyncio.sleep(0)
return Resolver.__call__(self, context)
IDENTITY = ("user-1", "pat", "addon")
def ident(record):
return (record.user_sub, record.caller_kind, record.client_id)
class TestEventIdentity:
@pytest.mark.parametrize("resolver_cls", [Resolver, AsyncResolver])
async def test_event_in_call_gets_resolver_identity(self, resolver_cls):
who = resolver_cls()
app, sink, mw, _ = make_app(who)
await call(app, mw, ("submit", {"job_id": "j1"}))
[rec] = sink.calls
[ev] = sink.events
assert ident(ev) == IDENTITY
assert ident(rec) == IDENTITY
assert ev.call_id == rec.id
assert rec.identity == {"org": "acme"}
async def test_sync_tool_event_gets_identity(self):
app, sink, mw, _ = make_app(Resolver())
await call(app, mw, ("submit_sync", {"job_id": "j1"}))
await asyncio.sleep(0.01)
await mw.flush()
[ev] = sink.events
assert ident(ev) == IDENTITY
async def test_llm_call_event_gets_identity(self):
app, sink, mw, _ = make_app(Resolver())
await call(app, mw, ("chat", {}))
[ev] = sink.events
assert ev.kind == "llm.call" and ident(ev) == IDENTITY
async def test_failing_call_event_and_record_share_identity(self):
app, sink, mw, _ = make_app(Resolver())
await call(app, mw, ("fails", {}))
[rec] = sink.calls
[ev] = sink.events
assert rec.outcome == "error"
assert ident(ev) == ident(rec) == IDENTITY
async def test_resolver_runs_once_per_call(self):
who = Resolver()
app, sink, mw, _ = make_app(who)
await call(
app,
mw,
("submit", {"job_id": "a"}),
("chat", {}),
("fails", {}),
("submit", {"job_id": "b"}),
)
assert who.calls == 4
assert [r.user_sub for r in sink.calls] == [
"user-1",
"user-2",
"user-3",
"user-4",
]
# Each event carries its own call's identity.
assert [(e.user_sub, e.call_id) for e in sink.events] == [
(r.user_sub, r.id) for r in sink.calls
]
async def test_explicit_identity_wins(self):
app, sink, mw, _ = make_app(Resolver())
await call(app, mw, ("submit_as", {"job_id": "j1"}))
[ev] = sink.events
assert ident(ev) == ("given-user", "given-kind", "given-client")
assert ident(sink.calls[0]) == IDENTITY
async def test_task_after_call_gets_no_identity(self):
who = Resolver()
app, sink, mw, spawned = make_app(who)
await call(app, mw, ("spawn", {"job_id": "j1"}))
await asyncio.gather(*spawned)
await mw.flush()
[rec] = sink.calls
[ev] = sink.events
assert ident(rec) == IDENTITY
assert ev.kind == "job.started"
assert ev.call_id is None and ev.session_id is None
assert ident(ev) == (None, None, None)
assert who.calls == 1
async def test_outside_call_unchanged(self):
who = Resolver()
app, sink, mw, _ = make_app(who)
mw.record_event("job.finished", key="j1")
mw.record_event("job.finished", key="j2", user_sub="u9", client_id="c9")
await mw.flush()
first, second = sink.events
assert ident(first) == (None, None, None) and first.call_id is None
assert ident(second) == ("u9", None, "c9")
assert who.calls == 0
async def test_identity_redacted_for_events(self):
token = "bmcp_abcdefgh12345678"
app, sink, mw, _ = make_app(Resolver({"caller_kind": f"pat:{token}"}))
await call(app, mw, ("submit", {"job_id": "j1"}))
[ev] = sink.events
assert ev.caller_kind == f"pat:{REDACTED}"
assert sink.calls[0].caller_kind == f"pat:{REDACTED}"
async def test_failing_resolver_leaves_identity_empty(self):
calls = []
def boom(context):
calls.append(1)
raise RuntimeError("resolver bug")
app, sink, mw, _ = make_app(boom)
await call(app, mw, ("submit", {"job_id": "j1"}))
[rec] = sink.calls
[ev] = sink.events
assert rec.ok and ev.call_id == rec.id
assert ev.user_sub is None and rec.user_sub is None
# An empty first answer gets a second run after the tool; it fails too.
assert calls == [1, 1]
async def test_slow_resolver_cut_off_before_tool(self):
async def slow(context):
await asyncio.sleep(5)
return {"user_sub": "late"}
app, sink, mw, _ = make_app(slow, hook_timeout=0.05)
await call(app, mw, ("submit", {"job_id": "j1"}))
[rec] = sink.calls
[ev] = sink.events
assert rec.user_sub is None and ev.user_sub is None and rec.ok
async def test_mode_off_runs_no_resolver(self):
who = Resolver()
app, sink, mw, _ = make_app(who, mode="off")
await call(app, mw, ("submit", {"job_id": "j1"}))
assert who.calls == 0 and sink.calls == [] and sink.events == []
async def test_excluded_tool_runs_no_resolver(self):
who = Resolver()
app, sink, mw, _ = make_app(who, exclude_tools={"submit"})
await call(app, mw, ("submit", {"job_id": "j1"}))
[ev] = sink.events
assert who.calls == 0 and ev.user_sub is None and ev.call_id is None
# Logging
def run_python(code, *args, env=None):
return subprocess.run(
[sys.executable, "-c", code, *args],
capture_output=True,
text=True,
timeout=60,
env=env,
)
class TestLogging:
def test_import_configures_nothing_and_prints_nothing(self):
proc = run_python(
# FastMCP 2.x and 3.0 reset warning filters on import and authlib
# then warns; drop warnings so stderr holds only logging output.
"import warnings\nwarnings.showwarning = lambda *a, **k: None\n"
"import logging, fastmcp_feedback, fastmcp_feedback.instrumentation\n"
"root = logging.getLogger()\n"
"print(len(root.handlers), root.level)\n"
"print([type(h).__name__ for h in"
" logging.getLogger('fastmcp_feedback').handlers])\n"
)
assert proc.returncode == 0, proc.stderr
assert proc.stderr == ""
handlers, names = proc.stdout.splitlines()
assert handlers == f"0 {30}" # no handler, level still WARNING
assert names == "['NullHandler']"
def test_import_logs_nothing_above_debug(self):
# A host that set up logging first sees only the DEBUG line.
proc = run_python(
"import logging, json\n"
"seen = []\n"
"class H(logging.Handler):\n"
" def emit(self, r):\n"
" if r.name.startswith('fastmcp_feedback'):\n"
" seen.append((r.levelname, r.getMessage()))\n"
"logging.basicConfig(level=logging.DEBUG, handlers=[H()])\n"
"import fastmcp_feedback\n"
"print(json.dumps(seen))\n"
)
assert proc.returncode == 0, proc.stderr
seen = json.loads(proc.stdout.splitlines()[-1])
assert [level for level, _ in seen] == ["DEBUG"]
assert "initialized" in seen[0][1]
def test_setup_logging_is_opt_in(self):
proc = run_python("import fastmcp_feedback\nfastmcp_feedback.setup_logging()\n")
assert proc.returncode == 0
assert "fastmcp_feedback - INFO - FastMCP Feedback v" in proc.stderr
def test_package_info_matches_pyproject(self):
pyproject = ROOT / "pyproject.toml"
if not pyproject.exists():
pytest.skip("not running from a source checkout")
project = tomllib.loads(pyproject.read_text())["project"]
info = fastmcp_feedback.PACKAGE_INFO
assert info["version"] == fastmcp_feedback.__version__ == project["version"]
def reqs(items):
return {str(Requirement(r)) for r in items}
assert reqs(info["dependencies"]) == reqs(project["dependencies"])
extras = project["optional-dependencies"]
assert set(info["optional_dependencies"]) == set(extras)
for name, items in extras.items():
if any(r.startswith("fastmcp-feedback") for r in items):
continue # self-references are expanded in the metadata
assert reqs(info["optional_dependencies"][name]) == reqs(items), name
assert info["classifiers"] == sorted(project["classifiers"])
class TestBackfillCliLogging:
@pytest.fixture
def stored_db(self, tmp_path):
"""A calls database with one error, written by the CLI's own sink."""
url = f"sqlite+aiosqlite:///{tmp_path}/calls.db"
code = (
"import asyncio, sys\n"
"from datetime import UTC, datetime\n"
"from fastmcp_feedback.instrumentation import DatabaseSink,"
" ToolCallRecord\n"
"async def main():\n"
" sink = DatabaseSink(sys.argv[1], create_tables=True)\n"
" await sink.write([ToolCallRecord(tool='t',"
" started_at=datetime.now(UTC), duration_ms=1, ok=False,"
" outcome='error', mode='meta', error_type='E',"
" error_message='broke')])\n"
" await sink.ensure_embeddings()\n"
" await sink.engine.dispose()\n"
"asyncio.run(main())\n"
)
proc = run_python(code, url)
assert proc.returncode == 0, proc.stderr
return url
def backfill(self, url, *flags):
env = {k: v for k, v in os.environ.items() if k != "FFB_EMBED_API_KEY"}
return subprocess.run(
[
sys.executable,
"-m",
"fastmcp_feedback.instrumentation.backfill",
"--database-url",
url,
"--dry-run",
*flags,
],
capture_output=True,
text=True,
env=env,
timeout=60,
)
def test_verbose_logs_at_info(self, stored_db):
proc = self.backfill(stored_db, "-v")
assert proc.returncode == 0, proc.stderr
assert "INFO fastmcp_feedback.instrumentation.backfill: Backfill dry run" in (
proc.stderr
)
assert json.loads(proc.stdout.splitlines()[-1])["call_error"] == 1
def test_quiet_without_verbose(self, stored_db):
proc = self.backfill(stored_db)
assert proc.returncode == 0, proc.stderr
assert "INFO" not in proc.stderr
assert "initialized" not in proc.stderr
+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(db_url):
"""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(db_url(name), 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]]
+162
View File
@@ -0,0 +1,162 @@
"""create_tables=True when several processes start against the same empty database.
Both processes see the tables missing and both CREATE them; one fails. The
sink retries create_all once, which skips what the other process made.
"""
import json
import subprocess
import sys
from pathlib import Path
import pytest
from sqlalchemy import create_engine, func, select
from sqlalchemy.exc import OperationalError
from fastmcp_feedback.instrumentation import (
DatabaseSink,
InstrumentationMiddleware,
build_metadata,
)
EVENTS = build_metadata().tables["ffb_events"]
WORKER = Path(__file__).parent.parent / "fixtures" / "create_tables_race_worker.py"
def _already_exists():
return OperationalError(
"CREATE TABLE ffb_tool_calls",
{},
Exception("table ffb_tool_calls already exists"),
)
def race(url: str, trigger: Path, workers: int = 2, events: int = 20) -> list[dict]:
"""Start the workers, release them together, return their JSON results."""
procs = [
subprocess.Popen(
[sys.executable, str(WORKER), url, str(trigger), str(events)],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
for _ in range(workers)
]
try:
for p in procs:
assert p.stdout.readline().strip() == "ready"
trigger.touch()
results = []
for p in procs:
out, err = p.communicate(timeout=30)
assert p.returncode == 0, err
results.append(json.loads(out.strip().splitlines()[-1]))
return results
finally:
for p in procs:
if p.poll() is None:
p.kill()
p.wait()
@pytest.mark.slow
def test_two_processes_cold_start(tmp_path):
workers, events = 2, 20
for run in range(5):
db = tmp_path / f"shared{run}.db"
results = race(
f"sqlite+aiosqlite:///{db}", tmp_path / f"go{run}", workers, events
)
engine = create_engine(f"sqlite:///{db}")
try:
with engine.connect() as conn:
stored = conn.execute(
select(func.count()).select_from(EVENTS)
).scalar_one()
finally:
engine.dispose()
assert [r["sink_errors"] for r in results] == [0] * workers, f"run {run}"
assert stored == workers * events, f"run {run}"
@pytest.fixture
async def sink(tmp_path):
s = DatabaseSink(f"sqlite+aiosqlite:///{tmp_path}/calls.db", create_tables=True)
yield s
await s.engine.dispose()
class TestEnsureTablesRetry:
async def test_first_create_fails_retry_succeeds(self, sink, monkeypatch, caplog):
real = sink.metadata.create_all
calls = []
def create_all(bind, **kwargs):
calls.append(1)
if len(calls) == 1:
raise _already_exists()
return real(bind, **kwargs)
monkeypatch.setattr(sink.metadata, "create_all", create_all)
mw = InstrumentationMiddleware([sink])
caplog.set_level("DEBUG", logger="fastmcp_feedback.instrumentation.storage")
assert mw.record_event("a.b", key="k") is True
await mw.flush()
assert mw.dispatcher.sink_errors == 0
assert len(calls) == 2
assert sink._tables_ready
async with sink.engine.connect() as conn:
count = (
await conn.execute(select(func.count()).select_from(sink.events))
).scalar_one()
assert count == 1
retry_logs = [r for r in caplog.records if "retry succeeded" in r.getMessage()]
assert retry_logs and all(r.levelname == "INFO" for r in retry_logs)
assert not [
r
for r in caplog.records
if r.levelname == "WARNING" and r.name.endswith("storage")
]
async def test_persistent_failure_surfaces(self, sink, monkeypatch):
calls = []
def create_all(bind, **kwargs):
calls.append(1)
raise _already_exists()
monkeypatch.setattr(sink.metadata, "create_all", create_all)
mw = InstrumentationMiddleware([sink])
assert mw.record_event("a.b") is True
await mw.flush()
assert mw.dispatcher.sink_errors == 1
assert len(calls) == 2
assert not sink._tables_ready
# The next batch tries again rather than assuming the tables exist.
assert mw.record_event("a.c") is True
await mw.flush()
assert mw.dispatcher.sink_errors == 2
assert len(calls) == 4
await mw.aclose()
async def test_non_database_error_not_retried(self, sink, monkeypatch):
calls = []
def create_all(bind, **kwargs):
calls.append(1)
raise RuntimeError("bug")
monkeypatch.setattr(sink.metadata, "create_all", create_all)
with pytest.raises(RuntimeError):
await sink.ensure_tables()
assert len(calls) == 1
async def test_create_tables_false_never_creates(self, tmp_path, monkeypatch):
s = DatabaseSink(f"sqlite+aiosqlite:///{tmp_path}/none.db")
calls = []
monkeypatch.setattr(s.metadata, "create_all", lambda *a, **k: calls.append(1))
try:
await s._ready()
finally:
await s.engine.dispose()
assert calls == []
+5 -5
View File
@@ -140,10 +140,10 @@ class TestDatabaseAdvancedOperations:
stats = await get_database_stats(db)
assert stats["total_count"] == 2
assert stats["by_type"]["BUG"] == 1
assert stats["by_type"]["FEATURE"] == 1
assert stats["by_status"]["OPEN"] == 1
assert stats["by_status"]["RESOLVED"] == 1
assert stats["by_type"]["bug"] == 1
assert stats["by_type"]["feature"] == 1
assert stats["by_status"]["open"] == 1
assert stats["by_status"]["resolved"] == 1
assert stats["database_url"] == db.database_url
assert stats["is_initialized"] is True
@@ -629,7 +629,7 @@ class TestDatabasePerformanceAndLimits:
stats = await get_database_stats(db)
assert stats["total_count"] == 1000
assert len(stats["by_type"]) >= 1
assert stats["by_type"]["IMPROVEMENT"] == 1000
assert stats["by_type"]["improvement"] == 1000
await db.close()
+848
View File
@@ -0,0 +1,848 @@
"""Tests for EmbeddingSink, OpenAIEmbedder and similarity search.
DB tests take the ``db_url`` fixture, so they run on SQLite and, with
FFB_TEST_DATABASE_URL set, on Postgres with pgvector.
"""
import asyncio
import contextlib
import hashlib
import json
import logging
import math
import os
import re
import time
import uuid
from datetime import UTC, datetime, timedelta
import pytest
from fastmcp import Client, FastMCP
from fastmcp.exceptions import ToolError
from sqlalchemy import func, select, text, update
from fastmcp_feedback import add_feedback_tools
from fastmcp_feedback.instrumentation import (
REDACTED,
DatabaseSink,
EmbeddingSink,
EventRecord,
OpenAIEmbedder,
Redactor,
ToolCallRecord,
build_metadata,
extract_texts,
instrument,
)
from fastmcp_feedback.instrumentation.embeddings import cosine_score
DIM = 256
SECRET = "sk-abcdefghijklmnopqrstuvwx"
def token_vector(text_: str, dim: int = DIM) -> list[float]:
"""Bag of hashed words, normalized: texts sharing words are close."""
v = [0.0] * dim
for tok in re.findall(r"[a-z0-9]+", text_.lower()):
h = int.from_bytes(hashlib.sha256(tok.encode()).digest()[:4], "big")
v[h % dim] += 1.0
n = math.sqrt(sum(x * x for x in v)) or 1.0
return [x / n for x in v]
class FakeEmbedder:
def __init__(
self,
dim=DIM,
model="fake-embed",
*,
fail=False,
delay=0.0,
gate=None,
out_dim=None,
):
self.dim = dim
self.model = model
self.fail = fail
self.delay = delay
self.gate = gate
self.out_dim = out_dim or dim
self.calls: list[list[str]] = []
@property
def seen(self) -> list[str]:
return [t for batch in self.calls for t in batch]
async def embed(self, texts):
self.calls.append(list(texts))
if self.gate is not None:
await self.gate.wait()
if self.delay:
await asyncio.sleep(self.delay)
if self.fail:
raise RuntimeError("embedding backend down")
return [token_vector(t, self.out_dim) for t in texts]
@pytest.fixture
async def db(db_url):
made = []
def factory(name="calls", **kwargs):
kwargs.setdefault("create_tables", True)
kwargs.setdefault("embedding_dim", DIM)
sink = DatabaseSink(db_url(name), **kwargs)
made.append(sink)
return sink
yield factory
for sink in made:
await sink.engine.dispose()
def call_record(tool="render", outcome="error", message="render timed out", **kw):
return ToolCallRecord(
tool=tool,
started_at=datetime.now(UTC),
duration_ms=1.0,
ok=outcome == "ok",
outcome=outcome,
mode="meta",
error_type=None if outcome == "ok" else kw.pop("error_type", "TimeoutError"),
error_message=None if outcome == "ok" else message,
**kw,
)
def event(kind, key=None, **attrs):
return EventRecord(kind=kind, occurred_at=datetime.now(UTC), key=key, attrs=attrs)
async def rows(sink, *where):
t = sink.embeddings
if not await sink._has_table(t.name): # created on the first embedding
return []
async with sink.engine.connect() as conn:
result = await conn.execute(select(t).where(*where).order_by(t.c.source_id))
return [dict(r) for r in result.mappings()]
async def settle(mw, emb):
await mw.flush()
await emb.flush()
class TestExtractTexts:
def test_call_errors(self):
ok = call_record(outcome="ok")
err = call_record(message="render timed out", error_type="TimeoutError")
soft = call_record(
tool="find",
outcome="soft_error",
message="status=unknown_target",
error_type="SoftError",
)
items = extract_texts([ok, err, soft])
assert [(i.source_type, i.source_id, i.text) for i in items] == [
("call_error", err.id, "render: TimeoutError: render timed out"),
("call_error", soft.id, "find: SoftError: status=unknown_target"),
]
def test_feedback(self):
ev = event(
"feedback.submitted",
key="bug-7Q2X",
title="Render hangs",
description="the render tool hung on scene a",
type="bug",
)
[item] = extract_texts([ev])
assert (item.source_type, item.source_id, item.text) == (
"feedback",
"bug-7Q2X",
"Render hangs\nthe render tool hung on scene a",
)
def test_event_text_keys(self):
ev = event(
"job.failed",
key="job-1",
error_excerpt="solver diverged",
reason="residual NaN",
nproc=8,
)
quiet = event("job.finished", key="job-2", duration_s=3)
[item] = extract_texts([ev, quiet])
assert (item.source_type, item.source_id, item.text) == (
"event",
ev.id,
"solver diverged\nresidual NaN",
)
[custom] = extract_texts([quiet], event_text_keys=("duration_s",))
assert custom.text == "3"
def test_llm(self):
ev = event("llm.call", model="m", prompt="why red?", completion="the light")
[item] = extract_texts([ev])
assert (item.source_type, item.source_id, item.text) == (
"llm",
ev.id,
"prompt: why red?\ncompletion: the light",
)
def test_llm_without_text_is_a_plain_event(self):
ev = event("llm.call", model="m", ok=False, error="upstream 529 overloaded")
[item] = extract_texts([ev])
assert (item.source_type, item.text) == ("event", "upstream 529 overloaded")
def test_sources_filter(self):
records = [
call_record(),
event("feedback.submitted", key="1", title="t", description="d"),
event("job.failed", error="boom"),
event("llm.call", prompt="p"),
]
assert {i.source_type for i in extract_texts(records)} == {
"call_error",
"feedback",
"event",
"llm",
}
assert [
i.source_type for i in extract_texts(records, sources=["feedback"])
] == ["feedback"]
class TestEmbeddingSink:
async def test_every_source_lands(self, db):
sink = db()
fake = FakeEmbedder()
emb = EmbeddingSink(fake, sink)
err = call_record()
records = [
err,
event(
"feedback.submitted",
key="42",
title="Render hangs",
description="scene a",
),
event("job.failed", key="job-1", error_excerpt="solver diverged"),
event("llm.call", prompt="hello", completion="hi"),
]
await emb.write(records)
await emb.flush()
stored = await rows(sink)
assert {r["source_type"] for r in stored} == {
"call_error",
"feedback",
"event",
"llm",
}
for r in stored:
assert r["dim"] == DIM and r["model"] == "fake-embed"
assert len(r["embedding"]) == DIM
assert r["text_hash"] == hashlib.sha256(r["text"].encode()).hexdigest()
assert r["created_at"] is not None
assert emb.embedded == 4 and emb.errors == 0
await emb.aclose()
async def test_submit_feedback_is_embedded(self, db, tmp_path):
app = FastMCP("app")
sink = db("fb")
fake = FakeEmbedder()
emb = EmbeddingSink(fake, sink)
mw = instrument(app, [sink, emb])
add_feedback_tools(
app, database_url=f"sqlite:///{tmp_path}/fb.db", instrumentation=mw
)
async with Client(app) as client:
result = await client.call_tool(
"submit_feedback",
{
"request": {
"type": "bug",
"title": "Render hangs",
"description": "the render tool hung on scene a",
"submitter": "model",
}
},
)
ref = result.data["feedback_id"]
await settle(mw, emb)
[row] = await rows(sink, sink.embeddings.c.source_type == "feedback")
assert row["source_id"] == ref
assert row["text"] == "Render hangs\nthe render tool hung on scene a"
[ev] = await mw.events_for(ref, kinds=["feedback.submitted"])
assert ev["attrs"]["type"] == "bug" and ev["attrs"]["submitter"] == "model"
await mw.aclose()
async def test_redaction(self, db):
sink = db()
fake = FakeEmbedder()
emb = EmbeddingSink(
fake, sink, redactor=Redactor(extra_patterns=[r"acme_\w{8}"])
)
# Straight to the sink, so upstream redaction cannot hide a gap here.
await emb.write(
[
call_record(message=f"401 with api_key={SECRET} and acme_12345678"),
event("job.failed", error=f"Bearer {SECRET}"),
]
)
await emb.flush()
assert fake.seen and all(
SECRET not in t and "acme_12345678" not in t for t in fake.seen
)
stored = await rows(sink)
assert len(stored) == 2
for r in stored:
assert SECRET not in r["text"] and REDACTED in r["text"]
await emb.aclose()
async def test_instrument_hands_over_its_redactor(self, db):
app = FastMCP("app")
sink = db()
emb = EmbeddingSink(FakeEmbedder(), sink)
redactor = Redactor(extra_patterns=[r"acme_\w{8}"])
mw = instrument(app, [sink, emb], redactor=redactor)
assert emb.redactor is redactor
await mw.aclose()
async def test_max_chars(self, db):
sink = db()
fake = FakeEmbedder()
emb = EmbeddingSink(fake, sink, max_chars=50)
await emb.write([event("job.failed", error="word " * 100)])
await emb.flush()
[row] = await rows(sink)
assert len(row["text"]) == 50 and fake.seen == [row["text"]]
await emb.aclose()
async def test_dedupe_and_replace(self, db):
sink = db()
fake = FakeEmbedder()
emb = EmbeddingSink(fake, sink)
ev = event("feedback.submitted", key="7", title="Render hangs", description="a")
await emb.write([ev])
await emb.flush()
await emb.write([ev, ev])
await emb.flush()
assert fake.seen == ["Render hangs\na"]
assert emb.skipped == 2
changed = event(
"feedback.submitted", key="7", title="Render hangs", description="b"
)
await emb.write([changed])
await emb.flush()
[row] = await rows(sink)
assert row["text"] == "Render hangs\nb"
assert fake.seen == ["Render hangs\na", "Render hangs\nb"]
# Another model's vectors are kept apart.
other = EmbeddingSink(FakeEmbedder(model="other"), sink)
await other.write([changed])
await other.flush()
assert len(await rows(sink)) == 2
await emb.aclose()
await other.aclose()
async def test_queue_full_drops(self, db):
sink = db()
gate = asyncio.Event()
fake = FakeEmbedder(gate=gate)
emb = EmbeddingSink(fake, sink, max_queue=2, batch_size=1)
start = time.perf_counter()
await emb.write([event("job.failed", error=f"boom {i}") for i in range(6)])
assert time.perf_counter() - start < 0.5
await asyncio.sleep(0.05) # the worker takes one and blocks on the gate
await emb.write([event("job.failed", error=f"late {i}") for i in range(3)])
assert emb.dropped >= 4
gate.set()
await emb.flush()
assert emb.errors == 0
assert len(await rows(sink)) == 9 - emb.dropped
await emb.aclose()
async def test_embedder_errors_never_reach_tool_calls(self, db):
app = FastMCP("app")
@app.tool
def render(scene: str) -> str:
raise RuntimeError(f"render of {scene} timed out")
@app.tool
def ping() -> str:
return "pong"
sink = db()
emb = EmbeddingSink(FakeEmbedder(fail=True), sink)
mw = instrument(app, [sink, emb])
async with Client(app) as client:
with contextlib.suppress(ToolError):
await client.call_tool("render", {"scene": "a"})
assert (await client.call_tool("ping", {})).data == "pong"
await settle(mw, emb)
assert emb.errors == 1
assert mw.dispatcher.sink_errors == 0
assert await rows(sink) == []
async with sink.engine.connect() as conn:
calls = (
await conn.execute(select(func.count()).select_from(sink.table))
).scalar_one()
assert calls == 2
assert await mw.similar("render timed out") == []
await mw.aclose()
async def test_slow_embedder_stays_off_the_call_path(self, db):
app = FastMCP("app")
@app.tool
def render() -> str:
raise RuntimeError("render timed out")
sink = db()
emb = EmbeddingSink(FakeEmbedder(delay=2.0), sink, timeout=5.0)
mw = instrument(app, [sink, emb], sink_timeout=0.5)
async with Client(app) as client:
start = time.perf_counter()
for _ in range(3):
with contextlib.suppress(ToolError):
await client.call_tool("render", {})
elapsed = time.perf_counter() - start
await mw.flush()
assert elapsed < 1.5
assert mw.dispatcher.sink_errors == 0
await emb.flush()
assert emb.embedded == 3
await mw.aclose()
async def test_wrong_vector_length_is_an_error(self, db):
sink = db()
emb = EmbeddingSink(FakeEmbedder(out_dim=DIM + 1), sink)
await emb.write([event("job.failed", error="boom")])
await emb.flush()
assert emb.errors == 1 and await rows(sink) == []
await emb.aclose()
async def test_dim_mismatch_refused_at_construction(self, db):
with pytest.raises(ValueError, match="embedding_dim=512"):
EmbeddingSink(FakeEmbedder(dim=512), db())
async def test_unknown_source_refused(self, db):
with pytest.raises(ValueError, match="unknown sources"):
EmbeddingSink(FakeEmbedder(), db(), sources=["feedback", "chat"])
async def test_aclose_drains(self, db):
sink = db()
emb = EmbeddingSink(FakeEmbedder(delay=0.1), sink, batch_size=1)
await emb.write([event("job.failed", error=f"e{i}") for i in range(3)])
await emb.aclose()
assert emb.embedded == 3 and emb.dropped == 0
async def test_aclose_timeout_drops(self, db):
sink = db()
emb = EmbeddingSink(
FakeEmbedder(gate=asyncio.Event()), sink, batch_size=1, timeout=0.2
)
await emb.write([event("job.failed", error=f"e{i}") for i in range(3)])
await emb.aclose()
assert emb.dropped == 2 and emb.embedded == 0
class TestLlmText:
def app(self, sinks, **kwargs):
app = FastMCP("llm")
holder = {}
@app.tool
async def ask(q: str) -> str:
holder["mw"].record_llm_call(
model="m",
prompt=f"{q} token={SECRET}",
completion="because",
attrs={"prompt": "smuggled", "temperature": 0.2},
)
return "because"
holder["mw"] = mw = instrument(app, sinks, **kwargs)
return app, mw
async def test_dropped_by_default(self, db):
sink = db()
fake = FakeEmbedder()
emb = EmbeddingSink(fake, sink)
app, mw = self.app([sink, emb])
async with Client(app) as client:
await client.call_tool("ask", {"q": "why is the sky blue"})
await settle(mw, emb)
[ev] = [e for e in mw.recent_events if e.kind == "llm.call"]
assert "prompt" not in ev.attrs and "completion" not in ev.attrs
assert ev.attrs["temperature"] == 0.2
assert fake.seen == []
assert await rows(sink) == []
await mw.aclose()
async def test_embedded_when_captured(self, db):
sink = db()
fake = FakeEmbedder()
emb = EmbeddingSink(fake, sink)
app, mw = self.app([sink, emb], capture_llm_text=True, max_error_chars=10)
async with Client(app) as client:
await client.call_tool("ask", {"q": "why is the sky blue " * 10})
await settle(mw, emb)
[ev] = [e for e in mw.recent_events if e.kind == "llm.call"]
assert len(ev.attrs["prompt"]) == 80 # 8 * max_error_chars
assert ev.attrs["completion"] == "because"
[row] = await rows(sink)
assert row["source_type"] == "llm" and row["source_id"] == ev.id
assert row["text"].startswith("prompt: why is the sky blue")
assert row["text"].endswith("completion: because")
await mw.aclose()
async def test_captured_text_is_redacted(self, db):
sink = db()
emb = EmbeddingSink(FakeEmbedder(), sink)
app, mw = self.app([sink, emb], capture_llm_text=True)
async with Client(app) as client:
await client.call_tool("ask", {"q": "q"})
await settle(mw, emb)
[ev] = [e for e in mw.recent_events if e.kind == "llm.call"]
assert SECRET not in ev.attrs["prompt"] and REDACTED in ev.attrs["prompt"]
[row] = await rows(sink)
assert SECRET not in row["text"]
await mw.aclose()
FIXTURE = [
("call_error", "c1", "render: TimeoutError: render timed out after 30 s"),
("call_error", "c2", "upload: OSError: disk full on volume data"),
("event", "e1", "render job timed out waiting for the gpu"),
("feedback", "bug-1", "Render hangs\nthe render tool timed out on scene a"),
("feedback", "bug-2", "Dark mode please\nthe ui is too bright at night"),
("llm", "l1", "prompt: summarise the log\ncompletion: nothing notable"),
]
async def seed(sink, emb):
from fastmcp_feedback.instrumentation import EmbeddingText
for source_type, source_id, text_ in FIXTURE:
emb._enqueue(EmbeddingText(source_type, source_id, text_))
await emb.flush()
assert len(await rows(sink)) == len(FIXTURE)
class TestSimilar:
async def test_ranking_matches_brute_force(self, db, db_backend):
sink = db()
app = FastMCP("s")
emb = EmbeddingSink(FakeEmbedder(), sink)
mw = instrument(app, [sink, emb])
await seed(sink, emb)
query = "render timed out"
q = token_vector(query)
expected = sorted(
((cosine_score(q, token_vector(t)), sid) for _, sid, t in FIXTURE),
reverse=True,
)
found = await mw.similar(query, k=len(FIXTURE))
# Rows sharing no word with the query all score 0; their order is free.
positive = [sid for score, sid in expected if score > 1e-6]
assert len(positive) >= 3
assert [r["source_id"] for r in found][: len(positive)] == positive
assert {r["source_id"] for r in found} == {sid for _, sid in expected}
want = {sid: score for score, sid in expected}
for r in found:
assert r["score"] == pytest.approx(want[r["source_id"]], abs=1e-4)
assert 0.0 <= r["score"] <= 1.0
assert r["created_at"].tzinfo is not None
assert set(r) == {"source_type", "source_id", "text", "score", "created_at"}
assert found[0]["source_id"] == "c1"
top = await mw.similar(query, k=2)
assert [r["source_id"] for r in top] == positive[:2]
only = await mw.similar(query, source_types=["feedback"])
assert [r["source_id"] for r in only] == ["bug-1", "bug-2"]
strong = await mw.similar(query, min_score=0.4)
assert strong and all(r["score"] >= 0.4 for r in strong)
assert len(strong) < len(FIXTURE)
await mw.aclose()
async def test_similar_feedback_excludes_itself(self, db):
sink = db()
app = FastMCP("s")
emb = EmbeddingSink(FakeEmbedder(), sink)
mw = instrument(app, [sink, emb])
await seed(sink, emb)
found = await mw.similar_feedback("bug-1", k=3)
assert "bug-1" not in [r["source_id"] for r in found]
assert found[0]["source_id"] in ("c1", "e1")
feedback_only = await mw.similar_feedback("bug-1", source_types=["feedback"])
assert [r["source_id"] for r in feedback_only] == ["bug-2"]
assert await mw.similar_feedback("bug-404") == []
await mw.aclose()
async def test_scan_limit_without_pgvector(self, db, db_backend, caplog):
if db_backend == "postgres":
pytest.skip("brute force only runs without pgvector")
sink = db()
app = FastMCP("s")
emb = EmbeddingSink(FakeEmbedder(), sink, scan_limit=2)
mw = instrument(app, [sink, emb])
await seed(sink, emb)
caplog.set_level(logging.INFO, "fastmcp_feedback.instrumentation.embeddings")
assert len(await mw.similar("render", k=10)) <= 2
await mw.similar("render", k=10)
assert len([r for r in caplog.records if "scan_limit" in r.getMessage()]) == 1
await mw.aclose()
async def test_never_raises(self, db):
app = FastMCP("s")
mw = instrument(app, [])
assert await mw.similar("x") == []
assert await mw.similar_feedback("1") == []
await mw.aclose()
sink = db()
emb = EmbeddingSink(FakeEmbedder(fail=True), sink)
mw = instrument(FastMCP("t"), [sink, emb])
assert await mw.similar("x") == []
# No table yet: the lookup itself fails.
assert await mw.similar_feedback("1") == []
await mw.aclose()
class TestPrune:
async def test_keeps_feedback_embeddings(self, db):
sink = db(prune_batch=2)
emb = EmbeddingSink(FakeEmbedder(), sink)
await seed(sink, emb)
t = sink.embeddings
old = datetime.now(UTC) - timedelta(days=40)
async with sink.engine.begin() as conn:
await conn.execute(
update(t).where(t.c.source_id != "c2").values(created_at=old)
)
deleted = await sink.prune(older_than=datetime.now(UTC) - timedelta(days=30))
assert deleted == 3
left = sorted(r["source_id"] for r in await rows(sink))
assert left == ["bug-1", "bug-2", "c2"]
await emb.aclose()
async def test_prune_without_embeddings_table(self, db):
sink = db()
await sink.write([call_record()])
assert not await sink._has_table(sink.embeddings.name)
assert await sink.prune(older_than=datetime.now(UTC) + timedelta(days=1)) == 1
class TestCreateTables:
async def test_embeddings_table_only_with_an_embedding_sink(self, db):
sink = db()
await sink.write([call_record()])
assert not await sink._has_table(sink.embeddings.name)
emb = EmbeddingSink(FakeEmbedder(), sink)
await emb.write([call_record()])
await emb.flush()
assert await sink._has_table(sink.embeddings.name)
await emb.aclose()
async def test_create_tables_false_creates_nothing(self, db):
sink = db(create_tables=False)
assert await sink.ensure_embeddings() is True
assert not await sink._has_table(sink.embeddings.name)
async def test_failure_is_logged_not_raised(self, db, monkeypatch, caplog):
sink = db()
async def boom():
raise RuntimeError("no room")
monkeypatch.setattr(sink, "_create_embeddings_table", boom)
caplog.set_level(logging.WARNING)
emb = EmbeddingSink(FakeEmbedder(), sink)
assert await sink.ensure_embeddings() is False
assert any("Could not create" in r.getMessage() for r in caplog.records)
await emb.write([call_record()])
await emb.flush()
assert emb.errors == 1 # the insert fails; counted, not raised
await emb.aclose()
@pytest.mark.postgres
async def test_postgres_extension_table_and_index(self, db, db_backend):
if db_backend != "postgres":
pytest.skip("Postgres only")
sink = db()
await sink._ready()
async with sink.engine.begin() as conn:
await conn.execute(text("DROP EXTENSION IF EXISTS vector"))
assert await sink.ensure_embeddings() is True
async with sink.engine.connect() as conn:
ext = (
await conn.execute(
text("SELECT count(*) FROM pg_extension WHERE extname = 'vector'")
)
).scalar_one()
index = (
await conn.execute(
text(
"SELECT indexdef FROM pg_indexes "
"WHERE indexname = 'ffb_embeddings_hnsw'"
)
)
).scalar_one()
coltype = (
await conn.execute(
text(
"SELECT format_type(atttypid, atttypmod) FROM pg_attribute "
"WHERE attrelid = 'ffb_embeddings'::regclass "
"AND attname = 'embedding'"
)
)
).scalar_one()
assert ext == 1
assert "hnsw" in index and "vector_cosine_ops" in index
assert coltype == f"vector({DIM})"
@pytest.mark.postgres
async def test_postgres_without_privilege(self, db, db_backend, db_url, caplog):
if db_backend != "postgres":
pytest.skip("Postgres only")
from sqlalchemy.engine import make_url
admin = db("priv", embedding_dim=DIM)
role = f"ffb_limited_{uuid.uuid4().hex[:8]}"
password = uuid.uuid4().hex
url = make_url(db_url("priv"))
async with admin.engine.begin() as conn:
await conn.execute(text("DROP EXTENSION IF EXISTS vector"))
await conn.execute(text(f"CREATE ROLE {role} LOGIN PASSWORD '{password}'"))
await conn.execute(text(f"GRANT ALL ON SCHEMA public TO {role}"))
limited = DatabaseSink(
url.set(username=role, password=password).render_as_string(
hide_password=False
),
create_tables=True,
embedding_dim=DIM,
)
try:
caplog.set_level(logging.WARNING)
emb = EmbeddingSink(FakeEmbedder(), limited)
assert await limited.ensure_embeddings() is False
messages = [r.getMessage() for r in caplog.records]
assert any("CREATE EXTENSION vector failed" in m for m in messages)
assert any("Could not create" in m for m in messages)
await limited.write([call_record()]) # the rest still works
await emb.write([call_record()])
await emb.flush()
assert emb.errors == 1
await emb.aclose()
finally:
await limited.engine.dispose()
async with admin.engine.begin() as conn:
await conn.execute(text(f"DROP OWNED BY {role}"))
await conn.execute(text(f"DROP ROLE {role}"))
class TestMetadata:
def test_build_metadata_tables(self):
md = build_metadata("x_", embedding_dim=768)
t = md.tables["x_embeddings"]
assert t.c.embedding.type.dim == 768
assert {c.name for c in t.c} == {
"id",
"source_type",
"source_id",
"model",
"dim",
"text_hash",
"text",
"created_at",
"embedding",
}
without = build_metadata("y_", embedding_dim=None)
assert "y_embeddings" not in without.tables
assert "y_tool_calls" in without.tables
class TestOpenAIEmbedder:
def transport(self, handler):
from fastmcp_feedback.instrumentation.embeddings import _http_module
return _http_module().MockTransport(handler)
async def test_request_batches_and_order(self):
seen = []
def handler(request):
body = json.loads(request.content)
seen.append((request.url.path, request.headers.get("authorization"), body))
data = [
{"index": i, "embedding": [float(len(t)), 0.0, 1.0]}
for i, t in enumerate(body["input"])
]
from fastmcp_feedback.instrumentation.embeddings import _http_module
return _http_module().Response(200, json={"data": list(reversed(data))})
e = OpenAIEmbedder(
"https://gw.example/v1/",
api_key="k",
model="m",
dim=3,
batch_size=2,
transport=self.transport(handler),
)
out = await e.embed(["a", "bb", "ccc"])
assert out == [[1.0, 0.0, 1.0], [2.0, 0.0, 1.0], [3.0, 0.0, 1.0]]
assert [s[0] for s in seen] == ["/v1/embeddings", "/v1/embeddings"]
assert seen[0][1] == "Bearer k"
assert [s[2]["input"] for s in seen] == [["a", "bb"], ["ccc"]]
assert all(s[2]["model"] == "m" for s in seen)
assert await e.embed([]) == []
async def test_dim_mismatch_raises(self):
from fastmcp_feedback.instrumentation.embeddings import _http_module
def handler(request):
return _http_module().Response(
200, json={"data": [{"index": 0, "embedding": [0.1, 0.2]}]}
)
e = OpenAIEmbedder(
"https://gw.example/v1", dim=3, transport=self.transport(handler)
)
with pytest.raises(ValueError, match="2-dim vector, expected 3"):
await e.embed(["a"])
async def test_http_error_raises(self):
from fastmcp_feedback.instrumentation.embeddings import _http_module
e = OpenAIEmbedder(
"https://gw.example/v1",
dim=3,
transport=self.transport(
lambda r: _http_module().Response(403, json={"error": "nope"})
),
)
with pytest.raises(Exception, match="403"):
await e.embed(["a"])
LIVE_URL = os.environ.get("FFB_TEST_EMBED_URL")
@pytest.mark.skipif(not LIVE_URL, reason="FFB_TEST_EMBED_URL is not set")
async def test_live_gateway():
"""FFB_TEST_EMBED_URL=https://.../v1 FFB_TEST_EMBED_KEY=... [FFB_TEST_EMBED_MODEL]."""
e = OpenAIEmbedder(
LIVE_URL,
api_key=os.environ.get("FFB_TEST_EMBED_KEY"),
model=os.environ.get("FFB_TEST_EMBED_MODEL", "mxbai-embed-large"),
dim=int(os.environ.get("FFB_TEST_EMBED_DIM", "1024")),
)
a, b, c = await e.embed(
["render timed out", "the render took too long and timed out", "dark mode"]
)
assert len(a) == e.dim
assert cosine_score(a, b) > cosine_score(a, c)
+582
View File
@@ -0,0 +1,582 @@
"""Tests for events: record_event, record_llm_call, events_for and their storage.
Real FastMCP servers through fastmcp.Client, as in test_instrumentation.py.
"""
import asyncio
import contextlib
import io
import json
import threading
from datetime import UTC, datetime, timedelta
import pytest
from fastmcp import Client, FastMCP
from fastmcp.exceptions import ToolError
from sqlalchemy import MetaData, func, select
from sqlalchemy.exc import DBAPIError
from fastmcp_feedback.instrumentation import (
REDACTED,
CallbackSink,
DatabaseSink,
EventRecord,
JsonLinesSink,
MemorySink,
ToolCallRecord,
build_metadata,
instrument,
)
@pytest.fixture
async def db(db_url):
"""DatabaseSinks whose engines are always disposed, or the process hangs."""
made = []
def factory(name="calls", **kwargs):
kwargs.setdefault("create_tables", True)
sink = DatabaseSink(db_url(name), **kwargs)
made.append(sink)
return sink
yield factory
for sink in made:
await sink.engine.dispose()
def make_app(sinks=None, **kwargs):
"""A server whose tools record events through the middleware returned."""
sink = MemorySink()
app = FastMCP("events")
holder = {}
spawned = []
@app.tool
async def submit(job_id: str) -> dict:
mw = holder["mw"]
mw.record_event("job.submitted", key=job_id, attrs={"case": "wing", "nproc": 4})
return {"job_id": job_id}
@app.tool
def submit_sync(job_id: str) -> dict:
holder["mw"].record_event("job.submitted", key=job_id)
return {"job_id": job_id}
@app.tool
async def spawn(job_id: str) -> dict:
"""Start a watcher task that records after the call has returned."""
started = asyncio.Event()
async def watcher():
started.set()
await asyncio.sleep(0.05)
holder["mw"].record_event("job.started", key=job_id)
spawned.append(asyncio.get_running_loop().create_task(watcher()))
await started.wait()
return {"job_id": job_id}
@app.tool
async def chat(prompt: str) -> str:
holder["mw"].record_llm_call(
model="claude-opus-4",
provider="anthropic",
duration_ms=812.5,
input_tokens=1200,
output_tokens=90,
attrs={"cache_read_input_tokens": 1000},
)
return "ok"
@app.tool
async def chat_fails(prompt: str) -> str:
err = RuntimeError(
"upstream 401: api_key=sk-abcdefghijklmnopqrstuv " + "x" * 800
)
holder["mw"].record_llm_call(model="gpt-x", provider="openai", error=err)
raise err
@app.tool
def poll() -> dict:
return {"status": "idle"}
@app.tool
def poll_with_event() -> dict:
holder["mw"].record_event("poll.saw_job", key="j9")
return {"status": "idle"}
mw = instrument(app, sinks if sinks is not None else [sink], **kwargs)
holder["mw"] = mw
return app, sink, mw, spawned
async def call(app, mw, *calls):
async with Client(app) as client:
for tool, args in calls:
with contextlib.suppress(ToolError):
await client.call_tool(tool, args)
await mw.flush()
async def table_rows(sink, table):
async with sink.engine.connect() as conn:
return [dict(r) for r in (await conn.execute(select(table))).mappings()]
def event(kind="job.step", key="j1", minutes_ago=0, **kwargs):
return EventRecord(
kind=kind,
occurred_at=datetime.now(UTC) - timedelta(minutes=minutes_ago),
key=key,
**kwargs,
)
class TestLinking:
async def test_event_in_tool_links_to_stored_call(self, db):
store = db()
app, _, mw, _ = make_app([store])
await call(app, mw, ("submit", {"job_id": "j1"}))
[call_row] = await table_rows(store, store.table)
[ev] = await table_rows(store, store.events)
assert ev["kind"] == "job.submitted"
assert ev["call_id"] == call_row["id"]
assert ev["session_id"] == call_row["session_id"]
assert ev["call_id"] == mw.recent[-1].id
async def test_sync_tool_links_too(self):
app, sink, mw, _ = make_app()
await call(app, mw, ("submit_sync", {"job_id": "j2"}))
await asyncio.sleep(0.01)
await mw.flush()
[ev] = sink.events
[rec] = sink.calls
assert ev.call_id == rec.id
async def test_event_outside_call_has_no_call_id(self):
app, sink, mw, _ = make_app()
assert mw.record_event("job.finished", key="j1") is True
await mw.flush()
[ev] = sink.events
assert ev.call_id is None and ev.session_id is None
async def test_task_outliving_call_is_not_linked(self):
app, sink, mw, spawned = make_app()
await call(app, mw, ("spawn", {"job_id": "j3"}))
await asyncio.gather(*spawned)
await mw.flush()
[ev] = sink.events
assert ev.kind == "job.started" and ev.call_id is None
async def test_explicit_call_id_wins(self):
app, sink, mw, _ = make_app()
mw.record_event("x.y", call_id="given")
await mw.flush()
assert sink.events[0].call_id == "given"
async def test_sampled_out_call_kept_when_it_recorded_event(self):
app, sink, mw, _ = make_app(
sample_rates={"poll_with_event": 0.01, "poll": 0.01},
random=lambda: 0.99,
)
await call(app, mw, ("poll", {}), ("poll_with_event", {}))
[rec] = sink.calls
[ev] = sink.events
assert rec.tool == "poll_with_event" and rec.sample_rate is None
assert ev.call_id == rec.id
async def test_excluded_tool_events_unlinked(self):
app, sink, mw, _ = make_app(exclude_tools={"poll_with_event"})
await call(app, mw, ("poll_with_event", {}))
assert sink.calls == []
[ev] = sink.events
assert ev.call_id is None
class TestRecordEvent:
async def test_key_attrs_and_server_version_stored(self, db):
store = db()
app, _, mw, _ = make_app([store], server_version="1.2.3")
mw.record_event(
"step.finished",
key=42,
attrs={"step": "snappyHexMesh", "cells": 1200000, "mesh_ok": True},
user_sub="u1",
)
await mw.flush()
[ev] = await table_rows(store, store.events)
assert ev["key"] == "42"
assert ev["attrs"] == {
"step": "snappyHexMesh",
"cells": 1200000,
"mesh_ok": True,
}
assert ev["user_sub"] == "u1" and ev["server_version"] == "1.2.3"
async def test_attrs_redacted(self):
app, sink, mw, _ = make_app()
mw.record_event(
"job.failed",
attrs={
"token": "abc123",
"nested": {"db_password": "hunter2"},
"error_excerpt": "curl -H 'Authorization: Bearer abcdefghijklmnop'",
},
)
await mw.flush()
attrs = sink.events[0].attrs
assert attrs["token"] == REDACTED
assert attrs["nested"]["db_password"] == REDACTED
assert "abcdefghijklmnop" not in attrs["error_excerpt"]
async def test_attrs_made_json_safe(self, db):
store = db()
app, _, mw, _ = make_app([store])
when = datetime(2026, 9, 29, tzinfo=UTC)
mw.record_event(
"solver.progress",
key="j1",
attrs={"residuals": {"p": float("nan"), "U": 1e-5}, "at": when, 3: (1, 2)},
)
await mw.flush()
[ev] = await table_rows(store, store.events)
assert ev["attrs"] == {
"residuals": {"p": "nan", "U": 1e-5},
"at": when.isoformat(),
"3": [1, 2],
}
@pytest.mark.parametrize(
"kind",
["", " ", None, 5, "k" * 129],
ids=["empty", "blank", "none", "int", "long"],
)
async def test_invalid_kind_rejected(self, kind):
app, sink, mw, _ = make_app()
assert mw.record_event(kind) is False
await mw.flush()
assert sink.events == [] and list(mw.recent_events) == []
async def test_invalid_key_and_attrs_rejected(self):
app, sink, mw, _ = make_app()
assert mw.record_event("a.b", key="k" * 256) is False
assert mw.record_event("a.b", attrs=["not", "a", "dict"]) is False
await mw.flush()
assert sink.events == []
async def test_mode_off_is_noop(self):
app, sink, mw, _ = make_app(mode="off")
assert mw.record_event("job.submitted", key="j1") is False
assert mw.record_llm_call(model="m") is False
await call(app, mw, ("submit", {"job_id": "j1"}))
assert sink.records == [] and list(mw.recent_events) == []
assert mw.dispatcher.dropped == 0
async def test_queue_full_returns_false_and_counts(self):
app, sink, mw, _ = make_app(max_queue=1)
assert mw.record_event("a.one") is True
assert mw.record_event("a.two") is False
assert mw.dispatcher.dropped == 1
await mw.flush()
assert [e.kind for e in sink.events] == ["a.one"]
assert [e.kind for e in mw.recent_events] == ["a.one"]
async def test_broken_sink_never_raises(self):
def boom(records):
raise RuntimeError("sink down")
app, _, mw, _ = make_app([CallbackSink(boom)])
assert mw.record_event("a.b", key="j") is True
await mw.flush()
assert mw.dispatcher.sink_errors == 1
async def test_broken_redactor_never_raises(self):
app, sink, mw, _ = make_app()
class Broken:
def redact(self, value):
raise RuntimeError("bad pattern")
mw.redactor = Broken()
assert mw.record_event("a.b", attrs={"x": 1}) is False
class TestThreads:
async def test_record_from_thread_while_server_runs(self, db):
store = db()
app, _, mw, _ = make_app([store])
results = []
async with Client(app) as client:
await client.call_tool("submit", {"job_id": "j1"})
def watcher():
for kind in ("step.started", "job.finished"):
results.append(
mw.record_event(kind, key="j1", attrs={"case": "wing"})
)
t = threading.Thread(target=watcher)
t.start()
await asyncio.to_thread(t.join)
await asyncio.sleep(0)
await mw.flush()
assert results == [True, True]
rows = await table_rows(store, store.events)
assert sorted(r["kind"] for r in rows) == [
"job.finished",
"job.submitted",
"step.started",
]
threaded = [r for r in rows if r["kind"] != "job.submitted"]
assert all(r["call_id"] is None for r in threaded)
assert [e.kind for e in mw.recent_events] == [
"job.submitted",
"step.started",
"job.finished",
]
async def test_thread_with_its_own_loop_hands_over(self):
app, sink, mw, _ = make_app()
mw.record_event("a.first") # binds the dispatcher to this loop
out = []
t = threading.Thread(
target=lambda: out.append(asyncio.run(self._record_in_loop(mw)))
)
t.start()
await asyncio.to_thread(t.join)
await asyncio.sleep(0)
await mw.flush()
assert out == [True]
assert [e.kind for e in sink.events] == ["a.first", "a.second"]
assert mw.dispatcher._loop is asyncio.get_running_loop()
@staticmethod
async def _record_in_loop(mw):
return mw.record_event("a.second")
def test_no_loop_and_nothing_recorded_drops(self):
app, sink, mw, _ = make_app()
assert mw.record_event("job.started", key="j1") is False
assert mw.dispatcher.dropped == 1
assert list(mw.recent_events) == []
async def test_thread_before_anything_recorded_drops(self):
app, sink, mw, _ = make_app()
out = []
t = threading.Thread(target=lambda: out.append(mw.record_event("a.b")))
t.start()
await asyncio.to_thread(t.join)
assert out == [False] and mw.dispatcher.dropped == 1
class TestLlmCall:
async def test_attrs_and_linking(self, db):
store = db()
app, _, mw, _ = make_app([store])
await call(app, mw, ("chat", {"prompt": "hi"}))
[call_row] = await table_rows(store, store.table)
[ev] = await table_rows(store, store.events)
assert ev["kind"] == "llm.call" and ev["call_id"] == call_row["id"]
assert ev["attrs"] == {
"cache_read_input_tokens": 1000,
"model": "claude-opus-4",
"provider": "anthropic",
"duration_ms": 812.5,
"input_tokens": 1200,
"output_tokens": 90,
"ok": True,
}
async def test_error_redacted_truncated_and_not_ok(self):
app, sink, mw, _ = make_app(max_error_chars=100)
await call(app, mw, ("chat_fails", {"prompt": "hi"}))
[ev] = sink.events
attrs = ev.attrs
assert attrs["ok"] is False and attrs["error_type"] == "RuntimeError"
assert "sk-abcdefghijklmnopqrstuv" not in attrs["error"]
assert len(attrs["error"]) == 100
assert ev.call_id == sink.calls[0].id and sink.calls[0].outcome == "error"
async def test_named_fields_win_and_ok_explicit(self):
app, sink, mw, _ = make_app()
mw.record_llm_call(
model="m", ok=False, key="turn-1", attrs={"model": "spoofed", "round": 2}
)
await mw.flush()
[ev] = sink.events
assert ev.key == "turn-1"
assert ev.attrs == {"model": "m", "round": 2, "ok": False}
class TestSinks:
async def test_database_sink_mixed_batch(self, db):
store = db()
rec = ToolCallRecord(
tool="t",
started_at=datetime.now(UTC),
duration_ms=1.0,
ok=True,
mode="meta",
)
ev = event(call_id=rec.id, attrs={"a": 1})
await store.write([rec, ev])
assert [r["id"] for r in await table_rows(store, store.table)] == [rec.id]
[row] = await table_rows(store, store.events)
assert row["id"] == ev.id and row["call_id"] == rec.id
async def test_hand_built_event_attrs_made_json_safe(self, db):
store = db()
await store.write([event(attrs={"p": float("nan"), "q": float("-inf")})])
[row] = await table_rows(store, store.events)
assert row["attrs"] == {"p": "nan", "q": "-inf"}
async def test_database_sink_events_only_and_calls_only(self, db):
store = db()
await store.write([event()])
assert len(await table_rows(store, store.events)) == 1
assert await table_rows(store, store.table) == []
async def test_calls_stored_when_events_table_missing(self, db):
store = db("old", create_tables=False)
old = MetaData()
build_metadata("ffb_", old)
old.remove(old.tables["ffb_events"])
async with store.engine.begin() as conn:
await conn.run_sync(old.create_all)
rec = ToolCallRecord(
tool="t",
started_at=datetime.now(UTC),
duration_ms=1.0,
ok=True,
mode="meta",
)
# SQLite raises OperationalError, Postgres ProgrammingError.
with pytest.raises(DBAPIError, match="no such table|does not exist"):
await store.write([rec, event()])
assert len(await table_rows(store, store.table)) == 1
async def test_json_lines_record_field(self):
stream = io.StringIO()
app, _, mw, _ = make_app([JsonLinesSink(stream)])
await call(app, mw, ("submit", {"job_id": "j1"}))
lines = [json.loads(line) for line in stream.getvalue().splitlines()]
by_type = {line["record"]: line for line in lines}
assert set(by_type) == {"tool_call", "event"}
assert by_type["event"]["kind"] == "job.submitted"
assert by_type["event"]["call_id"] == by_type["tool_call"]["id"]
assert by_type["tool_call"]["tool"] == "submit"
async def test_memory_sink_calls_and_events(self):
app, sink, mw, _ = make_app()
await call(app, mw, ("submit", {"job_id": "j1"}), ("poll", {}))
assert [r.tool for r in sink.calls] == ["submit", "poll"]
assert [e.kind for e in sink.events] == ["job.submitted"]
assert len(sink.records) == 3
class TestEventsFor:
async def test_merges_database_and_memory_in_time_order(self, db):
store = db()
app, _, mw, _ = make_app([store])
# Written by another worker: only in the database.
await store.write([event("job.submitted", minutes_ago=30)])
# Recorded here and flushed: in both, returned once.
mw.record_event("step.started", key="j1")
await mw.flush()
# Recorded here, not yet written: only in memory.
pending = event("step.finished", minutes_ago=-1)
mw.recent_events.append(pending)
# Another job, and a kind filtered out below.
mw.record_event("job.submitted", key="j2")
await mw.flush()
rows = await mw.events_for("j1")
assert [r["kind"] for r in rows] == [
"job.submitted",
"step.started",
"step.finished",
]
assert all(r["occurred_at"].tzinfo is not None for r in rows)
assert rows[-1]["id"] == pending.id
assert len({r["id"] for r in rows}) == 3
steps = await mw.events_for("j1", kinds=["step.started", "step.finished"])
assert [r["kind"] for r in steps] == ["step.started", "step.finished"]
last = await mw.events_for("j1", limit=2)
assert [r["kind"] for r in last] == ["step.started", "step.finished"]
async def test_memory_only_without_database(self):
app, sink, mw, _ = make_app()
await call(app, mw, ("submit", {"job_id": "j1"}))
mw.record_event("job.finished", key="j1")
rows = await mw.events_for("j1")
assert [r["kind"] for r in rows] == ["job.submitted", "job.finished"]
assert rows[0]["call_id"] == sink.calls[0].id
async def test_database_failure_falls_back_to_memory(self, db):
store = db("nodb", create_tables=False)
app, _, mw, _ = make_app([store])
mw.recent_events.append(event("job.submitted"))
rows = await mw.events_for("j1")
assert [r["kind"] for r in rows] == ["job.submitted"]
class TestPrune:
async def test_prune_deletes_old_events_in_batches(self, db):
store = db(retention=timedelta(days=30), prune_batch=2)
old_call = ToolCallRecord(
tool="old",
started_at=datetime.now(UTC) - timedelta(days=40),
duration_ms=1.0,
ok=True,
mode="meta",
)
store._last_prune = float("inf") # keep write() from pruning first
await store.write(
[old_call]
+ [event(f"old.{i}", minutes_ago=60 * 24 * 40) for i in range(5)]
+ [event("new.one"), event("new.two", minutes_ago=60 * 24 * 29)]
)
statements = []
from sqlalchemy import event as sa_event
def count_deletes(conn, cursor, statement, *args):
if statement.lstrip().upper().startswith("DELETE FROM FFB_EVENTS"):
statements.append(statement)
sa_event.listen(
store.engine.sync_engine, "before_cursor_execute", count_deletes
)
deleted = await store.prune()
assert deleted == 6 # 1 call + 5 events
assert len(statements) == 3 # 2 + 2 + 1
kinds = sorted(r["kind"] for r in await table_rows(store, store.events))
assert kinds == ["new.one", "new.two"]
async def test_auto_prune_covers_events(self, db):
store = db(retention=timedelta(days=30))
await store.write([event("old", minutes_ago=60 * 24 * 31), event("new")])
assert [r["kind"] for r in await table_rows(store, store.events)] == ["new"]
async def test_prune_without_events_table(self, db):
store = db("legacy", create_tables=False)
old = MetaData()
build_metadata("ffb_", old)
old.remove(old.tables["ffb_events"])
async with store.engine.begin() as conn:
await conn.run_sync(old.create_all)
rec = ToolCallRecord(
tool="old",
started_at=datetime.now(UTC) - timedelta(days=40),
duration_ms=1.0,
ok=True,
mode="meta",
)
await store.write([rec])
assert await store.prune(datetime.now(UTC) - timedelta(days=30)) == 1
async with store.engine.connect() as conn:
n = (
await conn.execute(select(func.count()).select_from(store.table))
).scalar()
assert n == 0
+28 -3
View File
@@ -345,7 +345,7 @@ class TestServerComposition:
async def test_compose_feedback_services_success(self):
"""Test successful service composition."""
main_server = Mock()
main_server.import_server = Mock()
main_server.mount = Mock()
services = {
"service1": {
@@ -376,13 +376,38 @@ class TestServerComposition:
mock_submission.assert_called_once()
mock_management.assert_called_once()
mock_feedback.assert_called_once()
assert main_server.import_server.call_count == 3
assert main_server.mount.call_count == 3
@pytest.mark.asyncio
async def test_compose_feedback_services_real_servers(self, tmp_path):
"""Composed tools must actually appear under their prefix.
The mocked tests above can't catch a missing await: a sync Mock
records the call either way. This one uses real FastMCP servers.
"""
from fastmcp import FastMCP
from tests._compat import tool_map
main_server = FastMCP("Main")
compose_feedback_services(
main_server,
{
"public": {"database_url": f"sqlite:///{tmp_path}/p.db", "type": "submission"},
"admin": {"database_url": f"sqlite:///{tmp_path}/a.db", "type": "full"},
},
)
names = set(await tool_map(main_server))
assert "public_submit_feedback" in names
assert "admin_list_feedback" in names
assert "admin_update_feedback_status" in names
@pytest.mark.asyncio
async def test_compose_feedback_services_with_shared_insights(self):
"""Test service composition with shared insights."""
main_server = Mock()
main_server.import_server = Mock()
main_server.mount = Mock()
shared_insights = Mock()
services = {
+492
View File
@@ -0,0 +1,492 @@
"""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, db_url):
db = DatabaseSink(db_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, db_url):
engine = create_async_engine(db_url("host"))
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_events",
"qa_feedback_call_links",
"qa_tool_calls",
]
async def test_no_tables_created_by_default(self, db_url):
engine = create_async_engine(db_url("host"))
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, db_url):
"""Meta mode stores no payloads; they must be SQL NULL, not JSON null."""
db = DatabaseSink(db_url("n"), 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_embeddings",
"ffb_events",
"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"]
+320
View File
@@ -0,0 +1,320 @@
"""The identity resolver's second run, after the tool, for identity the tool creates.
Modelled on blender-mcp: an addon calls register_client, and only after that
call can the resolver find the bus client (bus_client_uuid, bus_role) for the
caller's session. The first run, before the tool, already knows the user.
"""
import asyncio
import contextlib
import time
from fastmcp import Client, FastMCP
from fastmcp.exceptions import ToolError
from fastmcp_feedback.instrumentation import REDACTED, MemorySink, instrument
class Bus:
"""Who is registered, as the tool leaves it; read by the resolvers."""
def __init__(self):
self.client = None
class BlenderResolver:
"""blender-mcp's identify(): user and role always, bus keys once registered."""
def __init__(self, bus):
self.bus = bus
self.calls = 0
def __call__(self, context):
self.calls += 1
out = {"user_sub": "user-1", "caller_kind": "addon", "client_id": "dcr-9"}
if self.bus.client is not None:
out["bus_client_uuid"] = self.bus.client
out["bus_role"] = "interactive"
return out
class EmptyUntilRegistered:
"""Nothing before registration, every field after."""
def __init__(self, bus):
self.bus = bus
self.calls = 0
def __call__(self, context):
self.calls += 1
if self.bus.client is None:
return {}
return {
"user_sub": "user-1",
"caller_kind": "addon",
"client_id": "dcr-9",
"bus_client_uuid": self.bus.client,
}
def make_app(resolver_cls, *, late=(), sample=None, **kwargs):
bus = Bus()
resolver = resolver_cls(bus)
sink = MemorySink()
app = FastMCP("late-identity")
holder = {}
@app.tool
async def register_client(client_uuid: str) -> dict:
holder["mw"].record_event("bus.registering", key=client_uuid)
bus.client = client_uuid
return {"status": "ok"}
@app.tool
async def register_then_fail(client_uuid: str) -> dict:
bus.client = client_uuid
raise RuntimeError("registered, then broke")
@app.tool
async def quiet() -> str:
return "ok"
@app.tool
async def ping() -> str:
holder["mw"].record_event("bus.ping")
return "pong"
kwargs.setdefault("identity_resolver", resolver)
if sample is not None:
kwargs.update(
sample_rates={"ping": sample, "quiet": sample}, random=lambda: 0.99
)
mw = instrument(app, [sink], late_identity_keys=late, **kwargs)
holder["mw"] = mw
return app, sink, mw, resolver
async def call(app, mw, *calls):
async with Client(app) as client:
for tool, args in calls:
with contextlib.suppress(ToolError):
await client.call_tool(tool, args)
await mw.flush()
def columns(record):
return (record.user_sub, record.caller_kind, record.client_id)
class TestRegistration:
async def test_blender_shaped_registration_row_gets_bus_identity(self):
app, sink, mw, who = make_app(BlenderResolver, late=("bus_client_uuid",))
await call(app, mw, ("register_client", {"client_uuid": "c-1"}))
[rec] = sink.calls
assert columns(rec) == ("user-1", "addon", "dcr-9")
assert rec.identity == {"bus_client_uuid": "c-1", "bus_role": "interactive"}
assert who.calls == 2
# The event recorded during the call saw the first answer only.
[ev] = sink.events
assert ev.call_id == rec.id and columns(ev) == ("user-1", "addon", "dcr-9")
async def test_blender_shaped_without_late_keys_runs_once(self):
# The first answer is not empty, so without late_identity_keys there
# is no second run and the row lacks the bus keys. Opt-in by design.
app, sink, mw, who = make_app(BlenderResolver)
await call(app, mw, ("register_client", {"client_uuid": "c-1"}))
[rec] = sink.calls
assert rec.identity == {} and "bus_client_uuid" not in rec.identity
assert columns(rec) == ("user-1", "addon", "dcr-9")
assert who.calls == 1
async def test_registered_caller_runs_once(self):
app, sink, mw, who = make_app(BlenderResolver, late=("bus_client_uuid",))
await call(
app,
mw,
("register_client", {"client_uuid": "c-1"}),
("ping", {}),
("ping", {}),
)
assert who.calls == 2 + 1 + 1
assert [r.identity.get("bus_client_uuid") for r in sink.calls] == [
"c-1",
"c-1",
"c-1",
]
async def test_empty_first_answer_filled_after(self):
app, sink, mw, who = make_app(EmptyUntilRegistered)
await call(app, mw, ("register_client", {"client_uuid": "c-2"}))
[rec] = sink.calls
[ev] = sink.events
assert columns(rec) == ("user-1", "addon", "dcr-9")
assert rec.identity == {"bus_client_uuid": "c-2"}
assert columns(ev) == (None, None, None) # not rewritten
assert who.calls == 2
async def test_tool_that_raises_gets_second_run_too(self):
app, sink, mw, who = make_app(EmptyUntilRegistered)
await call(app, mw, ("register_then_fail", {"client_uuid": "c-3"}))
[rec] = sink.calls
assert rec.outcome == "error"
assert columns(rec) == ("user-1", "addon", "dcr-9")
assert who.calls == 2
class TestMerge:
async def test_first_answer_wins_on_conflict(self):
answers = iter(
[
{"user_sub": "early", "caller_kind": None, "org": None},
{
"user_sub": "late",
"caller_kind": "k",
"client_id": "c",
"org": "acme",
"team": "qa",
},
]
)
app, sink, mw, _ = make_app(
EmptyUntilRegistered,
identity_resolver=lambda c: next(answers),
late=("team",),
)
await call(app, mw, ("ping", {}))
[rec] = sink.calls
[ev] = sink.events
assert columns(rec) == ("early", "k", "c")
assert rec.identity == {"org": "acme", "team": "qa"}
assert columns(ev) == ("early", None, None)
async def test_second_answer_redacted(self):
token = "bmcp_abcdefgh12345678"
answers = iter([{}, {"user_sub": "u", "caller_kind": f"pat:{token}"}])
app, sink, mw, _ = make_app(
EmptyUntilRegistered, identity_resolver=lambda c: next(answers)
)
await call(app, mw, ("ping", {}))
assert sink.calls[0].caller_kind == f"pat:{REDACTED}"
class TestFailures:
async def test_second_run_raising_keeps_first_answer(self):
runs = []
def who(context):
runs.append(1)
if len(runs) == 1:
return {"user_sub": "u1"}
raise RuntimeError("resolver bug")
app, sink, mw, _ = make_app(
EmptyUntilRegistered, identity_resolver=who, late=("bus_client_uuid",)
)
await call(app, mw, ("ping", {}))
[rec] = sink.calls
assert rec.ok and rec.user_sub == "u1" and len(runs) == 2
async def test_second_run_timing_out_keeps_first_answer(self):
runs = []
async def who(context):
runs.append(1)
if len(runs) == 1:
return {"user_sub": "u1"}
await asyncio.sleep(5)
return {"user_sub": "late", "caller_kind": "k"}
app, sink, mw, _ = make_app(
EmptyUntilRegistered,
identity_resolver=who,
hook_timeout=0.05,
late=("bus_client_uuid",),
)
await call(app, mw, ("ping", {}))
[rec] = sink.calls
assert rec.ok and rec.user_sub == "u1" and rec.caller_kind is None
assert len(runs) == 2
class McspeakResolver:
"""mcspeak's _who: user_sub and caller_kind, never client_id."""
def __init__(self, bus):
self.calls = 0
def __call__(self, context):
self.calls += 1
return {"caller_kind": "claude-code", "user_sub": "mctalkbox"}
class TestWhenItRuns:
async def test_missing_column_alone_does_not_rerun(self):
app, sink, mw, who = make_app(McspeakResolver)
await call(app, mw, ("ping", {}), ("quiet", {}), ("ping", {}))
assert who.calls == 3 and len(sink.calls) == 3
assert {columns(r) for r in sink.calls} == {("mctalkbox", "claude-code", None)}
async def test_only_none_values_count_as_empty(self):
answers = iter([{"user_sub": None}, {"user_sub": "u2", "caller_kind": "k"}])
app, sink, mw, _ = make_app(
EmptyUntilRegistered, identity_resolver=lambda c: next(answers)
)
await call(app, mw, ("ping", {}))
assert columns(sink.calls[0])[:2] == ("u2", "k")
async def test_complete_answer_runs_once(self):
app, sink, mw, who = make_app(BlenderResolver)
await call(app, mw, ("ping", {}), ("ping", {}))
assert who.calls == 2 and len(sink.calls) == 2
async def test_sampled_out_call_makes_no_second_run(self):
app, sink, mw, who = make_app(EmptyUntilRegistered, sample=0.1)
await call(app, mw, ("quiet", {}))
assert sink.calls == [] and who.calls == 1
async def test_sampled_call_kept_for_its_event_gets_second_run(self):
app, sink, mw, who = make_app(EmptyUntilRegistered, sample=0.1)
await call(app, mw, ("ping", {}))
assert len(sink.calls) == 1 and who.calls == 2
async def test_no_resolver_no_runs(self):
app, sink, mw, _ = make_app(EmptyUntilRegistered, identity_resolver=None)
await call(app, mw, ("ping", {}))
[rec] = sink.calls
assert columns(rec) == (None, None, None)
async def test_second_run_overlaps_enricher(self):
# The record is built before the result reaches the client, so the
# second run must not add its time to the enricher's.
runs = []
async def who(context):
runs.append(1)
if len(runs) > 1:
await asyncio.sleep(0.4)
return {}
async def enrich(tool, args, result, context):
await asyncio.sleep(0.4)
return {}
app, sink, mw, _ = make_app(
EmptyUntilRegistered,
identity_resolver=who,
enricher=enrich,
hook_timeout=2,
)
async with Client(app) as client:
start = time.perf_counter()
await client.call_tool("ping", {})
elapsed = time.perf_counter() - start
assert len(runs) == 2
assert elapsed < 0.75, elapsed
async def test_mode_off_runs_nothing():
app, sink, mw, who = make_app(EmptyUntilRegistered, mode="off")
await call(app, mw, ("ping", {}))
assert who.calls == 0
+10 -9
View File
@@ -7,6 +7,7 @@ from fastmcp import FastMCP
from fastmcp_feedback.mixins import SubmissionMixin, RetrievalMixin, ManagementMixin
from fastmcp_feedback.models import SubmitFeedbackRequest, FeedbackStatus
from fastmcp_feedback.database import FeedbackDatabase
from tests._compat import tool_map
@pytest.mark.unit
@@ -95,7 +96,7 @@ class TestSubmissionMixin:
mixin.register_tools(mock_fastmcp_server)
# Verify tool was registered
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
tool_names = list(tools.keys())
assert "submit_feedback" in tool_names
@@ -107,7 +108,7 @@ class TestSubmissionMixin:
mixin.register_tools(mock_fastmcp_server, prefix="support", separator="_")
# Verify tool was registered with prefix
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
tool_names = list(tools.keys())
assert "support_submit_feedback" in tool_names
@@ -267,7 +268,7 @@ class TestRetrievalMixin:
mixin.register_tools(mock_fastmcp_server)
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
tool_names = list(tools.keys())
expected_tools = ["list_feedback", "get_feedback_statistics"]
@@ -437,7 +438,7 @@ class TestManagementMixin:
mixin.register_tools(mock_fastmcp_server)
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
tool_names = list(tools.keys())
expected_tools = ["update_feedback_status", "delete_feedback"]
@@ -508,7 +509,7 @@ class TestMixinIntegration:
# 5. Get statistics
stats = await retrieval.get_feedback_statistics()
assert stats["total_count"] >= 1
assert "IN_PROGRESS" in stats["by_status"]
assert "in_progress" in stats["by_status"]
# 6. Final status update
final_result = await management.update_feedback_status(
@@ -532,7 +533,7 @@ class TestMixinIntegration:
management.register_tools(mock_fastmcp_server)
# Verify all tools are registered
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
tool_names = list(tools.keys())
expected_tools = [
@@ -555,7 +556,7 @@ class TestMixinIntegration:
submission.register_tools(mock_fastmcp_server, prefix="admin")
retrieval.register_tools(mock_fastmcp_server, prefix="public")
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
tool_names = list(tools.keys())
assert "admin_submit_feedback" in tool_names
@@ -663,7 +664,7 @@ class TestMixinConfiguration:
# Register with dash separator
submission.register_tools(mock_fastmcp_server, prefix="api", separator="-")
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
tool_names = list(tools.keys())
assert "api-submit_feedback" in tool_names
@@ -676,7 +677,7 @@ class TestMixinConfiguration:
# Test the standard registration instead
submission.register_tools(mock_fastmcp_server)
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
tool_names = list(tools.keys())
# SubmissionMixin should only register its tools
+250
View File
@@ -0,0 +1,250 @@
"""max_payload_bytes: stored arguments, results and event attrs are bounded."""
import json
import pytest
from fastmcp import Client, FastMCP
from fastmcp_feedback.instrumentation import (
REDACTED,
DatabaseSink,
EventRecord,
MemorySink,
instrument,
)
from fastmcp_feedback.instrumentation import payload as payload_mod
from fastmcp_feedback.instrumentation.embeddings import extract_texts
from fastmcp_feedback.instrumentation.payload import (
DEFAULT_MAX_PAYLOAD_BYTES,
PREVIEW_CHARS,
TRUNCATED,
cap_attrs,
is_truncated,
json_size,
preview_chars,
)
SECRET = "sk-" + "a1b2c3d4e5" * 4 # matches the default sk- pattern
def make_app(**kwargs):
sink = MemorySink()
app = FastMCP("cap")
holder = {}
@app.tool
def echo(blob: str) -> dict:
return {"blob": blob}
@app.tool
def big(n: int) -> dict:
return {"rows": ["x" * 100] * n}
@app.tool
def report(error: str) -> str:
holder["mw"].record_event(
"job.finished", key="j1", attrs={"ok": False, "error": error}
)
return "ok"
kwargs.setdefault("mode", "full")
mw = instrument(app, [sink], **kwargs)
holder["mw"] = mw
return app, sink, mw
async def call(app, mw, tool, args):
async with Client(app) as client:
await client.call_tool(tool, args)
await mw.flush()
class TestCallPayloads:
async def test_default_is_64_kib(self):
app, sink, mw = make_app()
assert mw.max_payload_bytes == DEFAULT_MAX_PAYLOAD_BYTES == 65536
async def test_under_the_cap_stored_whole(self):
app, sink, mw = make_app()
await call(app, mw, "echo", {"blob": "y" * 1000})
[rec] = sink.calls
assert rec.args == {"blob": "y" * 1000}
assert rec.result == {"blob": "y" * 1000}
async def test_over_the_cap_becomes_a_marker(self):
app, sink, mw = make_app()
await call(app, mw, "big", {"n": 2000})
[rec] = sink.calls
assert rec.args == {"n": 2000}
assert rec.result_size > 65536
assert set(rec.result) == {TRUNCATED, "size", "preview"}
assert rec.result[TRUNCATED] is True
assert rec.result["size"] == rec.result_size
assert len(rec.result["preview"]) == PREVIEW_CHARS
assert rec.result["preview"].startswith('{"rows": ["xxxx')
json.dumps(rec.result) # JSON-safe
async def test_args_over_the_cap(self):
app, sink, mw = make_app(max_payload_bytes=500)
await call(app, mw, "echo", {"blob": "z" * 2000})
[rec] = sink.calls
assert rec.args["size"] == rec.args_size and is_truncated(rec.args)
# Under a small cap the preview shrinks so the marker fits.
assert len(rec.args["preview"]) == preview_chars(500) == 125
assert json_size(rec.args) <= 500
async def test_none_means_unlimited(self):
app, sink, mw = make_app(max_payload_bytes=None)
await call(app, mw, "big", {"n": 20000})
[rec] = sink.calls
assert len(rec.result["rows"]) == 20000
async def test_meta_mode_stores_no_payload_but_sizes(self):
app, sink, mw = make_app(mode="meta")
await call(app, mw, "big", {"n": 2000})
[rec] = sink.calls
assert rec.args is None and rec.result is None
assert rec.result_size > 65536
@pytest.mark.parametrize("before_end", [40, 15])
async def test_redaction_happens_before_the_cap(self, before_end):
# At 15 the preview ends inside the secret: cutting the raw JSON there
# would leave a fragment too short for the pattern to match.
app, sink, mw = make_app(max_payload_bytes=4000)
pad = preview_chars(4000) - len('{"blob": "') - before_end
blob = "p" * pad + " " + SECRET + " " + "q" * 5000
await call(app, mw, "echo", {"blob": blob})
[rec] = sink.calls
for stored in (rec.args, rec.result):
assert is_truncated(stored)
assert SECRET[:8] not in stored["preview"]
assert "[REDACT" in stored["preview"]
async def test_secret_key_redacted_in_preview(self):
app, sink, mw = make_app(max_payload_bytes=200)
app.tool(lambda api_key, pad: "ok", name="login")
await call(app, mw, "login", {"api_key": "hunter2-secret", "pad": "x" * 500})
[rec] = sink.calls
assert "hunter2" not in rec.args["preview"]
assert REDACTED in rec.args["preview"]
@pytest.mark.parametrize("bad", [0, -1, True, "64k", 1.5])
def test_invalid_cap_rejected(self, bad):
with pytest.raises(ValueError, match="max_payload_bytes"):
make_app(max_payload_bytes=bad)
class TestNoSecondEncoding:
async def test_payloads_encoded_once(self, monkeypatch):
# The size columns and the cap share one measurement per payload,
# whether or not the payload is over the cap.
app, sink, mw = make_app(max_payload_bytes=1000)
measured = []
original = payload_mod.json_size
def counting(value):
measured.append(1)
return original(value)
monkeypatch.setattr(
"fastmcp_feedback.instrumentation.middleware.json_size", counting
)
await call(app, mw, "echo", {"blob": "small"})
await call(app, mw, "big", {"n": 5000})
assert len(measured) == 4 # args and result, two calls
assert is_truncated(sink.calls[1].result)
def test_preview_stops_early(self, monkeypatch):
chunks = []
encoder = payload_mod._ENCODER
class Counting:
def iterencode(self, value):
for chunk in encoder.iterencode(value):
chunks.append(chunk)
yield chunk
monkeypatch.setattr(payload_mod, "_ENCODER", Counting())
huge = {"rows": ["x" * 100] * 100_000} # about 10 MB
text = payload_mod.preview(huge)
assert len(text) == PREVIEW_CHARS
assert len(chunks) < 50
class TestEventAttrs:
async def test_large_attr_replaced_small_ones_kept(self):
app, sink, mw = make_app(mode="meta")
await call(app, mw, "report", {"error": "E" * 100_000})
[ev] = sink.events
assert ev.attrs["ok"] is False
assert is_truncated(ev.attrs["error"])
assert ev.attrs["error"]["size"] == json_size("E" * 100_000)
assert json_size(ev.attrs) <= 65536
async def test_small_attrs_untouched(self):
app, sink, mw = make_app(mode="meta")
await call(app, mw, "report", {"error": "short"})
assert sink.events[0].attrs == {"ok": False, "error": "short"}
async def test_attrs_redacted_before_the_cap(self):
app, sink, mw = make_app(mode="meta", max_payload_bytes=4000)
error = "x" * (preview_chars(4000) - 30) + f" token {SECRET} " + "y" * 9000
await call(app, mw, "report", {"error": error})
stored = sink.events[0].attrs["error"]
assert SECRET[:8] not in stored["preview"] and REDACTED in stored["preview"]
async def test_outside_a_call_and_unlimited(self):
app, sink, mw = make_app(mode="meta", max_payload_bytes=None)
assert mw.record_event("job.log", key="j", attrs={"log": "L" * 200_000})
await mw.flush()
assert sink.events[0].attrs["log"] == "L" * 200_000
async def test_llm_call_attrs_capped_text_bounded(self):
app, sink, mw = make_app(mode="meta", capture_llm_text=True)
assert mw.record_llm_call(
model="m",
prompt="P" * 100_000,
attrs={"messages": ["M" * 1000] * 100},
)
await mw.flush()
[ev] = sink.events
assert len(ev.attrs["prompt"]) == mw.max_llm_text_chars
assert is_truncated(ev.attrs["messages"])
assert ev.attrs["model"] == "m" and ev.attrs["ok"] is True
def test_many_small_values_become_one_marker(self):
attrs = {f"k{i}": "v" * 50 for i in range(500)}
capped = cap_attrs(attrs, 1000)
assert is_truncated(capped) and capped["size"] == json_size(attrs)
def test_tiny_cap(self):
capped = cap_attrs({"a": "x" * 100}, 10)
assert is_truncated(capped) and len(capped["preview"]) == 2
class TestDownstream:
async def test_marker_round_trips_through_database(self, db_url):
sink = DatabaseSink(db_url("cap"), create_tables=True)
try:
app, _, mw = make_app(mode="meta")
mw.dispatcher.sinks.append(sink)
await call(app, mw, "report", {"error": "E" * 100_000})
rows = await mw.events_for("j1")
[row] = [r for r in rows if r["kind"] == "job.finished"]
assert row["attrs"]["error"][TRUNCATED] is True
assert row["attrs"]["ok"] is False
finally:
await sink.engine.dispose()
def test_embedding_text_uses_the_preview(self):
from datetime import UTC, datetime
marker = payload_mod.marker("disk full: " + "z" * 5000, 5013, 100)
record = EventRecord(
kind="job.failed",
occurred_at=datetime.now(UTC),
attrs={"error": marker},
)
[text] = extract_texts([record])
assert text.text.startswith('"disk full: zzz')
assert "_truncated" not in text.text
+403
View File
@@ -0,0 +1,403 @@
"""Tests for per-tool exclusion, sampling and retention pruning.
Real FastMCP servers through fastmcp.Client, as in test_instrumentation.py.
"""
import logging
import time
from datetime import UTC, datetime, timedelta
import pytest
from fastmcp import Client, FastMCP
from fastmcp.exceptions import ToolError
from sqlalchemy import event, func, select
from fastmcp_feedback.instrumentation import (
DatabaseSink,
MemorySink,
ToolCallRecord,
instrument,
)
@pytest.fixture
async def db(db_url):
"""DatabaseSinks whose engines are always disposed, or the process hangs."""
made = []
def factory(name="calls", **kwargs):
sink = DatabaseSink(db_url(name), create_tables=True, **kwargs)
made.append(sink)
return sink
yield factory
for sink in made:
await sink.engine.dispose()
class Rolls:
"""Stand-in for random.random that returns the given values in turn."""
def __init__(self, *values):
self.values = list(values)
self.calls = 0
def __call__(self):
self.calls += 1
return self.values.pop(0)
def make_app(sinks=None, **kwargs):
sink = MemorySink()
app = FastMCP("sampling")
@app.tool
def poll() -> dict:
return {"status": "idle"}
@app.tool
def render(status: str) -> dict:
return {"status": status, "detail": f"render said {status}"}
@app.tool
def crash() -> dict:
raise RuntimeError("exploded")
@app.tool
def other() -> str:
return "fine"
mw = instrument(app, sinks if sinks is not None else [sink], **kwargs)
return app, sink, mw
async def call(app, mw, *calls):
"""Run (tool, args) pairs; return the results, None where the tool raised."""
results = []
async with Client(app) as client:
for tool, args in calls:
try:
results.append(await client.call_tool(tool, args))
except ToolError:
results.append(None)
await mw.flush()
return results
def old_record(tool="old", days=40, **kwargs):
return ToolCallRecord(
tool=tool,
started_at=datetime.now(UTC) - timedelta(days=days),
duration_ms=1.0,
ok=True,
mode="meta",
**kwargs,
)
async def rows(sink, *columns):
t = sink.table
cols = [t.c[c] for c in columns] if columns else [t.c.tool]
async with sink.engine.connect() as conn:
return [
tuple(r)
for r in (await conn.execute(select(*cols).order_by(t.c.started_at))).all()
]
async def count(sink):
async with sink.engine.connect() as conn:
return (
await conn.execute(select(func.count()).select_from(sink.table))
).scalar()
class TestExclusion:
async def test_excluded_tool_not_recorded_nor_kept(self):
app, sink, mw = make_app(exclude_tools={"poll"})
results = await call(app, mw, ("poll", {}), ("other", {}), ("poll", {}))
assert [r.tool for r in sink.records] == ["other"]
assert [r.tool for r in mw.recent] == ["other"]
# The call itself is untouched.
assert results[0].structured_content == {"status": "idle"}
async def test_excluded_tool_failures_not_recorded_either(self):
app, sink, mw = make_app(exclude_tools=["crash"])
[result] = await call(app, mw, ("crash", {}))
assert result is None # the error still reached the client
assert sink.records == []
assert list(mw.recent) == []
async def test_excluded_tool_skips_hooks(self):
seen = []
app, sink, mw = make_app(
exclude_tools={"poll"}, identity_resolver=lambda c: seen.append(1) or {}
)
await call(app, mw, ("poll", {}))
assert seen == []
class TestSampling:
async def test_recorded_when_random_below_rate(self):
rolls = Rolls(0.29, 0.3, 0.31, 0.0)
app, sink, mw = make_app(sample_rates={"poll": 0.3}, random=rolls)
await call(app, mw, *[("poll", {})] * 4)
assert rolls.calls == 4
assert len(sink.records) == 2
assert [r.sample_rate for r in sink.records] == [0.3, 0.3]
assert all(r.outcome == "ok" for r in sink.records)
async def test_sampled_out_calls_not_in_recent(self):
app, sink, mw = make_app(sample_rates={"poll": 0.5}, random=Rolls(0.9, 0.1))
results = await call(app, mw, ("poll", {}), ("poll", {}))
assert len(sink.records) == 1
assert [r.id for r in mw.recent] == [sink.records[0].id]
# A sampled-out call still returns its result.
assert results[0].structured_content == {"status": "idle"}
async def test_sampled_out_ok_calls_skip_enricher(self):
# The identity resolver runs before the tool, when sampling cannot
# know yet whether the call will fail or record events; the enricher
# runs after, and only for calls that are kept.
seen = []
def who(context):
seen.append("identity")
return {}
def enrich(tool, args, result, context):
seen.append("enrich")
return {}
app, sink, mw = make_app(
sample_rates={"poll": 0.1},
random=Rolls(0.99),
identity_resolver=who,
enricher=enrich,
)
await call(app, mw, ("poll", {}))
assert sink.records == []
assert seen == ["identity"]
async def test_unsampled_tools_recorded_without_rate_or_roll(self):
rolls = Rolls()
app, sink, mw = make_app(sample_rates={"poll": 0.1}, random=rolls)
await call(app, mw, ("other", {}), ("other", {}))
assert rolls.calls == 0
assert [(r.tool, r.sample_rate) for r in sink.records] == [
("other", None),
("other", None),
]
async def test_rate_of_one_records_everything_with_rate(self):
app, sink, mw = make_app(sample_rates={"poll": 1}, random=Rolls(0.999))
await call(app, mw, ("poll", {}))
[record] = sink.records
assert record.sample_rate == 1.0
async def test_exception_of_sampled_tool_always_recorded(self):
# Rolls would sample every call out; failures must not consult them.
rolls = Rolls(0.99, 0.99)
app, sink, mw = make_app(sample_rates={"crash": 0.01}, random=rolls)
await call(app, mw, ("crash", {}), ("crash", {}))
assert [(r.outcome, r.error_type, r.sample_rate) for r in sink.records] == [
("error", "RuntimeError", None),
("error", "RuntimeError", None),
]
assert len(mw.recent) == 2
async def test_soft_error_of_sampled_tool_always_recorded(self):
rolls = Rolls(0.99, 0.99)
app, sink, mw = make_app(sample_rates={"render": 0.01}, random=rolls)
await call(
app, mw, ("render", {"status": "failed"}), ("render", {"status": "done"})
)
assert [(r.outcome, r.sample_rate) for r in sink.records] == [
("soft_error", None)
]
assert sink.records[0].error_message == "status=failed: render said failed"
assert [r.id for r in mw.recent] == [sink.records[0].id]
async def test_sampled_in_failure_has_no_rate(self):
app, sink, mw = make_app(sample_rates={"crash": 0.5}, random=Rolls(0.0))
await call(app, mw, ("crash", {}))
[record] = sink.records
assert record.sample_rate is None
@pytest.mark.parametrize("rate", [0, -0.1, 1.01, 2, float("nan")])
def test_invalid_rates_rejected(self, rate):
with pytest.raises(ValueError, match="poll"):
make_app(sample_rates={"poll": rate})
def test_non_numeric_rate_rejected(self):
with pytest.raises((TypeError, ValueError)):
make_app(sample_rates={"poll": "often"})
async def test_estimated_count(self):
"""sum(1 / coalesce(sample_rate, 1)) estimates the true call count."""
rolls = Rolls(*[i / 10 for i in range(10)])
app, sink, mw = make_app(sample_rates={"poll": 0.2}, random=rolls)
await call(app, mw, *[("poll", {})] * 10, ("crash", {}))
estimate = sum(1 / (r.sample_rate or 1) for r in sink.records)
assert estimate == pytest.approx(11)
class TestDatabaseSinkColumn:
async def test_writes_sample_rate(self, db):
sink = db()
app, _, mw = make_app(
sinks=[sink],
sample_rates={"poll": 0.25, "crash": 0.25},
random=Rolls(0.1, 0.9),
)
await call(app, mw, ("poll", {}), ("poll", {}), ("crash", {}), ("other", {}))
assert await rows(sink, "tool", "outcome", "sample_rate") == [
("poll", "ok", 0.25),
("crash", "error", None),
("other", "ok", None),
]
class TestPrune:
async def test_deletes_old_rows_keeps_new(self, db):
sink = db()
await sink.write([old_record("a", days=40), old_record("b", days=31)])
await sink.write([old_record("c", days=29), old_record("d", days=0)])
deleted = await sink.prune(datetime.now(UTC) - timedelta(days=30))
assert deleted == 2
assert await rows(sink) == [("c",), ("d",)]
async def test_default_cutoff_uses_retention(self, db):
sink = db(retention=timedelta(days=30))
sink._last_prune = time.monotonic() # keep write() from pruning
await sink.write([old_record("a", days=40), old_record("b", days=1)])
assert await count(sink) == 2
assert await sink.prune() == 1
assert await rows(sink) == [("b",)]
async def test_no_retention_no_cutoff_is_a_no_op(self, db):
sink = db()
await sink.write([old_record(days=400)])
assert await sink.prune() == 0
assert await count(sink) == 1
async def test_keeps_linked_calls_and_links(self, db):
sink = db()
linked, plain = old_record("linked"), old_record("plain")
await sink.write([linked, plain])
await sink.replace_links(
"bug-1",
[
{
"feedback_ref": "bug-1",
"call_id": linked.id,
"position": 0,
"rule": "session",
"linked_at": datetime.now(UTC),
},
{
# A link to a call that is not in this database.
"feedback_ref": "bug-1",
"call_id": "missing",
"position": 1,
"rule": "session",
"linked_at": datetime.now(UTC),
},
],
)
assert await sink.prune(datetime.now(UTC)) == 1
assert await rows(sink) == [("linked",)]
context = await sink.linked_calls("bug-1")
assert [(r["call_id"], r["tool"]) for r in context] == [
(linked.id, "linked"),
("missing", None),
]
async def test_deletes_in_batches(self, db):
sink = db(prune_batch=3)
await sink.write(
[old_record(f"t{i}") for i in range(10)] + [old_record("new", 0)]
)
deletes = []
def spy(conn, cursor, statement, params, context, executemany):
if statement.lstrip().upper().startswith("DELETE"):
deletes.append(statement)
event.listen(sink.engine.sync_engine, "before_cursor_execute", spy)
try:
assert await sink.prune(datetime.now(UTC) - timedelta(days=1)) == 10
finally:
event.remove(sink.engine.sync_engine, "before_cursor_execute", spy)
assert len(deletes) == 4 # 3 + 3 + 3 + 1
assert await rows(sink) == [("new",)]
async def test_exact_multiple_of_batch(self, db):
sink = db(prune_batch=5)
await sink.write([old_record(f"t{i}") for i in range(10)])
assert await sink.prune(datetime.now(UTC)) == 10
assert await count(sink) == 0
def test_invalid_settings_rejected(self):
with pytest.raises(ValueError):
DatabaseSink("sqlite+aiosqlite://", retention=timedelta(0))
with pytest.raises(ValueError):
DatabaseSink("sqlite+aiosqlite://", prune_batch=0)
class TestAutomaticPrune:
async def test_first_write_prunes_then_waits_for_interval(self, db):
sink = db(retention=timedelta(days=30), prune_interval=timedelta(hours=1))
await sink.write([old_record("a"), old_record("new", 0)])
assert await rows(sink) == [("new",)]
await sink.write([old_record("b")])
assert await count(sink) == 2 # inside the interval: not pruned
sink._last_prune = time.monotonic() - 3601
await sink.write([old_record("c", 0)])
assert sorted(await rows(sink)) == [("c",), ("new",)]
async def test_no_retention_never_prunes(self, db):
sink = db()
await sink.write([old_record(days=4000)])
assert await count(sink) == 1
assert sink._last_prune is None
async def test_failing_prune_does_not_stop_inserts(self, db, caplog):
sink = db(retention=timedelta(days=30))
attempts = []
async def broken(older_than=None):
attempts.append(older_than)
raise RuntimeError("prune blew up")
sink.prune = broken
with caplog.at_level(logging.WARNING):
await sink.write([old_record("a", 0)])
await sink.write([old_record("b", 0)])
assert await rows(sink) == [("a",), ("b",)]
assert len(attempts) == 1 # not retried on every write
assert "prune blew up" in caplog.text
async def test_through_the_middleware(self, db):
sink = db(retention=timedelta(days=30))
sink._last_prune = time.monotonic()
await sink.write([old_record("stale")])
sink._last_prune = None
app, _, mw = make_app(sinks=[sink])
await call(app, mw, ("other", {}))
assert mw.dispatcher.sink_errors == 0
assert await rows(sink) == [("other",)]
async def test_prune_failure_through_middleware_keeps_records(self, db):
sink = db(retention=timedelta(days=30))
async def broken(older_than=None):
raise RuntimeError("no")
sink.prune = broken
app, _, mw = make_app(sinks=[sink])
await call(app, mw, ("other", {}), ("poll", {}))
assert mw.dispatcher.sink_errors == 0
assert sorted(await rows(sink)) == [("other",), ("poll",)]
+551
View File
@@ -0,0 +1,551 @@
"""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(db_url):
"""DatabaseSinks whose engines are always disposed, or the process hangs."""
made = []
def factory(name="calls"):
sink = DatabaseSink(db_url(name), 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, "detail": 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",
),
# mcspeak's failures: an error string and no status or flag key.
(
{"played": False, "error": "audio output stalled after 30s"},
"error: audio output stalled after 30s",
),
(
{"error": "Not listening: microphone stopped"},
"error: Not listening: microphone stopped",
),
({"error": " padded "}, "error: padded"),
({"status": "unhealthy", "error": "db gone"}, "error: db gone"),
# Earlier checks still win, with their own message.
({"success": False, "error": "x"}, "success=False: x"),
],
)
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},
{"played": True, "error": None},
{"error": ""},
{"error": " "},
{"error": 0.02},
{"error": 0},
{"error": False},
# A structured error is left to custom classifiers.
{"error": {"code": 1}},
{"error": ["a"]},
{"failure_reason": "x"},
[{"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
def test_unknown_status_with_error_string(self):
"""blender-mcp's shape: flagged by default, labelled by status when known."""
result = {"status": "unknown_target", "error": "no object named Cube"}
assert DEFAULT_RESULT_CLASSIFIER("t", result) == "error: no object named Cube"
classify = soft_error_classifier(extra_statuses=("unknown_target",))
assert classify("t", result) == "status=unknown_target: no object named Cube"
def test_custom_error_keys(self):
classify = soft_error_classifier(error_keys=("failure_reason",))
assert classify("t", {"failure_reason": "no gpu"}) == "failure_reason: no gpu"
assert classify("t", {"error": "ignored"}) is None
assert classify("t", {"failure_reason": ""}) is None
def test_empty_error_keys_disable(self):
classify = soft_error_classifier(error_keys=())
assert classify("t", {"played": False, "error": "stalled"}) is None
assert classify("t", {"ok": False, "error": "x"}) == "ok=False: x"
class TestExplicitSuccess:
"""A flag key that is exactly True turns an error string into a warning."""
@pytest.mark.parametrize(
"result",
[
{"ok": True, "error": "deprecated arg"},
{"success": True, "error": "x"},
{"ok": True, "status": "unknown_target", "error": "fell back"},
],
)
def test_true_flag_with_error_string_is_ok(self, result):
assert DEFAULT_RESULT_CLASSIFIER("t", result) is None
@pytest.mark.parametrize(
"result, expected",
[
({"ok": False, "error": "x"}, "ok=False: x"),
# Not exactly True, so not an explicit success.
({"ok": 1, "error": "x"}, "error: x"),
({"ok": "yes", "error": "x"}, "error: x"),
({"success": 1.0, "error": "x"}, "error: x"),
# No flag key: mcspeak's shape, as in 2026.10.01.
(
{"played": False, "error": "audio output stalled"},
"error: audio output stalled",
),
# A False flag beats a True one.
({"ok": True, "success": False, "error": "x"}, "success=False: x"),
# A failing status is more specific than the flag, so it still flags.
({"success": True, "status": "failed"}, "status=failed"),
({"ok": True, "state": "error", "error": "x"}, "state=error: x"),
({"ok": True, "isError": True, "content": ["boom"]}, "isError: boom"),
],
)
def test_still_flagged(self, result, expected):
assert DEFAULT_RESULT_CLASSIFIER("t", result) == expected
def test_custom_flag_keys(self):
classify = soft_error_classifier(flag_keys=("passed",))
assert classify("t", {"passed": True, "error": "slow"}) is None
assert classify("t", {"passed": False, "error": "x"}) == "passed=False: x"
# ok is not a flag key here, so it does not suppress the error check.
assert classify("t", {"ok": True, "error": "x"}) == "error: x"
def test_no_flag_keys(self):
classify = soft_error_classifier(flag_keys=())
assert classify("t", {"ok": True, "error": "x"}) == "error: x"
async def test_recorded_as_ok(self):
record = await record_for(
"respond", {"payload": {"ok": True, "error": "deprecated arg"}}
)
assert (record.outcome, record.ok, record.error_message) == ("ok", True, None)
async def test_truthy_flag_recorded_as_soft_error(self):
record = await record_for("respond", {"payload": {"ok": 1, "error": "x"}})
assert (record.outcome, record.ok, record.error_message) == (
"soft_error",
False,
"error: x",
)
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_error_string_is_soft_error(self):
record = await record_for(
"respond",
{"payload": {"played": False, "error": f"stalled, key {BMCP_TOKEN}"}},
)
assert (record.outcome, record.ok, record.error_type) == (
"soft_error",
False,
"SoftError",
)
assert record.error_message == f"error: stalled, key {REDACTED}"
@pytest.mark.parametrize("value", [None, "", 0.02])
async def test_empty_or_numeric_error_stays_ok(self, value):
record = await record_for(
"respond", {"payload": {"played": True, "error": value}}
)
assert (record.outcome, record.ok, record.error_message) == ("ok", True, None)
async def test_error_keys_disabled(self):
record = await record_for(
"respond",
{"payload": {"error": "stalled"}},
result_classifier=soft_error_classifier(error_keys=()),
)
assert (record.outcome, record.ok) == ("ok", True)
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}"
)
async def test_list_feedback_failure_is_soft_error(self, monkeypatch):
"""list_feedback reports failure as {"feedback": [], "error": ...}."""
app = FastMCP("fb")
sink = MemorySink()
mw = instrument(app, [sink])
add_feedback_tools(app, database_url="sqlite:///:memory:", instrumentation=mw)
def broken(*args, **kwargs):
raise RuntimeError("database down")
from fastmcp_feedback.database import FeedbackDatabase
monkeypatch.setattr(FeedbackDatabase, "session", broken)
async with Client(app) as client:
result = await client.call_tool("list_feedback", {})
await mw.flush()
assert result.data["error"] == "database down"
[record] = sink.records
assert (record.tool, record.outcome, record.error_message) == (
"list_feedback",
"soft_error",
"error: database down",
)
+70
View File
@@ -0,0 +1,70 @@
"""Checks on the db_url fixture itself: isolation between tests, and pgvector."""
from datetime import UTC, datetime
import pytest
from sqlalchemy import func, select, text
from fastmcp_feedback.instrumentation import DatabaseSink, ToolCallRecord
SHARED_ID = "00000000-0000-0000-0000-00000000f00d"
@pytest.fixture
async def sink(db_url):
made = DatabaseSink(db_url(), create_tables=True)
yield made
await made.engine.dispose()
async def write_fixed_row(sink):
"""Insert a row with a fixed primary key and return the table's row count.
Two tests that did this against one shared database would collide on the
key, so each passing proves it had a database of its own.
"""
rec = ToolCallRecord(
id=SHARED_ID,
tool="t",
started_at=datetime.now(UTC),
duration_ms=1.0,
ok=True,
mode="meta",
)
await sink.write([rec])
async with sink.engine.connect() as conn:
return (
await conn.execute(select(func.count()).select_from(sink.table))
).scalar()
class TestIsolation:
async def test_first_writer(self, sink):
assert await write_fixed_row(sink) == 1
async def test_second_writer(self, sink):
assert await write_fixed_row(sink) == 1
def test_names_map_to_databases(self, db_url):
assert db_url("a") == db_url("a")
assert db_url("a") != db_url("b")
async def test_vector_extension_available(db_url, db_backend):
if db_backend != "postgres":
pytest.skip("pgvector is Postgres only")
sink = DatabaseSink(db_url())
try:
async with sink.engine.connect() as conn:
version = (
await conn.execute(
text("SELECT extversion FROM pg_extension WHERE extname = 'vector'")
)
).scalar()
distance = (
await conn.execute(text("SELECT '[1,2,3]'::vector <-> '[1,2,4]'"))
).scalar()
finally:
await sink.engine.dispose()
assert version is not None
assert distance == 1.0
+37 -36
View File
@@ -7,6 +7,7 @@ from fastmcp import FastMCP
from fastmcp_feedback.tools import add_feedback_tools
from fastmcp_feedback.feedback import create_feedback_server
from fastmcp_feedback.models import SubmitFeedbackRequest
from tests._compat import tool_map
@pytest.mark.unit
@@ -18,7 +19,7 @@ class TestAddFeedbackTools:
"""Test adding feedback tools with basic configuration."""
add_feedback_tools(mock_fastmcp_server, database_url=memory_db_url)
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
tool_names = list(tools.keys())
expected_tools = [
@@ -40,7 +41,7 @@ class TestAddFeedbackTools:
prefix="support"
)
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
tool_names = list(tools.keys())
expected_tools = [
@@ -62,7 +63,7 @@ class TestAddFeedbackTools:
insights=test_insights
)
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
# Should have all standard tools - check tool names in the keys
assert len([name for name in tools.keys() if "feedback" in name]) >= 5
@@ -76,7 +77,7 @@ class TestAddFeedbackTools:
# Should use default SQLite database
add_feedback_tools(mock_fastmcp_server)
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
tool_names = list(tools.keys())
assert "submit_feedback" in tool_names
@@ -91,7 +92,7 @@ class TestAddFeedbackTools:
separator="-"
)
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
tool_names = list(tools.keys())
assert "api-submit_feedback" in tool_names
@@ -105,7 +106,7 @@ class TestAddFeedbackTools:
# Second call with different prefix
add_feedback_tools(mock_fastmcp_server, database_url=memory_db_url, prefix="user")
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
tool_names = list(tools.keys())
# Should have tools with both prefixes
@@ -128,7 +129,7 @@ class TestCreateFeedbackServer:
assert isinstance(server, FastMCP)
assert server.name == "Test Feedback Server"
tools = await server.get_tools()
tools = await tool_map(server)
tool_names = list(tools.keys())
expected_tools = [
@@ -150,7 +151,7 @@ class TestCreateFeedbackServer:
)
assert server.name == "Custom DB Server"
tools = await server.get_tools()
tools = await tool_map(server)
assert len(tools) >= 5
async def test_create_feedback_server_with_insights(self, test_insights):
@@ -162,7 +163,7 @@ class TestCreateFeedbackServer:
assert server.name == "Analytics Server"
# Verify insights are integrated
tools = await server.get_tools()
tools = await tool_map(server)
assert len(tools) >= 5
async def test_create_feedback_server_with_description(self):
@@ -184,8 +185,8 @@ class TestCreateFeedbackServer:
assert server1.name != server2.name
# Each should have independent tool sets
tools1 = await server1.get_tools()
tools2 = await server2.get_tools()
tools1 = await tool_map(server1)
tools2 = await tool_map(server2)
assert len(tools1) >= 5
assert len(tools2) >= 5
@@ -201,7 +202,7 @@ class TestToolIntegration:
add_feedback_tools(mock_fastmcp_server, database_url=memory_db_url)
# Get the submit_feedback tool
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
submit_tool = tools["submit_feedback"]
assert submit_tool is not None
@@ -218,7 +219,7 @@ class TestToolIntegration:
add_feedback_tools(mock_fastmcp_server, database_url=memory_db_url)
# Get the list_feedback tool
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
list_tool = tools["list_feedback"]
assert list_tool is not None
@@ -234,7 +235,7 @@ class TestToolIntegration:
add_feedback_tools(mock_fastmcp_server, database_url=memory_db_url)
# Get the statistics tool
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
stats_tool = tools["get_feedback_statistics"]
assert stats_tool is not None
@@ -250,7 +251,7 @@ class TestToolIntegration:
add_feedback_tools(mock_fastmcp_server, database_url=memory_db_url)
# Get the update status tool
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
update_tool = tools["update_feedback_status"]
assert update_tool is not None
@@ -269,7 +270,7 @@ class TestToolIntegration:
add_feedback_tools(mock_fastmcp_server, database_url=memory_db_url)
# Get the delete tool
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
delete_tool = tools["delete_feedback"]
assert delete_tool is not None
@@ -290,7 +291,7 @@ class TestToolValidation:
"""Test submit_feedback tool parameter validation."""
add_feedback_tools(mock_fastmcp_server, database_url=memory_db_url)
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
submit_tool = tools["submit_feedback"]
# Test with invalid parameters
@@ -301,7 +302,7 @@ class TestToolValidation:
"""Test list_feedback tool parameter validation."""
add_feedback_tools(mock_fastmcp_server, database_url=memory_db_url)
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
list_tool = tools["list_feedback"]
# Test with valid optional parameters
@@ -318,7 +319,7 @@ class TestToolValidation:
"""Test update_feedback_status parameter validation."""
add_feedback_tools(mock_fastmcp_server, database_url=memory_db_url)
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
update_tool = tools["update_feedback_status"]
# Test with missing required parameters
@@ -329,7 +330,7 @@ class TestToolValidation:
"""Test delete_feedback parameter validation."""
add_feedback_tools(mock_fastmcp_server, database_url=memory_db_url)
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
delete_tool = tools["delete_feedback"]
# Test with missing required parameters
@@ -348,9 +349,9 @@ class TestServerComposition:
feedback_server = create_feedback_server("Feedback Service")
# Import feedback server with prefix
await main_server.import_server(feedback_server, prefix="feedback")
main_server.mount(feedback_server, "feedback")
tools = await main_server.get_tools()
tools = await tool_map(main_server)
tool_names = list(tools.keys())
# Should have feedback tools with prefix
@@ -364,10 +365,10 @@ class TestServerComposition:
user_feedback = create_feedback_server("User Feedback")
admin_feedback = create_feedback_server("Admin Feedback")
await main_server.import_server(user_feedback, prefix="user")
await main_server.import_server(admin_feedback, prefix="admin")
main_server.mount(user_feedback, "user")
main_server.mount(admin_feedback, "admin")
tools = await main_server.get_tools()
tools = await tool_map(main_server)
tool_names = list(tools.keys())
# Should have tools from both servers
@@ -386,9 +387,9 @@ class TestServerComposition:
# Implementation would need to support this feature
# FastMCP import_server doesn't support selective tool import
# but we can test the full import with prefix
await main_server.import_server(feedback_server, prefix="public")
main_server.mount(feedback_server, "public")
tools = await main_server.get_tools()
tools = await tool_map(main_server)
tool_names = list(tools.keys())
# All tools should be imported with prefix
@@ -416,7 +417,7 @@ class TestToolPerformance:
assert performance_timer.duration_ms < 1000 # 1 second
# Verify all tools were registered
tools = await server.get_tools()
tools = await tool_map(server)
assert len(tools) >= 5
async def test_multiple_server_creation_performance(self, performance_timer):
@@ -436,14 +437,14 @@ class TestToolPerformance:
# Each server should be functional
for server in servers:
tools = await server.get_tools()
tools = await tool_map(server)
assert len(tools) >= 5
async def test_tool_execution_performance(self, mock_fastmcp_server, memory_db_url, performance_timer):
"""Test performance of tool execution."""
add_feedback_tools(mock_fastmcp_server, database_url=memory_db_url)
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
list_tool = tools["list_feedback"]
performance_timer.start()
@@ -467,7 +468,7 @@ class TestToolConfiguration:
"""Test that tools have proper descriptions."""
add_feedback_tools(mock_fastmcp_server, database_url=memory_db_url)
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
for tool_name, tool_obj in tools.items():
# Each tool should have a description
@@ -480,7 +481,7 @@ class TestToolConfiguration:
"""Test tool metadata and categorization."""
add_feedback_tools(mock_fastmcp_server, database_url=memory_db_url)
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
feedback_tools = [name for name in tools.keys() if "feedback" in name]
# Should have categorized the tools appropriately
@@ -490,7 +491,7 @@ class TestToolConfiguration:
"""Test tool parameter schemas."""
add_feedback_tools(mock_fastmcp_server, database_url=memory_db_url)
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
submit_tool = tools["submit_feedback"]
# Tool should have parameter schema defined
@@ -510,7 +511,7 @@ class TestToolErrorHandling:
# Use invalid database URL
add_feedback_tools(server, database_url="sqlite:///nonexistent/path/db.sqlite")
tools = await server.get_tools()
tools = await tool_map(server)
list_tool = tools["list_feedback"]
# Tool execution should handle database errors gracefully
@@ -523,7 +524,7 @@ class TestToolErrorHandling:
"""Test tool handling of invalid parameters."""
add_feedback_tools(mock_fastmcp_server, database_url=memory_db_url)
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
submit_tool = tools["submit_feedback"]
# Test with malformed request
@@ -534,7 +535,7 @@ class TestToolErrorHandling:
"""Test concurrent execution of tools."""
add_feedback_tools(mock_fastmcp_server, database_url=memory_db_url)
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
list_tool = tools["list_feedback"]
import asyncio
+29 -28
View File
@@ -23,6 +23,7 @@ from fastmcp_feedback.tools import (
)
from fastmcp_feedback.database import FeedbackDatabase
from fastmcp_feedback.insights import FeedbackInsights
from tests._compat import tool_map
@pytest.mark.unit
@@ -34,7 +35,7 @@ class TestSubmissionTools:
"""Test adding submission tools with basic configuration."""
add_submission_tools(mock_fastmcp_server, database_url=memory_db_url)
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
tool_names = list(tools.keys())
# Should have submission-related tools
@@ -51,7 +52,7 @@ class TestSubmissionTools:
prefix="user"
)
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
tool_names = list(tools.keys())
assert "user_submit_feedback" in tool_names
@@ -65,14 +66,14 @@ class TestSubmissionTools:
insights=test_insights
)
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
assert len(tools) > 0
async def test_add_submission_tools_without_database_url(self, mock_fastmcp_server):
"""Test adding submission tools without database URL (uses default)."""
add_submission_tools(mock_fastmcp_server)
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
assert "submit_feedback" in tools
@patch('fastmcp_feedback.tools.FeedbackDatabase')
@@ -105,7 +106,7 @@ class TestSubmissionTools:
prefix="api_" # With trailing underscore
)
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
tool_names = list(tools.keys())
# Should strip trailing underscore
@@ -121,7 +122,7 @@ class TestRetrievalTools:
"""Test adding retrieval tools with basic configuration."""
add_retrieval_tools(mock_fastmcp_server, database_url=memory_db_url)
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
tool_names = list(tools.keys())
# Should have retrieval-related tools
@@ -139,7 +140,7 @@ class TestRetrievalTools:
prefix="public"
)
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
tool_names = list(tools.keys())
assert "public_list_feedback" in tool_names
@@ -154,14 +155,14 @@ class TestRetrievalTools:
insights=test_insights
)
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
assert len(tools) > 0
async def test_add_retrieval_tools_without_database_url(self, mock_fastmcp_server):
"""Test adding retrieval tools without database URL (uses default)."""
add_retrieval_tools(mock_fastmcp_server)
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
assert "list_feedback" in tools
assert "get_feedback_statistics" in tools
@@ -197,7 +198,7 @@ class TestManagementTools:
"""Test adding management tools with basic configuration."""
add_management_tools(mock_fastmcp_server, database_url=memory_db_url)
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
tool_names = list(tools.keys())
# Should have management-related tools
@@ -215,7 +216,7 @@ class TestManagementTools:
prefix="admin"
)
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
tool_names = list(tools.keys())
assert "admin_update_feedback_status" in tool_names
@@ -230,14 +231,14 @@ class TestManagementTools:
insights=test_insights
)
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
assert len(tools) > 0
async def test_add_management_tools_without_database_url(self, mock_fastmcp_server):
"""Test adding management tools without database URL (uses default)."""
add_management_tools(mock_fastmcp_server)
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
assert "update_feedback_status" in tools
assert "delete_feedback" in tools
@@ -347,7 +348,7 @@ class TestAdvancedIntegrationPatterns:
create_multi_tenant_tools(mock_fastmcp_server, tenants)
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
tool_names = list(tools.keys())
# Should have tools for each tenant with prefixes
@@ -365,7 +366,7 @@ class TestAdvancedIntegrationPatterns:
create_multi_tenant_tools(mock_fastmcp_server, tenants, insights=test_insights)
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
assert len(tools) >= 10 # At least 5 tools per tenant
async def test_create_multi_tenant_tools_empty_tenants(self, mock_fastmcp_server):
@@ -374,7 +375,7 @@ class TestAdvancedIntegrationPatterns:
create_multi_tenant_tools(mock_fastmcp_server, tenants)
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
# Should complete without error but not add any tools
assert len(tools) == 0
@@ -394,7 +395,7 @@ class TestAdvancedIntegrationPatterns:
"""Test creating role-based tools."""
create_role_based_tools(mock_fastmcp_server, database_url=memory_db_url)
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
tool_names = list(tools.keys())
# Should have public tools (submission and retrieval)
@@ -416,14 +417,14 @@ class TestAdvancedIntegrationPatterns:
insights=test_insights
)
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
assert len(tools) >= 7 # Public + admin tools
async def test_create_role_based_tools_without_database(self, mock_fastmcp_server):
"""Test creating role-based tools without database URL."""
create_role_based_tools(mock_fastmcp_server)
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
tool_names = list(tools.keys())
assert "public_submit_feedback" in tool_names
@@ -465,7 +466,7 @@ class TestMigrationHelpers:
mock_fastmcp_server.remove_tool.assert_any_call("old_list_tool")
# Should have new tools added
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
tool_names = list(tools.keys())
assert "submit_feedback" in tool_names
assert "list_feedback" in tool_names
@@ -501,7 +502,7 @@ class TestMigrationHelpers:
insights=test_insights
)
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
assert len(tools) >= 5
async def test_migrate_tools_to_mixins_empty_list(self, mock_fastmcp_server, memory_db_url):
@@ -515,7 +516,7 @@ class TestMigrationHelpers:
)
# Should still add new tools
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
assert len(tools) >= 5
@patch('fastmcp_feedback.tools.add_feedback_tools')
@@ -757,7 +758,7 @@ class TestErrorHandlingAndEdgeCases:
# Verify all servers have tools
for server in servers:
tools = await server.get_tools()
tools = await tool_map(server)
assert "submit_feedback" in tools
assert "public_list_feedback" in tools
assert "admin_update_feedback_status" in tools
@@ -774,7 +775,7 @@ class TestErrorHandlingAndEdgeCases:
# Multiple underscores
add_management_tools(mock_fastmcp_server, database_url=memory_db_url, prefix="admin___")
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
tool_names = list(tools.keys())
# Should handle edge cases gracefully
@@ -812,7 +813,7 @@ class TestIntegrationWithExistingTests:
# Add all tools
add_feedback_tools(mock_fastmcp_server, database_url=memory_db_url, prefix="complete")
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
tool_names = list(tools.keys())
# Should have both individual and complete sets
@@ -830,7 +831,7 @@ class TestIntegrationWithExistingTests:
insights=test_insights
)
tools = await mock_fastmcp_server.get_tools()
tools = await tool_map(mock_fastmcp_server)
submit_tool = tools["submit_feedback"]
# Should work with existing sample data
@@ -848,9 +849,9 @@ class TestIntegrationWithExistingTests:
add_retrieval_tools(feedback_server, prefix="readonly")
# Import with prefix
await main_server.import_server(feedback_server, prefix="external")
main_server.mount(feedback_server, "external")
tools = await main_server.get_tools()
tools = await tool_map(main_server)
tool_names = list(tools.keys())
# Should have properly composed tool names
Generated
+2310
View File
File diff suppressed because it is too large Load Diff