wip(app): mcp settings
parent
7665b8e30d
commit
664f4c0a68
|
|
@ -8,6 +8,7 @@ import { SettingsGeneral } from "./settings-general"
|
|||
import { SettingsKeybinds } from "./settings-keybinds"
|
||||
import { SettingsProviders } from "./settings-providers"
|
||||
import { SettingsModels } from "./settings-models"
|
||||
import { SettingsMcp } from "./settings-mcp"
|
||||
|
||||
export const DialogSettings: Component = () => {
|
||||
const language = useLanguage()
|
||||
|
|
@ -45,6 +46,10 @@ export const DialogSettings: Component = () => {
|
|||
<Icon name="models" />
|
||||
{language.t("settings.models.title")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="mcp">
|
||||
<Icon name="mcp" />
|
||||
{language.t("settings.mcp.title")}
|
||||
</Tabs.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -67,6 +72,9 @@ export const DialogSettings: Component = () => {
|
|||
<Tabs.Content value="models" class="no-scrollbar">
|
||||
<SettingsModels />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="mcp" class="no-scrollbar">
|
||||
<SettingsMcp />
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</Dialog>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,15 +1,619 @@
|
|||
import { Component } from "solid-js"
|
||||
import type { Config, McpLocalConfig, McpRemoteConfig, McpStatus } from "@opencode-ai/sdk/v2/client"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon, type IconProps } from "@opencode-ai/ui/icon"
|
||||
import { Tag } from "@opencode-ai/ui/tag"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { For, Show, createMemo, onMount, type Component } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
type Mode = "remote" | "local"
|
||||
type McpMap = NonNullable<Config["mcp"]>
|
||||
type McpEntry = McpMap[string]
|
||||
type McpConfig = McpLocalConfig | McpRemoteConfig
|
||||
type McpState = McpStatus["status"]
|
||||
|
||||
const FEATURED = [
|
||||
{
|
||||
name: "context7",
|
||||
title: "Context7",
|
||||
description: "Fresh framework docs and API references in one remote server.",
|
||||
icon: "code-lines",
|
||||
panel: "linear-gradient(135deg, rgba(14, 165, 233, 0.16), rgba(15, 23, 42, 0.04))",
|
||||
glow: "rgba(56, 189, 248, 0.2)",
|
||||
badge: "rgba(8, 145, 178, 0.14)",
|
||||
color: "rgb(8, 145, 178)",
|
||||
config: {
|
||||
type: "remote",
|
||||
url: "https://mcp.context7.com/mcp",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gh_grep",
|
||||
title: "Grep by Vercel",
|
||||
description: "Search public code snippets on GitHub through grep.app.",
|
||||
icon: "magnifying-glass-menu",
|
||||
panel: "linear-gradient(135deg, rgba(99, 102, 241, 0.14), rgba(30, 41, 59, 0.04))",
|
||||
glow: "rgba(129, 140, 248, 0.18)",
|
||||
badge: "rgba(79, 70, 229, 0.14)",
|
||||
color: "rgb(79, 70, 229)",
|
||||
config: {
|
||||
type: "remote",
|
||||
url: "https://mcp.grep.app",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "playwright",
|
||||
title: "Playwright",
|
||||
description: "Browser automation tools for testing, scraping, and repros.",
|
||||
icon: "window-cursor",
|
||||
panel: "linear-gradient(135deg, rgba(59, 130, 246, 0.14), rgba(15, 23, 42, 0.04))",
|
||||
glow: "rgba(96, 165, 250, 0.18)",
|
||||
badge: "rgba(37, 99, 235, 0.14)",
|
||||
color: "rgb(37, 99, 235)",
|
||||
config: {
|
||||
type: "local",
|
||||
command: ["npx", "@playwright/mcp@latest"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "github",
|
||||
title: "GitHub",
|
||||
description: "Repo, PR, and issue tools powered by your GitHub token.",
|
||||
icon: "github",
|
||||
panel: "linear-gradient(135deg, rgba(71, 85, 105, 0.14), rgba(15, 23, 42, 0.06))",
|
||||
glow: "rgba(100, 116, 139, 0.18)",
|
||||
badge: "rgba(51, 65, 85, 0.14)",
|
||||
color: "rgb(51, 65, 85)",
|
||||
config: {
|
||||
type: "local",
|
||||
command: ["npx", "-y", "@modelcontextprotocol/server-github"],
|
||||
environment: {
|
||||
GITHUB_PERSONAL_ACCESS_TOKEN: "{env:GITHUB_PERSONAL_ACCESS_TOKEN}",
|
||||
},
|
||||
},
|
||||
},
|
||||
] satisfies Array<{
|
||||
name: string
|
||||
title: string
|
||||
description: string
|
||||
icon: IconProps["name"]
|
||||
panel: string
|
||||
glow: string
|
||||
badge: string
|
||||
color: string
|
||||
config: McpConfig
|
||||
}>
|
||||
|
||||
const STATUS = {
|
||||
connected: "mcp.status.connected",
|
||||
failed: "mcp.status.failed",
|
||||
needs_auth: "mcp.status.needs_auth",
|
||||
disabled: "mcp.status.disabled",
|
||||
needs_client_registration: "settings.mcp.status.needs_client_registration",
|
||||
} satisfies Record<McpState, string>
|
||||
|
||||
const empty = (mode: Mode = "remote") => ({
|
||||
mode,
|
||||
name: "",
|
||||
url: "",
|
||||
command: "",
|
||||
headers: "",
|
||||
environment: "",
|
||||
timeout: "",
|
||||
})
|
||||
|
||||
const isConfig = (value: McpEntry | undefined): value is McpConfig =>
|
||||
typeof value === "object" && value !== null && "type" in value
|
||||
|
||||
const split = (value: string) =>
|
||||
value
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const parseMap = (value: string, allowColon: boolean) => {
|
||||
const out: Record<string, string> = {}
|
||||
|
||||
for (const line of split(value)) {
|
||||
const eq = line.indexOf("=")
|
||||
const cut = !allowColon ? eq : ([line.indexOf(":"), eq].filter((part) => part > 0).sort((a, b) => a - b)[0] ?? -1)
|
||||
|
||||
if (cut < 1) return { error: line }
|
||||
|
||||
const key = line.slice(0, cut).trim()
|
||||
const item = line.slice(cut + 1).trim()
|
||||
if (!key || !item) return { error: line }
|
||||
out[key] = item
|
||||
}
|
||||
|
||||
return { value: Object.keys(out).length > 0 ? out : undefined }
|
||||
}
|
||||
|
||||
const parseCmd = (value: string) =>
|
||||
(value.match(/"[^"]*"|'[^']*'|[^\s]+/g) ?? []).map((part) => {
|
||||
if (part.startsWith('"') && part.endsWith('"')) return part.slice(1, -1)
|
||||
if (part.startsWith("'") && part.endsWith("'")) return part.slice(1, -1)
|
||||
return part
|
||||
})
|
||||
|
||||
export const SettingsMcp: Component = () => {
|
||||
// TODO: Replace this placeholder with full MCP settings controls.
|
||||
const language = useLanguage()
|
||||
const lang = useLanguage()
|
||||
const sdk = useGlobalSDK()
|
||||
const sync = useGlobalSync()
|
||||
const [state, setState] = createStore({
|
||||
form: empty(),
|
||||
submitting: "",
|
||||
statusLoading: false,
|
||||
status: {} as Record<string, McpStatus>,
|
||||
})
|
||||
|
||||
const busy = createMemo(() => state.submitting.length > 0)
|
||||
|
||||
const items = createMemo(() => {
|
||||
return Object.entries(sync.data.config.mcp ?? {})
|
||||
.filter((item): item is [string, McpConfig] => isConfig(item[1]))
|
||||
.map(([name, config]) => ({ name, config }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
})
|
||||
|
||||
const names = createMemo(() => new Set(items().map((item) => item.name)))
|
||||
|
||||
const spin = () => `${lang.t("common.loading")}${lang.t("common.loading.ellipsis")}`
|
||||
|
||||
const kind = (value: Mode) => {
|
||||
if (value === "remote") return lang.t("settings.mcp.type.remote")
|
||||
return lang.t("settings.mcp.type.local")
|
||||
}
|
||||
|
||||
const fail = (description: string) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: lang.t("common.requestFailed"),
|
||||
description,
|
||||
})
|
||||
}
|
||||
|
||||
const load = () => {
|
||||
setState("statusLoading", true)
|
||||
return sdk.client.mcp
|
||||
.status()
|
||||
.then((x) => {
|
||||
setState("status", x.data ?? {})
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
setState("statusLoading", false)
|
||||
})
|
||||
}
|
||||
|
||||
const save = (next: McpMap, job: string, onSuccess: () => void, title: string, description: string) => {
|
||||
const prev = sync.data.config.mcp
|
||||
setState("submitting", job)
|
||||
sync.set("config", "mcp", next)
|
||||
|
||||
sync
|
||||
.updateConfig({ mcp: next })
|
||||
.then(() => {
|
||||
onSuccess()
|
||||
void load()
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title,
|
||||
description,
|
||||
})
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
sync.set("config", "mcp", prev)
|
||||
fail(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
.finally(() => {
|
||||
setState("submitting", "")
|
||||
})
|
||||
}
|
||||
|
||||
const add = (name: string, config: McpConfig, job: string, reset: boolean) => {
|
||||
const key = name.trim()
|
||||
if (!key) {
|
||||
fail(lang.t("settings.mcp.validation.name"))
|
||||
return
|
||||
}
|
||||
|
||||
if (names().has(key)) {
|
||||
fail(lang.t("settings.mcp.validation.duplicate", { name: key }))
|
||||
return
|
||||
}
|
||||
|
||||
const next = {
|
||||
...(sync.data.config.mcp ?? {}),
|
||||
[key]: config,
|
||||
}
|
||||
|
||||
save(
|
||||
next,
|
||||
job,
|
||||
() => {
|
||||
if (!reset) return
|
||||
setState("form", empty(state.form.mode))
|
||||
},
|
||||
lang.t("settings.mcp.toast.added.title"),
|
||||
lang.t("settings.mcp.toast.added.description", { name: key }),
|
||||
)
|
||||
}
|
||||
|
||||
const addForm = () => {
|
||||
if (busy()) return
|
||||
|
||||
const timeout = state.form.timeout.trim()
|
||||
const wait = timeout ? Number(timeout) : undefined
|
||||
if (wait !== undefined && (!Number.isInteger(wait) || wait <= 0)) {
|
||||
fail(lang.t("settings.mcp.validation.timeout"))
|
||||
return
|
||||
}
|
||||
|
||||
if (state.form.mode === "remote") {
|
||||
const url = state.form.url.trim()
|
||||
if (!url) {
|
||||
fail(lang.t("settings.mcp.validation.url"))
|
||||
return
|
||||
}
|
||||
|
||||
const headers = parseMap(state.form.headers, true)
|
||||
if (headers.error) {
|
||||
fail(lang.t("settings.mcp.validation.headers", { line: headers.error }))
|
||||
return
|
||||
}
|
||||
|
||||
add(
|
||||
state.form.name,
|
||||
{
|
||||
type: "remote",
|
||||
url,
|
||||
...(headers.value ? { headers: headers.value } : {}),
|
||||
...(wait ? { timeout: wait } : {}),
|
||||
},
|
||||
"form",
|
||||
true,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const command = parseCmd(state.form.command.trim())
|
||||
if (command.length === 0) {
|
||||
fail(lang.t("settings.mcp.validation.command"))
|
||||
return
|
||||
}
|
||||
|
||||
const environment = parseMap(state.form.environment, false)
|
||||
if (environment.error) {
|
||||
fail(lang.t("settings.mcp.validation.environment", { line: environment.error }))
|
||||
return
|
||||
}
|
||||
|
||||
add(
|
||||
state.form.name,
|
||||
{
|
||||
type: "local",
|
||||
command,
|
||||
...(environment.value ? { environment: environment.value } : {}),
|
||||
...(wait ? { timeout: wait } : {}),
|
||||
},
|
||||
"form",
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
const addFeatured = (item: (typeof FEATURED)[number]) => {
|
||||
if (busy()) return
|
||||
add(item.name, item.config, `featured:${item.name}`, false)
|
||||
}
|
||||
|
||||
const remove = (name: string) => {
|
||||
if (busy()) return
|
||||
|
||||
const next = { ...(sync.data.config.mcp ?? {}) }
|
||||
delete next[name]
|
||||
|
||||
save(
|
||||
next,
|
||||
`remove:${name}`,
|
||||
() => undefined,
|
||||
lang.t("settings.mcp.toast.removed.title"),
|
||||
lang.t("settings.mcp.toast.removed.description", { name }),
|
||||
)
|
||||
}
|
||||
|
||||
const label = (name: string) => {
|
||||
const value = state.status[name]?.status
|
||||
if (!value) return
|
||||
return lang.t(STATUS[value])
|
||||
}
|
||||
|
||||
const issue = (name: string) => {
|
||||
const value = state.status[name]
|
||||
if (!value || !("error" in value)) return
|
||||
return value.error
|
||||
}
|
||||
|
||||
const line = (config: McpConfig) => {
|
||||
if (config.type === "remote") return config.url
|
||||
return config.command.join(" ")
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void load()
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto">
|
||||
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
|
||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.mcp.title")}</h2>
|
||||
<p class="text-14-regular text-text-weak">{language.t("settings.mcp.description")}</p>
|
||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||
<div class="flex flex-col gap-1 pt-6 pb-8 max-w-[720px]">
|
||||
<h2 class="text-16-medium text-text-strong">{lang.t("settings.mcp.title")}</h2>
|
||||
<p class="text-14-regular text-text-weak">{lang.t("settings.mcp.description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-8 max-w-[720px]">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong">{lang.t("settings.mcp.section.featured")}</h3>
|
||||
<p class="text-12-regular text-text-weak">{lang.t("settings.mcp.section.featured.description")}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<For each={FEATURED}>
|
||||
{(item) => {
|
||||
const added = () => names().has(item.name)
|
||||
const pending = () => state.submitting === `featured:${item.name}`
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="group relative overflow-hidden rounded-2xl border border-border-weak-base p-4 text-left transition-transform duration-200 disabled:cursor-default"
|
||||
classList={{
|
||||
"hover:-translate-y-0.5": !added() && !busy(),
|
||||
"opacity-60": added(),
|
||||
}}
|
||||
disabled={added() || busy()}
|
||||
onClick={() => addFeatured(item)}
|
||||
>
|
||||
<div class="absolute inset-0" aria-hidden="true">
|
||||
<div class="absolute inset-0" style={{ background: item.panel }} />
|
||||
<div
|
||||
class="absolute -right-6 -top-6 size-24 rounded-full blur-2xl"
|
||||
style={{ background: item.glow }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="relative flex flex-col gap-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div
|
||||
class="flex size-11 shrink-0 items-center justify-center rounded-2xl border border-border-weak-base"
|
||||
style={{ background: item.badge, color: item.color }}
|
||||
>
|
||||
<Icon name={item.icon} class="size-5" />
|
||||
</div>
|
||||
<span class="rounded-full bg-surface-base px-2.5 py-1 text-11-medium text-text-weak">
|
||||
{kind(item.config.type)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-14-medium text-text-strong">{item.title}</span>
|
||||
<Show when={added()}>
|
||||
<span class="rounded-full bg-surface-base px-2 py-0.5 text-11-medium text-text-weak">
|
||||
{lang.t("settings.mcp.featured.added")}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={pending()}>
|
||||
<span class="text-11-regular text-text-weak">{spin()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<span class="text-12-regular leading-5 text-text-weak">{item.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="text-14-medium text-text-strong">{lang.t("settings.mcp.section.configured")}</h3>
|
||||
<Show when={state.statusLoading}>
|
||||
<span class="text-11-regular text-text-weak">{spin()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<p class="text-12-regular text-text-weak">{lang.t("settings.mcp.section.configured.description")}</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||
<Show
|
||||
when={items().length > 0}
|
||||
fallback={<div class="py-4 text-14-regular text-text-weak">{lang.t("dialog.mcp.empty")}</div>}
|
||||
>
|
||||
<For each={items()}>
|
||||
{(item) => {
|
||||
const current = () => state.status[item.name]?.status
|
||||
const text = () => label(item.name)
|
||||
const problem = () => issue(item.name)
|
||||
const pending = () => state.submitting === `remove:${item.name}`
|
||||
|
||||
return (
|
||||
<div class="flex flex-wrap items-start justify-between gap-4 py-4 border-b border-border-weak-base last:border-none">
|
||||
<div class="min-w-0 flex-1 flex flex-col gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-14-medium text-text-strong">{item.name}</span>
|
||||
<Tag>{kind(item.config.type)}</Tag>
|
||||
<Show when={text()}>
|
||||
<span
|
||||
class="rounded-full bg-surface-base px-2 py-0.5 text-11-medium"
|
||||
classList={{
|
||||
"text-icon-success-base": current() === "connected",
|
||||
"text-icon-warning-base": current() === "needs_auth",
|
||||
"text-icon-critical-base":
|
||||
current() === "failed" || current() === "needs_client_registration",
|
||||
"text-text-weak": current() === "disabled",
|
||||
}}
|
||||
>
|
||||
{text()}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<span class="text-12-regular text-text-weak break-all">{line(item.config)}</span>
|
||||
|
||||
<Show when={problem()}>
|
||||
<span class="text-12-regular text-icon-critical-base break-all">{problem()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Button size="large" variant="ghost" disabled={busy()} onClick={() => remove(item.name)}>
|
||||
{pending() ? spin() : lang.t("settings.mcp.action.remove")}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong">{lang.t("settings.mcp.section.add")}</h3>
|
||||
<p class="text-12-regular text-text-weak">{lang.t("settings.mcp.section.add.description")}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-border-weak-base bg-surface-raised-base p-4 sm:p-5">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-12-medium text-text-strong">{lang.t("settings.mcp.form.type.label")}</span>
|
||||
<div class="inline-flex w-full gap-1 rounded-xl bg-surface-base p-1 sm:w-auto">
|
||||
<For each={["remote", "local"] as const}>
|
||||
{(mode) => (
|
||||
<button
|
||||
type="button"
|
||||
class="h-9 flex-1 rounded-lg px-3 text-12-medium transition-colors sm:flex-none"
|
||||
classList={{
|
||||
"bg-surface-raised-base text-text-strong": state.form.mode === mode,
|
||||
"text-text-weak": state.form.mode !== mode,
|
||||
}}
|
||||
onClick={() => {
|
||||
if (busy()) return
|
||||
setState("form", "mode", mode)
|
||||
}}
|
||||
>
|
||||
{kind(mode)}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<TextField
|
||||
label={lang.t("settings.mcp.form.name.label")}
|
||||
value={state.form.name}
|
||||
onChange={(value) => setState("form", "name", value)}
|
||||
placeholder={lang.t("settings.mcp.form.name.placeholder")}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label={lang.t("settings.mcp.form.timeout.label")}
|
||||
value={state.form.timeout}
|
||||
onChange={(value) => setState("form", "timeout", value)}
|
||||
placeholder={lang.t("settings.mcp.form.timeout.placeholder")}
|
||||
inputMode="numeric"
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={state.form.mode === "remote"}
|
||||
fallback={
|
||||
<>
|
||||
<TextField
|
||||
label={lang.t("settings.mcp.form.command.label")}
|
||||
value={state.form.command}
|
||||
onChange={(value) => setState("form", "command", value)}
|
||||
placeholder={lang.t("settings.mcp.form.command.placeholder")}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label={lang.t("settings.mcp.form.environment.label")}
|
||||
description={lang.t("settings.mcp.form.environment.description")}
|
||||
value={state.form.environment}
|
||||
onChange={(value) => setState("form", "environment", value)}
|
||||
placeholder="API_KEY={env:API_KEY}"
|
||||
multiline
|
||||
rows={4}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TextField
|
||||
label={lang.t("settings.mcp.form.url.label")}
|
||||
value={state.form.url}
|
||||
onChange={(value) => setState("form", "url", value)}
|
||||
placeholder={lang.t("settings.mcp.form.url.placeholder")}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label={lang.t("settings.mcp.form.headers.label")}
|
||||
description={lang.t("settings.mcp.form.headers.description")}
|
||||
value={state.form.headers}
|
||||
onChange={(value) => setState("form", "headers", value)}
|
||||
placeholder="Authorization: Bearer {env:API_KEY}"
|
||||
multiline
|
||||
rows={4}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Button
|
||||
size="large"
|
||||
variant="secondary"
|
||||
icon="plus-small"
|
||||
class="w-full justify-center sm:w-auto"
|
||||
disabled={busy()}
|
||||
onClick={addForm}
|
||||
>
|
||||
{state.submitting === "form" ? spin() : lang.t("settings.mcp.action.add")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -770,7 +770,44 @@ export const dict = {
|
|||
"settings.commands.title": "Commands",
|
||||
"settings.commands.description": "Command settings will be configurable here.",
|
||||
"settings.mcp.title": "MCP",
|
||||
"settings.mcp.description": "MCP settings will be configurable here.",
|
||||
"settings.mcp.description": "Manage local and remote MCP servers that OpenCode can use across your workspaces.",
|
||||
"settings.mcp.section.featured": "Featured",
|
||||
"settings.mcp.section.featured.description": "Add a polished preset for popular MCP servers in one click.",
|
||||
"settings.mcp.section.configured": "Configured servers",
|
||||
"settings.mcp.section.configured.description":
|
||||
"See which MCP servers are installed, how they connect, and remove the ones you no longer need.",
|
||||
"settings.mcp.section.add": "Add a server",
|
||||
"settings.mcp.section.add.description": "Create your own local or remote MCP server configuration.",
|
||||
"settings.mcp.type.local": "Local",
|
||||
"settings.mcp.type.remote": "Remote",
|
||||
"settings.mcp.featured.added": "Added",
|
||||
"settings.mcp.action.add": "Add server",
|
||||
"settings.mcp.action.remove": "Remove",
|
||||
"settings.mcp.form.type.label": "Connection type",
|
||||
"settings.mcp.form.name.label": "Server name",
|
||||
"settings.mcp.form.name.placeholder": "my-mcp-server",
|
||||
"settings.mcp.form.url.label": "Remote URL",
|
||||
"settings.mcp.form.url.placeholder": "https://mcp.example.com/mcp",
|
||||
"settings.mcp.form.command.label": "Command",
|
||||
"settings.mcp.form.command.placeholder": "npx -y @modelcontextprotocol/server-memory",
|
||||
"settings.mcp.form.headers.label": "Headers",
|
||||
"settings.mcp.form.headers.description": "Optional. Add one header per line using KEY: value.",
|
||||
"settings.mcp.form.environment.label": "Environment",
|
||||
"settings.mcp.form.environment.description": "Optional. Add one variable per line using KEY=value.",
|
||||
"settings.mcp.form.timeout.label": "Timeout (ms)",
|
||||
"settings.mcp.form.timeout.placeholder": "5000",
|
||||
"settings.mcp.toast.added.title": "MCP server added",
|
||||
"settings.mcp.toast.added.description": "{{name}} has been saved to your MCP settings.",
|
||||
"settings.mcp.toast.removed.title": "MCP server removed",
|
||||
"settings.mcp.toast.removed.description": "{{name}} has been removed from your MCP settings.",
|
||||
"settings.mcp.validation.name": "Enter a server name before saving.",
|
||||
"settings.mcp.validation.duplicate": "{{name}} is already configured.",
|
||||
"settings.mcp.validation.url": "Enter a remote MCP server URL.",
|
||||
"settings.mcp.validation.command": "Enter the command used to start the local MCP server.",
|
||||
"settings.mcp.validation.timeout": "Timeout must be a positive whole number.",
|
||||
"settings.mcp.validation.headers": "Couldn't parse header line: {{line}}",
|
||||
"settings.mcp.validation.environment": "Couldn't parse environment line: {{line}}",
|
||||
"settings.mcp.status.needs_client_registration": "Needs client registration",
|
||||
|
||||
"settings.permissions.title": "Permissions",
|
||||
"settings.permissions.description": "Control what tools the server can use by default.",
|
||||
|
|
|
|||
Loading…
Reference in New Issue