feat(server): expose runtime MCP controls over HTTP (#37712)
This commit is contained in:
parent
d5669ca934
commit
fe9a936867
11 changed files with 473 additions and 131 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue