diff --git a/pyproject.toml b/pyproject.toml index 23bcbd7..5368355 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcpcat" -version = "0.1.14" +version = "0.1.15b1" description = "Analytics Tool for MCP Servers - provides insights into MCP tool usage patterns" authors = [ { name = "MCPCat", email = "support@mcpcat.io" }, @@ -19,7 +19,7 @@ classifiers = [ ] dependencies = [ "mcp>=1.2.0", - "mcpcat-api==0.1.4", + "mcpcat-api==0.1.9", "pydantic>=2.0.0,<2.12", "requests>=2.31.0", ] diff --git a/src/mcpcat/__init__.py b/src/mcpcat/__init__.py index aaa440c..25815e8 100644 --- a/src/mcpcat/__init__.py +++ b/src/mcpcat/__init__.py @@ -1,9 +1,13 @@ """MCPCat - Analytics Tool for MCP Servers.""" import os +import warnings from datetime import datetime, timezone +from importlib.metadata import version from typing import Any +__version__ = version("mcpcat") + from mcpcat.modules.overrides.mcp_server import override_lowlevel_mcp_server from mcpcat.modules.session import get_session_info, new_session_id @@ -25,6 +29,28 @@ ) +def _detect_stateless(server) -> bool: + """Auto-detect stateless mode from FastMCP server settings. + + Best-effort: community FastMCP v3 deprecated per-instance .settings + in favor of global fastmcp.settings, but the global isn't per-server. + The deprecated shim is the only per-instance API available. + MCPCatOptions(stateless=True) is the recommended explicit path. + """ + try: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + result = server.settings.stateless_http + if result: + write_to_log( + "Auto-detected stateless HTTP mode from your FastMCP server's .settings. " + "If this is incorrect, please pass stateless=False to MCPCatOptions and file a bug report." + ) + return result + except (AttributeError, RuntimeError): + return False + + def track( server: Any, project_id: str | None = None, options: MCPCatOptions | None = None ) -> Any: @@ -89,6 +115,7 @@ def track( session_info=session_info, identified_sessions={}, options=options, + is_stateless=options.stateless if options.stateless is not None else _detect_stateless(server), ) set_server_tracking_data(lowlevel_server, data) diff --git a/src/mcpcat/modules/identify.py b/src/mcpcat/modules/identify.py index 718567f..df62843 100644 --- a/src/mcpcat/modules/identify.py +++ b/src/mcpcat/modules/identify.py @@ -1,14 +1,12 @@ from datetime import datetime, timezone -from typing import Optional from mcpcat.modules import event_queue -from mcpcat.modules.event_queue import publish_event from mcpcat.modules.internal import get_server_tracking_data, set_server_tracking_data from mcpcat.modules.logging import write_to_log from mcpcat.types import EventType, UnredactedEvent, UserIdentity -def identify_session(server, request: any, context: any) -> None: +def identify_session(server, request: any, context: any) -> UserIdentity | None: """ Identify the user based on the request and context. @@ -25,7 +23,21 @@ def identify_session(server, request: any, context: any) -> None: if not data or not data.options or not data.options.identify: return - # Handle None context (e.g., in stateless HTTP mode outside handlers) + # Stateless mode: run identify on every request, return identity directly + if data.is_stateless: + try: + identify_result = data.options.identify(request, context) + if not identify_result or not isinstance(identify_result, UserIdentity): + write_to_log( + f"User identification function did not return a valid UserIdentity instance. Received: {identify_result}" + ) + return + return identify_result + except Exception as e: + write_to_log(f"Error occurred during user identification: {e}") + return + + # Stateful mode: existing behavior unchanged if context is None: write_to_log("Context is None, skipping user identification") return diff --git a/src/mcpcat/modules/overrides/community/monkey_patch.py b/src/mcpcat/modules/overrides/community/monkey_patch.py index 2a37075..2dc51fb 100644 --- a/src/mcpcat/modules/overrides/community/monkey_patch.py +++ b/src/mcpcat/modules/overrides/community/monkey_patch.py @@ -99,8 +99,9 @@ async def wrapped_call_tool_handler(request: CallToolRequest) -> ServerResult: # Handle session identification try: get_client_info_from_request_context(lowlevel_server, request_context) - identify_session(lowlevel_server, request, request_context) + identity = identify_session(lowlevel_server, request, request_context) except Exception as e: + identity = None write_to_log(f"Non-critical error in session handling: {e}") # Extract user intent from context parameter @@ -120,6 +121,9 @@ async def wrapped_call_tool_handler(request: CallToolRequest) -> ServerResult: event_type=EventType.MCP_TOOLS_CALL.value, resource_name=tool_name, user_intent=user_intent, + identify_actor_given_id=identity.user_id if identity else None, + identify_actor_name=identity.user_name if identity else None, + identify_data=identity.user_data if identity else None, ) try: diff --git a/src/mcpcat/modules/overrides/community_v3/middleware.py b/src/mcpcat/modules/overrides/community_v3/middleware.py index d3fd179..03d36db 100644 --- a/src/mcpcat/modules/overrides/community_v3/middleware.py +++ b/src/mcpcat/modules/overrides/community_v3/middleware.py @@ -107,8 +107,9 @@ async def on_initialize( request_context = self._get_request_context(context) try: get_client_info_from_request_context(self.server, request_context) - identify_session(self.server, context.message, request_context) + identity = identify_session(self.server, context.message, request_context) except Exception as e: + identity = None write_to_log(f"Non-critical error in session handling: {e}") event = UnredactedEvent( @@ -116,6 +117,9 @@ async def on_initialize( timestamp=datetime.now(timezone.utc), parameters=params.model_dump() if params else {}, event_type=EventType.MCP_INITIALIZE.value, + identify_actor_given_id=identity.user_id if identity else None, + identify_actor_name=identity.user_name if identity else None, + identify_data=identity.user_data if identity else None, ) try: @@ -154,8 +158,9 @@ async def on_call_tool( request_context = self._get_request_context(context) try: get_client_info_from_request_context(self.server, request_context) - identify_session(self.server, context.message, request_context) + identity = identify_session(self.server, context.message, request_context) except Exception as e: + identity = None write_to_log(f"Non-critical error in session handling: {e}") register_tool(self.server, tool_name) @@ -180,6 +185,9 @@ async def on_call_tool( event_type=EventType.MCP_TOOLS_CALL.value, resource_name=tool_name, user_intent=user_intent, + identify_actor_given_id=identity.user_id if identity else None, + identify_actor_name=identity.user_name if identity else None, + identify_data=identity.user_data if identity else None, ) # Create modified context without context parameter if needed @@ -241,16 +249,21 @@ async def on_list_tools( request_context = self._get_request_context(context) try: get_client_info_from_request_context(self.server, request_context) - identify_session(self.server, context.message, request_context) + identity = identify_session(self.server, context.message, request_context) except Exception as e: + identity = None write_to_log(f"Non-critical error in session handling: {e}") params = getattr(context.message, "params", None) + event = UnredactedEvent( session_id=session_id, timestamp=datetime.now(timezone.utc), parameters=params.model_dump() if params else {}, event_type=EventType.MCP_TOOLS_LIST.value, + identify_actor_given_id=identity.user_id if identity else None, + identify_actor_name=identity.user_name if identity else None, + identify_data=identity.user_data if identity else None, ) try: diff --git a/src/mcpcat/modules/overrides/mcp_server.py b/src/mcpcat/modules/overrides/mcp_server.py index f1388ad..6310497 100644 --- a/src/mcpcat/modules/overrides/mcp_server.py +++ b/src/mcpcat/modules/overrides/mcp_server.py @@ -41,12 +41,16 @@ async def wrapped_initialize_handler(request: InitializeRequest) -> ServerResult """Intercept initialize requests to add MCPCat data to the request context.""" session_id = get_server_session_id(server) request_context = safe_request_context(server) - identify_session(server, request, request_context) + identity = identify_session(server, request, request_context) + event = UnredactedEvent( session_id=session_id, timestamp=datetime.now(timezone.utc), parameters=request.params.model_dump() if request.params else {}, event_type=EventType.MCP_INITIALIZE.value, + identify_actor_given_id=identity.user_id if identity else None, + identify_actor_name=identity.user_name if identity else None, + identify_data=identity.user_data if identity else None, ) # Call the original handler @@ -64,7 +68,8 @@ async def wrapped_list_tools_handler(request: ListToolsRequest) -> ServerResult: session_id = get_server_session_id(server) request_context = safe_request_context(server) get_client_info_from_request_context(server, request_context) - identify_session(server, request, request_context) + identity = identify_session(server, request, request_context) + event = UnredactedEvent( session_id=session_id, timestamp=datetime.now(timezone.utc), @@ -72,6 +77,9 @@ async def wrapped_list_tools_handler(request: ListToolsRequest) -> ServerResult: if request and request.params else {}, event_type=EventType.MCP_TOOLS_LIST.value, + identify_actor_given_id=identity.user_id if identity else None, + identify_actor_name=identity.user_name if identity else None, + identify_data=identity.user_data if identity else None, ) # Call the original handler to get the tools @@ -142,7 +150,7 @@ async def wrapped_call_tool_handler(request: CallToolRequest) -> ServerResult: session_id = get_server_session_id(server) request_context = safe_request_context(server) get_client_info_from_request_context(server, request_context) - identify_session(server, request, request_context) + identity = identify_session(server, request, request_context) write_to_log( f"Intercepted call to tool '{tool_name}' with arguments: {arguments} and request context: {request_context}" @@ -153,6 +161,9 @@ async def wrapped_call_tool_handler(request: CallToolRequest) -> ServerResult: parameters=request.params.model_dump() if request.params else {}, event_type=EventType.MCP_TOOLS_CALL.value, resource_name=tool_name, + identify_actor_given_id=identity.user_id if identity else None, + identify_actor_name=identity.user_name if identity else None, + identify_data=identity.user_data if identity else None, ) # Extract user intent from context (but don't pop yet - we need it for the event) @@ -220,12 +231,20 @@ async def wrapped_initialize_handler(request: InitializeRequest) -> ServerResult """Intercept initialize requests to add MCPCat data to the request context.""" session_id = get_server_session_id(server) request_context = safe_request_context(server) - identify_session(server, request, request_context) + try: + identity = identify_session(server, request, request_context) + except Exception as e: + identity = None + write_to_log(f"Ran into an error in session identification, no identity could be determined: {e}") + event = UnredactedEvent( session_id=session_id, timestamp=datetime.now(timezone.utc), parameters=request.params.model_dump() if request.params else {}, event_type=EventType.MCP_INITIALIZE.value, + identify_actor_given_id=identity.user_id if identity else None, + identify_actor_name=identity.user_name if identity else None, + identify_data=identity.user_data if identity else None, ) # Call the original handler @@ -241,7 +260,8 @@ async def wrapped_list_tools_handler(request: ListToolsRequest) -> ServerResult: session_id = get_server_session_id(server) request_context = safe_request_context(server) get_client_info_from_request_context(server, request_context) - identify_session(server, request, request_context) + identity = identify_session(server, request, request_context) + event = UnredactedEvent( session_id=session_id, timestamp=datetime.now(timezone.utc), @@ -249,6 +269,9 @@ async def wrapped_list_tools_handler(request: ListToolsRequest) -> ServerResult: if request and request.params else {}, event_type=EventType.MCP_TOOLS_LIST.value, + identify_actor_given_id=identity.user_id if identity else None, + identify_actor_name=identity.user_name if identity else None, + identify_data=identity.user_data if identity else None, ) # Call the original handler - tool modifications are handled by monkey-patch diff --git a/src/mcpcat/modules/overrides/official/monkey_patch.py b/src/mcpcat/modules/overrides/official/monkey_patch.py index b8aa1ec..ebe2e66 100644 --- a/src/mcpcat/modules/overrides/official/monkey_patch.py +++ b/src/mcpcat/modules/overrides/official/monkey_patch.py @@ -259,8 +259,9 @@ async def patched_call_tool( }, )() - identify_session(server._mcp_server, mock_request, request_context) + identity = identify_session(server._mcp_server, mock_request, request_context) except Exception as e: + identity = None write_to_log(f"Non-critical error in session handling: {e}") # Continue without session identification @@ -294,6 +295,9 @@ async def patched_call_tool( event_type=EventType.MCP_TOOLS_CALL.value, resource_name=name, user_intent=user_intent, + identify_actor_given_id=identity.user_id if identity else None, + identify_actor_name=identity.user_name if identity else None, + identify_data=identity.user_data if identity else None, ) except Exception as e: write_to_log(f"Error creating event: {e}") diff --git a/src/mcpcat/modules/session.py b/src/mcpcat/modules/session.py index 99b5698..803a382 100644 --- a/src/mcpcat/modules/session.py +++ b/src/mcpcat/modules/session.py @@ -138,7 +138,7 @@ def get_client_info_from_request_context( def get_session_info(server: Server, data: MCPCatData | None = None) -> SessionInfo: """Get session information for the current MCP session.""" actor_info: Optional[UserIdentity] = None - if data: + if data and not data.is_stateless: actor_info = data.identified_sessions.get(data.session_id, None) session_info = SessionInfo( @@ -176,12 +176,15 @@ def set_last_activity(server: Server) -> None: set_server_tracking_data(server, data) -def get_server_session_id(server: Server) -> str: +def get_server_session_id(server: Server) -> str | None: data = get_server_tracking_data(server) if not data: raise Exception("MCPCat data not initialized for this server") + if data.is_stateless: + return None + now = datetime.now(timezone.utc) timeout = timedelta(minutes=INACTIVITY_TIMEOUT_IN_MINUTES) # If last activity timed out diff --git a/src/mcpcat/types.py b/src/mcpcat/types.py index 32170ce..d308b08 100644 --- a/src/mcpcat/types.py +++ b/src/mcpcat/types.py @@ -46,8 +46,10 @@ class Event(PublishEventRequest): # Error tracking types + class StackFrame(TypedDict, total=False): """Stack frame information for error tracking.""" + filename: str abs_path: str function: str # Function name or "" @@ -59,6 +61,7 @@ class StackFrame(TypedDict, total=False): class ChainedErrorData(TypedDict, total=False): """Chained exception data (from __cause__ or __context__).""" + message: str type: NotRequired[str | None] stack: NotRequired[str] @@ -67,8 +70,11 @@ class ChainedErrorData(TypedDict, total=False): class ErrorData(TypedDict, total=False): """Complete error information for an exception.""" + message: str - type: NotRequired[str | None] # Exception class name (e.g., "ValueError", "TypeError") + type: NotRequired[ + str | None + ] # Exception class name (e.g., "ValueError", "TypeError") stack: NotRequired[str] frames: NotRequired[list[StackFrame]] chained_errors: NotRequired[list[ChainedErrorData]] @@ -158,7 +164,7 @@ class MCPCatOptions: exporters: dict[str, ExporterConfig] | None = None debug_mode: bool = False api_base_url: str | None = None - + stateless: bool | None = None @dataclass @@ -177,3 +183,4 @@ class MCPCatData: wrapped_tools: Set[str] = field(default_factory=set) tracker_initialized: bool = False monkey_patched: bool = False + is_stateless: bool = False diff --git a/tests/test_stateless.py b/tests/test_stateless.py new file mode 100644 index 0000000..6287ae9 --- /dev/null +++ b/tests/test_stateless.py @@ -0,0 +1,167 @@ +"""Tests for stateless mode behavior.""" + +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +import mcpcat +from mcpcat.modules.internal import ( + get_server_tracking_data, + set_server_tracking_data, + reset_all_tracking_data, +) +from mcpcat.modules.session import get_server_session_id +from mcpcat.modules.identify import identify_session +from mcpcat.types import MCPCatData, MCPCatOptions, SessionInfo, UserIdentity + +from .test_utils.todo_server import create_todo_server + + +def _make_identify_fn(user_id="user_123", user_name="Test User"): + """Return an identify function that always returns a UserIdentity.""" + def identify(request, context): + return UserIdentity(user_id=user_id, user_name=user_name, user_data=None) + return identify + + +class TestStatelessMode: + """Tests for SDK stateless mode behavior.""" + + def setup_method(self): + reset_all_tracking_data() + self.server = create_todo_server() + + def teardown_method(self): + reset_all_tracking_data() + + def _setup_data(self, stateless=False, identify=None): + """Create and store MCPCatData on the server.""" + options = MCPCatOptions() + if identify: + options.identify = identify + data = MCPCatData( + project_id="test_project", + session_id="ses_existing123", + session_info=SessionInfo(), + last_activity=datetime.now(timezone.utc), + identified_sessions={}, + options=options, + is_stateless=stateless, + ) + set_server_tracking_data(self.server, data) + return data + + def test_stateless_option_sets_flag(self): + """MCPCatOptions(stateless=True) should set is_stateless on data.""" + data = self._setup_data(stateless=True) + assert data.is_stateless is True + + def test_stateless_session_id_is_none(self): + """In stateless mode, get_server_session_id() should return None.""" + self._setup_data(stateless=True) + session_id = get_server_session_id(self.server) + assert session_id is None + + @patch("mcpcat.modules.identify.event_queue") + def test_stateless_identify_runs_every_time(self, mock_event_queue): + """In stateless mode, identify should run on every call (no early-return guard).""" + mock_fn = MagicMock(return_value=UserIdentity( + user_id="alice", user_name="Alice", user_data=None + )) + self._setup_data(stateless=True, identify=mock_fn) + + identify_session(self.server, MagicMock(), MagicMock()) + identify_session(self.server, MagicMock(), MagicMock()) + + assert mock_fn.call_count == 2 + + @patch("mcpcat.modules.identify.event_queue") + def test_stateless_identify_returns_identity(self, mock_event_queue): + """In stateless mode, identify_session() should return the UserIdentity.""" + self._setup_data(stateless=True, identify=_make_identify_fn()) + + result = identify_session(self.server, MagicMock(), MagicMock()) + + assert isinstance(result, UserIdentity) + assert result.user_id == "user_123" + assert result.user_name == "Test User" + + @patch("mcpcat.modules.identify.event_queue") + def test_stateless_identify_no_shared_state(self, mock_event_queue): + """In stateless mode, identified_sessions should remain empty.""" + self._setup_data(stateless=True, identify=_make_identify_fn()) + + identify_session(self.server, MagicMock(), MagicMock()) + + data = get_server_tracking_data(self.server) + assert data.identified_sessions == {} + + @patch("mcpcat.modules.identify.event_queue") + def test_stateful_unchanged(self, mock_event_queue): + """Default (stateful) mode: session_id is a string, identify guard skips second call.""" + mock_fn = MagicMock(return_value=UserIdentity( + user_id="alice", user_name="Alice", user_data=None + )) + self._setup_data(stateless=False, identify=mock_fn) + + # Session ID should be a string + session_id = get_server_session_id(self.server) + assert isinstance(session_id, str) + assert session_id.startswith("ses_") + + # First identify call should work + identify_session(self.server, MagicMock(), MagicMock()) + assert mock_fn.call_count == 1 + + # Second call should be skipped (early-return guard) + identify_session(self.server, MagicMock(), MagicMock()) + assert mock_fn.call_count == 1 + + def test_track_stateless_true_sets_flag(self): + """track() with stateless=True should set is_stateless on data.""" + server = create_todo_server() + options = MCPCatOptions(stateless=True) + mcpcat.track(server, "test_project", options) + data = get_server_tracking_data(server) + assert data.is_stateless is True + + def test_track_stateless_false_overrides_detection(self): + """track() with stateless=False should force stateful even if server looks stateless.""" + server = create_todo_server() + # Mock the server to look stateless + server.settings = MagicMock() + server.settings.stateless_http = True + options = MCPCatOptions(stateless=False) + mcpcat.track(server, "test_project", options) + data = get_server_tracking_data(server) + assert data.is_stateless is False + + def test_track_stateless_none_auto_detects(self): + """track() with stateless=None (default) should auto-detect from server.""" + server = create_todo_server() + options = MCPCatOptions() # stateless=None by default + mcpcat.track(server, "test_project", options) + data = get_server_tracking_data(server) + # create_todo_server() is not stateless, so should be False + assert data.is_stateless is False + + @patch("mcpcat.modules.identify.event_queue") + def test_stateless_identify_bad_return(self, mock_event_queue): + """In stateless mode, identify returning non-UserIdentity should return None.""" + bad_fn = MagicMock(return_value="not a UserIdentity") + self._setup_data(stateless=True, identify=bad_fn) + + result = identify_session(self.server, MagicMock(), MagicMock()) + + assert result is None + assert bad_fn.call_count == 1 + + @patch("mcpcat.modules.identify.event_queue") + def test_stateless_identify_exception(self, mock_event_queue): + """In stateless mode, identify raising should return None, not propagate.""" + raising_fn = MagicMock(side_effect=RuntimeError("identify exploded")) + self._setup_data(stateless=True, identify=raising_fn) + + result = identify_session(self.server, MagicMock(), MagicMock()) + + assert result is None + assert raising_fn.call_count == 1