diff --git a/docs/my-website/docs/mcp_oauth.md b/docs/my-website/docs/mcp_oauth.md index ed69408196f..9cd7b1e77be 100644 --- a/docs/my-website/docs/mcp_oauth.md +++ b/docs/my-website/docs/mcp_oauth.md @@ -1,6 +1,3 @@ -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; - # MCP OAuth LiteLLM supports two OAuth 2.0 flows for MCP servers: @@ -98,8 +95,71 @@ LiteLLM automatically fetches, caches, and refreshes OAuth2 tokens using the `cl ### Setup - - +You can configure M2M OAuth via the LiteLLM UI or `config.yaml`. + +### UI Setup + +Navigate to the **MCP Servers** page and click **+ Add New MCP Server**. + +![](https://colony-recorder.s3.amazonaws.com/files/2026-02-10/d1f1e89c-a789-4975-8846-b15d9821984a/ascreenshot_630800e00a2e4b598baabfc25efbabd3_text_export.jpeg) + +Enter a name for your server and select **HTTP** as the transport type. + +![](https://colony-recorder.s3.amazonaws.com/files/2026-02-10/2008c9d6-6093-4121-beab-1e52c71376aa/ascreenshot_516ffd6c7b524465a253a56048c3d228_text_export.jpeg) + +Paste the MCP server URL. + +![](https://colony-recorder.s3.amazonaws.com/files/2026-02-10/b0ee8b7d-6de8-492b-8962-287987feec29/ascreenshot_b3efca82078a4c6bb1453c58161909f9_text_export.jpeg) + +Under **Authentication**, select **OAuth**. + +![](https://colony-recorder.s3.amazonaws.com/files/2026-02-10/e1597814-ff8e-40b9-9d7b-864dcdbe0910/ascreenshot_2097612712264d8f9e553f7ca9175fb0_text_export.jpeg) + +![](https://colony-recorder.s3.amazonaws.com/files/2026-02-10/f6ea5694-f28a-4bc3-9c9a-bb79f199bd65/ascreenshot_9be839f55b1b4f96bfe24030ba2c7f8d_text_export.jpeg) + +Choose **Machine-to-Machine (M2M)** as the OAuth flow type. This is for server-to-server authentication using the `client_credentials` grant — no browser interaction required. + +![](https://colony-recorder.s3.amazonaws.com/files/2026-02-10/9853310c-1d86-4628-bad1-7a391eca0e4d/ascreenshot_f302a286fa264fdd8d56db53b8f9395c_text_export.jpeg) + +![](https://colony-recorder.s3.amazonaws.com/files/2026-02-10/df64dc65-ef86-475d-adaf-12e227d5e873/ascreenshot_9e2f41d43a76435f918a00b52ffcc639_text_export.jpeg) + +Fill in the **Client ID** and **Client Secret** provided by your OAuth provider. + +![](https://colony-recorder.s3.amazonaws.com/files/2026-02-10/0de5a7bd-9898-4fc7-8843-b23dd5aac47f/ascreenshot_b9087aaa81a14b5b9c199929efc4a563_text_export.jpeg) + +Enter the **Token URL** — this is the endpoint LiteLLM will call to fetch access tokens using `client_credentials`. + +![](https://colony-recorder.s3.amazonaws.com/files/2026-02-10/0aea70f1-558c-4dca-91bc-1175fe1ddc89/ascreenshot_b3fcf8a1287e4e2d9a3d67c4a29f7bff_text_export.jpeg) + +![](https://colony-recorder.s3.amazonaws.com/files/2026-02-10/e842ef09-1fd7-47a6-909b-252d389f0abc/ascreenshot_2a87dad3624847e7ac370591d1d1aedd_text_export.jpeg) + +Scroll down and review the server URL and all fields, then click **Create MCP Server**. + +![](https://colony-recorder.s3.amazonaws.com/files/2026-02-10/0857712b-4b53-40f8-8c1f-a4c72edaa644/ascreenshot_47be3fcd5de64ed391f70c1fb74a8bfc_text_export.jpeg) + +![](https://colony-recorder.s3.amazonaws.com/files/2026-02-10/9d961765-955f-4905-a3dc-1a446aa3b2cc/ascreenshot_43fd39d014224564bc6b35aced1fb6d3_text_export.jpeg) + +![](https://colony-recorder.s3.amazonaws.com/files/2026-02-10/3825d5fa-8fd1-4e71-b090-77ff0259c3f6/ascreenshot_2509a7ebd9bf421eb0e82f2553566745_text_export.jpeg) + +Once created, open the server and navigate to the **MCP Tools** tab to verify that LiteLLM can connect and list available tools. + +![](https://colony-recorder.s3.amazonaws.com/files/2026-02-10/8107e27b-5072-4675-8fd6-89b47692b1bd/ascreenshot_f774bc76138f430d808fb4482ebfcdca_text_export.jpeg) + +![](https://colony-recorder.s3.amazonaws.com/files/2026-02-10/ce94bb7b-c81b-4396-9939-178efb2cdfce/ascreenshot_28b838ab6ae34c76858454555c4c1d79_text_export.jpeg) + +Select a tool (e.g. **echo**) to test it. Fill in the required parameters and click **Call Tool**. + +![](https://colony-recorder.s3.amazonaws.com/files/2026-02-10/c459c1d3-ec29-4211-9c28-37fbe7783bbc/ascreenshot_e9b138b3c2cc4440bb1a6f42ac7ae861_text_export.jpeg) + +![](https://colony-recorder.s3.amazonaws.com/files/2026-02-10/5438ac60-e0ac-4a79-bf6f-5594f160d3b5/ascreenshot_9133a17d26204c46bce497e74685c483_text_export.jpeg) + +![](https://colony-recorder.s3.amazonaws.com/files/2026-02-10/a8f6821b-3982-4b4d-9b25-70c8aff5ac31/ascreenshot_28d474d0e62545a482cff6128527883a_text_export.jpeg) + +LiteLLM automatically fetches an OAuth token behind the scenes and calls the tool. The result confirms the M2M OAuth flow is working end-to-end. + +![](https://colony-recorder.s3.amazonaws.com/files/2026-02-10/c3924549-a949-48d1-ac67-ab4c30475859/ascreenshot_8f6eca9d717f45478d50a881bd244bb3_text_export.jpeg) + +### Config.yaml Setup ```yaml title="config.yaml" showLineNumbers mcp_servers: @@ -112,14 +172,6 @@ mcp_servers: scopes: ["mcp:read", "mcp:write"] # optional ``` - - - -Navigate to **MCP Servers → Add Server → Authentication → OAuth**, then fill in `client_id`, `client_secret`, and `token_url`. - - - - ### How It Works 1. On first MCP request, LiteLLM POSTs to `token_url` with `grant_type=client_credentials` diff --git a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py index 65d3a2caa16..43b388993d9 100644 --- a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py @@ -1,6 +1,6 @@ import importlib from datetime import datetime -from typing import Dict, List, Optional, Union +from typing import Any, Awaitable, Callable, Dict, List, Optional, Union from fastapi import APIRouter, Depends, HTTPException, Query, Request @@ -501,24 +501,50 @@ async def call_tool_rest_api( NewMCPServerRequest, ) + def _extract_credentials( + request: NewMCPServerRequest, + ) -> tuple: + """ + Extract OAuth credentials from the nested ``request.credentials`` dict. + + Returns: + (client_id, client_secret, scopes) — any value may be ``None``. + """ + creds = request.credentials if isinstance(request.credentials, dict) else {} + client_id: Optional[str] = creds.get("client_id") + client_secret: Optional[str] = creds.get("client_secret") + scopes_raw = creds.get("scopes") + scopes: Optional[List[str]] = scopes_raw if isinstance(scopes_raw, list) else None + return client_id, client_secret, scopes + async def _execute_with_mcp_client( request: NewMCPServerRequest, - operation, + operation: Callable[..., Awaitable[Any]], mcp_auth_header: Optional[Union[str, Dict[str, str]]] = None, oauth2_headers: Optional[Dict[str, str]] = None, raw_headers: Optional[Dict[str, str]] = None, - ): + ) -> dict: """ - Common helper to create MCP client, execute operation, and ensure proper cleanup. + Create a temporary MCP client from *request*, run *operation*, and return the result. + + For M2M OAuth servers (those with ``client_id``, ``client_secret``, and + ``token_url``), the incoming ``oauth2_headers`` are dropped so that + ``resolve_mcp_auth`` can auto-fetch a token via ``client_credentials``. Args: - request: MCP server configuration - operation: Async function that takes a client and returns the operation result + request: MCP server configuration submitted by the UI. + operation: Async callable that receives the created client and returns a result dict. + mcp_auth_header: Pre-resolved credential header (API-key / bearer token). + oauth2_headers: Headers extracted from the incoming request (may contain the + litellm API key — must NOT be forwarded for M2M servers). + raw_headers: Raw request headers forwarded for stdio env construction. Returns: - Operation result or error response + The dict returned by *operation*, or an error dict on failure. """ try: + client_id, client_secret, scopes = _extract_credentials(request) + server_model = MCPServer( server_id=request.server_id or "", name=request.alias or request.server_name or "", @@ -530,14 +556,26 @@ async def _execute_with_mcp_client( args=request.args, env=request.env, static_headers=request.static_headers, + client_id=client_id, + client_secret=client_secret, + token_url=request.token_url, + scopes=scopes, + authorization_url=request.authorization_url, + registration_url=request.registration_url, ) stdio_env = global_mcp_server_manager._build_stdio_env( server_model, raw_headers ) + # For M2M OAuth servers, drop the incoming Authorization header so that + # resolve_mcp_auth can auto-fetch a token via client_credentials. + effective_oauth2_headers = ( + None if server_model.has_client_credentials else oauth2_headers + ) + merged_headers = merge_mcp_headers( - extra_headers=oauth2_headers, + extra_headers=effective_oauth2_headers, static_headers=request.static_headers, ) @@ -550,11 +588,14 @@ async def _execute_with_mcp_client( return await operation(client) - except Exception as e: - verbose_logger.error(f"Error in MCP operation: {e}", exc_info=True) + except (KeyboardInterrupt, SystemExit): + raise + except BaseException as e: + verbose_logger.error("Error in MCP operation: %s", e, exc_info=True) return { "status": "error", - "message": "An internal error has occurred while testing the MCP server.", + "error": True, + "message": "Failed to connect to MCP server. Check proxy logs for details.", } @router.post("/test/connection", dependencies=[Depends(user_api_key_auth)]) diff --git a/litellm/proxy/proxy_config.yaml b/litellm/proxy/proxy_config.yaml index d87ae8b14ca..a094eb84bf3 100644 --- a/litellm/proxy/proxy_config.yaml +++ b/litellm/proxy/proxy_config.yaml @@ -46,6 +46,7 @@ mcp_servers: transport: "http" url: "https://mcp.deepwiki.com/mcp" + # General Settings general_settings: master_key: sk-1234 diff --git a/tests/mcp_tests/test_oauth2_mcp_config.yaml b/tests/mcp_tests/test_oauth2_mcp_config.yaml new file mode 100644 index 00000000000..c2704c5fe71 --- /dev/null +++ b/tests/mcp_tests/test_oauth2_mcp_config.yaml @@ -0,0 +1,14 @@ +model_list: + - model_name: fake-model + litellm_params: + model: openai/fake + api_key: fake-key + +mcp_servers: + test_oauth2_server: + url: "http://localhost:8765/mcp" + transport: "http" + auth_type: "oauth2" + client_id: "test-client" + client_secret: "test-secret" + token_url: "http://localhost:8765/oauth/token" diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py index f6c8115f7bc..9c77edc6743 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py @@ -155,6 +155,160 @@ async def ok_operation(client): } + @pytest.mark.asyncio + async def test_m2m_credentials_forwarded_to_server_model(self, monkeypatch): + """M2M OAuth credentials (client_id, client_secret) from the nested + ``credentials`` dict must be forwarded to the MCPServer model so that + ``has_client_credentials`` returns True and the proxy auto-fetches tokens.""" + captured: dict = {} + + def fake_build_stdio_env(server, raw_headers): + return None + + async def fake_create_client(*args, **kwargs): + captured["server"] = kwargs.get("server") + return object() + + monkeypatch.setattr( + rest_endpoints.global_mcp_server_manager, + "_build_stdio_env", + fake_build_stdio_env, + raising=False, + ) + monkeypatch.setattr( + rest_endpoints.global_mcp_server_manager, + "_create_mcp_client", + fake_create_client, + raising=False, + ) + + async def ok_operation(client): + return {"status": "ok"} + + payload = NewMCPServerRequest( + server_name="m2m-server", + url="https://example.com", + auth_type=MCPAuth.oauth2, + token_url="https://auth.example.com/token", + credentials={ + "client_id": "my-id", + "client_secret": "my-secret", + "scopes": ["read", "write"], + }, + ) + + result = await rest_endpoints._execute_with_mcp_client( + payload, ok_operation + ) + + assert result["status"] == "ok" + server = captured["server"] + assert server.client_id == "my-id" + assert server.client_secret == "my-secret" + assert server.token_url == "https://auth.example.com/token" + assert server.scopes == ["read", "write"] + assert server.has_client_credentials is True + + @pytest.mark.asyncio + async def test_m2m_drops_incoming_oauth2_headers(self, monkeypatch): + """For M2M OAuth servers the incoming Authorization header (which carries + the litellm API key) must NOT be forwarded as extra_headers — otherwise + it overwrites the auto-fetched M2M token.""" + captured: dict = {} + + def fake_build_stdio_env(server, raw_headers): + return None + + async def fake_create_client(*args, **kwargs): + captured["extra_headers"] = kwargs.get("extra_headers") + return object() + + monkeypatch.setattr( + rest_endpoints.global_mcp_server_manager, + "_build_stdio_env", + fake_build_stdio_env, + raising=False, + ) + monkeypatch.setattr( + rest_endpoints.global_mcp_server_manager, + "_create_mcp_client", + fake_create_client, + raising=False, + ) + + async def ok_operation(client): + return {"status": "ok"} + + payload = NewMCPServerRequest( + server_name="m2m-server", + url="https://example.com", + auth_type=MCPAuth.oauth2, + token_url="https://auth.example.com/token", + credentials={ + "client_id": "my-id", + "client_secret": "my-secret", + }, + ) + + incoming_oauth2 = {"Authorization": "Bearer sk-litellm-api-key"} + result = await rest_endpoints._execute_with_mcp_client( + payload, + ok_operation, + oauth2_headers=incoming_oauth2, + ) + + assert result["status"] == "ok" + # The incoming Authorization must be dropped — extra_headers should + # contain no oauth2 headers (only static_headers, which are None here). + assert captured["extra_headers"] is None or "Authorization" not in captured["extra_headers"] + + @pytest.mark.asyncio + async def test_catches_exception_group(self, monkeypatch): + """MCP SDK's anyio TaskGroup raises BaseExceptionGroup which does not + inherit from Exception. The handler must catch it and return an error + dict instead of letting a raw 500 propagate.""" + + def fake_build_stdio_env(server, raw_headers): + return None + + async def fake_create_client(*args, **kwargs): + raise BaseExceptionGroup( + "test group", [RuntimeError("Cancelled via cancel scope")] + ) + + monkeypatch.setattr( + rest_endpoints.global_mcp_server_manager, + "_build_stdio_env", + fake_build_stdio_env, + raising=False, + ) + monkeypatch.setattr( + rest_endpoints.global_mcp_server_manager, + "_create_mcp_client", + fake_create_client, + raising=False, + ) + + async def ok_operation(client): + return {"status": "ok"} + + payload = NewMCPServerRequest( + server_name="bad-server", + url="https://example.com", + auth_type=MCPAuth.none, + ) + + result = await rest_endpoints._execute_with_mcp_client( + payload, ok_operation + ) + + assert result["status"] == "error" + assert result["error"] is True + assert "Failed to connect to MCP server" in result["message"] + # Error message must not leak raw exception details + assert "cancel scope" not in result["message"] + + class TestTestConnection: def test_requires_auth_dependency(self): route = _get_route("/mcp-rest/test/connection", "POST") diff --git a/ui/litellm-dashboard/src/components/mcp_tools/OAuthFormFields.tsx b/ui/litellm-dashboard/src/components/mcp_tools/OAuthFormFields.tsx new file mode 100644 index 00000000000..acad72cb21c --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/OAuthFormFields.tsx @@ -0,0 +1,166 @@ +import React from "react"; +import { Form, Select, Tooltip } from "antd"; +import { InfoCircleOutlined } from "@ant-design/icons"; +import { Button, TextInput } from "@tremor/react"; +import { OAUTH_FLOW } from "./types"; + +interface OAuthFlowStatus { + startOAuthFlow: () => void; + status: string; + error: string | null; + tokenResponse: { access_token?: string; expires_in?: number } | null; +} + +interface OAuthFormFieldsProps { + isM2M: boolean; + isEditing?: boolean; + oauthFlow?: OAuthFlowStatus; + initialFlowType?: string; +} + +const fieldClassName = "rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"; + +const FieldLabel: React.FC<{ label: string; tooltip: string }> = ({ label, tooltip }) => ( + + {label} + + + + +); + +const OAuthFormFields: React.FC = ({ + isM2M, + isEditing = false, + oauthFlow, + initialFlowType, +}) => { + const placeholderSuffix = isEditing ? " (leave blank to keep existing)" : ""; + + return ( + <> + + } + name="oauth_flow_type" + {...(initialFlowType ? { initialValue: initialFlowType } : {})} + > + + + + {isM2M ? ( + <> + } + name={["credentials", "client_id"]} + rules={[{ required: true, message: "Client ID is required for M2M OAuth" }]} + > + + + } + name={["credentials", "client_secret"]} + rules={[{ required: true, message: "Client Secret is required for M2M OAuth" }]} + > + + + } + name="token_url" + rules={[{ required: true, message: "Token URL is required for M2M OAuth" }]} + > + + + } + name={["credentials", "scopes"]} + > + + + } + name="authorization_url" + > + + + } + name="token_url" + > + + + } + name="registration_url" + > + + + {oauthFlow && ( +
+

