Просмотр исходного кода

feat: ability to toggle MCP Servers in TUI (#4509)

Daniel Polito 2 месяцев назад
Родитель
Сommit
203f3312ee

+ 9 - 0
packages/opencode/src/cli/cmd/tui/app.tsx

@@ -12,6 +12,7 @@ import { SDKProvider, useSDK } from "@tui/context/sdk"
 import { SyncProvider, useSync } from "@tui/context/sync"
 import { LocalProvider, useLocal } from "@tui/context/local"
 import { DialogModel, useConnected } from "@tui/component/dialog-model"
+import { DialogMcp } from "@tui/component/dialog-mcp"
 import { DialogStatus } from "@tui/component/dialog-status"
 import { DialogThemeList } from "@tui/component/dialog-theme-list"
 import { DialogHelp } from "./ui/dialog-help"
@@ -301,6 +302,14 @@ function App() {
         dialog.replace(() => <DialogAgent />)
       },
     },
+    {
+      title: "Toggle MCPs",
+      value: "mcp.list",
+      category: "Agent",
+      onSelect: () => {
+        dialog.replace(() => <DialogMcp />)
+      },
+    },
     {
       title: "Agent cycle",
       value: "agent.cycle",

+ 86 - 0
packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx

@@ -0,0 +1,86 @@
+import { createMemo, createSignal } from "solid-js"
+import { useLocal } from "@tui/context/local"
+import { useSync } from "@tui/context/sync"
+import { map, pipe, entries, sortBy } from "remeda"
+import { DialogSelect, type DialogSelectRef, type DialogSelectOption } from "@tui/ui/dialog-select"
+import { useTheme } from "../context/theme"
+import { Keybind } from "@/util/keybind"
+import { TextAttributes } from "@opentui/core"
+import { useSDK } from "@tui/context/sdk"
+
+function Status(props: { enabled: boolean; loading: boolean }) {
+  const { theme } = useTheme()
+  if (props.loading) {
+    return <span style={{ fg: theme.textMuted }}>⋯ Loading</span>
+  }
+  if (props.enabled) {
+    return <span style={{ fg: theme.success, attributes: TextAttributes.BOLD }}>✓ Enabled</span>
+  }
+  return <span style={{ fg: theme.textMuted }}>○ Disabled</span>
+}
+
+export function DialogMcp() {
+  const local = useLocal()
+  const sync = useSync()
+  const sdk = useSDK()
+  const [, setRef] = createSignal<DialogSelectRef<unknown>>()
+  const [loading, setLoading] = createSignal<string | null>(null)
+
+  const options = createMemo(() => {
+    // Track sync data and loading state to trigger re-render when they change
+    const mcpData = sync.data.mcp
+    const loadingMcp = loading()
+
+    return pipe(
+      mcpData ?? {},
+      entries(),
+      sortBy(([name]) => name),
+      map(([name, status]) => ({
+        value: name,
+        title: name,
+        description: status.status === "failed" ? "failed" : status.status,
+        footer: <Status enabled={local.mcp.isEnabled(name)} loading={loadingMcp === name} />,
+        category: undefined,
+      })),
+    )
+  })
+
+  const keybinds = createMemo(() => [
+    {
+      keybind: Keybind.parse("space")[0],
+      title: "toggle",
+      onTrigger: async (option: DialogSelectOption<string>) => {
+        // Prevent toggling while an operation is already in progress
+        if (loading() !== null) return
+
+        setLoading(option.value)
+        try {
+          await local.mcp.toggle(option.value)
+          // Refresh MCP status from server
+          const status = await sdk.client.mcp.status()
+          if (status.data) {
+            sync.set("mcp", status.data)
+          } else {
+            console.error("Failed to refresh MCP status: no data returned")
+          }
+        } catch (error) {
+          console.error("Failed to toggle MCP:", error)
+        } finally {
+          setLoading(null)
+        }
+      },
+    },
+  ])
+
+  return (
+    <DialogSelect
+      ref={setRef}
+      title="MCPs"
+      options={options()}
+      keybind={keybinds()}
+      onSelect={(option) => {
+        // Don't close on select, only on escape
+      }}
+    />
+  )
+}

+ 5 - 1
packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx

@@ -307,10 +307,14 @@ export function Autocomplete(props: {
       },
       {
         display: "/status",
-        aliases: ["/mcp"],
         description: "show status",
         onSelect: () => command.trigger("opencode.status"),
       },
+      {
+        display: "/mcp",
+        description: "toggle MCPs",
+        onSelect: () => command.trigger("mcp.list"),
+      },
       {
         display: "/theme",
         description: "toggle theme",

+ 20 - 0
packages/opencode/src/cli/cmd/tui/context/local.tsx

@@ -10,12 +10,14 @@ import { createSimpleContext } from "./helper"
 import { useToast } from "../ui/toast"
 import { Provider } from "@/provider/provider"
 import { useArgs } from "./args"
+import { useSDK } from "./sdk"
 import { RGBA } from "@opentui/core"
 
 export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
   name: "Local",
   init: () => {
     const sync = useSync()
+    const sdk = useSDK()
     const toast = useToast()
 
     function isModelValid(model: { providerID: string; modelID: string }) {
@@ -310,9 +312,27 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
       }
     })
 
+    const mcp = {
+      isEnabled(name: string) {
+        const status = sync.data.mcp[name]
+        return status?.status === "connected"
+      },
+      async toggle(name: string) {
+        const status = sync.data.mcp[name]
+        if (status?.status === "connected") {
+          // Disable: disconnect the MCP
+          await sdk.client.mcp.disconnect({ name })
+        } else {
+          // Enable/Retry: connect the MCP (handles disabled, failed, and other states)
+          await sdk.client.mcp.connect({ name })
+        }
+      },
+    }
+
     const result = {
       model,
       agent,
+      mcp,
     }
     return result
   },

+ 7 - 3
packages/opencode/src/cli/cmd/tui/routes/home.tsx

@@ -24,8 +24,12 @@ export function Home() {
     return Object.values(sync.data.mcp).some((x) => x.status === "failed")
   })
 
