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

@ -376,6 +376,34 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
const attention = createTuiAttention({ renderer, config: tuiConfig, kv })
const clipboard = useClipboard()
// Toast once when an MCP server enters a failed or needs-auth state so the user knows to act,
// without having to open the status panel. Tracking the last alerted status avoids re-toasting
// the same problem on every refresh while still re-alerting if the state changes.
const mcpAlerted: Record<string, string> = {}
createEffect(() => {
for (const server of data.location.mcp.list() ?? []) {
const status = server.status
if (status.status !== "failed" && status.status !== "needs_auth") {
delete mcpAlerted[server.name]
continue
}
if (mcpAlerted[server.name] === status.status) continue
mcpAlerted[server.name] = status.status
if (status.status === "needs_auth")
toast.show({
variant: "warning",
title: "MCP server needs authentication",
message: `Connect "${server.name}" to use its tools.`,
})
else
toast.show({
variant: "error",
title: "MCP server failed to connect",
message: `${server.name}: ${status.error}`,
})
}
})
const api = createTuiApi(
createTuiApiAdapters({
version: InstallationVersion,

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>

View file

@ -3,6 +3,7 @@ import type {
CommandV2Info,
IntegrationInfo,
LocationRef,
McpServer,
ModelV2Info,
PermissionSavedInfo,
PermissionV2Request,
@ -30,6 +31,7 @@ type LocationData = {
agent?: AgentV2Info[]
command?: CommandV2Info[]
integration?: IntegrationInfo[]
mcp?: McpServer[]
model?: ModelV2Info[]
provider?: ProviderV2Info[]
reference?: ReferenceInfo[]
@ -529,6 +531,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
result.location.provider.refresh(event.location),
])
break
// Authenticating an MCP integration reconnects its server, which emits mcp.status.changed,
// so the mcp list refreshes here rather than off integration.updated.
case "mcp.status.changed":
void result.location.mcp.refresh(event.location)
break
}
}
@ -674,6 +681,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
setStore("location", key, { ...store.location[key], integration: mutable(result.data) })
},
},
mcp: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.mcp
},
async refresh(ref?: LocationRef) {
const result = await sdk.client.v2.mcp.list({ location: locationQuery(ref) }, { throwOnError: true })
const key = locationKey(result.data.location)
setStore("location", key, { ...store.location[key], mcp: result.data.data })
},
},
model: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.model
@ -747,6 +764,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
result.location.refresh(),
result.location.agent.refresh(),
result.location.integration.refresh(),
result.location.mcp.refresh(),
result.location.model.refresh(),
result.location.provider.refresh(),
result.location.reference.refresh(),

View file

@ -1,6 +1,7 @@
import { createMemo, Match, onCleanup, onMount, Show, Switch } from "solid-js"
import { useTheme } from "../../context/theme"
import { useSync } from "../../context/sync"
import { useData } from "../../context/data"
import { useDirectory } from "../../context/directory"
import { useConnected } from "../../component/use-connected"
import { createStore } from "solid-js/store"
@ -9,9 +10,10 @@ import { useRoute } from "../../context/route"
export function Footer() {
const { theme } = useTheme()
const sync = useSync()
const data = useData()
const route = useRoute()
const mcp = createMemo(() => Object.values(sync.data.mcp).filter((x) => x.status === "connected").length)
const mcpError = createMemo(() => Object.values(sync.data.mcp).some((x) => x.status === "failed"))
const mcp = createMemo(() => (data.location.mcp.list() ?? []).filter((x) => x.status.status === "connected").length)
const mcpError = createMemo(() => (data.location.mcp.list() ?? []).some((x) => x.status.status === "failed"))
const lsp = createMemo(() => Object.keys(sync.data.lsp))
const permissions = createMemo(() => {
if (route.data.type !== "session") return []