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 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
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any