Files
fastmcp-feedback/CHANGELOG.md
T
rsp2k 4206e15293
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
Release 2026.10.04: payload size limit, dotted classifier keys
2026-10-04 14:43:32 -06:00

32 KiB

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Calendar Versioning (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

  • Initial public release
  • Core feedback tools: submit_feedback, list_feedback, get_feedback_stats, update_feedback_status
  • add_feedback_tools() one-line integration function
  • Modular mixin architecture:
    • SubmissionMixin - feedback collection
    • RetrievalMixin - listing and statistics
    • ManagementMixin - status workflow management
    • InsightsMixin - privacy-compliant analytics
  • Multi-database support: SQLite, PostgreSQL, MySQL
  • create_feedback_server() for server composition
  • Comprehensive test suite (97%+ coverage)
  • Full type hints and Pydantic validation
  • Documentation site (on a private development address at the time)

Technical Details

  • Built on FastMCP 2.12.2+
  • SQLAlchemy 2.0+ ORM
  • Pydantic 2.11+ validation
  • Python 3.11+ required