feat(mcp): Add tool call and tool list support via UI for Oauth mcps#28454
Conversation
…e-auth After a user creates an OAuth MCP server and completes the authorization flow, the resulting access token is now stored in sessionStorage keyed by server_id. The MCP Tools tab reads this cached token and includes it as an MCP auth header when listing and invoking tools, so the user never sees an empty tool list. When the session ends (tab close / new browser) an Authorize button re-triggers the flow without leaving the Tools screen. Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]>
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
Greptile SummaryThis PR adds OAuth PKCE tool-listing support in the MCP UI by caching the access token in
Confidence Score: 4/5Safe to merge; the OAuth gate, token caching, and 401 recovery paths all look correct with only minor improvement opportunities. The implementation handles the core auth flow, expiry, and 401 recovery correctly. The only issues found are: ui/litellm-dashboard/src/utils/mcpTokenStore.ts (expiry grace period) and ui/litellm-dashboard/src/hooks/mcpOAuthUtils.ts (SSR guard pattern)
|
| Filename | Overview |
|---|---|
| litellm/proxy/_experimental/mcp_server/utils.py | Adds sanitize_mcp_alias_for_header and lookup_mcp_server_auth_in_headers — clean, well-tested utilities that centralize alias normalization and auth header resolution for all call sites. |
| ui/litellm-dashboard/src/utils/mcpTokenStore.ts | New sessionStorage-backed OAuth token store, scoped by user+server, with TTL validation, SSR guards, and a clearAllMcpTokens for logout. Well-tested. |
| ui/litellm-dashboard/src/components/networking.tsx | Restructures listMCPTools to never throw while propagating HTTP status codes; parse errors are caught and non-ok responses return an error-shape object with status. |
| ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx | Adds OAuth session gate (PKCE only, M2M excluded via tokenUrl heuristic), reads token from sessionStorage on mount, and handles 401 by clearing the cached token and re-showing the auth gate. |
| ui/litellm-dashboard/src/hooks/useToolsOAuthFlow.tsx | New Tools-specific PKCE hook; uses a unique FLOW_STATE_KEY to guard against consuming results from admin/user flows and stores token in sessionStorage only. |
| ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx | Adds early-return guard so the admin hook only resumes if its own FLOW_STATE_KEY is present, preventing spurious session-state-lost errors from tools re-auth redirects. |
| ui/litellm-dashboard/src/hooks/useUserMcpOAuthFlow.tsx | Same guard pattern as useMcpOAuthFlow; also extracts shared helpers to mcpOAuthUtils.ts. |
Reviews (5): Last reviewed commit: "fix(ui/mcp): return error shape when lis..." | Re-trigger Greptile
listMCPTools previously swallowed all errors (including HTTP 401) by
returning a synthetic { tools: [], error: 'network_error', ... } payload.
That made the useQuery retry-on-401 guard and mcpToolsError dead code,
so expired OAuth tokens never re-triggered the auth gate.
- Throw an enhanced Error with .status attached on non-2xx responses
(still preserves the legacy shape for true network failures so the
caller can render a generic message without crashing).
- Clear the cached OAuth session token when the tools query fails with
401, mirroring callMCPTool's onError handler so the Authorize button
is shown again.
- Surface mcpToolsError in the existing error banner.
Co-authored-by: Yassin Kortam <[email protected]>
|
|
- Pass stable setOauthToken setter directly as onSuccess to avoid recreating useToolsOAuthFlow's resumeOAuthFlow on every render. - Reuse the already-parsed FLOW_STATE_KEY value (peeked) instead of re-reading and re-parsing sessionStorage in resumeOAuthFlow. Co-authored-by: Yassin Kortam <[email protected]>
The previous fix made listMCPTools throw on HTTP errors while still
returning a synthetic object on network errors. This inconsistent
contract broke existing callers (MCPToolPermissions, MCPAppsPanel,
MCPConnectPicker) which inspect result.error / result.message and
expect the function to never throw.
- Return a normalized { tools: [], error, message, status, ... }
object on HTTP errors (instead of throwing) so all callers see a
consistent shape and the user-visible error text from
result.message is preserved.
- Convert the returned error object into a thrown Error inside the
one caller that needs it — the useQuery in mcp_tools.tsx — so the
401 retry/onError handlers still trigger and clear the cached
OAuth token.
Co-authored-by: Yassin Kortam <[email protected]>
PR overviewMCP OAuth tools UI supportThis PR adds session-backed OAuth handling for the MCP tools view and normalizes server-specific MCP auth header lookup for sanitized aliases. I checked the REST tool list/call paths, OAuth callback state handling, token forwarding headers, and allowed-server authorization flow and did not find an attacker-reachable security issue in the changed lines. Security review
Risk: 2/10 |
|
@greptile re review |
Backend auth header resolution now matches x-mcp-{alias} keys produced by
the dashboard sanitizer, and the Tools tab re-syncs OAuth tokens when
serverId changes.
Co-authored-by: Cursor <[email protected]>
Accept legacy str | dict server auth maps and annotate list_tools server_auth_header as Union[str, dict] for mypy. Co-authored-by: Cursor <[email protected]>
…uth hooks Hoist the duplicate buildCallbackUrl and clearStorage helpers out of useToolsOAuthFlow and useUserMcpOAuthFlow into a new shared module src/hooks/mcpOAuthUtils.ts so the two hooks cannot drift if the URL construction or storage cleanup logic needs to change. Co-authored-by: Yassin Kortam <[email protected]>
|
@greptile re review |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high mode and found 1 potential issue.
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: OAuth auth gate blocks M2M client_credentials servers unnecessarily
- Added a tokenUrl prop to MCPToolsViewer and narrowed isOAuth to auth_type === "oauth2" && !tokenUrl, matching the existing M2M heuristic in mcp_server_edit.tsx so M2M servers bypass the interactive auth gate.
Preview (b64c81ae55)
diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
--- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
+++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
@@ -1212,12 +1212,18 @@
return []
# Get server-specific auth header if available
- server_auth_header = None
- if mcp_server_auth_headers and server.alias:
- server_auth_header = mcp_server_auth_headers.get(server.alias)
- elif mcp_server_auth_headers and server.server_name:
- server_auth_header = mcp_server_auth_headers.get(server.server_name)
+ server_auth_header: Optional[Union[str, Dict[str, str]]] = None
+ if mcp_server_auth_headers:
+ from litellm.proxy._experimental.mcp_server.utils import (
+ lookup_mcp_server_auth_in_headers,
+ )
+ server_auth_header = lookup_mcp_server_auth_in_headers(
+ mcp_server_auth_headers,
+ alias=server.alias,
+ server_name=server.server_name,
+ )
+
# Fall back to deprecated mcp_auth_header if no server-specific header found
if server_auth_header is None:
server_auth_header = mcp_auth_header
@@ -2707,16 +2713,15 @@
server_auth_header: Optional[Union[Dict[str, str], str]] = None
if mcp_server_auth_headers:
# Normalize keys for case-insensitive lookup
- normalized_headers = {
- k.lower(): v for k, v in mcp_server_auth_headers.items()
- }
+ from litellm.proxy._experimental.mcp_server.utils import (
+ lookup_mcp_server_auth_in_headers,
+ )
- if mcp_server.alias:
- server_auth_header = normalized_headers.get(mcp_server.alias.lower())
- if server_auth_header is None and mcp_server.server_name:
- server_auth_header = normalized_headers.get(
- mcp_server.server_name.lower()
- )
+ server_auth_header = lookup_mcp_server_auth_in_headers(
+ mcp_server_auth_headers,
+ alias=mcp_server.alias,
+ server_name=mcp_server.server_name,
+ )
# Fall back to deprecated mcp_auth_header if no server-specific header found
if server_auth_header is None:
diff --git a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py
--- a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py
+++ b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py
@@ -62,22 +62,18 @@
mcp_auth_header: Optional[str],
) -> Optional[Union[Dict[str, str], str]]:
"""Helper function to get server-specific auth header with case-insensitive matching."""
- if mcp_server_auth_headers and server.alias:
- normalized_server_alias = server.alias.lower()
- normalized_headers = {
- k.lower(): v for k, v in mcp_server_auth_headers.items()
- }
- server_auth = normalized_headers.get(normalized_server_alias)
+ from litellm.proxy._experimental.mcp_server.utils import (
+ lookup_mcp_server_auth_in_headers,
+ )
+
+ if mcp_server_auth_headers:
+ server_auth = lookup_mcp_server_auth_in_headers(
+ mcp_server_auth_headers,
+ alias=getattr(server, "alias", None),
+ server_name=getattr(server, "server_name", None),
+ )
if server_auth is not None:
return server_auth
- elif mcp_server_auth_headers and server.server_name:
- normalized_server_name = server.server_name.lower()
- normalized_headers = {
- k.lower(): v for k, v in mcp_server_auth_headers.items()
- }
- server_auth = normalized_headers.get(normalized_server_name)
- if server_auth is not None:
- return server_auth
return mcp_auth_header
def _get_oauth2_server_ids(allowed_server_ids: List[str]) -> Set[str]:
diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py
--- a/litellm/proxy/_experimental/mcp_server/server.py
+++ b/litellm/proxy/_experimental/mcp_server/server.py
@@ -1114,11 +1114,17 @@
) -> Tuple[Optional[Union[Dict[str, str], str]], Optional[Dict[str, str]]]:
"""Build auth and extra headers for a server."""
server_auth_header: Optional[Union[Dict[str, str], str]] = None
- if mcp_server_auth_headers and server.alias is not None:
- server_auth_header = mcp_server_auth_headers.get(server.alias)
- elif mcp_server_auth_headers and server.server_name is not None:
- server_auth_header = mcp_server_auth_headers.get(server.server_name)
+ if mcp_server_auth_headers:
+ from litellm.proxy._experimental.mcp_server.utils import (
+ lookup_mcp_server_auth_in_headers,
+ )
+ server_auth_header = lookup_mcp_server_auth_in_headers(
+ mcp_server_auth_headers,
+ alias=server.alias,
+ server_name=server.server_name,
+ )
+
extra_headers: Optional[Dict[str, str]] = None
if server.auth_type == MCPAuth.oauth2:
# For OAuth2 M2M servers, upstream Authorization must come from
diff --git a/litellm/proxy/_experimental/mcp_server/utils.py b/litellm/proxy/_experimental/mcp_server/utils.py
--- a/litellm/proxy/_experimental/mcp_server/utils.py
+++ b/litellm/proxy/_experimental/mcp_server/utils.py
@@ -2,7 +2,8 @@
MCP Server Utilities
"""
-from typing import Any, Dict, Iterator, Mapping, Optional, Tuple
+import re
+from typing import Any, Dict, Iterator, Mapping, Optional, Tuple, Union
import hashlib
import importlib
@@ -117,6 +118,50 @@
return server_name.replace(" ", "_")
+_MCP_ALIAS_HEADER_INVALID_RE = re.compile(r"[^a-z0-9_]")
+
+
+def sanitize_mcp_alias_for_header(alias: str) -> str:
+ """
+ Sanitize an MCP server alias for x-mcp-{alias}-{header} HTTP headers.
+
+ Must stay in sync with ui/litellm-dashboard/src/utils/mcpHeaderUtils.ts.
+ """
+ sanitized = _MCP_ALIAS_HEADER_INVALID_RE.sub("_", alias.lower().strip())
+ sanitized = re.sub(r"_+", "_", sanitized)
+ return sanitized.strip("_")
+
+
+def lookup_mcp_server_auth_in_headers(
+ mcp_server_auth_headers: Mapping[str, Union[str, Dict[str, str]]],
+ *,
+ alias: Optional[str] = None,
+ server_name: Optional[str] = None,
+) -> Optional[Union[str, Dict[str, str]]]:
+ """
+ Resolve server-specific auth headers with case-insensitive matching.
+
+ Tries the raw alias/server_name (lowercased) and the header-safe sanitized
+ alias so dashboard clients using sanitize_mcp_alias_for_header() still match.
+ """
+ if not mcp_server_auth_headers:
+ return None
+
+ normalized_headers = {k.lower(): v for k, v in mcp_server_auth_headers.items()}
+
+ for identifier in (alias, server_name):
+ if not identifier:
+ continue
+ keys_to_try = [identifier.lower()]
+ sanitized = sanitize_mcp_alias_for_header(identifier)
+ if sanitized and sanitized not in keys_to_try:
+ keys_to_try.append(sanitized)
+ for key in keys_to_try:
+ if key in normalized_headers:
+ return normalized_headers[key]
+ return None
+
+
def validate_and_normalize_mcp_server_payload(payload: Any) -> None:
"""
Validate and normalize MCP server payload fields (server_name and alias).
diff --git a/tests/mcp_tests/test_mcp_server.py b/tests/mcp_tests/test_mcp_server.py
--- a/tests/mcp_tests/test_mcp_server.py
+++ b/tests/mcp_tests/test_mcp_server.py
@@ -1770,6 +1770,26 @@
assert result == "Bearer default_token"
+def test_get_server_auth_header_hyphenated_alias_sanitized_header_key():
+ """Header keys use sanitized alias; lookup must match legacy hyphenated aliases."""
+ from litellm.proxy._experimental.mcp_server.rest_endpoints import (
+ _get_server_auth_header,
+ )
+
+ mock_server = MagicMock()
+ mock_server.alias = "GitHub-MCP"
+ mock_server.server_name = "github_mcp_server"
+
+ mcp_server_auth_headers = {
+ "github_mcp": {"Authorization": "Bearer github-mcp-token"},
+ }
+
+ result = _get_server_auth_header(
+ mock_server, mcp_server_auth_headers, "Bearer default_token"
+ )
+ assert result == {"Authorization": "Bearer github-mcp-token"}
+
+
def test_get_server_auth_header_no_auth_headers():
"""Test _get_server_auth_header function with no auth headers."""
from litellm.proxy._experimental.mcp_server.rest_endpoints import (
diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_header_alias_utils.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_header_alias_utils.py
new file mode 100644
--- /dev/null
+++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_header_alias_utils.py
@@ -1,0 +1,18 @@
+"""Tests for MCP header alias sanitization and auth header lookup."""
+
+from litellm.proxy._experimental.mcp_server.utils import (
+ lookup_mcp_server_auth_in_headers,
+ sanitize_mcp_alias_for_header,
+)
+
+
+def test_sanitize_mcp_alias_for_header():
+ assert sanitize_mcp_alias_for_header("My Server") == "my_server"
+ assert sanitize_mcp_alias_for_header("GitHub-MCP!") == "github_mcp"
+ assert sanitize_mcp_alias_for_header("github_mcp2") == "github_mcp2"
+
+
+def test_lookup_mcp_server_auth_in_headers_sanitized_alias():
+ headers = {"github_mcp": {"Authorization": "Bearer token"}}
+ result = lookup_mcp_server_auth_in_headers(headers, alias="GitHub-MCP")
+ assert result == {"Authorization": "Bearer token"}
diff --git a/ui/litellm-dashboard/src/app/mcp/oauth/callback/page.tsx b/ui/litellm-dashboard/src/app/mcp/oauth/callback/page.tsx
--- a/ui/litellm-dashboard/src/app/mcp/oauth/callback/page.tsx
+++ b/ui/litellm-dashboard/src/app/mcp/oauth/callback/page.tsx
@@ -4,11 +4,12 @@
import { useSearchParams } from "next/navigation";
import { getSecureItem, setSecureItem } from "@/utils/secureStorage";
-// Written to sessionStorage so both the admin hook (useMcpOAuthFlow) and the
-// user hook (useUserMcpOAuthFlow) can pick up the result. Each hook reads
-// its own namespace to avoid cross-flow collisions.
+// Written to sessionStorage so the admin hook (useMcpOAuthFlow), the user hook
+// (useUserMcpOAuthFlow), and the tools re-auth hook (useToolsOAuthFlow) can each
+// pick up the result. Each hook reads its own namespace to avoid cross-flow collisions.
const ADMIN_RESULT_KEY = "litellm-mcp-oauth-result";
const USER_RESULT_KEY = "litellm-user-mcp-oauth-result";
+const TOOLS_RESULT_KEY = "litellm-tools-mcp-oauth-result";
const RETURN_URL_STORAGE_KEY = "litellm-mcp-oauth-return-url";
const resolveDefaultRedirect = () => {
@@ -50,11 +51,12 @@
}
try {
- // Write to both namespace keys (admin and user) so whichever hook is
- // active can consume the result. sessionStorage only — no localStorage.
+ // Write to all namespace keys so whichever hook is active can consume
+ // the result. sessionStorage only — no localStorage.
const serialized = JSON.stringify(payload);
setSecureItem(ADMIN_RESULT_KEY, serialized);
setSecureItem(USER_RESULT_KEY, serialized);
+ setSecureItem(TOOLS_RESULT_KEY, serialized);
} catch (err) {
// Silently ignore storage errors
}
diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx
--- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx
+++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx
@@ -3,6 +3,7 @@
import { InfoCircleOutlined } from "@ant-design/icons";
import { Button, TextInput } from "@tremor/react";
import { createMCPServer, registerMCPServer } from "../networking";
+import { setToken } from "@/utils/mcpTokenStore";
import { AUTH_TYPE, DiscoverableMCPServer, OAUTH_FLOW, MCPServer, MCPServerCostInfo, TRANSPORT } from "./types";
import OAuthFormFields from "./OAuthFormFields";
import MCPServerCostConfig from "./mcp_server_cost_config";
@@ -24,6 +25,7 @@
interface CreateMCPServerProps {
userRole: string;
+ userID?: string | null;
accessToken: string | null;
onCreateSuccess: (newMcpServer: MCPServer) => void;
isModalVisible: boolean;
@@ -47,6 +49,7 @@
};
const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
+ userID,
userRole,
accessToken,
onCreateSuccess,
@@ -409,6 +412,21 @@
? await createMCPServer(accessToken, payload)
: await registerMCPServer(accessToken, payload);
+ // Cache the OAuth token in sessionStorage so the Tools tab can use it
+ // immediately without re-authenticating. No backend DB write.
+ if (oauthTokenResponse?.access_token && response?.server_id) {
+ 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,
+ );
+ }
+
NotificationsManager.success(
isAdmin
? "MCP Server created successfully"
diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_view.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_view.tsx
--- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_view.tsx
+++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_view.tsx
@@ -174,6 +174,7 @@
serverId={mcpServer.server_id}
accessToken={accessToken}
auth_type={mcpServer.auth_type}
+ tokenUrl={mcpServer.token_url}
userRole={userRole}
userID={userID}
serverAlias={mcpServer.alias}
diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx
--- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx
+++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx
@@ -287,6 +287,7 @@
</Modal>
<CreateMCPServer
userRole={userRole}
+ userID={userID}
accessToken={accessToken}
onCreateSuccess={handleCreateSuccess}
isModalVisible={isModalVisible}
diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx
--- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx
+++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx
@@ -1,17 +1,21 @@
-import React, { useState } from "react";
+import React, { useEffect, useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { ToolTestPanel } from "./ToolTestPanel";
import { MCPTool, MCPToolsViewerProps, MCPContent, CallMCPToolResponse } from "./types";
import { listMCPTools, callMCPTool } from "../networking";
+import { isTokenValid, getToken, removeToken } from "@/utils/mcpTokenStore";
+import { sanitizeMcpAliasForHeader } from "@/utils/mcpHeaderUtils";
+import { useToolsOAuthFlow } from "@/hooks/useToolsOAuthFlow";
import { Card, Title, Text } from "@tremor/react";
-import { RobotOutlined, ToolOutlined, SearchOutlined, KeyOutlined } from "@ant-design/icons";
+import { RobotOutlined, ToolOutlined, SearchOutlined, KeyOutlined, LockOutlined } from "@ant-design/icons";
import { Input, Button as AntdButton } from "antd";
const MCPToolsViewer = ({
serverId,
accessToken,
auth_type,
+ tokenUrl,
userRole,
userID,
serverAlias,
@@ -21,29 +25,85 @@
const [toolResult, setToolResult] = useState<MCPContent[] | null>(null);
const [toolError, setToolError] = useState<Error | null>(null);
const [toolSearchTerm, setToolSearchTerm] = useState("");
-
+
// State for passthrough headers
const [passthroughHeaders, setPassthroughHeaders] = useState<Record<string, string>>({});
const [showHeaderInput, setShowHeaderInput] = useState(false);
+ // OAuth session token (sessionStorage-backed, cleared on tab/browser close).
+ // Only the interactive (authorization_code/PKCE) flow needs a user-facing
+ // auth gate. M2M (client_credentials) servers are also `auth_type === "oauth2"`,
+ // but the backend fetches their token internally — gating tool listing on
+ // them would force users through a non-existent authorization endpoint.
+ // We detect M2M via the presence of `tokenUrl`, matching the heuristic in
+ // `mcp_server_edit.tsx`.
+ const isOAuth = auth_type === "oauth2" && !tokenUrl;
+ const [oauthToken, setOauthToken] = useState<string | null>(() =>
+ isOAuth && 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 (!isOAuth) {
+ setOauthToken(null);
+ return;
+ }
+ setOauthToken(
+ isTokenValid(serverId, userID)
+ ? (getToken(serverId, userID)?.access_token ?? null)
+ : null
+ );
+ }, [serverId, userID, isOAuth]);
+
+ const { startOAuthFlow, status: oauthStatus, error: oauthError } = useToolsOAuthFlow({
+ accessToken: accessToken ?? "",
+ serverId,
+ serverAlias,
+ userId: userID,
+ onSuccess: setOauthToken,
+ });
+
// Check if this server has extra headers configured
const hasExtraHeaders = extraHeaders && extraHeaders.length > 0;
// Build custom headers for MCP server requests
const buildCustomHeaders = () => {
- if (!serverAlias || !hasExtraHeaders) return undefined;
-
const customHeaders: Record<string, string> = {};
-
+
+ // Include the session OAuth token using MCP-specific headers so it doesn't
+ // conflict with the Authorization header used by the LiteLLM proxy itself.
+ // The backend's _get_mcp_server_auth_headers_from_headers() picks up the
+ // 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).
+ if (oauthToken) {
+ if (serverAlias) {
+ const safeAlias = sanitizeMcpAliasForHeader(serverAlias);
+ if (safeAlias) {
+ customHeaders[`x-mcp-${safeAlias}-authorization`] = `Bearer ${oauthToken}`;
+ } else {
+ customHeaders["x-mcp-auth"] = `Bearer ${oauthToken}`;
+ }
+ } else {
+ customHeaders["x-mcp-auth"] = `Bearer ${oauthToken}`;
+ }
+ }
+
// Add passthrough headers with server-specific prefix
- Object.entries(passthroughHeaders).forEach(([headerName, headerValue]) => {
- if (headerValue && headerValue.trim()) {
- // Format: x-mcp-{alias}-{header_name}
- const mcpHeaderName = `x-mcp-${serverAlias}-${headerName.toLowerCase()}`;
- customHeaders[mcpHeaderName] = headerValue;
+ if (serverAlias && hasExtraHeaders) {
+ const safeAlias = sanitizeMcpAliasForHeader(serverAlias);
+ if (safeAlias) {
+ Object.entries(passthroughHeaders).forEach(([headerName, headerValue]) => {
+ if (headerValue && headerValue.trim()) {
+ // Format: x-mcp-{alias}-{header_name}
+ const mcpHeaderName = `x-mcp-${safeAlias}-${headerName.toLowerCase()}`;
+ customHeaders[mcpHeaderName] = headerValue;
+ }
+ });
}
- });
-
+ }
+
return Object.keys(customHeaders).length > 0 ? customHeaders : undefined;
};
@@ -54,15 +114,55 @@
error: mcpToolsError,
refetch: refetchTools,
} = useQuery({
- queryKey: ["mcpTools", serverId, passthroughHeaders],
- queryFn: () => {
+ queryKey: ["mcpTools", serverId, passthroughHeaders, oauthToken],
+ queryFn: async () => {
if (!accessToken) throw new Error("Access Token required");
- return listMCPTools(accessToken, serverId, buildCustomHeaders());
+ const result = await listMCPTools(accessToken, serverId, buildCustomHeaders());
+ // listMCPTools never throws — surface error responses as thrown errors
+ // here so useQuery's retry/onError can react (e.g. clear the cached
+ // OAuth token on 401).
+ if (result?.error) {
+ const status = (result as { status?: number }).status;
+ if (status === 401) {
+ removeToken(serverId, userID);
+ }
+ const enhancedError = new Error(
+ result.message || result.error || "Failed to fetch MCP tools",
+ ) as Error & {
+ status?: number;
+ statusText?: string;
+ details?: any;
+ };
+ enhancedError.status = status;
+ enhancedError.statusText = (result as any).statusText;
+ enhancedError.details = (result as any).details;
+ throw enhancedError;
+ }
+ return result;
},
- enabled: !!accessToken,
+ // For OAuth servers, block the query until a session token is available
+ enabled: !!accessToken && (!isOAuth || oauthToken !== null),
staleTime: 30000, // Consider data fresh for 30 seconds
+ retry: (failureCount, error: any) => {
+ // Don't retry on 401 — token is invalid, user must re-authenticate
+ if (error?.status === 401 || error?.response?.status === 401) return false;
+ return failureCount < 2;
+ },
});
+ // If the tools query fails with 401, the cached OAuth token is invalid —
+ // clear it so the auth gate is shown again and the user can re-authenticate.
+ useEffect(() => {
+ const err = mcpToolsError as
+ | (Error & { status?: number; response?: { status?: number } })
+ | null;
+ const status = err?.status ?? err?.response?.status;
+ if (status === 401) {
+ removeToken(serverId, userID);
+ setOauthToken(null);
+ }
+ }, [mcpToolsError, serverId, userID]);
+
// Mutation for calling a tool
const { mutate: executeTool, isPending: isCallingTool } = useMutation({
mutationFn: async (args: { tool: MCPTool; arguments: Record<string, any> }) => {
@@ -85,9 +185,14 @@
setToolResult(data.content);
setToolError(null);
},
- onError: (error: Error) => {
+ onError: (error: Error & { status?: number; response?: { status?: number } }) => {
setToolError(error);
setToolResult(null);
+ // On 401, clear the cached token so the auth gate is shown again
+ if (error?.status === 401 || (error as any)?.response?.status === 401) {
+ removeToken(serverId, userID);
+ setOauthToken(null);
+ }
},
});
@@ -197,7 +302,31 @@
)}
</Text>
- {/* Search Bar */}
+ {/* OAuth Auth Gate — shown when token is absent for OAuth servers */}
+ {isOAuth && !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>
+ <p className="text-xs text-gray-500 mb-3">
+ Authenticate to view available tools
+ </p>
+ <AntdButton
+ size="small"
+ type="primary"
+ loading={oauthStatus === "authorizing" || oauthStatus === "exchanging"}
+ onClick={startOAuthFlow}
+ disabled={!accessToken}
+ >
+ Authorize
+ </AntdButton>
+ {oauthError && (
+ <p className="text-xs text-red-500 mt-2">{oauthError}</p>
+ )}
+ </div>
+ )}
+
+ {/* Search Bar — only shown when tools are loaded */}
+ {!isOAuth || oauthToken ? <>
{toolsData.length > 0 && (
<div className="mb-3">
<Input
@@ -224,14 +353,16 @@
)}
{/* Error State */}
- {mcpToolsResponse?.error && !isLoadingTools && !toolsData.length && (
+ {(mcpToolsResponse?.error || mcpToolsError) && !isLoadingTools && !toolsData.length && (
<div className="p-3 text-xs text-red-800 rounded-lg bg-red-50 border border-red-200">
- <p className="font-medium">Error: {mcpToolsResponse.message}</p>
+ <p className="font-medium">
+ Error: {mcpToolsResponse?.message || (mcpToolsError as Error)?.message}
+ </p>
</div>
)}
{/* No Tools State */}
- {!isLoadingTools && !mcpToolsResponse?.error && (!toolsData || toolsData.length === 0) && (
+ {!isLoadingTools && !mcpToolsResponse?.error && !mcpToolsError && (!toolsData || toolsData.length === 0) && (
<div className="p-4 text-center bg-white border border-gray-200 rounded-lg">
<div className="mx-auto w-8 h-8 bg-gray-200 rounded-full flex items-center justify-center mb-2">
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -315,6 +446,7 @@
)}
</>
)}
+ </> : null}
</div>
</div>
</div>
diff --git a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx
--- a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx
+++ b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx
@@ -163,6 +163,13 @@
serverId: string;
accessToken: string | null;
auth_type?: string | null;
+ /**
+ * When set, indicates the server uses the OAuth2 M2M (client_credentials)
+ * flow — the backend handles token acquisition internally, so the UI must
+ * not gate tool listing behind an interactive PKCE authorization. Mirrors
+ * the heuristic used in `mcp_server_edit.tsx` (`token_url` set => M2M).
+ */
+ tokenUrl?: string | null;
userRole: string | null;
userID: string | null;
serverAlias?: string | null;
diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx
--- a/ui/litellm-dashboard/src/components/networking.tsx
+++ b/ui/litellm-dashboard/src/components/networking.tsx
@@ -7068,46 +7068,33 @@
};
export const listMCPTools = async (
- accessToken: string,
+ accessToken: string,
serverId: string,
- customHeaders?: Record<string, string>
+ customHeaders?: Record<string, string>,
) => {
- try {
- // Construct base URL
- let url = proxyBaseUrl
- ? `${proxyBaseUrl}/mcp-rest/tools/list?server_id=${serverId}`
- : `/mcp-rest/tools/list?server_id=${serverId}`;
+ // Construct base URL
+ let url = proxyBaseUrl
+ ? `${proxyBaseUrl}/mcp-rest/tools/list?server_id=${serverId}`
+ : `/mcp-rest/tools/list?server_id=${serverId}`;
- console.log("Fetching MCP tools from:", url);
+ console.log("Fetching MCP tools from:", url);
- const headers: Record<string, string> = {
- [globalLitellmHeaderName]: `Bearer ${accessToken}`,
- "Content-Type": "application/json",
- ...customHeaders, // Merge custom headers for passthrough auth
- };
+ const headers: Record<string, string> = {
+ [globalLitellmHeaderName]: `Bearer ${accessToken}`,
+ "Content-Type": "application/json",
+ ...customHeaders, // Merge custom headers for passthrough auth
+ };
- const response = await fetch(url, {
+ let response: Response;
+ try {
+ response = await fetch(url, {
method: "GET",
headers,
});
-
- const data = await response.json();
- console.log("Fetched MCP tools response:", data);
-
- if (!response.ok) {
- // If the server returned an error response, use it
- if (data.error && data.message) {
- throw new Error(data.message);
- }
- // Otherwise use a generic error
- throw new Error("Failed to fetch MCP tools");
- }
-
- // Return the full response object which includes tools, error, message, and stack_trace
- return data;
} catch (error) {
- console.error("Failed to fetch MCP tools:", error);
- // Return an error response in the same format as the API
+ // Network-level failure (no HTTP response). Preserve legacy shape so the
+ // caller can render a generic error message without crashing.
+ console.error("Failed to fetch MCP tools (network error):", error);
return {
tools: [],
error: "network_error",
@@ -7115,6 +7102,36 @@
stack_trace: null,
};
}
+
+ let data: any = null;
+ try {
+ data = await response.json();
+ } catch (parseError) {
+ console.error("Failed to parse MCP tools response:", parseError);
+ }
+ console.log("Fetched MCP tools response:", data);
+
+ if (!response.ok) {
+ // Preserve the legacy "never throws" contract so existing callers
+ // (e.g. MCPToolPermissions, MCPAppsPanel, MCPConnectPicker) can continue
+ // to inspect `result.error` / `result.message`. Attach `status` so
+ // callers that need to react to auth failures (e.g. the useQuery in
+ // mcp_tools.tsx) can still detect 401s from the returned object.
+ const errorMessage =
+ (data && (data.message || data.error)) || "Failed to fetch MCP tools";
+ return {
+ tools: [],
+ error: (data && data.error) || `http_${response.status}`,
+ message: errorMessage,
+ status: response.status,
+ statusText: response.statusText,
+ details: data,
+ stack_trace: null,
+ };
+ }
+
+ // Return the full response object which includes tools, error, message, and stack_trace
+ return data;
};
interface CallMCPToolOptions {
diff --git a/ui/litellm-dashboard/src/hooks/mcpOAuthUtils.ts b/ui/litellm-dashboard/src/hooks/mcpOAuthUtils.ts
new file mode 100644
--- /dev/null
+++ b/ui/litellm-dashboard/src/hooks/mcpOAuthUtils.ts
@@ -1,0 +1,39 @@
+/**
+ * Shared utilities for MCP OAuth2 PKCE flow hooks.
+ *
+ * These helpers are used by both useToolsOAuthFlow and useUserMcpOAuthFlow
+ * to avoid divergence in URL construction and storage cleanup logic.
+ */
+
+import { getProxyBaseUrl, serverRootPath } from "@/components/networking";
+
+/**
+ * Build the OAuth callback URL for the current UI deployment.
+ *
+ * In the browser, derive the `/ui` prefix from the current pathname so the
+ * callback works regardless of how the proxy is mounted. Outside the browser
+ * (SSR), fall back to the configured proxy base URL and server root path.
+ */
+export const buildCallbackUrl = (): string => {
+ if (typeof window !== "undefined") {
+ const path = window.location.pathname || "";
+ const idx = path.indexOf("/ui");
+ const prefix = idx >= 0 ? path.slice(0, idx + 3).replace(/\/+$/, "") : "";
+ return `${window.location.origin}${prefix}/mcp/oauth/callback`;
+ }
+ const base = (getProxyBaseUrl() || "").replace(/\/+$/, "");
+ const root = serverRootPath && serverRootPath !== "/" ? serverRootPath : "";
+ return `${base}${root}/ui/mcp/oauth/callback`;
+};
+
+/**
+ * Remove the given keys from sessionStorage, ignoring errors (e.g. storage
+ * disabled by browser privacy settings).
+ */
+export const clearStorage = (...keys: string[]): void => {
+ keys.forEach((k) => {
+ try {
+ window.sessionStorage.removeItem(k);
+ } catch (_) {}
+ });
+};
diff --git a/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx b/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx
--- a/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx
+++ b/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx
@@ -224,12 +224,21 @@
if (!storedPayload) {
return;
}
-
+
+ // Guard: the callback page writes to the admin result key for *all* OAuth
+ // flows (including the tools re-auth flow). Only proceed if this hook's
+ // own flow state exists, meaning startOAuthFlow() was actually called here.
+ // Without this guard, a tools re-auth redirect triggers a spurious
+ // "OAuth session state was lost" error from this hook.
+ const storedFlowState = getStorageItem(FLOW_STATE_KEY);
+ if (!storedFlowState) {
+ return;
+ }
+
// Mark as processing
processingRef.current = true;
payload = JSON.parse(storedPayload);
- const storedFlowState = getStorageItem(FLOW_STATE_KEY);
- flowState = storedFlowState ? JSON.parse(storedFlowState) : null;
... diff truncated: showing 800 of 1356 linesYou can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit a8e78ae. Configure here.
M2M (client_credentials) OAuth servers share auth_type="oauth2" with interactive PKCE servers, but the backend fetches their token internally and they typically lack a user authorization endpoint. Gating tool listing on them rendered an Authorize button that would fail or redirect incorrectly. Detect M2M via the presence of token_url (matching the existing heuristic in mcp_server_edit.tsx) and skip the auth gate. Co-authored-by: Yassin Kortam <[email protected]>
|
@greptile re review |
Restore the never-throws contract when response.json() fails on a 2xx body so callers do not receive null and crash on result.tools. Co-authored-by: Cursor <[email protected]>
|
@greptile re review |
ef36e89
into
litellm_internal_staging
…erriAI#28454) * feat(mcp): cache OAuth token client-side so Tools tab loads without re-auth After a user creates an OAuth MCP server and completes the authorization flow, the resulting access token is now stored in sessionStorage keyed by server_id. The MCP Tools tab reads this cached token and includes it as an MCP auth header when listing and invoking tools, so the user never sees an empty tool list. When the session ends (tab close / new browser) an Authorize button re-triggers the flow without leaving the Tools screen. * fix(ui/mcp): surface listMCPTools 401 errors so auth gate reappears listMCPTools previously swallowed all errors (including HTTP 401) by returning a synthetic { tools: [], error: 'network_error', ... } payload. That made the useQuery retry-on-401 guard and mcpToolsError dead code, so expired OAuth tokens never re-triggered the auth gate. - Throw an enhanced Error with .status attached on non-2xx responses (still preserves the legacy shape for true network failures so the caller can render a generic message without crashing). - Clear the cached OAuth session token when the tools query fails with 401, mirroring callMCPTool's onError handler so the Authorize button is shown again. - Surface mcpToolsError in the existing error banner. Co-authored-by: Yassin Kortam <[email protected]> * fix(mcp-tools): stable onSuccess + reuse parsed flow state - Pass stable setOauthToken setter directly as onSuccess to avoid recreating useToolsOAuthFlow's resumeOAuthFlow on every render. - Reuse the already-parsed FLOW_STATE_KEY value (peeked) instead of re-reading and re-parsing sessionStorage in resumeOAuthFlow. Co-authored-by: Yassin Kortam <[email protected]> * fix(ui/mcp): restore listMCPTools never-throws contract The previous fix made listMCPTools throw on HTTP errors while still returning a synthetic object on network errors. This inconsistent contract broke existing callers (MCPToolPermissions, MCPAppsPanel, MCPConnectPicker) which inspect result.error / result.message and expect the function to never throw. - Return a normalized { tools: [], error, message, status, ... } object on HTTP errors (instead of throwing) so all callers see a consistent shape and the user-visible error text from result.message is preserved. - Convert the returned error object into a thrown Error inside the one caller that needs it — the useQuery in mcp_tools.tsx — so the 401 retry/onError handlers still trigger and clear the cached OAuth token. Co-authored-by: Yassin Kortam <[email protected]> * fix greptile * fix(mcp): align OAuth header alias lookup with dashboard sanitization Backend auth header resolution now matches x-mcp-{alias} keys produced by the dashboard sanitizer, and the Tools tab re-syncs OAuth tokens when serverId changes. Co-authored-by: Cursor <[email protected]> * fix(mcp): widen auth header lookup types for list_tools Accept legacy str | dict server auth maps and annotate list_tools server_auth_header as Union[str, dict] for mypy. Co-authored-by: Cursor <[email protected]> * refactor(ui): extract shared buildCallbackUrl/clearStorage for MCP OAuth hooks Hoist the duplicate buildCallbackUrl and clearStorage helpers out of useToolsOAuthFlow and useUserMcpOAuthFlow into a new shared module src/hooks/mcpOAuthUtils.ts so the two hooks cannot drift if the URL construction or storage cleanup logic needs to change. Co-authored-by: Yassin Kortam <[email protected]> * fix(ui): don't gate M2M OAuth MCP servers behind interactive authorize M2M (client_credentials) OAuth servers share auth_type="oauth2" with interactive PKCE servers, but the backend fetches their token internally and they typically lack a user authorization endpoint. Gating tool listing on them rendered an Authorize button that would fail or redirect incorrectly. Detect M2M via the presence of token_url (matching the existing heuristic in mcp_server_edit.tsx) and skip the auth gate. Co-authored-by: Yassin Kortam <[email protected]> * fix(ui/mcp): return error shape when listMCPTools JSON parse fails Restore the never-throws contract when response.json() fails on a 2xx body so callers do not receive null and crash on result.tools. Co-authored-by: Cursor <[email protected]> --------- Co-authored-by: Cursor Agent <[email protected]> Co-authored-by: Yassin Kortam <[email protected]>

Summary
sessionStoragekeyed byserver_idvia a newmcpTokenStoreutilityx-mcp-{alias}-authorization(orx-mcp-authfallback) when listing and invoking tools — tools load immediately without re-authenticatinguseMcpOAuthFlowanduseUserMcpOAuthFlowto try to resume flows they never started — all three hooks now guard with an early return if their own flow state is absentFIxes LIT-3260
FLow 1: User adds oauth mcp server, and then saves it, and tries to list tool on UI
https://www.loom.com/share/1e7814ebae114d34b2f777bd485a8112
FLow 2: User comes back after some time and clicks on an existing MCP server with Oauth auth type
https://www.loom.com/share/3805b39ad6fc458bba8cdaba384237e9
Note
Medium Risk
Touches MCP auth header resolution in both proxy and dashboard, including new token caching and header-name sanitization; regressions could break tool access or inadvertently send wrong credentials (especially around OAuth vs M2M heuristics).
Overview
Enables the MCP Tools UI to list and call tools against OAuth (PKCE) MCP servers by caching the exchanged access token in
sessionStorage(scoped byserver_id+ user) and forwarding it asx-mcp-{sanitized_alias}-authorization(withx-mcp-authfallback), including a new Tools-specific OAuth re-auth flow and an auth gate when no valid session token exists.Consolidates backend auth-header lookup into
lookup_mcp_server_auth_in_headers()with case-insensitive + sanitized-alias matching, updates multiple call sites to use it, and adds shared UI OAuth helpers (buildCallbackUrl, storage cleanup), improved error propagation forlistMCPTools(returningstatus/parse errors without throwing), and logout cleanup to clear all cached MCP session tokens.Reviewed by Cursor Bugbot for commit 49b7107. Bugbot is set up for automated code reviews on this repo. Configure here.