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