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

This commit is contained in:
Daniel Polito 2025-12-08 14:31:22 -03:00 committed by GitHub
parent 0c77c46dc7
commit 203f3312ee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 562 additions and 5 deletions

View file

@ -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",

View file

@ -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
}}
/>
)
}

View file

@ -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",

View file

@ -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
},

View file

@ -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>

View file

@ -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 = {

View file

@ -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({

View file

@ -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",

View file

@ -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 })
}

View file

@ -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

View file

@ -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 })
}

View file

@ -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

View file

@ -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",