Skip to content

Commit 3d73bf7

Browse files
authored
feat: add support for stateless mode (#27)
1 parent 7f4ef0b commit 3d73bf7

File tree

10 files changed

+280
-20
lines changed

10 files changed

+280
-20
lines changed

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "mcpcat"
3-
version = "0.1.14"
3+
version = "0.1.15b1"
44
description = "Analytics Tool for MCP Servers - provides insights into MCP tool usage patterns"
55
authors = [
66
{ name = "MCPCat", email = "support@mcpcat.io" },
@@ -19,7 +19,7 @@ classifiers = [
1919
]
2020
dependencies = [
2121
"mcp>=1.2.0",
22-
"mcpcat-api==0.1.4",
22+
"mcpcat-api==0.1.9",
2323
"pydantic>=2.0.0,<2.12",
2424
"requests>=2.31.0",
2525
]

src/mcpcat/__init__.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
"""MCPCat - Analytics Tool for MCP Servers."""
22

33
import os
4+
import warnings
45
from datetime import datetime, timezone
6+
from importlib.metadata import version
57
from typing import Any
68

9+
__version__ = version("mcpcat")
10+
711
from mcpcat.modules.overrides.mcp_server import override_lowlevel_mcp_server
812
from mcpcat.modules.session import get_session_info, new_session_id
913

@@ -25,6 +29,28 @@
2529
)
2630

2731

32+
def _detect_stateless(server) -> bool:
33+
"""Auto-detect stateless mode from FastMCP server settings.
34+
35+
Best-effort: community FastMCP v3 deprecated per-instance .settings
36+
in favor of global fastmcp.settings, but the global isn't per-server.
37+
The deprecated shim is the only per-instance API available.
38+
MCPCatOptions(stateless=True) is the recommended explicit path.
39+
"""
40+
try:
41+
with warnings.catch_warnings():
42+
warnings.simplefilter("ignore", DeprecationWarning)
43+
result = server.settings.stateless_http
44+
if result:
45+
write_to_log(
46+
"Auto-detected stateless HTTP mode from your FastMCP server's .settings. "
47+
"If this is incorrect, please pass stateless=False to MCPCatOptions and file a bug report."
48+
)
49+
return result
50+
except (AttributeError, RuntimeError):
51+
return False
52+
53+
2854
def track(
2955
server: Any, project_id: str | None = None, options: MCPCatOptions | None = None
3056
) -> Any:
@@ -89,6 +115,7 @@ def track(
89115
session_info=session_info,
90116
identified_sessions={},
91117
options=options,
118+
is_stateless=options.stateless if options.stateless is not None else _detect_stateless(server),
92119
)
93120
set_server_tracking_data(lowlevel_server, data)
94121

src/mcpcat/modules/identify.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
11
from datetime import datetime, timezone
2-
from typing import Optional
32

43
from mcpcat.modules import event_queue
5-
from mcpcat.modules.event_queue import publish_event
64
from mcpcat.modules.internal import get_server_tracking_data, set_server_tracking_data
75
from mcpcat.modules.logging import write_to_log
86
from mcpcat.types import EventType, UnredactedEvent, UserIdentity
97

108

11-
def identify_session(server, request: any, context: any) -> None:
9+
def identify_session(server, request: any, context: any) -> UserIdentity | None:
1210
"""
1311
Identify the user based on the request and context.
1412
@@ -25,7 +23,21 @@ def identify_session(server, request: any, context: any) -> None:
2523
if not data or not data.options or not data.options.identify:
2624
return
2725

28-
# Handle None context (e.g., in stateless HTTP mode outside handlers)
26+
# Stateless mode: run identify on every request, return identity directly
27+
if data.is_stateless:
28+
try:
29+
identify_result = data.options.identify(request, context)
30+
if not identify_result or not isinstance(identify_result, UserIdentity):
31+
write_to_log(
32+
f"User identification function did not return a valid UserIdentity instance. Received: {identify_result}"
33+
)
34+
return
35+
return identify_result
36+
except Exception as e:
37+
write_to_log(f"Error occurred during user identification: {e}")
38+
return
39+
40+
# Stateful mode: existing behavior unchanged
2941
if context is None:
3042
write_to_log("Context is None, skipping user identification")
3143
return

src/mcpcat/modules/overrides/community/monkey_patch.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,9 @@ async def wrapped_call_tool_handler(request: CallToolRequest) -> ServerResult:
9999
# Handle session identification
100100
try:
101101
get_client_info_from_request_context(lowlevel_server, request_context)
102-
identify_session(lowlevel_server, request, request_context)
102+
identity = identify_session(lowlevel_server, request, request_context)
103103
except Exception as e:
104+
identity = None
104105
write_to_log(f"Non-critical error in session handling: {e}")
105106

106107
# Extract user intent from context parameter
@@ -120,6 +121,9 @@ async def wrapped_call_tool_handler(request: CallToolRequest) -> ServerResult:
120121
event_type=EventType.MCP_TOOLS_CALL.value,
121122
resource_name=tool_name,
122123
user_intent=user_intent,
124+
identify_actor_given_id=identity.user_id if identity else None,
125+
identify_actor_name=identity.user_name if identity else None,
126+
identify_data=identity.user_data if identity else None,
123127
)
124128

125129
try:

src/mcpcat/modules/overrides/community_v3/middleware.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,15 +107,19 @@ async def on_initialize(
107107
request_context = self._get_request_context(context)
108108
try:
109109
get_client_info_from_request_context(self.server, request_context)
110-
identify_session(self.server, context.message, request_context)
110+
identity = identify_session(self.server, context.message, request_context)
111111
except Exception as e:
112+
identity = None
112113
write_to_log(f"Non-critical error in session handling: {e}")
113114

114115
event = UnredactedEvent(
115116
session_id=session_id,
116117
timestamp=datetime.now(timezone.utc),
117118
parameters=params.model_dump() if params else {},
118119
event_type=EventType.MCP_INITIALIZE.value,
120+
identify_actor_given_id=identity.user_id if identity else None,
121+
identify_actor_name=identity.user_name if identity else None,
122+
identify_data=identity.user_data if identity else None,
119123
)
120124

121125
try:
@@ -154,8 +158,9 @@ async def on_call_tool(
154158
request_context = self._get_request_context(context)
155159
try:
156160
get_client_info_from_request_context(self.server, request_context)
157-
identify_session(self.server, context.message, request_context)
161+
identity = identify_session(self.server, context.message, request_context)
158162
except Exception as e:
163+
identity = None
159164
write_to_log(f"Non-critical error in session handling: {e}")
160165

161166
register_tool(self.server, tool_name)
@@ -180,6 +185,9 @@ async def on_call_tool(
180185
event_type=EventType.MCP_TOOLS_CALL.value,
181186
resource_name=tool_name,
182187
user_intent=user_intent,
188+
identify_actor_given_id=identity.user_id if identity else None,
189+
identify_actor_name=identity.user_name if identity else None,
190+
identify_data=identity.user_data if identity else None,
183191
)
184192

185193
# Create modified context without context parameter if needed
@@ -241,16 +249,21 @@ async def on_list_tools(
241249
request_context = self._get_request_context(context)
242250
try:
243251
get_client_info_from_request_context(self.server, request_context)
244-
identify_session(self.server, context.message, request_context)
252+
identity = identify_session(self.server, context.message, request_context)
245253
except Exception as e:
254+
identity = None
246255
write_to_log(f"Non-critical error in session handling: {e}")
247256

248257
params = getattr(context.message, "params", None)
258+
249259
event = UnredactedEvent(
250260
session_id=session_id,
251261
timestamp=datetime.now(timezone.utc),
252262
parameters=params.model_dump() if params else {},
253263
event_type=EventType.MCP_TOOLS_LIST.value,
264+
identify_actor_given_id=identity.user_id if identity else None,
265+
identify_actor_name=identity.user_name if identity else None,
266+
identify_data=identity.user_data if identity else None,
254267
)
255268

256269
try:

src/mcpcat/modules/overrides/mcp_server.py

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,16 @@ async def wrapped_initialize_handler(request: InitializeRequest) -> ServerResult
4141
"""Intercept initialize requests to add MCPCat data to the request context."""
4242
session_id = get_server_session_id(server)
4343
request_context = safe_request_context(server)
44-
identify_session(server, request, request_context)
44+
identity = identify_session(server, request, request_context)
45+
4546
event = UnredactedEvent(
4647
session_id=session_id,
4748
timestamp=datetime.now(timezone.utc),
4849
parameters=request.params.model_dump() if request.params else {},
4950
event_type=EventType.MCP_INITIALIZE.value,
51+
identify_actor_given_id=identity.user_id if identity else None,
52+
identify_actor_name=identity.user_name if identity else None,
53+
identify_data=identity.user_data if identity else None,
5054
)
5155

5256
# Call the original handler
@@ -64,14 +68,18 @@ async def wrapped_list_tools_handler(request: ListToolsRequest) -> ServerResult:
6468
session_id = get_server_session_id(server)
6569
request_context = safe_request_context(server)
6670
get_client_info_from_request_context(server, request_context)
67-
identify_session(server, request, request_context)
71+
identity = identify_session(server, request, request_context)
72+
6873
event = UnredactedEvent(
6974
session_id=session_id,
7075
timestamp=datetime.now(timezone.utc),
7176
parameters=request.params.model_dump()
7277
if request and request.params
7378
else {},
7479
event_type=EventType.MCP_TOOLS_LIST.value,
80+
identify_actor_given_id=identity.user_id if identity else None,
81+
identify_actor_name=identity.user_name if identity else None,
82+
identify_data=identity.user_data if identity else None,
7583
)
7684

7785
# Call the original handler to get the tools
@@ -142,7 +150,7 @@ async def wrapped_call_tool_handler(request: CallToolRequest) -> ServerResult:
142150
session_id = get_server_session_id(server)
143151
request_context = safe_request_context(server)
144152
get_client_info_from_request_context(server, request_context)
145-
identify_session(server, request, request_context)
153+
identity = identify_session(server, request, request_context)
146154

147155
write_to_log(
148156
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:
153161
parameters=request.params.model_dump() if request.params else {},
154162
event_type=EventType.MCP_TOOLS_CALL.value,
155163
resource_name=tool_name,
164+
identify_actor_given_id=identity.user_id if identity else None,
165+
identify_actor_name=identity.user_name if identity else None,
166+
identify_data=identity.user_data if identity else None,
156167
)
157168

158169
# 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
220231
"""Intercept initialize requests to add MCPCat data to the request context."""
221232
session_id = get_server_session_id(server)
222233
request_context = safe_request_context(server)
223-
identify_session(server, request, request_context)
234+
try:
235+
identity = identify_session(server, request, request_context)
236+
except Exception as e:
237+
identity = None
238+
write_to_log(f"Ran into an error in session identification, no identity could be determined: {e}")
239+
224240
event = UnredactedEvent(
225241
session_id=session_id,
226242
timestamp=datetime.now(timezone.utc),
227243
parameters=request.params.model_dump() if request.params else {},
228244
event_type=EventType.MCP_INITIALIZE.value,
245+
identify_actor_given_id=identity.user_id if identity else None,
246+
identify_actor_name=identity.user_name if identity else None,
247+
identify_data=identity.user_data if identity else None,
229248
)
230249

231250
# Call the original handler
@@ -241,14 +260,18 @@ async def wrapped_list_tools_handler(request: ListToolsRequest) -> ServerResult:
241260
session_id = get_server_session_id(server)
242261
request_context = safe_request_context(server)
243262
get_client_info_from_request_context(server, request_context)
244-
identify_session(server, request, request_context)
263+
identity = identify_session(server, request, request_context)
264+
245265
event = UnredactedEvent(
246266
session_id=session_id,
247267
timestamp=datetime.now(timezone.utc),
248268
parameters=request.params.model_dump()
249269
if request and request.params
250270
else {},
251271
event_type=EventType.MCP_TOOLS_LIST.value,
272+
identify_actor_given_id=identity.user_id if identity else None,
273+
identify_actor_name=identity.user_name if identity else None,
274+
identify_data=identity.user_data if identity else None,
252275
)
253276

254277
# Call the original handler - tool modifications are handled by monkey-patch

src/mcpcat/modules/overrides/official/monkey_patch.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,8 +259,9 @@ async def patched_call_tool(
259259
},
260260
)()
261261

262-
identify_session(server._mcp_server, mock_request, request_context)
262+
identity = identify_session(server._mcp_server, mock_request, request_context)
263263
except Exception as e:
264+
identity = None
264265
write_to_log(f"Non-critical error in session handling: {e}")
265266
# Continue without session identification
266267

@@ -294,6 +295,9 @@ async def patched_call_tool(
294295
event_type=EventType.MCP_TOOLS_CALL.value,
295296
resource_name=name,
296297
user_intent=user_intent,
298+
identify_actor_given_id=identity.user_id if identity else None,
299+
identify_actor_name=identity.user_name if identity else None,
300+
identify_data=identity.user_data if identity else None,
297301
)
298302
except Exception as e:
299303
write_to_log(f"Error creating event: {e}")

src/mcpcat/modules/session.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ def get_client_info_from_request_context(
138138
def get_session_info(server: Server, data: MCPCatData | None = None) -> SessionInfo:
139139
"""Get session information for the current MCP session."""
140140
actor_info: Optional[UserIdentity] = None
141-
if data:
141+
if data and not data.is_stateless:
142142
actor_info = data.identified_sessions.get(data.session_id, None)
143143

144144
session_info = SessionInfo(
@@ -176,12 +176,15 @@ def set_last_activity(server: Server) -> None:
176176
set_server_tracking_data(server, data)
177177

178178

179-
def get_server_session_id(server: Server) -> str:
179+
def get_server_session_id(server: Server) -> str | None:
180180
data = get_server_tracking_data(server)
181181

182182
if not data:
183183
raise Exception("MCPCat data not initialized for this server")
184184

185+
if data.is_stateless:
186+
return None
187+
185188
now = datetime.now(timezone.utc)
186189
timeout = timedelta(minutes=INACTIVITY_TIMEOUT_IN_MINUTES)
187190
# If last activity timed out

src/mcpcat/types.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,10 @@ class Event(PublishEventRequest):
4646

4747
# Error tracking types
4848

49+
4950
class StackFrame(TypedDict, total=False):
5051
"""Stack frame information for error tracking."""
52+
5153
filename: str
5254
abs_path: str
5355
function: str # Function name or "<module>"
@@ -59,6 +61,7 @@ class StackFrame(TypedDict, total=False):
5961

6062
class ChainedErrorData(TypedDict, total=False):
6163
"""Chained exception data (from __cause__ or __context__)."""
64+
6265
message: str
6366
type: NotRequired[str | None]
6467
stack: NotRequired[str]
@@ -67,8 +70,11 @@ class ChainedErrorData(TypedDict, total=False):
6770

6871
class ErrorData(TypedDict, total=False):
6972
"""Complete error information for an exception."""
73+
7074
message: str
71-
type: NotRequired[str | None] # Exception class name (e.g., "ValueError", "TypeError")
75+
type: NotRequired[
76+
str | None
77+
] # Exception class name (e.g., "ValueError", "TypeError")
7278
stack: NotRequired[str]
7379
frames: NotRequired[list[StackFrame]]
7480
chained_errors: NotRequired[list[ChainedErrorData]]
@@ -158,7 +164,7 @@ class MCPCatOptions:
158164
exporters: dict[str, ExporterConfig] | None = None
159165
debug_mode: bool = False
160166
api_base_url: str | None = None
161-
167+
stateless: bool | None = None
162168

163169

164170
@dataclass
@@ -177,3 +183,4 @@ class MCPCatData:
177183
wrapped_tools: Set[str] = field(default_factory=set)
178184
tracker_initialized: bool = False
179185
monkey_patched: bool = False
186+
is_stateless: bool = False

0 commit comments

Comments
 (0)