fix(ui): route MCP playground auth by oauth2 mode instead of token_url (#29714)

Interactive PKCE and OBO servers were mislabeled as M2M, so passthrough never showed the Authorize gate; classify by oauth2_flow + delegate_auth_to_upstream instead.
This commit is contained in:
tin-berri 2026-06-05 10:51:46 -07:00 committed by Tin Chi Lo
parent 23e3f73d8b
commit 83d33800bb
5 changed files with 357 additions and 175 deletions

View file

@ -174,6 +174,8 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
serverId={mcpServer.server_id}
accessToken={accessToken}
auth_type={mcpServer.auth_type}
oauth2_flow={mcpServer.oauth2_flow}
delegate_auth_to_upstream={mcpServer.delegate_auth_to_upstream}
tokenUrl={mcpServer.token_url}
userRole={userRole}
userID={userID}

View file

@ -0,0 +1,91 @@
import { render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, expect, it, vi, beforeEach } from "vitest";
import MCPToolsViewer from "./mcp_tools";
import { listMCPTools } from "../networking";
import { isTokenValid, getToken } from "@/utils/mcpTokenStore";
vi.mock("../networking", () => ({
listMCPTools: vi.fn(),
callMCPTool: vi.fn(),
}));
vi.mock("@/utils/mcpTokenStore", () => ({
isTokenValid: vi.fn(),
getToken: vi.fn(),
removeToken: vi.fn(),
}));
vi.mock("@/hooks/useToolsOAuthFlow", () => ({
useToolsOAuthFlow: () => ({ startOAuthFlow: vi.fn(), status: "idle", error: null }),
}));
const GATE_TEXT = "Authentication required";
// Realistic interactive servers carry a token endpoint; the old heuristic
// (`oauth2 && !tokenUrl`) mislabeled exactly these as M2M. Setting it here is
// what makes the passthrough cases fail on the pre-fix code.
const TOKEN_URL = "https://slack.com/api/oauth.v2.user.access";
const renderViewer = (props: Record<string, unknown>) =>
render(
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<MCPToolsViewer
serverId="srv-1"
accessToken="litellm-key"
userRole="admin"
userID="tin@berri.ai"
serverAlias="slack"
auth_type="oauth2"
tokenUrl={TOKEN_URL}
{...props}
/>
</QueryClientProvider>,
);
describe("MCPToolsViewer auth gate routing", () => {
beforeEach(() => {
vi.mocked(listMCPTools).mockReset().mockResolvedValue({ tools: [], error: null });
vi.mocked(isTokenValid).mockReset().mockReturnValue(false);
vi.mocked(getToken)
.mockReset()
.mockReturnValue(undefined as any);
});
it("shows the Authorize gate for a passthrough server with a token endpoint and does not list tools", async () => {
renderViewer({ oauth2_flow: null, delegate_auth_to_upstream: true });
expect(await screen.findByText(GATE_TEXT)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Authorize" })).toBeInTheDocument();
expect(vi.mocked(listMCPTools)).not.toHaveBeenCalled();
});
it("forwards the session token via the x-mcp header for a passthrough server that has one", async () => {
vi.mocked(isTokenValid).mockReturnValue(true);
vi.mocked(getToken).mockReturnValue({ access_token: "slack-tok" } as any);
renderViewer({ oauth2_flow: null, delegate_auth_to_upstream: true });
await waitFor(() =>
expect(vi.mocked(listMCPTools)).toHaveBeenCalledWith(
"litellm-key",
"srv-1",
expect.objectContaining({ "x-mcp-slack-authorization": "Bearer slack-tok" }),
),
);
expect(screen.queryByText(GATE_TEXT)).not.toBeInTheDocument();
});
it("does not gate an OBO server with a token endpoint; lists with the LiteLLM key and no x-mcp header", async () => {
renderViewer({ oauth2_flow: null, delegate_auth_to_upstream: false });
await waitFor(() => expect(vi.mocked(listMCPTools)).toHaveBeenCalledWith("litellm-key", "srv-1", undefined));
expect(screen.queryByText(GATE_TEXT)).not.toBeInTheDocument();
});
it("does not gate an M2M server; lists with the LiteLLM key", async () => {
renderViewer({ oauth2_flow: "client_credentials", delegate_auth_to_upstream: false });
await waitFor(() => expect(vi.mocked(listMCPTools)).toHaveBeenCalledWith("litellm-key", "srv-1", undefined));
expect(screen.queryByText(GATE_TEXT)).not.toBeInTheDocument();
});
});

View file

@ -1,7 +1,7 @@
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 { MCPTool, MCPToolsViewerProps, MCPContent, CallMCPToolResponse, getMcpOAuthMode } from "./types";
import { listMCPTools, callMCPTool } from "../networking";
import { isTokenValid, getToken, removeToken } from "@/utils/mcpTokenStore";
import { sanitizeMcpAliasForHeader } from "@/utils/mcpHeaderUtils";
@ -15,7 +15,8 @@ const MCPToolsViewer = ({
serverId,
accessToken,
auth_type,
tokenUrl,
oauth2_flow,
delegate_auth_to_upstream,
userRole,
userID,
serverAlias,
@ -30,34 +31,29 @@ const MCPToolsViewer = ({
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;
// Only PKCE passthrough uses a browser-held session token (sessionStorage,
// cleared on tab/browser close) and a user-facing auth gate. OBO uses the
// backend-stored per-user token and M2M uses the backend's own service token,
// so neither needs a gate — they list tools with just the LiteLLM key.
const isPassthrough = getMcpOAuthMode({ auth_type, oauth2_flow, delegate_auth_to_upstream }) === "passthrough";
const [oauthToken, setOauthToken] = useState<string | null>(() =>
isOAuth && isTokenValid(serverId, userID)
? (getToken(serverId, userID)?.access_token ?? null)
: null
isPassthrough && 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) {
if (!isPassthrough) {
setOauthToken(null);
return;
}
setOauthToken(
isTokenValid(serverId, userID)
? (getToken(serverId, userID)?.access_token ?? null)
: null
);
}, [serverId, userID, isOAuth]);
setOauthToken(isTokenValid(serverId, userID) ? getToken(serverId, userID)?.access_token ?? null : null);
}, [serverId, userID, isPassthrough]);
const { startOAuthFlow, status: oauthStatus, error: oauthError } = useToolsOAuthFlow({
const {
startOAuthFlow,
status: oauthStatus,
error: oauthError,
} = useToolsOAuthFlow({
accessToken: accessToken ?? "",
serverId,
serverAlias,
@ -77,7 +73,8 @@ const MCPToolsViewer = ({
// 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) {
// Passthrough only: OBO/M2M tokens are attached server-side, not from the browser.
if (isPassthrough && oauthToken) {
if (serverAlias) {
const safeAlias = sanitizeMcpAliasForHeader(serverAlias);
if (safeAlias) {
@ -126,9 +123,7 @@ const MCPToolsViewer = ({
if (status === 401) {
removeToken(serverId, userID);
}
const enhancedError = new Error(
result.message || result.error || "Failed to fetch MCP tools",
) as Error & {
const enhancedError = new Error(result.message || result.error || "Failed to fetch MCP tools") as Error & {
status?: number;
statusText?: string;
details?: any;
@ -141,7 +136,7 @@ const MCPToolsViewer = ({
return result;
},
// For OAuth servers, block the query until a session token is available
enabled: !!accessToken && (!isOAuth || oauthToken !== null),
enabled: !!accessToken && (!isPassthrough || 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
@ -153,9 +148,7 @@ const MCPToolsViewer = ({
// 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 err = mcpToolsError as (Error & { status?: number; response?: { status?: number } }) | null;
const status = err?.status ?? err?.response?.status;
if (status === 401) {
removeToken(serverId, userID);
@ -169,13 +162,9 @@ const MCPToolsViewer = ({
if (!accessToken) throw new Error("Access Token required");
try {
const result: CallMCPToolResponse = await callMCPTool(
accessToken,
serverId,
args.tool.name,
args.arguments,
{ customHeaders: buildCustomHeaders() }
);
const result: CallMCPToolResponse = await callMCPTool(accessToken, serverId, args.tool.name, args.arguments, {
customHeaders: buildCustomHeaders(),
});
return result;
} catch (error) {
throw error;
@ -223,9 +212,7 @@ const MCPToolsViewer = ({
<div className="flex items-center justify-between mb-2">
<div className="flex items-center">
<KeyOutlined className="text-blue-600 mr-2" />
<Text className="text-sm font-medium text-blue-800">
Additional Headers
</Text>
<Text className="text-sm font-medium text-blue-800">Additional Headers</Text>
</div>
<AntdButton
size="small"
@ -236,20 +223,18 @@ const MCPToolsViewer = ({
{showHeaderInput ? "Hide" : "Configure"}
</AntdButton>
</div>
{!showHeaderInput && Object.keys(passthroughHeaders).length === 0 && (
<Text className="text-xs text-blue-700">
This server requires additional headers. Click &quot;Configure&quot; to provide values.
</Text>
)}
{showHeaderInput && (
<div className="mt-3 space-y-2">
{extraHeaders?.map((headerName) => (
<div key={headerName}>
<label className="block text-xs font-medium text-gray-700 mb-1">
{headerName}
</label>
<label className="block text-xs font-medium text-gray-700 mb-1">{headerName}</label>
<Input
size="small"
placeholder={`Enter ${headerName}`}
@ -272,14 +257,14 @@ const MCPToolsViewer = ({
refetchTools();
setShowHeaderInput(false);
}}
disabled={Object.values(passthroughHeaders).every(v => !v || !v.trim())}
disabled={Object.values(passthroughHeaders).every((v) => !v || !v.trim())}
className="w-full mt-2"
>
Load Tools
</AntdButton>
</div>
)}
{!showHeaderInput && Object.keys(passthroughHeaders).length > 0 && (
<div className="mt-2">
<Text className="text-xs text-green-700 flex items-center">
@ -303,13 +288,11 @@ const MCPToolsViewer = ({
</Text>
{/* OAuth Auth Gate — shown when token is absent for OAuth servers */}
{isOAuth && !oauthToken && (
{isPassthrough && !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>
<p className="text-xs text-gray-500 mb-3">Authenticate to view available tools</p>
<AntdButton
size="small"
type="primary"
@ -319,134 +302,145 @@ const MCPToolsViewer = ({
>
Authorize
</AntdButton>
{oauthError && (
<p className="text-xs text-red-500 mt-2">{oauthError}</p>
)}
{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
placeholder="Search tools..."
prefix={<SearchOutlined className="text-gray-400" />}
value={toolSearchTerm}
onChange={(e) => setToolSearchTerm(e.target.value)}
allowClear
className="rounded-lg"
size="middle"
/>
</div>
)}
{/* Loading State */}
{isLoadingTools && (
<div className="flex flex-col items-center justify-center py-8 bg-white border border-gray-200 rounded-lg">
<div className="relative mb-3">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-gray-200"></div>
<div className="animate-spin rounded-full h-6 w-6 border-2 border-blue-600 border-t-transparent absolute top-0"></div>
</div>
<p className="text-xs font-medium text-gray-700">Loading tools...</p>
</div>
)}
{/* Error State */}
{(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 || (mcpToolsError as Error)?.message}
</p>
</div>
)}
{/* No Tools State */}
{!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">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 8.172V5L8 4z"
/>
</svg>
</div>
<p className="text-xs font-medium text-gray-700 mb-1">No tools available</p>
<p className="text-xs text-gray-500">No tools found for this server</p>
</div>
)}
{/* Tools List */}
{!isLoadingTools && !mcpToolsResponse?.error && toolsData.length > 0 && (
{!isPassthrough || oauthToken ? (
<>
{filteredTools.length === 0 ? (
<div className="p-4 text-center bg-white border border-gray-200 rounded-lg">
<SearchOutlined className="text-2xl text-gray-400 mb-2" />
<p className="text-xs font-medium text-gray-700 mb-1">No tools found</p>
<p className="text-xs text-gray-500">No tools match &quot;{toolSearchTerm}&quot;</p>
</div>
) : (
<div
className="space-y-2 flex-1 overflow-y-auto min-h-0 mcp-tools-scrollable"
style={{
maxHeight: "400px",
scrollbarWidth: "auto",
scrollbarColor: "#cbd5e0 #f7fafc",
}}
>
{filteredTools.map((tool: MCPTool) => (
<div
key={tool.name}
className={`border rounded-lg p-3 cursor-pointer transition-all hover:shadow-sm ${selectedTool?.name === tool.name
? "border-blue-500 bg-blue-50 ring-1 ring-blue-200"
: "border-gray-200 bg-white hover:border-gray-300"
}`}
onClick={() => {
setSelectedTool(tool);
setToolResult(null);
setToolError(null);
}}
>
<div className="flex items-start space-x-2">
{tool.mcp_info.logo_url && (
<img
src={tool.mcp_info.logo_url}
alt={`${tool.mcp_info.server_name} logo`}
className="w-4 h-4 object-contain flex-shrink-0 mt-0.5"
/>
)}
<div className="flex-1 min-w-0">
<h4 className="font-mono text-xs font-medium text-gray-900 truncate">{tool.name}</h4>
<p className="text-xs text-gray-500 truncate">{tool.mcp_info.server_name}</p>
<p className="text-xs text-gray-600 mt-1 line-clamp-2 leading-relaxed">
{tool.description}
</p>
</div>
</div>
{selectedTool?.name === tool.name && (
<div className="mt-2 pt-2 border-t border-blue-200">
<div className="flex items-center text-xs font-medium text-blue-700">
<svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
Selected
</div>
</div>
)}
</div>
))}
{toolsData.length > 0 && (
<div className="mb-3">
<Input
placeholder="Search tools..."
prefix={<SearchOutlined className="text-gray-400" />}
value={toolSearchTerm}
onChange={(e) => setToolSearchTerm(e.target.value)}
allowClear
className="rounded-lg"
size="middle"
/>
</div>
)}
{/* Loading State */}
{isLoadingTools && (
<div className="flex flex-col items-center justify-center py-8 bg-white border border-gray-200 rounded-lg">
<div className="relative mb-3">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-gray-200"></div>
<div className="animate-spin rounded-full h-6 w-6 border-2 border-blue-600 border-t-transparent absolute top-0"></div>
</div>
<p className="text-xs font-medium text-gray-700">Loading tools...</p>
</div>
)}
{/* Error State */}
{(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 || (mcpToolsError as Error)?.message}
</p>
</div>
)}
{/* No Tools State */}
{!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"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 8.172V5L8 4z"
/>
</svg>
</div>
<p className="text-xs font-medium text-gray-700 mb-1">No tools available</p>
<p className="text-xs text-gray-500">No tools found for this server</p>
</div>
)}
{/* Tools List */}
{!isLoadingTools && !mcpToolsResponse?.error && toolsData.length > 0 && (
<>
{filteredTools.length === 0 ? (
<div className="p-4 text-center bg-white border border-gray-200 rounded-lg">
<SearchOutlined className="text-2xl text-gray-400 mb-2" />
<p className="text-xs font-medium text-gray-700 mb-1">No tools found</p>
<p className="text-xs text-gray-500">No tools match &quot;{toolSearchTerm}&quot;</p>
</div>
) : (
<div
className="space-y-2 flex-1 overflow-y-auto min-h-0 mcp-tools-scrollable"
style={{
maxHeight: "400px",
scrollbarWidth: "auto",
scrollbarColor: "#cbd5e0 #f7fafc",
}}
>
{filteredTools.map((tool: MCPTool) => (
<div
key={tool.name}
className={`border rounded-lg p-3 cursor-pointer transition-all hover:shadow-sm ${
selectedTool?.name === tool.name
? "border-blue-500 bg-blue-50 ring-1 ring-blue-200"
: "border-gray-200 bg-white hover:border-gray-300"
}`}
onClick={() => {
setSelectedTool(tool);
setToolResult(null);
setToolError(null);
}}
>
<div className="flex items-start space-x-2">
{tool.mcp_info.logo_url && (
<img
src={tool.mcp_info.logo_url}
alt={`${tool.mcp_info.server_name} logo`}
className="w-4 h-4 object-contain flex-shrink-0 mt-0.5"
/>
)}
<div className="flex-1 min-w-0">
<h4 className="font-mono text-xs font-medium text-gray-900 truncate">
{tool.name}
</h4>
<p className="text-xs text-gray-500 truncate">{tool.mcp_info.server_name}</p>
<p className="text-xs text-gray-600 mt-1 line-clamp-2 leading-relaxed">
{tool.description}
</p>
</div>
</div>
{selectedTool?.name === tool.name && (
<div className="mt-2 pt-2 border-t border-blue-200">
<div className="flex items-center text-xs font-medium text-blue-700">
<svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
Selected
</div>
</div>
)}
</div>
))}
</div>
)}
</>
)}
</>
)}
</> : null}
) : null}
</div>
</div>
</div>

View file

@ -1,5 +1,13 @@
import { describe, it, expect } from "vitest";
import { AUTH_TYPE, OAUTH_FLOW, TRANSPORT, handleTransport, handleAuth } from "./types";
import {
AUTH_TYPE,
OAUTH_FLOW,
MCP_OAUTH2_FLOW_M2M,
TRANSPORT,
handleTransport,
handleAuth,
getMcpOAuthMode,
} from "./types";
describe("handleTransport", () => {
it("should default to SSE when transport is null", () => {
@ -56,4 +64,65 @@ describe("constants", () => {
expect(OAUTH_FLOW.INTERACTIVE).toBe("interactive");
expect(OAUTH_FLOW.M2M).toBe("m2m");
});
it("should define the backend M2M flow value", () => {
expect(MCP_OAUTH2_FLOW_M2M).toBe("client_credentials");
});
});
describe("getMcpOAuthMode", () => {
it("returns null for non-OAuth2 servers", () => {
expect(getMcpOAuthMode({ auth_type: AUTH_TYPE.API_KEY })).toBeNull();
expect(getMcpOAuthMode({ auth_type: AUTH_TYPE.NONE })).toBeNull();
expect(getMcpOAuthMode({})).toBeNull();
});
it("classifies client_credentials as m2m", () => {
expect(getMcpOAuthMode({ auth_type: AUTH_TYPE.OAUTH2, oauth2_flow: MCP_OAUTH2_FLOW_M2M })).toBe("m2m");
});
it("treats m2m as m2m even when delegate_auth_to_upstream is true", () => {
expect(
getMcpOAuthMode({
auth_type: AUTH_TYPE.OAUTH2,
oauth2_flow: MCP_OAUTH2_FLOW_M2M,
delegate_auth_to_upstream: true,
}),
).toBe("m2m");
});
it("classifies an interactive server with delegate_auth_to_upstream as passthrough", () => {
expect(getMcpOAuthMode({ auth_type: AUTH_TYPE.OAUTH2, oauth2_flow: null, delegate_auth_to_upstream: true })).toBe(
"passthrough",
);
});
it("classifies an interactive server without delegation as obo", () => {
expect(getMcpOAuthMode({ auth_type: AUTH_TYPE.OAUTH2, oauth2_flow: null, delegate_auth_to_upstream: false })).toBe(
"obo",
);
});
it("defaults to obo when delegate_auth_to_upstream is undefined", () => {
expect(getMcpOAuthMode({ auth_type: AUTH_TYPE.OAUTH2 })).toBe("obo");
});
it("treats explicit authorization_code as interactive, not m2m", () => {
expect(
getMcpOAuthMode({
auth_type: AUTH_TYPE.OAUTH2,
oauth2_flow: "authorization_code",
delegate_auth_to_upstream: false,
}),
).toBe("obo");
});
// Regression: the old heuristic labeled any OAuth2 server with a token endpoint
// as M2M. getMcpOAuthMode ignores token_url, so an interactive server that
// legitimately carries one is classified by oauth2_flow + delegate, never M2M.
it("does not treat an interactive server with a token endpoint as m2m", () => {
expect(getMcpOAuthMode({ auth_type: AUTH_TYPE.OAUTH2, oauth2_flow: null, delegate_auth_to_upstream: false })).toBe(
"obo",
);
});
});

View file

@ -47,6 +47,28 @@ export const OAUTH_FLOW = {
M2M: "m2m",
};
// 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";
export type McpOAuthMode = "m2m" | "passthrough" | "obo";
// Classify an OAuth2 MCP server into the mode that decides how the tool list is
// authenticated: M2M (backend service token), PKCE passthrough (browser-held
// session token), or OBO (backend-stored per-user token). `token_url` is
// intentionally not consulted: every OAuth2 grant that exchanges for a token
// carries one (interactive PKCE and client_credentials alike), so it cannot
// distinguish the modes; `oauth2_flow` is the authoritative M2M signal.
export function getMcpOAuthMode(s: {
auth_type?: string | null;
oauth2_flow?: string | null;
delegate_auth_to_upstream?: boolean | null;
}): McpOAuthMode | null {
if (s.auth_type !== AUTH_TYPE.OAUTH2) return null;
if (s.oauth2_flow === MCP_OAUTH2_FLOW_M2M) return "m2m";
return s.delegate_auth_to_upstream ? "passthrough" : "obo";
}
export const TRANSPORT = {
SSE: "sse",
HTTP: "http",
@ -163,11 +185,14 @@ export interface MCPToolsViewerProps {
serverId: string;
accessToken: string | null;
auth_type?: string | null;
/** Backend OAuth2 grant; `client_credentials` marks an M2M server. */
oauth2_flow?: string | null;
/** When true (interactive OAuth2), the server uses PKCE passthrough. */
delegate_auth_to_upstream?: boolean | 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).
* Connection field present on every OAuth2 flow (interactive and M2M alike),
* so it does not indicate the mode. Retained for callers/other uses; not read
* for mode detection see getMcpOAuthMode.
*/
tokenUrl?: string | null;
userRole: string | null;
@ -189,6 +214,7 @@ export interface MCPServer {
spec_path?: string | null;
transport?: string | null;
auth_type?: string | null;
oauth2_flow?: string | null;
authorization_url?: string | null;
token_url?: string | null;
registration_url?: string | null;