feat(core): add mcp support (#34513)
This commit is contained in:
parent
12887e572e
commit
b1ca070b3b
30 changed files with 1966 additions and 388 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue