Add MCP section to config web UI (#2770)
* Add MCP section to config UI * Handle MCP sse and URL-based server mapping * Validate duplicate MCP server names before save * Disable MCP discovery options based on mutual exclusivity in config section Co-authored-by: Copilot <copilot@github.com> * Clear stale MCP transport fields in patch payload * Fix MCP config form state preservation and validation * Avoid MCP form ID collisions for distinct server names * Validate remote MCP URLs in config UI * fix(config): correct MCP discovery merge patch behavior * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix(config): align MCP discovery semantics and MCP server editor behavior * fix(config): validate MCP server fields only when active --------- Co-authored-by: Copilot <copilot@github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
parent
6e6293e596
commit
1055e082a4
5 changed files with 796 additions and 3 deletions
|
|
@ -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<string, string>,
|
||||
previous: Record<string, string>,
|
||||
): Record<string, string | null> {
|
||||
const patch: Record<string, string | null> = { ...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 = <K extends keyof MCPServerForm>(
|
||||
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<string, unknown> = {
|
||||
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<string, unknown> = {
|
||||
enabled: form.execEnabled,
|
||||
}
|
||||
|
||||
let mcpServersPatch: Record<string, Record<string, unknown> | 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<string, number>()
|
||||
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() {
|
|||
|
||||
<RuntimeSection form={form} onFieldChange={updateField} />
|
||||
|
||||
<MCPSection
|
||||
form={form}
|
||||
onFieldChange={updateField}
|
||||
onAddServer={handleMCPServerAdd}
|
||||
onRemoveServer={handleMCPServerRemove}
|
||||
onServerFieldChange={handleMCPServerFieldChange}
|
||||
/>
|
||||
|
||||
<ExecSection form={form} onFieldChange={updateField} />
|
||||
|
||||
<CronSection form={form} onFieldChange={updateField} />
|
||||
|
|
|
|||
|
|
@ -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: <K extends keyof MCPServerForm>(
|
||||
id: string,
|
||||
key: K,
|
||||
value: MCPServerForm[K],
|
||||
) => void
|
||||
}
|
||||
|
||||
export function MCPSection({
|
||||
form,
|
||||
onFieldChange,
|
||||
onAddServer,
|
||||
onRemoveServer,
|
||||
onServerFieldChange,
|
||||
}: MCPSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<ConfigSectionCard
|
||||
title={t("pages.config.sections.mcp")}
|
||||
description={t("pages.config.mcp_section_hint")}
|
||||
>
|
||||
<SwitchCardField
|
||||
label={t("pages.config.mcp_enabled")}
|
||||
hint={t("pages.config.mcp_enabled_hint")}
|
||||
layout="setting-row"
|
||||
checked={form.mcpEnabled}
|
||||
onCheckedChange={(checked) => onFieldChange("mcpEnabled", checked)}
|
||||
/>
|
||||
|
||||
{form.mcpEnabled && (
|
||||
<>
|
||||
<SwitchCardField
|
||||
label={t("pages.config.mcp_discovery_enabled")}
|
||||
hint={t("pages.config.mcp_discovery_enabled_hint")}
|
||||
layout="setting-row"
|
||||
checked={form.mcpDiscoveryEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onFieldChange("mcpDiscoveryEnabled", checked)
|
||||
}
|
||||
/>
|
||||
|
||||
{form.mcpDiscoveryEnabled && (
|
||||
<>
|
||||
<Field
|
||||
label={t("pages.config.mcp_discovery_ttl")}
|
||||
hint={t("pages.config.mcp_discovery_ttl_hint")}
|
||||
layout="setting-row"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.mcpDiscoveryTTL}
|
||||
onChange={(e) =>
|
||||
onFieldChange("mcpDiscoveryTTL", e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("pages.config.mcp_discovery_max_results")}
|
||||
hint={t("pages.config.mcp_discovery_max_results_hint")}
|
||||
layout="setting-row"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.mcpDiscoveryMaxSearchResults}
|
||||
onChange={(e) =>
|
||||
onFieldChange(
|
||||
"mcpDiscoveryMaxSearchResults",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<SwitchCardField
|
||||
label={t("pages.config.mcp_discovery_use_bm25")}
|
||||
hint={t("pages.config.mcp_discovery_use_bm25_hint")}
|
||||
layout="setting-row"
|
||||
checked={form.mcpDiscoveryUseBM25}
|
||||
disabled={
|
||||
form.mcpDiscoveryUseBM25 && !form.mcpDiscoveryUseRegex
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
onFieldChange("mcpDiscoveryUseBM25", checked)
|
||||
}
|
||||
/>
|
||||
|
||||
<SwitchCardField
|
||||
label={t("pages.config.mcp_discovery_use_regex")}
|
||||
hint={t("pages.config.mcp_discovery_use_regex_hint")}
|
||||
layout="setting-row"
|
||||
checked={form.mcpDiscoveryUseRegex}
|
||||
disabled={
|
||||
form.mcpDiscoveryUseRegex && !form.mcpDiscoveryUseBM25
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
onFieldChange("mcpDiscoveryUseRegex", checked)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Field
|
||||
label={t("pages.config.mcp_servers")}
|
||||
hint={t("pages.config.mcp_servers_hint")}
|
||||
layout="setting-row"
|
||||
controlClassName="md:max-w-2xl"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{form.mcpServers.map((server) => (
|
||||
<div
|
||||
key={server.id}
|
||||
className="border-border rounded-md border p-3"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="text-sm font-medium">
|
||||
{server.name.trim() || t("pages.config.mcp_server_new")}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onRemoveServer(server.id)}
|
||||
>
|
||||
<IconTrash className="size-4" />
|
||||
{t("pages.config.mcp_server_remove")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Input
|
||||
value={server.name}
|
||||
placeholder={t(
|
||||
"pages.config.mcp_server_name_placeholder",
|
||||
)}
|
||||
aria-label={t("pages.config.mcp_server_name_placeholder")}
|
||||
onChange={(e) =>
|
||||
onServerFieldChange(server.id, "name", e.target.value)
|
||||
}
|
||||
/>
|
||||
|
||||
<Select
|
||||
value={server.type}
|
||||
onValueChange={(value) =>
|
||||
onServerFieldChange(
|
||||
server.id,
|
||||
"type",
|
||||
value as MCPServerType,
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={t("pages.config.mcp_server_discovery_mode")}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="stdio">stdio</SelectItem>
|
||||
<SelectItem value="sse">sse</SelectItem>
|
||||
<SelectItem value="http">http</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<SwitchCardField
|
||||
label={t("pages.config.mcp_server_enabled")}
|
||||
layout="setting-row"
|
||||
checked={server.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onServerFieldChange(server.id, "enabled", checked)
|
||||
}
|
||||
/>
|
||||
|
||||
<Select
|
||||
value={
|
||||
server.deferredOverride === null
|
||||
? "inherit"
|
||||
: server.deferredOverride
|
||||
? "deferred"
|
||||
: "eager"
|
||||
}
|
||||
onValueChange={(value) =>
|
||||
onServerFieldChange(
|
||||
server.id,
|
||||
"deferredOverride",
|
||||
value === "inherit" ? null : value === "deferred",
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={t("pages.config.mcp_server_discovery_mode")}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"pages.config.mcp_server_discovery_mode",
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="inherit">
|
||||
{t("pages.config.mcp_server_discovery_mode_inherit")}
|
||||
</SelectItem>
|
||||
<SelectItem value="deferred">
|
||||
{t("pages.config.mcp_server_discovery_mode_deferred")}
|
||||
</SelectItem>
|
||||
<SelectItem value="eager">
|
||||
{t("pages.config.mcp_server_discovery_mode_eager")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{server.type !== "stdio" ? (
|
||||
<div className="mt-3 grid gap-3">
|
||||
<Input
|
||||
value={server.url}
|
||||
placeholder={t(
|
||||
"pages.config.mcp_server_url_placeholder",
|
||||
)}
|
||||
aria-label={t(
|
||||
"pages.config.mcp_server_url_placeholder",
|
||||
)}
|
||||
onChange={(e) =>
|
||||
onServerFieldChange(server.id, "url", e.target.value)
|
||||
}
|
||||
/>
|
||||
<Textarea
|
||||
value={server.headersText}
|
||||
placeholder={t(
|
||||
"pages.config.mcp_server_headers_placeholder",
|
||||
)}
|
||||
aria-label={t(
|
||||
"pages.config.mcp_server_headers_placeholder",
|
||||
)}
|
||||
className="min-h-[88px] font-mono text-xs"
|
||||
onChange={(e) =>
|
||||
onServerFieldChange(
|
||||
server.id,
|
||||
"headersText",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 grid gap-3">
|
||||
<Input
|
||||
value={server.command}
|
||||
placeholder={t(
|
||||
"pages.config.mcp_server_command_placeholder",
|
||||
)}
|
||||
aria-label={t(
|
||||
"pages.config.mcp_server_command_placeholder",
|
||||
)}
|
||||
onChange={(e) =>
|
||||
onServerFieldChange(
|
||||
server.id,
|
||||
"command",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
value={server.envFile}
|
||||
placeholder={t(
|
||||
"pages.config.mcp_server_env_file_placeholder",
|
||||
)}
|
||||
aria-label={t(
|
||||
"pages.config.mcp_server_env_file_placeholder",
|
||||
)}
|
||||
onChange={(e) =>
|
||||
onServerFieldChange(
|
||||
server.id,
|
||||
"envFile",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Textarea
|
||||
value={server.argsText}
|
||||
placeholder={t(
|
||||
"pages.config.mcp_server_args_placeholder",
|
||||
)}
|
||||
aria-label={t(
|
||||
"pages.config.mcp_server_args_placeholder",
|
||||
)}
|
||||
className="min-h-[88px] font-mono text-xs"
|
||||
onChange={(e) =>
|
||||
onServerFieldChange(
|
||||
server.id,
|
||||
"argsText",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Textarea
|
||||
value={server.envText}
|
||||
placeholder={t(
|
||||
"pages.config.mcp_server_env_placeholder",
|
||||
)}
|
||||
aria-label={t(
|
||||
"pages.config.mcp_server_env_placeholder",
|
||||
)}
|
||||
className="min-h-[88px] font-mono text-xs"
|
||||
onChange={(e) =>
|
||||
onServerFieldChange(
|
||||
server.id,
|
||||
"envText",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div>
|
||||
<Button type="button" variant="outline" onClick={onAddServer}>
|
||||
<IconPlus className="size-4" />
|
||||
{t("pages.config.mcp_server_add")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
</ConfigSectionCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function ExecSection({ form, onFieldChange }: ExecSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const [testCommand, setTestCommand] = useState("")
|
||||
|
|
@ -545,9 +885,7 @@ export function LauncherSection({
|
|||
disabled={disabled}
|
||||
autoComplete="new-password"
|
||||
placeholder={t("pages.config.dashboard_password_placeholder")}
|
||||
onChange={(e) =>
|
||||
onFieldChange("dashboardPassword", e.target.value)
|
||||
}
|
||||
onChange={(e) => onFieldChange("dashboardPassword", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,29 @@ export interface CoreConfigForm {
|
|||
heartbeatInterval: string
|
||||
devicesEnabled: boolean
|
||||
monitorUSB: boolean
|
||||
mcpEnabled: boolean
|
||||
mcpDiscoveryEnabled: boolean
|
||||
mcpDiscoveryTTL: string
|
||||
mcpDiscoveryMaxSearchResults: string
|
||||
mcpDiscoveryUseBM25: boolean
|
||||
mcpDiscoveryUseRegex: boolean
|
||||
mcpServers: MCPServerForm[]
|
||||
}
|
||||
|
||||
export type MCPServerType = "http" | "sse" | "stdio"
|
||||
|
||||
export interface MCPServerForm {
|
||||
id: string
|
||||
name: string
|
||||
enabled: boolean
|
||||
deferredOverride: boolean | null
|
||||
type: MCPServerType
|
||||
url: string
|
||||
command: string
|
||||
argsText: string
|
||||
envText: string
|
||||
envFile: string
|
||||
headersText: string
|
||||
}
|
||||
|
||||
export interface LauncherForm {
|
||||
|
|
@ -91,6 +114,13 @@ export const EMPTY_FORM: CoreConfigForm = {
|
|||
heartbeatInterval: "30",
|
||||
devicesEnabled: false,
|
||||
monitorUSB: true,
|
||||
mcpEnabled: false,
|
||||
mcpDiscoveryEnabled: false,
|
||||
mcpDiscoveryTTL: "5",
|
||||
mcpDiscoveryMaxSearchResults: "5",
|
||||
mcpDiscoveryUseBM25: true,
|
||||
mcpDiscoveryUseRegex: false,
|
||||
mcpServers: [],
|
||||
}
|
||||
|
||||
export const EMPTY_LAUNCHER_FORM: LauncherForm = {
|
||||
|
|
@ -116,6 +146,10 @@ function asBool(value: unknown): boolean {
|
|||
return value === true
|
||||
}
|
||||
|
||||
function asOptionalBool(value: unknown): boolean | null {
|
||||
return typeof value === "boolean" ? value : null
|
||||
}
|
||||
|
||||
function asNumberString(value: unknown, fallback: string): string {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return String(value)
|
||||
|
|
@ -126,6 +160,54 @@ function asNumberString(value: unknown, fallback: string): string {
|
|||
return fallback
|
||||
}
|
||||
|
||||
function toMCPServerType(value: unknown): MCPServerType {
|
||||
if (value === "http" || value === "sse") {
|
||||
return value
|
||||
}
|
||||
return "stdio"
|
||||
}
|
||||
|
||||
function makeMCPServerID(name: string): string {
|
||||
const encoded = encodeURIComponent(name)
|
||||
if (encoded.length > 0) {
|
||||
return `mcp-${encoded}`
|
||||
}
|
||||
return `mcp-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
function mapMCPServers(value: unknown): MCPServerForm[] {
|
||||
const servers = asRecord(value)
|
||||
return Object.entries(servers).map(([name, rawConfig]) => {
|
||||
const cfg = asRecord(rawConfig)
|
||||
const argsList = Array.isArray(cfg.args)
|
||||
? cfg.args.filter((item): item is string => typeof item === "string")
|
||||
: []
|
||||
const url = asString(cfg.url)
|
||||
const type =
|
||||
cfg.type === undefined
|
||||
? url
|
||||
? "sse"
|
||||
: "stdio"
|
||||
: toMCPServerType(cfg.type)
|
||||
const env = asRecord(cfg.env)
|
||||
const headers = asRecord(cfg.headers)
|
||||
|
||||
return {
|
||||
id: makeMCPServerID(name),
|
||||
name,
|
||||
enabled: cfg.enabled !== false,
|
||||
deferredOverride: asOptionalBool(cfg.deferred),
|
||||
type,
|
||||
url,
|
||||
command: asString(cfg.command),
|
||||
argsText: argsList.join("\n"),
|
||||
envText: JSON.stringify(env, null, 2),
|
||||
envFile: asString(cfg.env_file),
|
||||
headersText: JSON.stringify(headers, null, 2),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function buildFormFromConfig(config: unknown): CoreConfigForm {
|
||||
const root = asRecord(config)
|
||||
const agents = asRecord(root.agents)
|
||||
|
|
@ -134,6 +216,8 @@ export function buildFormFromConfig(config: unknown): CoreConfigForm {
|
|||
const heartbeat = asRecord(root.heartbeat)
|
||||
const devices = asRecord(root.devices)
|
||||
const tools = asRecord(root.tools)
|
||||
const mcp = asRecord(tools.mcp)
|
||||
const mcpDiscovery = asRecord(mcp.discovery)
|
||||
const cron = asRecord(tools.cron)
|
||||
const exec = asRecord(tools.exec)
|
||||
const toolFeedback = asRecord(defaults.tool_feedback)
|
||||
|
|
@ -228,6 +312,29 @@ export function buildFormFromConfig(config: unknown): CoreConfigForm {
|
|||
devices.monitor_usb === undefined
|
||||
? EMPTY_FORM.monitorUSB
|
||||
: asBool(devices.monitor_usb),
|
||||
mcpEnabled:
|
||||
mcp.enabled === undefined ? EMPTY_FORM.mcpEnabled : asBool(mcp.enabled),
|
||||
mcpDiscoveryEnabled:
|
||||
mcpDiscovery.enabled === undefined
|
||||
? EMPTY_FORM.mcpDiscoveryEnabled
|
||||
: asBool(mcpDiscovery.enabled),
|
||||
mcpDiscoveryTTL: asNumberString(
|
||||
mcpDiscovery.ttl,
|
||||
EMPTY_FORM.mcpDiscoveryTTL,
|
||||
),
|
||||
mcpDiscoveryMaxSearchResults: asNumberString(
|
||||
mcpDiscovery.max_search_results,
|
||||
EMPTY_FORM.mcpDiscoveryMaxSearchResults,
|
||||
),
|
||||
mcpDiscoveryUseBM25:
|
||||
mcpDiscovery.use_bm25 === undefined
|
||||
? EMPTY_FORM.mcpDiscoveryUseBM25
|
||||
: asBool(mcpDiscovery.use_bm25),
|
||||
mcpDiscoveryUseRegex:
|
||||
mcpDiscovery.use_regex === undefined
|
||||
? EMPTY_FORM.mcpDiscoveryUseRegex
|
||||
: asBool(mcpDiscovery.use_regex),
|
||||
mcpServers: mapMCPServers(mcp.servers),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -268,3 +375,34 @@ export function parseMultilineList(raw: string): string[] {
|
|||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0)
|
||||
}
|
||||
|
||||
export function parseJSONObjectField(
|
||||
rawValue: string,
|
||||
label: string,
|
||||
): Record<string, string> {
|
||||
const trimmed = rawValue.trim()
|
||||
if (trimmed === "") {
|
||||
return {}
|
||||
}
|
||||
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(trimmed)
|
||||
} catch {
|
||||
throw new Error(`${label} must be valid JSON.`)
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error(`${label} must be a JSON object.`)
|
||||
}
|
||||
|
||||
const entries = Object.entries(parsed as Record<string, unknown>)
|
||||
const result: Record<string, string> = {}
|
||||
for (const [key, value] of entries) {
|
||||
if (typeof value !== "string") {
|
||||
throw new Error(`${label}.${key} must be a string.`)
|
||||
}
|
||||
result[key] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
|
|||
|
|
@ -731,9 +731,40 @@
|
|||
"allowed_cidrs": "Allowed Network CIDRs",
|
||||
"allowed_cidrs_hint": "Only clients from these CIDR ranges can access the service. One per line or comma-separated. Leave empty to allow all.",
|
||||
"allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8",
|
||||
"mcp_section_hint": "Configure MCP servers without editing config.json manually.",
|
||||
"mcp_enabled": "Enable MCP",
|
||||
"mcp_enabled_hint": "Turn MCP server integration on or off.",
|
||||
"mcp_discovery_enabled": "Enable MCP Discovery",
|
||||
"mcp_discovery_enabled_hint": "Allow MCP discovery tools to search registered MCP servers.",
|
||||
"mcp_discovery_ttl": "Discovered tool unlock TTL",
|
||||
"mcp_discovery_ttl_hint": "How many tool-execution TTL ticks discovered tools remain available after search.",
|
||||
"mcp_discovery_max_results": "Discovery Max Results",
|
||||
"mcp_discovery_max_results_hint": "Maximum MCP discovery matches returned per query.",
|
||||
"mcp_discovery_use_bm25": "Use BM25 Ranking",
|
||||
"mcp_discovery_use_bm25_hint": "Use BM25 lexical scoring for MCP discovery results.",
|
||||
"mcp_discovery_use_regex": "Enable Regex Search",
|
||||
"mcp_discovery_use_regex_hint": "Allow regex-based matching in MCP discovery.",
|
||||
"mcp_servers": "MCP Servers",
|
||||
"mcp_servers_hint": "Add, edit, or remove MCP servers.",
|
||||
"mcp_server_new": "New MCP server",
|
||||
"mcp_server_add": "Add server",
|
||||
"mcp_server_remove": "Remove",
|
||||
"mcp_server_enabled": "Enabled",
|
||||
"mcp_server_discovery_mode": "Discovery mode",
|
||||
"mcp_server_discovery_mode_inherit": "Follow global discovery mode",
|
||||
"mcp_server_discovery_mode_deferred": "Deferred discovery",
|
||||
"mcp_server_discovery_mode_eager": "Eager registration",
|
||||
"mcp_server_name_placeholder": "Server name (e.g. github)",
|
||||
"mcp_server_url_placeholder": "Server URL (e.g. https://example.com/mcp)",
|
||||
"mcp_server_command_placeholder": "Command (e.g. npx)",
|
||||
"mcp_server_env_file_placeholder": "Environment file path (optional)",
|
||||
"mcp_server_args_placeholder": "Args, one per line",
|
||||
"mcp_server_env_placeholder": "Environment JSON object",
|
||||
"mcp_server_headers_placeholder": "Headers JSON object",
|
||||
"sections": {
|
||||
"agent": "Agent",
|
||||
"runtime": "Runtime",
|
||||
"mcp": "MCP",
|
||||
"exec": "Run Commands",
|
||||
"cron": "Cron Tasks",
|
||||
"launcher": "Launcher",
|
||||
|
|
|
|||
|
|
@ -731,9 +731,40 @@
|
|||
"allowed_cidrs": "允许访问网段",
|
||||
"allowed_cidrs_hint": "仅允许这些 CIDR 网段的客户端访问服务。可按行或逗号分隔;留空表示允许所有来源",
|
||||
"allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8",
|
||||
"mcp_section_hint": "通过可视化界面配置 MCP 服务器,无需手动编辑 config.json",
|
||||
"mcp_enabled": "启用 MCP",
|
||||
"mcp_enabled_hint": "开启或关闭 MCP 服务集成",
|
||||
"mcp_discovery_enabled": "启用 MCP 发现",
|
||||
"mcp_discovery_enabled_hint": "允许 MCP 发现工具检索已注册的 MCP 服务器",
|
||||
"mcp_discovery_ttl": "发现工具解锁保持轮数",
|
||||
"mcp_discovery_ttl_hint": "已发现的工具保持解锁状态的对话轮数",
|
||||
"mcp_discovery_max_results": "发现最大结果数",
|
||||
"mcp_discovery_max_results_hint": "每次查询返回的 MCP 发现结果上限",
|
||||
"mcp_discovery_use_bm25": "使用 BM25 排序",
|
||||
"mcp_discovery_use_bm25_hint": "在 MCP 发现中启用 BM25 词法评分",
|
||||
"mcp_discovery_use_regex": "启用正则搜索",
|
||||
"mcp_discovery_use_regex_hint": "允许在 MCP 发现中使用正则匹配",
|
||||
"mcp_servers": "MCP 服务器",
|
||||
"mcp_servers_hint": "添加、编辑或删除 MCP 服务器",
|
||||
"mcp_server_new": "新 MCP 服务器",
|
||||
"mcp_server_add": "添加服务器",
|
||||
"mcp_server_remove": "移除",
|
||||
"mcp_server_enabled": "已启用",
|
||||
"mcp_server_discovery_mode": "发现模式",
|
||||
"mcp_server_discovery_mode_inherit": "跟随全局发现模式",
|
||||
"mcp_server_discovery_mode_deferred": "延迟发现",
|
||||
"mcp_server_discovery_mode_eager": "立即注册",
|
||||
"mcp_server_name_placeholder": "服务器名称(例如 github)",
|
||||
"mcp_server_url_placeholder": "服务器 URL(例如 https://example.com/mcp)",
|
||||
"mcp_server_command_placeholder": "命令(例如 npx)",
|
||||
"mcp_server_env_file_placeholder": "环境变量文件路径(可选)",
|
||||
"mcp_server_args_placeholder": "参数(每行一个)",
|
||||
"mcp_server_env_placeholder": "环境变量 JSON 对象",
|
||||
"mcp_server_headers_placeholder": "请求头 JSON 对象",
|
||||
"sections": {
|
||||
"agent": "智能体",
|
||||
"runtime": "运行时",
|
||||
"mcp": "MCP",
|
||||
"exec": "运行命令",
|
||||
"cron": "定时任务",
|
||||
"launcher": "启动器",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue