Litellm ishaan march23 - MCP Toolsets + GCP Caching fix (#25146) (#25155)

* Litellm ishaan march23 - MCP Toolsets + GCP Caching fix  (#25146)

* feat(mcp): MCP Toolsets — curated tool subsets from one or more MCP servers (#24335)

* feat(mcp): add LiteLLM_MCPToolsetTable and mcp_toolsets to ObjectPermissionTable

* feat(mcp): add prisma migration for MCPToolset table

* feat(mcp): add MCPToolset Python types

* feat(mcp): add toolset_db.py with CRUD helpers for MCPToolset

* feat(mcp): add toolset CRUD endpoints to mcp_management_endpoints

* fix(mcp): skip allow_all_keys servers when explicit mcp_servers permission is set (toolset scope fix)

* feat(mcp): add _apply_toolset_scope and toolset route handling in server.py

* fix(mcp): resolve toolset names in responses API before fetching tools

* feat(mcp): add mcp_toolsets field to LiteLLM_ObjectPermissionTable type

* feat(mcp): register LiteLLM_MCPToolsetTable in prisma client initialization

* feat(mcp): validate mcp_toolsets in key-vs-team permission check

* feat(mcp): register toolset routes in proxy_server.py

* feat(mcp): add MCPToolset and MCPToolsetTool TypeScript types

* feat(mcp): add fetchMCPToolsets, createMCPToolset, updateMCPToolset, deleteMCPToolset API functions

* feat(mcp): add useMCPToolsets React Query hook

* feat(mcp): add toolsets (purple) as third option type in MCPServerSelector

* feat(mcp): extract toolsets from combined MCP field in key form

* feat(mcp): extract toolsets from combined MCP field in team form

* feat(mcp): show toolsets section in MCPServerPermissions read view

* feat(mcp): pass mcp_toolsets through object_permissions_view

* feat(mcp): add MCPToolsetsTab component for creating and managing toolsets

* feat(mcp): add Toolsets tab to mcp_servers.tsx

* feat(mcp): pass mcpToolsets to playground chat and responses API calls

* feat(mcp): generate correct server_url for toolsets in playground API calls

* docs(mcp): add MCP Toolsets documentation

* docs(mcp): add mcp_toolsets to sidebar

* fix(mcp): replace x-mcp-toolset-id header with ContextVar to prevent client forgery

* fix(mcp): use ContextVar + StreamingResponse for toolset MCP routes (fixes SSE streaming)

* fix(mcp): cache toolset permission lookups to avoid per-request DB calls

* test(mcp): add tests for toolset scope enforcement, ContextVar isolation, and access control

* fix(mcp): cache toolset name lookups in MCPServerManager to avoid per-request DB calls

* fix(mcp): prevent body_iter deadlock + use cached toolset lookup in responses API

- _stream_mcp_asgi_response: add done callback to handler_task that puts
  the EOF sentinel on body_queue when the task exits, preventing body_iter
  from hanging forever if the handler raises after headers are sent.
- litellm_proxy_mcp_handler: replace raw get_mcp_toolset_by_name() DB call
  with global_mcp_server_manager.get_toolset_by_name_cached() so toolset
  resolution uses the 60s TTL cache added for this purpose instead of
  hitting the DB on every responses-API request.

* fix(mcp): toolset access control, asyncio fix, and real unit tests

- server.py: _apply_toolset_scope now enforces that non-admin keys must
  have the requested toolset_id in their mcp_toolsets grant list;
  admin keys always bypass the check.
- mcp_management_endpoints.py: three access-control fixes:
  * fetch_mcp_toolsets: non-admin keys with mcp_toolsets=None now
    return [] instead of all toolsets (only admins get 'all' when
    the field is absent)
  * fetch_mcp_toolset: non-admin keys that haven't been granted the
    requested toolset_id now get 403 instead of the full result
  * add_mcp_toolset: duplicate toolset_name now returns 409 Conflict
    instead of an opaque 500
- proxy_server.py: use asyncio.get_running_loop() instead of
  get_event_loop() inside an already-running coroutine (Python 3.10+).
- test_mcp_toolset_scope.py: replace four hollow tests that only
  asserted local variable properties with real tests that call the
  production fetch_mcp_toolsets() and handle_streamable_http_mcp()
  functions with mocked dependencies.

* fix(mcp): add mcp_toolsets to ObjectPermissionBase, fix multi-toolset overwrite, fix delete 404, allow standalone key toolsets

* fix(mcp): add auth check on toolset resolution in responses API; union mcp_servers in _merge_toolset_permissions

* fix(mcp): handle RecordNotFoundError in update_mcp_toolset; union direct servers with toolset servers

* fix(mcp): use _user_has_admin_view; deny None mcp_toolsets for non-admin; use direct RecordNotFoundError import; fix docstring

* fix(mcp): add @default(now()) to MCPToolsetTable.updated_at; fix test for non-admin toolset access

* fix: use UniqueViolationError import; guard _ensure_eof for error/cancel only

* fix(mcp): preserve mcp_access_groups in toolset scope, use shared Redis cache for toolset perms

- Remove mcp_access_groups=[] from _apply_toolset_scope (server.py) and the
  responses API toolset path (litellm_proxy_mcp_handler.py). A key's access-group
  grants remain valid even when the request is scoped to a single toolset; clearing
  them silently revoked legitimate entitlements.

- Switch resolve_toolset_tool_permissions and get_toolset_by_name_cached to use
  user_api_key_cache (Redis-backed DualCache in production) instead of per-instance
  in-memory dicts. Cache entries are now shared across workers, eliminating the
  per-worker stale-toolset-permission window flagged as a P1 by Greptile.

- Use union merge (set union of tool names per server) when applying toolset
  permissions in the responses API path so direct-server tool restrictions are not
  overwritten by toolset permissions.

* fix(mcp): return 404 when edit_mcp_toolset target does not exist

* fix(mcp): align mcp_toolsets default to None in LiteLLM_ObjectPermissionTable

* fix(mcp): admin toolset visibility, in-place tool name mutation, test helper coercion

* fix(mcp): treat None/[] team mcp_toolsets as no restriction in key validation

* fix(mcp): allow_all_keys backward compat, blocked_tools API write-path, efficient startup query

* fix(mcp): use _mcp_active_toolset_id ContextVar to detect toolset scope, avoiding DB-default false-positive

* fix(mcp): remove dead toolset cache stubs, log invalidation failures, align schema updated_at defaults

* fix(mcp): deserialise MCPToolset from Redis cache hit, replace fastapi import in test

* fix(mcp): evict name-cache on toolset mutation, 409 on rename conflict, warning-level list errors

* fix(redis): regenerate GCP IAM token per connection for async cluster (#24426)

* fix(redis): regenerate GCP IAM token per connection for async cluster clients

Async RedisCluster was generating the IAM token once at startup and
storing it as a static password. After the 1-hour GCP token TTL, any
new connection (including to newly-discovered cluster nodes) would fail
to authenticate.

Fix: introduce GCPIAMCredentialProvider that implements redis-py's
CredentialProvider protocol. It calls _generate_gcp_iam_access_token()
on every new connection, matching what the sync redis_connect_func
already does. async_redis.RedisCluster accepts a credential_provider
kwarg which is invoked per-connection.

* refactor(redis): move GCPIAMCredentialProvider to its own file

Extract GCPIAMCredentialProvider and _generate_gcp_iam_access_token
into litellm/_redis_credential_provider.py. _redis.py imports them
from there, keeping the public API unchanged.

* fix: address Greptile review issues

- GCPIAMCredentialProvider now inherits from redis.credentials.CredentialProvider
  so redis-py's async path calls get_credentials_async() properly
- move _redis_credential_provider import to top of _redis.py (PEP 8)
- remove dead else-branch that silently no-oped (gcp_service_account from
  redis_kwargs.get() was always None since it's popped by _get_redis_client_logic)
- remove mid-function 'from litellm import get_secret_str' inline import
- remove unused 'call' import from test_redis.py

* chore: retrigger CI/review

* chore: sync schema.prisma copies from root

* chore: sync schema.prisma copies from root

* fix(proxy_server): use bounded asyncio.Queue with maxsize to prevent unbounded growth

* fix(a2a/pydantic_ai): make api_base Optional to match base class signature

* fix(a2a/pydantic_ai): make api_base Optional in handler and guard against None

* fix(mcp): remove unused get_all_mcp_servers import

* fix(mcp): remove unused MCPToolset import

* refactor(mcp): extract toolset permission logic to reduce statement count below PLR0915 limit

* fix(tests): update reload_servers_from_database tests to mock prisma directly

---------

Co-authored-by: Ishaan Jaff <ishaanjaffer0324@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* fix(toolset_db): lazy-import prisma to avoid ImportError when prisma not installed

* fix(tests): update UI tests for toolset tab and updated empty state text

* fix(tests): add get_mcp_server_by_name to fake_manager stub

---------

Co-authored-by: Ishaan Jaff <ishaanjaffer0324@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
ishaan-berri 2026-04-04 16:23:21 -07:00 committed by GitHub
parent 51876292a0
commit 693ad49719
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 2962 additions and 355 deletions

View file

@ -0,0 +1,231 @@
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
# MCP Toolsets
A **Toolset** is a named collection of specific tools drawn from one or more MCP servers. Instead of giving an agent access to every tool on every server, you pick exactly which tools it needs — from whichever servers they live on — and bundle them under a single name.
## How it works
```
┌─────────────────────────────────┐
│ MCP Toolset │
│ "devtooling-prod" │
└────────────┬────────────────────┘
┌──────────────────┴──────────────────┐
│ │
┌────────▼────────┐ ┌────────▼────────┐
│ CircleCI MCP │ │ DeepWiki MCP │
│ (10+ tools) │ │ (3 tools) │
└────────┬────────┘ └────────┬────────┘
│ │
┌─────────┴──────────┐ ┌──────────┴──────────┐
│ ✓ get_build_logs │ │ ✓ read_wiki_structure│
│ ✓ find_flaky_tests │ │ ✓ read_wiki_contents │
│ ✓ get_pipeline_ │ │ ✗ ask_question │
│ status │ └─────────────────────┘
│ ✓ run_pipeline │
│ ✗ list_followed_ │
│ projects │
└────────────────────┘
Agent sees exactly 6 tools, nothing more.
```
Instead of 13+ tools across two servers, the agent gets 6 — the ones it actually needs.
**Why this matters:**
- Smaller tool lists → fewer tokens, faster responses, less hallucination
- Combine tools from GitHub + Linear + CircleCI into one named grant
- Assign to keys and teams the same way you assign MCP servers today
---
## Create a toolset
### 1. Go to the MCP page
Navigate to **MCP** in the left sidebar.
![Navigate to MCP](https://colony-recorder.s3.amazonaws.com/files/2026-03-22/1a96c713-6a37-4f96-92f1-07bd58c1973c/ascreenshot_23515f386ccc4597b0633987667fe01f_text_export.jpeg)
### 2. Open the Toolsets tab
Click the **Toolsets** tab on the MCP page.
![Click Toolsets tab](https://colony-recorder.s3.amazonaws.com/files/2026-03-22/65b6986b-595a-4b28-8fdc-a7b36bc76e59/ascreenshot_ca70c18fe7ec415486f96a6b405bf550_text_export.jpeg)
### 3. Click "New Toolset"
![New Toolset button](https://colony-recorder.s3.amazonaws.com/files/2026-03-22/798c55c4-5d6b-4815-a642-70ac9f34f102/ascreenshot_3f144f54a1a944e28454239c837b4e6d_text_export.jpeg)
### 4. Enter a name
Type a name for the toolset. Pick something descriptive — this is what agents will reference.
![Enter toolset name](https://colony-recorder.s3.amazonaws.com/files/2026-03-22/62b412e0-d38f-44c3-99e4-3693f1512f6a/ascreenshot_b678c7c988a04f8b887b0f54c4dd95a7_text_export.jpeg)
![Toolset name field](https://colony-recorder.s3.amazonaws.com/files/2026-03-22/ba5ebc95-cab7-470b-a7c9-21f12b9b01a3/ascreenshot_a602e982a2a44890a83dca64d61c38eb_text_export.jpeg)
### 5. Add the first tool
Select an MCP server from the dropdown, then choose the tool you want to include from that server.
![Select MCP server](https://colony-recorder.s3.amazonaws.com/files/2026-03-22/2aa5bcba-6414-42e3-9813-efb0a9078e32/ascreenshot_58fbff35ba654210a1b4dc5452aa6bd9_text_export.jpeg)
![Choose server from dropdown](https://colony-recorder.s3.amazonaws.com/files/2026-03-22/4fd9cffb-d3ba-461a-8679-89f278bf67ad/ascreenshot_b61e9e85a51b494a8d09fe61198d63e1_text_export.jpeg)
![Select tool from server](https://colony-recorder.s3.amazonaws.com/files/2026-03-22/60718e72-2062-494b-9a23-456992c88cbd/ascreenshot_7a1f8eeab30a4a05ba39c450e5458b78_text_export.jpeg)
### 6. Add tools from a second server
Click **Add Tool**, pick a different MCP server, and select another tool. Repeat for as many tools as you need — they can come from any number of servers.
![Add tool from second server](https://colony-recorder.s3.amazonaws.com/files/2026-03-22/f34e0600-cc74-4b18-8794-88d45f326144/ascreenshot_98834b14ab9343e39fb503e458d72b7c_text_export.jpeg)
![Select second server](https://colony-recorder.s3.amazonaws.com/files/2026-03-22/75150368-2202-4da1-99f1-6f0620e9b133/ascreenshot_f94d0bc08ea147348a9cf021cce7d854_text_export.jpeg)
![Select tool from second server](https://colony-recorder.s3.amazonaws.com/files/2026-03-22/ed2cdf6e-025d-4d50-8b12-ed68745d5c51/ascreenshot_0c1c7f76524b46c5a056fda5e6956e2b_text_export.jpeg)
### 7. Create the toolset
Click **Create Toolset** to save.
![Create Toolset](https://colony-recorder.s3.amazonaws.com/files/2026-03-22/021ca7b3-2d9a-49a0-8758-dae3dc3bcb4d/ascreenshot_14c6434e71114a6091e359a996f20e12_text_export.jpeg)
---
## Use a toolset in the Playground
Once created, your toolset appears alongside MCP servers in the **MCP Servers** dropdown in the Playground — it's selectable the same way.
### 1. Go to the Playground
![Navigate to Playground](https://colony-recorder.s3.amazonaws.com/files/2026-03-22/f9d4aa4c-d98e-4767-b98e-aad2890e97ca/ascreenshot_d84239c441bb4e828f229d0c9e079e3f_text_export.jpeg)
![Click Playground](https://colony-recorder.s3.amazonaws.com/files/2026-03-22/d8a07563-97fe-453a-b974-88da46c87294/ascreenshot_ea494300a536400abb2ea6bf3bdfd5ab_text_export.jpeg)
### 2. Select your toolset from MCP Servers
In the left panel under **MCP Servers**, open the dropdown and pick your toolset. The model will only see the tools you included in it.
![Select MCP servers dropdown](https://colony-recorder.s3.amazonaws.com/files/2026-03-22/ee8cb38c-c4ff-4b4b-844c-22f2e40832ae/ascreenshot_e300fb39cea0434fb5e3986e912a2b8d_text_export.jpeg)
![Open MCP server picker](https://colony-recorder.s3.amazonaws.com/files/2026-03-22/8672070c-5d07-4f63-878c-6fc7dcbc9b65/ascreenshot_326ddd0868224c99a6fa5dab2d144f1f_text_export.jpeg)
![Select toolset](https://colony-recorder.s3.amazonaws.com/files/2026-03-22/955826ad-2bbb-403e-ab26-c1ac03ec2675/ascreenshot_13f837ad53574535986ca7ca5998d34a_text_export.jpeg)
![Toolset selected and active](https://colony-recorder.s3.amazonaws.com/files/2026-03-22/9a59c3b9-1563-4731-838f-1c35d636ddc9/ascreenshot_c05d8fa5f37a4b3093fc46e26f293b4d_text_export.jpeg)
The model now has access to exactly the tools in your toolset and nothing else.
---
## Use a toolset via API
Pass the toolset's route as the `server_url` in your tools list. LiteLLM resolves it server-side — no public URL needed.
<Tabs>
<TabItem value="responses" label="Responses API">
```python
import openai
client = openai.OpenAI(
api_key="your-litellm-key",
base_url="http://your-proxy/v1",
)
response = client.responses.create(
model="gpt-4o",
input="What CI/CD tools do you have?",
tools=[
{
"type": "mcp",
"server_label": "devtooling-prod",
"server_url": "litellm_proxy/mcp/devtooling-prod",
"require_approval": "never",
}
],
)
print(response.output_text)
```
</TabItem>
<TabItem value="chat" label="Chat Completions API">
```python
import openai
client = openai.OpenAI(
api_key="your-litellm-key",
base_url="http://your-proxy/v1",
)
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "What CI/CD tools do you have?"}],
tools=[
{
"type": "mcp",
"server_label": "devtooling-prod",
"server_url": "litellm_proxy/mcp/devtooling-prod",
"require_approval": "never",
}
],
)
print(response.choices[0].message.content)
```
</TabItem>
<TabItem value="rest" label="REST">
```bash
curl http://your-proxy/v1/responses \
-H "Authorization: Bearer your-litellm-key" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-4o",
"input": "What CI/CD tools do you have?",
"tools": [
{
"type": "mcp",
"server_label": "devtooling-prod",
"server_url": "litellm_proxy/mcp/devtooling-prod",
"require_approval": "never"
}
]
}'
```
</TabItem>
</Tabs>
---
## Manage toolsets via API
```bash
# List all toolsets
curl http://your-proxy/v1/mcp/toolset \
-H "Authorization: Bearer your-litellm-key"
# Create a toolset
curl -X POST http://your-proxy/v1/mcp/toolset \
-H "Authorization: Bearer your-litellm-key" \
-H "Content-Type: application/json" \
-d '{
"toolset_name": "devtooling-prod",
"description": "CircleCI + DeepWiki tools for the dev team",
"tools": [
{"server_id": "<circleci-server-id>", "tool_name": "get_build_failure_logs"},
{"server_id": "<circleci-server-id>", "tool_name": "run_pipeline"},
{"server_id": "<deepwiki-server-id>", "tool_name": "read_wiki_structure"}
]
}'
# Delete a toolset
curl -X DELETE http://your-proxy/v1/mcp/toolset/<toolset_id> \
-H "Authorization: Bearer your-litellm-key"
```

View file

@ -325,6 +325,7 @@ const sidebars = {
"mcp_control",
"mcp_cost",
"mcp_guardrail",
"mcp_toolsets",
{
type: "link",
label: "MCP Troubleshooting Guide",

View file

@ -0,0 +1,19 @@
-- CreateTable: LiteLLM_MCPToolsetTable
CREATE TABLE IF NOT EXISTS "LiteLLM_MCPToolsetTable" (
"toolset_id" TEXT NOT NULL,
"toolset_name" TEXT NOT NULL,
"description" TEXT,
"tools" JSONB NOT NULL DEFAULT '[]',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" TEXT,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_by" TEXT,
CONSTRAINT "LiteLLM_MCPToolsetTable_pkey" PRIMARY KEY ("toolset_id")
);
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "LiteLLM_MCPToolsetTable_toolset_name_key" ON "LiteLLM_MCPToolsetTable"("toolset_name");
-- AlterTable: add mcp_toolsets to ObjectPermissionTable
ALTER TABLE "LiteLLM_ObjectPermissionTable" ADD COLUMN IF NOT EXISTS "mcp_toolsets" TEXT[] DEFAULT ARRAY[]::TEXT[];

View file

@ -273,6 +273,7 @@ model LiteLLM_ObjectPermissionTable {
agent_access_groups String[] @default([])
models String[] @default([])
blocked_tools String[] @default([]) // Tool names blocked for any key/team/user with this permission
mcp_toolsets String[] @default([]) // Toolset IDs granted to this key/team/user
teams LiteLLM_TeamTable[]
projects LiteLLM_ProjectTable[]
verification_tokens LiteLLM_VerificationToken[]
@ -331,6 +332,18 @@ model LiteLLM_MCPServerTable {
@@index([approval_status])
}
// Named collection of {server_id, tool_name} pairs that can be granted to keys/teams
model LiteLLM_MCPToolsetTable {
toolset_id String @id @default(uuid())
toolset_name String @unique
description String?
tools Json @default("[]") // [{server_id: string, tool_name: string}]
created_at DateTime @default(now())
created_by String?
updated_at DateTime @default(now()) @updatedAt
updated_by String?
}
// Per-user BYOK credentials for MCP servers
model LiteLLM_MCPUserCredentials {
id String @id @default(uuid())

View file

@ -18,6 +18,10 @@ import redis # type: ignore
import redis.asyncio as async_redis # type: ignore
from litellm import get_secret, get_secret_str
from litellm._redis_credential_provider import (
GCPIAMCredentialProvider,
_generate_gcp_iam_access_token,
)
from litellm.constants import REDIS_CONNECTION_POOL_TIMEOUT, REDIS_SOCKET_TIMEOUT
from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker
@ -107,33 +111,6 @@ def _redis_kwargs_from_environment():
return return_dict
def _generate_gcp_iam_access_token(service_account: str) -> str:
"""
Generate GCP IAM access token for Redis authentication.
Args:
service_account: GCP service account in format 'projects/-/serviceAccounts/name@project.iam.gserviceaccount.com'
Returns:
Access token string for GCP IAM authentication
"""
try:
from google.cloud import iam_credentials_v1
except ImportError:
raise ImportError(
"google-cloud-iam is required for GCP IAM Redis authentication. "
"Install it with: pip install google-cloud-iam"
)
client = iam_credentials_v1.IAMCredentialsClient()
request = iam_credentials_v1.GenerateAccessTokenRequest(
name=service_account,
scope=["https://www.googleapis.com/auth/cloud-platform"],
)
response = client.generate_access_token(request=request)
return str(response.access_token)
def create_gcp_iam_redis_connect_func(
service_account: str,
ssl_ca_certs: Optional[str] = None,
@ -266,7 +243,7 @@ def _get_redis_client_logic(**env_overrides):
service_account=_gcp_service_account, ssl_ca_certs=_gcp_ssl_ca_certs
)
# Store GCP service account in redis_connect_func for async cluster access
redis_kwargs["redis_connect_func"]._gcp_service_account = _gcp_service_account
redis_kwargs["redis_connect_func"]._gcp_service_account = _gcp_service_account # type: ignore[attr-defined]
# Remove GCP-specific kwargs that shouldn't be passed to Redis client
redis_kwargs.pop("gcp_service_account", None)
@ -413,41 +390,13 @@ def get_redis_async_client(
# Handle GCP IAM authentication for async clusters
redis_connect_func = cluster_kwargs.pop("redis_connect_func", None)
from litellm import get_secret_str
# Get GCP service account - first try from redis_connect_func, then from environment
gcp_service_account = None
# Use a CredentialProvider so the IAM token is regenerated on every new
# connection — mirrors the sync path where redis_connect_func is invoked
# per connection. Without this, the token would expire after ~1 hour.
if redis_connect_func and hasattr(redis_connect_func, "_gcp_service_account"):
gcp_service_account = redis_connect_func._gcp_service_account
else:
gcp_service_account = redis_kwargs.get(
"gcp_service_account"
) or get_secret_str("REDIS_GCP_SERVICE_ACCOUNT")
verbose_logger.debug(
f"DEBUG: Redis cluster kwargs: redis_connect_func={redis_connect_func is not None}, gcp_service_account_provided={gcp_service_account is not None}"
)
# If GCP IAM is configured (indicated by redis_connect_func), generate access token and use as password
if redis_connect_func and gcp_service_account:
verbose_logger.debug(
"DEBUG: Generating IAM token for service account (value not logged for security reasons)"
)
try:
# Generate IAM access token using the helper function
access_token = _generate_gcp_iam_access_token(gcp_service_account)
cluster_kwargs["password"] = access_token
verbose_logger.debug(
"DEBUG: Successfully generated GCP IAM access token for async Redis cluster"
)
except Exception as e:
verbose_logger.error(f"Failed to generate GCP IAM access token: {e}")
from redis.exceptions import AuthenticationError
raise AuthenticationError("Failed to generate GCP IAM access token")
else:
verbose_logger.debug(
f"DEBUG: Not using GCP IAM auth - redis_connect_func={redis_connect_func is not None}, gcp_service_account_provided={gcp_service_account is not None}"
cluster_kwargs["credential_provider"] = GCPIAMCredentialProvider(
redis_connect_func._gcp_service_account
)
new_startup_nodes: List[ClusterNode] = []

View file

@ -0,0 +1,53 @@
import asyncio
from typing import Tuple
from redis.credentials import CredentialProvider # type: ignore[attr-defined]
def _generate_gcp_iam_access_token(service_account: str) -> str:
"""
Generate GCP IAM access token for Redis authentication.
Args:
service_account: GCP service account in format 'projects/-/serviceAccounts/name@project.iam.gserviceaccount.com'
Returns:
Access token string for GCP IAM authentication
"""
try:
from google.cloud import iam_credentials_v1
except ImportError:
raise ImportError(
"google-cloud-iam is required for GCP IAM Redis authentication. "
"Install it with: pip install google-cloud-iam"
)
client = iam_credentials_v1.IAMCredentialsClient()
request = iam_credentials_v1.GenerateAccessTokenRequest(
name=service_account,
scope=["https://www.googleapis.com/auth/cloud-platform"],
)
response = client.generate_access_token(request=request)
return str(response.access_token)
class GCPIAMCredentialProvider(CredentialProvider):
"""
redis.credentials.CredentialProvider implementation that generates a fresh GCP IAM
token on every new connection. This fixes the 1-hour token expiry issue for async
Redis cluster clients, which previously generated the token once at startup and
cached it as a static password.
"""
def __init__(self, gcp_service_account: str) -> None:
self._gcp_service_account = gcp_service_account
def get_credentials(self) -> Tuple[str]:
token = _generate_gcp_iam_access_token(self._gcp_service_account)
return (token,)
async def get_credentials_async(self) -> Tuple[str]:
token = await asyncio.to_thread(
_generate_gcp_iam_access_token, self._gcp_service_account
)
return (token,)

View file

@ -5,7 +5,7 @@ Pydantic AI agents follow A2A protocol but don't support streaming natively.
This handler provides fake streaming by converting non-streaming responses into streaming chunks.
"""
from typing import Any, AsyncIterator, Dict
from typing import Any, AsyncIterator, Dict, Optional
from litellm._logging import verbose_logger
from litellm.a2a_protocol.providers.pydantic_ai_agents.transformation import (
@ -26,7 +26,7 @@ class PydanticAIHandler:
async def handle_non_streaming(
request_id: str,
params: Dict[str, Any],
api_base: str,
api_base: Optional[str] = None,
timeout: float = 60.0,
) -> Dict[str, Any]:
"""
@ -41,6 +41,8 @@ class PydanticAIHandler:
Returns:
A2A SendMessageResponse dict
"""
if api_base is None:
raise ValueError("api_base is required for Pydantic AI agents")
verbose_logger.info(f"Pydantic AI: Routing to Pydantic AI agent at {api_base}")
# Send request directly to Pydantic AI agent
@ -57,7 +59,7 @@ class PydanticAIHandler:
async def handle_streaming(
request_id: str,
params: Dict[str, Any],
api_base: str,
api_base: Optional[str] = None,
timeout: float = 60.0,
chunk_size: int = 50,
delay_ms: int = 10,
@ -80,6 +82,8 @@ class PydanticAIHandler:
Yields:
A2A streaming response events
"""
if api_base is None:
raise ValueError("api_base is required for Pydantic AI agents")
verbose_logger.info(
f"Pydantic AI: Faking streaming for Pydantic AI agent at {api_base}"
)

View file

@ -0,0 +1,16 @@
"""
Shared ContextVars for the MCP server layer.
Lives in its own module to avoid circular imports between
mcp_server_manager.py and server.py.
"""
from contextvars import ContextVar
from typing import Optional
# Set server-side in proxy_server.py route handlers when a request arrives via
# /toolset/{name}/mcp or the toolset fallback in dynamic_mcp_route.
# Never populated from client-supplied headers.
_mcp_active_toolset_id: ContextVar[Optional[str]] = ContextVar(
"_mcp_active_toolset_id", default=None
)

View file

@ -503,12 +503,12 @@ class MCPServerManager:
)
# Update tool name to server name mapping (for both prefixed and base names)
self.tool_name_to_mcp_server_name_mapping[
base_tool_name
] = server_prefix
self.tool_name_to_mcp_server_name_mapping[
prefixed_tool_name
] = server_prefix
self.tool_name_to_mcp_server_name_mapping[base_tool_name] = (
server_prefix
)
self.tool_name_to_mcp_server_name_mapping[prefixed_tool_name] = (
server_prefix
)
registered_count += 1
verbose_logger.debug(
@ -790,7 +790,18 @@ class MCPServerManager:
f"Allowed MCP Servers for user api key auth: {allowed_mcp_servers}"
)
combined_servers = set(allowed_mcp_servers)
combined_servers.update(allow_all_server_ids)
# Only skip allow_all_keys servers when the request is inside a toolset
# scope. toolset_mcp_route / dynamic_mcp_route set _mcp_active_toolset_id
# before calling the handler — that ContextVar is the reliable signal.
# Using op.mcp_toolsets==[] would false-positive on DB-default rows where
# Postgres initialises the column to ARRAY[]::TEXT[].
from litellm.proxy._experimental.mcp_server.mcp_context import ( # noqa: PLC0415
_mcp_active_toolset_id,
)
in_toolset_scope = _mcp_active_toolset_id.get() is not None
if not in_toolset_scope:
combined_servers.update(allow_all_server_ids)
if len(combined_servers) == 0:
verbose_logger.debug(
@ -801,6 +812,132 @@ class MCPServerManager:
verbose_logger.warning(f"Failed to get allowed MCP servers: {str(e)}.")
return allow_all_server_ids
async def resolve_toolset_tool_permissions(
self,
toolset_ids: List[str],
) -> Dict[str, List[str]]:
"""
Resolve a list of toolset IDs into a mcp_tool_permissions dict.
Returns: {server_id: [tool_name, ...]} the union of all tools across
the given toolsets. Results are cached via ``user_api_key_cache`` (a
Redis-backed ``DualCache`` in production) so that cache entries are
shared across workers and cold-cache DB hits are minimised.
"""
from litellm.constants import DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL
from litellm.proxy._experimental.mcp_server.toolset_db import list_mcp_toolsets
from litellm.proxy.proxy_server import prisma_client, user_api_key_cache
if not toolset_ids or prisma_client is None:
return {}
cache_key = "toolset_perms:" + ",".join(sorted(toolset_ids))
cached = await user_api_key_cache.async_get_cache(key=cache_key)
if cached is not None:
return cached
try:
toolsets = await list_mcp_toolsets(prisma_client, toolset_ids=toolset_ids)
tool_permissions: Dict[str, List[str]] = {}
for toolset in toolsets:
for tool in toolset.tools:
raw_name = tool["tool_name"]
unprefixed, _ = split_server_prefix_from_name(raw_name)
tool_permissions.setdefault(tool["server_id"], [])
if unprefixed not in tool_permissions[tool["server_id"]]:
tool_permissions[tool["server_id"]].append(unprefixed)
await user_api_key_cache.async_set_cache(
key=cache_key,
value=tool_permissions,
ttl=DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL,
)
return tool_permissions
except Exception as e:
verbose_logger.warning(f"Failed to resolve toolset permissions: {str(e)}")
return {}
def invalidate_toolset_cache(self, toolset_id: Optional[str] = None) -> None:
"""Evict cached toolset permission entries.
Called after create/update/delete of a toolset so stale data is not served.
The in-memory layer of ``user_api_key_cache`` is cleared immediately;
Redis entries expire naturally after the configured TTL.
Pass toolset_id to evict only entries containing that ID, or None to clear all.
"""
# Clear the in-memory layer of the shared DualCache for affected keys.
# We can't enumerate Redis keys by pattern, so Redis entries expire via TTL.
try:
from litellm.proxy.proxy_server import user_api_key_cache
in_mem = getattr(user_api_key_cache, "in_memory_cache", None)
if in_mem is None:
return
cache_dict = getattr(in_mem, "cache_dict", {})
if toolset_id is None:
keys_to_remove = [k for k in cache_dict if k.startswith("toolset_")]
else:
# Evict permission-cache entries that reference this toolset ID.
# Also evict ALL name-cache entries (toolset_name:*): we can't map
# toolset_id → toolset_name without a DB call, and the name may have
# changed in an update anyway.
keys_to_remove = [
k
for k in cache_dict
if (k.startswith("toolset_perms:") and toolset_id in k)
or k.startswith("toolset_name:")
]
for k in keys_to_remove:
cache_dict.pop(k, None)
except Exception as e:
verbose_logger.warning(
f"invalidate_toolset_cache: failed to evict in-memory entries: {e}"
)
async def get_toolset_by_name_cached(
self,
prisma_client: Any,
toolset_name: str,
) -> Optional[Any]:
"""Return a toolset by name, cached in ``user_api_key_cache`` (Redis-backed
``DualCache`` in production) to avoid a DB hit on every routed request.
Serialisation note: the cache value is stored as a plain JSON-safe dict via
``model_dump(mode="json")`` so that Redis round-trips correctly in multi-worker
deployments. On a cache hit we reconstruct the ``MCPToolset`` Pydantic object
so callers can always use attribute access (e.g. ``toolset.toolset_id``).
"""
from litellm.constants import DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL
from litellm.proxy.proxy_server import user_api_key_cache
from litellm.types.mcp_server.mcp_toolset import MCPToolset
cache_key = f"toolset_name:{toolset_name}"
cached = await user_api_key_cache.async_get_cache(key=cache_key)
if cached is not None:
# Sentinel value used to cache "not found" so we don't re-query for
# names that don't exist.
if cached == "__not_found__":
return None
# Redis deserialises JSON back as a plain dict — reconstruct the model.
if isinstance(cached, dict):
return MCPToolset(**cached)
return cached
from litellm.proxy._experimental.mcp_server.toolset_db import (
get_mcp_toolset_by_name,
)
toolset = await get_mcp_toolset_by_name(prisma_client, toolset_name)
await user_api_key_cache.async_set_cache(
key=cache_key,
value=(
toolset.model_dump(mode="json")
if toolset is not None
else "__not_found__"
),
ttl=DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL,
)
return toolset
def filter_server_ids_by_ip(
self, server_ids: List[str], client_ip: Optional[str]
) -> List[str]:
@ -1077,6 +1214,24 @@ class MCPServerManager:
tools = global_mcp_tool_registry.convert_tools_to_mcp_sdk_tool_type(
_tools
)
# OpenAPI tools are stored in the registry with their prefix already
# applied (e.g. "test_petstore-getinventory"). Do NOT pass them
# through _create_prefixed_tools — that would add the prefix a second
# time producing "test_petstore-test_petstore-getinventory".
if not add_prefix:
prefix = get_server_prefix(server)
sep = MCP_TOOL_PREFIX_SEPARATOR
tools = [
(
t.model_copy(
update={"name": t.name[len(prefix) + len(sep) :]}
)
if t.name.startswith(f"{prefix}{sep}")
else t
)
for t in tools
]
return tools
else:
tools = await self._fetch_tools_with_timeout(client, server.name)
@ -2418,7 +2573,6 @@ class MCPServerManager:
async def reload_servers_from_database(self):
"""Re-synchronize the in-memory MCP server registry with the database."""
from litellm.proxy._experimental.mcp_server.db import get_all_mcp_servers
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
get_prisma_client_or_throw,
)
@ -2429,9 +2583,19 @@ class MCPServerManager:
prisma_client = get_prisma_client_or_throw(
"Database not connected. Connect a database to your proxy"
)
db_mcp_servers = await get_all_mcp_servers(
prisma_client, approval_status="active"
# Load only "active", legacy "approved", and NULL (no approval workflow) rows.
# Pending/rejected servers are excluded at the DB level so we never load them.
from litellm.proxy._experimental.mcp_server.db import LiteLLM_MCPServerTable
raw_rows = await prisma_client.db.litellm_mcpservertable.find_many(
where={
"OR": [
{"approval_status": None},
{"approval_status": {"in": ["active", "approved"]}},
]
}
)
db_mcp_servers = [LiteLLM_MCPServerTable(**r.model_dump()) for r in raw_rows]
verbose_logger.info(f"Found {len(db_mcp_servers)} MCP servers in database")
previous_registry = self.registry

View file

@ -1,6 +1,7 @@
"""
LiteLLM MCP Server Routes
"""
# pyright: reportInvalidTypeForm=false, reportArgumentType=false, reportOptionalCall=false
import asyncio
@ -36,6 +37,7 @@ from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
get_request_base_url,
)
from litellm.proxy._experimental.mcp_server.mcp_context import _mcp_active_toolset_id
from litellm.proxy._experimental.mcp_server.mcp_debug import MCPDebug
from litellm.proxy._experimental.mcp_server.utils import (
LITELLM_MCP_SERVER_DESCRIPTION,
@ -1455,6 +1457,49 @@ if MCP_AVAILABLE:
return filtered_tools
async def _merge_toolset_permissions(
user_api_key_auth: Optional[UserAPIKeyAuth],
) -> Optional[UserAPIKeyAuth]:
"""
Resolve mcp_toolsets on the key's object_permission into tool-level permissions
and merge them (union) into object_permission.mcp_tool_permissions.
Returns the (possibly mutated copy of) user_api_key_auth.
"""
if user_api_key_auth is None:
return None
op = user_api_key_auth.object_permission
if op is None:
return user_api_key_auth
toolset_ids = getattr(op, "mcp_toolsets", None) or []
if not toolset_ids:
return user_api_key_auth
toolset_perms = (
await global_mcp_server_manager.resolve_toolset_tool_permissions(
toolset_ids=toolset_ids
)
)
if not toolset_perms:
return user_api_key_auth
# Merge toolset_perms into existing mcp_tool_permissions (union)
existing = dict(op.mcp_tool_permissions or {})
for server_id, tool_names in toolset_perms.items():
existing_tools = existing.get(server_id, [])
merged = list(set(existing_tools) | set(tool_names))
existing[server_id] = merged
# Build updated object_permission with merged tool permissions and server IDs.
# Union the toolset's server IDs into mcp_servers so downstream server-level
# filtering doesn't silently drop servers that the toolset references but that
# aren't already in the key's explicit mcp_servers list.
merged_servers = list(set(op.mcp_servers or []) | set(existing.keys()))
updated_op = op.model_copy(
update={"mcp_servers": merged_servers, "mcp_tool_permissions": existing}
)
return user_api_key_auth.model_copy(update={"object_permission": updated_op})
async def _list_mcp_tools(
user_api_key_auth: Optional[UserAPIKeyAuth] = None,
mcp_auth_header: Optional[str] = None,
@ -1479,6 +1524,11 @@ if MCP_AVAILABLE:
"""
if not MCP_AVAILABLE:
return []
# Resolve toolset permissions and merge into the key's object_permission
# so that the existing filter_tools_by_key_team_permissions logic picks them up.
user_api_key_auth = await _merge_toolset_permissions(user_api_key_auth)
# Get tools from managed MCP servers with error handling
managed_tools = []
try:
@ -1822,9 +1872,9 @@ if MCP_AVAILABLE:
"litellm_logging_obj", None
)
if litellm_logging_obj:
litellm_logging_obj.model_call_details[
"mcp_tool_call_metadata"
] = standard_logging_mcp_tool_call
litellm_logging_obj.model_call_details["mcp_tool_call_metadata"] = (
standard_logging_mcp_tool_call
)
litellm_logging_obj.model = f"MCP: {name}"
# Resolve the MCP server early so BYOK checks and credential injection
# apply to ALL dispatch paths (local tool registry AND managed MCP server).
@ -1836,9 +1886,9 @@ if MCP_AVAILABLE:
mcp_server.mcp_info or {}
).get("mcp_server_cost_info")
if litellm_logging_obj:
litellm_logging_obj.model_call_details[
"mcp_tool_call_metadata"
] = standard_logging_mcp_tool_call
litellm_logging_obj.model_call_details["mcp_tool_call_metadata"] = (
standard_logging_mcp_tool_call
)
# BYOK: retrieve the stored per-user credential. A single DB call
# both checks existence and fetches the value, avoiding a double query.
@ -2358,6 +2408,63 @@ if MCP_AVAILABLE:
]
return False
async def _apply_toolset_scope(
user_api_key_auth: UserAPIKeyAuth,
toolset_id: str,
) -> UserAPIKeyAuth:
"""
Restrict a key's MCP permissions to a single toolset.
When a request arrives via /toolset/{name}/mcp we override the key's
object_permission so that only the toolset's tools are visible.
Raises HTTPException(403) if the key has an explicit toolset grant list
that does not include toolset_id (i.e. mcp_toolsets is set but empty,
or set to a list that omits this toolset). Admin keys always pass.
"""
from litellm.proxy._types import LiteLLM_ObjectPermissionTable
from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view
# Access control: non-admin keys must have this toolset in their grant list.
# Use _user_has_admin_view so that PROXY_ADMIN_VIEW_ONLY is also treated as admin.
is_admin = _user_has_admin_view(user_api_key_auth)
if not is_admin:
op = user_api_key_auth.object_permission
granted = getattr(op, "mcp_toolsets", None) if op else None
# granted=None → key has no explicit toolset grants → deny (same semantics as
# fetch_mcp_toolsets which returns [] for non-admin keys with no grants configured).
# granted=[] or list without toolset_id → also deny.
if granted is None or toolset_id not in granted:
raise HTTPException(
status_code=403,
detail=f"API key does not have access to toolset '{toolset_id}'.",
)
tool_permissions = (
await global_mcp_server_manager.resolve_toolset_tool_permissions(
toolset_ids=[toolset_id]
)
)
server_ids = list(tool_permissions.keys())
existing_op = user_api_key_auth.object_permission
if existing_op is not None:
updated_op = existing_op.model_copy(
update={
"mcp_servers": server_ids,
"mcp_tool_permissions": tool_permissions,
"mcp_toolsets": [],
# mcp_access_groups is preserved: a key's access-group grants
# remain valid even when the request is scoped to a single toolset.
}
)
else:
updated_op = LiteLLM_ObjectPermissionTable(
object_permission_id="toolset-scope",
mcp_servers=server_ids,
mcp_tool_permissions=tool_permissions,
)
return user_api_key_auth.model_copy(update={"object_permission": updated_op})
async def handle_streamable_http_mcp(
scope: Scope, receive: Receive, send: Send
) -> None:
@ -2402,6 +2509,21 @@ if MCP_AVAILABLE:
headers={"www-authenticate": authorization_uri},
)
# Strip any client-supplied x-mcp-toolset-id to prevent forgery.
scope["headers"] = [
(k, v)
for k, v in scope.get("headers", [])
if k.lower() != b"x-mcp-toolset-id"
]
# Apply toolset scope if set server-side via ContextVar (set by
# /toolset/{name}/mcp and /{name}/mcp route handlers in proxy_server.py).
active_toolset_id = _mcp_active_toolset_id.get()
if active_toolset_id and user_api_key_auth is not None:
user_api_key_auth = await _apply_toolset_scope(
user_api_key_auth, active_toolset_id
)
# Inject masked debug headers when client sends x-litellm-mcp-debug: true
_debug_headers = MCPDebug.maybe_build_debug_headers(
raw_headers=raw_headers,
@ -2580,17 +2702,15 @@ if MCP_AVAILABLE:
)
auth_context_var.set(auth_user)
def get_auth_context() -> (
Tuple[
Optional[UserAPIKeyAuth],
Optional[str],
Optional[List[str]],
Optional[Dict[str, Dict[str, str]]],
Optional[Dict[str, str]],
Optional[Dict[str, str]],
Optional[str],
]
):
def get_auth_context() -> Tuple[
Optional[UserAPIKeyAuth],
Optional[str],
Optional[List[str]],
Optional[Dict[str, Dict[str, str]]],
Optional[Dict[str, str]],
Optional[Dict[str, str]],
Optional[str],
]:
"""
Get the UserAPIKeyAuth from the auth context variable.

View file

@ -0,0 +1,117 @@
import json
from typing import List, Optional
from litellm._logging import verbose_proxy_logger
from litellm._uuid import uuid
from litellm.proxy.utils import PrismaClient
from litellm.types.mcp_server.mcp_toolset import (
MCPToolset,
NewMCPToolsetRequest,
UpdateMCPToolsetRequest,
)
def _toolset_from_row(row) -> MCPToolset:
data = row.model_dump()
tools = data.get("tools") or []
if isinstance(tools, str):
tools = json.loads(tools)
data["tools"] = tools
return MCPToolset(**data)
async def create_mcp_toolset(
prisma_client: PrismaClient,
data: NewMCPToolsetRequest,
touched_by: str,
) -> MCPToolset:
data_dict = data.model_dump(exclude_none=True)
data_dict["toolset_id"] = str(uuid.uuid4())
data_dict["tools"] = json.dumps(data_dict.get("tools", []))
data_dict["created_by"] = touched_by
data_dict["updated_by"] = touched_by
row = await prisma_client.db.litellm_mcptoolsettable.create(data=data_dict)
return _toolset_from_row(row)
async def get_mcp_toolset(
prisma_client: PrismaClient,
toolset_id: str,
) -> Optional[MCPToolset]:
row = await prisma_client.db.litellm_mcptoolsettable.find_unique(
where={"toolset_id": toolset_id}
)
if row is None:
return None
return _toolset_from_row(row)
async def list_mcp_toolsets(
prisma_client: PrismaClient,
toolset_ids: Optional[List[str]] = None,
) -> List[MCPToolset]:
try:
where = {}
if toolset_ids is not None:
where = {"toolset_id": {"in": toolset_ids}}
rows = await prisma_client.db.litellm_mcptoolsettable.find_many(where=where)
return [_toolset_from_row(r) for r in rows]
except Exception as e:
verbose_proxy_logger.warning(
"litellm.proxy._experimental.mcp_server.toolset_db::list_mcp_toolsets - {}".format(
str(e)
)
)
return []
async def get_mcp_toolset_by_name(
prisma_client: PrismaClient,
toolset_name: str,
) -> Optional[MCPToolset]:
row = await prisma_client.db.litellm_mcptoolsettable.find_first(
where={"toolset_name": toolset_name}
)
if row is None:
return None
return _toolset_from_row(row)
async def update_mcp_toolset(
prisma_client: PrismaClient,
data: UpdateMCPToolsetRequest,
touched_by: str,
) -> Optional[MCPToolset]:
data_dict = data.model_dump(exclude_none=True, exclude={"toolset_id"})
if "tools" in data_dict:
data_dict["tools"] = json.dumps(data_dict["tools"])
data_dict["updated_by"] = touched_by
try:
row = await prisma_client.db.litellm_mcptoolsettable.update(
where={"toolset_id": data.toolset_id},
data=data_dict,
)
except Exception as e:
from prisma.errors import RecordNotFoundError
if isinstance(e, RecordNotFoundError):
return None
raise
return _toolset_from_row(row)
async def delete_mcp_toolset(
prisma_client: PrismaClient,
toolset_id: str,
) -> Optional[MCPToolset]:
try:
row = await prisma_client.db.litellm_mcptoolsettable.delete(
where={"toolset_id": toolset_id}
)
except Exception as e:
from prisma.errors import RecordNotFoundError
if isinstance(e, RecordNotFoundError):
return None
raise
return _toolset_from_row(row)

View file

@ -857,6 +857,8 @@ class LiteLLM_ObjectPermissionBase(LiteLLMPydanticObjectBase):
mcp_servers: Optional[List[str]] = None
mcp_access_groups: Optional[List[str]] = None
mcp_tool_permissions: Optional[Dict[str, List[str]]] = None
mcp_toolsets: Optional[List[str]] = None
blocked_tools: Optional[List[str]] = None
vector_stores: Optional[List[str]] = None
agents: Optional[List[str]] = None
agent_access_groups: Optional[List[str]] = None
@ -885,9 +887,9 @@ class GenerateRequestBase(LiteLLMPydanticObjectBase):
allowed_cache_controls: Optional[list] = []
config: Optional[dict] = {}
permissions: Optional[dict] = {}
model_max_budget: Optional[
dict
] = {} # {"gpt-4": 5.0, "gpt-3.5-turbo": 5.0}, defaults to {}
model_max_budget: Optional[dict] = (
{}
) # {"gpt-4": 5.0, "gpt-3.5-turbo": 5.0}, defaults to {}
model_config = ConfigDict(protected_namespaces=())
model_rpm_limit: Optional[dict] = None
@ -1029,9 +1031,9 @@ class RegenerateKeyRequest(GenerateKeyRequest):
spend: Optional[float] = None
metadata: Optional[dict] = None
new_master_key: Optional[str] = None
grace_period: Optional[
str
] = None # Duration to keep old key valid (e.g. "24h", "2d"); None = immediate revoke
grace_period: Optional[str] = (
None # Duration to keep old key valid (e.g. "24h", "2d"); None = immediate revoke
)
class ResetSpendRequest(LiteLLMPydanticObjectBase):
@ -1541,12 +1543,12 @@ class NewCustomerRequest(BudgetNewRequest):
blocked: bool = False # allow/disallow requests for this end-user
budget_id: Optional[str] = None # give either a budget_id or max_budget
spend: Optional[float] = None
allowed_model_region: Optional[
AllowedModelRegion
] = None # require all user requests to use models in this specific region
default_model: Optional[
str
] = None # if no equivalent model in allowed region - default all requests to this model
allowed_model_region: Optional[AllowedModelRegion] = (
None # require all user requests to use models in this specific region
)
default_model: Optional[str] = (
None # if no equivalent model in allowed region - default all requests to this model
)
object_permission: Optional[LiteLLM_ObjectPermissionBase] = None
@model_validator(mode="before")
@ -1569,12 +1571,12 @@ class UpdateCustomerRequest(LiteLLMPydanticObjectBase):
blocked: bool = False # allow/disallow requests for this end-user
max_budget: Optional[float] = None
budget_id: Optional[str] = None # give either a budget_id or max_budget
allowed_model_region: Optional[
AllowedModelRegion
] = None # require all user requests to use models in this specific region
default_model: Optional[
str
] = None # if no equivalent model in allowed region - default all requests to this model
allowed_model_region: Optional[AllowedModelRegion] = (
None # require all user requests to use models in this specific region
)
default_model: Optional[str] = (
None # if no equivalent model in allowed region - default all requests to this model
)
object_permission: Optional[LiteLLM_ObjectPermissionBase] = None
@ -1664,15 +1666,15 @@ class NewTeamRequest(TeamBase):
] = None # raise an error if 'guaranteed_throughput' is set and we're overallocating tpm
model_tpm_limit: Optional[Dict[str, int]] = None
team_member_budget: Optional[
float
] = None # allow user to set a budget for all team members
team_member_rpm_limit: Optional[
int
] = None # allow user to set RPM limit for all team members
team_member_tpm_limit: Optional[
int
] = None # allow user to set TPM limit for all team members
team_member_budget: Optional[float] = (
None # allow user to set a budget for all team members
)
team_member_rpm_limit: Optional[int] = (
None # allow user to set RPM limit for all team members
)
team_member_tpm_limit: Optional[int] = (
None # allow user to set TPM limit for all team members
)
team_member_key_duration: Optional[str] = None # e.g. "1d", "1w", "1m"
team_member_budget_duration: Optional[str] = None # e.g. "30d", "1mo"
allowed_vector_store_indexes: Optional[List[AllowedVectorStoreIndexItem]] = None
@ -1769,9 +1771,9 @@ class BlockKeyRequest(LiteLLMPydanticObjectBase):
class AddTeamCallback(LiteLLMPydanticObjectBase):
callback_name: str
callback_type: Optional[
Literal["success", "failure", "success_and_failure"]
] = "success_and_failure"
callback_type: Optional[Literal["success", "failure", "success_and_failure"]] = (
"success_and_failure"
)
callback_vars: Dict[str, str]
@model_validator(mode="before")
@ -1848,6 +1850,8 @@ class LiteLLM_ObjectPermissionTable(LiteLLMPydanticObjectBase):
vector_stores: Optional[List[str]] = []
agents: Optional[List[str]] = []
agent_access_groups: Optional[List[str]] = []
mcp_toolsets: Optional[List[str]] = None
blocked_tools: Optional[List[str]] = []
class LiteLLM_TeamTable(TeamBase):
@ -2111,9 +2115,9 @@ class ConfigList(LiteLLMPydanticObjectBase):
stored_in_db: Optional[bool]
field_default_value: Any
premium_field: bool = False
nested_fields: Optional[
List[FieldDetail]
] = None # For nested dictionary or Pydantic fields
nested_fields: Optional[List[FieldDetail]] = (
None # For nested dictionary or Pydantic fields
)
class UserHeaderMapping(LiteLLMPydanticObjectBase):
@ -2471,9 +2475,9 @@ class UserAPIKeyAuth(
user_max_budget: Optional[float] = None
request_route: Optional[str] = None
user: Optional[Any] = None # Expanded user object when expand=user is used
created_by_user: Optional[
Any
] = None # Expanded created_by user when expand=user is used
created_by_user: Optional[Any] = (
None # Expanded created_by user when expand=user is used
)
end_user_object_permission: Optional[LiteLLM_ObjectPermissionTable] = None
# Decoded upstream IdP claims (groups, roles, etc.) propagated by JWT auth machinery
# and forwarded into outbound tokens by guardrails such as MCPJWTSigner.
@ -2612,9 +2616,9 @@ class LiteLLM_OrganizationMembershipTable(LiteLLMPydanticObjectBase):
budget_id: Optional[str] = None
created_at: datetime
updated_at: datetime
user: Optional[
Any
] = None # You might want to replace 'Any' with a more specific type if available
user: Optional[Any] = (
None # You might want to replace 'Any' with a more specific type if available
)
litellm_budget_table: Optional[LiteLLM_BudgetTable] = None
user_email: Optional[str] = None
@ -3769,9 +3773,9 @@ class TeamModelDeleteRequest(BaseModel):
# Organization Member Requests
class OrganizationMemberAddRequest(OrgMemberAddRequest):
organization_id: str
max_budget_in_organization: Optional[
float
] = None # Users max budget within the organization
max_budget_in_organization: Optional[float] = (
None # Users max budget within the organization
)
class OrganizationMemberDeleteRequest(MemberDeleteRequest):
@ -4026,9 +4030,9 @@ class ProviderBudgetResponse(LiteLLMPydanticObjectBase):
Maps provider names to their budget configs.
"""
providers: Dict[
str, ProviderBudgetResponseObject
] = {} # Dictionary mapping provider names to their budget configurations
providers: Dict[str, ProviderBudgetResponseObject] = (
{}
) # Dictionary mapping provider names to their budget configurations
class ProxyStateVariables(TypedDict):
@ -4190,9 +4194,9 @@ class LiteLLM_JWTAuth(LiteLLMPydanticObjectBase):
enforce_rbac: bool = False
roles_jwt_field: Optional[str] = None # v2 on role mappings
role_mappings: Optional[List[RoleMapping]] = None
object_id_jwt_field: Optional[
str
] = None # can be either user / team, inferred from the role mapping
object_id_jwt_field: Optional[str] = (
None # can be either user / team, inferred from the role mapping
)
scope_mappings: Optional[List[ScopeMapping]] = None
enforce_scope_based_access: bool = False
enforce_team_based_model_access: bool = False

View file

@ -431,7 +431,13 @@ class PrismaManager:
else:
# Use prisma db push with increased timeout
subprocess.run(
["prisma", "db", "push", "--accept-data-loss"],
[
"prisma",
"db",
"push",
"--accept-data-loss",
"--skip-generate",
],
timeout=60,
check=True,
)

View file

@ -37,9 +37,10 @@ from fastapi import (
from fastapi.responses import JSONResponse
try:
from prisma.errors import RecordNotFoundError
from prisma.errors import RecordNotFoundError, UniqueViolationError
except ImportError:
RecordNotFoundError = Exception # type: ignore
UniqueViolationError = Exception # type: ignore
import litellm
from litellm._logging import verbose_logger, verbose_proxy_logger
@ -424,17 +425,17 @@ if MCP_AVAILABLE:
inherited_credentials["scopes"] = existing_server.scopes
# AWS SigV4 fields
if existing_server.aws_access_key_id:
inherited_credentials[
"aws_access_key_id"
] = existing_server.aws_access_key_id
inherited_credentials["aws_access_key_id"] = (
existing_server.aws_access_key_id
)
if existing_server.aws_secret_access_key:
inherited_credentials[
"aws_secret_access_key"
] = existing_server.aws_secret_access_key
inherited_credentials["aws_secret_access_key"] = (
existing_server.aws_secret_access_key
)
if existing_server.aws_session_token:
inherited_credentials[
"aws_session_token"
] = existing_server.aws_session_token
inherited_credentials["aws_session_token"] = (
existing_server.aws_session_token
)
if existing_server.aws_region_name:
inherited_credentials["aws_region_name"] = existing_server.aws_region_name
if existing_server.aws_service_name:
@ -2031,3 +2032,192 @@ if MCP_AVAILABLE:
f"Failed to load OpenAPI registry from {_OPENAPI_REGISTRY_PATH}: {e}"
)
return {"apis": []}
# ---------------------------------------------------------------------------
# MCP Toolset endpoints
# ---------------------------------------------------------------------------
from litellm.proxy._experimental.mcp_server.toolset_db import (
create_mcp_toolset,
delete_mcp_toolset,
get_mcp_toolset,
list_mcp_toolsets,
update_mcp_toolset,
)
from litellm.types.mcp_server.mcp_toolset import (
NewMCPToolsetRequest,
UpdateMCPToolsetRequest,
)
@router.post(
"/toolset",
description="Create a new MCP toolset (admin only)",
status_code=status.HTTP_201_CREATED,
)
@management_endpoint_wrapper
async def add_mcp_toolset(
payload: NewMCPToolsetRequest,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
litellm_changed_by: Optional[str] = Header(None),
):
"""Create a named toolset — a curated selection of {server_id, tool_name} pairs."""
prisma_client = get_prisma_client_or_throw(
"Database not connected. Connect a database to your proxy"
)
if LitellmUserRoles.PROXY_ADMIN != user_api_key_dict.user_role:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"error": "Only proxy admins can create MCP toolsets."},
)
touched_by = (
litellm_changed_by or user_api_key_dict.user_id or LITELLM_PROXY_ADMIN_NAME
)
try:
result = await create_mcp_toolset(prisma_client, payload, touched_by)
except UniqueViolationError:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"error": f"A toolset named '{payload.toolset_name}' already exists."
},
)
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
global_mcp_server_manager.invalidate_toolset_cache()
return result
@router.get(
"/toolset",
description="List MCP toolsets accessible to the calling key",
)
@management_endpoint_wrapper
async def fetch_mcp_toolsets(
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""Return toolsets the calling key is allowed to access."""
prisma_client = get_prisma_client_or_throw(
"Database not connected. Connect a database to your proxy"
)
is_admin = _user_has_admin_view(user_api_key_dict)
op = user_api_key_dict.object_permission
# mcp_toolsets=None or [] both mean "not restricted by toolsets".
# For admins: either value → no restriction → return all.
# For non-admins: either value → no toolsets explicitly granted → return nothing.
# (An admin whose DB row has mcp_toolsets=[] should still see all toolsets.)
raw_toolsets = getattr(op, "mcp_toolsets", None) if op else None
if not raw_toolsets:
if is_admin:
return await list_mcp_toolsets(prisma_client)
return []
return await list_mcp_toolsets(prisma_client, toolset_ids=raw_toolsets)
@router.get(
"/toolset/{toolset_id}",
description="Get a specific MCP toolset by ID",
)
@management_endpoint_wrapper
async def fetch_mcp_toolset(
toolset_id: str,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
prisma_client = get_prisma_client_or_throw(
"Database not connected. Connect a database to your proxy"
)
# Non-admin keys may only fetch toolsets they've been explicitly granted.
if not _user_has_admin_view(user_api_key_dict):
op = user_api_key_dict.object_permission
granted = getattr(op, "mcp_toolsets", None) if op else None
if granted is None or toolset_id not in granted:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"error": "API key does not have access to this toolset."},
)
toolset = await get_mcp_toolset(prisma_client, toolset_id)
if toolset is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": f"Toolset '{toolset_id}' not found."},
)
return toolset
@router.put(
"/toolset",
description="Update an existing MCP toolset (admin only)",
)
@management_endpoint_wrapper
async def edit_mcp_toolset(
payload: UpdateMCPToolsetRequest,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
litellm_changed_by: Optional[str] = Header(None),
):
prisma_client = get_prisma_client_or_throw(
"Database not connected. Connect a database to your proxy"
)
if LitellmUserRoles.PROXY_ADMIN != user_api_key_dict.user_role:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"error": "Only proxy admins can update MCP toolsets."},
)
touched_by = (
litellm_changed_by or user_api_key_dict.user_id or LITELLM_PROXY_ADMIN_NAME
)
try:
result = await update_mcp_toolset(prisma_client, payload, touched_by)
except UniqueViolationError:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"error": (
f"A toolset named '{payload.toolset_name}' already exists."
if payload.toolset_name
else "A toolset with that name already exists."
)
},
)
if result is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": f"Toolset '{payload.toolset_id}' not found."},
)
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
global_mcp_server_manager.invalidate_toolset_cache(
getattr(payload, "toolset_id", None)
)
return result
@router.delete(
"/toolset/{toolset_id}",
description="Delete an MCP toolset (admin only)",
status_code=status.HTTP_202_ACCEPTED,
)
@management_endpoint_wrapper
async def remove_mcp_toolset(
toolset_id: str,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
litellm_changed_by: Optional[str] = Header(None),
):
prisma_client = get_prisma_client_or_throw(
"Database not connected. Connect a database to your proxy"
)
if LitellmUserRoles.PROXY_ADMIN != user_api_key_dict.user_role:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"error": "Only proxy admins can delete MCP toolsets."},
)
deleted = await delete_mcp_toolset(prisma_client, toolset_id)
if deleted is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": f"Toolset '{toolset_id}' not found."},
)
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
global_mcp_server_manager.invalidate_toolset_cache(toolset_id)
return Response(status_code=status.HTTP_202_ACCEPTED)

View file

@ -208,10 +208,10 @@ async def _resolve_team_allowed_mcp_servers(
)
direct_servers: List[str] = team_object_permission.mcp_servers or []
access_group_servers: List[
str
] = await MCPRequestHandler._get_mcp_servers_from_access_groups(
team_object_permission.mcp_access_groups or []
access_group_servers: List[str] = (
await MCPRequestHandler._get_mcp_servers_from_access_groups(
team_object_permission.mcp_access_groups or []
)
)
raw_tool_perms = team_object_permission.mcp_tool_permissions or {}
if isinstance(raw_tool_perms, str):
@ -286,6 +286,19 @@ def _extract_requested_mcp_access_groups(
return set()
def _extract_requested_mcp_toolsets(
object_permission: Optional[dict],
) -> Set[str]:
"""Extract MCP toolset IDs from a key's object_permission dict."""
if not object_permission or not isinstance(object_permission, dict):
return set()
toolsets = object_permission.get("mcp_toolsets")
if isinstance(toolsets, list):
return set(toolsets)
return set()
async def validate_key_mcp_servers_against_team(
object_permission: Optional[dict],
team_obj: Optional["LiteLLM_TeamTableCachedObj"],
@ -305,8 +318,10 @@ async def validate_key_mcp_servers_against_team(
requested_servers = _extract_requested_mcp_server_ids(object_permission)
requested_access_groups = _extract_requested_mcp_access_groups(object_permission)
requested_toolsets = _extract_requested_mcp_toolsets(object_permission)
# Nothing to validate
if not requested_servers and not requested_access_groups:
if not requested_servers and not requested_access_groups and not requested_toolsets:
return
allow_all_keys_servers = _get_allow_all_keys_server_ids()
@ -364,3 +379,24 @@ async def validate_key_mcp_servers_against_team(
status_code=status.HTTP_403_FORBIDDEN,
detail={"error": detail},
)
# Validate requested toolsets against team's allowed toolsets.
# Only enforce the team-based restriction when a team is present — standalone
# keys (no team) can freely be granted any toolset by an admin.
if requested_toolsets and team_obj is not None:
team_op = team_obj.object_permission
team_mcp_toolsets = team_op.mcp_toolsets if team_op is not None else None
# None or [] means the team has no toolset restriction — allow any toolsets.
if team_mcp_toolsets:
disallowed_toolsets = requested_toolsets - set(team_mcp_toolsets)
if disallowed_toolsets:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"error": (
f"Key requests MCP toolsets not allowed by team '{team_obj.team_id}': "
f"{sorted(disallowed_toolsets)}. "
f"Team allows: {sorted(team_mcp_toolsets)}."
)
},
)

View file

@ -194,6 +194,7 @@ def generate_feedback_box():
print() # noqa
import contextlib
from collections import defaultdict
from contextlib import asynccontextmanager
from functools import lru_cache
@ -2159,11 +2160,9 @@ def run_ollama_serve():
with open(os.devnull, "w") as devnull:
subprocess.Popen(command, stdout=devnull, stderr=devnull)
except Exception as e:
verbose_proxy_logger.debug(
f"""
verbose_proxy_logger.debug(f"""
LiteLLM Warning: proxy started with `ollama` model\n`ollama serve` failed with Exception{e}. \nEnsure you run `ollama serve`
"""
)
""")
def _get_process_rss_mb() -> Optional[float]:
@ -6019,9 +6018,9 @@ class ProxyStartupEvent:
"""
from litellm.secret_managers.main import str_to_bool
_use_redis_transaction_buffer: Optional[
Union[bool, str]
] = general_settings.get("use_redis_transaction_buffer", False)
_use_redis_transaction_buffer: Optional[Union[bool, str]] = (
general_settings.get("use_redis_transaction_buffer", False)
)
if isinstance(_use_redis_transaction_buffer, str):
_use_redis_transaction_buffer = str_to_bool(_use_redis_transaction_buffer)
@ -13908,18 +13907,147 @@ app.include_router(agent_endpoints_router)
app.include_router(compliance_router)
app.include_router(a2a_router)
app.include_router(access_group_router)
async def _stream_mcp_asgi_response(
handle_fn, scope: dict, receive
) -> "StreamingResponse":
"""
Call an ASGI MCP handler and return a StreamingResponse so SSE/streaming works.
asyncio.create_task copies the current context, so any ContextVar set before
this call (e.g. _mcp_active_toolset_id) is visible inside the handler task.
"""
from starlette.responses import StreamingResponse
headers_ready: asyncio.Future = asyncio.get_running_loop().create_future()
body_queue: asyncio.Queue = asyncio.Queue(maxsize=1024)
async def bridging_send(message):
if message["type"] == "http.response.start":
if not headers_ready.done():
headers_ready.set_result(
(message.get("status", 200), message.get("headers", []))
)
elif message["type"] == "http.response.body":
chunk = message.get("body", b"")
if chunk:
await body_queue.put(chunk)
if not message.get("more_body", False):
await body_queue.put(None) # EOF sentinel
handler_task = asyncio.create_task(handle_fn(scope, receive, bridging_send))
# If the handler task dies (exception or cancellation) without sending the EOF
# sentinel, body_iter() would block forever on body_queue.get(). The callback
# below guarantees the queue gets unblocked regardless of how the task ends.
def _ensure_eof(task: asyncio.Task) -> None:
if task.cancelled() or task.exception() is not None:
body_queue.put_nowait(None)
handler_task.add_done_callback(_ensure_eof)
try:
status, raw_headers = await asyncio.wait_for(
asyncio.shield(headers_ready), timeout=30.0
)
except asyncio.TimeoutError:
handler_task.cancel()
raise HTTPException(
status_code=504, detail="MCP handler did not respond in time"
)
headers_dict = {k.decode("latin-1"): v.decode("latin-1") for k, v in raw_headers}
async def body_iter():
try:
while True:
chunk = await body_queue.get()
if chunk is None:
break
yield chunk
finally:
if not handler_task.done():
handler_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await handler_task
return StreamingResponse(
body_iter(),
status_code=status,
headers=headers_dict,
media_type=headers_dict.get("content-type"),
)
########################################################
# MCP Server
########################################################
# Toolset-namespaced MCP routes - handle /toolset/{toolset_name}/mcp
# Must be declared BEFORE /{mcp_server_name}/mcp to avoid being swallowed by the catchall.
@app.api_route(
"/toolset/{toolset_name}/mcp",
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
)
async def toolset_mcp_route(toolset_name: str, request: Request):
"""
Namespace a toolset as its own MCP endpoint.
Connecting to /toolset/<name>/mcp exposes exactly the tools defined in
the toolset. Access is enforced: non-admin API keys must have the toolset
listed in their object_permission.mcp_toolsets grant list, or the request
will be rejected with a 403.
"""
try:
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.proxy._experimental.mcp_server.server import (
_mcp_active_toolset_id,
handle_streamable_http_mcp,
)
if prisma_client is None:
raise HTTPException(status_code=503, detail="Database not available")
toolset = await global_mcp_server_manager.get_toolset_by_name_cached(
prisma_client, toolset_name
)
if toolset is None:
raise HTTPException(
status_code=404,
detail=f"Toolset '{toolset_name}' not found",
)
scope = dict(request.scope)
scope["path"] = "/mcp"
token = _mcp_active_toolset_id.set(toolset.toolset_id)
try:
return await _stream_mcp_asgi_response(
handle_streamable_http_mcp, scope, request.receive
)
finally:
_mcp_active_toolset_id.reset(token)
except HTTPException as e:
raise e
except Exception as e:
verbose_proxy_logger.error(
f"Error handling toolset MCP route for {toolset_name}: {str(e)}"
)
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
# Dynamic MCP server routes - handle /{mcp_server_name}/mcp
@app.api_route(
"/{mcp_server_name}/mcp",
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
)
async def dynamic_mcp_route(mcp_server_name: str, request: Request):
"""Handle dynamic MCP server routes like /github_mcp/mcp"""
"""Handle dynamic MCP server routes like /github_mcp/mcp and toolset routes like /devtooling-prod/mcp"""
try:
# Validate that the MCP server exists
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
@ -13933,6 +14061,32 @@ async def dynamic_mcp_route(mcp_server_name: str, request: Request):
mcp_server_name, client_ip=client_ip
)
if mcp_server is None:
# Check if this is a toolset name — toolsets are accessible at /{name}/mcp
# the same way individual servers are, no separate /toolset/ prefix needed.
if prisma_client is not None:
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.proxy._experimental.mcp_server.server import (
_mcp_active_toolset_id,
handle_streamable_http_mcp,
)
toolset = await global_mcp_server_manager.get_toolset_by_name_cached(
prisma_client, mcp_server_name
)
if toolset is not None:
scope = dict(request.scope)
scope["path"] = "/mcp"
token = _mcp_active_toolset_id.set(toolset.toolset_id)
try:
return await _stream_mcp_asgi_response(
handle_streamable_http_mcp, scope, request.receive
)
finally:
_mcp_active_toolset_id.reset(token)
raise HTTPException(
status_code=404, detail=f"MCP server '{mcp_server_name}' not found"
)

View file

@ -273,6 +273,7 @@ model LiteLLM_ObjectPermissionTable {
agent_access_groups String[] @default([])
models String[] @default([])
blocked_tools String[] @default([]) // Tool names blocked for any key/team/user with this permission
mcp_toolsets String[] @default([]) // Toolset IDs granted to this key/team/user
teams LiteLLM_TeamTable[]
projects LiteLLM_ProjectTable[]
verification_tokens LiteLLM_VerificationToken[]
@ -331,6 +332,18 @@ model LiteLLM_MCPServerTable {
@@index([approval_status])
}
// Named collection of {server_id, tool_name} pairs that can be granted to keys/teams
model LiteLLM_MCPToolsetTable {
toolset_id String @id @default(uuid())
toolset_name String @unique
description String?
tools Json @default("[]") // [{server_id: string, tool_name: string}]
created_at DateTime @default(now())
created_by String?
updated_at DateTime @default(now()) @updatedAt
updated_by String?
}
// Per-user BYOK credentials for MCP servers
model LiteLLM_MCPUserCredentials {
id String @id @default(uuid())

View file

@ -119,6 +119,54 @@ class LiteLLM_Proxy_MCP_Handler:
return mcp_tools_with_litellm_proxy, other_tools
@staticmethod
async def _apply_toolset_permissions(
resolved_toolset_ids: List[str],
resolved_mcp_servers: List[str],
user_api_key_auth: Any,
) -> Any:
"""Apply resolved toolset permissions to user_api_key_auth and return updated auth."""
from litellm.proxy._types import LiteLLM_ObjectPermissionTable
try:
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
tool_permissions = (
await global_mcp_server_manager.resolve_toolset_tool_permissions(
toolset_ids=resolved_toolset_ids
)
)
all_server_ids = list(
set(tool_permissions.keys()) | set(resolved_mcp_servers)
)
existing_op = user_api_key_auth.object_permission
if existing_op is not None:
merged_tool_perms = dict(existing_op.mcp_tool_permissions or {})
for server_id, tool_names in tool_permissions.items():
existing_tools = merged_tool_perms.get(server_id, [])
merged_tool_perms[server_id] = list(
set(existing_tools) | set(tool_names)
)
updated_op = existing_op.model_copy(
update={
"mcp_servers": all_server_ids,
"mcp_tool_permissions": merged_tool_perms,
"mcp_toolsets": [],
}
)
else:
updated_op = LiteLLM_ObjectPermissionTable(
object_permission_id="toolset-scope",
mcp_servers=all_server_ids,
mcp_tool_permissions=tool_permissions,
)
return user_api_key_auth.model_copy(update={"object_permission": updated_op})
except Exception as _e:
verbose_logger.debug(f"Could not apply toolset permissions: {_e}")
return user_api_key_auth
@staticmethod
async def _get_mcp_tools_from_manager(
user_api_key_auth: Any,
@ -160,10 +208,75 @@ class LiteLLM_Proxy_MCP_Handler:
):
mcp_servers.append(server_url.split("/")[-1])
# Resolve toolset names: collect all toolset IDs first, then apply their
# combined permissions in a single pass so multiple toolsets are unioned
# rather than the last one overwriting the others.
resolved_mcp_servers: List[str] = []
resolved_toolset_ids: List[str] = []
for name in mcp_servers:
if not global_mcp_server_manager.get_mcp_server_by_name(name):
try:
from litellm.proxy.proxy_server import prisma_client
if prisma_client is not None:
toolset = (
await global_mcp_server_manager.get_toolset_by_name_cached(
prisma_client, name
)
)
if toolset is not None:
# Access control: only allow if the key explicitly grants this toolset.
if user_api_key_auth is not None:
from litellm.proxy.management_endpoints.common_utils import (
_user_has_admin_view,
)
is_admin = _user_has_admin_view(user_api_key_auth)
if not is_admin:
op = user_api_key_auth.object_permission
granted = (
getattr(op, "mcp_toolsets", None)
if op
else None
)
# None means no grants configured → deny (consistent with
# fetch_mcp_toolsets which returns [] for unconfigured keys)
if (
granted is None
or toolset.toolset_id not in granted
):
verbose_logger.debug(
f"Key does not have access to toolset '{name}', skipping."
)
continue
resolved_toolset_ids.append(toolset.toolset_id)
# Don't add to resolved_mcp_servers — toolset scope
# restricts via object_permission, not server name filter.
continue
except Exception as _e:
verbose_logger.debug(f"Could not resolve '{name}' as toolset: {_e}")
resolved_mcp_servers.append(name)
# Apply all resolved toolsets at once (union), avoiding permission overwrite.
if resolved_toolset_ids and user_api_key_auth is not None:
user_api_key_auth = await LiteLLM_Proxy_MCP_Handler._apply_toolset_permissions(
resolved_toolset_ids=resolved_toolset_ids,
resolved_mcp_servers=resolved_mcp_servers,
user_api_key_auth=user_api_key_auth,
)
# When toolsets were resolved we updated object_permission.mcp_servers to the
# full union (toolset server IDs + direct server names). Passing a name-based
# filter here would exclude those toolset server IDs (which are UUIDs, not
# names), so use None and let the auth object's mcp_servers do the filtering.
effective_server_filter = (
None if resolved_toolset_ids else (resolved_mcp_servers or None)
)
tools = await _get_tools_from_mcp_servers(
user_api_key_auth=user_api_key_auth,
mcp_auth_header=mcp_auth_header,
mcp_servers=mcp_servers,
mcp_servers=effective_server_filter,
mcp_server_auth_headers=mcp_server_auth_headers,
log_list_tools_to_spendlogs=True,
list_tools_log_source="responses",
@ -178,7 +291,7 @@ class LiteLLM_Proxy_MCP_Handler:
)
allowed_mcp_servers = await _get_allowed_mcp_servers_from_mcp_server_names(
mcp_servers=mcp_servers,
mcp_servers=effective_server_filter,
allowed_mcp_servers=allowed_mcp_servers,
)
@ -682,14 +795,14 @@ class LiteLLM_Proxy_MCP_Handler:
standard_logging_mcp_tool_call["mcp_server_logo_url"] = logo_url
cost_info = mcp_info.get("mcp_server_cost_info")
if cost_info:
standard_logging_mcp_tool_call[
"mcp_server_cost_info"
] = cost_info
standard_logging_mcp_tool_call["mcp_server_cost_info"] = (
cost_info
)
if litellm_logging_obj:
litellm_logging_obj.model_call_details[
"mcp_tool_call_metadata"
] = standard_logging_mcp_tool_call
litellm_logging_obj.model_call_details["mcp_tool_call_metadata"] = (
standard_logging_mcp_tool_call
)
litellm_logging_obj.model = f"MCP: {tool_name}"
litellm_logging_obj.call_type = CallTypes.call_mcp_tool.value

View file

@ -0,0 +1,34 @@
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel
from typing_extensions import TypedDict
class MCPToolsetTool(TypedDict):
server_id: str
tool_name: str
class MCPToolset(BaseModel):
toolset_id: str
toolset_name: str
description: Optional[str] = None
tools: List[MCPToolsetTool] = []
created_at: Optional[datetime] = None
created_by: Optional[str] = None
updated_at: Optional[datetime] = None
updated_by: Optional[str] = None
class NewMCPToolsetRequest(BaseModel):
toolset_name: str
description: Optional[str] = None
tools: List[MCPToolsetTool] = []
class UpdateMCPToolsetRequest(BaseModel):
toolset_id: str
toolset_name: Optional[str] = None
description: Optional[str] = None
tools: Optional[List[MCPToolsetTool]] = None

View file

@ -273,6 +273,7 @@ model LiteLLM_ObjectPermissionTable {
agent_access_groups String[] @default([])
models String[] @default([])
blocked_tools String[] @default([]) // Tool names blocked for any key/team/user with this permission
mcp_toolsets String[] @default([]) // Toolset IDs granted to this key/team/user
teams LiteLLM_TeamTable[]
projects LiteLLM_ProjectTable[]
verification_tokens LiteLLM_VerificationToken[]
@ -331,6 +332,18 @@ model LiteLLM_MCPServerTable {
@@index([approval_status])
}
// Named collection of {server_id, tool_name} pairs that can be granted to keys/teams
model LiteLLM_MCPToolsetTable {
toolset_id String @id @default(uuid())
toolset_name String @unique
description String?
tools Json @default("[]") // [{server_id: string, tool_name: string}]
created_at DateTime @default(now())
created_by String?
updated_at DateTime @default(now()) @updatedAt
updated_by String?
}
// Per-user BYOK credentials for MCP servers
model LiteLLM_MCPUserCredentials {
id String @id @default(uuid())

View file

@ -1897,18 +1897,18 @@ class TestMCPServerManagerReload:
db_row = _make_db_mcp_server("server-1", timestamp)
mock_prisma = MagicMock()
mock_prisma.db.litellm_mcpservertable.find_many = AsyncMock(
return_value=[db_row]
)
with patch(
"litellm.proxy._experimental.mcp_server.db.get_all_mcp_servers",
new=AsyncMock(return_value=[db_row]),
) as mock_get_all, patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
return_value=object(),
return_value=mock_prisma,
), patch.object(
manager, "build_mcp_server_from_table", AsyncMock()
) as mock_build:
await manager.reload_servers_from_database()
mock_get_all.assert_awaited_once()
mock_build.assert_not_awaited()
assert manager.registry["server-1"] is existing_server
@ -1940,12 +1940,13 @@ class TestMCPServerManagerReload:
updated_at=new_timestamp,
)
mock_prisma = MagicMock()
mock_prisma.db.litellm_mcpservertable.find_many = AsyncMock(
return_value=[db_row]
)
with patch(
"litellm.proxy._experimental.mcp_server.db.get_all_mcp_servers",
new=AsyncMock(return_value=[db_row]),
) as mock_get_all, patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
return_value=object(),
return_value=mock_prisma,
), patch.object(
manager,
"build_mcp_server_from_table",
@ -1953,7 +1954,6 @@ class TestMCPServerManagerReload:
) as mock_build:
await manager.reload_servers_from_database()
mock_get_all.assert_awaited_once()
mock_build.assert_awaited_once_with(db_row)
assert manager.registry["server-1"] is rebuilt_server

View file

@ -0,0 +1,288 @@
"""Tests for MCP toolset scope enforcement."""
import asyncio
from typing import Dict, List, Optional
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from litellm.proxy._types import (
LiteLLM_ObjectPermissionTable,
LitellmUserRoles,
UserAPIKeyAuth,
)
def _make_auth(
mcp_servers: Optional[List[str]] = None,
mcp_tool_permissions: Optional[Dict[str, List[str]]] = None,
mcp_toolsets: Optional[List[str]] = None,
) -> UserAPIKeyAuth:
op = LiteLLM_ObjectPermissionTable(
object_permission_id="test",
mcp_servers=mcp_servers,
mcp_tool_permissions=mcp_tool_permissions or {},
mcp_toolsets=mcp_toolsets,
)
return UserAPIKeyAuth(
api_key="sk-test",
object_permission=op,
)
class TestApplyToolsetScope:
"""Tests for _apply_toolset_scope helper."""
@pytest.mark.asyncio
async def test_restricts_to_toolset_servers_and_tools(self):
from litellm.proxy._experimental.mcp_server.server import _apply_toolset_scope
toolset_perms = {
"server-a": ["tool1", "tool2"],
"server-b": ["tool3"],
}
with patch(
"litellm.proxy._experimental.mcp_server.server."
"global_mcp_server_manager.resolve_toolset_tool_permissions",
new=AsyncMock(return_value=toolset_perms),
):
# Key has been explicitly granted toolset-123 — access check passes.
auth = _make_auth(
mcp_servers=["server-a", "server-b", "server-c"],
mcp_toolsets=["toolset-123"],
)
result = await _apply_toolset_scope(auth, "toolset-123")
op = result.object_permission
assert op is not None
assert set(op.mcp_servers or []) == {"server-a", "server-b"}
assert op.mcp_tool_permissions == toolset_perms
@pytest.mark.asyncio
async def test_admin_creates_object_permission_when_none(self):
"""Admin key with object_permission=None can access any toolset."""
from litellm.proxy._experimental.mcp_server.server import _apply_toolset_scope
toolset_perms = {"server-a": ["tool1"]}
with patch(
"litellm.proxy._experimental.mcp_server.server."
"global_mcp_server_manager.resolve_toolset_tool_permissions",
new=AsyncMock(return_value=toolset_perms),
):
auth = UserAPIKeyAuth(
api_key="sk-test",
user_role=LitellmUserRoles.PROXY_ADMIN,
object_permission=None,
)
result = await _apply_toolset_scope(auth, "toolset-123")
op = result.object_permission
assert op is not None
assert op.mcp_servers == ["server-a"]
assert op.mcp_tool_permissions == toolset_perms
@pytest.mark.asyncio
async def test_non_admin_no_object_permission_raises_403(self):
"""Non-admin key with object_permission=None is denied (no grants configured)."""
from starlette.exceptions import HTTPException
from litellm.proxy._experimental.mcp_server.server import _apply_toolset_scope
auth = UserAPIKeyAuth(api_key="sk-test", object_permission=None)
with pytest.raises(HTTPException) as exc_info:
await _apply_toolset_scope(auth, "toolset-123")
assert exc_info.value.status_code == 403
class TestFetchMCPToolsetsAccess:
"""Tests for GET /v1/mcp/toolset access control."""
@pytest.mark.asyncio
async def test_non_admin_empty_grants_returns_empty(self):
"""Non-admin key with mcp_toolsets=[] must not see any toolsets."""
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
fetch_mcp_toolsets,
)
auth = _make_auth(mcp_toolsets=[])
mock_client = MagicMock()
with (
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
return_value=mock_client,
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.list_mcp_toolsets",
new=AsyncMock(return_value=[]),
) as mock_list,
):
result = await fetch_mcp_toolsets(user_api_key_dict=auth)
assert result == []
mock_list.assert_not_called()
@pytest.mark.asyncio
async def test_admin_unrestricted_returns_all(self):
"""Admin key with mcp_toolsets absent (None) gets all toolsets."""
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
fetch_mcp_toolsets,
)
auth = UserAPIKeyAuth(
api_key="sk-test",
user_role=LitellmUserRoles.PROXY_ADMIN,
object_permission=None,
)
fake_toolsets = [MagicMock(), MagicMock()]
mock_client = MagicMock()
with (
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
return_value=mock_client,
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.list_mcp_toolsets",
new=AsyncMock(return_value=fake_toolsets),
) as mock_list,
):
result = await fetch_mcp_toolsets(user_api_key_dict=auth)
assert result == fake_toolsets
mock_list.assert_called_once_with(mock_client)
@pytest.mark.asyncio
async def test_non_admin_none_grants_returns_empty(self):
"""Non-admin key with no object_permission (field absent) gets no toolsets."""
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
fetch_mcp_toolsets,
)
auth = UserAPIKeyAuth(api_key="sk-test", object_permission=None)
mock_client = MagicMock()
with (
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
return_value=mock_client,
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.list_mcp_toolsets",
new=AsyncMock(return_value=[]),
) as mock_list,
):
result = await fetch_mcp_toolsets(user_api_key_dict=auth)
assert result == []
mock_list.assert_not_called()
@pytest.mark.asyncio
async def test_populated_grants_filters_toolsets(self):
"""Key with explicit toolset IDs fetches only those IDs from the DB."""
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
fetch_mcp_toolsets,
)
auth = _make_auth(mcp_toolsets=["ts-1", "ts-2"])
fake_toolsets = [MagicMock(toolset_id="ts-1"), MagicMock(toolset_id="ts-2")]
mock_client = MagicMock()
with (
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
return_value=mock_client,
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.list_mcp_toolsets",
new=AsyncMock(return_value=fake_toolsets),
) as mock_list,
):
result = await fetch_mcp_toolsets(user_api_key_dict=auth)
assert len(result) == 2
mock_list.assert_called_once_with(mock_client, toolset_ids=["ts-1", "ts-2"])
class TestMCPActiveToolsetContextVar:
"""Tests for _mcp_active_toolset_id ContextVar — clients cannot inject it."""
def test_contextvar_default_is_none(self):
from litellm.proxy._experimental.mcp_server.server import _mcp_active_toolset_id
assert _mcp_active_toolset_id.get() is None
def test_contextvar_set_and_reset(self):
from litellm.proxy._experimental.mcp_server.server import _mcp_active_toolset_id
token = _mcp_active_toolset_id.set("toolset-abc")
assert _mcp_active_toolset_id.get() == "toolset-abc"
_mcp_active_toolset_id.reset(token)
assert _mcp_active_toolset_id.get() is None
@pytest.mark.asyncio
async def test_client_header_is_stripped_in_scope(self):
"""handle_streamable_http_mcp strips x-mcp-toolset-id from scope before passing to session manager."""
from litellm.proxy._experimental.mcp_server.server import (
handle_streamable_http_mcp,
)
scope = {
"type": "http",
"path": "/mcp",
"method": "GET",
"query_string": b"",
"headers": [
(b"authorization", b"Bearer sk-test"),
(b"x-mcp-toolset-id", b"evil-toolset"),
(b"content-type", b"application/json"),
],
}
mock_auth = UserAPIKeyAuth(api_key="sk-test")
async def fake_receive():
return {"type": "http.disconnect"}
async def fake_send(msg):
pass
with (
patch(
"litellm.proxy._experimental.mcp_server.server.extract_mcp_auth_context",
new=AsyncMock(
return_value=(mock_auth, None, [], {}, {}, scope["headers"])
),
),
patch(
"litellm.proxy._experimental.mcp_server.server.IPAddressUtils",
MagicMock(get_mcp_client_ip=MagicMock(return_value="127.0.0.1")),
),
patch(
"litellm.proxy._experimental.mcp_server.server.global_mcp_server_manager",
MagicMock(get_mcp_server_by_name=MagicMock(return_value=None)),
),
patch(
"litellm.proxy._experimental.mcp_server.server.MCPDebug",
MagicMock(
maybe_build_debug_headers=MagicMock(return_value=None),
),
),
patch(
"litellm.proxy._experimental.mcp_server.server.set_auth_context",
MagicMock(),
),
patch(
"litellm.proxy._experimental.mcp_server.server._SESSION_MANAGERS_INITIALIZED",
True,
),
patch(
"litellm.proxy._experimental.mcp_server.server._handle_stale_mcp_session",
new=AsyncMock(return_value=True),
),
):
await handle_streamable_http_mcp(scope, fake_receive, fake_send)
header_keys = [k for k, _ in scope["headers"]]
assert b"x-mcp-toolset-id" not in header_keys
assert b"authorization" in header_keys
assert b"content-type" in header_keys

View file

@ -385,6 +385,7 @@ async def test_get_mcp_tools_from_manager_enables_list_tools_logging(monkeypatch
fake_manager = types.SimpleNamespace(
get_allowed_mcp_servers=AsyncMock(return_value=[]),
get_mcp_servers_from_ids=MagicMock(return_value=[]),
get_mcp_server_by_name=MagicMock(return_value=None),
)
monkeypatch.setattr(
"litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager",

View file

@ -1,16 +1,20 @@
import json
import os
from unittest.mock import MagicMock, patch
import pytest
import redis
import redis.asyncio as async_redis
from litellm._redis import (
get_redis_url_from_environment,
_get_redis_cluster_kwargs,
get_redis_async_client,
get_redis_client,
get_redis_connection_pool,
get_redis_url_from_environment,
)
import json
import os
import pytest
from unittest.mock import MagicMock, patch
import redis
import redis.asyncio as async_redis
from litellm._redis_credential_provider import GCPIAMCredentialProvider
def test_get_redis_url_from_environment_single_url(monkeypatch):
"""Test when REDIS_URL is directly provided"""
@ -23,6 +27,7 @@ def test_get_redis_url_from_environment_single_url(monkeypatch):
# Assert that the returned URL matches the expected value
assert redis_url == "redis://redis-server:6379/0"
def test_get_redis_url_from_environment_host_port(monkeypatch):
"""Test when REDIS_HOST and REDIS_PORT are provided"""
# Set the environment variables
@ -39,6 +44,7 @@ def test_get_redis_url_from_environment_host_port(monkeypatch):
# Assert that the returned URL matches the expected value
assert redis_url == "redis://redis-server:6379"
def test_get_redis_url_from_environment_with_ssl(monkeypatch):
"""Test when SSL is enabled"""
# Set the environment variables
@ -55,6 +61,7 @@ def test_get_redis_url_from_environment_with_ssl(monkeypatch):
# Assert that the returned URL uses rediss:// protocol
assert redis_url == "rediss://redis-server:6379"
def test_get_redis_url_from_environment_with_username_password(monkeypatch):
"""Test when username and password are provided"""
# Set the environment variables
@ -69,6 +76,7 @@ def test_get_redis_url_from_environment_with_username_password(monkeypatch):
# Assert that the returned URL includes username:password@
assert redis_url == "redis://user:password@redis-server:6379"
def test_get_redis_url_from_environment_with_password_only(monkeypatch):
"""Test when only password is provided"""
# Set the environment variables
@ -85,6 +93,7 @@ def test_get_redis_url_from_environment_with_password_only(monkeypatch):
# Assert that the returned URL includes :password@
assert redis_url == "redis://password@redis-server:6379"
def test_get_redis_url_from_environment_with_all_options(monkeypatch):
"""Test when all options are provided"""
# Set the environment variables
@ -100,6 +109,7 @@ def test_get_redis_url_from_environment_with_all_options(monkeypatch):
# Assert that the returned URL includes all components
assert redis_url == "rediss://user:password@redis-server:6379"
def test_get_redis_url_from_environment_missing_host_port(monkeypatch):
"""Test error when required variables are missing"""
# Make sure these environment variables don't exist
@ -110,9 +120,13 @@ def test_get_redis_url_from_environment_missing_host_port(monkeypatch):
# Call the function and expect a ValueError
with pytest.raises(ValueError) as excinfo:
get_redis_url_from_environment()
# Check the error message
assert "Either 'REDIS_URL' or both 'REDIS_HOST' and 'REDIS_PORT' must be specified" in str(excinfo.value)
assert (
"Either 'REDIS_URL' or both 'REDIS_HOST' and 'REDIS_PORT' must be specified"
in str(excinfo.value)
)
def test_get_redis_url_from_environment_missing_port(monkeypatch):
"""Test error when only REDIS_HOST is provided but REDIS_PORT is missing"""
@ -124,57 +138,135 @@ def test_get_redis_url_from_environment_missing_port(monkeypatch):
# Call the function and expect a ValueError
with pytest.raises(ValueError) as excinfo:
get_redis_url_from_environment()
# Check the error message
assert "Either 'REDIS_URL' or both 'REDIS_HOST' and 'REDIS_PORT' must be specified" in str(excinfo.value)
assert (
"Either 'REDIS_URL' or both 'REDIS_HOST' and 'REDIS_PORT' must be specified"
in str(excinfo.value)
)
def test_max_connections_in_cluster_kwargs():
"""Test that max_connections is included in Redis cluster kwargs"""
kwargs = _get_redis_cluster_kwargs()
assert "max_connections" in kwargs, "max_connections should be in available Redis cluster kwargs"
assert (
"max_connections" in kwargs
), "max_connections should be in available Redis cluster kwargs"
def test_get_redis_async_client_with_connection_pool():
"""Test that connection_pool parameter is properly passed to Redis client"""
# Create a mock connection pool
mock_pool = MagicMock(spec=async_redis.BlockingConnectionPool)
# Mock the Redis client creation
with patch('litellm._redis.async_redis.Redis') as mock_redis, \
patch('litellm._redis._get_redis_client_logic') as mock_logic:
with patch("litellm._redis.async_redis.Redis") as mock_redis, patch(
"litellm._redis._get_redis_client_logic"
) as mock_logic:
# Configure mock to return basic redis kwargs
mock_logic.return_value = {
"host": "localhost",
"port": 6379,
"db": 0
}
mock_logic.return_value = {"host": "localhost", "port": 6379, "db": 0}
# Call get_redis_async_client with connection_pool
get_redis_async_client(connection_pool=mock_pool)
# Verify Redis was called with connection_pool in kwargs
call_kwargs = mock_redis.call_args[1]
assert "connection_pool" in call_kwargs, "connection_pool should be passed to Redis client"
assert call_kwargs["connection_pool"] == mock_pool, "connection_pool should match the provided pool"
assert (
"connection_pool" in call_kwargs
), "connection_pool should be passed to Redis client"
assert (
call_kwargs["connection_pool"] == mock_pool
), "connection_pool should match the provided pool"
def test_get_redis_async_client_without_connection_pool():
"""Test that Redis client works without connection_pool parameter"""
with patch('litellm._redis.async_redis.Redis') as mock_redis, \
patch('litellm._redis._get_redis_client_logic') as mock_logic:
with patch("litellm._redis.async_redis.Redis") as mock_redis, patch(
"litellm._redis._get_redis_client_logic"
) as mock_logic:
# Configure mock to return basic redis kwargs
mock_logic.return_value = {
"host": "localhost",
"port": 6379,
"db": 0
}
mock_logic.return_value = {"host": "localhost", "port": 6379, "db": 0}
# Call get_redis_async_client without connection_pool
get_redis_async_client()
# Verify Redis was called without connection_pool in kwargs
call_kwargs = mock_redis.call_args[1]
assert "connection_pool" not in call_kwargs, "connection_pool should not be in kwargs when not provided"
assert (
"connection_pool" not in call_kwargs
), "connection_pool should not be in kwargs when not provided"
def test_gcp_iam_credential_provider_get_credentials():
"""GCPIAMCredentialProvider.get_credentials() returns a fresh token tuple on every call."""
service_account = "projects/-/serviceAccounts/test@project.iam.gserviceaccount.com"
with patch(
"litellm._redis_credential_provider._generate_gcp_iam_access_token",
return_value="tok-1",
) as mock_gen:
provider = GCPIAMCredentialProvider(service_account)
creds = provider.get_credentials()
assert creds == ("tok-1",)
mock_gen.assert_called_once_with(service_account)
def test_gcp_iam_credential_provider_regenerates_token_on_each_call():
"""Each call to get_credentials() generates a new token (no caching)."""
service_account = "projects/-/serviceAccounts/test@project.iam.gserviceaccount.com"
tokens = ["tok-1", "tok-2", "tok-3"]
with patch(
"litellm._redis_credential_provider._generate_gcp_iam_access_token",
side_effect=tokens,
) as mock_gen:
provider = GCPIAMCredentialProvider(service_account)
results = [provider.get_credentials() for _ in range(3)]
assert results == [("tok-1",), ("tok-2",), ("tok-3",)]
assert mock_gen.call_count == 3
def test_get_redis_async_client_gcp_cluster_uses_credential_provider():
"""
When startup_nodes + gcp_service_account are provided, the async cluster client
must be constructed with a GCPIAMCredentialProvider not a static password.
This ensures that the 1-hour IAM token expiry does not cause auth failures.
"""
startup_nodes = [{"host": "redis-node-1", "port": 6379}]
mock_connect_func = MagicMock()
mock_connect_func._gcp_service_account = (
"projects/-/serviceAccounts/sa@project.iam.gserviceaccount.com"
)
redis_kwargs = {
"startup_nodes": startup_nodes,
"redis_connect_func": mock_connect_func,
}
with patch("litellm._redis.async_redis.RedisCluster") as mock_cluster, patch(
"litellm._redis._get_redis_client_logic", return_value=redis_kwargs
):
get_redis_async_client()
assert mock_cluster.called
cluster_call_kwargs = mock_cluster.call_args[1]
# Must use credential_provider, not a static password
assert (
"credential_provider" in cluster_call_kwargs
), "async GCP cluster must use credential_provider for per-connection token refresh"
assert isinstance(
cluster_call_kwargs["credential_provider"], GCPIAMCredentialProvider
)
assert (
"password" not in cluster_call_kwargs
), "async GCP cluster must not use a static password (expires after 1h)"
@patch("litellm._redis.init_redis_cluster")
def test_sync_client_prefers_cluster_over_url(mock_init_cluster, monkeypatch):
@ -194,6 +286,7 @@ def test_sync_client_prefers_cluster_over_url(mock_init_cluster, monkeypatch):
"startup_nodes" in call_kwargs
), "startup_nodes must be forwarded to init_redis_cluster"
@patch("litellm._redis.async_redis.RedisCluster")
def test_async_client_prefers_cluster_over_url(mock_cluster_cls, monkeypatch):
"""
@ -207,12 +300,18 @@ def test_async_client_prefers_cluster_over_url(mock_cluster_cls, monkeypatch):
mock_cluster_cls.assert_called_once()
call_kwargs = mock_cluster_cls.call_args[1]
assert "startup_nodes" in call_kwargs, "startup_nodes must be forwarded to async RedisCluster"
assert len(call_kwargs["startup_nodes"]) == 1, "should forward exactly 1 cluster node"
assert (
"startup_nodes" in call_kwargs
), "startup_nodes must be forwarded to async RedisCluster"
assert (
len(call_kwargs["startup_nodes"]) == 1
), "should forward exactly 1 cluster node"
@patch("litellm._redis.async_redis.RedisCluster")
def test_async_client_prefers_cluster_over_url_via_env_var(mock_cluster_cls, monkeypatch):
def test_async_client_prefers_cluster_over_url_via_env_var(
mock_cluster_cls, monkeypatch
):
"""
Test get_redis_async_client returns async RedisCluster when REDIS_CLUSTER_NODES is set
even if REDIS_URL is also set.
@ -227,10 +326,15 @@ def test_async_client_prefers_cluster_over_url_via_env_var(mock_cluster_cls, mon
mock_cluster_cls.assert_called_once()
call_kwargs = mock_cluster_cls.call_args[1]
assert "startup_nodes" in call_kwargs, "startup_nodes must be forwarded to async RedisCluster"
assert (
"startup_nodes" in call_kwargs
), "startup_nodes must be forwarded to async RedisCluster"
@patch("litellm._redis.init_redis_cluster")
def test_sync_client_prefers_cluster_over_url_via_env_var(mock_init_cluster, monkeypatch):
def test_sync_client_prefers_cluster_over_url_via_env_var(
mock_init_cluster, monkeypatch
):
"""
Test get_redis_client returns RedisCluster when REDIS_CLUSTER_NODES is set even if
REDIS_URL is also set.
@ -246,11 +350,16 @@ def test_sync_client_prefers_cluster_over_url_via_env_var(mock_init_cluster, mon
mock_init_cluster.assert_called_once()
call_kwargs = mock_init_cluster.call_args[0][0]
assert "startup_nodes" in call_kwargs, "startup_nodes must be forwarded to init_redis_cluster"
assert (
"startup_nodes" in call_kwargs
), "startup_nodes must be forwarded to init_redis_cluster"
assert len(call_kwargs["startup_nodes"]) == 1
@patch("litellm._redis.init_redis_cluster")
def test_sync_client_preserves_password_for_cluster_when_url_also_set(mock_init_cluster, monkeypatch):
def test_sync_client_preserves_password_for_cluster_when_url_also_set(
mock_init_cluster, monkeypatch
):
"""
Test _get_redis_client_logic does not strip password from redis_kwargs when
startup_nodes is present even if REDIS_URL is also set.
@ -264,7 +373,9 @@ def test_sync_client_preserves_password_for_cluster_when_url_also_set(mock_init_
mock_init_cluster.assert_called_once()
call_kwargs = mock_init_cluster.call_args[0][0]
assert "password" in call_kwargs, "password must not be stripped when routing to cluster"
assert (
"password" in call_kwargs
), "password must not be stripped when routing to cluster"
assert call_kwargs["password"] == "secret"

View file

@ -0,0 +1,16 @@
import { useQuery } from "@tanstack/react-query";
import { createQueryKeys } from "../common/queryKeysFactory";
import { fetchMCPToolsets } from "@/components/networking";
import { MCPToolset } from "@/components/mcp_tools/types";
import useAuthorized from "../useAuthorized";
const mcpToolsetKeys = createQueryKeys("mcpToolsets");
export const useMCPToolsets = () => {
const { accessToken } = useAuthorized();
return useQuery<MCPToolset[]>({
queryKey: mcpToolsetKeys.list(),
queryFn: async () => await fetchMCPToolsets(accessToken!),
enabled: !!accessToken,
});
};

View file

@ -1,13 +1,15 @@
import { useMCPAccessGroups } from "@/app/(dashboard)/hooks/mcpServers/useMCPAccessGroups";
import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
import { useMCPToolsets } from "@/app/(dashboard)/hooks/mcpServers/useMCPToolsets";
import { Select } from "antd";
import React from "react";
interface MCPServerSelectorProps {
onChange: (selected: { servers: string[]; accessGroups: string[] }) => void;
onChange: (selected: { servers: string[]; accessGroups: string[]; toolsets: string[] }) => void;
value?: {
servers: string[];
accessGroups: string[];
toolsets?: string[];
};
className?: string;
accessToken: string;
@ -16,6 +18,8 @@ interface MCPServerSelectorProps {
teamId?: string | null;
}
const TOOLSET_PREFIX = "toolset:";
const MCPServerSelector: React.FC<MCPServerSelectorProps> = ({
onChange,
value,
@ -27,33 +31,61 @@ const MCPServerSelector: React.FC<MCPServerSelectorProps> = ({
}) => {
const { data: mcpServers = [], isLoading: serversLoading } = useMCPServers(teamId);
const { data: accessGroups = [], isLoading: groupsLoading } = useMCPAccessGroups();
const { data: toolsets = [], isLoading: toolsetsLoading } = useMCPToolsets();
const loading = serversLoading || groupsLoading;
const loading = serversLoading || groupsLoading || toolsetsLoading;
// Combine options, access groups first
const accessGroupSet = new Set(accessGroups);
// Combine options: access groups (green) + servers (blue) + toolsets (purple)
const options = [
...accessGroups.map((group) => ({
label: group,
value: group,
isAccessGroup: true,
type: "accessGroup" as const,
searchText: `${group} Access Group`,
})),
...mcpServers.map((server) => ({
label: `${server.server_name || server.server_id} (${server.server_id})`,
value: server.server_id,
isAccessGroup: false,
type: "server" as const,
searchText: `${server.server_name || server.server_id} ${server.server_id} MCP Server`,
})),
...toolsets.map((toolset) => ({
label: toolset.toolset_name,
value: `${TOOLSET_PREFIX}${toolset.toolset_id}`,
type: "toolset" as const,
searchText: `${toolset.toolset_name} ${toolset.toolset_id} Toolset`,
})),
];
// Flatten value for Select
const selectedValues = [...(value?.servers || []), ...(value?.accessGroups || [])];
const colorByType: Record<string, string> = {
accessGroup: "#52c41a",
server: "#1890ff",
toolset: "#722ed1",
};
const labelByType: Record<string, string> = {
accessGroup: "Access Group",
server: "MCP Server",
toolset: "Toolset",
};
// Flatten value for Select — prefix toolset IDs
const selectedValues = [
...(value?.servers || []),
...(value?.accessGroups || []),
...(value?.toolsets || []).map((id) => `${TOOLSET_PREFIX}${id}`),
];
// Handle selection
const handleChange = (selected: string[]) => {
const servers = selected.filter((v) => !accessGroups.includes(v));
const accessGroupsSelected = selected.filter((v) => accessGroups.includes(v));
onChange({ servers, accessGroups: accessGroupsSelected });
const toolsetsSelected = selected
.filter((v) => v.startsWith(TOOLSET_PREFIX))
.map((v) => v.slice(TOOLSET_PREFIX.length));
const rest = selected.filter((v) => !v.startsWith(TOOLSET_PREFIX));
const servers = rest.filter((v) => !accessGroupSet.has(v));
const accessGroupsSelected = rest.filter((v) => accessGroupSet.has(v));
onChange({ servers, accessGroups: accessGroupsSelected, toolsets: toolsetsSelected });
};
return (
@ -83,20 +115,20 @@ const MCPServerSelector: React.FC<MCPServerSelectorProps> = ({
width: 8,
height: 8,
borderRadius: "50%",
background: opt.isAccessGroup ? "#52c41a" : "#1890ff",
background: colorByType[opt.type],
flexShrink: 0,
}}
/>
<span style={{ flex: 1 }}>{opt.label}</span>
<span
style={{
color: opt.isAccessGroup ? "#52c41a" : "#1890ff",
color: colorByType[opt.type],
fontSize: "12px",
fontWeight: 500,
opacity: 0.8,
}}
>
{opt.isAccessGroup ? "Access Group" : "MCP Server"}
{labelByType[opt.type]}
</span>
</div>
</Select.Option>

View file

@ -0,0 +1,524 @@
import React, { useState, useCallback } from "react";
import { Button, Text, Title } from "@tremor/react";
import { Modal, Form, Input, message, Spin, Card, Typography, Space } from "antd";
import { PlusIcon, PencilIcon, TrashIcon } from "@heroicons/react/outline";
import { ColumnDef } from "@tanstack/react-table";
import { useMCPToolsets } from "@/app/(dashboard)/hooks/mcpServers/useMCPToolsets";
import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
import { useQueryClient } from "@tanstack/react-query";
import { DataTable } from "../view_logs/table";
import {
createMCPToolset,
updateMCPToolset,
deleteMCPToolset,
listMCPTools,
getProxyBaseUrl,
} from "../networking";
import { MCPToolset, MCPToolsetTool } from "./types";
const { Text: AntdText } = Typography;
interface MCPToolsetsTabProps {
accessToken: string | null;
userRole: string | null;
}
interface ToolsetFormValues {
toolset_name: string;
description?: string;
}
interface MCPToolListProps {
serverId: string;
serverName: string;
accessToken: string | null;
selectedTools: MCPToolsetTool[];
onToggle: (tool: MCPToolsetTool) => void;
}
interface ToolEntry {
name: string;
description?: string;
}
function MCPToolList({ serverId, serverName, accessToken, selectedTools, onToggle }: MCPToolListProps) {
const [tools, setTools] = useState<ToolEntry[]>([]);
const [loading, setLoading] = useState(false);
const [expanded, setExpanded] = useState(false);
const selectedSet = new Set(selectedTools.filter((t) => t.server_id === serverId).map((t) => t.tool_name));
const fetchTools = useCallback(async () => {
if (!accessToken || tools.length > 0) return;
setLoading(true);
try {
const result = await listMCPTools(accessToken, serverId);
const toolList = Array.isArray(result) ? result : result?.tools ?? [];
setTools(toolList.map((t: any) => ({ name: t.name ?? t.tool_name ?? t, description: t.description ?? "" })));
} catch {
setTools([]);
} finally {
setLoading(false);
}
}, [accessToken, serverId, tools.length]);
const handleToggle = () => {
if (!expanded) fetchTools();
setExpanded(!expanded);
};
return (
<div className="border border-gray-200 rounded-lg overflow-hidden">
<button
type="button"
className="w-full flex items-center justify-between px-4 py-3 bg-gray-50 hover:bg-gray-100 transition-colors"
onClick={handleToggle}
>
<span className="text-sm font-medium text-gray-700 flex items-center gap-2">
<span className="inline-block w-2 h-2 rounded-full bg-blue-500 flex-shrink-0" />
{serverName}
{selectedSet.size > 0 && (
<span className="ml-1 text-xs text-purple-600 font-semibold">{selectedSet.size} selected</span>
)}
</span>
<span className="text-gray-400 text-xs">{expanded ? "▲" : "▼"}</span>
</button>
{expanded && (
<div className="p-2">
{loading ? (
<div className="flex justify-center py-3"><Spin size="small" /></div>
) : tools.length === 0 ? (
<p className="text-xs text-gray-400 px-2 py-2">No tools found for this server.</p>
) : (
<div className="flex flex-col gap-1">
{tools.map((tool) => {
const selected = selectedSet.has(tool.name);
return (
<button
key={tool.name}
type="button"
onClick={() => onToggle({ server_id: serverId, tool_name: tool.name })}
className={`flex items-start justify-between px-3 py-2 rounded-lg text-left transition-colors ${
selected
? "bg-purple-50 border border-purple-300"
: "bg-white border border-gray-100 hover:bg-gray-50"
}`}
>
<div className="min-w-0 flex-1">
<p className={`text-sm font-medium leading-tight ${selected ? "text-purple-800" : "text-gray-800"}`}>
{tool.name}
</p>
{tool.description && (
<p className="text-xs text-gray-400 mt-0.5 leading-tight line-clamp-2">{tool.description}</p>
)}
</div>
{selected && <span className="text-purple-500 text-xs font-semibold ml-2 flex-shrink-0 mt-0.5"></span>}
</button>
);
})}
</div>
)}
</div>
)}
</div>
);
}
interface CreateToolsetModalProps {
open: boolean;
onClose: () => void;
onSave: (name: string, description: string | undefined, tools: MCPToolsetTool[]) => Promise<void>;
accessToken: string | null;
initialToolset?: MCPToolset;
}
function CreateToolsetModal({ open, onClose, onSave, accessToken, initialToolset }: CreateToolsetModalProps) {
const [form] = Form.useForm<ToolsetFormValues>();
const [selectedTools, setSelectedTools] = useState<MCPToolsetTool[]>(initialToolset?.tools || []);
const [saving, setSaving] = useState(false);
const [serverSearch, setServerSearch] = useState("");
const { data: mcpServers = [] } = useMCPServers();
React.useEffect(() => {
if (open) {
form.setFieldsValue({
toolset_name: initialToolset?.toolset_name || "",
description: initialToolset?.description || "",
});
setSelectedTools(initialToolset?.tools || []);
setServerSearch("");
}
}, [open, initialToolset]);
const handleToggleTool = (tool: MCPToolsetTool) => {
setSelectedTools((prev) => {
const exists = prev.some((t) => t.server_id === tool.server_id && t.tool_name === tool.tool_name);
return exists
? prev.filter((t) => !(t.server_id === tool.server_id && t.tool_name === tool.tool_name))
: [...prev, tool];
});
};
const handleSubmit = async () => {
const values = await form.validateFields();
setSaving(true);
try {
await onSave(values.toolset_name, values.description, selectedTools);
onClose();
} finally {
setSaving(false);
}
};
const filteredServers = mcpServers.filter((s) => {
const q = serverSearch.toLowerCase();
return (
!q ||
(s.alias || "").toLowerCase().includes(q) ||
(s.server_name || "").toLowerCase().includes(q)
);
});
return (
<Modal
open={open}
onCancel={onClose}
title={initialToolset ? "Edit Toolset" : "New Toolset"}
width={960}
footer={null}
forceRender
>
<Form form={form} layout="vertical" className="mt-2">
<div className="flex gap-4 mb-4">
<Form.Item
label="Toolset Name"
name="toolset_name"
rules={[{ required: true, message: "Please enter a toolset name" }]}
className="flex-1 mb-0"
>
<Input placeholder="e.g. github-linear-tools" />
</Form.Item>
<Form.Item label="Description" name="description" className="flex-1 mb-0">
<Input placeholder="Optional description" />
</Form.Item>
</div>
</Form>
<div className="flex gap-4 mt-2" style={{ minHeight: 360 }}>
{/* Left panel: Available Tools */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-2">
<Text className="text-sm font-semibold text-gray-700">Available Tools</Text>
</div>
<Input
placeholder="Search MCP servers..."
value={serverSearch}
onChange={(e) => setServerSearch(e.target.value)}
className="mb-2"
allowClear
/>
<div className="space-y-2 overflow-y-auto" style={{ maxHeight: 300 }}>
{filteredServers.length === 0 ? (
<Text className="text-gray-400 text-sm">{mcpServers.length === 0 ? "No MCP servers configured" : "No servers match your search"}</Text>
) : (
filteredServers.map((server) => (
<MCPToolList
key={server.server_id}
serverId={server.server_id}
serverName={server.alias || server.server_name || server.server_id}
accessToken={accessToken}
selectedTools={selectedTools}
onToggle={handleToggleTool}
/>
))
)}
</div>
</div>
{/* Divider */}
<div className="w-px bg-gray-200 flex-shrink-0" />
{/* Right panel: Your Toolset */}
<div className="w-72 flex-shrink-0">
<Text className="text-sm font-semibold text-gray-700 mb-2 block">
Your Toolset{" "}
<span className="text-xs font-normal text-gray-400">({selectedTools.length} tools)</span>
</Text>
<div className="space-y-1 overflow-y-auto" style={{ maxHeight: 340 }}>
{selectedTools.length === 0 ? (
<Text className="text-gray-400 text-sm">No tools added yet</Text>
) : (
selectedTools.map((tool, idx) => (
<button
key={idx}
type="button"
onClick={() => handleToggleTool(tool)}
className="w-full flex items-center justify-between px-3 py-1.5 rounded-lg border border-purple-200 bg-purple-50 hover:bg-red-50 hover:border-red-200 group transition-colors"
>
<div className="min-w-0 text-left">
<span className="text-xs font-medium text-purple-800 group-hover:text-red-600 truncate block">{tool.tool_name}</span>
<span className="text-[10px] text-purple-400 truncate block">{tool.server_id.slice(0, 8)}</span>
</div>
<span className="ml-2 text-purple-300 group-hover:text-red-400 text-xs flex-shrink-0"></span>
</button>
))
)}
</div>
</div>
</div>
<div className="flex justify-end gap-2 mt-4 pt-4 border-t border-gray-200">
<Button variant="secondary" onClick={onClose}>Cancel</Button>
<Button onClick={handleSubmit} loading={saving}>
{initialToolset ? "Save Changes" : "Create Toolset"}
</Button>
</div>
</Modal>
);
}
function toolsetColumns(
isAdmin: boolean,
onEdit: (t: MCPToolset) => void,
onDelete: (id: string) => void,
proxyBaseUrl: string,
): ColumnDef<MCPToolset>[] {
return [
{
header: "Toolset ID",
accessorKey: "toolset_id",
cell: ({ row }) => (
<span className="font-mono text-xs bg-gray-100 px-2 py-0.5 rounded text-gray-600">
{row.original.toolset_id.slice(0, 8)}
</span>
),
},
{
header: "Name",
accessorKey: "toolset_name",
cell: ({ row }) => {
const url = `${proxyBaseUrl}/toolset/${row.original.toolset_name}/mcp`;
return (
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
<span className="inline-block w-2 h-2 rounded-full bg-purple-500 flex-shrink-0" />
<span className="font-medium text-gray-900">{row.original.toolset_name}</span>
</div>
<button
type="button"
className="text-xs text-gray-400 hover:text-purple-600 font-mono truncate max-w-xs text-left transition-colors"
onClick={() => navigator.clipboard.writeText(url)}
title="Click to copy endpoint URL"
>
{url}
</button>
</div>
);
},
},
{
header: "Description",
accessorKey: "description",
cell: ({ row }) => (
<span className="text-sm text-gray-500">{row.original.description || "—"}</span>
),
},
{
header: "Tools",
accessorKey: "tools",
cell: ({ row }) => {
const tools = row.original.tools;
return (
<div className="flex flex-wrap gap-1 max-w-xs">
{tools.slice(0, 4).map((t, i) => (
<span key={i} className="inline-flex items-center px-1.5 py-0.5 rounded bg-purple-50 border border-purple-200 text-purple-700 text-xs">
{t.tool_name}
</span>
))}
{tools.length > 4 && (
<span className="text-xs text-gray-400 self-center">+{tools.length - 4} more</span>
)}
</div>
);
},
},
{
header: "Created",
accessorKey: "created_at",
cell: ({ row }) => (
<span className="text-xs text-gray-500">
{row.original.created_at ? new Date(row.original.created_at).toLocaleDateString() : "—"}
</span>
),
},
...(isAdmin ? [{
header: "",
id: "actions",
cell: ({ row }: { row: { original: MCPToolset } }) => (
<div className="flex items-center gap-1 justify-end">
<button
type="button"
className="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 hover:text-gray-700 transition-colors"
onClick={() => onEdit(row.original)}
>
<PencilIcon className="h-4 w-4" />
</button>
<button
type="button"
className="p-1.5 rounded-lg hover:bg-red-50 text-gray-400 hover:text-red-500 transition-colors"
onClick={() => onDelete(row.original.toolset_id)}
>
<TrashIcon className="h-4 w-4" />
</button>
</div>
),
} as ColumnDef<MCPToolset>] : []),
];
}
function ToolsetUsageGuide() {
const [copied, setCopied] = useState(false);
const proxyBaseUrl = getProxyBaseUrl();
const snippet = `{
"mcpServers": {
"my-toolset": {
"url": "${proxyBaseUrl}/toolset/<toolset-name>/mcp",
"headers": { "x-litellm-api-key": "Bearer <your-api-key>" }
}
}
}`;
const copy = async () => {
try {
await navigator.clipboard.writeText(snippet);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
// ignore
}
};
return (
<div className="mb-6 rounded-lg border border-gray-200 bg-gray-50 px-5 py-4">
<p className="text-sm font-medium text-gray-700 mb-1">How toolsets work</p>
<p className="text-sm text-gray-500 mb-3">
Create a toolset, assign it to a key via <span className="font-medium text-gray-700">API Keys Edit Key MCP Servers</span>, then point your MCP client at the toolset URL. The client only sees the tools you picked.
</p>
<div className="text-xs text-gray-400 mb-1">Claude Code / Cursor config</div>
<div className="relative">
<pre className="bg-white border border-gray-200 rounded px-4 py-3 text-xs font-mono text-gray-700 overflow-x-auto leading-relaxed pr-14">
{snippet}
</pre>
<button
type="button"
onClick={copy}
className="absolute top-2 right-2 px-2 py-1 text-xs rounded border bg-white hover:bg-gray-50 text-gray-400 hover:text-gray-600 border-gray-200 transition-colors"
>
{copied ? "✓" : "copy"}
</button>
</div>
</div>
);
}
export function MCPToolsetsTab({ accessToken, userRole }: MCPToolsetsTabProps) {
const queryClient = useQueryClient();
const { data: toolsets = [], isLoading } = useMCPToolsets();
const [createOpen, setCreateOpen] = useState(false);
const [editToolset, setEditToolset] = useState<MCPToolset | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const isAdmin = userRole === "Admin" || userRole === "proxy_admin";
const handleCreate = async (name: string, description: string | undefined, tools: MCPToolsetTool[]) => {
if (!accessToken) return;
await createMCPToolset(accessToken, { toolset_name: name, description, tools });
message.success("Toolset created");
queryClient.invalidateQueries({ queryKey: ["mcpToolsets"] });
};
const handleUpdate = async (name: string, description: string | undefined, tools: MCPToolsetTool[]) => {
if (!accessToken || !editToolset) return;
await updateMCPToolset(accessToken, { toolset_id: editToolset.toolset_id, toolset_name: name, description, tools });
message.success("Toolset updated");
queryClient.invalidateQueries({ queryKey: ["mcpToolsets"] });
setEditToolset(null);
};
const handleDelete = async () => {
if (!accessToken || !deleteId) return;
setDeleting(true);
try {
await deleteMCPToolset(accessToken, deleteId);
message.success("Toolset deleted");
queryClient.invalidateQueries({ queryKey: ["mcpToolsets"] });
setDeleteId(null);
} finally {
setDeleting(false);
}
};
const proxyBaseUrl = getProxyBaseUrl();
const columns = toolsetColumns(isAdmin, setEditToolset, setDeleteId, proxyBaseUrl);
return (
<div className="mt-4">
<div className="flex items-center justify-between mb-4">
<div>
<Title>MCP Toolsets</Title>
<Text className="text-gray-500 text-sm">
Curated collections of tools from one or more MCP servers. Assign toolsets to keys and teams via the MCP permissions dropdown.
</Text>
</div>
{isAdmin && (
<Button icon={PlusIcon} onClick={() => setCreateOpen(true)}>
New Toolset
</Button>
)}
</div>
<ToolsetUsageGuide />
<DataTable
data={toolsets}
columns={columns}
renderSubComponent={() => <div />}
getRowCanExpand={() => false}
isLoading={isLoading}
noDataMessage="No toolsets yet. Click 'New Toolset' to create one."
loadingMessage="Loading toolsets..."
enableSorting={true}
/>
<CreateToolsetModal
open={createOpen}
onClose={() => setCreateOpen(false)}
onSave={handleCreate}
accessToken={accessToken}
/>
{editToolset && (
<CreateToolsetModal
open={!!editToolset}
onClose={() => setEditToolset(null)}
onSave={handleUpdate}
accessToken={accessToken}
initialToolset={editToolset}
/>
)}
<Modal
open={!!deleteId}
onCancel={() => setDeleteId(null)}
onOk={handleDelete}
okText="Delete"
okButtonProps={{ danger: true, loading: deleting }}
title="Delete Toolset"
>
<p>Are you sure you want to delete this toolset? Keys and teams using it will lose access to the scoped tools.</p>
</Modal>
</div>
);
}

View file

@ -102,7 +102,7 @@ describe("MCPServers", () => {
vi.mocked(networking.fetchMCPServers).mockResolvedValue(mockServers);
const queryClient = createQueryClient();
const { getByText } = render(
const { getByText, getAllByText } = render(
<QueryClientProvider client={queryClient}>
<MCPServers {...defaultProps} />
</QueryClientProvider>,
@ -121,8 +121,8 @@ describe("MCPServers", () => {
// Verify the mocked server data is rendered in the table
expect(getByText("Test Server 1")).toBeInTheDocument();
expect(getByText("Test Server 2")).toBeInTheDocument();
expect(getByText("test-server-1")).toBeInTheDocument();
expect(getByText("test-server-2")).toBeInTheDocument();
expect(getAllByText("test-server-1").length).toBeGreaterThan(0);
expect(getAllByText("test-server-2").length).toBeGreaterThan(0);
// Verify the API was called
// Note: useMCPServers uses useAuthorized() internally, which returns "123" from global mock

View file

@ -9,6 +9,7 @@ import { useMCPServerHealth } from "../../app/(dashboard)/hooks/mcpServers/useMC
import NotificationsManager from "../molecules/notifications_manager";
import { deleteMCPServer } from "../networking";
import { MCPSubmissionsTab } from "./MCPSubmissionsTab";
import { MCPToolsetsTab } from "./MCPToolsetsTab";
import { DataTable } from "../view_logs/table";
import CreateMCPServer from "./create_mcp_server";
import MCPConnect from "./mcp_connect";
@ -348,6 +349,7 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
<TabList className="flex justify-between mt-2 w-full items-center">
<div className="flex">
<Tab>All Servers</Tab>
<Tab>Toolsets</Tab>
<Tab>Connect</Tab>
<Tab>Semantic Filter</Tab>
<Tab>Network Settings</Tab>
@ -426,6 +428,9 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
</div>
)}
</TabPanel>
<TabPanel>
<MCPToolsetsTab accessToken={accessToken} userRole={userRole} />
</TabPanel>
<TabPanel>
<MCPConnect />
</TabPanel>

View file

@ -231,6 +231,20 @@ export interface MCPServerProps {
userID: string | null;
}
export interface MCPToolsetTool {
server_id: string;
tool_name: string;
}
export interface MCPToolset {
toolset_id: string;
toolset_name: string;
description?: string;
tools: MCPToolsetTool[];
created_at?: string;
created_by?: string;
}
// Discoverable MCP server from the curated registry
export interface DiscoverableMCPServer {
name: string;

View file

@ -6675,6 +6675,99 @@ export const deleteMCPServer = async (accessToken: string, serverId: string) =>
}
};
export const fetchMCPToolsets = async (accessToken: string): Promise<any[]> => {
try {
const url = (proxyBaseUrl ? `${proxyBaseUrl}` : "") + `/v1/mcp/toolset`;
const response = await fetch(url, {
method: HTTP_REQUEST.GET,
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
return await response.json();
} catch (error) {
console.error("Failed to fetch MCP toolsets:", error);
throw error;
}
};
export const createMCPToolset = async (accessToken: string, formValues: Record<string, any>) => {
try {
const url = (proxyBaseUrl ? `${proxyBaseUrl}` : "") + `/v1/mcp/toolset`;
const response = await fetch(url, {
method: HTTP_REQUEST.POST,
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(formValues),
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
return await response.json();
} catch (error) {
console.error("Failed to create MCP toolset:", error);
throw error;
}
};
export const updateMCPToolset = async (accessToken: string, formValues: Record<string, any>) => {
try {
const url = (proxyBaseUrl ? `${proxyBaseUrl}` : "") + `/v1/mcp/toolset`;
const response = await fetch(url, {
method: HTTP_REQUEST.PUT,
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(formValues),
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
return await response.json();
} catch (error) {
console.error("Failed to update MCP toolset:", error);
throw error;
}
};
export const deleteMCPToolset = async (accessToken: string, toolsetId: string) => {
try {
const url = (proxyBaseUrl ? `${proxyBaseUrl}` : "") + `/v1/mcp/toolset/${toolsetId}`;
const response = await fetch(url, {
method: HTTP_REQUEST.DELETE,
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
} catch (error) {
console.error("Failed to delete MCP toolset:", error);
throw error;
}
};
export const registerMCPServer = async (accessToken: string, formValues: Record<string, any>) => {
try {
const url = (proxyBaseUrl ? `${proxyBaseUrl}` : "") + `/v1/mcp/server/register`;

View file

@ -9,6 +9,7 @@ interface ObjectPermission {
mcp_servers: string[];
mcp_access_groups?: string[];
mcp_tool_permissions?: Record<string, string[]>;
mcp_toolsets?: string[];
vector_stores: string[];
agents?: string[];
agent_access_groups?: string[];
@ -31,17 +32,19 @@ export function ObjectPermissionsView({
const mcpServers = objectPermission?.mcp_servers || [];
const mcpAccessGroups = objectPermission?.mcp_access_groups || [];
const mcpToolPermissions = objectPermission?.mcp_tool_permissions || {};
const mcpToolsets = objectPermission?.mcp_toolsets || [];
const agents = objectPermission?.agents || [];
const agentAccessGroups = objectPermission?.agent_access_groups || [];
const content = (
<div className={variant === "card" ? "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6" : "space-y-4"}>
<VectorStorePermissions vectorStores={vectorStores} accessToken={accessToken} />
<MCPServerPermissions
mcpServers={mcpServers}
mcpAccessGroups={mcpAccessGroups}
<MCPServerPermissions
mcpServers={mcpServers}
mcpAccessGroups={mcpAccessGroups}
mcpToolPermissions={mcpToolPermissions}
accessToken={accessToken}
mcpToolsets={mcpToolsets}
accessToken={accessToken}
/>
<AgentPermissions
agents={agents}

View file

@ -242,7 +242,7 @@ describe("MCPServerPermissions", () => {
);
// Verify empty state message
expect(screen.getByText("No MCP servers or access groups configured")).toBeInTheDocument();
expect(screen.getByText("No MCP servers, access groups, or toolsets configured")).toBeInTheDocument();
// Verify count badge shows 0
expect(screen.getByText("0")).toBeInTheDocument();

View file

@ -2,25 +2,28 @@ import React, { useState, useEffect } from "react";
import { Text, Badge } from "@tremor/react";
import { ServerIcon, ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/outline";
import { Tooltip } from "antd";
import { fetchMCPServers } from "../networking";
import { MCPServer } from "../mcp_tools/types";
import { fetchMCPServers, fetchMCPToolsets } from "../networking";
import { MCPServer, MCPToolset } from "../mcp_tools/types";
interface MCPServerPermissionsProps {
mcpServers: string[];
mcpAccessGroups?: string[];
mcpToolPermissions?: Record<string, string[]>;
mcpToolsets?: string[];
accessToken?: string | null;
}
export function MCPServerPermissions({
mcpServers,
mcpAccessGroups = [],
export function MCPServerPermissions({
mcpServers,
mcpAccessGroups = [],
mcpToolPermissions = {},
accessToken
mcpToolsets = [],
accessToken
}: MCPServerPermissionsProps) {
const [mcpServerDetails, setMCPServerDetails] = useState<MCPServer[]>([]);
const [accessGroupNames, setAccessGroupNames] = useState<string[]>([]);
const [toolsetDetails, setToolsetDetails] = useState<MCPToolset[]>([]);
const [expandedServers, setExpandedServers] = useState<Set<string>>(new Set());
const [expandedToolsets, setExpandedToolsets] = useState<Set<string>>(new Set());
const toggleServerExpansion = (serverId: string) => {
setExpandedServers((prev) => {
@ -34,6 +37,18 @@ export function MCPServerPermissions({
});
};
const toggleToolsetExpansion = (toolsetId: string) => {
setExpandedToolsets((prev) => {
const newSet = new Set(prev);
if (newSet.has(toolsetId)) {
newSet.delete(toolsetId);
} else {
newSet.add(toolsetId);
}
return newSet;
});
};
// Fetch MCP server details when component mounts
useEffect(() => {
const fetchMCPServerDetails = async () => {
@ -53,20 +68,23 @@ export function MCPServerPermissions({
fetchMCPServerDetails();
}, [accessToken, mcpServers.length]);
// Fetch MCP access group names
// Fetch toolset details
useEffect(() => {
const fetchGroups = async () => {
if (accessToken && mcpAccessGroups.length > 0) {
const fetchToolsets = async () => {
if (accessToken && mcpToolsets.length > 0) {
try {
const groups = await import("../networking").then((m) => m.fetchMCPAccessGroups(accessToken));
setAccessGroupNames(Array.isArray(groups) ? groups : groups.data || []);
const all = await fetchMCPToolsets(accessToken);
const filtered = Array.isArray(all)
? all.filter((t: MCPToolset) => mcpToolsets.includes(t.toolset_id))
: [];
setToolsetDetails(filtered);
} catch (error) {
console.error("Error fetching MCP access groups:", error);
console.error("Error fetching toolsets:", error);
}
}
};
fetchGroups();
}, [accessToken, mcpAccessGroups.length]);
fetchToolsets();
}, [accessToken, mcpToolsets.length]);
// Function to get display name for MCP server
const getMCPServerDisplayName = (serverId: string) => {
@ -78,17 +96,12 @@ export function MCPServerPermissions({
return serverId;
};
// Function to get display name for access group
const getAccessGroupDisplayName = (group: string) => {
return group;
};
// Merge servers and access groups into one list
const mergedItems = [
...mcpServers.map((server) => ({ type: "server", value: server })),
...mcpAccessGroups.map((group) => ({ type: "accessGroup", value: group })),
];
const totalCount = mergedItems.length;
const totalCount = mergedItems.length + mcpToolsets.length;
return (
<div className="space-y-3">
@ -99,21 +112,21 @@ export function MCPServerPermissions({
{totalCount}
</Badge>
</div>
{totalCount > 0 ? (
<div className="max-h-[400px] overflow-y-auto space-y-2 pr-1">
{mergedItems.map((item, index) => {
const toolsForServer = item.type === "server" ? mcpToolPermissions[item.value] : undefined;
const hasToolRestrictions = toolsForServer && toolsForServer.length > 0;
const isExpanded = expandedServers.has(item.value);
return (
<div key={index} className="space-y-2">
<div
<div
onClick={() => hasToolRestrictions && toggleServerExpansion(item.value)}
className={`flex items-center gap-3 py-2 px-3 rounded-lg border border-gray-200 transition-all ${
hasToolRestrictions
? 'cursor-pointer hover:bg-gray-50 hover:border-gray-300'
hasToolRestrictions
? 'cursor-pointer hover:bg-gray-50 hover:border-gray-300'
: 'bg-white'
}`}
>
@ -128,14 +141,14 @@ export function MCPServerPermissions({
) : (
<div className="inline-flex items-center gap-2 min-w-0">
<span className="inline-block w-1.5 h-1.5 bg-green-500 rounded-full flex-shrink-0"></span>
<span className="text-sm font-medium text-gray-900 truncate">{getAccessGroupDisplayName(item.value)}</span>
<span className="text-sm font-medium text-gray-900 truncate">{item.value}</span>
<span className="ml-1 px-1.5 py-0.5 text-[9px] font-semibold text-green-600 bg-green-50 border border-green-200 rounded uppercase tracking-wide flex-shrink-0">
Group
</span>
</div>
)}
</div>
{hasToolRestrictions && (
<div className="flex items-center gap-1 flex-shrink-0 whitespace-nowrap">
<span className="text-xs font-medium text-gray-600">{toolsForServer.length}</span>
@ -148,7 +161,7 @@ export function MCPServerPermissions({
</div>
)}
</div>
{/* Show tool permissions if expanded */}
{hasToolRestrictions && isExpanded && (
<div className="ml-4 pl-4 border-l-2 border-blue-200 pb-1">
@ -167,11 +180,66 @@ export function MCPServerPermissions({
</div>
);
})}
{/* Toolsets section */}
{mcpToolsets.length > 0 && mcpToolsets.map((toolsetId, index) => {
const detail = toolsetDetails.find((t) => t.toolset_id === toolsetId);
const isExpanded = expandedToolsets.has(toolsetId);
const toolCount = detail?.tools.length ?? 0;
return (
<div key={`toolset-${index}`} className="space-y-2">
<div
onClick={() => toolCount > 0 && toggleToolsetExpansion(toolsetId)}
className={`flex items-center gap-3 py-2 px-3 rounded-lg border border-purple-200 transition-all ${
toolCount > 0 ? 'cursor-pointer hover:bg-purple-50 hover:border-purple-300' : 'bg-white'
}`}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<span className="inline-block w-1.5 h-1.5 bg-purple-500 rounded-full flex-shrink-0"></span>
<span className="text-sm font-medium text-gray-900 truncate">
{detail?.toolset_name ?? toolsetId}
</span>
<span className="ml-1 px-1.5 py-0.5 text-[9px] font-semibold text-purple-600 bg-purple-50 border border-purple-200 rounded uppercase tracking-wide flex-shrink-0">
Toolset
</span>
</div>
{toolCount > 0 && (
<div className="flex items-center gap-1 flex-shrink-0 whitespace-nowrap">
<span className="text-xs font-medium text-gray-600">{toolCount}</span>
<span className="text-xs text-gray-500">{toolCount === 1 ? "tool" : "tools"}</span>
{isExpanded ? (
<ChevronDownIcon className="h-3.5 w-3.5 text-gray-400 ml-0.5" />
) : (
<ChevronRightIcon className="h-3.5 w-3.5 text-gray-400 ml-0.5" />
)}
</div>
)}
</div>
{toolCount > 0 && isExpanded && detail && (
<div className="ml-4 pl-4 border-l-2 border-purple-200 pb-1">
<div className="flex flex-wrap gap-1.5">
{detail.tools.map((tool, toolIndex) => (
<span
key={toolIndex}
className="inline-flex items-center px-2.5 py-1 rounded-lg bg-purple-50 border border-purple-200 text-purple-800 text-xs font-medium"
>
<span className="text-purple-400 mr-1 text-[10px]">{tool.server_id.slice(0, 6)}</span>
{tool.tool_name}
</span>
))}
</div>
</div>
)}
</div>
);
})}
</div>
) : (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-gray-50 border border-gray-200">
<ServerIcon className="h-4 w-4 text-gray-400" />
<Text className="text-gray-500 text-sm">No MCP servers or access groups configured</Text>
<Text className="text-gray-500 text-sm">No MCP servers, access groups, or toolsets configured</Text>
</div>
)}
</div>

View file

@ -34,7 +34,8 @@ import MCPToolArgumentsForm, { MCPToolArgumentsFormRef } from "../../mcp_tools/M
import { MCPServer } from "../../mcp_tools/types";
import { ByokCredentialModal } from "../../mcp_tools/ByokCredentialModal";
import NotificationsManager from "../../molecules/notifications_manager";
import { callMCPTool, fetchMCPServers, listMCPTools } from "../../networking";
import { callMCPTool, fetchMCPServers, fetchMCPToolsets, listMCPTools } from "../../networking";
import { MCPToolset } from "../../mcp_tools/types";
import TagSelector from "../../tag_management/TagSelector";
import VectorStoreSelector from "../../vector_store_management/VectorStoreSelector";
import { makeA2ASendMessageRequest } from "../llm_calls/a2a_send_message";
@ -111,6 +112,8 @@ const ChatUI: React.FC<ChatUIProps> = ({
fixedModel,
}) => {
const [mcpServers, setMCPServers] = useState<MCPServer[]>([]);
const [mcpToolsets, setMCPToolsets] = useState<MCPToolset[]>([]);
const [isToolsetsInfoModalVisible, setIsToolsetsInfoModalVisible] = useState(false);
const [byokModalServer, setByokModalServer] = useState<MCPServer | null>(null);
const [selectedMCPServers, setSelectedMCPServers] = useState<string[]>(() => {
const saved = sessionStorage.getItem("selectedMCPServers");
@ -257,15 +260,19 @@ const ChatUI: React.FC<ChatUIProps> = ({
const chatEndRef = useRef<HTMLDivElement>(null);
// Fetch MCP servers
// Fetch MCP servers and toolsets
const loadMCPServers = async () => {
const userApiKey = apiKeySource === "session" ? accessToken : apiKey;
if (!userApiKey) return;
setIsLoadingMCPServers(true);
try {
const servers = await fetchMCPServers(userApiKey);
const [servers, toolsets] = await Promise.all([
fetchMCPServers(userApiKey),
fetchMCPToolsets(userApiKey).catch(() => []),
]);
setMCPServers(Array.isArray(servers) ? servers : servers.data || []);
setMCPToolsets(Array.isArray(toolsets) ? toolsets : []);
} catch (error) {
console.error("Error fetching MCP servers:", error);
} finally {
@ -416,17 +423,29 @@ const ChatUI: React.FC<ChatUIProps> = ({
loadMCPServers();
}, [accessToken, userID, userRole, apiKeySource, apiKey, token, simplified]);
// Load tools when MCP direct mode has a server selected
// Load tools when MCP direct mode has a server (or toolset) selected
useEffect(() => {
if (
endpointType === EndpointType.MCP &&
selectedMCPServers.length === 1 &&
selectedMCPServers[0] !== "__all__" &&
!serverToolsMap[selectedMCPServers[0]]
selectedMCPServers[0] !== "__all__"
) {
loadServerTools(selectedMCPServers[0]);
const selected = selectedMCPServers[0];
if (selected.startsWith("toolset:")) {
// For a toolset, load tools for each server in it
const toolsetId = selected.slice("toolset:".length);
const toolset = mcpToolsets.find((t) => t.toolset_id === toolsetId);
if (toolset) {
const uniqueServerIds = [...new Set(toolset.tools.map((t) => t.server_id))];
uniqueServerIds.forEach((sid) => {
if (!serverToolsMap[sid]) loadServerTools(sid);
});
}
} else if (!serverToolsMap[selected]) {
loadServerTools(selected);
}
}
}, [endpointType, selectedMCPServers, serverToolsMap]);
}, [endpointType, selectedMCPServers, serverToolsMap, mcpToolsets]);
// Fetch agents when A2A endpoint is selected
useEffect(() => {
@ -572,19 +591,34 @@ const ChatUI: React.FC<ChatUIProps> = ({
// For MCP direct mode, require server and tool selection, and get form values early
let mcpToolArguments: Record<string, any> = {};
if (endpointType === EndpointType.MCP) {
const mcpServerId =
const rawSelected =
selectedMCPServers.length === 1 && selectedMCPServers[0] !== "__all__"
? selectedMCPServers[0]
: null;
if (!mcpServerId) {
if (!rawSelected) {
NotificationsManager.fromBackend("Please select an MCP server to test");
return;
}
// Resolve the real server ID (toolsets use toolset: prefix)
const mcpServerId = rawSelected.startsWith("toolset:") ? rawSelected : rawSelected;
if (!selectedMCPDirectTool) {
NotificationsManager.fromBackend("Please select an MCP tool to call");
return;
}
const mcpTool = (serverToolsMap[selectedMCPServers[0]] || []).find(
// For toolsets, find the tool in the servers that back this toolset
const toolsetForSelected = rawSelected.startsWith("toolset:")
? mcpToolsets.find((t) => t.toolset_id === rawSelected.slice("toolset:".length))
: null;
let searchPool: any[] = [];
if (toolsetForSelected) {
const uniqueServerIds = [...new Set(toolsetForSelected.tools.map((t) => t.server_id))];
uniqueServerIds.forEach((sid) => {
searchPool = searchPool.concat(serverToolsMap[sid] || []);
});
} else {
searchPool = serverToolsMap[rawSelected] || [];
}
const mcpTool = searchPool.find(
(t: any) => t.name === selectedMCPDirectTool,
);
if (!mcpTool) {
@ -742,6 +776,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
mcpServerToolRestrictions,
handleMCPEvent,
mockTestFallbacks,
mcpToolsets,
);
} else if (endpointType === EndpointType.IMAGE) {
// For image generation
@ -822,6 +857,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
customProxyBaseUrl || undefined,
mcpServers,
mcpServerToolRestrictions,
mcpToolsets,
);
} else if (endpointType === EndpointType.ANTHROPIC_MESSAGES) {
const apiChatHistory = [
@ -879,14 +915,22 @@ const ChatUI: React.FC<ChatUIProps> = ({
// Handle MCP direct tool calls (no chat completions)
if (endpointType === EndpointType.MCP) {
const mcpServerId =
const rawSelected =
selectedMCPServers.length === 1 && selectedMCPServers[0] !== "__all__"
? selectedMCPServers[0]
: null;
if (mcpServerId && selectedMCPDirectTool) {
// For toolsets, resolve the real server_id from the toolset's tool list
let resolvedServerId = rawSelected;
if (rawSelected?.startsWith("toolset:")) {
const toolsetId = rawSelected.slice("toolset:".length);
const toolset = mcpToolsets.find((t) => t.toolset_id === toolsetId);
const toolEntry = toolset?.tools.find((t) => t.tool_name === selectedMCPDirectTool);
resolvedServerId = toolEntry?.server_id ?? rawSelected;
}
if (resolvedServerId && !resolvedServerId.startsWith("toolset:") && selectedMCPDirectTool) {
const result = await callMCPTool(
effectiveApiKey,
mcpServerId,
resolvedServerId,
selectedMCPDirectTool,
mcpToolArguments,
selectedGuardrails.length > 0 ? { guardrails: selectedGuardrails } : undefined,
@ -1301,11 +1345,14 @@ const ChatUI: React.FC<ChatUIProps> = ({
className="ml-1"
title={
endpointType === EndpointType.MCP
? "Select an MCP server to test tools directly."
: "Select MCP servers to use in your conversation."
? "Select an MCP server or toolset to test tools directly."
: "Select MCP servers or toolsets to use in your conversation."
}
>
<InfoCircleOutlined />
<InfoCircleOutlined
className="cursor-pointer"
onClick={() => setIsToolsetsInfoModalVisible(true)}
/>
</Tooltip>
</Text>
<Select
@ -1361,7 +1408,18 @@ const ChatUI: React.FC<ChatUIProps> = ({
if (option?.value === "__all__") {
return "All MCP Servers".toLowerCase().includes(input.toLowerCase());
}
const server = mcpServers.find((s) => s.server_id === option?.value);
const val = option?.value as string | undefined;
if (val?.startsWith("toolset:")) {
const toolsetId = val.slice("toolset:".length);
const toolset = mcpToolsets.find((t) => t.toolset_id === toolsetId);
if (!toolset) return false;
return [toolset.toolset_name, toolset.description]
.filter(Boolean)
.join(" ")
.toLowerCase()
.includes(input.toLowerCase());
}
const server = mcpServers.find((s) => s.server_id === val);
if (!server) return false;
const searchText = [
server.server_name,
@ -1385,44 +1443,99 @@ const ChatUI: React.FC<ChatUIProps> = ({
</Select.Option>
)}
{/* Toolsets (purple badge) */}
{mcpToolsets.length > 0 && (
<Select.OptGroup label="Toolsets">
{mcpToolsets.map((toolset) => (
<Select.Option
key={`toolset:${toolset.toolset_id}`}
value={`toolset:${toolset.toolset_id}`}
label={toolset.toolset_name}
disabled={
endpointType === EndpointType.MCP ? false : selectedMCPServers.includes("__all__")
}
>
<div className="flex flex-col py-1">
<div className="flex items-center gap-1">
<span className="font-medium">{toolset.toolset_name}</span>
<span
className="text-xs px-1 rounded"
style={{ background: "#ede9fe", color: "#7c3aed" }}
>
Toolset
</span>
<span className="text-xs text-gray-500">
({toolset.tools.length} tools)
</span>
</div>
{toolset.description && (
<span className="text-xs text-gray-500 mt-1">{toolset.description}</span>
)}
</div>
</Select.Option>
))}
</Select.OptGroup>
)}
{/* Individual servers */}
{mcpServers.map((server) => (
<Select.Option
key={server.server_id}
value={server.server_id}
label={server.alias || server.server_name || server.server_id}
disabled={
endpointType === EndpointType.MCP ? false : selectedMCPServers.includes("__all__")
}
>
<div className="flex flex-col py-1">
<span className="font-medium">{server.alias || server.server_name || server.server_id}</span>
{server.description && <span className="text-xs text-gray-500 mt-1">{server.description}</span>}
</div>
</Select.Option>
))}
{mcpServers.length > 0 && (
<Select.OptGroup label="Servers">
{mcpServers.map((server) => (
<Select.Option
key={server.server_id}
value={server.server_id}
label={server.alias || server.server_name || server.server_id}
disabled={
endpointType === EndpointType.MCP ? false : selectedMCPServers.includes("__all__")
}
>
<div className="flex flex-col py-1">
<span className="font-medium">{server.alias || server.server_name || server.server_id}</span>
{server.description && <span className="text-xs text-gray-500 mt-1">{server.description}</span>}
</div>
</Select.Option>
))}
</Select.OptGroup>
)}
</Select>
{/* MCP Tool selector - only for MCP direct mode */}
{endpointType === EndpointType.MCP &&
selectedMCPServers.length === 1 &&
selectedMCPServers[0] !== "__all__" && (
<div className="mt-3">
<Text className="text-xs text-gray-600 mb-1 block">Select Tool</Text>
<Select
style={{ width: "100%" }}
placeholder="Select a tool to call"
value={selectedMCPDirectTool}
onChange={(value) => setSelectedMCPDirectTool(value)}
options={(serverToolsMap[selectedMCPServers[0]] || []).map((tool: any) => ({
value: tool.name,
label: tool.name,
}))}
allowClear
className="rounded-md"
/>
</div>
)}
selectedMCPServers[0] !== "__all__" && (() => {
const rawSel = selectedMCPServers[0];
const isToolset = rawSel.startsWith("toolset:");
let toolOptions: { value: string; label: string }[] = [];
if (isToolset) {
const toolsetId = rawSel.slice("toolset:".length);
const toolset = mcpToolsets.find((t) => t.toolset_id === toolsetId);
if (toolset) {
toolOptions = toolset.tools.map((t) => ({
value: t.tool_name,
label: t.tool_name,
}));
}
} else {
toolOptions = (serverToolsMap[rawSel] || []).map((tool: any) => ({
value: tool.name,
label: tool.name,
}));
}
return (
<div className="mt-3">
<Text className="text-xs text-gray-600 mb-1 block">Select Tool</Text>
<Select
style={{ width: "100%" }}
placeholder="Select a tool to call"
value={selectedMCPDirectTool}
onChange={(value) => setSelectedMCPDirectTool(value)}
options={toolOptions}
allowClear
className="rounded-md"
/>
</div>
);
})()}
{/* Tool restrictions UI (optional) - hidden for MCP direct mode */}
{selectedMCPServers.length > 0 &&
@ -1924,7 +2037,21 @@ const ChatUI: React.FC<ChatUIProps> = ({
selectedMCPDirectTool ? (
<div className="flex-1 overflow-y-auto max-h-48 min-h-[44px] p-2 border border-gray-200 rounded-lg bg-gray-50/50">
{(() => {
const mcpTool = (serverToolsMap[selectedMCPServers[0]] || []).find(
const rawSel = selectedMCPServers[0];
let toolPool: any[] = [];
if (rawSel.startsWith("toolset:")) {
const toolsetId = rawSel.slice("toolset:".length);
const toolset = mcpToolsets.find((t) => t.toolset_id === toolsetId);
if (toolset) {
const uniqueServerIds = [...new Set(toolset.tools.map((t) => t.server_id))];
uniqueServerIds.forEach((sid) => {
toolPool = toolPool.concat(serverToolsMap[sid] || []);
});
}
} else {
toolPool = serverToolsMap[rawSel] || [];
}
const mcpTool = toolPool.find(
(t: any) => t.name === selectedMCPDirectTool,
);
return mcpTool ? (
@ -2070,6 +2197,47 @@ const ChatUI: React.FC<ChatUIProps> = ({
accessToken={accessToken || ""}
/>
)}
{/* Toolsets info modal */}
<Modal
title="How Toolsets Work"
open={isToolsetsInfoModalVisible}
onCancel={() => setIsToolsetsInfoModalVisible(false)}
footer={[
<Button key="close" onClick={() => setIsToolsetsInfoModalVisible(false)}>
Close
</Button>,
]}
width={600}
>
<div className="space-y-4 py-2">
<p className="text-gray-700">
<strong>Toolsets</strong> are named collections of specific tools from one or more MCP servers.
Instead of exposing all tools from a server, a toolset gives an agent exactly the tools it needs.
</p>
<div>
<h4 className="font-semibold text-gray-800 mb-2">How to use a toolset:</h4>
<ol className="list-decimal list-inside space-y-2 text-gray-700">
<li>Select a <span style={{ color: "#7c3aed", fontWeight: 600 }}>Toolset</span> (purple badge) from the MCP Servers dropdown.</li>
<li>The tool picker will show only the tools included in that toolset.</li>
<li>Select a tool and fill in its parameters, then send.</li>
<li>The tool call is routed to the correct underlying MCP server automatically.</li>
</ol>
</div>
<div className="bg-purple-50 border border-purple-200 rounded p-3">
<p className="text-sm text-purple-800">
<strong>Example:</strong> A &quot;GitHub Read-only&quot; toolset might include only <code>list_repos</code> and <code>get_file</code> from a GitHub MCP server preventing agents from making writes.
</p>
</div>
<div>
<h4 className="font-semibold text-gray-800 mb-1">Creating toolsets:</h4>
<p className="text-sm text-gray-600">
Admins can create and manage toolsets from the <strong>MCP</strong> page <strong>Toolsets</strong> tab.
Toolsets can then be assigned to keys and teams to scope their tool access.
</p>
</div>
</div>
</Modal>
</div>
);
};

View file

@ -3,7 +3,7 @@ import { ChatCompletionMessageParam } from "openai/resources/chat/completions";
import { TokenUsage } from "../chat_ui/ResponseMetrics";
import { VectorStoreSearchResponse } from "../chat_ui/types";
import { getProxyBaseUrl } from "@/components/networking";
import { MCPServer, type MCPEvent } from "../../mcp_tools/types";
import { MCPServer, MCPToolset, type MCPEvent } from "../../mcp_tools/types";
export async function makeOpenAIChatCompletionRequest(
chatHistory: { role: string; content: string | any[] }[],
@ -30,6 +30,7 @@ export async function makeOpenAIChatCompletionRequest(
mcpServerToolRestrictions?: Record<string, string[]>,
onMCPEvent?: (event: MCPEvent) => void,
mockTestFallbacks?: boolean,
mcpToolsets?: MCPToolset[],
) {
// base url should be the current base_url
const isLocal = process.env.NODE_ENV === "development";
@ -82,19 +83,31 @@ export async function makeOpenAIChatCompletionRequest(
require_approval: "never",
});
} else {
// Individual servers selected - create one entry per server
// Individual servers/toolsets selected - create one entry per item
selectedMCPServers.forEach((serverId) => {
const server = mcpServers?.find((s) => s.server_id === serverId);
const serverName = server?.alias || server?.server_name || serverId;
const allowedTools = mcpServerToolRestrictions?.[serverId] || [];
if (serverId.startsWith("toolset:")) {
const toolsetId = serverId.slice("toolset:".length);
const toolset = mcpToolsets?.find((t) => t.toolset_id === toolsetId);
const toolsetName = toolset?.toolset_name || toolsetId;
tools.push({
type: "mcp",
server_label: toolsetName,
server_url: `litellm_proxy/mcp/${encodeURIComponent(toolsetName)}`,
require_approval: "never",
});
} else {
const server = mcpServers?.find((s) => s.server_id === serverId);
const serverName = server?.alias || server?.server_name || serverId;
const allowedTools = mcpServerToolRestrictions?.[serverId] || [];
tools.push({
type: "mcp",
server_label: "litellm",
server_url: `litellm_proxy/mcp/${serverName}`,
require_approval: "never",
...(allowedTools.length > 0 ? { allowed_tools: allowedTools } : {}),
});
tools.push({
type: "mcp",
server_label: "litellm",
server_url: `litellm_proxy/mcp/${serverName}`,
require_approval: "never",
...(allowedTools.length > 0 ? { allowed_tools: allowedTools } : {}),
});
}
});
}
}

View file

@ -4,7 +4,7 @@ import { TokenUsage } from "../chat_ui/ResponseMetrics";
import { getProxyBaseUrl } from "@/components/networking";
import NotificationManager from "@/components/molecules/notifications_manager";
import type { MCPEvent } from "../../mcp_tools/types";
import { MCPServer } from "../../mcp_tools/types";
import { MCPServer, MCPToolset } from "../../mcp_tools/types";
import {
CodeInterpreterResult,
CodeInterpreterState,
@ -37,6 +37,7 @@ export async function makeOpenAIResponsesRequest(
customBaseUrl?: string,
mcpServers?: MCPServer[],
mcpServerToolRestrictions?: Record<string, string[]>,
mcpToolsets?: MCPToolset[],
) {
if (!accessToken) {
throw new Error("Virtual Key is required");
@ -102,21 +103,34 @@ export async function makeOpenAIResponsesRequest(
require_approval: "never",
});
} else {
// Individual servers selected - create one entry per server
// Individual servers/toolsets selected - create one entry per item
selectedMCPServers.forEach((serverId) => {
const server = mcpServers?.find((s) => s.server_id === serverId);
// Use server_name for both routing and labelling. server_name is the
// unique registered identifier; aliases can collide across servers.
const routeName = server?.server_name || serverId;
const allowedTools = mcpServerToolRestrictions?.[serverId] || [];
if (serverId.startsWith("toolset:")) {
// Toolset: same /{name}/mcp pattern as individual servers
const toolsetId = serverId.slice("toolset:".length);
const toolset = mcpToolsets?.find((t) => t.toolset_id === toolsetId);
const toolsetName = toolset?.toolset_name || toolsetId;
tools.push({
type: "mcp",
server_label: toolsetName,
server_url: `${proxyBaseUrl}/mcp/${encodeURIComponent(toolsetName)}`,
require_approval: "never",
});
} else {
const server = mcpServers?.find((s) => s.server_id === serverId);
// Use server_name for both routing and labelling. server_name is the
// unique registered identifier; aliases can collide across servers.
const routeName = server?.server_name || serverId;
const allowedTools = mcpServerToolRestrictions?.[serverId] || [];
tools.push({
type: "mcp",
server_label: routeName, // unique per request — collisions cause silent tool-routing failures
server_url: `${proxyBaseUrl}/mcp/${encodeURIComponent(routeName)}`,
require_approval: "never",
...(allowedTools.length > 0 ? { allowed_tools: allowedTools } : {}),
});
tools.push({
type: "mcp",
server_label: routeName, // unique per request — collisions cause silent tool-routing failures
server_url: `${proxyBaseUrl}/mcp/${encodeURIComponent(routeName)}`,
require_approval: "never",
...(allowedTools.length > 0 ? { allowed_tools: allowedTools } : {}),
});
}
});
}
}

View file

@ -105,6 +105,7 @@ export interface TeamData {
mcp_servers: string[];
mcp_access_groups?: string[];
mcp_tool_permissions?: Record<string, string[]>;
mcp_toolsets?: string[];
vector_stores: string[];
agents?: string[];
agent_access_groups?: string[];
@ -516,9 +517,10 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
}
// Handle object_permission updates
const { servers, accessGroups } = values.mcp_servers_and_groups || {
const { servers, accessGroups, toolsets } = values.mcp_servers_and_groups || {
servers: [],
accessGroups: [],
toolsets: [],
};
const serverIds = new Set(servers || []);
const mcpToolPermissions = Object.fromEntries(
@ -535,6 +537,9 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
if (mcpToolPermissions) {
updateData.object_permission.mcp_tool_permissions = mcpToolPermissions;
}
if (toolsets) {
updateData.object_permission.mcp_toolsets = toolsets;
}
delete values.mcp_servers_and_groups;
delete values.mcp_tool_permissions;
@ -838,6 +843,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
mcp_servers_and_groups: {
servers: info.object_permission?.mcp_servers || [],
accessGroups: info.object_permission?.mcp_access_groups || [],
toolsets: info.object_permission?.mcp_toolsets || [],
},
mcp_tool_permissions: info.object_permission?.mcp_tool_permissions || {},
agents_and_groups: {

View file

@ -160,11 +160,12 @@ export default function KeyInfoView({
}
if (formValues.mcp_servers_and_groups !== undefined) {
const { servers, accessGroups } = formValues.mcp_servers_and_groups || { servers: [], accessGroups: [] };
const { servers, accessGroups, toolsets } = formValues.mcp_servers_and_groups || { servers: [], accessGroups: [], toolsets: [] };
formValues.object_permission = {
...currentKeyData.object_permission,
mcp_servers: servers || [],
mcp_access_groups: accessGroups || [],
mcp_toolsets: toolsets || [],
};
// Remove mcp_servers_and_groups from the top level as it should be in object_permission
delete formValues.mcp_servers_and_groups;