+  const connectedMcpCount = createMemo(() => {
+    return Object.values(sync.data.mcp).filter((x) => x.status === "connected").length
+  })
+
   const Hint = (
-    <Show when={Object.keys(sync.data.mcp).length > 0}>
+    <Show when={connectedMcpCount() > 0}>
       <box flexShrink={0} flexDirection="row" gap={1}>
         <text fg={theme.text}>
           <Switch>
@@ -35,7 +39,7 @@ export function Home() {
             </Match>
             <Match when={true}>
               <span style={{ fg: theme.success }}>•</span>{" "}
-              {Locale.pluralize(Object.values(sync.data.mcp).length, "{} mcp server", "{} mcp servers")}
+              {Locale.pluralize(connectedMcpCount(), "{} mcp server", "{} mcp servers")}
             </Match>
           </Switch>
         </text>
@@ -85,7 +89,7 @@ export function Home() {
                   <span style={{ fg: theme.success }}>⊙ </span>
                 </Match>
               </Switch>
-              {Object.keys(sync.data.mcp).length} MCP
+              {connectedMcpCount()} MCP
             </text>
             <text fg={theme.textMuted}>/status</text>
           </Show>

+ 62 - 1
packages/opencode/src/mcp/index.ts

@@ -86,6 +86,12 @@ export namespace MCP {
 
       await Promise.all(
         Object.entries(config).map(async ([key, mcp]) => {
+          // If disabled by config, mark as disabled without trying to connect
+          if (mcp.enabled === false) {
+            status[key] = { status: "disabled" }
+            return
+          }
+
           const result = await create(key, mcp).catch(() => undefined)
           if (!result) return
 
@@ -319,18 +325,73 @@ export namespace MCP {
   }
 
   export async function status() {
-    return state().then((state) => state.status)
+    const s = await state()
+    const cfg = await Config.get()
+    const config = cfg.mcp ?? {}
+    const result: Record<string, Status> = {}
+
+    // Include all MCPs from config, not just connected ones
+    for (const key of Object.keys(config)) {
+      result[key] = s.status[key] ?? { status: "disabled" }
+    }
+
+    return result
   }
 
   export async function clients() {
     return state().then((state) => state.clients)
   }
 
+  export async function connect(name: string) {
+    const cfg = await Config.get()
+    const config = cfg.mcp ?? {}
+    const mcp = config[name]
+    if (!mcp) {
+      log.error("MCP config not found", { name })
+      return
+    }
+
+    const result = await create(name, { ...mcp, enabled: true })
+
+    if (!result) {
+      const s = await state()
+      s.status[name] = {
+        status: "failed",
+        error: "Unknown error during connection",
+      }
+      return
+    }
+
+    const s = await state()
+    s.status[name] = result.status
+    if (result.mcpClient) {
+      s.clients[name] = result.mcpClient
+    }
+  }
+
+  export async function disconnect(name: string) {
+    const s = await state()
+    const client = s.clients[name]
+    if (client) {
+      await client.close().catch((error) => {
+        log.error("Failed to close MCP client", { name, error })
+      })
+      delete s.clients[name]
+    }
+    s.status[name] = { status: "disabled" }
+  }
+
   export async function tools() {
     const result: Record<string, Tool> = {}
     const s = await state()
     const clientsSnapshot = await clients()
+
     for (const [clientName, client] of Object.entries(clientsSnapshot)) {
+      // Only include tools from connected MCPs (skip disabled ones)
+      if (s.status[clientName]?.status !== "connected") {
+        continue
+      }
+
       const tools = await client.tools().catch((e) => {
         log.error("failed to get tools", { clientName, error: e.message })
         const failedStatus = {

+ 46 - 0
packages/opencode/src/server/server.ts

@@ -1984,6 +1984,52 @@ export namespace Server {
           return c.json({ success: true as const })
         },
       )
+      .post(
+        "/mcp/:name/connect",
+        describeRoute({
+          description: "Connect an MCP server",
+          operationId: "mcp.connect",
+          responses: {
+            200: {
+              description: "MCP server connected successfully",
+              content: {
+                "application/json": {
+                  schema: resolver(z.boolean()),
+                },
+              },
+            },
+          },
+        }),
+        validator("param", z.object({ name: z.string() })),
+        async (c) => {
+          const { name } = c.req.valid("param")
+          await MCP.connect(name)
+          return c.json(true)
+        },
+      )
+      .post(
+        "/mcp/:name/disconnect",
+        describeRoute({
+          description: "Disconnect an MCP server",
+          operationId: "mcp.disconnect",
+          responses: {
+            200: {
+              description: "MCP server disconnected successfully",
+              content: {
+                "application/json": {
+                  schema: resolver(z.boolean()),
+                },
+              },
+            },
+          },
+        }),
+        validator("param", z.object({ name: z.string() })),
+        async (c) => {
+          const { name } = c.req.valid("param")
+          await MCP.disconnect(name)
+          return c.json(true)
+        },
+      )
       .get(
         "/lsp",
         describeRoute({

+ 82 - 0
packages/sdk/js/openapi.json

@@ -3996,6 +3996,88 @@
         ]
       }
     },
+    "/mcp/{name}/connect": {
+      "post": {
+        "operationId": "mcp.connect",
+        "parameters": [
+          {
+            "in": "query",
+            "name": "directory",
+            "schema": {
+              "type": "string"
+            }
+          },
+          {
+            "in": "path",
+            "name": "name",
+            "schema": {
+              "type": "string"
+            },
+            "required": true
+          }
+        ],
+        "description": "Connect an MCP server",
+        "responses": {
+          "200": {
+            "description": "MCP server connected successfully",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "boolean"
+                }
+              }
+            }
+          }
+        },
+        "x-codeSamples": [
+          {
+            "lang": "js",
+            "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.connect({\n  ...\n})"
+          }
+        ]
+      }
+    },
+    "/mcp/{name}/disconnect": {
+      "post": {
+        "operationId": "mcp.disconnect",
+        "parameters": [
+          {
+            "in": "query",
+            "name": "directory",
+            "schema": {
+              "type": "string"
+            }
+          },
+          {
+            "in": "path",
+            "name": "name",
+            "schema": {
+              "type": "string"
+            },
+            "required": true
+          }
+        ],
+        "description": "Disconnect an MCP server",
+        "responses": {
+          "200": {
+            "description": "MCP server disconnected successfully",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "boolean"
+                }
+              }
+            }
+          }
+        },
+        "x-codeSamples": [
+          {
+            "lang": "js",
+            "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.disconnect({\n  ...\n})"
+          }
+        ]
+      }
+    },
     "/lsp": {
       "get": {
         "operationId": "lsp.status",

+ 25 - 0
packages/sdk/js/src/gen/sdk.gen.ts

@@ -160,6 +160,10 @@ import type {
   McpAuthAuthenticateData,
   McpAuthAuthenticateResponses,
   McpAuthAuthenticateErrors,
+  McpConnectData,
+  McpConnectResponses,
+  McpDisconnectData,
+  McpDisconnectResponses,
   LspStatusData,
   LspStatusResponses,
   FormatterStatusData,
@@ -945,6 +949,27 @@ class Mcp extends _HeyApiClient {
       },
     })
   }
+
+  /**
+   * Connect an MCP server
+   */
+  public connect<ThrowOnError extends boolean = false>(options: Options<McpConnectData, ThrowOnError>) {
+    return (options.client ?? this._client).post<McpConnectResponses, unknown, ThrowOnError>({
+      url: "/mcp/{name}/connect",
+      ...options,
+    })
+  }
+
+  /**
+   * Disconnect an MCP server
+   */
+  public disconnect<ThrowOnError extends boolean = false>(options: Options<McpDisconnectData, ThrowOnError>) {
+    return (options.client ?? this._client).post<McpDisconnectResponses, unknown, ThrowOnError>({
+      url: "/mcp/{name}/disconnect",
+      ...options,
+    })
+  }
+
   auth = new Auth({ client: this._client })
 }
 

+ 40 - 0
packages/sdk/js/src/gen/types.gen.ts

@@ -3494,6 +3494,46 @@ export type McpAuthAuthenticateResponses = {
 
 export type McpAuthAuthenticateResponse = McpAuthAuthenticateResponses[keyof McpAuthAuthenticateResponses]
 
+export type McpConnectData = {
+  body?: never
+  path: {
+    name: string
+  }
+  query?: {
+    directory?: string
+  }
+  url: "/mcp/{name}/connect"
+}
+
+export type McpConnectResponses = {
+  /**
+   * MCP server connected successfully
+   */
+  200: boolean
+}
+
+export type McpConnectResponse = McpConnectResponses[keyof McpConnectResponses]
+
+export type McpDisconnectData = {
+  body?: never
+  path: {
+    name: string
+  }
+  query?: {
+    directory?: string
+  }
+  url: "/mcp/{name}/disconnect"
+}
+
+export type McpDisconnectResponses = {
+  /**
+   * MCP server disconnected successfully
+   */
+  200: boolean
+}
+
+export type McpDisconnectResponse = McpDisconnectResponses[keyof McpDisconnectResponses]
+
 export type LspStatusData = {
   body?: never
   path?: never

+ 58 - 0
packages/sdk/js/src/v2/gen/sdk.gen.ts

@@ -41,6 +41,8 @@ import type {
   McpAuthRemoveResponses,
   McpAuthStartErrors,
   McpAuthStartResponses,
+  McpConnectResponses,
+  McpDisconnectResponses,
   McpLocalConfig,
   McpRemoteConfig,
   McpStatusResponses,
@@ -2077,6 +2079,62 @@ export class Mcp extends HeyApiClient {
     })
   }
 
+  /**
+   * Connect an MCP server
+   */
+  public connect<ThrowOnError extends boolean = false>(
+    parameters: {
+      name: string
+      directory?: string
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams(
+      [parameters],
+      [
+        {
+          args: [
+            { in: "path", key: "name" },
+            { in: "query", key: "directory" },
+          ],
+        },
+      ],
+    )
+    return (options?.client ?? this.client).post<McpConnectResponses, unknown, ThrowOnError>({
+      url: "/mcp/{name}/connect",
+      ...options,
+      ...params,
+    })
+  }
+
+  /**
+   * Disconnect an MCP server
+   */
+  public disconnect<ThrowOnError extends boolean = false>(
+    parameters: {
+      name: string
+      directory?: string
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams(
+      [parameters],
+      [
+        {
+          args: [
+            { in: "path", key: "name" },
+            { in: "query", key: "directory" },
+          ],
+        },
+      ],
+    )
+    return (options?.client ?? this.client).post<McpDisconnectResponses, unknown, ThrowOnError>({
+      url: "/mcp/{name}/disconnect",
+      ...options,
+      ...params,
+    })
+  }
+
   auth = new Auth({ client: this.client })
 }
 

+ 40 - 0
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -3503,6 +3503,46 @@ export type McpAuthAuthenticateResponses = {
 
 export type McpAuthAuthenticateResponse = McpAuthAuthenticateResponses[keyof McpAuthAuthenticateResponses]
 
+export type McpConnectData = {
+  body?: never
+  path: {
+    name: string
+  }
+  query?: {
+    directory?: string
+  }
+  url: "/mcp/{name}/connect"
+}
+
+export type McpConnectResponses = {
+  /**
+   * MCP server connected successfully
+   */
+  200: boolean
+}
+
+export type McpConnectResponse = McpConnectResponses[keyof McpConnectResponses]
+
+export type McpDisconnectData = {
+  body?: never
+  path: {
+    name: string
+  }
+  query?: {
+    directory?: string
+  }
+  url: "/mcp/{name}/disconnect"
+}
+
+export type McpDisconnectResponses = {
+  /**
+   * MCP server disconnected successfully
+   */
+  200: boolean
+}
+
+export type McpDisconnectResponse = McpDisconnectResponses[keyof McpDisconnectResponses]
+
 export type LspStatusData = {
   body?: never
   path?: never

+ 82 - 0
packages/sdk/openapi.json

@@ -3996,6 +3996,88 @@
         ]
       }
     },
+    "/mcp/{name}/connect": {
+      "post": {
+        "operationId": "mcp.connect",
+        "parameters": [
+          {
+            "in": "query",
+            "name": "directory",
+            "schema": {
+              "type": "string"
+            }
+          },
+          {
+            "in": "path",
+            "name": "name",
+            "schema": {
+              "type": "string"
+            },
+            "required": true
+          }
+        ],
+        "description": "Connect an MCP server",
+        "responses": {
+          "200": {
+            "description": "MCP server connected successfully",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "boolean"
+                }
+              }
+            }
+          }
+        },
+        "x-codeSamples": [
+          {
+            "lang": "js",
+            "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.connect({\n  ...\n})"
+          }
+        ]
+      }
+    },
+    "/mcp/{name}/disconnect": {
+      "post": {
+        "operationId": "mcp.disconnect",
+        "parameters": [
+          {
+            "in": "query",
+            "name": "directory",
+            "schema": {
+              "type": "string"
+            }
+          },
+          {
+            "in": "path",
+            "name": "name",
+            "schema": {
+              "type": "string"
+            },
+            "required": true
+          }
+        ],
+        "description": "Disconnect an MCP server",
+        "responses": {
+          "200": {
+            "description": "MCP server disconnected successfully",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "boolean"
+                }
+              }
+            }
+          }
+        },
+        "x-codeSamples": [
+          {
+            "lang": "js",
+            "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.disconnect({\n  ...\n})"
+          }
+        ]
+      }
+    },
     "/lsp": {
       "get": {
         "operationId": "lsp.status",