From a0b85bcc1d92e54d336390bbddd5909f0aeb50ce Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 1 Jun 2026 08:18:12 -0700 Subject: [PATCH] Studio: move MCP to a composer button with presets (#5926) Move MCP from the chat settings sidebar to a composer pill next to Artifacts. The dropdown lists keyless presets (Context7, Exa, Hugging Face), all off by default, created on first enable and deduped by URL. Add custom MCP opens the existing manage dialog. Enabled rows show a green underlay and a tick that becomes an X on hover to remove; enabling Exa turns off the built-in Web Search and shows a hover tooltip. Reconciles the per-chat MCP flag on load so already-enabled servers activate the pill. No backend changes. --- .../src/components/assistant-ui/thread.tsx | 2 + .../features/chat/chat-mcp-servers-dialog.tsx | 10 +- .../src/features/chat/chat-settings-sheet.tsx | 76 ---- .../src/features/chat/mcp-composer-button.tsx | 355 ++++++++++++++++++ .../src/features/chat/shared-composer.tsx | 2 + 5 files changed, 368 insertions(+), 77 deletions(-) create mode 100644 studio/frontend/src/features/chat/mcp-composer-button.tsx diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index 26fc60d972..b7e0f35d35 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -44,6 +44,7 @@ import { } from "@/components/ui/dropdown-menu"; import { sentAudioNames } from "@/features/chat/api/chat-adapter"; import { parseExternalModelId } from "@/features/chat/external-providers"; +import { McpComposerButton } from "@/features/chat/mcp-composer-button"; import { getExternalReasoningCapabilities } from "@/features/chat/provider-capabilities"; import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store"; import { useExternalProvidersStore } from "@/features/chat/stores/external-providers-store"; @@ -1215,6 +1216,7 @@ const ComposerAction: FC<{ +
diff --git a/studio/frontend/src/features/chat/chat-mcp-servers-dialog.tsx b/studio/frontend/src/features/chat/chat-mcp-servers-dialog.tsx index b377e7a88e..93c4c8972c 100644 --- a/studio/frontend/src/features/chat/chat-mcp-servers-dialog.tsx +++ b/studio/frontend/src/features/chat/chat-mcp-servers-dialog.tsx @@ -178,6 +178,8 @@ function HeadersEditor({ export interface ChatMcpServersDialogProps { open: boolean; onOpenChange: (open: boolean) => void; + /** Open straight into the "add server" form instead of the list view. */ + openToCreate?: boolean; } type View = @@ -188,6 +190,7 @@ type View = export function ChatMcpServersDialog({ open, onOpenChange, + openToCreate = false, }: ChatMcpServersDialogProps) { const [servers, setServers] = useState([]); const [loading, setLoading] = useState(false); @@ -214,7 +217,12 @@ export function ChatMcpServersDialog({ useEffect(() => { if (!open) return; refresh(); - }, [open, refresh]); + // Land on the create form when opened via "Add custom MCP". + if (openToCreate) { + setView({ kind: "create" }); + setForm(EMPTY_FORM); + } + }, [open, openToCreate, refresh]); function startCreate() { setView({ kind: "create" }); diff --git a/studio/frontend/src/features/chat/chat-settings-sheet.tsx b/studio/frontend/src/features/chat/chat-settings-sheet.tsx index b19aa68551..a6e42d18fc 100644 --- a/studio/frontend/src/features/chat/chat-settings-sheet.tsx +++ b/studio/frontend/src/features/chat/chat-settings-sheet.tsx @@ -90,8 +90,6 @@ import { providerSupportsFastMode, } from "./provider-capabilities"; import { useChatRuntimeStore } from "./stores/chat-runtime-store"; -import { ChatMcpServersDialog } from "./chat-mcp-servers-dialog"; -import { listMcpServers } from "./api/mcp-servers-api"; import type { InferenceParams } from "./types/runtime"; export { defaultInferenceParams, type Preset } from "./presets/preset-policy"; @@ -1332,12 +1330,6 @@ export function ChatSettingsPanel({
) : null} - - {!isExternalModel ? ( - - - - ) : null} s.mcpEnabledForChat); - const setMcpEnabledForChat = useChatRuntimeStore( - (s) => s.setMcpEnabledForChat, - ); - const [enabledServerCount, setEnabledServerCount] = useState( - null, - ); - const [dialogOpen, setDialogOpen] = useState(false); - const [refreshTick, setRefreshTick] = useState(0); - - useEffect(() => { - let cancelled = false; - listMcpServers() - .then((rows) => { - if (cancelled) return; - setEnabledServerCount(rows.filter((row) => row.is_enabled).length); - }) - .catch(() => { - if (!cancelled) setEnabledServerCount(0); - }); - return () => { - cancelled = true; - }; - }, [refreshTick]); - - return ( -
-
-
- - Use MCP Servers - - - When on, every server marked enabled in the manage dialog is - attached to this chat's tool list. - -
- -
-
- - {enabledServerCount === null - ? "Loading…" - : enabledServerCount === 0 - ? "No servers configured" - : `${enabledServerCount} server${enabledServerCount === 1 ? "" : "s"} enabled`} - - -
- { - setDialogOpen(next); - if (!next) setRefreshTick((tick) => tick + 1); - }} - /> -
- ); -} - function ChatTemplateFields() { const defaultTemplate = useChatRuntimeStore((s) => s.defaultChatTemplate); const override = useChatRuntimeStore((s) => s.chatTemplateOverride); diff --git a/studio/frontend/src/features/chat/mcp-composer-button.tsx b/studio/frontend/src/features/chat/mcp-composer-button.tsx new file mode 100644 index 0000000000..b98be8bf69 --- /dev/null +++ b/studio/frontend/src/features/chat/mcp-composer-button.tsx @@ -0,0 +1,355 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { + Cancel01Icon, + McpServerIcon, + Tick02Icon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; + +import { + type McpServerConfig, + createMcpServer, + listMcpServers, + updateMcpServer, +} from "./api/mcp-servers-api"; +import { ChatMcpServersDialog } from "./chat-mcp-servers-dialog"; +import { useChatRuntimeStore } from "./stores/chat-runtime-store"; + +type McpPreset = { + id: string; + displayName: string; // stored row name + url: string; + label?: string; // dropdown text, if different from displayName + hint?: string; // shown when the row is highlighted + disablesWebSearch?: boolean; // turn the built-in Search pill off when enabled +}; + +// Keyless remote MCP presets (rate-limited free tiers, no API key). +// Hugging Face runs anonymously; add a token via "Add custom MCP". +const MCP_PRESETS: readonly McpPreset[] = [ + { + id: "context7", + displayName: "Context7", + url: "https://mcp.context7.com/mcp", + label: "Context7 (Realtime Docs)", + }, + { + id: "exa", + displayName: "Exa", + url: "https://mcp.exa.ai/mcp", + label: "Exa (Semantic Search)", + hint: "Enabling Exa will disable default search", + disablesWebSearch: true, + }, + { + id: "huggingface", + displayName: "Hugging Face", + url: "https://huggingface.co/mcp", + }, +] as const; + +// mcp_servers has no UNIQUE(url); dedupe by normalized URL so a preset +// toggle reuses its row instead of creating duplicates. +function normalizeMcpUrl(url: string): string { + return (url || "").trim().toLowerCase().replace(/\/+$/, ""); +} + +// Static, so it is not rebuilt on every render. +const PRESET_URLS = new Set(MCP_PRESETS.map((p) => normalizeMcpUrl(p.url))); + +export function McpComposerButton() { + const modelLoaded = useChatRuntimeStore( + (s) => !!s.params.checkpoint && !s.modelLoading, + ); + const supportsTools = useChatRuntimeStore((s) => s.supportsTools); + const mcpEnabledForChat = useChatRuntimeStore((s) => s.mcpEnabledForChat); + const setMcpEnabledForChat = useChatRuntimeStore( + (s) => s.setMcpEnabledForChat, + ); + const setToolsEnabled = useChatRuntimeStore((s) => s.setToolsEnabled); + + const [servers, setServers] = useState([]); + const [dialogOpen, setDialogOpen] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); + const [pendingUrl, setPendingUrl] = useState(null); + const [hintKey, setHintKey] = useState(null); + + // mcp_enabled only applies on the local tool-capable send path; grey out otherwise. + const usable = modelLoaded && supportsTools; + + // Keep the per-chat flag in step with whether any server is enabled. Reads the + // store directly so the callback stays stable (no refetch loop on mount). + const reconcileFlag = useCallback( + (rows: McpServerConfig[]) => { + const anyEnabled = rows.some((s) => s.is_enabled); + const current = useChatRuntimeStore.getState().mcpEnabledForChat; + if (anyEnabled && !current) setMcpEnabledForChat(true); + else if (!anyEnabled && current) setMcpEnabledForChat(false); + }, + [setMcpEnabledForChat], + ); + + const refresh = useCallback(async () => { + try { + const rows = await listMcpServers(); + setServers(rows); + reconcileFlag(rows); + } catch { + // Keep prior state if the list call fails. + } + }, [reconcileFlag]); + + // Initial load reconciles the pill with already-enabled servers (also on open). + useEffect(() => { + void refresh(); + }, [refresh]); + + const enabledUrls = new Set( + servers.filter((s) => s.is_enabled).map((s) => normalizeMcpUrl(s.url)), + ); + // Non-preset servers, shown below the presets so they stay toggleable. + const customServers = servers.filter( + (s) => !PRESET_URLS.has(normalizeMcpUrl(s.url)), + ); + const enabledCount = servers.filter((s) => s.is_enabled).length; + const active = mcpEnabledForChat && enabledCount > 0; + + async function toggleServer(args: { + url: string; + displayName: string; + checked: boolean; + existing?: McpServerConfig; + disablesWebSearch?: boolean; + }) { + const norm = normalizeMcpUrl(args.url); + if (pendingUrl === norm) return; // guard rapid double-clicks + setPendingUrl(norm); + try { + if (args.checked) { + // Reuse the already-loaded row, else create one. + if (args.existing) { + if (!args.existing.is_enabled) { + await updateMcpServer(args.existing.id, { isEnabled: true }); + } + } else { + await createMcpServer({ + displayName: args.displayName, + url: args.url, + isEnabled: true, + }); + } + setMcpEnabledForChat(true); + // Exa is a search server; turn off the built-in Web Search to avoid overlap. + if (args.disablesWebSearch) setToolsEnabled(false); + } else if (args.existing) { + await updateMcpServer(args.existing.id, { isEnabled: false }); + } + await refresh(); + } catch (err) { + toast.error("Failed to update MCP server", { + description: err instanceof Error ? err.message : String(err), + }); + } finally { + setPendingUrl(null); + } + } + + // One dropdown row. Enabled rows get a green underlay and a tick that + // becomes an X on hover so a click removes them. A hint shows as a tooltip + // driven by row hover; the tooltip anchor is pointer-events-none so the whole + // row stays clickable (a Radix TooltipTrigger would swallow the select). + const renderRow = (opts: { + key: string; + label: string; + url: string; + displayName: string; + enabled: boolean; + existing?: McpServerConfig; + hint?: string; + disablesWebSearch?: boolean; + }) => ( + { + e.preventDefault(); + void toggleServer({ + url: opts.url, + displayName: opts.displayName, + checked: !opts.enabled, + existing: opts.existing, + disablesWebSearch: opts.disablesWebSearch, + }); + }} + onPointerEnter={opts.hint ? () => setHintKey(opts.key) : undefined} + onPointerLeave={ + opts.hint + ? () => setHintKey((k) => (k === opts.key ? null : k)) + : undefined + } + className={cn( + "group/mcp relative flex items-center justify-between gap-2", + opts.enabled && + "bg-emerald-500/10 data-[highlighted]:bg-emerald-500/20", + )} + > + {opts.label} + {opts.enabled ? ( + + + + + ) : null} + {opts.hint ? ( + + + + + {opts.hint} + + ) : null} + + ); + + return ( + <> + {usable ? ( + { + setMenuOpen(open); + if (open) void refresh(); + }} + > + + + + +
+ MCP Servers + +
+ {MCP_PRESETS.map((preset) => { + const norm = normalizeMcpUrl(preset.url); + return renderRow({ + key: preset.id, + label: preset.label ?? preset.displayName, + url: preset.url, + displayName: preset.displayName, + enabled: enabledUrls.has(norm), + existing: servers.find((s) => normalizeMcpUrl(s.url) === norm), + hint: preset.hint, + disablesWebSearch: preset.disablesWebSearch, + }); + })} + {customServers.length > 0 ? : null} + {customServers.map((server) => + renderRow({ + key: server.id, + label: server.display_name, + url: server.url, + displayName: server.display_name, + enabled: server.is_enabled, + existing: server, + }), + )} + + { + setMenuOpen(false); + setDialogOpen(true); + }} + > + Add custom MCP + +
+
+ ) : ( + + + {/* Not disabled, so the tooltip still fires on hover. */} + + + + MCP works with local tool-capable models + + + )} + { + setDialogOpen(next); + // Resync after managing servers. + if (!next) void refresh(); + }} + openToCreate={true} + /> + + ); +} diff --git a/studio/frontend/src/features/chat/shared-composer.tsx b/studio/frontend/src/features/chat/shared-composer.tsx index 5ee98e230c..b6dbffd5d0 100644 --- a/studio/frontend/src/features/chat/shared-composer.tsx +++ b/studio/frontend/src/features/chat/shared-composer.tsx @@ -43,6 +43,7 @@ import { providerTypeSupportsVision, } from "./external-providers"; import { useExternalProvidersStore } from "./stores/external-providers-store"; +import { McpComposerButton } from "./mcp-composer-button"; import { type ReasoningEffort, useChatRuntimeStore, @@ -1272,6 +1273,7 @@ export function SharedComposer({ Artifacts + {showWebFetchPill && (