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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "mcpcat"
version = "0.1.15b1"
version = "0.1.15b2"
description = "Analytics Tool for MCP Servers - provides insights into MCP tool usage patterns"
authors = [
{ name = "MCPCat", email = "support@mcpcat.io" },
Expand Down
5 changes: 4 additions & 1 deletion src/mcpcat/modules/overrides/community/monkey_patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,10 @@ async def wrapped_call_tool_handler(request: CallToolRequest) -> ServerResult:

# Handle session identification
try:
get_client_info_from_request_context(lowlevel_server, request_context)
client_name, client_version = get_client_info_from_request_context(lowlevel_server, request_context)
identity = identify_session(lowlevel_server, request, request_context)
except Exception as e:
client_name, client_version = None, None
identity = None
write_to_log(f"Non-critical error in session handling: {e}")

Expand All @@ -124,6 +125,8 @@ async def wrapped_call_tool_handler(request: CallToolRequest) -> ServerResult:
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,
client_name=client_name,
client_version=client_version,
)

try:
Expand Down
24 changes: 18 additions & 6 deletions src/mcpcat/modules/overrides/community_v3/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,20 +93,24 @@ async def on_initialize(
session_id = self._get_session_id()
params = context.message.params

# Extract client info from initialize params
# Extract client info from initialize params (MCP protocol provides clientInfo here)
client_name, client_version = None, None
if params and hasattr(params, "clientInfo") and params.clientInfo:
client_info = params.clientInfo
if hasattr(client_info, "name") and client_info.name:
self.mcpcat_data.session_info.client_name = client_info.name
client_name = client_info.name
if hasattr(client_info, "version") and client_info.version:
self.mcpcat_data.session_info.client_version = client_info.version
client_version = client_info.version

# Handle session identification
# Note: Use self.server (FastMCP) not self.server._mcp_server because
# tracking data is stored with the FastMCP server as the key for v3
request_context = self._get_request_context(context)
try:
get_client_info_from_request_context(self.server, request_context)
if not client_name:
client_name, client_version = get_client_info_from_request_context(self.server, request_context)
else:
get_client_info_from_request_context(self.server, request_context)
identity = identify_session(self.server, context.message, request_context)
except Exception as e:
identity = None
Expand All @@ -120,6 +124,8 @@ async def on_initialize(
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,
client_name=client_name,
client_version=client_version,
)

try:
Expand Down Expand Up @@ -157,9 +163,10 @@ async def on_call_tool(
# tracking data is stored with the FastMCP server as the key for v3
request_context = self._get_request_context(context)
try:
get_client_info_from_request_context(self.server, request_context)
client_name, client_version = get_client_info_from_request_context(self.server, request_context)
identity = identify_session(self.server, context.message, request_context)
except Exception as e:
client_name, client_version = None, None
identity = None
write_to_log(f"Non-critical error in session handling: {e}")

Expand Down Expand Up @@ -188,6 +195,8 @@ async def on_call_tool(
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,
client_name=client_name,
client_version=client_version,
)

# Create modified context without context parameter if needed
Expand Down Expand Up @@ -248,9 +257,10 @@ async def on_list_tools(
# tracking data is stored with the FastMCP server as the key for v3
request_context = self._get_request_context(context)
try:
get_client_info_from_request_context(self.server, request_context)
client_name, client_version = get_client_info_from_request_context(self.server, request_context)
identity = identify_session(self.server, context.message, request_context)
except Exception as e:
client_name, client_version = None, None
identity = None
write_to_log(f"Non-critical error in session handling: {e}")

Expand All @@ -264,6 +274,8 @@ async def on_list_tools(
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,
client_name=client_name,
client_version=client_version,
)

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 @@ -43,6 +43,14 @@ async def wrapped_initialize_handler(request: InitializeRequest) -> ServerResult
request_context = safe_request_context(server)
identity = identify_session(server, request, request_context)

# Extract clientInfo from InitializeRequest params (MCP protocol provides it here)
client_name, client_version = None, None
if request.params and hasattr(request.params, 'clientInfo') and request.params.clientInfo:
client_name = request.params.clientInfo.name
client_version = getattr(request.params.clientInfo, 'version', None)
if not client_name:
client_name, client_version = get_client_info_from_request_context(server, request_context)

event = UnredactedEvent(
session_id=session_id,
timestamp=datetime.now(timezone.utc),
Expand All @@ -51,13 +59,13 @@ async def wrapped_initialize_handler(request: InitializeRequest) -> ServerResult
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,
client_name=client_name,
client_version=client_version,
)

# Call the original handler
result = await original_initialize_handler(request)

# TODO: Grab client and server information from the request

# Record the event
event.response = result.model_dump() if result else None
event_queue.publish_event(server, event)
Expand All @@ -67,7 +75,7 @@ async def wrapped_list_tools_handler(request: ListToolsRequest) -> ServerResult:
"""Intercept list_tools requests to add MCPCat tools and modify existing ones."""
session_id = get_server_session_id(server)
request_context = safe_request_context(server)
get_client_info_from_request_context(server, request_context)
client_name, client_version = get_client_info_from_request_context(server, request_context)
identity = identify_session(server, request, request_context)

event = UnredactedEvent(
Expand All @@ -80,6 +88,8 @@ async def wrapped_list_tools_handler(request: ListToolsRequest) -> ServerResult:
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,
client_name=client_name,
client_version=client_version,
)

# Call the original handler to get the tools
Expand Down Expand Up @@ -149,7 +159,7 @@ async def wrapped_call_tool_handler(request: CallToolRequest) -> ServerResult:
arguments = request.params.arguments or {}
session_id = get_server_session_id(server)
request_context = safe_request_context(server)
get_client_info_from_request_context(server, request_context)
client_name, client_version = get_client_info_from_request_context(server, request_context)
identity = identify_session(server, request, request_context)

write_to_log(
Expand All @@ -164,6 +174,8 @@ async def wrapped_call_tool_handler(request: CallToolRequest) -> ServerResult:
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,
client_name=client_name,
client_version=client_version,
)

# Extract user intent from context (but don't pop yet - we need it for the event)
Expand Down Expand Up @@ -237,6 +249,13 @@ 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}")

client_name, client_version = None, None
if request.params and hasattr(request.params, 'clientInfo') and request.params.clientInfo:
client_name = request.params.clientInfo.name
client_version = getattr(request.params.clientInfo, 'version', None)
if not client_name:
client_name, client_version = get_client_info_from_request_context(server, request_context)
Comment on lines +253 to +257
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.

Did you do a db look up to see what they look like?

Separately, I'm worried initialized is either:

  1. Not consistently published via the Python SDK
  2. May have a different client ID and version than is advertised via headers

Not sure how you want to handle that since it may mean new sessions created incorrectly...

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.

Maybe good to do an analysis on DB for some of these questions :|

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.

Will look into this as a separate PR. Can merge this for now since it's still a beta version.


event = UnredactedEvent(
session_id=session_id,
timestamp=datetime.now(timezone.utc),
Expand All @@ -245,6 +264,8 @@ async def wrapped_initialize_handler(request: InitializeRequest) -> ServerResult
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,
client_name=client_name,
client_version=client_version,
)

# Call the original handler
Expand All @@ -259,7 +280,7 @@ async def wrapped_list_tools_handler(request: ListToolsRequest) -> ServerResult:
"""Intercept list_tools requests to track the event (tool modifications handled by monkey-patch)."""
session_id = get_server_session_id(server)
request_context = safe_request_context(server)
get_client_info_from_request_context(server, request_context)
client_name, client_version = get_client_info_from_request_context(server, request_context)
identity = identify_session(server, request, request_context)

event = UnredactedEvent(
Expand All @@ -272,6 +293,8 @@ async def wrapped_list_tools_handler(request: ListToolsRequest) -> ServerResult:
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,
client_name=client_name,
client_version=client_version,
)

# Call the original handler - tool modifications are handled by monkey-patch
Expand Down
8 changes: 5 additions & 3 deletions src/mcpcat/modules/overrides/official/monkey_patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,9 +239,9 @@ async def patched_call_tool(
# Handle session identification (non-critical)
try:
request_context = safe_request_context(server._mcp_server)
# Only call if request_context is not None
client_name, client_version = (None, None)
if request_context is not None:
get_client_info_from_request_context(
client_name, client_version = get_client_info_from_request_context(
server._mcp_server, request_context
)

Expand All @@ -261,9 +261,9 @@ async def patched_call_tool(

identity = identify_session(server._mcp_server, mock_request, request_context)
except Exception as e:
client_name, client_version = None, None
identity = None
write_to_log(f"Non-critical error in session handling: {e}")
# Continue without session identification

# Extract user intent (non-critical)
user_intent = None
Expand Down Expand Up @@ -298,6 +298,8 @@ async def patched_call_tool(
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,
client_name=client_name,
client_version=client_version,
)
except Exception as e:
write_to_log(f"Error creating event: {e}")
Expand Down
71 changes: 40 additions & 31 deletions src/mcpcat/modules/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,80 +59,89 @@ def get_headers_from_request_context(

def get_client_info_from_request_context(
server: Server, request_context: RequestContext | None
) -> None:
) -> tuple[str | None, str | None]:
"""Extract client information from request context or HTTP headers.

Returns (client_name, client_version). In stateless mode, extracts per-request
without caching. In stateful mode, caches on shared session_info.

This function is designed to be resilient and never fail - any error is logged
but won't affect the server operation.
"""
# Handle None request_context (e.g., in stateless HTTP mode outside handlers)
if request_context is None:
write_to_log("Request context is None, skipping client info extraction")
return
return (None, None)

try:
data = get_server_tracking_data(server)
if not data:
return
return (None, None)

client_name: str | None = None
client_version: str | None = None

# If client name and version are already set, no need to fetch again
if data.session_info.client_name and data.session_info.client_version:
return
# In stateful mode, return cached values if already set
if not data.is_stateless and data.session_info.client_name and data.session_info.client_version:
return (data.session_info.client_name, data.session_info.client_version)

try:
# Try to get from session (stateful mode)
# Try to get from MCP session (stateful mode)
if hasattr(request_context, "session") and request_context.session:
client_info = request_context.session.client_params.clientInfo
if client_info:
data.session_info.client_name = client_info.name
data.session_info.client_version = client_info.version
set_server_tracking_data(server, data)
return
except (AttributeError, TypeError) as e:
client_name = client_info.name
client_version = client_info.version
if not data.is_stateless:
data.session_info.client_name = client_name
data.session_info.client_version = client_version
set_server_tracking_data(server, data)
return (client_name, client_version)
except (AttributeError, TypeError):
# This is expected in stateless mode, just continue
pass
except Exception as e:
# Unexpected error, log but continue
write_to_log(f"Error extracting client info from session: {e}")

# Fallback: Try to extract from HTTP headers (stateless mode)
try:
headers = get_headers_from_request_context(request_context)
if headers:
# Check User-Agent header
# Parse User-Agent header (format: "ClientName/Version ...")
user_agent = headers.get("user-agent", "")
if user_agent:
# Parse User-Agent for client info
# Format could be: "ClientName/Version (additional info)"
match = re.match(r"^([^/]+)/([^\s]+)", user_agent)
if match:
data.session_info.client_name = match.group(1)
data.session_info.client_version = match.group(2)
client_name = match.group(1)
client_version = match.group(2)
else:
# If no neat match, use the whole string as client_name
data.session_info.client_name = user_agent
# No neat match, use the whole string as client_name
client_name = user_agent

# Also check custom MCP headers if any
# Clients might send: X-MCP-Client-Name, X-MCP-Client-Version
# Custom MCP headers override User-Agent if present
if headers.get("x-mcp-client-name"):
data.session_info.client_name = headers.get("x-mcp-client-name")
client_name = headers.get("x-mcp-client-name")
if headers.get("x-mcp-client-version"):
data.session_info.client_version = headers.get(
"x-mcp-client-version"
)
client_version = headers.get("x-mcp-client-version")

if data.session_info.client_name or data.session_info.client_version:
if not data.is_stateless and (client_name or client_version):
data.session_info.client_name = client_name
data.session_info.client_version = client_version
set_server_tracking_data(server, data)

if client_name or client_version:
write_to_log(
f"Extracted client info from headers: {data.session_info.client_name} v{data.session_info.client_version}"
f"Extracted client info from headers: {client_name} v{client_version}"
)
except Exception as e:
write_to_log(f"Error extracting client info from headers: {e}")
# Continue without client info

return (client_name, client_version)
except Exception as e:
# Catch-all for any unexpected errors - log but never fail
write_to_log(f"Unexpected error in get_client_info_from_request_context: {e}")
# Function continues and returns normally
return (None, None)


def get_session_info(server: Server, data: MCPCatData | None = None) -> SessionInfo:
Expand All @@ -148,10 +157,10 @@ def get_session_info(server: Server, data: MCPCatData | None = None) -> SessionI
server_name=server.name if hasattr(server, "name") else None,
server_version=server.version if hasattr(server, "version") else None,
client_name=data.session_info.client_name
if data and data.session_info
if data and data.session_info and not data.is_stateless
else None,
client_version=data.session_info.client_version
if data and data.session_info
if data and data.session_info and not data.is_stateless
else None,
identify_actor_given_id=actor_info.user_id if actor_info else None,
identify_actor_name=actor_info.user_name if actor_info else None,
Expand Down
Loading
Loading