diff --git a/packages/app/src/components/dialog-settings.tsx b/packages/app/src/components/dialog-settings.tsx index f8892ebbdc8..a8121ddeb50 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,15 +72,15 @@ export const DialogSettings: Component = () => { + + + {/* */} {/* */} {/* */} {/* */} {/* */} {/* */} - {/* */} - {/* */} - {/* */} ) diff --git a/packages/app/src/components/settings-mcp.tsx b/packages/app/src/components/settings-mcp.tsx index 928464a5138..b132e27b5a2 100644 --- a/packages/app/src/components/settings-mcp.tsx +++ b/packages/app/src/components/settings-mcp.tsx @@ -1,14 +1,286 @@ -import { Component } from "solid-js" +import { Component, For, Show, createResource } from "solid-js" +import { createStore } from "solid-js/store" +import { Button } from "@opencode-ai/ui/button" +import { Tag } from "@opencode-ai/ui/tag" +import { Icon } from "@opencode-ai/ui/icon" +import { showToast } from "@opencode-ai/ui/toast" +import { useGlobalSDK } from "@/context/global-sdk" import { useLanguage } from "@/context/language" +import type { McpStatus } from "@opencode-ai/sdk/v2/client" + +// Helper to check if an MCP status indicates missing OAuth credentials +const isAuthError = (status: McpStatus): boolean => + status.status === "needs_auth" || (status.status === "failed" && status.error?.includes("No OAuth state saved")) export const SettingsMcp: Component = () => { const language = useLanguage() + const globalSDK = useGlobalSDK() + + // State for tracking ongoing operations (using createStore per style guide) + const [store, setStore] = createStore({ + loading: null as string | null, + }) + + // Fetch MCP status and config from API + const [mcpStatus, { refetch: refetchStatus }] = createResource(async () => { + const result = await globalSDK.client.mcp.status() + return result.data ?? {} + }) + + const [mcpConfig] = createResource(async () => { + const result = await globalSDK.client.config.get() + return result.data?.mcp ?? {} + }) + + // Helper to refetch status + const refetch = async () => { + await refetchStatus() + } + + // Transform status into server list with additional metadata + const servers = () => { + const status = mcpStatus() ?? {} + const config = mcpConfig() ?? {} + return Object.entries(status) + .map(([name, serverStatus]) => { + const serverConfig = config[name] + + // Determine if server supports OAuth (has oauth field in config) + const supportsOAuth = serverConfig && "oauth" in serverConfig + + // Check if server needs auth (no stored credentials) + const needsAuth = isAuthError(serverStatus) + + // Check if authenticated (has stored OAuth credentials) + // A server has credentials if: + // 1. It's connected (implies successful auth), OR + // 2. It's disabled but supports OAuth and doesn't need auth + const isAuthenticated = + supportsOAuth && (serverStatus.status === "connected" || (serverStatus.status === "disabled" && !needsAuth)) + + return { + name, + status: serverStatus, + supportsOAuth, + isAuthenticated, + needsAuth, + } + }) + .sort((a, b) => a.name.localeCompare(b.name)) + } + + const handleAuthenticate = async (name: string) => { + if (store.loading) return + setStore("loading", name) + try { + // Enable the server first if it's disabled, so auth can connect it + const currentStatus = mcpStatus()?.[name] + if (currentStatus?.status === "disabled") { + await globalSDK.client.mcp.connect({ name }) + } + + const result = await globalSDK.client.mcp.auth.authenticate({ name }) + + // Auth succeeded if status is "connected" OR "disabled" (disabled means auth worked but server is turned off) + if (result.data && (result.data.status === "connected" || result.data.status === "disabled")) { + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("settings.mcp.toast.authenticated.title", { server: name }), + description: language.t("settings.mcp.toast.authenticated.description", { server: name }), + }) + await refetch() + } else if (result.data && result.data.status === "needs_client_registration") { + showToast({ + variant: "error", + title: language.t("settings.mcp.toast.needsRegistration.title"), + description: result.data.error, + }) + } else if (result.data && result.data.status === "failed") { + showToast({ + variant: "error", + title: language.t("settings.mcp.toast.authFailed.title"), + description: result.data.error || language.t("common.unknownError"), + }) + } else { + showToast({ + variant: "error", + title: language.t("settings.mcp.toast.authFailed.title"), + description: language.t("common.unknownError"), + }) + } + } catch (error) { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: error instanceof Error ? error.message : String(error), + }) + } finally { + setStore("loading", null) + } + } + + const handleLogout = async (name: string) => { + if (store.loading) return + setStore("loading", name) + try { + const currentStatus = mcpStatus()?.[name] + const wasConnected = currentStatus?.status === "connected" + const wasDisabled = currentStatus?.status === "disabled" + + // Remove OAuth credentials + await globalSDK.client.mcp.auth.remove({ name }) + + // Force status update by attempting to connect then disconnecting + // This makes the server check for credentials and show "needs auth" state + if (wasConnected) { + // If it was connected, just disconnect + await globalSDK.client.mcp.disconnect({ name }) + } + + if (wasDisabled) { + // If it was disabled, we need to try connecting to force it to check credentials + // It will fail with "needs auth" which updates the status correctly + try { + await globalSDK.client.mcp.connect({ name }) + } catch { + // Expected to fail due to missing credentials + } + } + + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("settings.mcp.toast.logout.title", { server: name }), + description: language.t("settings.mcp.toast.logout.description", { server: name }), + }) + await refetch() + } catch (error) { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: error instanceof Error ? error.message : String(error), + }) + } finally { + setStore("loading", null) + } + } + + return ( +
+
+
+

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

+

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

+
+
+ +
+
+
+

{language.t("settings.mcp.section.servers")}

+ +
+
+ 0} + fallback={ +
{language.t("settings.mcp.servers.empty")}
+ } + > + + {(server) => ( + + )} + +
+
+
+
+
+ ) +} + +type McpServerRowProps = { + server: { + name: string + status: McpStatus + supportsOAuth: boolean + isAuthenticated: boolean + needsAuth: boolean + } + loading?: boolean + onAuthenticate: (name: string) => void + onLogout: (name: string) => void +} + +const McpServerRow: Component = (props) => { + const language = useLanguage() return ( -
-
-

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

-

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

+
+
+ {props.server.name} + + + + {language.t("settings.mcp.auth.authenticated")} + + + + {(() => { + const status = props.server.status + if (status.status !== "failed") return null + return ( + + {status.error} + + ) + })()} + +
+ +
+ + props.onAuthenticate(props.server.name)} + disabled={props.loading} + > + {language.t("settings.mcp.button.authenticate")} + + } + > + + + +
) diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index a6a50506a09..c3a777357a6 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -495,6 +495,7 @@ export const dict = { "common.closeTab": "Close tab", "common.dismiss": "Dismiss", "common.requestFailed": "Request failed", + "common.unknownError": "An unknown error occurred", "common.moreOptions": "More options", "common.learnMore": "Learn more", "common.rename": "Rename", @@ -656,8 +657,21 @@ export const dict = { "settings.agents.description": "Agent settings will be configurable here.", "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.title": "MCP Servers", + "settings.mcp.description": "Manage Model Context Protocol servers and authentication.", + "settings.mcp.section.servers": "Configured servers", + "settings.mcp.servers.empty": "No MCP servers configured", + "settings.mcp.auth.authenticated": "Authenticated", + "settings.mcp.button.authenticate": "Authenticate", + "settings.mcp.button.reauthenticate": "Re-authenticate", + "settings.mcp.button.logout": "Logout", + "settings.mcp.button.refresh": "Refresh", + "settings.mcp.toast.authenticated.title": "{{server}} authenticated", + "settings.mcp.toast.authenticated.description": "Successfully authenticated with {{server}}", + "settings.mcp.toast.authFailed.title": "Authentication failed", + "settings.mcp.toast.needsRegistration.title": "Client registration required", + "settings.mcp.toast.logout.title": "{{server}} logged out", + "settings.mcp.toast.logout.description": "Removed OAuth credentials for {{server}}", "settings.permissions.title": "Permissions", "settings.permissions.description": "Control what tools the server can use by default.",