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.
This commit is contained in:
Tin 2026-07-09 10:43:13 -07:00
parent 68a4ca7247
commit 05f39bf942
7 changed files with 312 additions and 35 deletions

View file

@ -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,

View file

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

View file

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

View file

@ -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",

View file

@ -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<CreateMCPServerProps> = ({
const [oauthAccessToken, setOauthAccessToken] = useState<string | null>(null);
const [logoUrl, setLogoUrl] = useState<string | undefined>(undefined);
const [oauthDocsUrl, setOauthDocsUrl] = useState<string | null>(null);
const [authorizedUrl, setAuthorizedUrl] = useState<string | undefined>(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<string | undefined>(undefined);
// Single hook call shared by MCPConnectionStatus and MCPToolConfiguration to avoid duplicate requests.
const {
@ -125,12 +129,6 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
const isAwsSigV4AuthType = authType === AUTH_TYPE.AWS_SIGV4;
const isM2MFlow = isOAuthAuthType && formValues.oauth_flow_type === OAUTH_FLOW.M2M;
const getOAuthAuthorizationTarget = (values: Record<string, unknown>): 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<CreateMCPServerProps> = ({
// 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<CreateMCPServerProps> = ({
};
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<CreateMCPServerProps> = ({
flowSource: "create",
});
const clearAuthorizedOAuthState = (values: Record<string, unknown>) => {
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<string, unknown> = {}) => {
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<CreateMCPServerProps> = ({
: { 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<CreateMCPServerProps> = ({
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<CreateMCPServerProps> = ({
setOauthAccessToken(null);
clearTools();
resetOAuthFlow();
setAuthorizedUrl(undefined);
setAuthorizedIdentity(undefined);
}
}, [isModalVisible, form, clearTools, resetOAuthFlow]);
const isAdmin = isAdminRole(userRole);
const handleFormValuesChange = (changedValues: Record<string, unknown>, allValues: Record<string, unknown>) => {
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);
};

View file

@ -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<MCPServerEditProps> = ({
// 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<string | undefined>(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<MCPServerEditProps> = ({
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<MCPServerEditProps> = ({
};
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<MCPServerEditProps> = ({
// 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<string, unknown> = {}) => {
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<string, unknown>) => {
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<MCPServerEditProps> = ({
</TabList>
<TabPanels className="mt-6">
<TabPanel>
<Form form={form} onFinish={handleSave} initialValues={initialValues} layout="vertical">
<Form
form={form}
onFinish={handleSave}
onValuesChange={handleFormValuesChange}
initialValues={initialValues}
layout="vertical"
>
<Form.Item
label="MCP Server Name"
name="server_name"

View file

@ -57,6 +57,33 @@ export const OAUTH_FLOW = {
M2M: "m2m",
};
// The fields that determine which upstream OAuth token "Authorize & Fetch" mints: the resource/audience
// (url), the OAuth mode/grant (auth_type, oauth_flow_type), the OAuth client and requested scope
// (credentials.client_id / client_secret / scopes), and the authorization-server endpoints
// (authorization_url / token_url / registration_url). Grounded in RFC 8707 / RFC 8693 and the MCP auth
// spec: an access token is bound to exactly this tuple (resource/audience + scope + client + issuer), so
// a previously authorized token is stale if and only if this identity changes and must be re-minted.
// Deliberately EXCLUDES: transport (http<->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, unknown>): string => {
const credentials = (values.credentials ?? {}) as Record<string, unknown>;
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";