From 05f39bf9427290e077a6ef07c6d964cc90ef44df Mon Sep 17 00:00:00 2001 From: Tin Date: Thu, 9 Jul 2026 10:43:13 -0700 Subject: [PATCH] fix(mcp): invalidate a browser-authorized upstream token when a mint-relevant field changes An admin who ran Authorize & Fetch and then changed a field that determines which upstream OAuth token gets minted kept using the stale token for tool preview, sessionStorage, and (on the backend) the stored per-user credential and its cache. Grounded in RFC 8707/8693 and the MCP auth spec, a token is bound to one tuple: resource/audience (url), OAuth mode/grant (auth_type, oauth_flow_type), the authorization-server endpoints, and the OAuth client + scopes. A shared getOAuthAuthorizationIdentity captures exactly those fields; transport (http/sse on the same url is the same audience) and delegate_auth_to_upstream (a downstream-usage toggle never sent to the authorize request) are excluded. UI: both the create and edit forms now discard the held token (React state / sessionStorage / hook, plus the fetched token + DCR client in form.credentials) whenever the identity diverges from the one it was authorized against, re-applying the admin's in-flight edit so it is never wiped. The check lives in one shared helper so the two forms cannot drift. Backend: editing an MCP server now compares the pre/post identity and, on a mint-relevant change, purges every stored per-user OAuth credential for the server (DB row + per-user token cache) so no user forwards a token minted for a resource/AS/client that no longer matches. Best-effort; a purge failure never fails the update. --- litellm/proxy/_experimental/mcp_server/db.py | 44 +++++++++ .../mcp_management_endpoints.py | 29 ++++++ .../mcp_server/test_db_credentials.py | 89 +++++++++++++++++++ .../mcp_tools/create_mcp_server.test.tsx | 38 ++++++++ .../mcp_tools/create_mcp_server.tsx | 67 +++++++------- .../components/mcp_tools/mcp_server_edit.tsx | 53 ++++++++++- .../src/components/mcp_tools/types.tsx | 27 ++++++ 7 files changed, 312 insertions(+), 35 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/db.py b/litellm/proxy/_experimental/mcp_server/db.py index 10081ce19de..8c5e728d86b 100644 --- a/litellm/proxy/_experimental/mcp_server/db.py +++ b/litellm/proxy/_experimental/mcp_server/db.py @@ -1070,6 +1070,50 @@ async def list_user_oauth_credentials( return results +def mcp_oauth_token_identity(server: Any) -> tuple[Any, ...]: + """The upstream-OAuth-token-determining fields of an MCP server: the resource/audience (url), the + OAuth mode/grant (auth_type, oauth2_flow), the authorization-server endpoints, and the OAuth client + + scopes. Mirrors the dashboard's getOAuthAuthorizationIdentity. When any of these change on a server + update, previously stored per-user tokens were minted for the old identity and are stale. Excludes + transport and delegate_auth_to_upstream, which do not affect what token is minted (RFC 8707/8693).""" + creds = getattr(server, "credentials", None) + creds_dict: Dict[str, Any] = creds if isinstance(creds, dict) else {} + return ( + getattr(server, "url", None), + getattr(server, "auth_type", None), + getattr(server, "oauth2_flow", None), + getattr(server, "authorization_url", None), + getattr(server, "token_url", None), + getattr(server, "registration_url", None), + creds_dict.get("client_id"), + creds_dict.get("client_secret"), + creds_dict.get("scopes"), + ) + + +async def purge_user_oauth_credentials_for_server(prisma_client: PrismaClient, server_id: str) -> int: + """Delete every stored per-user OAuth credential for a server and drop each from the per-user token + cache, so no user keeps a token minted for a superseded configuration. Called when a server update + changes a mint-relevant field (see mcp_oauth_token_identity). Returns the number of rows removed.""" + repo = MCPUserCredentialsRepository(prisma_client) + rows = await repo.table.find_many(where={"server_id": server_id}) + if not rows: + return 0 + await repo.table.delete_many(where={"server_id": server_id}) + from litellm.proxy._experimental.mcp_server.oauth2_token_cache import ( + mcp_per_user_token_cache, + ) + + for row in rows: + try: + await mcp_per_user_token_cache.delete(row.user_id, server_id) + except Exception as exc: # noqa: BLE001 - cache drop is best-effort; the DB delete is authoritative + verbose_proxy_logger.warning( + "Failed to drop cached MCP OAuth token for user=%s server=%s: %s", row.user_id, server_id, exc + ) + return len(rows) + + async def refresh_user_oauth_token( prisma_client: PrismaClient, user_id: str, diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index c9952b245c7..8f6779b17b9 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -125,7 +125,9 @@ if MCP_AVAILABLE: get_user_env_vars_bulk, get_user_oauth_credential, list_user_oauth_credentials, + mcp_oauth_token_identity, merge_user_env_vars, + purge_user_oauth_credentials_for_server, reject_mcp_server, store_user_credential, store_user_oauth_credential, @@ -2318,6 +2320,9 @@ if MCP_AVAILABLE: }, ) + # Snapshot the pre-update identity so we can detect a mint-relevant change below. + old_server_record = await get_mcp_server(prisma_client, payload.server_id) + # try to update the mcp server mcp_server_record_updated = await update_mcp_server( prisma_client, @@ -2336,6 +2341,30 @@ if MCP_AVAILABLE: # Ensure registry is up to date by reloading from database await global_mcp_server_manager.reload_servers_from_database() + # If a field that determines which upstream OAuth token gets minted changed (url/audience, OAuth + # mode/grant, authorization-server endpoints, or the OAuth client + scopes), every stored per-user + # token was minted for the old configuration and is stale. Purge them (DB + cache) so the next + # tool call re-authorizes instead of forwarding a token for a resource/AS/client that no longer + # matches. Best-effort: a purge failure must not fail the update, whose primary job already + # succeeded. + if old_server_record is not None and mcp_oauth_token_identity(old_server_record) != mcp_oauth_token_identity( + mcp_server_record_updated + ): + try: + purged = await purge_user_oauth_credentials_for_server(prisma_client, payload.server_id) + if purged: + verbose_logger.info( + "MCP server %s: purged %d stale per-user OAuth token(s) after a mint-relevant config change", + payload.server_id, + purged, + ) + except Exception as exc: # noqa: BLE001 - purge is best-effort; the server update already succeeded + verbose_logger.warning( + "MCP server %s: failed to purge stale per-user OAuth tokens after config change: %s", + payload.server_id, + exc, + ) + # TODO: Enterprise: Finish audit log trail if litellm.store_audit_logs: pass diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_db_credentials.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_db_credentials.py index 8b8b8a363d5..628f422fbf1 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_db_credentials.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_db_credentials.py @@ -11,6 +11,7 @@ keeps a plain-base64 fallback on read so existing rows continue to work. import base64 import json from datetime import datetime, timedelta, timezone +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock import pytest @@ -63,6 +64,94 @@ def _legacy_row(payload: str): return row +def _identity_server(**overrides): + base = dict( + url="https://up.example.com/mcp", + auth_type="oauth2", + oauth2_flow="authorization_code", + authorization_url="https://idp.example.com/authorize", + token_url="https://idp.example.com/token", + registration_url="https://idp.example.com/register", + credentials={"client_id": "cid", "client_secret": "csec", "scopes": ["a"]}, + server_name="srv", + description="d", + ) + base.update(overrides) + return SimpleNamespace(**base) + + +@pytest.mark.parametrize( + "overrides", + [ + {"url": "https://other.example.com/mcp"}, + {"auth_type": "oauth_delegate"}, + {"oauth2_flow": "client_credentials"}, + {"authorization_url": "https://other.example.com/authorize"}, + {"token_url": "https://other.example.com/token"}, + {"registration_url": "https://other.example.com/register"}, + {"credentials": {"client_id": "new", "client_secret": "csec", "scopes": ["a"]}}, + {"credentials": {"client_id": "cid", "client_secret": "rotated", "scopes": ["a"]}}, + {"credentials": {"client_id": "cid", "client_secret": "csec", "scopes": ["b"]}}, + ], +) +def test_mcp_oauth_token_identity_changes_on_mint_relevant_fields(overrides): + from litellm.proxy._experimental.mcp_server.db import mcp_oauth_token_identity + + assert mcp_oauth_token_identity(_identity_server()) != mcp_oauth_token_identity(_identity_server(**overrides)) + + +@pytest.mark.parametrize( + "overrides", + [ + {"server_name": "renamed"}, + {"description": "changed"}, + ], +) +def test_mcp_oauth_token_identity_stable_on_non_mint_fields(overrides): + from litellm.proxy._experimental.mcp_server.db import mcp_oauth_token_identity + + assert mcp_oauth_token_identity(_identity_server()) == mcp_oauth_token_identity(_identity_server(**overrides)) + + +@pytest.mark.asyncio +async def test_purge_user_oauth_credentials_for_server_deletes_rows_and_cache(monkeypatch): + from litellm.proxy._experimental.mcp_server import oauth2_token_cache + from litellm.proxy._experimental.mcp_server.db import purge_user_oauth_credentials_for_server + + r1 = MagicMock(user_id="alice", server_id="srv-1") + r2 = MagicMock(user_id="bob", server_id="srv-1") + prisma = MagicMock() + prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[r1, r2]) + prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock() + + cache_deletes = [] + monkeypatch.setattr( + oauth2_token_cache.mcp_per_user_token_cache, + "delete", + AsyncMock(side_effect=lambda uid, sid: cache_deletes.append((uid, sid))), + ) + + purged = await purge_user_oauth_credentials_for_server(prisma, "srv-1") + + assert purged == 2 + prisma.db.litellm_mcpusercredentials.delete_many.assert_awaited_once() + assert set(cache_deletes) == {("alice", "srv-1"), ("bob", "srv-1")} + + +@pytest.mark.asyncio +async def test_purge_user_oauth_credentials_for_server_noop_when_empty(): + from litellm.proxy._experimental.mcp_server.db import purge_user_oauth_credentials_for_server + + prisma = MagicMock() + prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[]) + prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock() + + purged = await purge_user_oauth_credentials_for_server(prisma, "srv-1") + + assert purged == 0 + prisma.db.litellm_mcpusercredentials.delete_many.assert_not_awaited() + + def _stored_value(prisma) -> str: """Pull the credential_b64 value passed to the most recent upsert call.""" call = prisma.db.litellm_mcpusercredentials.upsert.call_args diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx index eecbc253b6b..7339420ed68 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx @@ -681,6 +681,44 @@ describe("CreateMCPServer", () => { // Asserted in setupOAuthInteractive }); + it("invalidates the held token when the auth mode changes after Authorize & Fetch", async () => { + await setupOAuthInteractive(); + const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com"); + await act(async () => { + fireEvent.change(urlInput, { target: { value: "https://a.example.com/mcp" } }); + }); + act(() => { + oauthHook.onTokenReceived?.({ access_token: "tok-a" }, { clientId: "client-a", clientSecret: "secret-a" }); + }); + oauthHook.reset.mockClear(); + + // Switching the Authentication mode changes the OAuth identity, so the held token is discarded. + await selectAntOption("Authentication", "True Passthrough (no LiteLLM auth)"); + + await waitFor(() => expect(oauthHook.reset).toHaveBeenCalled()); + }); + + it("does NOT invalidate the held token when a non-mint field (server name) changes", async () => { + await setupOAuthInteractive(); + const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com"); + await act(async () => { + fireEvent.change(urlInput, { target: { value: "https://a.example.com/mcp" } }); + }); + act(() => { + oauthHook.onTokenReceived?.({ access_token: "tok-a" }, { clientId: "client-a", clientSecret: "secret-a" }); + }); + oauthHook.reset.mockClear(); + + const nameInput = document.getElementById("server_name") as HTMLInputElement; + await act(async () => { + fireEvent.change(nameInput, { target: { value: "Renamed_Server" } }); + }); + + // server_name is not part of the OAuth identity, so the held token must survive the edit. + await waitFor(() => expect(screen.getAllByRole("button", { name: "Add MCP Server" }).length).toBeGreaterThan(0)); + expect(oauthHook.reset).not.toHaveBeenCalled(); + }); + it("includes token_validation in payload when token_validation_json is filled with valid JSON", async () => { vi.mocked(networking.createMCPServer).mockResolvedValue({ server_id: "new-server-oauth", 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 index 0b39add234c..17c7e7c0b9a 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx @@ -15,6 +15,7 @@ import { MCP_OAUTH2_FLOW_M2M, MCP_OAUTH2_FLOW_INTERACTIVE, isClientForwardedTokenMode, + getOAuthAuthorizationIdentity, } from "./types"; import OAuthFormFields from "./OAuthFormFields"; import TruePassthroughWarning from "./TruePassthroughWarning"; @@ -99,7 +100,10 @@ const CreateMCPServer: React.FC = ({ const [oauthAccessToken, setOauthAccessToken] = useState(null); const [logoUrl, setLogoUrl] = useState(undefined); const [oauthDocsUrl, setOauthDocsUrl] = useState(null); - const [authorizedUrl, setAuthorizedUrl] = useState(undefined); + // The OAuth authorization identity (see getOAuthAuthorizationIdentity) captured at the moment a token + // was fetched; undefined when no valid token is held. If any mint-relevant field diverges from this, + // the held token is stale and is discarded so the admin must re-authorize. + const [authorizedIdentity, setAuthorizedIdentity] = useState(undefined); // Single hook call shared by MCPConnectionStatus and MCPToolConfiguration to avoid duplicate requests. const { @@ -125,12 +129,6 @@ const CreateMCPServer: React.FC = ({ const isAwsSigV4AuthType = authType === AUTH_TYPE.AWS_SIGV4; const isM2MFlow = isOAuthAuthType && formValues.oauth_flow_type === OAUTH_FLOW.M2M; - const getOAuthAuthorizationTarget = (values: Record): string | undefined => { - const transport = values.transport || transportType; - const target = transport === TRANSPORT.OPENAPI ? values.spec_path : values.url; - return typeof target === "string" ? target : undefined; - }; - const persistCreateUiState = () => { if (typeof window === "undefined") { return; @@ -207,6 +205,7 @@ const CreateMCPServer: React.FC = ({ // and committed to sessionStorage on submit; it must never be written into form.credentials, // which would persist it as server-level credentials on the created server row. Mirrors the // edit form's onTokenReceived early return. + setAuthorizedIdentity(getOAuthAuthorizationIdentity(form.getFieldsValue(true))); NotificationsManager.success( "Token held for this browser session. Tools can now be previewed and configured; nothing will be saved to LiteLLM.", ); @@ -223,7 +222,9 @@ const CreateMCPServer: React.FC = ({ }; form.setFieldsValue({ credentials }); - setAuthorizedUrl(getOAuthAuthorizationTarget(form.getFieldsValue(true))); + // Capture the identity AFTER writing the DCR'd credentials so the held token is not spuriously + // invalidated by its own credential write. + setAuthorizedIdentity(getOAuthAuthorizationIdentity(form.getFieldsValue(true))); NotificationsManager.success( "OAuth authorization successful! Please click 'Create MCP Server' to save the configuration.", @@ -233,13 +234,24 @@ const CreateMCPServer: React.FC = ({ flowSource: "create", }); - const clearAuthorizedOAuthState = (values: Record) => { - form.resetFields(["credentials", "authorization_url", "token_url", "registration_url"]); - form.setFieldsValue(values); + // Discard the held browser-authorized token and its tool preview when the authorization identity + // changes (or the modal closes). For oauth2 the fetched token + DCR client also live in + // form.credentials, and the discovered endpoints in authorization_url/token_url/registration_url, so + // those form fields are reset too; whatever the admin just changed (passed via changedValues) is + // re-applied so the invalidation never wipes their in-flight edit. + const CLEARED_ON_INVALIDATION = ["credentials", "authorization_url", "token_url", "registration_url"] as const; + const clearHeldOAuthToken = (changedValues: Record = {}) => { setOauthAccessToken(null); clearTools(); resetOAuthFlow(); - setAuthorizedUrl(undefined); + setAuthorizedIdentity(undefined); + form.resetFields([...CLEARED_ON_INVALIDATION]); + const preserved = Object.fromEntries( + CLEARED_ON_INVALIDATION.filter((key) => key in changedValues).map((key) => [key, changedValues[key]]), + ); + if (Object.keys(preserved).length > 0) { + form.setFieldsValue(preserved); + } }; React.useEffect(() => { @@ -577,7 +589,7 @@ const CreateMCPServer: React.FC = ({ : { spec_path: undefined, command: undefined, args: undefined, env: undefined }; const nextValues = - authorizedUrl === undefined + authorizedIdentity === undefined ? transportValues : { ...transportValues, @@ -587,10 +599,9 @@ const CreateMCPServer: React.FC = ({ registration_url: undefined, }; - if (authorizedUrl !== undefined) { - clearAuthorizedOAuthState(nextValues); - } else { - form.setFieldsValue(nextValues); + form.setFieldsValue(nextValues); + if (authorizedIdentity !== undefined) { + clearHeldOAuthToken(); } }; @@ -652,28 +663,18 @@ const CreateMCPServer: React.FC = ({ setOauthAccessToken(null); clearTools(); resetOAuthFlow(); - setAuthorizedUrl(undefined); + setAuthorizedIdentity(undefined); } }, [isModalVisible, form, clearTools, resetOAuthFlow]); const isAdmin = isAdminRole(userRole); const handleFormValuesChange = (changedValues: Record, allValues: Record) => { - const changedAuthorizationTarget = "url" in changedValues || "spec_path" in changedValues; - if ( - changedAuthorizationTarget && - authorizedUrl !== undefined && - getOAuthAuthorizationTarget(allValues) !== authorizedUrl - ) { - const invalidated = { - credentials: undefined, - authorization_url: changedValues.authorization_url, - token_url: changedValues.token_url, - registration_url: changedValues.registration_url, - }; - clearAuthorizedOAuthState(invalidated); - setFormValues({ ...allValues, ...invalidated }); - return; + // Any change to a mint-relevant field (url, auth_type, oauth_flow_type, client creds/scopes, or the + // authorization/token/registration endpoints — see getOAuthAuthorizationIdentity) makes a held token + // stale, so discard it and force a fresh authorize. + if (authorizedIdentity !== undefined && getOAuthAuthorizationIdentity(allValues) !== authorizedIdentity) { + clearHeldOAuthToken(changedValues); } setFormValues(allValues); }; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx index ee7938d2904..55adcf2bb59 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx @@ -5,6 +5,7 @@ import { Button, TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/rea import { AUTH_TYPE, isClientForwardedTokenMode, + getOAuthAuthorizationIdentity, OAUTH_FLOW, MCP_OAUTH2_FLOW_M2M, MCP_OAUTH2_FLOW_INTERACTIVE, @@ -15,7 +16,7 @@ import { oauth2FlowToFormValue, } from "./types"; import { updateMCPServer, listMCPTools, storeMCPOAuthUserCredential } from "../networking"; -import { getToken, isTokenValid, setToken } from "@/utils/mcpTokenStore"; +import { getToken, isTokenValid, removeToken, setToken } from "@/utils/mcpTokenStore"; import { buildMcpPassthroughAuthHeader } from "@/utils/mcpHeaderUtils"; import MCPServerCostConfig from "./mcp_server_cost_config"; import MCPPermissionManagement from "./MCPPermissionManagement"; @@ -136,11 +137,17 @@ const MCPServerEdit: React.FC = ({ // that read only mcpServer.auth_type go stale the moment the admin switches modes in the form. const getEffectiveAuthType = () => form.getFieldValue("auth_type") ?? mcpServer.auth_type; + // The OAuth authorization identity (see getOAuthAuthorizationIdentity) captured when a token is fetched + // in this edit session; undefined when none is held. If a mint-relevant field later diverges from it, + // the held token (hook response + sessionStorage) is discarded so the admin must re-authorize. + const authorizedIdentityRef = React.useRef(undefined); + const { startOAuthFlow, status: oauthStatus, error: oauthError, tokenResponse: oauthTokenResponse, + reset: resetOAuthFlow, } = useMcpOAuthFlow({ accessToken, getCredentials: () => form.getFieldValue("credentials"), @@ -183,6 +190,7 @@ const MCPServerEdit: React.FC = ({ return; } + authorizedIdentityRef.current = getOAuthAuthorizationIdentity(form.getFieldsValue(true)); if (isClientForwardedTokenMode(getEffectiveAuthType())) { const browserHeldToken = { access_token: token.access_token, @@ -205,6 +213,8 @@ const MCPServerEdit: React.FC = ({ }; form.setFieldsValue({ credentials }); + // Re-capture after writing credentials so the token is not invalidated by its own credential write. + authorizedIdentityRef.current = getOAuthAuthorizationIdentity(form.getFieldsValue(true)); NotificationsManager.success( "OAuth authorization successful! Please click 'Update MCP Server' to save the credentials.", @@ -378,6 +388,39 @@ const MCPServerEdit: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [mcpServer, accessToken, userID, oauthTokenResponse?.access_token]); + // Invalidate a token authorized in this edit session once any mint-relevant field diverges from the + // identity it was minted against (url, auth_type, oauth_flow_type, client creds/scopes, or the + // authorization/token/registration endpoints — see getOAuthAuthorizationIdentity). Discards the hook + // token (resetOAuthFlow, which re-runs fetchTools to prompt a fresh authorize), the sessionStorage + // token (removeToken, browser-held modes), and the fetched token/DCR client in form.credentials + the + // discovered endpoint fields; the admin's in-flight edit is re-applied so it is never wiped. Only fires + // when a token was actually authorized here (ref set), so a token already valid for the saved server on + // mount is left untouched. Driven from onValuesChange (user input only), never programmatic resets. + const CLEARED_ON_INVALIDATION = ["credentials", "authorization_url", "token_url", "registration_url"] as const; + const clearHeldOAuthToken = (changedValues: Record = {}) => { + authorizedIdentityRef.current = undefined; + if (mcpServer.server_id) { + removeToken(mcpServer.server_id, userID); + } + resetOAuthFlow(); + form.resetFields([...CLEARED_ON_INVALIDATION]); + const preserved = Object.fromEntries( + CLEARED_ON_INVALIDATION.filter((key) => key in changedValues).map((key) => [key, changedValues[key]]), + ); + if (Object.keys(preserved).length > 0) { + form.setFieldsValue(preserved); + } + }; + + const handleFormValuesChange = (changedValues: Record) => { + if ( + authorizedIdentityRef.current !== undefined && + getOAuthAuthorizationIdentity(form.getFieldsValue(true)) !== authorizedIdentityRef.current + ) { + clearHeldOAuthToken(changedValues); + } + }; + const fetchTools = async () => { if (!accessToken || !mcpServer.server_id) return; @@ -805,7 +848,13 @@ const MCPServerEdit: React.FC = ({ -
+ sse on the same url is the same audience; a transport switch +// only matters when it changes the target url, which `target` already captures), delegate_auth_to_upstream +// (a downstream-usage toggle that is never sent to the authorize request), and all metadata/RBAC/routing +// fields. Shared by the create and edit forms so their invalidation logic cannot drift. +export const getOAuthAuthorizationIdentity = (values: Record): string => { + const credentials = (values.credentials ?? {}) as Record; + const target = values.transport === TRANSPORT.OPENAPI ? values.spec_path : values.url; + const identity = { + target: typeof target === "string" ? target : null, + auth_type: values.auth_type ?? null, + oauth_flow_type: values.oauth_flow_type ?? null, + client_id: credentials.client_id ?? null, + client_secret: credentials.client_secret ?? null, + scopes: credentials.scopes ?? null, + authorization_url: values.authorization_url ?? null, + token_url: values.token_url ?? null, + registration_url: values.registration_url ?? null, + }; + return JSON.stringify(identity); +}; + // Backend value of `oauth2_flow` that marks a machine-to-machine server. Distinct // from the UI-local OAUTH_FLOW.M2M ("m2m"); this is what the API actually returns. export const MCP_OAUTH2_FLOW_M2M = "client_credentials";