From b8d59334fbc3225c0ab87b51e071ab0a16cfb640 Mon Sep 17 00:00:00 2001 From: Shubhvardhan Manjayya Date: Thu, 20 Aug 2026 13:17:24 -0700 Subject: [PATCH 1/2] feat(desktop): add MCP server setup and connection testing --- .../components/settings-v2/dialog-mcp-v2.tsx | 303 ++++++++++++++++++ .../settings-v2/dialog-settings-v2.tsx | 8 + .../app/src/components/settings-v2/mcp.tsx | 184 +++++++++++ .../components/settings-v2/settings-v2.css | 46 +++ packages/app/src/context/directory-sync.ts | 5 +- packages/app/src/context/mcp.ts | 31 ++ packages/app/src/context/server-sync.tsx | 25 ++ packages/app/src/i18n/en.ts | 42 ++- packages/opencode/src/config/config.ts | 24 ++ packages/opencode/src/mcp/index.ts | 88 +++++ .../routes/instance/httpapi/groups/mcp.ts | 48 +++ .../routes/instance/httpapi/handlers/mcp.ts | 32 +- packages/opencode/test/fixture/config.ts | 1 + .../test/server/httpapi-mcp-oauth.test.ts | 3 + .../opencode/test/server/httpapi-mcp.test.ts | 86 +++++ packages/opencode/test/session/prompt.test.ts | 9 + .../test/session/snapshot-tool-race.test.ts | 9 + packages/sdk/js/src/v2/gen/sdk.gen.ts | 116 +++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 116 ++++++- 19 files changed, 1171 insertions(+), 5 deletions(-) create mode 100644 packages/app/src/components/settings-v2/dialog-mcp-v2.tsx create mode 100644 packages/app/src/components/settings-v2/mcp.tsx diff --git a/packages/app/src/components/settings-v2/dialog-mcp-v2.tsx b/packages/app/src/components/settings-v2/dialog-mcp-v2.tsx new file mode 100644 index 000000000000..cd1487066c2f --- /dev/null +++ b/packages/app/src/components/settings-v2/dialog-mcp-v2.tsx @@ -0,0 +1,303 @@ +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { Tag } from "@opencode-ai/ui/v2/badge-v2" +import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2" +import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2" +import { Switch } from "@opencode-ai/ui/v2/switch-v2" +import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" +import { TextareaV2 } from "@opencode-ai/ui/v2/textarea-v2" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { type Component, For, Show, createMemo, createSignal } from "solid-js" +import { createStore } from "solid-js/store" +import type { McpTestResult } from "@opencode-ai/sdk/v2/client" +import { useLanguage } from "@/context/language" +import { useSync } from "@/context/sync" +import { useMcpSave } from "@/context/mcp" +import type { McpServerConfig } from "@/context/server-sync" +import "./settings-v2.css" + +type ServerType = "local" | "remote" + +interface FormState { + name: string + type: ServerType + command: string + url: string + environment: string + headers: string + enabled: boolean + error?: string +} + +function parseKeyValues(text: string): Record | undefined { + const out: Record = {} + for (const line of text.split("\n")) { + const trimmed = line.trim() + if (!trimmed) continue + const eq = trimmed.indexOf("=") + if (eq === -1) continue + const key = trimmed.slice(0, eq).trim() + if (!key) continue + out[key] = trimmed.slice(eq + 1).trim() + } + return Object.keys(out).length ? out : undefined +} + +function formatKeyValues(record?: Record): string { + if (!record) return "" + return Object.entries(record) + .map(([key, value]) => `${key}=${value}`) + .join("\n") +} + +export const DialogMcpV2: Component<{ + mode: "add" | "edit" + name?: string + config?: McpServerConfig +}> = (props) => { + const dialog = useDialog() + const language = useLanguage() + const sync = useSync() + const save = useMcpSave() + + const [form, setForm] = createStore({ + name: props.name ?? "", + type: props.config?.type ?? "local", + command: props.config?.type === "local" ? props.config.command.join(" ") : "", + url: props.config?.type === "remote" ? props.config.url : "", + environment: formatKeyValues(props.config?.type === "local" ? props.config.environment : undefined), + headers: formatKeyValues(props.config?.type === "remote" ? props.config.headers : undefined), + enabled: props.config?.enabled !== false, + }) + + const [testing, setTesting] = createSignal(false) + const [testResult, setTestResult] = createSignal() + + const title = () => + props.mode === "add" ? language.t("dialog.mcp.form.title.add") : language.t("dialog.mcp.form.title.edit") + + const validate = (): McpServerConfig | undefined => { + if (!form.name.trim()) { + setForm("error", language.t("dialog.mcp.form.error.name")) + return + } + if (form.type === "local") { + const command = form.command.trim().split(/\s+/).filter(Boolean) + if (command.length === 0) { + setForm("error", language.t("dialog.mcp.form.error.command")) + return + } + setForm("error", undefined) + return { + type: "local", + command, + environment: parseKeyValues(form.environment), + enabled: form.enabled, + } + } + const url = form.url.trim() + if (!url || !URL.canParse(url)) { + setForm("error", language.t("dialog.mcp.form.error.url")) + return + } + setForm("error", undefined) + return { + type: "remote", + url, + headers: parseKeyValues(form.headers), + enabled: form.enabled, + } + } + + const runTest = async () => { + const config = validate() + if (!config) return + setTesting(true) + setTestResult(undefined) + try { + const result = await sync().mcp.test(form.name.trim() || "test", config) + setTestResult(result) + } catch (error) { + setTestResult({ + status: { status: "failed", error: error instanceof Error ? error.message : String(error) }, + reachable: false, + authStatus: "not_authenticated", + tools: [], + error: error instanceof Error ? error.message : String(error), + }) + } finally { + setTesting(false) + } + } + + const submit = async () => { + const config = validate() + if (!config) return + await save.mutateAsync({ name: form.name.trim(), config }) + dialog.close() + } + + const busy = createMemo(() => testing() || save.isPending) + + return ( + + + {title()} + + + +
+
+ + setForm("name", event.currentTarget.value)} + /> +
+ +
+ +
+ setForm("type", "local")} + > + {language.t("settings.mcp.type.local")} + + setForm("type", "remote")} + > + {language.t("settings.mcp.type.remote")} + +
+
+ + +
+ + setForm("command", event.currentTarget.value)} + /> + {language.t("dialog.mcp.form.command.hint")} +
+
+ + setForm("environment", event.currentTarget.value)} + /> + {language.t("dialog.mcp.form.keyValue.hint")} +
+
+ + +
+ + setForm("url", event.currentTarget.value)} + /> +
+
+ + setForm("headers", event.currentTarget.value)} + /> + {language.t("dialog.mcp.form.keyValue.hint")} +
+
+ + setForm("enabled", value)}> + {language.t("dialog.mcp.form.enabled")} + + + + {form.error} + + + + {(result) => ( +
+
+ + {result().reachable + ? language.t("dialog.mcp.test.reachable") + : language.t("dialog.mcp.test.unreachable")} + + + {language.t("dialog.mcp.test.success")} + + + + {language.t("dialog.mcp.test.auth")}: {result().authStatus} + + +
+
+ + {language.t("dialog.mcp.test.tools")} ({result().tools.length}) + + 0} + fallback={ + + {language.t("dialog.mcp.test.tools.none")} + + } + > +
+ {(tool) => {tool}} +
+
+
+ +
+ {language.t("dialog.mcp.test.error")} + {result().error} +
+
+
+ )} +
+
+
+ + dialog.close()}> + {language.t("dialog.mcp.form.cancel")} + + + {testing() ? language.t("dialog.mcp.form.testing") : language.t("dialog.mcp.form.test")} + + + {language.t("dialog.mcp.form.save")} + + +
+ ) +} diff --git a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx index 4116f4a6208c..84517b878e76 100644 --- a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx +++ b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx @@ -10,6 +10,7 @@ import { SettingsProvidersV2 } from "./providers" import { SettingsModelsV2 } from "./models" import "./settings-v2.css" import { SettingsServersV2 } from "./servers" +import { SettingsMcpV2 } from "./mcp" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useLayout } from "@/context/layout" import { useTabs } from "@/context/tabs" @@ -83,6 +84,10 @@ export const DialogSettings: Component<{ {language.t("settings.models.title")} + + + {language.t("settings.mcp.title")} + @@ -108,6 +113,9 @@ export const DialogSettings: Component<{ + + + ) diff --git a/packages/app/src/components/settings-v2/mcp.tsx b/packages/app/src/components/settings-v2/mcp.tsx new file mode 100644 index 000000000000..e7c1969e6d4a --- /dev/null +++ b/packages/app/src/components/settings-v2/mcp.tsx @@ -0,0 +1,184 @@ +import { Tag } from "@opencode-ai/ui/v2/badge-v2" +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { Switch } from "@opencode-ai/ui/v2/switch-v2" +import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import fuzzysort from "fuzzysort" +import { type Component, For, Show, createMemo } from "solid-js" +import { createStore } from "solid-js/store" +import { useLanguage } from "@/context/language" +import { useSync } from "@/context/sync" +import { useMcpRemove, useMcpSave, useMcpToggle } from "@/context/mcp" +import type { McpServerConfig } from "@/context/server-sync" +import { DialogMcpV2 } from "./dialog-mcp-v2" +import { SettingsListV2 } from "./parts/list" +import "./settings-v2.css" + +const statusLabels = { + connected: "mcp.status.connected", + failed: "mcp.status.failed", + needs_auth: "mcp.status.needs_auth", + needs_client_registration: "mcp.status.needs_client_registration", + disabled: "mcp.status.disabled", +} as const + +type ConfigEntry = McpServerConfig | { enabled?: boolean } + +function isConfigured(entry: ConfigEntry | undefined): entry is McpServerConfig { + return !!entry && typeof entry === "object" && "type" in entry +} + +export const SettingsMcpV2: Component = () => { + const dialog = useDialog() + const language = useLanguage() + const sync = useSync() + const save = useMcpSave() + const remove = useMcpRemove() + const toggle = useMcpToggle() + const [store, setStore] = createStore({ filter: "" }) + + const liveStatus = (name: string) => sync().data.mcp?.[name]?.status + + const servers = createMemo(() => { + const config = (sync().data.config.mcp ?? {}) as Record + return Object.entries(config) + .filter(([, entry]) => isConfigured(entry)) + .map(([name, entry]) => ({ name, config: entry as McpServerConfig })) + .sort((a, b) => a.name.localeCompare(b.name)) + }) + + const showSearch = createMemo(() => servers().length > 1) + + const filtered = createMemo(() => { + const items = servers() + const query = store.filter.trim() + if (!query) return items + return fuzzysort.go(query, items, { keys: [(item) => item.name] }).map((result) => result.obj) + }) + + const openAdd = () => dialog.push(() => ) + const openEdit = (name: string, config: McpServerConfig) => + dialog.push(() => ) + + const statusLabel = (name: string) => { + const status = liveStatus(name) + const key = status ? statusLabels[status as keyof typeof statusLabels] : undefined + return key ? language.t(key) : undefined + } + + const summary = (config: McpServerConfig) => + config.type === "local" ? config.command.join(" ") : config.url + + const toggleEnabled = (name: string, config: McpServerConfig) => + save.mutate({ name, config: { ...config, enabled: config.enabled === false } }) + + return ( + <> +
+
+

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

+ + {language.t("settings.mcp.add")} + +
+ + + +
+ +
+ 0} + fallback={ +
+ {store.filter ? language.t("palette.empty") : language.t("settings.mcp.empty")} +
+ } + > + + + {(item) => { + const enabled = () => item.config.enabled !== false + return ( +
+
+
+ {item.name} + + {item.config.type === "local" + ? language.t("settings.mcp.type.local") + : language.t("settings.mcp.type.remote")} + + + {statusLabel(item.name)} + +
+ {summary(item.config)} +
+
+ + toggle.mutate(item.name)} + > + {language.t("settings.mcp.menu.authenticate")} + + + toggleEnabled(item.name, item.config)} + > + {language.t("dialog.mcp.form.enabled")} + + openEdit(item.name, item.config)}> + {language.t("settings.mcp.menu.edit")} + + remove.mutate(item.name)} + > + {language.t("settings.mcp.menu.remove")} + +
+
+ ) + }} +
+
+
+
+ + ) +} diff --git a/packages/app/src/components/settings-v2/settings-v2.css b/packages/app/src/components/settings-v2/settings-v2.css index b73269b496df..dc0302426812 100644 --- a/packages/app/src/components/settings-v2/settings-v2.css +++ b/packages/app/src/components/settings-v2/settings-v2.css @@ -727,3 +727,49 @@ line-height: 1; color: var(--v2-state-fg-danger); } + +.settings-v2-server-dialog-hint { + font-size: 11px; + font-weight: 440; + line-height: 1.4; + color: var(--v2-text-text-muted); +} + +.settings-v2-mcp-test-result { + border: 1px solid var(--v2-border-border-base); + border-radius: 8px; + padding: 12px; +} + +.settings-v2-mcp-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; +} + +.settings-v2-mcp-row-lead { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; +} + +.settings-v2-mcp-row-name { + font-size: 13px; + font-weight: 530; + color: var(--v2-text-text-base); +} + +.settings-v2-mcp-row-meta { + font-size: 11px; + font-weight: 440; + color: var(--v2-text-text-muted); +} + +.settings-v2-mcp-row-actions { + display: flex; + align-items: center; + gap: 8px; +} diff --git a/packages/app/src/context/directory-sync.ts b/packages/app/src/context/directory-sync.ts index befd5b61e095..85a78818004b 100644 --- a/packages/app/src/context/directory-sync.ts +++ b/packages/app/src/context/directory-sync.ts @@ -3,7 +3,7 @@ import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client" import { createMemo } from "solid-js" import { produce, reconcile, type SetStoreFunction } from "solid-js/store" import type { createServerSdkContext } from "./server-sdk" -import type { createServerSyncContextInner } from "./server-sync" +import type { createServerSyncContextInner, McpServerConfig } from "./server-sync" import type { State } from "./global-sync/types" import { normalizeSessionInfo } from "@/utils/session" @@ -147,6 +147,9 @@ export const createDirSyncContext = ( }, mcp: { toggle: (name: string) => serverSync.mcp.toggle(directory, name), + test: (name: string, config: McpServerConfig) => serverSync.mcp.test(directory, name, config), + save: (name: string, config: McpServerConfig) => serverSync.mcp.save(directory, name, config), + remove: (name: string) => serverSync.mcp.remove(directory, name), }, absolute, get directory() { diff --git a/packages/app/src/context/mcp.ts b/packages/app/src/context/mcp.ts index af34b49886e4..2194951cba45 100644 --- a/packages/app/src/context/mcp.ts +++ b/packages/app/src/context/mcp.ts @@ -1,6 +1,7 @@ import { useMutation } from "@tanstack/solid-query" import { useLanguage } from "@/context/language" import { useSync } from "@/context/sync" +import type { McpServerConfig } from "@/context/server-sync" import { showToast } from "@/utils/toast" export function useMcpToggle() { @@ -17,3 +18,33 @@ export function useMcpToggle() { }), })) } + +export function useMcpSave() { + const sync = useSync() + const language = useLanguage() + + return useMutation(() => ({ + mutationFn: (input: { name: string; config: McpServerConfig }) => sync().mcp.save(input.name, input.config), + onError: (error) => + showToast({ + variant: "error", + title: language.t("settings.mcp.toast.saveFailed"), + description: error instanceof Error ? error.message : String(error), + }), + })) +} + +export function useMcpRemove() { + const sync = useSync() + const language = useLanguage() + + return useMutation(() => ({ + mutationFn: (name: string) => sync().mcp.remove(name), + onError: (error) => + showToast({ + variant: "error", + title: language.t("settings.mcp.toast.removeFailed"), + description: error instanceof Error ? error.message : String(error), + }), + })) +} diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index 13a0b74bc6f3..9a57b48f630d 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -1,11 +1,15 @@ import type { Config, + McpLocalConfig, + McpRemoteConfig, OpencodeClient, Path, Project, ProviderAuthResponse, SessionStatus, } from "@opencode-ai/sdk/v2/client" + +export type McpServerConfig = McpLocalConfig | McpRemoteConfig import { showToast } from "@/utils/toast" import { getFilename } from "@opencode-ai/core/util/path" import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js" @@ -690,6 +694,27 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { session, homeSessions, mcp: { + // Test a server config without persisting it. Returns the structured result + // (reachability, auth status, discovered tools, error) from the server. + test: async (directory: string, name: string, config: McpServerConfig) => { + const key = directoryKey(directory) + const result = await serverSDK.client.mcp.test({ name, config, directory: key }) + return result.data + }, + // Persist a server to the global config (add or edit) and refresh live status. + save: async (directory: string, name: string, config: McpServerConfig) => { + const key = directoryKey(directory) + await serverSDK.client.mcp.save({ name, config, directory: key }) + bootstrap.refetch() + await queryClient.refetchQueries(queryOptionsApi.mcp(key)) + }, + // Remove a server from the global config and refresh live status. + remove: async (directory: string, name: string) => { + const key = directoryKey(directory) + await serverSDK.client.mcp.remove({ name, directory: key }) + bootstrap.refetch() + await queryClient.refetchQueries(queryOptionsApi.mcp(key)) + }, toggle: async (directory: string, name: string) => { const key = directoryKey(directory) const sdk = sdkFor(key) diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 62d4dc1ae6e9..e2ad6701e4d2 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -1077,7 +1077,47 @@ 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": "Add, edit, and test Model Context Protocol servers. Changes are saved to the same config used by the CLI.", + "settings.mcp.add": "Add server", + "settings.mcp.empty": "No MCP servers configured yet.", + "settings.mcp.search.placeholder": "Search MCP servers", + "settings.mcp.type.local": "Local", + "settings.mcp.type.remote": "Remote", + "settings.mcp.menu.edit": "Edit", + "settings.mcp.menu.enable": "Enable", + "settings.mcp.menu.disable": "Disable", + "settings.mcp.menu.remove": "Remove", + "settings.mcp.menu.authenticate": "Authenticate", + "settings.mcp.toast.saveFailed": "Failed to save MCP server", + "settings.mcp.toast.removeFailed": "Failed to remove MCP server", + "dialog.mcp.form.title.add": "Add MCP server", + "dialog.mcp.form.title.edit": "Edit MCP server", + "dialog.mcp.form.name": "Name", + "dialog.mcp.form.name.placeholder": "my-server", + "dialog.mcp.form.type": "Type", + "dialog.mcp.form.command": "Command", + "dialog.mcp.form.command.placeholder": "npx -y @modelcontextprotocol/server-filesystem /path", + "dialog.mcp.form.command.hint": "The command and arguments used to launch the server, separated by spaces.", + "dialog.mcp.form.url": "URL", + "dialog.mcp.form.url.placeholder": "https://example.com/mcp", + "dialog.mcp.form.environment": "Environment variables", + "dialog.mcp.form.headers": "Headers", + "dialog.mcp.form.keyValue.hint": "One KEY=VALUE per line.", + "dialog.mcp.form.enabled": "Enabled", + "dialog.mcp.form.test": "Test connection", + "dialog.mcp.form.testing": "Testing…", + "dialog.mcp.form.save": "Save", + "dialog.mcp.form.cancel": "Cancel", + "dialog.mcp.form.error.name": "Enter a unique server name.", + "dialog.mcp.form.error.command": "Enter a command to run.", + "dialog.mcp.form.error.url": "Enter a valid URL.", + "dialog.mcp.test.reachable": "Reachable", + "dialog.mcp.test.unreachable": "Unreachable", + "dialog.mcp.test.auth": "Authentication", + "dialog.mcp.test.tools": "Tools discovered", + "dialog.mcp.test.tools.none": "No tools discovered", + "dialog.mcp.test.error": "Error", + "dialog.mcp.test.success": "Connected successfully", "settings.permissions.title": "Permissions", "settings.permissions.description": "Control what tools the server can use by default.", diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 86238f1a844c..f24f21ee7d2e 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -127,6 +127,12 @@ export interface Interface { readonly getConsoleState: () => Effect.Effect readonly update: (config: Info) => Effect.Effect readonly updateGlobal: (config: Info) => Effect.Effect<{ info: Info; changed: boolean }> + /** + * Add, replace, or remove a single MCP server entry (`mcp.`) in the global + * config file, preserving comments/formatting. Pass `undefined` to remove the entry. + * Mirrors the CLI `opencode mcp add` write path so both interfaces stay consistent. + */ + readonly updateGlobalMcp: (name: string, entry: unknown) => Effect.Effect<{ changed: boolean }> readonly invalidate: () => Effect.Effect readonly directories: () => Effect.Effect readonly waitForDependencies: () => Effect.Effect @@ -659,12 +665,30 @@ const layer = Layer.effect( return { info: next, changed } }) + const updateGlobalMcp = Effect.fn("Config.updateGlobalMcp")(function* (name: string, entry: unknown) { + const file = globalConfigFile() + const before = (yield* readConfigFile(file)) ?? "{}" + // jsonc-parser `modify` replaces the whole `mcp.` entry (or removes it when + // `entry` is undefined) while preserving surrounding comments and formatting. + const edits = modify(before, ["mcp", name], entry, { + formattingOptions: { insertSpaces: true, tabSize: 2 }, + }) + const updated = applyEdits(before, edits) + const changed = updated !== before + if (changed) { + yield* fs.writeFileString(file, updated).pipe(Effect.orDie) + yield* invalidate() + } + return { changed } + }) + return Service.of({ get, getGlobal, getConsoleState, update, updateGlobal, + updateGlobalMcp, invalidate, directories, waitForDependencies, diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 05f12fa2ee45..3ce63f3dea31 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -106,6 +106,27 @@ export const Status = Schema.Union([ ]).annotate({ identifier: "MCPStatus", discriminator: "status" }) export type Status = Schema.Schema.Type +const AuthStatusSchema = Schema.Union([ + Schema.Literal("authenticated"), + Schema.Literal("expired"), + Schema.Literal("not_authenticated"), +]).annotate({ identifier: "MCPAuthStatus" }) + +/** + * Result of a non-persistent connection test. Reports whether the server was + * reachable, the resulting status (incl. auth needs), the discovered tool names, + * the current auth status, and the raw error message on failure. + */ +export const TestResult = Schema.Struct({ + status: Status, + reachable: Schema.Boolean, + authStatus: AuthStatusSchema, + tools: Schema.Array(Schema.String), + instructions: Schema.optional(Schema.String), + error: Schema.optional(Schema.String), +}).annotate({ identifier: "MCPTestResult" }) +export type TestResult = Schema.Schema.Type + // Store transports for OAuth servers to allow finishing auth type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport const pendingOAuthTransports = new Map() @@ -172,6 +193,18 @@ export interface Interface { clientName?: string, ) => Effect.Effect> readonly add: (name: string, mcp: ConfigMCPV1.Info) => Effect.Effect<{ status: Record | Status }> + /** + * Test a server config without persisting it or mounting it into instance state. + * Connects, discovers tools, then closes the client. Safe to call with unsaved config. + */ + readonly test: (name: string, mcp: ConfigMCPV1.Info) => Effect.Effect + /** + * Persist an MCP server to the global config file (add or edit) and reflect the + * change in the running instance. Enable/disable is expressed via `mcp.enabled`. + */ + readonly save: (name: string, mcp: ConfigMCPV1.Info) => Effect.Effect<{ status: Record }> + /** Remove an MCP server from the global config file and the running instance. */ + readonly remove: (name: string) => Effect.Effect readonly connect: (name: string) => Effect.Effect readonly disconnect: (name: string) => Effect.Effect readonly getPrompt: ( @@ -957,6 +990,58 @@ const layer = Layer.effect( return !!entry?.tokens }) + const authStatusFor = Effect.fnUntraced(function* (name: string, mcp: ConfigMCPV1.Info) { + if (mcp.type !== "remote") return "not_authenticated" as AuthStatus + const entry = yield* auth.getForUrl(name, mcp.url) + if (!entry?.tokens) return "not_authenticated" as AuthStatus + if (entry.tokens.expiresAt && entry.tokens.expiresAt < Date.now() / 1000) return "expired" as AuthStatus + return "authenticated" as AuthStatus + }) + + const test = Effect.fn("MCP.test")(function* (name: string, mcp: ConfigMCPV1.Info) { + // Force enabled so a disabled config can still be tested, and never persist/store. + const result = yield* create(name, { ...mcp, enabled: true }) + if (result.mcpClient) yield* Effect.tryPromise(() => result.mcpClient!.close()).pipe(Effect.ignore) + const authStatus = yield* authStatusFor(name, mcp) + const status = result.status + return { + status, + reachable: status.status !== "failed", + authStatus, + tools: (result.defs ?? []).map((def) => def.name), + instructions: result.instructions, + error: "error" in status ? status.error : undefined, + } satisfies TestResult + }) + + const save = Effect.fn("MCP.save")(function* (name: string, mcp: ConfigMCPV1.Info) { + yield* cfgSvc.updateGlobalMcp(name, mcp) + const s = yield* InstanceState.get(state) + s.config[name] = mcp + if (mcp.enabled === false) { + yield* closeClient(s, name) + delete s.clients[name] + s.status[name] = { status: "disabled" } + } else { + yield* createAndStore(name, mcp) + } + return { status: s.status } + }) + + const remove = Effect.fn("MCP.remove")(function* (name: string) { + const existing = yield* getMcpConfig(name) + if (!existing) return yield* new NotFoundError({ name }) + yield* cfgSvc.updateGlobalMcp(name, undefined) + const s = yield* InstanceState.get(state) + yield* closeClient(s, name) + delete s.clients[name] + delete s.config[name] + delete s.status[name] + yield* auth.remove(name).pipe(Effect.ignore) + McpOAuthCallback.cancelPending(name) + pendingOAuthTransports.delete(name) + }) + const getAuthStatus = Effect.fn("MCP.getAuthStatus")(function* (mcpName: string) { const runtimeConfig = (yield* InstanceState.has(state)) ? (yield* InstanceState.get(state)).config[mcpName] @@ -978,6 +1063,9 @@ const layer = Layer.effect( resources, resourceTemplates, add, + test, + save, + remove, connect, disconnect, getPrompt, diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts index a6fb064d73e4..3b07c2b661a3 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts @@ -13,6 +13,14 @@ export const AddPayload = Schema.Struct({ config: ConfigMCPV1.Info, }) +export const SavePayload = Schema.Struct({ + config: ConfigMCPV1.Info, +}) + +export const RemoveResponse = Schema.Struct({ + success: Schema.Literal(true), +}) + export const StatusMap = Schema.Record(Schema.String, MCP.Status) export const AuthStartResponse = Schema.Struct({ authorizationUrl: Schema.String, @@ -31,6 +39,8 @@ export class UnsupportedOAuthError extends Schema.ErrorClass Effect.gen(function* () { @@ -20,6 +20,33 @@ export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handler ).pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) }) + const test = Effect.fn("McpHttpApi.test")(function* (ctx: { payload: typeof AddPayload.Type }) { + return yield* mcp.test(ctx.payload.name, ctx.payload.config) + }) + + const save = Effect.fn("McpHttpApi.save")(function* (ctx: { + params: { name: string } + payload: typeof SavePayload.Type + }) { + const result = (yield* mcp.save(ctx.params.name, ctx.payload.config)).status + return yield* Schema.decodeUnknownEffect(StatusMap)(result).pipe( + Effect.mapError(() => new HttpApiError.BadRequest({})), + ) + }) + + const remove = Effect.fn("McpHttpApi.remove")(function* (ctx: { params: { name: string } }) { + yield* mcp + .remove(ctx.params.name) + .pipe( + Effect.catchTag("MCP.NotFoundError", (error) => + Effect.fail( + new McpServerNotFoundError({ name: error.name, message: `MCP server not found: ${error.name}` }), + ), + ), + ) + return { success: true as const } + }) + const authStart = Effect.fn("McpHttpApi.authStart")(function* (ctx: { params: { name: string } }) { return yield* Effect.gen(function* () { if (!(yield* mcp.supportsOAuth(ctx.params.name))) { @@ -101,6 +128,9 @@ export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handler return handlers .handle("status", status) .handle("add", add) + .handle("test", test) + .handle("save", save) + .handle("remove", remove) .handle("authStart", authStart) .handle("authCallback", authCallback) .handle("authAuthenticate", authAuthenticate) diff --git a/packages/opencode/test/fixture/config.ts b/packages/opencode/test/fixture/config.ts index 71133521380e..eb412719227d 100644 --- a/packages/opencode/test/fixture/config.ts +++ b/packages/opencode/test/fixture/config.ts @@ -9,6 +9,7 @@ export function make(overrides: Partial = {}) { getConsoleState: () => Effect.succeed(emptyConsoleState), update: () => Effect.void, updateGlobal: (config) => Effect.succeed({ info: config, changed: false }), + updateGlobalMcp: () => Effect.succeed({ changed: false }), invalidate: () => Effect.void, directories: () => Effect.succeed([]), waitForDependencies: () => Effect.void, diff --git a/packages/opencode/test/server/httpapi-mcp-oauth.test.ts b/packages/opencode/test/server/httpapi-mcp-oauth.test.ts index d3ca4ae6835b..02554dec898a 100644 --- a/packages/opencode/test/server/httpapi-mcp-oauth.test.ts +++ b/packages/opencode/test/server/httpapi-mcp-oauth.test.ts @@ -20,6 +20,9 @@ const testMcpHandlers = HttpApiBuilder.group(TestHttpApi, "mcp", (handlers) => handlers .handle("status", () => Effect.die("unexpected MCP status")) .handle("add", () => Effect.die("unexpected MCP add")) + .handle("test", () => Effect.die("unexpected MCP test")) + .handle("save", () => Effect.die("unexpected MCP save")) + .handle("remove", () => Effect.die("unexpected MCP remove")) .handle("authStart", () => Effect.succeed({ authorizationUrl: "https://auth.example/start", oauthState: "state-123" }), ) diff --git a/packages/opencode/test/server/httpapi-mcp.test.ts b/packages/opencode/test/server/httpapi-mcp.test.ts index 42a4398ba720..c98d65834697 100644 --- a/packages/opencode/test/server/httpapi-mcp.test.ts +++ b/packages/opencode/test/server/httpapi-mcp.test.ts @@ -189,6 +189,92 @@ describe("mcp HttpApi", () => { }, ) + it.instance( + "tests a connection without persisting it", + () => + Effect.gen(function* () { + const tmp = yield* TestInstance + const handler = HttpApiApp.webHandler() + const response = yield* request(handler, McpPaths.test, tmp.directory, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "probe", + config: { + type: "local", + command: ["opencode-nonexistent-binary-xyz"], + timeout: 2000, + }, + }), + }) + expect(response.status).toBe(200) + const result = yield* json<{ + status: { status: string } + reachable: boolean + authStatus: string + tools: string[] + }>(response) + expect(result.status.status).toBe("failed") + expect(result.reachable).toBe(false) + expect(result.authStatus).toBe("not_authenticated") + expect(result.tools).toEqual([]) + + // A test must not add the server to the running instance. + const status = yield* request(handler, McpPaths.status, tmp.directory) + expect(yield* json>(status)).not.toHaveProperty("probe") + }), + { config: { formatter: false, lsp: false, mcp: {} } }, + ) + + it.instance( + "saves and removes an MCP server in config", + () => + Effect.gen(function* () { + const tmp = yield* TestInstance + const handler = HttpApiApp.webHandler() + + const saved = yield* request(handler, "/mcp/saved", tmp.directory, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + config: { + type: "local", + command: ["echo", "saved"], + enabled: false, + }, + }), + }) + expect(saved.status).toBe(200) + expect(yield* json(saved)).toMatchObject({ saved: { status: "disabled" } }) + + // The saved server is now visible via the status endpoint. + const afterSave = yield* request(handler, McpPaths.status, tmp.directory) + expect(yield* json>(afterSave)).toHaveProperty("saved") + + const removed = yield* request(handler, "/mcp/saved", tmp.directory, { method: "DELETE" }) + expect(removed.status).toBe(200) + expect(yield* json(removed)).toEqual({ success: true }) + }), + { config: { formatter: false, lsp: false, mcp: {} } }, + ) + + it.instance( + "returns not found when removing a missing MCP server", + () => + Effect.gen(function* () { + const tmp = yield* TestInstance + const handler = HttpApiApp.webHandler() + const response = yield* request(handler, "/mcp/missing", tmp.directory, { method: "DELETE" }) + expect(response.status).toBe(404) + expect(yield* json(response)).toEqual({ + _tag: "McpServerNotFoundError", + name: "missing", + message: "MCP server not found: missing", + }) + }), + { config: { formatter: false, lsp: false, mcp: {} } }, + ) + it.instance( "returns typed not found errors for missing MCP servers", () => diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 5a0176abc9b0..49870ec4bc4f 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -121,6 +121,15 @@ function makeMcp(instructions: MCP.ServerInstructions[] = []) { resources: () => Effect.succeed({}), resourceTemplates: () => Effect.succeed({}), add: () => Effect.succeed({ status: { status: "disabled" as const } }), + test: () => + Effect.succeed({ + status: { status: "disabled" as const }, + reachable: false, + authStatus: "not_authenticated" as const, + tools: [], + }), + save: () => Effect.succeed({ status: {} }), + remove: () => Effect.void, connect: () => Effect.void, disconnect: () => Effect.void, getPrompt: () => Effect.succeed(undefined), diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 1265237840f3..3ac456a09bdb 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -43,6 +43,15 @@ const mcp = Layer.succeed( resources: () => Effect.succeed({}), resourceTemplates: () => Effect.succeed({}), add: () => Effect.succeed({ status: { status: "disabled" as const } }), + test: () => + Effect.succeed({ + status: { status: "disabled" as const }, + reachable: false, + authStatus: "not_authenticated" as const, + tools: [], + }), + save: () => Effect.succeed({ status: {} }), + remove: () => Effect.void, connect: () => Effect.void, disconnect: () => Effect.void, getPrompt: () => Effect.succeed(undefined), diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 9ed0084aac84..3be4910946ed 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -109,8 +109,14 @@ import type { McpDisconnectResponses, McpLocalConfig, McpRemoteConfig, + McpRemoveErrors, + McpRemoveResponses, + McpSaveErrors, + McpSaveResponses, McpStatusErrors, McpStatusResponses, + McpTestErrors, + McpTestResponses, ModelRef, MoveSessionDestination, OutputFormat, @@ -2461,6 +2467,116 @@ export class Mcp extends HeyApiClient { }) } + /** + * Test MCP connection + * + * Test a Model Context Protocol (MCP) server configuration without saving it. Reports reachability, auth status, discovered tools, and any error. + */ + public test( + parameters?: { + directory?: string + workspace?: string + name?: string + config?: McpLocalConfig | McpRemoteConfig + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "name" }, + { in: "body", key: "config" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/mcp/test", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Remove MCP server + * + * Remove a Model Context Protocol (MCP) server from the persisted config. + */ + public remove( + parameters: { + name: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "name" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete({ + url: "/mcp/{name}", + ...options, + ...params, + }) + } + + /** + * Save MCP server + * + * Create or update a Model Context Protocol (MCP) server in the persisted config, keeping CLI and Desktop in sync. + */ + public save( + parameters: { + name: string + directory?: string + workspace?: string + config?: McpLocalConfig | McpRemoteConfig + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "name" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "config" }, + ], + }, + ], + ) + return (options?.client ?? this.client).put({ + url: "/mcp/{name}", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * Connect an MCP server. */ diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 90c91e9158cc..78ed8eeb2b5f 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2411,8 +2411,15 @@ export type McpStatus = | McpStatusNeedsAuth | McpStatusNeedsClientRegistration -export type McpUnsupportedOAuthError = { - error: string +export type McpAuthStatus = "authenticated" | "expired" | "not_authenticated" + +export type McpTestResult = { + status: McpStatus + reachable: boolean + authStatus: McpAuthStatus + tools: Array + instructions?: string + error?: string } export type McpServerNotFoundError = { @@ -2421,6 +2428,10 @@ export type McpServerNotFoundError = { message: string } +export type McpUnsupportedOAuthError = { + error: string +} + export type Project = { id: string worktree: string @@ -8491,6 +8502,107 @@ export type McpAddResponses = { export type McpAddResponse = McpAddResponses[keyof McpAddResponses] +export type McpTestData = { + body?: { + name: string + config: McpLocalConfig | McpRemoteConfig + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/mcp/test" +} + +export type McpTestErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type McpTestError = McpTestErrors[keyof McpTestErrors] + +export type McpTestResponses = { + /** + * MCP connection test result + */ + 200: McpTestResult +} + +export type McpTestResponse = McpTestResponses[keyof McpTestResponses] + +export type McpRemoveData = { + body?: never + path: { + name: string + } + query?: { + directory?: string + workspace?: string + } + url: "/mcp/{name}" +} + +export type McpRemoveErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * McpServerNotFoundError + */ + 404: McpServerNotFoundError +} + +export type McpRemoveError = McpRemoveErrors[keyof McpRemoveErrors] + +export type McpRemoveResponses = { + /** + * MCP server removed from config + */ + 200: { + success: true + } +} + +export type McpRemoveResponse = McpRemoveResponses[keyof McpRemoveResponses] + +export type McpSaveData = { + body?: { + config: McpLocalConfig | McpRemoteConfig + } + path: { + name: string + } + query?: { + directory?: string + workspace?: string + } + url: "/mcp/{name}" +} + +export type McpSaveErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError +} + +export type McpSaveError = McpSaveErrors[keyof McpSaveErrors] + +export type McpSaveResponses = { + /** + * MCP server saved to config + */ + 200: { + [key: string]: McpStatus + } +} + +export type McpSaveResponse = McpSaveResponses[keyof McpSaveResponses] + export type McpAuthRemoveData = { body?: never path: { From 70cb15d9ac5e42cff039a52c1aa62ede986815e0 Mon Sep 17 00:00:00 2001 From: Shubhvardhan Manjayya Date: Thu, 20 Aug 2026 13:41:26 -0700 Subject: [PATCH 2/2] fix(desktop): use server sync context for MCP settings page --- .../components/settings-v2/dialog-mcp-v2.tsx | 48 +++++++++--- .../settings-v2/dialog-settings-v2.tsx | 2 +- .../app/src/components/settings-v2/mcp.tsx | 76 ++++++++++++------- packages/app/src/context/mcp.ts | 31 -------- packages/app/src/i18n/en.ts | 1 + 5 files changed, 90 insertions(+), 68 deletions(-) diff --git a/packages/app/src/components/settings-v2/dialog-mcp-v2.tsx b/packages/app/src/components/settings-v2/dialog-mcp-v2.tsx index cd1487066c2f..858d05ede47c 100644 --- a/packages/app/src/components/settings-v2/dialog-mcp-v2.tsx +++ b/packages/app/src/components/settings-v2/dialog-mcp-v2.tsx @@ -6,13 +6,13 @@ import { Switch } from "@opencode-ai/ui/v2/switch-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { TextareaV2 } from "@opencode-ai/ui/v2/textarea-v2" import { useDialog } from "@opencode-ai/ui/context/dialog" -import { type Component, For, Show, createMemo, createSignal } from "solid-js" +import { type Accessor, type Component, For, Show, createMemo, createSignal } from "solid-js" import { createStore } from "solid-js/store" import type { McpTestResult } from "@opencode-ai/sdk/v2/client" import { useLanguage } from "@/context/language" -import { useSync } from "@/context/sync" -import { useMcpSave } from "@/context/mcp" +import { useServerSync } from "@/context/server-sync" import type { McpServerConfig } from "@/context/server-sync" +import { showToast } from "@/utils/toast" import "./settings-v2.css" type ServerType = "local" | "remote" @@ -53,11 +53,12 @@ export const DialogMcpV2: Component<{ mode: "add" | "edit" name?: string config?: McpServerConfig + directory: Accessor }> = (props) => { const dialog = useDialog() const language = useLanguage() - const sync = useSync() - const save = useMcpSave() + const serverSync = useServerSync() + const [saving, setSaving] = createSignal(false) const [form, setForm] = createStore({ name: props.name ?? "", @@ -111,10 +112,19 @@ export const DialogMcpV2: Component<{ const runTest = async () => { const config = validate() if (!config) return + const dir = props.directory() + if (!dir) { + showToast({ + variant: "error", + title: language.t("dialog.mcp.form.test"), + description: language.t("settings.mcp.noWorkspace"), + }) + return + } setTesting(true) setTestResult(undefined) try { - const result = await sync().mcp.test(form.name.trim() || "test", config) + const result = await serverSync().mcp.test(dir, form.name.trim() || "test", config) setTestResult(result) } catch (error) { setTestResult({ @@ -132,11 +142,31 @@ export const DialogMcpV2: Component<{ const submit = async () => { const config = validate() if (!config) return - await save.mutateAsync({ name: form.name.trim(), config }) - dialog.close() + const dir = props.directory() + if (!dir) { + showToast({ + variant: "error", + title: language.t("settings.mcp.toast.saveFailed"), + description: language.t("settings.mcp.noWorkspace"), + }) + return + } + setSaving(true) + try { + await serverSync().mcp.save(dir, form.name.trim(), config) + dialog.close() + } catch (error) { + showToast({ + variant: "error", + title: language.t("settings.mcp.toast.saveFailed"), + description: error instanceof Error ? error.message : String(error), + }) + } finally { + setSaving(false) + } } - const busy = createMemo(() => testing() || save.isPending) + const busy = createMemo(() => testing() || saving()) return ( diff --git a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx index 84517b878e76..9f1380465c93 100644 --- a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx +++ b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx @@ -114,7 +114,7 @@ export const DialogSettings: Component<{ - + diff --git a/packages/app/src/components/settings-v2/mcp.tsx b/packages/app/src/components/settings-v2/mcp.tsx index e7c1969e6d4a..bcc725ab6e7e 100644 --- a/packages/app/src/components/settings-v2/mcp.tsx +++ b/packages/app/src/components/settings-v2/mcp.tsx @@ -6,12 +6,12 @@ import { Switch } from "@opencode-ai/ui/v2/switch-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { useDialog } from "@opencode-ai/ui/context/dialog" import fuzzysort from "fuzzysort" -import { type Component, For, Show, createMemo } from "solid-js" +import { type Accessor, type Component, For, Show, createMemo, createSignal } from "solid-js" import { createStore } from "solid-js/store" import { useLanguage } from "@/context/language" -import { useSync } from "@/context/sync" -import { useMcpRemove, useMcpSave, useMcpToggle } from "@/context/mcp" +import { useServerSync } from "@/context/server-sync" import type { McpServerConfig } from "@/context/server-sync" +import { showToast } from "@/utils/toast" import { DialogMcpV2 } from "./dialog-mcp-v2" import { SettingsListV2 } from "./parts/list" import "./settings-v2.css" @@ -30,25 +30,32 @@ function isConfigured(entry: ConfigEntry | undefined): entry is McpServerConfig return !!entry && typeof entry === "object" && "type" in entry } -export const SettingsMcpV2: Component = () => { +export const SettingsMcpV2: Component<{ directory: Accessor }> = (props) => { const dialog = useDialog() const language = useLanguage() - const sync = useSync() - const save = useMcpSave() - const remove = useMcpRemove() - const toggle = useMcpToggle() + const serverSync = useServerSync() const [store, setStore] = createStore({ filter: "" }) + const [busy, setBusy] = createSignal(false) - const liveStatus = (name: string) => sync().data.mcp?.[name]?.status + // MCP config is global, but test/connect need an instance to run against. Fall + // back to the first open project when the settings dialog has no active directory. + const directory = createMemo(() => props.directory() ?? serverSync().data.project?.[0]?.worktree) const servers = createMemo(() => { - const config = (sync().data.config.mcp ?? {}) as Record + const config = (serverSync().data.config.mcp ?? {}) as Record return Object.entries(config) .filter(([, entry]) => isConfigured(entry)) .map(([name, entry]) => ({ name, config: entry as McpServerConfig })) .sort((a, b) => a.name.localeCompare(b.name)) }) + const statusMap = createMemo(() => { + const dir = directory() + if (!dir) return {} as Record + return serverSync().child(dir, { mcp: true })[0].mcp ?? {} + }) + const liveStatus = (name: string) => statusMap()[name]?.status + const showSearch = createMemo(() => servers().length > 1) const filtered = createMemo(() => { @@ -58,9 +65,29 @@ export const SettingsMcpV2: Component = () => { return fuzzysort.go(query, items, { keys: [(item) => item.name] }).map((result) => result.obj) }) - const openAdd = () => dialog.push(() => ) + const run = async (fn: (dir: string) => Promise, failKey: string) => { + const dir = directory() + if (!dir) { + showToast({ variant: "error", title: language.t(failKey), description: language.t("settings.mcp.noWorkspace") }) + return + } + setBusy(true) + try { + await fn(dir) + } catch (error) { + showToast({ + variant: "error", + title: language.t(failKey), + description: error instanceof Error ? error.message : String(error), + }) + } finally { + setBusy(false) + } + } + + const openAdd = () => dialog.push(() => ) const openEdit = (name: string, config: McpServerConfig) => - dialog.push(() => ) + dialog.push(() => ) const statusLabel = (name: string) => { const status = liveStatus(name) @@ -68,11 +95,14 @@ export const SettingsMcpV2: Component = () => { return key ? language.t(key) : undefined } - const summary = (config: McpServerConfig) => - config.type === "local" ? config.command.join(" ") : config.url + const summary = (config: McpServerConfig) => (config.type === "local" ? config.command.join(" ") : config.url) const toggleEnabled = (name: string, config: McpServerConfig) => - save.mutate({ name, config: { ...config, enabled: config.enabled === false } }) + run((dir) => serverSync().mcp.save(dir, name, { ...config, enabled: config.enabled === false }), "settings.mcp.toast.saveFailed") + + const removeServer = (name: string) => run((dir) => serverSync().mcp.remove(dir, name), "settings.mcp.toast.removeFailed") + + const authenticate = (name: string) => run((dir) => serverSync().mcp.toggle(dir, name), "common.requestFailed") return ( <> @@ -145,30 +175,22 @@ export const SettingsMcpV2: Component = () => {
- toggle.mutate(item.name)} - > + authenticate(item.name)}> {language.t("settings.mcp.menu.authenticate")} toggleEnabled(item.name, item.config)} > {language.t("dialog.mcp.form.enabled")} - openEdit(item.name, item.config)}> + openEdit(item.name, item.config)}> {language.t("settings.mcp.menu.edit")} - remove.mutate(item.name)} - > + removeServer(item.name)}> {language.t("settings.mcp.menu.remove")}
diff --git a/packages/app/src/context/mcp.ts b/packages/app/src/context/mcp.ts index 2194951cba45..af34b49886e4 100644 --- a/packages/app/src/context/mcp.ts +++ b/packages/app/src/context/mcp.ts @@ -1,7 +1,6 @@ import { useMutation } from "@tanstack/solid-query" import { useLanguage } from "@/context/language" import { useSync } from "@/context/sync" -import type { McpServerConfig } from "@/context/server-sync" import { showToast } from "@/utils/toast" export function useMcpToggle() { @@ -18,33 +17,3 @@ export function useMcpToggle() { }), })) } - -export function useMcpSave() { - const sync = useSync() - const language = useLanguage() - - return useMutation(() => ({ - mutationFn: (input: { name: string; config: McpServerConfig }) => sync().mcp.save(input.name, input.config), - onError: (error) => - showToast({ - variant: "error", - title: language.t("settings.mcp.toast.saveFailed"), - description: error instanceof Error ? error.message : String(error), - }), - })) -} - -export function useMcpRemove() { - const sync = useSync() - const language = useLanguage() - - return useMutation(() => ({ - mutationFn: (name: string) => sync().mcp.remove(name), - onError: (error) => - showToast({ - variant: "error", - title: language.t("settings.mcp.toast.removeFailed"), - description: error instanceof Error ? error.message : String(error), - }), - })) -} diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index e2ad6701e4d2..831d903c469f 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -1080,6 +1080,7 @@ export const dict = { "settings.mcp.description": "Add, edit, and test Model Context Protocol servers. Changes are saved to the same config used by the CLI.", "settings.mcp.add": "Add server", "settings.mcp.empty": "No MCP servers configured yet.", + "settings.mcp.noWorkspace": "Open a project to test or save MCP servers.", "settings.mcp.search.placeholder": "Search MCP servers", "settings.mcp.type.local": "Local", "settings.mcp.type.remote": "Remote",