Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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" },
Expand All @@ -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",
]
Expand Down
27 changes: 27 additions & 0 deletions src/mcpcat/__init__.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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:
Expand Down Expand Up @@ -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)

Expand Down
20 changes: 16 additions & 4 deletions src/mcpcat/modules/identify.py
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I mean, in TypeScript we run it on every request. And we only actually send the MCPCat identify events to our servers if the identity of the user has changed. So I feel like you should change this to run on every request.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. So the TS SDK seems like it:

  1. Runs identify on every request.
  2. Compares against cached identity
  3. Only emits mcpcat:identify when it changes

That's a pretty big change to include into this PR though, so I can do it as a follow-up PR before we take this stateless stuff out of beta.

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
Expand Down
6 changes: 5 additions & 1 deletion src/mcpcat/modules/overrides/community/monkey_patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
19 changes: 16 additions & 3 deletions src/mcpcat/modules/overrides/community_v3/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,15 +107,19 @@ 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,
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:
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
33 changes: 28 additions & 5 deletions src/mcpcat/modules/overrides/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -64,14 +68,18 @@ 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),
parameters=request.params.model_dump()
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
Expand Down Expand Up @@ -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}"
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -241,14 +260,18 @@ 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),
parameters=request.params.model_dump()
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
Expand Down
6 changes: 5 additions & 1 deletion src/mcpcat/modules/overrides/official/monkey_patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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}")
Expand Down
7 changes: 5 additions & 2 deletions src/mcpcat/modules/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions src/mcpcat/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<module>"
Expand All @@ -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]
Expand All @@ -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]]
Expand Down Expand Up @@ -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
Expand All @@ -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
Loading
Loading