>({});
+
+ const refresh = async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const { servers } = await mcpApi.listServers();
+ setServers(servers);
+ } catch (e: unknown) {
+ setError((e as Error)?.message || "Failed to load MCP servers");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ refresh();
+ }, []);
+
+ const setBusy = (name: string, v: boolean) =>
+ setBusyByName((p) => ({ ...p, [name]: v }));
+
+ const handleToggle = async (server: McpServer) => {
+ setBusy(server.name, true);
+ try {
+ await mcpApi.setEnabled(server.name, !server.enabled);
+ await refresh();
+ } catch (e: unknown) {
+ setError((e as Error)?.message || "Toggle failed");
+ } finally {
+ setBusy(server.name, false);
+ }
+ };
+
+ const handleRemove = async (server: McpServer) => {
+ if (!confirm(`Remove MCP server "${server.name}"?`)) return;
+ setBusy(server.name, true);
+ try {
+ await mcpApi.removeServer(server.name);
+ await refresh();
+ } catch (e: unknown) {
+ const body = (e as { body?: { error?: string } }).body;
+ setError(body?.error || (e as Error)?.message || "Remove failed");
+ } finally {
+ setBusy(server.name, false);
+ }
+ };
+
+ const handleHealth = async (server: McpServer) => {
+ setBusy(server.name, true);
+ try {
+ await mcpApi.checkHealth(server.name);
+ await refresh();
+ } catch (e: unknown) {
+ setError((e as Error)?.message || "Health check failed");
+ } finally {
+ setBusy(server.name, false);
+ }
+ };
+
+ const canSubmit = (() => {
+ if (!form.name.trim()) return false;
+ if (form.transport === "stdio") return !!form.command.trim();
+ if (form.transport === "http" || form.transport === "sse")
+ return !!form.url.trim();
+ if (form.transport === "unix") return !!form.socketPath.trim();
+ return false;
+ })();
+
+ const handleSubmit = async () => {
+ if (!canSubmit) return;
+ setSubmitting(true);
+ setSubmitError(null);
+ try {
+ const body = buildAddBody(form);
+ const { server } = await mcpApi.addServer(body);
+ // Best-effort: auto-run health check so the UI shows tool count.
+ try {
+ await mcpApi.checkHealth(server.name);
+ } catch {
+ /* health check is informational; don't block the add flow */
+ }
+ setAdding(false);
+ setForm(EMPTY_FORM);
+ await refresh();
+ } catch (e: unknown) {
+ const body = (e as { body?: { error?: string; fix?: string } }).body;
+ setSubmitError(
+ [body?.error, body?.fix].filter(Boolean).join(" — ") ||
+ (e as Error)?.message ||
+ "Add failed",
+ );
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ // Group by origin. "local" = user-registered via the UI or CLI. Everything
+ // else (built-in package entries, registry-installed entries) sits under
+ // "Built-in" since the user can't remove them from the UI.
+ const builtIns = (servers || []).filter((s) => s.source !== "local");
+ const custom = (servers || []).filter((s) => s.source === "local");
+
+ return (
+
+
+
+
MCP Servers
+
+ Register your own MCP servers so queens can use their tools. New
+ servers take effect in the next queen session you start.
+
+
+
+
+
+
+
+
+ {error && (
+
+
+
{error}
+
+
+ )}
+
+ {loading && !servers && (
+
+ Loading MCP servers…
+
+ )}
+
+ {servers && (
+ <>
+ {custom.length > 0 && (
+
+ {custom.map((s) => (
+ handleToggle(s)}
+ onRemove={() => handleRemove(s)}
+ onHealth={() => handleHealth(s)}
+ isLocal
+ />
+ ))}
+
+ )}
+
+ {builtIns.length === 0 ? (
+
+ No built-in servers registered.
+
+ ) : (
+ builtIns.map((s) => (
+ handleToggle(s)}
+ onRemove={() => handleRemove(s)}
+ onHealth={() => handleHealth(s)}
+ />
+ ))
+ )}
+
+ >
+ )}
+
+ {/* Add MCP modal */}
+ {adding && (
+
+
!submitting && setAdding(false)}
+ />
+
+
+ )}
+
+ );
+}
+
+const inputCls =
+ "w-full bg-muted/30 border border-border/50 rounded-lg px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary/40";
+const textareaCls = `${inputCls} resize-none font-mono text-xs`;
+
+function FieldRow({
+ label,
+ hint,
+ children,
+}: {
+ label: string;
+ hint?: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {children}
+ {hint && (
+
{hint}
+ )}
+
+ );
+}
+
+function Section({
+ title,
+ children,
+}: {
+ title: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {title}
+
+
{children}
+
+ );
+}
+
+function ServerRow({
+ server,
+ busy,
+ onToggle,
+ onRemove,
+ onHealth,
+ isLocal,
+}: {
+ server: McpServer;
+ busy: boolean;
+ onToggle: () => void;
+ onRemove: () => void;
+ onHealth: () => void;
+ isLocal?: boolean;
+}) {
+ // Package-baked servers live in the repo and aren't managed by
+ // MCPRegistry, so toggling / removing / health-checking them would
+ // fail against the backend. Show them as read-only.
+ const isBuiltIn = server.source === "built-in";
+ return (
+
+
+
+
+
+
+
+ {server.name}
+
+
+ {server.transport}
+
+ {isBuiltIn && (
+
+ Built-in
+
+ )}
+ {server.tool_count !== null && server.tool_count !== undefined && (
+
+ {server.tool_count} tools
+
+ )}
+
+
+ {!isBuiltIn && healthBadge(server)}
+ {server.description && (
+
+ {isBuiltIn ? server.description : `· ${server.description}`}
+
+ )}
+
+
+ {!isBuiltIn && (
+ <>
+
+
+ >
+ )}
+ {isLocal && !isBuiltIn && (
+
+ )}
+
+ );
+}
diff --git a/core/frontend/src/components/QueenProfilePanel.tsx b/core/frontend/src/components/QueenProfilePanel.tsx
index 0905b731..9cfbbbdf 100644
--- a/core/frontend/src/components/QueenProfilePanel.tsx
+++ b/core/frontend/src/components/QueenProfilePanel.tsx
@@ -7,6 +7,7 @@ import { executionApi } from "@/api/execution";
import { compressImage } from "@/lib/image-utils";
import type { Colony } from "@/types/colony";
import { slugToColonyId } from "@/lib/colony-registry";
+import QueenToolsSection from "./QueenToolsSection";
interface QueenProfilePanelProps {
queenId: string;
@@ -354,6 +355,10 @@ export default function QueenProfilePanel({ queenId, colonies, onClose }: QueenP
)}
+
+
+
+
{colonies.length > 0 && (
Assigned Colonies
diff --git a/core/frontend/src/components/QueenToolsSection.tsx b/core/frontend/src/components/QueenToolsSection.tsx
new file mode 100644
index 00000000..6438f239
--- /dev/null
+++ b/core/frontend/src/components/QueenToolsSection.tsx
@@ -0,0 +1,22 @@
+import { useCallback } from "react";
+import { queensApi } from "@/api/queens";
+import ToolsEditor from "./ToolsEditor";
+
+export default function QueenToolsSection({ queenId }: { queenId: string }) {
+ const fetchSnapshot = useCallback(
+ () => queensApi.getTools(queenId),
+ [queenId],
+ );
+ const saveAllowlist = useCallback(
+ (enabled: string[] | null) => queensApi.updateTools(queenId, enabled),
+ [queenId],
+ );
+ return (
+
+ );
+}
diff --git a/core/frontend/src/components/SettingsModal.tsx b/core/frontend/src/components/SettingsModal.tsx
index d6333880..47595829 100644
--- a/core/frontend/src/components/SettingsModal.tsx
+++ b/core/frontend/src/components/SettingsModal.tsx
@@ -6,11 +6,12 @@ import { useModel, LLM_PROVIDERS } from "@/context/ModelContext";
import { credentialsApi } from "@/api/credentials";
import { configApi, type ModelOption } from "@/api/config";
import { compressImage } from "@/lib/image-utils";
+import McpServersPanel from "./McpServersPanel";
interface SettingsModalProps {
open: boolean;
onClose: () => void;
- initialSection?: "profile" | "byok";
+ initialSection?: "profile" | "byok" | "mcp";
}
function ValidationBadge({ state }: { state: "validating" | { valid: boolean | null; message: string } | undefined }) {
@@ -37,7 +38,7 @@ export default function SettingsModal({ open, onClose, initialSection }: Setting
const [displayName, setDisplayName] = useState(userProfile.displayName);
const [about, setAbout] = useState(userProfile.about);
- const [activeSection, setActiveSection] = useState<"profile" | "byok">(initialSection || "profile");
+ const [activeSection, setActiveSection] = useState<"profile" | "byok" | "mcp">(initialSection || "profile");
const [editingProvider, setEditingProvider] = useState(null);
const [keyInput, setKeyInput] = useState("");
const [showKey, setShowKey] = useState(false);
@@ -187,6 +188,12 @@ export default function SettingsModal({ open, onClose, initialSection }: Setting
>
BYOK
+
@@ -267,6 +274,8 @@ export default function SettingsModal({ open, onClose, initialSection }: Setting
>
)}
+ {activeSection === "mcp" && }
+
{activeSection === "byok" && (
<>
diff --git a/core/frontend/src/components/Sidebar.tsx b/core/frontend/src/components/Sidebar.tsx
index ded5211e..55d47f70 100644
--- a/core/frontend/src/components/Sidebar.tsx
+++ b/core/frontend/src/components/Sidebar.tsx
@@ -12,6 +12,7 @@ import {
X,
Crown,
Loader2,
+ Wrench,
} from "lucide-react";
import SidebarColonyItem from "./SidebarColonyItem";
import SidebarQueenItem from "./SidebarQueenItem";
@@ -172,6 +173,13 @@ export default function Sidebar() {
Prompt Library
+