diff --git a/packages/app/src/components/dialog-select-mcp.tsx b/packages/app/src/components/dialog-select-mcp.tsx index c29cd827e3b..4b67f6f6cf3 100644 --- a/packages/app/src/components/dialog-select-mcp.tsx +++ b/packages/app/src/components/dialog-select-mcp.tsx @@ -1,14 +1,29 @@ -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(null) + const [selectedMcp, setSelectedMcp] = createSignal(null) + const [tools, setTools] = createSignal([]) + const [toolsLoading, setToolsLoading] = createSignal(false) + const [toolLoading, setToolLoading] = createSignal(null) + + const isDrilledDown = createMemo(() => selectedMcp() !== null) const items = createMemo(() => Object.entries(sync.data.mcp ?? {}) @@ -16,8 +31,63 @@ export const DialogSelectMcp: Component = () => { .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") { @@ -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 ( - - 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 ( -
-
-
- {i.name} - - connected - - - failed - - - needs auth - - - disabled - - - ... - + sync.data.config?.tools?.[t.id] !== false).length} of ${toolItems().length} tools enabled` + : `${enabledCount()} of ${totalCount()} enabled` + } + > + 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 ( +
+
+
+ {i.name} + + connected + + + failed + + + needs auth + + + disabled + + + ... + +
+ + {error()} + +
+
e.stopPropagation()}> + toggle(i.name)} /> +
- - {error()} - -
-
e.stopPropagation()}> - toggle(i.name)} /> -
-
- ) - }} - + ) + }} + + } + > +
+ + Loading tools...
+ } + > + 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 ( +
+
+ +
+ {tool.name} + + enabled + + + disabled + + + ... + +
+
+
+
e.stopPropagation()}> + { + if (selectedMcp()) { + toggleTool(selectedMcp()!, tool.name) + } + }} + /> +
+
+ ) + }} +
+ +
+
) -} +} \ No newline at end of file diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx index 9cfa30d4df9..fc92b112ea4 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx @@ -1,4 +1,4 @@ -import { createMemo, createSignal } from "solid-js" +import { createMemo, createSignal, createEffect, Show, onMount, onCleanup } from "solid-js" import { useLocal } from "@tui/context/local" import { useSync } from "@tui/context/sync" import { map, pipe, entries, sortBy } from "remeda" @@ -7,6 +7,7 @@ import { useTheme } from "../context/theme" import { Keybind } from "@/util/keybind" import { TextAttributes } from "@opentui/core" import { useSDK } from "@tui/context/sdk" +import { useDialogEscape } from "@tui/ui/dialog" function Status(props: { enabled: boolean; loading: boolean }) { const { theme } = useTheme() @@ -19,15 +20,26 @@ function Status(props: { enabled: boolean; loading: boolean }) { return ○ Disabled } +type ToolInfo = { + name: string + description: string + id: string +} + export function DialogMcp() { const local = useLocal() const sync = useSync() const sdk = useSDK() const [, setRef] = createSignal>() const [loading, setLoading] = createSignal(null) + const [selectedMcp, setSelectedMcp] = createSignal(null) + const [tools, setTools] = createSignal([]) + const [toolsLoading, setToolsLoading] = createSignal(false) + const [toolLoading, setToolLoading] = createSignal(null) - const options = createMemo(() => { - // Track sync data and loading state to trigger re-render when they change + const isDrilledDown = createMemo(() => selectedMcp() !== null) + + const mcpOptions = createMemo(() => { const mcpData = sync.data.mcp const loadingMcp = loading() @@ -45,41 +57,166 @@ export function DialogMcp() { ) }) - const keybinds = createMemo(() => [ - { - keybind: Keybind.parse("space")[0], - title: "toggle", - onTrigger: async (option: DialogSelectOption) => { - // 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) + const toolOptions = createMemo(() => { + const toolList = tools() + const cfg = sync.data.config + const currentToolLoading = toolLoading() + + return toolList.map((tool) => { + const isEnabled = cfg?.tools?.[tool.id] !== false + return { + value: tool.id, + title: tool.name, + description: tool.description || "", + footer: , + category: undefined, + } + }) + }) + + const currentOptions = createMemo(() => { + return isDrilledDown() ? toolOptions() : mcpOptions() + }) + + createEffect(() => { + const mcp = selectedMcp() + if (!mcp) { + setTools([]) + setToolsLoading(false) + return + } + + const status = sync.data.mcp[mcp] + if (status?.status !== "connected") { + setTools([]) + setToolsLoading(false) + return + } + + let cancelled = false + + setToolsLoading(true) + local.mcp + .getTools(mcp) + .then((data) => { + if (!cancelled && selectedMcp() === mcp) { + setTools(data) + } + }) + .catch((error) => { + if (!cancelled && selectedMcp() === mcp) { + console.error("Failed to fetch tools:", error) + setTools([]) } + }) + .finally(() => { + if (!cancelled && selectedMcp() === mcp) { + setToolsLoading(false) + } + }) + + return () => { + cancelled = true + } + }) + + useDialogEscape(() => { + if (isDrilledDown()) { + setSelectedMcp(null) + setTools([]) + return true + } + return false + }) + + const keybinds = createMemo(() => { + if (isDrilledDown()) { + return [ + { + keybind: Keybind.parse("escape")[0], + title: "back", + onTrigger: () => { + setSelectedMcp(null) + setTools([]) + }, + }, + { + keybind: Keybind.parse("space")[0], + title: "toggle tool", + onTrigger: async (option: DialogSelectOption) => { + if (toolLoading() !== null) return + + const mcp = selectedMcp() + if (!mcp) return + + const tool = tools().find((t) => t.id === option.value) + if (!tool) return + + setToolLoading(option.value) + try { + await local.mcp.toggleTool(mcp, tool.name) + const config = await sdk.client.config.get() + if (config.data) { + sync.set("config", config.data) + } + } catch (error) { + console.error("Failed to toggle tool:", error) + } finally { + setToolLoading(null) + } + }, + }, + ] + } + + return [ + { + keybind: Keybind.parse("space")[0], + title: "toggle", + onTrigger: async (option: DialogSelectOption) => { + if (loading() !== null) return + + setLoading(option.value) + try { + await local.mcp.toggle(option.value) + const status = await sdk.client.mcp.status() + if (status.data) { + sync.set("mcp", status.data) + } + } catch (error) { + console.error("Failed to toggle MCP:", error) + } finally { + setLoading(null) + } + }, + }, + { + keybind: Keybind.parse("return")[0], + title: "view tools", + onTrigger: async (option: DialogSelectOption) => { + const status = sync.data.mcp[option.value] + if (status?.status === "connected") { + setSelectedMcp(option.value) + } + }, }, - }, - ]) + ] + }) return ( { - // Don't close on select, only on escape + if (isDrilledDown()) { + return + } + const status = sync.data.mcp[option.value] + if (status?.status === "connected") { + setSelectedMcp(option.value) + } }} /> ) diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index b60a775b375..51e7a5dfee8 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -362,6 +362,25 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ await sdk.client.mcp.connect({ name }) } }, + async getTools(name: string) { + const status = sync.data.mcp[name] + if (status?.status !== "connected") { + return [] + } + const response = await fetch(`${sdk.url}/mcp/${encodeURIComponent(name)}/tools`) + if (!response.ok) { + throw new Error(`Failed to get tools: ${response.statusText}`) + } + return response.json() + }, + async toggleTool(mcpName: string, toolName: string) { + 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}`) + } + }, } // Automatically update model when agent changes diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx index 79bca42406a..ec7c75faf6a 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx @@ -1,5 +1,5 @@ import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" -import { batch, createContext, Show, useContext, type JSX, type ParentProps } from "solid-js" +import { batch, createContext, createEffect, createMemo, onCleanup, Show, useContext, type JSX, type ParentProps } from "solid-js" import { useTheme } from "@tui/context/theme" import { Renderable, RGBA } from "@opentui/core" import { createStore } from "solid-js/store" @@ -10,6 +10,7 @@ export function Dialog( props: ParentProps<{ size?: "medium" | "large" onClose: () => void + handleEscape?: () => boolean }>, ) { const dimensions = useTerminalDimensions() @@ -56,15 +57,7 @@ function init() { size: "medium" as "medium" | "large", }) - useKeyboard((evt) => { - if (evt.name === "escape" && store.stack.length > 0) { - const current = store.stack.at(-1)! - current.onClose?.() - setStore("stack", store.stack.slice(0, -1)) - evt.preventDefault() - refocus() - } - }) + const escapeHandlers = new Map boolean>() const renderer = useRenderer() let focus: Renderable | null @@ -85,18 +78,41 @@ function init() { }, 1) } + useKeyboard((evt) => { + if (evt.name === "escape" && store.stack.length > 0) { + if (evt.defaultPrevented) return + const current = store.stack.at(-1)! + const stackIndex = store.stack.length - 1 + const handleEscape = escapeHandlers.get(stackIndex) + if (handleEscape) { + const handled = handleEscape() + if (handled) { + evt.preventDefault() + return + } + } + current.onClose?.() + const lastIndex = store.stack.length - 1 + escapeHandlers.delete(lastIndex) + setStore("stack", store.stack.slice(0, -1)) + evt.preventDefault() + refocus() + } + }) + return { clear() { for (const item of store.stack) { if (item.onClose) item.onClose() } + escapeHandlers.clear() batch(() => { setStore("size", "medium") setStore("stack", []) }) refocus() }, - replace(input: any, onClose?: () => void) { + replace(input: any, onClose?: () => void, handleEscape?: () => boolean) { if (store.stack.length === 0) { focus = renderer.currentFocusedRenderable focus?.blur() @@ -104,6 +120,10 @@ function init() { for (const item of store.stack) { if (item.onClose) item.onClose() } + escapeHandlers.clear() + if (handleEscape) { + escapeHandlers.set(0, handleEscape) + } setStore("size", "medium") setStore("stack", [ { @@ -121,6 +141,21 @@ function init() { setSize(size: "medium" | "large") { setStore("size", size) }, + focus: () => focus, + setFocus: (f: Renderable | null) => { + focus = f + }, + refocus: () => refocus(), + setStackHandleEscape: (handler: (() => boolean) | undefined) => { + const stackIndex = store.stack.length - 1 + if (stackIndex >= 0) { + if (handler) { + escapeHandlers.set(stackIndex, handler) + } else { + escapeHandlers.delete(stackIndex) + } + } + }, } } @@ -169,3 +204,15 @@ export function useDialog() { } return value } + +export function useDialogEscape(handler: () => boolean) { + const dialog = useDialog() + const handlerFn = createMemo(() => handler) + createEffect(() => { + handlerFn() + dialog.setStackHandleEscape(() => handlerFn()()) + onCleanup(() => { + dialog.setStackHandleEscape(undefined) + }) + }) +} diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index fb32472d7ab..ed02e7e1f08 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -34,6 +34,7 @@ export const WebCommand = cmd({ handler: async (args) => { const opts = await resolveNetworkOptions(args) const server = Server.listen(opts) + UI.empty() UI.println(UI.logo(" ")) UI.empty() diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index a91c91cf0a0..d41b0cb1e4a 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -12,7 +12,14 @@ import { lazy } from "../util/lazy" import { NamedError } from "@opencode-ai/util/error" import { Flag } from "../flag/flag" import { Auth } from "../auth" -import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser" +import { + type ParseError as JsoncParseError, + parse as parseJsonc, + printParseErrorCode, + modify, + applyEdits, + type FormattingOptions, +} from "jsonc-parser" import { Instance } from "../project/instance" import { LSPServer } from "../lsp/server" import { BunProc } from "@/bun" @@ -1140,6 +1147,40 @@ export namespace Config { await Instance.dispose() } + export async function patch(edit: { path: (string | number)[]; value: any }) { + const opencodeDir = path.join(Instance.worktree, ".opencode") + const configPath = path.join(opencodeDir, "opencode.jsonc") + + let configFile: string + if (await Bun.file(configPath).exists()) { + configFile = await Bun.file(configPath).text() + } else { + if (!existsSync(opencodeDir)) { + await fs.mkdir(opencodeDir, { recursive: true }) + } + configFile = "{\n \"$schema\": \"https://opencode.ai/config.json\"\n}" + } + + const formattingOptions: FormattingOptions = { + tabSize: 2, + insertSpaces: true, + eol: "\n", + } + + const edits = modify(configFile, edit.path, edit.value, { formattingOptions }) + const updatedContent = applyEdits(configFile, edits) + + await Bun.write(configPath, updatedContent) + + const isToolOnlyChange = edit.path.length > 0 && edit.path[0] === "tools" + + if (!isToolOnlyChange) { + await Instance.dispose() + } else { + ;(state as any).reset?.() + } + } + export async function directories() { return state().then((x) => x.directories) } diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index aca0c663152..7d8a1afdcd5 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -572,6 +572,55 @@ export namespace MCP { return result } + export async function getTools(name: string) { + const s = await state() + const client = s.clients[name] + + if (!client) { + return [] + } + + if (s.status[name]?.status !== "connected") { + return [] + } + + const toolsResult = await client.listTools().catch((e) => { + log.error("failed to get tools", { name, error: e.message }) + return undefined + }) + + if (!toolsResult) { + return [] + } + + const sanitizedClientName = name.replace(/[^a-zA-Z0-9_-]/g, "_") + return toolsResult.tools.map((mcpTool) => { + const sanitizedToolName = mcpTool.name.replace(/[^a-zA-Z0-9_-]/g, "_") + return { + name: mcpTool.name, + description: mcpTool.description ?? "", + id: `${sanitizedClientName}_${sanitizedToolName}`, + } + }) + } + + export async function toggleTool(mcpName: string, toolName: string) { + const cfg = await Config.get() + const sanitizedClientName = mcpName.replace(/[^a-zA-Z0-9_-]/g, "_") + const sanitizedToolName = toolName.replace(/[^a-zA-Z0-9_-]/g, "_") + const toolId = `${sanitizedClientName}_${sanitizedToolName}` + + const currentTools = cfg.tools ?? {} + const currentValue = currentTools[toolId] + + const newValue = currentValue === false ? true : false + + await Config.patch({ + path: ["tools", toolId], + value: newValue, + }) + } + export async function prompts() { const s = await state() const clientsSnapshot = await clients() diff --git a/packages/opencode/src/project/state.ts b/packages/opencode/src/project/state.ts index c1ac23c5d26..943bd5af3df 100644 --- a/packages/opencode/src/project/state.ts +++ b/packages/opencode/src/project/state.ts @@ -10,22 +10,31 @@ export namespace State { const recordsByKey = new Map>() export function create(root: () => string, init: () => S, dispose?: (state: Awaited) => Promise) { - return () => { + const initFn = init + const getter = () => { const key = root() let entries = recordsByKey.get(key) if (!entries) { entries = new Map() recordsByKey.set(key, entries) } - const exists = entries.get(init) + const exists = entries.get(initFn) if (exists) return exists.state as S - const state = init() - entries.set(init, { + const state = initFn() + entries.set(initFn, { state, dispose, }) return state } + getter.reset = () => { + const key = root() + const entries = recordsByKey.get(key) + if (entries) { + entries.delete(initFn) + } + } + return getter } export async function dispose(key: string) { @@ -58,7 +67,7 @@ export namespace State { tasks.push(task) } entries.clear() - await Promise.all(tasks) + await Promise.allSettled(tasks) disposalFinished = true log.info("state disposal completed", { key }) } diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 04ec4673ec4..486410a867e 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -72,6 +72,7 @@ export namespace Server { const app = new Hono() export const App = lazy(() => + // @ts-expect-error - Type instantiation is excessively deep (complex Hono chain) app .onError((err, c) => { log.error("failed", { @@ -2356,6 +2357,68 @@ export namespace Server { return c.json(true) }, ) + .get( + "/mcp/:name/tools", + describeRoute({ + summary: "Get MCP tools", + description: "Get the list of tools for a specific MCP server", + operationId: "mcp.tools.get", + responses: { + 200: { + description: "List of tools", + content: { + "application/json": { + schema: resolver( + z + .array( + z + .object({ + name: z.string(), + description: z.string(), + id: z.string(), + }) + .meta({ ref: "McpTool" }), + ) + .meta({ ref: "McpToolList" }), + ), + }, + }, + }, + ...errors(404), + }, + }), + validator("param", z.object({ name: z.string() })), + async (c) => { + const { name } = c.req.valid("param") + const tools = await MCP.getTools(name) + return c.json(tools) + }, + ) + .post( + "/mcp/:name/tools/:tool/toggle", + describeRoute({ + summary: "Toggle MCP tool", + description: "Toggle a specific tool within an MCP server", + operationId: "mcp.tools.toggle", + responses: { + 200: { + description: "Tool toggled successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(404), + }, + }), + validator("param", z.object({ name: z.string(), tool: z.string() })), + async (c) => { + const { name, tool } = c.req.valid("param") + await MCP.toggleTool(name, tool) + return c.json(true) + }, + ) .get( "/experimental/resource", describeRoute({