fix(ui): show indirectly granted and name-keyed MCP servers in the tool matrix (#35154)

* fix(ui): show indirectly granted and name-keyed MCP servers in the tool matrix

The MCP tool permission editor was fed the direct server list only, so a server a
principal reaches through an access group or a toolset never appeared in the matrix.
That single blind spot produced two opposite bugs depending on how a save handler
filtered mcp_tool_permissions: filtering by the selected servers deletes an indirect
server's allowlist, and because a missing entry means "no restriction from this
level", the principal silently gains every tool on it; not filtering leaves a stale
entry that keeps a removed access group's server reachable, since a server named
under mcp_tool_permissions is entitled on purpose.

The editor now resolves the selected access groups and toolsets to their servers and
renders them alongside the direct ones, badged with where the grant comes from, so an
admin can see and clear an inherited server's tools like any other. Resolution reuses
the data the selector already loads: access groups resolve from each server's
mcp_access_groups, toolsets from the toolset's own tool list. When that data cannot be
loaded the editor says so instead of rendering an empty list, because an absent
inherited server reads as "there are none". Servers named only by an
mcp_tool_permissions key are listed too, which is what makes a leftover entry
visible; the opt-out sentinel still renders nothing, since it short-circuits the
backend resolver to zero servers.

Opening the editor no longer applies the delete-blocked-by-default allowlist to an
inherited server. Writing an entry for one would narrow a grant the admin never
touched just by opening the form; direct servers keep that default.

Both components also matched on server_id alone, while the backend accepts a server
id, name or alias interchangeably. A grant or allowlist written by API or config with
a name rendered as a selected server with no tools under it, which reads as "this
server has no tools". Matching now covers all three identifiers, and an edit writes
back to the key the entry already uses rather than forking a second id-keyed entry.

The same mismatch could also put one server under several keys at once, its id and
its name for instance. The backend unions every key's list, so reading one key
understated what was in force and writing one key left the others granting. The
resolver now reports, per server, the key an edit keeps, the equivalent keys it
supersedes, and the union those keys allow; the card renders the union and every
write goes through one function that writes the kept key and drops the superseded
ones. A key that also names a DIFFERENT server, which happens when two servers share
a name, is never dropped, because dropping it would strip the neighbouring server's
restriction; the card names such a key and says its tools stay allowed until the
servers no longer share the name, so an admin is told rather than left to infer it
from an edit that bounces back.

A third divergence from the backend sat in the same matching. The backend resolves an
identifier with exact-id precedence: a string that is a registry server id names that
server and stops, and only a string that is no server's id falls back to name and alias,
which can name several. Matching all three fields at once meant a server merely named
after another server's id joined the matrix as if it had been selected, and because it
landed there as a directly selected server it also received the delete-blocked default
write on open. Since an mcp_tool_permissions key is itself a grant source, saving then
handed out a server nobody granted, with no admin gesture involved. Identifier
resolution now mirrors the backend's precedence, and a key is read as this server's only
when it resolves back to it, so an entry that belongs to the id's owner is neither read
into this server's allowlist nor overwritten by an edit made against it.

A toolset grant was also invisible to the tool matrix. The backend unions a toolset's
tools with whatever mcp_tool_permissions allows, so a toolset-only grant restricts the
server to that toolset's tools; the editor read the map alone, found no entry and
rendered every tool on the server as allowed. Deselecting one from that state wrote all
the others as a permission entry, and the union turned a revocation into a grant of
every tool the toolset never included. The resolved entry now carries the toolset's
tools, so the matrix opens on what is actually in force, the delete-blocked default is
withheld from a server a toolset restricts, and a write keeps out the tools only the
toolset accounts for so a grant that ends with the toolset does not become a standing
one. Those tools cannot be revoked from this screen at all, since the backend unions
them in; they render allowed and locked and the card says which of them a toolset holds
open and where to go to revoke them.

That guard originally covered only the keys an edit supersedes, on the assumption that
the key it keeps names one server. It does not when a shared key is a server's only
entry: it then becomes the key an edit writes, and writing it moves the other server's
allowlist too, which is the widening the guard exists to prevent. The key an edit writes
is now the first one naming this server and no other, falling back to the server's own
id, so a shared key is never written through and an edit against one card cannot reach
the server behind the other. Both cards say the shared key holds tools open, since
neither can revoke them.

No owner's save handler changes here. With the full effective set now available to
the editor, the key and team handlers can filter against it instead of guessing,
which makes the internal-user surface's unfiltered save redundant

Resolves LIT-4963
Resolves LIT-4958

* chore: drop tsbuildinfo churn from merge

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(ui): satisfy dashboard lint budgets

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(ui): keep MCP tool allowlists for indirect grants

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(ui): keep standing MCP grants on team save

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* style(ui): format TeamInfo and hoist inline object args

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(ui): keep MCP tool allowlists for team servers granted indirectly (#35153)

* fix(ui): filter team MCP tool allowlists against the effective server set

Saving a team filtered mcp_tool_permissions down to the directly selected servers. A server reached
through an access group or a toolset is never in that list, so any save dropped its entry, including
a save that only changed the team alias. Because the resolver unions tool-permission keys into the
entitled server set and treats a missing entry as "no restriction from this level", the team kept
the server and lost the tool allowlist on it

Filtering on the direct list alone cannot get this right in either direction. Keeping every entry a
level did not directly select leaves a removed access group's server reachable through its own stale
entry, which breaks revocation. Dropping on deselection alone widens a server that an access group
still supplies

The save handler now resolves the effective server set with resolveEffectiveMcpServers and keeps an
entry only when something other than the entry itself still grants that server: a direct selection, a
selected access group, or a selected toolset. Unified access group ids are added when that selection
is untouched, since the loaded server list is then still accurate

When the server or toolset list cannot be resolved, every entry is kept and the admin is told the
allowlists were saved unchanged. Pruning on incomplete knowledge is the direction that silently
widens, so it only happens when the editor can show the server became unreachable. A failed lookup
and a changed access group selection are separate cases in a tagged union, so the notice names what
actually happened instead of describing the intentional one as a failure, and both hooks gate the
filter symmetrically so a save fired before toolsets settle cannot resolve against an empty toolset
list

Resolves LIT-4961

* fix(ui): resolve team MCP grants from access group metadata and refuse unsafe saves

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(ui): resolve team access group grants from team info when the access group list is role-gated

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(ui): match every selected access group by id instead of by count

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(ui): reload team access group grants at save time

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(ui): keep frontend lint budget within limit

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(ui): cover a standing allowlist no group grant covers at load or save

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(ui): keep MCP grant inputs in named variables for the lint budget

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(ui): guard MCP default write on toolset load, keep create toolsets, fix flat view

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Yassin Kortam 2026-09-05 14:49:30 -07:00 • committed by GitHub
parent 1745d74293
commit 0cb759772c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 2440 additions and 82 deletions

View file

@ -24,6 +24,30 @@ vi.mock("./agent_form_fields", () => ({
default: () => <div data-testid="agent-form-fields" />,
}));
vi.mock("@/components/mcp_server_management/MCPServerSelector", () => ({
default: ({
onChange,
}: {
onChange: (selection: { servers: string[]; accessGroups: string[]; toolsets: string[] }) => void;
}) => (
<button
type="button"
data-testid="select-mcp-toolset"
onClick={() => onChange({ servers: [], accessGroups: [], toolsets: ["ts-1"] })}
>
Select MCP toolset
</button>
),
}));
vi.mock("@/components/mcp_server_management/MCPToolPermissions", () => ({
default: () => null,
}));
vi.mock("@/components/common_components/team_dropdown", () => ({
default: () => null,
}));
const a2aInfo: AgentCreateInfo = {
agent_type: "a2a",
agent_type_display_name: "A2A Agent",
@ -97,4 +121,25 @@ describe("AddAgentForm logos", () => {
expect(warnSpy).toHaveBeenCalledTimes(2);
warnSpy.mockRestore();
});
it("includes selected MCP toolsets in the create payload", async () => {
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
vi.mocked(networking.createAgentCall).mockResolvedValue({
agent_id: "agent-1",
agent_name: "Test Agent",
} as never);
vi.mocked(networking.keyListCall).mockResolvedValue({ keys: [] });
renderForm();
await user.click(screen.getByRole("button", { name: "Next →" }));
await user.click(screen.getByTestId("select-mcp-toolset"));
await user.click(screen.getByRole("button", { name: "Next →" }));
await user.click(screen.getByRole("button", { name: "Next →" }));
await user.click(screen.getByText(/Skip for now/));
await user.click(screen.getByRole("button", { name: "Create Agent →" }));
await vi.waitFor(() => expect(networking.createAgentCall).toHaveBeenCalled());
const [, payload] = vi.mocked(networking.createAgentCall).mock.calls[0];
expect(payload.object_permission).toEqual({ mcp_toolsets: ["ts-1"] });
});
});

View file

@ -361,6 +361,7 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
const objectPermission: Record<string, unknown> = {
...(mcpServersAndGroups.servers?.length ? { mcp_servers: mcpServersAndGroups.servers } : {}),
...(mcpServersAndGroups.accessGroups?.length ? { mcp_access_groups: mcpServersAndGroups.accessGroups } : {}),
...(mcpServersAndGroups.toolsets?.length ? { mcp_toolsets: mcpServersAndGroups.toolsets } : {}),
...(Object.keys(toolPermissions).length ? { mcp_tool_permissions: toolPermissions } : {}),
...(entitlementModels.length ? { models: entitlementModels } : {}),
...(entitlementAgents.length ? { agents: entitlementAgents } : {}),
@ -520,6 +521,8 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
<MCPToolPermissions
accessToken={accessToken ?? ""}
selectedServers={mcpSelection?.servers ?? []}
selectedAccessGroups={mcpSelection?.accessGroups ?? []}
selectedToolsets={mcpSelection?.toolsets ?? []}
toolPermissions={mcpToolPermissions ?? {}}
onChange={(toolPerms: Record<string, string[]>) => form.setValue("mcp_tool_permissions", toolPerms)}
/>

View file

@ -17,6 +17,22 @@ import {
import Teams from "./Teams";
import { chooseSelectOption } from "../../tests/test-utils";
vi.mock("./mcp_server_management/MCPServerSelector", () => ({
default: ({
onChange,
}: {
onChange: (selection: { servers: string[]; accessGroups: string[]; toolsets: string[] }) => void;
}) => (
<button
type="button"
data-testid="select-mcp-toolset"
onClick={() => onChange({ servers: [], accessGroups: [], toolsets: ["ts-1"] })}
>
Select MCP toolset
</button>
),
}));
const can = vi.fn();
vi.mock("@/app/(dashboard)/hooks/useCan", () => ({
default: (...args: unknown[]) => can(...args),
@ -1343,6 +1359,16 @@ describe("Teams - the exact bytes the create call sends", () => {
});
});
it("includes selected MCP toolsets in the create object permission", async () => {
await openCreateModal();
await openSection("MCP Settings", /Allowed MCP Servers/);
fireEvent.click(screen.getByTestId("select-mcp-toolset"));
const payload = await submit();
expect(payload.object_permission).toStrictEqual({ mcp_toolsets: ["ts-1"] });
});
it.each([
["MCP Settings", /Allowed MCP Servers/, ["allowed_mcp_servers_and_groups", "mcp_tool_permissions"]],
["Agent Settings", /Allowed Agents/, ["allowed_agents_and_groups"]],

View file

@ -443,6 +443,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
(formValues.allowed_mcp_servers_and_groups &&
(formValues.allowed_mcp_servers_and_groups.servers?.length > 0 ||
formValues.allowed_mcp_servers_and_groups.accessGroups?.length > 0 ||
formValues.allowed_mcp_servers_and_groups.toolsets?.length > 0 ||
formValues.allowed_mcp_servers_and_groups.toolPermissions))
) {
if (!formValues.object_permission) {
@ -453,13 +454,16 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
delete formValues.allowed_vector_store_ids;
}
if (formValues.allowed_mcp_servers_and_groups) {
const { servers, accessGroups } = formValues.allowed_mcp_servers_and_groups;
const { servers, accessGroups, toolsets } = formValues.allowed_mcp_servers_and_groups;
if (servers && servers.length > 0) {
formValues.object_permission.mcp_servers = servers;
}
if (accessGroups && accessGroups.length > 0) {
formValues.object_permission.mcp_access_groups = accessGroups;
}
if (toolsets && toolsets.length > 0) {
formValues.object_permission.mcp_toolsets = toolsets;
}
delete formValues.allowed_mcp_servers_and_groups;
}
@ -1086,6 +1090,8 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
<MCPToolPermissions
accessToken={accessToken || ""}
selectedServers={watchedMcpSelection?.servers || []}
selectedAccessGroups={watchedMcpSelection?.accessGroups || []}
selectedToolsets={watchedMcpSelection?.toolsets || []}
toolPermissions={watchedToolPermissions || {}}
onChange={(toolPerms) => form.setValue("mcp_tool_permissions", toolPerms)}
/>

View file

@ -2,9 +2,11 @@ import { useState } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "../../../tests/test-utils";
import { renderWithProviders, testQueryClient } from "../../../tests/test-utils";
import MCPToolPermissions from "./MCPToolPermissions";
import * as networking from "../networking";
import { NO_MCP_SERVERS_SENTINEL } from "../mcp_tools/constants";
import type { MCPToolset } from "../mcp_tools/types";
vi.mock("../networking");
@ -15,6 +17,8 @@ describe("MCPToolPermissions", () => {
beforeEach(() => {
vi.clearAllMocks();
testQueryClient.clear();
vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([]);
});
it("should update tool permissions when user selects a tool", async () => {
@ -71,9 +75,10 @@ describe("MCPToolPermissions", () => {
await userEvent.click(screen.getByRole("checkbox", { name: "read_wiki_structure" }));
// Verify onChange was called with read_wiki_structure removed
expect(mockOnChange).toHaveBeenCalledWith({
const expectedToolPermissions = {
[mockServerId]: ["read_wiki_contents", "ask_question"],
});
};
expect(mockOnChange).toHaveBeenCalledWith(expectedToolPermissions);
// Verify API calls
// Note: useMCPServers uses useAuthorized() internally, which returns "123" from global mock
@ -184,6 +189,654 @@ describe("MCPToolPermissions", () => {
});
});
describe("servers reached indirectly", () => {
const groupServer = {
server_id: "srv-group-1",
server_name: "Group Server",
alias: "Group Server",
mcp_access_groups: ["production-group"],
};
const groupTools = [
{ name: "list_issues", description: "List issues" },
{ name: "delete_issue", description: "Delete an issue" },
];
it("renders the tool matrix for a server granted only through an access group", async () => {
vi.mocked(networking.fetchMCPServers).mockResolvedValue([groupServer]);
vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([]);
vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: groupTools, error: false });
const mockOnChange = vi.fn();
renderWithProviders(
<MCPToolPermissions
accessToken={mockAccessToken}
selectedServers={[]}
selectedAccessGroups={["production-group"]}
toolPermissions={{}}
onChange={mockOnChange}
/>,
);
expect(await screen.findByText("Group Server")).toBeInTheDocument();
expect(await screen.findByText("list_issues")).toBeInTheDocument();
expect(screen.getByText("delete_issue")).toBeInTheDocument();
expect(networking.listMCPTools).toHaveBeenCalledWith(mockAccessToken, groupServer.server_id);
});
it("shows every tool selected in flat view for an unrestricted access-group server", async () => {
vi.mocked(networking.fetchMCPServers).mockResolvedValue([groupServer]);
vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([]);
vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: groupTools, error: false });
const mockOnChange = vi.fn();
renderWithProviders(
<MCPToolPermissions
accessToken={mockAccessToken}
selectedServers={[]}
selectedAccessGroups={["production-group"]}
toolPermissions={{}}
onChange={mockOnChange}
/>,
);
await screen.findByText("Group Server");
await userEvent.click(screen.getByText("Flat List"));
const [listIssues, deleteIssue] = screen.getAllByRole("checkbox");
expect(listIssues).toBeChecked();
expect(deleteIssue).toBeChecked();
await userEvent.click(listIssues);
expect(mockOnChange).toHaveBeenCalledWith({ [groupServer.server_id]: ["delete_issue"] });
});
it("marks an access-group server as inherited and leaves a directly selected one unmarked", async () => {
const directServer = { server_id: "srv-direct-1", server_name: "Direct Server", alias: "Direct Server" };
vi.mocked(networking.fetchMCPServers).mockResolvedValue([directServer, groupServer]);
vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([]);
vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: groupTools, error: false });
renderWithProviders(
<MCPToolPermissions
accessToken={mockAccessToken}
selectedServers={[directServer.server_id]}
selectedAccessGroups={["production-group"]}
toolPermissions={{ [directServer.server_id]: ["list_issues"], [groupServer.server_id]: ["list_issues"] }}
onChange={vi.fn()}
/>,
);
expect(await screen.findByText("Direct Server")).toBeInTheDocument();
expect(await screen.findByText("Group Server")).toBeInTheDocument();
expect(screen.getByText("Via access group: production-group")).toBeInTheDocument();
expect(screen.queryAllByText(/^Via /)).toHaveLength(1);
});
it("renders a toolset server as inherited from that toolset", async () => {
const toolsetServer = { server_id: "srv-toolset-1", server_name: "Toolset Server", alias: "Toolset Server" };
vi.mocked(networking.fetchMCPServers).mockResolvedValue([toolsetServer]);
vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([
{
toolset_id: "ts-1",
toolset_name: "Support Toolset",
tools: [{ server_id: toolsetServer.server_id, tool_name: "list_issues" }],
},
]);
vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: groupTools, error: false });
renderWithProviders(
<MCPToolPermissions
accessToken={mockAccessToken}
selectedServers={[]}
selectedToolsets={["ts-1"]}
toolPermissions={{}}
onChange={vi.fn()}
/>,
);
expect(await screen.findByText("Toolset Server")).toBeInTheDocument();
expect(screen.getByText("Via toolset: Support Toolset")).toBeInTheDocument();
});
// The backend adds a toolset's tools to whatever mcp_tool_permissions holds, so showing the
// server as unrestricted would invite a deselection that grants every other tool on it.
it("shows a toolset's own tools as the allowed set and locks them", async () => {
const toolsetServer = { server_id: "srv-toolset-1", server_name: "Toolset Server", alias: "Toolset Server" };
vi.mocked(networking.fetchMCPServers).mockResolvedValue([toolsetServer]);
vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([
{
toolset_id: "ts-1",
toolset_name: "Support Toolset",
tools: [{ server_id: toolsetServer.server_id, tool_name: "list_issues" }],
},
]);
vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: groupTools, error: false });
const mockOnChange = vi.fn();
renderWithProviders(
<MCPToolPermissions
accessToken={mockAccessToken}
selectedServers={[]}
selectedToolsets={["ts-1"]}
toolPermissions={{}}
onChange={mockOnChange}
/>,
);
expect(await screen.findByText("list_issues")).toBeInTheDocument();
expect(
screen.getByText(
"list_issues is granted by a selected toolset, so it stays allowed here; edit the toolset to revoke it",
),
).toBeInTheDocument();
await userEvent.click(screen.getByText("Flat List"));
const [listIssues, deleteIssue] = screen.getAllByRole("checkbox");
expect(listIssues).toBeChecked();
expect(listIssues).toBeDisabled();
expect(deleteIssue).not.toBeChecked();
await userEvent.click(listIssues);
expect(mockOnChange).not.toHaveBeenCalled();
});
it("ignores a click on a locked tool in the risk-group view", async () => {
const toolsetServer = { server_id: "srv-toolset-1", server_name: "Toolset Server", alias: "Toolset Server" };
vi.mocked(networking.fetchMCPServers).mockResolvedValue([toolsetServer]);
vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([
{
toolset_id: "ts-1",
toolset_name: "Support Toolset",
tools: [{ server_id: toolsetServer.server_id, tool_name: "list_issues" }],
},
]);
vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: groupTools, error: false });
const mockOnChange = vi.fn();
renderWithProviders(
<MCPToolPermissions
accessToken={mockAccessToken}
selectedServers={[]}
selectedToolsets={["ts-1"]}
toolPermissions={{}}
onChange={mockOnChange}
/>,
);
await userEvent.click(await screen.findByText("list_issues"));
expect(mockOnChange).not.toHaveBeenCalled();
});
// Turning a risk group off must not drop a tool the entry grants in its own right, which the
// toolset happens to grant too: that tool outlives the toolset and the admin did not clear it.
it("keeps a locked tool the entry also grants when its risk group is turned off", async () => {
const toolsetServer = { server_id: "srv-toolset-1", server_name: "Toolset Server", alias: "Toolset Server" };
vi.mocked(networking.fetchMCPServers).mockResolvedValue([toolsetServer]);
vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([
{
toolset_id: "ts-1",
toolset_name: "Support Toolset",
tools: [{ server_id: toolsetServer.server_id, tool_name: "list_issues" }],
},
]);
vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: groupTools, error: false });
const mockOnChange = vi.fn();
renderWithProviders(
<MCPToolPermissions
accessToken={mockAccessToken}
selectedServers={[]}
selectedToolsets={["ts-1"]}
toolPermissions={{ [toolsetServer.server_id]: ["list_issues"] }}
onChange={mockOnChange}
/>,
);
expect(await screen.findByText("list_issues")).toBeInTheDocument();
// First checkbox is the header toggle of the group holding list_issues.
await userEvent.click(screen.getAllByRole("checkbox")[0]);
expect(mockOnChange).toHaveBeenCalledWith({ [toolsetServer.server_id]: ["list_issues"] });
});
// Copying the toolset's tools into the entry would outlive the toolset, so a write keeps only
// what this level grants on its own.
it("leaves a toolset's tools out of the entry a Select All writes", async () => {
const toolsetServer = { server_id: "srv-toolset-1", server_name: "Toolset Server", alias: "Toolset Server" };
vi.mocked(networking.fetchMCPServers).mockResolvedValue([toolsetServer]);
vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([
{
toolset_id: "ts-1",
toolset_name: "Support Toolset",
tools: [{ server_id: toolsetServer.server_id, tool_name: "list_issues" }],
},
]);
vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: groupTools, error: false });
const mockOnChange = vi.fn();
renderWithProviders(
<MCPToolPermissions
accessToken={mockAccessToken}
selectedServers={[]}
selectedToolsets={["ts-1"]}
toolPermissions={{}}
onChange={mockOnChange}
/>,
);
expect(await screen.findByText("list_issues")).toBeInTheDocument();
await userEvent.click(screen.getByText("Select All"));
expect(mockOnChange).toHaveBeenCalledWith({ [toolsetServer.server_id]: ["delete_issue"] });
});
// The default narrows an unrestricted server; against a toolset-restricted one it would widen
// the grant to every non-delete tool the server exposes.
it("does not write the delete-blocked default for a directly selected server a toolset restricts", async () => {
const directServer = { server_id: "srv-direct-1", server_name: "Direct Server", alias: "Direct Server" };
vi.mocked(networking.fetchMCPServers).mockResolvedValue([directServer]);
vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([
{
toolset_id: "ts-1",
toolset_name: "Support Toolset",
tools: [{ server_id: directServer.server_id, tool_name: "list_issues" }],
},
]);
vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: groupTools, error: false });
const mockOnChange = vi.fn();
renderWithProviders(
<MCPToolPermissions
accessToken={mockAccessToken}
selectedServers={[directServer.server_id]}
selectedToolsets={["ts-1"]}
toolPermissions={{}}
onChange={mockOnChange}
/>,
);
expect(await screen.findByText("list_issues")).toBeInTheDocument();
expect(mockOnChange).not.toHaveBeenCalled();
});
it("waits for toolsets before writing the delete-blocked default", async () => {
const directServer = { server_id: "srv-direct-1", server_name: "Direct Server", alias: "Direct Server" };
let resolveToolsets: (toolsets: MCPToolset[]) => void = () => {};
const pendingToolsets = new Promise<MCPToolset[]>((resolve) => {
resolveToolsets = resolve;
});
vi.mocked(networking.fetchMCPServers).mockResolvedValue([directServer]);
vi.mocked(networking.fetchMCPToolsets).mockReturnValue(pendingToolsets);
vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: groupTools, error: false });
const mockOnChange = vi.fn();
renderWithProviders(
<MCPToolPermissions
accessToken={mockAccessToken}
selectedServers={[directServer.server_id]}
selectedToolsets={["ts-1"]}
toolPermissions={{}}
onChange={mockOnChange}
/>,
);
await screen.findByText("Direct Server");
expect(mockOnChange).not.toHaveBeenCalled();
resolveToolsets([
{
toolset_id: "ts-1",
toolset_name: "Support Toolset",
tools: [{ server_id: directServer.server_id, tool_name: "list_issues" }],
},
]);
await screen.findByText("list_issues");
expect(screen.getByRole("checkbox", { name: "list_issues" })).toHaveAttribute("aria-disabled", "true");
expect(mockOnChange).not.toHaveBeenCalled();
});
// The backend resolves a selection that is a registry id to that server alone. Rendering the
// server merely named after it would fire the default write against a server nobody granted,
// and a tool-permission entry is itself a grant.
it.each([
{ label: "id owner first", idOwnerFirst: true },
{ label: "name twin first", idOwnerFirst: false },
])("does not offer a server merely named after a selected id ($label)", async ({ idOwnerFirst }) => {
const idOwner = { server_id: "srv-collide", server_name: "Payments", alias: "Payments" };
const nameTwin = { server_id: "srv-twin", server_name: "srv-collide", alias: "srv-collide" };
vi.mocked(networking.fetchMCPServers).mockResolvedValue(idOwnerFirst ? [idOwner, nameTwin] : [nameTwin, idOwner]);
vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([]);
vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: groupTools, error: false });
const mockOnChange = vi.fn();
renderWithProviders(
<MCPToolPermissions
accessToken={mockAccessToken}
selectedServers={["srv-collide"]}
toolPermissions={{}}
onChange={mockOnChange}
/>,
);
expect(await screen.findByText("Payments")).toBeInTheDocument();
expect(screen.queryByText("srv-collide")).not.toBeInTheDocument();
await waitFor(() => {
expect(mockOnChange).toHaveBeenCalledWith({ "srv-collide": ["list_issues"] });
});
expect(mockOnChange.mock.calls.every(([written]) => !Object.hasOwn(written, "srv-twin"))).toBe(true);
expect(networking.listMCPTools).not.toHaveBeenCalledWith(mockAccessToken, "srv-twin");
});
it("does not write a default allowlist for an inherited server", async () => {
vi.mocked(networking.fetchMCPServers).mockResolvedValue([groupServer]);
vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([]);
vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: groupTools, error: false });
const mockOnChange = vi.fn();
renderWithProviders(
<MCPToolPermissions
accessToken={mockAccessToken}
selectedServers={[]}
selectedAccessGroups={["production-group"]}
toolPermissions={{}}
onChange={mockOnChange}
/>,
);
expect(await screen.findByText("list_issues")).toBeInTheDocument();
expect(mockOnChange).not.toHaveBeenCalled();
});
it("keeps blocking delete tools by default for a directly selected server", async () => {
const directServer = { server_id: "srv-direct-1", server_name: "Direct Server", alias: "Direct Server" };
vi.mocked(networking.fetchMCPServers).mockResolvedValue([directServer]);
vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([]);
vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: groupTools, error: false });
const mockOnChange = vi.fn();
renderWithProviders(
<MCPToolPermissions
accessToken={mockAccessToken}
selectedServers={[directServer.server_id]}
toolPermissions={{}}
onChange={mockOnChange}
/>,
);
await waitFor(() => {
expect(mockOnChange).toHaveBeenCalledWith({ [directServer.server_id]: ["list_issues"] });
});
});
it("shows a server that only a stale tool-permission entry still entitles", async () => {
vi.mocked(networking.fetchMCPServers).mockResolvedValue([groupServer]);
vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([]);
vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: groupTools, error: false });
renderWithProviders(
<MCPToolPermissions
accessToken={mockAccessToken}
selectedServers={[]}
selectedAccessGroups={[]}
toolPermissions={{ [groupServer.server_id]: ["list_issues"] }}
onChange={vi.fn()}
/>,
);
expect(await screen.findByText("Group Server")).toBeInTheDocument();
expect(screen.getByText("Via tool permissions")).toBeInTheDocument();
});
it("shows nothing for a principal blocked from every MCP server", async () => {
vi.mocked(networking.fetchMCPServers).mockResolvedValue([groupServer]);
vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([]);
vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: groupTools, error: false });
const { container } = renderWithProviders(
<MCPToolPermissions
accessToken={mockAccessToken}
selectedServers={[NO_MCP_SERVERS_SENTINEL]}
toolPermissions={{ [groupServer.server_id]: ["list_issues"] }}
onChange={vi.fn()}
/>,
);
expect(container).toBeEmptyDOMElement();
expect(networking.listMCPTools).not.toHaveBeenCalled();
});
it("warns instead of showing no inherited servers when the server list cannot be loaded", async () => {
vi.mocked(networking.fetchMCPServers).mockRejectedValue(new Error("boom"));
vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([]);
renderWithProviders(
<MCPToolPermissions
accessToken={mockAccessToken}
selectedServers={[]}
selectedAccessGroups={["production-group"]}
toolPermissions={{}}
onChange={vi.fn()}
/>,
);
expect(await screen.findByText("Unable to load MCP servers")).toBeInTheDocument();
});
it("warns when the selected toolsets cannot be resolved to servers", async () => {
vi.mocked(networking.fetchMCPServers).mockResolvedValue([]);
vi.mocked(networking.fetchMCPToolsets).mockRejectedValue(new Error("boom"));
renderWithProviders(
<MCPToolPermissions
accessToken={mockAccessToken}
selectedServers={[]}
selectedToolsets={["ts-1"]}
toolPermissions={{}}
onChange={vi.fn()}
/>,
);
expect(await screen.findByText("Unable to load toolsets")).toBeInTheDocument();
});
});
describe("grants keyed by server name", () => {
const namedServer = {
server_id: "1f4bd6c1-0000-4000-8000-000000000001",
server_name: "github_mcp",
alias: "GitHub",
};
const namedTools = [
{ name: "list_issues", description: "List issues" },
{ name: "delete_issue", description: "Delete an issue" },
];
it("renders the tool matrix for a grant that names the server instead of its id", async () => {
vi.mocked(networking.fetchMCPServers).mockResolvedValue([namedServer]);
vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([]);
vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: namedTools, error: false });
renderWithProviders(
<MCPToolPermissions
accessToken={mockAccessToken}
selectedServers={["github_mcp"]}
toolPermissions={{ github_mcp: ["list_issues"] }}
onChange={vi.fn()}
/>,
);
expect(await screen.findByText("github_mcp")).toBeInTheDocument();
expect(await screen.findByText("list_issues")).toBeInTheDocument();
expect(screen.getByText("delete_issue")).toBeInTheDocument();
expect(networking.listMCPTools).toHaveBeenCalledWith(mockAccessToken, namedServer.server_id);
});
it("writes an edit back to the name key instead of adding a second id-keyed entry", async () => {
vi.mocked(networking.fetchMCPServers).mockResolvedValue([namedServer]);
vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([]);
vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: namedTools, error: false });
const mockOnChange = vi.fn();
renderWithProviders(
<MCPToolPermissions
accessToken={mockAccessToken}
selectedServers={["github_mcp"]}
toolPermissions={{ github_mcp: ["list_issues"] }}
onChange={mockOnChange}
/>,
);
expect(await screen.findByText("list_issues")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Deselect All" }));
expect(mockOnChange).toHaveBeenCalledWith({ github_mcp: [] });
});
});
describe("a server named by several equivalent keys", () => {
const namedServer = {
server_id: "1f4bd6c1-0000-4000-8000-000000000001",
server_name: "github_mcp",
alias: "GitHub",
mcp_access_groups: ["production-group"],
};
const namedTools = [
{ name: "list_issues", description: "List issues" },
{ name: "create_issue", description: "Open an issue" },
{ name: "delete_issue", description: "Delete an issue" },
];
const renderWithBothKeys = (onChange: () => void) =>
renderWithProviders(
<MCPToolPermissions
accessToken={mockAccessToken}
selectedServers={[namedServer.server_id]}
toolPermissions={{ [namedServer.server_id]: ["list_issues"], github_mcp: ["create_issue"] }}
onChange={onChange}
/>,
);
beforeEach(() => {
vi.mocked(networking.fetchMCPServers).mockResolvedValue([namedServer]);
vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([]);
vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: namedTools, error: false });
});
it("renders one card showing the union both keys grant", async () => {
renderWithBothKeys(vi.fn());
expect(await screen.findByText("github_mcp")).toBeInTheDocument();
expect(screen.getAllByText("github_mcp")).toHaveLength(1);
expect(await screen.findByText("list_issues")).toBeInTheDocument();
// Flat view keeps checkbox order identical to the fetched tool order.
await userEvent.click(screen.getByText("Flat List"));
const [listIssues, createIssue, deleteIssue] = screen.getAllByRole("checkbox");
expect(listIssues).toBeChecked();
expect(createIssue).toBeChecked();
expect(deleteIssue).not.toBeChecked();
});
it("removes a deselected tool from every equivalent key, leaving one entry for the server", async () => {
const mockOnChange = vi.fn();
renderWithBothKeys(mockOnChange);
expect(await screen.findByText("list_issues")).toBeInTheDocument();
await userEvent.click(screen.getByText("Flat List"));
await userEvent.click(screen.getAllByRole("checkbox")[0]);
const written = mockOnChange.mock.calls.at(-1)?.[0] as Record<string, string[]>;
expect(Object.keys(written)).toEqual([namedServer.server_id]);
expect(written[namedServer.server_id]).not.toContain("list_issues");
expect(written[namedServer.server_id]).toContain("create_issue");
});
// Both catalog orders, because a name resolves to two servers here and a first-match
// implementation is only wrong in one of them.
it.each([
{ label: "edited server first", editedFirst: true },
{ label: "twin first", editedFirst: false },
])(
"says on the card when a key names another server too, since its tools cannot be revoked here ($label)",
async ({ editedFirst }) => {
const twin = { server_id: "1f4bd6c1-0000-4000-8000-000000000002", server_name: "github_mcp", alias: "Twin" };
vi.mocked(networking.fetchMCPServers).mockResolvedValue(
editedFirst ? [namedServer, twin] : [twin, namedServer],
);
renderWithProviders(
<MCPToolPermissions
accessToken={mockAccessToken}
selectedServers={[namedServer.server_id]}
toolPermissions={{ [namedServer.server_id]: ["list_issues"], github_mcp: ["create_issue"] }}
onChange={vi.fn()}
/>,
);
// Both cards say it: the shared key grants on either server and neither card can revoke it,
// so an admin looking at either one has to be told the same thing.
expect(
await screen.findAllByText(
'Also granted by "github_mcp", which names another server too. Those tools stay allowed here until the servers no longer share that name',
),
).toHaveLength(2);
},
);
// The shared key is the twin's only entry, so it would otherwise be the key an edit writes,
// and writing it would move the allowlist of the server the admin is not looking at.
it.each([
{ label: "edited server first", editedFirst: true },
{ label: "twin first", editedFirst: false },
])("edits the twin through its own id rather than the shared key ($label)", async ({ editedFirst }) => {
const twin = { server_id: "1f4bd6c1-0000-4000-8000-000000000002", server_name: "github_mcp", alias: "Twin" };
vi.mocked(networking.fetchMCPServers).mockResolvedValue(editedFirst ? [namedServer, twin] : [twin, namedServer]);
const mockOnChange = vi.fn();
renderWithProviders(
<MCPToolPermissions
accessToken={mockAccessToken}
selectedServers={[twin.server_id]}
toolPermissions={{ github_mcp: ["list_issues"] }}
onChange={mockOnChange}
/>,
);
// The directly selected twin is the first card; both share the display name "github_mcp".
expect(await screen.findAllByText("list_issues")).toHaveLength(2);
await userEvent.click(screen.getAllByText("Select All")[0]);
const written = mockOnChange.mock.calls.at(-1)?.[0] as Record<string, string[]>;
expect(written["github_mcp"]).toEqual(["list_issues"]);
expect(written[twin.server_id]).toEqual(["list_issues", "create_issue", "delete_issue"]);
});
it("says nothing about shared names when every key names one server", async () => {
renderWithBothKeys(vi.fn());
expect(await screen.findByText("github_mcp")).toBeInTheDocument();
expect(screen.queryByText(/names another server too/)).not.toBeInTheDocument();
});
it("badges the server once, by its strongest grant, when a key and a group both name it", async () => {
renderWithProviders(
<MCPToolPermissions
accessToken={mockAccessToken}
selectedServers={[]}
selectedAccessGroups={["production-group"]}
toolPermissions={{ github_mcp: ["list_issues"] }}
onChange={vi.fn()}
/>,
);
expect(await screen.findByText("github_mcp")).toBeInTheDocument();
expect(screen.getByText("Via access group: production-group")).toBeInTheDocument();
expect(screen.queryByText("Via tool permissions")).not.toBeInTheDocument();
expect(screen.queryAllByText(/^Via /)).toHaveLength(1);
});
});
describe("risk-group (CRUD) view", () => {
const crudTools = [
{ name: "list_documents", description: "List every document" },

View file

@ -1,28 +1,62 @@
import React, { useEffect, useRef, useState, useMemo } from "react";
import { listMCPTools } from "../networking";
import { MCPTool, MCPServer } from "../mcp_tools/types";
import { MCPTool } from "../mcp_tools/types";
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 { useMCPToolsets } from "../../app/(dashboard)/hooks/mcpServers/useMCPToolsets";
import McpCrudPermissionPanel from "../mcp_tools/McpCrudPermissionPanel";
import { classifyToolOp } from "../../utils/mcpToolCrudClassification";
import { NO_MCP_SERVERS_SENTINEL } from "../mcp_tools/constants";
import {
EffectiveMcpServer,
McpGrantSource,
applyToolPermissionWrite,
mcpAllowedToolsFor,
resolveEffectiveMcpServers,
} from "./effectiveMcpServers";
interface MCPToolPermissionsProps {
accessToken: string;
selectedServers: string[];
selectedServers: readonly string[];
selectedAccessGroups?: readonly string[];
selectedToolsets?: readonly string[];
toolPermissions: Record<string, string[]>;
onChange: (toolPermissions: Record<string, string[]>) => void;
disabled?: boolean;
}
const NO_SELECTION: readonly string[] = [];
interface InheritedBadge {
readonly label: string;
readonly className: string;
}
const inheritedBadgeFor = (source: McpGrantSource): InheritedBadge | null => {
switch (source.kind) {
case "direct":
return null;
case "accessGroup":
return { label: `Via access group: ${source.name}`, className: "text-green-700 bg-green-50 border-green-200" };
case "toolset":
return { label: `Via toolset: ${source.name}`, className: "text-purple-700 bg-purple-50 border-purple-200" };
case "toolPermission":
return { label: "Via tool permissions", className: "text-amber-700 bg-amber-50 border-amber-200" };
}
};
const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
accessToken,
selectedServers,
selectedAccessGroups = NO_SELECTION,
selectedToolsets = NO_SELECTION,
toolPermissions,
onChange,
disabled = false,
}) => {
const { data: allServers = [] } = useMCPServers();
const { data: allServers = [], isError: serversFailed, isLoading: serversLoading } = useMCPServers();
const { data: toolsets = [], isError: toolsetsFailed, isLoading: toolsetsLoading } = useMCPToolsets();
const [serverTools, setServerTools] = useState<Record<string, MCPTool[]>>({});
const [loadingTools, setLoadingTools] = useState<Record<string, boolean>>({});
const [toolErrors, setToolErrors] = useState<Record<string, string>>({});
@ -36,15 +70,25 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
toolPermissionsRef.current = toolPermissions;
}, [toolPermissions]);
// Filter servers based on selectedServers
const servers = useMemo(() => {
if (selectedServers.length === 0) return [];
return allServers.filter((server: MCPServer) => selectedServers.includes(server.server_id));
}, [allServers, selectedServers]);
// Every server this permission level reaches, not just the directly selected ones: a server
// reached through an access group or a toolset needs its allowlist visible and editable too.
const effectiveMcpInput = {
allServers,
selectedServers,
selectedAccessGroups,
selectedToolsets,
toolsets,
toolPermissions,
};
const servers = useMemo(
() => resolveEffectiveMcpServers(effectiveMcpInput),
[allServers, selectedServers, selectedAccessGroups, selectedToolsets, toolsets, toolPermissions],
);
// Fetch tools for a specific server; applies delete-blocked-by-default for new servers.
// `token` is passed explicitly so the closure never captures a stale accessToken.
const fetchToolsForServer = async (serverId: string, token: string) => {
const fetchToolsForServer = async (entry: EffectiveMcpServer, token: string) => {
const serverId = entry.server.server_id;
setLoadingTools((prev) => ({ ...prev, [serverId]: true }));
setToolErrors((prev) => ({ ...prev, [serverId]: "" }));
@ -58,14 +102,18 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
const fetchedTools: MCPTool[] = response.tools || [];
setServerTools((prev) => ({ ...prev, [serverId]: fetchedTools }));
// For servers that have no permissions stored yet, block delete tools by default.
// Default only unrestricted direct servers to non-delete tools.
// Read latest permissions from the ref to avoid clobbering concurrent results.
const latestPermissions = toolPermissionsRef.current;
if (!latestPermissions[serverId] && fetchedTools.length > 0) {
const isDirect = entry.source.kind === "direct";
const unrestricted =
mcpAllowedToolsFor(entry.server, latestPermissions, allServers) === undefined &&
entry.toolsetTools === undefined;
if (isDirect && unrestricted && (selectedToolsets.length === 0 || !toolsetsFailed) && fetchedTools.length > 0) {
const nonDeleteTools = fetchedTools
.filter((t) => classifyToolOp(t.name, t.description || "") !== "delete")
.map((t) => t.name);
onChange({ ...latestPermissions, [serverId]: nonDeleteTools });
onChange(applyToolPermissionWrite({ toolPermissions: latestPermissions, entry, allowed: nonDeleteTools }));
}
}
} catch (err) {
@ -79,58 +127,124 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
// Auto-fetch tools when servers or accessToken change
useEffect(() => {
servers.forEach((server) => {
if (!serverTools[server.server_id] && !loadingTools[server.server_id]) {
fetchToolsForServer(server.server_id, accessToken);
if (toolsetsLoading) return;
servers.forEach((entry) => {
const serverId = entry.server.server_id;
if (!serverTools[serverId] && !loadingTools[serverId]) {
fetchToolsForServer(entry, accessToken);
}
});
// fetchToolsForServer is defined in this render scope but receives `accessToken`
// as an explicit argument, so it is safe to omit from deps here.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [servers, accessToken]);
}, [servers, accessToken, toolsetsLoading]);
const handleCrudPanelChange = (serverId: string, allowed: string[]) => {
onChange({ ...toolPermissions, [serverId]: allowed });
// Every write goes through here so an edit is authoritative for the SERVER, not for one of the
// equivalent keys that may name it.
const writeAllowedTools = (entry: EffectiveMcpServer, allowed: string[]) => {
onChange(applyToolPermissionWrite({ toolPermissions, entry, allowed }));
};
const handleSelectAll = (serverId: string) => {
const tools = serverTools[serverId] || [];
onChange({ ...toolPermissions, [serverId]: tools.map((t) => t.name) });
const handleSelectAll = (entry: EffectiveMcpServer) => {
const tools = serverTools[entry.server.server_id] || [];
writeAllowedTools(
entry,
tools.map((t) => t.name),
);
};
const handleDeselectAll = (serverId: string) => {
onChange({ ...toolPermissions, [serverId]: [] });
};
// The opt-out sentinel short-circuits the backend resolver to zero servers, so nothing stored
// here is in force and showing a tool matrix would claim otherwise.
if (selectedServers.includes(NO_MCP_SERVERS_SENTINEL)) {
return null;
}
if (selectedServers.length === 0) {
const selectionSizes = [
selectedServers.length,
selectedAccessGroups.length,
selectedToolsets.length,
Object.keys(toolPermissions).length,
];
if (!selectionSizes.some((size) => size > 0)) {
return null;
}
return (
<div className="space-y-4">
{servers.map((server) => {
const serverName = server.server_name || server.alias || server.server_id;
const tools = serverTools[server.server_id] || [];
const selectedTools = toolPermissions[server.server_id] || [];
const isLoading = loadingTools[server.server_id];
const error = toolErrors[server.server_id];
const viewMode = viewModes[server.server_id] ?? "crud";
{serversFailed && (
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm text-yellow-800 font-medium">Unable to load MCP servers</p>
<p className="text-sm text-yellow-700 mt-1">
This list is incomplete; servers granted directly or through an access group may be missing. Reload before
changing tool permissions
</p>
</div>
)}
{toolsetsFailed && selectedToolsets.length > 0 && (
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm text-yellow-800 font-medium">Unable to load toolsets</p>
<p className="text-sm text-yellow-700 mt-1">
Servers reached through the selected toolsets are not listed below
</p>
</div>
)}
{serversLoading && (
<div className="flex items-center justify-center py-6">
<UiLoadingSpinner />
<p className="ml-3 text-sm text-muted-foreground">Loading MCP servers...</p>
</div>
)}
{servers.map((entry) => {
const server = entry.server;
const serverId = server.server_id;
const serverName = server.server_name || server.alias || serverId;
const tools = serverTools[serverId] || [];
const selectedTools = entry.allowedTools ?? tools.map((t) => t.name);
const isLoading = loadingTools[serverId];
const error = toolErrors[serverId];
const viewMode = viewModes[serverId] ?? "crud";
const inherited = inheritedBadgeFor(entry.source);
// The backend adds a toolset's tools to whatever this map allows, so these stay on however
// the boxes are ticked. Locking them is what keeps the matrix an honest picture of the grant.
const toolsetTools = entry.toolsetTools ?? [];
return (
<div key={server.server_id} className="border rounded-lg bg-muted">
<div key={serverId} className={`border rounded-lg bg-muted ${inherited ? "border-dashed" : ""}`}>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b bg-card rounded-t-lg">
<div>
<p className="text-sm font-semibold text-foreground">{serverName}</p>
<div className="flex items-center gap-2">
<p className="text-sm font-semibold text-foreground">{serverName}</p>
{inherited && (
<span
className={`px-1.5 py-0.5 text-[10px] font-semibold border rounded-sm uppercase tracking-wide ${inherited.className}`}
>
{inherited.label}
</span>
)}
</div>
{server.description && <p className="text-sm text-muted-foreground">{server.description}</p>}
{entry.ambiguousKeys.length > 0 && (
<p className="text-sm text-amber-700 mt-1">
{`Also granted by ${entry.ambiguousKeys.map((key) => `"${key}"`).join(", ")}, which names another server too. Those tools stay allowed here until the servers no longer share that name`}
</p>
)}
{toolsetTools.length > 0 && (
<p className="text-sm text-purple-700 mt-1">
{toolsetTools.length === 1
? `${toolsetTools[0]} is granted by a selected toolset, so it stays allowed here; edit the toolset to revoke it`
: `${toolsetTools.join(", ")} are granted by a selected toolset, so they stay allowed here; edit the toolset to revoke them`}
</p>
)}
</div>
<div className="flex items-center gap-3">
{!disabled && tools.length > 0 && (
<RadioGroup
value={viewMode}
onValueChange={(next) =>
setViewModes((prev) => ({ ...prev, [server.server_id]: next as "crud" | "flat" }))
}
onValueChange={(next) => setViewModes((prev) => ({ ...prev, [serverId]: next as "crud" | "flat" }))}
className="flex w-auto items-center gap-4"
>
<label className="flex items-center gap-2 text-sm">
@ -148,7 +262,7 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
<button
type="button"
className="text-sm text-info hover:text-info/80 font-medium"
onClick={() => handleSelectAll(server.server_id)}
onClick={() => handleSelectAll(entry)}
disabled={isLoading}
>
Select All
@ -156,7 +270,7 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
<button
type="button"
className="text-sm text-info hover:text-info/80 font-medium"
onClick={() => handleDeselectAll(server.server_id)}
onClick={() => writeAllowedTools(entry, [])}
disabled={isLoading}
>
Deselect All
@ -188,8 +302,9 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
{!isLoading && !error && tools.length > 0 && viewMode === "crud" && (
<McpCrudPermissionPanel
tools={tools}
value={!toolPermissions[server.server_id] ? undefined : selectedTools}
onChange={(allowed) => handleCrudPanelChange(server.server_id, allowed)}
value={entry.allowedTools === undefined ? undefined : [...selectedTools]}
lockedTools={toolsetTools}
onChange={(allowed) => writeAllowedTools(entry, allowed)}
readOnly={disabled}
/>
)}
@ -199,6 +314,7 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
<div className="space-y-2">
{tools.map((tool) => {
const isSelected = selectedTools.includes(tool.name);
const isLocked = toolsetTools.includes(tool.name);
return (
<div key={tool.name} className="flex items-start gap-2">
<input
@ -206,13 +322,13 @@ const MCPToolPermissions: React.FC<MCPToolPermissionsProps> = ({
aria-label={tool.name}
checked={isSelected}
onChange={() => {
if (disabled) return;
if (disabled || isLocked) return;
const next = isSelected
? selectedTools.filter((n) => n !== tool.name)
: [...selectedTools, tool.name];
handleCrudPanelChange(server.server_id, next);
writeAllowedTools(entry, next);
}}
disabled={disabled}
disabled={disabled || isLocked}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">

View file

@ -0,0 +1,524 @@
import { describe, it, expect } from "vitest";
import { MCPServer, MCPToolset } from "../mcp_tools/types";
import {
applyToolPermissionWrite,
mcpAllowedToolsFor,
mcpServersForIdentifier,
mcpToolPermissionKeyFor,
resolveEffectiveMcpServers,
} from "./effectiveMcpServers";
const server = (overrides: Partial<MCPServer> & { server_id: string }): MCPServer =>
({
server_name: null,
alias: null,
created_at: "2026-01-01",
created_by: "admin",
updated_at: "2026-01-01",
updated_by: "admin",
...overrides,
}) as MCPServer;
const toolset = (overrides: Partial<MCPToolset> & { toolset_id: string }): MCPToolset =>
({ toolset_name: overrides.toolset_id, tools: [], ...overrides }) as MCPToolset;
const emptyInput = {
allServers: [] as readonly MCPServer[],
selectedServers: [] as readonly string[],
selectedAccessGroups: [] as readonly string[],
selectedToolsets: [] as readonly string[],
toolsets: [] as readonly MCPToolset[],
toolPermissions: {} as Readonly<Record<string, readonly string[]>>,
};
describe("mcpServersForIdentifier", () => {
const target = server({ server_id: "uuid-1", server_name: "github_mcp", alias: "GitHub" });
it("matches on id, server name and alias alike", () => {
expect(mcpServersForIdentifier([target], "uuid-1")).toEqual([target]);
expect(mcpServersForIdentifier([target], "github_mcp")).toEqual([target]);
expect(mcpServersForIdentifier([target], "GitHub")).toEqual([target]);
expect(mcpServersForIdentifier([target], "other")).toEqual([]);
});
it("names every server sharing a duplicated name, as the backend does", () => {
const twin = server({ server_id: "uuid-2", server_name: "github_mcp" });
expect(mcpServersForIdentifier([target, twin], "github_mcp").map((match) => match.server_id)).toEqual([
"uuid-1",
"uuid-2",
]);
});
// The backend's expand_permission_list resolves a registry server id to that server and stops;
// only a string that is no server's id falls through to the name/alias pass. An identifier can
// name several servers, so this has to hold in either catalog order.
it.each([
{ label: "id owner first", idOwnerFirst: true },
{ label: "name twin first", idOwnerFirst: false },
])("resolves an id to its own server even when another server is named after it ($label)", ({ idOwnerFirst }) => {
const byId = server({ server_id: "collide", server_name: "Payments" });
const byName = server({ server_id: "uuid-9", server_name: "collide" });
const catalog = idOwnerFirst ? [byId, byName] : [byName, byId];
expect(mcpServersForIdentifier(catalog, "collide").map((match) => match.server_id)).toEqual(["collide"]);
});
});
describe("mcpToolPermissionKeyFor", () => {
const target = server({ server_id: "uuid-1", server_name: "github_mcp", alias: "GitHub" });
it("returns the existing name key so an edit does not fork into a second entry", () => {
expect(mcpToolPermissionKeyFor(target, { github_mcp: ["list_issues"] }, [target])).toBe("github_mcp");
});
// The map is a second collection of non-unique identifiers for one server, and this picks a
// winner from it, so both write orders have to hold or a map-order winner would slip through.
it.each([
{ label: "id key first", toolPermissions: { "uuid-1": ["list_prs"], github_mcp: ["list_issues"] } },
{ label: "name key first", toolPermissions: { github_mcp: ["list_issues"], "uuid-1": ["list_prs"] } },
])("prefers the id key when both an id and a name key exist ($label)", ({ toolPermissions }) => {
expect(mcpToolPermissionKeyFor(target, toolPermissions, [target])).toBe("uuid-1");
});
it("falls back to the server id when no entry exists yet", () => {
expect(mcpToolPermissionKeyFor(target, {}, [target])).toBe("uuid-1");
});
// Writing this key would hand the entry's tools to the server that owns the id, not to the one
// being edited, so it is not this server's key however much its name looks like it.
it.each([
{ label: "id owner first", idOwnerFirst: true },
{ label: "name twin first", idOwnerFirst: false },
])("ignores a key that is another server's id ($label)", ({ idOwnerFirst }) => {
const byId = server({ server_id: "collide", server_name: "Payments" });
const byName = server({ server_id: "uuid-9", server_name: "collide" });
const catalog = idOwnerFirst ? [byId, byName] : [byName, byId];
expect(mcpToolPermissionKeyFor(byName, { collide: ["list_issues"] }, catalog)).toBe("uuid-9");
expect(mcpAllowedToolsFor(byName, { collide: ["list_issues"] }, catalog)).toBeUndefined();
expect(mcpAllowedToolsFor(byId, { collide: ["list_issues"] }, catalog)).toEqual(["list_issues"]);
});
});
describe("resolveEffectiveMcpServers", () => {
const direct = server({ server_id: "srv-direct", server_name: "Direct" });
const grouped = server({ server_id: "srv-group", server_name: "Grouped", mcp_access_groups: ["prod"] });
const inToolset = server({ server_id: "srv-toolset", server_name: "Toolsetted" });
it("resolves a selected access group to its member servers", () => {
const resolved = resolveEffectiveMcpServers({
...emptyInput,
allServers: [direct, grouped],
selectedAccessGroups: ["prod"],
});
expect(resolved).toEqual([
{
server: grouped,
permissionKey: "srv-group",
supersededKeys: [],
ambiguousKeys: [],
keyedTools: undefined,
toolsetTools: undefined,
allowedTools: undefined,
source: { kind: "accessGroup", name: "prod" },
},
]);
});
it("resolves access groups stored as objects rather than plain names", () => {
const objectGrouped = { ...grouped, mcp_access_groups: [{ name: "prod" }] } as unknown as MCPServer;
const resolved = resolveEffectiveMcpServers({
...emptyInput,
allServers: [objectGrouped],
selectedAccessGroups: ["prod"],
});
expect(resolved.map((entry) => entry.server.server_id)).toEqual(["srv-group"]);
});
it("resolves a selected toolset to the servers its tools live on", () => {
const input = {
...emptyInput,
allServers: [direct, inToolset],
selectedToolsets: ["ts-1"],
toolsets: [
toolset({
toolset_id: "ts-1",
toolset_name: "Support",
tools: [{ server_id: "srv-toolset", tool_name: "list_issues" }],
}),
],
};
const resolved = resolveEffectiveMcpServers(input);
expect(resolved).toEqual([
{
server: inToolset,
permissionKey: "srv-toolset",
supersededKeys: [],
ambiguousKeys: [],
keyedTools: undefined,
toolsetTools: ["list_issues"],
allowedTools: ["list_issues"],
source: { kind: "toolset", name: "Support" },
},
]);
});
it("yields nothing for a toolset that is not in the loaded list", () => {
const input = {
...emptyInput,
allServers: [inToolset],
selectedToolsets: ["ts-missing"],
toolsets: [],
};
const resolved = resolveEffectiveMcpServers(input);
expect(resolved).toEqual([]);
});
it("includes a server that only a tool-permission entry names", () => {
const resolved = resolveEffectiveMcpServers({
...emptyInput,
allServers: [grouped],
toolPermissions: { "srv-group": ["list_issues"] },
});
expect(resolved).toEqual([
{
server: grouped,
permissionKey: "srv-group",
supersededKeys: [],
ambiguousKeys: [],
keyedTools: ["list_issues"],
toolsetTools: undefined,
allowedTools: ["list_issues"],
source: { kind: "toolPermission" },
},
]);
});
it("reports a server once, attributing it to the strongest grant", () => {
const input = {
...emptyInput,
allServers: [grouped],
selectedServers: ["srv-group"],
selectedAccessGroups: ["prod"],
toolPermissions: { "srv-group": ["list_issues"] },
};
const resolved = resolveEffectiveMcpServers(input);
expect(resolved).toEqual([
{
server: grouped,
permissionKey: "srv-group",
supersededKeys: [],
ambiguousKeys: [],
keyedTools: ["list_issues"],
toolsetTools: undefined,
allowedTools: ["list_issues"],
source: { kind: "direct" },
},
]);
});
it("resolves a server selected by name", () => {
const named = server({ server_id: "uuid-1", server_name: "github_mcp", alias: "GitHub" });
const input = {
...emptyInput,
allServers: [named],
selectedServers: ["github_mcp"],
toolPermissions: { github_mcp: ["list_issues"] },
};
const resolved = resolveEffectiveMcpServers(input);
expect(resolved).toEqual([
{
server: named,
permissionKey: "github_mcp",
supersededKeys: [],
ambiguousKeys: [],
keyedTools: ["list_issues"],
toolsetTools: undefined,
allowedTools: ["list_issues"],
source: { kind: "direct" },
},
]);
});
// The backend resolves a selection that is a registry id to that server alone, so a server that
// merely answers to the same string is not in the grant and must not become editable here: an
// edit would write its own id into mcp_tool_permissions, which is itself a grant.
it.each([
{ label: "id owner first", idOwnerFirst: true },
{ label: "name twin first", idOwnerFirst: false },
])("does not resolve a selected id to a server merely named after it ($label)", ({ idOwnerFirst }) => {
const byId = server({ server_id: "collide", server_name: "Payments" });
const byName = server({ server_id: "uuid-9", server_name: "collide" });
const resolved = resolveEffectiveMcpServers({
...emptyInput,
allServers: idOwnerFirst ? [byId, byName] : [byName, byId],
selectedServers: ["collide"],
});
expect(resolved.map((entry) => entry.server.server_id)).toEqual(["collide"]);
});
it("resolves every server sharing a duplicated name, as the backend does", () => {
const first = server({ server_id: "uuid-1", server_name: "shared" });
const second = server({ server_id: "uuid-2", server_name: "shared" });
const resolved = resolveEffectiveMcpServers({
...emptyInput,
allServers: [first, second],
selectedServers: ["shared"],
});
expect(resolved.map((entry) => entry.server.server_id)).toEqual(["uuid-1", "uuid-2"]);
});
});
describe("equivalent permission keys for one server", () => {
const named = server({ server_id: "uuid-1", server_name: "github_mcp", alias: "GitHub" });
const other = server({ server_id: "uuid-2", server_name: "other_mcp" });
it("unions every equivalent key, which is what the backend enforces", () => {
expect(mcpAllowedToolsFor(named, { "uuid-1": ["list_issues"], github_mcp: ["create_issue"] }, [named])).toEqual([
"list_issues",
"create_issue",
]);
});
it("reports the extra keys as superseded so a write can collapse them", () => {
const input = {
...emptyInput,
allServers: [named],
selectedServers: ["uuid-1"],
toolPermissions: { "uuid-1": ["list_issues"], github_mcp: ["create_issue"], GitHub: ["delete_issue"] },
};
const resolved = resolveEffectiveMcpServers(input);
expect(resolved).toHaveLength(1);
expect(resolved[0].permissionKey).toBe("uuid-1");
expect(resolved[0].supersededKeys).toEqual(["github_mcp", "GitHub"]);
expect(resolved[0].ambiguousKeys).toEqual([]);
expect(resolved[0].allowedTools).toEqual(["list_issues", "create_issue", "delete_issue"]);
});
it("collapses a write onto the kept key and drops the equivalents", () => {
const toolPermissions = { "uuid-1": ["list_issues"], github_mcp: ["create_issue"], "uuid-2": ["ping"] };
const input = {
...emptyInput,
allServers: [named, other],
selectedServers: ["uuid-1"],
toolPermissions,
};
const [entry] = resolveEffectiveMcpServers(input);
expect(applyToolPermissionWrite({ toolPermissions, entry, allowed: ["list_issues"] })).toEqual({
"uuid-1": ["list_issues"],
"uuid-2": ["ping"],
});
});
it("leaves a single-key server, and every other server, untouched", () => {
const toolPermissions = { github_mcp: ["list_issues"], "uuid-2": ["ping"] };
const input = {
...emptyInput,
allServers: [named, other],
selectedServers: ["github_mcp"],
toolPermissions,
};
const [entry] = resolveEffectiveMcpServers(input);
expect(entry.supersededKeys).toEqual([]);
expect(entry.ambiguousKeys).toEqual([]);
expect(applyToolPermissionWrite({ toolPermissions, entry, allowed: [] })).toEqual({
github_mcp: [],
"uuid-2": ["ping"],
});
});
// A name resolves to several servers, so a first-match implementation is right in one catalog
// order and wrong in the other; both orders have to hold for this to pin anything.
it.each([
{ label: "edited server first", editedFirst: true },
{ label: "other server first", editedFirst: false },
])("never drops a key that also names a different server ($label)", ({ editedFirst }) => {
const firstShared = server({ server_id: "uuid-1", server_name: "shared" });
const secondShared = server({ server_id: "uuid-2", server_name: "shared" });
const toolPermissions = { "uuid-1": ["list_issues"], shared: ["create_issue"] };
const input = {
...emptyInput,
allServers: editedFirst ? [firstShared, secondShared] : [secondShared, firstShared],
selectedServers: ["uuid-1"],
toolPermissions,
};
const [entry] = resolveEffectiveMcpServers(input);
expect(entry.supersededKeys).toEqual([]);
expect(entry.ambiguousKeys).toEqual(["shared"]);
expect(applyToolPermissionWrite({ toolPermissions, entry, allowed: ["list_issues"] })).toEqual({
"uuid-1": ["list_issues"],
shared: ["create_issue"],
});
});
// The shared key is the only entry, so it would otherwise be the key an edit writes; writing it
// moves the other server's allowlist too, which is the same widening the secondary-key guard
// exists to prevent. Both catalog orders, since a shared name resolves to several servers.
it.each([
{ label: "edited server first", editedFirst: true },
{ label: "other server first", editedFirst: false },
])("never writes through a shared key, even as a server's only entry ($label)", ({ editedFirst }) => {
const firstShared = server({ server_id: "uuid-1", server_name: "shared" });
const secondShared = server({ server_id: "uuid-2", server_name: "shared" });
const toolPermissions = { shared: ["list_issues"] };
const input = {
...emptyInput,
allServers: editedFirst ? [firstShared, secondShared] : [secondShared, firstShared],
selectedServers: ["uuid-1"],
toolPermissions,
};
const resolved = resolveEffectiveMcpServers(input);
const edited = resolved.find((entry) => entry.server.server_id === "uuid-1")!;
expect(edited.permissionKey).toBe("uuid-1");
expect(edited.supersededKeys).toEqual([]);
expect(edited.ambiguousKeys).toEqual(["shared"]);
// What the backend enforces on this server today, which is what the card has to show.
expect(edited.allowedTools).toEqual(["list_issues"]);
expect(
applyToolPermissionWrite({ toolPermissions, entry: edited, allowed: ["list_issues", "create_issue"] }),
).toEqual({
shared: ["list_issues"],
"uuid-1": ["list_issues", "create_issue"],
});
});
it("adds an entry for a server that had none", () => {
const [entry] = resolveEffectiveMcpServers({
...emptyInput,
allServers: [named],
selectedServers: ["uuid-1"],
});
expect(applyToolPermissionWrite({ toolPermissions: {}, entry, allowed: ["list_issues"] })).toEqual({
"uuid-1": ["list_issues"],
});
});
// A key that is another server's id belongs to that server, so reading it here would overstate
// what this one allows and writing it would hand this server's tools to the other one.
it.each([
{ label: "id owner first", idOwnerFirst: true },
{ label: "name twin first", idOwnerFirst: false },
])("does not treat another server's id as this server's key ($label)", ({ idOwnerFirst }) => {
const byId = server({ server_id: "collide", server_name: "Payments" });
const byName = server({ server_id: "uuid-9", server_name: "collide" });
const toolPermissions = { collide: ["list_issues"] };
const input = {
...emptyInput,
allServers: idOwnerFirst ? [byId, byName] : [byName, byId],
selectedServers: ["uuid-9"],
toolPermissions,
};
const resolved = resolveEffectiveMcpServers(input);
const edited = resolved.find((entry) => entry.server.server_id === "uuid-9")!;
expect(edited.permissionKey).toBe("uuid-9");
expect(edited.supersededKeys).toEqual([]);
expect(edited.allowedTools).toBeUndefined();
expect(applyToolPermissionWrite({ toolPermissions, entry: edited, allowed: ["ping"] })).toEqual({
collide: ["list_issues"],
"uuid-9": ["ping"],
});
});
});
// The backend adds a toolset's tools to whatever mcp_tool_permissions holds, so a toolset grant is
// part of what this level allows and none of it can be revoked by writing that map.
describe("tools a selected toolset grants", () => {
const inToolset = server({ server_id: "srv-toolset", server_name: "Toolsetted" });
const support = toolset({
toolset_id: "ts-1",
toolset_name: "Support",
tools: [
{ server_id: "srv-toolset", tool_name: "list_issues" },
{ server_id: "srv-other", tool_name: "ignored" },
],
});
const resolveOne = (toolPermissions: Readonly<Record<string, readonly string[]>>) => {
const input = {
...emptyInput,
allServers: [inToolset],
selectedToolsets: ["ts-1"],
toolsets: [support],
toolPermissions,
};
return resolveEffectiveMcpServers(input)[0];
};
it("reports them as allowed rather than leaving the server unrestricted", () => {
const entry = resolveOne({});
expect(entry.toolsetTools).toEqual(["list_issues"]);
expect(entry.allowedTools).toEqual(["list_issues"]);
});
it("unions them with what the permission key allows", () => {
const entry = resolveOne({ "srv-toolset": ["create_issue"] });
expect(entry.keyedTools).toEqual(["create_issue"]);
expect(entry.allowedTools).toEqual(["create_issue", "list_issues"]);
});
it("keeps a write from copying a toolset tool into the permission entry", () => {
const entry = resolveOne({});
expect(applyToolPermissionWrite({ toolPermissions: {}, entry, allowed: ["list_issues", "create_issue"] })).toEqual({
"srv-toolset": ["create_issue"],
});
});
it("keeps a tool the entry already holds even when the toolset grants it too", () => {
const toolPermissions = { "srv-toolset": ["list_issues", "create_issue"] };
const entry = resolveOne(toolPermissions);
expect(applyToolPermissionWrite({ toolPermissions, entry, allowed: ["list_issues", "create_issue"] })).toEqual({
"srv-toolset": ["list_issues", "create_issue"],
});
});
it("still narrows a tool only the permission entry grants", () => {
const toolPermissions = { "srv-toolset": ["create_issue", "delete_repo"] };
const entry = resolveOne(toolPermissions);
expect(applyToolPermissionWrite({ toolPermissions, entry, allowed: ["list_issues", "create_issue"] })).toEqual({
"srv-toolset": ["create_issue"],
});
});
it("leaves a server no selected toolset names unrestricted", () => {
const input = {
...emptyInput,
allServers: [inToolset],
selectedServers: ["srv-toolset"],
selectedToolsets: [],
toolsets: [support],
};
const untouched = resolveEffectiveMcpServers(input)[0];
expect(untouched.toolsetTools).toBeUndefined();
expect(untouched.allowedTools).toBeUndefined();
});
});

View file

@ -0,0 +1,216 @@
import { z } from "zod/v4";
import { MCPServer, MCPToolset } from "../mcp_tools/types";
// Mirrors the backend resolver's union (direct + access_group + tool_perm + toolset), so the
// editor shows exactly the servers this permission level entitles.
export type McpGrantSource =
| { readonly kind: "direct" }
| { readonly kind: "accessGroup"; readonly name: string }
| { readonly kind: "toolset"; readonly name: string }
| { readonly kind: "toolPermission" };
export interface EffectiveMcpServer {
readonly server: MCPServer;
// The mcp_tool_permissions key an edit writes to. The backend accepts a server id, name or
// alias interchangeably, so an API- or config-written entry may use any of them; writing the
// key already in the map keeps an edit from leaving the original entry behind.
readonly permissionKey: string;
// Other keys in the map that name this same server. The backend unions every key's list, so a
// write that touched only `permissionKey` would leave these still granting.
readonly supersededKeys: readonly string[];
// Keys naming this server that name another server too, which happens when two servers share a
// name or alias. They are kept rather than collapsed, so their tools cannot be revoked here; the
// editor has to say so rather than let an edit look like it narrowed the grant.
readonly ambiguousKeys: readonly string[];
// What the mcp_tool_permissions entries alone allow: the union across every equivalent key.
// `undefined` means no entry at all, so those keys impose no restriction.
readonly keyedTools: readonly string[] | undefined;
// Tools a selected toolset grants on this server. The backend unions them with the keyed tools,
// so they are allowed whatever this map holds and no edit here can revoke them; the editor shows
// them allowed and locked rather than as tools an admin is free to turn off.
readonly toolsetTools: readonly string[] | undefined;
// What this level actually allows on the server, which is what the backend enforces: the keyed
// union widened by the toolset grant. `undefined` means nothing restricts the server from here.
readonly allowedTools: readonly string[] | undefined;
readonly source: McpGrantSource;
}
interface ResolveInput {
readonly allServers: readonly MCPServer[];
readonly selectedServers: readonly string[];
readonly selectedAccessGroups: readonly string[];
readonly selectedToolsets: readonly string[];
readonly toolsets: readonly MCPToolset[];
readonly toolPermissions: Readonly<Record<string, readonly string[]>>;
}
// Access groups come back as plain names, but older records carry `{ name }` objects.
const accessGroupRefSchema = z.union([z.string(), z.object({ name: z.string() })]);
const accessGroupNamesOf = (server: MCPServer): readonly string[] =>
(server.mcp_access_groups ?? []).flatMap((group) => {
const parsed = accessGroupRefSchema.safeParse(group);
if (!parsed.success) return [];
return [typeof parsed.data === "string" ? parsed.data : parsed.data.name];
});
// Which servers an identifier names, with the same precedence the backend's expand_permission_list
// applies: a string that is a registry server id names exactly that server, and only a string that
// is not falls back to server_name/alias, which can name several. Matching all three fields at once
// would attach a grant to a server the backend never resolves the identifier to, so an edit made
// against that server would hand it access the original grant did not include.
export const mcpServersForIdentifier = (allServers: readonly MCPServer[], identifier: string): readonly MCPServer[] => {
const byId = allServers.filter((server) => server.server_id === identifier);
if (byId.length > 0) return byId;
return allServers.filter((server) => server.server_name === identifier || server.alias === identifier);
};
// Every key in the map that names this server, id first so an id key stays the one an edit keeps.
// A key spelled like this server's name still belongs to another server when that string is that
// server's id, so the catalog decides membership rather than a field-by-field comparison.
export const mcpToolPermissionKeysFor = (
server: MCPServer,
toolPermissions: Readonly<Record<string, readonly string[]>>,
allServers: readonly MCPServer[],
): readonly string[] =>
[server.server_id, server.server_name, server.alias].filter(
(identifier): identifier is string =>
typeof identifier === "string" &&
Object.hasOwn(toolPermissions, identifier) &&
mcpServersForIdentifier(allServers, identifier).some((match) => match.server_id === server.server_id),
);
// A key naming more than one server cannot be written on any one server's behalf: the backend
// unions it into every match, so an edit made here would move the other server's allowlist too.
const mcpKeyNamesOneServerOnly = (allServers: readonly MCPServer[], key: string): boolean =>
mcpServersForIdentifier(allServers, key).length === 1;
// The key an edit writes: the first one that names this server and no other, falling back to the
// server's own id. When the only entry is a key several servers share, that fallback creates an
// id-keyed entry rather than rewriting the shared one, which would edit the other server too.
export const mcpToolPermissionKeyFor = (
server: MCPServer,
toolPermissions: Readonly<Record<string, readonly string[]>>,
allServers: readonly MCPServer[],
): string =>
mcpToolPermissionKeysFor(server, toolPermissions, allServers).find((key) =>
mcpKeyNamesOneServerOnly(allServers, key),
) ?? server.server_id;
// The union the backend enforces across equivalent keys, first-seen order preserved.
export const mcpAllowedToolsFor = (
server: MCPServer,
toolPermissions: Readonly<Record<string, readonly string[]>>,
allServers: readonly MCPServer[],
): readonly string[] | undefined => {
const keys = mcpToolPermissionKeysFor(server, toolPermissions, allServers);
if (keys.length === 0) return undefined;
return [...new Set(keys.flatMap((key) => toolPermissions[key] ?? []))];
};
// Tool names the given toolsets grant on this server, `undefined` when they grant none.
const mcpToolsetToolsFor = (
server: MCPServer,
selectedToolsets: readonly string[],
toolsets: readonly MCPToolset[],
): readonly string[] | undefined => {
const granted = [
...new Set(
toolsets
.filter((toolset) => selectedToolsets.includes(toolset.toolset_id))
.flatMap((toolset) =>
toolset.tools.filter((tool) => tool.server_id === server.server_id).map((tool) => tool.tool_name),
),
),
];
return granted.length > 0 ? granted : undefined;
};
// Collapse a server's grant onto one key: the kept key gets exactly what the admin sees, and the
// equivalent keys are dropped so nothing keeps granting under another spelling. A key that also
// names a DIFFERENT server (duplicate server names) is never dropped, since that would silently
// strip the other server's restriction.
export const applyToolPermissionWrite = ({
toolPermissions,
entry,
allowed,
}: {
readonly toolPermissions: Readonly<Record<string, readonly string[]>>;
readonly entry: EffectiveMcpServer;
readonly allowed: readonly string[];
}): Record<string, string[]> => {
// A toolset grant is unioned in by the backend, so copying its tools into this entry would turn a
// grant that ends with the toolset into a standing one. Leaving out a tool the entry already
// holds would go the other way and drop a grant that survives the toolset, so only the tools the
// toolset alone accounts for are withheld.
const toolsetOnly = (entry.toolsetTools ?? []).filter((tool) => !(entry.keyedTools ?? []).includes(tool));
const written = allowed.filter((tool) => !toolsetOnly.includes(tool));
const kept: [string, string[]][] = Object.entries(toolPermissions)
.filter(([key]) => !entry.supersededKeys.includes(key))
.map(([key, tools]) => [key, key === entry.permissionKey ? [...written] : [...tools]]);
const withWrite: [string, string[]][] = Object.hasOwn(toolPermissions, entry.permissionKey)
? kept
: [...kept, [entry.permissionKey, [...written]]];
return Object.fromEntries(withWrite);
};
export const resolveEffectiveMcpServers = ({
allServers,
selectedServers,
selectedAccessGroups,
selectedToolsets,
toolsets,
toolPermissions,
}: ResolveInput): readonly EffectiveMcpServer[] => {
const entry = (server: MCPServer, source: McpGrantSource): EffectiveMcpServer => {
const keys = mcpToolPermissionKeysFor(server, toolPermissions, allServers);
const permissionKey = mcpToolPermissionKeyFor(server, toolPermissions, allServers);
const editable = keys.filter((key) => key !== permissionKey);
const keyedTools = mcpAllowedToolsFor(server, toolPermissions, allServers);
const toolsetTools = mcpToolsetToolsFor(server, selectedToolsets, toolsets);
return {
server,
permissionKey,
supersededKeys: editable.filter((key) => mcpKeyNamesOneServerOnly(allServers, key)),
ambiguousKeys: editable.filter((key) => !mcpKeyNamesOneServerOnly(allServers, key)),
keyedTools,
toolsetTools,
allowedTools:
keyedTools === undefined && toolsetTools === undefined
? undefined
: [...new Set([...(keyedTools ?? []), ...(toolsetTools ?? [])])],
source,
};
};
const direct = selectedServers.flatMap((identifier) =>
mcpServersForIdentifier(allServers, identifier).map((server) => entry(server, { kind: "direct" })),
);
const viaAccessGroups = selectedAccessGroups.flatMap((group) =>
allServers
.filter((server) => accessGroupNamesOf(server).includes(group))
.map((server) => entry(server, { kind: "accessGroup", name: group })),
);
const viaToolsets = selectedToolsets.flatMap((toolsetId) => {
const toolset = toolsets.find((candidate) => candidate.toolset_id === toolsetId);
if (!toolset) return [];
const toolsetServerIds = new Set(toolset.tools.map((tool) => tool.server_id));
return allServers
.filter((server) => toolsetServerIds.has(server.server_id))
.map((server) => entry(server, { kind: "toolset", name: toolset.toolset_name }));
});
// A server named only under mcp_tool_permissions is entitled on purpose, so it belongs in the
// editor: without it, an entry left over from a removed access group is invisible and unclearable.
const viaToolPermissions = Object.keys(toolPermissions).flatMap((key) =>
mcpServersForIdentifier(allServers, key).map((server) => entry(server, { kind: "toolPermission" })),
);
const candidates = [...direct, ...viaAccessGroups, ...viaToolsets, ...viaToolPermissions];
return candidates.filter(
(candidate, index) =>
candidates.findIndex((other) => other.server.server_id === candidate.server.server_id) === index,
);
};

View file

@ -25,6 +25,11 @@ interface McpCrudPermissionPanelProps {
value: string[] | undefined;
/** Called whenever the allowed set changes. Always emits a concrete string[]. */
onChange: (allowed: string[]) => void;
/**
* Tools that are allowed by a grant this panel cannot edit, so they stay allowed whatever is
* ticked here. They render allowed and disabled, and a group toggle leaves them on.
*/
lockedTools?: readonly string[];
readOnly?: boolean;
/**
* Optional search filter string. When set, only tools whose name or description
@ -61,10 +66,13 @@ const GROUP_HEADER_BG: Record<CrudOp, string> = {
// ---------------------------------------------------------------------------
const NO_LOCKED_TOOLS: readonly string[] = [];
const McpCrudPermissionPanel: React.FC<McpCrudPermissionPanelProps> = ({
tools,
value,
onChange,
lockedTools = NO_LOCKED_TOOLS,
readOnly = false,
searchFilter = "",
}) => {
@ -90,6 +98,8 @@ const McpCrudPermissionPanel: React.FC<McpCrudPermissionPanelProps> = ({
return new Set(value);
}, [value, tools]);
const locked: Set<string> = useMemo(() => new Set(lockedTools), [lockedTools]);
const isToolAllowed = (name: string) => effectiveAllowed.has(name);
const isGroupFullyAllowed = (op: CrudOp) => {
@ -105,7 +115,7 @@ const McpCrudPermissionPanel: React.FC<McpCrudPermissionPanelProps> = ({
};
const toggleTool = (toolName: string) => {
if (readOnly) return;
if (readOnly || locked.has(toolName)) return;
const next = new Set(effectiveAllowed);
if (next.has(toolName)) {
next.delete(toolName);
@ -121,7 +131,7 @@ const McpCrudPermissionPanel: React.FC<McpCrudPermissionPanelProps> = ({
for (const tool of grouped[op]) {
if (enable) {
next.add(tool.name);
} else {
} else if (!locked.has(tool.name)) {
next.delete(tool.name);
}
}
@ -220,18 +230,19 @@ const McpCrudPermissionPanel: React.FC<McpCrudPermissionPanelProps> = ({
)
.map((tool) => {
const allowed = isToolAllowed(tool.name);
const isLocked = locked.has(tool.name);
return (
<div
key={tool.name}
className={`flex items-start gap-3 px-4 py-2.5 transition-colors hover:bg-accent ${
!readOnly ? "cursor-pointer" : ""
!readOnly && !isLocked ? "cursor-pointer" : ""
} ${allowed ? "" : "opacity-60"}`}
onClick={() => toggleTool(tool.name)}
>
<Checkbox
aria-label={tool.name}
checked={allowed}
disabled={readOnly}
disabled={readOnly || isLocked}
onClick={(e) => e.stopPropagation()}
/>
<div className="flex-1 min-w-0">

View file

@ -61,7 +61,6 @@ import {
} from "../key_team_helpers/fetch_available_models_team_key";
import { Team } from "../key_team_helpers/key_list";
import MCPServerSelector from "../mcp_server_management/MCPServerSelector";
import { NO_MCP_SERVERS_SENTINEL } from "../mcp_tools/constants";
import MCPToolPermissions from "../mcp_server_management/MCPToolPermissions";
import { toast } from "@/lib/toast";
import {
@ -119,14 +118,18 @@ interface McpToolPermissionsFieldProps {
}
const McpToolPermissionsField: React.FC<McpToolPermissionsFieldProps> = ({ accessToken, control, setValue }) => {
const selection = useWatch({ control, name: "allowed_mcp_servers_and_groups" }) as { servers?: string[] } | undefined;
const selection = useWatch({ control, name: "allowed_mcp_servers_and_groups" }) as
| { servers?: string[]; accessGroups?: string[]; toolsets?: string[] }
| undefined;
const toolPermissions = useWatch({ control, name: "mcp_tool_permissions" }) as Record<string, string[]> | undefined;
return (
<div className="mt-6">
<MCPToolPermissions
accessToken={accessToken}
selectedServers={(selection?.servers || []).filter((s: string) => s !== NO_MCP_SERVERS_SENTINEL)}
selectedServers={selection?.servers || []}
selectedAccessGroups={selection?.accessGroups || []}
selectedToolsets={selection?.toolsets || []}
toolPermissions={toolPermissions || {}}
onChange={(toolPerms) => setValue("mcp_tool_permissions", toolPerms)}
/>

View file

@ -106,8 +106,7 @@ describe("MCPServerPermissions", () => {
expect(screen.queryByText("ask_question")).not.toBeInTheDocument();
// Click the server row to expand
const serverRow = screen.getByText(/DW_MCP/).closest("div");
await userEvent.click(serverRow!);
await userEvent.click(screen.getByText(/DW_MCP/));
// Now tools should be visible
await waitFor(() => {
@ -117,7 +116,7 @@ describe("MCPServerPermissions", () => {
});
// Click the server row again to collapse
await userEvent.click(serverRow!);
await userEvent.click(screen.getByText(/DW_MCP/));
// Tools should be hidden again
await waitFor(() => {
@ -297,11 +296,8 @@ describe("MCPServerPermissions", () => {
expect(toolLabels.length).toBeGreaterThan(0);
// Expand both servers by clicking their rows
const server1Row = screen.getByText(/DW_MCP/).closest("div");
const server2Row = screen.getByText(/Test Server/).closest("div");
await userEvent.click(server1Row!); // Expand server 1
await userEvent.click(server2Row!); // Expand server 2
await userEvent.click(screen.getByText(/DW_MCP/)); // Expand server 1
await userEvent.click(screen.getByText(/Test Server/)); // Expand server 2
// Verify server 1 tools are now visible
await waitFor(() => {
@ -356,6 +352,69 @@ describe("MCPServerPermissions", () => {
expect(networking.fetchMCPServers).not.toHaveBeenCalled();
});
it("should name a server a grant references by server name rather than by id", async () => {
/**
* The backend accepts a server id, name or alias interchangeably, so a grant written by API
* or config can name the server. Falling through to the raw string hides which server it is.
*/
vi.mocked(networking.fetchMCPServers).mockResolvedValue([
{
server_id: mockServerId1,
server_name: "github_mcp",
alias: "GitHub",
},
]);
render(
<MCPServerPermissions
mcpServers={["github_mcp"]}
mcpAccessGroups={[]}
mcpToolPermissions={{}}
accessToken={mockAccessToken}
/>,
);
expect(await screen.findByText(/GitHub/)).toBeInTheDocument();
expect(screen.queryByText("github_mcp")).not.toBeInTheDocument();
});
it("should display tools for a name-keyed allowlist on an id-referenced server", async () => {
/**
* The allowlist key and the grant entry can name the same server differently; matching only on
* server_id renders the server as if it had no tool restrictions at all.
*/
vi.mocked(networking.fetchMCPServers).mockResolvedValue([
{
server_id: mockServerId1,
server_name: "github_mcp",
alias: "GitHub",
},
]);
render(
<MCPServerPermissions
mcpServers={[mockServerId1]}
mcpAccessGroups={[]}
mcpToolPermissions={{ github_mcp: ["list_issues", "list_prs"] }}
accessToken={mockAccessToken}
/>,
);
await waitFor(() => {
expect(screen.getByText(/GitHub/)).toBeInTheDocument();
});
expect(screen.getByText("2")).toBeInTheDocument();
expect(screen.getByText("tools")).toBeInTheDocument();
await userEvent.click(screen.getByText(/GitHub/));
await waitFor(() => {
expect(screen.getByText("list_issues")).toBeInTheDocument();
expect(screen.getByText("list_prs")).toBeInTheDocument();
});
});
it("should display the All Proxy MCP Servers state instead of the raw sentinel string", async () => {
vi.mocked(networking.fetchMCPServers).mockResolvedValue([]);

View file

@ -5,6 +5,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { fetchMCPServers, fetchMCPToolsets } from "../networking";
import { MCPServer, MCPToolset } from "../mcp_tools/types";
import { ALL_PROXY_MCP_SERVERS_SENTINEL, NO_MCP_SERVERS_SENTINEL } from "../mcp_tools/constants";
import { mcpAllowedToolsFor, mcpServersForIdentifier } from "../mcp_server_management/effectiveMcpServers";
import { InheritedGrant, inheritedGrantTooltip } from "./inheritedGrants";
interface MCPServerPermissionsProps {
@ -94,14 +95,26 @@ export function MCPServerPermissions({
fetchToolsets();
}, [accessToken, mcpToolsets.length]);
// Function to get display name for MCP server
const getMCPServerDisplayName = (serverId: string) => {
const serverDetail = mcpServerDetails.find((server) => server.server_id === serverId);
// A grant may name a server by id, name or alias, resolved the way the backend resolves it.
const getMCPServerDisplayName = (serverIdentifier: string) => {
const [serverDetail] = mcpServersForIdentifier(mcpServerDetails, serverIdentifier);
if (serverDetail) {
const label = serverDetail.alias || serverDetail.server_name || serverDetail.server_id;
const serverId = serverDetail.server_id;
const truncatedId = serverId.length > 7 ? `${serverId.slice(0, 3)}...${serverId.slice(-4)}` : serverId;
return `${serverDetail.alias || serverDetail.server_name || serverId} (${truncatedId})`;
return `${label} (${truncatedId})`;
}
return serverId;
return serverIdentifier;
};
// The allowlist may be keyed by a different identifier than the grant names the server by, and
// several equivalent keys may each carry part of it; the backend enforces their union.
const getToolPermissionsFor = (serverIdentifier: string): readonly string[] | undefined => {
const [serverDetail] = mcpServersForIdentifier(mcpServerDetails, serverIdentifier);
if (!serverDetail) {
return mcpToolPermissions[serverIdentifier];
}
return mcpAllowedToolsFor(serverDetail, mcpToolPermissions, mcpServerDetails);
};
const blocksAllMcpServers = mcpServers.includes(NO_MCP_SERVERS_SENTINEL);
@ -143,7 +156,7 @@ export function MCPServerPermissions({
) : totalCount > 0 ? (
<div className="max-h-[400px] overflow-y-auto space-y-2 pr-1">
{mergedItems.map((item, index) => {
const toolsForServer = item.type === "server" ? mcpToolPermissions[item.value] : undefined;
const toolsForServer = item.type === "server" ? getToolPermissionsFor(item.value) : undefined;
const hasToolRestrictions = toolsForServer && toolsForServer.length > 0;
const isExpanded = expandedServers.has(item.value);

View file

@ -4,7 +4,16 @@ import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { chooseSelectOption, renderWithProviders, testQueryClient } from "../../../tests/test-utils";
import TeamInfoView, { type TeamData } from "./TeamInfo";
import { toast } from "@/lib/toast";
import type { EffectiveMcpServer } from "../mcp_server_management/effectiveMcpServers";
import type { MCPServer } from "../mcp_tools/types";
import TeamInfoView, {
grantedMcpServerIds,
type McpGrantInput,
retainedMcpToolPermissions,
standingToolPermissionServerIds,
type TeamData,
} from "./TeamInfo";
const authState = vi.hoisted(() => ({ userRole: "Admin" }));
@ -37,9 +46,11 @@ vi.mock("@/components/networking", () => ({
getTeamPermissionsCall: vi.fn(),
organizationInfoCall: vi.fn(),
getRouterSettingsCall: vi.fn().mockResolvedValue({ fields: [] }),
getPassThroughEndpointsCall: vi.fn(),
getPassThroughEndpointsCall: vi.fn().mockResolvedValue({ endpoints: [] }),
fetchMCPServers: vi.fn().mockResolvedValue([]),
fetchMCPToolsets: vi.fn().mockResolvedValue([]),
listMCPTools: vi.fn().mockResolvedValue({ tools: [] }),
vectorStoreListCall: vi.fn().mockResolvedValue({ data: [] }),
getAgentsList: vi.fn().mockResolvedValue({ agents: [] }),
}));
@ -66,6 +77,7 @@ vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
}));
vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({
organizationKeys: { all: ["organizations"] },
useOrganization: vi.fn(),
useOrganizations: vi.fn().mockReturnValue({ data: [], isLoading: false }),
}));
@ -74,6 +86,38 @@ vi.mock("@/app/(dashboard)/hooks/users/useCurrentUser", () => ({
useCurrentUser: vi.fn(),
}));
vi.mock("@/app/(dashboard)/hooks/mcpServers/useMCPServers", () => ({
useMCPServers: vi.fn(),
}));
vi.mock("@/app/(dashboard)/hooks/mcpServers/useMCPToolsets", () => ({
useMCPToolsets: vi.fn(),
}));
vi.mock("@/components/mcp_server_management/MCPServerSelector", () => ({
default: ({
value,
onChange,
}: {
value?: { servers: string[]; accessGroups: string[]; toolsets?: string[] };
onChange: (next: { servers: string[]; accessGroups: string[]; toolsets: string[] }) => void;
}) => (
<>
<button
type="button"
onClick={() =>
onChange({ servers: [], accessGroups: value?.accessGroups ?? [], toolsets: value?.toolsets ?? [] })
}
>
deselect all mcp servers
</button>
<button type="button" onClick={() => onChange({ servers: value?.servers ?? [], accessGroups: [], toolsets: [] })}>
remove all access groups
</button>
</>
),
}));
vi.mock("@/components/team/TeamMemberTab", () => ({
default: vi.fn(({ setIsAddMemberModalVisible }) => (
<div>
@ -127,7 +171,9 @@ vi.mock("@/components/common_components/ModelAliasManager", () => ({
default: vi.fn(({ initialModelAliases, onAliasUpdate }) => (
<div>
<div data-testid="alias-editor-initial">{JSON.stringify(initialModelAliases)}</div>
<button onClick={() => onAliasUpdate({ "gpt-4o": "gpt-4" })}>Set Alias</button>
<button type="button" onClick={() => onAliasUpdate({ "gpt-4o": "gpt-4" })}>
Set Alias
</button>
<button type="button" onClick={() => onAliasUpdate({})}>
Clear Aliases
</button>
@ -138,14 +184,22 @@ vi.mock("@/components/common_components/ModelAliasManager", () => ({
vi.mock("@/app/(dashboard)/hooks/accessGroups/useAccessGroups", () => ({
useAccessGroups: vi.fn().mockReturnValue({
data: [
{ access_group_id: "ag-1", access_group_name: "Group 1" },
{ access_group_id: "ag-2", access_group_name: "Group 2" },
{ access_group_id: "ag-1", access_group_name: "Group 1", access_mcp_server_ids: [] },
{ access_group_id: "ag-2", access_group_name: "Group 2", access_mcp_server_ids: [] },
],
isLoading: false,
isError: false,
}),
}));
vi.mock("@/components/common_components/AccessGroupSelector", () => ({
default: ({ value, onChange }: { value?: string[]; onChange?: (next: string[]) => void }) => (
<button type="button" onClick={() => onChange?.((value ?? []).slice(1))}>
remove first unified access group
</button>
),
}));
vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({
useKeys: vi.fn().mockReturnValue({
data: { keys: [], total_count: 0, current_page: 1, total_pages: 1 },
@ -170,12 +224,18 @@ import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
import { useOrganization } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import { useTeam } from "@/app/(dashboard)/hooks/teams/useTeams";
import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser";
import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
import { useMCPToolsets } from "@/app/(dashboard)/hooks/mcpServers/useMCPToolsets";
import { useAccessGroups } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups";
const mockUseAllProxyModels = vi.mocked(useAllProxyModels);
const mockUseKeys = vi.mocked(useKeys);
const mockUseTeam = vi.mocked(useTeam);
const mockUseOrganization = vi.mocked(useOrganization);
const mockUseCurrentUser = vi.mocked(useCurrentUser);
const mockUseMCPServers = vi.mocked(useMCPServers);
const mockUseMCPToolsets = vi.mocked(useMCPToolsets);
const mockUseAccessGroups = vi.mocked(useAccessGroups);
const createMockTeamData = (overrides = {}) => ({
team_id: "123",
@ -234,6 +294,16 @@ const seedDefaultMocks = () => {
data: { models: [] },
isLoading: false,
} as any);
mockUseMCPServers.mockReturnValue({ data: [], isLoading: false, isError: false } as any);
mockUseMCPToolsets.mockReturnValue({ data: [], isLoading: false, isError: false } as any);
mockUseAccessGroups.mockReturnValue({
data: [
{ access_group_id: "ag-1", access_group_name: "Group 1", access_mcp_server_ids: [] },
{ access_group_id: "ag-2", access_group_name: "Group 2", access_mcp_server_ids: [] },
],
isLoading: false,
isError: false,
} as any);
mockUseKeys.mockReturnValue({
data: { keys: [], total_count: 0, current_page: 1, total_pages: 1 },
isPending: false,
@ -2193,3 +2263,442 @@ describe("TeamInfoView - the exact bytes the update call sends", () => {
expect(networking.teamUpdateCall).not.toHaveBeenCalled();
});
});
describe("TeamInfo MCP permission retention", () => {
beforeEach(seedDefaultMocks);
afterEach(() => {
vi.clearAllMocks();
});
const server = (serverId: string, serverName: string, alias?: string): MCPServer =>
({
server_id: serverId,
server_name: serverName,
alias,
mcp_access_groups: [],
created_at: "",
created_by: "",
updated_at: "",
updated_by: "",
}) as MCPServer;
const effective = (serverId: string, kind: EffectiveMcpServer["source"]["kind"]): EffectiveMcpServer =>
({
server: server(serverId, serverId),
permissionKey: serverId,
supersededKeys: [],
ambiguousKeys: [],
keyedTools: undefined,
toolsetTools: undefined,
allowedTools: undefined,
source: kind === "accessGroup" ? { kind, name: "ops_readonly" } : { kind },
}) as EffectiveMcpServer;
const UNIFIED_SERVER = server("unified-server", "wiki");
const UNIFIED_GROUPS = [
{ access_group_id: "ag-1", access_group_name: "Group 1", access_mcp_server_ids: ["unified-server"] },
{ access_group_id: "ag-2", access_group_name: "Group 2", access_mcp_server_ids: [] },
];
const unifiedTeam = (toolPermissions: Record<string, string[]>, serverIds: string[] = []) => {
const teamData = {
models: ["gpt-4"],
access_group_ids: ["ag-1", "ag-2"],
access_group_mcp_server_ids: serverIds,
object_permission: {
mcp_servers: [],
mcp_access_groups: [],
mcp_toolsets: [],
mcp_tool_permissions: toolPermissions,
},
};
return createMockTeamData(teamData);
};
const renderMcpEditor = async (
user: ReturnType<typeof userEvent.setup>,
{
initialTeam = unifiedTeam({ wiki: ["read_page"] }, ["unified-server"]),
freshTeam = initialTeam,
accessGroups = [],
}: {
initialTeam?: ReturnType<typeof createMockTeamData>;
freshTeam?: ReturnType<typeof createMockTeamData>;
accessGroups?: typeof UNIFIED_GROUPS;
} = {},
) => {
mockUseMCPServers.mockReturnValue({ data: [UNIFIED_SERVER], isLoading: false, isError: false } as any);
mockUseMCPToolsets.mockReturnValue({ data: [], isLoading: false, isError: false } as any);
mockUseAccessGroups.mockReturnValue({ data: accessGroups, isLoading: false, isError: false } as any);
vi.mocked(networking.teamInfoCall).mockResolvedValueOnce(initialTeam).mockResolvedValue(freshTeam);
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
renderWithProviders(
<TeamInfoView
teamId="123"
onUpdate={vi.fn()}
onClose={vi.fn()}
accessToken="test-token"
is_team_admin
is_proxy_admin
userModels={["gpt-4"]}
editTeam={false}
/>,
);
await waitFor(() => expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0));
await user.click(screen.getByRole("tab", { name: "Settings" }));
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
await screen.findByLabelText("Team Name");
};
const saveMcpEditor = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(networking.teamUpdateCall).toHaveBeenCalled());
const calls = vi.mocked(networking.teamUpdateCall).mock.calls;
const [, payload] = calls[calls.length - 1];
return payload.object_permission.mcp_tool_permissions;
};
const refuseMcpSave = async (user: ReturnType<typeof userEvent.setup>, reason: RegExp) => {
const errorToast = vi.spyOn(toast, "fromError").mockImplementation(() => {});
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(errorToast).toHaveBeenCalledWith(expect.stringMatching(reason)));
expect(networking.teamUpdateCall).not.toHaveBeenCalled();
errorToast.mockRestore();
};
const resolveGrants = (overrides: Partial<McpGrantInput>) => {
const input: McpGrantInput = {
effectiveServers: [effective("server-1", "direct")],
selectedAccessGroupIds: ["ag-1"],
accessGroups: [],
standingServerIds: new Set(),
loadTeamGroups: vi.fn(),
...overrides,
};
return grantedMcpServerIds(input);
};
it("retains permissions for directly and indirectly granted servers", async () => {
const resolution = await resolveGrants({
effectiveServers: [effective("direct", "direct"), effective("inherited", "toolPermission")],
accessGroups: [{ access_group_id: "ag-1", access_mcp_server_ids: ["inherited"] }],
});
expect(resolution.kind).toBe("resolved");
if (resolution.kind !== "resolved") return;
expect(
retainedMcpToolPermissions(
{ direct: ["create_issue"], inherited: ["list_issues"], removed: ["delete_repo"] },
resolution.serverIds,
[server("direct", "deploy_tracker"), server("inherited", "issue_tracker"), server("removed", "old_tracker")],
),
).toEqual({
direct: ["create_issue"],
inherited: ["list_issues"],
});
});
it("matches name and alias permission keys to granted server ids", () => {
const catalog = [server("server-1", "issue_tracker", "issues")];
expect(
retainedMcpToolPermissions(
{ issue_tracker: ["list_issues"], issues: ["create_issue"] },
new Set(["server-1"]),
catalog,
),
).toEqual({
issue_tracker: ["list_issues"],
issues: ["create_issue"],
});
});
it("does not reload the team when the access group list covers the selection", async () => {
const loadTeamGroups = vi.fn();
await expect(
resolveGrants({
accessGroups: [{ access_group_id: "ag-1", access_mcp_server_ids: ["group-server"] }],
loadTeamGroups,
}),
).resolves.toEqual({
kind: "resolved",
serverIds: new Set(["server-1", "group-server"]),
});
expect(loadTeamGroups).not.toHaveBeenCalled();
});
it("falls back to the team's loaded access group servers when the list is unavailable and the selection is unchanged", async () => {
expect(
await resolveGrants({
loadTeamGroups: vi.fn().mockResolvedValue({ ids: ["ag-1"], serverIds: ["group-server"] }),
}),
).toEqual({
kind: "resolved",
serverIds: new Set(["server-1", "group-server"]),
});
});
it("adds standing tool-permission grants to the reloaded team grants", async () => {
expect(
await resolveGrants({
effectiveServers: [effective("server-1", "direct"), effective("standing", "toolPermission")],
standingServerIds: new Set(["standing"]),
loadTeamGroups: vi.fn().mockResolvedValue({ ids: ["ag-1"], serverIds: ["group-server"] }),
}),
).toEqual({
kind: "resolved",
serverIds: new Set(["server-1", "group-server", "standing"]),
});
});
it("is unresolvable when the list is unavailable and the selection changed", async () => {
expect(
await resolveGrants({
loadTeamGroups: vi.fn().mockResolvedValue({ ids: ["ag-1", "ag-2"], serverIds: ["group-server"] }),
}),
).toEqual({
kind: "unresolvable",
reason: expect.stringMatching(/access groups could not be loaded/),
});
});
it("is unresolvable when the team reload fails", async () => {
expect(await resolveGrants({ loadTeamGroups: vi.fn().mockRejectedValue(new Error("boom")) })).toEqual({
kind: "unresolvable",
reason: expect.stringMatching(/access groups could not be reloaded/),
});
});
it("refuses an unresolved selected access group", async () => {
expect(
await resolveGrants({
selectedAccessGroupIds: ["missing"],
loadTeamGroups: vi.fn().mockResolvedValue({ ids: [], serverIds: [] }),
}),
).toEqual({
kind: "unresolvable",
reason: expect.stringMatching(/access groups could not be loaded/),
});
});
it("retains an unknown permission key so an inventory refresh cannot erase it", () => {
expect(retainedMcpToolPermissions({ "not-yet-loaded": ["read"] }, new Set(), [])).toEqual({
"not-yet-loaded": ["read"],
});
});
it("splits an ambiguous permission across the matching granted server", () => {
const catalog = [server("server-1", "shared"), server("server-2", "shared")];
expect(
retainedMcpToolPermissions({ shared: ["read"], "server-1": ["write"] }, new Set(["server-1"]), catalog),
).toEqual({
"server-1": ["write", "read"],
});
});
it("keeps an ambiguous permission when all matching servers remain granted", () => {
const catalog = [server("server-1", "shared"), server("server-2", "shared")];
expect(retainedMcpToolPermissions({ shared: ["read"] }, new Set(["server-1", "server-2"]), catalog)).toEqual({
shared: ["read"],
});
});
it("keeps the group server allowlist when the access group list is unavailable and the selection is unchanged", async () => {
const user = userEvent.setup({ delay: null });
await renderMcpEditor(user);
await user.clear(screen.getByLabelText("Team Name"));
await user.type(screen.getByLabelText("Team Name"), "Renamed Team");
expect(await saveMcpEditor(user)).toEqual({ wiki: ["read_page"] });
});
it("refuses a save when the access group list is unavailable and the selection changed", async () => {
const user = userEvent.setup({ delay: null });
await renderMcpEditor(user);
await user.click(screen.getByRole("button", { name: "remove first unified access group" }));
await refuseMcpSave(user, /access groups could not be loaded/);
});
it("keeps the group server allowlist when a selected access group is missing from the list", async () => {
const user = userEvent.setup({ delay: null });
await renderMcpEditor(user, {
accessGroups: [UNIFIED_GROUPS[1]],
initialTeam: unifiedTeam({ wiki: ["read_page"] }, ["unified-server"]),
});
expect(await saveMcpEditor(user)).toEqual({ wiki: ["read_page"] });
});
it("uses current group grants when a server was revoked after the page loaded", async () => {
const user = userEvent.setup({ delay: null });
await renderMcpEditor(user, {
initialTeam: unifiedTeam({ wiki: ["read_page"] }, ["unified-server"]),
freshTeam: unifiedTeam({ wiki: ["read_page"] }),
});
expect(await saveMcpEditor(user)).toEqual({});
});
it("uses current group grants when a server was granted after the page loaded", async () => {
const user = userEvent.setup({ delay: null });
await renderMcpEditor(user, {
initialTeam: unifiedTeam({ wiki: ["read_page"] }),
freshTeam: unifiedTeam({ wiki: ["read_page"] }, ["unified-server"]),
});
expect(await saveMcpEditor(user)).toEqual({ wiki: ["read_page"] });
});
it("keeps a standing allowlist that no group grant covers at load or at save", async () => {
const user = userEvent.setup({ delay: null });
await renderMcpEditor(user, {
initialTeam: unifiedTeam({ wiki: ["read_page"] }),
freshTeam: unifiedTeam({ wiki: ["read_page"] }),
});
expect(await saveMcpEditor(user)).toEqual({ wiki: ["read_page"] });
});
it("refuses a save when the access group list is unavailable and the team reload fails", async () => {
const user = userEvent.setup({ delay: null });
await renderMcpEditor(user);
vi.mocked(networking.teamInfoCall).mockRejectedValueOnce(new Error("boom"));
await refuseMcpSave(user, /access groups could not be reloaded/);
});
it("identifies standing tool-permission grants not covered by loaded access groups", () => {
expect(
standingToolPermissionServerIds(
[effective("a", "toolPermission"), effective("b", "toolPermission"), effective("c", "direct")],
["ag-1"],
[{ access_group_id: "ag-1", access_mcp_server_ids: ["b"] }],
[],
),
).toEqual(new Set(["a"]));
});
it("does not treat a server granted by the team's loaded access groups as standing", () => {
expect(
standingToolPermissionServerIds(
[effective("a", "toolPermission"), effective("b", "toolPermission")],
["ag-1"],
[],
["b"],
),
).toEqual(new Set(["a"]));
});
it("includes standing tool-permission grants in the resolved server ids", async () => {
const standingOnly = { effectiveServers: [effective("x", "toolPermission")], selectedAccessGroupIds: [] };
expect(await resolveGrants({ ...standingOnly, standingServerIds: new Set(["x"]) })).toEqual({
kind: "resolved",
serverIds: new Set(["x"]),
});
expect(await resolveGrants(standingOnly)).toEqual({
kind: "resolved",
serverIds: new Set(),
});
});
it("retains an indirectly granted server on an unrelated team save", async () => {
const user = userEvent.setup({ delay: null });
const catalog = [server("direct-server", "deploy_tracker"), server("perm-only-server", "issue_tracker")];
mockUseMCPServers.mockReturnValue({ data: catalog, isLoading: false, isError: false } as any);
mockUseMCPToolsets.mockReturnValue({ data: [], isLoading: false, isError: false } as any);
mockUseAccessGroups.mockReturnValue({
data: [],
isLoading: false,
isError: false,
} as any);
vi.mocked(networking.teamInfoCall).mockResolvedValue(
createMockTeamData({
models: ["gpt-4"],
access_group_ids: [],
object_permission: {
mcp_servers: ["direct-server"],
mcp_access_groups: [],
mcp_toolsets: [],
mcp_tool_permissions: {
"direct-server": ["create_issue"],
"perm-only-server": ["list_issues"],
},
},
}),
);
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
renderWithProviders(
<TeamInfoView
teamId="123"
onUpdate={vi.fn()}
onClose={vi.fn()}
accessToken="test-token"
is_team_admin
is_proxy_admin
userModels={["gpt-4"]}
editTeam={false}
/>,
);
await waitFor(() => expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0));
await user.click(screen.getByRole("tab", { name: "Settings" }));
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
await user.clear(screen.getByLabelText("Team Name"));
await user.type(screen.getByLabelText("Team Name"), "Renamed Team");
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(networking.teamUpdateCall).toHaveBeenCalled());
const [, payload] = vi.mocked(networking.teamUpdateCall).mock.calls[0];
expect(payload.object_permission.mcp_tool_permissions).toEqual({
"direct-server": ["create_issue"],
"perm-only-server": ["list_issues"],
});
});
it("refuses a save with MCP permissions while the server inventory is unavailable", async () => {
const user = userEvent.setup({ delay: null });
mockUseMCPServers.mockReturnValue({ data: [], isLoading: false, isError: true } as any);
vi.mocked(networking.teamInfoCall).mockResolvedValue(
createMockTeamData({
models: ["gpt-4"],
object_permission: {
mcp_servers: ["direct-server"],
mcp_access_groups: [],
mcp_toolsets: [],
mcp_tool_permissions: { "direct-server": ["create_issue"] },
},
}),
);
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
const errorToast = vi.spyOn(toast, "fromError").mockImplementation(() => {});
renderWithProviders(
<TeamInfoView
teamId="123"
onUpdate={vi.fn()}
onClose={vi.fn()}
accessToken="test-token"
is_team_admin
is_proxy_admin
userModels={["gpt-4"]}
editTeam={false}
/>,
);
await waitFor(() => expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0));
await user.click(screen.getByRole("tab", { name: "Settings" }));
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() =>
expect(errorToast).toHaveBeenCalledWith(expect.stringMatching(/server list could not be loaded/)),
);
expect(networking.teamUpdateCall).not.toHaveBeenCalled();
errorToast.mockRestore();
});
});

View file

@ -74,6 +74,15 @@ import GuardrailSettingsView from "../GuardrailSettingsView";
import LoggingSettingsView from "../logging_settings_view";
import MCPServerSelector from "../mcp_server_management/MCPServerSelector";
import MCPToolPermissions from "../mcp_server_management/MCPToolPermissions";
import {
mcpServersForIdentifier,
resolveEffectiveMcpServers,
type EffectiveMcpServer,
} from "../mcp_server_management/effectiveMcpServers";
import type { MCPServer } from "../mcp_tools/types";
import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
import { useMCPToolsets } from "@/app/(dashboard)/hooks/mcpServers/useMCPToolsets";
import { useAccessGroups, type AccessGroupResponse } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups";
import { ModelSelect } from "../ModelSelect/ModelSelect";
import { estimateChecks, estimateTooltips } from "../templates/estimatedOutputTokens";
import ObjectPermissionsView from "../object_permissions_view";
@ -118,6 +127,110 @@ const TEAM_MODEL_BADGE_TONES: Record<TeamModelBadgeKind, StatusTone> = {
const teamModelBadgeHref = (badge: TeamModelBadge): string | undefined =>
badge.kind === "direct" || badge.kind === "access-group" ? modelGroupHref(badge.label) : undefined;
export type McpGrantResolution =
| { readonly kind: "resolved"; readonly serverIds: ReadonlySet<string> }
| { readonly kind: "unresolvable"; readonly reason: string };
export type TeamAccessGroupGrants = {
readonly ids: readonly string[];
readonly serverIds: readonly string[];
};
const sameIdSelection = (a: readonly string[], b: readonly string[]): boolean => {
const aSet = new Set(a);
const bSet = new Set(b);
return aSet.size === bSet.size && [...aSet].every((id) => bSet.has(id));
};
export const standingToolPermissionServerIds = (
loadedEffectiveServers: readonly EffectiveMcpServer[],
loadedAccessGroupIds: readonly string[],
accessGroups: readonly Pick<AccessGroupResponse, "access_group_id" | "access_mcp_server_ids">[],
loadedTeamGroupServerIds: readonly string[],
): ReadonlySet<string> => {
const loadedUnifiedServerIds = new Set([
...accessGroups
.filter((group) => loadedAccessGroupIds.includes(group.access_group_id))
.flatMap((group) => group.access_mcp_server_ids),
...loadedTeamGroupServerIds,
]);
return new Set(
loadedEffectiveServers
.filter(({ source, server }) => source.kind === "toolPermission" && !loadedUnifiedServerIds.has(server.server_id))
.map(({ server }) => server.server_id),
);
};
export type McpGrantInput = {
readonly effectiveServers: readonly EffectiveMcpServer[];
readonly selectedAccessGroupIds: readonly string[];
readonly accessGroups: readonly Pick<AccessGroupResponse, "access_group_id" | "access_mcp_server_ids">[];
readonly standingServerIds: ReadonlySet<string>;
readonly loadTeamGroups: () => Promise<TeamAccessGroupGrants>;
};
export const grantedMcpServerIds = async ({
effectiveServers,
selectedAccessGroupIds,
accessGroups,
standingServerIds,
loadTeamGroups,
}: McpGrantInput): Promise<McpGrantResolution> => {
const selectedGroups = accessGroups.filter((group) => selectedAccessGroupIds.includes(group.access_group_id));
const direct = effectiveServers
.filter(({ source }) => source.kind !== "toolPermission")
.map(({ server }) => server.server_id);
if (selectedAccessGroupIds.every((id) => selectedGroups.some((group) => group.access_group_id === id))) {
return {
kind: "resolved",
serverIds: new Set([
...direct,
...selectedGroups.flatMap((group) => group.access_mcp_server_ids),
...standingServerIds,
]),
};
}
const loadedTeamGroups = await loadTeamGroups().catch(() => null);
if (loadedTeamGroups === null) {
return { kind: "unresolvable", reason: "the team's access groups could not be reloaded" };
}
if (sameIdSelection(selectedAccessGroupIds, loadedTeamGroups.ids)) {
return {
kind: "resolved",
serverIds: new Set([...direct, ...loadedTeamGroups.serverIds, ...standingServerIds]),
};
}
return { kind: "unresolvable", reason: "the team's access groups could not be loaded" };
};
export const retainedMcpToolPermissions = (
toolPermissions: Record<string, string[]>,
grantedServerIds: ReadonlySet<string>,
knownServers: readonly MCPServer[],
): Record<string, string[]> => {
const entries = Object.entries(toolPermissions).flatMap(([key, tools]) => {
const named = mcpServersForIdentifier(knownServers, key);
const granted = named.filter((server) => grantedServerIds.has(server.server_id));
if (named.length === 0 || granted.length === named.length) {
return [[key, tools] as const];
}
if (granted.length === 0) {
return [];
}
return granted.map(({ server_id }) => [server_id, [...(toolPermissions[server_id] ?? []), ...tools]] as const);
});
return entries.reduce<Record<string, string[]>>(
(retained, [key, tools]) => ({
...retained,
[key]: [...new Set([...(retained[key] ?? []), ...tools])],
}),
{},
);
};
export const mcpUnresolvableSaveError = (reason: string): string =>
`Cannot save MCP tool permissions because ${reason}. Retry once the page has finished loading`;
export interface TeamMembership {
user_id: string;
team_id: string;
@ -442,6 +555,9 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
const routerSettingsRef = React.useRef<RouterSettingsAccordionRef>(null);
const [organization, setOrganization] = useState<Organization | null>(null);
const { userRole, userId } = useAuthorized();
const { data: allMcpServers = [], isError: mcpServersFailed, isLoading: mcpServersLoading } = useMCPServers();
const { data: allMcpToolsets = [], isError: mcpToolsetsFailed, isLoading: mcpToolsetsLoading } = useMCPToolsets();
const { data: allAccessGroups = [], isError: accessGroupsFailed, isLoading: accessGroupsLoading } = useAccessGroups();
const canEditTeamEstimates = isProxyAdminRole(userRole);
const teamEstimateTooltip = estimateTooltips(canEditTeamEstimates, "team");
const { data: userOrganizations = [] } = useOrganizations();
@ -462,6 +578,15 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
const killSwitchOn = form.watch("disable_global_guardrails");
const watchedMcpSelection = form.watch("mcp_servers_and_groups");
const watchedToolPermissions = form.watch("mcp_tool_permissions");
const mcpLookupFailure =
(
[
[mcpServersFailed, "the MCP server list could not be loaded"],
[mcpToolsetsFailed, "the MCP toolset list could not be loaded"],
[accessGroupsFailed, "the access group list could not be loaded"],
[mcpServersLoading || mcpToolsetsLoading || accessGroupsLoading, "the MCP server inventory is still loading"],
] as const
).find(([failed]) => failed)?.[1] ?? null;
const availableRateLimitModels = useMemo(() => {
const selected = watchedModels ?? teamData?.team_info?.models ?? [];
if (selected.includes("all-proxy-models") || selected.includes("all-team-models")) {
@ -837,10 +962,56 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
accessGroups: [],
toolsets: [],
};
const serverIds = new Set(servers || []);
const mcpToolPermissions = Object.fromEntries(
Object.entries(values.mcp_tool_permissions || {}).filter(([serverId]) => serverIds.has(serverId)),
const submittedToolPermissions: Record<string, string[]> = values.mcp_tool_permissions || {};
const effectiveMcpInput = {
allServers: allMcpServers,
selectedServers: servers || [],
selectedAccessGroups: accessGroups || [],
selectedToolsets: toolsets || [],
toolsets: allMcpToolsets,
toolPermissions: submittedToolPermissions,
};
const loadedObjectPermission = info.object_permission ?? {};
const loadedMcpInput = {
allServers: allMcpServers,
selectedServers: loadedObjectPermission.mcp_servers ?? [],
selectedAccessGroups: loadedObjectPermission.mcp_access_groups ?? [],
selectedToolsets: loadedObjectPermission.mcp_toolsets ?? [],
toolsets: allMcpToolsets,
toolPermissions: loadedObjectPermission.mcp_tool_permissions ?? {},
};
const loadedEffectiveMcpServers = resolveEffectiveMcpServers(loadedMcpInput);
const standingServerIds = standingToolPermissionServerIds(
loadedEffectiveMcpServers,
info.access_group_ids ?? [],
allAccessGroups,
info.access_group_mcp_server_ids ?? [],
);
const mcpGrantInput: McpGrantInput = {
effectiveServers: resolveEffectiveMcpServers(effectiveMcpInput),
selectedAccessGroupIds: values.access_group_ids || [],
accessGroups: allAccessGroups,
standingServerIds,
loadTeamGroups: async () => {
const teamInfo = await teamInfoCall(accessToken, teamId);
return {
ids: teamInfo.team_info.access_group_ids ?? [],
serverIds: teamInfo.team_info.access_group_mcp_server_ids ?? [],
};
},
};
const mcpResolution: McpGrantResolution =
mcpLookupFailure !== null
? { kind: "unresolvable", reason: mcpLookupFailure }
: await grantedMcpServerIds(mcpGrantInput);
if (mcpResolution.kind === "unresolvable" && Object.keys(submittedToolPermissions).length > 0) {
toast.fromError(mcpUnresolvableSaveError(mcpResolution.reason));
return;
}
const mcpToolPermissions =
mcpResolution.kind === "resolved"
? retainedMcpToolPermissions(submittedToolPermissions, mcpResolution.serverIds, allMcpServers)
: submittedToolPermissions;
updateData.object_permission = {};
if (servers) {
@ -1629,6 +1800,8 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
<MCPToolPermissions
accessToken={accessToken || ""}
selectedServers={watchedMcpSelection?.servers || []}
selectedAccessGroups={watchedMcpSelection?.accessGroups || []}
selectedToolsets={watchedMcpSelection?.toolsets || []}
toolPermissions={watchedToolPermissions || {}}
onChange={(toolPerms) => form.setValue("mcp_tool_permissions", toolPerms)}
/>

View file

@ -54,7 +54,6 @@ import {
import { excludeProxyWideSentinel, hasAllModelsSentinel } from "../key_team_helpers/fetch_available_models_team_key";
import { KeyResponse } from "../key_team_helpers/key_list";
import MCPServerSelector from "../mcp_server_management/MCPServerSelector";
import { NO_MCP_SERVERS_SENTINEL } from "../mcp_tools/constants";
import MCPToolPermissions from "../mcp_server_management/MCPToolPermissions";
import { toast } from "@/lib/toast";
import { getPromptsList, modelAvailableCall, tagListCall } from "../networking";
@ -138,7 +137,9 @@ export function KeyEditView({
const selectedModels = (form.watch("models") as string[] | undefined) ?? [];
const allowedRoutes = parseAllowedRoutes(allowedRoutesValue);
const isModelsDisabled = allowedRoutes.includes("management_routes") || allowedRoutes.includes("info_routes");
const mcpServersAndGroups = form.watch("mcp_servers_and_groups");
const mcpSelection = form.watch("mcp_servers_and_groups") as
| { servers?: string[]; accessGroups?: string[]; toolsets?: string[] }
| undefined;
const mcpToolPermissions = form.watch("mcp_tool_permissions");
useEffect(() => {
@ -751,9 +752,9 @@ export function KeyEditView({
<div className="mb-6">
<MCPToolPermissions
accessToken={accessToken || ""}
selectedServers={((mcpServersAndGroups as { servers?: string[] } | undefined)?.servers || []).filter(
(s: string) => s !== NO_MCP_SERVERS_SENTINEL,
)}
selectedServers={mcpSelection?.servers || []}
selectedAccessGroups={mcpSelection?.accessGroups || []}
selectedToolsets={mcpSelection?.toolsets || []}
toolPermissions={(mcpToolPermissions as Record<string, string[]> | undefined) || {}}
onChange={(toolPerms) => form.setValue("mcp_tool_permissions", toolPerms)}
/>