feat(core): add mcp support (#34513)

This commit is contained in:
Aiden Cline 2026-06-30 08:37:44 -05:00 committed by GitHub
commit b1ca070b3b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1966 additions and 388 deletions

View file

@ -1,84 +1,53 @@
import { createMemo, createSignal } from "solid-js"
import { useLocal } from "../context/local"
import { useSync } from "../context/sync"
import { map, pipe, entries, sortBy } from "remeda"
import { DialogSelect, type DialogSelectRef, type DialogSelectOption } from "../ui/dialog-select"
import { useData } from "../context/data"
import { map, pipe, sortBy } from "remeda"
import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select"
import { useTheme } from "../context/theme"
import { TextAttributes } from "@opentui/core"
import { useSDK } from "../context/sdk"
import type { McpServer } from "@opencode-ai/sdk/v2"
function Status(props: { enabled: boolean; loading: boolean }) {
function Status(props: { status: McpServer["status"] }) {
const { theme } = useTheme()
if (props.loading) {
return <span style={{ fg: theme.textMuted }}> Loading</span>
switch (props.status.status) {
case "connected":
return <span style={{ fg: theme.success, attributes: TextAttributes.BOLD }}> Connected</span>
case "failed":
return <span style={{ fg: theme.error }}> {props.status.error}</span>
case "needs_auth":
return <span style={{ fg: theme.warning }}>! Needs authentication</span>
case "needs_client_registration":
return <span style={{ fg: theme.error }}> {props.status.error}</span>
case "disabled":
return <span style={{ fg: theme.textMuted }}> Disabled</span>
default:
return <span style={{ fg: theme.textMuted }}> Disconnected</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 data = useData()
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} />,
const options = createMemo(() =>
pipe(
data.location.mcp.list() ?? [],
sortBy((server) => server.name),
map((server) => ({
value: server.name,
title: server.name,
footer: <Status status={server.status} />,
category: undefined,
})),
)
})
const actions = createMemo(() => [
{
command: "dialog.mcp.toggle",
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()}
actions={actions()}
onSelect={(_option) => {
// Don't close on select, only on escape
onSelect={() => {
// Read-only view: selection does nothing, the dialog closes on escape.
}}
/>
)

View file

@ -3,15 +3,18 @@ import { fileURLToPath } from "bun"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { useSync } from "../context/sync"
import { useData } from "../context/data"
import { For, Match, Switch, Show, createMemo } from "solid-js"
export type DialogStatusProps = {}
export function DialogStatus() {
const sync = useSync()
const data = useData()
const { theme } = useTheme()
const dialog = useDialog()
const mcp = createMemo(() => data.location.mcp.list() ?? [])
const enabledFormatters = createMemo(() => sync.data.formatter.filter((f) => f.enabled))
const plugins = createMemo(() => {
@ -50,11 +53,11 @@ export function DialogStatus() {
esc
</text>
</box>
<Show when={Object.keys(sync.data.mcp).length > 0} fallback={<text fg={theme.text}>No MCP Servers</text>}>
<Show when={mcp().length > 0} fallback={<text fg={theme.text}>No MCP Servers</text>}>
<box>
<text fg={theme.text}>{Object.keys(sync.data.mcp).length} MCP Servers</text>
<For each={Object.entries(sync.data.mcp)}>
{([key, item]) => (
<text fg={theme.text}>{mcp().length} MCP Servers</text>
<For each={mcp()}>
{(item) => (
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
@ -67,22 +70,20 @@ export function DialogStatus() {
needs_auth: theme.warning,
needs_client_registration: theme.error,
} as Record<string, typeof theme.success>
)[item.status],
)[item.status.status],
}}
>
</text>
<text fg={theme.text} wrapMode="word">
<b>{key}</b>{" "}
<b>{item.name}</b>{" "}
<span style={{ fg: theme.textMuted }}>
<Switch fallback={item.status}>
<Match when={item.status === "connected"}>Connected</Match>
<Match when={item.status === "failed" && item}>{(val) => val().error}</Match>
<Match when={item.status === "disabled"}>Disabled in configuration</Match>
<Match when={(item.status as string) === "needs_auth"}>
Needs authentication (run: opencode mcp auth {key})
</Match>
<Match when={(item.status as string) === "needs_client_registration" && item}>
<Switch fallback={item.status.status}>
<Match when={item.status.status === "connected"}>Connected</Match>
<Match when={item.status.status === "failed" && item.status}>{(val) => val().error}</Match>
<Match when={item.status.status === "disabled"}>Disabled in configuration</Match>
<Match when={item.status.status === "needs_auth"}>Needs authentication</Match>
<Match when={item.status.status === "needs_client_registration" && item.status}>
{(val) => (val() as { error: string }).error}
</Match>
</Switch>