+ Use OAuth to fetch a fresh access token and temporarily save it in the session as the authentication value. +

+ + {oauthFlow.error &&

{oauthFlow.error}

} + {oauthFlow.status === "success" && oauthFlow.tokenResponse?.access_token && ( +

+ Token fetched. Expires in {oauthFlow.tokenResponse.expires_in ?? "?"} seconds. +

+ )} +
+ )} + + )} + + ); +}; + +export default OAuthFormFields; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx index a56cbaf492a..ff476bd999d 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx @@ -3,7 +3,8 @@ import { Modal, Tooltip, Form, Select, Input } from "antd"; import { InfoCircleOutlined } from "@ant-design/icons"; import { Button, TextInput } from "@tremor/react"; import { createMCPServer } from "../networking"; -import { AUTH_TYPE, MCPServer, MCPServerCostInfo } from "./types"; +import { AUTH_TYPE, OAUTH_FLOW, MCPServer, MCPServerCostInfo } from "./types"; +import OAuthFormFields from "./OAuthFormFields"; import MCPServerCostConfig from "./mcp_server_cost_config"; import MCPConnectionStatus from "./mcp_connection_status"; import MCPToolConfiguration from "./mcp_tool_configuration"; @@ -52,6 +53,7 @@ const CreateMCPServer: React.FC = ({ const authType = formValues.auth_type as string | undefined; const shouldShowAuthValueField = authType ? AUTH_TYPES_REQUIRING_AUTH_VALUE.includes(authType) : false; const isOAuthAuthType = authType === AUTH_TYPE.OAUTH2; + const isM2MFlow = isOAuthAuthType && formValues.oauth_flow_type === OAUTH_FLOW.M2M; const persistCreateUiState = () => { if (typeof window === "undefined") { @@ -477,7 +479,7 @@ const CreateMCPServer: React.FC = ({ rules={[ { required: false, - message: "Please enter a server description!!!!!!!!!", + message: "Please enter a server description", }, ]} > @@ -561,131 +563,16 @@ const CreateMCPServer: React.FC = ({ )} {transportType !== "stdio" && isOAuthAuthType && ( - <> - - OAuth Client ID (optional) - - - - - } - name={["credentials", "client_id"]} - > - - - - OAuth Client Secret (optional) - - - - - } - name={["credentials", "client_secret"]} - > - - - - OAuth Scopes (optional) - - - - - } - name={["credentials", "scopes"]} - > - - - - Authorization URL Override (optional) - - - - - } - name="authorization_url" - > - - - - Token URL Override (optional) - - - - - } - name="token_url" - > - - - - Registration URL Override (optional) - - - - - } - name="registration_url" - > - - -
-

Use OAuth to fetch a fresh access token and temporarily save it in the session as the authentication value.

- - {oauthError &&

{oauthError}

} - {oauthStatus === "success" && oauthTokenResponse?.access_token && ( -

- Token fetched. Expires in {oauthTokenResponse.expires_in ?? "?"} seconds. -

- )} -
- + )} {/* Permission Management / Access Control Section */} @@ -600,6 +492,7 @@ const MCPServerEdit: React.FC = ({ transport: mcpServer.transport, auth_type: mcpServer.auth_type, mcp_info: mcpServer.mcp_info, + oauth_flow_type: mcpServer.token_url ? OAUTH_FLOW.M2M : OAUTH_FLOW.INTERACTIVE, }} allowedTools={allowedTools} existingAllowedTools={mcpServer.allowed_tools || null} diff --git a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx index d575254fe35..5cb840ec7d4 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx @@ -13,13 +13,17 @@ export const AUTH_TYPE = { OAUTH2: "oauth2", }; +export const OAUTH_FLOW = { + INTERACTIVE: "interactive", + M2M: "m2m", +}; + export const TRANSPORT = { SSE: "sse", HTTP: "http", }; export const handleTransport = (transport?: string | null): string => { - console.log(transport); if (transport === null || transport === undefined) { return TRANSPORT.SSE; } diff --git a/ui/litellm-dashboard/src/hooks/useTestMCPConnection.tsx b/ui/litellm-dashboard/src/hooks/useTestMCPConnection.tsx index 1743470559d..0cb4288db0e 100644 --- a/ui/litellm-dashboard/src/hooks/useTestMCPConnection.tsx +++ b/ui/litellm-dashboard/src/hooks/useTestMCPConnection.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from "react"; import { testMCPToolsListRequest } from "../components/networking"; -import { AUTH_TYPE } from "@/components/mcp_tools/types"; +import { AUTH_TYPE, OAUTH_FLOW } from "@/components/mcp_tools/types"; interface MCPServerConfig { server_id?: string; @@ -52,7 +52,9 @@ export const useTestMCPConnection = ({ const [hasShownSuccessMessage, setHasShownSuccessMessage] = useState(false); // Check if we have the minimum required fields to fetch tools - const requiresOAuthToken = formValues.auth_type === AUTH_TYPE.OAUTH2; + const isM2MOAuth = formValues.auth_type === AUTH_TYPE.OAUTH2 + && formValues.oauth_flow_type === OAUTH_FLOW.M2M; + const requiresOAuthToken = formValues.auth_type === AUTH_TYPE.OAUTH2 && !isM2MOAuth; const canFetchTools = !!( formValues.url && formValues.transport &&