Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 65 additions & 13 deletions docs/my-website/docs/mcp_oauth.md
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -98,8 +95,71 @@ LiteLLM automatically fetches, caches, and refreshes OAuth2 tokens using the `cl

### Setup

<Tabs>
<TabItem value="config" label="config.yaml">
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:
Expand All @@ -112,14 +172,6 @@ mcp_servers:
scopes: ["mcp:read", "mcp:write"] # optional
```

</TabItem>
<TabItem value="ui" label="LiteLLM UI">

Navigate to **MCP Servers → Add Server → Authentication → OAuth**, then fill in `client_id`, `client_secret`, and `token_url`.

</TabItem>
</Tabs>

### How It Works

1. On first MCP request, LiteLLM POSTs to `token_url` with `grant_type=client_credentials`
Expand Down
63 changes: 52 additions & 11 deletions litellm/proxy/_experimental/mcp_server/rest_endpoints.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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 "",
Expand All @@ -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,
)

Expand All @@ -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)])
Expand Down
1 change: 1 addition & 0 deletions litellm/proxy/proxy_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ mcp_servers:
transport: "http"
url: "https://mcp.deepwiki.com/mcp"


# General Settings
general_settings:
master_key: sk-1234
Expand Down
14 changes: 14 additions & 0 deletions tests/mcp_tests/test_oauth2_mcp_config.yaml
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading