diff --git a/docs/plans/provider-executed-mcp.md b/docs/plans/provider-executed-mcp.md index f770d075632..0b3b69be8b7 100644 --- a/docs/plans/provider-executed-mcp.md +++ b/docs/plans/provider-executed-mcp.md @@ -1,6 +1,6 @@ # Provider-executed MCP registration -Status: database, isolation, and admin registration API slices approved and pushed through `1ba99633e4`. Provider YAML registration and the explicit provider input model are reviewed and approved for commit and push. Permission integration and native-request enforcement are next +Status: registration API, YAML registration, execution isolation, and the explicit provider input model are approved and pushed through `a44cd0949e`. Permission integration is reviewed and approved. Native-request enforcement is next ## Implementation plan @@ -10,35 +10,46 @@ Work through these layers in order on `litellm_provider_mcp_registration`. Updat | --- | --- | --- | | 1 | Settle registration fields, values, storage, and validation | Approved | | 2 | Implement persistence and execution-mode isolation before enabling provider registration | Database, isolation, and admin API committed; YAML registration and provider input model reviewed and approved | -| 3 | Management UI/API, stored tool options, and existing key/team/toolset selectors | Admin CRUD and inventory API approved; UI and scoped selectors pending | +| 3 | Management UI/API, stored tool options, and existing key/team/toolset selectors | Scoped retrieval, assignment, and stored tool selectors implemented and approved. Provider registration form remains separate | | 4 | Authorize effective OpenAI Responses and Anthropic Messages MCP attachments through existing MCP permissions | Pending | | 5 | Live provider tests, streaming admission tests, UI tests, and gateway regressions | Pending | -The next phase connects existing object permissions to provider registrations and enforces native attachments before provider dispatch. Existing permission storage can represent the IDs and tools, but listing a server in the admin catalog does not complete this integration. Assignment validation still resolves config entries through executable inventory, scoped lists still retrieve executable server objects, and tool pickers still call `/mcp-rest/tools/list`. Those paths must read registration identity and stored allowed tools from management inventory for provider mode +The next phase enforces native attachments before provider dispatch using the completed permission integration. Preserve the current sources of truth and lookup paths. Object permissions remain database-backed and use the existing auth caches. Key assignment keeps its database lookup and gains recognition of provider-only YAML records in its config fallback. Scoped lists keep their existing permission calculation and hydrate authorized provider IDs from the already-loaded registration inventory. Provider tool selectors use stored allowed tools and descriptions. Gateway lookup, discovery, and execution retain their existing paths -Reuse the existing server/tool grant resolution functions, groups, toolsets, and explicit denies. Apply the agreed key/team/server ceiling to provider authorization. The current resolver also has legacy additive access-group grants and some permissive lookup-error handling, so calling it unchanged would not guarantee that contract. Native admission must resolve the canonical URL, require provider execution mode, and validate a finite requested tool scope after effective request configuration is assembled. Cover both native APIs and streaming, with denied requests producing no provider dispatch. Provider MCP discovery/execution stays outside LiteLLM +Reuse the existing server/tool grant resolution functions, groups, toolsets, and explicit denies. The user accepted today's permission combination and error-handling semantics for v1, including existing inheritance and additive access-group behavior. Do not introduce a stricter policy or broadly replace auth lookups in this feature. Apply the provider registration's allowed-tools ceiling to the resolved permission scope. Native admission must resolve the canonical URL, require provider execution mode, and validate a finite requested tool scope after effective request configuration is assembled. Cover both native APIs and streaming, with denied requests producing no provider dispatch. Provider MCP discovery/execution stays outside LiteLLM -V1 supports URL-based OpenAI Responses MCP and both Anthropic Messages connector formats. Request authorization applies to streaming and non-streaming calls before provider dispatch. Server restrictions, team permissions, and key permissions intersect. Existing applicable permission ceilings and explicit denies continue to apply +V1 supports URL-based OpenAI Responses MCP and both Anthropic Messages connector formats. Request authorization applies to streaming and non-streaming calls before provider dispatch. Reuse today's key/team permission semantics, bounded by the registered server's allowed tools. Existing applicable permission ceilings and explicit denies continue to apply Response/SSE inspection, per-invocation argument policy inside a provider's tool loop, automatic MCP discovery for provider registrations, local MCP references, provider connector IDs, and additional API bridges are outside v1 +The registry design review is deferred by agreement on 2026-11-02. This batch retains the executable registry and separate provider registration inventory. A later revision may publish both modes through one registry and derive executable views at gateway boundaries. No registry consolidation is included in this approval + ## Current review acceptance criteria -The approved batch covered admin CRUD, input validation, cached management inventory, and execution isolation. The current batch adds YAML registration to that same catalog. Key/team-scoped retrieval, selectors, toolsets, and native-request admission remain later layers. Their absence is intentional for this batch +This batch connects provider registrations to existing permission assignment, scoped management retrieval, and key/team/toolset tool choices. It changes no database schema or permission-combination rules. Native LLM request admission remains the next batch. The provider create/edit form, response inspection, and billing attribution remain separate work -Core provider fields remain `execution_mode`, `server_name`, `url`, `allowed_tools`, and optional `tool_name_to_description`, alongside the existing shared management fields. Neutral connection values remain accepted where the shared schema permits them, but never enable MCP execution. Active credentials, transports, OAuth settings, commands, and other connection configuration are rejected. No strict five-field schema rewrite is part of this revision - -| Acceptance area | Required proof in this batch | +| Area | Behavior and proof | | --- | --- | -| Admin CRUD | Create, list, detail, partial update, delete, missing/deleted IDs, duplicate URL, immutable mode | -| Input validation | Active connection settings reject; supported neutral settings succeed; rejected updates leave the row unchanged; omitted tools preserve scope and an explicit empty list stays enforced | -| Cached inventory | Provider registrations appear in admin inventory and refresh after updates and deletion without becoming executable | -| Execution isolation | No provider initialization, discovery, health probe, or tool call from management or gateway endpoints | -| Compatibility | Existing LiteLLM credential merging and update transaction rules remain in place | +| Assignment | Config provider IDs, names, and aliases retain server and tool grants during key create/update. Existing DB lookups remain in place | +| Scoped retrieval | Team/user selectors and key-scoped list/detail include authorized provider records. Unrelated server grants do not expose them. Read grants do not authorize edits | +| Shared permissions | Existing inheritance, key/team tool intersection, access groups, all-server grants, explicit denies, and toolset expansion resolve provider identities | +| Tool choices | Key/team permission and toolset selectors read stored `allowed_tools` and descriptions. Registration edits, including `[]`, replace choices without discovery | +| Execution isolation | Provider registrations remain outside the executable registry, gateway discovery, and connected-app reachability | -Provider updates keep read-merge-validation atomic through the existing writer. The gateway credential path is not moved into a new blanket transaction. One initial row lookup selects the immutable execution mode and is reused when the gateway credential flow needs stored values +Control flow for this review -The later permissions layer will test matching list/detail visibility under key/team intersection, explicit denial, disjoint scopes, revocation after caching, and separation of read grants from mutation privileges. The admin-only live run is not evidence for that layer +1. Key create/update calls `validate_key_mcp_servers_against_team`. Its existing database identity query and gateway fallback stay unchanged. The config fallback also recognizes provider records, so they are not removed as stale +2. Existing permission expansion recognizes provider IDs, names, aliases, and access groups. Object-permission rows and auth caches still own the grants. Toolsets keep their current stored server/tool pairs and resolver +3. Team/user-scoped lists calculate permitted IDs as before, then hydrate provider IDs from the loaded catalog alongside executable records. Unscoped lists retain the existing gateway `view_all` shortcut and add only permitted provider records for non-admins +4. Detail resolves provider identity from the captured management inventory. Config detail uses that record directly. Database detail maps names/aliases to an ID and keeps the existing database lookup and authorization. Neither path probes a provider server +5. `MCPToolPermissions` and the toolset picker use `providerToolChoices` for provider registrations. Gateway choices continue through `/mcp-rest/tools/list`. Stored choices are derived from current registration data rather than a stale discovery cache +6. Connected-app annotations require LiteLLM execution mode even when the caller has a provider grant. Provider registration is an authorization resource, not a gateway connection target + +Validation for this permission-integration batch passed 1,384 focused Python tests, then five additional provider tool-resolution cases and targeted checks after final revisions. All 66 affected UI cases passed across the suite and final targeted run, including provider toolset creation with real hooks and components. Production dashboard build and typecheck passed. Seventy live proxy/PostgreSQL checks covered DB/config assignments, name/alias updates, scoped list/detail grants and denials, inheritance, toolsets, sentinel denial, cached grant revocation, CRUD, and gateway isolation. The upstream recorder saw zero connections + +The config-assignment regression fails on approved commit `a44cd0949e` for ID, name, and alias, with both server and tool grants incorrectly removed. It passes with the permission-integration changes. Changed-file type comparison introduced no diagnostics; strict Ruff and type-discipline rule counts did not grow + +Live browser QA remains incomplete. The browser connector was unavailable, and native Chrome automation encountered active user interaction. No successful interactive flow or screenshots are claimed. Component integration tests cover selecting provider tools and refresh behavior. Evidence is under `/Users/mchanshetty/Desktop/code/mcp-gap-matrix/permission-review/` The config slice accepts provider entries under the existing `mcp_servers` key, reuses registration validation, and publishes read-only management records in the shared catalog without constructing gateway clients. It covers startup and reload, removal, stable IDs, conflicting config/database identities or URLs, and zero upstream activity. Existing LiteLLM entries use the existing gateway loader @@ -251,7 +262,7 @@ Existing `metadata.allowed_tools` remains an independent compatibility restricti | Schema and compatibility | Old registrations load as `litellm`; provider records round-trip with null transport, no upstream credentials, and exact allowed tools | | Field validation | Mode/URL/list validation; partial updates; enforced empty list; duplicate endpoint rejection | | Management | API and UI create/edit/select the same server and tool IDs without live MCP discovery | -| Toolsets | Mixed-mode toolsets remain assignable; grants cannot change execution mode or exceed server/team ceilings | +| Toolsets | Mixed-mode toolsets remain assignable; grants cannot change execution mode or exceed the server tool ceiling and existing effective permission scope | | Isolation | Zero outbound MCP initialize/list/call/probe activity for provider registrations, including startup, refresh, and malicious gateway references | | Authorization | Registered allowed native calls succeed; unknown URL, wrong mode, disallowed tools, unbounded scope, and narrower key/team grants reject before provider dispatch | | API coverage | OpenAI Responses and both Anthropic Messages formats; request-supplied and model-configured attachments; streaming and non-streaming | diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index 4b1f6b12fd0..bc7c015de87 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -1129,13 +1129,16 @@ if MCP_AVAILABLE: ) from litellm.proxy.proxy_server import prisma_client - access_groups: Final = set() + access_groups: Final = set[str]() # Get from config-loaded servers for server in global_mcp_server_manager.config_mcp_servers.values(): if server.access_groups: access_groups.update(server.access_groups) + for server in global_mcp_server_manager.catalog.config_registrations.values(): + access_groups.update(server.mcp_access_groups) + # Get from DB if prisma_client is not None: try: @@ -1220,11 +1223,17 @@ if MCP_AVAILABLE: return "restricted" def _redacted_server_tables(server_ids: Iterable[str]) -> list[LiteLLM_MCPServerTable]: - registered: Final = (global_mcp_server_manager.get_mcp_server_by_id(server_id) for server_id in server_ids) + selected_ids: Final = frozenset(server_ids) + registered: Final = (global_mcp_server_manager.get_mcp_server_by_id(server_id) for server_id in selected_ids) tables: Final = [ global_mcp_server_manager.build_mcp_server_table(server) for server in registered if server is not None ] - return _redact_mcp_credentials_list(tables) + provider_tables: Final = [ + server + for server_id, server in global_mcp_server_manager.catalog.provider_registrations().items() + if server_id in selected_ids + ] + return _redact_mcp_credentials_list(tables + provider_tables) async def _granted_mcp_server_ids( object_permission_id: str | None, @@ -1356,7 +1365,7 @@ if MCP_AVAILABLE: if view_providers or getattr(server, "execution_mode", MCPExecutionMode.litellm) != MCPExecutionMode.provider ] - if view_all: + if view_all and len(visible_inventory) == len(inventory): return visible_inventory provider_servers: Final = [ @@ -1364,10 +1373,12 @@ if MCP_AVAILABLE: for server in visible_inventory if getattr(server, "execution_mode", MCPExecutionMode.litellm) == MCPExecutionMode.provider ] - aggregated: Final[dict[str, LiteLLM_MCPServerTable]] = {server.server_id: server for server in provider_servers} + initial_servers: Final = visible_inventory if view_all else provider_servers + aggregated: Final[dict[str, LiteLLM_MCPServerTable]] = {server.server_id: server for server in initial_servers} for auth_context in await build_effective_auth_contexts(user_api_key_dict): for server in await global_mcp_server_manager.get_all_allowed_mcp_servers(user_api_key_auth=auth_context): - aggregated.setdefault(server.server_id, server) + if not view_all or server.execution_mode == MCPExecutionMode.provider: + aggregated.setdefault(server.server_id, server) return list(aggregated.values()) @catalog_operation(lambda: global_mcp_server_manager) @@ -1501,7 +1512,9 @@ if MCP_AVAILABLE: if connected_app_view is True and is_ui_session_credential(user_api_key_dict): reachable_ids: Final = await _connected_app_reachable_server_ids(user_api_key_dict) for server in redacted_mcp_servers: - server.connected_app_reachable = server.server_id in reachable_ids + server.connected_app_reachable = ( + server.execution_mode == MCPExecutionMode.litellm and server.server_id in reachable_ids + ) # Annotate has_user_credential for BYOK servers (single batched query) from litellm.proxy.proxy_server import prisma_client as _byok_prisma_client @@ -1974,13 +1987,12 @@ if MCP_AVAILABLE: is_admin_view: Final = user_api_key_has_admin_view(user_api_key_dict) is_restricted_virtual_key: Final = _is_restricted_virtual_key_request(user_api_key_dict) snapshot: Final = global_mcp_server_manager.catalog.current() - config_match: Final = ( + provider_match: Final = ( next( ( server for server in snapshot.management_servers.values() - if server.is_config - and server.execution_mode == MCPExecutionMode.provider + if server.execution_mode == MCPExecutionMode.provider and server_id in (server.server_id, server.server_name, server.alias) ), None, @@ -1989,10 +2001,10 @@ if MCP_AVAILABLE: else None ) resolved: Final = ( - ResolvedMCPServer(table=config_match, runtime=None, source="registry") - if config_match is not None + ResolvedMCPServer(table=provider_match, runtime=None, source="registry") + if provider_match is not None and provider_match.is_config else await resolve_mcp_server( - server_id, + provider_match.server_id if provider_match is not None else server_id, manager=global_mcp_server_manager, db_lookup=lambda sid: get_mcp_server( get_prisma_client_or_throw("Database not connected. Connect a database to your proxy"), diff --git a/litellm/proxy/management_helpers/object_permission_utils.py b/litellm/proxy/management_helpers/object_permission_utils.py index a719a5c67d4..db86b3455ab 100644 --- a/litellm/proxy/management_helpers/object_permission_utils.py +++ b/litellm/proxy/management_helpers/object_permission_utils.py @@ -325,6 +325,11 @@ async def _resolve_mcp_server_identifiers_to_ids( if identifier == registry_key or _mcp_server_identifier_matches(server, identifier): resolved[identifier].add(server_id) + for server in global_mcp_server_manager.catalog.config_registrations.values(): + for identifier in identifiers: + if _mcp_server_identifier_matches(server, identifier): + resolved[identifier].add(server.server_id) + return resolved @@ -491,7 +496,7 @@ def _get_all_mcp_server_ids() -> set[str]: global_mcp_server_manager, ) - return set(global_mcp_server_manager.get_registry().keys()) + return global_mcp_server_manager.get_all_mcp_server_ids() async def _existing_object_permission_mcp_servers( diff --git a/litellm/proxy/mcp_server/auth/user_api_key_auth_mcp.py b/litellm/proxy/mcp_server/auth/user_api_key_auth_mcp.py index be24b1630b0..482c2ad2587 100644 --- a/litellm/proxy/mcp_server/auth/user_api_key_auth_mcp.py +++ b/litellm/proxy/mcp_server/auth/user_api_key_auth_mcp.py @@ -3754,7 +3754,7 @@ class MCPRequestHandler: if object_permissions is None: return set(team_access_group_servers) if SpecialMCPServerName.all_proxy_servers.value in (object_permissions.mcp_servers or []): - return set(global_mcp_server_manager.get_registry().keys()) + return global_mcp_server_manager.get_all_mcp_server_ids() legacy_access_group_servers: Final = await MCPRequestHandler.get_mcp_servers_from_access_groups( object_permissions.mcp_access_groups or [], requires_fresh_policy=requires_fresh_policy or require_readable_policy, @@ -4758,8 +4758,21 @@ class MCPRequestHandler: ) snapshot: Final = global_mcp_server_manager.catalog.current() + provider_registrations: Final = ( + global_mcp_server_manager.catalog.provider_registrations() + if snapshot is not None + else global_mcp_server_manager.catalog.config_registrations + ) + provider_ids: Final = { + server.server_id + for server in provider_registrations.values() + if set(access_groups).intersection(server.mcp_access_groups) + } if snapshot is not None: - return list(MCPRequestHandler._get_config_server_ids_for_access_groups(snapshot.servers, access_groups)) + return list( + MCPRequestHandler._get_config_server_ids_for_access_groups(snapshot.servers, access_groups) + | provider_ids + ) # Use the new helper for config-loaded servers server_ids: Final = MCPRequestHandler._get_config_server_ids_for_access_groups( @@ -4772,7 +4785,7 @@ class MCPRequestHandler: ) server_ids.update(db_server_ids) - return list(server_ids) + return list(server_ids | provider_ids) except Exception as e: if requires_fresh_policy: raise diff --git a/litellm/proxy/mcp_server/catalog.py b/litellm/proxy/mcp_server/catalog.py index 01ec5ea41af..55bc714737b 100644 --- a/litellm/proxy/mcp_server/catalog.py +++ b/litellm/proxy/mcp_server/catalog.py @@ -198,6 +198,16 @@ class CatalogSnapshots: scoped: Final = self._operation.get() return scoped[0] if scoped is not None and not scoped[1].is_set() else None + def provider_registrations(self) -> Mapping[str, LiteLLM_MCPServerTable]: + snapshot: Final = self.current() + if snapshot is not None: + return { + server_id: server + for server_id, server in snapshot.management_servers.items() + if server.execution_mode == MCPExecutionMode.provider + } + return {**self.config_registrations, **self._registration_only_servers} + def routing(self) -> MutableMapping[str, str]: staged: Final = self._staged_routing.get() if staged is not None and not staged[1].is_set(): diff --git a/litellm/proxy/mcp_server/mcp_server_manager.py b/litellm/proxy/mcp_server/mcp_server_manager.py index 06f805250e0..43b4a8a1084 100644 --- a/litellm/proxy/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/mcp_server/mcp_server_manager.py @@ -3643,11 +3643,15 @@ class MCPServerManager: Get all MCP server IDs """ all_servers: Final = list(self.get_registry().values()) - return {server.server_id for server in all_servers} + return {server.server_id for server in all_servers} | self.catalog.provider_registrations().keys() def get_allow_all_keys_server_ids(self) -> list[str]: """Return server IDs that bypass per-key restrictions.""" - return [server.server_id for server in self.get_registry().values() if server.allow_all_keys is True] + return [server.server_id for server in self.get_registry().values() if server.allow_all_keys is True] + [ + server.server_id + for server in self.catalog.provider_registrations().values() + if server.allow_all_keys is True + ] @staticmethod def get_byom_submitted_servers_cache_key(user_id: str) -> str: @@ -3848,7 +3852,7 @@ class MCPServerManager: else access or await MCPRequestHandler.get_mcp_server_access(user_api_key_auth) ) resolved_server_ids: Final = ( - set(self.get_registry().keys()) if admin_unscoped else set(resolved_access.server_ids) + self.get_all_mcp_server_ids() if admin_unscoped else set(resolved_access.server_ids) ) combined_servers: Final = set(resolved_server_ids) verbose_logger.debug("Allowed MCP Servers for user api key auth: %s", combined_servers) @@ -7529,6 +7533,7 @@ class MCPServerManager: if not identifiers: return [] registry: Final = self.get_registry() + providers: Final = self.catalog.provider_registrations() expanded: Final[set[str]] = set() for identifier in identifiers: if identifier in registry: @@ -7539,6 +7544,11 @@ class MCPServerManager: for server_id, server in registry.items() if server.alias == identifier or server.server_name == identifier or server.name == identifier ] + matches.extend( + server.server_id + for server in providers.values() + if identifier in (server.server_id, server.server_name, server.alias) + ) if matches: expanded.update(matches) else: @@ -7863,10 +7873,14 @@ class MCPServerManager: allowed_server_ids: Final = await self.get_allowed_mcp_servers(user_api_key_auth) list_mcp_servers: Final[list[LiteLLM_MCPServerTable]] = [] + providers: Final = self.catalog.provider_registrations() for server_id in allowed_server_ids: server = self.get_mcp_server_by_id(server_id) if not server: + if server_id in providers: + list_mcp_servers.append(providers[server_id]) + continue verbose_logger.warning("MCP Server %s not found in registry", server_id) continue diff --git a/tests/unit/proxy/management_endpoints/test_mcp_management_endpoints.py b/tests/unit/proxy/management_endpoints/test_mcp_management_endpoints.py index 6b2a1a3ffc4..9d27bfff7fd 100644 --- a/tests/unit/proxy/management_endpoints/test_mcp_management_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_mcp_management_endpoints.py @@ -8025,7 +8025,10 @@ class TestConnectedAppViewAnnotation: caller_auth = self._ui_session_auth() admitted_auth = UserAPIKeyAuth(user_id="test_user_id") admitted_auth.mcp_admitted_user_subject = True - mock_manager = self._mock_manager(self._servers(), ["server-1"]) + provider: Final = LiteLLM_MCPServerTable( + server_id="native", execution_mode="provider", transport=None, url="https://native.example/mcp", + ) + mock_manager = self._mock_manager([*self._servers(), provider], ["server-1", "native"]) reload_mock = AsyncMock(return_value=admitted_auth) with ( @@ -8049,7 +8052,7 @@ class TestConnectedAppViewAnnotation: result = await fetch_all_mcp_servers(user_api_key_dict=caller_auth, connected_app_view=True) flags = {server.server_id: server.connected_app_reachable for server in result} - assert flags == {"server-1": True, "server-2": False} + assert flags == {"server-1": True, "server-2": False, "native": False} reload_mock.assert_awaited_once_with("test_user_id", requires_fresh_policy=False) mock_manager.get_allowed_mcp_servers.assert_awaited_once_with(admitted_auth) @@ -11797,3 +11800,43 @@ async def test_mcp_register_application_type_reaches_upstream_or_is_rejected( assert posted["application_type"] == application_type finally: mgmt_endpoints._temporary_mcp_servers.pop(server.server_id, None) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("view_mode", ["restricted", "view_all"]) +@pytest.mark.parametrize("granted", [True, False]) +async def test_provider_management_list_and_detail_share_explicit_grants( + monkeypatch: pytest.MonkeyPatch, view_mode: str, granted: bool, +) -> None: + from litellm.proxy import proxy_server + from litellm.proxy.mcp_server import mcp_server_manager + + manager: Final = MCPServerManager() + monkeypatch.setattr(mcp_server_manager, "global_mcp_server_manager", manager) + monkeypatch.setattr(mgmt_endpoints, "global_mcp_server_manager", manager) + monkeypatch.setattr(proxy_server, "prisma_client", None) + monkeypatch.setattr(proxy_server, "user_api_key_cache", DualCache()) + monkeypatch.setattr(proxy_server, "general_settings", {"user_mcp_management_mode": view_mode}) + await manager.load_servers_from_config({"native_name": { + "server_id": "native-id", "alias": "native_alias", "execution_mode": "provider", + "url": "https://native.example/mcp", "allowed_tools": ["search"], + }}) + auth: Final = UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + object_permission=LiteLLM_ObjectPermissionTable( + object_permission_id="native-grant", mcp_servers=["native_alias"] if granted else [], + ), + ) + servers: Final = await mgmt_endpoints._resolve_accessible_mcp_servers(auth) + assert [server.server_id for server in servers] == (["native-id"] if granted else []) + request: Final = Request({"type": "http", "headers": []}) + if granted: + detail: Final = await mgmt_endpoints.fetch_mcp_server(request, "native_name", user_api_key_dict=auth) + assert detail.server_id == "native-id" and detail.allowed_tools == ["search"] + assert detail.last_health_check is None + assert [server.server_id for server in mgmt_endpoints._redacted_server_tables(iter(["native-id"]))] == ["native-id"] + else: + with pytest.raises(HTTPException) as denied: + await mgmt_endpoints.fetch_mcp_server(request, "native_alias", user_api_key_dict=auth) + assert denied.value.status_code == 403 + assert manager.get_registry() == {} diff --git a/tests/unit/proxy/management_helpers/test_object_permission_utils.py b/tests/unit/proxy/management_helpers/test_object_permission_utils.py index 5814d8d2a97..b698cf86ad6 100644 --- a/tests/unit/proxy/management_helpers/test_object_permission_utils.py +++ b/tests/unit/proxy/management_helpers/test_object_permission_utils.py @@ -267,6 +267,7 @@ def _make_mock_mcp_manager(*existing_ids: str, servers=None): for server_id in existing_ids: server_objs.setdefault(server_id, _make_mock_mcp_server(server_id)) mock_mgr.get_registry.return_value = server_objs + mock_mgr.get_all_mcp_server_ids.side_effect = lambda: set(server_objs) mock_mgr.get_mcp_server_by_id.side_effect = lambda sid: server_objs.get(sid) return mock_mgr @@ -1074,6 +1075,7 @@ async def test_resolve_team_all_proxy_sentinel_resolves_dynamically(mock_access_ } mock_mgr = MagicMock() mock_mgr.get_registry.return_value = registry + mock_mgr.get_all_mcp_server_ids.side_effect = lambda: set(registry) team_perm = MagicMock(spec=LiteLLM_ObjectPermissionTable) team_perm.mcp_servers = [SpecialMCPServerName.all_proxy_servers.value] @@ -1636,3 +1638,40 @@ def test_object_permission_dict_mirrors_pydantic_model(): f"Only in Pydantic model: {sorted(pydantic_fields - typeddict_fields)}\n" f"Only in TypedDict: {sorted(typeddict_fields - pydantic_fields)}" ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("identifier", ["native-id", "native_name", "native_alias"]) +async def test_provider_config_key_assignment_retains_server_and_tool_grants( + monkeypatch: pytest.MonkeyPatch, identifier: str, +) -> None: + from litellm.proxy import proxy_server + from litellm.proxy.mcp_server import mcp_server_manager + + manager: Final = mcp_server_manager.MCPServerManager() + monkeypatch.setattr(mcp_server_manager, "global_mcp_server_manager", manager) + monkeypatch.setattr(proxy_server, "prisma_client", None) + await manager.load_servers_from_config({"native_name": { + "server_id": "native-id", "alias": "native_alias", "execution_mode": "provider", + "url": "https://native.example/mcp", "allowed_tools": ["search"], + }}) + permissions: Final[ObjectPermissionDict] = { + "mcp_servers": [identifier], "mcp_tool_permissions": {identifier: ["search"]}, + } + team: Final = LiteLLM_TeamTableCachedObj( + team_id="native-team", object_permission=LiteLLM_ObjectPermissionTable( + object_permission_id="team-grant", mcp_servers=["native-id"], + ), + ) + assert await validate_key_mcp_servers_against_team(permissions, team) == { + "mcp_servers": [identifier], "mcp_tool_permissions": {identifier: ["search"]}, + } + assert await validate_key_mcp_servers_against_team( + permissions, team, existing_key_object_permission=LiteLLM_ObjectPermissionTable( + object_permission_id="key-grant", mcp_servers=[identifier], + ), + ) == {"mcp_servers": [identifier], "mcp_tool_permissions": {identifier: ["search"]}} + with pytest.raises(HTTPException) as denied: + await validate_key_mcp_servers_against_team(permissions, None) + assert denied.value.status_code == 403 + assert manager.get_registry() == {} diff --git a/tests/unit/proxy/mcp_server/auth/test_user_api_key_auth_mcp.py b/tests/unit/proxy/mcp_server/auth/test_user_api_key_auth_mcp.py index 1b95cbf3c94..3b5abc3caf6 100644 --- a/tests/unit/proxy/mcp_server/auth/test_user_api_key_auth_mcp.py +++ b/tests/unit/proxy/mcp_server/auth/test_user_api_key_auth_mcp.py @@ -9759,7 +9759,7 @@ class TestAdmittedSubjectPerTeamOrgCap: # registry may be a list of bare server_ids (MagicMock servers) OR a dict of # {server_id: server_obj} for tests that need real alias/name resolution (config servers). - reg = registry if isinstance(registry, dict) else {s: MagicMock() for s in registry} + reg = registry if isinstance(registry, dict) else {s: MagicMock(server_id=s) for s in registry} cms.append(patch.object(global_mcp_server_manager, "get_registry", return_value=reg)) with contextlib.ExitStack() as es: for cm in cms: @@ -13252,3 +13252,48 @@ async def test_admin_dashboard_session_keeps_the_whole_mcp_registry( assert set(await global_mcp_server_manager.get_allowed_mcp_servers(dashboard_session)) == set( global_mcp_server_manager.get_registry() ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("key_tools,team_tools,use_toolset,expected", [ + (["search", "fetch"], ["search"], False, ["search"]), + (["fetch"], ["search"], False, []), + (None, ["search"], False, ["search"]), + ([], None, False, []), + (None, ["search"], True, ["search"]), +]) +async def test_provider_tool_grants_use_existing_key_team_and_toolset_resolution( + monkeypatch: pytest.MonkeyPatch, + key_tools: list[str] | None, team_tools: list[str] | None, use_toolset: bool, expected: list[str], +) -> None: + from litellm.proxy import proxy_server + from litellm.proxy.mcp_server import mcp_server_manager + + manager: Final = mcp_server_manager.MCPServerManager() + cache: Final = DualCache() + monkeypatch.setattr(mcp_server_manager, "global_mcp_server_manager", manager) + monkeypatch.setattr(proxy_server, "prisma_client", None) + monkeypatch.setattr(proxy_server, "user_api_key_cache", cache) + await manager.load_servers_from_config({"native_name": { + "server_id": "native-id", "alias": "native_alias", "execution_mode": "provider", + "url": "https://native.example/mcp", "allowed_tools": ["search", "fetch"], + }}) + team: Final = LiteLLM_TeamTable( + team_id="native-team", object_permission=LiteLLM_ObjectPermissionTable( + object_permission_id="native-team-grant", mcp_servers=["native_name"], + mcp_tool_permissions={"native_name": team_tools} if team_tools is not None else {}, + ), + ) + await cache.async_set_cache(key="team_id:native-team", value=team) + await cache.async_set_cache(key="toolset_perms:native-set", value={"native-id": ["search"]}) + monkeypatch.setattr(proxy_server, "prisma_client", SimpleNamespace()) + auth: Final = UserAPIKeyAuth( + team_id="native-team", object_permission=LiteLLM_ObjectPermissionTable( + object_permission_id="native-key-grant", + mcp_servers=[] if use_toolset else ["native_alias"], + mcp_tool_permissions={"native_alias": key_tools} if key_tools is not None else {}, + mcp_toolsets=["native-set"] if use_toolset else [], + ), + ) + assert await MCPRequestHandler.get_allowed_tools_for_server("native-id", auth) == expected + assert manager.get_registry() == {} diff --git a/tests/unit/proxy/mcp_server/test_catalog.py b/tests/unit/proxy/mcp_server/test_catalog.py index 10629672bd9..656f604ace0 100644 --- a/tests/unit/proxy/mcp_server/test_catalog.py +++ b/tests/unit/proxy/mcp_server/test_catalog.py @@ -1052,3 +1052,32 @@ async def test_provider_config_changes_invalidate_cached_inventory_without_datab assert (await manager.get_all_mcp_servers_unfiltered())[0].allowed_tools == [] await manager.load_servers_from_config({}) assert await manager.get_all_mcp_servers_unfiltered() == [] + + +@pytest.mark.asyncio +async def test_provider_permission_identifiers_and_groups_follow_config_reload(monkeypatch: pytest.MonkeyPatch) -> None: + from litellm.proxy import proxy_server + from litellm.proxy.mcp_server import mcp_server_manager + from litellm.proxy.mcp_server.auth.user_api_key_auth_mcp import MCPRequestHandler + + manager: Final = mcp_server_manager.MCPServerManager() + monkeypatch.setattr(mcp_server_manager, "global_mcp_server_manager", manager) + monkeypatch.setattr(proxy_server, "prisma_client", None) + config: Final = {"native_name": { + "server_id": "native-id", "alias": "native_alias", "execution_mode": "provider", + "url": "https://native.example/mcp", "allowed_tools": ["search"], + "access_groups": ["research"], "allow_all_keys": True, + }} + await manager.load_servers_from_config(config) + async with manager.catalog.operation(): + assert manager.get_all_mcp_server_ids() == {"native-id"} + assert manager.get_allow_all_keys_server_ids() == ["native-id"] + assert manager.expand_permission_list(["native_name", "native_alias"]) == ["native-id"] + assert manager.expand_tool_permissions({"native_alias": ["search"]}) == {"native-id": ["search"]} + assert await MCPRequestHandler.get_mcp_servers_from_access_groups(["research"]) == ["native-id"] + assert await manager.catalog.list() == {} + await manager.load_servers_from_config({}) + assert manager.get_all_mcp_server_ids() == {"native-id"} + assert manager.get_all_mcp_server_ids() == set() + assert manager.get_allow_all_keys_server_ids() == [] + assert await MCPRequestHandler.get_mcp_servers_from_access_groups(["research"]) == [] diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServerTools.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServerTools.test.ts index 3ef8e88b2c9..d60b52875ba 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServerTools.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServerTools.test.ts @@ -4,6 +4,7 @@ import { act, renderHook, waitFor } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { mcpServerToolKeys, useMCPServerTools } from "./useMCPServerTools"; +import type { MCPServer } from "@/components/mcp_tools/types"; import * as networking from "@/components/networking"; vi.mock("@/components/networking", () => ({ @@ -116,3 +117,34 @@ describe("useMCPServerTools", () => { }); }); }); + +describe("provider tool choices", () => { + it("reads current registered tools without discovery, including an empty allowlist", async () => { + vi.clearAllMocks(); + vi.mocked(networking.listMCPTools).mockResolvedValue(toolsFor("gateway")); + const provider: MCPServer = { + server_id: "native", + execution_mode: "provider", + allowed_tools: ["search"], + tool_name_to_description: { search: "Search docs" }, + created_at: "", + created_by: "", + updated_at: "", + updated_by: "", + }; + const { result, rerender } = renderHook( + ({ server }) => useMCPServerTools(["native", "gateway"], new Set(["native", "gateway"]), [server]), + { wrapper, initialProps: { server: provider } }, + ); + await waitFor(() => expect(result.current.get("gateway")?.status).toBe("success")); + expect(result.current.get("native")).toEqual({ + tools: [{ name: "search", description: "Search docs" }], + status: "success", + }); + expect(networking.listMCPTools).toHaveBeenCalledTimes(1); + expect(networking.listMCPTools).toHaveBeenCalledWith("test-token-123", "gateway"); + rerender({ server: { ...provider, allowed_tools: [] } }); + expect(result.current.get("native")).toEqual({ tools: [], status: "success" }); + expect(networking.listMCPTools).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServerTools.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServerTools.ts index bc4a6696a6e..685457476fb 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServerTools.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServerTools.ts @@ -2,6 +2,8 @@ import { useQueries, type QueryObserverResult } from "@tanstack/react-query"; import { createQueryKeys } from "../common/queryKeysFactory"; import { listMCPTools } from "@/components/networking"; import useAuthorized from "../useAuthorized"; +import type { MCPServer } from "@/components/mcp_tools/types"; +import { providerToolChoices } from "@/components/mcp_tools/providerTools"; export const mcpServerToolKeys = createQueryKeys("mcpServerTools"); @@ -48,23 +50,33 @@ const toStatus = ({ export const useMCPServerTools = ( serverIds: readonly string[], enabledServerIds: ReadonlySet, + servers: readonly MCPServer[] = [], ): ReadonlyMap => { const { accessToken } = useAuthorized(); + const providers = new Map( + servers.filter((server) => server.execution_mode === "provider").map((server) => [server.server_id, server]), + ); return useQueries({ queries: serverIds.map((serverId) => ({ queryKey: mcpServerToolKeys.detail(serverId), queryFn: () => fetchServerTools(accessToken!, serverId), - enabled: !!accessToken && enabledServerIds.has(serverId), + enabled: !!accessToken && enabledServerIds.has(serverId) && !providers.has(serverId), staleTime: 5 * 60 * 1000, retry: false, refetchOnWindowFocus: false, })), combine: (results) => new Map( - results.map((result, idx): [string, MCPServerToolsState] => [ - serverIds[idx], - { tools: result.data ?? [], status: toStatus(result) }, - ]), + results.map((result, idx): [string, MCPServerToolsState] => { + const serverId = serverIds[idx]; + const provider = providers.get(serverId); + return [ + serverId, + provider + ? { tools: providerToolChoices(provider), status: "success" } + : { tools: result.data ?? [], status: toStatus(result) }, + ]; + }), ), }); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.integration.test.tsx index ba9f2bfa839..280c08ed539 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.integration.test.tsx @@ -11,6 +11,8 @@ import { mcpServerToolKeys } from "@/app/(dashboard)/hooks/mcpServers/useMCPServ import { MCPToolset } from "@/components/mcp_tools/types"; vi.mock("@/components/networking", () => ({ + fetchMCPServers: vi.fn(), + fetchMCPToolsets: vi.fn(), createMCPToolset: vi.fn(), updateMCPToolset: vi.fn(), deleteMCPToolset: vi.fn(), @@ -421,3 +423,49 @@ describe("MCPToolsetsTab usage guide", () => { expect(screen.getByText(/https:\/\/gateway\.example\.com\/toolset\/\/mcp/)).toBeInTheDocument(); }); }); + +it("creates a provider toolset from registration choices without MCP discovery", async () => { + vi.clearAllMocks(); + const serverHooks = await vi.importActual( + "@/app/(dashboard)/hooks/mcpServers/useMCPServers", + ); + const toolsetHooks = await vi.importActual( + "@/app/(dashboard)/hooks/mcpServers/useMCPToolsets", + ); + vi.mocked(useMCPServers).mockImplementation(serverHooks.useMCPServers); + vi.mocked(useMCPToolsets).mockImplementation(toolsetHooks.useMCPToolsets); + vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([]); + vi.mocked(networking.fetchMCPServers).mockResolvedValue([ + { + server_id: "native", + server_name: "native_docs", + execution_mode: "provider", + allowed_tools: ["search", "fetch"], + tool_name_to_description: { search: "Search docs" }, + created_at: "", + created_by: "", + updated_at: "", + updated_by: "", + }, + ]); + vi.mocked(networking.createMCPToolset).mockResolvedValue({} as MCPToolset); + const user = setup(); + render( + + + , + ); + await openCreate(user); + fireEvent.change(screen.getByPlaceholderText("e.g. github-linear-tools"), { target: { value: "native_research" } }); + await user.click(await screen.findByText("native_docs")); + await user.click(await screen.findByRole("checkbox", { name: /^search/ })); + await user.click(screen.getByRole("button", { name: "Create Toolset" })); + await waitFor(() => + expect(networking.createMCPToolset).toHaveBeenCalledWith("sk-test", { + toolset_name: "native_research", + description: "", + tools: [{ server_id: "native", tool_name: "search" }], + }), + ); + expect(networking.listMCPTools).not.toHaveBeenCalled(); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx index dc1477b79d3..f5779bffc2a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx @@ -263,7 +263,7 @@ function CreateToolsetModal({ open, onClose, onSave, initialToolset }: CreateToo () => serversToFetch(open, searching, serverIds, expandedServerIds), [open, searching, serverIds, expandedServerIds], ); - const toolsByServer = useMCPServerTools(serverIds, fetchServerIds); + const toolsByServer = useMCPServerTools(serverIds, fetchServerIds, mcpServers); const serverPrefixById = React.useMemo( () => new Map(mcpServers.map((s) => [s.server_id, s.alias || s.server_name || s.server_id])), [mcpServers], diff --git a/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.test.tsx b/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.integration.test.tsx similarity index 96% rename from ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.test.tsx rename to ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.integration.test.tsx index bad4356aafb..95dd9c8a9c5 100644 --- a/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.integration.test.tsx @@ -1116,3 +1116,41 @@ describe("MCPToolPermissions", () => { }); }); }); + +it("selects stored provider tools and reflects registration edits without discovery", async () => { + vi.clearAllMocks(); + testQueryClient.clear(); + vi.mocked(networking.fetchMCPAccessGroups).mockResolvedValue([]); + vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([]); + const provider = { + server_id: "native", + server_name: "Provider docs", + execution_mode: "provider" as const, + allowed_tools: ["search", "fetch"], + tool_name_to_description: { search: "Search docs" }, + created_at: "", + created_by: "", + updated_at: "", + updated_by: "", + }; + vi.mocked(networking.fetchMCPServers).mockResolvedValue([provider]); + const changed = vi.fn(); + renderWithProviders( + , + ); + await screen.findByText("Provider docs"); + await userEvent.click(screen.getByRole("radio", { name: "Flat List" })); + await userEvent.click(await screen.findByRole("checkbox", { name: "search" })); + expect(changed).toHaveBeenCalledWith({ native: ["search"] }, "edit"); + expect(networking.listMCPTools).not.toHaveBeenCalled(); + vi.mocked(networking.fetchMCPServers).mockResolvedValue([{ ...provider, allowed_tools: ["fetch"] }]); + await testQueryClient.invalidateQueries(); + await waitFor(() => expect(screen.queryByRole("checkbox", { name: "search" })).not.toBeInTheDocument()); + expect(screen.getByRole("checkbox", { name: "fetch" })).toBeInTheDocument(); + expect(networking.listMCPTools).not.toHaveBeenCalled(); +}); diff --git a/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.tsx b/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.tsx index b12b31601b8..12339880c60 100644 --- a/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.tsx +++ b/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.tsx @@ -1,13 +1,13 @@ import React, { useEffect, useRef, useState, useMemo } from "react"; import { listMCPTools } from "../networking"; -import { MCPTool } from "../mcp_tools/types"; +import { providerToolChoices } from "../mcp_tools/providerTools"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { useMCPServers } from "../../app/(dashboard)/hooks/mcpServers/useMCPServers"; import { useMCPAccessGroups } from "../../app/(dashboard)/hooks/mcpServers/useMCPAccessGroups"; import { useMCPToolsets } from "../../app/(dashboard)/hooks/mcpServers/useMCPToolsets"; import McpCrudPermissionPanel from "../mcp_tools/McpCrudPermissionPanel"; -import { classifyToolOp } from "../../utils/mcpToolCrudClassification"; +import { classifyToolOp, type MCPToolEntry } from "../../utils/mcpToolCrudClassification"; import { MCP_ALL_TOOLS_WILDCARD, NO_MCP_SERVERS_SENTINEL } from "../mcp_tools/constants"; import { EffectiveMcpServer, @@ -71,7 +71,18 @@ const MCPToolPermissions: React.FC = ({ } = useMCPServers(); const { data: populatedAccessGroups = [], isSuccess: accessGroupsLoaded } = useMCPAccessGroups(); const { data: toolsets = [], isError: toolsetsFailed, isLoading: toolsetsLoading } = useMCPToolsets(); - const [serverTools, setServerTools] = useState>({}); + const [fetchedServerTools, setServerTools] = useState>({}); + const serverTools = useMemo( + () => ({ + ...fetchedServerTools, + ...Object.fromEntries( + allServers + .filter((server) => server.execution_mode === "provider") + .map((server) => [server.server_id, providerToolChoices(server)]), + ), + }), + [allServers, fetchedServerTools], + ); const [loadingTools, setLoadingTools] = useState>({}); const [toolErrors, setToolErrors] = useState>({}); const [viewModes, setViewModes] = useState>({}); @@ -117,13 +128,16 @@ const MCPToolPermissions: React.FC = ({ setToolErrors((prev) => ({ ...prev, [serverId]: "" })); try { - const response = await listMCPTools(token, serverId); + const response = + entry.server.execution_mode === "provider" + ? { tools: providerToolChoices(entry.server), error: null } + : await listMCPTools(token, serverId); if (response.error) { setToolErrors((prev) => ({ ...prev, [serverId]: response.message || "Failed to fetch tools" })); setServerTools((prev) => ({ ...prev, [serverId]: [] })); } else { - const fetchedTools: MCPTool[] = response.tools || []; + const fetchedTools: MCPToolEntry[] = response.tools || []; setServerTools((prev) => ({ ...prev, [serverId]: fetchedTools })); // Default only unrestricted direct servers to non-delete tools. @@ -163,7 +177,7 @@ const MCPToolPermissions: React.FC = ({ if (toolsetsLoading) return; servers.forEach((entry) => { const serverId = entry.server.server_id; - if (!serverTools[serverId] && !loadingTools[serverId]) { + if (!fetchedServerTools[serverId] && !loadingTools[serverId]) { fetchToolsForServer(entry, accessToken); } }); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/providerTools.ts b/ui/litellm-dashboard/src/components/mcp_tools/providerTools.ts new file mode 100644 index 00000000000..aa0dcb5cd9b --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/providerTools.ts @@ -0,0 +1,4 @@ +import type { MCPServer } from "./types"; + +export const providerToolChoices = (server: MCPServer): { name: string; description: string }[] => + (server.allowed_tools ?? []).map((name) => ({ name, description: server.tool_name_to_description?.[name] ?? "" })); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx index 61c81c030c5..2f6043c18bd 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx @@ -426,6 +426,7 @@ export const MCP_REACHABLE_DESCRIPTION = "Server responded. Authentication and t export interface MCPServer { server_id: string; + execution_mode?: "litellm" | "provider"; is_config?: boolean; server_name?: string | null; alias?: string | null;