mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
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:
parent
23e3f73d8b
commit
83d33800bb
5 changed files with 357 additions and 175 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 "Configure" 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 "{toolSearchTerm}"</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 "{toolSearchTerm}"</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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue