feat(mcp): show live gateway sessions by AI client and user

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yassin 2026-09-17 22:15:55 +00:00
parent 356b8d4074
commit a9ab7392ae
13 changed files with 1131 additions and 13 deletions

View file

@ -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)

View file

@ -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",

View file

@ -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

View file

@ -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.",

View file

@ -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)

View file

@ -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:

View file

@ -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()

View file

@ -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(
<QueryClientProvider client={queryClient}>
<MCPGatewaySessionsTab accessToken="token" />
</QueryClientProvider>,
);
};
describe("formatIdleSeconds", () => {
it("renders seconds under a minute and minutes plus seconds above it", () => {
expect(formatIdleSeconds(0)).toBe("0s");
expect(formatIdleSeconds(59.9)).toBe("59s");
expect(formatIdleSeconds(60)).toBe("1m");
expect(formatIdleSeconds(75)).toBe("1m 15s");
expect(formatIdleSeconds(-4)).toBe("0s");
});
});
describe("MCPGatewaySessionsTab", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows grouped counts and session rows from /v1/mcp/sessions", async () => {
vi.mocked(networking.fetchMCPGatewaySessions).mockResolvedValue(REPORT);
renderTab();
const byClient = await screen.findByRole("region", { name: "Sessions by AI client" });
expect(within(byClient).getByRole("row", { name: /claude-code 2/ })).toBeInTheDocument();
expect(within(byClient).getByRole("row", { name: /cursor 1/ })).toBeInTheDocument();
const byUser = screen.getByRole("region", { name: "Sessions by user" });
expect(within(byUser).getByRole("row", { name: /alice 2/ })).toBeInTheDocument();
expect(within(byUser).getByRole("row", { name: /\(unknown\) 1/ })).toBeInTheDocument();
const sessions = screen.getByRole("region", { name: "Live sessions" });
const firstRow = within(sessions).getByRole("row", { name: /aaaa1111/ });
expect(firstRow).toHaveTextContent("claude-code");
expect(firstRow).toHaveTextContent("v1.0.0");
expect(firstRow).toHaveTextContent("alice@example.com");
expect(firstRow).toHaveTextContent("platform");
expect(firstRow).toHaveTextContent("1m 15s");
expect(within(sessions).getByRole("row", { name: /cccc3333/ })).toHaveTextContent("(unknown)");
expect(screen.getByText("Live sessions (worker pid 4242)")).toBeInTheDocument();
expect(networking.fetchMCPGatewaySessions).toHaveBeenCalledWith("token");
});
it("shows an empty state when the worker holds no live sessions", async () => {
const emptyReport: MCPGatewaySessionsResponse = {
worker_pid: 7,
total_sessions: 0,
by_client: [],
by_user: [],
sessions: [],
};
vi.mocked(networking.fetchMCPGatewaySessions).mockResolvedValue(emptyReport);
renderTab();
expect(await screen.findByText(/No live MCP connections on this worker \(pid 7\)/)).toBeInTheDocument();
expect(screen.queryByRole("region", { name: "Live sessions" })).not.toBeInTheDocument();
});
it("shows the API error when the request fails", async () => {
vi.mocked(networking.fetchMCPGatewaySessions).mockRejectedValue(new Error("Admin access required"));
renderTab();
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Could not load live connections");
expect(alert).toHaveTextContent("Admin access required");
});
});

View file

@ -0,0 +1,222 @@
"use client";
import React from "react";
import { useQuery } from "@tanstack/react-query";
import { RefreshCw } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { fetchMCPGatewaySessions } from "@/components/networking";
import type { MCPGatewaySessionGroupCount, MCPGatewaySessionsResponse } from "@/components/mcp_tools/types";
import { createQueryKeys } from "@/app/(dashboard)/hooks/common/queryKeysFactory";
const mcpGatewaySessionKeys = createQueryKeys("mcpGatewaySessions");
const REFETCH_INTERVAL_MS = 15000;
const UNKNOWN_LABEL = "(unknown)";
export function formatIdleSeconds(idleSeconds: number): string {
const total = Math.max(0, Math.floor(idleSeconds));
if (total < 60) return `${total}s`;
const minutes = Math.floor(total / 60);
const seconds = total % 60;
return seconds === 0 ? `${minutes}m` : `${minutes}m ${seconds}s`;
}
function groupLabel(label: string | null): string {
if (label === null) return UNKNOWN_LABEL;
return label === "" ? '""' : label;
}
function StatCard({ label, value }: { label: string; value: number }) {
return (
<div className="bg-card border border-border rounded-lg px-4 py-3">
<div className="text-2xl font-bold text-foreground">{value}</div>
<div className="text-xs text-muted-foreground mt-0.5">{label}</div>
</div>
);
}
function GroupCountTable({
title,
groups,
labelHeader,
}: {
title: string;
groups: MCPGatewaySessionGroupCount[];
labelHeader: string;
}) {
return (
<section aria-label={title} className="rounded-lg border border-border bg-card">
<h3 className="border-b border-border px-4 py-2 text-sm font-semibold text-foreground">{title}</h3>
<Table>
<TableHeader>
<TableRow>
<TableHead>{labelHeader}</TableHead>
<TableHead className="text-right">Sessions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groups.map((group) => (
<TableRow key={group.label ?? "__unknown__"}>
<TableCell className="font-mono text-xs">{groupLabel(group.label)}</TableCell>
<TableCell className="text-right">{group.count}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</section>
);
}
function SessionsBody({
data,
error,
isLoading,
}: {
data: MCPGatewaySessionsResponse | undefined;
error: Error | null;
isLoading: boolean;
}) {
if (isLoading) {
return (
<div
role="status"
className="flex items-center justify-center gap-3 rounded-lg border border-dashed border-border bg-card p-12"
>
<UiLoadingSpinner className="size-6 text-muted-foreground" />
<p className="text-sm text-muted-foreground">Loading live connections...</p>
</div>
);
}
if (error) {
return (
<Alert variant="destructive">
<AlertTitle>Could not load live connections</AlertTitle>
<AlertDescription>{error.message}</AlertDescription>
</Alert>
);
}
if (!data) return null;
if (data.total_sessions === 0) {
return (
<div className="rounded-lg border border-dashed border-border bg-card p-12 text-center">
<p className="text-sm text-muted-foreground">
No live MCP connections on this worker (pid {data.worker_pid}). Connect an AI client to the gateway to see it
here.
</p>
</div>
);
}
return (
<>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<StatCard label="Live sessions" value={data.total_sessions} />
<StatCard label="AI clients" value={data.by_client.length} />
<StatCard label="Users" value={data.by_user.length} />
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<GroupCountTable title="Sessions by AI client" labelHeader="Client" groups={data.by_client} />
<GroupCountTable title="Sessions by user" labelHeader="User" groups={data.by_user} />
</div>
<section aria-label="Live sessions" className="rounded-lg border border-border bg-card">
<h3 className="border-b border-border px-4 py-2 text-sm font-semibold text-foreground">
Live sessions (worker pid {data.worker_pid})
</h3>
<Table>
<TableHeader>
<TableRow>
<TableHead>Session</TableHead>
<TableHead>Client</TableHead>
<TableHead>User</TableHead>
<TableHead>Key alias</TableHead>
<TableHead>Team</TableHead>
<TableHead>Client IP</TableHead>
<TableHead className="text-right">Idle</TableHead>
<TableHead className="text-right">In flight</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.sessions.map((session) => (
<TableRow key={session.session_id_prefix}>
<TableCell className="font-mono text-xs">{session.session_id_prefix}</TableCell>
<TableCell>
{session.client_name === null ? (
<span className="text-muted-foreground">{UNKNOWN_LABEL}</span>
) : (
<>
<span className="font-mono text-xs">{groupLabel(session.client_name)}</span>
{session.client_version ? (
<span className="ml-1 text-xs text-muted-foreground">v{session.client_version}</span>
) : null}
</>
)}
</TableCell>
<TableCell>
{session.user_id === null ? (
<span className="text-muted-foreground">{UNKNOWN_LABEL}</span>
) : (
<>
<span className="font-mono text-xs">{session.user_id}</span>
{session.user_email ? (
<span className="ml-1 text-xs text-muted-foreground">{session.user_email}</span>
) : null}
</>
)}
</TableCell>
<TableCell className="text-xs">{session.key_alias ?? "-"}</TableCell>
<TableCell className="text-xs">{session.team_alias ?? session.team_id ?? "-"}</TableCell>
<TableCell className="font-mono text-xs">{session.client_ip || "-"}</TableCell>
<TableCell className="text-right text-xs">{formatIdleSeconds(session.idle_seconds)}</TableCell>
<TableCell className="text-right text-xs">{session.in_flight_requests}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</section>
</>
);
}
interface MCPGatewaySessionsTabProps {
accessToken: string | null;
}
export function MCPGatewaySessionsTab({ accessToken }: MCPGatewaySessionsTabProps) {
const queryOptions = {
queryKey: mcpGatewaySessionKeys.lists(),
queryFn: () => fetchMCPGatewaySessions(accessToken!),
enabled: !!accessToken,
refetchInterval: REFETCH_INTERVAL_MS,
};
const { data, error, isLoading, isFetching, refetch } = useQuery<MCPGatewaySessionsResponse, Error>(queryOptions);
return (
<div className="mt-4 space-y-4" data-testid="mcp-gateway-sessions-tab">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-foreground">Live Connections</h2>
<p className="text-sm text-muted-foreground">
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.
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
disabled={isFetching}
aria-label="Refresh live connections"
>
<RefreshCw className={`size-4 ${isFetching ? "animate-spin" : ""}`} />
Refresh
</Button>
</div>
<SessionsBody data={data} error={error} isLoading={isLoading} />
</div>
);
}
export default MCPGatewaySessionsTab;

View file

@ -22,6 +22,7 @@ import { useMCPServerHealth } from "@/app/(dashboard)/hooks/mcpServers/useMCPSer
import { toast } from "@/lib/toast";
import { deleteMCPServer } from "@/components/networking";
import { MCPSubmissionsTab } from "./MCPSubmissionsTab";
import { MCPGatewaySessionsTab } from "./MCPGatewaySessionsTab";
import { MCPToolsetsTab } from "./MCPToolsetsTab";
import CreateMCPServer from "./CreateMCPServer";
import ImportMCPServers from "./ImportMCPServers";
@ -560,6 +561,11 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
Submitted MCPs
</TabsTrigger>
)}
{isAdminRole(userRole) && (
<TabsTrigger value="connections" className="flex-none rounded-none px-4 py-2">
Live Connections
</TabsTrigger>
)}
</TabsList>
<TabsContent value="servers" keepMounted>
{selectedServerId ? (
@ -747,6 +753,11 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
<MCPSubmissionsTab accessToken={accessToken} />
</TabsContent>
)}
{isAdminRole(userRole) && (
<TabsContent value="connections">
<MCPGatewaySessionsTab accessToken={accessToken} />
</TabsContent>
)}
</Tabs>
{byokModalServer && (

View file

@ -560,3 +560,30 @@ export interface MCPSubmissionsSummary {
rejected: number;
items: MCPServer[];
}
export interface MCPGatewaySession {
session_id_prefix: string;
client_name: string | null;
client_version: string | null;
user_id: string | null;
user_email: string | null;
key_alias: string | null;
team_id: string | null;
team_alias: string | null;
client_ip: string | null;
idle_seconds: number;
in_flight_requests: number;
}
export interface MCPGatewaySessionGroupCount {
label: string | null;
count: number;
}
export interface MCPGatewaySessionsResponse {
worker_pid: number;
total_sessions: number;
by_client: MCPGatewaySessionGroupCount[];
by_user: MCPGatewaySessionGroupCount[];
sessions: MCPGatewaySession[];
}

View file

@ -97,7 +97,7 @@ import type { ModelBudgetUsage, ModelMaxBudget } from "./key_team_helpers/ModelM
import type { ObjectPermission } from "./object_permission_types";
import type { components } from "@/lib/http/schema";
import { jsonFields } from "./common_components/check_openapi_schema";
import type { MCPUserEnvVarsStatus } from "./mcp_tools/types";
import type { MCPGatewaySessionsResponse, MCPUserEnvVarsStatus } from "./mcp_tools/types";
import type {
CoordinationRedisSettings,
CoordinationRedisSettingsResponse,
@ -5109,6 +5109,9 @@ export const fetchMCPSubmissions = async (accessToken: string) => {
}
};
export const fetchMCPGatewaySessions = async (accessToken: string): Promise<MCPGatewaySessionsResponse> =>
apiClient.get<MCPGatewaySessionsResponse>(`/v1/mcp/sessions`, { accessToken });
export const approveMCPServer = async (accessToken: string, serverId: string) => {
try {
const url = (proxyBaseUrl ? `${proxyBaseUrl}` : "") + `/v1/mcp/server/${encodeURIComponent(serverId)}/approve`;

View file

@ -19002,6 +19002,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/v1/mcp/sessions": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get Mcp Gateway Sessions
* @description Live stateful MCP gateway sessions on this proxy worker, grouped by AI client and by user.
*/
get: operations["get_mcp_gateway_sessions_v1_mcp_sessions_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/mcp/tools": {
parameters: {
query?: never;
@ -32315,6 +32335,54 @@ export interface components {
* @enum {string}
*/
MCPEnvVarScope: "global" | "user";
/**
* MCPGatewaySession
* @description One live stateful Streamable HTTP session held by this proxy worker.
*/
MCPGatewaySession: {
/** Client Ip */
client_ip?: string | null;
/** Client Name */
client_name?: string | null;
/** Client Version */
client_version?: string | null;
/** Idle Seconds */
idle_seconds: number;
/** In Flight Requests */
in_flight_requests: number;
/** Key Alias */
key_alias?: string | null;
/** Session Id Prefix */
session_id_prefix: string;
/** Team Alias */
team_alias?: string | null;
/** Team Id */
team_id?: string | null;
/** User Email */
user_email?: string | null;
/** User Id */
user_id?: string | null;
};
/** MCPGatewaySessionGroupCount */
MCPGatewaySessionGroupCount: {
/** Count */
count: number;
/** Label */
label?: string | null;
};
/** MCPGatewaySessionsResponse */
MCPGatewaySessionsResponse: {
/** By Client */
by_client?: components["schemas"]["MCPGatewaySessionGroupCount"][];
/** By User */
by_user?: components["schemas"]["MCPGatewaySessionGroupCount"][];
/** Sessions */
sessions?: components["schemas"]["MCPGatewaySession"][];
/** Total Sessions */
total_sessions: number;
/** Worker Pid */
worker_pid: number;
};
/**
* MCPOAuthUserCredentialRequest
* @description Stores a user's OAuth2 token for an OpenAPI MCP server.
@ -65299,6 +65367,26 @@ export interface operations {
};
};
};
get_mcp_gateway_sessions_v1_mcp_sessions_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MCPGatewaySessionsResponse"];
};
};
};
};
get_mcp_tools_v1_mcp_tools_get: {
parameters: {
query?: never;