diff --git a/packages/app/src/components/dialog-settings.tsx b/packages/app/src/components/dialog-settings.tsx index 83cea131f5..43385d0bc4 100644 --- a/packages/app/src/components/dialog-settings.tsx +++ b/packages/app/src/components/dialog-settings.tsx @@ -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 = () => { {language.t("settings.models.title")} + + + {language.t("settings.mcp.title")} + @@ -67,6 +72,9 @@ export const DialogSettings: Component = () => { + + + ) diff --git a/packages/app/src/components/settings-mcp.tsx b/packages/app/src/components/settings-mcp.tsx index 507e041aa8..8171cb4498 100644 --- a/packages/app/src/components/settings-mcp.tsx +++ b/packages/app/src/components/settings-mcp.tsx @@ -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 +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 + +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 = {} + + 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, + }) + + 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 ( -
-
-

{language.t("settings.mcp.title")}

-

{language.t("settings.mcp.description")}

+
+
+
+

{lang.t("settings.mcp.title")}

+

{lang.t("settings.mcp.description")}

+
+
+ +
+
+
+

{lang.t("settings.mcp.section.featured")}

+

{lang.t("settings.mcp.section.featured.description")}

+
+ +
+ + {(item) => { + const added = () => names().has(item.name) + const pending = () => state.submitting === `featured:${item.name}` + + return ( + + ) + }} + +
+
+ +
+
+
+

{lang.t("settings.mcp.section.configured")}

+ + {spin()} + +
+

{lang.t("settings.mcp.section.configured.description")}

+
+ +
+ 0} + fallback={
{lang.t("dialog.mcp.empty")}
} + > + + {(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 ( +
+
+
+ {item.name} + {kind(item.config.type)} + + + {text()} + + +
+ + {line(item.config)} + + + {problem()} + +
+ + +
+ ) + }} +
+
+
+
+ +
+
+

{lang.t("settings.mcp.section.add")}

+

{lang.t("settings.mcp.section.add.description")}

+
+ +
+
+
+ {lang.t("settings.mcp.form.type.label")} +
+ + {(mode) => ( + + )} + +
+
+ +
+ setState("form", "name", value)} + placeholder={lang.t("settings.mcp.form.name.placeholder")} + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + /> + + setState("form", "timeout", value)} + placeholder={lang.t("settings.mcp.form.timeout.placeholder")} + inputMode="numeric" + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + /> +
+ + + setState("form", "command", value)} + placeholder={lang.t("settings.mcp.form.command.placeholder")} + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + /> + + setState("form", "environment", value)} + placeholder="API_KEY={env:API_KEY}" + multiline + rows={4} + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + /> + + } + > + setState("form", "url", value)} + placeholder={lang.t("settings.mcp.form.url.placeholder")} + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + /> + + setState("form", "headers", value)} + placeholder="Authorization: Bearer {env:API_KEY}" + multiline + rows={4} + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + /> + + + +
+
+
) diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 7e95fd739d..92d962ac09 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -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.",