From c849cbc0bd97d60cb54ce41a97728e46833b120d Mon Sep 17 00:00:00 2001 From: lewis Date: Sat, 17 Oct 2026 18:27:10 -0400 Subject: [PATCH] fix(ui): keep colliding MCP names distinct and show unrestricted and no-tool grants The MCP selector typed only toolset values, so a stored server whose ID or alias matched an access group became that group on the next edit and widened the grant. Servers and access groups now carry their own prefixes The customer MCP filter called unrestricted customers 'No MCP access'. It now separates limited, denied and unrestricted customers, matching the table column Server details showed an empty tool allowlist like an unrestricted server. It now says no tools are allowed --- .../CustomersTable.integration.test.tsx | 4 +- .../_components/CustomersTableColumns.tsx | 12 +++-- .../_components/customerFilters.test.ts | 35 ++++++++------- .../customers/_components/customerFilters.ts | 12 +++-- .../_components/customerResources.test.ts | 20 ++++----- .../_components/customerResources.ts | 14 ++++-- .../MCPServerSelector.test.tsx | 39 ++++++++++++++++ .../MCPServerSelector.tsx | 44 +++++++++++++------ .../permissions/MCPServerPermissions.test.tsx | 18 ++++++++ .../permissions/MCPServerPermissions.tsx | 2 + 10 files changed, 145 insertions(+), 55 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomersTable.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomersTable.integration.test.tsx index 74b3c3b6a08..62f74e49e4b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomersTable.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomersTable.integration.test.tsx @@ -66,7 +66,7 @@ describe("CustomersTable controls", () => { expect(screen.getByRole("combobox", { name: "Budget" })).toHaveTextContent(/^All\b/); await chooseSelectOption(user, screen.getByRole("combobox", { name: "Status" }), "Active"); await chooseSelectOption(user, screen.getByRole("combobox", { name: "Budget" }), "Has budget"); - await chooseSelectOption(user, screen.getByRole("combobox", { name: "MCP access" }), "Has MCP access"); + await chooseSelectOption(user, screen.getByRole("combobox", { name: "MCP access" }), "Limited MCP access"); expect(screen.getByRole("combobox", { name: "Budget" })).toHaveTextContent(/^Has budget\b/); await user.click(screen.getByRole("button", { name: "Apply Filters" })); @@ -78,7 +78,7 @@ describe("CustomersTable controls", () => { const params = onUrlUpdate.mock.calls.at(-1)?.[0].searchParams; expect(params.get("filter_blocked")).toBe("active"); expect(params.get("filter_max_budget")).toBe("yes"); - expect(params.get("filter_mcp_access")).toBe("yes"); + expect(params.get("filter_mcp_access")).toBe("limited"); await user.click(screen.getByRole("button", { name: /^Filters/ })); await user.click(screen.getByRole("button", { name: "Reset" })); await user.click(screen.getByRole("button", { name: "Apply Filters" })); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomersTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomersTableColumns.tsx index 9ba8ae32d0b..91edd53fadc 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomersTableColumns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/CustomersTableColumns.tsx @@ -22,7 +22,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp import { cn } from "@/lib/cva.config"; import { copyToClipboard } from "@/utils/dataUtils"; -import { getCustomerResourceCounts } from "./customerResources"; +import { customerMcpScope, getCustomerResourceCounts } from "./customerResources"; export type CustomerTableActions = { canModify: boolean; @@ -102,8 +102,9 @@ export function CustomerBlockAction({ const countLabel = (count: number, noun: string): string => (count ? `${count} ${noun}${count === 1 ? "" : "s"}` : ""); export function describeMcpGrant(permission: EndUser["object_permission"]): string { - const servers = permission?.mcp_servers ?? []; - if (servers.includes(NO_MCP_SERVERS_SENTINEL)) return "No MCP access"; + const scope = customerMcpScope(permission); + if (scope === "denied") return "No MCP access"; + if (scope === "unrestricted") return "No restriction"; const counts = getCustomerResourceCounts({ object_permission: permission }); const toolPermissionKeys = Object.keys(permission?.mcp_tool_permissions ?? {}).filter( (server) => server !== ALL_PROXY_MCP_SERVERS_SENTINEL && server !== NO_MCP_SERVERS_SENTINEL, @@ -114,10 +115,7 @@ export function describeMcpGrant(permission: EndUser["object_permission"]): stri countLabel(counts.toolsets, "toolset"), countLabel(toolPermissionKeys.length, "tool limit"), ].filter(Boolean); - const hasUnsupportedAll = - servers.includes(ALL_PROXY_MCP_SERVERS_SENTINEL) || - Object.hasOwn(permission?.mcp_tool_permissions ?? {}, ALL_PROXY_MCP_SERVERS_SENTINEL); - return parts.join(", ") || (hasUnsupportedAll ? "No MCP access" : "No restriction"); + return parts.join(", "); } const RESOURCE_ITEMS = [ diff --git a/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerFilters.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerFilters.test.ts index 590f3d2bdda..727d5d352d8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerFilters.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerFilters.test.ts @@ -20,6 +20,11 @@ const permission: NonNullable = { vector_stores: [], }; +const MCP_SCOPES = ["limited", "denied", "unrestricted"] as const; + +const scopesMatching = (row: EndUser): string[] => + MCP_SCOPES.filter((scope) => customerMatchesFilter(row, "mcp_access", scope)); + describe("customer filters", () => { it("searches ID and alias case-insensitively and trims the search", () => { expect(customerMatchesSearch(customer, " CUSTOMER-1 ")).toBe(true); @@ -52,34 +57,34 @@ describe("customer filters", () => { { mcp_tool_permissions: { "server-1": ["read"] } }, ])("detects configured MCP grants %j", (grant) => { const row = { ...customer, object_permission: { ...permission, ...grant } }; - expect(customerMatchesFilter(row, "mcp_access", "yes")).toBe(true); - expect(customerMatchesFilter(row, "mcp_access", "no")).toBe(false); + expect(scopesMatching(row)).toEqual(["limited"]); }); - it("does not mistake absent, empty, or explicitly denied MCP grants for access", () => { - const denied = { ...permission, mcp_servers: [NO_MCP_SERVERS_SENTINEL], mcp_access_groups: ["group-1"] }; - for (const object_permission of [ - undefined, - null, - permission, - denied, - { ...permission, mcp_servers: [ALL_PROXY_MCP_SERVERS_SENTINEL] }, - ]) { - expect(customerMatchesFilter({ ...customer, object_permission }, "mcp_access", "yes")).toBe(false); - expect(customerMatchesFilter({ ...customer, object_permission }, "mcp_access", "no")).toBe(true); - } + it.each([ + { mcp_servers: [NO_MCP_SERVERS_SENTINEL], mcp_access_groups: ["group-1"] }, + { mcp_servers: [ALL_PROXY_MCP_SERVERS_SENTINEL] }, + ])("matches denied MCP grants only as No MCP access %j", (grant) => { + expect(scopesMatching({ ...customer, object_permission: { ...permission, ...grant } })).toEqual(["denied"]); }); + it.each([undefined, null, permission])( + "matches customers without a grant only as No MCP restriction (%j)", + (object_permission) => { + expect(scopesMatching({ ...customer, object_permission })).toEqual(["unrestricted"]); + }, + ); + it("ignores invalid URL filter values and unrelated columns", () => { expect(customerMatchesFilter(customer, "blocked", "unexpected")).toBe(true); expect(customerMatchesFilter(customer, "max_budget", false)).toBe(true); expect(customerMatchesFilter(customer, "mcp_access", "unexpected")).toBe(true); + expect(customerMatchesFilter(customer, "mcp_access", "yes")).toBe(true); expect(customerMatchesFilter(customer, "unrelated", "yes")).toBe(true); }); it("formats filter chips with the labels shown in the drawer", () => { expect(formatCustomerFilterValue("blocked", "active")).toBe("Active"); expect(formatCustomerFilterValue("max_budget", "yes")).toBe("Has budget"); - expect(formatCustomerFilterValue("mcp_access", "no")).toBe("No MCP access"); + expect(formatCustomerFilterValue("mcp_access", "unrestricted")).toBe("No MCP restriction"); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerFilters.ts b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerFilters.ts index bce9adf696d..c078eeed42e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerFilters.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerFilters.ts @@ -2,7 +2,7 @@ import type { FilterFn } from "@tanstack/react-table"; import type { EndUser } from "@/app/(dashboard)/hooks/customers/useCustomers"; -import { customerHasMcpAccess } from "./customerResources"; +import { customerMcpScope } from "./customerResources"; export const CUSTOMER_FILTER_FIELDS = [ { @@ -25,8 +25,9 @@ export const CUSTOMER_FILTER_FIELDS = [ id: "mcp_access", label: "MCP access", options: [ - { value: "yes", label: "Has MCP access" }, - { value: "no", label: "No MCP access" }, + { value: "limited", label: "Limited MCP access" }, + { value: "denied", label: "No MCP access" }, + { value: "unrestricted", label: "No MCP restriction" }, ], }, ] as const; @@ -40,9 +41,12 @@ export const customerMatchesFilter = (customer: EndUser, columnId: string, value if (value === "active") return !customer.blocked; return true; } + if (columnId === "mcp_access") { + if (value !== "limited" && value !== "denied" && value !== "unrestricted") return true; + return customerMcpScope(customer.object_permission) === value; + } if (value !== "yes" && value !== "no") return true; if (columnId === "max_budget") return (customer.litellm_budget_table?.max_budget != null) === (value === "yes"); - if (columnId === "mcp_access") return customerHasMcpAccess(customer) === (value === "yes"); return true; }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerResources.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerResources.test.ts index 48c5b7a5b2b..25046fcc0ea 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerResources.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerResources.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import type { EndUser } from "@/app/(dashboard)/hooks/customers/useCustomers"; import { ALL_PROXY_MCP_SERVERS_SENTINEL, NO_MCP_SERVERS_SENTINEL } from "@/components/mcp_tools/constants"; -import { customerHasMcpAccess, getCustomerResourceCounts, type CustomerResourceCounts } from "./customerResources"; +import { customerMcpScope, getCustomerResourceCounts, type CustomerResourceCounts } from "./customerResources"; const emptyPermission: NonNullable = { object_permission_id: "permission-id", @@ -55,7 +55,7 @@ describe("getCustomerResourceCounts", () => { }, }; expect(getCustomerResourceCounts(configured).servers).toBe(2); - expect(customerHasMcpAccess(configured)).toBe(true); + expect(customerMcpScope(configured.object_permission)).toBe("limited"); }); it("excludes the unsupported all-MCP sentinel from customer server counts", () => { @@ -64,7 +64,7 @@ describe("getCustomerResourceCounts", () => { object_permission: { ...emptyPermission, mcp_servers: [ALL_PROXY_MCP_SERVERS_SENTINEL, "server-a"] }, }; expect(getCustomerResourceCounts(configured).servers).toBe(1); - expect(customerHasMcpAccess(configured)).toBe(true); + expect(customerMcpScope(configured.object_permission)).toBe("limited"); }); it("does not claim MCP access from unsupported all-MCP sentinel-only grants", () => { @@ -77,7 +77,7 @@ describe("getCustomerResourceCounts", () => { }, }; expect(getCustomerResourceCounts(configured).servers).toBe(0); - expect(customerHasMcpAccess(configured)).toBe(false); + expect(customerMcpScope(configured.object_permission)).toBe("denied"); }); it("zeroes every MCP grant when no-MCP overrides other grants", () => { @@ -92,7 +92,7 @@ describe("getCustomerResourceCounts", () => { }, }; expect(getCustomerResourceCounts(configured)).toEqual(noMcpCounts); - expect(customerHasMcpAccess(configured)).toBe(false); + expect(customerMcpScope(configured.object_permission)).toBe("denied"); }); it("excludes permission-table models and resources not enforced for customers", () => { @@ -110,19 +110,19 @@ describe("getCustomerResourceCounts", () => { }, }; expect(getCustomerResourceCounts(configured)).toEqual({ ...noMcpCounts, models: 1 }); - expect(customerHasMcpAccess(configured)).toBe(false); + expect(customerMcpScope(configured.object_permission)).toBe("unrestricted"); }); }); -describe("customerHasMcpAccess", () => { +describe("customerMcpScope", () => { it.each([ undefined, null, emptyPermission, { ...emptyPermission, mcp_servers: null, mcp_access_groups: null, mcp_toolsets: null }, - ])("treats an empty customer-level grant as not configured (%j)", (object_permission) => { + ])("treats an empty customer-level grant as unrestricted (%j)", (object_permission) => { const unconfigured = { ...customer, object_permission }; - expect(customerHasMcpAccess(unconfigured)).toBe(false); + expect(customerMcpScope(object_permission)).toBe("unrestricted"); expect(getCustomerResourceCounts(unconfigured)).toEqual(noMcpCounts); }); @@ -132,6 +132,6 @@ describe("customerHasMcpAccess", () => { { mcp_toolsets: ["toolset-id"] }, { mcp_tool_permissions: { "server-id": ["read"] } }, ])("recognizes a grant from each MCP source (%j)", (grant) => { - expect(customerHasMcpAccess({ ...customer, object_permission: { ...emptyPermission, ...grant } })).toBe(true); + expect(customerMcpScope({ ...emptyPermission, ...grant })).toBe("limited"); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerResources.ts b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerResources.ts index cc15e5583af..49006149b15 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerResources.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/customers/_components/customerResources.ts @@ -30,7 +30,15 @@ export function getCustomerResourceCounts( }; } -export function customerHasMcpAccess(customer: EndUser): boolean { - const counts = getCustomerResourceCounts(customer); - return counts.servers > 0 || counts.accessGroups > 0 || counts.toolsets > 0; +export type CustomerMcpScope = "limited" | "denied" | "unrestricted"; + +export function customerMcpScope(permission: EndUser["object_permission"]): CustomerMcpScope { + const servers = permission?.mcp_servers ?? []; + if (servers.includes(NO_MCP_SERVERS_SENTINEL)) return "denied"; + const counts = getCustomerResourceCounts({ object_permission: permission }); + if (counts.servers > 0 || counts.accessGroups > 0 || counts.toolsets > 0) return "limited"; + const onlyUnsupportedAll = + servers.includes(ALL_PROXY_MCP_SERVERS_SENTINEL) || + Object.hasOwn(permission?.mcp_tool_permissions ?? {}, ALL_PROXY_MCP_SERVERS_SENTINEL); + return onlyUnsupportedAll ? "denied" : "unrestricted"; } diff --git a/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.test.tsx b/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.test.tsx index 55dae66a3c9..71f0c248952 100644 --- a/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.test.tsx @@ -258,3 +258,42 @@ describe("MCPServerSelector scoping", () => { expect(optionByLabel("Wiki A")).toBeDefined(); }); }); + +describe("MCPServerSelector name collisions", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseMCPServers.mockReturnValue({ + data: [ + { server_id: "shared", server_name: "Shared" }, + { server_id: "srv-x", server_name: "Other" }, + ], + isLoading: false, + } as any); + mockUseMCPAccessGroups.mockReturnValue({ data: ["shared", "alias-a"], isLoading: false } as any); + mockUseMCPToolsets.mockReturnValue({ data: [], isLoading: false } as any); + }); + + it.each(["shared", "alias-a"])( + "keeps the stored server %s a server when it matches an access group name", + async (server) => { + const user = userEvent.setup(); + const onChange = vi.fn(); + renderWithProviders( + , + ); + + await openSelector(user); + await user.click(optionByLabel("Other")!); + + expect(onChange).toHaveBeenCalledWith({ servers: [server, "srv-x"], accessGroups: [], toolsets: [] }); + }, + ); + + it("shows a stored server alias by its name", () => { + renderWithProviders( + , + ); + + expect(screen.getByLabelText("alias-a")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.tsx b/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.tsx index 89405aaa9c1..073f995f3f5 100644 --- a/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.tsx +++ b/ui/litellm-dashboard/src/components/mcp_server_management/MCPServerSelector.tsx @@ -26,6 +26,14 @@ interface MCPServerSelectorProps { } const TOOLSET_PREFIX = "toolset:"; +const ACCESS_GROUP_PREFIX = "access-group:"; +const SERVER_PREFIX = "server:"; +const SENTINELS: readonly string[] = [ALL_PROXY_MCP_SERVERS_SENTINEL, NO_MCP_SERVERS_SENTINEL]; + +const serverValue = (server: string): string => (SENTINELS.includes(server) ? server : `${SERVER_PREFIX}${server}`); + +const valuesWithPrefix = (values: readonly string[], prefix: string): string[] => + values.filter((value) => value.startsWith(prefix)).map((value) => value.slice(prefix.length)); export const serversWithinAccessGroups = ( servers: readonly MCPServer[], @@ -60,19 +68,18 @@ const MCPServerSelector: React.FC = ({ const loading = serversLoading || groupsLoading || toolsetsLoading; - const accessGroupSet = new Set(accessGroups); const visibleServers = serversWithinAccessGroups(mcpServers, value?.accessGroups ?? [], value?.servers ?? []); // Combine options: access groups + servers + toolsets const options = [ ...accessGroups.map((group) => ({ label: group, - value: group, + value: `${ACCESS_GROUP_PREFIX}${group}`, description: "Access Group", })), ...visibleServers.map((server) => ({ label: `${server.server_name || server.server_id} (${server.server_id})`, - value: server.server_id, + value: serverValue(server.server_id), description: "MCP Server", })), ...toolsets.map((toolset) => ({ @@ -82,13 +89,24 @@ const MCPServerSelector: React.FC = ({ })), ]; - // Flatten value for Select — prefix toolset IDs const selectedValues = [ - ...(value?.servers || []), - ...(value?.accessGroups || []), + ...(value?.servers || []).map(serverValue), + ...(value?.accessGroups || []).map((group) => `${ACCESS_GROUP_PREFIX}${group}`), ...(value?.toolsets || []).map((id) => `${TOOLSET_PREFIX}${id}`), ]; + const listedValues = new Set(options.map((option) => option.value)); + const unlistedSelections = [ + ...(value?.servers || []) + .filter((server) => !SENTINELS.includes(server)) + .map((server) => ({ label: server, value: serverValue(server), description: "MCP Server" })), + ...(value?.accessGroups || []).map((group) => ({ + label: group, + value: `${ACCESS_GROUP_PREFIX}${group}`, + description: "Access Group", + })), + ].filter((option) => !listedValues.has(option.value)); + const hasNoMcpServersSelected = allowNoMcpServers && selectedValues.includes(NO_MCP_SERVERS_SENTINEL); const hasAllProxyMcpServersSelected = selectedValues.includes(ALL_PROXY_MCP_SERVERS_SENTINEL); @@ -103,13 +121,11 @@ const MCPServerSelector: React.FC = ({ onChange({ servers: [NO_MCP_SERVERS_SENTINEL], accessGroups: [], toolsets: [] }); return; } - const toolsetsSelected = selected - .filter((v) => v.startsWith(TOOLSET_PREFIX)) - .map((v) => v.slice(TOOLSET_PREFIX.length)); - const rest = selected.filter((v) => !v.startsWith(TOOLSET_PREFIX)); - const servers = rest.filter((v) => !accessGroupSet.has(v)); - const accessGroupsSelected = rest.filter((v) => accessGroupSet.has(v)); - onChange({ servers, accessGroups: accessGroupsSelected, toolsets: toolsetsSelected }); + onChange({ + servers: [...selected.filter((v) => SENTINELS.includes(v)), ...valuesWithPrefix(selected, SERVER_PREFIX)], + accessGroups: valuesWithPrefix(selected, ACCESS_GROUP_PREFIX), + toolsets: valuesWithPrefix(selected, TOOLSET_PREFIX), + }); }; const selectOptions: MultiSelectOption[] = [ @@ -119,7 +135,7 @@ const MCPServerSelector: React.FC = ({ ...(allowNoMcpServers ? [{ label: "No MCP Servers", value: NO_MCP_SERVERS_SENTINEL, description: "Block all" }] : []), - ...options.map((opt) => ({ + ...[...options, ...unlistedSelections].map((opt) => ({ ...opt, disabled: hasNoMcpServersSelected || hasAllProxyMcpServersSelected, })), diff --git a/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.test.tsx b/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.test.tsx index 7ef1c273b53..3a1628ae81b 100644 --- a/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.test.tsx +++ b/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.test.tsx @@ -157,6 +157,24 @@ describe("MCPServerPermissions", () => { expect(screen.queryByText(/tool/)).not.toBeInTheDocument(); }); + it("labels an empty tool allowlist as allowing no tools", async () => { + vi.mocked(networking.fetchMCPServers).mockResolvedValue([ + { server_id: mockServerId1, server_name: mockServerName1, alias: mockServerName1 }, + ]); + + render( + , + ); + + expect(await screen.findByText(/DW_MCP/)).toBeInTheDocument(); + expect(screen.getByText("No tools allowed")).toBeInTheDocument(); + }); + it("should display access groups correctly", async () => { /** * Tests that access groups are displayed with the correct styling diff --git a/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.tsx b/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.tsx index 9bf66af0944..24e77108afe 100644 --- a/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.tsx +++ b/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.tsx @@ -165,6 +165,7 @@ export function MCPServerPermissions({ {mergedItems.map((item, index) => { const toolsForServer = item.type === "server" ? getToolPermissionsFor(item.value) : undefined; const hasToolRestrictions = toolsForServer && toolsForServer.length > 0; + const allowsNoTools = toolsForServer?.length === 0; const isExpanded = expandedServers.has(item.value); return ( @@ -197,6 +198,7 @@ export function MCPServerPermissions({ )} + {allowsNoTools && No tools allowed} {hasToolRestrictions && (
{toolsForServer.length}