From 27611b843de9ce1592827c7f7ca780b714980876 Mon Sep 17 00:00:00 2001 From: Kashish Hora Date: Sun, 29 Mar 2026 20:02:55 +0200 Subject: [PATCH 1/6] feat: add stateless mode for multi-user MCP server deployments --- src/mcpcat/__init__.py | 12 ++ src/mcpcat/modules/identify.py | 20 ++- .../overrides/community/monkey_patch.py | 26 ++-- .../overrides/community_v3/middleware.py | 71 +++++++---- src/mcpcat/modules/overrides/mcp_server.py | 111 +++++++++++------ .../overrides/official/monkey_patch.py | 26 ++-- src/mcpcat/modules/session.py | 7 +- src/mcpcat/types.py | 11 +- tests/test_stateless.py | 116 ++++++++++++++++++ 9 files changed, 315 insertions(+), 85 deletions(-) create mode 100644 tests/test_stateless.py diff --git a/src/mcpcat/__init__.py b/src/mcpcat/__init__.py index aaa440c..a40384b 100644 --- a/src/mcpcat/__init__.py +++ b/src/mcpcat/__init__.py @@ -25,6 +25,17 @@ ) +def _detect_stateless(server) -> bool: + """Auto-detect stateless mode from FastMCP server settings.""" + import warnings + try: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + return server.settings.stateless_http + except (AttributeError, RuntimeError): + return False + + def track( server: Any, project_id: str | None = None, options: MCPCatOptions | None = None ) -> Any: @@ -89,6 +100,7 @@ def track( session_info=session_info, identified_sessions={}, options=options, + is_stateless=options.stateless if (options and 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..ab655ba 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 @@ -113,14 +114,21 @@ async def wrapped_call_tool_handler(request: CallToolRequest) -> ServerResult: user_intent = arguments.get("context", None) # Create tracking event - event = UnredactedEvent( - session_id=session_id, - timestamp=datetime.now(timezone.utc), - parameters={"name": tool_name, "arguments": arguments}, - event_type=EventType.MCP_TOOLS_CALL.value, - resource_name=tool_name, - user_intent=user_intent, - ) + event_kwargs = { + "session_id": session_id, + "timestamp": datetime.now(timezone.utc), + "parameters": {"name": tool_name, "arguments": arguments}, + "event_type": EventType.MCP_TOOLS_CALL.value, + "resource_name": tool_name, + "user_intent": user_intent, + } + # Stateless: attach identity directly to each event + if session_id is None and identity: + event_kwargs["identify_actor_given_id"] = identity.user_id + event_kwargs["identify_actor_name"] = identity.user_name + event_kwargs["identify_data"] = identity.user_data + + event = UnredactedEvent(**event_kwargs) try: # Handle get_more_tools specially - don't intercept for community FastMCP diff --git a/src/mcpcat/modules/overrides/community_v3/middleware.py b/src/mcpcat/modules/overrides/community_v3/middleware.py index d3fd179..4c9697b 100644 --- a/src/mcpcat/modules/overrides/community_v3/middleware.py +++ b/src/mcpcat/modules/overrides/community_v3/middleware.py @@ -107,16 +107,24 @@ 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( - session_id=session_id, - timestamp=datetime.now(timezone.utc), - parameters=params.model_dump() if params else {}, - event_type=EventType.MCP_INITIALIZE.value, - ) + event_kwargs = { + "session_id": session_id, + "timestamp": datetime.now(timezone.utc), + "parameters": params.model_dump() if params else {}, + "event_type": EventType.MCP_INITIALIZE.value, + } + # Stateless: attach identity directly to each event + if session_id is None and identity: + event_kwargs["identify_actor_given_id"] = identity.user_id + event_kwargs["identify_actor_name"] = identity.user_name + event_kwargs["identify_data"] = identity.user_data + + event = UnredactedEvent(**event_kwargs) try: result = await call_next(context) @@ -154,8 +162,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) @@ -173,14 +182,21 @@ async def on_call_tool( elif should_remove_context: user_intent = arguments.pop("context", None) - event = UnredactedEvent( - session_id=session_id, - timestamp=datetime.now(timezone.utc), - parameters={"name": tool_name, "arguments": arguments}, - event_type=EventType.MCP_TOOLS_CALL.value, - resource_name=tool_name, - user_intent=user_intent, - ) + event_kwargs = { + "session_id": session_id, + "timestamp": datetime.now(timezone.utc), + "parameters": {"name": tool_name, "arguments": arguments}, + "event_type": EventType.MCP_TOOLS_CALL.value, + "resource_name": tool_name, + "user_intent": user_intent, + } + # Stateless: attach identity directly to each event + if session_id is None and identity: + event_kwargs["identify_actor_given_id"] = identity.user_id + event_kwargs["identify_actor_name"] = identity.user_name + event_kwargs["identify_data"] = identity.user_data + + event = UnredactedEvent(**event_kwargs) # Create modified context without context parameter if needed call_context = context @@ -241,17 +257,26 @@ 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, - ) + + event_kwargs = { + "session_id": session_id, + "timestamp": datetime.now(timezone.utc), + "parameters": params.model_dump() if params else {}, + "event_type": EventType.MCP_TOOLS_LIST.value, + } + # Stateless: attach identity directly to each event + if session_id is None and identity: + event_kwargs["identify_actor_given_id"] = identity.user_id + event_kwargs["identify_actor_name"] = identity.user_name + event_kwargs["identify_data"] = identity.user_data + + event = UnredactedEvent(**event_kwargs) try: tools = list(await call_next(context)) diff --git a/src/mcpcat/modules/overrides/mcp_server.py b/src/mcpcat/modules/overrides/mcp_server.py index f1388ad..f243a39 100644 --- a/src/mcpcat/modules/overrides/mcp_server.py +++ b/src/mcpcat/modules/overrides/mcp_server.py @@ -41,13 +41,21 @@ 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) - 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, - ) + identity = identify_session(server, request, request_context) + + event_kwargs = { + "session_id": session_id, + "timestamp": datetime.now(timezone.utc), + "parameters": request.params.model_dump() if request.params else {}, + "event_type": EventType.MCP_INITIALIZE.value, + } + # Stateless: attach identity directly to each event + if session_id is None and identity: + event_kwargs["identify_actor_given_id"] = identity.user_id + event_kwargs["identify_actor_name"] = identity.user_name + event_kwargs["identify_data"] = identity.user_data + + event = UnredactedEvent(**event_kwargs) # Call the original handler result = await original_initialize_handler(request) @@ -64,15 +72,23 @@ 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) - event = UnredactedEvent( - session_id=session_id, - timestamp=datetime.now(timezone.utc), - parameters=request.params.model_dump() + identity = identify_session(server, request, request_context) + + event_kwargs = { + "session_id": session_id, + "timestamp": datetime.now(timezone.utc), + "parameters": request.params.model_dump() if request and request.params else {}, - event_type=EventType.MCP_TOOLS_LIST.value, - ) + "event_type": EventType.MCP_TOOLS_LIST.value, + } + # Stateless: attach identity directly to each event + if session_id is None and identity: + event_kwargs["identify_actor_given_id"] = identity.user_id + event_kwargs["identify_actor_name"] = identity.user_name + event_kwargs["identify_data"] = identity.user_data + + event = UnredactedEvent(**event_kwargs) # Call the original handler to get the tools original_result = await original_list_tools_handler(request) @@ -142,18 +158,25 @@ 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}" ) - event = UnredactedEvent( - session_id=session_id, - timestamp=datetime.now(timezone.utc), - parameters=request.params.model_dump() if request.params else {}, - event_type=EventType.MCP_TOOLS_CALL.value, - resource_name=tool_name, - ) + event_kwargs = { + "session_id": session_id, + "timestamp": datetime.now(timezone.utc), + "parameters": request.params.model_dump() if request.params else {}, + "event_type": EventType.MCP_TOOLS_CALL.value, + "resource_name": tool_name, + } + # Stateless: attach identity directly to each event + if session_id is None and identity: + event_kwargs["identify_actor_given_id"] = identity.user_id + event_kwargs["identify_actor_name"] = identity.user_name + event_kwargs["identify_data"] = identity.user_data + + event = UnredactedEvent(**event_kwargs) # Extract user intent from context (but don't pop yet - we need it for the event) if data.options.enable_tool_call_context and tool_name != "get_more_tools": @@ -220,13 +243,21 @@ 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) - 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, - ) + identity = identify_session(server, request, request_context) + + event_kwargs = { + "session_id": session_id, + "timestamp": datetime.now(timezone.utc), + "parameters": request.params.model_dump() if request.params else {}, + "event_type": EventType.MCP_INITIALIZE.value, + } + # Stateless: attach identity directly to each event + if session_id is None and identity: + event_kwargs["identify_actor_given_id"] = identity.user_id + event_kwargs["identify_actor_name"] = identity.user_name + event_kwargs["identify_data"] = identity.user_data + + event = UnredactedEvent(**event_kwargs) # Call the original handler result = await original_initialize_handler(request) @@ -241,15 +272,23 @@ 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) - event = UnredactedEvent( - session_id=session_id, - timestamp=datetime.now(timezone.utc), - parameters=request.params.model_dump() + identity = identify_session(server, request, request_context) + + event_kwargs = { + "session_id": session_id, + "timestamp": datetime.now(timezone.utc), + "parameters": request.params.model_dump() if request and request.params else {}, - event_type=EventType.MCP_TOOLS_LIST.value, - ) + "event_type": EventType.MCP_TOOLS_LIST.value, + } + # Stateless: attach identity directly to each event + if session_id is None and identity: + event_kwargs["identify_actor_given_id"] = identity.user_id + event_kwargs["identify_actor_name"] = identity.user_name + event_kwargs["identify_data"] = identity.user_data + + event = UnredactedEvent(**event_kwargs) # Call the original handler - tool modifications are handled by monkey-patch result = await original_list_tools_handler(request) diff --git a/src/mcpcat/modules/overrides/official/monkey_patch.py b/src/mcpcat/modules/overrides/official/monkey_patch.py index b8aa1ec..b8b0248 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 @@ -287,14 +288,21 @@ async def patched_call_tool( # Create tracking event (non-critical) try: - event = UnredactedEvent( - session_id=session_id, - timestamp=datetime.now(timezone.utc), - parameters={"name": name, "arguments": arguments}, - event_type=EventType.MCP_TOOLS_CALL.value, - resource_name=name, - user_intent=user_intent, - ) + event_kwargs = { + "session_id": session_id, + "timestamp": datetime.now(timezone.utc), + "parameters": {"name": name, "arguments": arguments}, + "event_type": EventType.MCP_TOOLS_CALL.value, + "resource_name": name, + "user_intent": user_intent, + } + # Stateless: attach identity directly to each event + if session_id is None and identity: + event_kwargs["identify_actor_given_id"] = identity.user_id + event_kwargs["identify_actor_name"] = identity.user_name + event_kwargs["identify_data"] = identity.user_data + + event = UnredactedEvent(**event_kwargs) except Exception as e: write_to_log(f"Error creating event: {e}") event = None 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..dc51a40 --- /dev/null +++ b/tests/test_stateless.py @@ -0,0 +1,116 @@ +"""Tests for stateless mode behavior.""" + +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +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 From 31585f33b2b0bf152f34cf4934c086d0087acd49 Mon Sep 17 00:00:00 2001 From: Kashish Hora Date: Sun, 29 Mar 2026 20:06:32 +0200 Subject: [PATCH 2/6] test: add track() wiring and identify failure tests for stateless mode --- tests/test_stateless.py | 51 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/test_stateless.py b/tests/test_stateless.py index dc51a40..6287ae9 100644 --- a/tests/test_stateless.py +++ b/tests/test_stateless.py @@ -3,6 +3,7 @@ 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, @@ -114,3 +115,53 @@ def test_stateful_unchanged(self, mock_event_queue): # 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 From 05360b16781b791826cb31bcc2950d215df28c49 Mon Sep 17 00:00:00 2001 From: Kashish Hora Date: Sun, 29 Mar 2026 22:46:09 +0200 Subject: [PATCH 3/6] chore: bump mcpcat-api to 0.1.9 and version to 0.1.15b1 --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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", ] From 0db0c79d6b04dc7e4ab79eac94a87417cd77de91 Mon Sep 17 00:00:00 2001 From: Kashish Hora Date: Mon, 30 Mar 2026 11:48:37 +0200 Subject: [PATCH 4/6] fix: address PR review feedback on identity attachment and logging --- src/mcpcat/__init__.py | 20 ++++++++++++++---- .../overrides/community/monkey_patch.py | 3 +-- .../overrides/community_v3/middleware.py | 9 +++----- src/mcpcat/modules/overrides/mcp_server.py | 21 +++++++++---------- .../overrides/official/monkey_patch.py | 3 +-- 5 files changed, 31 insertions(+), 25 deletions(-) diff --git a/src/mcpcat/__init__.py b/src/mcpcat/__init__.py index a40384b..91a2125 100644 --- a/src/mcpcat/__init__.py +++ b/src/mcpcat/__init__.py @@ -1,6 +1,7 @@ """MCPCat - Analytics Tool for MCP Servers.""" import os +import warnings from datetime import datetime, timezone from typing import Any @@ -26,12 +27,23 @@ def _detect_stateless(server) -> bool: - """Auto-detect stateless mode from FastMCP server settings.""" - import warnings + """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) - return server.settings.stateless_http + 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 @@ -100,7 +112,7 @@ def track( session_info=session_info, identified_sessions={}, options=options, - is_stateless=options.stateless if (options and options.stateless is not None) else _detect_stateless(server), + 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/overrides/community/monkey_patch.py b/src/mcpcat/modules/overrides/community/monkey_patch.py index ab655ba..5291faf 100644 --- a/src/mcpcat/modules/overrides/community/monkey_patch.py +++ b/src/mcpcat/modules/overrides/community/monkey_patch.py @@ -122,8 +122,7 @@ async def wrapped_call_tool_handler(request: CallToolRequest) -> ServerResult: "resource_name": tool_name, "user_intent": user_intent, } - # Stateless: attach identity directly to each event - if session_id is None and identity: + if identity: event_kwargs["identify_actor_given_id"] = identity.user_id event_kwargs["identify_actor_name"] = identity.user_name event_kwargs["identify_data"] = identity.user_data diff --git a/src/mcpcat/modules/overrides/community_v3/middleware.py b/src/mcpcat/modules/overrides/community_v3/middleware.py index 4c9697b..dd241e7 100644 --- a/src/mcpcat/modules/overrides/community_v3/middleware.py +++ b/src/mcpcat/modules/overrides/community_v3/middleware.py @@ -118,8 +118,7 @@ async def on_initialize( "parameters": params.model_dump() if params else {}, "event_type": EventType.MCP_INITIALIZE.value, } - # Stateless: attach identity directly to each event - if session_id is None and identity: + if identity: event_kwargs["identify_actor_given_id"] = identity.user_id event_kwargs["identify_actor_name"] = identity.user_name event_kwargs["identify_data"] = identity.user_data @@ -190,8 +189,7 @@ async def on_call_tool( "resource_name": tool_name, "user_intent": user_intent, } - # Stateless: attach identity directly to each event - if session_id is None and identity: + if identity: event_kwargs["identify_actor_given_id"] = identity.user_id event_kwargs["identify_actor_name"] = identity.user_name event_kwargs["identify_data"] = identity.user_data @@ -270,8 +268,7 @@ async def on_list_tools( "parameters": params.model_dump() if params else {}, "event_type": EventType.MCP_TOOLS_LIST.value, } - # Stateless: attach identity directly to each event - if session_id is None and identity: + if identity: event_kwargs["identify_actor_given_id"] = identity.user_id event_kwargs["identify_actor_name"] = identity.user_name event_kwargs["identify_data"] = identity.user_data diff --git a/src/mcpcat/modules/overrides/mcp_server.py b/src/mcpcat/modules/overrides/mcp_server.py index f243a39..d5825c7 100644 --- a/src/mcpcat/modules/overrides/mcp_server.py +++ b/src/mcpcat/modules/overrides/mcp_server.py @@ -49,8 +49,7 @@ async def wrapped_initialize_handler(request: InitializeRequest) -> ServerResult "parameters": request.params.model_dump() if request.params else {}, "event_type": EventType.MCP_INITIALIZE.value, } - # Stateless: attach identity directly to each event - if session_id is None and identity: + if identity: event_kwargs["identify_actor_given_id"] = identity.user_id event_kwargs["identify_actor_name"] = identity.user_name event_kwargs["identify_data"] = identity.user_data @@ -82,8 +81,7 @@ async def wrapped_list_tools_handler(request: ListToolsRequest) -> ServerResult: else {}, "event_type": EventType.MCP_TOOLS_LIST.value, } - # Stateless: attach identity directly to each event - if session_id is None and identity: + if identity: event_kwargs["identify_actor_given_id"] = identity.user_id event_kwargs["identify_actor_name"] = identity.user_name event_kwargs["identify_data"] = identity.user_data @@ -170,8 +168,7 @@ async def wrapped_call_tool_handler(request: CallToolRequest) -> ServerResult: "event_type": EventType.MCP_TOOLS_CALL.value, "resource_name": tool_name, } - # Stateless: attach identity directly to each event - if session_id is None and identity: + if identity: event_kwargs["identify_actor_given_id"] = identity.user_id event_kwargs["identify_actor_name"] = identity.user_name event_kwargs["identify_data"] = identity.user_data @@ -243,7 +240,11 @@ 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) - identity = 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_kwargs = { "session_id": session_id, @@ -251,8 +252,7 @@ async def wrapped_initialize_handler(request: InitializeRequest) -> ServerResult "parameters": request.params.model_dump() if request.params else {}, "event_type": EventType.MCP_INITIALIZE.value, } - # Stateless: attach identity directly to each event - if session_id is None and identity: + if identity: event_kwargs["identify_actor_given_id"] = identity.user_id event_kwargs["identify_actor_name"] = identity.user_name event_kwargs["identify_data"] = identity.user_data @@ -282,8 +282,7 @@ async def wrapped_list_tools_handler(request: ListToolsRequest) -> ServerResult: else {}, "event_type": EventType.MCP_TOOLS_LIST.value, } - # Stateless: attach identity directly to each event - if session_id is None and identity: + if identity: event_kwargs["identify_actor_given_id"] = identity.user_id event_kwargs["identify_actor_name"] = identity.user_name event_kwargs["identify_data"] = identity.user_data diff --git a/src/mcpcat/modules/overrides/official/monkey_patch.py b/src/mcpcat/modules/overrides/official/monkey_patch.py index b8b0248..cc46bfd 100644 --- a/src/mcpcat/modules/overrides/official/monkey_patch.py +++ b/src/mcpcat/modules/overrides/official/monkey_patch.py @@ -296,8 +296,7 @@ async def patched_call_tool( "resource_name": name, "user_intent": user_intent, } - # Stateless: attach identity directly to each event - if session_id is None and identity: + if identity: event_kwargs["identify_actor_given_id"] = identity.user_id event_kwargs["identify_actor_name"] = identity.user_name event_kwargs["identify_data"] = identity.user_data From 44a082aa817a2c666af01c582db64d4987464ef8 Mon Sep 17 00:00:00 2001 From: Kashish Hora Date: Mon, 30 Mar 2026 12:03:31 +0200 Subject: [PATCH 5/6] refactor: simplify event construction by removing kwargs pattern --- .../overrides/community/monkey_patch.py | 25 ++-- .../overrides/community_v3/middleware.py | 67 +++++------ src/mcpcat/modules/overrides/mcp_server.py | 107 ++++++++---------- .../overrides/official/monkey_patch.py | 25 ++-- 4 files changed, 97 insertions(+), 127 deletions(-) diff --git a/src/mcpcat/modules/overrides/community/monkey_patch.py b/src/mcpcat/modules/overrides/community/monkey_patch.py index 5291faf..2dc51fb 100644 --- a/src/mcpcat/modules/overrides/community/monkey_patch.py +++ b/src/mcpcat/modules/overrides/community/monkey_patch.py @@ -114,20 +114,17 @@ async def wrapped_call_tool_handler(request: CallToolRequest) -> ServerResult: user_intent = arguments.get("context", None) # Create tracking event - event_kwargs = { - "session_id": session_id, - "timestamp": datetime.now(timezone.utc), - "parameters": {"name": tool_name, "arguments": arguments}, - "event_type": EventType.MCP_TOOLS_CALL.value, - "resource_name": tool_name, - "user_intent": user_intent, - } - if identity: - event_kwargs["identify_actor_given_id"] = identity.user_id - event_kwargs["identify_actor_name"] = identity.user_name - event_kwargs["identify_data"] = identity.user_data - - event = UnredactedEvent(**event_kwargs) + event = UnredactedEvent( + session_id=session_id, + timestamp=datetime.now(timezone.utc), + parameters={"name": tool_name, "arguments": arguments}, + 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: # Handle get_more_tools specially - don't intercept for community FastMCP diff --git a/src/mcpcat/modules/overrides/community_v3/middleware.py b/src/mcpcat/modules/overrides/community_v3/middleware.py index dd241e7..03d36db 100644 --- a/src/mcpcat/modules/overrides/community_v3/middleware.py +++ b/src/mcpcat/modules/overrides/community_v3/middleware.py @@ -112,18 +112,15 @@ async def on_initialize( identity = None write_to_log(f"Non-critical error in session handling: {e}") - event_kwargs = { - "session_id": session_id, - "timestamp": datetime.now(timezone.utc), - "parameters": params.model_dump() if params else {}, - "event_type": EventType.MCP_INITIALIZE.value, - } - if identity: - event_kwargs["identify_actor_given_id"] = identity.user_id - event_kwargs["identify_actor_name"] = identity.user_name - event_kwargs["identify_data"] = identity.user_data - - event = UnredactedEvent(**event_kwargs) + event = UnredactedEvent( + session_id=session_id, + 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: result = await call_next(context) @@ -181,20 +178,17 @@ async def on_call_tool( elif should_remove_context: user_intent = arguments.pop("context", None) - event_kwargs = { - "session_id": session_id, - "timestamp": datetime.now(timezone.utc), - "parameters": {"name": tool_name, "arguments": arguments}, - "event_type": EventType.MCP_TOOLS_CALL.value, - "resource_name": tool_name, - "user_intent": user_intent, - } - if identity: - event_kwargs["identify_actor_given_id"] = identity.user_id - event_kwargs["identify_actor_name"] = identity.user_name - event_kwargs["identify_data"] = identity.user_data - - event = UnredactedEvent(**event_kwargs) + event = UnredactedEvent( + session_id=session_id, + timestamp=datetime.now(timezone.utc), + parameters={"name": tool_name, "arguments": arguments}, + 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 call_context = context @@ -262,18 +256,15 @@ async def on_list_tools( params = getattr(context.message, "params", None) - event_kwargs = { - "session_id": session_id, - "timestamp": datetime.now(timezone.utc), - "parameters": params.model_dump() if params else {}, - "event_type": EventType.MCP_TOOLS_LIST.value, - } - if identity: - event_kwargs["identify_actor_given_id"] = identity.user_id - event_kwargs["identify_actor_name"] = identity.user_name - event_kwargs["identify_data"] = identity.user_data - - event = UnredactedEvent(**event_kwargs) + 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: tools = list(await call_next(context)) diff --git a/src/mcpcat/modules/overrides/mcp_server.py b/src/mcpcat/modules/overrides/mcp_server.py index d5825c7..6310497 100644 --- a/src/mcpcat/modules/overrides/mcp_server.py +++ b/src/mcpcat/modules/overrides/mcp_server.py @@ -43,18 +43,15 @@ async def wrapped_initialize_handler(request: InitializeRequest) -> ServerResult request_context = safe_request_context(server) identity = identify_session(server, request, request_context) - event_kwargs = { - "session_id": session_id, - "timestamp": datetime.now(timezone.utc), - "parameters": request.params.model_dump() if request.params else {}, - "event_type": EventType.MCP_INITIALIZE.value, - } - if identity: - event_kwargs["identify_actor_given_id"] = identity.user_id - event_kwargs["identify_actor_name"] = identity.user_name - event_kwargs["identify_data"] = identity.user_data - - event = UnredactedEvent(**event_kwargs) + 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 result = await original_initialize_handler(request) @@ -73,20 +70,17 @@ async def wrapped_list_tools_handler(request: ListToolsRequest) -> ServerResult: get_client_info_from_request_context(server, request_context) identity = identify_session(server, request, request_context) - event_kwargs = { - "session_id": session_id, - "timestamp": datetime.now(timezone.utc), - "parameters": request.params.model_dump() + event = UnredactedEvent( + session_id=session_id, + timestamp=datetime.now(timezone.utc), + parameters=request.params.model_dump() if request and request.params else {}, - "event_type": EventType.MCP_TOOLS_LIST.value, - } - if identity: - event_kwargs["identify_actor_given_id"] = identity.user_id - event_kwargs["identify_actor_name"] = identity.user_name - event_kwargs["identify_data"] = identity.user_data - - event = UnredactedEvent(**event_kwargs) + 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 original_result = await original_list_tools_handler(request) @@ -161,19 +155,16 @@ async def wrapped_call_tool_handler(request: CallToolRequest) -> ServerResult: write_to_log( f"Intercepted call to tool '{tool_name}' with arguments: {arguments} and request context: {request_context}" ) - event_kwargs = { - "session_id": session_id, - "timestamp": datetime.now(timezone.utc), - "parameters": request.params.model_dump() if request.params else {}, - "event_type": EventType.MCP_TOOLS_CALL.value, - "resource_name": tool_name, - } - if identity: - event_kwargs["identify_actor_given_id"] = identity.user_id - event_kwargs["identify_actor_name"] = identity.user_name - event_kwargs["identify_data"] = identity.user_data - - event = UnredactedEvent(**event_kwargs) + event = UnredactedEvent( + session_id=session_id, + timestamp=datetime.now(timezone.utc), + 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) if data.options.enable_tool_call_context and tool_name != "get_more_tools": @@ -246,18 +237,15 @@ async def wrapped_initialize_handler(request: InitializeRequest) -> ServerResult identity = None write_to_log(f"Ran into an error in session identification, no identity could be determined: {e}") - event_kwargs = { - "session_id": session_id, - "timestamp": datetime.now(timezone.utc), - "parameters": request.params.model_dump() if request.params else {}, - "event_type": EventType.MCP_INITIALIZE.value, - } - if identity: - event_kwargs["identify_actor_given_id"] = identity.user_id - event_kwargs["identify_actor_name"] = identity.user_name - event_kwargs["identify_data"] = identity.user_data - - event = UnredactedEvent(**event_kwargs) + 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 result = await original_initialize_handler(request) @@ -274,20 +262,17 @@ async def wrapped_list_tools_handler(request: ListToolsRequest) -> ServerResult: get_client_info_from_request_context(server, request_context) identity = identify_session(server, request, request_context) - event_kwargs = { - "session_id": session_id, - "timestamp": datetime.now(timezone.utc), - "parameters": request.params.model_dump() + event = UnredactedEvent( + session_id=session_id, + timestamp=datetime.now(timezone.utc), + parameters=request.params.model_dump() if request and request.params else {}, - "event_type": EventType.MCP_TOOLS_LIST.value, - } - if identity: - event_kwargs["identify_actor_given_id"] = identity.user_id - event_kwargs["identify_actor_name"] = identity.user_name - event_kwargs["identify_data"] = identity.user_data - - event = UnredactedEvent(**event_kwargs) + 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 result = await original_list_tools_handler(request) diff --git a/src/mcpcat/modules/overrides/official/monkey_patch.py b/src/mcpcat/modules/overrides/official/monkey_patch.py index cc46bfd..ebe2e66 100644 --- a/src/mcpcat/modules/overrides/official/monkey_patch.py +++ b/src/mcpcat/modules/overrides/official/monkey_patch.py @@ -288,20 +288,17 @@ async def patched_call_tool( # Create tracking event (non-critical) try: - event_kwargs = { - "session_id": session_id, - "timestamp": datetime.now(timezone.utc), - "parameters": {"name": name, "arguments": arguments}, - "event_type": EventType.MCP_TOOLS_CALL.value, - "resource_name": name, - "user_intent": user_intent, - } - if identity: - event_kwargs["identify_actor_given_id"] = identity.user_id - event_kwargs["identify_actor_name"] = identity.user_name - event_kwargs["identify_data"] = identity.user_data - - event = UnredactedEvent(**event_kwargs) + event = UnredactedEvent( + session_id=session_id, + timestamp=datetime.now(timezone.utc), + parameters={"name": name, "arguments": arguments}, + 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}") event = None From 2d6d5582997d84add229abebda358c44edf79c80 Mon Sep 17 00:00:00 2001 From: Kashish Hora Date: Mon, 30 Mar 2026 13:22:37 +0200 Subject: [PATCH 6/6] chore: expose __version__ on mcpcat package --- src/mcpcat/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/mcpcat/__init__.py b/src/mcpcat/__init__.py index 91a2125..25815e8 100644 --- a/src/mcpcat/__init__.py +++ b/src/mcpcat/__init__.py @@ -3,8 +3,11 @@ 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