fix(mcp): OAuth M2M form sync, health refresh after create, oauth2_flow + M2M health

- Dashboard: Form.useWatch for oauth_flow_type on create; refetch MCP health after create
- useMCPServerHealth: merge recheck results by server_id (append new rows)
- Backend: oauth2_flow plumbing, M2M OAuth health check; proxy-extras oauth2_flow migration
- Tests: MCP server manager, oauth2_flow_utils

Made-with: Cursor
This commit is contained in:
Milan 2026-04-11 01:15:21 +03:00
parent ec524a0e7a
commit 74cfa4ebb0
23 changed files with 471 additions and 256 deletions

View file

@ -0,0 +1,3 @@
-- AlterTable
-- OAuth2 flow discriminator: "client_credentials" (M2M) | "authorization_code" (interactive); nullable for legacy rows / inference
ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN IF NOT EXISTS "oauth2_flow" TEXT;

View file

@ -316,6 +316,8 @@ model LiteLLM_MCPServerTable {
authorization_url String?
token_url String?
registration_url String?
// OAuth2: "client_credentials" (M2M) | "authorization_code" (interactive); null = infer on read/write when safe
oauth2_flow String?
allow_all_keys Boolean @default(false)
available_on_public_internet Boolean @default(true)
is_byok Boolean @default(false)

View file

@ -41,6 +41,9 @@ def _prepare_mcp_server_data(
Dict with properly serialized JSON fields
"""
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.proxy._experimental.mcp_server.oauth2_flow_utils import (
infer_oauth2_flow_for_storage,
)
# Convert model to dict
data_dict = data.model_dump(exclude_none=True)
@ -48,6 +51,21 @@ def _prepare_mcp_server_data(
if "alias" not in data_dict:
data_dict["alias"] = getattr(data, "alias", None)
# Persist oauth2_flow for OAuth2 M2M when the UI omits it (same rule as test MCP client).
_creds_plain = (
data_dict["credentials"]
if isinstance(data_dict.get("credentials"), dict)
else None
)
_inferred_flow = infer_oauth2_flow_for_storage(
auth_type=data_dict.get("auth_type") or getattr(data, "auth_type", None),
oauth2_flow=data_dict.get("oauth2_flow") or getattr(data, "oauth2_flow", None),
token_url=data_dict.get("token_url") or getattr(data, "token_url", None),
credentials_plain=_creds_plain,
)
if _inferred_flow is not None:
data_dict["oauth2_flow"] = _inferred_flow
# Handle credentials serialization
credentials = data_dict.get("credentials")
if credentials is not None:

View file

@ -46,6 +46,9 @@ from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
MCPRequestHandler,
)
from litellm.proxy._experimental.mcp_server.oauth2_flow_utils import (
resolve_oauth2_flow_for_runtime,
)
from litellm.proxy._experimental.mcp_server.oauth2_token_cache import resolve_mcp_auth
from litellm.proxy._experimental.mcp_server.utils import (
MCP_TOOL_PREFIX_SEPARATOR,
@ -640,6 +643,18 @@ class MCPServerManager:
mcp_oauth_metadata.scopes if mcp_oauth_metadata else None
)
token_url_for_oauth_flow = mcp_server.token_url or (
getattr(mcp_oauth_metadata, "token_url", None) if mcp_oauth_metadata else None
)
oauth2_flow_effective = resolve_oauth2_flow_for_runtime(
auth_type=auth_type,
stored_oauth2_flow=getattr(mcp_server, "oauth2_flow", None),
token_url=token_url_for_oauth_flow,
client_id=client_id_value or getattr(mcp_server, "client_id", None),
client_secret=client_secret_value
or getattr(mcp_server, "client_secret", None),
)
new_server = MCPServer(
server_id=mcp_server.server_id,
name=name_for_prefix,
@ -656,12 +671,11 @@ class MCPServerManager:
client_id=client_id_value or getattr(mcp_server, "client_id", None),
client_secret=client_secret_value
or getattr(mcp_server, "client_secret", None),
oauth2_flow=getattr(mcp_server, "oauth2_flow", None),
oauth2_flow=oauth2_flow_effective,
scopes=resolved_scopes,
authorization_url=mcp_server.authorization_url
or getattr(mcp_oauth_metadata, "authorization_url", None),
token_url=mcp_server.token_url
or getattr(mcp_oauth_metadata, "token_url", None),
token_url=token_url_for_oauth_flow,
registration_url=mcp_server.registration_url
or getattr(mcp_oauth_metadata, "registration_url", None),
command=getattr(mcp_server, "command", None),
@ -2875,12 +2889,15 @@ class MCPServerManager:
if server.requires_per_user_auth:
should_skip_health_check = True
# Skip if auth_type is not none and authentication_token is missing
# (except aws_sigv4 which uses its own credential fields)
# (except aws_sigv4 which uses its own credential fields).
# OAuth2 M2M (client_credentials) uses client_id/client_secret — no static
# authentication_token; _create_mcp_client can still obtain a token, so do not skip.
elif (
server.auth_type
and server.auth_type != MCPAuth.none
and server.auth_type != MCPAuth.aws_sigv4
and not server.authentication_token
and not server.has_client_credentials
):
should_skip_health_check = True
@ -2945,6 +2962,7 @@ class MCPServerManager:
authorization_url=server.authorization_url,
token_url=server.token_url,
registration_url=server.registration_url,
oauth2_flow=getattr(server, "oauth2_flow", None),
allow_all_keys=server.allow_all_keys,
)

View file

@ -0,0 +1,83 @@
"""
Persistence and runtime resolution for MCP OAuth2 ``oauth2_flow`` (M2M vs interactive).
Mirrors the inference used in ``rest_endpoints._execute_with_mcp_client`` so DB-backed
servers created from the UI (which omits ``oauth2_flow``) still persist and load as M2M
when ``client_id``, ``client_secret``, and ``token_url`` are present.
Interactive-only servers that reuse the same three fields (e.g. some GitHub Enterprise setups)
should set ``oauth2_flow`` to ``authorization_code`` explicitly via API or config.
"""
from __future__ import annotations
from typing import Any, Dict, Literal, Optional, Union
from litellm.types.mcp import MCPAuth
OAuth2FlowLiteral = Literal["client_credentials", "authorization_code"]
def _auth_type_str(auth_type: Optional[Any]) -> Optional[str]:
if auth_type is None:
return None
return auth_type.value if hasattr(auth_type, "value") else str(auth_type)
def infer_oauth2_flow_for_storage(
*,
auth_type: Optional[Any],
oauth2_flow: Optional[str],
token_url: Optional[str],
credentials_plain: Optional[Dict[str, Any]],
) -> Optional[OAuth2FlowLiteral]:
"""
Resolve ``oauth2_flow`` to store on ``LiteLLM_MCPServerTable``.
- Honors explicit ``client_credentials`` or ``authorization_code``.
- If unset and auth is OAuth2, infers ``client_credentials`` when
``client_id``, ``client_secret``, and ``token_url`` are all present
(same rule as ``_execute_with_mcp_client``).
"""
if oauth2_flow in ("client_credentials", "authorization_code"):
return oauth2_flow # type: ignore[return-value]
if oauth2_flow is not None and str(oauth2_flow).strip() != "":
return None
if _auth_type_str(auth_type) != MCPAuth.oauth2.value:
return None
creds = credentials_plain or {}
client_id = creds.get("client_id")
client_secret = creds.get("client_secret")
if client_id and client_secret and token_url:
return "client_credentials"
return None
def resolve_oauth2_flow_for_runtime(
*,
auth_type: Optional[Any],
stored_oauth2_flow: Optional[str],
token_url: Optional[str],
client_id: Optional[str],
client_secret: Optional[str],
) -> Optional[OAuth2FlowLiteral]:
"""
Effective ``oauth2_flow`` when constructing ``MCPServer`` from a DB row.
Uses the stored column when set; otherwise applies the same inference as
:func:`infer_oauth2_flow_for_storage` using decrypted credential fields.
"""
if stored_oauth2_flow in ("client_credentials", "authorization_code"):
return stored_oauth2_flow # type: ignore[return-value]
return infer_oauth2_flow_for_storage(
auth_type=auth_type,
oauth2_flow=None,
token_url=token_url,
credentials_plain=(
{"client_id": client_id, "client_secret": client_secret}
if (client_id or client_secret)
else None
),
)

View file

@ -2589,17 +2589,15 @@ if MCP_AVAILABLE:
f"MCP server auth headers: {list(mcp_server_auth_headers.keys()) if mcp_server_auth_headers else None}"
)
# https://datatracker.ietf.org/doc/html/rfc9728#name-www-authenticate-response
# Pre-emptive www-authenticate 401 is only for OAuth2 servers that are neither
# M2M (proxy fetches client_credentials tokens) nor interactive-without-headers
# (those skip 401 here so list_tools/call_tool can return a proper protocol error).
for server_name in mcp_servers or []:
server = global_mcp_server_manager.get_mcp_server_by_name(
server_name, client_ip=_client_ip
)
if server and server.auth_type == MCPAuth.oauth2 and not oauth2_headers:
# For servers that store per-user tokens server-side, skip the
# pre-emptive 401 — the call_tool / list_tools dispatch will look
# up the stored token from Redis / DB and only fail at the MCP
# protocol level if none is found, giving the client a proper
# tool-execution error rather than an HTTP 401.
if server.needs_user_oauth_token:
if server.has_client_credentials or server.needs_user_oauth_token:
continue
request = StarletteRequest(scope)

View file

@ -1222,6 +1222,7 @@ class UpdateMCPServerRequest(LiteLLMPydanticObjectBase):
authorization_url: Optional[str] = None
token_url: Optional[str] = None
registration_url: Optional[str] = None
oauth2_flow: Optional[Literal["client_credentials", "authorization_code"]] = None
allow_all_keys: bool = False
available_on_public_internet: bool = True
is_byok: bool = False
@ -1292,6 +1293,7 @@ class LiteLLM_MCPServerTable(LiteLLMPydanticObjectBase):
authorization_url: Optional[str] = None
token_url: Optional[str] = None
registration_url: Optional[str] = None
oauth2_flow: Optional[Literal["client_credentials", "authorization_code"]] = None
allow_all_keys: bool = False
available_on_public_internet: bool = True
is_byok: bool = False

View file

@ -461,9 +461,24 @@ if MCP_AVAILABLE:
payload: NewMCPServerRequest,
created_by: Optional[str],
) -> LiteLLM_MCPServerTable:
from litellm.proxy._experimental.mcp_server.oauth2_flow_utils import (
infer_oauth2_flow_for_storage,
)
now = datetime.utcnow()
server_id = payload.server_id or str(uuid.uuid4())
server_name = payload.server_name or payload.alias or server_id
_creds = (
payload.credentials
if isinstance(payload.credentials, dict)
else None
)
_oauth2_flow = infer_oauth2_flow_for_storage(
auth_type=payload.auth_type,
oauth2_flow=payload.oauth2_flow,
token_url=payload.token_url,
credentials_plain=_creds,
)
return LiteLLM_MCPServerTable(
server_id=server_id,
server_name=server_name,
@ -489,6 +504,7 @@ if MCP_AVAILABLE:
authorization_url=payload.authorization_url,
token_url=payload.token_url,
registration_url=payload.registration_url,
oauth2_flow=_oauth2_flow,
allow_all_keys=payload.allow_all_keys,
available_on_public_internet=payload.available_on_public_internet,
)

View file

@ -316,6 +316,8 @@ model LiteLLM_MCPServerTable {
authorization_url String?
token_url String?
registration_url String?
// OAuth2: "client_credentials" (M2M) | "authorization_code" (interactive); null = infer on read/write when safe
oauth2_flow String?
allow_all_keys Boolean @default(false)
available_on_public_internet Boolean @default(true)
is_byok Boolean @default(false)

View file

@ -316,6 +316,8 @@ model LiteLLM_MCPServerTable {
authorization_url String?
token_url String?
registration_url String?
// OAuth2: "client_credentials" (M2M) | "authorization_code" (interactive); null = infer on read/write when safe
oauth2_flow String?
allow_all_keys Boolean @default(false)
available_on_public_internet Boolean @default(true)
is_byok Boolean @default(false)

View file

@ -959,6 +959,37 @@ class TestMCPServerManager:
assert result.health_check_error is None
assert result.last_health_check is not None
@pytest.mark.asyncio
async def test_health_check_server_oauth2_m2m_runs_check(self):
"""OAuth2 M2M uses client credentials — health check should run (not stay unknown)."""
manager = MCPServerManager()
server = MCPServer(
server_id="oauth2-m2m",
name="oauth2-m2m",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
oauth2_flow="client_credentials",
client_id="cid",
client_secret="sec",
token_url="https://idp.example.com/token",
url="http://mcp.example.com",
)
manager.get_mcp_server_by_id = MagicMock(return_value=server)
mock_client = AsyncMock()
mock_client.run_with_session = AsyncMock(return_value="ok")
manager._create_mcp_client = AsyncMock(return_value=mock_client)
result = await manager.health_check_server("oauth2-m2m")
manager._create_mcp_client.assert_called_once()
assert isinstance(result, LiteLLM_MCPServerTable)
assert result.server_id == "oauth2-m2m"
assert result.status == "healthy"
assert result.health_check_error is None
@pytest.mark.asyncio
async def test_health_check_server_no_token_skips_check(self):
"""Test that health check is skipped when auth_type is set but authentication_token is missing"""

View file

@ -0,0 +1,99 @@
"""Tests for MCP OAuth2 flow persistence helpers."""
import pytest
from litellm.proxy._experimental.mcp_server.oauth2_flow_utils import (
infer_oauth2_flow_for_storage,
resolve_oauth2_flow_for_runtime,
)
from litellm.types.mcp import MCPAuth
def test_infer_explicit_client_credentials():
assert (
infer_oauth2_flow_for_storage(
auth_type=MCPAuth.oauth2,
oauth2_flow="client_credentials",
token_url="https://idp.example.com/token",
credentials_plain={"client_id": "a", "client_secret": "b"},
)
== "client_credentials"
)
def test_infer_explicit_authorization_code():
assert (
infer_oauth2_flow_for_storage(
auth_type=MCPAuth.oauth2,
oauth2_flow="authorization_code",
token_url="https://github.example.com/token",
credentials_plain={"client_id": "a", "client_secret": "b"},
)
== "authorization_code"
)
def test_infer_from_credentials_and_token_url_ui_pattern():
"""UI omits oauth2_flow but sends M2M-style fields (same as _execute_with_mcp_client)."""
assert (
infer_oauth2_flow_for_storage(
auth_type=MCPAuth.oauth2,
oauth2_flow=None,
token_url="http://127.0.0.1:18901/oauth/token",
credentials_plain={
"client_id": "cid",
"client_secret": "sec",
},
)
== "client_credentials"
)
def test_infer_not_oauth2():
assert (
infer_oauth2_flow_for_storage(
auth_type=MCPAuth.api_key,
oauth2_flow=None,
token_url="https://x/token",
credentials_plain={"client_id": "a", "client_secret": "b"},
)
is None
)
def test_infer_partial_credentials():
assert (
infer_oauth2_flow_for_storage(
auth_type=MCPAuth.oauth2,
oauth2_flow=None,
token_url="https://x/token",
credentials_plain={"client_id": "only-id"},
)
is None
)
def test_resolve_runtime_uses_stored():
assert (
resolve_oauth2_flow_for_runtime(
auth_type=MCPAuth.oauth2,
stored_oauth2_flow="authorization_code",
token_url="https://gh/token",
client_id="a",
client_secret="b",
)
== "authorization_code"
)
def test_resolve_runtime_infers_when_stored_null():
assert (
resolve_oauth2_flow_for_runtime(
auth_type=MCPAuth.oauth2,
stored_oauth2_flow=None,
token_url="https://idp/token",
client_id="x",
client_secret="y",
)
== "client_credentials"
)

View file

@ -36,10 +36,13 @@ export const useMCPServerHealth = () => {
{ queryKey: mcpServerHealthKeys.lists() },
(oldData) => {
if (!oldData) return result;
return oldData.map((h) => {
const updated = result.find((r) => r.server_id === h.server_id);
return updated ?? h;
});
// Merge by id: update existing rows and append any new server_ids from this fetch
// (single-server recheck previously dropped rows not present in oldData).
const byId = new Map(oldData.map((h) => [h.server_id, h]));
for (const r of result) {
byId.set(r.server_id, r);
}
return Array.from(byId.values());
},
);
} finally {

View file

@ -45,8 +45,8 @@ const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
<Form.Item
label={
<FieldLabel
label="OAuth Flow Type"
tooltip="Choose how the proxy authenticates with this MCP server. M2M is for server-to-server communication using client credentials. Interactive (PKCE) is for user-facing flows that require browser-based authorization."
label="OAuth grant type"
tooltip="Stored as oauth2_flow on the server: client_credentials (M2M) or authorization_code (interactive). This controls how LiteLLM obtains tokens for this MCP."
/>
}
name="oauth_flow_type"
@ -55,14 +55,14 @@ const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
<Select className="rounded-lg" size="large">
<Select.Option value={OAUTH_FLOW.M2M}>
<div>
<span className="font-medium">Machine-to-Machine (M2M)</span>
<span className="text-gray-400 text-xs ml-2">server-to-server, no user interaction</span>
<span className="font-medium">Machine-to-machine (client credentials)</span>
<span className="text-gray-400 text-xs ml-2">no browser proxy fetches token with client id/secret</span>
</div>
</Select.Option>
<Select.Option value={OAUTH_FLOW.INTERACTIVE}>
<div>
<span className="font-medium">Interactive (PKCE)</span>
<span className="text-gray-400 text-xs ml-2">browser-based user authorization</span>
<span className="font-medium">Interactive (authorization code / PKCE)</span>
<span className="text-gray-400 text-xs ml-2">users authorize in a browser; per-user tokens</span>
</div>
</Select.Option>
</Select>
@ -73,21 +73,33 @@ const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
<Form.Item
label={<FieldLabel label="Client ID" tooltip="OAuth2 client ID for the client_credentials grant." />}
name={["credentials", "client_id"]}
rules={[{ required: true, message: "Client ID is required for M2M OAuth" }]}
rules={
isEditing
? []
: [{ required: true, message: "Client ID is required for M2M OAuth" }]
}
>
<TextInput type="password" placeholder={`Enter OAuth client ID${placeholderSuffix}`} className={fieldClassName} />
</Form.Item>
<Form.Item
label={<FieldLabel label="Client Secret" tooltip="OAuth2 client secret for the client_credentials grant." />}
name={["credentials", "client_secret"]}
rules={[{ required: true, message: "Client Secret is required for M2M OAuth" }]}
rules={
isEditing
? []
: [{ required: true, message: "Client Secret is required for M2M OAuth" }]
}
>
<TextInput type="password" placeholder={`Enter OAuth client secret${placeholderSuffix}`} className={fieldClassName} />
</Form.Item>
<Form.Item
label={<FieldLabel label="Token URL" tooltip="Token endpoint URL for the client_credentials grant." />}
name="token_url"
rules={[{ required: true, message: "Token URL is required for M2M OAuth" }]}
rules={
isEditing
? []
: [{ required: true, message: "Token URL is required for M2M OAuth" }]
}
>
<TextInput placeholder="https://auth.example.com/oauth/token" className={fieldClassName} />
</Form.Item>

View file

@ -349,12 +349,14 @@ describe("CreateMCPServer", () => {
await selectAntOption("Authentication", "OAuth");
// Wait for OAuthFormFields to render (OAuth Flow Type selector is the sentinel)
// Wait for OAuthFormFields to render (OAuth grant type selector is the sentinel)
await waitFor(() => {
expect(screen.getByText("OAuth Flow Type")).toBeInTheDocument();
expect(screen.getByText("OAuth grant type")).toBeInTheDocument();
});
// OAuthFormFields defaults to INTERACTIVE, so the new fields should appear
// Default grant is M2M; switch to interactive so token validation / PKCE fields appear (tests target this path)
await selectAntOption("OAuth grant type", "Interactive");
await waitFor(() => {
expect(screen.getByText("Token Validation Rules (optional)")).toBeInTheDocument();
expect(screen.getByText("Token Storage TTL (seconds, optional)")).toBeInTheDocument();

View file

@ -3,7 +3,15 @@ import { Modal, Tooltip, Form, Select, Input, Switch, Collapse } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
import { Button, TextInput } from "@tremor/react";
import { createMCPServer, registerMCPServer } from "../networking";
import { AUTH_TYPE, DiscoverableMCPServer, OAUTH_FLOW, MCPServer, MCPServerCostInfo, TRANSPORT } from "./types";
import {
AUTH_TYPE,
DiscoverableMCPServer,
OAUTH_FLOW,
mapUiOAuthFlowToApi,
MCPServer,
MCPServerCostInfo,
TRANSPORT,
} from "./types";
import OAuthFormFields from "./OAuthFormFields";
import MCPServerCostConfig from "./mcp_server_cost_config";
import MCPConnectionStatus from "./mcp_connection_status";
@ -87,7 +95,11 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
const shouldShowAuthValueField = authType ? AUTH_TYPES_REQUIRING_AUTH_VALUE.includes(authType) : false;
const isOAuthAuthType = authType === AUTH_TYPE.OAUTH2;
const isAwsSigV4AuthType = authType === AUTH_TYPE.AWS_SIGV4;
const isM2MFlow = isOAuthAuthType && formValues.oauth_flow_type === OAUTH_FLOW.M2M;
// Use Form.useWatch — formValues from onValuesChange does not update on initialValue mount, so the
// grant-type Select could show M2M while formValues.oauth_flow_type was still undefined (PKCE fields shown).
const oauthFlowTypeWatched = Form.useWatch("oauth_flow_type", form) as string | undefined;
const isM2MFlow =
isOAuthAuthType && (oauthFlowTypeWatched ?? OAUTH_FLOW.M2M) === OAUTH_FLOW.M2M;
const persistCreateUiState = () => {
if (typeof window === "undefined") {
@ -146,6 +158,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
authorization_url: values.authorization_url,
token_url: values.token_url,
registration_url: values.registration_url,
oauth2_flow: mapUiOAuthFlowToApi(values.oauth_flow_type),
mcp_access_groups: values.mcp_access_groups,
static_headers: staticHeaders,
command: values.command,
@ -285,6 +298,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
allow_all_keys: allowAllKeysRaw,
available_on_public_internet: availableOnPublicInternetRaw,
token_validation_json: rawTokenValidationJson,
oauth_flow_type: oauthFlowType,
...restValues
} = values;
@ -390,6 +404,9 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
available_on_public_internet: Boolean(availableOnPublicInternetRaw),
static_headers: staticHeaders,
...(tokenValidation !== null && { token_validation: tokenValidation }),
...(restValues.auth_type === AUTH_TYPE.OAUTH2 && mapUiOAuthFlowToApi(oauthFlowType)
? { oauth2_flow: mapUiOAuthFlowToApi(oauthFlowType) }
: {}),
};
payload.static_headers = staticHeaders;
@ -818,7 +835,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
{isOAuthAuthType && (
<OAuthFormFields
isM2M={isM2MFlow}
initialFlowType={OAUTH_FLOW.INTERACTIVE}
initialFlowType={OAUTH_FLOW.M2M}
docsUrl={oauthDocsUrl}
oauthFlow={{
startOAuthFlow,

View file

@ -7,7 +7,7 @@ import NotificationsManager from "../molecules/notifications_manager";
vi.mock("../networking", () => ({
updateMCPServer: vi.fn(),
testMCPToolsListRequest: vi.fn().mockResolvedValue({ tools: [], error: null }),
listMCPTools: vi.fn().mockResolvedValue({ tools: [], error: null }),
}));
vi.mock("../molecules/notifications_manager", () => ({
@ -48,7 +48,8 @@ const interactiveOAuthServer = {
transport: "http",
url: "https://example.com/mcp",
auth_type: "oauth2",
// No token_url → edit form defaults to INTERACTIVE flow
oauth2_flow: "authorization_code",
// No token_url → interactive flow; oauth2_flow disambiguates from inferred M2M
token_url: null,
authorization_url: null,
registration_url: null,

View file

@ -2,14 +2,23 @@ import React, { useState, useEffect } from "react";
import { Form, Select, Button as AntdButton, Tooltip, Input, InputNumber } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
import { Button, TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react";
import { AUTH_TYPE, OAUTH_FLOW, MCPServer, MCPServerCostInfo, TRANSPORT } from "./types";
import { updateMCPServer, testMCPToolsListRequest } from "../networking";
import {
AUTH_TYPE,
OAUTH_FLOW,
mapApiOAuthFlowToUi,
mapUiOAuthFlowToApi,
MCPServer,
MCPServerCostInfo,
TRANSPORT,
} from "./types";
import { updateMCPServer, listMCPTools } from "../networking";
import MCPServerCostConfig from "./mcp_server_cost_config";
import MCPPermissionManagement from "./MCPPermissionManagement";
import MCPToolConfiguration from "./mcp_tool_configuration";
import StdioConfiguration from "./StdioConfiguration";
import MCPLogoSelector from "./MCPLogoSelector";
import { validateMCPServerUrl, validateMCPServerName } from "./utils";
import OAuthFormFields from "./OAuthFormFields";
import NotificationsManager from "../molecules/notifications_manager";
import { useMcpOAuthFlow } from "@/hooks/useMcpOAuthFlow";
import { getSecureItem, setSecureItem } from "@/utils/secureStorage";
@ -37,6 +46,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
const [costConfig, setCostConfig] = useState<MCPServerCostInfo>({});
const [tools, setTools] = useState<any[]>([]);
const [isLoadingTools, setIsLoadingTools] = useState(false);
const [toolsListError, setToolsListError] = useState<string | null>(null);
const [searchValue, setSearchValue] = useState<string>("");
const [aliasManuallyEdited, setAliasManuallyEdited] = useState(false);
const [allowedTools, setAllowedTools] = useState<string[]>([]);
@ -125,6 +135,10 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
transport,
auth_type: AUTH_TYPE.OAUTH2,
credentials: values.credentials,
authorization_url: values.authorization_url ?? mcpServer.authorization_url,
token_url: values.token_url ?? mcpServer.token_url,
registration_url: values.registration_url ?? mcpServer.registration_url,
oauth2_flow: mapUiOAuthFlowToApi(values.oauth_flow_type),
mcp_access_groups: values.mcp_access_groups || mcpServer.mcp_access_groups,
static_headers: staticHeaders,
command: values.command,
@ -189,7 +203,9 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
...mcpServer,
transport: effectiveTransport,
static_headers: initialStaticHeaders,
oauth_flow_type: mcpServer.token_url ? OAUTH_FLOW.M2M : OAUTH_FLOW.INTERACTIVE,
oauth_flow_type:
mapApiOAuthFlowToUi(mcpServer.oauth2_flow) ??
(mcpServer.token_url ? OAUTH_FLOW.M2M : OAUTH_FLOW.INTERACTIVE),
token_validation_json: mcpServer.token_validation
? JSON.stringify(mcpServer.token_validation, null, 2)
: undefined,
@ -271,57 +287,37 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
}
}, [mcpServer]);
// Fetch tools when component mounts or when OAuth token is received
// But only if the server has been properly saved (has a permanent server_id)
// Load tools from the registry (same path as MCP Tools tab) so OAuth M2M and other
// secrets stored only in the DB work — /test/tools/list only sees the request body.
useEffect(() => {
// Don't fetch if server hasn't been saved yet (no permanent server_id)
if (!mcpServer.server_id || mcpServer.server_id.trim() === "") {
return;
}
fetchTools();
}, [mcpServer, accessToken, oauthAccessToken]);
}, [mcpServer.server_id, accessToken]);
const fetchTools = async () => {
if (!accessToken) return;
// HTTP/SSE requires a URL (unless spec_path is set); stdio does not.
if (mcpServer.transport !== "stdio" && !mcpServer.url && !mcpServer.spec_path) return;
const isM2M = mcpServer.auth_type === AUTH_TYPE.OAUTH2 && !!mcpServer.token_url;
if (mcpServer.auth_type === AUTH_TYPE.OAUTH2 && !isM2M && !oauthAccessToken) {
return;
}
setIsLoadingTools(true);
setToolsListError(null);
try {
// Prepare the MCP server config from existing server data
const mcpServerConfig = {
server_id: mcpServer.server_id,
server_name: mcpServer.server_name,
url: mcpServer.url,
transport: mcpServer.transport,
auth_type: mcpServer.auth_type,
mcp_info: mcpServer.mcp_info,
authorization_url: mcpServer.authorization_url,
token_url: mcpServer.token_url,
registration_url: mcpServer.registration_url,
command: mcpServer.command,
args: mcpServer.args,
env: mcpServer.env,
};
const toolsResponse = await testMCPToolsListRequest(accessToken, mcpServerConfig, oauthAccessToken);
const toolsResponse = await listMCPTools(accessToken, mcpServer.server_id);
if (toolsResponse.tools && !toolsResponse.error) {
setTools(toolsResponse.tools);
setToolsListError(null);
} else {
console.error("Failed to fetch tools:", toolsResponse.message);
const msg = toolsResponse.message || "Failed to fetch MCP tools";
console.error("Failed to fetch tools:", msg);
setTools([]);
setToolsListError(msg);
}
} catch (error) {
console.error("Tools fetch error:", error);
setTools([]);
setToolsListError(error instanceof Error ? error.message : String(error));
} finally {
setIsLoadingTools(false);
}
@ -404,6 +400,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
allow_all_keys: allowAllKeysRaw,
available_on_public_internet: availableOnPublicInternetRaw,
token_validation_json: rawTokenValidationJson,
oauth_flow_type: oauthFlowType,
...restValues
} = values;
@ -575,6 +572,9 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
...(tokenValidation !== null || mcpServer.token_validation
? { token_validation: tokenValidation }
: {}),
...(restValues.auth_type === AUTH_TYPE.OAUTH2 && mapUiOAuthFlowToApi(oauthFlowType)
? { oauth2_flow: mapUiOAuthFlowToApi(oauthFlowType) }
: {}),
};
const includeCredentials = restValues.auth_type && AUTH_TYPES_REQUIRING_CREDENTIALS.includes(restValues.auth_type);
@ -782,179 +782,16 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
)}
{!isStdioTransport && isOAuthAuthType && (
<>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
OAuth Client ID (optional)
<Tooltip title="Provide only if your MCP server cannot handle dynamic client registration.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name={["credentials", "client_id"]}
>
<Input.Password
placeholder="Enter OAuth client ID (leave blank to keep existing)"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
OAuth Client Secret (optional)
<Tooltip title="Provide only if your MCP server cannot handle dynamic client registration.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name={["credentials", "client_secret"]}
>
<Input.Password
placeholder="Enter OAuth client secret (leave blank to keep existing)"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
OAuth Scopes (optional)
<Tooltip title="Add scopes to override the default scope list used for this MCP server.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name={["credentials", "scopes"]}
>
<Select
mode="tags"
tokenSeparators={[","]}
placeholder="Add scopes"
className="rounded-lg"
size="large"
/>
</Form.Item>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
Authorization URL Override (optional)
<Tooltip title="Optional override for the authorization endpoint.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name="authorization_url"
>
<Input
placeholder="https://example.com/oauth/authorize"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
Token URL Override (optional)
<Tooltip title="Optional override for the token endpoint.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name="token_url"
>
<Input
placeholder="https://example.com/oauth/token"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
Registration URL Override (optional)
<Tooltip title="Optional override for the dynamic client registration endpoint.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name="registration_url"
>
<Input
placeholder="https://example.com/oauth/register"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
{!isM2MFlow && (
<>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
Token Validation Rules (optional)
<Tooltip title='JSON object of key-value rules checked against the OAuth token response before storing. Supports dot-notation for nested fields (e.g. {"organization": "my-org", "team.id": "123"}). Tokens that fail validation are rejected with HTTP 403.'>
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name="token_validation_json"
rules={[
{
validator: (_: any, value: string) => {
if (!value || value.trim() === "") return Promise.resolve();
try {
JSON.parse(value);
return Promise.resolve();
} catch {
return Promise.reject(new Error("Must be valid JSON"));
}
},
},
]}
>
<Input.TextArea
placeholder={'{\n "organization": "my-org",\n "team.id": "123"\n}'}
rows={4}
className="font-mono text-sm rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
Token Storage TTL (seconds, optional)
<Tooltip title="How long to cache each user's OAuth access token in Redis before evicting it (regardless of the token's own expires_in). Leave blank to derive the TTL from the token's expires_in, or fall back to the 12-hour default.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name="token_storage_ttl_seconds"
>
<InputNumber
min={1}
placeholder="e.g. 3600"
style={{ width: "100%" }}
className="rounded-lg"
/>
</Form.Item>
</>
)}
<div className="rounded-lg border border-dashed border-gray-300 p-4 space-y-2">
<p className="text-sm text-gray-600">Use OAuth to fetch a fresh access token and temporarily save it in the session as the authentication value.</p>
<Button
variant="secondary"
onClick={startOAuthFlow}
disabled={oauthStatus === "authorizing" || oauthStatus === "exchanging"}
>
{oauthStatus === "authorizing"
? "Waiting for authorization..."
: oauthStatus === "exchanging"
? "Exchanging authorization code..."
: "Authorize & Fetch Token"}
</Button>
{oauthError && <p className="text-sm text-red-500">{oauthError}</p>}
{oauthStatus === "success" && oauthTokenResponse?.access_token && (
<p className="text-sm text-green-600">
Token fetched. Expires in {oauthTokenResponse.expires_in ?? "?"} seconds.
</p>
)}
</div>
</>
<OAuthFormFields
isM2M={isM2MFlow}
isEditing
oauthFlow={{
startOAuthFlow,
status: oauthStatus,
error: oauthError,
tokenResponse: oauthTokenResponse,
}}
/>
)}
{!isStdioTransport && isAwsSigV4AuthType && (
@ -1099,6 +936,10 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
<MCPToolConfiguration
accessToken={accessToken}
oauthAccessToken={oauthAccessToken}
externalTools={tools}
externalIsLoading={isLoadingTools}
externalError={toolsListError}
externalCanFetch={!!accessToken && !!mcpServer.server_id}
formValues={{
server_id: mcpServer.server_id,
server_name: currentServerName ?? mcpServer.server_name,
@ -1107,7 +948,10 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
transport: transportType ?? mcpServer.transport,
auth_type: currentAuthType ?? mcpServer.auth_type,
mcp_info: mcpServer.mcp_info,
oauth_flow_type: (currentTokenUrl ?? mcpServer.token_url) ? OAUTH_FLOW.M2M : OAUTH_FLOW.INTERACTIVE,
oauth_flow_type:
oauthFlowTypeValue ??
mapApiOAuthFlowToUi(mcpServer.oauth2_flow) ??
((currentTokenUrl ?? mcpServer.token_url) ? OAUTH_FLOW.M2M : OAUTH_FLOW.INTERACTIVE),
static_headers: currentStaticHeaders ?? mcpServer.static_headers,
credentials: currentCredentials,
authorization_url: currentAuthorizationUrl ?? mcpServer.authorization_url,

View file

@ -2,7 +2,7 @@ import React, { useState } from "react";
import { ArrowLeftIcon, EyeIcon, EyeOffIcon } from "@heroicons/react/outline";
import { Title, Card, Button, Text, Grid, TabGroup, TabList, TabPanel, TabPanels, Tab, Icon } from "@tremor/react";
import { MCPServer, handleTransport, handleAuth } from "./types";
import { MCPServer, formatOAuth2FlowForDisplay, handleTransport, handleAuth, AUTH_TYPE } from "./types";
// TODO: Move Tools viewer from index file
import { MCPToolsViewer } from ".";
import MCPServerEdit from "./mcp_server_edit";
@ -141,6 +141,15 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
</div>
</Card>
{mcpServer.auth_type === AUTH_TYPE.OAUTH2 && (
<Card className="p-4">
<Text className="text-xs font-medium text-gray-500 uppercase tracking-wide">OAuth grant (stored)</Text>
<div className="mt-3">
<Text className="text-sm text-gray-800 leading-snug">{formatOAuth2FlowForDisplay(mcpServer.oauth2_flow)}</Text>
</div>
</Card>
)}
<Card className="p-4">
<Text className="text-xs font-medium text-gray-500 uppercase tracking-wide">Host URL</Text>
<div className="mt-3 flex items-center gap-2">

View file

@ -28,10 +28,16 @@ const EDIT_OAUTH_UI_STATE_KEY = "litellm-mcp-oauth-edit-state";
const { Option } = Select;
const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID }) => {
const { data: mcpServers, isLoading: isLoadingServers, refetch } = useMCPServers();
const { data: mcpServers, isLoading: isLoadingServers, refetch: refetchMcpServers } = useMCPServers();
// Fetch health status for all servers
const { data: healthStatuses, isLoading: isLoadingHealth, recheckServerHealth, recheckingServerIds } = useMCPServerHealth();
const {
data: healthStatuses,
isLoading: isLoadingHealth,
recheckServerHealth,
recheckingServerIds,
refetch: refetchMcpHealth,
} = useMCPServerHealth();
// Merge health status data into servers
const serversWithHealth = useMemo(() => {
@ -190,7 +196,7 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
setIsDeletingServer(true);
await deleteMCPServer(accessToken, serverIdToDelete);
NotificationsManager.success("Deleted MCP Server successfully");
refetch();
refetchMcpServers();
} catch (error) {
console.error("Error deleting the mcp server:", error);
} finally {
@ -210,10 +216,12 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
? (mcpServers || []).find((server) => server.server_id === serverIdToDelete)
: null;
const handleCreateSuccess = (newMcpServer: MCPServer) => {
const handleCreateSuccess = async (newMcpServer: MCPServer) => {
setFilteredServers((prev) => [...prev, newMcpServer]);
setModalVisible(false);
refetch();
// Refresh list + health so the new row shows real status (not "unknown" until full page reload).
await refetchMcpServers();
await refetchMcpHealth();
};
// Memoize the selected server to prevent unnecessary re-renders
@ -236,8 +244,8 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
const handleBack = React.useCallback(() => {
setEditServer(false);
setSelectedServerId(null);
refetch();
}, [refetch]);
refetchMcpServers();
}, [refetchMcpServers]);
if (!accessToken || !userRole || !userID) {
console.log("Missing required authentication parameters", { accessToken, userRole, userID });
@ -455,7 +463,7 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
open={!!byokModalServer}
onClose={() => setByokModalServer(null)}
onSuccess={(_serverId) => {
refetch();
refetchMcpServers();
setByokModalServer(null);
}}
accessToken={accessToken || ""}

View file

@ -384,14 +384,6 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
)}
</div>
{/* Description */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<Text className="text-blue-800 text-sm">
<strong>Select which tools users can call:</strong> Only checked tools will be available for users to
invoke. Unchecked tools will be blocked from execution.
</Text>
</div>
{/* Loading state */}
{isLoadingTools && (
<div className="flex items-center justify-center py-6">

View file

@ -1,5 +1,14 @@
import { describe, it, expect } from "vitest";
import { AUTH_TYPE, OAUTH_FLOW, TRANSPORT, handleTransport, handleAuth } from "./types";
import {
AUTH_TYPE,
OAUTH_FLOW,
TRANSPORT,
handleTransport,
handleAuth,
mapUiOAuthFlowToApi,
mapApiOAuthFlowToUi,
formatOAuth2FlowForDisplay,
} from "./types";
describe("handleTransport", () => {
it("should default to SSE when transport is null", () => {
@ -57,3 +66,22 @@ describe("constants", () => {
expect(OAUTH_FLOW.M2M).toBe("m2m");
});
});
describe("OAuth2 flow API mapping", () => {
it("maps UI M2M to client_credentials", () => {
expect(mapUiOAuthFlowToApi(OAUTH_FLOW.M2M)).toBe("client_credentials");
});
it("maps UI interactive to authorization_code", () => {
expect(mapUiOAuthFlowToApi(OAUTH_FLOW.INTERACTIVE)).toBe("authorization_code");
});
it("maps API values back to UI constants", () => {
expect(mapApiOAuthFlowToUi("client_credentials")).toBe(OAUTH_FLOW.M2M);
expect(mapApiOAuthFlowToUi("authorization_code")).toBe(OAUTH_FLOW.INTERACTIVE);
expect(mapApiOAuthFlowToUi(null)).toBeUndefined();
});
it("formats display strings", () => {
expect(formatOAuth2FlowForDisplay("client_credentials")).toContain("Machine-to-machine");
expect(formatOAuth2FlowForDisplay("authorization_code")).toContain("Interactive");
expect(formatOAuth2FlowForDisplay(null)).toContain("infer");
});
});

View file

@ -47,6 +47,29 @@ export const OAUTH_FLOW = {
M2M: "m2m",
};
/** Values persisted by the proxy / API (`NewMCPServerRequest.oauth2_flow`). */
export type OAuth2FlowApi = "client_credentials" | "authorization_code";
export function mapUiOAuthFlowToApi(ui: string | null | undefined): OAuth2FlowApi | undefined {
if (ui === OAUTH_FLOW.M2M) return "client_credentials";
if (ui === OAUTH_FLOW.INTERACTIVE) return "authorization_code";
return undefined;
}
export function mapApiOAuthFlowToUi(api: string | null | undefined): typeof OAUTH_FLOW.M2M | typeof OAUTH_FLOW.INTERACTIVE | undefined {
if (api === "client_credentials") return OAUTH_FLOW.M2M;
if (api === "authorization_code") return OAUTH_FLOW.INTERACTIVE;
return undefined;
}
/** Human-readable label for overview / read-only UI. */
export function formatOAuth2FlowForDisplay(api: string | null | undefined): string {
if (api === "client_credentials") return "Machine-to-machine (client credentials)";
if (api === "authorization_code") return "Interactive (authorization code)";
if (api == null || api === "") return "Not set — proxy may infer from credentials";
return api;
}
export const TRANSPORT = {
SSE: "sse",
HTTP: "http",
@ -185,6 +208,8 @@ export interface MCPServer {
authorization_url?: string | null;
token_url?: string | null;
registration_url?: string | null;
/** OAuth2 grant: `client_credentials` (M2M) or `authorization_code` (interactive). */
oauth2_flow?: string | null;
mcp_info?: MCPInfo | null;
created_at: string;
created_by: string;