Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 0 additions & 36 deletions ui/litellm-dashboard/eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -520,9 +520,6 @@
},
"react-hooks/set-state-in-effect": {
"count": 1
},
"unused-imports/no-unused-imports": {
"count": 2
}
},
"src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.tsx": {
Expand Down Expand Up @@ -1333,16 +1330,6 @@
"count": 1
}
},
"src/components/AIHub/AgentHubTableColumns.test.tsx": {
"unused-imports/no-unused-imports": {
"count": 1
}
},
"src/components/AIHub/AgentHubTableColumns.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/AIHub/ModelHubTable.test.tsx": {
"max-params": {
"count": 1
Expand All @@ -1356,11 +1343,6 @@
"count": 1
}
},
"src/components/AIHub/SkillHubDashboard.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/AIHub/UsefulLinksManagement.tsx": {
"no-restricted-imports": {
"count": 1
Expand Down Expand Up @@ -1885,11 +1867,6 @@
"count": 1
}
},
"src/components/mcp_hub_table_columns.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/mcp_server_management/MCPToolPermissions.tsx": {
"no-restricted-imports": {
"count": 1
Expand Down Expand Up @@ -1982,11 +1959,6 @@
"count": 1
}
},
"src/components/model_hub_table_columns.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/model_info_view.tsx": {
"no-nested-ternary": {
"count": 14
Expand Down Expand Up @@ -2119,9 +2091,6 @@
}
},
"src/components/public_model_hub.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
Expand Down Expand Up @@ -2172,11 +2141,6 @@
"count": 1
}
},
"src/components/skill_hub_table_columns.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/team/EditMembership.tsx": {
"no-nested-ternary": {
"count": 1
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { DataTable } from "@/components/shared/DataTable";
import { MCPToolset } from "@/components/mcp_tools/types";
import { getMCPToolsetTableColumns } from "./MCPToolsetTableColumns";

vi.mock("@/components/networking", () => ({
getProxyBaseUrl: () => "http://localhost:4000",
}));

const mockToolset: MCPToolset = {
toolset_id: "ts-1",
toolset_name: "github-tools",
description: "GitHub helpers",
tools: [
{ server_id: "srv-1", tool_name: "create_issue" },
{ server_id: "srv-1", tool_name: "list_issues" },
{ server_id: "srv-2", tool_name: "search" },
{ server_id: "srv-2", tool_name: "fetch" },
{ server_id: "srv-2", tool_name: "crawl" },
],
created_at: "2026-01-01T00:00:00Z",
};

const serverPrefixById = new Map([
["srv-1", "github"],
["srv-2", "exa"],
]);

function renderTable({ isAdmin = true, onEditClick = vi.fn(), onDeleteClick = vi.fn() } = {}) {
const deps = { isAdmin, serverPrefixById, onEditClick, onDeleteClick };
render(
<DataTable
data={[mockToolset]}
columns={getMCPToolsetTableColumns(deps)}
getRowId={(toolset) => toolset.toolset_id}
sortingMode="client"
size="compact"
/>,
);
return { onEditClick, onDeleteClick };
}

describe("getMCPToolsetTableColumns", () => {
it("renders the toolset with its endpoint url as subtitle", () => {
renderTable();
expect(screen.getByText("github-tools")).toBeInTheDocument();
expect(screen.getByText("http://localhost:4000/toolset/github-tools/mcp")).toBeInTheDocument();
});

it("renders server-prefixed tool chips capped at four with an overflow count", () => {
renderTable();
expect(screen.getByText("github-create_issue")).toBeInTheDocument();
expect(screen.getByText("github-list_issues")).toBeInTheDocument();
expect(screen.getByText("exa-search")).toBeInTheDocument();
expect(screen.getByText("exa-fetch")).toBeInTheDocument();
expect(screen.queryByText("exa-crawl")).not.toBeInTheDocument();
expect(screen.getByText("+1 more")).toBeInTheDocument();
});

it("opens the edit modal when an admin clicks the toolset name", async () => {
const user = userEvent.setup();
const { onEditClick } = renderTable();
await user.click(screen.getByRole("button", { name: /github-tools/ }));
expect(onEditClick).toHaveBeenCalledWith(mockToolset);
});

it("does not make the name clickable for non-admins", () => {
renderTable({ isAdmin: false });
expect(screen.queryByRole("button", { name: /github-tools/ })).not.toBeInTheDocument();
});

it("copies the endpoint url and toolset id from the actions menu", async () => {
const user = userEvent.setup();
renderTable({ isAdmin: false });

await user.click(screen.getByTestId("toolset-actions-ts-1"));
await user.click(await screen.findByTestId("toolset-action-copy-url"));
expect(await window.navigator.clipboard.readText()).toBe("http://localhost:4000/toolset/github-tools/mcp");

await user.click(screen.getByTestId("toolset-actions-ts-1"));
await user.click(await screen.findByTestId("toolset-action-copy-id"));
expect(await window.navigator.clipboard.readText()).toBe("ts-1");
});

it("edits and deletes through the actions menu as admin", async () => {
const user = userEvent.setup();
const { onEditClick, onDeleteClick } = renderTable();

await user.click(screen.getByTestId("toolset-actions-ts-1"));
await user.click(await screen.findByTestId("toolset-action-edit"));
expect(onEditClick).toHaveBeenCalledWith(mockToolset);

await user.click(screen.getByTestId("toolset-actions-ts-1"));
await user.click(await screen.findByTestId("toolset-action-delete"));
expect(onDeleteClick).toHaveBeenCalledWith("ts-1");
});

it("hides edit and delete from non-admins but keeps the copy actions", async () => {
const user = userEvent.setup();
renderTable({ isAdmin: false });

await user.click(screen.getByTestId("toolset-actions-ts-1"));
expect(await screen.findByTestId("toolset-action-copy-url")).toBeInTheDocument();
expect(screen.getByTestId("toolset-action-copy-id")).toBeInTheDocument();
expect(screen.queryByTestId("toolset-action-edit")).not.toBeInTheDocument();
expect(screen.queryByTestId("toolset-action-delete")).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
"use client";

import { ColumnDef } from "@tanstack/react-table";
import { Copy, Link2, MoreHorizontal, Pencil, Trash2 } from "lucide-react";

import { DataTableSortHeader } from "@/components/shared/DataTable";
import { DateCell, IdCell, IdentityCell } from "@/components/shared/table_cells";
import { buttonVariants } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/cva.config";
import { getProxyBaseUrl } from "@/components/networking";
import { MCPToolset } from "@/components/mcp_tools/types";
import { copyToClipboard } from "@/utils/dataUtils";

// Display-only. Toolsets persist {server_id, bare tool_name}; the gateway serves
// each tool prefixed as "{server-prefix}-{tool}". Render that qualified form so
// the same tool name on different servers stays distinguishable. This mirrors the
// backend default MCP_TOOL_PREFIX_SEPARATOR; overriding that env var only changes
// this cosmetic label, never what is stored or how tools are matched.
const MCP_TOOL_PREFIX_SEPARATOR = "-";

export function displayToolName(serverPrefix: string | undefined, toolName: string): string {
return serverPrefix ? `${serverPrefix}${MCP_TOOL_PREFIX_SEPARATOR}${toolName}` : toolName;
}

export function toolsetEndpointUrl(toolsetName: string): string {
return `${getProxyBaseUrl()}/toolset/${toolsetName}/mcp`;
}

interface ToolsetRowActionsProps {
toolset: MCPToolset;
isAdmin: boolean;
onEditClick: (toolset: MCPToolset) => void;
onDeleteClick: (toolsetId: string) => void;
}

function ToolsetRowActions({ toolset, isAdmin, onEditClick, onDeleteClick }: ToolsetRowActionsProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Open toolset actions"
data-testid={`toolset-actions-${toolset.toolset_id}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem
data-testid="toolset-action-copy-url"
onClick={() => void copyToClipboard(toolsetEndpointUrl(toolset.toolset_name), "Endpoint URL copied")}
>
<Link2 />
Copy endpoint URL
</DropdownMenuItem>
<DropdownMenuItem
data-testid="toolset-action-copy-id"
onClick={() => void copyToClipboard(toolset.toolset_id, "Toolset ID copied")}
>
<Copy />
Copy toolset ID
</DropdownMenuItem>
{isAdmin && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem data-testid="toolset-action-edit" onClick={() => onEditClick(toolset)}>
<Pencil />
Edit
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
data-testid="toolset-action-delete"
onClick={() => onDeleteClick(toolset.toolset_id)}
>
<Trash2 />
Delete
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}

interface MCPToolsetTableColumnsDeps {
isAdmin: boolean;
serverPrefixById: Map<string, string>;
onEditClick: (toolset: MCPToolset) => void;
onDeleteClick: (toolsetId: string) => void;
}

export const getMCPToolsetTableColumns = ({
isAdmin,
serverPrefixById,
onEditClick,
onDeleteClick,
}: MCPToolsetTableColumnsDeps): ColumnDef<MCPToolset>[] => [
{
id: "toolset_id",
accessorKey: "toolset_id",
meta: { title: "Toolset ID" },
header: "Toolset ID",
size: 140,
enableSorting: false,
cell: ({ row }) => <IdCell value={row.original.toolset_id} />,
},
{
id: "toolset_name",
accessorKey: "toolset_name",
meta: { title: "Name" },
header: ({ column }) => <DataTableSortHeader column={column} title="Name" />,
size: 260,
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => (
<IdentityCell
title={row.original.toolset_name}
subtitle={toolsetEndpointUrl(row.original.toolset_name)}
className="max-w-80"
onClick={isAdmin ? () => onEditClick(row.original) : undefined}
/>
),
},
{
id: "description",
accessorKey: "description",
meta: { title: "Description" },
header: "Description",
size: 200,
enableSorting: false,
cell: ({ row }) => (
<span className="block max-w-72 truncate text-sm text-muted-foreground" title={row.original.description}>
{row.original.description || "—"}
</span>
),
},
{
id: "tools",
meta: { title: "Tools", skeleton: "chips" },
header: "Tools",
size: 260,
enableSorting: false,
cell: ({ row }) => {
const tools = row.original.tools;
return (
<div className="flex max-w-xs flex-wrap gap-1">
{tools.slice(0, 4).map((tool) => (
<span
key={`${tool.server_id}-${tool.tool_name}`}
className="inline-flex items-center rounded-md bg-muted px-1.5 py-0.5 text-xs"
>
{displayToolName(serverPrefixById.get(tool.server_id), tool.tool_name)}
</span>
))}
{tools.length > 4 && (
<span className="self-center text-xs text-muted-foreground">+{tools.length - 4} more</span>
)}
</div>
);
},
},
{
id: "created_at",
accessorKey: "created_at",
meta: { title: "Created" },
header: ({ column }) => <DataTableSortHeader column={column} title="Created" />,
size: 120,
enableSorting: true,
cell: ({ row }) => <DateCell value={row.original.created_at} precision="date" />,
},
{
id: "actions",
meta: { className: "text-right", headerClassName: "text-right" },
header: () => <span className="sr-only">Actions</span>,
size: 64,
enableSorting: false,
enableHiding: false,
cell: ({ row }) => (
<div className="flex justify-end">
<ToolsetRowActions
toolset={row.original}
isAdmin={isAdmin}
onEditClick={onEditClick}
onDeleteClick={onDeleteClick}
/>
</div>
),
},
];
Loading
Loading