diff --git a/web/frontend/src/components/config/config-page.tsx b/web/frontend/src/components/config/config-page.tsx index 0b56656400..f74b258f6f 100644 --- a/web/frontend/src/components/config/config-page.tsx +++ b/web/frontend/src/components/config/config-page.tsx @@ -22,6 +22,7 @@ import { DevicesSection, ExecSection, LauncherSection, + MCPSection, RuntimeSection, } from "@/components/config/config-sections" import { @@ -29,9 +30,11 @@ import { EMPTY_FORM, EMPTY_LAUNCHER_FORM, type LauncherForm, + type MCPServerForm, buildFormFromConfig, parseCIDRText, parseIntField, + parseJSONObjectField, parseMultilineList, } from "@/components/config/form-model" import { PageHeader } from "@/components/page-header" @@ -40,6 +43,21 @@ import { Button } from "@/components/ui/button" import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" import { refreshGatewayState } from "@/store/gateway" +function buildStringMapMergePatch( + next: Record, + previous: Record, +): Record { + const patch: Record = { ...next } + + for (const key of Object.keys(previous)) { + if (!(key in next)) { + patch[key] = null + } + } + + return patch +} + export function ConfigPage() { const { t } = useTranslation() const queryClient = useQueryClient() @@ -143,6 +161,44 @@ export function ConfigPage() { setLauncherForm((prev) => ({ ...prev, [key]: value })) } + const handleMCPServerAdd = () => { + const nextIndex = form.mcpServers.length + 1 + const server: MCPServerForm = { + id: `mcp-${Date.now()}-${nextIndex}`, + name: "", + enabled: true, + deferredOverride: null, + type: "stdio", + url: "", + command: "", + argsText: "", + envText: "{}", + envFile: "", + headersText: "{}", + } + updateField("mcpServers", [...form.mcpServers, server]) + } + + const handleMCPServerRemove = (id: string) => { + updateField( + "mcpServers", + form.mcpServers.filter((server) => server.id !== id), + ) + } + + const handleMCPServerFieldChange = ( + id: string, + key: K, + value: MCPServerForm[K], + ) => { + updateField( + "mcpServers", + form.mcpServers.map((server) => + server.id === id ? { ...server, [key]: value } : server, + ), + ) + } + const handleReset = () => { setForm(baseline) setLauncherForm(launcherBaseline) @@ -178,6 +234,17 @@ export function ConfigPage() { throw new Error("Session scope is required.") } + if ( + form.mcpEnabled && + form.mcpDiscoveryEnabled && + !form.mcpDiscoveryUseBM25 && + !form.mcpDiscoveryUseRegex + ) { + throw new Error( + "MCP discovery requires at least one search method (BM25 or regex).", + ) + } + const maxTokens = parseIntField(form.maxTokens, "Max tokens", { min: 1, }) @@ -214,10 +281,185 @@ export function ConfigPage() { "Cron exec timeout", { min: 0 }, ) + const mcpDiscoveryValidationEnabled = + form.mcpEnabled && form.mcpDiscoveryEnabled + const mcpDiscoveryPatch: Record = { + enabled: form.mcpDiscoveryEnabled, + use_bm25: form.mcpDiscoveryUseBM25, + use_regex: form.mcpDiscoveryUseRegex, + } + + if (mcpDiscoveryValidationEnabled) { + mcpDiscoveryPatch.ttl = parseIntField( + form.mcpDiscoveryTTL, + "MCP discovery ttl", + { + min: 1, + }, + ) + mcpDiscoveryPatch.max_search_results = parseIntField( + form.mcpDiscoveryMaxSearchResults, + "MCP discovery max search results", + { min: 1 }, + ) + } const execConfigPatch: Record = { enabled: form.execEnabled, } + let mcpServersPatch: Record | null> = {} + if (form.mcpEnabled) { + const baselineServerNames = new Set( + baseline.mcpServers + .map((server) => server.name.trim()) + .filter((name) => name !== ""), + ) + + const normalizedServers = form.mcpServers + .map((server) => ({ + ...server, + name: server.name.trim(), + url: server.url.trim(), + command: server.command.trim(), + envFile: server.envFile.trim(), + })) + .filter((server) => server.name !== "") + + const serverNameCounts = new Map() + for (const server of normalizedServers) { + serverNameCounts.set( + server.name, + (serverNameCounts.get(server.name) ?? 0) + 1, + ) + } + + const duplicateNames = Array.from(serverNameCounts.entries()) + .filter(([, count]) => count > 1) + .map(([name]) => name) + .sort((a, b) => a.localeCompare(b)) + + if (duplicateNames.length > 0) { + throw new Error( + `MCP server names must be unique. Duplicates: ${duplicateNames.join(", ")}.`, + ) + } + + const currentServerNames = new Set( + normalizedServers.map((server) => server.name), + ) + + const removedServerEntries = Array.from(baselineServerNames) + .filter((name) => !currentServerNames.has(name)) + .map((name) => [name, null] as const) + + const baselineServersByName = new Map( + baseline.mcpServers + .map((server) => ({ + ...server, + name: server.name.trim(), + })) + .filter((server) => server.name !== "") + .map((server) => [server.name, server] as const), + ) + + const upsertServerEntries = normalizedServers.map((server) => { + const deferredPatch = { deferred: server.deferredOverride } + const baselineServer = baselineServersByName.get(server.name) + const shouldValidateServer = server.enabled + + if (server.type !== "stdio") { + if (shouldValidateServer && server.url === "") { + throw new Error(`MCP server ${server.name} requires a URL.`) + } + + if (shouldValidateServer) { + try { + const parsedURL = new URL(server.url) + if ( + parsedURL.protocol !== "http:" && + parsedURL.protocol !== "https:" + ) { + throw new Error("invalid protocol") + } + } catch { + throw new Error( + `MCP server ${server.name} requires a valid HTTP(S) URL.`, + ) + } + } + + const baselineHeaders = baselineServer + ? parseJSONObjectField( + baselineServer.headersText, + `Saved MCP server ${server.name} headers`, + ) + : {} + + return [ + server.name, + { + ...deferredPatch, + enabled: server.enabled, + type: server.type, + url: server.url, + headers: buildStringMapMergePatch( + shouldValidateServer + ? parseJSONObjectField( + server.headersText, + `MCP server ${server.name} headers`, + ) + : baselineHeaders, + baselineHeaders, + ), + command: null, + args: null, + env: null, + env_file: null, + }, + ] as const + } + + if (shouldValidateServer && server.command === "") { + throw new Error(`MCP server ${server.name} requires a command.`) + } + + const baselineEnv = baselineServer + ? parseJSONObjectField( + baselineServer.envText, + `Saved MCP server ${server.name} env`, + ) + : {} + + return [ + server.name, + { + ...deferredPatch, + enabled: server.enabled, + type: "stdio", + command: server.command, + args: parseMultilineList(server.argsText), + env: buildStringMapMergePatch( + shouldValidateServer + ? parseJSONObjectField( + server.envText, + `MCP server ${server.name} env`, + ) + : baselineEnv, + baselineEnv, + ), + env_file: server.envFile === "" ? null : server.envFile, + url: null, + headers: null, + }, + ] as const + }) + + mcpServersPatch = Object.fromEntries([ + ...upsertServerEntries, + ...removedServerEntries, + ]) + } + if (form.execEnabled) { execConfigPatch.allow_remote = form.allowRemote execConfigPatch.enable_deny_patterns = form.enableDenyPatterns @@ -264,6 +506,11 @@ export function ConfigPage() { exec_timeout_minutes: cronExecTimeoutMinutes, }, exec: execConfigPatch, + mcp: { + enabled: form.mcpEnabled, + discovery: mcpDiscoveryPatch, + servers: mcpServersPatch, + }, }, heartbeat: { enabled: form.heartbeatEnabled, @@ -414,6 +661,14 @@ export function ConfigPage() { + + diff --git a/web/frontend/src/components/config/config-sections.tsx b/web/frontend/src/components/config/config-sections.tsx index fa6b3a0795..f71f025a65 100644 --- a/web/frontend/src/components/config/config-sections.tsx +++ b/web/frontend/src/components/config/config-sections.tsx @@ -1,3 +1,4 @@ +import { IconPlus, IconTrash } from "@tabler/icons-react" import { useState } from "react" import type { ReactNode } from "react" import { useTranslation } from "react-i18next" @@ -6,6 +7,8 @@ import { type CoreConfigForm, DM_SCOPE_OPTIONS, type LauncherForm, + type MCPServerForm, + type MCPServerType, } from "@/components/config/form-model" import { Field, SwitchCardField } from "@/components/shared-form" import { Button } from "@/components/ui/button" @@ -221,6 +224,343 @@ interface ExecSectionProps { onFieldChange: UpdateCoreField } +interface MCPSectionProps { + form: CoreConfigForm + onFieldChange: UpdateCoreField + onAddServer: () => void + onRemoveServer: (id: string) => void + onServerFieldChange: ( + id: string, + key: K, + value: MCPServerForm[K], + ) => void +} + +export function MCPSection({ + form, + onFieldChange, + onAddServer, + onRemoveServer, + onServerFieldChange, +}: MCPSectionProps) { + const { t } = useTranslation() + + return ( + + onFieldChange("mcpEnabled", checked)} + /> + + {form.mcpEnabled && ( + <> + + onFieldChange("mcpDiscoveryEnabled", checked) + } + /> + + {form.mcpDiscoveryEnabled && ( + <> + + + onFieldChange("mcpDiscoveryTTL", e.target.value) + } + /> + + + + + onFieldChange( + "mcpDiscoveryMaxSearchResults", + e.target.value, + ) + } + /> + + + + onFieldChange("mcpDiscoveryUseBM25", checked) + } + /> + + + onFieldChange("mcpDiscoveryUseRegex", checked) + } + /> + + )} + + +
+ {form.mcpServers.map((server) => ( +
+
+
+ {server.name.trim() || t("pages.config.mcp_server_new")} +
+ +
+ +
+ + onServerFieldChange(server.id, "name", e.target.value) + } + /> + + + + + onServerFieldChange(server.id, "enabled", checked) + } + /> + + +
+ + {server.type !== "stdio" ? ( +
+ + onServerFieldChange(server.id, "url", e.target.value) + } + /> +