diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index e7fa4a6de..226de4796 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/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(() => ) }, }, + { + title: "Toggle MCPs", + value: "mcp.list", + category: "Agent", + onSelect: () => { + dialog.replace(() => ) + }, + }, { title: "Agent cycle", value: "agent.cycle", diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx new file mode 100644 index 000000000..9cfa30d4d --- /dev/null +++ b/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 ⋯ Loading + } + if (props.enabled) { + return ✓ Enabled + } + return ○ Disabled +} + +export function DialogMcp() { + const local = useLocal() + const sync = useSync() + const sdk = useSDK() + const [, setRef] = createSignal>() + const [loading, setLoading] = createSignal(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: , + category: undefined, + })), + ) + }) + + const keybinds = createMemo(() => [ + { + keybind: Keybind.parse("space")[0], + title: "toggle", + onTrigger: async (option: DialogSelectOption) => { + // 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 ( + { + // Don't close on select, only on escape + }} + /> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 7ef465368..f41b33852 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/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", diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index 3bfedf34e..6cc97e041 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/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 }, diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx index cd308b44c..d0bb296eb 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx +++ b/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 = ( - 0}> + 0}> @@ -35,7 +39,7 @@ export function Home() { {" "} - {Locale.pluralize(Object.values(sync.data.mcp).length, "{} mcp server", "{} mcp servers")} + {Locale.pluralize(connectedMcpCount(), "{} mcp server", "{} mcp servers")} @@ -85,7 +89,7 @@ export function Home() { - {Object.keys(sync.data.mcp).length} MCP + {connectedMcpCount()} MCP /status diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 82a9a3d36..e030f83b5 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/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 = {} + + // 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 = {} 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 = { diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 78ef5924e..855663cb9 100644 --- a/packages/opencode/src/server/server.ts +++ b/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({ diff --git a/packages/sdk/js/openapi.json b/packages/sdk/js/openapi.json index 1fef9e623..421edfaf1 100644 --- a/packages/sdk/js/openapi.json +++ b/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", diff --git a/packages/sdk/js/src/gen/sdk.gen.ts b/packages/sdk/js/src/gen/sdk.gen.ts index af69b42ff..5e3e67e1c 100644 --- a/packages/sdk/js/src/gen/sdk.gen.ts +++ b/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(options: Options) { + return (options.client ?? this._client).post({ + url: "/mcp/{name}/connect", + ...options, + }) + } + + /** + * Disconnect an MCP server + */ + public disconnect(options: Options) { + return (options.client ?? this._client).post({ + url: "/mcp/{name}/disconnect", + ...options, + }) + } + auth = new Auth({ client: this._client }) } diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 5267c0e51..8d4550525 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/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 diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 77e3ba1f5..38f39b2a9 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/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( + parameters: { + name: string + directory?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "name" }, + { in: "query", key: "directory" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/mcp/{name}/connect", + ...options, + ...params, + }) + } + + /** + * Disconnect an MCP server + */ + public disconnect( + parameters: { + name: string + directory?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "name" }, + { in: "query", key: "directory" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/mcp/{name}/disconnect", + ...options, + ...params, + }) + } + auth = new Auth({ client: this.client }) } diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index ffd7ee373..9b80026f0 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/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 diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 1fef9e623..421edfaf1 100644 --- a/packages/sdk/openapi.json +++ b/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",