mcpcat 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mcpcat/__init__.py +106 -0
- mcpcat/modules/__init__.py +38 -0
- mcpcat/modules/compatibility.py +63 -0
- mcpcat/modules/context_parameters.py +52 -0
- mcpcat/modules/internal.py +40 -0
- mcpcat/modules/logging.py +128 -0
- mcpcat/modules/overrides/fastmcp.py +31 -0
- mcpcat/modules/overrides/mcp_server.py +160 -0
- mcpcat/modules/redaction.py +42 -0
- mcpcat/modules/session.py +85 -0
- mcpcat/modules/tools.py +39 -0
- mcpcat/modules/tracing.py +101 -0
- mcpcat/modules/version_detection.py +47 -0
- mcpcat/types.py +66 -0
- mcpcat-0.1.0.dist-info/METADATA +145 -0
- mcpcat-0.1.0.dist-info/RECORD +17 -0
- mcpcat-0.1.0.dist-info/WHEEL +4 -0
mcpcat/__init__.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""MCPCat - Analytics Tool for MCP Servers."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
from mcpcat.modules.overrides.fastmcp import override_fastmcp
|
|
6
|
+
from mcpcat.modules.overrides.mcp_server import override_lowlevel_mcp_server
|
|
7
|
+
|
|
8
|
+
from .modules.compatibility import is_compatible_server, is_fastmcp_server
|
|
9
|
+
from .modules.internal import get_mcpcat_data, has_mcpcat_data, set_mcpcat_data
|
|
10
|
+
from .modules.logging import log_error, log_info
|
|
11
|
+
from .modules.session import get_unknown_or_stdio_session
|
|
12
|
+
from .modules.tools import handle_report_missing as handleReportMissing
|
|
13
|
+
from .types import MCPCatData, MCPCatOptions, UserData
|
|
14
|
+
|
|
15
|
+
__version__ = "1.0.0"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def track(server: Any, options: MCPCatOptions | None = None) -> Any:
|
|
19
|
+
"""
|
|
20
|
+
Enable analytics tracking for an MCP server.
|
|
21
|
+
|
|
22
|
+
This function modifies the server's tool handlers to add analytics tracking,
|
|
23
|
+
context parameter injection, and additional MCP tools.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
server: The MCP server instance (e.g., FastMCP)
|
|
27
|
+
options: Optional configuration options
|
|
28
|
+
|
|
29
|
+
Raises:
|
|
30
|
+
TypeError: If the server is not compatible
|
|
31
|
+
"""
|
|
32
|
+
# Use default options if not provided
|
|
33
|
+
if options is None:
|
|
34
|
+
options = MCPCatOptions()
|
|
35
|
+
|
|
36
|
+
# Validate server compatibility
|
|
37
|
+
if not is_compatible_server(server):
|
|
38
|
+
raise TypeError(
|
|
39
|
+
"Server must be a FastMCP instance or MCP Low-level Server instance"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Check if already tracked
|
|
43
|
+
if has_mcpcat_data(server):
|
|
44
|
+
log_info(
|
|
45
|
+
"MCPCat already initialized for this server",
|
|
46
|
+
{"server": server.__class__.__name__},
|
|
47
|
+
options
|
|
48
|
+
)
|
|
49
|
+
return server
|
|
50
|
+
|
|
51
|
+
# Create and store tracking data
|
|
52
|
+
data = MCPCatData(options=options)
|
|
53
|
+
set_mcpcat_data(server, data)
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
# Set up tool handlers
|
|
57
|
+
if is_fastmcp_server(server):
|
|
58
|
+
override_fastmcp(server, data)
|
|
59
|
+
else:
|
|
60
|
+
override_lowlevel_mcp_server(server, data)
|
|
61
|
+
|
|
62
|
+
# Log initialization
|
|
63
|
+
log_info(
|
|
64
|
+
"MCPCat tracking initialized",
|
|
65
|
+
{
|
|
66
|
+
"server": server.__class__.__name__,
|
|
67
|
+
"features": {
|
|
68
|
+
"context": options.enableToolCallContext,
|
|
69
|
+
"tracing": options.enableTracing,
|
|
70
|
+
"report_missing": options.enableReportMissing,
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
options
|
|
74
|
+
)
|
|
75
|
+
except Exception as e:
|
|
76
|
+
# Clean up on failure
|
|
77
|
+
if has_mcpcat_data(server):
|
|
78
|
+
# Remove from tracking
|
|
79
|
+
get_mcpcat_data(server) # This will remove the weak reference
|
|
80
|
+
|
|
81
|
+
log_error(
|
|
82
|
+
"Failed to initialize MCPCat",
|
|
83
|
+
e,
|
|
84
|
+
{"server": server.__class__.__name__},
|
|
85
|
+
options
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
raise
|
|
89
|
+
|
|
90
|
+
# Return the server (like TypeScript version)
|
|
91
|
+
return server
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _getServerTrackingData(server: Any) -> MCPCatData | None:
|
|
95
|
+
"""Get server tracking data (for testing)."""
|
|
96
|
+
return get_mcpcat_data(server)
|
|
97
|
+
|
|
98
|
+
__all__ = [
|
|
99
|
+
"track",
|
|
100
|
+
"get_unknown_or_stdio_session",
|
|
101
|
+
"_getServerTrackingData",
|
|
102
|
+
"handleReportMissing",
|
|
103
|
+
"MCPCatOptions",
|
|
104
|
+
"UserData",
|
|
105
|
+
"__version__",
|
|
106
|
+
]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""MCPCat modules."""
|
|
2
|
+
|
|
3
|
+
from .compatibility import is_compatible_server, is_fastmcp_server
|
|
4
|
+
from .context_parameters import (
|
|
5
|
+
add_context_parameter_to_schema,
|
|
6
|
+
add_context_parameter_to_tools,
|
|
7
|
+
)
|
|
8
|
+
from .internal import get_mcpcat_data, has_mcpcat_data, set_mcpcat_data
|
|
9
|
+
from .logging import log_error, log_info, log_trace, log_warning
|
|
10
|
+
from .session import capture_session_info, get_unknown_or_stdio_session
|
|
11
|
+
from .tools import handle_report_missing
|
|
12
|
+
from .tracing import record_trace
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
# Compatibility
|
|
16
|
+
"is_compatible_server",
|
|
17
|
+
"is_fastmcp_server",
|
|
18
|
+
# Context parameters
|
|
19
|
+
"add_context_parameter_to_schema",
|
|
20
|
+
"add_context_parameter_to_tools",
|
|
21
|
+
# Internal
|
|
22
|
+
"get_mcpcat_data",
|
|
23
|
+
"has_mcpcat_data",
|
|
24
|
+
"set_mcpcat_data",
|
|
25
|
+
# Logging
|
|
26
|
+
"log_error",
|
|
27
|
+
"log_info",
|
|
28
|
+
"log_trace",
|
|
29
|
+
"log_warning",
|
|
30
|
+
# Redaction
|
|
31
|
+
# Session
|
|
32
|
+
"get_unknown_or_stdio_session",
|
|
33
|
+
"capture_session_info",
|
|
34
|
+
# Tools
|
|
35
|
+
"handle_report_missing",
|
|
36
|
+
# Tracing
|
|
37
|
+
"record_trace",
|
|
38
|
+
]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Compatibility checks for MCP servers."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Protocol, runtime_checkable
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@runtime_checkable
|
|
7
|
+
class MCPServerProtocol(Protocol):
|
|
8
|
+
"""Protocol for MCP server compatibility."""
|
|
9
|
+
|
|
10
|
+
def list_tools(self) -> Any:
|
|
11
|
+
"""List available tools."""
|
|
12
|
+
...
|
|
13
|
+
|
|
14
|
+
def call_tool(self, name: str, arguments: dict) -> Any:
|
|
15
|
+
"""Call a tool by name."""
|
|
16
|
+
...
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def is_fastmcp_server(server: Any) -> bool:
|
|
20
|
+
"""Check if the server is a FastMCP instance."""
|
|
21
|
+
# Check for FastMCP class name or specific attributes
|
|
22
|
+
return hasattr(server, "_mcp_server")
|
|
23
|
+
|
|
24
|
+
def has_neccessary_attributes(server: Any) -> bool:
|
|
25
|
+
"""Check if the server has necessary attributes for compatibility."""
|
|
26
|
+
required_methods = ["list_tools", "call_tool"]
|
|
27
|
+
|
|
28
|
+
# Check for core methods that both FastMCP and Server implementations have
|
|
29
|
+
for method in required_methods:
|
|
30
|
+
if not hasattr(server, method):
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
# For FastMCP servers, verify internal MCP server exists
|
|
34
|
+
if hasattr(server, "_mcp_server"):
|
|
35
|
+
# FastMCP server - check that internal MCP server has request_context
|
|
36
|
+
# Use dir() to avoid triggering property getters that might raise exceptions
|
|
37
|
+
if "request_context" not in dir(server._mcp_server):
|
|
38
|
+
return False
|
|
39
|
+
# Check for get_context method which is FastMCP specific
|
|
40
|
+
if not hasattr(server, "get_context"):
|
|
41
|
+
return False
|
|
42
|
+
# Check for request_handlers dictionary on internal server
|
|
43
|
+
if not hasattr(server._mcp_server, "request_handlers"):
|
|
44
|
+
return False
|
|
45
|
+
if not isinstance(server._mcp_server.request_handlers, dict):
|
|
46
|
+
return False
|
|
47
|
+
else:
|
|
48
|
+
# Regular Server implementation - check for request_context directly
|
|
49
|
+
# Use dir() to avoid triggering property getters that might raise exceptions
|
|
50
|
+
if "request_context" not in dir(server):
|
|
51
|
+
return False
|
|
52
|
+
# Check for request_handlers dictionary
|
|
53
|
+
if not hasattr(server, "request_handlers"):
|
|
54
|
+
return False
|
|
55
|
+
if not isinstance(server.request_handlers, dict):
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
return True
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def is_compatible_server(server: Any) -> bool:
|
|
62
|
+
"""Check if the server is compatible with MCPCat."""
|
|
63
|
+
return has_neccessary_attributes(server)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Context parameter injection for MCP tools."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def add_context_parameter_to_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
7
|
+
"""Add context parameter to tool schemas."""
|
|
8
|
+
modified_tools = []
|
|
9
|
+
|
|
10
|
+
for tool in tools:
|
|
11
|
+
# Create a copy to avoid modifying original
|
|
12
|
+
modified_tool = tool.copy()
|
|
13
|
+
|
|
14
|
+
if "inputSchema" in modified_tool:
|
|
15
|
+
modified_tool["inputSchema"] = add_context_parameter_to_schema(
|
|
16
|
+
modified_tool["inputSchema"]
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
modified_tools.append(modified_tool)
|
|
20
|
+
|
|
21
|
+
return modified_tools
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def add_context_parameter_to_schema(schema: dict[str, Any]) -> dict[str, Any]:
|
|
25
|
+
"""Add context parameter to a JSON schema."""
|
|
26
|
+
# Create a copy to avoid modifying original
|
|
27
|
+
modified_schema = schema.copy()
|
|
28
|
+
|
|
29
|
+
# Ensure properties exists
|
|
30
|
+
if "properties" not in modified_schema:
|
|
31
|
+
modified_schema["properties"] = {}
|
|
32
|
+
else:
|
|
33
|
+
# Deep copy properties
|
|
34
|
+
modified_schema["properties"] = modified_schema["properties"].copy()
|
|
35
|
+
|
|
36
|
+
# Add context parameter
|
|
37
|
+
modified_schema["properties"]["context"] = {
|
|
38
|
+
"type": "string",
|
|
39
|
+
"description": "Describe why you are calling this tool and how it fits into your overall task"
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# Add to required fields
|
|
43
|
+
if "required" not in modified_schema:
|
|
44
|
+
modified_schema["required"] = []
|
|
45
|
+
else:
|
|
46
|
+
# Copy required list
|
|
47
|
+
modified_schema["required"] = list(modified_schema["required"])
|
|
48
|
+
|
|
49
|
+
if "context" not in modified_schema["required"]:
|
|
50
|
+
modified_schema["required"].append("context")
|
|
51
|
+
|
|
52
|
+
return modified_schema
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Internal data storage for MCPCat."""
|
|
2
|
+
|
|
3
|
+
import weakref
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ..types import MCPCatData
|
|
7
|
+
from .compatibility import is_fastmcp_server
|
|
8
|
+
|
|
9
|
+
# WeakKeyDictionary to store data associated with server instances
|
|
10
|
+
_server_data_map: weakref.WeakKeyDictionary[Any, MCPCatData] = weakref.WeakKeyDictionary()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def set_mcpcat_data(server: Any, data: MCPCatData) -> None:
|
|
14
|
+
"""Store MCPCat data for a server instance."""
|
|
15
|
+
# Always use low-level server as key
|
|
16
|
+
if is_fastmcp_server(server):
|
|
17
|
+
key = server._mcp_server
|
|
18
|
+
else:
|
|
19
|
+
key = server
|
|
20
|
+
_server_data_map[key] = data
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_mcpcat_data(server: Any) -> MCPCatData | None:
|
|
24
|
+
"""Retrieve MCPCat data for a server instance."""
|
|
25
|
+
# Always use low-level server as key
|
|
26
|
+
if is_fastmcp_server(server):
|
|
27
|
+
key = server._mcp_server
|
|
28
|
+
else:
|
|
29
|
+
key = server
|
|
30
|
+
return _server_data_map.get(key)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def has_mcpcat_data(server: Any) -> bool:
|
|
34
|
+
"""Check if a server instance has MCPCat data."""
|
|
35
|
+
# Always use low-level server as key
|
|
36
|
+
if is_fastmcp_server(server):
|
|
37
|
+
key = server._mcp_server
|
|
38
|
+
else:
|
|
39
|
+
key = server
|
|
40
|
+
return key in _server_data_map
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Logging functionality for MCPCat."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ..types import MCPCatOptions, Trace
|
|
9
|
+
|
|
10
|
+
logPath = "mcpcat.log" # Default log file path
|
|
11
|
+
|
|
12
|
+
async def log_trace(trace: Trace, options: MCPCatOptions) -> None:
|
|
13
|
+
"""Log a tool call trace."""
|
|
14
|
+
# Convert Trace to dict using Pydantic's model_dump
|
|
15
|
+
log_data = trace.model_dump()
|
|
16
|
+
# Convert datetime to ISO format string for JSON serialization
|
|
17
|
+
log_data["timestamp"] = trace.timestamp.isoformat()
|
|
18
|
+
|
|
19
|
+
# Apply redaction if configured and function exists
|
|
20
|
+
if hasattr(options, 'redactSensitiveInformation') and options.redactSensitiveInformation is not None:
|
|
21
|
+
if log_data.get("context"):
|
|
22
|
+
log_data["context"] = await redact_message(log_data["context"], options)
|
|
23
|
+
if log_data.get("arguments"):
|
|
24
|
+
log_data["arguments"] = await redact_message(log_data["arguments"], options)
|
|
25
|
+
if log_data.get("response"):
|
|
26
|
+
log_data["response"] = await redact_message(log_data["response"], options)
|
|
27
|
+
|
|
28
|
+
_write_log(log_data, options)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def log_info(message: str, data: dict[str, Any], options: MCPCatOptions) -> None:
|
|
32
|
+
"""Log an informational message."""
|
|
33
|
+
log_data = {
|
|
34
|
+
"timestamp": datetime.now().isoformat(),
|
|
35
|
+
"event": "info",
|
|
36
|
+
"message": message,
|
|
37
|
+
**data
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
_write_log(log_data, options)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def log_warning(message: str, data: dict[str, Any], options: MCPCatOptions) -> None:
|
|
44
|
+
"""Log a warning message."""
|
|
45
|
+
log_data = {
|
|
46
|
+
"timestamp": datetime.now().isoformat(),
|
|
47
|
+
"event": "warning",
|
|
48
|
+
"message": message,
|
|
49
|
+
**data
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
_write_log(log_data, options)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def log_error(message: str, error: Exception, data: dict[str, Any], options: MCPCatOptions) -> None:
|
|
56
|
+
"""Log an error message."""
|
|
57
|
+
log_data = {
|
|
58
|
+
"timestamp": datetime.now().isoformat(),
|
|
59
|
+
"event": "error",
|
|
60
|
+
"message": message,
|
|
61
|
+
"error": str(error),
|
|
62
|
+
"error_type": type(error).__name__,
|
|
63
|
+
**data
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
_write_log(log_data, options)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _write_log(log_data: dict[str, Any], options: MCPCatOptions) -> None:
|
|
70
|
+
"""Write log data to file."""
|
|
71
|
+
try:
|
|
72
|
+
# Ensure log directory exists
|
|
73
|
+
log_dir = os.path.dirname(logPath)
|
|
74
|
+
if log_dir and not os.path.exists(log_dir):
|
|
75
|
+
os.makedirs(log_dir, exist_ok=True)
|
|
76
|
+
|
|
77
|
+
# Write to log file in JSON format
|
|
78
|
+
with open(logPath, "a") as f:
|
|
79
|
+
f.write(json.dumps(log_data) + "\n")
|
|
80
|
+
except Exception:
|
|
81
|
+
# Silently fail - we don't want logging errors to break the server
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def write_to_log(message: str, options: MCPCatOptions) -> None:
|
|
86
|
+
"""Write a simple text message to log (TypeScript-compatible format)."""
|
|
87
|
+
timestamp = datetime.now().isoformat()
|
|
88
|
+
log_entry = f"[{timestamp}] {message}\n"
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
# Ensure log directory exists
|
|
92
|
+
log_dir = os.path.dirname(logPath)
|
|
93
|
+
if log_dir and not os.path.exists(log_dir):
|
|
94
|
+
os.makedirs(log_dir, exist_ok=True)
|
|
95
|
+
|
|
96
|
+
# Write to log file
|
|
97
|
+
with open(logPath, "a") as f:
|
|
98
|
+
f.write(log_entry)
|
|
99
|
+
except Exception:
|
|
100
|
+
# Silently fail - we don't want logging errors to break the server
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def redact_message(message: str, options: MCPCatOptions) -> str:
|
|
105
|
+
"""Apply redaction to a message if enabled."""
|
|
106
|
+
if not hasattr(options, 'redactSensitiveInformation'):
|
|
107
|
+
return message
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
if options.redactSensitiveInformation:
|
|
111
|
+
return await options.redactSensitiveInformation(message)
|
|
112
|
+
return message
|
|
113
|
+
except Exception as e:
|
|
114
|
+
write_to_log(f"Warning: Failed to redact message - Error: {e}", options)
|
|
115
|
+
return message
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
async def log_with_session(server, session_id: str, user_id: str | None, message: str, options: MCPCatOptions) -> None:
|
|
119
|
+
"""Log a message with session information (TypeScript-compatible format)."""
|
|
120
|
+
try:
|
|
121
|
+
user_info = f" (User: {user_id})" if user_id else ""
|
|
122
|
+
full_message = f"[Session: {session_id}{user_info}] {message}"
|
|
123
|
+
|
|
124
|
+
# Apply redaction
|
|
125
|
+
redacted_message = await redact_message(full_message, options)
|
|
126
|
+
write_to_log(redacted_message, options)
|
|
127
|
+
except Exception as e:
|
|
128
|
+
write_to_log(f"Warning: Failed to log message - {e}", options)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from typing import Any, TYPE_CHECKING
|
|
2
|
+
|
|
3
|
+
from mcp import ServerResult, Tool
|
|
4
|
+
from mcp.types import CallToolRequest, CallToolResult, ListToolsRequest, TextContent
|
|
5
|
+
|
|
6
|
+
from mcpcat.modules.overrides.mcp_server import override_lowlevel_mcp_server
|
|
7
|
+
from mcpcat.modules.tools import handle_report_missing
|
|
8
|
+
from mcpcat.modules.tracing import record_trace
|
|
9
|
+
|
|
10
|
+
from ...types import MCPCatData
|
|
11
|
+
from ..logging import log_info, log_warning
|
|
12
|
+
from ..session import capture_session_info
|
|
13
|
+
from ..version_detection import has_fastmcp_support
|
|
14
|
+
|
|
15
|
+
"""Tool management and interception for MCPCat."""
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING or has_fastmcp_support():
|
|
18
|
+
try:
|
|
19
|
+
from mcp.server import FastMCP
|
|
20
|
+
except ImportError:
|
|
21
|
+
FastMCP = None
|
|
22
|
+
else:
|
|
23
|
+
FastMCP = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def override_fastmcp(server: Any, data: MCPCatData) -> None:
|
|
27
|
+
"""Set up tool list and call handlers for FastMCP."""
|
|
28
|
+
if FastMCP is None:
|
|
29
|
+
raise ImportError("FastMCP is not available in this MCP version")
|
|
30
|
+
from mcp.types import CallToolResult, ListToolsResult
|
|
31
|
+
override_lowlevel_mcp_server(server._mcp_server, data)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from mcp import ServerResult, Tool
|
|
4
|
+
from mcp.server import Server
|
|
5
|
+
from mcp.types import CallToolRequest, CallToolResult, ListToolsRequest, TextContent
|
|
6
|
+
|
|
7
|
+
from mcpcat.modules.tools import handle_report_missing
|
|
8
|
+
from mcpcat.modules.tracing import record_trace
|
|
9
|
+
|
|
10
|
+
from ...types import MCPCatData
|
|
11
|
+
from ..logging import log_info, log_warning
|
|
12
|
+
from ..session import capture_session_info
|
|
13
|
+
|
|
14
|
+
"""Tool management and interception for MCPCat."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def override_lowlevel_mcp_server(server: Server, data: MCPCatData) -> None:
|
|
18
|
+
"""Set up tool list and call handlers for FastMCP."""
|
|
19
|
+
from mcp.types import CallToolResult, ListToolsResult
|
|
20
|
+
|
|
21
|
+
# Store original request handlers - we only need to intercept at the low-level
|
|
22
|
+
original_call_tool_handler = server.request_handlers.get(CallToolRequest)
|
|
23
|
+
original_list_tools_handler = server.request_handlers.get(ListToolsRequest)
|
|
24
|
+
|
|
25
|
+
async def wrapped_list_tools_handler(request: ListToolsRequest) -> ServerResult:
|
|
26
|
+
"""Intercept list_tools requests to add MCPCat tools and modify existing ones."""
|
|
27
|
+
# Call the original handler to get the tools
|
|
28
|
+
original_result = await original_list_tools_handler(request)
|
|
29
|
+
if not original_result or not hasattr(original_result, 'root') or not hasattr(original_result.root, 'tools'):
|
|
30
|
+
return original_result
|
|
31
|
+
tools_list = original_result.root.tools
|
|
32
|
+
|
|
33
|
+
# Add report_missing tool if enabled
|
|
34
|
+
if data.options.enableReportMissing:
|
|
35
|
+
report_missing_tool = Tool(
|
|
36
|
+
name="report_missing",
|
|
37
|
+
description="Report when a tool you need is missing from this server",
|
|
38
|
+
inputSchema={
|
|
39
|
+
"type": "object",
|
|
40
|
+
"properties": {
|
|
41
|
+
"missing_tool": {
|
|
42
|
+
"type": "string",
|
|
43
|
+
"description": "Name of the missing tool"
|
|
44
|
+
},
|
|
45
|
+
"description": {
|
|
46
|
+
"type": "string",
|
|
47
|
+
"description": "Description of what the tool should do"
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"required": ["missing_tool", "description"]
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
tools_list.append(report_missing_tool)
|
|
54
|
+
|
|
55
|
+
# Add context parameters to existing tools if enabled
|
|
56
|
+
if data.options.enableToolCallContext:
|
|
57
|
+
for tool in tools_list:
|
|
58
|
+
if tool.name != "report_missing": # Don't modify our own tool
|
|
59
|
+
if not tool.inputSchema:
|
|
60
|
+
tool.inputSchema = {
|
|
61
|
+
"type": "object",
|
|
62
|
+
"properties": {},
|
|
63
|
+
"required": []
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
# Add context property if it doesn't exist
|
|
67
|
+
if "context" not in tool.inputSchema.get("properties", {}):
|
|
68
|
+
if "properties" not in tool.inputSchema:
|
|
69
|
+
tool.inputSchema["properties"] = {}
|
|
70
|
+
|
|
71
|
+
tool.inputSchema["properties"]["context"] = {
|
|
72
|
+
"type": "string",
|
|
73
|
+
"description": "Describe why you are calling this tool and how it fits into your overall task"
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# Add context to required array if it exists
|
|
77
|
+
if isinstance(tool.inputSchema.get("required"), list):
|
|
78
|
+
if "context" not in tool.inputSchema["required"]:
|
|
79
|
+
tool.inputSchema["required"].append("context")
|
|
80
|
+
else:
|
|
81
|
+
tool.inputSchema["required"] = ["context"]
|
|
82
|
+
|
|
83
|
+
return ServerResult(ListToolsResult(tools=tools_list))
|
|
84
|
+
|
|
85
|
+
async def wrapped_call_tool_handler(request: CallToolRequest) -> ServerResult:
|
|
86
|
+
"""Intercept call_tool requests to add MCPCat tracking and handle special tools."""
|
|
87
|
+
tool_name = request.params.name
|
|
88
|
+
arguments = request.params.arguments or {}
|
|
89
|
+
|
|
90
|
+
# Handle report_missing tool directly
|
|
91
|
+
if tool_name == "report_missing":
|
|
92
|
+
return await handle_report_missing(arguments, data)
|
|
93
|
+
|
|
94
|
+
# Extract MCPCat context if enabled
|
|
95
|
+
mcpcat_user_context = None
|
|
96
|
+
if data.options.enableToolCallContext:
|
|
97
|
+
mcpcat_user_context = arguments.pop("context", None)
|
|
98
|
+
# Log warning if context is missing and tool is not report_missing
|
|
99
|
+
if mcpcat_user_context is None and tool_name != "report_missing":
|
|
100
|
+
log_warning("Missing context parameter", {"tool_name": tool_name}, data.options)
|
|
101
|
+
|
|
102
|
+
# Get session info for tracking
|
|
103
|
+
try:
|
|
104
|
+
request_context = server.request_context
|
|
105
|
+
session_id, user_id = await capture_session_info(server, arguments=arguments, request_context=request_context)
|
|
106
|
+
except:
|
|
107
|
+
request_context = None
|
|
108
|
+
session_id, user_id = None, None
|
|
109
|
+
|
|
110
|
+
# If tracing is enabled, wrap the call with timing and logging
|
|
111
|
+
if data.options.enableTracing:
|
|
112
|
+
import time
|
|
113
|
+
start_time = time.time()
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
# Call the original handler
|
|
117
|
+
result = await original_call_tool_handler(request)
|
|
118
|
+
duration = time.time() - start_time
|
|
119
|
+
|
|
120
|
+
# Record the trace using existing infrastructure
|
|
121
|
+
await record_trace(
|
|
122
|
+
server=server,
|
|
123
|
+
tool_name=tool_name,
|
|
124
|
+
arguments=arguments,
|
|
125
|
+
request_context=request_context,
|
|
126
|
+
session_id=session_id,
|
|
127
|
+
user_id=user_id,
|
|
128
|
+
tool_result=result.model_dump() if result else None,
|
|
129
|
+
duration=duration,
|
|
130
|
+
mcpcat_context=mcpcat_user_context
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
return result
|
|
134
|
+
|
|
135
|
+
except Exception as e:
|
|
136
|
+
duration = time.time() - start_time
|
|
137
|
+
|
|
138
|
+
# Record the error trace
|
|
139
|
+
await record_trace(
|
|
140
|
+
server=server,
|
|
141
|
+
tool_name=tool_name,
|
|
142
|
+
arguments=arguments,
|
|
143
|
+
request_context=request_context,
|
|
144
|
+
session_id=session_id,
|
|
145
|
+
user_id=user_id,
|
|
146
|
+
tool_result=None,
|
|
147
|
+
duration=duration,
|
|
148
|
+
mcpcat_context=mcpcat_user_context,
|
|
149
|
+
error=str(e)
|
|
150
|
+
)
|
|
151
|
+
# Re-raise the exception
|
|
152
|
+
raise
|
|
153
|
+
else:
|
|
154
|
+
# No tracing, just call the original handler
|
|
155
|
+
return await original_call_tool_handler(request)
|
|
156
|
+
|
|
157
|
+
# Replace only the low-level request handlers
|
|
158
|
+
server.request_handlers[CallToolRequest] = wrapped_call_tool_handler
|
|
159
|
+
server.request_handlers[ListToolsRequest] = wrapped_list_tools_handler
|
|
160
|
+
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from datafog import DataFog
|
|
2
|
+
from typing import Any
|
|
3
|
+
"""PII redaction for MCPCat logs."""
|
|
4
|
+
|
|
5
|
+
def create_datafog_redactor():
|
|
6
|
+
"""Create a default Datafog redactor function.
|
|
7
|
+
Returns a redactor function that uses Datafog for PII detection
|
|
8
|
+
and replaces all detected PII with '[Redacted by MCPCat]'.
|
|
9
|
+
Falls back to regex-based redaction if Datafog is not available.
|
|
10
|
+
"""
|
|
11
|
+
def datafog_redact(data: Any) -> Any:
|
|
12
|
+
"""Redact sensitive information using Datafog."""
|
|
13
|
+
if isinstance(data, dict):
|
|
14
|
+
return {k: datafog_redact(v) for k, v in data.items()}
|
|
15
|
+
elif isinstance(data, list):
|
|
16
|
+
return [datafog_redact(item) for item in data]
|
|
17
|
+
elif isinstance(data, str):
|
|
18
|
+
# Use Datafog to scan and redact with custom replacement
|
|
19
|
+
redactor = DataFog(operations=["scan", "redact"])
|
|
20
|
+
# Process the text - Datafog will replace detected PII with [REDACTED]
|
|
21
|
+
redacted_text = redactor.process_text(data)
|
|
22
|
+
# Replace Datafog's [REDACTED] with our custom message
|
|
23
|
+
return redacted_text.replace("[REDACTED]", "[Redacted by MCPCat]")
|
|
24
|
+
else:
|
|
25
|
+
return data
|
|
26
|
+
return datafog_redact
|
|
27
|
+
# Singleton redactor instance
|
|
28
|
+
_default_redactor = None
|
|
29
|
+
def get_default_redactor():
|
|
30
|
+
"""Get or create the singleton default redactor."""
|
|
31
|
+
global _default_redactor
|
|
32
|
+
if _default_redactor is None:
|
|
33
|
+
_default_redactor = create_datafog_redactor()
|
|
34
|
+
return _default_redactor
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def defaultRedactor(text: str) -> str:
|
|
38
|
+
"""Default async redactor function using Datafog."""
|
|
39
|
+
return text # For now, just pass through
|
|
40
|
+
# TODO: Implement actual redaction
|
|
41
|
+
# redactor = get_default_redactor()
|
|
42
|
+
# return redactor(text)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Session management for MCPCat."""
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from mcp.server import Server
|
|
8
|
+
|
|
9
|
+
from mcpcat.modules.internal import get_mcpcat_data
|
|
10
|
+
|
|
11
|
+
from ..types import MCPSession, UserData
|
|
12
|
+
from .logging import log_info, log_warning
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_unknown_or_stdio_session(server: Server) -> str:
|
|
16
|
+
"""Get or create an unknown/STDIO session."""
|
|
17
|
+
data = get_mcpcat_data(server)
|
|
18
|
+
now = datetime.now()
|
|
19
|
+
|
|
20
|
+
# Check if we have an existing session
|
|
21
|
+
if data.unknown_session:
|
|
22
|
+
# Check if session is still valid (30 minutes)
|
|
23
|
+
if now - data.unknown_session.last_used < timedelta(minutes=30):
|
|
24
|
+
# Update last used time
|
|
25
|
+
data.unknown_session.last_used = now
|
|
26
|
+
return data.unknown_session.id
|
|
27
|
+
|
|
28
|
+
# Create new session
|
|
29
|
+
session_id = str(uuid.uuid4())
|
|
30
|
+
data.unknown_session = MCPSession(
|
|
31
|
+
id=session_id,
|
|
32
|
+
created=now,
|
|
33
|
+
last_used=now
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
return session_id
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def capture_session_info(server: Server, arguments: dict[str, Any] | None = None, request_context: Any | None = None ) -> tuple[str | None, str | None]:
|
|
40
|
+
"""Get session and user ID from request context."""
|
|
41
|
+
session_id = None
|
|
42
|
+
user_id = 'unidentified-user-id'
|
|
43
|
+
|
|
44
|
+
# Extract session_id from request context if available
|
|
45
|
+
if request_context is not None:
|
|
46
|
+
try:
|
|
47
|
+
session = request_context.session # assumes .session is always present
|
|
48
|
+
session_id = getattr(session, "session_id", None) or getattr(session, "id", None)
|
|
49
|
+
except (ValueError, AttributeError):
|
|
50
|
+
# No valid session in context
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
# If session_id is not found, use the unknown session ID for stdio implementations
|
|
54
|
+
if not session_id:
|
|
55
|
+
session_id = get_unknown_or_stdio_session(server)
|
|
56
|
+
|
|
57
|
+
data = get_mcpcat_data(server)
|
|
58
|
+
|
|
59
|
+
found_user_for_session = data.identified_sessions.get(session_id, None)
|
|
60
|
+
if found_user_for_session:
|
|
61
|
+
return session_id, found_user_for_session
|
|
62
|
+
|
|
63
|
+
# Try custom identification for user data if no user ID is found
|
|
64
|
+
if data.options.identify:
|
|
65
|
+
try:
|
|
66
|
+
# Call identify function with (arguments, context) signature
|
|
67
|
+
user_data: UserData = await data.options.identify(arguments, request_context)
|
|
68
|
+
if user_data:
|
|
69
|
+
user_id = user_data.get("userId")
|
|
70
|
+
# Store identified session with user ID
|
|
71
|
+
if user_id:
|
|
72
|
+
data.identified_sessions[session_id] = user_id
|
|
73
|
+
log_info(f"User identified (ID: {user_id})", {
|
|
74
|
+
"userId": user_id,
|
|
75
|
+
"sessionId": session_id,
|
|
76
|
+
"userData": user_data.get("userData") if user_data else None
|
|
77
|
+
}, data.options)
|
|
78
|
+
except Exception as e:
|
|
79
|
+
# Log identification errors
|
|
80
|
+
log_warning(f"Failed to identify session: {str(e)}", {
|
|
81
|
+
"sessionId": session_id,
|
|
82
|
+
"error": str(e)
|
|
83
|
+
}, data.options)
|
|
84
|
+
|
|
85
|
+
return session_id, user_id
|
mcpcat/modules/tools.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Tool management and interception for MCPCat."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from mcp import ServerResult, Tool
|
|
6
|
+
from mcp.types import CallToolRequest, CallToolResult, ListToolsRequest, TextContent
|
|
7
|
+
|
|
8
|
+
from mcpcat.modules.tracing import record_trace
|
|
9
|
+
from mcpcat.modules.version_detection import has_fastmcp_support
|
|
10
|
+
|
|
11
|
+
from ..types import MCPCatData
|
|
12
|
+
from .logging import log_info
|
|
13
|
+
from .session import capture_session_info
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING or has_fastmcp_support():
|
|
16
|
+
try:
|
|
17
|
+
from mcp.server import FastMCP
|
|
18
|
+
except ImportError:
|
|
19
|
+
FastMCP = None
|
|
20
|
+
|
|
21
|
+
async def handle_report_missing(arguments: dict[str, Any], data: MCPCatData) -> CallToolResult:
|
|
22
|
+
"""Handle the report_missing tool."""
|
|
23
|
+
missing_tool = arguments.get("missing_tool", "")
|
|
24
|
+
description = arguments.get("description", "")
|
|
25
|
+
|
|
26
|
+
# Log the report
|
|
27
|
+
log_info("Missing tool reported", {
|
|
28
|
+
"missing_tool": missing_tool,
|
|
29
|
+
"description": description,
|
|
30
|
+
}, data.options)
|
|
31
|
+
|
|
32
|
+
return CallToolResult(
|
|
33
|
+
content=[
|
|
34
|
+
TextContent(
|
|
35
|
+
type="text",
|
|
36
|
+
text=f"Thank you for reporting that you need a '{missing_tool}' tool. This feedback helps improve the server."
|
|
37
|
+
)
|
|
38
|
+
]
|
|
39
|
+
)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Tool call tracing for MCPCat."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from mcp import Tool
|
|
9
|
+
|
|
10
|
+
from ..types import Trace
|
|
11
|
+
from .logging import log_trace
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def record_trace(
|
|
15
|
+
server: Any,
|
|
16
|
+
tool_name: str,
|
|
17
|
+
arguments: dict[str, Any],
|
|
18
|
+
request_context: Any,
|
|
19
|
+
session_id: str | None,
|
|
20
|
+
user_id: str | None,
|
|
21
|
+
tool_result: Any,
|
|
22
|
+
duration: float,
|
|
23
|
+
mcpcat_context: str | None = None,
|
|
24
|
+
error: str | None = None
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Record trace information for a tool call."""
|
|
27
|
+
from .internal import get_mcpcat_data
|
|
28
|
+
# Check if call result is an error using the isError property or passed error
|
|
29
|
+
is_error = False
|
|
30
|
+
response = ""
|
|
31
|
+
# handle unexpected exception raised
|
|
32
|
+
if error:
|
|
33
|
+
response = error
|
|
34
|
+
is_error = True
|
|
35
|
+
# handle application error in tool result
|
|
36
|
+
elif 'isError' in tool_result:
|
|
37
|
+
is_error = tool_result['isError']
|
|
38
|
+
|
|
39
|
+
# handle tool content
|
|
40
|
+
if tool_result and 'content' in tool_result:
|
|
41
|
+
# if tool_result is a dict, use its content
|
|
42
|
+
if isinstance(tool_result['content'], dict) or isinstance(tool_result['content'], Sequence):
|
|
43
|
+
response = json.dumps(tool_result['content'], separators=(',', ':'))
|
|
44
|
+
else:
|
|
45
|
+
response = str(tool_result['content'])
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Get MCPCat data and options
|
|
49
|
+
data = get_mcpcat_data(server)
|
|
50
|
+
|
|
51
|
+
# Store the trace
|
|
52
|
+
if data:
|
|
53
|
+
# Debug: verify all values are strings
|
|
54
|
+
context_value = mcpcat_context or ""
|
|
55
|
+
arguments_value = json.dumps(arguments, separators=(',', ':'))
|
|
56
|
+
response_value = response or ""
|
|
57
|
+
|
|
58
|
+
trace = Trace(
|
|
59
|
+
timestamp=datetime.now(),
|
|
60
|
+
session_id=session_id or "unknown",
|
|
61
|
+
user_id=user_id or "unknown",
|
|
62
|
+
event_type="tool_call",
|
|
63
|
+
tool_name=tool_name,
|
|
64
|
+
is_error=is_error,
|
|
65
|
+
duration=duration,
|
|
66
|
+
context=context_value,
|
|
67
|
+
arguments=arguments_value,
|
|
68
|
+
response=response_value
|
|
69
|
+
)
|
|
70
|
+
if data.options.enableTracing:
|
|
71
|
+
data.traces.append(trace)
|
|
72
|
+
await log_trace(trace, data.options)
|
|
73
|
+
|
|
74
|
+
def add_user_context_to_existing_tools(toolList: list[Tool]) -> list[Tool]:
|
|
75
|
+
"""Add MCPCat's user intention context to every existing tool."""
|
|
76
|
+
for tool in toolList:
|
|
77
|
+
if not tool.inputSchema:
|
|
78
|
+
tool.inputSchema = {
|
|
79
|
+
"type": "object",
|
|
80
|
+
"properties": {},
|
|
81
|
+
"required": []
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
# Add context property if it doesn't exist
|
|
85
|
+
if "context" not in tool.inputSchema.get("properties", {}):
|
|
86
|
+
if "properties" not in tool.inputSchema:
|
|
87
|
+
tool.inputSchema["properties"] = {}
|
|
88
|
+
|
|
89
|
+
tool.inputSchema["properties"]["context"] = {
|
|
90
|
+
"type": "string",
|
|
91
|
+
"description": "Describe why you are calling this tool and how it fits into your overall task"
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
# Add context to required array if it exists
|
|
95
|
+
if isinstance(tool.inputSchema.get("required"), list):
|
|
96
|
+
if "context" not in tool.inputSchema["required"]:
|
|
97
|
+
tool.inputSchema["required"].append("context")
|
|
98
|
+
else:
|
|
99
|
+
tool.inputSchema["required"] = ["context"]
|
|
100
|
+
|
|
101
|
+
return toolList
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""MCP version detection utilities."""
|
|
2
|
+
|
|
3
|
+
import importlib.metadata
|
|
4
|
+
from typing import Tuple, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_mcp_version() -> Optional[str]:
|
|
8
|
+
"""Get the installed MCP version."""
|
|
9
|
+
try:
|
|
10
|
+
return importlib.metadata.version("mcp")
|
|
11
|
+
except importlib.metadata.PackageNotFoundError:
|
|
12
|
+
return None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_version(version_str: str) -> Tuple[int, int, int]:
|
|
16
|
+
"""Parse version string to tuple of integers."""
|
|
17
|
+
parts = version_str.split(".")
|
|
18
|
+
major = int(parts[0]) if len(parts) > 0 else 0
|
|
19
|
+
minor = int(parts[1]) if len(parts) > 1 else 0
|
|
20
|
+
patch = int(parts[2]) if len(parts) > 2 else 0
|
|
21
|
+
return (major, minor, patch)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def has_fastmcp_support() -> bool:
|
|
25
|
+
"""Check if the current MCP version supports FastMCP."""
|
|
26
|
+
version = get_mcp_version()
|
|
27
|
+
if not version:
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
major, minor, _ = parse_version(version)
|
|
31
|
+
|
|
32
|
+
# FastMCP was introduced after version 1.1
|
|
33
|
+
if major < 1:
|
|
34
|
+
return False
|
|
35
|
+
if major == 1 and minor <= 1:
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
return True
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def can_import_fastmcp() -> bool:
|
|
42
|
+
"""Check if FastMCP can be imported."""
|
|
43
|
+
try:
|
|
44
|
+
from mcp.server import FastMCP
|
|
45
|
+
return True
|
|
46
|
+
except ImportError:
|
|
47
|
+
return False
|
mcpcat/types.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Type definitions for MCPCat."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Awaitable, Callable
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any, Optional, TypedDict
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
# Type alias for identify function
|
|
11
|
+
IdentifyFunction = Callable[[dict[str, Any], Any], Optional["UserData"]]
|
|
12
|
+
# Type alias for redaction function
|
|
13
|
+
RedactionFunction = Callable[[str], str | Awaitable[str]]
|
|
14
|
+
|
|
15
|
+
# Import default redactor
|
|
16
|
+
from .modules.redaction import defaultRedactor
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class UserData(TypedDict, total=False):
|
|
20
|
+
"""User identification data."""
|
|
21
|
+
userId: str
|
|
22
|
+
userData: dict[str, str] | None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class MCPCatOptions:
|
|
27
|
+
"""Configuration options for MCPCat."""
|
|
28
|
+
enableToolCallContext: bool = True
|
|
29
|
+
enableTracing: bool = True
|
|
30
|
+
enableReportMissing: bool = True
|
|
31
|
+
identify: IdentifyFunction | None = None
|
|
32
|
+
redactSensitiveInformation: RedactionFunction | None = defaultRedactor
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class MCPSession:
|
|
37
|
+
"""Session information."""
|
|
38
|
+
id: str
|
|
39
|
+
created: datetime
|
|
40
|
+
last_used: datetime
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Trace(BaseModel):
|
|
44
|
+
"""Pydantic model for structured trace data."""
|
|
45
|
+
# Structured fields
|
|
46
|
+
timestamp: datetime
|
|
47
|
+
session_id: str
|
|
48
|
+
user_id: str
|
|
49
|
+
event_type: str
|
|
50
|
+
tool_name: str
|
|
51
|
+
is_error: bool
|
|
52
|
+
duration: float
|
|
53
|
+
context: str
|
|
54
|
+
|
|
55
|
+
# Unstructured fields
|
|
56
|
+
arguments: str = ""
|
|
57
|
+
response: str = ""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class MCPCatData:
|
|
62
|
+
"""Internal data structure for tracking."""
|
|
63
|
+
options: MCPCatOptions
|
|
64
|
+
traces: list[Trace] = field(default_factory=list)
|
|
65
|
+
identified_sessions: dict[str, str] = field(default_factory=dict)
|
|
66
|
+
unknown_session: MCPSession | None = None
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcpcat
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Analytics Tool for MCP Servers - provides insights into MCP tool usage patterns
|
|
5
|
+
Author-email: Your Name <your.email@example.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Requires-Dist: datafog>=4.1.1
|
|
16
|
+
Requires-Dist: mcp>=1.0.0
|
|
17
|
+
Requires-Dist: pydantic>=2.0.0
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: mypy>=1.0.0; extra == 'dev'
|
|
20
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
|
|
21
|
+
Requires-Dist: pytest>=7.0.0; extra == 'dev'
|
|
22
|
+
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# MCPCat Python SDK
|
|
26
|
+
|
|
27
|
+
Analytics tool for MCP (Model Context Protocol) servers that provides insights into tool usage patterns.
|
|
28
|
+
|
|
29
|
+
## Features
|
|
30
|
+
|
|
31
|
+
- **Tool Usage Analytics**: Tracks which tools are called and how frequently
|
|
32
|
+
- **Context Injection**: Adds context parameters to tools to understand user intent
|
|
33
|
+
- **Session Tracking**: Identifies and tracks user sessions
|
|
34
|
+
- **Report Missing Tools**: Allows clients to report when needed tools are missing
|
|
35
|
+
- **PII Redaction**: Automatically redacts sensitive information from logs
|
|
36
|
+
- **Non-invasive Integration**: Simple one-line integration with existing MCP servers
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install mcpcat
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Quick Start
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from fastmcp import FastMCP
|
|
48
|
+
from mcpcat import track
|
|
49
|
+
|
|
50
|
+
# Create your MCP server
|
|
51
|
+
mcp = FastMCP("my-server")
|
|
52
|
+
|
|
53
|
+
# Add your tools
|
|
54
|
+
@mcp.tool()
|
|
55
|
+
def my_tool(arg: str) -> str:
|
|
56
|
+
return f"Result: {arg}"
|
|
57
|
+
|
|
58
|
+
# Enable MCPCat tracking
|
|
59
|
+
track(mcp)
|
|
60
|
+
|
|
61
|
+
# Run the server
|
|
62
|
+
mcp.run()
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Configuration
|
|
66
|
+
|
|
67
|
+
MCPCat can be configured with various options:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from mcpcat import track, MCPCatOptions
|
|
71
|
+
|
|
72
|
+
options = MCPCatOptions(
|
|
73
|
+
enableToolCallContext=True, # Add context parameters to tools
|
|
74
|
+
enableTracing=True, # Trace tool calls
|
|
75
|
+
enableReportMissing=True, # Add report_missing tool
|
|
76
|
+
identify=my_identify_func # Custom session identification
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
track(mcp, options)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## How It Works
|
|
83
|
+
|
|
84
|
+
1. MCPCat intercepts the MCP server's tool listing and calling mechanisms
|
|
85
|
+
2. It injects a `context` parameter into each tool's schema
|
|
86
|
+
3. When tools are called, it captures analytics data including timing, arguments, and results
|
|
87
|
+
4. The `report_missing` tool allows LLMs to report when they need functionality that isn't available
|
|
88
|
+
|
|
89
|
+
## Custom Session Identification
|
|
90
|
+
|
|
91
|
+
You can provide a custom function to identify users:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
def identify_user(request_context):
|
|
95
|
+
# Your logic to identify the user
|
|
96
|
+
return {
|
|
97
|
+
"sessionId": "session-123",
|
|
98
|
+
"userId": "user-456"
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
options = MCPCatOptions(identify=identify_user)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Log Format
|
|
105
|
+
|
|
106
|
+
MCPCat logs are written in JSON format with the following structure:
|
|
107
|
+
|
|
108
|
+
```json
|
|
109
|
+
{
|
|
110
|
+
"timestamp": "2024-01-20T10:30:00Z",
|
|
111
|
+
"event": "tool_call",
|
|
112
|
+
"tool_name": "my_tool",
|
|
113
|
+
"session_id": "session-123",
|
|
114
|
+
"user_id": "user-456",
|
|
115
|
+
"duration": 0.123,
|
|
116
|
+
"result": "success",
|
|
117
|
+
"context_provided": true
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Development
|
|
122
|
+
|
|
123
|
+
To set up for development:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
# Clone the repository
|
|
127
|
+
git clone https://github.com/yourusername/mcpcat-python-sdk.git
|
|
128
|
+
cd mcpcat-python-sdk
|
|
129
|
+
|
|
130
|
+
# Install dependencies
|
|
131
|
+
pip install -e ".[dev]"
|
|
132
|
+
|
|
133
|
+
# Run tests
|
|
134
|
+
pytest
|
|
135
|
+
|
|
136
|
+
# Run type checking
|
|
137
|
+
mypy src
|
|
138
|
+
|
|
139
|
+
# Run linting
|
|
140
|
+
ruff check src
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## License
|
|
144
|
+
|
|
145
|
+
MIT
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
mcpcat/__init__.py,sha256=4wfzE16AWIOR0SyA_4cBt_93LcJF6pkHWV4E83FBgjo,3151
|
|
2
|
+
mcpcat/types.py,sha256=rfclJNO3snn2RB31lSqmXt5Hy7vEPKeRtKqo5haze1o,1671
|
|
3
|
+
mcpcat/modules/__init__.py,sha256=xUtsS9mcbN55RsGs6U6jcQAHbDCo6eNgwy8dPBFm13E,1009
|
|
4
|
+
mcpcat/modules/compatibility.py,sha256=QbJIS3u7Mcf6JZ6JbOV34za-LxFD8LDvpdR3s4M-aE0,2322
|
|
5
|
+
mcpcat/modules/context_parameters.py,sha256=1jrVP3LfqlU7ePOtQE96RsPkdsEkGlU5fZS4DhtIUaE,1626
|
|
6
|
+
mcpcat/modules/internal.py,sha256=xbA643LUGQjnLDHRXnb3eRf5rC2vh-5DCRxL3vIeZqI,1159
|
|
7
|
+
mcpcat/modules/logging.py,sha256=sKCgOeDUnPUGnfDoLgsGkrptp5D4XTwxoIuGZJf2W_M,4317
|
|
8
|
+
mcpcat/modules/redaction.py,sha256=VTP2sUEXY3nEtWrL7nSJys5wt905NFvgt5d3Sb7jRTk,1735
|
|
9
|
+
mcpcat/modules/session.py,sha256=vWNfNAYBg4r-8xohiikM5VUnKpy2dphTZaQJT9rsqtk,3062
|
|
10
|
+
mcpcat/modules/tools.py,sha256=VNwj7dbgEUgKBnxZ2AqJggAONmpOOWqdCdqz4vMoMHo,1225
|
|
11
|
+
mcpcat/modules/tracing.py,sha256=n2R7C-HsCWc97rYde0_e9LaGaG8FDltRYK5-KP8P7A0,3321
|
|
12
|
+
mcpcat/modules/version_detection.py,sha256=IAZJzwC7XR76sq602kv6MoEGU8lv0GvOlW-y2AyXMCo,1231
|
|
13
|
+
mcpcat/modules/overrides/fastmcp.py,sha256=Ghr6dG97apbmS91Zkx14VDKCl2c1fISQ44AeO4Yeh0Q,1064
|
|
14
|
+
mcpcat/modules/overrides/mcp_server.py,sha256=6y51YUho1qGb3mLpYXcMEDryEKqgL_4Agl1bfJ7vOaQ,6892
|
|
15
|
+
mcpcat-0.1.0.dist-info/METADATA,sha256=qdJ_a-z3PRJplfs0Dcd5bHDMepzbFyygUEdMHRPvTd8,3545
|
|
16
|
+
mcpcat-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
17
|
+
mcpcat-0.1.0.dist-info/RECORD,,
|