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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion frontend/src/lib/toolGroups.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { ToolInfo } from "../api/types";
import { groupState, groupTools, toggleGroup, toolLabel, unavailableGrants } from "./toolGroups";
import { groupState, groupTools, setGroup, toggleGroup, toolLabel, unavailableGrants } from "./toolGroups";

const tool = (name: string, source: string): ToolInfo => ({ name, description: name, source, args_schema: {} });
const tools = [
Expand Down Expand Up @@ -36,6 +36,13 @@ describe("toggleGroup", () => {
expect(toggleGroup({ tool_names: ["run_shell", ...names], approval_tools: ["Linear__create_issue", "run_shell"] }, names))
.toEqual({ tool_names: ["run_shell"], approval_tools: ["run_shell"] });
});

it("setGroup grants or clears the server's tools whatever the current state", () => {
const names = ["Linear__create_issue", "Linear__list_issues"];
const some = { tool_names: ["run_shell", "Linear__create_issue"], approval_tools: ["Linear__create_issue"] };
expect(setGroup(some, names, true)).toEqual({ tool_names: ["run_shell", ...names], approval_tools: ["Linear__create_issue"] });
expect(setGroup(some, names, false)).toEqual({ tool_names: ["run_shell"], approval_tools: [] });
});
});

describe("unavailableGrants and toolLabel", () => {
Expand Down
11 changes: 7 additions & 4 deletions frontend/src/lib/toolGroups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,14 @@ export function groupState(names: string[], selected: string[]): GroupState {

interface Selection { tool_names: string[]; approval_tools: string[] }

/** Group checkbox: select every tool of the server unless all already are, in which case clear them.
* Approvals for cleared tools are dropped, since approval_tools must stay a subset of tool_names. */
export function toggleGroup(sel: Selection, names: string[]): Selection {
if (groupState(names, sel.tool_names) === "all") {
/** Grant all of `names`, or clear them. Approvals for cleared tools are dropped, since approval_tools must
* stay a subset of tool_names. */
export function setGroup(sel: Selection, names: string[], on: boolean): Selection {
if (!on) {
return { tool_names: sel.tool_names.filter((n) => !names.includes(n)), approval_tools: sel.approval_tools.filter((n) => !names.includes(n)) };
}
return { tool_names: [...sel.tool_names, ...names.filter((n) => !sel.tool_names.includes(n))], approval_tools: sel.approval_tools };
}

/** Group checkbox: select every tool of the server unless all already are, in which case clear them. */
export const toggleGroup = (sel: Selection, names: string[]): Selection => setGroup(sel, names, groupState(names, sel.tool_names) !== "all");
53 changes: 52 additions & 1 deletion frontend/src/pages/SettingsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router-dom";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { Api } from "../api/client";
import type { AppSetting } from "../api/types";
import type { AppSetting, Bot, McpServer } from "../api/types";
import SettingsPage from "./SettingsPage";

(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
Expand Down Expand Up @@ -64,3 +64,54 @@ it("renders the default bot model as an OpenRouter catalog picker and saves the
await until(() => patch.mock.calls.length > 0);
expect(patch.mock.calls[0][0]).toEqual({ bot_model: "z-ai/glm-5.3-flash" });
});

const mcpServer = (tools: string[]): McpServer => ({ name: "lin", transport: "http", status: tools.length ? "connected" : "disconnected", enabled: true, oauth: false,
url: "https://mcp.example.com/mcp", error: null, tools, source: "db", authorization_url: null, command: null, args: [], cwd: null, env: {}, headers: {} });
const bot = (id: string, tool_names: string[], approval_tools: string[] = []): Bot => ({ id, handle: id, name: `Bot ${id}`, description: "", icon: "bot", enabled: true, active: false,
instructions: "", provider: "auto", model: "", model_settings: {}, tool_names, approval_tools, skill_names: [], memory_enabled: true, created_at: "", updated_at: "" });
const grantBox = (handle: string) => document.body.querySelector<HTMLInputElement>(`input[aria-label="Grant lin to @${handle}"]`);
const dialogButton = (label: string) => [...document.getElementById("mcp-dialog-title")!.closest("form")!.querySelectorAll("button")].find((b) => b.textContent === label)!;
async function editServer() {
await act(async () => root.render(<QueryClientProvider client={qc}><MemoryRouter><SettingsPage /></MemoryRouter></QueryClientProvider>));
await until(() => [...el.querySelectorAll("button")].some((b) => b.textContent === "Edit"));
await act(async () => { [...el.querySelectorAll("button")].find((b) => b.textContent === "Edit")!.click(); });
}

it("grants or clears a server's tools for several bots from its edit dialog", async () => {
const tools = ["lin__create_issue", "lin__list_issues"];
vi.mocked(Api.listMcpServers).mockResolvedValue([mcpServer(tools)]);
vi.spyOn(Api, "listBots").mockResolvedValue([
bot("all", ["run_shell", ...tools], ["lin__create_issue"]), bot("some", ["lin__list_issues"]), bot("none", ["run_shell"]),
]);
const updateBot = vi.spyOn(Api, "updateBot").mockImplementation(async (id) => bot(id, []));
const updateServer = vi.spyOn(Api, "updateMcpServer").mockResolvedValue(mcpServer(tools));
await editServer();
await until(() => grantBox("none") !== null);
expect([grantBox("all")!.checked, grantBox("some")!.checked, grantBox("some")!.indeterminate, grantBox("none")!.checked]).toEqual([true, false, true, false]);
expect(document.body.textContent).toContain("1 of 2");

await act(async () => { grantBox("all")!.click(); });
await act(async () => { grantBox("some")!.click(); });
expect([grantBox("all")!.checked, grantBox("some")!.checked]).toEqual([false, true]);
await act(async () => { dialogButton("Save").click(); });
await until(() => updateServer.mock.calls.length > 0);
expect(updateBot.mock.calls).toEqual([
["all", { tool_names: ["run_shell"], approval_tools: [] }],
["some", { tool_names: ["lin__list_issues", "lin__create_issue"], approval_tools: [] }],
]);
});

it("asks to connect a server without tools before granting it to bots, and lists no bots when adding one", async () => {
vi.mocked(Api.listMcpServers).mockResolvedValue([mcpServer([])]);
const listBots = vi.spyOn(Api, "listBots").mockResolvedValue([bot("b1", [])]);
await editServer();
await until(() => document.body.textContent!.includes("Connect the server to grant its tools to bots here."));
expect(grantBox("b1")).toBeNull();

listBots.mockClear();
await act(async () => { dialogButton("Cancel").click(); });
await act(async () => { [...el.querySelectorAll("button")].find((b) => b.textContent === "Add server")!.click(); });
await until(() => document.body.textContent!.includes("Add MCP server"));
expect(document.body.textContent).not.toContain("Bots with this server's tools");
expect(listBots).not.toHaveBeenCalled();
});
49 changes: 44 additions & 5 deletions frontend/src/pages/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { dismissSaveNotification, notifySave, useSaveMutation } from "../lib/sav
import { formatSettingValue, groupSettings, parseSettingInput, type SettingGroup } from "../lib/appSettings";
import { formatArgs, formatKeyValues, mcpActions, mcpInFlight, mcpStatusBadge, parseArgs, parseKeyValues, validateNewMcpServer, type McpTransport } from "../lib/mcpServers";
import { ChevronDownIcon, ChevronRightIcon } from "../components/icons";
import { groupTools, toolLabel } from "../lib/toolGroups";
import { groupState, groupTools, setGroup, toolLabel } from "../lib/toolGroups";

const emptyExt = { handle: "", name: "", webhook_url: "", webhook_secret: "" };
const Code = ({ children }: { children: React.ReactNode }) => <code className="rounded-ui border border-line bg-sunken px-1 text-[11px]">{children}</code>;
Expand Down Expand Up @@ -297,7 +297,7 @@ function McpServersCard() {
<SectionTitle className="flex-1">MCP servers</SectionTitle>
<Button variant="secondary" size="sm" onClick={() => setDialog({ mode: "add" })}>Add server</Button>
</div>
<Hint>Tools from Model Context Protocol servers, remote (HTTPS, OAuth when the server asks) or local (a command run over stdio). Tools appear in the tool list as <Code>server__tool</Code>; grant them per bot in the bot editor, by server or one at a time.</Hint>
<Hint>Tools from Model Context Protocol servers, remote (HTTPS, OAuth when the server asks) or local (a command run over stdio). Tools appear in the tool list as <Code>server__tool</Code>; grant them per bot in the bot editor, by server or one at a time, or to several bots at once under Edit.</Hint>
<details><summary className="cursor-pointer text-sm font-medium">Browse MCP catalog</summary><div className="pt-3"><McpCatalog installed={servers.data ?? []} onInstalled={refresh} /></div></details>
{dialog && <McpServerDialog server={dialog.mode === "edit" ? dialog.server : undefined} onClose={() => setDialog(null)} onSaved={() => { setDialog(null); refresh(); }} />}
<ErrorText error={servers.error ?? connect.error ?? disconnect.error ?? forget.error ?? remove.error ?? toggleEnabled.error} />
Expand Down Expand Up @@ -381,6 +381,11 @@ function McpCatalog({ installed, onInstalled }: { installed: McpServer[]; onInst

function McpServerDialog({ server, onClose, onSaved }: { server?: McpServer; onClose: () => void; onSaved: () => void }) {
const editing = !!server;
const qc = useQueryClient();
const bots = useQuery({ queryKey: ["bots"], queryFn: Api.listBots, enabled: editing });
const serverTools = server?.tools ?? [];
// Bots ticked or unticked here: true grants all of the server's tools, false clears them.
const [grants, setGrants] = useState<Record<string, boolean>>({});
const [transport, setTransport] = useState<McpTransport>(server?.transport ?? "http");
const [name, setName] = useState(server?.name ?? "");
const [url, setUrl] = useState(server?.url ?? "");
Expand All @@ -393,11 +398,19 @@ function McpServerDialog({ server, onClose, onSaved }: { server?: McpServer; onC
const errors = validateNewMcpServer(name, url, transport, command);
const kv = parseKeyValues(transport === "http" ? headers : env);
const save = useSaveMutation({
mutationFn: () => {
mutationFn: async () => {
const body: McpServerInput = transport === "http"
? { url: url.trim(), headers: kv.values }
: { command: command.trim(), args: parseArgs(args), env: kv.values, cwd: cwd.trim() || null };
return editing ? Api.updateMcpServer(server!.name, body) : Api.addMcpServer({ name: name.trim(), ...body });
if (!editing) return Api.addMcpServer({ name: name.trim(), ...body });
try {
for (const b of bots.data ?? []) {
if (b.id in grants) await Api.updateBot(b.id, setGroup(b, serverTools, grants[b.id]));
}
} finally {
if (Object.keys(grants).length) { qc.invalidateQueries({ queryKey: ["bots"] }); qc.invalidateQueries({ queryKey: ["bot"] }); }
}
return Api.updateMcpServer(server!.name, body);
},
onSuccess: onSaved,
}, editing ? "MCP server settings saved" : "MCP server added");
Expand Down Expand Up @@ -446,8 +459,34 @@ function McpServerDialog({ server, onClose, onSaved }: { server?: McpServer; onC
<Input value={cwd} placeholder="Working directory (optional)" onChange={(e) => setCwd(e.target.value)} />
</>
)}
{editing && (
<div className="space-y-1">
<div className="text-xs font-medium">Bots with this server's tools</div>
{serverTools.length === 0 ? (
<Hint className="text-xs">Connect the server to grant its tools to bots here.</Hint>
) : (
<>
<div className="max-h-48 divide-y divide-line overflow-y-auto">
{bots.data?.map((b) => {
const state = b.id in grants ? (grants[b.id] ? "all" : "none") : groupState(serverTools, b.tool_names);
return (
<label key={b.id} className="flex items-center gap-2 py-1.5 text-[13px]">
<input type="checkbox" checked={state === "all"} ref={(el) => { if (el) el.indeterminate = state === "some"; }}
onChange={() => setGrants((g) => ({ ...g, [b.id]: state !== "all" }))} aria-label={`Grant ${server!.name} to @${b.handle}`} />
<span>@{b.handle}</span>
<span className="text-xs text-muted">{b.name}</span>
{state === "some" && <span className="ml-auto text-xs text-muted">{serverTools.filter((t) => b.tool_names.includes(t)).length} of {serverTools.length}</span>}
</label>
);
})}
</div>
<Hint className="text-xs">Grants every tool the server has now. Tools it adds later, and single tools, are granted in the bot editor.</Hint>
</>
)}
</div>
)}
<Hint className="text-xs">Only add servers from developers you trust. OpenBot does not control which tools a server exposes or what they do, and every bot you grant those tools acts with whatever access you provide. Mark tools that write as needing approval in the bot editor.</Hint>
<ErrorText error={save.error} />
<ErrorText error={save.error ?? bots.error} />
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={save.isPending || (touched && (Object.keys(errors).length > 0 || !!kv.error))}>{save.isPending ? "Saving…" : editing ? "Save" : "Continue"}</Button>
Expand Down
Loading