diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py
index ad886c66de7..9c8ad2f4613 100644
--- a/litellm/proxy/_experimental/mcp_server/server.py
+++ b/litellm/proxy/_experimental/mcp_server/server.py
@@ -9,6 +9,7 @@ import contextlib
import contextvars
import hashlib
import json
+import os
import time
import traceback
import types
@@ -84,7 +85,13 @@ from litellm.proxy.litellm_pre_call_utils import (
LiteLLMProxyRequestSetup,
get_chain_id_from_headers,
)
-from litellm.types.mcp import MCPAuth, MCPSpecVersion
+from litellm.types.mcp import (
+ MCPAuth,
+ MCPGatewaySession,
+ MCPGatewaySessionGroupCount,
+ MCPGatewaySessionsResponse,
+ MCPSpecVersion,
+)
from litellm.types.mcp_server.mcp_server_manager import MCPInfo, MCPServer
from litellm.types.utils import CallTypes, StandardLoggingMCPToolCall
from litellm.utils import Rules, client, function_setup
@@ -454,6 +461,8 @@ if MCP_AVAILABLE:
StreamableHTTPSessionManager = None
from mcp.types import (
CallToolResult,
+ Implementation,
+ InitializeRequest,
ListToolsResult,
Prompt,
TextContent,
@@ -607,6 +616,7 @@ if MCP_AVAILABLE:
# still reading the shared object.
_stateful_session_locks: Final[dict[str, asyncio.Lock]] = {}
_stateful_session_active_request_counts: Final[dict[str, int]] = {}
+ _stateful_session_client_info: Final[dict[str, Implementation]] = {} # mutable-ok: cleared on session teardown
class _TerminableTransport(Protocol):
async def terminate(self) -> None: ...
@@ -625,6 +635,7 @@ if MCP_AVAILABLE:
_stateful_session_owners.pop(session_id, None)
_stateful_session_locks.pop(session_id, None)
_stateful_session_active_request_counts.pop(session_id, None)
+ _stateful_session_client_info.pop(session_id, None)
# Keep this alias so existing references to session_manager still work
session_manager: Final = session_manager_stateless
@@ -3816,6 +3827,63 @@ if MCP_AVAILABLE:
except (json.JSONDecodeError, TypeError):
return False
+ def _extract_initialize_client_info(body: bytes) -> Implementation | None:
+ try:
+ return InitializeRequest.model_validate_json(body).params.clientInfo
+ except ValidationError:
+ return None
+
+ def _group_session_counts(
+ sessions: Sequence[MCPGatewaySession],
+ label_for: Callable[[MCPGatewaySession], str | None],
+ ) -> tuple[MCPGatewaySessionGroupCount, ...]:
+ labels: Final = tuple(label_for(session) for session in sessions)
+ return tuple(
+ sorted(
+ (MCPGatewaySessionGroupCount(label=label, count=labels.count(label)) for label in frozenset(labels)),
+ key=lambda group: (-group.count, group.label is None, group.label or ""),
+ )
+ )
+
+ def _gateway_session_for(session_id: str, auth_user: MCPAuthenticatedUser, now: float) -> MCPGatewaySession:
+ client_info: Final = _stateful_session_client_info.get(session_id)
+ key_auth: Final = auth_user.user_api_key_auth
+ return MCPGatewaySession(
+ session_id_prefix=session_id[:8],
+ client_name=client_info.name if client_info is not None else None,
+ client_version=client_info.version if client_info is not None else None,
+ user_id=key_auth.user_id if key_auth is not None else None,
+ user_email=key_auth.user_email if key_auth is not None else None,
+ key_alias=key_auth.key_alias if key_auth is not None else None,
+ team_id=key_auth.team_id if key_auth is not None else None,
+ team_alias=key_auth.team_alias if key_auth is not None else None,
+ client_ip=auth_user.client_ip,
+ idle_seconds=max(0.0, now - _stateful_session_auth_context_last_seen.get(session_id, now)),
+ in_flight_requests=_stateful_session_active_request_counts.get(session_id, 0),
+ )
+
+ def get_mcp_gateway_sessions_report(now: float | None = None) -> MCPGatewaySessionsResponse:
+ """Live stateful Streamable HTTP sessions held by this worker process.
+
+ Only sessions whose transport is still registered with the stateful
+ session manager are reported; SSE and stateless requests hold no
+ session and are never counted.
+ """
+ report_time: Final = time.monotonic() if now is None else now
+ live_session_ids: Final = frozenset(_stateful_server_instances())
+ sessions: Final = tuple(
+ _gateway_session_for(session_id, auth_user, report_time)
+ for session_id, auth_user in tuple(_stateful_session_auth_contexts.items())
+ if session_id in live_session_ids
+ )
+ return MCPGatewaySessionsResponse(
+ worker_pid=os.getpid(),
+ total_sessions=len(sessions),
+ by_client=_group_session_counts(sessions, lambda session: session.client_name),
+ by_user=_group_session_counts(sessions, lambda session: session.user_id),
+ sessions=sessions,
+ )
+
async def _read_request_body_for_routing(
receive: Receive,
) -> tuple[list[Message], bytes]:
@@ -4652,6 +4720,7 @@ if MCP_AVAILABLE:
auth_user,
_owner_fingerprint_for(user_api_key_auth, oauth2_headers, _client_ip),
_track_initialized_stateful_session,
+ client_info=_extract_initialize_client_info(body),
)
async with _gateway_initialize_instructions_request_scope(
@@ -4965,6 +5034,7 @@ if MCP_AVAILABLE:
auth_user: MCPAuthenticatedUser,
owner_fingerprint: str,
on_session_registered: Callable[[str], None] | None = None,
+ client_info: Implementation | None = None,
) -> Send:
async def wrapped_send(message: Message) -> None:
if message.get("type") == "http.response.start":
@@ -4979,6 +5049,8 @@ if MCP_AVAILABLE:
_stateful_session_auth_contexts[session_id] = auth_user
_stateful_session_auth_context_last_seen[session_id] = time.monotonic()
_stateful_session_owners[session_id] = owner_fingerprint
+ if client_info is not None:
+ _stateful_session_client_info[session_id] = client_info
break
await send(message)
diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json
index 216b9f6def6..82b709feb92 100644
--- a/litellm/proxy/_lazy_openapi_snapshot.json
+++ b/litellm/proxy/_lazy_openapi_snapshot.json
@@ -27473,6 +27473,181 @@
"title": "MCPEnvVarScope",
"type": "string"
},
+ "MCPGatewaySession": {
+ "description": "One live stateful Streamable HTTP session held by this proxy worker.",
+ "properties": {
+ "client_ip": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Client Ip"
+ },
+ "client_name": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Client Name"
+ },
+ "client_version": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Client Version"
+ },
+ "idle_seconds": {
+ "title": "Idle Seconds",
+ "type": "number"
+ },
+ "in_flight_requests": {
+ "title": "In Flight Requests",
+ "type": "integer"
+ },
+ "key_alias": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Key Alias"
+ },
+ "session_id_prefix": {
+ "title": "Session Id Prefix",
+ "type": "string"
+ },
+ "team_alias": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Team Alias"
+ },
+ "team_id": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Team Id"
+ },
+ "user_email": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "User Email"
+ },
+ "user_id": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "User Id"
+ }
+ },
+ "required": [
+ "session_id_prefix",
+ "idle_seconds",
+ "in_flight_requests"
+ ],
+ "title": "MCPGatewaySession",
+ "type": "object"
+ },
+ "MCPGatewaySessionGroupCount": {
+ "properties": {
+ "count": {
+ "title": "Count",
+ "type": "integer"
+ },
+ "label": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Label"
+ }
+ },
+ "required": [
+ "count"
+ ],
+ "title": "MCPGatewaySessionGroupCount",
+ "type": "object"
+ },
+ "MCPGatewaySessionsResponse": {
+ "properties": {
+ "by_client": {
+ "items": {
+ "$ref": "#/components/schemas/MCPGatewaySessionGroupCount"
+ },
+ "title": "By Client",
+ "type": "array"
+ },
+ "by_user": {
+ "items": {
+ "$ref": "#/components/schemas/MCPGatewaySessionGroupCount"
+ },
+ "title": "By User",
+ "type": "array"
+ },
+ "sessions": {
+ "items": {
+ "$ref": "#/components/schemas/MCPGatewaySession"
+ },
+ "title": "Sessions",
+ "type": "array"
+ },
+ "total_sessions": {
+ "title": "Total Sessions",
+ "type": "integer"
+ },
+ "worker_pid": {
+ "title": "Worker Pid",
+ "type": "integer"
+ }
+ },
+ "required": [
+ "worker_pid",
+ "total_sessions"
+ ],
+ "title": "MCPGatewaySessionsResponse",
+ "type": "object"
+ },
"MCPOAuthUserCredentialRequest": {
"description": "Stores a user's OAuth2 token for an OpenAPI MCP server.",
"properties": {
@@ -30207,6 +30382,33 @@
]
}
},
+ "/v1/mcp/sessions": {
+ "get": {
+ "description": "Live stateful MCP gateway sessions on this proxy worker, grouped by AI client and by user.",
+ "operationId": "get_mcp_gateway_sessions_v1_mcp_sessions_get",
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/MCPGatewaySessionsResponse"
+ }
+ }
+ },
+ "description": "Successful Response"
+ }
+ },
+ "security": [
+ {
+ "APIKeyHeader": []
+ }
+ ],
+ "summary": "Get Mcp Gateway Sessions",
+ "tags": [
+ "mcp_management"
+ ]
+ }
+ },
"/v1/mcp/tools": {
"get": {
"description": "Get all MCP tools available for the current key, including those from access groups",
diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py
index 680c63393e8..ff32d4784df 100644
--- a/litellm/proxy/_types.py
+++ b/litellm/proxy/_types.py
@@ -531,6 +531,7 @@ class LiteLLMRoutes(enum.Enum):
mcp_management_routes = [
"/v1/mcp/server",
"/v1/mcp/server/{path:path}",
+ "/v1/mcp/sessions",
]
# Backwards-compat union — virtual keys may be configured with
diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py
index 6fa91c16eb2..82a1cdcdd00 100644
--- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py
+++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py
@@ -22,7 +22,7 @@ import os
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
-from typing import TYPE_CHECKING, Final, Literal, Protocol
+from typing import TYPE_CHECKING, Annotated, Final, Literal, Protocol
from fastapi import (
APIRouter,
@@ -220,6 +220,7 @@ if MCP_AVAILABLE:
MCP_ADMIN_CONFIG_CREDENTIAL_KEYS,
MCPAuth,
MCPCredentials,
+ MCPGatewaySessionsResponse,
normalize_upstream_header_name,
)
from litellm.types.mcp_server.mcp_server_manager import MCPServer
@@ -1346,6 +1347,32 @@ if MCP_AVAILABLE:
# Do NOT add to runtime registry — pending servers are not active
return _redact_mcp_credentials(new_mcp_server)
+ @router.get(
+ "/sessions",
+ description="Live stateful MCP gateway sessions on this proxy worker, grouped by AI client and by user.",
+ dependencies=(Depends(user_api_key_auth),),
+ response_model=MCPGatewaySessionsResponse,
+ )
+ @management_endpoint_wrapper
+ async def get_mcp_gateway_sessions(
+ user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
+ ) -> MCPGatewaySessionsResponse:
+ if user_api_key_dict.user_role not in (
+ LitellmUserRoles.PROXY_ADMIN,
+ LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY,
+ ):
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail={ # mutable-ok: HTTPException detail must be a plain mapping to keep this route's {"error": ...} response shape
+ "error": "Admin access required to view MCP gateway sessions."
+ },
+ )
+ from litellm.proxy._experimental.mcp_server.server import (
+ get_mcp_gateway_sessions_report,
+ )
+
+ return get_mcp_gateway_sessions_report()
+
@router.get(
"/server/submissions",
description="Returns all MCP servers submitted by non-admin users (admin review queue). Mirrors GET /guardrails/submissions.",
diff --git a/litellm/types/mcp.py b/litellm/types/mcp.py
index a59fcb1bcb5..2d06bb9a009 100644
--- a/litellm/types/mcp.py
+++ b/litellm/types/mcp.py
@@ -435,3 +435,32 @@ class MCPPostCallResponseObject(BaseModel):
mcp_tool_call_response: list[MCPTextContent | MCPImageContent | MCPEmbeddedResource]
hidden_params: HiddenParams
+
+
+class MCPGatewaySession(BaseModel):
+ """One live stateful Streamable HTTP session held by this proxy worker."""
+
+ session_id_prefix: str
+ client_name: str | None = None
+ client_version: str | None = None
+ user_id: str | None = None
+ user_email: str | None = None
+ key_alias: str | None = None
+ team_id: str | None = None
+ team_alias: str | None = None
+ client_ip: str | None = None
+ idle_seconds: float
+ in_flight_requests: int
+
+
+class MCPGatewaySessionGroupCount(BaseModel):
+ label: str | None = None
+ count: int
+
+
+class MCPGatewaySessionsResponse(BaseModel):
+ worker_pid: int
+ total_sessions: int
+ by_client: list[MCPGatewaySessionGroupCount] = Field(default_factory=list)
+ by_user: list[MCPGatewaySessionGroupCount] = Field(default_factory=list)
+ sessions: list[MCPGatewaySession] = Field(default_factory=list)
diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py
index 02182ebbe60..bbc36991e21 100644
--- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py
+++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py
@@ -2609,6 +2609,246 @@ async def test_initialize_request_tracks_active_session_after_response_header():
mcp_server._remove_stateful_session_tracking(session_id)
+_INITIALIZE_WITH_CLIENT_INFO: Final = (
+ b'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18",'
+ b'"capabilities":{},"clientInfo":{"name":"claude-code","version":"1.0.0"}}}'
+)
+
+
+@pytest.mark.parametrize(
+ ("body", "expected_name", "expected_version"),
+ [
+ (_INITIALIZE_WITH_CLIENT_INFO, "claude-code", "1.0.0"),
+ (
+ b'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18",'
+ b'"capabilities":{},"clientInfo":{"name":"","version":"0"}}}',
+ "",
+ "0",
+ ),
+ ],
+)
+def test_extract_initialize_client_info_reads_client_name_and_version(body, expected_name, expected_version):
+ try:
+ from litellm.proxy._experimental.mcp_server import server as mcp_server
+ except ImportError:
+ pytest.skip("MCP server not available")
+
+ client_info = mcp_server._extract_initialize_client_info(body)
+
+ assert client_info is not None
+ assert client_info.name == expected_name
+ assert client_info.version == expected_version
+
+
+@pytest.mark.parametrize(
+ "body",
+ [
+ b"",
+ b"not json",
+ b'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}',
+ b'{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}',
+ ],
+)
+def test_extract_initialize_client_info_returns_none_without_client_info(body):
+ try:
+ from litellm.proxy._experimental.mcp_server import server as mcp_server
+ except ImportError:
+ pytest.skip("MCP server not available")
+
+ assert mcp_server._extract_initialize_client_info(body) is None
+
+
+@pytest.mark.asyncio
+async def test_initialize_request_records_client_name_in_gateway_sessions_report():
+ """The real initialize body's clientInfo is attributed to the session the
+ stateful manager creates, together with the authenticated user."""
+ try:
+ from litellm.proxy._experimental.mcp_server import server as mcp_server
+ from litellm.proxy._experimental.mcp_server.server import (
+ handle_streamable_http_mcp,
+ session_manager_stateful,
+ session_manager_stateless,
+ )
+ except ImportError:
+ pytest.skip("MCP server not available")
+
+ session_id = "initialize-client-info-session-1"
+ owner_auth = UserAPIKeyAuth(
+ api_key="initialize-key",
+ user_id="user-a",
+ user_email="a@example.com",
+ key_alias="alice-key",
+ team_id="team-1",
+ )
+ scope = {
+ "type": "http",
+ "method": "POST",
+ "path": "/mcp",
+ "headers": [
+ (b"content-type", b"application/json"),
+ (b"authorization", b"Bearer initialize-key"),
+ ],
+ }
+ receive = AsyncMock(return_value={"type": "http.request", "body": _INITIALIZE_WITH_CLIENT_INFO, "more_body": False})
+ instances: dict[str, object] = {}
+
+ async def stateful_handle(s, r, se):
+ instances[session_id] = MagicMock()
+ await se(
+ {
+ "type": "http.response.start",
+ "headers": [(b"mcp-session-id", session_id.encode())],
+ }
+ )
+
+ async def stateless_handle(s, r, se):
+ raise AssertionError("initialize request should use stateful manager")
+
+ try:
+ with (
+ patch( # test-quality-ok: admission auth is resolved by a module-level function; the suite's only seam
+ "litellm.proxy._experimental.mcp_server.server.extract_mcp_auth_context",
+ new_callable=AsyncMock,
+ return_value=(owner_auth, None, None, None, None, None),
+ ),
+ patch( # test-quality-ok: registry is empty in unit tests; key owns one server
+ "litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers",
+ new_callable=AsyncMock,
+ return_value=[MagicMock()],
+ ),
+ patch( # test-quality-ok: session manager init is a module-level flag; the suite's only seam
+ "litellm.proxy._experimental.mcp_server.server._SESSION_MANAGERS_INITIALIZED",
+ True,
+ ),
+ patch.object( # test-quality-ok: the transports are module-level singletons; the suite's only seam
+ session_manager_stateful, "handle_request", side_effect=stateful_handle
+ ),
+ patch.object( # test-quality-ok: the transports are module-level singletons; the suite's only seam
+ session_manager_stateless, "handle_request", side_effect=stateless_handle
+ ),
+ patch.object( # test-quality-ok: the transport registry is a module-level singleton; the suite's only seam
+ session_manager_stateful, "_server_instances", instances
+ ),
+ patch.dict( # test-quality-ok: the session tables are module-level singletons; the suite's only seam
+ mcp_server._stateful_session_auth_contexts, {}, clear=True
+ ),
+ patch.dict( # test-quality-ok: the session tables are module-level singletons; the suite's only seam
+ mcp_server._stateful_session_client_info, {}, clear=True
+ ),
+ ):
+ await handle_streamable_http_mcp(scope, receive, AsyncMock())
+ report = mcp_server.get_mcp_gateway_sessions_report()
+
+ assert report.total_sessions == 1
+ assert [session.model_dump() for session in report.sessions] == [
+ {
+ "session_id_prefix": session_id[:8],
+ "client_name": "claude-code",
+ "client_version": "1.0.0",
+ "user_id": "user-a",
+ "user_email": "a@example.com",
+ "key_alias": "alice-key",
+ "team_id": "team-1",
+ "team_alias": None,
+ "client_ip": "",
+ "idle_seconds": report.sessions[0].idle_seconds,
+ "in_flight_requests": 0,
+ }
+ ]
+ assert [(group.label, group.count) for group in report.by_client] == [("claude-code", 1)]
+ assert [(group.label, group.count) for group in report.by_user] == [("user-a", 1)]
+ assert "initialize-key" not in report.model_dump_json()
+ finally:
+ mcp_server._remove_stateful_session_tracking(session_id)
+
+
+def test_gateway_sessions_report_groups_live_sessions_by_client_and_user():
+ try:
+ from litellm.proxy._experimental.mcp_server import server as mcp_server
+ from litellm.proxy._experimental.mcp_server.server import session_manager_stateful
+ except ImportError:
+ pytest.skip("MCP server not available")
+ from mcp.types import Implementation
+
+ def auth_user(user_id: str) -> object:
+ return mcp_server.MCPAuthenticatedUser(
+ user_api_key_auth=UserAPIKeyAuth(api_key=f"key-{user_id}", user_id=user_id),
+ client_ip="10.0.0.1",
+ )
+
+ contexts = {
+ "alice-1": auth_user("alice"),
+ "alice-2": auth_user("alice"),
+ "bob-1": auth_user("bob"),
+ "anon-1": mcp_server.MCPAuthenticatedUser(user_api_key_auth=None),
+ "gone-1": auth_user("alice"),
+ }
+ client_info = {
+ "alice-1": Implementation(name="claude-code", version="1.0.0"),
+ "alice-2": Implementation(name="claude-code", version="1.0.1"),
+ "bob-1": Implementation(name="cursor", version="0.50.0"),
+ "gone-1": Implementation(name="cursor", version="0.50.0"),
+ }
+ last_seen = {"alice-1": 90.0, "alice-2": 100.0, "bob-1": 70.0, "anon-1": 100.0, "gone-1": 100.0}
+ live_instances = {session_id: MagicMock() for session_id in ("alice-1", "alice-2", "bob-1", "anon-1")}
+
+ with (
+ patch.object( # test-quality-ok: the transport registry is a module-level singleton; the suite's only seam
+ session_manager_stateful, "_server_instances", live_instances
+ ),
+ patch.dict( # test-quality-ok: the session tables are module-level singletons; the suite's only seam
+ mcp_server._stateful_session_auth_contexts, contexts, clear=True
+ ),
+ patch.dict( # test-quality-ok: the session tables are module-level singletons; the suite's only seam
+ mcp_server._stateful_session_client_info, client_info, clear=True
+ ),
+ patch.dict( # test-quality-ok: the session tables are module-level singletons; the suite's only seam
+ mcp_server._stateful_session_auth_context_last_seen, last_seen, clear=True
+ ),
+ patch.dict( # test-quality-ok: the session tables are module-level singletons; the suite's only seam
+ mcp_server._stateful_session_active_request_counts, {"bob-1": 2}, clear=True
+ ),
+ ):
+ report = mcp_server.get_mcp_gateway_sessions_report(now=100.0)
+
+ assert report.total_sessions == 4
+ assert [(group.label, group.count) for group in report.by_client] == [
+ ("claude-code", 2),
+ ("cursor", 1),
+ (None, 1),
+ ]
+ assert [(group.label, group.count) for group in report.by_user] == [
+ ("alice", 2),
+ ("bob", 1),
+ (None, 1),
+ ]
+ by_prefix = {session.session_id_prefix: session for session in report.sessions}
+ assert set(by_prefix) == {"alice-1", "alice-2", "bob-1", "anon-1"}
+ assert by_prefix["alice-1"].idle_seconds == 10.0
+ assert by_prefix["bob-1"].in_flight_requests == 2
+ assert by_prefix["bob-1"].client_ip == "10.0.0.1"
+ assert by_prefix["anon-1"].client_name is None
+ assert by_prefix["anon-1"].user_id is None
+ assert "key-alice" not in report.model_dump_json()
+
+
+def test_remove_stateful_session_tracking_drops_client_info():
+ try:
+ from litellm.proxy._experimental.mcp_server import server as mcp_server
+ except ImportError:
+ pytest.skip("MCP server not available")
+ from mcp.types import Implementation
+
+ session_id = "client-info-cleanup-session"
+ with patch.dict( # test-quality-ok: the session tables are module-level singletons; the suite's only seam
+ mcp_server._stateful_session_client_info,
+ {session_id: Implementation(name="cursor", version="1")},
+ clear=True,
+ ):
+ mcp_server._remove_stateful_session_tracking(session_id)
+ assert session_id not in mcp_server._stateful_session_client_info
+
+
@pytest.mark.asyncio
async def test_initialize_request_with_existing_session_tracks_new_session():
try:
diff --git a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py
index 54b190f7195..7c874aff3df 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py
@@ -2360,7 +2360,7 @@ class TestTemporaryMCPSessionEndpoints:
"litellm.proxy.management_endpoints.mcp_management_endpoints.validate_and_normalize_mcp_server_payload",
MagicMock(),
):
- with pytest.raises(Exception, match='User does not have permission to create temporary mcp') as exc_info:
+ with pytest.raises(Exception, match="User does not have permission to create temporary mcp") as exc_info:
await add_session_mcp_server(
payload=payload,
user_api_key_dict=non_admin,
@@ -4093,8 +4093,11 @@ async def test_health_discovery_respects_route_restricted_key_grants(
manager: Final = mcp_server_manager.MCPServerManager()
manager.registry = {
server_id: MCPServer(
- server_id=server_id, name=server_id, transport=MCPTransport.http,
- spec_path=f"https://93.184.216.34/{server_id}.json", auth_type=MCPAuth.none,
+ server_id=server_id,
+ name=server_id,
+ transport=MCPTransport.http,
+ spec_path=f"https://93.184.216.34/{server_id}.json",
+ auth_type=MCPAuth.none,
)
for server_id in ("server-x", "server-y")
}
@@ -4107,18 +4110,24 @@ async def test_health_discovery_respects_route_restricted_key_grants(
api_key="test-health-key",
allowed_routes=["/v1/mcp/server", "/v1/mcp/server/health"] if restricted else [],
object_permission=LiteLLM_ObjectPermissionTable(
- object_permission_id="health-permissions", mcp_servers=list(grants),
+ object_permission_id="health-permissions",
+ mcp_servers=list(grants),
),
)
with (
patch.object( # test-quality-ok: TQ008 inject real registry into legacy route binding
- mgmt_endpoints, "global_mcp_server_manager", manager,
+ mgmt_endpoints,
+ "global_mcp_server_manager",
+ manager,
),
patch.object( # test-quality-ok: TQ008 inject shared registry without mocking permission policy
- mcp_server_manager, "global_mcp_server_manager", manager,
+ mcp_server_manager,
+ "global_mcp_server_manager",
+ manager,
),
patch( # test-quality-ok: TQ008 configure mode without mocking authorization
- "litellm.proxy.proxy_server.general_settings", {"user_mcp_management_mode": mode},
+ "litellm.proxy.proxy_server.general_settings",
+ {"user_mcp_management_mode": mode},
),
):
result: Final = await mgmt_endpoints.health_check_servers(
@@ -7125,9 +7134,7 @@ class TestImportMCPServers:
import_mcp_servers,
)
- payload = MCPConnectorImportRequest.model_validate(
- {"mcpServers": {"srv": {"url": "https://x.example/mcp"}}}
- )
+ payload = MCPConnectorImportRequest.model_validate({"mcpServers": {"srv": {"url": "https://x.example/mcp"}}})
caller = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.INTERNAL_USER)
with patch( # test-quality-ok: endpoint takes collaborators from module scope, matching the suite's pattern
@@ -7263,3 +7270,54 @@ class TestImportMCPServers:
assert [entry.name for entry in result.imported] == ["new-server"]
mock_manager.reload_servers_from_database.assert_awaited_once()
+
+
+class TestGetMCPGatewaySessions:
+ @pytest.mark.asyncio
+ async def test_non_admin_forbidden(self):
+ from litellm.proxy.management_endpoints.mcp_management_endpoints import (
+ get_mcp_gateway_sessions,
+ )
+
+ non_admin = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.INTERNAL_USER)
+ with pytest.raises(HTTPException) as exc_info:
+ await get_mcp_gateway_sessions(user_api_key_dict=non_admin)
+ assert exc_info.value.status_code == 403
+
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize("role", [LitellmUserRoles.PROXY_ADMIN, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY])
+ async def test_admin_roles_receive_live_session_report(self, role):
+ from mcp.types import Implementation
+
+ from litellm.proxy._experimental.mcp_server import server as mcp_server
+ from litellm.proxy.management_endpoints.mcp_management_endpoints import (
+ get_mcp_gateway_sessions,
+ )
+ from litellm.types.mcp import MCPGatewaySessionsResponse
+
+ session_id = "gateway-sessions-endpoint-1"
+ auth_user = mcp_server.MCPAuthenticatedUser(
+ user_api_key_auth=UserAPIKeyAuth(api_key="sk-live-secret", user_id="alice"),
+ )
+ with (
+ patch.object( # test-quality-ok: the transport registry is a module-level singleton; the suite's only seam
+ mcp_server.session_manager_stateful, "_server_instances", {session_id: MagicMock()}
+ ),
+ patch.dict( # test-quality-ok: the session tables are module-level singletons; the suite's only seam
+ mcp_server._stateful_session_auth_contexts, {session_id: auth_user}, clear=True
+ ),
+ patch.dict( # test-quality-ok: the session tables are module-level singletons; the suite's only seam
+ mcp_server._stateful_session_client_info,
+ {session_id: Implementation(name="cursor", version="0.50.0")},
+ clear=True,
+ ),
+ ):
+ result = await get_mcp_gateway_sessions(
+ user_api_key_dict=generate_mock_user_api_key_auth(user_role=role),
+ )
+
+ assert isinstance(result, MCPGatewaySessionsResponse)
+ assert result.total_sessions == 1
+ assert [(group.label, group.count) for group in result.by_client] == [("cursor", 1)]
+ assert [(group.label, group.count) for group in result.by_user] == [("alice", 1)]
+ assert "sk-live-secret" not in result.model_dump_json()
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPGatewaySessionsTab.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPGatewaySessionsTab.integration.test.tsx
new file mode 100644
index 00000000000..11328ff1a3d
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPGatewaySessionsTab.integration.test.tsx
@@ -0,0 +1,138 @@
+import React from "react";
+import { render, screen, within } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { MCPGatewaySessionsTab, formatIdleSeconds } from "./MCPGatewaySessionsTab";
+import * as networking from "@/components/networking";
+import type { MCPGatewaySessionsResponse } from "@/components/mcp_tools/types";
+
+vi.mock("@/components/networking", () => ({
+ fetchMCPGatewaySessions: vi.fn(),
+}));
+
+const REPORT: MCPGatewaySessionsResponse = {
+ worker_pid: 4242,
+ total_sessions: 3,
+ by_client: [
+ { label: "claude-code", count: 2 },
+ { label: "cursor", count: 1 },
+ ],
+ by_user: [
+ { label: "alice", count: 2 },
+ { label: null, count: 1 },
+ ],
+ sessions: [
+ {
+ session_id_prefix: "aaaa1111",
+ client_name: "claude-code",
+ client_version: "1.0.0",
+ user_id: "alice",
+ user_email: "alice@example.com",
+ key_alias: "alice-key",
+ team_id: "team-1",
+ team_alias: "platform",
+ client_ip: "10.0.0.1",
+ idle_seconds: 75,
+ in_flight_requests: 0,
+ },
+ {
+ session_id_prefix: "bbbb2222",
+ client_name: "claude-code",
+ client_version: "1.0.1",
+ user_id: "alice",
+ user_email: null,
+ key_alias: null,
+ team_id: null,
+ team_alias: null,
+ client_ip: "",
+ idle_seconds: 3,
+ in_flight_requests: 1,
+ },
+ {
+ session_id_prefix: "cccc3333",
+ client_name: "cursor",
+ client_version: null,
+ user_id: null,
+ user_email: null,
+ key_alias: null,
+ team_id: null,
+ team_alias: null,
+ client_ip: null,
+ idle_seconds: 0,
+ in_flight_requests: 0,
+ },
+ ],
+};
+
+const renderTab = () => {
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
+ return render(
+
Loading live connections...
++ No live MCP connections on this worker (pid {data.worker_pid}). Connect an AI client to the gateway to see it + here. +
++ Stateful Streamable HTTP sessions currently open on this proxy worker, grouped by the AI client that sent + the MCP initialize request and by the authenticated LiteLLM user. Stateless requests and SSE connections are + not counted. +
+