Skip to content

feat(mcp): Add tool call and tool list support via UI for Oauth mcps#28454

Merged
mateo-berri merged 10 commits into
litellm_internal_stagingfrom
litellm_mcp-oauth-session-token-caching
May 22, 2026
Merged

feat(mcp): Add tool call and tool list support via UI for Oauth mcps#28454
mateo-berri merged 10 commits into
litellm_internal_stagingfrom
litellm_mcp-oauth-session-token-caching

Conversation

@Sameerlite

@Sameerlite Sameerlite commented May 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • After a user creates an OAuth MCP server and completes the authorization flow, the resulting access token is stored in sessionStorage keyed by server_id via a new mcpTokenStore utility
  • The MCP Tools tab reads the cached token on mount and passes it as x-mcp-{alias}-authorization (or x-mcp-auth fallback) when listing and invoking tools — tools load immediately without re-authenticating
  • When the browser session ends (tab close / new window), the token is gone and the Tools tab shows an Authorize button that re-triggers the OAuth flow and returns the user to the same page
  • Fixed a spurious "OAuth session state was lost" error that appeared when the tools re-auth redirect triggered useMcpOAuthFlow and useUserMcpOAuthFlow to try to resume flows they never started — all three hooks now guard with an early return if their own flow state is absent

FIxes 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 by server_id + user) and forwarding it as x-mcp-{sanitized_alias}-authorization (with x-mcp-auth fallback), 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 for listMCPTools (returning status/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.

…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

codecov Bot commented May 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.29412% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...oxy/_experimental/mcp_server/mcp_server_manager.py 66.66% 2 Missing ⚠️
litellm/proxy/_experimental/mcp_server/utils.py 90.90% 2 Missing ⚠️
...m/proxy/_experimental/mcp_server/rest_endpoints.py 66.66% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@greptile-apps

greptile-apps Bot commented May 21, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds OAuth PKCE tool-listing support in the MCP UI by caching the access token in sessionStorage (scoped by user + server ID), forwarding it as x-mcp-{alias}-authorization on list/call requests, and showing an auth gate when the session token is absent. It also fixes spurious "session state lost" errors across all three OAuth hooks by adding a FLOW_STATE_KEY guard, and centralises backend auth-header resolution into lookup_mcp_server_auth_in_headers with sanitized-alias matching.

  • Backend: new sanitize_mcp_alias_for_header + lookup_mcp_server_auth_in_headers utility consolidates four previously duplicated lookup sites with case-insensitive and sanitized-alias fallback matching.
  • Frontend token lifecycle: mcpTokenStore, mcpHeaderUtils, and useToolsOAuthFlow provide a clean sessionStorage-only auth path; clearAllMcpTokens is wired into clearTokenCookies for logout cleanup.
  • 401 recovery: the listMCPTools query and callMCPTool mutation both remove the cached token and clear oauthToken state on 401, causing the auth gate to reappear.

Confidence Score: 4/5

Safe 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: isTokenValid has no expiry grace buffer (tokens expiring within milliseconds are still considered valid, causing an avoidable 401 round-trip), and clearStorage in mcpOAuthUtils.ts relies on a caught ReferenceError for SSR safety rather than an explicit typeof-window guard. Neither affects correctness in production.

ui/litellm-dashboard/src/utils/mcpTokenStore.ts (expiry grace period) and ui/litellm-dashboard/src/hooks/mcpOAuthUtils.ts (SSR guard pattern)

Important Files Changed

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

Comment thread ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx
Comment thread ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx
Comment thread ui/litellm-dashboard/src/hooks/useToolsOAuthFlow.tsx Outdated
Comment thread ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx
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]>
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
1 out of 2 committers have signed the CLA.

✅ Sameerlite
❌ cursoragent
You have signed the CLA already but the status is still pending? Let us recheck it.

Comment thread ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx Outdated
Comment thread ui/litellm-dashboard/src/hooks/useToolsOAuthFlow.tsx Outdated
- 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]>
Comment thread ui/litellm-dashboard/src/components/networking.tsx
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]>
Comment thread ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx
Comment thread ui/litellm-dashboard/src/components/networking.tsx
@veria-ai

veria-ai Bot commented May 21, 2026

Copy link
Copy Markdown
Contributor

PR overview

MCP OAuth tools UI support

This 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

  • No new security issues were flagged in the latest review.
  • No review issues remain open on this pull request.

Risk: 2/10

Comment thread ui/litellm-dashboard/src/utils/mcpTokenStore.ts Outdated
@Sameerlite

Copy link
Copy Markdown
Collaborator Author

@greptile re review

Comment thread ui/litellm-dashboard/src/utils/mcpHeaderUtils.ts
Comment thread ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx
@Sameerlite Sameerlite changed the title feat(mcp): cache OAuth token in sessionStorage so MCP Tools tab loads without re-auth feat(mcp): Add tool call and tool list support via UI for Oauth mcps May 21, 2026
Sameerlite and others added 2 commits May 21, 2026 22:35
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]>
Comment thread ui/litellm-dashboard/src/hooks/useToolsOAuthFlow.tsx Outdated
…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]>
@Sameerlite

Copy link
Copy Markdown
Collaborator Author

@greptile re review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high mode and found 1 potential issue.

Fix All in Cursor

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 lines

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit a8e78ae. Configure here.

Comment thread ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx Outdated
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]>
@Sameerlite

Copy link
Copy Markdown
Collaborator Author

@greptile re review

Comment thread ui/litellm-dashboard/src/components/networking.tsx
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]>
@Sameerlite

Copy link
Copy Markdown
Collaborator Author

@greptile re review

@mateo-berri mateo-berri left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM; thanks!

@mateo-berri mateo-berri merged commit ef36e89 into litellm_internal_staging May 22, 2026
116 of 117 checks passed
fzowl pushed a commit to fzowl/litellm that referenced this pull request Jun 24, 2026
…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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants