feat(ui): search and multi-select tools in the MCP toolset editor (#48601)

* feat(ui): add per-server MCP tool catalog query hook

One query per server keyed by server_id, so the toolset editor can load
tools for many servers in parallel and a failing server surfaces as an
error on its own row instead of an empty list. (LIT-10012)

* feat(ui): add toolset tool search and bulk selection helpers

Pure functions for the toolset editor: filter a server's tools by a
search query, select/deselect many tools without duplicates or dropping
other selections, and compute tri-state selection. (LIT-10012)

* feat(ui): search tools and bulk select in the toolset editor

The toolset editor search now matches tool names and descriptions as
well as server names. Typing fetches every server's tools in parallel
through the per-server query hook, auto-expands matching servers and
hides servers that loaded with no match. Tool rows become checkboxes,
each server header gets a tri-state select-all over its visible tools,
and a bar offers select/deselect all matching tools. Bulk actions only
touch visible tools, so selections on other servers are kept on save.
(LIT-10012)

* feat(ui): clear all and flag unconfirmed tools in the toolset panel

Adds a Clear all action to the Your Toolset panel and labels selected
tools whose server is gone, unreachable, or no longer lists the tool.
Those tools stay selected and are still saved, so an outage never
silently drops part of a toolset. (LIT-10012)

* test(ui): cover toolset editor tool search and bulk selection

Integration tests that drive the real toolset modal against stubbed
networking and assert the saved tools payload: cross-server search by
tool name and description, select/deselect all matching, server
select-all, single-row toggles, Clear all, and that tools on unreachable
or deleted servers survive a save. (LIT-10012)

* fix(ui): pointer cursor on the toolset server select-all checkbox

Base UI renders the checkbox as a span, which the global button cursor
rule does not cover, so the server header select-all showed the default
arrow. Use a pointer, and not-allowed while it is disabled. (LIT-10012)

* fix(ui): stop toolset editor background fan-out and keep tools on failed refetch

The toolset modal stays mounted when closed, so a finished search kept
every server's tool query enabled and refocusing the window refetched
all of them once stale. Fetch only while the editor is open, require
both the live and debounced search before fanning out, and disable
refetch on window focus for these queries. A failed refetch now keeps
the tools that already loaded instead of flagging the server
unreachable, and tools without a name are dropped. Trim comments that
restated the code. (LIT-10012)

* fix(ui): clear toolset search on close and only keep non-empty tool lists on failed refetch

Reopening the editor after a search rendered once with the old query,
which fetched every stale server; clear the search whenever the editor
closes. A failed refetch now keeps the previous list only when it has
tools, so a server that loaded none and then fails reports the error.
(LIT-10012)

* fix(ui): reset toolset editor state on close and avoid mutating selection sets

Closing the editor now also clears expanded servers, so reopening it cannot
refetch them for one render. Build the selection and expanded-server sets
without in-place mutation, drop a comment that restated the code, and use
screen queries in the new tests to stay within the prefer-screen-queries
lint budget. (LIT-10012)
This commit is contained in:
mrinal-berri 2026-10-28 15:35:33 -07:00 • committed by GitHub
parent 0b57b9976a
commit cf1006a368
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 890 additions and 176 deletions

View file

@ -498,9 +498,6 @@
}
},
"src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx": {
"no-nested-ternary": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}

View file

@ -0,0 +1,118 @@
/* @vitest-environment jsdom */
import React from "react";
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 * as networking from "@/components/networking";
vi.mock("@/components/networking", () => ({
listMCPTools: vi.fn(),
}));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: vi.fn(() => ({ accessToken: "test-token-123" })),
}));
const wrapper = ({ children }: { children: React.ReactNode }) => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
return React.createElement(QueryClientProvider, { client: queryClient }, children);
};
const toolsFor = (serverId: string) => ({
tools: [{ name: `${serverId}_tool`, description: `from ${serverId}` }],
error: null,
});
describe("useMCPServerTools", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("fetches only the enabled servers and leaves the rest idle", async () => {
vi.mocked(networking.listMCPTools).mockImplementation(async (_token, serverId) => toolsFor(serverId));
const { result } = renderHook(() => useMCPServerTools(["a", "b"], new Set(["a"])), { wrapper });
await waitFor(() => expect(result.current.get("a")?.tools).toEqual([{ name: "a_tool", description: "from a" }]));
expect(result.current.get("b")).toEqual({ tools: [], status: "idle" });
expect(networking.listMCPTools).toHaveBeenCalledTimes(1);
expect(networking.listMCPTools).toHaveBeenCalledWith("test-token-123", "a");
});
it("isolates a failing server from the others", async () => {
vi.mocked(networking.listMCPTools).mockImplementation(async (_token, serverId) =>
serverId === "bad" ? { tools: [], error: "http_500", message: "upstream down" } : toolsFor(serverId),
);
const { result } = renderHook(() => useMCPServerTools(["good", "bad"], new Set(["good", "bad"])), { wrapper });
await waitFor(() => expect(result.current.get("bad")).toEqual({ tools: [], status: "error" }));
expect(result.current.get("good")).toEqual({
tools: [{ name: "good_tool", description: "from good" }],
status: "success",
});
});
it("reports a server that is being fetched as loading", () => {
vi.mocked(networking.listMCPTools).mockReturnValue(new Promise(() => {}));
const { result } = renderHook(() => useMCPServerTools(["a"], new Set(["a"])), { wrapper });
expect(result.current.get("a")).toEqual({ tools: [], status: "loading" });
});
it("normalizes a bare array response, tools without descriptions, and drops tools without a name", async () => {
vi.mocked(networking.listMCPTools).mockResolvedValue([
{ tool_name: "legacy" },
"plain",
{ description: "nameless" },
]);
const { result } = renderHook(() => useMCPServerTools(["a"], new Set(["a"])), { wrapper });
await waitFor(() =>
expect(result.current.get("a")?.tools).toEqual([
{ name: "legacy", description: "" },
{ name: "plain", description: "" },
]),
);
});
it("reports an error when a refetch fails for a server that had loaded no tools", async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const sharedWrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
vi.mocked(networking.listMCPTools)
.mockResolvedValueOnce({ tools: [], error: null })
.mockResolvedValueOnce({ tools: [], error: "http_502", message: "upstream down" });
const { result } = renderHook(() => useMCPServerTools(["a"], new Set(["a"])), { wrapper: sharedWrapper });
await waitFor(() => expect(result.current.get("a")?.status).toBe("success"));
await act(() => queryClient.refetchQueries({ queryKey: mcpServerToolKeys.detail("a") }));
await waitFor(() => expect(result.current.get("a")).toEqual({ tools: [], status: "error" }));
});
it("keeps showing the loaded tools when a later refetch fails", async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const sharedWrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
vi.mocked(networking.listMCPTools)
.mockResolvedValueOnce(toolsFor("a"))
.mockResolvedValueOnce({ tools: [], error: "http_502", message: "upstream down" });
const { result } = renderHook(() => useMCPServerTools(["a"], new Set(["a"])), { wrapper: sharedWrapper });
await waitFor(() => expect(result.current.get("a")?.status).toBe("success"));
await act(() => queryClient.refetchQueries({ queryKey: mcpServerToolKeys.detail("a") }));
await waitFor(() => expect(queryClient.getQueryState(mcpServerToolKeys.detail("a"))?.status).toBe("error"));
expect(networking.listMCPTools).toHaveBeenCalledTimes(2);
expect(result.current.get("a")).toEqual({
tools: [{ name: "a_tool", description: "from a" }],
status: "success",
});
});
});

View file

@ -0,0 +1,70 @@
import { useQueries, type QueryObserverResult } from "@tanstack/react-query";
import { createQueryKeys } from "../common/queryKeysFactory";
import { listMCPTools } from "@/components/networking";
import useAuthorized from "../useAuthorized";
export const mcpServerToolKeys = createQueryKeys("mcpServerTools");
export interface MCPServerToolEntry {
name: string;
description: string;
}
export type MCPServerToolsStatus = "idle" | "loading" | "error" | "success";
export interface MCPServerToolsState {
tools: MCPServerToolEntry[];
status: MCPServerToolsStatus;
}
type RawTool = string | { name?: string; tool_name?: string; description?: string };
const toEntry = (tool: RawTool): MCPServerToolEntry =>
typeof tool === "string"
? { name: tool, description: "" }
: { name: tool.name ?? tool.tool_name ?? "", description: tool.description ?? "" };
// listMCPTools never throws: it reports failures as `{ tools: [], error }`.
// Throw here so a broken server surfaces as a query error instead of an empty tool list.
export const fetchServerTools = async (accessToken: string, serverId: string): Promise<MCPServerToolEntry[]> => {
const result = await listMCPTools(accessToken, serverId);
if (!Array.isArray(result) && result?.error) {
throw new Error(result.message || result.error);
}
const toolList: RawTool[] = Array.isArray(result) ? result : result?.tools ?? [];
return toolList.map(toEntry).filter((tool) => tool.name !== "");
};
const toStatus = ({
status,
fetchStatus,
data,
}: Pick<QueryObserverResult<MCPServerToolEntry[]>, "status" | "fetchStatus" | "data">): MCPServerToolsStatus => {
if (status === "error" && data !== undefined && data.length > 0) return "success";
if (status !== "pending") return status;
return fetchStatus === "idle" ? "idle" : "loading";
};
export const useMCPServerTools = (
serverIds: readonly string[],
enabledServerIds: ReadonlySet<string>,
): ReadonlyMap<string, MCPServerToolsState> => {
const { accessToken } = useAuthorized();
return useQueries({
queries: serverIds.map((serverId) => ({
queryKey: mcpServerToolKeys.detail(serverId),
queryFn: () => fetchServerTools(accessToken!, serverId),
enabled: !!accessToken && enabledServerIds.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) },
]),
),
});
};

View file

@ -1,5 +1,5 @@
import React from "react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@ -7,6 +7,7 @@ import { MCPToolsetsTab } from "./MCPToolsetsTab";
import * as networking from "@/components/networking";
import { useMCPToolsets } from "@/app/(dashboard)/hooks/mcpServers/useMCPToolsets";
import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
import { mcpServerToolKeys } from "@/app/(dashboard)/hooks/mcpServers/useMCPServerTools";
import { MCPToolset } from "@/components/mcp_tools/types";
vi.mock("@/components/networking", () => ({
@ -20,6 +21,7 @@ vi.mock("@/components/networking", () => ({
vi.mock("@/app/(dashboard)/hooks/mcpServers/useMCPToolsets", () => ({ useMCPToolsets: vi.fn() }));
vi.mock("@/app/(dashboard)/hooks/mcpServers/useMCPServers", () => ({ useMCPServers: vi.fn() }));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => ({ accessToken: "sk-test" }) }));
const setup = () => userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
@ -197,6 +199,221 @@ describe("MCPToolsetsTab create/edit toolset form", () => {
});
});
describe("MCPToolsetsTab tool search and bulk selection", () => {
const servers = [
{ server_id: "gh", alias: "github", server_name: "GitHub" },
{ server_id: "lin", alias: "linear", server_name: "Linear" },
{ server_id: "broken", alias: "flaky", server_name: "Flaky" },
];
const catalogs: Record<string, { name: string; description: string }[]> = {
gh: [
{ name: "create_issue", description: "Open a GitHub issue" },
{ name: "list_pulls", description: "List pull requests" },
],
lin: [
{ name: "create_ticket", description: "Create a Linear issue" },
{ name: "list_projects", description: "List projects" },
],
};
const renderWithServers = (toolsets: MCPToolset[] = []) => {
vi.mocked(useMCPToolsets).mockReturnValue({
data: toolsets,
isLoading: false,
} as unknown as ReturnType<typeof useMCPToolsets>);
vi.mocked(useMCPServers).mockReturnValue({ data: servers, isSuccess: true } as unknown as ReturnType<
typeof useMCPServers
>);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
render(
<QueryClientProvider client={queryClient}>
<MCPToolsetsTab accessToken="sk-test" userRole="Admin" />
</QueryClientProvider>,
);
return queryClient;
};
const editToolset = (tools: MCPToolset["tools"]) =>
({ toolset_id: "ts-1", toolset_name: "triage", description: "", tools }) as MCPToolset;
const expectedUpdate = (tools: MCPToolset["tools"]) => ({
toolset_id: "ts-1",
toolset_name: "triage",
description: "",
tools,
});
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(networking.listMCPTools).mockImplementation(async (_token, serverId) =>
serverId in catalogs
? { tools: catalogs[serverId], error: null }
: { tools: [], error: "http_502", message: "upstream down" },
);
vi.mocked(networking.createMCPToolset).mockResolvedValue(
{} as Awaited<ReturnType<typeof networking.createMCPToolset>>,
);
vi.mocked(networking.updateMCPToolset).mockResolvedValue(
{} as Awaited<ReturnType<typeof networking.updateMCPToolset>>,
);
});
it("searches tool names and descriptions across collapsed servers and selects every match", async () => {
const user = setup();
renderWithServers();
await openCreate(user);
fireEvent.change(screen.getByPlaceholderText("e.g. github-linear-tools"), { target: { value: "issues" } });
await user.type(screen.getByRole("textbox", { name: "Search tools or servers" }), "issue");
expect(await screen.findByRole("checkbox", { name: /^create_ticket/ })).toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: /^create_issue/ })).toBeInTheDocument();
expect(screen.queryByRole("checkbox", { name: /^list_pulls/ })).not.toBeInTheDocument();
expect(await screen.findByText("2 matching tools")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Select all matching" }));
await user.click(screen.getByRole("button", { name: "Create Toolset" }));
await waitFor(() => {
expect(networking.createMCPToolset).toHaveBeenCalledWith("sk-test", {
toolset_name: "issues",
description: "",
tools: [
{ server_id: "gh", tool_name: "create_issue" },
{ server_id: "lin", tool_name: "create_ticket" },
],
});
});
});
it("deselecting all matches keeps selected tools the search hides", async () => {
const user = setup();
renderWithServers([
editToolset([
{ server_id: "gh", tool_name: "list_pulls" },
{ server_id: "gh", tool_name: "create_issue" },
{ server_id: "lin", tool_name: "create_ticket" },
]),
]);
await openEditFor(user);
await user.type(screen.getByRole("textbox", { name: "Search tools or servers" }), "create");
expect(await screen.findByRole("checkbox", { name: /^create_ticket/ })).toBeChecked();
await user.click(screen.getByRole("button", { name: "Deselect all matching" }));
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(networking.updateMCPToolset).toHaveBeenCalledWith(
"sk-test",
expectedUpdate([{ server_id: "gh", tool_name: "list_pulls" }]),
);
});
});
it("selects all of one server's tools from its header and toggles single rows, fetching only that server", async () => {
const user = setup();
renderWithServers();
await openCreate(user);
fireEvent.change(screen.getByPlaceholderText("e.g. github-linear-tools"), { target: { value: "gh-only" } });
await user.click(screen.getByRole("button", { name: /^github/ }));
expect(await screen.findByRole("checkbox", { name: /^list_pulls/ })).toBeInTheDocument();
await user.click(screen.getByRole("checkbox", { name: "Select all tools on github" }));
expect(screen.getByRole("checkbox", { name: /^list_pulls/ })).toBeChecked();
// Clicking anywhere on a tool row toggles it exactly once.
await user.click(screen.getByText("List pull requests"));
expect(screen.getByRole("checkbox", { name: /^list_pulls/ })).not.toBeChecked();
await user.click(screen.getByRole("button", { name: "Create Toolset" }));
await waitFor(() => {
expect(networking.createMCPToolset).toHaveBeenCalledWith("sk-test", {
toolset_name: "gh-only",
description: "",
tools: [{ server_id: "gh", tool_name: "create_issue" }],
});
});
expect(networking.listMCPTools).toHaveBeenCalledTimes(1);
});
it("keeps and flags tools whose server is unreachable or gone when saving", async () => {
const user = setup();
const tools = [
{ server_id: "broken", tool_name: "flaky_tool" },
{ server_id: "deleted", tool_name: "old_tool" },
{ server_id: "gh", tool_name: "create_issue" },
];
renderWithServers([editToolset(tools)]);
await openEditFor(user);
await user.type(screen.getByRole("textbox", { name: "Search tools or servers" }), "create");
expect(await screen.findByText("Couldn't load tools for this server.")).toBeInTheDocument();
expect(await screen.findByText("server unreachable")).toBeInTheDocument();
expect(screen.getByText("server not found")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(networking.updateMCPToolset).toHaveBeenCalledWith("sk-test", expectedUpdate(tools));
});
});
it("stops fetching server tools once the editor is closed after a search", async () => {
const user = setup();
const queryClient = renderWithServers();
await openCreate(user);
await user.type(screen.getByRole("textbox", { name: "Search tools or servers" }), "issue");
expect(await screen.findByRole("checkbox", { name: /^create_ticket/ })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
const callsWhileOpen = vi.mocked(networking.listMCPTools).mock.calls.length;
await act(() => queryClient.invalidateQueries({ queryKey: mcpServerToolKeys.all }));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(networking.listMCPTools).toHaveBeenCalledTimes(callsWhileOpen);
await openCreate(user);
expect(screen.getByRole("textbox", { name: "Search tools or servers" })).toHaveValue("");
await new Promise((resolve) => setTimeout(resolve, 50));
expect(networking.listMCPTools).toHaveBeenCalledTimes(callsWhileOpen);
});
it("does not refetch a previously expanded server when the editor is reopened", async () => {
const user = setup();
const queryClient = renderWithServers();
await openCreate(user);
await user.click(screen.getByRole("button", { name: /^github/ }));
expect(await screen.findByRole("checkbox", { name: /^list_pulls/ })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
await act(() => queryClient.invalidateQueries({ queryKey: mcpServerToolKeys.all }));
await openCreate(user);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(networking.listMCPTools).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("checkbox", { name: /^list_pulls/ })).not.toBeInTheDocument();
});
it("clears the whole toolset with Clear all", async () => {
const user = setup();
renderWithServers([editToolset([{ server_id: "gh", tool_name: "create_issue" }])]);
await openEditFor(user);
await user.click(screen.getByRole("button", { name: "Clear all" }));
expect(screen.getByText("No tools added yet")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(networking.updateMCPToolset).toHaveBeenCalledWith("sk-test", expectedUpdate([]));
});
});
});
describe("MCPToolsetsTab usage guide", () => {
it("shows the toolset URL on the gateway host", () => {
renderTab();

View file

@ -1,28 +1,39 @@
import React, { useState, useCallback } from "react";
import React, { useState } from "react";
import { z } from "zod";
import { SortingState } from "@tanstack/react-table";
import { Inbox, Plus, X } from "lucide-react";
import { useMCPToolsets } from "@/app/(dashboard)/hooks/mcpServers/useMCPToolsets";
import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
import { useQueryClient } from "@tanstack/react-query";
import { DataTable } from "@/components/shared/DataTable";
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
import {
createMCPToolset,
updateMCPToolset,
deleteMCPToolset,
listMCPTools,
getGatewayBaseUrl,
} from "@/components/networking";
import { MCPToolset, MCPToolsetTool } from "@/components/mcp_tools/types";
useMCPServerTools,
type MCPServerToolEntry,
type MCPServerToolsState,
} from "@/app/(dashboard)/hooks/mcpServers/useMCPServerTools";
import { DataTable } from "@/components/shared/DataTable";
import { createMCPToolset, updateMCPToolset, deleteMCPToolset, getGatewayBaseUrl } from "@/components/networking";
import { MCPServer, MCPToolset, MCPToolsetTool } from "@/components/mcp_tools/types";
import { FieldGroup } from "@/components/ui/field";
import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { useZodForm } from "@/lib/forms/useZodForm";
import { toast } from "@/lib/toast";
import { displayToolName, getMCPToolsetTableColumns } from "./MCPToolsetTableColumns";
import {
deselectTools,
filterServerTools,
isToolSelected,
matchesServerName,
selectTools,
selectionState,
toggleTool,
type SelectionState,
} from "./toolsetSelection";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
interface MCPToolsetsTabProps {
@ -37,110 +48,191 @@ const toolsetSchema = z.object({
type ToolsetFormValues = z.infer<typeof toolsetSchema>;
interface MCPToolListProps {
const SEARCH_DEBOUNCE_WAIT_MS = 300;
const IDLE_SERVER_TOOLS: MCPServerToolsState = { tools: [], status: "idle" };
const NO_SERVERS: ReadonlySet<string> = new Set();
// The modal stays mounted while closed, so fetch only while it is open; otherwise a finished
// search keeps every server's query live and refetches all of them in the background.
const serversToFetch = (
open: boolean,
searching: boolean,
serverIds: readonly string[],
expandedServerIds: ReadonlySet<string>,
): ReadonlySet<string> => {
if (!open) return NO_SERVERS;
return searching ? new Set(serverIds) : expandedServerIds;
};
const checkboxStateProps = (state: SelectionState) => ({
checked: state === "all",
indeterminate: state === "some",
});
interface ServerView {
server: MCPServer;
serverName: string;
state: MCPServerToolsState;
visibleTools: readonly MCPServerToolEntry[];
}
const isPending = (state: MCPServerToolsState) => state.status === "idle" || state.status === "loading";
// While searching, a server stays listed until its tools load and turn out not to match.
const isHiddenBySearch = (
search: string,
serverNames: readonly string[],
state: MCPServerToolsState,
visibleTools: readonly MCPServerToolEntry[],
) => {
if (!search.trim() || state.status !== "success") return false;
return visibleTools.length === 0 && !matchesServerName(search, serverNames);
};
const buildServerViews = (
servers: readonly MCPServer[],
toolsByServer: ReadonlyMap<string, MCPServerToolsState>,
search: string,
): ServerView[] =>
servers.flatMap((server) => {
const state = toolsByServer.get(server.server_id) ?? IDLE_SERVER_TOOLS;
const names = [server.alias ?? "", server.server_name ?? ""];
const visibleTools = filterServerTools(search, names, state.tools);
if (isHiddenBySearch(search, names, state, visibleTools)) return [];
return [{ server, serverName: server.alias || server.server_name || server.server_id, state, visibleTools }];
});
const summarizeSearch = (serverViews: readonly ServerView[]) => ({
matchingTools: serverViews.flatMap(({ server, visibleTools }) =>
visibleTools.map((tool) => ({ server_id: server.server_id, tool_name: tool.name })),
),
pendingServerCount: serverViews.filter(({ state }) => isPending(state)).length,
});
// Why a selected tool cannot be confirmed against its server. The tool stays selected and is
// still saved; the note only keeps it from looking lost.
const selectedToolIssue = (
tool: MCPToolsetTool,
serverNameById: ReadonlyMap<string, string>,
toolsByServer: ReadonlyMap<string, MCPServerToolsState>,
): string | undefined => {
if (!serverNameById.has(tool.server_id)) return "server not found";
const state = toolsByServer.get(tool.server_id);
if (state?.status === "error") return "server unreachable";
if (state?.status === "success" && !state.tools.some((t) => t.name === tool.tool_name)) return "tool not found";
return undefined;
};
interface ServerToolGroupProps {
serverId: string;
serverName: string;
accessToken: string | null;
state: MCPServerToolsState;
visibleTools: readonly MCPServerToolEntry[];
expanded: boolean;
searchActive: boolean;
selectedTools: MCPToolsetTool[];
onToggle: (tool: MCPToolsetTool) => void;
onToggleExpanded: () => void;
onToggleTool: (tool: MCPToolsetTool) => void;
onSetToolsSelected: (tools: MCPToolsetTool[], selected: boolean) => void;
}
interface ToolEntry {
name: string;
description?: string;
}
function ServerToolGroup({
serverId,
serverName,
state,
visibleTools,
expanded,
searchActive,
selectedTools,
onToggleExpanded,
onToggleTool,
onSetToolsSelected,
}: ServerToolGroupProps) {
const selectedCount = selectedTools.filter((t) => t.server_id === serverId).length;
const visibleRefs = visibleTools.map((tool) => ({ server_id: serverId, tool_name: tool.name }));
const groupState = selectionState(selectedTools, visibleRefs);
function MCPToolList({ serverId, serverName, accessToken, selectedTools, onToggle }: MCPToolListProps) {
const [tools, setTools] = useState<ToolEntry[]>([]);
const [loading, setLoading] = useState(false);
const [expanded, setExpanded] = useState(false);
const selectedSet = new Set(selectedTools.filter((t) => t.server_id === serverId).map((t) => t.tool_name));
const fetchTools = useCallback(async () => {
if (!accessToken || tools.length > 0) return;
setLoading(true);
try {
const result = await listMCPTools(accessToken, serverId);
const toolList = Array.isArray(result) ? result : result?.tools ?? [];
setTools(toolList.map((t: any) => ({ name: t.name ?? t.tool_name ?? t, description: t.description ?? "" })));
} catch {
setTools([]);
} finally {
setLoading(false);
const renderBody = () => {
if (isPending(state)) {
return (
<div className="flex justify-center py-3">
<UiLoadingSpinner className="size-4" />
</div>
);
}
}, [accessToken, serverId, tools.length]);
const handleToggle = () => {
if (!expanded) fetchTools();
setExpanded(!expanded);
if (state.status === "error") {
return <p className="text-xs text-destructive px-2 py-2">Couldn&apos;t load tools for this server.</p>;
}
if (visibleTools.length === 0) {
return (
<p className="text-xs text-muted-foreground px-2 py-2">
{searchActive ? "No matching tools on this server." : "No tools found for this server."}
</p>
);
}
return (
<div className="flex flex-col gap-1">
{visibleRefs.map((ref, idx) => {
const tool = visibleTools[idx];
const selected = isToolSelected(selectedTools, ref);
return (
<label
key={tool.name}
className={`flex cursor-pointer items-start gap-3 px-3 py-2 rounded-lg transition-colors ${
selected
? "bg-purple-50 border border-purple-300 dark:bg-purple-950 dark:border-purple-700"
: "bg-card border border-border hover:bg-muted"
}`}
>
<Checkbox checked={selected} onCheckedChange={() => onToggleTool(ref)} className="mt-0.5" />
<div className="min-w-0 flex-1">
<p
className={`text-sm font-medium leading-tight ${selected ? "text-purple-800 dark:text-purple-200" : "text-foreground"}`}
>
{tool.name}
</p>
{tool.description && (
<p className="text-xs text-muted-foreground mt-0.5 leading-tight line-clamp-2">{tool.description}</p>
)}
</div>
</label>
);
})}
</div>
);
};
return (
<div className="border border-border rounded-lg overflow-hidden">
<button
type="button"
className="w-full flex items-center justify-between px-4 py-3 bg-muted hover:bg-accent transition-colors"
onClick={handleToggle}
>
<span className="text-sm font-medium text-foreground flex items-center gap-2">
<span className="inline-block w-2 h-2 rounded-full bg-info shrink-0" />
{serverName}
{selectedSet.size > 0 && (
<span className="ml-1 text-xs text-purple-600 font-semibold dark:text-purple-400">
{selectedSet.size} selected
</span>
)}
</span>
<span className="text-muted-foreground text-xs">{expanded ? "▲" : "▼"}</span>
</button>
{expanded && (
<div className="p-2">
{loading ? (
<div className="flex justify-center py-3">
<UiLoadingSpinner className="size-4" />
</div>
) : tools.length === 0 ? (
<p className="text-xs text-muted-foreground px-2 py-2">No tools found for this server.</p>
) : (
<div className="flex flex-col gap-1">
{tools.map((tool) => {
const selected = selectedSet.has(tool.name);
return (
<button
key={tool.name}
type="button"
onClick={() => onToggle({ server_id: serverId, tool_name: tool.name })}
className={`flex items-start justify-between px-3 py-2 rounded-lg text-left transition-colors ${
selected
? "bg-purple-50 border border-purple-300 dark:bg-purple-950 dark:border-purple-700"
: "bg-card border border-border hover:bg-muted"
}`}
>
<div className="min-w-0 flex-1">
<p
className={`text-sm font-medium leading-tight ${selected ? "text-purple-800 dark:text-purple-200" : "text-foreground"}`}
>
{tool.name}
</p>
{tool.description && (
<p className="text-xs text-muted-foreground mt-0.5 leading-tight line-clamp-2">
{tool.description}
</p>
)}
</div>
{selected && (
<span className="text-purple-500 text-xs font-semibold ml-2 shrink-0 mt-0.5 dark:text-purple-400">
✓
</span>
)}
</button>
);
})}
</div>
)}
</div>
)}
<div className="flex items-center gap-3 pl-4 bg-muted">
<Checkbox
{...checkboxStateProps(groupState)}
disabled={visibleRefs.length === 0}
// Base UI renders a span, which the global button cursor rule does not cover.
className="cursor-pointer data-disabled:cursor-not-allowed"
aria-label={`${searchActive ? "Select matching tools" : "Select all tools"} on ${serverName}`}
onCheckedChange={(checked) => onSetToolsSelected(visibleRefs, checked)}
/>
<button
type="button"
aria-expanded={expanded}
className="flex-1 flex items-center justify-between pr-4 py-3 hover:bg-accent transition-colors"
onClick={onToggleExpanded}
>
<span className="text-sm font-medium text-foreground flex items-center gap-2">
{serverName}
{selectedCount > 0 && (
<span className="ml-1 text-xs text-purple-600 font-semibold dark:text-purple-400">
{selectedCount} selected
</span>
)}
</span>
<span className="text-muted-foreground text-xs">{expanded ? "▲" : "▼"}</span>
</button>
</div>
{expanded && <div className="p-2">{renderBody()}</div>}
</div>
);
}
@ -149,21 +241,29 @@ interface CreateToolsetModalProps {
open: boolean;
onClose: () => void;
onSave: (name: string, description: string | undefined, tools: MCPToolsetTool[]) => Promise<void>;
accessToken: string | null;
initialToolset?: MCPToolset;
}
function CreateToolsetModal({ open, onClose, onSave, accessToken, initialToolset }: CreateToolsetModalProps) {
const form = useZodForm(toolsetSchema, {
defaultValues: {
toolset_name: initialToolset?.toolset_name || "",
description: initialToolset?.description || "",
},
});
const toolsetFormDefaults = (toolset?: MCPToolset): ToolsetFormValues => ({
toolset_name: toolset?.toolset_name || "",
description: toolset?.description || "",
});
function CreateToolsetModal({ open, onClose, onSave, initialToolset }: CreateToolsetModalProps) {
const form = useZodForm(toolsetSchema, { defaultValues: toolsetFormDefaults(initialToolset) });
const [selectedTools, setSelectedTools] = useState<MCPToolsetTool[]>(initialToolset?.tools || []);
const [saving, setSaving] = useState(false);
const [serverSearch, setServerSearch] = useState("");
const { data: mcpServers = [] } = useMCPServers();
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, { wait: SEARCH_DEBOUNCE_WAIT_MS });
const [expandedServerIds, setExpandedServerIds] = useState<ReadonlySet<string>>(() => new Set());
const { data: mcpServers = [], isSuccess: serversLoaded } = useMCPServers();
const serverIds = React.useMemo(() => mcpServers.map((s) => s.server_id), [mcpServers]);
const searching = search.trim() !== "" && debouncedSearch.trim() !== "";
const fetchServerIds = React.useMemo(
() => serversToFetch(open, searching, serverIds, expandedServerIds),
[open, searching, serverIds, expandedServerIds],
);
const toolsByServer = useMCPServerTools(serverIds, fetchServerIds);
const serverPrefixById = React.useMemo(
() => new Map(mcpServers.map((s) => [s.server_id, s.alias || s.server_name || s.server_id])),
[mcpServers],
@ -171,41 +271,54 @@ function CreateToolsetModal({ open, onClose, onSave, accessToken, initialToolset
React.useEffect(() => {
if (open) {
form.reset({
toolset_name: initialToolset?.toolset_name || "",
description: initialToolset?.description || "",
});
form.reset(toolsetFormDefaults(initialToolset));
setSelectedTools(initialToolset?.tools || []);
setServerSearch("");
setSearch("");
setExpandedServerIds(new Set());
}
}, [open, initialToolset, form]);
const handleToggleTool = (tool: MCPToolsetTool) => {
setSelectedTools((prev) => {
const exists = prev.some((t) => t.server_id === tool.server_id && t.tool_name === tool.tool_name);
return exists
? prev.filter((t) => !(t.server_id === tool.server_id && t.tool_name === tool.tool_name))
: [...prev, tool];
});
setSelectedTools((prev) => toggleTool(prev, tool));
};
const handleSetToolsSelected = (tools: MCPToolsetTool[], selected: boolean) => {
setSelectedTools((prev) => (selected ? selectTools(prev, tools) : deselectTools(prev, tools)));
};
const handleToggleExpanded = (serverId: string) => {
setExpandedServerIds((prev) =>
prev.has(serverId) ? new Set([...prev].filter((id) => id !== serverId)) : new Set([...prev, serverId]),
);
};
// The create modal stays mounted, so state left from the last session would fetch servers
// on the first render after reopening, before the open effect resets it.
const handleClose = () => {
setSearch("");
setExpandedServerIds(new Set());
onClose();
};
const handleSubmit = async (values: ToolsetFormValues) => {
setSaving(true);
try {
await onSave(values.toolset_name, values.description, selectedTools);
onClose();
handleClose();
} finally {
setSaving(false);
}
};
const filteredServers = mcpServers.filter((s) => {
const q = serverSearch.toLowerCase();
return !q || (s.alias || "").toLowerCase().includes(q) || (s.server_name || "").toLowerCase().includes(q);
});
const searchActive = search.trim() !== "";
const serverViews = buildServerViews(mcpServers, toolsByServer, search);
const { matchingTools, pendingServerCount } = searchActive
? summarizeSearch(serverViews)
: { matchingTools: [], pendingServerCount: 0 };
const matchingState = selectionState(selectedTools, matchingTools);
return (
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
<Dialog open={open} onOpenChange={(open) => !open && handleClose()}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[960px]">
<DialogHeader>
<DialogTitle>{initialToolset ? "Edit Toolset" : "New Toolset"}</DialogTitle>
@ -229,32 +342,65 @@ function CreateToolsetModal({ open, onClose, onSave, accessToken, initialToolset
</div>
<InputGroup className="mb-2">
<InputGroupInput
placeholder="Search MCP servers..."
value={serverSearch}
onChange={(e) => setServerSearch(e.target.value)}
placeholder="Search tools or servers..."
aria-label="Search tools or servers"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{serverSearch && (
{search && (
<InputGroupAddon align="inline-end">
<InputGroupButton size="icon-xs" aria-label="Clear search" onClick={() => setServerSearch("")}>
<InputGroupButton size="icon-xs" aria-label="Clear search" onClick={() => setSearch("")}>
<X />
</InputGroupButton>
</InputGroupAddon>
)}
</InputGroup>
{searchActive && (
<div className="mb-2 flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground" aria-live="polite">
{matchingTools.length} matching {matchingTools.length === 1 ? "tool" : "tools"}
{pendingServerCount > 0 &&
` · loading ${pendingServerCount} ${pendingServerCount === 1 ? "server" : "servers"}…`}
</p>
<div className="flex gap-1 shrink-0">
<Button
size="xs"
variant="outline"
disabled={matchingTools.length === 0 || matchingState === "all"}
onClick={() => handleSetToolsSelected(matchingTools, true)}
>
Select all matching
</Button>
<Button
size="xs"
variant="outline"
disabled={matchingState === "none"}
onClick={() => handleSetToolsSelected(matchingTools, false)}
>
Deselect all matching
</Button>
</div>
</div>
)}
<div className="space-y-2 overflow-y-auto" style={{ maxHeight: 300 }}>
{filteredServers.length === 0 ? (
{serverViews.length === 0 ? (
<p className="text-muted-foreground text-sm">
{mcpServers.length === 0 ? "No MCP servers configured" : "No servers match your search"}
{mcpServers.length === 0 ? "No MCP servers configured" : "No tools or servers match your search"}
</p>
) : (
filteredServers.map((server) => (
<MCPToolList
serverViews.map(({ server, serverName, state, visibleTools }) => (
<ServerToolGroup
key={server.server_id}
serverId={server.server_id}
serverName={server.alias || server.server_name || server.server_id}
accessToken={accessToken}
serverName={serverName}
state={state}
visibleTools={visibleTools}
expanded={searchActive || expandedServerIds.has(server.server_id)}
searchActive={searchActive}
selectedTools={selectedTools}
onToggle={handleToggleTool}
onToggleExpanded={() => handleToggleExpanded(server.server_id)}
onToggleTool={handleToggleTool}
onSetToolsSelected={handleSetToolsSelected}
/>
))
)}
@ -266,41 +412,60 @@ function CreateToolsetModal({ open, onClose, onSave, accessToken, initialToolset
{/* Right panel: Your Toolset */}
<div className="w-72 shrink-0">
<p className="text-sm font-semibold text-foreground mb-2 block">
Your Toolset{" "}
<span className="text-xs font-normal text-muted-foreground">({selectedTools.length} tools)</span>
</p>
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-semibold text-foreground">
Your Toolset{" "}
<span className="text-xs font-normal text-muted-foreground">({selectedTools.length} tools)</span>
</p>
<Button
size="xs"
variant="ghost"
disabled={selectedTools.length === 0}
onClick={() => setSelectedTools([])}
>
Clear all
</Button>
</div>
<div className="space-y-1 overflow-y-auto" style={{ maxHeight: 340 }}>
{selectedTools.length === 0 ? (
<p className="text-muted-foreground text-sm">No tools added yet</p>
) : (
selectedTools.map((tool, idx) => (
<button
key={idx}
type="button"
onClick={() => handleToggleTool(tool)}
className="w-full flex items-center justify-between px-3 py-1.5 rounded-lg border border-purple-200 bg-purple-50 hover:bg-destructive/10 hover:border-destructive/20 group transition-colors dark:border-purple-800 dark:bg-purple-950"
>
<div className="min-w-0 text-left">
<span className="text-xs font-medium text-purple-800 group-hover:text-destructive truncate block dark:text-purple-200">
{displayToolName(serverPrefixById.get(tool.server_id), tool.tool_name)}
selectedTools.map((tool) => {
const toolName = displayToolName(serverPrefixById.get(tool.server_id), tool.tool_name);
const issue = serversLoaded ? selectedToolIssue(tool, serverPrefixById, toolsByServer) : undefined;
return (
<button
key={`${tool.server_id}:${tool.tool_name}`}
type="button"
aria-label={`Remove ${toolName}`}
onClick={() => handleToggleTool(tool)}
className="w-full flex items-center justify-between px-3 py-1.5 rounded-lg border border-purple-200 bg-purple-50 hover:bg-destructive/10 hover:border-destructive/20 group transition-colors dark:border-purple-800 dark:bg-purple-950"
>
<div className="min-w-0 text-left">
<span className="text-xs font-medium text-purple-800 group-hover:text-destructive truncate block dark:text-purple-200">
{toolName}
</span>
{issue ? (
<span className="text-[10px] text-warning truncate block">{issue}</span>
) : (
<span className="text-[10px] text-purple-400 truncate block dark:text-purple-500">
{tool.server_id.slice(0, 8)}…
</span>
)}
</div>
<span className="ml-2 text-purple-300 group-hover:text-destructive text-xs shrink-0 dark:text-purple-600">
✕
</span>
<span className="text-[10px] text-purple-400 truncate block dark:text-purple-500">
{tool.server_id.slice(0, 8)}…
</span>
</div>
<span className="ml-2 text-purple-300 group-hover:text-destructive text-xs shrink-0 dark:text-purple-600">
✕
</span>
</button>
))
</button>
);
})
)}
</div>
</div>
</div>
<div className="flex justify-end gap-2 mt-4 pt-4 border-t border-border">
<Button variant="outline" onClick={onClose}>
<Button variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button onClick={() => void form.handleSubmit(handleSubmit)()} disabled={saving} aria-busy={saving}>
@ -463,19 +628,13 @@ export function MCPToolsetsTab({ accessToken, userRole }: MCPToolsetsTabProps) {
size="compact"
/>
<CreateToolsetModal
open={createOpen}
onClose={() => setCreateOpen(false)}
onSave={handleCreate}
accessToken={accessToken}
/>
<CreateToolsetModal open={createOpen} onClose={() => setCreateOpen(false)} onSave={handleCreate} />
{editToolset && (
<CreateToolsetModal
open={!!editToolset}
onClose={() => setEditToolset(null)}
onSave={handleUpdate}
accessToken={accessToken}
initialToolset={editToolset}
/>
)}

View file

@ -0,0 +1,90 @@
import { describe, it, expect } from "vitest";
import {
deselectTools,
filterServerTools,
isToolSelected,
matchesServerName,
selectTools,
selectionState,
toggleTool,
} from "./toolsetSelection";
const t = (server_id: string, tool_name: string) => ({ server_id, tool_name });
const githubTools = [
{ name: "create_issue", description: "Open a new issue" },
{ name: "list_pulls", description: "List pull requests" },
{ name: "search_code", description: "Search code across repos" },
];
describe("filterServerTools", () => {
it("returns every tool for an empty or whitespace query", () => {
expect(filterServerTools("", ["github"], githubTools)).toBe(githubTools);
expect(filterServerTools(" ", ["github"], githubTools)).toBe(githubTools);
});
it("returns every tool when the query matches a server name, case-insensitively", () => {
expect(filterServerTools("GitH", ["github_prod", "GitHub"], githubTools)).toBe(githubTools);
});
it("matches tool names and descriptions when the server name does not match", () => {
expect(filterServerTools("issue", ["github"], githubTools).map((tool) => tool.name)).toEqual(["create_issue"]);
expect(filterServerTools("PULL REQ", ["github"], githubTools).map((tool) => tool.name)).toEqual(["list_pulls"]);
});
it("returns nothing when neither the server nor any tool matches", () => {
expect(filterServerTools("slack", ["github"], githubTools)).toEqual([]);
});
});
describe("matchesServerName", () => {
it("matches any of the server names, case-insensitively, and never for an empty query", () => {
expect(matchesServerName("PROD", ["github_prod", "GitHub"])).toBe(true);
expect(matchesServerName("slack", ["github_prod", "GitHub"])).toBe(false);
expect(matchesServerName(" ", ["github"])).toBe(false);
});
});
describe("selectTools", () => {
it("appends new tools after the existing selection in order", () => {
expect(selectTools([t("a", "x")], [t("b", "y"), t("b", "z")])).toEqual([t("a", "x"), t("b", "y"), t("b", "z")]);
});
it("skips tools already selected and duplicates within the input", () => {
expect(selectTools([t("a", "x")], [t("a", "x"), t("a", "y"), t("a", "y")])).toEqual([t("a", "x"), t("a", "y")]);
});
it("treats the same tool name on different servers as different tools", () => {
expect(selectTools([t("a", "search")], [t("b", "search")])).toEqual([t("a", "search"), t("b", "search")]);
});
});
describe("deselectTools", () => {
it("removes only the given tools and keeps the rest of the selection", () => {
const selected = [t("a", "x"), t("b", "y"), t("gone", "z")];
expect(deselectTools(selected, [t("b", "y"), t("b", "not-selected")])).toEqual([t("a", "x"), t("gone", "z")]);
});
});
describe("toggleTool and isToolSelected", () => {
it("adds an unselected tool and removes a selected one", () => {
const added = toggleTool([t("a", "x")], t("a", "y"));
expect(added).toEqual([t("a", "x"), t("a", "y")]);
expect(isToolSelected(added, t("a", "y"))).toBe(true);
expect(toggleTool(added, t("a", "x"))).toEqual([t("a", "y")]);
});
});
describe("selectionState", () => {
const tools = [t("a", "x"), t("a", "y")];
it("is none for an empty tool list", () => {
expect(selectionState([t("a", "x")], [])).toBe("none");
});
it("reports none, some and all against the given tools only", () => {
expect(selectionState([t("b", "x")], tools)).toBe("none");
expect(selectionState([t("a", "x"), t("b", "z")], tools)).toBe("some");
expect(selectionState([t("a", "y"), t("a", "x")], tools)).toBe("all");
});
});

View file

@ -0,0 +1,63 @@
import type { MCPToolsetTool } from "@/components/mcp_tools/types";
export interface SearchableTool {
name: string;
description: string;
}
export type SelectionState = "all" | "some" | "none";
const toolKey = (tool: MCPToolsetTool): string => `${tool.server_id}\u0000${tool.tool_name}`;
const normalize = (query: string): string => query.trim().toLowerCase();
export const matchesServerName = (query: string, serverNames: readonly string[]): boolean => {
const q = normalize(query);
return q !== "" && serverNames.some((name) => name.toLowerCase().includes(q));
};
export const filterServerTools = <T extends SearchableTool>(
query: string,
serverNames: readonly string[],
tools: readonly T[],
): readonly T[] => {
const q = normalize(query);
if (!q || matchesServerName(query, serverNames)) {
return tools;
}
return tools.filter((tool) => tool.name.toLowerCase().includes(q) || tool.description.toLowerCase().includes(q));
};
export const selectTools = (
selected: readonly MCPToolsetTool[],
tools: readonly MCPToolsetTool[],
): MCPToolsetTool[] => {
const selectedKeys = new Set(selected.map(toolKey));
const uniqueTools = [...new Map(tools.map((tool) => [toolKey(tool), tool])).values()];
return [...selected, ...uniqueTools.filter((tool) => !selectedKeys.has(toolKey(tool)))];
};
export const deselectTools = (
selected: readonly MCPToolsetTool[],
tools: readonly MCPToolsetTool[],
): MCPToolsetTool[] => {
const removed = new Set(tools.map(toolKey));
return selected.filter((tool) => !removed.has(toolKey(tool)));
};
export const toggleTool = (selected: readonly MCPToolsetTool[], tool: MCPToolsetTool): MCPToolsetTool[] =>
isToolSelected(selected, tool) ? deselectTools(selected, [tool]) : selectTools(selected, [tool]);
export const isToolSelected = (selected: readonly MCPToolsetTool[], tool: MCPToolsetTool): boolean =>
selected.some((t) => toolKey(t) === toolKey(tool));
export const selectionState = (
selected: readonly MCPToolsetTool[],
tools: readonly MCPToolsetTool[],
): SelectionState => {
if (tools.length === 0) return "none";
const selectedKeys = new Set(selected.map(toolKey));
const count = tools.filter((tool) => selectedKeys.has(toolKey(tool))).length;
if (count === 0) return "none";
return count === tools.length ? "all" : "some";
};