Merge pull request #15346 from BerriAI/litellm_dev_10_08_2025_p2

MCP - specify allowed params per tool
This commit is contained in:
Krish Dholakia 2025-10-08 19:27:55 -07:00 committed by GitHub
commit 753ce49cdd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 250 additions and 43 deletions

View file

@ -441,8 +441,8 @@ paths:
type: object
```
## MCP Tool Filtering
## Allow/Disallow MCP Tools
Control which tools are available from your MCP servers. You can either allow only specific tools or block dangerous ones.
<Tabs>
@ -501,6 +501,119 @@ mcp_servers:
- If you specify both `allowed_tools` and `disallowed_tools`, the allowed list takes priority
- Tool names are case-sensitive
---
## Allow/Disallow MCP Tool Parameters
Control which parameters are allowed for specific MCP tools using the `allowed_params` configuration. This provides fine-grained control over tool usage by restricting the parameters that can be passed to each tool.
### Configuration
`allowed_params` is a dictionary that maps tool names to lists of allowed parameter names. When configured, only the specified parameters will be accepted for that tool - any other parameters will be rejected with a 403 error.
```yaml title="config.yaml with allowed_params" showLineNumbers
mcp_servers:
deepwiki_mcp:
url: https://mcp.deepwiki.com/mcp
transport: "http"
auth_type: "none"
allowed_params:
# Tool name: list of allowed parameters
read_wiki_contents: ["status"]
my_api_mcp:
url: "https://my-api-server.com"
auth_type: "api_key"
auth_value: "my-key"
allowed_params:
# Using unprefixed tool name
getpetbyid: ["status"]
# Using prefixed tool name (both formats work)
my_api_mcp-findpetsbystatus: ["status", "limit"]
# Another tool with multiple allowed params
create_issue: ["title", "body", "labels"]
```
### How It Works
1. **Tool-specific filtering**: Each tool can have its own list of allowed parameters
2. **Flexible naming**: Tool names can be specified with or without the server prefix (e.g., both `"getpetbyid"` and `"my_api_mcp-getpetbyid"` work)
3. **Whitelist approach**: Only parameters in the allowed list are permitted
4. **Unlisted tools**: If `allowed_params` is not set, all parameters are allowed
5. **Error handling**: Requests with disallowed parameters receive a 403 error with details about which parameters are allowed
### Example Request Behavior
With the configuration above, here's how requests would be handled:
**✅ Allowed Request:**
```json
{
"tool": "read_wiki_contents",
"arguments": {
"status": "active"
}
}
```
**❌ Rejected Request:**
```json
{
"tool": "read_wiki_contents",
"arguments": {
"status": "active",
"limit": 10 // This parameter is not allowed
}
}
```
**Error Response:**
```json
{
"error": "Parameters ['limit'] are not allowed for tool read_wiki_contents. Allowed parameters: ['status']. Contact proxy admin to allow these parameters."
}
```
### Use Cases
- **Security**: Prevent users from accessing sensitive parameters or dangerous operations
- **Cost control**: Restrict expensive parameters (e.g., limiting result counts)
- **Compliance**: Enforce parameter usage policies for regulatory requirements
- **Staged rollouts**: Gradually enable parameters as tools are tested
- **Multi-tenant isolation**: Different parameter access for different user groups
### Combining with Tool Filtering
`allowed_params` works alongside `allowed_tools` and `disallowed_tools` for complete control:
```yaml title="Combined filtering example" showLineNumbers
mcp_servers:
github_mcp:
url: "https://api.githubcopilot.com/mcp"
auth_type: oauth2
authorization_url: https://github.com/login/oauth/authorize
token_url: https://github.com/login/oauth/access_token
client_id: os.environ/GITHUB_OAUTH_CLIENT_ID
client_secret: os.environ/GITHUB_OAUTH_CLIENT_SECRET
scopes: ["public_repo", "user:email"]
# Only allow specific tools
allowed_tools: ["create_issue", "list_issues", "search_issues"]
# Block dangerous operations
disallowed_tools: ["delete_repo"]
# Restrict parameters per tool
allowed_params:
create_issue: ["title", "body", "labels"]
list_issues: ["state", "sort", "perPage"]
search_issues: ["query", "sort", "order", "perPage"]
```
This configuration ensures that:
1. Only the three listed tools are available
2. The `delete_repo` tool is explicitly blocked
3. Each tool can only use its specified parameters
---
## MCP Server Access Control
LiteLLM Proxy provides two methods for controlling access to specific MCP servers:

View file

@ -109,10 +109,21 @@ class MCPRequestHandler:
request.body = mock_body # type: ignore
if ".well-known" in str(request.url): # public routes
validated_user_api_key_auth = UserAPIKeyAuth()
# elif litellm_api_key == "":
# from fastapi import HTTPException
# raise HTTPException(
# status_code=401,
# detail="LiteLLM API key is missing. Please add it or use OAuth authentication.",
# headers={
# "WWW-Authenticate": f'Bearer resource_metadata=f"{request.base_url}/.well-known/oauth-protected-resource"',
# },
# )
else:
validated_user_api_key_auth = await user_api_key_auth(
api_key=litellm_api_key, request=request
)
return (
validated_user_api_key_auth,
mcp_auth_header,
@ -344,14 +355,14 @@ class MCPRequestHandler:
proxy_logging_obj,
user_api_key_cache,
)
if not user_api_key_auth:
return None
# Already loaded
if user_api_key_auth.object_permission:
return user_api_key_auth.object_permission
# Need to fetch from DB
if user_api_key_auth.object_permission_id and prisma_client:
return await get_object_permission(
@ -361,7 +372,7 @@ class MCPRequestHandler:
parent_otel_span=user_api_key_auth.parent_otel_span,
proxy_logging_obj=proxy_logging_obj,
)
return None
@staticmethod
@ -369,16 +380,19 @@ class MCPRequestHandler:
user_api_key_auth: Optional[UserAPIKeyAuth] = None,
):
"""Helper to get team object_permission from cache or DB."""
from litellm.proxy.auth.auth_checks import get_object_permission, get_team_object
from litellm.proxy.auth.auth_checks import (
get_object_permission,
get_team_object,
)
from litellm.proxy.proxy_server import (
prisma_client,
proxy_logging_obj,
user_api_key_cache,
)
if not user_api_key_auth or not user_api_key_auth.team_id or not prisma_client:
return None
# First get the team object (which may have object_permission already loaded)
team_obj: Optional[LiteLLM_TeamTable] = await get_team_object(
team_id=user_api_key_auth.team_id,
@ -387,14 +401,14 @@ class MCPRequestHandler:
parent_otel_span=user_api_key_auth.parent_otel_span,
proxy_logging_obj=proxy_logging_obj,
)
if not team_obj:
return None
# Already loaded
if team_obj.object_permission:
return team_obj.object_permission
# Need to fetch from DB using object_permission_id
if team_obj.object_permission_id:
return await get_object_permission(
@ -404,7 +418,7 @@ class MCPRequestHandler:
parent_otel_span=user_api_key_auth.parent_otel_span,
proxy_logging_obj=proxy_logging_obj,
)
return None
@staticmethod
@ -415,26 +429,38 @@ class MCPRequestHandler:
"""
Get list of allowed tool names for a specific server based on key/team permissions.
Follows same inheritance logic as get_allowed_mcp_servers.
Args:
server_id: Server ID to check permissions for
user_api_key_auth: User auth
Returns:
List[str] if restrictions exist, None if no restrictions (allow all)
"""
if not user_api_key_auth:
return None
try:
# Get key and team object permissions
key_obj_perm = await MCPRequestHandler._get_key_object_permission(user_api_key_auth)
team_obj_perm = await MCPRequestHandler._get_team_object_permission(user_api_key_auth)
key_obj_perm = await MCPRequestHandler._get_key_object_permission(
user_api_key_auth
)
team_obj_perm = await MCPRequestHandler._get_team_object_permission(
user_api_key_auth
)
# Extract tool permissions for this server
key_tools = key_obj_perm.mcp_tool_permissions.get(server_id) if key_obj_perm and key_obj_perm.mcp_tool_permissions else None
team_tools = team_obj_perm.mcp_tool_permissions.get(server_id) if team_obj_perm and team_obj_perm.mcp_tool_permissions else None
key_tools = (
key_obj_perm.mcp_tool_permissions.get(server_id)
if key_obj_perm and key_obj_perm.mcp_tool_permissions
else None
)
team_tools = (
team_obj_perm.mcp_tool_permissions.get(server_id)
if team_obj_perm and team_obj_perm.mcp_tool_permissions
else None
)
# Apply same inheritance logic as get_allowed_mcp_servers
if team_tools:
if key_tools:
@ -446,7 +472,7 @@ class MCPRequestHandler:
else:
# No team restrictions → use key restrictions
return key_tools
except Exception as e:
verbose_logger.warning(f"Failed to get allowed tools for server: {str(e)}")
return None
@ -459,12 +485,12 @@ class MCPRequestHandler:
) -> bool:
"""
Check if a specific tool is allowed for a server based on key/team permissions.
Args:
tool_name: Name of the tool to check
server_id: Server ID
user_api_key_auth: User auth
Returns:
True if allowed, False if blocked
"""
@ -472,15 +498,15 @@ class MCPRequestHandler:
server_id=server_id,
user_api_key_auth=user_api_key_auth,
)
# None means no restrictions (allow all)
if allowed_tools is None:
return True
# Empty list means no tools allowed
if not allowed_tools:
return False
# Check if tool is in allowed list
return tool_name in allowed_tools
@ -555,7 +581,7 @@ class MCPRequestHandler:
) -> List[str]:
"""
Get allowed MCP servers for a team.
Uses the helper _get_team_object_permission which:
1. First checks if object_permission is already loaded on the team
2. If not, fetches from DB using object_permission_id if it exists
@ -571,7 +597,7 @@ class MCPRequestHandler:
object_permissions = await MCPRequestHandler._get_team_object_permission(
user_api_key_auth
)
if object_permissions is None:
return []

View file

@ -216,6 +216,7 @@ class MCPServerManager:
extra_headers=server_config.get("extra_headers", None),
allowed_tools=server_config.get("allowed_tools", None),
disallowed_tools=server_config.get("disallowed_tools", None),
allowed_params=server_config.get("allowed_params", None),
access_groups=server_config.get("access_groups", None),
)
self.config_mcp_servers[server_id] = new_server
@ -771,6 +772,58 @@ class MCPServerManager:
)
return True
def validate_allowed_params(
self, tool_name: str, arguments: Dict[str, Any], server: MCPServer
) -> None:
"""
Filter arguments to only include allowed parameters for the given tool.
Args:
tool_name: Name of the tool (with or without prefix)
arguments: Dictionary of arguments to filter
server: MCPServer configuration
Returns:
Filtered dictionary containing only allowed parameters
Raises:
HTTPException: If allowed_params is configured for this tool but arguments contain disallowed params
"""
from litellm.proxy._experimental.mcp_server.utils import (
get_server_name_prefix_tool_mcp,
)
# If no allowed_params configured, return all arguments
if not server.allowed_params:
return
# Get the unprefixed tool name to match against config
unprefixed_tool_name, _ = get_server_name_prefix_tool_mcp(tool_name)
# Check both prefixed and unprefixed tool names
allowed_params_list = server.allowed_params.get(
tool_name
) or server.allowed_params.get(unprefixed_tool_name)
# If this tool doesn't have allowed_params specified, allow all params
if allowed_params_list is None:
return None
# Filter arguments to only include allowed parameters
disallowed_params = [
param for param in arguments.keys() if param not in allowed_params_list
]
if disallowed_params:
raise HTTPException(
status_code=403,
detail={
"error": f"Parameters {disallowed_params} are not allowed for tool {tool_name}. "
f"Allowed parameters: {allowed_params_list}. "
f"Contact proxy admin to allow these parameters."
},
)
async def check_tool_permission_for_key_team(
self,
tool_name: str,
@ -896,6 +949,13 @@ class MCPServerManager:
user_api_key_auth=user_api_key_auth,
)
## filter parameters based on allowed_params configuration
self.validate_allowed_params(
tool_name=name,
arguments=arguments,
server=server,
)
pre_hook_kwargs = {
"name": name,
"arguments": arguments,

View file

@ -902,6 +902,7 @@ if MCP_AVAILABLE:
await session_manager.handle_request(scope, receive, send)
except Exception as e:
raise e
verbose_logger.exception(f"Error handling MCP request: {e}")
# Instead of re-raising, try to send a graceful error response
try:

View file

@ -1883,7 +1883,9 @@ class ProxyConfig:
elif key == "priority_reservation_settings":
from litellm.types.utils import PriorityReservationSettings
litellm.priority_reservation_settings = PriorityReservationSettings(**value)
litellm.priority_reservation_settings = PriorityReservationSettings(
**value
)
elif key == "callbacks":
initialize_callbacks_on_proxy(
value=value,
@ -2966,32 +2968,32 @@ class ProxyConfig:
) -> bool:
"""
Check if an object type should be loaded from the database based on general_settings.supported_db_objects.
Args:
object_type: Type of object to check (e.g., SupportedDBObjectType.MODELS, "models", etc.)
Returns:
True if the object should be loaded, False otherwise
"""
global general_settings
# Get the supported_db_objects configuration
supported_db_objects = general_settings.get("supported_db_objects", None)
# If supported_db_objects is not set, load all objects (default behavior)
if supported_db_objects is None:
return True
# If supported_db_objects is set, only load specified objects
if not isinstance(supported_db_objects, list):
verbose_proxy_logger.warning(
f"supported_db_objects is not a list, got {type(supported_db_objects)}. Loading all objects."
)
return True
# Convert object_type to string for comparison (handles both str and enum)
object_type_str = str(object_type)
# Check if the object type is in the list (supports both str and enum values)
return any(str(obj) == object_type_str for obj in supported_db_objects)
@ -3063,19 +3065,19 @@ class ProxyConfig:
"""
if self._should_load_db_object(object_type="guardrails"):
await self._init_guardrails_in_db(prisma_client=prisma_client)
if self._should_load_db_object(object_type="vector_stores"):
await self._init_vector_stores_in_db(prisma_client=prisma_client)
if self._should_load_db_object(object_type="mcp"):
await self._init_mcp_servers_in_db()
if self._should_load_db_object(object_type="pass_through_endpoints"):
await self._init_pass_through_endpoints_in_db()
if self._should_load_db_object(object_type="prompts"):
await self._init_prompts_in_db(prisma_client=prisma_client)
if self._should_load_db_object(object_type="model_cost_map"):
await self._check_and_reload_model_cost_map(prisma_client=prisma_client)
@ -9708,6 +9710,8 @@ async def dynamic_mcp_route(mcp_server_name: str, request: Request):
media_type=headers_dict.get("content-type", "application/json"),
)
except HTTPException as e:
raise e
except Exception as e:
verbose_proxy_logger.error(
f"Error handling dynamic MCP route for {mcp_server_name}: {str(e)}"

View file

@ -26,6 +26,9 @@ class MCPServer(BaseModel):
)
allowed_tools: Optional[List[str]] = None
disallowed_tools: Optional[List[str]] = None
allowed_params: Optional[Dict[str, List[str]]] = (
None # map of tool names to allowed parameter lists
)
# OAuth-specific fields
client_id: Optional[str] = None
client_secret: Optional[str] = None