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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion litellm/proxy/_experimental/mcp_server/rest_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -386,8 +386,15 @@ async def _get_tools_for_single_server(
raw_headers: Optional[Dict[str, str]] = None,
user_api_key_auth: Optional[UserAPIKeyAuth] = None,
extra_headers: Optional[Dict[str, str]] = None,
apply_tool_filters: bool = True,
):
"""Helper function to get tools for a single server."""
"""Helper function to get tools for a single server.

When ``apply_tool_filters`` is False the raw server catalog is returned
without the allowed_tools/disallowed_tools gate or the per-key tool
permissions. This is the admin-only configuration view; every runtime
path keeps the default True so callable tools stay filtered.
"""
tools = await global_mcp_server_manager._get_tools_from_server(
server=server,
mcp_auth_header=server_auth_header,
Expand All @@ -397,6 +404,9 @@ async def _get_tools_for_single_server(
user_api_key_auth=user_api_key_auth,
)

if not apply_tool_filters:
return _create_tool_response_objects(tools, server.mcp_info)

# Always apply allowed_tools/disallowed_tools so the blacklist is
# enforced even when no allowlist is set (matches the SSE/HTTP path).
tools = filter_tools_by_allowed_tools(tools, server)
Expand Down Expand Up @@ -463,6 +473,7 @@ async def _list_tools_for_single_server(
mcp_auth_header: Optional[str],
raw_headers_from_request: dict,
user_api_key_dict: UserAPIKeyAuth,
apply_tool_filters: bool = True,
) -> dict:
"""Handle tool listing for a single server_id request."""
# Resolve a server name to its UUID if needed
Expand Down Expand Up @@ -527,6 +538,7 @@ async def _list_tools_for_single_server(
raw_headers_from_request,
user_api_key_dict,
extra_headers=user_oauth_extra_headers,
apply_tool_filters=apply_tool_filters,
)
except MCPUpstreamAuthError:
# Surface the upstream 401/403 to the caller so it can emit the
Expand All @@ -552,6 +564,14 @@ async def list_tool_rest_api(
server_id: Optional[str] = Query(
None, description="The server id to list tools for"
),
include_disabled_tools: bool = Query(
False,
description=(
"Admin only. Return the full server tool catalog without the "
"allowed_tools filter or per-key tool permissions, so the MCP "
"settings UI can configure the allowlist. Ignored for non-admins."
),
),
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
) -> dict:
"""
Expand Down Expand Up @@ -579,6 +599,13 @@ async def list_tool_rest_api(
)

try:
# The full catalog (allowlist filter skipped) is admin-only so the
# REST endpoint can't be used to enumerate deliberately-disabled tools.
apply_tool_filters = not (
include_disabled_tools
and user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN
)

# Extract auth headers from request
headers = request.headers
raw_headers_from_request = dict(headers)
Expand Down Expand Up @@ -620,6 +647,7 @@ async def list_tool_rest_api(
mcp_auth_header=mcp_auth_header,
raw_headers_from_request=raw_headers_from_request,
user_api_key_dict=user_api_key_dict,
apply_tool_filters=apply_tool_filters,
)
else:
if not allowed_server_ids:
Expand Down Expand Up @@ -677,6 +705,7 @@ async def list_tool_rest_api(
raw_headers_from_request,
user_api_key_dict,
extra_headers=user_oauth_extra_headers,
apply_tool_filters=apply_tool_filters,
)
list_tools_result.extend(tools_result)
except Exception as e:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,7 @@ async def fake_get_tools(
raw_headers=None,
user_api_key_auth=None,
extra_headers=None,
apply_tool_filters=True,
):
captured["called"] = True
captured["server"] = server
Expand Down Expand Up @@ -545,6 +546,78 @@ async def fake_get_tools(
assert result["error"] is None
assert result["message"] == "Successfully retrieved tools"

async def test_include_disabled_tools_is_admin_only(self, monkeypatch):
"""include_disabled_tools skips the allowlist filter only for PROXY_ADMIN;
a non-admin passing it stays filtered so the REST endpoint can't be used
to enumerate deliberately-disabled tools."""
from litellm.proxy._types import LitellmUserRoles

async def fake_contexts(user_api_key_auth):
return [user_api_key_auth]

async def fake_get_allowed_mcp_servers(*args, **kwargs):
return ["server-1"]

class StubServer:
alias = "server-1"
server_name = "server-1"
name = "stub"
allowed_tools = ["tool1"]
mcp_info = {"server_name": "stub"}
available_on_public_internet = True

stub_server = StubServer()
captured = {}

async def fake_get_tools(
server, server_auth_header, *args, apply_tool_filters=True, **kwargs
):
captured["apply_tool_filters"] = apply_tool_filters
return ["tool-1"]

monkeypatch.setattr(
rest_endpoints,
"build_effective_auth_contexts",
fake_contexts,
raising=False,
)
monkeypatch.setattr(
rest_endpoints.global_mcp_server_manager,
"get_allowed_mcp_servers",
fake_get_allowed_mcp_servers,
raising=False,
)
monkeypatch.setattr(
rest_endpoints.global_mcp_server_manager,
"get_mcp_server_by_id",
lambda server_id: stub_server if server_id == "server-1" else None,
raising=False,
)
monkeypatch.setattr(
rest_endpoints,
"_get_tools_for_single_server",
fake_get_tools,
raising=False,
)

request = _build_request(path="/mcp-rest/tools/list", method="GET")

await rest_endpoints.list_tool_rest_api(
request,
server_id="server-1",
include_disabled_tools=True,
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
)
assert captured["apply_tool_filters"] is False

await rest_endpoints.list_tool_rest_api(
request,
server_id="server-1",
include_disabled_tools=True,
user_api_key_dict=UserAPIKeyAuth(),
)
assert captured["apply_tool_filters"] is True

@pytest.mark.parametrize("upstream_status", [401, 403])
async def test_upstream_auth_failure_surfaces_status_and_challenge(
self, monkeypatch, upstream_status
Expand Down Expand Up @@ -649,6 +722,7 @@ async def fake_get_tools(
raw_headers=None,
user_api_key_auth=None,
extra_headers=None,
apply_tool_filters=True,
):
captured["called"] = True
captured["server_arg"] = server
Expand Down Expand Up @@ -792,6 +866,7 @@ async def fake_get_tools(
raw_headers=None,
user_api_key_auth=None,
extra_headers=None,
apply_tool_filters=True,
):
captured["server"] = server
captured["auth_header"] = server_auth_header
Expand Down Expand Up @@ -1284,6 +1359,56 @@ async def fake_get_tools_from_server(**kwargs):
assert "tool1" not in tool_names
assert "tool4" not in tool_names

async def test_apply_tool_filters_false_returns_full_catalog(self, monkeypatch):
"""apply_tool_filters=False returns the raw catalog without the server
allowed_tools gate, so the config UI can render disabled tools as off."""
from litellm.proxy._experimental.mcp_server.server import MCPServer
from litellm.types.mcp import MCPTransport

class MockTool:
def __init__(self, name):
self.name = name
self.description = name
self.inputSchema = {}

mock_tools = [MockTool("tool1"), MockTool("tool2"), MockTool("tool3")]

async def fake_get_tools_from_server(**kwargs):
return mock_tools

monkeypatch.setattr(
rest_endpoints.global_mcp_server_manager,
"_get_tools_from_server",
fake_get_tools_from_server,
raising=False,
)

# Server enforces an allowlist of just tool1.
server = MCPServer(
server_id="test-server-id",
name="test-server",
transport=MCPTransport.sse,
allowed_tools=["tool1"],
)
user_api_key_dict = UserAPIKeyAuth(api_key="test-key", object_permission=None)

# Runtime default: only the allowed tool comes back.
filtered = await rest_endpoints._get_tools_for_single_server(
server=server,
server_auth_header=None,
user_api_key_auth=user_api_key_dict,
)
assert [t.name for t in filtered] == ["tool1"]

# Config view: full catalog, including the disabled tools.
full = await rest_endpoints._get_tools_for_single_server(
server=server,
server_auth_header=None,
user_api_key_auth=user_api_key_dict,
apply_tool_filters=False,
)
assert {t.name for t in full} == {"tool1", "tool2", "tool3"}


class TestStdioCommandAllowlist:
"""Tests for MCP stdio command allowlist validation."""
Expand Down
10 changes: 0 additions & 10 deletions ui/litellm-dashboard/eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -1351,11 +1351,6 @@
"count": 1
}
},
"src/components/mcp_tools/mcp_server_edit.test.tsx": {
"unused-imports/no-unused-imports": {
"count": 1
}
},
"src/components/mcp_tools/mcp_server_edit.tsx": {
"no-restricted-imports": {
"count": 1
Expand Down Expand Up @@ -1517,11 +1512,6 @@
"count": 1
}
},
"src/components/organisms/RegenerateKeyModal.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/organisms/create_key_button.test.tsx": {
"@typescript-eslint/no-require-imports": {
"count": 2
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,9 @@
const [form] = Form.useForm();
const [isLoading, setIsLoading] = useState(false);
const [costConfig, setCostConfig] = useState<MCPServerCostInfo>({});
const [formValues, setFormValues] = useState<Record<string, any>>({});

Check warning on line 75 in ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
const [pendingRestoredValues, setPendingRestoredValues] = useState<{
values: Record<string, any>;

Check warning on line 77 in ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
transport?: string;
} | null>(null);
const [aliasManuallyEdited, setAliasManuallyEdited] = useState(false);
Expand Down Expand Up @@ -188,6 +188,7 @@
}
},
onBeforeRedirect: persistCreateUiState,
flowSource: "create",
});

React.useEffect(() => {
Expand Down Expand Up @@ -264,7 +265,7 @@
const transport = prefillData.transport || "";
setTransportType(transport);

const prefillValues: Record<string, any> = {

Check warning on line 268 in ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
server_name: sanitizedName,
alias: sanitizedName,
description: prefillData.description || "",
Expand All @@ -272,7 +273,7 @@
};

if (transport === "stdio") {
const stdioObj: Record<string, any> = {};

Check warning on line 276 in ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
if (prefillData.command) stdioObj.command = prefillData.command;
if (prefillData.args && prefillData.args.length > 0) stdioObj.args = prefillData.args;
if (prefillData.env_vars && prefillData.env_vars.length > 0) {
Expand All @@ -294,7 +295,7 @@
setAliasManuallyEdited(false);
}, [isModalVisible, prefillData, form]);

const handleCreate = async (values: Record<string, any>) => {

Check warning on line 298 in ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Async arrow function has a complexity of 37. Maximum allowed is 20

Check warning on line 298 in ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
setIsLoading(true);
try {
const {
Expand All @@ -318,7 +319,7 @@

const credentialsPayload =
credentialValues && typeof credentialValues === "object"
? Object.entries(credentialValues).reduce((acc: Record<string, any>, [key, value]) => {

Check warning on line 322 in ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
if (value === undefined || value === null || value === "") {
return acc;
}
Expand Down Expand Up @@ -351,12 +352,12 @@
// If it's the full mcpServers structure, extract the first server config
if (stdioConfig.mcpServers && typeof stdioConfig.mcpServers === "object") {
const serverNames = Object.keys(stdioConfig.mcpServers);
if (serverNames.length > 0) {

Check warning on line 355 in ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Blocks are nested too deeply (5). Maximum allowed is 4
const firstServerName = serverNames[0];
actualConfig = stdioConfig.mcpServers[firstServerName];

// If no alias is provided, use the server name from the JSON
if (!restValues.server_name) {

Check warning on line 360 in ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Blocks are nested too deeply (6). Maximum allowed is 4
restValues.server_name = firstServerName.replace(/-/g, "_"); // Replace hyphens with underscores
}
}
Expand All @@ -381,7 +382,7 @@
}

// Parse token_validation JSON if provided
let tokenValidation: Record<string, any> | null = null;

Check warning on line 385 in ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
if (rawTokenValidationJson && rawTokenValidationJson.trim() !== "") {
try {
tokenValidation = JSON.parse(rawTokenValidationJson);
Expand Down Expand Up @@ -1088,7 +1089,6 @@
<div className="mt-6">
<MCPToolConfiguration
accessToken={accessToken}
oauthAccessToken={oauthAccessToken}
formValues={formValues}
allowedTools={allowedTools}
existingAllowedTools={null}
Expand Down
Loading
Loading