Skip to content

Commit 812f047

Browse files
committed
feat: add dynamic tool tracking for FastMCP servers
Implement a hybrid tracking approach that combines traditional overrides for low-level MCP servers with monkey-patching for FastMCP servers to ensure complete tool coverage regardless of registration timing. Key changes: - Add monkey_patch.py module for intercepting FastMCP tool operations - Track tool registration timeline with ToolRegistration metadata - Support late-registered tools through dynamic interception - Separate tracking logic for FastMCP vs low-level servers - Add tool registry to track registration state and wrapped status - Improve null safety in session/context handling for stateless mode - Extract client info from HTTP headers as fallback - Add helper methods for tool discovery and tracking state This ensures MCPCat can track all tools even when: - Tools are registered after MCPCat initialization - Running in stateless HTTP mode without session context - Using FastMCP's dynamic tool registration patterns
1 parent 5218754 commit 812f047

File tree

12 files changed

+1455
-43
lines changed

12 files changed

+1455
-43
lines changed

.claude/settings.local.json

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,30 @@
1010
"Bash(/Users/naseemalnaji/.nvm/versions/node/v23.5.0/lib/node_modules/@anthropic-ai/claude-code/vendor/ripgrep/arm64-darwin/rg \"CallToolResult\" --type py -A 3 -B 3)",
1111
"Bash(/Users/naseemalnaji/.nvm/versions/node/v23.5.0/lib/node_modules/@anthropic-ai/claude-code/vendor/ripgrep/arm64-darwin/rg \"from mcp.types import.*CallToolResult\" --type py)",
1212
"Bash(pip show:*)",
13-
"WebFetch(domain:mcpcat.io)"
13+
"WebFetch(domain:mcpcat.io)",
14+
"mcp__serena__list_memories",
15+
"mcp__serena__activate_project",
16+
"mcp__serena__check_onboarding_performed",
17+
"mcp__serena__onboarding",
18+
"mcp__serena__list_dir",
19+
"mcp__serena__get_symbols_overview",
20+
"mcp__serena__find_symbol",
21+
"mcp__serena__write_memory",
22+
"mcp__serena__think_about_collected_information",
23+
"mcp__sequential-thinking__sequentialthinking",
24+
"mcp__serena__replace_symbol_body",
25+
"mcp__serena__replace_regex",
26+
"mcp__serena__search_for_pattern",
27+
"Bash(rm:*)",
28+
"Bash(cat:*)",
29+
"mcp__serena__insert_after_symbol",
30+
"mcp__fetch__fetch_markdown",
31+
"WebFetch(domain:spec.modelcontextprotocol.io)",
32+
"WebFetch(domain:modelcontextprotocol.io)"
1433
],
15-
"deny": []
34+
"deny": [],
35+
"additionalDirectories": [
36+
"/Users/naseemalnaji/Projects/mcpcat/model-context-protocol-sdks/"
37+
]
1638
}
1739
}

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,3 +147,4 @@ cython_debug/
147147
uv.lock
148148
CLAUDE.md
149149
.claude/
150+
.serena/

src/mcpcat/__init__.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ def track(server: Any, project_id: str, options: MCPCatOptions | None = None) ->
3333
)
3434

3535
lowlevel_server = server
36-
if is_fastmcp_server(server):
36+
is_fastmcp = is_fastmcp_server(server)
37+
if is_fastmcp:
3738
lowlevel_server = server._mcp_server
3839

3940
# Create and store tracking data
@@ -50,10 +51,31 @@ def track(server: Any, project_id: str, options: MCPCatOptions | None = None) ->
5051
set_server_tracking_data(lowlevel_server, data)
5152

5253
try:
53-
override_lowlevel_mcp_server(lowlevel_server, data)
54-
write_to_log(f"MCPCat initialized for sessions {session_id} on project {project_id}")
54+
# Always initialize dynamic tracking for complete tool coverage
55+
from mcpcat.modules.overrides.monkey_patch import apply_monkey_patches
56+
57+
# Initialize the dynamic tracking system by setting the flag
58+
if not data.tracker_initialized:
59+
data.tracker_initialized = True
60+
from mcpcat.modules.logging import write_to_log
61+
write_to_log(f"Dynamic tracking initialized for server {id(lowlevel_server)}")
62+
63+
# Apply appropriate tracking method based on server type
64+
if is_fastmcp:
65+
# For FastMCP servers, use monkey-patching for tool tracking
66+
apply_monkey_patches(server, data)
67+
# Only apply minimal overrides for non-tool events (like initialize, list_tools display)
68+
from mcpcat.modules.overrides.mcp_server import override_lowlevel_mcp_server_minimal
69+
override_lowlevel_mcp_server_minimal(lowlevel_server, data)
70+
else:
71+
# For low-level servers, use the traditional overrides (no monkey patching needed)
72+
override_lowlevel_mcp_server(lowlevel_server, data)
73+
74+
write_to_log(f"MCPCat initialized with dynamic tracking for session {session_id} on project {project_id}")
75+
5576
except Exception as e:
5677
write_to_log(f"Error initializing MCPCat: {e}")
78+
5779
return server
5880

5981
__all__ = [

src/mcpcat/modules/compatibility.py

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,37 @@ def call_tool(self, name: str, arguments: dict) -> Any:
2121
def is_fastmcp_server(server: Any) -> bool:
2222
"""Check if the server is a FastMCP instance."""
2323
# Check for FastMCP class name or specific attributes
24-
return hasattr(server, "_mcp_server")
24+
# A FastMCP server should have both _mcp_server and _tool_manager
25+
return hasattr(server, "_mcp_server") and hasattr(server, "_tool_manager")
26+
27+
def has_required_fastmcp_attributes(server: Any) -> bool:
28+
"""Check if a FastMCP server has all required attributes for monkey patching.
29+
30+
This validates that the server has all the attributes that monkey_patch.py expects.
31+
"""
32+
# Check for _tool_manager and its required methods
33+
if not hasattr(server, "_tool_manager"):
34+
return False
35+
36+
tool_manager = server._tool_manager
37+
required_tool_manager_methods = ["add_tool", "call_tool", "list_tools"]
38+
for method in required_tool_manager_methods:
39+
if not hasattr(tool_manager, method) or not callable(getattr(tool_manager, method)):
40+
return False
41+
42+
# Check for _tools dict on tool_manager (used for tracking existing tools)
43+
if not hasattr(tool_manager, "_tools") or not isinstance(tool_manager._tools, dict):
44+
return False
45+
46+
# Check for add_tool method on the server itself (used for adding get_more_tools)
47+
if not hasattr(server, "add_tool") or not callable(server.add_tool):
48+
return False
49+
50+
# Check for _mcp_server (used for event tracking and session management)
51+
if not hasattr(server, "_mcp_server"):
52+
return False
53+
54+
return True
2555

2656
def has_neccessary_attributes(server: Any) -> bool:
2757
"""Check if the server has necessary attributes for compatibility."""
@@ -32,9 +62,13 @@ def has_neccessary_attributes(server: Any) -> bool:
3262
if not hasattr(server, method):
3363
return False
3464

35-
# For FastMCP servers, verify internal MCP server exists
36-
if hasattr(server, "_mcp_server"):
37-
# FastMCP server - check that internal MCP server has request_context
65+
# For FastMCP servers, verify all required attributes for monkey patching
66+
if is_fastmcp_server(server):
67+
# Use the comprehensive FastMCP validation
68+
if not has_required_fastmcp_attributes(server):
69+
return False
70+
71+
# Additional checks for request handling
3872
# Use dir() to avoid triggering property getters that might raise exceptions
3973
if "request_context" not in dir(server._mcp_server):
4074
return False

src/mcpcat/modules/identify.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,24 @@ def identify_session(server, request: any, context: any) -> None:
1616
"""
1717
Identify the user based on the request and context.
1818
19-
This function should be implemented by the user to provide custom identification logic.
19+
Calls the user-defined identify function with the full request and context objects.
20+
The context may contain transport-specific information (e.g., HTTP headers for SSE/WebSocket transports).
2021
22+
:param server: The MCP server instance.
2123
:param request: The request data containing user information.
22-
:param context: The context in which the request is made.
24+
:param context: The full context object which may include transport-specific data.
2325
:return: An instance of UserIdentity or None if identification fails.
2426
"""
2527
data = get_server_tracking_data(server)
2628

2729
if not data or not data.options or not data.options.identify:
2830
return
2931

32+
# Handle None context (e.g., in stateless HTTP mode outside handlers)
33+
if context is None:
34+
write_to_log("Context is None, skipping user identification")
35+
return
36+
3037
if data.identified_sessions.get(data.session_id):
3138
write_to_log(f"User is already identified: {data.identified_sessions[data.session_id].user_id}")
3239
return

src/mcpcat/modules/internal.py

Lines changed: 129 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,147 @@
11
"""Internal data storage for MCPCat."""
22

33
import weakref
4-
from typing import Any
4+
from datetime import datetime, timezone
5+
from typing import Any, Dict, List, Optional
56

6-
from ..types import MCPCatData
7+
from ..types import MCPCatData, ToolRegistration
78
from .compatibility import is_fastmcp_server
9+
from .logging import write_to_log
810

911
# WeakKeyDictionary to store data associated with server instances
1012
_server_data_map: weakref.WeakKeyDictionary[Any, MCPCatData] = weakref.WeakKeyDictionary()
1113

14+
# Global storage for original unpatched methods (keyed by tool_manager or handler id)
15+
# This is global because tool managers might be shared between servers
16+
_original_methods: Dict[str, Any] = {}
17+
18+
19+
def _get_server_key(server: Any) -> Any:
20+
"""Get the canonical key for a server (handles FastMCP vs low-level)."""
21+
if is_fastmcp_server(server):
22+
return server._mcp_server
23+
return server
24+
1225

1326
def set_server_tracking_data(server: Any, data: MCPCatData) -> None:
1427
"""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
28+
key = _get_server_key(server)
2029
_server_data_map[key] = data
2130

2231

2332
def get_server_tracking_data(server: Any) -> MCPCatData | None:
2433
"""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
34+
key = _get_server_key(server)
3035
return _server_data_map.get(key, None)
36+
37+
38+
def reset_server_tracking_data(server: Any) -> None:
39+
"""Reset tracking data for a specific server (mainly for testing)."""
40+
key = _get_server_key(server)
41+
if key in _server_data_map:
42+
del _server_data_map[key]
43+
write_to_log(f"Reset tracking data for server {id(key)}")
44+
45+
46+
def reset_all_tracking_data() -> None:
47+
"""Reset all server tracking data (mainly for testing)."""
48+
_server_data_map.clear()
49+
_original_methods.clear()
50+
write_to_log("Reset all server tracking data")
51+
52+
53+
# Dynamic tracking helper methods
54+
def register_tool(server: Any, name: str) -> None:
55+
"""Register a tool in the server's tracking system."""
56+
data = get_server_tracking_data(server)
57+
if data and name not in data.tool_registry:
58+
data.tool_registry[name] = ToolRegistration(
59+
name=name,
60+
registered_at=datetime.now(timezone.utc)
61+
)
62+
write_to_log(f"Registered tool '{name}'")
63+
64+
65+
def mark_tool_tracked(server: Any, name: str) -> None:
66+
"""Mark a tool as being tracked by MCPCat for this server."""
67+
data = get_server_tracking_data(server)
68+
if data and name in data.tool_registry:
69+
data.tool_registry[name].tracked = True
70+
data.tool_registry[name].wrapped = True
71+
data.wrapped_tools.add(name)
72+
73+
74+
def is_tool_tracked(server: Any, name: str) -> bool:
75+
"""Check if a tool is already being tracked for this server."""
76+
data = get_server_tracking_data(server)
77+
return data and name in data.wrapped_tools
78+
79+
80+
def get_untracked_tools(server: Any) -> List[str]:
81+
"""Get list of tools that aren't tracked yet for this server."""
82+
data = get_server_tracking_data(server)
83+
if not data:
84+
return []
85+
return [
86+
name for name, reg in data.tool_registry.items()
87+
if not reg.tracked
88+
]
89+
90+
91+
def discover_new_tools(server: Any, tools: List[Any]) -> List[str]:
92+
"""Discover tools that weren't previously known for this server."""
93+
data = get_server_tracking_data(server)
94+
if not data:
95+
return []
96+
97+
new_tools = []
98+
for tool in tools:
99+
if tool.name not in data.tool_registry:
100+
register_tool(server, tool.name)
101+
new_tools.append(tool.name)
102+
return new_tools
103+
104+
105+
# Original methods storage (global, not per-server)
106+
def store_original_method(key: str, method: Any) -> None:
107+
"""Store an original unpatched method."""
108+
if key not in _original_methods:
109+
_original_methods[key] = method
110+
111+
112+
def get_original_method(key: str) -> Optional[Any]:
113+
"""Get an original unpatched method."""
114+
return _original_methods.get(key)
115+
116+
117+
def get_original_methods() -> Dict[str, Any]:
118+
"""Get the global original methods storage."""
119+
return _original_methods
120+
121+
122+
def get_tool_timeline(server: Any) -> List[Dict[str, Any]]:
123+
"""Get a timeline of tool registrations for debugging.
124+
125+
Args:
126+
server: MCP server instance
127+
128+
Returns:
129+
List of tool registration events sorted by time
130+
"""
131+
data = get_server_tracking_data(server)
132+
if not data:
133+
return []
134+
timeline = []
135+
136+
for name, reg in data.tool_registry.items():
137+
timeline.append({
138+
"name": name,
139+
"registered_at": reg.registered_at.isoformat(),
140+
"tracked": reg.tracked,
141+
"wrapped": reg.wrapped
142+
})
143+
144+
# Sort by registration time
145+
timeline.sort(key=lambda x: x["registered_at"])
146+
147+
return timeline

0 commit comments

Comments
 (0)