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 && (