mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
Merge 13107448d3 into 1b3a744957
This commit is contained in:
commit
8da95331a5
18 changed files with 989 additions and 120 deletions
|
|
@ -4,6 +4,7 @@ import { act, renderHook, waitFor } from "@testing-library/react";
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { mcpServerToolKeys, useMCPServerTools } from "./useMCPServerTools";
|
||||
import type { MCPServer } from "@/components/mcp_tools/types";
|
||||
import * as networking from "@/components/networking";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
|
|
@ -116,3 +117,34 @@ describe("useMCPServerTools", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider tool choices", () => {
|
||||
it("reads current registered tools without discovery, including an empty allowlist", async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(networking.listMCPTools).mockResolvedValue(toolsFor("gateway"));
|
||||
const provider: MCPServer = {
|
||||
server_id: "native",
|
||||
execution_mode: "provider",
|
||||
allowed_tools: ["search"],
|
||||
tool_name_to_description: { search: "Search docs" },
|
||||
created_at: "",
|
||||
created_by: "",
|
||||
updated_at: "",
|
||||
updated_by: "",
|
||||
};
|
||||
const { result, rerender } = renderHook(
|
||||
({ server }) => useMCPServerTools(["native", "gateway"], new Set(["native", "gateway"]), [server]),
|
||||
{ wrapper, initialProps: { server: provider } },
|
||||
);
|
||||
await waitFor(() => expect(result.current.get("gateway")?.status).toBe("success"));
|
||||
expect(result.current.get("native")).toEqual({
|
||||
tools: [{ name: "search", description: "Search docs" }],
|
||||
status: "success",
|
||||
});
|
||||
expect(networking.listMCPTools).toHaveBeenCalledTimes(1);
|
||||
expect(networking.listMCPTools).toHaveBeenCalledWith("test-token-123", "gateway");
|
||||
rerender({ server: { ...provider, allowed_tools: [] } });
|
||||
expect(result.current.get("native")).toEqual({ tools: [], status: "success" });
|
||||
expect(networking.listMCPTools).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { useQueries, type QueryObserverResult } from "@tanstack/react-query";
|
|||
import { createQueryKeys } from "../common/queryKeysFactory";
|
||||
import { listMCPTools } from "@/components/networking";
|
||||
import useAuthorized from "../useAuthorized";
|
||||
import type { MCPServer } from "@/components/mcp_tools/types";
|
||||
import { providerToolChoices } from "@/components/mcp_tools/providerTools";
|
||||
|
||||
export const mcpServerToolKeys = createQueryKeys("mcpServerTools");
|
||||
|
||||
|
|
@ -48,23 +50,33 @@ const toStatus = ({
|
|||
export const useMCPServerTools = (
|
||||
serverIds: readonly string[],
|
||||
enabledServerIds: ReadonlySet<string>,
|
||||
servers: readonly MCPServer[] = [],
|
||||
): ReadonlyMap<string, MCPServerToolsState> => {
|
||||
const { accessToken } = useAuthorized();
|
||||
const providers = new Map(
|
||||
servers.filter((server) => server.execution_mode === "provider").map((server) => [server.server_id, server]),
|
||||
);
|
||||
return useQueries({
|
||||
queries: serverIds.map((serverId) => ({
|
||||
queryKey: mcpServerToolKeys.detail(serverId),
|
||||
queryFn: () => fetchServerTools(accessToken!, serverId),
|
||||
enabled: !!accessToken && enabledServerIds.has(serverId),
|
||||
enabled: !!accessToken && enabledServerIds.has(serverId) && !providers.has(serverId),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
})),
|
||||
combine: (results) =>
|
||||
new Map(
|
||||
results.map((result, idx): [string, MCPServerToolsState] => [
|
||||
serverIds[idx],
|
||||
{ tools: result.data ?? [], status: toStatus(result) },
|
||||
]),
|
||||
results.map((result, idx): [string, MCPServerToolsState] => {
|
||||
const serverId = serverIds[idx];
|
||||
const provider = providers.get(serverId);
|
||||
return [
|
||||
serverId,
|
||||
provider
|
||||
? { tools: providerToolChoices(provider), status: "success" }
|
||||
: { tools: result.data ?? [], status: toStatus(result) },
|
||||
];
|
||||
}),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -54,6 +54,8 @@ import { StdioDisabledBanner, TransportSelectItems } from "./StdioAvailability";
|
|||
import MCPPermissionManagement from "./MCPPermissionManagement";
|
||||
import OpenAPIFormSection, { OpenAPIKeyTool } from "./OpenAPIFormSection";
|
||||
import MCPLogoSelector from "./MCPLogoSelector";
|
||||
import ProviderMCPRegistrationForm from "./ProviderMCPRegistrationForm";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import EnvVarsSection from "./EnvVarsSection";
|
||||
import { isAdminRole } from "@/utils/roles";
|
||||
import { validateMCPServerUrl, validateMCPServerName } from "./utils";
|
||||
|
|
@ -86,6 +88,7 @@ interface CreateMCPServerProps {
|
|||
prefillData?: DiscoverableMCPServer | null;
|
||||
onBackToDiscovery?: () => void;
|
||||
stdioEnabled?: boolean;
|
||||
executionModeSelector?: React.ReactNode;
|
||||
}
|
||||
|
||||
const payloadErrorMessage = (result: Exclude<BuildCreatePayloadResult, { kind: "ok" }>, t: TFunction): string => {
|
||||
|
|
@ -106,7 +109,7 @@ const CREATE_DEFAULTS: MountedFormValues = {
|
|||
oauth_passthrough: false,
|
||||
};
|
||||
|
||||
const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
||||
const GatewayCreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
||||
userID,
|
||||
userRole,
|
||||
accessToken,
|
||||
|
|
@ -118,6 +121,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
prefillData,
|
||||
onBackToDiscovery,
|
||||
stdioEnabled = true,
|
||||
executionModeSelector,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const form = useForm<MountedFormValues>({ mode: "onChange", defaultValues: CREATE_DEFAULTS });
|
||||
|
|
@ -654,6 +658,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{executionModeSelector}
|
||||
<div className="mt-6">
|
||||
<FormProvider {...form}>
|
||||
<MountedFormProvider value={{ control: form.control, registry }}>
|
||||
|
|
@ -1047,4 +1052,70 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
const CreateMCPServer: React.FC<CreateMCPServerProps> = (props) => {
|
||||
const [executionMode, setExecutionMode] = useState<"litellm" | "provider">("litellm");
|
||||
const selectorId = React.useId();
|
||||
const { setModalVisible, isModalVisible } = props;
|
||||
// A parent can close the modal by flipping isModalVisible without going through setVisible, so
|
||||
// reset on every close; the next open always starts on the gateway form.
|
||||
const [wasModalVisible, setWasModalVisible] = useState(isModalVisible);
|
||||
if (wasModalVisible !== isModalVisible) {
|
||||
setWasModalVisible(isModalVisible);
|
||||
if (!isModalVisible) setExecutionMode("litellm");
|
||||
}
|
||||
const setVisible = React.useCallback(
|
||||
(visible: boolean) => {
|
||||
if (!visible) setExecutionMode("litellm");
|
||||
setModalVisible(visible);
|
||||
},
|
||||
[setModalVisible],
|
||||
);
|
||||
const executionModeSelector = isAdminRole(props.userRole) ? (
|
||||
<div className="space-y-2 pt-2">
|
||||
<Label htmlFor={selectorId}>Execution mode</Label>
|
||||
<Select
|
||||
value={executionMode}
|
||||
onValueChange={(value) => setExecutionMode(value === "provider" ? "provider" : "litellm")}
|
||||
items={[
|
||||
{ value: "litellm", label: "LiteLLM-executed" },
|
||||
{ value: "provider", label: "Provider-executed" },
|
||||
]}
|
||||
>
|
||||
<SelectTrigger id={selectorId} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="litellm">LiteLLM-executed</SelectItem>
|
||||
<SelectItem value="provider">Provider-executed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null;
|
||||
if (executionMode !== "provider" || !isAdminRole(props.userRole)) {
|
||||
return (
|
||||
<GatewayCreateMCPServer {...props} setModalVisible={setVisible} executionModeSelector={executionModeSelector} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Dialog open={props.isModalVisible} onOpenChange={setVisible}>
|
||||
<DialogContent className="top-8 max-h-[calc(100dvh-4rem)] translate-y-0 overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add New MCP Server</DialogTitle>
|
||||
</DialogHeader>
|
||||
{executionModeSelector}
|
||||
<ProviderMCPRegistrationForm
|
||||
accessToken={props.accessToken}
|
||||
availableAccessGroups={props.availableAccessGroups}
|
||||
existingServers={props.existingServers}
|
||||
onCancel={() => setVisible(false)}
|
||||
onSuccess={(server) => {
|
||||
setVisible(false);
|
||||
props.onCreateSuccess(server);
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateMCPServer;
|
||||
|
|
|
|||
|
|
@ -58,6 +58,9 @@ const MCPServerCard: FC<MCPServerCardProps> = ({
|
|||
stdioEnabled = true,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const isProvider = server.execution_mode === "provider";
|
||||
const recheckHealth = isProvider ? undefined : onRecheckHealth;
|
||||
const deleteServer = isProvider && server.is_config ? undefined : onDelete;
|
||||
const alias = server.alias || server.server_name || "";
|
||||
const name = server.server_name || alias || server.server_id;
|
||||
// Logo is sourced exclusively from the admin-set `mcp_info.logo_url`.
|
||||
|
|
@ -115,7 +118,7 @@ const MCPServerCard: FC<MCPServerCardProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
const hasMenu = !!onRecheckHealth || !!onDelete;
|
||||
const hasMenu = !!recheckHealth || !!deleteServer;
|
||||
|
||||
// Card uses role="button" + nested <button> children (Set, BYOK Connect, the
|
||||
// recheck-health Badge), so a real <button> wrapper would produce invalid
|
||||
|
|
@ -172,25 +175,25 @@ const MCPServerCard: FC<MCPServerCardProps> = ({
|
|||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end">
|
||||
{onRecheckHealth && (
|
||||
{recheckHealth && (
|
||||
<DropdownMenuItem
|
||||
disabled={isRechecking}
|
||||
onClick={(e) => {
|
||||
stop(e);
|
||||
onRecheckHealth();
|
||||
recheckHealth();
|
||||
}}
|
||||
>
|
||||
<Zap />
|
||||
{t("mcpServers.serverCard.actionsMenu.testConnection")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onRecheckHealth && onDelete && <DropdownMenuSeparator />}
|
||||
{onDelete && (
|
||||
{recheckHealth && deleteServer && <DropdownMenuSeparator />}
|
||||
{deleteServer && (
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={(e) => {
|
||||
stop(e);
|
||||
onDelete();
|
||||
deleteServer();
|
||||
}}
|
||||
>
|
||||
<Trash2 />
|
||||
|
|
@ -214,17 +217,26 @@ const MCPServerCard: FC<MCPServerCardProps> = ({
|
|||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<HealthChip
|
||||
status={status}
|
||||
isLoadingHealth={isLoadingHealth}
|
||||
isRechecking={isRechecking}
|
||||
onRecheck={onRecheckHealth}
|
||||
lastCheck={server.last_health_check}
|
||||
error={server.health_check_error}
|
||||
dotClass={healthTone.dot}
|
||||
/>
|
||||
<Badge variant="outline">{displayTransport.toUpperCase()}</Badge>
|
||||
<Badge variant="outline">{authType}</Badge>
|
||||
{isProvider ? (
|
||||
<>
|
||||
<Badge variant="outline">Provider-executed</Badge>
|
||||
<Badge variant="outline">{server.allowed_tools?.length ?? 0} allowed tools</Badge>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<HealthChip
|
||||
status={status}
|
||||
isLoadingHealth={isLoadingHealth}
|
||||
isRechecking={isRechecking}
|
||||
onRecheck={recheckHealth}
|
||||
lastCheck={server.last_health_check}
|
||||
error={server.health_check_error}
|
||||
dotClass={healthTone.dot}
|
||||
/>
|
||||
<Badge variant="outline">{displayTransport.toUpperCase()}</Badge>
|
||||
<Badge variant="outline">{authType}</Badge>
|
||||
</>
|
||||
)}
|
||||
{transport === "stdio" && !stdioEnabled && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
|
|
@ -238,7 +250,7 @@ const MCPServerCard: FC<MCPServerCardProps> = ({
|
|||
<TooltipContent>{t(STDIO_DISABLED_MESSAGE_KEY)}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{oauthFlowUnset && (
|
||||
{!isProvider && oauthFlowUnset && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
|
|
@ -251,17 +263,19 @@ const MCPServerCard: FC<MCPServerCardProps> = ({
|
|||
<TooltipContent>{t("mcpServers.serverCard.oauthFlowUnset.tooltip")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Badge variant="outline">
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", networkAccess.dotClassName)} />
|
||||
{networkAccess.label}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>{networkAccess.description}</TooltipContent>
|
||||
</Tooltip>
|
||||
{!isProvider && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Badge variant="outline">
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", networkAccess.dotClassName)} />
|
||||
{networkAccess.label}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>{networkAccess.description}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{accessGroups.slice(0, 2).map((g) => (
|
||||
<Tooltip key={g}>
|
||||
<TooltipTrigger
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import { mcpServerToolKeys } from "@/app/(dashboard)/hooks/mcpServers/useMCPServ
|
|||
import { MCPToolset } from "@/components/mcp_tools/types";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
fetchMCPServers: vi.fn(),
|
||||
fetchMCPToolsets: vi.fn(),
|
||||
createMCPToolset: vi.fn(),
|
||||
updateMCPToolset: vi.fn(),
|
||||
deleteMCPToolset: vi.fn(),
|
||||
|
|
@ -421,3 +423,50 @@ describe("MCPToolsetsTab usage guide", () => {
|
|||
expect(screen.getByText(/https:\/\/gateway\.example\.com\/toolset\/<toolset-name>\/mcp/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("creates a provider toolset from registration choices without MCP discovery", async () => {
|
||||
vi.clearAllMocks();
|
||||
const serverHooks = await vi.importActual<typeof import("@/app/(dashboard)/hooks/mcpServers/useMCPServers")>(
|
||||
"@/app/(dashboard)/hooks/mcpServers/useMCPServers",
|
||||
);
|
||||
const toolsetHooks = await vi.importActual<typeof import("@/app/(dashboard)/hooks/mcpServers/useMCPToolsets")>(
|
||||
"@/app/(dashboard)/hooks/mcpServers/useMCPToolsets",
|
||||
);
|
||||
vi.mocked(useMCPServers).mockImplementation(serverHooks.useMCPServers);
|
||||
vi.mocked(useMCPToolsets).mockImplementation(toolsetHooks.useMCPToolsets);
|
||||
vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([]);
|
||||
vi.mocked(networking.fetchMCPServers).mockResolvedValue([
|
||||
{
|
||||
server_id: "native",
|
||||
server_name: "native_docs",
|
||||
execution_mode: "provider",
|
||||
allowed_tools: ["search", "fetch"],
|
||||
tool_name_to_description: { search: "Search docs" },
|
||||
created_at: "",
|
||||
created_by: "",
|
||||
updated_at: "",
|
||||
updated_by: "",
|
||||
},
|
||||
]);
|
||||
vi.mocked(networking.createMCPToolset).mockResolvedValue({} as MCPToolset);
|
||||
const user = setup();
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } })}>
|
||||
<MCPToolsetsTab accessToken="sk-test" userRole="Admin" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await openCreate(user);
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. github-linear-tools"), { target: { value: "native_research" } });
|
||||
await user.click(await screen.findByText("native_docs"));
|
||||
expect(screen.getByText("provider · permission only")).toBeInTheDocument();
|
||||
await user.click(await screen.findByRole("checkbox", { name: /^search/ }));
|
||||
await user.click(screen.getByRole("button", { name: "Create Toolset" }));
|
||||
await waitFor(() =>
|
||||
expect(networking.createMCPToolset).toHaveBeenCalledWith("sk-test", {
|
||||
toolset_name: "native_research",
|
||||
description: "",
|
||||
tools: [{ server_id: "native", tool_name: "search" }],
|
||||
}),
|
||||
);
|
||||
expect(networking.listMCPTools).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -133,6 +133,7 @@ const selectedToolIssue = (
|
|||
interface ServerToolGroupProps {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
providerNative: boolean;
|
||||
state: MCPServerToolsState;
|
||||
visibleTools: readonly MCPServerToolEntry[];
|
||||
expanded: boolean;
|
||||
|
|
@ -146,6 +147,7 @@ interface ServerToolGroupProps {
|
|||
function ServerToolGroup({
|
||||
serverId,
|
||||
serverName,
|
||||
providerNative,
|
||||
state,
|
||||
visibleTools,
|
||||
expanded,
|
||||
|
|
@ -235,6 +237,11 @@ function ServerToolGroup({
|
|||
>
|
||||
<span className="text-sm font-medium text-foreground flex items-center gap-2">
|
||||
{serverName}
|
||||
{providerNative && (
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
{t("mcpServers.toolsetsTab.serverGroup.providerPermissionOnly")}
|
||||
</span>
|
||||
)}
|
||||
{selectedCount > 0 && (
|
||||
<span className="ml-1 text-xs text-purple-600 font-semibold dark:text-purple-400">
|
||||
{t("mcpServers.toolsetsTab.serverGroup.selectedCount", { count: selectedCount })}
|
||||
|
|
@ -277,7 +284,7 @@ function CreateToolsetModal({ open, onClose, onSave, initialToolset }: CreateToo
|
|||
() => serversToFetch(open, searching, serverIds, expandedServerIds),
|
||||
[open, searching, serverIds, expandedServerIds],
|
||||
);
|
||||
const toolsByServer = useMCPServerTools(serverIds, fetchServerIds);
|
||||
const toolsByServer = useMCPServerTools(serverIds, fetchServerIds, mcpServers);
|
||||
const serverPrefixById = React.useMemo(
|
||||
() => new Map(mcpServers.map((s) => [s.server_id, s.alias || s.server_name || s.server_id])),
|
||||
[mcpServers],
|
||||
|
|
@ -429,6 +436,7 @@ function CreateToolsetModal({ open, onClose, onSave, initialToolset }: CreateToo
|
|||
key={server.server_id}
|
||||
serverId={server.server_id}
|
||||
serverName={serverName}
|
||||
providerNative={server.execution_mode === "provider"}
|
||||
state={state}
|
||||
visibleTools={visibleTools}
|
||||
expanded={searchActive || expandedServerIds.has(server.server_id)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,183 @@
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { MCPServer } from "@/components/mcp_tools/types";
|
||||
import * as networking from "@/components/networking";
|
||||
import CreateMCPServer from "./CreateMCPServer";
|
||||
import MCPServerEdit from "./mcp_server_edit";
|
||||
import { MCPServerView } from "./mcp_server_view";
|
||||
import MCPServerCard from "./MCPServerCard";
|
||||
import { selectOption } from "./testUtils";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
createMCPServer: vi.fn(),
|
||||
updateMCPServer: vi.fn(),
|
||||
fetchOpenAPIRegistry: vi.fn().mockResolvedValue({ apis: [] }),
|
||||
listMCPTools: vi.fn().mockResolvedValue({ tools: [] }),
|
||||
testMCPToolsListRequest: vi.fn().mockResolvedValue({ tools: [] }),
|
||||
getMCPOAuthUserCredentialStatus: vi.fn(),
|
||||
}));
|
||||
|
||||
const server: MCPServer = {
|
||||
server_id: "provider-docs",
|
||||
execution_mode: "provider",
|
||||
server_name: "docs_native",
|
||||
url: "https://docs.example.com/mcp",
|
||||
allowed_tools: ["search_docs", "fetch_doc"],
|
||||
mcp_info: { tool_allowlist_enforced: true },
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
created_by: "admin",
|
||||
updated_by: "admin",
|
||||
};
|
||||
|
||||
const DELEGATE_AUTH_LABEL = "Delegate Auth to Upstream";
|
||||
const OAUTH_PASSTHROUGH_LABEL = "OAuth Pass-through";
|
||||
|
||||
const props = {
|
||||
accessToken: "test-token",
|
||||
userRole: "Admin",
|
||||
userID: "admin",
|
||||
availableAccessGroups: ["engineering"],
|
||||
};
|
||||
|
||||
function renderView(isConfig = false) {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MCPServerView
|
||||
{...props}
|
||||
mcpServer={{ ...server, is_config: isConfig }}
|
||||
isProxyAdmin
|
||||
isEditing={false}
|
||||
onBack={vi.fn()}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("provider registration UI", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(networking.createMCPServer).mockResolvedValue(server);
|
||||
vi.mocked(networking.updateMCPServer).mockResolvedValue({ ...server, allowed_tools: [] });
|
||||
});
|
||||
|
||||
it("switches from a gateway draft to provider registration without sending connection settings", async () => {
|
||||
const onCreateSuccess = vi.fn();
|
||||
render(<CreateMCPServer {...props} isModalVisible setModalVisible={vi.fn()} onCreateSuccess={onCreateSuccess} />);
|
||||
await selectOption("Transport Type", "Streamable HTTP");
|
||||
await selectOption("Authentication", "API Key");
|
||||
fireEvent.change(screen.getByLabelText("Authentication Value"), { target: { value: "draft-credential" } });
|
||||
await selectOption("Execution mode", "Provider-executed");
|
||||
fireEvent.change(screen.getByLabelText("MCP Server Name"), { target: { value: "docs_native" } });
|
||||
fireEvent.change(screen.getByLabelText("MCP Server URL"), { target: { value: server.url } });
|
||||
fireEvent.change(screen.getByLabelText("Allowed tools"), { target: { value: "search_docs\nfetch_doc" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add MCP Server" }));
|
||||
await waitFor(() => expect(onCreateSuccess).toHaveBeenCalledWith(server));
|
||||
const expectedPayload = {
|
||||
execution_mode: "provider",
|
||||
server_name: "docs_native",
|
||||
alias: null,
|
||||
description: null,
|
||||
url: server.url,
|
||||
allowed_tools: ["search_docs", "fetch_doc"],
|
||||
mcp_access_groups: [],
|
||||
allow_all_keys: false,
|
||||
mcp_info: { tool_allowlist_enforced: true },
|
||||
};
|
||||
expect(networking.createMCPServer).toHaveBeenCalledExactlyOnceWith("test-token", expectedPayload);
|
||||
expect(networking.testMCPToolsListRequest).not.toHaveBeenCalled();
|
||||
expect(screen.queryByLabelText("Authentication Value")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reopens on the gateway form after the parent closes the modal in provider mode", async () => {
|
||||
const createProps = { ...props, setModalVisible: vi.fn(), onCreateSuccess: vi.fn() };
|
||||
const { rerender } = render(<CreateMCPServer {...createProps} isModalVisible />);
|
||||
await selectOption("Execution mode", "Provider-executed");
|
||||
expect(screen.getByLabelText("Allowed tools")).toBeInTheDocument();
|
||||
rerender(<CreateMCPServer {...createProps} isModalVisible={false} />);
|
||||
rerender(<CreateMCPServer {...createProps} isModalVisible />);
|
||||
expect(await screen.findByText("LiteLLM-executed")).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Allowed tools")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("edits stored tools and can deny all without discovery", async () => {
|
||||
const onSuccess = vi.fn();
|
||||
render(<MCPServerEdit {...props} mcpServer={server} onCancel={vi.fn()} onSuccess={onSuccess} />);
|
||||
expect(screen.getByLabelText("Allowed tools")).toHaveValue("search_docs\nfetch_doc");
|
||||
fireEvent.change(screen.getByLabelText("Allowed tools"), { target: { value: "" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith(expect.objectContaining({ allowed_tools: [] })));
|
||||
expect(networking.updateMCPServer).toHaveBeenCalledWith(
|
||||
"test-token",
|
||||
expect.objectContaining({
|
||||
server_id: server.server_id,
|
||||
allowed_tools: [],
|
||||
mcp_info: { tool_allowlist_enforced: true },
|
||||
}),
|
||||
);
|
||||
expect(networking.listMCPTools).not.toHaveBeenCalled();
|
||||
expect(networking.testMCPToolsListRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("displays stored tools without fetching live tools or credentials", async () => {
|
||||
renderView();
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Allowed tools" }));
|
||||
expect(await screen.findByRole("cell", { name: "search_docs" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("tab", { name: /User Credentials/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /Test Connection/i })).not.toBeInTheDocument();
|
||||
expect(networking.listMCPTools).not.toHaveBeenCalled();
|
||||
expect(networking.getMCPOAuthUserCredentialStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hides gateway OAuth rows in provider settings even when gateway auth fields are present", () => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MCPServerView
|
||||
{...props}
|
||||
mcpServer={{ ...server, auth_type: "oauth2", extra_headers: ["Authorization"] }}
|
||||
isProxyAdmin
|
||||
isEditing={false}
|
||||
onBack={vi.fn()}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Settings" }));
|
||||
expect(screen.queryByText(DELEGATE_AUTH_LABEL)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(OAUTH_PASSTHROUGH_LABEL)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps config registrations read-only", () => {
|
||||
renderView(true);
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Settings" }));
|
||||
expect(screen.getByRole("button", { name: "Edit Settings" })).toBeDisabled();
|
||||
expect(screen.getByText("Defined in config. Edit your YAML configuration to make changes")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps deletion available but hides gateway connection actions on provider cards", async () => {
|
||||
const onDelete = vi.fn();
|
||||
render(<MCPServerCard server={server} onClick={vi.fn()} onRecheckHealth={vi.fn()} onDelete={onDelete} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Server actions" }));
|
||||
expect(await screen.findByRole("menuitem", { name: "Delete" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("menuitem", { name: "Test Connection" })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
|
||||
expect(onDelete).toHaveBeenCalledOnce();
|
||||
expect(screen.getByText("2 allowed tools")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not offer deletion for a config-backed provider card", () => {
|
||||
const onDelete = vi.fn();
|
||||
render(
|
||||
<MCPServerCard
|
||||
server={{ ...server, is_config: true }}
|
||||
onClick={vi.fn()}
|
||||
onRecheckHealth={vi.fn()}
|
||||
onDelete={onDelete}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "Server actions" })).not.toBeInTheDocument();
|
||||
expect(onDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import type { MCPServer } from "@/components/mcp_tools/types";
|
||||
import { createMCPServer, updateMCPServer } from "@/components/networking";
|
||||
import { MultiSelect } from "@/components/shared/MultiSelect";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { DUPLICATE_IDENTIFIER_MESSAGE_KEY, findDuplicateMcpServer, mcpSubmitErrorReason } from "./duplicateServerCheck";
|
||||
import {
|
||||
buildProviderRegistrationPayload,
|
||||
providerRegistrationDefaults,
|
||||
providerRegistrationSchema,
|
||||
type ProviderRegistrationValues,
|
||||
} from "./providerRegistrationForm";
|
||||
|
||||
interface ProviderMCPRegistrationFormProps {
|
||||
readonly mcpServer?: MCPServer;
|
||||
readonly accessToken: string | null;
|
||||
readonly availableAccessGroups: string[];
|
||||
readonly existingServers?: MCPServer[];
|
||||
readonly onCancel: () => void;
|
||||
readonly onSuccess: (server: MCPServer) => void;
|
||||
}
|
||||
|
||||
export default function ProviderMCPRegistrationForm({
|
||||
mcpServer,
|
||||
accessToken,
|
||||
availableAccessGroups,
|
||||
existingServers,
|
||||
onCancel,
|
||||
onSuccess,
|
||||
}: ProviderMCPRegistrationFormProps) {
|
||||
const id = useId();
|
||||
const { t } = useTranslation();
|
||||
const form = useForm<ProviderRegistrationValues>({
|
||||
resolver: zodResolver(providerRegistrationSchema),
|
||||
defaultValues: providerRegistrationDefaults(mcpServer),
|
||||
});
|
||||
const { errors, isSubmitting } = form.formState;
|
||||
const submitLabel = mcpServer ? "Save Changes" : "Add MCP Server";
|
||||
const save = async (values: ProviderRegistrationValues) => {
|
||||
if (!accessToken || mcpServer?.is_config) return;
|
||||
const duplicate = findDuplicateMcpServer(existingServers, values.server_name, values.alias, mcpServer?.server_id);
|
||||
if (duplicate) {
|
||||
form.setError(duplicate.field, { message: t(DUPLICATE_IDENTIFIER_MESSAGE_KEY) });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = buildProviderRegistrationPayload(values, mcpServer);
|
||||
const server = mcpServer
|
||||
? await updateMCPServer(accessToken, payload)
|
||||
: await createMCPServer(accessToken, payload);
|
||||
toast.success(mcpServer ? "MCP server updated" : "MCP server registered");
|
||||
onSuccess(server);
|
||||
} catch (error) {
|
||||
form.setError("root", { message: mcpSubmitErrorReason(error) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.handleSubmit(save)} className="space-y-5">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The LLM provider discovers and calls this MCP server. LiteLLM checks server and tool permissions without
|
||||
connecting to it. Supply authentication in your provider request.
|
||||
</p>
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
<Field data-invalid={!!errors.server_name}>
|
||||
<FieldLabel htmlFor={`${id}-name`}>MCP Server Name</FieldLabel>
|
||||
<Input
|
||||
id={`${id}-name`}
|
||||
{...form.register("server_name")}
|
||||
aria-invalid={!!errors.server_name}
|
||||
placeholder="docs_native"
|
||||
/>
|
||||
<FieldError errors={[errors.server_name]} />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.alias}>
|
||||
<FieldLabel htmlFor={`${id}-alias`}>Alias (optional)</FieldLabel>
|
||||
<Input id={`${id}-alias`} {...form.register("alias")} aria-invalid={!!errors.alias} placeholder="docs" />
|
||||
<FieldError errors={[errors.alias]} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field data-invalid={!!errors.url}>
|
||||
<FieldLabel htmlFor={`${id}-url`}>MCP Server URL</FieldLabel>
|
||||
<Input
|
||||
id={`${id}-url`}
|
||||
{...form.register("url")}
|
||||
aria-invalid={!!errors.url}
|
||||
placeholder="https://docs.example.com/mcp"
|
||||
/>
|
||||
<FieldDescription>Use the same HTTPS endpoint in your provider request.</FieldDescription>
|
||||
<FieldError errors={[errors.url]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`${id}-description`}>Description (optional)</FieldLabel>
|
||||
<Input id={`${id}-description`} {...form.register("description")} placeholder="Search and read documentation" />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.tools_text}>
|
||||
<FieldLabel htmlFor={`${id}-tools`}>Allowed tools</FieldLabel>
|
||||
<Textarea
|
||||
id={`${id}-tools`}
|
||||
{...form.register("tools_text")}
|
||||
aria-invalid={!!errors.tools_text}
|
||||
aria-describedby={`${id}-tools-help`}
|
||||
placeholder={"search_docs\nfetch_doc"}
|
||||
rows={4}
|
||||
className="font-mono"
|
||||
/>
|
||||
<FieldDescription id={`${id}-tools-help`}>
|
||||
Enter one exact tool name per line. An empty list blocks all tool calls. LiteLLM does not discover this list.
|
||||
</FieldDescription>
|
||||
<FieldError errors={[errors.tools_text]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`${id}-groups`}>Access groups</FieldLabel>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="mcp_access_groups"
|
||||
render={({ field }) => (
|
||||
<MultiSelect
|
||||
id={`${id}-groups`}
|
||||
options={availableAccessGroups.map((group) => ({ label: group, value: group }))}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
allowCustomValues
|
||||
placeholder="Select or create access groups"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="allow_all_keys"
|
||||
render={({ field }) => (
|
||||
<Switch id={`${id}-all-keys`} checked={field.value} onCheckedChange={field.onChange} />
|
||||
)}
|
||||
/>
|
||||
<div>
|
||||
<FieldLabel htmlFor={`${id}-all-keys`}>Allow All LiteLLM Keys</FieldLabel>
|
||||
<FieldDescription>Otherwise, grant access through key, team, or toolset permissions.</FieldDescription>
|
||||
</div>
|
||||
</Field>
|
||||
<FieldError errors={[errors.root]} />
|
||||
<div className="flex justify-end gap-3 border-t pt-4">
|
||||
<Button type="button" variant="secondary" onClick={onCancel} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting || !accessToken || mcpServer?.is_config} aria-busy={isSubmitting}>
|
||||
{isSubmitting ? "Saving..." : submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import type { MCPServer } from "@/components/mcp_tools/types";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
|
||||
export default function ProviderMCPTools({ server }: { readonly server: MCPServer }) {
|
||||
const tools = server.allowed_tools ?? [];
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
These allowed tools are stored in the registration. The LLM provider discovers and executes them.
|
||||
</p>
|
||||
{tools.length === 0 ? (
|
||||
<p className="text-sm">No tools allowed</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Tool name</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tools.map((name) => (
|
||||
<TableRow key={name}>
|
||||
<TableCell className="font-mono">{name}</TableCell>
|
||||
<TableCell>{server.tool_name_to_description?.[name] || "No description"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -50,6 +50,7 @@ import TokenExchangeFormFields from "./TokenExchangeFormFields";
|
|||
import IdJagFormFields from "./IdJagFormFields";
|
||||
import OAuthFormFields from "./OAuthFormFields";
|
||||
import MCPLogoSelector from "./MCPLogoSelector";
|
||||
import ProviderMCPRegistrationForm from "./ProviderMCPRegistrationForm";
|
||||
import EnvVarsSection from "./EnvVarsSection";
|
||||
import { validateMCPServerUrl, validateMCPServerName, normalizeToolOverrideMap } from "./utils";
|
||||
import { AUTH_TYPES_REQUIRING_AUTH_VALUE } from "./createServerPayload";
|
||||
|
|
@ -98,7 +99,7 @@ interface MCPServerEditProps {
|
|||
|
||||
export const EDIT_OAUTH_UI_STATE_KEY = "litellm-mcp-oauth-edit-state";
|
||||
|
||||
const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
||||
const GatewayMCPServerEdit: React.FC<MCPServerEditProps> = ({
|
||||
mcpServer,
|
||||
accessToken,
|
||||
userID,
|
||||
|
|
@ -1391,4 +1392,11 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
const MCPServerEdit: React.FC<MCPServerEditProps> = (props) => {
|
||||
if (props.mcpServer.execution_mode === "provider") {
|
||||
return <ProviderMCPRegistrationForm {...props} />;
|
||||
}
|
||||
return <GatewayMCPServerEdit {...props} />;
|
||||
};
|
||||
|
||||
export default MCPServerEdit;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { MCPServerUserCredentialsPanel } from "./MCPServerUserCredentialsPanel";
|
|||
import { getSecureItem } from "@/utils/secureStorage";
|
||||
import { isProxyAdminRole, isProxyAdminTierRole } from "@/utils/roles";
|
||||
import MCPServerCostDisplay from "./mcp_server_cost_display";
|
||||
import ProviderMCPTools from "./ProviderMCPTools";
|
||||
import { StdioDisabledBanner } from "./StdioAvailability";
|
||||
import { getMaskedAndFullUrl, getMCPNetworkAccess } from "./utils";
|
||||
import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils";
|
||||
|
|
@ -72,6 +73,7 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
|
|||
// redirect, so the "token fetched" feedback shows where the user left off (Settings=2).
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const isProvider = mcpServer.execution_mode === "provider";
|
||||
const canEdit = isProxyAdmin && !isViewOnly && !mcpServer.is_config;
|
||||
const returningFromEditOAuth = isReturningFromEditOAuth(canEdit, mcpServer.server_id);
|
||||
const [editing, setEditing] = useState(isEditing || returningFromEditOAuth);
|
||||
|
|
@ -81,7 +83,7 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
|
|||
const [selectedTabIndex, setSelectedTabIndex] = useState(returningFromEditOAuth ? 2 : initialTabIndex);
|
||||
const editFormShowsStdioBanner = selectedTabIndex === 2 && editing && canEdit;
|
||||
const showStdioBanner = mcpServer.transport === TRANSPORT.STDIO && !stdioEnabled && !editFormShowsStdioBanner;
|
||||
const canViewUserCredentials = userRole !== null && isProxyAdminTierRole(userRole);
|
||||
const canViewUserCredentials = !isProvider && userRole !== null && isProxyAdminTierRole(userRole);
|
||||
const canRevokeUserCredentials = userRole !== null && isProxyAdminRole(userRole) && !isViewOnly;
|
||||
|
||||
const handleSuccess = (updated: MCPServer) => {
|
||||
|
|
@ -125,6 +127,7 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
|
|||
<h1 className="text-2xl font-semibold">
|
||||
{mcpServer.server_name || mcpServer.alias || t("mcpServers.serverView.unnamedServer")}
|
||||
</h1>
|
||||
{isProvider && <Badge variant="outline">Provider-executed</Badge>}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
|
|
@ -161,7 +164,7 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
|
|||
{t("mcpServers.serverView.tabs.overview")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="1" className="flex-none rounded-none px-4 py-2">
|
||||
{t("mcpServers.serverView.tabs.tools")}
|
||||
{isProvider ? "Allowed tools" : t("mcpServers.serverView.tabs.tools")}
|
||||
</TabsTrigger>
|
||||
{isProxyAdmin && (
|
||||
<TabsTrigger value="2" className="flex-none rounded-none px-4 py-2">
|
||||
|
|
@ -180,10 +183,14 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
|
|||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Card className="p-4">
|
||||
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
||||
{t("mcpServers.serverView.overview.transport")}
|
||||
{isProvider ? "Execution mode" : t("mcpServers.serverView.overview.transport")}
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
{getTransportBadge(handleTransport(mcpServer.transport ?? undefined, mcpServer.spec_path ?? undefined))}
|
||||
{isProvider ? (
|
||||
<Badge variant="outline">Provider-executed</Badge>
|
||||
) : (
|
||||
getTransportBadge(handleTransport(mcpServer.transport ?? undefined, mcpServer.spec_path ?? undefined))
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
|
@ -191,7 +198,13 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
|
|||
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
||||
{t("mcpServers.serverView.overview.authentication")}
|
||||
</p>
|
||||
<div className="mt-3">{getAuthBadge(handleAuth(mcpServer.auth_type ?? undefined))}</div>
|
||||
<div className="mt-3">
|
||||
{isProvider ? (
|
||||
<p className="text-sm">Supplied in the provider request</p>
|
||||
) : (
|
||||
getAuthBadge(handleAuth(mcpServer.auth_type ?? undefined))
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
|
|
@ -222,31 +235,37 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
|
|||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<Card className="mt-4 p-4">
|
||||
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
||||
{t("mcpServers.serverView.overview.costConfiguration")}
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<MCPServerCostDisplay costConfig={mcpServer.mcp_info?.mcp_server_cost_info} />
|
||||
</div>
|
||||
</Card>
|
||||
{!isProvider && (
|
||||
<Card className="mt-4 p-4">
|
||||
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
||||
{t("mcpServers.serverView.overview.costConfiguration")}
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<MCPServerCostDisplay costConfig={mcpServer.mcp_info?.mcp_server_cost_info} />
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Tool Panel */}
|
||||
<TabsContent value="1" keepMounted>
|
||||
<MCPToolsViewer
|
||||
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}
|
||||
dcr_bridge={mcpServer.dcr_bridge}
|
||||
tokenUrl={mcpServer.token_url}
|
||||
userRole={userRole}
|
||||
userID={userID}
|
||||
serverAlias={mcpServer.alias}
|
||||
extraHeaders={mcpServer.extra_headers}
|
||||
/>
|
||||
{isProvider ? (
|
||||
<ProviderMCPTools server={mcpServer} />
|
||||
) : (
|
||||
<MCPToolsViewer
|
||||
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}
|
||||
dcr_bridge={mcpServer.dcr_bridge}
|
||||
tokenUrl={mcpServer.token_url}
|
||||
userRole={userRole}
|
||||
userID={userID}
|
||||
serverAlias={mcpServer.alias}
|
||||
extraHeaders={mcpServer.extra_headers}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Settings Panel */}
|
||||
|
|
@ -323,31 +342,39 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("mcpServers.serverView.settings.fields.transport")}
|
||||
</p>
|
||||
<div className="col-span-2">
|
||||
{getTransportBadge(handleTransport(mcpServer.transport, mcpServer.spec_path))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("mcpServers.serverView.settings.fields.authentication")}
|
||||
</p>
|
||||
<div className="col-span-2">{getAuthBadge(handleAuth(mcpServer.auth_type))}</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("mcpServers.serverView.settings.fields.extraHeaders")}
|
||||
</p>
|
||||
<div className="col-span-2 text-sm">
|
||||
{mcpServer.extra_headers && mcpServer.extra_headers.length > 0 ? (
|
||||
mcpServer.extra_headers.join(", ")
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Execution mode</p>
|
||||
<div className="col-span-2 text-sm">{isProvider ? "Provider-executed" : "LiteLLM-executed"}</div>
|
||||
</div>
|
||||
{!isProvider && (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("mcpServers.serverView.settings.fields.transport")}
|
||||
</p>
|
||||
<div className="col-span-2">
|
||||
{getTransportBadge(handleTransport(mcpServer.transport, mcpServer.spec_path))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("mcpServers.serverView.settings.fields.authentication")}
|
||||
</p>
|
||||
<div className="col-span-2">{getAuthBadge(handleAuth(mcpServer.auth_type))}</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("mcpServers.serverView.settings.fields.extraHeaders")}
|
||||
</p>
|
||||
<div className="col-span-2 text-sm">
|
||||
{mcpServer.extra_headers && mcpServer.extra_headers.length > 0 ? (
|
||||
mcpServer.extra_headers.join(", ")
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("mcpServers.serverView.settings.fields.allowAllKeys")}
|
||||
|
|
@ -363,19 +390,21 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("mcpServers.serverView.settings.fields.networkAccess")}
|
||||
</p>
|
||||
<div className="col-span-2">
|
||||
<Badge variant="outline">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${networkAccess.dotClassName}`} />
|
||||
{networkAccess.label}
|
||||
</Badge>
|
||||
<p className="mt-2 text-xs text-muted-foreground">{networkAccess.description}</p>
|
||||
{!isProvider && (
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("mcpServers.serverView.settings.fields.networkAccess")}
|
||||
</p>
|
||||
<div className="col-span-2">
|
||||
<Badge variant="outline">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${networkAccess.dotClassName}`} />
|
||||
{networkAccess.label}
|
||||
</Badge>
|
||||
<p className="mt-2 text-xs text-muted-foreground">{networkAccess.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{handleAuth(mcpServer.auth_type) === "oauth2" && (
|
||||
)}
|
||||
{!isProvider && handleAuth(mcpServer.auth_type) === "oauth2" && (
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("mcpServers.serverView.settings.fields.delegateAuth")}
|
||||
|
|
@ -392,7 +421,8 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
{handleAuth(mcpServer.auth_type) !== "oauth2" &&
|
||||
{!isProvider &&
|
||||
handleAuth(mcpServer.auth_type) !== "oauth2" &&
|
||||
Array.isArray(mcpServer.extra_headers) &&
|
||||
mcpServer.extra_headers.some((h) => typeof h === "string" && h.toLowerCase() === "authorization") && (
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
|
|
@ -443,18 +473,24 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
|
|||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Badge variant="outline">{t("mcpServers.serverView.settings.status.allToolsEnabled")}</Badge>
|
||||
<Badge variant="outline">
|
||||
{isProvider || mcpServer.mcp_info?.tool_allowlist_enforced
|
||||
? "No tools allowed"
|
||||
: t("mcpServers.serverView.settings.status.allToolsEnabled")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("mcpServers.serverView.settings.fields.cost")}
|
||||
</p>
|
||||
<div className="col-span-2">
|
||||
<MCPServerCostDisplay costConfig={mcpServer.mcp_info?.mcp_server_cost_info} />
|
||||
{!isProvider && (
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("mcpServers.serverView.settings.fields.cost")}
|
||||
</p>
|
||||
<div className="col-span-2">
|
||||
<MCPServerCostDisplay costConfig={mcpServer.mcp_info?.mcp_server_cost_info} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { MCPServer } from "@/components/mcp_tools/types";
|
||||
import {
|
||||
buildProviderRegistrationPayload,
|
||||
providerRegistrationDefaults,
|
||||
providerRegistrationSchema,
|
||||
} from "./providerRegistrationForm";
|
||||
|
||||
const values = {
|
||||
...providerRegistrationDefaults(),
|
||||
server_name: "docs_native",
|
||||
url: "https://docs.example.com/mcp",
|
||||
tools_text: " search_docs \nfetch_doc\nsearch_docs\n\n",
|
||||
};
|
||||
|
||||
const server: MCPServer = {
|
||||
server_id: "provider-docs",
|
||||
execution_mode: "provider",
|
||||
server_name: "docs_native",
|
||||
url: values.url,
|
||||
allowed_tools: ["search_docs", "fetch_doc"],
|
||||
mcp_info: { tool_allowlist_enforced: true, logo_url: "https://example.com/icon.svg" },
|
||||
tool_name_to_description: { search_docs: "Search documentation", fetch_doc: "Fetch a document" },
|
||||
tool_name_to_display_name: { search_docs: "Search", fetch_doc: "Fetch" },
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
created_by: "admin",
|
||||
updated_by: "admin",
|
||||
};
|
||||
|
||||
describe("provider registration payload", () => {
|
||||
it("sends finite deduplicated tools and only provider registration fields", () => {
|
||||
const expectedPayload = {
|
||||
execution_mode: "provider",
|
||||
server_name: "docs_native",
|
||||
alias: null,
|
||||
description: null,
|
||||
url: values.url,
|
||||
allowed_tools: ["search_docs", "fetch_doc"],
|
||||
mcp_access_groups: [],
|
||||
allow_all_keys: false,
|
||||
mcp_info: { tool_allowlist_enforced: true },
|
||||
};
|
||||
expect(buildProviderRegistrationPayload(providerRegistrationSchema.parse(values))).toEqual(expectedPayload);
|
||||
});
|
||||
|
||||
it("clears tools with an enforced empty list and removes stale tool metadata", () => {
|
||||
const edited = { ...providerRegistrationDefaults(server), tools_text: " \n " };
|
||||
const payload = buildProviderRegistrationPayload(edited, server);
|
||||
expect(payload).not.toHaveProperty("execution_mode");
|
||||
const expectedPayload = {
|
||||
server_id: server.server_id,
|
||||
allowed_tools: [],
|
||||
mcp_info: server.mcp_info,
|
||||
tool_name_to_description: {},
|
||||
tool_name_to_display_name: {},
|
||||
};
|
||||
expect(payload).toMatchObject(expectedPayload);
|
||||
});
|
||||
|
||||
it("preserves descriptions for retained tools and hydrates stored permissions", () => {
|
||||
const stored = { ...server, mcp_access_groups: ["engineering"], allow_all_keys: true };
|
||||
const defaults = providerRegistrationDefaults(stored);
|
||||
expect(defaults).toMatchObject({
|
||||
tools_text: "search_docs\nfetch_doc",
|
||||
mcp_access_groups: ["engineering"],
|
||||
allow_all_keys: true,
|
||||
});
|
||||
expect(buildProviderRegistrationPayload({ ...defaults, tools_text: "search_docs" }, stored)).toMatchObject({
|
||||
allowed_tools: ["search_docs"],
|
||||
tool_name_to_description: { search_docs: "Search documentation" },
|
||||
tool_name_to_display_name: { search_docs: "Search" },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
"http://example.com/mcp",
|
||||
"https:example.com/mcp",
|
||||
"https://user:password@example.com/mcp",
|
||||
"https://@example.com/mcp",
|
||||
"https://example.com/mcp#tools",
|
||||
"https://example.com/with space",
|
||||
"https://example.com/with\u0001control",
|
||||
"https://example.com\\mcp",
|
||||
])("rejects unsupported endpoint %s", (url) => {
|
||||
expect(providerRegistrationSchema.safeParse({ ...values, url }).success).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["*", "search?", "search[docs]"])("rejects wildcard tool %s", (tools_text) => {
|
||||
expect(providerRegistrationSchema.safeParse({ ...values, tools_text }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import { z } from "zod";
|
||||
import type { MCPServer } from "@/components/mcp_tools/types";
|
||||
|
||||
const toolNames = (value: string): string[] => [
|
||||
...new Set(
|
||||
value
|
||||
.split(/\r?\n/)
|
||||
.map((name) => name.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
|
||||
const isProviderUrl = (value: string): boolean => {
|
||||
if (/[\s\\#]/.test(value) || [...value].some((char) => char.charCodeAt(0) < 32)) return false;
|
||||
if (!/^https:\/\/[^/?#@]+(?:[/?]|$)/i.test(value)) return false;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "https:" && !url.username && !url.password;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const identifier = z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((value) => !/[\s-]/.test(value), "Use underscores instead of spaces or hyphens");
|
||||
|
||||
const providerRegistrationFields = {
|
||||
server_name: identifier.refine(Boolean, "Enter a server name"),
|
||||
alias: identifier,
|
||||
description: z.string(),
|
||||
url: z.string().trim().refine(isProviderUrl, "Enter an HTTPS URL without credentials or a fragment"),
|
||||
tools_text: z
|
||||
.string()
|
||||
.refine(
|
||||
(value) => toolNames(value).every((name) => !/[*?\[\]]/.test(name)),
|
||||
"Enter exact tool names without wildcards",
|
||||
),
|
||||
mcp_access_groups: z.array(z.string()),
|
||||
allow_all_keys: z.boolean(),
|
||||
};
|
||||
|
||||
export const providerRegistrationSchema = z.object(providerRegistrationFields);
|
||||
|
||||
export type ProviderRegistrationValues = z.infer<typeof providerRegistrationSchema>;
|
||||
|
||||
export const providerRegistrationDefaults = (server?: MCPServer): ProviderRegistrationValues => ({
|
||||
server_name: server?.server_name ?? "",
|
||||
alias: server?.alias ?? "",
|
||||
description: server?.description ?? "",
|
||||
url: server?.url ?? "",
|
||||
tools_text: (server?.allowed_tools ?? []).join("\n"),
|
||||
mcp_access_groups: server?.mcp_access_groups ?? [],
|
||||
allow_all_keys: server?.allow_all_keys ?? false,
|
||||
});
|
||||
|
||||
export const buildProviderRegistrationPayload = (values: ProviderRegistrationValues, server?: MCPServer) => {
|
||||
const allowedTools = toolNames(values.tools_text);
|
||||
const retainedOverrides = (overrides: Readonly<Record<string, string>> | undefined) =>
|
||||
Object.fromEntries(Object.entries(overrides ?? {}).filter(([name]) => allowedTools.includes(name)));
|
||||
return {
|
||||
...(server ? { server_id: server.server_id } : { execution_mode: "provider" as const }),
|
||||
server_name: values.server_name,
|
||||
alias: values.alias || null,
|
||||
description: values.description || null,
|
||||
url: values.url,
|
||||
allowed_tools: allowedTools,
|
||||
mcp_access_groups: values.mcp_access_groups,
|
||||
allow_all_keys: values.allow_all_keys,
|
||||
mcp_info: { ...server?.mcp_info, tool_allowlist_enforced: true },
|
||||
...(server
|
||||
? {
|
||||
tool_name_to_description: retainedOverrides(server.tool_name_to_description),
|
||||
tool_name_to_display_name: retainedOverrides(server.tool_name_to_display_name),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
|
@ -1116,3 +1116,73 @@ describe("MCPToolPermissions", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("selects stored provider tools and reflects registration edits without discovery", async () => {
|
||||
vi.clearAllMocks();
|
||||
testQueryClient.clear();
|
||||
vi.mocked(networking.fetchMCPAccessGroups).mockResolvedValue([]);
|
||||
vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([]);
|
||||
const provider = {
|
||||
server_id: "native",
|
||||
server_name: "Provider docs",
|
||||
execution_mode: "provider" as const,
|
||||
allowed_tools: ["search", "fetch"],
|
||||
tool_name_to_description: { search: "Search docs" },
|
||||
created_at: "",
|
||||
created_by: "",
|
||||
updated_at: "",
|
||||
updated_by: "",
|
||||
};
|
||||
vi.mocked(networking.fetchMCPServers).mockResolvedValue([provider]);
|
||||
const changed = vi.fn();
|
||||
renderWithProviders(
|
||||
<MCPToolPermissions
|
||||
accessToken="test"
|
||||
selectedServers={["native"]}
|
||||
toolPermissions={{ native: [] }}
|
||||
onChange={changed}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText("Provider docs");
|
||||
await userEvent.click(screen.getByRole("radio", { name: "Flat List" }));
|
||||
await userEvent.click(await screen.findByRole("checkbox", { name: "search" }));
|
||||
expect(changed).toHaveBeenCalledWith({ native: ["search"] }, "edit");
|
||||
expect(networking.listMCPTools).not.toHaveBeenCalled();
|
||||
vi.mocked(networking.fetchMCPServers).mockResolvedValue([{ ...provider, allowed_tools: ["fetch"] }]);
|
||||
await testQueryClient.invalidateQueries();
|
||||
await waitFor(() => expect(screen.queryByRole("checkbox", { name: "search" })).not.toBeInTheDocument());
|
||||
expect(screen.getByRole("checkbox", { name: "fetch" })).toBeInTheDocument();
|
||||
expect(networking.listMCPTools).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the delete-blocked default for every provider server that loads in the same pass", async () => {
|
||||
vi.clearAllMocks();
|
||||
testQueryClient.clear();
|
||||
vi.mocked(networking.fetchMCPAccessGroups).mockResolvedValue([]);
|
||||
vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([]);
|
||||
const provider = (server_id: string, allowed_tools: string[]) => ({
|
||||
server_id,
|
||||
server_name: server_id,
|
||||
execution_mode: "provider" as const,
|
||||
allowed_tools,
|
||||
created_at: "",
|
||||
created_by: "",
|
||||
updated_at: "",
|
||||
updated_by: "",
|
||||
});
|
||||
vi.mocked(networking.fetchMCPServers).mockResolvedValue([
|
||||
provider("docs", ["search", "delete"]),
|
||||
provider("files", ["read", "purge"]),
|
||||
]);
|
||||
const changed = vi.fn();
|
||||
renderWithProviders(
|
||||
<MCPToolPermissions
|
||||
accessToken="test"
|
||||
selectedServers={["docs", "files"]}
|
||||
toolPermissions={{}}
|
||||
onChange={changed}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(changed).toHaveBeenCalledTimes(2));
|
||||
expect(changed).toHaveBeenLastCalledWith({ docs: ["search"], files: ["read"] }, "default");
|
||||
});
|
||||
|
|
@ -2,14 +2,14 @@ import React, { useEffect, useRef, useState, useMemo } from "react";
|
|||
import type { TFunction } from "i18next";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { listMCPTools } from "../networking";
|
||||
import { MCPTool } from "../mcp_tools/types";
|
||||
import { providerToolChoices } from "../mcp_tools/providerTools";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { useMCPServers } from "../../app/(dashboard)/hooks/mcpServers/useMCPServers";
|
||||
import { useMCPAccessGroups } from "../../app/(dashboard)/hooks/mcpServers/useMCPAccessGroups";
|
||||
import { useMCPToolsets } from "../../app/(dashboard)/hooks/mcpServers/useMCPToolsets";
|
||||
import McpCrudPermissionPanel from "../mcp_tools/McpCrudPermissionPanel";
|
||||
import { classifyToolOp } from "../../utils/mcpToolCrudClassification";
|
||||
import { classifyToolOp, type MCPToolEntry } from "../../utils/mcpToolCrudClassification";
|
||||
import { MCP_ALL_TOOLS_WILDCARD, NO_MCP_SERVERS_SENTINEL } from "../mcp_tools/constants";
|
||||
import {
|
||||
EffectiveMcpServer,
|
||||
|
|
@ -83,7 +83,18 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
|
|||
} = useMCPServers();
|
||||
const { data: populatedAccessGroups = [], isSuccess: accessGroupsLoaded } = useMCPAccessGroups();
|
||||
const { data: toolsets = [], isError: toolsetsFailed, isLoading: toolsetsLoading } = useMCPToolsets();
|
||||
const [serverTools, setServerTools] = useState<Record<string, MCPTool[]>>({});
|
||||
const [fetchedServerTools, setServerTools] = useState<Record<string, MCPToolEntry[]>>({});
|
||||
const serverTools = useMemo(
|
||||
() => ({
|
||||
...fetchedServerTools,
|
||||
...Object.fromEntries(
|
||||
allServers
|
||||
.filter((server) => server.execution_mode === "provider")
|
||||
.map((server) => [server.server_id, providerToolChoices(server)]),
|
||||
),
|
||||
}),
|
||||
[allServers, fetchedServerTools],
|
||||
);
|
||||
const [loadingTools, setLoadingTools] = useState<Record<string, boolean>>({});
|
||||
const [toolErrors, setToolErrors] = useState<Record<string, string>>({});
|
||||
const [viewModes, setViewModes] = useState<Record<string, "crud" | "flat">>({});
|
||||
|
|
@ -129,7 +140,10 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
|
|||
setToolErrors((prev) => ({ ...prev, [serverId]: "" }));
|
||||
|
||||
try {
|
||||
const response = await listMCPTools(token, serverId);
|
||||
const response =
|
||||
entry.server.execution_mode === "provider"
|
||||
? { tools: providerToolChoices(entry.server), error: null }
|
||||
: await listMCPTools(token, serverId);
|
||||
|
||||
if (response.error) {
|
||||
setToolErrors((prev) => ({
|
||||
|
|
@ -138,7 +152,7 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
|
|||
}));
|
||||
setServerTools((prev) => ({ ...prev, [serverId]: [] }));
|
||||
} else {
|
||||
const fetchedTools: MCPTool[] = response.tools || [];
|
||||
const fetchedTools: MCPToolEntry[] = response.tools || [];
|
||||
setServerTools((prev) => ({ ...prev, [serverId]: fetchedTools }));
|
||||
|
||||
// Default only unrestricted direct servers to non-delete tools.
|
||||
|
|
@ -158,10 +172,11 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
|
|||
const nonDeleteTools = fetchedTools
|
||||
.filter((t) => classifyToolOp(t.name, t.description || "") !== "delete")
|
||||
.map((t) => t.name);
|
||||
onChange(
|
||||
applyToolPermissionWrite({ toolPermissions: latestPermissions, entry, allowed: nonDeleteTools }),
|
||||
"default",
|
||||
);
|
||||
const next = applyToolPermissionWrite({ toolPermissions: latestPermissions, entry, allowed: nonDeleteTools });
|
||||
// Provider servers resolve synchronously in one pass, before the parent re-renders,
|
||||
// so advance the ref here or the next server's default overwrites this one.
|
||||
toolPermissionsRef.current = next;
|
||||
onChange(next, "default");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -178,7 +193,7 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
|
|||
if (toolsetsLoading) return;
|
||||
servers.forEach((entry) => {
|
||||
const serverId = entry.server.server_id;
|
||||
if (!serverTools[serverId] && !loadingTools[serverId]) {
|
||||
if (!fetchedServerTools[serverId] && !loadingTools[serverId]) {
|
||||
fetchToolsForServer(entry, accessToken);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
import type { MCPServer } from "./types";
|
||||
|
||||
export const providerToolChoices = (server: MCPServer): { name: string; description: string }[] =>
|
||||
(server.allowed_tools ?? []).map((name) => ({ name, description: server.tool_name_to_description?.[name] ?? "" }));
|
||||
|
|
@ -428,6 +428,7 @@ export const MCP_REACHABLE_DESCRIPTION: ParseKeys = "mcpTools.reachableDescripti
|
|||
|
||||
export interface MCPServer {
|
||||
server_id: string;
|
||||
execution_mode?: "litellm" | "provider";
|
||||
is_config?: boolean;
|
||||
server_name?: string | null;
|
||||
alias?: string | null;
|
||||
|
|
|
|||
|
|
@ -13225,7 +13225,8 @@
|
|||
"noTools": "No tools found for this server.",
|
||||
"selectMatching": "Select matching tools on {{serverName}}",
|
||||
"selectAll": "Select all tools on {{serverName}}",
|
||||
"selectedCount": "{{count}} selected"
|
||||
"selectedCount": "{{count}} selected",
|
||||
"providerPermissionOnly": "provider · permission only"
|
||||
},
|
||||
"modal": {
|
||||
"editTitle": "Edit Toolset",
|
||||
|
|
@ -13261,7 +13262,7 @@
|
|||
},
|
||||
"usageGuide": {
|
||||
"title": "How toolsets work",
|
||||
"body": "Create a toolset, assign it to a key via <menu>API Keys → Edit Key → MCP Servers</menu>, then point your MCP client at the toolset URL. The client only sees the tools you picked.",
|
||||
"body": "Create a toolset, assign it to a key via <menu>API Keys → Edit Key → MCP Servers</menu>, then point your MCP client at the toolset URL. The client only sees the tools you picked. Tools from provider-native registrations are permission grants for provider-native requests only and are not served at the toolset URL.",
|
||||
"configLabel": "Claude Code / Cursor config",
|
||||
"copy": "copy"
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue