mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
[UI] M2M OAuth2 UI Flow (#20794)
* add has_client_credentials * MCPOAuth2TokenCache * init MCP Oauth2 constants * MCPOAuth2TokenCache * resolve_mcp_auth * test fixes * docs fix * address greptile review: min TTL, env-configurable constants, tests, docs - Fix zero-TTL edge case: floor at MCP_OAUTH2_TOKEN_CACHE_MIN_TTL (10s) - Make all MCP OAuth2 constants env-configurable via os.getenv() - Move test file to follow 1:1 mapping convention (test_oauth2_token_cache.py) - Add MCP OAuth doc page (mcp_oauth.md) with M2M and PKCE sections - Update FAQ in mcp.md to reflect M2M support - Add E2E test script and config Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix mypy lint * fix oauth2 * ui feat fixes * test M2M * test fix * ui feats * ui fixes * ui fix client ID * fix: backend endpoints * docs fix * fixes greptile --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b70f97e653
commit
36e0361187
10 changed files with 494 additions and 280 deletions
|
|
@ -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
|
||||
|
||||
<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**.
|
||||
|
||||

|
||||
|
||||
Enter a name for your server and select **HTTP** as the transport type.
|
||||
|
||||

|
||||
|
||||
Paste the MCP server URL.
|
||||
|
||||

|
||||
|
||||
Under **Authentication**, select **OAuth**.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
Fill in the **Client ID** and **Client Secret** provided by your OAuth provider.
|
||||
|
||||

|
||||
|
||||
Enter the **Token URL** — this is the endpoint LiteLLM will call to fetch access tokens using `client_credentials`.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
Scroll down and review the server URL and all fields, then click **Create MCP Server**.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
Once created, open the server and navigate to the **MCP Tools** tab to verify that LiteLLM can connect and list available tools.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
Select a tool (e.g. **echo**) to test it. Fill in the required parameters and click **Call Tool**.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
### Config.yaml Setup
|
||||
|
||||
```yaml title="config.yaml" showLineNumbers
|
||||
mcp_servers:
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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 @@ if MCP_AVAILABLE:
|
|||
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 @@ if MCP_AVAILABLE:
|
|||
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 @@ if MCP_AVAILABLE:
|
|||
|
||||
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)])
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ mcp_servers:
|
|||
transport: "http"
|
||||
url: "https://mcp.deepwiki.com/mcp"
|
||||
|
||||
|
||||
# General Settings
|
||||
general_settings:
|
||||
master_key: sk-1234
|
||||
|
|
|
|||
14
tests/mcp_tests/test_oauth2_mcp_config.yaml
Normal file
14
tests/mcp_tests/test_oauth2_mcp_config.yaml
Normal file
|
|
@ -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"
|
||||
|
|
@ -155,6 +155,160 @@ class TestExecuteWithMcpClient:
|
|||
}
|
||||
|
||||
|
||||
@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")
|
||||
|
|
|
|||
|
|
@ -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 }) => (
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
{label}
|
||||
<Tooltip title={tooltip}>
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
);
|
||||
|
||||
const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
|
||||
isM2M,
|
||||
isEditing = false,
|
||||
oauthFlow,
|
||||
initialFlowType,
|
||||
}) => {
|
||||
const placeholderSuffix = isEditing ? " (leave blank to keep existing)" : "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<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."
|
||||
/>
|
||||
}
|
||||
name="oauth_flow_type"
|
||||
{...(initialFlowType ? { initialValue: initialFlowType } : {})}
|
||||
>
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
{isM2M ? (
|
||||
<>
|
||||
<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" }]}
|
||||
>
|
||||
<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" }]}
|
||||
>
|
||||
<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" }]}
|
||||
>
|
||||
<TextInput placeholder="https://auth.example.com/oauth/token" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={<FieldLabel label="Scopes (optional)" tooltip="Optional scopes to request with the client_credentials grant." />}
|
||||
name={["credentials", "scopes"]}
|
||||
>
|
||||
<Select mode="tags" tokenSeparators={[","]} placeholder="Add scopes" className="rounded-lg" size="large" />
|
||||
</Form.Item>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Form.Item
|
||||
label={<FieldLabel label="Client ID (optional)" tooltip="Provide only if your MCP server cannot handle dynamic client registration." />}
|
||||
name={["credentials", "client_id"]}
|
||||
>
|
||||
<TextInput type="password" placeholder={`Enter client ID${placeholderSuffix}`} className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={<FieldLabel label="Client Secret (optional)" tooltip="Provide only if your MCP server cannot handle dynamic client registration." />}
|
||||
name={["credentials", "client_secret"]}
|
||||
>
|
||||
<TextInput type="password" placeholder={`Enter client secret${placeholderSuffix}`} className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={<FieldLabel label="Scopes (optional)" tooltip="Optional scopes requested during token exchange. Separate multiple scopes with enter or commas." />}
|
||||
name={["credentials", "scopes"]}
|
||||
>
|
||||
<Select mode="tags" tokenSeparators={[","]} placeholder="Add scopes" className="rounded-lg" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={<FieldLabel label="Authorization URL (optional)" tooltip="Optional override for the authorization endpoint." />}
|
||||
name="authorization_url"
|
||||
>
|
||||
<TextInput placeholder="https://example.com/oauth/authorize" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={<FieldLabel label="Token URL (optional)" tooltip="Optional override for the token endpoint." />}
|
||||
name="token_url"
|
||||
>
|
||||
<TextInput placeholder="https://example.com/oauth/token" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={<FieldLabel label="Registration URL (optional)" tooltip="Optional override for the dynamic client registration endpoint." />}
|
||||
name="registration_url"
|
||||
>
|
||||
<TextInput placeholder="https://example.com/oauth/register" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
{oauthFlow && (
|
||||
<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={oauthFlow.startOAuthFlow}
|
||||
disabled={oauthFlow.status === "authorizing" || oauthFlow.status === "exchanging"}
|
||||
>
|
||||
{oauthFlow.status === "authorizing"
|
||||
? "Waiting for authorization..."
|
||||
: oauthFlow.status === "exchanging"
|
||||
? "Exchanging authorization code..."
|
||||
: "Authorize & Fetch Token"}
|
||||
</Button>
|
||||
{oauthFlow.error && <p className="text-sm text-red-500">{oauthFlow.error}</p>}
|
||||
{oauthFlow.status === "success" && oauthFlow.tokenResponse?.access_token && (
|
||||
<p className="text-sm text-green-600">
|
||||
Token fetched. Expires in {oauthFlow.tokenResponse.expires_in ?? "?"} seconds.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default OAuthFormFields;
|
||||
|
|
@ -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<CreateMCPServerProps> = ({
|
|||
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<CreateMCPServerProps> = ({
|
|||
rules={[
|
||||
{
|
||||
required: false,
|
||||
message: "Please enter a server description!!!!!!!!!",
|
||||
message: "Please enter a server description",
|
||||
},
|
||||
]}
|
||||
>
|
||||
|
|
@ -561,131 +563,16 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
)}
|
||||
|
||||
{transportType !== "stdio" && 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"]}
|
||||
>
|
||||
<TextInput
|
||||
type="password"
|
||||
placeholder="Enter OAuth client ID"
|
||||
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"]}
|
||||
>
|
||||
<TextInput
|
||||
type="password"
|
||||
placeholder="Enter OAuth client secret"
|
||||
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="Optional scopes requested during token exchange. Separate multiple scopes with enter or commas.">
|
||||
<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"
|
||||
>
|
||||
<TextInput
|
||||
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"
|
||||
>
|
||||
<TextInput
|
||||
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 orverride for the dynamic client registration endpoint.">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="registration_url"
|
||||
>
|
||||
<TextInput
|
||||
placeholder="https://example.com/oauth/register"
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</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}
|
||||
initialFlowType={OAUTH_FLOW.INTERACTIVE}
|
||||
oauthFlow={{
|
||||
startOAuthFlow,
|
||||
status: oauthStatus,
|
||||
error: oauthError,
|
||||
tokenResponse: oauthTokenResponse,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Stdio Configuration - only show for stdio transport */}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import React, { useState, useEffect } from "react";
|
|||
import { Form, Select, Button as AntdButton, Tooltip } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { Button, TextInput, TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react";
|
||||
import { AUTH_TYPE, MCPServer, MCPServerCostInfo } from "./types";
|
||||
import { AUTH_TYPE, OAUTH_FLOW, MCPServer, MCPServerCostInfo } from "./types";
|
||||
import OAuthFormFields from "./OAuthFormFields";
|
||||
import { updateMCPServer, testMCPToolsListRequest } from "../networking";
|
||||
import MCPServerCostConfig from "./mcp_server_cost_config";
|
||||
import MCPPermissionManagement from "./MCPPermissionManagement";
|
||||
|
|
@ -41,7 +42,9 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
const authType = Form.useWatch("auth_type", form) as string | undefined;
|
||||
const shouldShowAuthValueField = authType ? AUTH_TYPES_REQUIRING_AUTH_VALUE.includes(authType) : false;
|
||||
const isOAuthAuthType = authType === AUTH_TYPE.OAUTH2;
|
||||
|
||||
const oauthFlowTypeValue = Form.useWatch("oauth_flow_type", form) as string | undefined;
|
||||
const isM2MFlow = isOAuthAuthType && oauthFlowTypeValue === OAUTH_FLOW.M2M;
|
||||
|
||||
const [oauthAccessToken, setOauthAccessToken] = useState<string | null>(null);
|
||||
|
||||
const persistEditUiState = () => {
|
||||
|
|
@ -128,6 +131,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
() => ({
|
||||
...mcpServer,
|
||||
static_headers: initialStaticHeaders,
|
||||
oauth_flow_type: mcpServer.token_url ? OAUTH_FLOW.M2M : OAUTH_FLOW.INTERACTIVE,
|
||||
}),
|
||||
[mcpServer, initialStaticHeaders],
|
||||
);
|
||||
|
|
@ -214,7 +218,8 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
if (mcpServer.auth_type === AUTH_TYPE.OAUTH2 && !oauthAccessToken) {
|
||||
const isM2M = mcpServer.auth_type === AUTH_TYPE.OAUTH2 && !!mcpServer.token_url;
|
||||
if (mcpServer.auth_type === AUTH_TYPE.OAUTH2 && !isM2M && !oauthAccessToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -452,129 +457,16 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
)}
|
||||
|
||||
{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"]}
|
||||
>
|
||||
<TextInput
|
||||
type="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"]}
|
||||
>
|
||||
<TextInput
|
||||
type="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"
|
||||
>
|
||||
<TextInput
|
||||
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"
|
||||
>
|
||||
<TextInput
|
||||
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"
|
||||
>
|
||||
<TextInput
|
||||
placeholder="https://example.com/oauth/register"
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</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,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Permission Management / Access Control Section */}
|
||||
|
|
@ -600,6 +492,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue