Files
fastmcp-feedback/src/fastmcp_feedback/tools.py
T
rsp2k 58e531019d
Tests / test (==2.12.2) (push) Canceled after 0s
Tests / test (==2.14.3) (push) Canceled after 0s
Tests / test (>=3.3,<4) (push) Canceled after 0s
Tests / test (>=4,<5) (push) Canceled after 0s
Tests / canary (push) Canceled after 0s
Add feedback-to-call correlation (unreleased)
capture_recent_calls() picks the calls that led up to a feedback item:
same client session first, then the same user within a window, each link
recording which rule matched. It reads an in-memory buffer (so calls still
queued count) and falls back to DatabaseSink for other workers and
restarts. link_feedback() stores links against any feedback id in a
prefixed feedback_call_links table; feedback_context() reads them back.
add_feedback_tools(instrumentation=mw) makes submit_feedback link
automatically and report linked_calls.

session_key() works around FastMCP 4.0 returning a new ctx.session_id on
every call: it prefers the HTTP mcp-session-id header, then one key per
process for stdio, then ctx.session_id.

Also fixes a race where the writer and link_feedback both ran create_all
with create_tables=True.

468 tests pass on FastMCP 2.12.2, 2.14.3, 3.3.1, 3.4.7; 467 + 1 skip on
4.0.10. Still to do before release: execute the new README examples,
CHANGELOG, version bump.
2026-09-27 09:12:10 -06:00

375 lines
11 KiB
Python

"""Main integration functions for FastMCP Feedback tools."""
import logging
from fastmcp import FastMCP
from .database import FeedbackDatabase
from .insights import FeedbackInsights
from .mixins import ManagementMixin, RetrievalMixin, SubmissionMixin
logger = logging.getLogger(__name__)
def add_feedback_tools(
mcp: FastMCP,
database_url: str | None = None,
insights: FeedbackInsights | None = None,
prefix: str = "",
separator: str = "_",
instrumentation=None,
) -> None:
"""Add all feedback tools to a FastMCP server.
This is the main integration function that adds all feedback functionality
to an existing FastMCP server using the mixin architecture.
Args:
mcp: FastMCP server instance to add tools to.
database_url: Database connection URL. Defaults to SQLite in-memory.
insights: Optional insights instance. Creates default if not provided.
prefix: Optional prefix for tool names.
separator: Separator between prefix and tool name.
instrumentation: an InstrumentationMiddleware (see ``instrument``).
When given, each submission is linked to the tool calls that led
up to it, and the response reports ``linked_calls``.
"""
try:
# Initialize database
database = FeedbackDatabase(database_url)
# Initialize insights if not provided
if insights is None:
insights = FeedbackInsights()
# Create mixin instances
submission_mixin = SubmissionMixin(database, insights, instrumentation)
retrieval_mixin = RetrievalMixin(database, insights)
management_mixin = ManagementMixin(database, insights)
# Register tools with proper prefix handling
if prefix:
clean_prefix = prefix.rstrip(separator)
submission_mixin.register_tools(
mcp, prefix=clean_prefix, separator=separator
)
retrieval_mixin.register_tools(
mcp, prefix=clean_prefix, separator=separator
)
management_mixin.register_tools(
mcp, prefix=clean_prefix, separator=separator
)
else:
submission_mixin.register_tools(mcp)
retrieval_mixin.register_tools(mcp)
management_mixin.register_tools(mcp)
logger.info(
f"Feedback tools added to server '{mcp.name}' with prefix '{prefix}'"
)
except Exception as e:
logger.error(f"Failed to add feedback tools: {e}")
raise
def add_submission_tools(
mcp: FastMCP,
database_url: str | None = None,
insights: FeedbackInsights | None = None,
prefix: str = "",
) -> None:
"""Add only submission tools to a FastMCP server.
Args:
mcp: FastMCP server instance.
database_url: Database connection URL.
insights: Optional insights instance.
prefix: Optional prefix for tool names.
"""
try:
database = FeedbackDatabase(database_url)
insights = insights or FeedbackInsights()
submission_mixin = SubmissionMixin(database, insights)
if prefix:
submission_mixin.register_tools(mcp, prefix=prefix.rstrip("_"))
else:
submission_mixin.register_tools(mcp)
logger.info(f"Submission tools added to server '{mcp.name}'")
except Exception as e:
logger.error(f"Failed to add submission tools: {e}")
raise
def add_retrieval_tools(
mcp: FastMCP,
database_url: str | None = None,
insights: FeedbackInsights | None = None,
prefix: str = "",
) -> None:
"""Add only retrieval tools to a FastMCP server.
Args:
mcp: FastMCP server instance.
database_url: Database connection URL.
insights: Optional insights instance.
prefix: Optional prefix for tool names.
"""
try:
database = FeedbackDatabase(database_url)
insights = insights or FeedbackInsights()
retrieval_mixin = RetrievalMixin(database, insights)
if prefix:
retrieval_mixin.register_tools(mcp, prefix=prefix.rstrip("_"))
else:
retrieval_mixin.register_tools(mcp)
logger.info(f"Retrieval tools added to server '{mcp.name}'")
except Exception as e:
logger.error(f"Failed to add retrieval tools: {e}")
raise
def add_management_tools(
mcp: FastMCP,
database_url: str | None = None,
insights: FeedbackInsights | None = None,
prefix: str = "",
) -> None:
"""Add only management tools to a FastMCP server.
Args:
mcp: FastMCP server instance.
database_url: Database connection URL.
insights: Optional insights instance.
prefix: Optional prefix for tool names.
"""
try:
database = FeedbackDatabase(database_url)
insights = insights or FeedbackInsights()
management_mixin = ManagementMixin(database, insights)
if prefix:
management_mixin.register_tools(mcp, prefix=prefix.rstrip("_"))
else:
management_mixin.register_tools(mcp)
logger.info(f"Management tools added to server '{mcp.name}'")
except Exception as e:
logger.error(f"Failed to add management tools: {e}")
raise
# Tool configuration helpers
def configure_feedback_tools(
database_url: str | None = None,
enable_insights: bool = True,
insights_retention_days: int = 90,
) -> dict:
"""Configure feedback tools with specified parameters.
Args:
database_url: Database connection URL.
enable_insights: Whether to enable analytics.
insights_retention_days: Days to retain analytics data.
Returns:
Configuration dictionary for tool setup.
"""
config = {
"database_url": database_url or "sqlite:///:memory:",
"insights": FeedbackInsights(
enabled=enable_insights, retention_days=insights_retention_days
),
}
logger.info(
f"Feedback tools configured: db={config['database_url']}, insights={enable_insights}"
)
return config
def get_tool_info() -> dict:
"""Get information about available feedback tools.
Returns:
Dictionary with tool information.
"""
return {
"available_tools": [
"submit_feedback",
"list_feedback",
"get_feedback_statistics",
"update_feedback_status",
"delete_feedback",
],
"mixin_classes": ["SubmissionMixin", "RetrievalMixin", "ManagementMixin"],
"integration_functions": [
"add_feedback_tools",
"add_submission_tools",
"add_retrieval_tools",
"add_management_tools",
],
"supported_databases": ["sqlite", "postgresql"],
"features": [
"Privacy-compliant analytics",
"Modular mixin architecture",
"Flexible tool prefixing",
"Comprehensive error handling",
"Production-ready database support",
],
}
# Advanced integration patterns
def create_multi_tenant_tools(
mcp: FastMCP, tenants: dict, insights: FeedbackInsights | None = None
) -> None:
"""Create feedback tools for multiple tenants with isolation.
Args:
mcp: FastMCP server instance.
tenants: Dictionary mapping tenant names to database URLs.
insights: Shared insights instance.
"""
try:
shared_insights = insights or FeedbackInsights()
for tenant_name, database_url in tenants.items():
add_feedback_tools(
mcp,
database_url=database_url,
insights=shared_insights,
prefix=tenant_name,
)
logger.info(f"Multi-tenant feedback tools created for {len(tenants)} tenants")
except Exception as e:
logger.error(f"Failed to create multi-tenant tools: {e}")
raise
def create_role_based_tools(
mcp: FastMCP,
database_url: str | None = None,
insights: FeedbackInsights | None = None,
) -> None:
"""Create role-based feedback tools with different access levels.
Args:
mcp: FastMCP server instance.
database_url: Database connection URL.
insights: Optional insights instance.
"""
try:
# Public tools - submission and listing only
add_submission_tools(mcp, database_url, insights, prefix="public")
add_retrieval_tools(mcp, database_url, insights, prefix="public")
# Admin tools - full access
add_feedback_tools(mcp, database_url, insights, prefix="admin")
logger.info("Role-based feedback tools created")
except Exception as e:
logger.error(f"Failed to create role-based tools: {e}")
raise
# Migration and upgrade helpers
def migrate_tools_to_mixins(
mcp: FastMCP,
old_tools: list,
database_url: str | None = None,
insights: FeedbackInsights | None = None,
) -> None:
"""Migrate from old tool structure to mixin-based architecture.
Args:
mcp: FastMCP server instance.
old_tools: List of old tool names to replace.
database_url: Database connection URL.
insights: Optional insights instance.
"""
try:
# Remove old tools
for tool_name in old_tools:
if hasattr(mcp, "remove_tool"):
mcp.remove_tool(tool_name)
# Add new mixin-based tools
add_feedback_tools(mcp, database_url, insights)
logger.info(f"Migrated {len(old_tools)} old tools to mixin architecture")
except Exception as e:
logger.error(f"Failed to migrate tools: {e}")
raise
# Health check and diagnostics
def check_tools_health(mcp: FastMCP) -> dict:
"""Check health of feedback tools.
Args:
mcp: FastMCP server instance.
Returns:
Health status dictionary.
"""
try:
return {
"status": "healthy",
"server_name": getattr(mcp, "name", "unknown"),
"message": "FastMCP server is healthy",
}
except Exception as e:
logger.error(f"Health check failed: {e}")
return {"status": "unhealthy", "error": str(e)}
def validate_tool_configuration(config: dict) -> dict:
"""Validate tool configuration.
Args:
config: Configuration dictionary.
Returns:
Validation result.
"""
errors = []
warnings = []
# Check database URL
if not config.get("database_url"):
warnings.append("No database URL specified, using in-memory SQLite")
elif "sqlite:///:memory:" in config.get("database_url", ""):
warnings.append("Using in-memory database - data will be lost on restart")
# Check insights configuration
insights = config.get("insights")
if insights and insights.enabled and insights.retention_days < 1:
errors.append("Insights retention days must be at least 1")
return {"valid": len(errors) == 0, "errors": errors, "warnings": warnings}