mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
Merge pull request #32414 from BerriAI/litellm_mcp_passthrough_ui_enum
feat(mcp/ui): expose true_passthrough and oauth_delegate auth types with a no-auth warning
This commit is contained in:
commit
68a4ca7247
20 changed files with 981 additions and 190 deletions
|
|
@ -465,8 +465,19 @@ async def _store_per_user_token_server_side(
|
|||
|
||||
|
||||
def _raise_if_not_oauth2(mcp_server: MCPServer) -> None:
|
||||
"""Reject a non-oauth2 server from the gateway's OAuth authorize/token/register flow."""
|
||||
if mcp_server.auth_type == MCPAuth.oauth2:
|
||||
"""Reject a server without upstream OAuth from the gateway's authorize/token/register flow.
|
||||
|
||||
The client-forwarded token modes (``true_passthrough`` / ``oauth_delegate``) are allowed
|
||||
through: the caller owns the upstream token, and this relayed flow is how a browser obtains
|
||||
one against the upstream IdP (the admin UI's browser-only Authorize uses it). The minted
|
||||
token is upstream-audienced and held by the caller; the gateway persists nothing for these
|
||||
modes (DCR persistence is opt-in and never enabled on this path).
|
||||
"""
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( # noqa: PLC0415 # circular import with mcp_server_manager at module load
|
||||
_UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES,
|
||||
)
|
||||
|
||||
if mcp_server.auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES:
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
|
|
@ -515,8 +526,7 @@ async def authorize_with_server(
|
|||
response_type: Optional[str] = None,
|
||||
scope: Optional[str] = None,
|
||||
):
|
||||
if mcp_server.auth_type != "oauth2":
|
||||
raise HTTPException(status_code=400, detail="MCP server is not OAuth2")
|
||||
_raise_if_not_oauth2(mcp_server)
|
||||
if mcp_server.authorization_url is None:
|
||||
raise HTTPException(status_code=400, detail="MCP server authorization url is not set")
|
||||
|
||||
|
|
|
|||
|
|
@ -171,6 +171,16 @@ _user_env_vars_cache: dict[tuple[str, str], tuple[dict[str, str], float]] = {}
|
|||
_USER_ENV_VARS_CACHE_TTL = 60 # seconds
|
||||
_USER_ENV_VARS_CACHE_MAX_SIZE = 4096 # cap to prevent unbounded growth
|
||||
|
||||
# Auth types whose upstream OAuth endpoints (protected-resource + authorization-server metadata) the
|
||||
# gateway discovers from the upstream itself: interactive oauth2 and the two client-forwarded modes.
|
||||
# OBO/M2M endpoint discovery is decided separately via _obo_needs_endpoint_discovery. Shared by the
|
||||
# config-YAML and DB server loaders so the two paths cannot drift on which modes trigger discovery.
|
||||
_UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES: tuple[MCPAuth, ...] = (
|
||||
MCPAuth.oauth2,
|
||||
MCPAuth.true_passthrough,
|
||||
MCPAuth.oauth_delegate,
|
||||
)
|
||||
|
||||
|
||||
def invalidate_user_env_vars_cache(user_id: str, server_id: str) -> None:
|
||||
"""Drop a cached entry after the user stores or clears their env var values
|
||||
|
|
@ -984,7 +994,7 @@ class MCPServerManager:
|
|||
|
||||
auth_type = server_config.get("auth_type", None)
|
||||
if server_url and (
|
||||
auth_type == MCPAuth.oauth2
|
||||
auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES
|
||||
or self._obo_needs_endpoint_discovery(
|
||||
auth_type,
|
||||
server_config.get("token_exchange_endpoint"),
|
||||
|
|
@ -993,7 +1003,7 @@ class MCPServerManager:
|
|||
):
|
||||
mcp_oauth_metadata = await self._descovery_metadata(
|
||||
server_url=server_url,
|
||||
allow_origin_fallback=auth_type == MCPAuth.oauth2,
|
||||
allow_origin_fallback=auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES,
|
||||
)
|
||||
else:
|
||||
mcp_oauth_metadata = None
|
||||
|
|
@ -1385,7 +1395,7 @@ class MCPServerManager:
|
|||
auth_type = cast(MCPAuthType, mcp_server.auth_type)
|
||||
server_url = mcp_server.url
|
||||
needs_discovery = bool(server_url) and (
|
||||
(auth_type == MCPAuth.oauth2 and not mcp_server.authorization_url)
|
||||
(auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES and not mcp_server.authorization_url)
|
||||
or self._obo_needs_endpoint_discovery(
|
||||
auth_type,
|
||||
mcp_server.token_exchange_endpoint
|
||||
|
|
@ -1396,7 +1406,7 @@ class MCPServerManager:
|
|||
mcp_oauth_metadata = (
|
||||
await self._descovery_metadata(
|
||||
server_url=server_url, # type: ignore[arg-type]
|
||||
allow_origin_fallback=auth_type == MCPAuth.oauth2,
|
||||
allow_origin_fallback=auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES,
|
||||
)
|
||||
if needs_discovery
|
||||
else None
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ if MCP_AVAILABLE:
|
|||
from mcp.types import Tool as MCPTool
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
|
||||
_UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES,
|
||||
global_mcp_server_manager,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.oauth_utils import (
|
||||
|
|
@ -1321,8 +1322,13 @@ if MCP_AVAILABLE:
|
|||
if isinstance(credentials, dict):
|
||||
mcp_auth_header = credentials.get("auth_value")
|
||||
|
||||
# Authorization doubles as the admission fallback (LITELLM_API_KEY_HEADER_NAME_SECONDARY):
|
||||
# when the primary x-litellm-api-key header is absent, the Authorization value is the
|
||||
# caller's LiteLLM key, not an upstream token, and must never be forwarded upstream.
|
||||
oauth2_headers: Optional[Dict[str, str]] = None
|
||||
if new_mcp_server_request.auth_type == MCPAuth.oauth2:
|
||||
if new_mcp_server_request.auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES and headers.get(
|
||||
MCPRequestHandler.LITELLM_API_KEY_HEADER_NAME_PRIMARY
|
||||
):
|
||||
oauth2_headers = MCPRequestHandler._get_oauth2_headers_from_headers(headers)
|
||||
|
||||
async def _list_tools_operation(client):
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ from typing import (
|
|||
Union,
|
||||
cast,
|
||||
)
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, HTTPException
|
||||
|
|
@ -105,6 +106,27 @@ _MAX_STATEFUL_SESSIONS_PER_OWNER = 100
|
|||
_MCP_ROUTING_PEEK_MAX_BYTES = 4096
|
||||
|
||||
|
||||
def _redact_mcp_resource_url(url: Optional[str]) -> Optional[str]:
|
||||
"""Reduce an MCP server URL to its origin (scheme + host + port) for logging.
|
||||
|
||||
Everything else is dropped: userinfo (``user:pass@``), the query string, the
|
||||
fragment, and the path, because hosted MCP servers routinely embed the
|
||||
credential in the path (e.g. ``/mcp/s/<token>``) and this value is persisted
|
||||
in spend-log metadata that a caller who can invoke the tool can read back.
|
||||
Returns None when the URL has no host to identify (nothing safe to log).
|
||||
"""
|
||||
if not isinstance(url, str) or not url:
|
||||
return None
|
||||
try:
|
||||
parts = urlsplit(url)
|
||||
except ValueError:
|
||||
return None
|
||||
if not parts.hostname:
|
||||
return None
|
||||
netloc = f"{parts.hostname}:{parts.port}" if parts.port else parts.hostname
|
||||
return urlunsplit((parts.scheme, netloc, "", "", "")) or None
|
||||
|
||||
|
||||
def _invalidate_byok_cred_cache(user_id: str, server_id: str) -> None:
|
||||
"""Remove a (user_id, server_id) entry from the BYOK credential cache.
|
||||
|
||||
|
|
@ -3064,6 +3086,8 @@ if MCP_AVAILABLE:
|
|||
mcp_server_logo_url=mcp_info.get("logo_url"),
|
||||
namespaced_tool_name=namespaced_tool_name,
|
||||
mcp_session_id=session_id,
|
||||
mcp_auth_mode=mcp_server.auth_type,
|
||||
mcp_server_resource=_redact_mcp_resource_url(mcp_server.url),
|
||||
)
|
||||
else:
|
||||
return StandardLoggingMCPToolCall(
|
||||
|
|
|
|||
|
|
@ -2523,6 +2523,22 @@ class StandardLoggingMCPToolCall(TypedDict, total=False):
|
|||
the client is driving a stateful session. Absent for stateless calls.
|
||||
"""
|
||||
|
||||
mcp_auth_mode: Optional[str]
|
||||
"""
|
||||
The server's auth_type for this call (e.g. `true_passthrough`, `oauth_delegate`,
|
||||
`oauth2`). For the client-forwarded token modes this records that the caller's own
|
||||
upstream token was relayed, so an audit can attribute a relayed request to its mode
|
||||
without logging any credential.
|
||||
"""
|
||||
|
||||
mcp_server_resource: Optional[str]
|
||||
"""
|
||||
The upstream MCP server resource identifier (scheme + host + path) the tool call was
|
||||
forwarded to. Redacted for logging: userinfo, query string, and fragment are stripped so an
|
||||
upstream URL carrying an embedded token or secret query parameter never reaches log metadata.
|
||||
Records which upstream received a relayed request; never a credential.
|
||||
"""
|
||||
|
||||
|
||||
class StandardLoggingVectorStoreRequest(TypedDict, total=False):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from litellm.types.mcp import MCPAuth
|
||||
|
||||
|
||||
# Fixture to mock IP address check for all MCP tests
|
||||
# This prevents tests from failing due to IP-based access control
|
||||
|
|
@ -119,6 +121,61 @@ async def test_authorize_endpoint_includes_response_type():
|
|||
assert "scope=read+write" in response.headers["location"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("auth_type_value", ["true_passthrough", "oauth_delegate"])
|
||||
async def test_authorize_endpoint_allows_client_forwarded_modes(auth_type_value):
|
||||
"""The browser-only Authorize relays the gateway authorize flow for the client-forwarded
|
||||
token modes; the oauth2-only gate must let them through and redirect to the upstream IdP."""
|
||||
try:
|
||||
from fastapi import Request
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
authorize,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
|
||||
global_mcp_server_manager,
|
||||
)
|
||||
from litellm.proxy._types import MCPTransport
|
||||
from litellm.types.mcp import MCPAuth
|
||||
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
||||
except ImportError:
|
||||
pytest.skip("MCP discoverable endpoints not available")
|
||||
|
||||
global_mcp_server_manager.registry.clear()
|
||||
|
||||
server = MCPServer(
|
||||
server_id="test_cf_server",
|
||||
name="test_cf",
|
||||
server_name="test_cf",
|
||||
alias="test_cf",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth(auth_type_value),
|
||||
# Discovery stamps these onto the in-memory registry entry at build time.
|
||||
authorization_url="https://provider.com/oauth/authorize",
|
||||
token_url="https://provider.com/oauth/token",
|
||||
)
|
||||
global_mcp_server_manager.registry[server.server_id] = server
|
||||
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.base_url = "https://litellm.example.com/"
|
||||
mock_request.headers = {}
|
||||
|
||||
with patch("litellm.proxy._experimental.mcp_server.discoverable_endpoints.encrypt_value_helper") as mock_encrypt:
|
||||
mock_encrypt.return_value = "mocked_encrypted_state"
|
||||
|
||||
response = await authorize(
|
||||
request=mock_request,
|
||||
client_id="dcr_client_id",
|
||||
mcp_server_name="test_cf",
|
||||
redirect_uri="http://127.0.0.1:60108/callback",
|
||||
state="test_state",
|
||||
)
|
||||
|
||||
assert response.status_code == 307
|
||||
assert "https://provider.com/oauth/authorize" in response.headers["location"]
|
||||
assert "client_id=dcr_client_id" in response.headers["location"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authorize_endpoint_preserves_existing_query_params():
|
||||
"""Test that authorize endpoint merges OAuth params with existing query params in authorization_url"""
|
||||
|
|
@ -3348,6 +3405,83 @@ async def test_token_exchange_passes_through_upstream_expires_in():
|
|||
assert body["expires_in"] == 43200
|
||||
|
||||
|
||||
async def _exchange_persistence_attempted_for_auth_type(auth_type) -> bool:
|
||||
"""Run exchange_token_with_server for a server of ``auth_type`` and report whether it attempted
|
||||
to persist the exchanged token server-side. The client-forwarded token modes must not persist:
|
||||
their contract is that the upstream token stays browser-held, minted/stored/refreshed nowhere."""
|
||||
from fastapi import Request
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
exchange_token_with_server,
|
||||
)
|
||||
from litellm.proxy._types import MCPTransport
|
||||
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
||||
|
||||
server = MCPServer(
|
||||
server_id="t",
|
||||
name="t",
|
||||
server_name="t",
|
||||
alias="t",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=auth_type,
|
||||
client_id="cid",
|
||||
client_secret="cs",
|
||||
authorization_url="https://provider.com/oauth/authorize",
|
||||
token_url="https://provider.com/oauth/token",
|
||||
)
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.base_url = "https://litellm.example.com/"
|
||||
mock_request.headers = {}
|
||||
|
||||
fake_http_response = MagicMock()
|
||||
fake_http_response.json.return_value = {"access_token": "tok", "refresh_token": "r", "token_type": "Bearer"}
|
||||
fake_http_response.raise_for_status = MagicMock()
|
||||
fake_http_client = MagicMock()
|
||||
fake_http_client.post = AsyncMock(return_value=fake_http_response)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.discoverable_endpoints.get_async_httpx_client",
|
||||
return_value=fake_http_client,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.discoverable_endpoints._extract_user_id_from_request",
|
||||
new_callable=AsyncMock,
|
||||
return_value="admin-user",
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.discoverable_endpoints._store_per_user_token_server_side",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_store,
|
||||
):
|
||||
await exchange_token_with_server(
|
||||
request=mock_request,
|
||||
mcp_server=server,
|
||||
grant_type="authorization_code",
|
||||
code="c",
|
||||
redirect_uri="http://127.0.0.1:3000/cb",
|
||||
client_id="cid",
|
||||
client_secret=None,
|
||||
code_verifier=None,
|
||||
)
|
||||
return mock_store.await_count > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("auth_type", [MCPAuth.true_passthrough, MCPAuth.oauth_delegate])
|
||||
async def test_token_exchange_does_not_persist_for_client_forwarded_modes(auth_type):
|
||||
"""The browser-only Authorize for true_passthrough / oauth_delegate must not write the upstream
|
||||
token to the DB: these modes forward a browser-held token and persist nothing server-side."""
|
||||
assert await _exchange_persistence_attempted_for_auth_type(auth_type) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_exchange_persists_for_oauth2():
|
||||
"""Guard the test's own discriminator: a genuine oauth2 (authorization_code) server DOES persist,
|
||||
so the passthrough no-persist assertion above is meaningful and not vacuously true."""
|
||||
assert await _exchange_persistence_attempted_for_auth_type(MCPAuth.oauth2) is True
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# OBO (token_exchange) Protected Resource Metadata: discovery must name the
|
||||
# JWT-auth issuer the client SSOs with, not the gateway.
|
||||
|
|
|
|||
|
|
@ -6854,3 +6854,27 @@ async def test_call_tool_with_legacy_db_m2m_server_resolves_oauth2_flow():
|
|||
resolved = captured_servers["allowed"]
|
||||
assert resolved and resolved[0].oauth2_flow == "client_credentials"
|
||||
assert resolved[0].has_client_credentials is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url, expected",
|
||||
[
|
||||
# only the origin may be logged: userinfo, query, fragment, and the PATH are all stripped,
|
||||
# because hosted MCP servers routinely embed the credential in the path (e.g. /mcp/s/<token>)
|
||||
("https://user:s3cr3t@mcp.example.com/mcp?token=abcd1234&x=1", "https://mcp.example.com"),
|
||||
("https://mcp.example.com/mcp#frag", "https://mcp.example.com"),
|
||||
("https://host:8443/a/b?q=1", "https://host:8443"),
|
||||
("https://mcp.zapier.com/api/mcp/s/NDgzcret-token/mcp", "https://mcp.zapier.com"),
|
||||
("https://mcp.notion.com/mcp", "https://mcp.notion.com"),
|
||||
(None, None),
|
||||
("", None),
|
||||
("not a url", None),
|
||||
],
|
||||
)
|
||||
def test_redact_mcp_resource_url_strips_credentials(url, expected):
|
||||
"""The MCP tool-call log records the upstream resource, so the URL must be redacted to
|
||||
scheme+host+path: userinfo, query string, and fragment (which can carry embedded tokens or
|
||||
secret parameters) must never reach spend-log metadata or logging callbacks."""
|
||||
from litellm.proxy._experimental.mcp_server.server import _redact_mcp_resource_url
|
||||
|
||||
assert _redact_mcp_resource_url(url) == expected
|
||||
|
|
|
|||
|
|
@ -833,6 +833,39 @@ class TestMCPServerManager:
|
|||
assert spec is not None and isinstance(spec.config, TokenExchangeConfig)
|
||||
assert spec.config.profile == "entra_obo"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("auth_type", [MCPAuth.true_passthrough, MCPAuth.oauth_delegate])
|
||||
async def test_build_from_table_discovers_upstream_oauth_for_client_forwarded_modes(self, auth_type):
|
||||
"""The gateway's relayed authorize flow (used by the browser-only Authorize) needs the
|
||||
upstream's authorization_url on the registry entry, and these rows never persist one, so
|
||||
the DB build must discover it the same way oauth2 rows do."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
manager = MCPServerManager()
|
||||
row = LiteLLM_MCPServerTable(
|
||||
server_id="cf-db-1",
|
||||
alias="cf_db",
|
||||
description="client-forwarded from db",
|
||||
url="https://up.example.com/mcp",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=auth_type,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
|
||||
metadata = SimpleNamespace(
|
||||
authorization_url="https://idp.example.com/authorize",
|
||||
token_url="https://idp.example.com/token",
|
||||
registration_url="https://idp.example.com/register",
|
||||
scopes=None,
|
||||
)
|
||||
with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=metadata)) as mock_discovery:
|
||||
built = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False)
|
||||
|
||||
mock_discovery.assert_awaited_once()
|
||||
assert built.authorization_url == "https://idp.example.com/authorize"
|
||||
assert built.token_url == "https://idp.example.com/token"
|
||||
|
||||
async def _capture_subject_token(self, call) -> Optional[str]:
|
||||
"""Run a manager method (via ``call(manager)``) and return the subject_token it threaded
|
||||
into ``_create_mcp_client``."""
|
||||
|
|
|
|||
|
|
@ -37,10 +37,7 @@ def _build_request(
|
|||
body_bytes = body
|
||||
else:
|
||||
body_bytes = b""
|
||||
raw_headers = [
|
||||
(key.lower().encode("latin-1"), value.encode("latin-1"))
|
||||
for key, value in headers.items()
|
||||
]
|
||||
raw_headers = [(key.lower().encode("latin-1"), value.encode("latin-1")) for key, value in headers.items()]
|
||||
scope = {
|
||||
"type": "http",
|
||||
"http_version": "1.1",
|
||||
|
|
@ -62,25 +59,18 @@ def _build_request(
|
|||
|
||||
def _get_route(path: str, method: str):
|
||||
for route in rest_endpoints.router.routes:
|
||||
if getattr(route, "path", None) == path and method in getattr(
|
||||
route, "methods", set()
|
||||
):
|
||||
if getattr(route, "path", None) == path and method in getattr(route, "methods", set()):
|
||||
return route
|
||||
raise AssertionError(f"Route {method} {path} not found")
|
||||
|
||||
|
||||
def _route_has_dependency(route, dependency) -> bool:
|
||||
if any(
|
||||
getattr(dep, "dependency", None) == dependency
|
||||
for dep in getattr(route, "dependencies", [])
|
||||
):
|
||||
if any(getattr(dep, "dependency", None) == dependency for dep in getattr(route, "dependencies", [])):
|
||||
return True
|
||||
dependant = getattr(route, "dependant", None)
|
||||
if dependant is None:
|
||||
return False
|
||||
return any(
|
||||
getattr(dep, "call", None) == dependency for dep in dependant.dependencies
|
||||
)
|
||||
return any(getattr(dep, "call", None) == dependency for dep in dependant.dependencies)
|
||||
|
||||
|
||||
class TestExecuteWithMcpClient:
|
||||
|
|
@ -104,9 +94,7 @@ class TestExecuteWithMcpClient:
|
|||
auth_type=MCPAuth.none,
|
||||
)
|
||||
|
||||
result = await rest_endpoints._execute_with_mcp_client(
|
||||
payload, failing_operation
|
||||
)
|
||||
result = await rest_endpoints._execute_with_mcp_client(payload, failing_operation)
|
||||
|
||||
assert result["status"] == "error"
|
||||
assert "stack_trace" not in result
|
||||
|
|
@ -267,15 +255,10 @@ class TestExecuteWithMcpClient:
|
|||
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"]
|
||||
)
|
||||
assert captured["extra_headers"] is None or "Authorization" not in captured["extra_headers"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interactive_oauth_resolves_forwarded_token_via_presented_store(
|
||||
self, monkeypatch
|
||||
):
|
||||
async def test_interactive_oauth_resolves_forwarded_token_via_presented_store(self, monkeypatch):
|
||||
"""Interactive authorization_code preview (oauth2, no client credentials): the forwarded
|
||||
just-authorized token is resolved THROUGH the v2 resolver via a one-shot presented store
|
||||
(cred_provider), not the caller-override path. The bare token (Bearer stripped) is the
|
||||
|
|
@ -433,9 +416,7 @@ class TestExecuteWithMcpClient:
|
|||
return None
|
||||
|
||||
async def fake_create_client(*args, **kwargs):
|
||||
raise BaseExceptionGroup(
|
||||
"test group", [RuntimeError("Cancelled via cancel scope")]
|
||||
)
|
||||
raise BaseExceptionGroup("test group", [RuntimeError("Cancelled via cancel scope")])
|
||||
|
||||
monkeypatch.setattr(
|
||||
rest_endpoints.global_mcp_server_manager,
|
||||
|
|
@ -497,9 +478,7 @@ class TestTestToolsList:
|
|||
"message": "Successfully retrieved tools",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
rest_endpoints, "_execute_with_mcp_client", fake_execute, raising=False
|
||||
)
|
||||
monkeypatch.setattr(rest_endpoints, "_execute_with_mcp_client", fake_execute, raising=False)
|
||||
|
||||
oauth_call_counter = {"count": 0}
|
||||
|
||||
|
|
@ -555,9 +534,7 @@ class TestTestToolsList:
|
|||
"message": "Successfully retrieved tools",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
rest_endpoints, "_execute_with_mcp_client", fake_execute, raising=False
|
||||
)
|
||||
monkeypatch.setattr(rest_endpoints, "_execute_with_mcp_client", fake_execute, raising=False)
|
||||
|
||||
oauth_headers = {"Authorization": "Bearer oauth"}
|
||||
oauth_call_counter = {"count": 0}
|
||||
|
|
@ -573,7 +550,7 @@ class TestTestToolsList:
|
|||
raising=False,
|
||||
)
|
||||
|
||||
request = _build_request({"authorization": "Bearer incoming"})
|
||||
request = _build_request({"authorization": "Bearer incoming", "x-litellm-api-key": "sk-admission"})
|
||||
payload = NewMCPServerRequest(
|
||||
server_name="example",
|
||||
url="https://example.com",
|
||||
|
|
@ -593,6 +570,101 @@ class TestTestToolsList:
|
|||
assert captured["oauth2_headers"] == oauth_headers
|
||||
assert oauth_call_counter["count"] == 1
|
||||
|
||||
@pytest.mark.parametrize("auth_type", [MCPAuth.true_passthrough, MCPAuth.oauth_delegate])
|
||||
async def test_extracts_oauth2_headers_for_client_forwarded_modes(self, monkeypatch, auth_type):
|
||||
"""The browser-only authorize flow sends the upstream token as Authorization; the preview
|
||||
must thread it through for the client-forwarded token modes so the passthrough arm can
|
||||
forward it, instead of probing the upstream unauthenticated."""
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
async def fake_execute(
|
||||
request,
|
||||
operation,
|
||||
mcp_auth_header=None,
|
||||
oauth2_headers=None,
|
||||
raw_headers=None,
|
||||
):
|
||||
captured["mcp_auth_header"] = mcp_auth_header
|
||||
captured["oauth2_headers"] = oauth2_headers
|
||||
return {
|
||||
"tools": [],
|
||||
"error": None,
|
||||
"message": "Successfully retrieved tools",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(rest_endpoints, "_execute_with_mcp_client", fake_execute, raising=False)
|
||||
|
||||
oauth_headers = {"Authorization": "Bearer upstream-token"}
|
||||
|
||||
monkeypatch.setattr(
|
||||
auth_mcp.MCPRequestHandler,
|
||||
"_get_oauth2_headers_from_headers",
|
||||
staticmethod(lambda headers: oauth_headers),
|
||||
raising=False,
|
||||
)
|
||||
|
||||
request = _build_request({"authorization": "Bearer upstream-token", "x-litellm-api-key": "sk-admission"})
|
||||
payload = NewMCPServerRequest(
|
||||
server_name="example",
|
||||
url="https://example.com",
|
||||
auth_type=auth_type,
|
||||
)
|
||||
|
||||
from litellm.proxy._types import LitellmUserRoles
|
||||
|
||||
result = await rest_endpoints.test_tools_list(
|
||||
request,
|
||||
payload,
|
||||
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
|
||||
)
|
||||
|
||||
assert result["message"] == "Successfully retrieved tools"
|
||||
assert captured["mcp_auth_header"] is None
|
||||
assert captured["oauth2_headers"] == oauth_headers
|
||||
|
||||
@pytest.mark.parametrize("auth_type", [MCPAuth.oauth2, MCPAuth.true_passthrough, MCPAuth.oauth_delegate])
|
||||
async def test_does_not_forward_authorization_that_satisfied_admission(self, monkeypatch, auth_type):
|
||||
"""Authorization is also the admission fallback: with no x-litellm-api-key on the request,
|
||||
the Authorization value is the caller's LiteLLM key, so forwarding it would send the
|
||||
admission credential to the upstream."""
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
async def fake_execute(
|
||||
request,
|
||||
operation,
|
||||
mcp_auth_header=None,
|
||||
oauth2_headers=None,
|
||||
raw_headers=None,
|
||||
):
|
||||
captured["oauth2_headers"] = oauth2_headers
|
||||
return {
|
||||
"tools": [],
|
||||
"error": None,
|
||||
"message": "Successfully retrieved tools",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(rest_endpoints, "_execute_with_mcp_client", fake_execute, raising=False)
|
||||
|
||||
request = _build_request({"authorization": "Bearer sk-litellm-admission-key"})
|
||||
payload = NewMCPServerRequest(
|
||||
server_name="example",
|
||||
url="https://example.com",
|
||||
auth_type=auth_type,
|
||||
)
|
||||
|
||||
from litellm.proxy._types import LitellmUserRoles
|
||||
|
||||
result = await rest_endpoints.test_tools_list(
|
||||
request,
|
||||
payload,
|
||||
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
|
||||
)
|
||||
|
||||
assert result["message"] == "Successfully retrieved tools"
|
||||
assert captured["oauth2_headers"] is None
|
||||
|
||||
|
||||
class TestListToolsRestAPI:
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
|
@ -722,9 +794,7 @@ class TestListToolsRestAPI:
|
|||
stub_server = StubServer()
|
||||
captured = {}
|
||||
|
||||
async def fake_get_tools(
|
||||
server, server_auth_header, *args, apply_tool_filters=True, **kwargs
|
||||
):
|
||||
async def fake_get_tools(server, server_auth_header, *args, apply_tool_filters=True, **kwargs):
|
||||
captured["apply_tool_filters"] = apply_tool_filters
|
||||
return ["tool-1"]
|
||||
|
||||
|
|
@ -772,9 +842,7 @@ class TestListToolsRestAPI:
|
|||
assert captured["apply_tool_filters"] is True
|
||||
|
||||
@pytest.mark.parametrize("upstream_status", [401, 403])
|
||||
async def test_upstream_auth_failure_surfaces_status_and_challenge(
|
||||
self, monkeypatch, upstream_status
|
||||
):
|
||||
async def test_upstream_auth_failure_surfaces_status_and_challenge(self, monkeypatch, upstream_status):
|
||||
"""A single-server pass-through request whose upstream rejects the token
|
||||
must surface the upstream status (401 or 403) plus its WWW-Authenticate
|
||||
challenge, not collapse into a 200 ``unexpected_error`` body."""
|
||||
|
|
@ -1362,9 +1430,7 @@ class TestListToolsRestAPI:
|
|||
|
||||
oauth_headers = {"Authorization": "Bearer user-oauth-token"}
|
||||
|
||||
async def fake_get_user_oauth_extra_headers(
|
||||
server, user_api_key_dict, prefetched_creds=None
|
||||
):
|
||||
async def fake_get_user_oauth_extra_headers(server, user_api_key_dict, prefetched_creds=None):
|
||||
return oauth_headers
|
||||
|
||||
captured = {}
|
||||
|
|
@ -1608,9 +1674,7 @@ class TestGetToolsForSingleServer:
|
|||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
async def test_filters_tools_by_object_permission_mcp_tool_permissions(
|
||||
self, monkeypatch
|
||||
):
|
||||
async def test_filters_tools_by_object_permission_mcp_tool_permissions(self, monkeypatch):
|
||||
"""Test that tools are filtered by user_api_key_auth.object_permission.mcp_tool_permissions"""
|
||||
from litellm.proxy._experimental.mcp_server.server import MCPServer
|
||||
from litellm.proxy._types import LiteLLM_ObjectPermissionTable
|
||||
|
|
@ -1773,9 +1837,7 @@ class TestGetToolsForSingleServer:
|
|||
# All tools should be returned
|
||||
assert len(result) == 2
|
||||
|
||||
async def test_no_filtering_when_server_not_in_mcp_tool_permissions(
|
||||
self, monkeypatch
|
||||
):
|
||||
async def test_no_filtering_when_server_not_in_mcp_tool_permissions(self, monkeypatch):
|
||||
"""Test that all tools are returned when server is not in mcp_tool_permissions"""
|
||||
from litellm.proxy._experimental.mcp_server.server import MCPServer
|
||||
from litellm.proxy._types import LiteLLM_ObjectPermissionTable
|
||||
|
|
@ -1828,9 +1890,7 @@ class TestGetToolsForSingleServer:
|
|||
# All tools should be returned since server is not in permissions
|
||||
assert len(result) == 2
|
||||
|
||||
async def test_combines_server_allowed_tools_and_object_permission_filters(
|
||||
self, monkeypatch
|
||||
):
|
||||
async def test_combines_server_allowed_tools_and_object_permission_filters(self, monkeypatch):
|
||||
"""Test that both server.allowed_tools and object_permission.mcp_tool_permissions filters are applied"""
|
||||
from litellm.proxy._experimental.mcp_server.server import MCPServer
|
||||
from litellm.proxy._types import LiteLLM_ObjectPermissionTable
|
||||
|
|
@ -2148,9 +2208,7 @@ class TestPreviewOpenAPITools:
|
|||
"paths": {
|
||||
"/repos/{owner}/{repo}/actions/jobs/{job_id}/logs": {
|
||||
"get": {
|
||||
"operationId": (
|
||||
"actions/download-job-logs-for-workflow-run"
|
||||
),
|
||||
"operationId": ("actions/download-job-logs-for-workflow-run"),
|
||||
"summary": "Download job logs",
|
||||
}
|
||||
},
|
||||
|
|
@ -2193,9 +2251,7 @@ class TestPreviewOpenAPITools:
|
|||
names = [t["name"] for t in result["tools"]]
|
||||
anthropic_re = re.compile(r"^[a-zA-Z0-9_-]{1,128}$")
|
||||
for name in names:
|
||||
assert anthropic_re.match(
|
||||
name
|
||||
), f"preview tool name {name!r} violates ^[a-zA-Z0-9_-]+$"
|
||||
assert anthropic_re.match(name), f"preview tool name {name!r} violates ^[a-zA-Z0-9_-]+$"
|
||||
assert "actions_download-job-logs-for-workflow-run" in names
|
||||
assert "pulls_list-files" in names
|
||||
|
||||
|
|
@ -2252,9 +2308,7 @@ class TestPreviewOpenAPITools:
|
|||
|
||||
registered_summary_to_name: dict = {}
|
||||
|
||||
def fake_create_tool_function(
|
||||
path, method, operation, base_url
|
||||
): # noqa: ANN001
|
||||
def fake_create_tool_function(path, method, operation, base_url): # noqa: ANN001
|
||||
def _f():
|
||||
return None
|
||||
|
||||
|
|
@ -2267,9 +2321,7 @@ class TestPreviewOpenAPITools:
|
|||
)
|
||||
|
||||
class _StubRegistry:
|
||||
def register_tool(
|
||||
self, name, description, input_schema, handler
|
||||
): # noqa: ANN001
|
||||
def register_tool(self, name, description, input_schema, handler): # noqa: ANN001
|
||||
registered_summary_to_name[description] = name
|
||||
|
||||
monkeypatch.setattr(
|
||||
|
|
@ -2278,9 +2330,7 @@ class TestPreviewOpenAPITools:
|
|||
_StubRegistry(),
|
||||
)
|
||||
|
||||
openapi_to_mcp_generator.register_tools_from_openapi(
|
||||
spec, base_url="https://example.invalid"
|
||||
)
|
||||
openapi_to_mcp_generator.register_tools_from_openapi(spec, base_url="https://example.invalid")
|
||||
|
||||
assert preview_summary_to_name == registered_summary_to_name, (
|
||||
f"preview {preview_summary_to_name} != "
|
||||
|
|
@ -2308,15 +2358,11 @@ class TestConnectionErrorMessage:
|
|||
assert secret not in message
|
||||
|
||||
def test_connect_error_points_at_reachability(self):
|
||||
message = rest_endpoints._connection_error_message(
|
||||
httpx.ConnectError("All connection attempts failed")
|
||||
)
|
||||
message = rest_endpoints._connection_error_message(httpx.ConnectError("All connection attempts failed"))
|
||||
assert "unreachable" in message.lower()
|
||||
|
||||
def test_timeout_error_message(self):
|
||||
message = rest_endpoints._connection_error_message(
|
||||
httpx.ConnectTimeout("timed out")
|
||||
)
|
||||
message = rest_endpoints._connection_error_message(httpx.ConnectTimeout("timed out"))
|
||||
assert "unreachable" in message.lower()
|
||||
|
||||
def test_http_status_error_includes_status_code(self):
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"@typescript-eslint/no-explicit-any": 1980,
|
||||
"complexity": 128,
|
||||
"local/no-large-inline-object-arg": 519,
|
||||
"local/no-large-inline-object-arg": 512,
|
||||
"local/no-long-condition-chain": 233,
|
||||
"max-depth": 59,
|
||||
"no-console": 15
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
import React from "react";
|
||||
import { Button, Form, Input } from "antd";
|
||||
import { isClientForwardedTokenMode } from "./types";
|
||||
|
||||
interface PassthroughOAuthFlow {
|
||||
startOAuthFlow: () => void | Promise<void>;
|
||||
status: string;
|
||||
error: string | null;
|
||||
tokenResponse: { access_token?: string; expires_in?: number } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser-only Authorize & Fetch for the client-forwarded token modes
|
||||
* (true_passthrough / oauth_delegate). LiteLLM never stores upstream
|
||||
* credentials for these modes, so the token obtained here lives in this
|
||||
* browser session only: it is forwarded per-server for the tools preview and
|
||||
* allowlist configuration, and is never written to the server row or the
|
||||
* per-user credential store. The optional client credentials cover IdPs
|
||||
* without dynamic client registration (e.g. a pre-registered Slack app) and
|
||||
* ride the temporary authorize session only.
|
||||
*/
|
||||
export default function PassthroughAuthorizeSection({
|
||||
authType,
|
||||
oauthFlow,
|
||||
}: {
|
||||
authType?: string | null;
|
||||
oauthFlow: PassthroughOAuthFlow;
|
||||
}) {
|
||||
if (!isClientForwardedTokenMode(authType)) return null;
|
||||
const authorizeButtonLabels: Record<string, string> = {
|
||||
authorizing: "Waiting for authorization...",
|
||||
exchanging: "Exchanging authorization code...",
|
||||
};
|
||||
const authorizeButtonLabel = authorizeButtonLabels[oauthFlow.status] ?? "Authorize & Fetch Tools (browser-only)";
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-gray-300 p-4 space-y-2 mb-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
Callers bring their own upstream token for this auth type, so LiteLLM stores no upstream credentials. To preview
|
||||
tools and configure the tool allowlist, authorize against the upstream here: the token stays in this browser
|
||||
session only and is never saved to LiteLLM.
|
||||
</p>
|
||||
<Form.Item
|
||||
label={<span className="text-sm font-medium text-gray-700">OAuth Client ID (optional, not saved)</span>}
|
||||
name={["credentials", "client_id"]}
|
||||
extra="Only needed when the upstream does not support dynamic client registration (e.g. a pre-registered Slack app). Used for this browser authorization only."
|
||||
>
|
||||
<Input.Password
|
||||
placeholder="Leave blank to use dynamic client registration"
|
||||
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">OAuth Client Secret (optional, not saved)</span>}
|
||||
name={["credentials", "client_secret"]}
|
||||
>
|
||||
<Input.Password
|
||||
placeholder="Leave blank for public clients / PKCE"
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button
|
||||
onClick={oauthFlow.startOAuthFlow}
|
||||
disabled={oauthFlow.status === "authorizing" || oauthFlow.status === "exchanging"}
|
||||
>
|
||||
{authorizeButtonLabel}
|
||||
</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 held for this browser session. Tools can now be previewed and configured; nothing was saved to LiteLLM.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import React from "react";
|
||||
import { Alert } from "antd";
|
||||
import { AUTH_TYPE } from "./types";
|
||||
|
||||
/**
|
||||
* Warning shown in the create/edit MCP server forms when auth_type
|
||||
* true_passthrough is selected: the gateway performs no admission auth for
|
||||
* that server, so callers reach the upstream without a LiteLLM identity.
|
||||
*/
|
||||
export default function TruePassthroughWarning({ authType }: { authType?: string | null }) {
|
||||
if (authType !== AUTH_TYPE.TRUE_PASSTHROUGH) return null;
|
||||
return (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
className="mb-4 rounded-lg"
|
||||
message="True Passthrough disables LiteLLM authentication for this server"
|
||||
description="Anyone who can reach the gateway can call this server without a LiteLLM key. The caller's Authorization header is forwarded to the upstream verbatim, per-key and per-team rate limits and spend tracking do not apply, and the upstream is fully responsible for authenticating callers. Choose OAuth Delegate instead if callers should still authenticate to LiteLLM."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@ const oauthHook = vi.hoisted(() => ({
|
|||
onTokenReceived: null as
|
||||
| ((token: Record<string, unknown> | null, registeredClient?: { clientId?: string; clientSecret?: string }) => void)
|
||||
| null,
|
||||
getCredentials: null as (() => Record<string, unknown> | undefined) | null,
|
||||
}));
|
||||
vi.mock("@/hooks/useMcpOAuthFlow", () => ({
|
||||
useMcpOAuthFlow: (opts: {
|
||||
|
|
@ -37,8 +38,10 @@ vi.mock("@/hooks/useMcpOAuthFlow", () => ({
|
|||
token: Record<string, unknown> | null,
|
||||
registeredClient?: { clientId?: string; clientSecret?: string },
|
||||
) => void;
|
||||
getCredentials?: () => Record<string, unknown> | undefined;
|
||||
}) => {
|
||||
oauthHook.onTokenReceived = opts.onTokenReceived;
|
||||
oauthHook.getCredentials = opts.getCredentials ?? null;
|
||||
return {
|
||||
startOAuthFlow: vi.fn(),
|
||||
status: "idle",
|
||||
|
|
@ -164,6 +167,57 @@ describe("CreateMCPServer", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("should warn that LiteLLM auth is disabled when True Passthrough is selected", async () => {
|
||||
await selectHttpTransport();
|
||||
|
||||
await selectAntOption("Authentication", "True Passthrough (no LiteLLM auth)");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("True Passthrough disables LiteLLM authentication for this server"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should not show the True Passthrough warning when OAuth Delegate is selected", async () => {
|
||||
await selectHttpTransport();
|
||||
|
||||
await selectAntOption("Authentication", "OAuth Delegate (client-supplied upstream token)");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("OAuth Delegate (client-supplied upstream token)").length).toBeGreaterThan(0);
|
||||
});
|
||||
expect(
|
||||
screen.queryByText("True Passthrough disables LiteLLM authentication for this server"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([["True Passthrough (no LiteLLM auth)"], ["OAuth Delegate (client-supplied upstream token)"]])(
|
||||
"should show the browser-only authorize section when %s is selected",
|
||||
async (optionLabel) => {
|
||||
await selectHttpTransport();
|
||||
|
||||
await selectAntOption("Authentication", optionLabel);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Authorize & Fetch Tools (browser-only)" })).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("OAuth Client ID (optional, not saved)")).toBeInTheDocument();
|
||||
expect(screen.getByText("OAuth Client Secret (optional, not saved)")).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it("should not show the browser-only authorize section for API Key auth", async () => {
|
||||
await selectHttpTransport();
|
||||
|
||||
await selectAntOption("Authentication", "API Key");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Authentication Value")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByRole("button", { name: "Authorize & Fetch Tools (browser-only)" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not require auth value when creating a server with API Key auth type", async () => {
|
||||
await selectHttpTransport();
|
||||
|
||||
|
|
@ -298,6 +352,28 @@ describe("CreateMCPServer", () => {
|
|||
expect(payload.credentials).toEqual({ auth_value: "my-secret-key" });
|
||||
});
|
||||
|
||||
it("does not write the browser-authorized token into form.credentials for true_passthrough", async () => {
|
||||
await selectHttpTransport();
|
||||
|
||||
const user = userEvent.setup({ delay: null });
|
||||
await user.type(getServerNameInput(), "PT_Server");
|
||||
await user.type(screen.getByPlaceholderText("https://your-mcp-server.com"), "https://example.com/mcp");
|
||||
|
||||
await selectAntOption("Authentication", "True Passthrough (no LiteLLM auth)");
|
||||
|
||||
// Simulate the browser Authorize & Fetch flow handing back an upstream token.
|
||||
await waitFor(() => expect(oauthHook.onTokenReceived).toBeTruthy());
|
||||
await act(async () => {
|
||||
oauthHook.onTokenReceived!({ access_token: "upstream-tok", token_type: "Bearer" }, undefined);
|
||||
});
|
||||
|
||||
// For a browser-only mode the token must never land in form.credentials, which the OAuth flow's
|
||||
// getCredentials reads for preview requests and the redirect-persist cache serializes. Without
|
||||
// the guard, onTokenReceived writes it here and this returns { access_token: "upstream-tok" }.
|
||||
const credentials = oauthHook.getCredentials?.() ?? {};
|
||||
expect(credentials.access_token).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should not show auth value field when None auth type is selected", async () => {
|
||||
await selectHttpTransport();
|
||||
|
||||
|
|
|
|||
|
|
@ -14,8 +14,11 @@ import {
|
|||
getMcpOAuthMode,
|
||||
MCP_OAUTH2_FLOW_M2M,
|
||||
MCP_OAUTH2_FLOW_INTERACTIVE,
|
||||
isClientForwardedTokenMode,
|
||||
} from "./types";
|
||||
import OAuthFormFields from "./OAuthFormFields";
|
||||
import TruePassthroughWarning from "./TruePassthroughWarning";
|
||||
import PassthroughAuthorizeSection from "./PassthroughAuthorizeSection";
|
||||
import TokenExchangeFormFields from "./TokenExchangeFormFields";
|
||||
import MCPServerCostConfig from "./mcp_server_cost_config";
|
||||
import MCPConnectionStatus from "./mcp_connection_status";
|
||||
|
|
@ -134,20 +137,18 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
}
|
||||
try {
|
||||
const values = form.getFieldsValue(true);
|
||||
setSecureItem(
|
||||
CREATE_OAUTH_UI_STATE_KEY,
|
||||
JSON.stringify({
|
||||
modalVisible: isModalVisible,
|
||||
formValues: values,
|
||||
transportType,
|
||||
costConfig,
|
||||
allowedTools,
|
||||
hasToolAllowlistInteraction,
|
||||
searchValue,
|
||||
aliasManuallyEdited,
|
||||
logoUrl,
|
||||
}),
|
||||
);
|
||||
const uiState = {
|
||||
modalVisible: isModalVisible,
|
||||
formValues: values,
|
||||
transportType,
|
||||
costConfig,
|
||||
allowedTools,
|
||||
hasToolAllowlistInteraction,
|
||||
searchValue,
|
||||
aliasManuallyEdited,
|
||||
logoUrl,
|
||||
};
|
||||
setSecureItem(CREATE_OAUTH_UI_STATE_KEY, JSON.stringify(uiState));
|
||||
} catch (err) {
|
||||
console.warn("Failed to persist MCP create state", err);
|
||||
}
|
||||
|
|
@ -182,7 +183,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
description: values.description,
|
||||
url,
|
||||
transport: transport === TRANSPORT.OPENAPI ? "http" : transport,
|
||||
auth_type: AUTH_TYPE.OAUTH2,
|
||||
auth_type: isClientForwardedTokenMode(values.auth_type) ? values.auth_type : AUTH_TYPE.OAUTH2,
|
||||
credentials: values.credentials,
|
||||
authorization_url: values.authorization_url,
|
||||
token_url: values.token_url,
|
||||
|
|
@ -197,23 +198,36 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
onTokenReceived: (token, registeredClient) => {
|
||||
setOauthAccessToken(token?.access_token ?? null);
|
||||
|
||||
if (token?.access_token) {
|
||||
const credentials = {
|
||||
access_token: token.access_token,
|
||||
...(token.refresh_token && { refresh_token: token.refresh_token }),
|
||||
...(token.expires_in && { expires_in: token.expires_in }),
|
||||
...(token.scope && { scope: token.scope }),
|
||||
...(registeredClient?.clientId && { client_id: registeredClient.clientId }),
|
||||
...(registeredClient?.clientSecret && { client_secret: registeredClient.clientSecret }),
|
||||
};
|
||||
|
||||
form.setFieldsValue({ credentials });
|
||||
setAuthorizedUrl(getOAuthAuthorizationTarget(form.getFieldsValue(true)));
|
||||
|
||||
NotificationsManager.success(
|
||||
"OAuth authorization successful! Please click 'Create MCP Server' to save the configuration.",
|
||||
);
|
||||
if (!token?.access_token) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isClientForwardedTokenMode(form.getFieldValue("auth_type"))) {
|
||||
// Browser-only modes: the token is held in local state (oauthAccessToken) for tool preview
|
||||
// and committed to sessionStorage on submit; it must never be written into form.credentials,
|
||||
// which would persist it as server-level credentials on the created server row. Mirrors the
|
||||
// edit form's onTokenReceived early return.
|
||||
NotificationsManager.success(
|
||||
"Token held for this browser session. Tools can now be previewed and configured; nothing will be saved to LiteLLM.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const credentials = {
|
||||
access_token: token.access_token,
|
||||
...(token.refresh_token && { refresh_token: token.refresh_token }),
|
||||
...(token.expires_in && { expires_in: token.expires_in }),
|
||||
...(token.scope && { scope: token.scope }),
|
||||
...(registeredClient?.clientId && { client_id: registeredClient.clientId }),
|
||||
...(registeredClient?.clientSecret && { client_secret: registeredClient.clientSecret }),
|
||||
};
|
||||
|
||||
form.setFieldsValue({ credentials });
|
||||
setAuthorizedUrl(getOAuthAuthorizationTarget(form.getFieldsValue(true)));
|
||||
|
||||
NotificationsManager.success(
|
||||
"OAuth authorization successful! Please click 'Create MCP Server' to save the configuration.",
|
||||
);
|
||||
},
|
||||
onBeforeRedirect: persistCreateUiState,
|
||||
flowSource: "create",
|
||||
|
|
@ -494,23 +508,21 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
});
|
||||
if (oauthMode === "authorization_code") {
|
||||
const scope = oauthTokenResponse.scope;
|
||||
await storeMCPOAuthUserCredential(accessToken, response.server_id, {
|
||||
const oauthCredentialPayload = {
|
||||
access_token: oauthTokenResponse.access_token,
|
||||
refresh_token: oauthTokenResponse.refresh_token,
|
||||
expires_in: oauthTokenResponse.expires_in,
|
||||
scopes: typeof scope === "string" && scope ? scope.split(" ") : undefined,
|
||||
});
|
||||
};
|
||||
await storeMCPOAuthUserCredential(accessToken, response.server_id, oauthCredentialPayload);
|
||||
} else {
|
||||
setToken(
|
||||
response.server_id,
|
||||
{
|
||||
access_token: oauthTokenResponse.access_token,
|
||||
expires_in: oauthTokenResponse.expires_in,
|
||||
refresh_token: oauthTokenResponse.refresh_token,
|
||||
token_type: oauthTokenResponse.token_type,
|
||||
},
|
||||
userID,
|
||||
);
|
||||
const browserHeldToken = {
|
||||
access_token: oauthTokenResponse.access_token,
|
||||
expires_in: oauthTokenResponse.expires_in,
|
||||
refresh_token: oauthTokenResponse.refresh_token,
|
||||
token_type: oauthTokenResponse.token_type,
|
||||
};
|
||||
setToken(response.server_id, browserHeldToken, userID);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -970,9 +982,25 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
<Select.Option value="oauth2">OAuth</Select.Option>
|
||||
<Select.Option value="oauth2_token_exchange">OAuth Token Exchange (OBO)</Select.Option>
|
||||
<Select.Option value="aws_sigv4">AWS SigV4 (Bedrock AgentCore MCPs)</Select.Option>
|
||||
<Select.Option value="true_passthrough">True Passthrough (no LiteLLM auth)</Select.Option>
|
||||
<Select.Option value="oauth_delegate">
|
||||
OAuth Delegate (client-supplied upstream token)
|
||||
</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<TruePassthroughWarning authType={authType} />
|
||||
|
||||
<PassthroughAuthorizeSection
|
||||
authType={authType}
|
||||
oauthFlow={{
|
||||
startOAuthFlow,
|
||||
status: oauthStatus,
|
||||
error: oauthError,
|
||||
tokenResponse: oauthTokenResponse,
|
||||
}}
|
||||
/>
|
||||
|
||||
{shouldShowAuthValueField && (
|
||||
<Form.Item
|
||||
label={
|
||||
|
|
|
|||
|
|
@ -19,14 +19,20 @@ vi.mock("../molecules/notifications_manager", () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
const mockOauth: { tokenResponse: any } = { tokenResponse: null };
|
||||
const mockOauth: {
|
||||
tokenResponse: any;
|
||||
getTemporaryPayload: (() => Record<string, unknown> | null) | null;
|
||||
} = { tokenResponse: null, getTemporaryPayload: null };
|
||||
vi.mock("@/hooks/useMcpOAuthFlow", () => ({
|
||||
useMcpOAuthFlow: () => ({
|
||||
startOAuthFlow: vi.fn(),
|
||||
status: "idle",
|
||||
error: null,
|
||||
tokenResponse: mockOauth.tokenResponse,
|
||||
}),
|
||||
useMcpOAuthFlow: (opts: { getTemporaryPayload?: () => Record<string, unknown> | null }) => {
|
||||
mockOauth.getTemporaryPayload = opts?.getTemporaryPayload ?? null;
|
||||
return {
|
||||
startOAuthFlow: vi.fn(),
|
||||
status: "idle",
|
||||
error: null,
|
||||
tokenResponse: mockOauth.tokenResponse,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./mcp_server_cost_config", () => ({
|
||||
|
|
@ -307,6 +313,69 @@ describe("MCPServerEdit (delegate auth)", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("MCPServerEdit (true passthrough warning)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderWithAuthType = (authType: string) =>
|
||||
render(
|
||||
<MCPServerEdit
|
||||
mcpServer={{
|
||||
...interactiveOAuthServer,
|
||||
auth_type: authType,
|
||||
}}
|
||||
accessToken="access-token"
|
||||
onCancel={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
availableAccessGroups={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
it("warns that LiteLLM auth is disabled for a true_passthrough server", async () => {
|
||||
renderWithAuthType("true_passthrough");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("True Passthrough disables LiteLLM authentication for this server")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not warn for an oauth_delegate server", async () => {
|
||||
renderWithAuthType("oauth_delegate");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole("button", { name: "Save Changes" }).length).toBeGreaterThan(0);
|
||||
});
|
||||
expect(
|
||||
screen.queryByText("True Passthrough disables LiteLLM authentication for this server"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("browser-authorize temp payload uses the selected auth_type, not the stored one", async () => {
|
||||
// Stored server is oauth2; the admin switches the dropdown to true_passthrough before saving.
|
||||
// The temp OAuth-relay payload must reflect the selection so the exchange is treated as
|
||||
// browser-held (no DB persistence), matching onTokenReceived and the create form.
|
||||
render(
|
||||
<MCPServerEdit
|
||||
mcpServer={{ ...interactiveOAuthServer, auth_type: "oauth2" }}
|
||||
accessToken="access-token"
|
||||
onCancel={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
availableAccessGroups={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await selectAntOption("Authentication", "True Passthrough (no LiteLLM auth)");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOauth.getTemporaryPayload).toBeTruthy();
|
||||
});
|
||||
const payload = mockOauth.getTemporaryPayload!();
|
||||
expect(payload).toBeTruthy();
|
||||
expect(payload?.auth_type).toBe("true_passthrough");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MCPServerEdit (auth type switch)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
|
@ -883,6 +952,55 @@ describe("MCPServerEdit (tool list fetch)", () => {
|
|||
expect(mockGetToken).toHaveBeenCalledWith("oauth_server_1", "user-1");
|
||||
});
|
||||
|
||||
it("forwards the sessionStorage token as the x-mcp header for an oauth_delegate server", async () => {
|
||||
mockIsTokenValid.mockReturnValue(true);
|
||||
mockGetToken.mockReturnValue({ access_token: "browser-token" });
|
||||
|
||||
render(
|
||||
<MCPServerEdit
|
||||
mcpServer={{ ...interactiveOAuthServer, auth_type: "oauth_delegate" }}
|
||||
accessToken="access-token"
|
||||
userID="user-1"
|
||||
onCancel={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
availableAccessGroups={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(networking.listMCPTools).toHaveBeenCalledWith(
|
||||
"access-token",
|
||||
"oauth_server_1",
|
||||
{ "x-mcp-oauth_server-authorization": "Bearer browser-token" },
|
||||
true,
|
||||
);
|
||||
});
|
||||
expect(mockGetToken).toHaveBeenCalledWith("oauth_server_1", "user-1");
|
||||
});
|
||||
|
||||
it("prompts for the browser-only authorize when a true_passthrough server has no token", async () => {
|
||||
mockIsTokenValid.mockReturnValue(false);
|
||||
|
||||
render(
|
||||
<MCPServerEdit
|
||||
mcpServer={{ ...interactiveOAuthServer, auth_type: "true_passthrough" }}
|
||||
accessToken="access-token"
|
||||
userID="user-1"
|
||||
onCancel={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
availableAccessGroups={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mcp-tool-config").getAttribute("data-external-error")).toContain(
|
||||
"Authorize with the upstream (browser-only",
|
||||
);
|
||||
});
|
||||
expect(networking.listMCPTools).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("button", { name: "Authorize & Fetch Tools (browser-only)" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses the staged OAuth token to load passthrough tools after authorize", async () => {
|
||||
const passthroughServer = { ...interactiveOAuthServer, delegate_auth_to_upstream: true };
|
||||
mockIsTokenValid.mockReturnValue(false);
|
||||
|
|
@ -1055,6 +1173,76 @@ describe("MCPServerEdit (OAuth token persistence on save)", () => {
|
|||
expect(onSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([["true_passthrough"], ["oauth_delegate"]])(
|
||||
"persists the staged token to sessionStorage on save for the %s mode",
|
||||
async (authType) => {
|
||||
// Regression: the save path classified the staged token with getMcpOAuthMode, which returns
|
||||
// null for the client-forwarded modes, so setToken was never called and the browser-held
|
||||
// token was dropped on save; the create form's submit path already committed it.
|
||||
mockOauth.tokenResponse = { access_token: "cf-tok", expires_in: 1800, token_type: "bearer" };
|
||||
vi.mocked(networking.updateMCPServer).mockResolvedValue({
|
||||
...interactiveOAuthServer,
|
||||
auth_type: authType,
|
||||
});
|
||||
|
||||
render(
|
||||
<MCPServerEdit
|
||||
mcpServer={{ ...interactiveOAuthServer, auth_type: authType }}
|
||||
accessToken="access-token"
|
||||
userID="user-1"
|
||||
onCancel={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
availableAccessGroups={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Save Changes" })[0]);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetToken).toHaveBeenCalledWith(
|
||||
"oauth_server_1",
|
||||
expect.objectContaining({ access_token: "cf-tok" }),
|
||||
"user-1",
|
||||
);
|
||||
});
|
||||
expect(networking.storeMCPOAuthUserCredential).not.toHaveBeenCalled();
|
||||
const [, payload] = vi.mocked(networking.updateMCPServer).mock.calls[0];
|
||||
expect(payload.credentials).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("forwards a newly authorized browser-held token for tool loading before the form is saved", async () => {
|
||||
// Regression: fetchTools keyed the browser-held decision off the saved mcpServer.auth_type, so
|
||||
// after switching the form to true_passthrough and authorizing, the fresh token was not sent as
|
||||
// the x-mcp header until the server was saved.
|
||||
vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: [], error: null });
|
||||
mockIsTokenValid.mockReturnValue(false);
|
||||
|
||||
render(
|
||||
<MCPServerEdit
|
||||
mcpServer={{ ...interactiveOAuthServer, auth_type: "api_key" }}
|
||||
accessToken="access-token"
|
||||
userID="user-1"
|
||||
onCancel={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
availableAccessGroups={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await selectAntOption("Authentication", "OAuth Delegate (client-supplied upstream token)");
|
||||
mockOauth.tokenResponse = { access_token: "fresh-tok", token_type: "bearer" };
|
||||
await selectAntOption("Authentication", "True Passthrough (no LiteLLM auth)");
|
||||
|
||||
await waitFor(() => {
|
||||
const withHeaders = vi
|
||||
.mocked(networking.listMCPTools)
|
||||
.mock.calls.find(([, , headers]) => headers && JSON.stringify(headers).includes("fresh-tok"));
|
||||
expect(withHeaders).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("persists the passthrough token to sessionStorage on save after authorize", async () => {
|
||||
mockOauth.tokenResponse = { access_token: "pt-tok", expires_in: 1800, token_type: "bearer" };
|
||||
vi.mocked(networking.updateMCPServer).mockResolvedValue({
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { InfoCircleOutlined } from "@ant-design/icons";
|
|||
import { Button, TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react";
|
||||
import {
|
||||
AUTH_TYPE,
|
||||
isClientForwardedTokenMode,
|
||||
OAUTH_FLOW,
|
||||
MCP_OAUTH2_FLOW_M2M,
|
||||
MCP_OAUTH2_FLOW_INTERACTIVE,
|
||||
|
|
@ -18,6 +19,8 @@ import { getToken, isTokenValid, setToken } from "@/utils/mcpTokenStore";
|
|||
import { buildMcpPassthroughAuthHeader } from "@/utils/mcpHeaderUtils";
|
||||
import MCPServerCostConfig from "./mcp_server_cost_config";
|
||||
import MCPPermissionManagement from "./MCPPermissionManagement";
|
||||
import TruePassthroughWarning from "./TruePassthroughWarning";
|
||||
import PassthroughAuthorizeSection from "./PassthroughAuthorizeSection";
|
||||
import MCPToolConfiguration from "./mcp_tool_configuration";
|
||||
import StdioConfiguration from "./StdioConfiguration";
|
||||
import TokenExchangeFormFields from "./TokenExchangeFormFields";
|
||||
|
|
@ -128,6 +131,11 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
// The auth mode every decision must key off: the admin's in-flight form selection wins over the
|
||||
// saved record, so authorizing, loading tools, and saving all agree with what the form shows. Paths
|
||||
// that read only mcpServer.auth_type go stale the moment the admin switches modes in the form.
|
||||
const getEffectiveAuthType = () => form.getFieldValue("auth_type") ?? mcpServer.auth_type;
|
||||
|
||||
const {
|
||||
startOAuthFlow,
|
||||
status: oauthStatus,
|
||||
|
|
@ -161,7 +169,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
description: values.description || mcpServer.description,
|
||||
url,
|
||||
transport,
|
||||
auth_type: AUTH_TYPE.OAUTH2,
|
||||
auth_type: isClientForwardedTokenMode(values.auth_type) ? values.auth_type : AUTH_TYPE.OAUTH2,
|
||||
credentials: values.credentials,
|
||||
mcp_access_groups: values.mcp_access_groups || mcpServer.mcp_access_groups,
|
||||
static_headers: staticHeaders,
|
||||
|
|
@ -171,20 +179,36 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
};
|
||||
},
|
||||
onTokenReceived: (token) => {
|
||||
if (token?.access_token) {
|
||||
const credentials = {
|
||||
access_token: token.access_token,
|
||||
...(token.refresh_token && { refresh_token: token.refresh_token }),
|
||||
...(token.expires_in && { expires_in: token.expires_in }),
|
||||
...(token.scope && { scope: token.scope }),
|
||||
};
|
||||
|
||||
form.setFieldsValue({ credentials });
|
||||
|
||||
NotificationsManager.success(
|
||||
"OAuth authorization successful! Please click 'Update MCP Server' to save the credentials.",
|
||||
);
|
||||
if (!token?.access_token) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isClientForwardedTokenMode(getEffectiveAuthType())) {
|
||||
const browserHeldToken = {
|
||||
access_token: token.access_token,
|
||||
expires_in: token.expires_in,
|
||||
refresh_token: token.refresh_token,
|
||||
token_type: token.token_type,
|
||||
};
|
||||
setToken(mcpServer.server_id, browserHeldToken, userID);
|
||||
NotificationsManager.success(
|
||||
"Token held for this browser session. Tools can now be loaded and configured; nothing was saved to LiteLLM.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const credentials = {
|
||||
access_token: token.access_token,
|
||||
...(token.refresh_token && { refresh_token: token.refresh_token }),
|
||||
...(token.expires_in && { expires_in: token.expires_in }),
|
||||
...(token.scope && { scope: token.scope }),
|
||||
};
|
||||
|
||||
form.setFieldsValue({ credentials });
|
||||
|
||||
NotificationsManager.success(
|
||||
"OAuth authorization successful! Please click 'Update MCP Server' to save the credentials.",
|
||||
);
|
||||
},
|
||||
onBeforeRedirect: persistEditUiState,
|
||||
flowSource: "edit",
|
||||
|
|
@ -368,7 +392,8 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
oauth2_flow: mcpServer.oauth2_flow,
|
||||
delegate_auth_to_upstream: mcpServer.delegate_auth_to_upstream,
|
||||
}) === "passthrough";
|
||||
if (isPassthrough) {
|
||||
const isBrowserHeldTokenMode = isClientForwardedTokenMode(getEffectiveAuthType());
|
||||
if (isPassthrough || isBrowserHeldTokenMode) {
|
||||
const token =
|
||||
oauthTokenResponse?.access_token ??
|
||||
(isTokenValid(mcpServer.server_id, userID)
|
||||
|
|
@ -376,7 +401,11 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
: null);
|
||||
if (!token) {
|
||||
setTools([]);
|
||||
setToolsError("Authenticate with this server in the Tools tab to load and configure its tools.");
|
||||
setToolsError(
|
||||
isBrowserHeldTokenMode
|
||||
? "Authorize with the upstream (browser-only, in the Authentication section) to load and configure this server's tools."
|
||||
: "Authenticate with this server in the Tools tab to load and configure its tools.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
customHeaders = buildMcpPassthroughAuthHeader(mcpServer.alias, token);
|
||||
|
|
@ -439,7 +468,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
const handleTransportChange = (value: string) => {
|
||||
// Clear fields that are not relevant for the selected transport.
|
||||
if (value === "stdio") {
|
||||
form.setFieldsValue({
|
||||
const clearedForStdio = {
|
||||
url: undefined,
|
||||
spec_path: undefined,
|
||||
auth_type: undefined,
|
||||
|
|
@ -447,15 +476,17 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
authorization_url: undefined,
|
||||
token_url: undefined,
|
||||
registration_url: undefined,
|
||||
});
|
||||
};
|
||||
form.setFieldsValue(clearedForStdio);
|
||||
} else if (value === TRANSPORT.OPENAPI) {
|
||||
form.setFieldsValue({
|
||||
const clearedForOpenapi = {
|
||||
url: undefined,
|
||||
command: undefined,
|
||||
args: undefined,
|
||||
env_json: undefined,
|
||||
stdio_config: undefined,
|
||||
});
|
||||
};
|
||||
form.setFieldsValue(clearedForOpenapi);
|
||||
} else {
|
||||
form.setFieldsValue({
|
||||
spec_path: undefined,
|
||||
|
|
@ -722,8 +753,9 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
const updated = await updateMCPServer(accessToken, payload);
|
||||
|
||||
// Persist the token staged via "Authorize & Fetch" (mirrors the create flow's
|
||||
// commit-on-submit): OBO writes the per-user token to the DB, passthrough keeps
|
||||
// it in sessionStorage. M2M/static auth resolve server-side and need neither.
|
||||
// commit-on-submit): OBO writes the per-user token to the DB; legacy passthrough and the
|
||||
// client-forwarded modes (true_passthrough / oauth_delegate) keep it in sessionStorage and
|
||||
// never in the server row. M2M/static auth resolve server-side and need neither.
|
||||
if (oauthTokenResponse?.access_token) {
|
||||
const oauthMode = getMcpOAuthMode({
|
||||
auth_type: restValues.auth_type,
|
||||
|
|
@ -733,23 +765,21 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
try {
|
||||
if (oauthMode === "authorization_code") {
|
||||
const scope = oauthTokenResponse.scope;
|
||||
await storeMCPOAuthUserCredential(accessToken, mcpServer.server_id, {
|
||||
const oauthCredentialPayload = {
|
||||
access_token: oauthTokenResponse.access_token,
|
||||
refresh_token: oauthTokenResponse.refresh_token,
|
||||
expires_in: oauthTokenResponse.expires_in,
|
||||
scopes: typeof scope === "string" && scope ? scope.split(" ") : undefined,
|
||||
});
|
||||
} else if (oauthMode === "passthrough") {
|
||||
setToken(
|
||||
mcpServer.server_id,
|
||||
{
|
||||
access_token: oauthTokenResponse.access_token,
|
||||
expires_in: oauthTokenResponse.expires_in,
|
||||
refresh_token: oauthTokenResponse.refresh_token,
|
||||
token_type: oauthTokenResponse.token_type,
|
||||
},
|
||||
userID,
|
||||
);
|
||||
};
|
||||
await storeMCPOAuthUserCredential(accessToken, mcpServer.server_id, oauthCredentialPayload);
|
||||
} else if (oauthMode === "passthrough" || isClientForwardedTokenMode(restValues.auth_type)) {
|
||||
const browserHeldToken = {
|
||||
access_token: oauthTokenResponse.access_token,
|
||||
expires_in: oauthTokenResponse.expires_in,
|
||||
refresh_token: oauthTokenResponse.refresh_token,
|
||||
token_type: oauthTokenResponse.token_type,
|
||||
};
|
||||
setToken(mcpServer.server_id, browserHeldToken, userID);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
|
|
@ -874,18 +904,34 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
|
||||
{/* Authentication - for HTTP, SSE, and OpenAPI */}
|
||||
{!isStdioTransport && (
|
||||
<Form.Item label="Authentication" name="auth_type" rules={[{ required: true }]}>
|
||||
<Select>
|
||||
<Select.Option value="none">None</Select.Option>
|
||||
<Select.Option value="api_key">API Key</Select.Option>
|
||||
<Select.Option value="bearer_token">Bearer Token</Select.Option>
|
||||
<Select.Option value="token">Token</Select.Option>
|
||||
<Select.Option value="basic">Basic Auth</Select.Option>
|
||||
<Select.Option value="oauth2">OAuth</Select.Option>
|
||||
<Select.Option value="oauth2_token_exchange">OAuth Token Exchange (OBO)</Select.Option>
|
||||
<Select.Option value="aws_sigv4">AWS SigV4 (Bedrock AgentCore MCPs)</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<>
|
||||
<Form.Item label="Authentication" name="auth_type" rules={[{ required: true }]}>
|
||||
<Select>
|
||||
<Select.Option value="none">None</Select.Option>
|
||||
<Select.Option value="api_key">API Key</Select.Option>
|
||||
<Select.Option value="bearer_token">Bearer Token</Select.Option>
|
||||
<Select.Option value="token">Token</Select.Option>
|
||||
<Select.Option value="basic">Basic Auth</Select.Option>
|
||||
<Select.Option value="oauth2">OAuth</Select.Option>
|
||||
<Select.Option value="oauth2_token_exchange">OAuth Token Exchange (OBO)</Select.Option>
|
||||
<Select.Option value="aws_sigv4">AWS SigV4 (Bedrock AgentCore MCPs)</Select.Option>
|
||||
<Select.Option value="true_passthrough">True Passthrough (no LiteLLM auth)</Select.Option>
|
||||
<Select.Option value="oauth_delegate">
|
||||
OAuth Delegate (client-supplied upstream token)
|
||||
</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<TruePassthroughWarning authType={authType} />
|
||||
<PassthroughAuthorizeSection
|
||||
authType={authType}
|
||||
oauthFlow={{
|
||||
startOAuthFlow,
|
||||
status: oauthStatus,
|
||||
error: oauthError,
|
||||
tokenResponse: oauthTokenResponse,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isStdioTransport && (
|
||||
|
|
|
|||
|
|
@ -91,6 +91,37 @@ describe("MCPToolsViewer auth gate routing", () => {
|
|||
expect(screen.queryByText(GATE_TEXT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([["true_passthrough"], ["oauth_delegate"]])(
|
||||
"shows the Authorize gate for a %s server without a browser token and does not list tools",
|
||||
async (authType) => {
|
||||
renderViewer({ auth_type: authType, oauth2_flow: null, delegate_auth_to_upstream: false });
|
||||
|
||||
expect(await screen.findByText(GATE_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Authorize" })).toBeInTheDocument();
|
||||
expect(vi.mocked(listMCPTools)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(getMCPOAuthUserCredentialStatus)).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([["true_passthrough"], ["oauth_delegate"]])(
|
||||
"forwards the session token via the x-mcp header for a %s server that has one",
|
||||
async (authType) => {
|
||||
vi.mocked(isTokenValid).mockReturnValue(true);
|
||||
vi.mocked(getToken).mockReturnValue({ access_token: "upstream-tok" } as ReturnType<typeof getToken>);
|
||||
|
||||
renderViewer({ auth_type: authType, oauth2_flow: null, delegate_auth_to_upstream: false });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(vi.mocked(listMCPTools)).toHaveBeenCalledWith(
|
||||
"litellm-key",
|
||||
"srv-1",
|
||||
expect.objectContaining({ "x-mcp-slack-authorization": "Bearer upstream-tok" }),
|
||||
),
|
||||
);
|
||||
expect(screen.queryByText(GATE_TEXT)).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it("lists tools for an OBO server when the user has a DB credential, with no x-mcp header", async () => {
|
||||
renderViewer({ oauth2_flow: null, delegate_auth_to_upstream: false });
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,14 @@ import React, { useCallback, useEffect, useState } from "react";
|
|||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { ToolTestPanel } from "./ToolTestPanel";
|
||||
import { resolveLogoSrc } from "@/lib/assetPaths";
|
||||
import { MCPTool, MCPToolsViewerProps, MCPContent, CallMCPToolResponse, getMcpOAuthMode } from "./types";
|
||||
import {
|
||||
isClientForwardedTokenMode,
|
||||
MCPTool,
|
||||
MCPToolsViewerProps,
|
||||
MCPContent,
|
||||
CallMCPToolResponse,
|
||||
getMcpOAuthMode,
|
||||
} from "./types";
|
||||
import { listMCPTools, callMCPTool, getMCPOAuthUserCredentialStatus } from "../networking";
|
||||
import { isTokenValid, getToken, removeToken } from "@/utils/mcpTokenStore";
|
||||
import { sanitizeMcpAliasForHeader, buildMcpPassthroughAuthHeader } from "@/utils/mcpHeaderUtils";
|
||||
|
|
@ -42,19 +49,23 @@ const MCPToolsViewer = ({
|
|||
// service token and needs no gate.
|
||||
const oauthMode = getMcpOAuthMode({ auth_type, oauth2_flow, delegate_auth_to_upstream });
|
||||
const isPassthrough = oauthMode === "passthrough";
|
||||
// The client-forwarded token modes gate the same way as PKCE passthrough: the
|
||||
// browser session token (established via the browser-only Authorize in the
|
||||
// create/edit forms, or right here) is the upstream credential.
|
||||
const usesBrowserHeldToken = isPassthrough || isClientForwardedTokenMode(auth_type);
|
||||
const isAuthorizationCode = oauthMode === "authorization_code";
|
||||
const [oauthToken, setOauthToken] = useState<string | null>(() =>
|
||||
isPassthrough && isTokenValid(serverId, userID) ? getToken(serverId, userID)?.access_token ?? null : null,
|
||||
usesBrowserHeldToken && isTokenValid(serverId, userID) ? getToken(serverId, userID)?.access_token ?? null : null,
|
||||
);
|
||||
|
||||
// Re-sync token when serverId/userID changes (useState initializer only runs on mount).
|
||||
useEffect(() => {
|
||||
if (!isPassthrough) {
|
||||
if (!usesBrowserHeldToken) {
|
||||
setOauthToken(null);
|
||||
return;
|
||||
}
|
||||
setOauthToken(isTokenValid(serverId, userID) ? getToken(serverId, userID)?.access_token ?? null : null);
|
||||
}, [serverId, userID, isPassthrough]);
|
||||
}, [serverId, userID, usesBrowserHeldToken]);
|
||||
|
||||
const {
|
||||
startOAuthFlow,
|
||||
|
|
@ -109,7 +120,7 @@ const MCPToolsViewer = ({
|
|||
// x-mcp-{alias}-{header} pattern and forwards it to the upstream MCP server.
|
||||
// When no alias is available, fall back to x-mcp-auth (legacy but still supported).
|
||||
// Passthrough only: authorization_code/token_exchange/M2M tokens are attached server-side, not from the browser.
|
||||
if (isPassthrough && oauthToken) {
|
||||
if (usesBrowserHeldToken && oauthToken) {
|
||||
Object.assign(customHeaders, buildMcpPassthroughAuthHeader(serverAlias, oauthToken));
|
||||
}
|
||||
|
||||
|
|
@ -164,7 +175,8 @@ const MCPToolsViewer = ({
|
|||
// Passthrough blocks until a browser session token exists; authorization_code blocks until
|
||||
// the user has a valid DB credential (else the backend returns no tools).
|
||||
enabled:
|
||||
!!accessToken && (isPassthrough ? oauthToken !== null : isAuthorizationCode ? hasAuthorizationCodeCred : true),
|
||||
!!accessToken &&
|
||||
(usesBrowserHeldToken ? oauthToken !== null : isAuthorizationCode ? hasAuthorizationCodeCred : true),
|
||||
staleTime: 30000, // Consider data fresh for 30 seconds
|
||||
retry: (failureCount, error: any) => {
|
||||
// Don't retry on 401 — token is invalid, user must re-authenticate
|
||||
|
|
@ -253,7 +265,8 @@ const MCPToolsViewer = ({
|
|||
// passthrough needs a browser token; authorization_code needs a stored DB credential or a
|
||||
// still-valid one — a 401 from the list call means the backend has none even
|
||||
// after attempting a refresh, so re-authorization is required.
|
||||
const authGateActive = (isPassthrough && !oauthToken) || authorizationCodeNeedsAuth || authorizationCodeTokenRejected;
|
||||
const authGateActive =
|
||||
(usesBrowserHeldToken && !oauthToken) || authorizationCodeNeedsAuth || authorizationCodeTokenRejected;
|
||||
// Treat authorization_code credential-status loading as "tools loading" so the empty state
|
||||
// doesn't flash before we know whether the user needs to authorize.
|
||||
const toolsAreaLoading = isLoadingTools || authorizationCodeStatusLoading;
|
||||
|
|
@ -359,7 +372,7 @@ const MCPToolsViewer = ({
|
|||
</Text>
|
||||
|
||||
{/* Passthrough auth gate — browser session token absent */}
|
||||
{isPassthrough && !oauthToken && (
|
||||
{usesBrowserHeldToken && !oauthToken && (
|
||||
<div className="p-4 text-center bg-white border border-gray-200 rounded-lg">
|
||||
<LockOutlined className="text-2xl text-gray-400 mb-2" />
|
||||
<p className="text-xs font-medium text-gray-700 mb-1">Authentication required</p>
|
||||
|
|
|
|||
|
|
@ -41,8 +41,17 @@ export const AUTH_TYPE = {
|
|||
OAUTH2: "oauth2",
|
||||
OAUTH2_TOKEN_EXCHANGE: "oauth2_token_exchange",
|
||||
AWS_SIGV4: "aws_sigv4",
|
||||
TRUE_PASSTHROUGH: "true_passthrough",
|
||||
OAUTH_DELEGATE: "oauth_delegate",
|
||||
};
|
||||
|
||||
// The two client-forwarded token modes: the caller supplies the upstream Authorization (forwarded
|
||||
// verbatim for true_passthrough, alongside LiteLLM admission for oauth_delegate). The dashboard holds
|
||||
// their token in sessionStorage instead of persisting it, and the browser-authorize temp payload keeps
|
||||
// their real auth_type so the backend does not treat them as needing a stored per-user token.
|
||||
export const isClientForwardedTokenMode = (authType?: string | null): boolean =>
|
||||
authType === AUTH_TYPE.TRUE_PASSTHROUGH || authType === AUTH_TYPE.OAUTH_DELEGATE;
|
||||
|
||||
export const OAUTH_FLOW = {
|
||||
INTERACTIVE: "interactive",
|
||||
M2M: "m2m",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useEffect, useCallback } from "react";
|
||||
import { testMCPToolsListRequest } from "../components/networking";
|
||||
import { AUTH_TYPE, OAUTH_FLOW, TRANSPORT } from "@/components/mcp_tools/types";
|
||||
import { AUTH_TYPE, OAUTH_FLOW, TRANSPORT, isClientForwardedTokenMode } from "@/components/mcp_tools/types";
|
||||
|
||||
interface MCPServerConfig {
|
||||
server_id?: string;
|
||||
|
|
@ -56,7 +56,8 @@ export const useTestMCPConnection = ({
|
|||
|
||||
// Check if we have the minimum required fields to fetch tools
|
||||
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 isBrowserHeldTokenMode = isClientForwardedTokenMode(formValues.auth_type);
|
||||
const requiresOAuthToken = (formValues.auth_type === AUTH_TYPE.OAUTH2 && !isM2MOAuth) || isBrowserHeldTokenMode;
|
||||
const isOpenAPITransport = formValues.transport === TRANSPORT.OPENAPI;
|
||||
const hasEndpoint = isOpenAPITransport ? !!formValues.spec_path : !!formValues.url;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue