From 5de68cb8032892e68e221d2e7faaf718081e8c54 Mon Sep 17 00:00:00 2001 From: Gabrielsv01 Date: Mon, 4 May 2026 22:41:22 -0300 Subject: [PATCH] Add MCP section to config UI --- .../src/components/config/config-page.tsx | 134 +++++++++ .../src/components/config/config-sections.tsx | 265 +++++++++++++++++- .../src/components/config/form-model.ts | 123 ++++++++ web/frontend/src/i18n/locales/en.json | 26 ++ web/frontend/src/i18n/locales/zh.json | 26 ++ 5 files changed, 571 insertions(+), 3 deletions(-) diff --git a/web/frontend/src/components/config/config-page.tsx b/web/frontend/src/components/config/config-page.tsx index 0b5665640..071c4d4a0 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" @@ -143,6 +146,42 @@ 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, + type: "stdio", + url: "", + command: "", + argsText: "", + envText: "{}", + 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) @@ -214,10 +253,86 @@ export function ConfigPage() { "Cron exec timeout", { min: 0 }, ) + const mcpDiscoveryTTL = parseIntField( + form.mcpDiscoveryTTL, + "MCP discovery ttl", + { min: 0 }, + ) + const mcpDiscoveryMaxSearchResults = parseIntField( + form.mcpDiscoveryMaxSearchResults, + "MCP discovery max search results", + { min: 0 }, + ) const execConfigPatch: Record = { enabled: form.execEnabled, } + 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(), + })) + .filter((server) => server.name !== "") + + 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 upsertServerEntries = normalizedServers.map((server) => { + if (server.type === "http") { + if (server.url === "") { + throw new Error(`MCP server ${server.name} requires an HTTP URL.`) + } + + return [ + server.name, + { + enabled: server.enabled, + type: "http", + url: server.url, + headers: parseJSONObjectField( + server.headersText, + `MCP server ${server.name} headers`, + ), + }, + ] as const + } + + if (server.command === "") { + throw new Error(`MCP server ${server.name} requires a command.`) + } + + return [ + server.name, + { + enabled: server.enabled, + command: server.command, + args: parseMultilineList(server.argsText), + env: parseJSONObjectField( + server.envText, + `MCP server ${server.name} env`, + ), + }, + ] as const + }) + + const mcpServersPatch = Object.fromEntries([ + ...upsertServerEntries, + ...removedServerEntries, + ]) + if (form.execEnabled) { execConfigPatch.allow_remote = form.allowRemote execConfigPatch.enable_deny_patterns = form.enableDenyPatterns @@ -264,6 +379,17 @@ export function ConfigPage() { exec_timeout_minutes: cronExecTimeoutMinutes, }, exec: execConfigPatch, + mcp: { + enabled: form.mcpEnabled, + discovery: { + enabled: form.mcpDiscoveryEnabled, + ttl: mcpDiscoveryTTL, + max_search_results: mcpDiscoveryMaxSearchResults, + use_bm25: form.mcpDiscoveryUseBM25, + use_regex: form.mcpDiscoveryUseRegex, + }, + servers: mcpServersPatch, + }, }, heartbeat: { enabled: form.heartbeatEnabled, @@ -414,6 +540,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 fa6b3a079..323b1d194 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,264 @@ 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 === "http" ? ( +
+ + onServerFieldChange(server.id, "url", e.target.value) + } + /> +