mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +00:00
Merge pull request #39234 from BerriAI/litellm_fix_agent_mcp_grants
fix(mcp): clear error when an agent-bound key is denied a scoped MCP server + agent MCP grants in the UI
This commit is contained in:
commit
26d589cd28
22 changed files with 1185 additions and 123 deletions
|
|
@ -82,7 +82,7 @@ async def anthropic_messages_with_mcp(
|
|||
LiteLLM_Proxy_MCP_Handler,
|
||||
)
|
||||
|
||||
mcp_references, other_tools = LiteLLM_Proxy_MCP_Handler._parse_mcp_tools(tools)
|
||||
mcp_references, other_tools = await LiteLLM_Proxy_MCP_Handler._split_mcp_tools(tools)
|
||||
|
||||
if not mcp_references:
|
||||
return await _AnthropicMessagesCall(fn=litellm.anthropic_messages).fn(
|
||||
|
|
|
|||
|
|
@ -3108,15 +3108,17 @@ class MCPRequestHandler:
|
|||
@staticmethod
|
||||
async def _get_allowed_mcp_servers_for_agent(
|
||||
user_api_key_auth: UserAPIKeyAuth | None = None,
|
||||
agent_object_permission=None,
|
||||
agent_object_permission: LiteLLM_ObjectPermissionTable | None = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Get allowed MCP servers for an agent (from the agent's object_permission).
|
||||
|
||||
Returns the MCP servers from the agent's object_permission.
|
||||
If agent has no object_permission, returns [] (no extra restriction). An entitlement the
|
||||
agent LINKS but that cannot be read raises ``UnloadableEntitlementError`` out of here so the
|
||||
resolver denies.
|
||||
Returns the agent's direct servers, the servers in its access groups, and the servers reached
|
||||
through its toolsets, exactly as the key, team, and org levels count theirs. If agent has no
|
||||
object_permission, returns [] (no extra restriction). An entitlement the agent LINKS but that
|
||||
cannot be read, or a declared toolset that resolves to no grants, raises
|
||||
``UnloadableEntitlementError`` out of here so the resolver denies instead of reading the
|
||||
agent as unrestricted.
|
||||
|
||||
Args:
|
||||
user_api_key_auth: User auth with agent_id
|
||||
|
|
@ -3126,31 +3128,30 @@ class MCPRequestHandler:
|
|||
if not user_api_key_auth or not user_api_key_auth.agent_id:
|
||||
return []
|
||||
|
||||
obj_perm = agent_object_permission
|
||||
if obj_perm is None:
|
||||
obj_perm = await MCPRequestHandler._get_agent_object_permission(user_api_key_auth)
|
||||
obj_perm: Final = (
|
||||
agent_object_permission
|
||||
if agent_object_permission is not None
|
||||
else await MCPRequestHandler._get_agent_object_permission(user_api_key_auth)
|
||||
)
|
||||
if obj_perm is None:
|
||||
return []
|
||||
|
||||
try:
|
||||
direct_mcp_servers = getattr(obj_perm, "mcp_servers", None) or []
|
||||
if isinstance(direct_mcp_servers, str):
|
||||
direct_mcp_servers = []
|
||||
mcp_access_groups = getattr(obj_perm, "mcp_access_groups", None) or []
|
||||
if isinstance(mcp_access_groups, str):
|
||||
mcp_access_groups = []
|
||||
|
||||
# Permission entries may be server_ids OR names/aliases — expand to ids.
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
|
||||
global_mcp_server_manager,
|
||||
)
|
||||
|
||||
expanded_direct_servers: Final = global_mcp_server_manager.expand_permission_list(list(direct_mcp_servers))
|
||||
|
||||
access_group_servers: Final = await MCPRequestHandler._get_mcp_servers_from_access_groups(mcp_access_groups)
|
||||
all_servers: Final = expanded_direct_servers + access_group_servers
|
||||
return list(set(all_servers))
|
||||
expanded_direct_servers: Final = global_mcp_server_manager.expand_permission_list(
|
||||
obj_perm.mcp_servers or []
|
||||
)
|
||||
access_group_servers: Final = await MCPRequestHandler._get_mcp_servers_from_access_groups(
|
||||
obj_perm.mcp_access_groups or []
|
||||
)
|
||||
toolset_grants: Final = await MCPRequestHandler._toolset_tool_permissions(obj_perm)
|
||||
return list({*expanded_direct_servers, *access_group_servers, *toolset_grants})
|
||||
except Exception as e:
|
||||
if isinstance(e, UnloadableEntitlementError):
|
||||
raise
|
||||
verbose_logger.warning("Failed to get allowed MCP servers for agent: %s", e)
|
||||
return []
|
||||
|
||||
|
|
@ -3158,13 +3159,15 @@ class MCPRequestHandler:
|
|||
async def _get_agent_tool_permissions_for_server(
|
||||
server_id: str,
|
||||
user_api_key_auth: UserAPIKeyAuth | None = None,
|
||||
agent_object_permission=None,
|
||||
agent_object_permission: LiteLLM_ObjectPermissionTable | None = None,
|
||||
) -> list[str] | None:
|
||||
"""
|
||||
Get allowed tool names for a server from the agent's object_permission.
|
||||
Returns None if agent has no tool restrictions for this server. An entitlement the agent
|
||||
LINKS but that cannot be read raises ``UnloadableEntitlementError`` out of here, which the
|
||||
tool resolver turns into deny-all for the server rather than an unrestricted tool list.
|
||||
Get allowed tool names for a server from the agent's object_permission: the union of its
|
||||
direct tool permissions and the tools its toolsets grant on that server, mirroring the key and
|
||||
team levels. Returns None if agent has no tool restrictions for this server. An entitlement the
|
||||
agent LINKS but that cannot be read, or a declared toolset that resolves to no grants, raises
|
||||
``UnloadableEntitlementError`` out of here, which the tool resolver turns into deny-all for the
|
||||
server rather than an unrestricted tool list.
|
||||
|
||||
Args:
|
||||
server_id: Server ID to check permissions for
|
||||
|
|
@ -3175,24 +3178,30 @@ class MCPRequestHandler:
|
|||
if not user_api_key_auth or not user_api_key_auth.agent_id:
|
||||
return None
|
||||
|
||||
obj_perm = agent_object_permission
|
||||
if obj_perm is None:
|
||||
obj_perm = await MCPRequestHandler._get_agent_object_permission(user_api_key_auth)
|
||||
obj_perm: Final = (
|
||||
agent_object_permission
|
||||
if agent_object_permission is not None
|
||||
else await MCPRequestHandler._get_agent_object_permission(user_api_key_auth)
|
||||
)
|
||||
if obj_perm is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
mcp_tool_permissions: Final = getattr(obj_perm, "mcp_tool_permissions", None)
|
||||
if not mcp_tool_permissions or not isinstance(mcp_tool_permissions, dict):
|
||||
return None
|
||||
# Dict keys may be server_ids OR names/aliases; normalize before lookup.
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
|
||||
global_mcp_server_manager,
|
||||
)
|
||||
|
||||
tools: Final = global_mcp_server_manager.expand_tool_permissions(mcp_tool_permissions).get(server_id)
|
||||
return list(tools) if tools else None
|
||||
direct_tools: Final = (
|
||||
global_mcp_server_manager.expand_tool_permissions(obj_perm.mcp_tool_permissions).get(server_id)
|
||||
if obj_perm.mcp_tool_permissions
|
||||
else None
|
||||
)
|
||||
toolset_tools: Final = await MCPRequestHandler._toolset_tools_for_server(obj_perm, server_id)
|
||||
agent_tools: Final = MCPRequestHandler._union_tool_grants(direct_tools, toolset_tools)
|
||||
return list(agent_tools) if agent_tools else None
|
||||
except Exception as e:
|
||||
if isinstance(e, UnloadableEntitlementError):
|
||||
raise
|
||||
verbose_logger.warning("Failed to get agent tool permissions for server: %s", e)
|
||||
return None
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ from pydantic import AnyUrl, ConfigDict
|
|||
from starlette.requests import Request as StarletteRequest
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.types import Message, Receive, Scope, Send
|
||||
from typing_extensions import ReadOnly, TypedDict
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.constants import MAXIMUM_TRACEBACK_LINES_TO_LOG
|
||||
|
|
@ -816,6 +817,11 @@ if MCP_AVAILABLE:
|
|||
}
|
||||
}
|
||||
return ListToolsResult.model_validate({"tools": listing.tools, "_meta": outcome_meta})
|
||||
except HTTPException as e:
|
||||
from mcp.shared.exceptions import McpError
|
||||
from mcp.types import INVALID_REQUEST, ErrorData
|
||||
|
||||
raise McpError(ErrorData(code=INVALID_REQUEST, message=_http_detail_message(e.detail))) from e
|
||||
except Exception as e:
|
||||
verbose_logger.exception("Error in list_tools endpoint: %s", e)
|
||||
# Return empty list instead of failing completely
|
||||
|
|
@ -1095,6 +1101,7 @@ if MCP_AVAILABLE:
|
|||
mcp_server_auth_headers=mcp_server_auth_headers,
|
||||
oauth2_headers=oauth2_headers,
|
||||
raw_headers=raw_headers,
|
||||
client_ip=_client_ip,
|
||||
host_progress_callback=host_progress_callback,
|
||||
**data, # for logging
|
||||
)
|
||||
|
|
@ -1128,7 +1135,7 @@ if MCP_AVAILABLE:
|
|||
except HTTPException as e:
|
||||
verbose_logger.error("HTTPException in MCP tool call: %s", e)
|
||||
return CallToolResult(
|
||||
content=[TextContent(text=f"Error: {e.detail}", type="text")],
|
||||
content=[TextContent(text=f"Error: {_http_detail_message(e.detail)}", type="text")],
|
||||
isError=True,
|
||||
)
|
||||
except MCPUpstreamAuthError as e:
|
||||
|
|
@ -1392,7 +1399,7 @@ if MCP_AVAILABLE:
|
|||
########################################################
|
||||
|
||||
async def _get_allowed_mcp_servers_from_mcp_server_names(
|
||||
mcp_servers: list[str] | None,
|
||||
mcp_servers: Sequence[str] | None,
|
||||
allowed_mcp_servers: list[MCPServer],
|
||||
) -> list[MCPServer]:
|
||||
"""
|
||||
|
|
@ -1413,13 +1420,10 @@ if MCP_AVAILABLE:
|
|||
server_name_matched = False
|
||||
|
||||
for server in allowed_mcp_servers:
|
||||
if server:
|
||||
match_list = [s.lower() for s in iter_known_server_prefixes(server) if s]
|
||||
|
||||
if server_or_group.lower() in match_list:
|
||||
filtered_server[server.server_id] = server
|
||||
server_name_matched = True
|
||||
break
|
||||
if server and _server_answers_to(server, server_or_group):
|
||||
filtered_server[server.server_id] = server
|
||||
server_name_matched = True
|
||||
break
|
||||
|
||||
if not server_name_matched:
|
||||
try:
|
||||
|
|
@ -1449,6 +1453,72 @@ if MCP_AVAILABLE:
|
|||
|
||||
return allowed_mcp_servers
|
||||
|
||||
def _http_detail_message(detail: object) -> str:
|
||||
return str(detail.get("error")) if isinstance(detail, dict) and detail.get("error") else str(detail)
|
||||
|
||||
def _server_answers_to(server: MCPServer, name: str) -> bool:
|
||||
requested: Final = name.lower()
|
||||
return any(requested == known.lower() for known in iter_known_server_prefixes(server) if known)
|
||||
|
||||
class _McpDeniedDetail(TypedDict):
|
||||
error: ReadOnly[str]
|
||||
|
||||
async def raise_denied_scoped_mcp_access(
|
||||
requested_names: Sequence[str],
|
||||
user_api_key_auth: UserAPIKeyAuth | None,
|
||||
client_ip: str | None = None,
|
||||
) -> None:
|
||||
"""A scoped request (``/mcp/<name>`` path or ``x-mcp-servers`` header) resolved to zero
|
||||
allowed servers, so the denial must be loud: a silent 200 with no tools reads as a healthy
|
||||
server with no tools. Unknown, unauthorized, and access-group names all share one generic
|
||||
error so scoping cannot probe which servers exist; the agent variant fires only when the
|
||||
same request resolves once the agent binding is stripped, proving the binding caused the veto."""
|
||||
agent_id: Final = user_api_key_auth.agent_id if user_api_key_auth else None
|
||||
if user_api_key_auth is not None and agent_id:
|
||||
resolved_without_agent: Final = await _get_allowed_mcp_servers(
|
||||
user_api_key_auth=user_api_key_auth.model_copy(update=types.MappingProxyType({"agent_id": None})),
|
||||
mcp_servers=requested_names,
|
||||
client_ip=client_ip,
|
||||
)
|
||||
|
||||
def _resolved_to_server(name: str) -> bool:
|
||||
return any(_server_answers_to(server, name) for server in resolved_without_agent)
|
||||
|
||||
vetoed_server: Final = next((name for name in requested_names if _resolved_to_server(name)), None)
|
||||
if vetoed_server is not None:
|
||||
agent_denial: Final[_McpDeniedDetail] = {
|
||||
"error": (
|
||||
f"MCP server '{vetoed_server}' is not available to this key: the key is bound to "
|
||||
f"agent '{agent_id}', whose MCP grants do not include this server. Add the server "
|
||||
f"to the agent's object_permission.mcp_servers (edit the agent in the Admin UI or "
|
||||
f"PATCH /v1/agents/{agent_id}), or use a key that is not bound to the agent."
|
||||
)
|
||||
}
|
||||
raise HTTPException(status_code=403, detail=agent_denial)
|
||||
vetoed_group: Final = next(
|
||||
(
|
||||
name
|
||||
for name in requested_names
|
||||
if not _resolved_to_server(name)
|
||||
and any(name in (server.access_groups or ()) for server in resolved_without_agent)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if vetoed_group is not None:
|
||||
group_denial: Final[_McpDeniedDetail] = {
|
||||
"error": (
|
||||
f"MCP access group '{vetoed_group}' is not available to this key: the key is bound to "
|
||||
f"agent '{agent_id}', whose MCP grants do not include it. Add the group to the "
|
||||
f"agent's object_permission.mcp_access_groups (edit the agent in the Admin UI or "
|
||||
f"PATCH /v1/agents/{agent_id}), or use a key that is not bound to the agent."
|
||||
)
|
||||
}
|
||||
raise HTTPException(status_code=403, detail=group_denial)
|
||||
generic_denial: Final[_McpDeniedDetail] = {
|
||||
"error": f"The key is not allowed to access the requested MCP servers: {', '.join(requested_names)}"
|
||||
}
|
||||
raise HTTPException(status_code=403, detail=generic_denial)
|
||||
|
||||
def _tool_name_matches(tool_name: str, filter_list: list[str], mcp_server: MCPServer) -> bool:
|
||||
"""
|
||||
Check if a tool name matches any name in the filter list.
|
||||
|
|
@ -1541,7 +1611,7 @@ if MCP_AVAILABLE:
|
|||
|
||||
async def _get_allowed_mcp_servers(
|
||||
user_api_key_auth: UserAPIKeyAuth | None,
|
||||
mcp_servers: list[str] | None,
|
||||
mcp_servers: Sequence[str] | None,
|
||||
client_ip: str | None = None,
|
||||
) -> list[MCPServer]:
|
||||
"""Return allowed MCP servers for a request after applying filters.
|
||||
|
|
@ -1977,6 +2047,12 @@ if MCP_AVAILABLE:
|
|||
mcp_servers=mcp_servers,
|
||||
client_ip=client_ip,
|
||||
)
|
||||
if mcp_servers and not allowed_mcp_servers:
|
||||
await raise_denied_scoped_mcp_access(
|
||||
requested_names=mcp_servers,
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
client_ip=client_ip,
|
||||
)
|
||||
|
||||
# Pre-fetch OAuth credentials only when at least one server uses OAuth2,
|
||||
# to avoid an unnecessary DB round-trip on requests with no OAuth2 MCP servers.
|
||||
|
|
@ -2404,6 +2480,8 @@ if MCP_AVAILABLE:
|
|||
)
|
||||
verbose_logger.debug("Successfully fetched %s tools from managed MCP servers", len(listing.tools))
|
||||
return listing
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
verbose_logger.exception("Error getting tools from managed MCP servers: %s", e)
|
||||
# Continue with an empty listing instead of failing completely
|
||||
|
|
@ -3086,6 +3164,7 @@ if MCP_AVAILABLE:
|
|||
mcp_server_auth_headers: dict[str, dict[str, str]] | None = None,
|
||||
oauth2_headers: dict[str, str] | None = None,
|
||||
raw_headers: dict[str, str] | None = None,
|
||||
client_ip: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> CallToolResult:
|
||||
"""
|
||||
|
|
@ -3116,6 +3195,12 @@ if MCP_AVAILABLE:
|
|||
mcp_servers=mcp_servers,
|
||||
allowed_mcp_servers=allowed_mcp_servers,
|
||||
)
|
||||
if mcp_servers and not allowed_mcp_servers:
|
||||
await raise_denied_scoped_mcp_access(
|
||||
requested_names=mcp_servers,
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
client_ip=client_ip,
|
||||
)
|
||||
if not allowed_mcp_servers:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
|
|
|
|||
|
|
@ -366,6 +366,7 @@ async def handle_mcp_tool_call(
|
|||
from litellm.proxy._experimental.mcp_server.server import (
|
||||
_get_allowed_mcp_servers,
|
||||
execute_mcp_tool,
|
||||
raise_denied_scoped_mcp_access,
|
||||
)
|
||||
|
||||
allowed_mcp_servers: Final = await _get_allowed_mcp_servers(
|
||||
|
|
@ -373,6 +374,12 @@ async def handle_mcp_tool_call(
|
|||
mcp_servers=mcp_servers,
|
||||
client_ip=client_ip,
|
||||
)
|
||||
if mcp_servers and not allowed_mcp_servers:
|
||||
await raise_denied_scoped_mcp_access(
|
||||
requested_names=mcp_servers,
|
||||
user_api_key_auth=user_api_key_dict,
|
||||
client_ip=client_ip,
|
||||
)
|
||||
|
||||
# Reject before dispatch when the key has no accessible servers; otherwise an
|
||||
# unprefixed local tool name would fall through to the local registry in
|
||||
|
|
|
|||
|
|
@ -2634,6 +2634,20 @@
|
|||
],
|
||||
"title": "Mcp Tool Permissions"
|
||||
},
|
||||
"mcp_toolsets": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Mcp Toolsets"
|
||||
},
|
||||
"models": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ Pre-call hook that filters MCP tools semantically before LLM inference.
|
|||
Reduces context window size and improves tool selection accuracy.
|
||||
"""
|
||||
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from collections.abc import Awaitable, Callable, Collection, Iterable, Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Final, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
|
@ -104,7 +104,7 @@ class SemanticToolFilterHook(CustomLogger):
|
|||
)
|
||||
|
||||
# Parse to separate MCP tools from other tools
|
||||
mcp_tools, _ = LiteLLM_Proxy_MCP_Handler._parse_mcp_tools(tools)
|
||||
mcp_tools, _ = await LiteLLM_Proxy_MCP_Handler._split_mcp_tools(tools)
|
||||
|
||||
if not mcp_tools:
|
||||
return []
|
||||
|
|
@ -173,7 +173,11 @@ class SemanticToolFilterHook(CustomLogger):
|
|||
return [name for name in names if name]
|
||||
|
||||
@staticmethod
|
||||
def _narrow_mcp_references(tools: Sequence[Mapping[str, object]], selected_tool_names: list[str]) -> list[object]:
|
||||
async def _narrow_mcp_references(
|
||||
tools: Sequence[Mapping[str, object]],
|
||||
selected_tool_names: list[str],
|
||||
served_names: Callable[[Collection[str]], Awaitable[frozenset[str]]] | None = None,
|
||||
) -> list[object]:
|
||||
"""
|
||||
Restrict each litellm_proxy MCP reference to the semantically selected tools.
|
||||
|
||||
|
|
@ -192,13 +196,14 @@ class SemanticToolFilterHook(CustomLogger):
|
|||
LiteLLM_Proxy_MCP_Handler,
|
||||
)
|
||||
|
||||
via_gateway: Final = await (
|
||||
LiteLLM_Proxy_MCP_Handler.routes_through_gateway(tools, served_names)
|
||||
if served_names is not None
|
||||
else LiteLLM_Proxy_MCP_Handler.routes_through_gateway(tools)
|
||||
)
|
||||
return [
|
||||
(
|
||||
{**tool, "allowed_tools": selected_tool_names}
|
||||
if isinstance(tool, dict) and LiteLLM_Proxy_MCP_Handler._should_use_litellm_mcp_gateway([tool])
|
||||
else tool
|
||||
)
|
||||
for tool in tools
|
||||
{**tool, "allowed_tools": selected_tool_names} if isinstance(tool, dict) and routed else tool
|
||||
for tool, routed in zip(tools, via_gateway, strict=True)
|
||||
]
|
||||
|
||||
def _is_mcp_tool(self, tool: object) -> bool:
|
||||
|
|
@ -325,7 +330,7 @@ class SemanticToolFilterHook(CustomLogger):
|
|||
filtered_expanded_tools = await self._filter_expanded_tools(data=data, expanded_tools=expanded_tools)
|
||||
|
||||
selected_tool_names: Final = self._selected_tool_names(filtered_expanded_tools)
|
||||
narrowed_tools: Final = self._narrow_mcp_references(tools, selected_tool_names)
|
||||
narrowed_tools: Final = await self._narrow_mcp_references(tools, selected_tool_names)
|
||||
data["tools"] = narrowed_tools
|
||||
self._emit_filter_metadata_safe(
|
||||
data=data,
|
||||
|
|
|
|||
|
|
@ -177,7 +177,7 @@ async def aresponses_api_with_mcp(
|
|||
(
|
||||
mcp_tools_with_litellm_proxy,
|
||||
other_tools,
|
||||
) = LiteLLM_Proxy_MCP_Handler._parse_mcp_tools(tools)
|
||||
) = await LiteLLM_Proxy_MCP_Handler._split_mcp_tools(tools)
|
||||
|
||||
# Process MCP tools through the complete pipeline (fetch + filter + deduplicate + transform)
|
||||
# Extract user_api_key_auth from litellm_metadata (where it's added by add_user_api_key_auth_to_request_metadata)
|
||||
|
|
@ -236,6 +236,7 @@ async def aresponses_api_with_mcp(
|
|||
"timeout": timeout,
|
||||
"custom_llm_provider": custom_llm_provider,
|
||||
**kwargs,
|
||||
"_skip_mcp_handler": True,
|
||||
}
|
||||
|
||||
# Handle MCP streaming if requested
|
||||
|
|
@ -898,13 +899,14 @@ def _responses_try_dispatch_mcp_gateway(
|
|||
custom_llm_provider: str | None,
|
||||
kwargs: dict[str, object],
|
||||
_is_async: bool,
|
||||
skip_mcp_handler: bool,
|
||||
) -> Any | None:
|
||||
"""Return a response when MCP gateway handles the call; otherwise None."""
|
||||
from litellm.responses.mcp.litellm_proxy_mcp_handler import (
|
||||
LiteLLM_Proxy_MCP_Handler,
|
||||
)
|
||||
|
||||
if not LiteLLM_Proxy_MCP_Handler._should_use_litellm_mcp_gateway(tools=tools):
|
||||
if skip_mcp_handler or not LiteLLM_Proxy_MCP_Handler._should_use_litellm_mcp_gateway(tools=tools):
|
||||
return None
|
||||
mcp_call_kwargs: Final = {
|
||||
"input": input,
|
||||
|
|
@ -1074,6 +1076,7 @@ def responses(
|
|||
litellm_logging_obj: Final[LiteLLMLoggingObj] = kwargs.get("litellm_logging_obj")
|
||||
litellm_call_id: Final[str | None] = kwargs.get("litellm_call_id", None)
|
||||
_is_async: Final = kwargs.pop("aresponses", False) is True
|
||||
skip_mcp_handler: Final = kwargs.pop("_skip_mcp_handler", False)
|
||||
use_chat_completions_api = _pop_use_chat_completions_api_kw(kwargs)
|
||||
|
||||
client_headers: Final = kwargs.get("headers")
|
||||
|
|
@ -1168,6 +1171,7 @@ def responses(
|
|||
custom_llm_provider=custom_llm_provider,
|
||||
kwargs=kwargs,
|
||||
_is_async=_is_async,
|
||||
skip_mcp_handler=skip_mcp_handler,
|
||||
)
|
||||
if _mcp_dispatch is not None:
|
||||
return _mcp_dispatch
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ async def acompletion_with_mcp(
|
|||
(
|
||||
mcp_tools_with_litellm_proxy,
|
||||
other_tools,
|
||||
) = LiteLLM_Proxy_MCP_Handler._parse_mcp_tools(tools)
|
||||
) = await LiteLLM_Proxy_MCP_Handler._split_mcp_tools(tools)
|
||||
|
||||
if not mcp_tools_with_litellm_proxy:
|
||||
# No MCP tools, proceed with regular completion
|
||||
|
|
@ -114,6 +114,7 @@ async def acompletion_with_mcp(
|
|||
model=model,
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
_skip_mcp_handler=True,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import re
|
||||
import traceback
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from collections.abc import Awaitable, Callable, Collection, Iterable, Mapping, Sequence
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, Optional, TypeAlias, TypedDict, overload
|
||||
|
||||
|
|
@ -11,6 +11,7 @@ from litellm._logging import verbose_logger
|
|||
from litellm.constants import MAXIMUM_TRACEBACK_LINES_TO_LOG
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.proxy._experimental.mcp_server.utils import (
|
||||
iter_known_server_prefixes,
|
||||
logging_safe_mcp_headers,
|
||||
split_server_prefix_from_name,
|
||||
strip_known_server_prefix,
|
||||
|
|
@ -23,6 +24,7 @@ from litellm.types.llms.openai import (
|
|||
ResponsesAPIStreamingResponse,
|
||||
)
|
||||
from litellm.types.llms.openai import ToolParam as ResponsesToolParam
|
||||
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
||||
from litellm.types.utils import (
|
||||
CallTypes,
|
||||
ChatCompletionMessageCustomToolCall,
|
||||
|
|
@ -45,6 +47,7 @@ else:
|
|||
|
||||
# NOTE: We intentionally keep ToolParam as a broad type here to avoid tight coupling
|
||||
ToolParam: TypeAlias = Mapping[str, object]
|
||||
SplitTools: TypeAlias = tuple[list[ToolParam], list[Any]]
|
||||
|
||||
|
||||
class MCPToolResult(TypedDict):
|
||||
|
|
@ -56,14 +59,74 @@ class MCPToolResult(TypedDict):
|
|||
LITELLM_PROXY_MCP_SERVER_URL: Final = "litellm_proxy"
|
||||
LITELLM_PROXY_MCP_SERVER_URL_PREFIX: Final = f"{LITELLM_PROXY_MCP_SERVER_URL}/mcp/"
|
||||
|
||||
# Matches any URL whose path ends with /mcp/<server_name> — covers both root-path
|
||||
# (http://host:port/mcp/name) and sub-path (http://host/base/mcp/name) proxy deployments.
|
||||
# A false-positive match (e.g. an external URL that happens to end with /mcp/<name>) results
|
||||
# in a "server not found" error from the internal gateway, not a silent failure or data leak,
|
||||
# so this broad pattern is intentional and preferred over anchoring to localhost only.
|
||||
_PROXY_MCP_PATH_RE: Final = re.compile(r"^https?://.+/mcp/([^/]+)$")
|
||||
|
||||
|
||||
def _mcp_server_url(tool: ToolParam) -> str | None:
|
||||
if not isinstance(tool, dict) or tool.get("type") != "mcp":
|
||||
return None
|
||||
server_url: Final = tool.get("server_url")
|
||||
return server_url if isinstance(server_url, str) else None
|
||||
|
||||
|
||||
def _names_gateway_explicitly(tool: ToolParam) -> bool:
|
||||
return (_mcp_server_url(tool) or "").startswith(LITELLM_PROXY_MCP_SERVER_URL)
|
||||
|
||||
|
||||
def _proxy_path_mcp_name(tool: ToolParam) -> str | None:
|
||||
server_url: Final = _mcp_server_url(tool)
|
||||
match: Final = None if server_url is None else _PROXY_MCP_PATH_RE.match(server_url)
|
||||
return None if match is None else match.group(1)
|
||||
|
||||
|
||||
def _registered_mcp_servers() -> Collection[MCPServer]:
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
|
||||
global_mcp_server_manager,
|
||||
)
|
||||
|
||||
return global_mcp_server_manager.get_registry().values()
|
||||
|
||||
|
||||
def _registry_serves(name: str, servers: Collection[MCPServer]) -> bool:
|
||||
requested: Final = name.lower()
|
||||
return any(
|
||||
requested in (known.lower() for known in (*iter_known_server_prefixes(server), server.name))
|
||||
or name in (server.access_groups or ())
|
||||
for server in servers
|
||||
)
|
||||
|
||||
|
||||
async def _toolset_exists(name: str) -> bool:
|
||||
try:
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
|
||||
global_mcp_server_manager,
|
||||
)
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
return False
|
||||
return await global_mcp_server_manager.get_toolset_by_name_cached(prisma_client, name) is not None
|
||||
except Exception as e:
|
||||
verbose_logger.debug("Could not resolve '%s' as toolset: %s", name, e)
|
||||
return False
|
||||
|
||||
|
||||
async def _gateway_served_names(
|
||||
names: Collection[str],
|
||||
servers: Callable[[], Collection[MCPServer]] = _registered_mcp_servers,
|
||||
toolset_exists: Callable[[str], Awaitable[bool]] = _toolset_exists,
|
||||
) -> frozenset[str]:
|
||||
registered: Final = tuple(servers()) if names else ()
|
||||
return frozenset([name for name in names if _registry_serves(name, registered) or await toolset_exists(name)])
|
||||
|
||||
|
||||
async def _served_mcp_path_names(
|
||||
tools: Collection[ToolParam], served_names: Callable[[Collection[str]], Awaitable[frozenset[str]]]
|
||||
) -> frozenset[str]:
|
||||
names: Final = frozenset(name for name in map(_proxy_path_mcp_name, tools) if name is not None)
|
||||
return await served_names(names) if names else frozenset[str]()
|
||||
|
||||
|
||||
class LiteLLM_Proxy_MCP_Handler:
|
||||
"""
|
||||
Helper class with static methods for MCP integration with Responses API.
|
||||
|
|
@ -87,57 +150,41 @@ class LiteLLM_Proxy_MCP_Handler:
|
|||
|
||||
@staticmethod
|
||||
def _should_use_litellm_mcp_gateway(tools: Iterable[ToolParam] | None) -> bool:
|
||||
"""
|
||||
Returns True if any MCP tool should be handled via the litellm proxy MCP gateway.
|
||||
This includes tools with server_url="litellm_proxy" as well as URLs ending in /mcp/<name>.
|
||||
"""
|
||||
if tools:
|
||||
for tool in tools:
|
||||
if isinstance(tool, dict) and tool.get("type") == "mcp":
|
||||
server_url = tool.get("server_url", "")
|
||||
if isinstance(server_url, str) and server_url.startswith(LITELLM_PROXY_MCP_SERVER_URL):
|
||||
return True
|
||||
if isinstance(server_url, str) and _PROXY_MCP_PATH_RE.match(server_url):
|
||||
return True
|
||||
return False
|
||||
"""True when a tool may name this gateway: server_url "litellm_proxy..." or an http(s) URL ending in
|
||||
/mcp/<name>. `_split_mcp_tools` then settles which of the latter the gateway actually serves."""
|
||||
return any(_names_gateway_explicitly(tool) or _proxy_path_mcp_name(tool) is not None for tool in tools or ())
|
||||
|
||||
@staticmethod
|
||||
def _parse_mcp_tools(
|
||||
def _parse_mcp_tools(tools: Iterable[Mapping[str, object]] | None) -> SplitTools:
|
||||
items: Final = tuple(tools or ())
|
||||
gateway_tools: Final[list[ToolParam]] = [tool for tool in items if _names_gateway_explicitly(tool)]
|
||||
other_tools: Final[list[Any]] = [tool for tool in items if not _names_gateway_explicitly(tool)]
|
||||
return gateway_tools, other_tools
|
||||
|
||||
@staticmethod
|
||||
async def _split_mcp_tools(
|
||||
tools: Iterable[Mapping[str, object]] | None,
|
||||
) -> tuple[list[ToolParam], list[Any]]:
|
||||
"""
|
||||
Parse tools and separate MCP tools with litellm_proxy from other tools.
|
||||
served_names: Callable[[Collection[str]], Awaitable[frozenset[str]]] = _gateway_served_names,
|
||||
) -> SplitTools:
|
||||
items: Final = tuple(tools or ())
|
||||
served: Final = await _served_mcp_path_names(items, served_names)
|
||||
return LiteLLM_Proxy_MCP_Handler._parse_mcp_tools(
|
||||
[
|
||||
{**tool, "server_url": f"{LITELLM_PROXY_MCP_SERVER_URL_PREFIX}{name}"}
|
||||
if (name := _proxy_path_mcp_name(tool)) in served
|
||||
else tool
|
||||
for tool in items
|
||||
]
|
||||
)
|
||||
|
||||
Returns:
|
||||
Tuple of (mcp_tools_with_litellm_proxy, other_tools)
|
||||
"""
|
||||
mcp_tools_with_litellm_proxy: Final[list[ToolParam]] = []
|
||||
other_tools: Final[list[Any]] = []
|
||||
|
||||
if tools:
|
||||
for tool in tools:
|
||||
if isinstance(tool, dict) and tool.get("type") == "mcp":
|
||||
server_url = tool.get("server_url", "")
|
||||
if isinstance(server_url, str) and server_url.startswith(LITELLM_PROXY_MCP_SERVER_URL):
|
||||
mcp_tools_with_litellm_proxy.append(tool)
|
||||
elif isinstance(server_url, str):
|
||||
# Also intercept URLs like http://localhost:4000/mcp/atlassian_test
|
||||
# by rewriting them to the internal litellm_proxy format.
|
||||
m = _PROXY_MCP_PATH_RE.match(server_url)
|
||||
if m:
|
||||
rewritten = {
|
||||
**tool,
|
||||
"server_url": f"{LITELLM_PROXY_MCP_SERVER_URL_PREFIX}{m.group(1)}",
|
||||
}
|
||||
mcp_tools_with_litellm_proxy.append(rewritten)
|
||||
else:
|
||||
other_tools.append(tool)
|
||||
else:
|
||||
other_tools.append(tool)
|
||||
else:
|
||||
other_tools.append(tool)
|
||||
|
||||
return mcp_tools_with_litellm_proxy, other_tools
|
||||
@staticmethod
|
||||
async def routes_through_gateway(
|
||||
tools: Iterable[Mapping[str, object]] | None,
|
||||
served_names: Callable[[Collection[str]], Awaitable[frozenset[str]]] = _gateway_served_names,
|
||||
) -> tuple[bool, ...]:
|
||||
items: Final = tuple(tools or ())
|
||||
served: Final = await _served_mcp_path_names(items, served_names)
|
||||
return tuple(_names_gateway_explicitly(tool) or _proxy_path_mcp_name(tool) in served for tool in items)
|
||||
|
||||
@staticmethod
|
||||
async def _apply_toolset_permissions(
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
from collections.abc import Mapping
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal
|
||||
|
||||
from pydantic import BaseModel, PrivateAttr, StrictInt
|
||||
from typing_extensions import Required, TypedDict
|
||||
from typing_extensions import ReadOnly, Required, TypedDict
|
||||
|
||||
from litellm.types.llms.base import LiteLLMPydanticObjectBase
|
||||
|
||||
|
|
@ -172,6 +172,7 @@ class AugmentedAgentCard(AgentCard):
|
|||
class AgentObjectPermission(TypedDict, total=False):
|
||||
mcp_servers: list[str] | None
|
||||
mcp_access_groups: list[str] | None
|
||||
mcp_toolsets: ReadOnly[Sequence[str] | None]
|
||||
mcp_tool_permissions: dict[str, list[str]] | None
|
||||
models: list[str] | None
|
||||
agents: list[str] | None
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from starlette.datastructures import Headers
|
|||
|
||||
from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
|
||||
MCPRequestHandler,
|
||||
UnloadableEntitlementError,
|
||||
_is_mcp_admitted_user_subject,
|
||||
)
|
||||
from litellm.proxy._types import (
|
||||
|
|
@ -4396,6 +4397,147 @@ class TestAgentMCPPermissions:
|
|||
)
|
||||
assert sorted(result) == ["tool_a", "tool_b"]
|
||||
|
||||
def _agent_object_permission(self, *, toolset_ids, servers=(), tool_permissions=None):
|
||||
agent_object_permission = MagicMock()
|
||||
agent_object_permission.mcp_servers = list(servers)
|
||||
agent_object_permission.mcp_access_groups = []
|
||||
agent_object_permission.mcp_tool_permissions = tool_permissions
|
||||
agent_object_permission.mcp_toolsets = list(toolset_ids)
|
||||
return agent_object_permission
|
||||
|
||||
def _mock_manager_with_toolsets(self, toolset_perms):
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.expand_permission_list = MagicMock(side_effect=lambda servers: list(servers))
|
||||
mock_manager.expand_tool_permissions = MagicMock(side_effect=lambda perms: perms or {})
|
||||
mock_manager.resolve_toolset_tool_permissions = AsyncMock(return_value=toolset_perms)
|
||||
return mock_manager
|
||||
|
||||
def _agent_toolset_patches(self, agent_object_permission, mock_manager):
|
||||
return (
|
||||
patch.object( # test-quality-ok: stub the agent perm loader; the resolver reads module globals with no injection seam
|
||||
MCPRequestHandler, "_get_agent_object_permission", AsyncMock(return_value=agent_object_permission)
|
||||
),
|
||||
patch( # test-quality-ok: isolate the MCP registry, same seam as the sibling toolset tests
|
||||
"litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager",
|
||||
mock_manager,
|
||||
),
|
||||
patch.object( # test-quality-ok: access-group lookup hits the DB, not under test here
|
||||
MCPRequestHandler, "_get_mcp_servers_from_access_groups", AsyncMock(return_value=[])
|
||||
),
|
||||
)
|
||||
|
||||
async def test_get_allowed_mcp_servers_for_agent_includes_toolset_servers(self):
|
||||
"""An agent granted only mcp_toolsets reaches the toolset's servers, exactly as a
|
||||
key, team, or org granted only toolsets does"""
|
||||
user_api_key_auth = UserAPIKeyAuth(api_key="test-key", agent_id="agent-toolsets")
|
||||
agent_object_permission = self._agent_object_permission(toolset_ids=["toolset-1"], servers=["server-direct"])
|
||||
mock_manager = self._mock_manager_with_toolsets({"server-a": ["lookup_status"]})
|
||||
|
||||
with contextlib.ExitStack() as stack:
|
||||
for patcher in self._agent_toolset_patches(agent_object_permission, mock_manager):
|
||||
stack.enter_context(patcher)
|
||||
result = await MCPRequestHandler._get_allowed_mcp_servers_for_agent(user_api_key_auth)
|
||||
|
||||
assert sorted(result) == ["server-a", "server-direct"]
|
||||
mock_manager.resolve_toolset_tool_permissions.assert_awaited_once_with(toolset_ids=["toolset-1"])
|
||||
|
||||
async def test_get_allowed_mcp_servers_toolset_only_agent_caps_key_servers(self):
|
||||
"""Regression: an agent whose only grant is a toolset used to resolve to [] and place
|
||||
no ceiling at all, so a key bound to it kept every server the key itself granted"""
|
||||
user_api_key_auth = UserAPIKeyAuth(api_key="test-key", agent_id="agent-toolsets")
|
||||
agent_object_permission = self._agent_object_permission(toolset_ids=["toolset-1"])
|
||||
mock_manager = self._mock_manager_with_toolsets({"server-a": ["lookup_status"]})
|
||||
|
||||
with contextlib.ExitStack() as stack:
|
||||
for patcher in self._agent_toolset_patches(agent_object_permission, mock_manager):
|
||||
stack.enter_context(patcher)
|
||||
stack.enter_context(
|
||||
patch.object( # test-quality-ok: key resolution has its own tests; pin its grants here
|
||||
MCPRequestHandler, "_get_allowed_mcp_servers_for_key", AsyncMock(return_value=["server-a", "server-b"])
|
||||
)
|
||||
)
|
||||
stack.enter_context(
|
||||
patch.object( # test-quality-ok: team resolution has its own tests; pin it empty here
|
||||
MCPRequestHandler, "_get_allowed_mcp_servers_for_team", AsyncMock(return_value=[])
|
||||
)
|
||||
)
|
||||
result = await MCPRequestHandler.get_allowed_mcp_servers(user_api_key_auth)
|
||||
|
||||
assert result == ["server-a"]
|
||||
|
||||
async def test_get_allowed_mcp_servers_agent_dangling_toolset_denies(self):
|
||||
"""An agent toolset that resolves to nothing is a known restriction with unknown
|
||||
contents: deny, never fall through to the key's own servers"""
|
||||
user_api_key_auth = UserAPIKeyAuth(api_key="test-key", agent_id="agent-toolsets")
|
||||
agent_object_permission = self._agent_object_permission(toolset_ids=["toolset-gone"])
|
||||
mock_manager = self._mock_manager_with_toolsets({})
|
||||
|
||||
with contextlib.ExitStack() as stack:
|
||||
for patcher in self._agent_toolset_patches(agent_object_permission, mock_manager):
|
||||
stack.enter_context(patcher)
|
||||
with pytest.raises(UnloadableEntitlementError):
|
||||
await MCPRequestHandler._get_allowed_mcp_servers_for_agent(user_api_key_auth)
|
||||
stack.enter_context(
|
||||
patch.object( # test-quality-ok: key resolution has its own tests; pin its grants here
|
||||
MCPRequestHandler, "_get_allowed_mcp_servers_for_key", AsyncMock(return_value=["server-a", "server-b"])
|
||||
)
|
||||
)
|
||||
stack.enter_context(
|
||||
patch.object( # test-quality-ok: team resolution has its own tests; pin it empty here
|
||||
MCPRequestHandler, "_get_allowed_mcp_servers_for_team", AsyncMock(return_value=[])
|
||||
)
|
||||
)
|
||||
result = await MCPRequestHandler.get_allowed_mcp_servers(user_api_key_auth)
|
||||
|
||||
assert result == []
|
||||
|
||||
async def test_get_agent_tool_permissions_for_server_unions_direct_and_toolset_tools(self):
|
||||
"""The agent's tool ceiling on a server is its direct tool grants plus the tools its
|
||||
toolsets grant there, and None only when neither names the server"""
|
||||
user_api_key_auth = UserAPIKeyAuth(api_key="test-key", agent_id="agent-toolsets")
|
||||
agent_object_permission = self._agent_object_permission(
|
||||
toolset_ids=["toolset-1"], tool_permissions={"server-a": ["tool_direct"]}
|
||||
)
|
||||
mock_manager = self._mock_manager_with_toolsets({"server-a": ["tool_via_toolset"], "server-b": ["tool_b"]})
|
||||
|
||||
with contextlib.ExitStack() as stack:
|
||||
for patcher in self._agent_toolset_patches(agent_object_permission, mock_manager):
|
||||
stack.enter_context(patcher)
|
||||
server_a_tools = await MCPRequestHandler._get_agent_tool_permissions_for_server("server-a", user_api_key_auth)
|
||||
server_b_tools = await MCPRequestHandler._get_agent_tool_permissions_for_server("server-b", user_api_key_auth)
|
||||
server_c_tools = await MCPRequestHandler._get_agent_tool_permissions_for_server("server-c", user_api_key_auth)
|
||||
|
||||
assert sorted(server_a_tools) == ["tool_direct", "tool_via_toolset"]
|
||||
assert server_b_tools == ["tool_b"]
|
||||
assert server_c_tools is None
|
||||
|
||||
async def test_get_allowed_tools_for_server_toolset_only_agent_caps_key_tools(self):
|
||||
"""Regression: a key allowing [tool_a, tool_b] bound to an agent whose toolset grants
|
||||
only tool_a on the server ends with [tool_a]; the toolset used to be ignored"""
|
||||
user_api_key_auth = UserAPIKeyAuth(api_key="test-key", agent_id="agent-toolsets")
|
||||
agent_object_permission = self._agent_object_permission(toolset_ids=["toolset-1"])
|
||||
mock_manager = self._mock_manager_with_toolsets({"server-a": ["tool_a"]})
|
||||
key_perm = MagicMock()
|
||||
key_perm.mcp_tool_permissions = {"server-a": ["tool_a", "tool_b"]}
|
||||
key_perm.mcp_toolsets = []
|
||||
|
||||
with contextlib.ExitStack() as stack:
|
||||
for patcher in self._agent_toolset_patches(agent_object_permission, mock_manager):
|
||||
stack.enter_context(patcher)
|
||||
stack.enter_context(
|
||||
patch.object( # test-quality-ok: stub the key perm loader; the resolver reads module globals with no injection seam
|
||||
MCPRequestHandler, "_get_key_object_permission", return_value=key_perm
|
||||
)
|
||||
)
|
||||
stack.enter_context(
|
||||
patch.object( # test-quality-ok: team resolution has its own tests; pin it absent here
|
||||
MCPRequestHandler, "_get_team_object_permission", AsyncMock(return_value=None)
|
||||
)
|
||||
)
|
||||
result = await MCPRequestHandler.get_allowed_tools_for_server("server-a", user_api_key_auth)
|
||||
|
||||
assert result == ["tool_a"]
|
||||
|
||||
async def test_get_agent_object_permission_uses_shared_helper(self):
|
||||
"""``_get_agent_object_permission`` must resolve the agent's
|
||||
``object_permission_id`` and then defer to the shared
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import asyncio
|
|||
import contextvars
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
|
@ -1368,6 +1369,295 @@ async def test_get_tools_from_mcp_servers_handles_all_servers_failing():
|
|||
mock_logger.info.assert_any_call("Successfully fetched %s tools total from all MCP servers", 0)
|
||||
|
||||
|
||||
def _denied_scope_manager(known_server_names_to_ids: dict[str, str]) -> MagicMock:
|
||||
servers = {name: MagicMock(server_id=server_id) for name, server_id in known_server_names_to_ids.items()}
|
||||
manager = MagicMock()
|
||||
manager.get_mcp_server_by_name = lambda name, client_ip=None: servers.get(name)
|
||||
return manager
|
||||
|
||||
|
||||
def _scope_resolver(resolved_without_agent: dict[str, str], access_groups: tuple[str, ...] = ()) -> AsyncMock:
|
||||
async def resolve(user_api_key_auth, mcp_servers, client_ip=None):
|
||||
if user_api_key_auth is not None and user_api_key_auth.agent_id:
|
||||
return []
|
||||
return [
|
||||
SimpleNamespace(
|
||||
server_id=server_id,
|
||||
server_name=server_name,
|
||||
alias=None,
|
||||
short_prefix=None,
|
||||
access_groups=list(access_groups),
|
||||
)
|
||||
for server_name, server_id in resolved_without_agent.items()
|
||||
]
|
||||
|
||||
return AsyncMock(side_effect=resolve)
|
||||
|
||||
|
||||
async def _denied_scoped_list(
|
||||
user_api_key_auth: UserAPIKeyAuth,
|
||||
mcp_servers: list[str],
|
||||
mock_manager: MagicMock,
|
||||
resolver: AsyncMock,
|
||||
) -> HTTPException:
|
||||
from litellm.proxy._experimental.mcp_server.server import _get_tools_from_mcp_servers
|
||||
|
||||
with (
|
||||
patch( # test-quality-ok: the permission resolver is a module-level function; the suite's only seam
|
||||
"litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers",
|
||||
resolver,
|
||||
),
|
||||
patch( # test-quality-ok: the server registry is a module-level singleton; the suite's only seam
|
||||
"litellm.proxy._experimental.mcp_server.server.global_mcp_server_manager",
|
||||
mock_manager,
|
||||
),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _get_tools_from_mcp_servers(
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
mcp_auth_header=None,
|
||||
mcp_servers=mcp_servers,
|
||||
)
|
||||
return exc_info.value
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scoped_list_denied_by_agent_binding_raises_403_naming_agent():
|
||||
"""The agent-binding veto must raise a 403 naming the agent, never a silent 200 with no tools."""
|
||||
pytest.importorskip("litellm.proxy._experimental.mcp_server.server")
|
||||
|
||||
user_api_key_auth = UserAPIKeyAuth(api_key="test_key", user_id="test_user", agent_id="agent-123")
|
||||
resolver = _scope_resolver(resolved_without_agent={"github": "srv-github"})
|
||||
|
||||
denial = await _denied_scoped_list(
|
||||
user_api_key_auth, ["github"], _denied_scope_manager({"github": "srv-github"}), resolver
|
||||
)
|
||||
|
||||
assert denial.status_code == 403
|
||||
message = denial.detail["error"]
|
||||
assert "MCP server 'github'" in message
|
||||
assert "agent 'agent-123'" in message
|
||||
assert "mcp_servers" in message
|
||||
rerun_auth = resolver.await_args_list[1].kwargs["user_api_key_auth"]
|
||||
assert rerun_auth.agent_id is None
|
||||
assert rerun_auth.user_id == "test_user"
|
||||
assert resolver.await_args_list[1].kwargs["mcp_servers"] == ["github"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_scope_lists_nothing_instead_of_raising_a_nameless_denial():
|
||||
"""An empty ``x-mcp-servers`` header scopes to no servers; that is an empty listing, not a 403."""
|
||||
pytest.importorskip("litellm.proxy._experimental.mcp_server.server")
|
||||
from litellm.proxy._experimental.mcp_server.server import _get_tools_from_mcp_servers
|
||||
|
||||
user_api_key_auth = UserAPIKeyAuth(api_key="test_key", user_id="test_user")
|
||||
resolver = AsyncMock(return_value=[])
|
||||
|
||||
with (
|
||||
patch( # test-quality-ok: the permission resolver is a module-level function; the suite's only seam
|
||||
"litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers",
|
||||
resolver,
|
||||
),
|
||||
patch( # test-quality-ok: the server registry is a module-level singleton; the suite's only seam
|
||||
"litellm.proxy._experimental.mcp_server.server.global_mcp_server_manager",
|
||||
_denied_scope_manager({"github": "srv-github"}),
|
||||
),
|
||||
):
|
||||
listing = await _get_tools_from_mcp_servers(
|
||||
user_api_key_auth=user_api_key_auth, mcp_auth_header=None, mcp_servers=[]
|
||||
)
|
||||
|
||||
assert listing.tools == []
|
||||
resolver.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scoped_list_denied_for_non_agent_key_raises_generic_403():
|
||||
"""A denial for a key with no agent binding stays generic and skips the agent-stripped rerun."""
|
||||
pytest.importorskip("litellm.proxy._experimental.mcp_server.server")
|
||||
|
||||
user_api_key_auth = UserAPIKeyAuth(api_key="test_key", user_id="test_user")
|
||||
resolver = AsyncMock(return_value=[])
|
||||
|
||||
denial = await _denied_scoped_list(
|
||||
user_api_key_auth, ["github"], _denied_scope_manager({"github": "srv-github"}), resolver
|
||||
)
|
||||
|
||||
assert denial.status_code == 403
|
||||
message = denial.detail["error"]
|
||||
assert "github" in message
|
||||
assert "agent" not in message
|
||||
resolver.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scoped_list_unknown_name_raises_same_generic_403_as_unauthorized():
|
||||
"""Unknown and registered-but-unauthorized names raise byte-identical generic 403s, so a
|
||||
caller cannot probe which server names exist."""
|
||||
pytest.importorskip("litellm.proxy._experimental.mcp_server.server")
|
||||
|
||||
user_api_key_auth = UserAPIKeyAuth(api_key="test_key", user_id="test_user", agent_id="agent-123")
|
||||
|
||||
unknown = await _denied_scoped_list(
|
||||
user_api_key_auth, ["github"], _denied_scope_manager({}), _scope_resolver(resolved_without_agent={})
|
||||
)
|
||||
unauthorized = await _denied_scoped_list(
|
||||
user_api_key_auth,
|
||||
["github"],
|
||||
_denied_scope_manager({"github": "srv-github"}),
|
||||
_scope_resolver(resolved_without_agent={}),
|
||||
)
|
||||
|
||||
assert unknown.status_code == unauthorized.status_code == 403
|
||||
assert unknown.detail["error"] == unauthorized.detail["error"]
|
||||
assert "github" in unknown.detail["error"]
|
||||
assert "agent" not in unknown.detail["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scoped_list_access_group_vetoed_by_agent_names_agent_and_group():
|
||||
"""An access-group scope vetoed by the agent binding raises the 403 naming the agent and the
|
||||
group instead of the silent empty list."""
|
||||
pytest.importorskip("litellm.proxy._experimental.mcp_server.server")
|
||||
|
||||
user_api_key_auth = UserAPIKeyAuth(api_key="test_key", user_id="test_user", agent_id="agent-123")
|
||||
|
||||
denial = await _denied_scoped_list(
|
||||
user_api_key_auth,
|
||||
["prod-group"],
|
||||
_denied_scope_manager({}),
|
||||
_scope_resolver(resolved_without_agent={"github": "srv-github"}, access_groups=("prod-group",)),
|
||||
)
|
||||
|
||||
assert denial.status_code == 403
|
||||
message = denial.detail["error"]
|
||||
assert "access group 'prod-group'" in message
|
||||
assert "agent 'agent-123'" in message
|
||||
assert "mcp_access_groups" in message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scoped_list_mixed_unknown_and_vetoed_group_names_the_group_that_resolved():
|
||||
"""With an unknown name ahead of the agent-vetoed group in the scope, the 403 must name the group
|
||||
whose servers the key can reach, never the unknown name, or the admin is told to grant a group
|
||||
that does not exist."""
|
||||
pytest.importorskip("litellm.proxy._experimental.mcp_server.server")
|
||||
|
||||
user_api_key_auth = UserAPIKeyAuth(api_key="test_key", user_id="test_user", agent_id="agent-123")
|
||||
|
||||
denial = await _denied_scoped_list(
|
||||
user_api_key_auth,
|
||||
["no-such-group", "prod-group"],
|
||||
_denied_scope_manager({}),
|
||||
_scope_resolver(resolved_without_agent={"github": "srv-github"}, access_groups=("prod-group",)),
|
||||
)
|
||||
|
||||
assert denial.status_code == 403
|
||||
message = denial.detail["error"]
|
||||
assert "access group 'prod-group'" in message
|
||||
assert "no-such-group" not in message
|
||||
assert "agent 'agent-123'" in message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scoped_list_agent_key_denied_by_key_grants_raises_generic_403():
|
||||
"""When the agent-stripped rerun still resolves nothing, the 403 stays generic instead of
|
||||
blaming the agent binding."""
|
||||
pytest.importorskip("litellm.proxy._experimental.mcp_server.server")
|
||||
|
||||
user_api_key_auth = UserAPIKeyAuth(api_key="test_key", user_id="test_user", agent_id="agent-123")
|
||||
resolver = _scope_resolver(resolved_without_agent={})
|
||||
|
||||
denial = await _denied_scoped_list(
|
||||
user_api_key_auth, ["github"], _denied_scope_manager({"github": "srv-github"}), resolver
|
||||
)
|
||||
|
||||
assert denial.status_code == 403
|
||||
message = denial.detail["error"]
|
||||
assert "github" in message
|
||||
assert "agent" not in message
|
||||
assert resolver.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scoped_list_agent_veto_attributed_for_differently_cased_server_name():
|
||||
"""The scope filter matches `/mcp/GitHub` to a server named `github` case-insensitively, so the
|
||||
agent-attributed 403 must match the same way instead of falling back to the generic denial."""
|
||||
pytest.importorskip("litellm.proxy._experimental.mcp_server.server")
|
||||
|
||||
user_api_key_auth = UserAPIKeyAuth(api_key="test_key", user_id="test_user", agent_id="agent-123")
|
||||
|
||||
denial = await _denied_scoped_list(
|
||||
user_api_key_auth,
|
||||
["GitHub"],
|
||||
_denied_scope_manager({"github": "srv-github"}),
|
||||
_scope_resolver(resolved_without_agent={"github": "srv-github"}),
|
||||
)
|
||||
|
||||
assert denial.status_code == 403
|
||||
message = denial.detail["error"]
|
||||
assert "MCP server 'GitHub'" in message
|
||||
assert "agent 'agent-123'" in message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_list_tools_converts_permission_httpexception_to_mcp_error():
|
||||
"""The MCP protocol handler surfaces a permission HTTPException as a clean JSON-RPC error
|
||||
(McpError, INVALID_REQUEST) carrying the denial message, instead of a raw 500."""
|
||||
try:
|
||||
from litellm.proxy._experimental.mcp_server.server import handle_list_tools
|
||||
except ImportError:
|
||||
pytest.skip("MCP server not available")
|
||||
|
||||
from mcp.shared.exceptions import McpError
|
||||
from mcp.types import INVALID_REQUEST
|
||||
|
||||
denial_message = "MCP server 'github' is not available to this key: the key is bound to agent 'agent-123'"
|
||||
denial = HTTPException(status_code=403, detail={"error": denial_message})
|
||||
|
||||
with (
|
||||
patch( # test-quality-ok: the protocol handler reads auth from module context; no injection seam
|
||||
"litellm.proxy._experimental.mcp_server.server.get_or_extract_auth_context",
|
||||
new=AsyncMock(return_value=(None, None, None, None, None, None, None)),
|
||||
),
|
||||
patch( # test-quality-ok: the listing helper is the handler's only collaborator; the suite's seam
|
||||
"litellm.proxy._experimental.mcp_server.server._list_mcp_tools",
|
||||
new=AsyncMock(side_effect=denial),
|
||||
),
|
||||
):
|
||||
with pytest.raises(McpError) as exc_info:
|
||||
await handle_list_tools()
|
||||
|
||||
assert exc_info.value.error.code == INVALID_REQUEST
|
||||
assert exc_info.value.error.message == denial_message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_server_tool_call_renders_denial_message_not_detail_dict():
|
||||
try:
|
||||
from litellm.proxy._experimental.mcp_server.server import mcp_server_tool_call
|
||||
except ImportError:
|
||||
pytest.skip("MCP server not available")
|
||||
|
||||
denial_message = "MCP server 'github' is not available to this key: the key is bound to agent 'agent-123'"
|
||||
denial = HTTPException(status_code=403, detail={"error": denial_message})
|
||||
|
||||
with (
|
||||
patch( # test-quality-ok: the protocol handler reads auth from module context; no injection seam
|
||||
"litellm.proxy._experimental.mcp_server.server.get_or_extract_auth_context",
|
||||
new=AsyncMock(return_value=(None, None, None, None, None, None, None)),
|
||||
),
|
||||
patch( # test-quality-ok: the tool-call helper is the handler's only collaborator; the suite's seam
|
||||
"litellm.proxy._experimental.mcp_server.server.call_mcp_tool",
|
||||
new=AsyncMock(side_effect=denial),
|
||||
),
|
||||
):
|
||||
result = await mcp_server_tool_call("github-search_issues", {})
|
||||
|
||||
assert result.isError is True
|
||||
assert result.content[0].text == f"Error: {denial_message}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_server_tool_call_body_with_none_arguments():
|
||||
"""Test that proxy_server_request body handles None arguments correctly"""
|
||||
|
|
@ -3518,6 +3808,35 @@ async def test_call_mcp_tool_user_unauthorized_access():
|
|||
assert "User not allowed to call this tool" in exc_info.value.detail
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_mcp_tool_scoped_denial_names_the_binding_agent():
|
||||
from litellm.proxy._experimental.mcp_server.server import call_mcp_tool
|
||||
|
||||
agent_bound_key = UserAPIKeyAuth(api_key="test-key", user_id="test-user", agent_id="agent-123")
|
||||
|
||||
with (
|
||||
patch( # test-quality-ok: the server registry is a module-level singleton; the suite's only seam
|
||||
"litellm.proxy._experimental.mcp_server.server.global_mcp_server_manager.get_allowed_mcp_servers",
|
||||
AsyncMock(return_value=[]),
|
||||
),
|
||||
patch( # test-quality-ok: the permission resolver is a module-level function; the suite's only seam
|
||||
"litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers",
|
||||
_scope_resolver({"github": "srv-github"}),
|
||||
),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await call_mcp_tool(
|
||||
name="github-search_issues",
|
||||
arguments={},
|
||||
user_api_key_auth=agent_bound_key,
|
||||
mcp_servers=["github"],
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "MCP server 'github'" in exc_info.value.detail["error"]
|
||||
assert "agent 'agent-123'" in exc_info.value.detail["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_mcp_tool_unauthorized_403_does_not_leak_server_credentials():
|
||||
"""Regression for LIT-4703 / GH #29936.
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ Covers:
|
|||
|
||||
import json
|
||||
from collections.abc import Sequence
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
|
@ -1271,3 +1272,37 @@ class TestMcpServerToolCallErrorHandling:
|
|||
|
||||
assert result.isError is True
|
||||
assert "User not allowed to call this tool" in result.content[0].text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_mcp_tool_call_scoped_denial_names_the_binding_agent() -> None:
|
||||
from fastapi import HTTPException
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.tool_search import handle_mcp_tool_call
|
||||
|
||||
agent_bound_key = UserAPIKeyAuth(api_key="test_key", agent_id="agent-123")
|
||||
|
||||
async def resolve(user_api_key_auth, mcp_servers, client_ip=None):
|
||||
if user_api_key_auth.agent_id:
|
||||
return []
|
||||
return [
|
||||
SimpleNamespace(
|
||||
server_id="srv-github", server_name="github", alias=None, short_prefix=None, access_groups=[]
|
||||
)
|
||||
]
|
||||
|
||||
with patch( # test-quality-ok: the permission resolver is a module-level function; the suite's only seam
|
||||
"litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers",
|
||||
new=AsyncMock(side_effect=resolve),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await handle_mcp_tool_call(
|
||||
tool_name="github-create_issue",
|
||||
arguments={},
|
||||
user_api_key_dict=agent_bound_key,
|
||||
mcp_servers=["github"],
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "MCP server 'github'" in exc_info.value.detail["error"]
|
||||
assert "agent 'agent-123'" in exc_info.value.detail["error"]
|
||||
|
|
|
|||
|
|
@ -2216,3 +2216,26 @@ async def test_top_k_above_router_default_is_respected():
|
|||
|
||||
assert len(filtered) == 6
|
||||
print("✅ Configured top_k above the semantic-router default of 5 is honored")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_semantic_filter_hook_narrows_only_references_the_gateway_serves():
|
||||
from litellm.proxy.hooks.mcp_semantic_filter import SemanticToolFilterHook
|
||||
|
||||
gateway_reference = {"type": "mcp", "server_url": "litellm_proxy", "require_approval": "never"}
|
||||
external_tool = {
|
||||
"type": "mcp",
|
||||
"server_label": "zapier",
|
||||
"server_url": "https://mcp.zapier.com/api/mcp/mcp",
|
||||
"allowed_tools": ["zapier_send_email"],
|
||||
}
|
||||
|
||||
async def served_names(names):
|
||||
assert names == {"mcp"}
|
||||
return frozenset()
|
||||
|
||||
narrowed = await SemanticToolFilterHook._narrow_mcp_references(
|
||||
[gateway_reference, external_tool], ["srv-tool_1"], served_names=served_names
|
||||
)
|
||||
|
||||
assert narrowed == [{**gateway_reference, "allowed_tools": ["srv-tool_1"]}, external_tool]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
import json
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
import respx
|
||||
from httpx import Response
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import litellm
|
||||
from litellm.types.utils import ModelResponse
|
||||
|
||||
from litellm.responses.mcp import chat_completions_handler
|
||||
|
|
@ -1344,3 +1351,39 @@ async def test_acompletion_with_mcp_streaming_drains_inner_stream_after_exhausti
|
|||
|
||||
assert len(all_chunks) == 3
|
||||
assert initial_stream.drained_after_exhaustion is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_acompletion_with_mcp_forwards_unserved_external_mcp_tool_to_the_provider(monkeypatch):
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager
|
||||
|
||||
zapier_tool = {"type": "mcp", "server_label": "zapier", "server_url": "https://mcp.zapier.com/api/mcp/mcp"}
|
||||
monkeypatch.setitem(sys.modules, "litellm.proxy.proxy_server", types.SimpleNamespace(prisma_client=None))
|
||||
monkeypatch.setattr(global_mcp_server_manager, "get_registry", lambda: {})
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
provider = respx.post("https://api.openai.com/v1/chat/completions").mock(
|
||||
return_value=Response(
|
||||
200,
|
||||
json={
|
||||
"id": "chatcmpl-zapier",
|
||||
"object": "chat.completion",
|
||||
"created": 0,
|
||||
"model": "gpt-4.1",
|
||||
"choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
|
||||
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
result = await acompletion_with_mcp(
|
||||
model="openai/gpt-4.1",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
tools=[zapier_tool],
|
||||
api_key="sk-test",
|
||||
acompletion=True,
|
||||
)
|
||||
|
||||
assert isinstance(result, ModelResponse)
|
||||
assert result.id == "chatcmpl-zapier"
|
||||
assert json.loads(provider.calls.last.request.content)["tools"] == [zapier_tool]
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock
|
|||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from openai.types.responses.tool_param import Mcp
|
||||
import importlib
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.faults.list_outcomes import AggregateToolListing
|
||||
|
|
@ -724,6 +725,178 @@ def test_extract_tool_call_details_still_prefers_openai_arguments():
|
|||
assert arguments == '{"city": "Paris"}'
|
||||
|
||||
|
||||
def _registered(
|
||||
server_id: str,
|
||||
name: str,
|
||||
alias: str | None = None,
|
||||
server_name: str | None = None,
|
||||
access_groups: list[str] | None = None,
|
||||
):
|
||||
from litellm.types.mcp import MCPTransport
|
||||
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
||||
|
||||
return MCPServer(
|
||||
server_id=server_id,
|
||||
name=name,
|
||||
alias=alias,
|
||||
server_name=server_name,
|
||||
transport=MCPTransport.http,
|
||||
access_groups=access_groups,
|
||||
)
|
||||
|
||||
|
||||
async def _no_toolset(_: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
ZAPIER_TOOL: Mcp = {
|
||||
"type": "mcp",
|
||||
"server_label": "zapier",
|
||||
"server_url": "https://mcp.zapier.com/api/mcp/mcp",
|
||||
"require_approval": "never",
|
||||
}
|
||||
EXPLICIT_GATEWAY_TOOL = {"type": "mcp", "server_label": "github", "server_url": "litellm_proxy/mcp/github"}
|
||||
FUNCTION_TOOL = {"type": "function", "name": "get_weather", "parameters": {}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_served_names_matches_alias_server_name_name_access_group_and_toolset():
|
||||
from litellm.responses.mcp.litellm_proxy_mcp_handler import _gateway_served_names
|
||||
|
||||
servers = (
|
||||
_registered("id-1", "github-name", alias="github", server_name="github-server", access_groups=["prod-group"]),
|
||||
_registered("id-2", "deepwiki"),
|
||||
)
|
||||
|
||||
async def toolset_exists(name: str) -> bool:
|
||||
return name == "my-toolset"
|
||||
|
||||
served = await _gateway_served_names(
|
||||
{"github", "github-server", "github-name", "deepwiki", "prod-group", "my-toolset", "mcp", "nope"},
|
||||
servers=lambda: servers,
|
||||
toolset_exists=toolset_exists,
|
||||
)
|
||||
|
||||
assert served == {"github", "github-server", "github-name", "deepwiki", "prod-group", "my-toolset"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_served_names_matches_server_id_short_prefix_and_alias_case_like_the_gateway():
|
||||
from litellm.proxy._experimental.mcp_server.utils import compute_short_server_prefix
|
||||
from litellm.responses.mcp.litellm_proxy_mcp_handler import _gateway_served_names
|
||||
|
||||
server_id = "0b9ae4ca-1bd2-4faa-b183-7dd812597e3b"
|
||||
short_prefix = compute_short_server_prefix(server_id)
|
||||
servers = (_registered(server_id, "github-name", alias="github", access_groups=["prod-group"]),)
|
||||
|
||||
served = await _gateway_served_names(
|
||||
{server_id, short_prefix, "GitHub", "PROD-GROUP", "nope"}, servers=lambda: servers, toolset_exists=_no_toolset
|
||||
)
|
||||
|
||||
assert served == {server_id, short_prefix, "GitHub"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_routes_through_gateway_flags_explicit_and_served_tools_only():
|
||||
served_tool = {"type": "mcp", "server_label": "github", "server_url": "http://localhost:4000/mcp/github"}
|
||||
|
||||
async def served_names(names):
|
||||
assert names == {"github", "mcp"}
|
||||
return frozenset({"github"})
|
||||
|
||||
flags = await LiteLLM_Proxy_MCP_Handler.routes_through_gateway(
|
||||
[ZAPIER_TOOL, EXPLICIT_GATEWAY_TOOL, served_tool, FUNCTION_TOOL], served_names=served_names
|
||||
)
|
||||
|
||||
assert flags == (False, True, True, False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_split_mcp_tools_leaves_external_mcp_path_urls_for_the_provider():
|
||||
|
||||
async def served_names(names):
|
||||
assert names == {"mcp"}
|
||||
return frozenset()
|
||||
|
||||
gateway_tools, other_tools = await LiteLLM_Proxy_MCP_Handler._split_mcp_tools(
|
||||
[ZAPIER_TOOL, EXPLICIT_GATEWAY_TOOL, FUNCTION_TOOL], served_names=served_names
|
||||
)
|
||||
|
||||
assert gateway_tools == [EXPLICIT_GATEWAY_TOOL]
|
||||
assert other_tools == [ZAPIER_TOOL, FUNCTION_TOOL]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_split_mcp_tools_repoints_served_proxy_urls_at_the_gateway():
|
||||
served_tool = {
|
||||
"type": "mcp",
|
||||
"server_label": "toolset",
|
||||
"server_url": "http://localhost:4000/mcp/my-toolset",
|
||||
"require_approval": "never",
|
||||
"allowed_tools": ["get_me"],
|
||||
}
|
||||
unserved_tool = {"type": "mcp", "server_label": "typo", "server_url": "http://localhost:4000/mcp/githb"}
|
||||
|
||||
async def served_names(names):
|
||||
return frozenset({"my-toolset"})
|
||||
|
||||
gateway_tools, other_tools = await LiteLLM_Proxy_MCP_Handler._split_mcp_tools(
|
||||
[served_tool, unserved_tool], served_names=served_names
|
||||
)
|
||||
|
||||
assert gateway_tools == [{**served_tool, "server_url": "litellm_proxy/mcp/my-toolset"}]
|
||||
assert other_tools == [unserved_tool]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_split_mcp_tools_skips_resolution_when_nothing_points_at_the_proxy():
|
||||
async def served_names(names):
|
||||
raise AssertionError("no lookup expected")
|
||||
|
||||
gateway_tools, other_tools = await LiteLLM_Proxy_MCP_Handler._split_mcp_tools(
|
||||
[EXPLICIT_GATEWAY_TOOL, FUNCTION_TOOL], served_names=served_names
|
||||
)
|
||||
|
||||
assert gateway_tools == [EXPLICIT_GATEWAY_TOOL]
|
||||
assert other_tools == [FUNCTION_TOOL]
|
||||
|
||||
|
||||
def test_should_use_gateway_still_triggers_on_http_mcp_path():
|
||||
assert LiteLLM_Proxy_MCP_Handler._should_use_litellm_mcp_gateway([ZAPIER_TOOL]) is True
|
||||
assert LiteLLM_Proxy_MCP_Handler._should_use_litellm_mcp_gateway([EXPLICIT_GATEWAY_TOOL]) is True
|
||||
assert LiteLLM_Proxy_MCP_Handler._should_use_litellm_mcp_gateway([FUNCTION_TOOL]) is False
|
||||
assert LiteLLM_Proxy_MCP_Handler._should_use_litellm_mcp_gateway(None) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aresponses_api_with_mcp_forwards_unserved_external_mcp_tool_to_the_provider(monkeypatch):
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager
|
||||
from litellm.responses import main as responses_main
|
||||
from litellm.types.llms.openai import ResponsesAPIResponse
|
||||
|
||||
monkeypatch.setitem(sys.modules, "litellm.proxy.proxy_server", types.SimpleNamespace(prisma_client=None))
|
||||
monkeypatch.setattr(global_mcp_server_manager, "get_registry", lambda: {})
|
||||
provider_tools: list[object] = []
|
||||
|
||||
def fake_provider(**kwargs: object) -> object:
|
||||
request_params = cast(dict[str, object], kwargs["response_api_optional_request_params"])
|
||||
provider_tools.append(request_params.get("tools"))
|
||||
|
||||
async def respond() -> ResponsesAPIResponse:
|
||||
return ResponsesAPIResponse(id="resp_zapier", created_at=0, output=[])
|
||||
|
||||
return respond()
|
||||
|
||||
monkeypatch.setattr(responses_main.base_llm_http_handler, "response_api_handler", fake_provider)
|
||||
|
||||
response = await responses_main.aresponses_api_with_mcp(
|
||||
input="Reply with the single word ok.", model="openai/gpt-4.1", tools=[ZAPIER_TOOL]
|
||||
)
|
||||
|
||||
assert isinstance(response, ResponsesAPIResponse)
|
||||
assert provider_tools == [[ZAPIER_TOOL]]
|
||||
|
||||
|
||||
def _response_with_reasoning_and_tool_call() -> Any:
|
||||
"""A first-turn response as a reasoning model returns it: reasoning item, then a function call."""
|
||||
return ResponsesAPIResponse(
|
||||
|
|
|
|||
|
|
@ -313,6 +313,26 @@ export const buildAgentDataFromForm = (values: any, existingAgent?: any) => {
|
|||
return agentData;
|
||||
};
|
||||
|
||||
export const parseMcpPermissionsForForm = (agent: any) => ({
|
||||
allowed_mcp_servers_and_groups: {
|
||||
servers: agent.object_permission?.mcp_servers ?? [],
|
||||
accessGroups: agent.object_permission?.mcp_access_groups ?? [],
|
||||
toolsets: agent.object_permission?.mcp_toolsets ?? [],
|
||||
},
|
||||
mcp_tool_permissions: agent.object_permission?.mcp_tool_permissions ?? {},
|
||||
});
|
||||
|
||||
/**
|
||||
* Always includes every MCP key (empty when cleared) so removals persist;
|
||||
* the proxy merges object_permission per key, leaving non-MCP grants untouched.
|
||||
*/
|
||||
export const buildMcpObjectPermission = (values: any) => ({
|
||||
mcp_servers: values.allowed_mcp_servers_and_groups?.servers ?? [],
|
||||
mcp_access_groups: values.allowed_mcp_servers_and_groups?.accessGroups ?? [],
|
||||
mcp_toolsets: values.allowed_mcp_servers_and_groups?.toolsets ?? [],
|
||||
mcp_tool_permissions: values.mcp_tool_permissions ?? {},
|
||||
});
|
||||
|
||||
/**
|
||||
* Parse agent data for form fields
|
||||
*/
|
||||
|
|
@ -356,5 +376,6 @@ export const parseAgentForForm = (agent: any) => {
|
|||
: [],
|
||||
// extra_headers: already an array of strings
|
||||
extra_headers: agent.extra_headers ?? [],
|
||||
...parseMcpPermissionsForForm(agent),
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
|
@ -10,6 +11,12 @@ vi.mock("@/components/networking", () => ({
|
|||
getAgentInfo: vi.fn(),
|
||||
patchAgentCall: vi.fn(),
|
||||
getAgentCreateMetadata: vi.fn(),
|
||||
getProxyBaseUrl: vi.fn(() => ""),
|
||||
getUiConfig: vi.fn(async () => ({})),
|
||||
fetchMCPServers: vi.fn(async () => []),
|
||||
fetchMCPAccessGroups: vi.fn(async () => []),
|
||||
fetchMCPToolsets: vi.fn(async () => []),
|
||||
listMCPTools: vi.fn(async () => ({ tools: [] })),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({
|
||||
|
|
@ -111,7 +118,14 @@ const bedrockAgentcoreInfo: AgentCreateInfo = {
|
|||
|
||||
const setup = () => userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
|
||||
const renderView = () => render(<AgentInfoView agentId="agent-1" onClose={vi.fn()} accessToken="tok" isAdmin={true} />);
|
||||
const renderView = () => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AgentInfoView agentId="agent-1" onClose={vi.fn()} accessToken="tok" isAdmin={true} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
const openEditor = async (user: ReturnType<typeof setup>) => {
|
||||
await user.click(await screen.findByRole("tab", { name: "Settings" }));
|
||||
|
|
@ -161,6 +175,7 @@ describe("AgentInfoView update payload", () => {
|
|||
rpm_limit: 222,
|
||||
session_tpm_limit: 333,
|
||||
session_rpm_limit: 444,
|
||||
object_permission: { mcp_servers: [], mcp_access_groups: [], mcp_toolsets: [], mcp_tool_permissions: {} },
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -201,6 +216,7 @@ describe("AgentInfoView update payload", () => {
|
|||
rpm_limit: 222,
|
||||
session_tpm_limit: 333,
|
||||
session_rpm_limit: 444,
|
||||
object_permission: { mcp_servers: [], mcp_access_groups: [], mcp_toolsets: [], mcp_tool_permissions: {} },
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -278,9 +294,30 @@ describe("AgentInfoView update payload", () => {
|
|||
api_base: "https://other.example.com",
|
||||
model: "langgraph/asst_1",
|
||||
},
|
||||
object_permission: { mcp_servers: [], mcp_access_groups: [], mcp_toolsets: [], mcp_tool_permissions: {} },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the agent's existing MCP grants in the update payload", async () => {
|
||||
const existingMcpGrants = {
|
||||
mcp_servers: ["srv-1"],
|
||||
mcp_access_groups: ["grp-a"],
|
||||
mcp_toolsets: ["toolset-1"],
|
||||
mcp_tool_permissions: { "srv-1": ["tool_x"] },
|
||||
};
|
||||
vi.mocked(networking.getAgentInfo).mockResolvedValue({
|
||||
...A2A_AGENT,
|
||||
object_permission: existingMcpGrants,
|
||||
} as never);
|
||||
const user = setup();
|
||||
renderView();
|
||||
await openEditor(user);
|
||||
|
||||
await save(user);
|
||||
|
||||
expect(patchedPayload().object_permission).toEqual(existingMcpGrants);
|
||||
});
|
||||
|
||||
it("preserves the full AgentCore runtime ARN (including the resource id after runtime/) across an unedited save", async () => {
|
||||
vi.mocked(networking.getAgentCreateMetadata).mockResolvedValue([bedrockAgentcoreInfo]);
|
||||
vi.mocked(networking.getAgentInfo).mockResolvedValue(BEDROCK_AGENTCORE_AGENT as never);
|
||||
|
|
|
|||
|
|
@ -24,6 +24,18 @@ vi.mock("./agent_form_fields", () => ({
|
|||
unmountedA2AFieldNames: () => [],
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/mcpServers/useMCPServers", () => ({
|
||||
useMCPServers: () => ({ data: [{ server_id: "srv-1", server_name: "github" }] }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/mcp_server_management/MCPServerSelector", () => ({
|
||||
default: () => <div data-testid="mcp-server-selector" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/mcp_server_management/MCPToolPermissions", () => ({
|
||||
default: () => <div data-testid="mcp-tool-permissions" />,
|
||||
}));
|
||||
|
||||
const agent = {
|
||||
agent_id: "agent-1",
|
||||
agent_name: "support-agent",
|
||||
|
|
@ -62,5 +74,18 @@ describe("AgentInfoView settings", () => {
|
|||
expect(token).toBe("sk-test");
|
||||
expect(agentId).toBe("agent-1");
|
||||
expect(payload.tpm_limit).toBe(42);
|
||||
const clearedMcpGrants = { mcp_servers: [], mcp_access_groups: [], mcp_toolsets: [], mcp_tool_permissions: {} };
|
||||
expect(payload.object_permission).toEqual(clearedMcpGrants);
|
||||
});
|
||||
|
||||
it("shows MCP grants with server names on the overview tab", async () => {
|
||||
vi.mocked(networking.getAgentInfo).mockResolvedValue({
|
||||
...agent,
|
||||
object_permission: { mcp_servers: ["srv-1"] },
|
||||
} as unknown as Agent);
|
||||
|
||||
render(<AgentInfoView agentId="agent-1" onClose={vi.fn()} accessToken="sk-test" isAdmin={true} />);
|
||||
|
||||
expect(await screen.findByText("github (srv-1)")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,16 +15,27 @@ import { getAgentInfo, patchAgentCall, getAgentCreateMetadata, AgentCreateInfo }
|
|||
import { Agent } from "@/components/agents/types";
|
||||
import { KeyResponse } from "@/components/key_team_helpers/key_list";
|
||||
import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
|
||||
import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
|
||||
import KeyInfoView from "@/components/templates/key_info_view";
|
||||
import MCPServerSelector from "@/components/mcp_server_management/MCPServerSelector";
|
||||
import MCPToolPermissions from "@/components/mcp_server_management/MCPToolPermissions";
|
||||
import AgentVirtualKeys from "./agent_virtual_keys";
|
||||
import AgentFormFields, { unmountedA2AFieldNames } from "./agent_form_fields";
|
||||
import DynamicAgentFormFields, { buildDynamicAgentData, unmountedDynamicFieldNames } from "./dynamic_agent_form_fields";
|
||||
import { AGENT_FORM_CONFIG, buildAgentDataFromForm, parseAgentForForm } from "./agent_config";
|
||||
import {
|
||||
AGENT_FORM_CONFIG,
|
||||
buildAgentDataFromForm,
|
||||
buildMcpObjectPermission,
|
||||
parseAgentForForm,
|
||||
parseMcpPermissionsForForm,
|
||||
} from "./agent_config";
|
||||
import {
|
||||
AgentFormField,
|
||||
AgentFormValues,
|
||||
AgentNumberInput,
|
||||
AgentRequestPayload,
|
||||
McpServerSelection,
|
||||
labelWithHint,
|
||||
omitFieldValues,
|
||||
useCollapsiblePanels,
|
||||
} from "./AgentFormKit";
|
||||
|
|
@ -111,7 +122,7 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
|
|||
} else {
|
||||
const typeInfo = agentTypeMetadata.find((t) => t.agent_type === agentType);
|
||||
if (typeInfo) {
|
||||
form.reset(parseDynamicAgentForForm(data, typeInfo));
|
||||
form.reset({ ...parseDynamicAgentForForm(data, typeInfo), ...parseMcpPermissionsForForm(data) });
|
||||
} else {
|
||||
form.reset(parseAgentForForm(data));
|
||||
}
|
||||
|
|
@ -131,7 +142,7 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
|
|||
if (agentType !== "a2a") {
|
||||
const typeInfo = agentTypeMetadata.find((t) => t.agent_type === agentType);
|
||||
if (typeInfo) {
|
||||
form.reset(parseDynamicAgentForForm(agent, typeInfo));
|
||||
form.reset({ ...parseDynamicAgentForForm(agent, typeInfo), ...parseMcpPermissionsForForm(agent) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -139,6 +150,14 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
|
|||
|
||||
const selectedAgentTypeInfo = agentTypeMetadata.find((t) => t.agent_type === detectedAgentType);
|
||||
const watchedFormValues = useWatch({ control: form.control });
|
||||
const mcpSelection = useWatch({ control: form.control, name: "allowed_mcp_servers_and_groups" });
|
||||
const mcpToolPermissions = useWatch({ control: form.control, name: "mcp_tool_permissions" });
|
||||
const { data: mcpServers = [] } = useMCPServers();
|
||||
|
||||
const mcpServerLabel = (serverId: string) => {
|
||||
const server = mcpServers.find((s) => s.server_id === serverId);
|
||||
return server?.server_name ? `${server.server_name} (${serverId})` : serverId;
|
||||
};
|
||||
|
||||
const discoveryRequest = useMemo(
|
||||
() => buildDiscoveryRequest(detectedAgentType, watchedFormValues || {}, selectedAgentTypeInfo),
|
||||
|
|
@ -199,7 +218,10 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
|
|||
? overlayDiscoveredCardParams(built, appliedDiscoveredSelection.selected_card)
|
||||
: built;
|
||||
|
||||
await patchAgentCall(accessToken, agentId, updateData);
|
||||
await patchAgentCall(accessToken, agentId, {
|
||||
...updateData,
|
||||
object_permission: buildMcpObjectPermission(values),
|
||||
});
|
||||
toast.success("Agent updated successfully");
|
||||
setIsEditing(false);
|
||||
fetchAgentInfo();
|
||||
|
|
@ -337,13 +359,20 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
|
|||
{agent.object_permission &&
|
||||
(agent.object_permission.mcp_servers?.length ||
|
||||
agent.object_permission.mcp_access_groups?.length ||
|
||||
agent.object_permission.mcp_toolsets?.length ||
|
||||
(agent.object_permission.mcp_tool_permissions &&
|
||||
Object.keys(agent.object_permission.mcp_tool_permissions).length > 0)) && (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<h3 className="text-lg font-medium">MCP Tool Permissions</h3>
|
||||
<DetailList className="mt-4">
|
||||
{agent.object_permission.mcp_servers && agent.object_permission.mcp_servers.length > 0 && (
|
||||
<DetailItem label="MCP Servers">{agent.object_permission.mcp_servers.join(", ")}</DetailItem>
|
||||
<DetailItem label="MCP Servers">
|
||||
<div className="space-y-1">
|
||||
{agent.object_permission.mcp_servers.map((serverId) => (
|
||||
<div key={serverId}>{mcpServerLabel(serverId)}</div>
|
||||
))}
|
||||
</div>
|
||||
</DetailItem>
|
||||
)}
|
||||
{agent.object_permission.mcp_access_groups &&
|
||||
agent.object_permission.mcp_access_groups.length > 0 && (
|
||||
|
|
@ -351,13 +380,16 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
|
|||
{agent.object_permission.mcp_access_groups.join(", ")}
|
||||
</DetailItem>
|
||||
)}
|
||||
{agent.object_permission.mcp_toolsets && agent.object_permission.mcp_toolsets.length > 0 && (
|
||||
<DetailItem label="MCP Toolsets">{agent.object_permission.mcp_toolsets.join(", ")}</DetailItem>
|
||||
)}
|
||||
{agent.object_permission.mcp_tool_permissions &&
|
||||
Object.keys(agent.object_permission.mcp_tool_permissions).length > 0 && (
|
||||
<DetailItem label="Tool permissions per server">
|
||||
<div className="space-y-1">
|
||||
{Object.entries(agent.object_permission.mcp_tool_permissions).map(([serverId, tools]) => (
|
||||
<div key={serverId}>
|
||||
<span className="font-medium">{serverId}:</span>{" "}
|
||||
<span className="font-medium">{mcpServerLabel(serverId)}:</span>{" "}
|
||||
{Array.isArray(tools) ? tools.join(", ") : String(tools)}
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -457,6 +489,41 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
|
|||
{rateLimitField("session_rpm_limit", "Session RPM Limit")}
|
||||
</div>
|
||||
|
||||
<Separator className="my-6" />
|
||||
<h3 className="text-lg font-medium mb-4">MCP Servers</h3>
|
||||
<FieldGroup>
|
||||
<AgentFormField
|
||||
name="allowed_mcp_servers_and_groups"
|
||||
label={labelWithHint(
|
||||
"Allowed MCP Servers",
|
||||
"Select which MCP servers or access groups this agent can access. Keys bound to this agent can only reach servers granted here.",
|
||||
)}
|
||||
>
|
||||
{({ value, onChange }) => (
|
||||
<MCPServerSelector
|
||||
onChange={onChange}
|
||||
value={{
|
||||
servers: (value as McpServerSelection | undefined)?.servers ?? [],
|
||||
accessGroups: (value as McpServerSelection | undefined)?.accessGroups ?? [],
|
||||
toolsets: (value as McpServerSelection | undefined)?.toolsets ?? [],
|
||||
}}
|
||||
accessToken={accessToken ?? ""}
|
||||
placeholder="Select MCP servers or access groups (optional)"
|
||||
/>
|
||||
)}
|
||||
</AgentFormField>
|
||||
</FieldGroup>
|
||||
<div className="mt-4">
|
||||
<MCPToolPermissions
|
||||
accessToken={accessToken ?? ""}
|
||||
selectedServers={mcpSelection?.servers ?? []}
|
||||
toolPermissions={mcpToolPermissions ?? {}}
|
||||
onChange={(toolPerms: Record<string, string[]>) =>
|
||||
form.setValue("mcp_tool_permissions", toolPerms)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -6243,6 +6243,7 @@ export const patchAgentCall = async (
|
|||
agent_name?: string;
|
||||
litellm_params?: Record<string, any>;
|
||||
agent_card_params?: Record<string, any>;
|
||||
object_permission?: Record<string, any>;
|
||||
tpm_limit?: number | null;
|
||||
rpm_limit?: number | null;
|
||||
session_tpm_limit?: number | null;
|
||||
|
|
@ -6878,13 +6879,14 @@ export const buildMcpOAuthAuthorizeUrl = ({
|
|||
const base = getProxyBaseUrl();
|
||||
const normalizedServerId = encodeURIComponent(serverId.trim());
|
||||
const url = `${base}/v1/mcp/server/oauth/${normalizedServerId}/authorize`;
|
||||
const params = new URLSearchParams({
|
||||
const authorizeParams = {
|
||||
redirect_uri: redirectUri,
|
||||
state,
|
||||
response_type: "code",
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
});
|
||||
};
|
||||
const params = new URLSearchParams(authorizeParams);
|
||||
if (clientId && clientId.trim().length > 0) {
|
||||
params.set("client_id", clientId);
|
||||
}
|
||||
|
|
|
|||
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -23089,6 +23089,8 @@ export interface components {
|
|||
mcp_tool_permissions?: {
|
||||
[key: string]: string[];
|
||||
} | null;
|
||||
/** Mcp Toolsets */
|
||||
mcp_toolsets?: string[] | null;
|
||||
/** Models */
|
||||
models?: string[] | null;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue