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 (
+
+ )
+}
+
+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.",