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