diff --git a/Cargo.lock b/Cargo.lock index 4768e33b..b761838f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -498,6 +498,7 @@ version = "0.10.0" dependencies = [ "botloft-core", "rusqlite", + "serde", "serde_json", "tempfile", "thiserror 2.0.21", diff --git a/app/src/features/account/SettingsDialog.tsx b/app/src/features/account/SettingsDialog.tsx index a0d65c2c..7ace8b21 100644 --- a/app/src/features/account/SettingsDialog.tsx +++ b/app/src/features/account/SettingsDialog.tsx @@ -11,11 +11,13 @@ import { type LucideIcon, MessageSquare, Palette, + Plug, SlidersHorizontal, } from "lucide-react"; import { useId, useState } from "react"; import { useT } from "../../i18n"; import { Dialog } from "../../ui/Dialog"; +import { ConnectedToolsSettings } from "../connections/ConnectedToolsSettings"; import { AboutSettings } from "./AboutSettings"; import { AlertsSettings } from "./AlertsSettings"; import { AppearanceSettings } from "./AppearanceSettings"; @@ -24,7 +26,15 @@ import { BackupSettings } from "./BackupSettings"; import { ChatSettings } from "./ChatSettings"; import { GeneralSettings } from "./GeneralSettings"; -type Page = "general" | "chat" | "alerts" | "appearance" | "archived" | "backup" | "about"; +type Page = + | "general" + | "chat" + | "alerts" + | "appearance" + | "archived" + | "tools" + | "backup" + | "about"; const PAGES: { id: Page; icon: LucideIcon }[] = [ { id: "general", icon: SlidersHorizontal }, @@ -32,6 +42,7 @@ const PAGES: { id: Page; icon: LucideIcon }[] = [ { id: "alerts", icon: Bell }, { id: "appearance", icon: Palette }, { id: "archived", icon: Archive }, + { id: "tools", icon: Plug }, { id: "backup", icon: DatabaseBackup }, { id: "about", icon: Info }, ]; @@ -61,7 +72,7 @@ export function SettingsDialog({ onClose }: { onClose(): void }) { aria-selected={selected} aria-controls={`${id}-panel`} onClick={() => setPage(each)} - className={`flex h-8 items-center gap-2 rounded-lg px-2.5 text-left font-medium text-sm transition-colors ${ + className={`flex min-h-8 items-center gap-2 rounded-lg px-2.5 py-1 text-left font-medium text-sm transition-colors ${ selected ? "bg-sunken text-ink" : "text-ink-soft hover:bg-sunken hover:text-ink" }`} > @@ -82,6 +93,7 @@ export function SettingsDialog({ onClose }: { onClose(): void }) { {page === "alerts" && } {page === "appearance" && } {page === "archived" && } + {page === "tools" && } {page === "backup" && } {page === "about" && } diff --git a/app/src/features/bots/BotPanels.tsx b/app/src/features/bots/BotPanels.tsx index 6d838ba9..afc38609 100644 --- a/app/src/features/bots/BotPanels.tsx +++ b/app/src/features/bots/BotPanels.tsx @@ -8,6 +8,7 @@ import type { Bot, Crew } from "../../lib/protocol.gen"; import { Button } from "../../ui/Button"; import { Callout } from "../../ui/Callout"; import { SidePanel } from "../../ui/SidePanel"; +import { BotTools } from "../connections/BotTools"; import { DesktopGrants } from "../desktop/DesktopGrants"; import { SignInButton } from "../onboarding/SignIn"; import { AllowRules } from "./AllowRules"; @@ -80,6 +81,7 @@ export function Details({ bot, onClose }: { bot: Bot; onClose(): void }) { + diff --git a/app/src/features/chat/toolNames.test.tsx b/app/src/features/chat/toolNames.test.tsx index 065dd134..7865848c 100644 --- a/app/src/features/chat/toolNames.test.tsx +++ b/app/src/features/chat/toolNames.test.tsx @@ -20,8 +20,11 @@ describe("tool names", () => { expect(toolTitle("mcp__botloft__browser_open", ptBR.tools)).toBe("Abrir uma página"); expect(toolTitle("Bash", ptBR.tools)).toBe("Rodar um comando"); expect(toolAction("Write", ptBR.tools)).toBe("escrever um arquivo"); - // One with no name here keeps its own. - expect(toolTitle("mcp__other__frobnicate", en.tools)).toBe("frobnicate"); + // A tool of the owner's own is named with the tool it belongs to. + expect(toolTitle("mcp__other__frobnicate", en.tools)).toBe("frobnicate (other)"); + expect(toolTitle("mcp__linkedin__search_people", en.tools)).toBe("search people (linkedin)"); + // One with no name here and no tool to belong to keeps its own. + expect(toolTitle("Frobnicate", en.tools)).toBe("Frobnicate"); expect(toolAction("mcp__other__frobnicate", ptBR.tools)).toBe("usar frobnicate"); expect(toolTitle("constructor", en.tools)).toBe("constructor"); }); diff --git a/app/src/features/chat/toolNames.ts b/app/src/features/chat/toolNames.ts index 74f6972f..a6e26791 100644 --- a/app/src/features/chat/toolNames.ts +++ b/app/src/features/chat/toolNames.ts @@ -7,6 +7,15 @@ import type { Messages } from "../../i18n/en"; type Words = Messages["tools"]; +/** A tool of the owner's, `mcp____`, as "tool name (slug)". */ +function connected(name: string): string | null { + const [prefix, slug, ...tool] = name.split("__"); + if (prefix !== "mcp" || !slug || slug === "botloft" || tool.length === 0) { + return null; + } + return `${tool.join("__").replaceAll("_", " ")} (${slug})`; +} + /** `mcp__botloft__send_message` is `send_message`; built-ins keep theirs. */ export function toolKey(name: string): string { return name.split("__").at(-1) ?? name; @@ -19,7 +28,7 @@ function known(key: string, t: Words): string | undefined { /** The tool as a title: "Send a message"; an unknown one, by its name. */ export function toolTitle(name: string, t: Words): string { const key = toolKey(name); - return known(key, t) ?? key; + return known(key, t) ?? connected(name) ?? key; } /** The tool inside a sentence: "wants to send a message". */ diff --git a/app/src/features/connections/AddToolDialog.tsx b/app/src/features/connections/AddToolDialog.tsx new file mode 100644 index 00000000..2054c452 --- /dev/null +++ b/app/src/features/connections/AddToolDialog.tsx @@ -0,0 +1,155 @@ +// "Connect a tool" (spec 25.2): the owner pastes its settings, sees what +// it is and what it can do on their computer, and says they trust it. A +// program (`stdio`) runs with the owner's powers, so it never saves without +// that "I understand". + +import { useMemo, useState } from "react"; +import { useT } from "../../i18n"; +import { errorText } from "../../lib/api"; +import type { McpSaveParams } from "../../lib/protocol.gen"; +import { useApi } from "../../store/context"; +import { Button } from "../../ui/Button"; +import { Callout } from "../../ui/Callout"; +import { Details } from "../../ui/Details"; +import { Dialog } from "../../ui/Dialog"; +import { TextArea, TextField } from "../../ui/Field"; +import { type PasteProblem, parsePasted } from "./paste"; + +/** The command line of a program, or the address, as one line. */ +function whatItRuns(tool: McpSaveParams): string { + return tool.kind === "stdio" ? [tool.command, ...tool.args].join(" ") : (tool.url ?? ""); +} + +export function AddToolDialog({ onClose }: { onClose(): void }) { + const all = useT(); + const t = all.connections; + const api = useApi(); + const [text, setText] = useState(""); + const [name, setName] = useState(""); + const [purpose, setPurpose] = useState(""); + const [trusted, setTrusted] = useState(false); + const [busy, setBusy] = useState(false); + const [failed, setFailed] = useState(null); + const pasted = useMemo(() => parsePasted(text, name), [text, name]); + const tools = pasted.ok ? pasted.tools : []; + const runsPrograms = tools.some((tool) => tool.kind === "stdio"); + + const problem = (found: PasteProblem): string => { + const words = t.problems; + switch (found.reason) { + case "unsupported": + return words.unsupported(found.server); + case "unknownField": + return words.unknownField(found.server, found.field); + case "badValue": + return words.badValue(found.server, found.field); + default: + return words[found.reason]; + } + }; + // Nothing is said about an empty box until something is in it. + const complaint = !pasted.ok && pasted.reason !== "empty" ? problem(pasted) : null; + const asksName = !pasted.ok && pasted.reason === "needsName"; + + const connect = async () => { + setBusy(true); + setFailed(null); + try { + for (const tool of tools) { + await api.call("mcp.save", { + ...tool, + description: tools.length === 1 ? purpose.trim() : "", + }); + } + onClose(); + } catch (error) { + setFailed(errorText(error)); + setBusy(false); + } + }; + + return ( + + + + + } + > +
+