diff --git a/docs/my-website/docs/mcp.md b/docs/my-website/docs/mcp.md index 462306e961d..6333f7f0913 100644 --- a/docs/my-website/docs/mcp.md +++ b/docs/my-website/docs/mcp.md @@ -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. @@ -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: diff --git a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py index 0c15138b05b..e77ad11fae4 100644 --- a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py +++ b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py @@ -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 [] diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 3c27a65f06a..ffdcfe8679f 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -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, diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 37157b4e0ac..77d6abfed62 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -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: diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 7a1e8f1e735..366d3f57dc0 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -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)}" diff --git a/litellm/types/mcp_server/mcp_server_manager.py b/litellm/types/mcp_server/mcp_server_manager.py index a247dc3f614..03bee94eb8b 100644 --- a/litellm/types/mcp_server/mcp_server_manager.py +++ b/litellm/types/mcp_server/mcp_server_manager.py @@ -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