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

622 lines
32 KiB
Markdown

# Changelog
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
- 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