This commit is contained in:
mrinal-berri 2026-11-07 05:50:57 +00:00 • committed by GitHub
commit 8da95331a5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 989 additions and 120 deletions

View file

@ -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);
});
});

View file

@ -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) },
];
}),
),
});
};

View file

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

View file

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

View file

@ -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();
});

View file

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

View file

@ -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();
});
});

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

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

View file

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

View file

@ -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);
});
});

View file

@ -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),
}
: {}),
};
};

View file

@ -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");
});

View file

@ -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);
}
});

View file

@ -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] ?? "" }));

View file

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

View file

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