Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
320 changes: 266 additions & 54 deletions packages/app/src/components/dialog-select-mcp.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,93 @@
import { Component, createMemo, createSignal, Show } from "solid-js"
import { Component, createMemo, createSignal, Show, createEffect } from "solid-js"
import { produce } from "solid-js/store"
import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { Switch } from "@opencode-ai/ui/switch"
import { Button } from "@opencode-ai/ui/button"
import { Tooltip } from "@opencode-ai/ui/tooltip"

type ToolInfo = {
name: string
description: string
id: string
}

export const DialogSelectMcp: Component = () => {
const sync = useSync()
const sdk = useSDK()
const [loading, setLoading] = createSignal<string | null>(null)
const [selectedMcp, setSelectedMcp] = createSignal<string | null>(null)
const [tools, setTools] = createSignal<ToolInfo[]>([])
const [toolsLoading, setToolsLoading] = createSignal(false)
const [toolLoading, setToolLoading] = createSignal<string | null>(null)

const isDrilledDown = createMemo(() => selectedMcp() !== null)

const items = createMemo(() =>
Object.entries(sync.data.mcp ?? {})
.map(([name, status]) => ({ name, status: status.status }))
.sort((a, b) => a.name.localeCompare(b.name)),
)

const toolItems = createMemo(() => tools())

createEffect(() => {
const mcp = selectedMcp()
if (!mcp) {
setTools([])
return
}

const status = sync.data.mcp[mcp]
if (status?.status !== "connected") {
setTools([])
return
}

let cancelled = false
const abortController = new AbortController()

setToolsLoading(true)
fetch(`${sdk.url}/mcp/${encodeURIComponent(mcp)}/tools`, {
signal: abortController.signal,
})
.then((response) => {
if (!response.ok) {
throw new Error(`Failed to get tools: ${response.statusText}`)
}
return response.json()
})
.then((data) => {
if (!cancelled && selectedMcp() === mcp) {
setTools(data)
}
})
.catch((error) => {
if (error.name === "AbortError") {
return
}
if (!cancelled && selectedMcp() === mcp) {
console.error("Failed to fetch tools:", error)
setTools([])
}
})
.finally(() => {
if (!cancelled && selectedMcp() === mcp) {
setToolsLoading(false)
}
})

return () => {
cancelled = true
abortController.abort()
}
})


const toggle = async (name: string) => {
if (loading()) return
if (loading() !== null) return
setLoading(name)
const status = sync.data.mcp[name]
if (status?.status === "connected") {
Expand All @@ -30,62 +100,204 @@ export const DialogSelectMcp: Component = () => {
setLoading(null)
}

const toggleTool = async (mcpName: string, toolName: string) => {
if (toolLoading() !== null) {
return
}

const tool = tools().find((t) => t.name === toolName)
if (!tool) {
return
}

const currentConfig = sync.data.config
const currentValue = currentConfig?.tools?.[tool.id]
const newValue = currentValue === false ? true : false

setToolLoading(tool.id)

sync.set(
"config",
produce((draft) => {
if (!draft.tools) {
draft.tools = {}
}
draft.tools[tool.id] = newValue
}),
)

try {
const response = await fetch(`${sdk.url}/mcp/${encodeURIComponent(mcpName)}/tools/${encodeURIComponent(toolName)}/toggle`, {
method: "POST",
})

if (!response.ok) {
throw new Error(`Failed to toggle tool: ${response.statusText}`)
}
} catch (error) {
sync.set(
"config",
produce((draft) => {
if (!draft.tools) {
draft.tools = {}
}
if (currentValue === undefined) {
delete draft.tools[tool.id]
} else {
draft.tools[tool.id] = currentValue
}
}),
)
console.error("Failed to toggle tool:", error)
} finally {
setToolLoading(null)
}
}

const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
const totalCount = createMemo(() => items().length)

return (
<Dialog title="MCPs" description={`${enabledCount()} of ${totalCount()} enabled`}>
<List
search={{ placeholder: "Search", autofocus: true }}
emptyMessage="No MCPs configured"
key={(x) => x?.name ?? ""}
items={items}
filterKeys={["name", "status"]}
sortBy={(a, b) => a.name.localeCompare(b.name)}
onSelect={(x) => {
if (x) toggle(x.name)
}}
>
{(i) => {
const mcpStatus = () => sync.data.mcp[i.name]
const status = () => mcpStatus()?.status
const error = () => {
const s = mcpStatus()
return s?.status === "failed" ? s.error : undefined
}
const enabled = () => status() === "connected"
return (
<div class="w-full flex items-center justify-between gap-x-3">
<div class="flex flex-col gap-0.5 min-w-0">
<div class="flex items-center gap-2">
<span class="truncate">{i.name}</span>
<Show when={status() === "connected"}>
<span class="text-11-regular text-text-weaker">connected</span>
</Show>
<Show when={status() === "failed"}>
<span class="text-11-regular text-text-weaker">failed</span>
</Show>
<Show when={status() === "needs_auth"}>
<span class="text-11-regular text-text-weaker">needs auth</span>
</Show>
<Show when={status() === "disabled"}>
<span class="text-11-regular text-text-weaker">disabled</span>
</Show>
<Show when={loading() === i.name}>
<span class="text-11-regular text-text-weak">...</span>
</Show>
<Dialog
title={isDrilledDown() ? `Tools: ${selectedMcp()}` : "MCPs"}
description={
isDrilledDown()
? `${toolItems().filter((t) => sync.data.config?.tools?.[t.id] !== false).length} of ${toolItems().length} tools enabled`
: `${enabledCount()} of ${totalCount()} enabled`
}
>
<Show
when={isDrilledDown()}
fallback={
<List
search={{ placeholder: "Search", autofocus: true }}
emptyMessage="No MCPs configured"
key={(x) => x?.name ?? ""}
items={items}
filterKeys={["name", "status"]}
sortBy={(a, b) => a.name.localeCompare(b.name)}
onSelect={(x) => {
if (x) {
const status = sync.data.mcp[x.name]
if (status?.status === "connected") {
setSelectedMcp(x.name)
} else {
toggle(x.name)
}
}
}}
>
{(i) => {
const mcpStatus = () => sync.data.mcp[i.name]
const status = () => mcpStatus()?.status
const error = () => {
const s = mcpStatus()
return s?.status === "failed" ? s.error : undefined
}
const enabled = () => status() === "connected"
return (
<div class="w-full flex items-center justify-between gap-x-3">
<div class="flex flex-col gap-0.5 min-w-0">
<div class="flex items-center gap-2">
<span class="truncate">{i.name}</span>
<Show when={status() === "connected"}>
<span class="text-11-regular text-text-weaker">connected</span>
</Show>
<Show when={status() === "failed"}>
<span class="text-11-regular text-text-weaker">failed</span>
</Show>
<Show when={status() === "needs_auth"}>
<span class="text-11-regular text-text-weaker">needs auth</span>
</Show>
<Show when={status() === "disabled"}>
<span class="text-11-regular text-text-weaker">disabled</span>
</Show>
<Show when={loading() === i.name}>
<span class="text-11-regular text-text-weak">...</span>
</Show>
</div>
<Show when={error()}>
<span class="text-11-regular text-text-weaker truncate">{error()}</span>
</Show>
</div>
<div onClick={(e) => e.stopPropagation()}>
<Switch checked={enabled()} disabled={loading() === i.name} onChange={() => toggle(i.name)} />
</div>
</div>
<Show when={error()}>
<span class="text-11-regular text-text-weaker truncate">{error()}</span>
</Show>
</div>
<div onClick={(e) => e.stopPropagation()}>
<Switch checked={enabled()} disabled={loading() === i.name} onChange={() => toggle(i.name)} />
</div>
</div>
)
}}
</List>
)
}}
</List>
}
>
<div class="flex flex-col gap-2">
<Button
variant="ghost"
onClick={() => {
setSelectedMcp(null)
setTools([])
}}
class="self-start ml-3"
>
← Back
</Button>
<Show
when={!toolsLoading()}
fallback={
<div class="text-11-regular text-text-weaker py-4 text-center">Loading tools...</div>
}
>
<List
search={{ placeholder: "Search tools", autofocus: true }}
emptyMessage="No tools available"
key={(x) => x?.id ?? ""}
items={toolItems}
filterKeys={["name", "description"]}
sortBy={(a, b) => a.name.localeCompare(b.name)}
onSelect={(x) => {
if (x && selectedMcp()) {
toggleTool(selectedMcp()!, x.name)
}
}}
>
{(tool) => {
const loading = () => toolLoading() === tool.id
const enabled = () => sync.data.config?.tools?.[tool.id] !== false
return (
<div class="w-full flex items-center justify-between gap-x-3">
<div class="flex flex-col gap-0.5 min-w-0 flex-1">
<Tooltip value={tool.description} placement="top">
<div class="flex items-center gap-2">
<span class="truncate">{tool.name}</span>
<Show when={enabled()}>
<span class="text-11-regular text-text-weaker">enabled</span>
</Show>
<Show when={!enabled()}>
<span class="text-11-regular text-text-weaker">disabled</span>
</Show>
<Show when={loading()}>
<span class="text-11-regular text-text-weak">...</span>
</Show>
</div>
</Tooltip>
</div>
<div onClick={(e) => e.stopPropagation()}>
<Switch
checked={enabled()}
disabled={loading()}
onChange={() => {
if (selectedMcp()) {
toggleTool(selectedMcp()!, tool.name)
}
}}
/>
</div>
</div>
)
}}
</List>
</Show>
</div>
</Show>
</Dialog>
)
}
}
Loading