feat(server): expose runtime MCP controls over HTTP (#37712)

This commit is contained in:
Aiden Cline 2026-07-19 10:28:25 -05:00 committed by GitHub
commit fe9a936867
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 473 additions and 131 deletions

View file

@ -1,5 +1,6 @@
import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js"
import { useData } from "../context/data"
import { useClient } from "../context/client"
import { Keymap } from "../context/keymap"
import { pipe, sortBy } from "remeda"
import { DialogSelect } from "../ui/dialog-select"
@ -12,73 +13,35 @@ import { useToast } from "../ui/toast"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { useConfig } from "../config"
import { getScrollAcceleration } from "../util/scroll"
import type { ComponentTheme } from "../theme/v2/component"
// Sort by how much attention a server needs: auth prompts first, then failures,
// then healthy servers, and intentionally-off servers last.
function statusMeta(status: McpServer["status"], themeV2: ComponentTheme) {
switch (status.status) {
case "needs_auth":
return {
rank: 0,
icon: "!",
label: "Needs authentication",
color: themeV2.text.feedback.warning(),
error: undefined,
bold: false,
}
case "needs_client_registration":
return {
rank: 1,
icon: "✗",
label: "Needs registration",
color: themeV2.text.feedback.error(),
error: status.error,
bold: false,
}
case "failed":
return {
rank: 2,
icon: "✗",
label: "Failed",
color: themeV2.text.feedback.error(),
error: status.error,
bold: false,
}
case "connected":
return {
rank: 3,
icon: "✓",
label: "Connected",
color: themeV2.text.feedback.success(),
error: undefined,
bold: true,
}
case "pending":
return { rank: 4, icon: "◌", label: "Pending", color: themeV2.text.subdued(), error: undefined, bold: false }
default:
return { rank: 5, icon: "○", label: "Disabled", color: themeV2.text.subdued(), error: undefined, bold: false }
function statusError(status: McpServer["status"]) {
if (status.status === "failed" || status.status === "needs_client_registration") return status.error
return undefined
}
function Status(props: { enabled: boolean; loading: boolean }) {
const { themeV2 } = useTheme().contextual("elevated")
if (props.loading) return <span style={{ fg: themeV2.text.subdued() }}> Loading</span>
if (props.enabled) {
return <span style={{ fg: themeV2.text.feedback.success(), attributes: TextAttributes.BOLD }}> Enabled</span>
}
return <span style={{ fg: themeV2.text.subdued() }}> Disabled</span>
}
export function DialogMcp() {
const data = useData()
const dialog = useDialog()
const client = useClient()
const toast = useToast()
const { themeV2 } = useTheme().contextual("elevated")
const [focused, setFocused] = createSignal<string>()
const [detail, setDetail] = createSignal<McpServer>()
onMount(() => {
dialog.setSize("large")
})
const [loading, setLoading] = createSignal<string | null>(null)
const servers = createMemo(() =>
pipe(
data.location.mcp.server.list() ?? [],
sortBy(
(server) => statusMeta(server.status, themeV2).rank,
(server) => server.name,
),
sortBy((server) => server.name),
),
)
@ -88,33 +51,41 @@ export function DialogMcp() {
if (first) setFocused(first.name)
})
const options = createMemo(() =>
servers().map((server) => {
const meta = statusMeta(server.status, themeV2)
return {
value: server.name,
title: server.name,
footer: (
<span style={{ fg: meta.color, attributes: meta.bold ? TextAttributes.BOLD : undefined }}>
{meta.icon} {meta.label}
</span>
),
}
}),
)
const options = createMemo(() => {
const loadingMcp = loading()
return servers().map((server) => ({
value: server.name,
title: server.name,
description: server.status.status,
footer: <Status enabled={server.status.status === "connected"} loading={loadingMcp === server.name} />,
}))
})
const focusedError = createMemo(() => {
const name = focused()
const server = servers().find((entry) => entry.name === name)
return server ? statusMeta(server.status, themeV2).error : undefined
return server ? statusError(server.status) : undefined
})
const open = (name: string | undefined) => {
const server = servers().find((entry) => entry.name === name)
if (!server || !statusMeta(server.status, themeV2).error) return
if (!server || !statusError(server.status)) return
setDetail(server)
}
// Connected servers disconnect; everything else (disabled, failed, needs_auth) retries a
// connection. The mcp.status.changed event refreshes the list, so no manual sync is needed.
const toggle = (name: string) => {
if (loading() !== null) return
const server = servers().find((entry) => entry.name === name)
if (!server || server.status.status === "pending") return
setLoading(name)
const current = data.location.default()
const input = { server: name, location: { directory: current.directory, workspace: current.workspaceID } }
const call = server.status.status === "connected" ? client.api.mcp.disconnect(input) : client.api.mcp.connect(input)
void call.catch(toast.error).finally(() => setLoading(null))
}
return (
<box>
<Show
@ -127,6 +98,16 @@ export function DialogMcp() {
preserveSelection
onMove={(option) => setFocused(option.value as string)}
onSelect={(option) => open(option.value as string)}
actions={[
{
title: "toggle",
command: "dialog.mcp.toggle",
onTrigger: (option) => {
setFocused(option.value as string)
toggle(option.value as string)
},
},
]}
footer={
<Show when={focusedError()}>
<text fg={themeV2.text.subdued()}>enter to view error</text>
@ -135,7 +116,15 @@ export function DialogMcp() {
/>
}
>
{(server) => <DialogMcpError server={server()} onBack={() => setDetail()} />}
{(server) => (
<DialogMcpError
server={server()}
onBack={() => {
setDetail()
dialog.setSize("medium")
}}
/>
)}
</Show>
</box>
)
@ -150,7 +139,7 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
const dimensions = useTerminalDimensions()
const config = useConfig().data
const [copied, setCopied] = createSignal(false)
const error = () => statusMeta(props.server.status, themeV2).error ?? "Unknown MCP connection error"
const error = () => statusError(props.server.status) ?? "Unknown MCP connection error"
const height = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2) - 5))
let scroll: ScrollBoxRenderable | undefined