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,
|
DevicesSection,
|
||||||
ExecSection,
|
ExecSection,
|
||||||
LauncherSection,
|
LauncherSection,
|
||||||
|
MCPSection,
|
||||||
RuntimeSection,
|
RuntimeSection,
|
||||||
} from "@/components/config/config-sections"
|
} from "@/components/config/config-sections"
|
||||||
import {
|
import {
|
||||||
|
|
@ -29,9 +30,11 @@ import {
|
||||||
EMPTY_FORM,
|
EMPTY_FORM,
|
||||||
EMPTY_LAUNCHER_FORM,
|
EMPTY_LAUNCHER_FORM,
|
||||||
type LauncherForm,
|
type LauncherForm,
|
||||||
|
type MCPServerForm,
|
||||||
buildFormFromConfig,
|
buildFormFromConfig,
|
||||||
parseCIDRText,
|
parseCIDRText,
|
||||||
parseIntField,
|
parseIntField,
|
||||||
|
parseJSONObjectField,
|
||||||
parseMultilineList,
|
parseMultilineList,
|
||||||
} from "@/components/config/form-model"
|
} from "@/components/config/form-model"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
|
@ -40,6 +43,21 @@ import { Button } from "@/components/ui/button"
|
||||||
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
|
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
|
||||||
import { refreshGatewayState } from "@/store/gateway"
|
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() {
|
export function ConfigPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
@ -143,6 +161,44 @@ export function ConfigPage() {
|
||||||
setLauncherForm((prev) => ({ ...prev, [key]: value }))
|
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 = () => {
|
const handleReset = () => {
|
||||||
setForm(baseline)
|
setForm(baseline)
|
||||||
setLauncherForm(launcherBaseline)
|
setLauncherForm(launcherBaseline)
|
||||||
|
|
@ -178,6 +234,17 @@ export function ConfigPage() {
|
||||||
throw new Error("Session scope is required.")
|
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", {
|
const maxTokens = parseIntField(form.maxTokens, "Max tokens", {
|
||||||
min: 1,
|
min: 1,
|
||||||
})
|
})
|
||||||
|
|
@ -214,10 +281,185 @@ export function ConfigPage() {
|
||||||
"Cron exec timeout",
|
"Cron exec timeout",
|
||||||
{ min: 0 },
|
{ 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> = {
|
const execConfigPatch: Record<string, unknown> = {
|
||||||
enabled: form.execEnabled,
|
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) {
|
if (form.execEnabled) {
|
||||||
execConfigPatch.allow_remote = form.allowRemote
|
execConfigPatch.allow_remote = form.allowRemote
|
||||||
execConfigPatch.enable_deny_patterns = form.enableDenyPatterns
|
execConfigPatch.enable_deny_patterns = form.enableDenyPatterns
|
||||||
|
|
@ -264,6 +506,11 @@ export function ConfigPage() {
|
||||||
exec_timeout_minutes: cronExecTimeoutMinutes,
|
exec_timeout_minutes: cronExecTimeoutMinutes,
|
||||||
},
|
},
|
||||||
exec: execConfigPatch,
|
exec: execConfigPatch,
|
||||||
|
mcp: {
|
||||||
|
enabled: form.mcpEnabled,
|
||||||
|
discovery: mcpDiscoveryPatch,
|
||||||
|
servers: mcpServersPatch,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
heartbeat: {
|
heartbeat: {
|
||||||
enabled: form.heartbeatEnabled,
|
enabled: form.heartbeatEnabled,
|
||||||
|
|
@ -414,6 +661,14 @@ export function ConfigPage() {
|
||||||
|
|
||||||
<RuntimeSection form={form} onFieldChange={updateField} />
|
<RuntimeSection form={form} onFieldChange={updateField} />
|
||||||
|
|
||||||
|
<MCPSection
|
||||||
|
form={form}
|
||||||
|
onFieldChange={updateField}
|
||||||
|
onAddServer={handleMCPServerAdd}
|
||||||
|
onRemoveServer={handleMCPServerRemove}
|
||||||
|
onServerFieldChange={handleMCPServerFieldChange}
|
||||||
|
/>
|
||||||
|
|
||||||
<ExecSection form={form} onFieldChange={updateField} />
|
<ExecSection form={form} onFieldChange={updateField} />
|
||||||
|
|
||||||
<CronSection 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 { useState } from "react"
|
||||||
import type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
@ -6,6 +7,8 @@ import {
|
||||||
type CoreConfigForm,
|
type CoreConfigForm,
|
||||||
DM_SCOPE_OPTIONS,
|
DM_SCOPE_OPTIONS,
|
||||||
type LauncherForm,
|
type LauncherForm,
|
||||||
|
type MCPServerForm,
|
||||||
|
type MCPServerType,
|
||||||
} from "@/components/config/form-model"
|
} from "@/components/config/form-model"
|
||||||
import { Field, SwitchCardField } from "@/components/shared-form"
|
import { Field, SwitchCardField } from "@/components/shared-form"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
@ -221,6 +224,343 @@ interface ExecSectionProps {
|
||||||
onFieldChange: UpdateCoreField
|
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) {
|
export function ExecSection({ form, onFieldChange }: ExecSectionProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [testCommand, setTestCommand] = useState("")
|
const [testCommand, setTestCommand] = useState("")
|
||||||
|
|
@ -545,9 +885,7 @@ export function LauncherSection({
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
placeholder={t("pages.config.dashboard_password_placeholder")}
|
placeholder={t("pages.config.dashboard_password_placeholder")}
|
||||||
onChange={(e) =>
|
onChange={(e) => onFieldChange("dashboardPassword", e.target.value)}
|
||||||
onFieldChange("dashboardPassword", e.target.value)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,29 @@ export interface CoreConfigForm {
|
||||||
heartbeatInterval: string
|
heartbeatInterval: string
|
||||||
devicesEnabled: boolean
|
devicesEnabled: boolean
|
||||||
monitorUSB: 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 {
|
export interface LauncherForm {
|
||||||
|
|
@ -91,6 +114,13 @@ export const EMPTY_FORM: CoreConfigForm = {
|
||||||
heartbeatInterval: "30",
|
heartbeatInterval: "30",
|
||||||
devicesEnabled: false,
|
devicesEnabled: false,
|
||||||
monitorUSB: true,
|
monitorUSB: true,
|
||||||
|
mcpEnabled: false,
|
||||||
|
mcpDiscoveryEnabled: false,
|
||||||
|
mcpDiscoveryTTL: "5",
|
||||||
|
mcpDiscoveryMaxSearchResults: "5",
|
||||||
|
mcpDiscoveryUseBM25: true,
|
||||||
|
mcpDiscoveryUseRegex: false,
|
||||||
|
mcpServers: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EMPTY_LAUNCHER_FORM: LauncherForm = {
|
export const EMPTY_LAUNCHER_FORM: LauncherForm = {
|
||||||
|
|
@ -116,6 +146,10 @@ function asBool(value: unknown): boolean {
|
||||||
return value === true
|
return value === true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function asOptionalBool(value: unknown): boolean | null {
|
||||||
|
return typeof value === "boolean" ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
function asNumberString(value: unknown, fallback: string): string {
|
function asNumberString(value: unknown, fallback: string): string {
|
||||||
if (typeof value === "number" && Number.isFinite(value)) {
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
return String(value)
|
return String(value)
|
||||||
|
|
@ -126,6 +160,54 @@ function asNumberString(value: unknown, fallback: string): string {
|
||||||
return fallback
|
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 {
|
export function buildFormFromConfig(config: unknown): CoreConfigForm {
|
||||||
const root = asRecord(config)
|
const root = asRecord(config)
|
||||||
const agents = asRecord(root.agents)
|
const agents = asRecord(root.agents)
|
||||||
|
|
@ -134,6 +216,8 @@ export function buildFormFromConfig(config: unknown): CoreConfigForm {
|
||||||
const heartbeat = asRecord(root.heartbeat)
|
const heartbeat = asRecord(root.heartbeat)
|
||||||
const devices = asRecord(root.devices)
|
const devices = asRecord(root.devices)
|
||||||
const tools = asRecord(root.tools)
|
const tools = asRecord(root.tools)
|
||||||
|
const mcp = asRecord(tools.mcp)
|
||||||
|
const mcpDiscovery = asRecord(mcp.discovery)
|
||||||
const cron = asRecord(tools.cron)
|
const cron = asRecord(tools.cron)
|
||||||
const exec = asRecord(tools.exec)
|
const exec = asRecord(tools.exec)
|
||||||
const toolFeedback = asRecord(defaults.tool_feedback)
|
const toolFeedback = asRecord(defaults.tool_feedback)
|
||||||
|
|
@ -228,6 +312,29 @@ export function buildFormFromConfig(config: unknown): CoreConfigForm {
|
||||||
devices.monitor_usb === undefined
|
devices.monitor_usb === undefined
|
||||||
? EMPTY_FORM.monitorUSB
|
? EMPTY_FORM.monitorUSB
|
||||||
: asBool(devices.monitor_usb),
|
: 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())
|
.map((value) => value.trim())
|
||||||
.filter((value) => value.length > 0)
|
.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": "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_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",
|
"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": {
|
"sections": {
|
||||||
"agent": "Agent",
|
"agent": "Agent",
|
||||||
"runtime": "Runtime",
|
"runtime": "Runtime",
|
||||||
|
"mcp": "MCP",
|
||||||
"exec": "Run Commands",
|
"exec": "Run Commands",
|
||||||
"cron": "Cron Tasks",
|
"cron": "Cron Tasks",
|
||||||
"launcher": "Launcher",
|
"launcher": "Launcher",
|
||||||
|
|
|
||||||
|
|
@ -731,9 +731,40 @@
|
||||||
"allowed_cidrs": "允许访问网段",
|
"allowed_cidrs": "允许访问网段",
|
||||||
"allowed_cidrs_hint": "仅允许这些 CIDR 网段的客户端访问服务。可按行或逗号分隔;留空表示允许所有来源",
|
"allowed_cidrs_hint": "仅允许这些 CIDR 网段的客户端访问服务。可按行或逗号分隔;留空表示允许所有来源",
|
||||||
"allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8",
|
"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": {
|
"sections": {
|
||||||
"agent": "智能体",
|
"agent": "智能体",
|
||||||
"runtime": "运行时",
|
"runtime": "运行时",
|
||||||
|
"mcp": "MCP",
|
||||||
"exec": "运行命令",
|
"exec": "运行命令",
|
||||||
"cron": "定时任务",
|
"cron": "定时任务",
|
||||||
"launcher": "启动器",
|
"launcher": "启动器",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue