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_bytesonInstrumentationMiddlewareandinstrument(), 64 KiB by default,Nonefor no limit. Arguments and results stored in modefull, and every event'sattrs(includingrecord_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 forargs_sizeandresult_size, so the limit adds no encoding; the preview encodes only the start of the value. Inattrs, only the largest values are replaced, so the dict keeps its keys.EmbeddingSinkembeds a marker's preview.fastmcp_feedback.instrumentation.payloadholds the marker helpers (TRUNCATED,is_truncated).message_keysonsoft_error_classifier(defaulterror,message,detail,reason, as before).- Dotted paths in
message_keysanderror_keys:"snapshot.error"readsresult["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=Nonekeeps 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 missinguser_sub,caller_kindorclient_idalso triggered it, so a resolver that never returns one of them (mcspeak's returns noclient_id) ran twice on every kept call.late_identity_keysstays empty by default: a resolver that answers anything runs once per call unless the server opts in. blender-mcp's registration fix still needslate_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_clientlostbus_client_uuid,bus_roleandparent_uuidfrom its row'sidentitythis way. When the first answer is incomplete, missinguser_sub,caller_kindorclient_id, or any key in the newlate_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 replaceNone; on conflict the first answer wins, so the row matches the events recorded during the call, which are not rewritten. Both runs keephook_timeout, redaction and never raising. A complete first answer still means one run.
Added
late_identity_keysonInstrumentationMiddlewareandinstrument(): 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_statisticsandget_database_statskeyby_typeandby_statusby enum value (bug,in_progress), the spellinglist_feedbackreturns and accepts as a filter. They grouped the raw columns, which hold the enum NAMES on both SQLite (VARCHAR) and Postgres (native enum typesfeedbacktype,feedbackstatus), so they returnedBUG,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 tohook_timeout, and it now also runs for ok calls that sampling drops afterwards. It does not run in modeoffor forexclude_tools. The enricher still runs after the call. PACKAGE_INFOis read from the installed distribution's metadata. It listed only thepostgresqlanddevextras and a classifier pyproject did not have.- psycopg2-binary is in the
devandtestextras, somake test-pgcovers the feedback tools on Postgres too.
Fixed
record_event(andrecord_llm_call) inside a tool call never setuser_sub,caller_kindorclient_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_idfalls back to the FastMCP context's as for calls. Values passed explicitly win, andrecord_eventtakescaller_kind=andclient_id=as well asuser_sub=. Outside a call, including a task the call started that records after it returned, nothing is filled in.- Importing
fastmcp_feedbackcalledlogging.basicConfigat 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 aNullHandlerto thefastmcp_feedbacklogger 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
-vshows 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-pgandmake lintreplace 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.instrumentationfailed onpip install fastmcp-feedbackwithout theinstrumentationextra: the package importedstorage.pyeagerly, which importssqlalchemy.ext.asyncio, and SQLAlchemy 2.1 raisesImportErrorthere whengreenletis missing.instrument(app)withJsonLinesSink,CallbackSinkorMemorySink, events, redaction and result classification now need only the base dependencies.DatabaseSink,EmbeddingVector,build_metadata,Embedder,EmbeddingSink,EmbeddingText,OpenAIEmbedder,extract_texts,backfill_embeddingsandbackfill_feedbackload on first access. Without the extra, accessing one raisesImportErrornaming 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 missinggreenletonly once. Import paths are unchanged.- The middleware finds a
DatabaseSinkorEmbeddingSinkamong its sinks without importing their modules, sodatabase_sink,embedding_sink,capture_recent_calls,link_feedback,feedback_context,events_for,similarandsimilar_feedbackreturn their usual empty results on a base install. DEFAULT_TABLE_PREFIX,DEFAULT_EMBEDDING_DIM,EMBEDDING_SOURCESandDEFAULT_EVENT_TEXT_KEYSmoved toinstrumentation/constants.pyand import without the extras;storageandembeddingsstill 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 exactlyTrue, theerror_keyscheck is skipped and the string is treated as a warning, so{"ok": True, "error": "deprecated arg"}is recorded asokagain. Only that check is skipped: a failing status ({"success": True, "status": "failed"}), any flag key exactlyFalseandisErrorstill flag the result. Truthy values that are notTrue, such as1or"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 oferror_keysis a non-empty string (after stripping) is a soft error, with message<key>: <string>, redacted and truncated as before. Checked after the status, flag andisErrorchecks, 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_CLASSIFIERhas it on.- Behavior change: results such as
{"played": False, "error": "..."},{"error": "..."}and{"status": "unknown_target", "error": "..."}were recorded asokand are nowsoft_error, soNOT okand soft-error counts can rise after upgrading. This includes the package's ownlist_feedback,get_feedback_statisticsand analytics tools, whose failure results carry anerrorstring and no flag key. Passresult_classifier=soft_error_classifier(error_keys=())for the old behavior.
Docs
record_event/events_for: akeyused 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
extrafield, for SQLite (json_extract(c.extra, '$.speech_id') = e.key) and Postgres (c.extra->>'speech_id' = e.key). - Schema notes:
ffb_tool_callshas primary keyidand nocall_idcolumn;ffb_events.call_idholds a call'sid. Ids are UUIDs, so order events byoccurred_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 theDatabaseSink's tables for calls with outcomeerrororsoft_error(and, for rows from before theoutcomecolumn, outcome NULL with anerror_message),feedback.submittedevents,llm.callevents with prompt or completion, and other events with any of the sink'sevent_text_keys. Each record goes through the live sink's extraction, redactor andmax_chars, so text,text_hashandsource_idmatch and later records dedupe against backfilled rows. Idempotent: a second run embeds nothing. Keyset-paged by timestamp and id,batch_sizerows per query; embeds in chunks of the sink'sbatch_size.limitembeds at most that many texts, oldest first. Returns counts per source type plusskippedanderrors; embed failures are counted, never raised. Raises only for bad arguments or a missing table.sourcesdefaults 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 afeedback.submittedevent 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 fromFFB_EMBED_API_KEYor 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 askey: valuelines, 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 beforemax_charsapplies. 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).writeextracts text and puts it on the sink's own bounded queue, returning at once, so a slow embedding backend never reaches the dispatcher'ssink_timeoutor a tool call. A background task embeds in batches and upserts through theDatabaseSink's engine. A full queue drops and counts (dropped); embedder and database failures are logged and counted (errors), never raised. An unchanged text (sametext_hash) is not embedded again; a changed one replaces its row. Text is redacted with the middleware'sRedactor(adopted when the sink is passed toinstrument) and truncated before it leaves the process.aclose()drains for up totimeout, then drops and counts the rest.Embedderprotocol (model,dim,async embed(texts)) andOpenAIEmbedder(base_url, api_key=None, model="mxbai-embed-large", dim=1024, batch_size=32, timeout=30.0)for any OpenAI-compatible/v1/embeddingsendpoint. It useshttpx, orhttpx2under FastMCP 4; no new dependency. A vector of the wrong length raisesValueError.await mw.similar(text, *, source_types=None, k=10, min_score=None)andawait mw.similar_feedback(feedback_ref, *, k=10, source_types=None, min_score=None). Results carrysource_type,source_id,text,score(1 - cosine distance, clamped to [0, 1]) andcreated_at. PostgreSQL ranks withembedding <=> :qon an HNSW index; other databases compare in Python against the newestscan_limitrows.similar_feedbackreuses the stored vector and leaves the item itself out. Neither raises; failures log and return [].- Feedback flows as an event. With
instrumentation=,submit_feedbackrecordsfeedback.submittedkeyed by the feedback id, withtype,title,descriptionandsubmitter. 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)andrecord_llm_call(..., prompt=None, completion=None). Prompt and completion are stored (redacted, cut to8 * max_error_chars, 4000 by default) only whencapture_llm_text=True.embeddingsextra:pgvector>=0.5.0. Postgres hosts also needasyncpg.build_metadata(prefix, metadata=None, embedding_dim=1024)andDatabaseSink(embedding_dim=1024).embedding_dim=Noneinbuild_metadataleaves 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).embeddingis pgvectorvector(dim)with an HNSWvector_cosine_opsindex on PostgreSQL, a JSON float list elsewhere.build_metadata()now includes it, so Alembic hosts need a migration; the README has the SQL, starting withCREATE EXTENSION IF NOT EXISTS vector. Withcreate_tables=Truethe table is created when anEmbeddingSinkfirst writes, not at startup, so hosts that do not embed need neither pgvector nor the extension. On PostgreSQL that step first runsCREATE 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_callnow dropspromptandcompletionkeys passed inattrsunlesscapture_llm_text=True. Earlier versions stored them.
Documentation
- README:
DatabaseSinkneeds theinstrumentationextra even with SQLAlchemy installed, since SQLAlchemy 2.1 no longer installsgreenlet.
[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 rancreate_all, and one failed (SQLite:table ... already exists; Postgres: a unique violation onpg_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 runscreate_allonce 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.attrsis 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: modeoff, an empty or over-128-characterkind, akeyover 255 characters, non-mappingattrs, 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 storedffb_tool_callsrow. Outside a call,call_idis None; a task a tool started that records after the tool returned counts as outside. - Thread safety.
record_eventworks from any thread: off the server's loop, the event is handed over withcall_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) -> boolrecords kindllm.callwith plain attribute names:model,provider,duration_ms,input_tokens,output_tokens,ok(defaults toerror is None),error(redacted, truncated tomax_error_chars) anderror_typefor 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 newestlimit), from theDatabaseSinkplus events still in the queue, deduplicated by id,occurred_atin UTC. Without aDatabaseSinkit answers from memory (recent_events=2000on the middleware).EventRecord, exported fromfastmcp_feedback.instrumentation.MemorySinkhas.callsand.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'scall_idhas a row.
Changed
- Sinks receive mixed batches of
ToolCallRecordandEventRecordonce events are recorded.record.record_typeistool_callorevent. ACallbackSinkcallback that assumes every record is a call needs a check. JsonLinesSinklines carry"record": "tool_call"or"record": "event"(a new field; existing fields unchanged).DatabaseSink.writestores calls and events in separate transactions, calls first, so a database without the events table still stores calls.DatabaseSink.prune()also deletes events whoseoccurred_atis before the cutoff, inprune_batchbatches, 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_eventsby default):id,occurred_at,kind,key,call_id,session_id,user_sub,caller_kind,client_id,server_version,attrs(JSON/JSONB), indexed onoccurred_at,kind,key,call_idanduser_sub. Hosts that manage the schema with Alembic need a migration creating it (build_metadata()includes it).create_tables=Truecreates 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'sokcalls. Failures are always recorded: calls with outcomeerrororsoft_errorare kept whatever the rate. Rates outside (0, 1] raiseValueError. Calls sampled out skip the hooks and redaction and are not kept for feedback links.random=takes a() -> floatfor tests.- Records have a
sample_rate: the configured rate on sampled-inokrows, 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). Withretentionset, the background writer prunes after an insert at most once perprune_interval(one hour by default). A failing prune is logged and never stops the insert.DatabaseSink.prune(older_than=None) -> intdeletes older rows in batches ofprune_batch, one transaction each, on SQLite and Postgres. Calls linked to feedback are never pruned, and links are never deleted.
Migration
- New nullable
sample_ratecolumn (Float) on{prefix}tool_calls. Hosts that manage the schema with Alembic need a migration adding it.create_tables=Truedoes 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,DatabaseSinkwrites fail and those records are dropped (logged). Hosts still on 2026.09.27.3 or earlier also need theoutcomecolumn 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) orerror(the tool raised). Soft errors geterror_type="SoftError"and anerror_messagebuilt from the result, such asstatus=failed: disk full, redacted and truncated tomax_error_chars. soft_error_classifier(statuses=..., status_keys=..., flag_keys=..., extra_statuses=...)builds a classifier that flags dict results whereokorsuccessisFalse, wherestatusorstateiserror,failedorfailure, or that the tool markedisErrorwithout raising.DEFAULT_RESULT_CLASSIFIERissoft_error_classifier().InstrumentationMiddleware(..., result_classifier=...)takes any(tool, result) -> str | None;Noneturns classification off. A classifier that raises is logged and the call is recorded asok.feedback_context()rows includeoutcome.
Changed
okis nowoutcome == "ok", so it is also false for soft errors. ExistingWHERE NOT okqueries and error rates will rise; filter onoutcome = 'error'for exceptions only.CallLink.okfollows.- The package's own feedback tools answer
{"success": False, ...}on failure, so those calls are now recorded as soft errors.
Migration
- New nullable, indexed
outcomecolumn (String(16)) on{prefix}tool_calls. Hosts that manage the schema with Alembic need a migration adding it.create_tables=Truedoes 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,DatabaseSinkwrites 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 (sessionrule), then fills remaining slots with calls by the sameuser_subinsidewindow(user_windowrule, 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
DatabaseSinkthe 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_linkstable.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_feedbacklinks the preceding calls automatically and returnslinked_callsin its response.session_key(ctx)gives a per-client key that stays stable across calls: the HTTPmcp-session-idheader when present, then one key per process for stdio, thenctx.session_id.
Fixed
- With
create_tables=True, the background writer andlink_feedbackcould both runcreate_allat 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 anidentity_resolver. Stdio servers link by session with every client.
[2026.09.27.2] - 2026-09-27
Fixed
DatabaseSinkstored missingargs/result(everymetamode row) as the JSON literalnull, soWHERE args IS NULLmatched nothing. They are now SQLNULL. No schema change; rows written by 2026.09.27.1 keep JSONnulland can be backfilled withUPDATE ffb_tool_calls SET args = NULL WHERE args = 'null'::jsonb(same forresult). Reported from production by blender-mcp.
[2026.09.27.1] - 2026-09-27
Per-call instrumentation for any FastMCP server.
Added
fastmcp_feedback.instrumentation:InstrumentationMiddlewareandinstrument(app)record every tool call on the server (tool, timing, outcome, original error type, session and request ids, argument and result sizes). Modesoff,meta(default) andfull, set by argument orFEEDBACK_INSTRUMENTATION_MODE;meta_only_toolskeeps chosen tools atmeta.- 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 andbmcp_tokens,password=...in text).- Sinks:
JsonLinesSink(stderr),CallbackSink,MemorySink, andDatabaseSinkon async SQLAlchemy.DatabaseSinkaccepts the host'sAsyncEngine, writes to a prefixedffb_tool_callstable, never creates tables unless asked, andbuild_metadata()plugs the table into a host's Alembicenv.py. identity_resolverandenricherhooks filluser_sub,caller_kind,client_id,server_versionand free-formidentity/extrafields.instrumentationextra:sqlalchemy[asyncio]andaiosqlite.- 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 calledimport_server(), which became async in 2.14, without awaiting it, then logged success anyway. On FastMCP 4, whereimport_server()no longer exists, it raisedAttributeError. It now usesmount(), 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 optionaldatabase_url. Examples list and call tools throughfastmcp.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 isget_feedback_statistics),InsightsMixin, anenable_insightsparameter, feedback typesbug_report/feature_request/general(they arebug/feature/improvement/question), and anew -> reviewing -> plannedworkflow (it isopen -> in_progress -> resolved -> closed). - The default database is in-memory SQLite (
sqlite:///:memory:), notsqlite:///feedback.dbas previously documented. Passdatabase_urlto keep feedback across restarts.
[2026.01.12.1] - 2026-01-12
Fixed
ImportErroron install:mixins/base.pydid 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 collectionRetrievalMixin- listing and statisticsManagementMixin- status workflow managementInsightsMixin- 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