diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 5da2740cce4c..840f624ab633 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -30,6 +30,7 @@ import { SyncProvider, useSync } from "@tui/context/sync" import { LocalProvider, useLocal } from "@tui/context/local" import { DialogModel, useConnected } from "@tui/component/dialog-model" import { DialogMcp } from "@tui/component/dialog-mcp" +import { DialogSessionTools } from "@tui/component/dialog-session-tools" import { DialogStatus } from "@tui/component/dialog-status" import { DialogThemeList } from "@tui/component/dialog-theme-list" import { DialogHelp } from "./ui/dialog-help" @@ -504,6 +505,85 @@ function App(props: { onSnapshot?: () => Promise }) { dialog.replace(() => ) }, }, + { + title: "Select MCP tools", + value: "session.tools", + keybind: undefined, + category: "Agent", + slash: { name: "tools" }, + onSelect: () => { + const id = route.data.type === "session" ? route.data.sessionID : undefined + dialog.replace(() => ( + { + if (id) { + // Mid-session: apply updated deny/allow rules directly + const mcp = sdk.client.mcp + void mcp.tools().then(async (res) => { + if (!res.data) return + + // Check which MCP servers have all tools disabled + const serversToDisable: string[] = [] + const serversToEnable: string[] = [] + + for (const [serverName, serverTools] of Object.entries(res.data)) { + const toolKeys = serverTools.map((t) => t.key) + const enabledTools = filter === "all" + ? toolKeys + : toolKeys.filter((k) => (filter as string[]).includes(k)) + + if (enabledTools.length === 0) { + // All tools disabled - disable the MCP server + serversToDisable.push(serverName) + } else if (enabledTools.length === toolKeys.length) { + // All tools enabled - ensure MCP server is enabled + serversToEnable.push(serverName) + } + } + + const all = Object.values(res.data).flatMap((list) => list.map((t) => t.key)) + const rules = + filter === "all" + ? all.map((k) => ({ permission: k, pattern: "*", action: "allow" as const })) + : [ + ...all + .filter((k) => !(filter as string[]).includes(k)) + .map((k) => ({ permission: k, pattern: "*", action: "deny" as const })), + ...(filter as string[]).map((k) => ({ + permission: k, + pattern: "*", + action: "allow" as const, + })), + ] + if (rules.length > 0) { + await sdk.client.session.update({ sessionID: id, permission: rules }) + } + + // Disable/enable MCP servers as needed + for (const serverName of serversToDisable) { + console.log(`[Command] Disabling MCP server: ${serverName}`) + await local.mcp.toggle(serverName) + } + for (const serverName of serversToEnable) { + const status = Object.entries(sync.data.mcp).find(([name]) => name === serverName)?.[1] + if (status?.status === "disabled") { + console.log(`[Command] Enabling MCP server: ${serverName}`) + await local.mcp.toggle(serverName) + } + } + }) + } else { + // Home screen (pre-session): store for use when first message is sent + local.sessionTools.set(filter) + } + dialog.clear() + }} + onDismiss={() => dialog.clear()} + /> + )) + }, + }, { title: "Agent cycle", value: "agent.cycle", diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-tools.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-tools.tsx new file mode 100644 index 000000000000..5b9eb088d79f --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-tools.tsx @@ -0,0 +1,258 @@ +import { createMemo, createSignal, onMount } from "solid-js" +import { useSDK } from "@tui/context/sdk" +import { useDialog } from "@tui/ui/dialog" +import { useTheme } from "@tui/context/theme" +import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" +import { Keybind } from "@/util" +import { TextAttributes } from "@opentui/core" + +type McpTool = { name: string; key: string; description: string; tokenEstimate: number } + +interface Props { + sessionID?: string + onConfirm: (filter: string[] | "all") => void + onDismiss: () => void +} + +export function DialogSessionTools(props: Props) { + const sdk = useSDK() + const dialog = useDialog() + const { theme } = useTheme() + + const [toolMap, setToolMap] = createSignal>({}) + const [loading, setLoading] = createSignal(true) + const [chosen, setChosen] = createSignal>(new Set()) + const [collapsed, setCollapsed] = createSignal>(new Set()) + const [searchQuery, setSearchQuery] = createSignal("") + + onMount(async () => { + dialog.setSize("large") + + const res = await sdk.client.mcp.tools() + if (!res.data) { + setLoading(false) + return + } + setToolMap(res.data) + + const all = Object.values(res.data).flatMap((t) => t.map((x) => x.key)) + + if (props.sessionID) { + const sres = await sdk.client.session.get({ sessionID: props.sessionID }) + const permission = sres.data?.permission ?? [] + // A tool is disabled if its last matching rule is deny with pattern "*" + const enabled = all.filter((key) => { + const rule = [...permission].reverse().find((r) => r.permission === key || r.permission === "*") + return !rule || rule.action !== "deny" || rule.pattern !== "*" + }) + setChosen(new Set(enabled)) + } else { + setChosen(new Set(all)) + } + + setLoading(false) + }) + + const allKeys = createMemo(() => Object.values(toolMap()).flatMap((t) => t.map((x) => x.key))) + + const totalTokens = createMemo(() => + Object.values(toolMap()) + .flat() + .reduce((sum, t) => sum + (chosen().has(t.key) ? t.tokenEstimate : 0), 0), + ) + + const serverTokens = createMemo(() => { + const result: Record = {} + for (const [server, list] of Object.entries(toolMap())) { + result[server] = list.reduce((sum, t) => sum + (chosen().has(t.key) ? t.tokenEstimate : 0), 0) + } + return result + }) + + const options = createMemo((): DialogSelectOption[] => { + const sel = chosen() + const collapsedSet = collapsed() + const isSearching = searchQuery().trim().length > 0 + + return Object.entries(toolMap()).flatMap(([server, list]) => { + const isCollapsed = collapsedSet.has(server) && !isSearching + const selectedCount = list.filter((t) => sel.has(t.key)).length + const serverTk = serverTokens()[server] ?? 0 + + // If collapsed, return only a summary row + if (isCollapsed) { + return [ + { + value: `__server_header__${server}`, + title: server, + description: `${list.length} tools, ${selectedCount} selected, ~${serverTk.toLocaleString()}tk`, + category: server, + categoryView: ( + + + {server} + + {" "} + {list.length} tools, {selectedCount} selected, ~{serverTk.toLocaleString()}tk + + + ) as any, + gutter: ( + + {" ".repeat(3)} + + ) as any, + }, + ] + } + + // Expanded: show header + all tools + return [ + // Server header (clickable to collapse) + { + value: `__server_header__${server}`, + title: server, + category: server, + categoryView: ( + + + {server} + {" "}~{serverTk.toLocaleString()}tk + + ) as any, + gutter: ( + + {" ".repeat(3)} + + ) as any, + }, + // All tools in this server + ...list.map((tool) => ({ + value: tool.key, + title: tool.name, + description: tool.description || undefined, + category: server, + footer: `~${tool.tokenEstimate}tk`, + gutter: ( + + {sel.has(tool.key) ? "[✓]" : "[ ]"} + + ) as any, + })), + ] + }) + }) + + function toggle(key: string) { + setChosen((prev) => { + const next = new Set(prev) + if (next.has(key)) next.delete(key) + else next.add(key) + return next + }) + } + + function toggleServerCollapse(server: string) { + setCollapsed((prev) => { + const next = new Set(prev) + if (next.has(server)) next.delete(server) + else next.add(server) + return next + }) + } + + function getServerFromKey(key: string): string | null { + for (const [server, list] of Object.entries(toolMap())) { + if (list.some((t) => t.key === key)) return server + if (key === `__server_header__${server}`) return server + } + return null + } + + function selectAll() { + setChosen(new Set(allKeys())) + } + + function selectNone() { + setChosen(new Set()) + } + + function confirm() { + const sel = chosen() + const keys = allKeys() + // If every tool is selected, treat as "all" so no deny rules are written + const filter: string[] | "all" = sel.size === keys.length ? "all" : [...sel] + props.onConfirm(filter) + } + + const keybinds = createMemo(() => [ + { + keybind: Keybind.parse("space")[0], + title: "toggle", + onTrigger: (opt: DialogSelectOption) => { + // If it's a server header, toggle collapse; otherwise toggle tool selection + if (opt.value.startsWith("__server_header__")) { + const server = opt.value.replace("__server_header__", "") + toggleServerCollapse(server) + } else { + toggle(opt.value) + } + }, + }, + { + keybind: Keybind.parse("c")[0], + title: "collapse", + onTrigger: (opt: DialogSelectOption) => { + const server = getServerFromKey(opt.value) + if (server) toggleServerCollapse(server) + }, + }, + { + keybind: Keybind.parse("a")[0], + title: "all", + onTrigger: () => selectAll(), + }, + { + keybind: Keybind.parse("n")[0], + title: "none", + onTrigger: () => selectNone(), + }, + { + // Display-only hint; actual confirm fires via onSelect (Enter) + keybind: Keybind.parse("return")[0], + title: "confirm", + side: "right" as const, + onTrigger: () => {}, + }, + ]) + + const title = createMemo(() => { + if (loading()) return "Session Tools Loading…" + const tk = totalTokens().toLocaleString() + const sel = chosen().size + const total = allKeys().length + return `Session Tools ${sel}/${total} tools · ~${tk} tokens` + }) + + return ( + setSearchQuery(query)} + onSelect={(opt) => { + // If user pressed Enter on a server header, toggle collapse instead of confirming + if (opt.value.startsWith("__server_header__")) { + const server = opt.value.replace("__server_header__", "") + toggleServerCollapse(server) + return + } + // Enter on a tool = confirm the current selection + confirm() + dialog.clear() + }} + /> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 2e08e66a4a2d..226c739bb240 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -41,6 +41,7 @@ import { useTextareaKeybindings } from "../textarea-keybindings" import { DialogSkill } from "../dialog-skill" import { DialogWorkspaceCreate, restoreWorkspaceSession } from "../dialog-workspace-create" import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable" +import { DialogSessionTools } from "../dialog-session-tools" import { useArgs } from "@tui/context/args" export type PromptProps = { @@ -680,6 +681,57 @@ export function Prompt(props: PromptProps) { let sessionID = props.sessionID if (sessionID == null) { + // Intercept on first message: show tool selection dialog if any MCP servers are connected + // and the dialog hasn't been shown yet for this session (pending === null). + // Wait for MCP status to be fully loaded before checking + if (sync.status !== "complete") { + console.log("[MCP Dialog] Waiting for sync to complete (status: %s)", sync.status) + toast.show({ + message: "Loading MCP servers...", + variant: "info", + }) + return false + } + + const mcpServers = Object.values(sync.data.mcp ?? {}) + const connectedServers = mcpServers.filter((s) => s.status === "connected") + const hasMcp = connectedServers.length > 0 + const pending = local.sessionTools.pending() + + console.log("[MCP Dialog] Check:", { + hasMcp, + connectedCount: connectedServers.length, + totalServers: mcpServers.length, + pending, + servers: Object.entries(sync.data.mcp ?? {}).map(([name, s]) => ({ name, status: s.status })), + }) + + if (hasMcp && pending === null) { + console.log("[MCP Dialog] Showing tool selection dialog") + local.sessionTools.setPending(store.prompt.input) + local.sessionTools.setResume(() => submit()) + dialog.replace(() => ( + { + console.log("[MCP Dialog] User confirmed with filter:", filter) + local.sessionTools.set(filter) + dialog.clear() + local.sessionTools.callResume() + }} + onDismiss={() => { + console.log("[MCP Dialog] User dismissed dialog, using all tools") + dialog.clear() + local.sessionTools.callResume() + }} + /> + )) + return false + } else if (hasMcp && pending !== null) { + console.log("[MCP Dialog] Skipping - dialog already shown (pending is not null)") + } else if (!hasMcp) { + console.log("[MCP Dialog] Skipping - no connected MCP servers") + } + const res = await sdk.client.session.create({ workspace: props.workspaceID }) if (res.error) { @@ -694,6 +746,23 @@ export function Prompt(props: PromptProps) { } sessionID = res.data.id + + // Apply tool filter: write deny rules for unselected tools onto the session permission + const filter = local.sessionTools.filter() + if (Array.isArray(filter)) { + const toolsRes = await sdk.client.mcp.tools() + if (toolsRes.data) { + const allKeys = Object.values(toolsRes.data).flatMap((list) => list.map((t) => t.key)) + const deny = allKeys + .filter((k) => !filter.includes(k)) + .map((k) => ({ permission: k, pattern: "*", action: "deny" as const })) + if (deny.length > 0) { + await sdk.client.session.update({ sessionID, permission: deny }) + } + } + } + + local.sessionTools.reset() } const messageID = MessageID.ascending() @@ -733,6 +802,83 @@ export function Prompt(props: PromptProps) { command: inputText, }) setStore("mode", "normal") + } else if (inputText.trim() === "/tools") { + // Special command: open MCP tool selection dialog + console.log("[/tools command] Opening tool selection dialog") + dialog.replace(() => ( + { + if (sessionID) { + // Mid-session: apply updated deny/allow rules directly + const mcp = sdk.client.mcp + void mcp.tools().then(async (res) => { + if (!res.data) return + + // Check which MCP servers have all tools disabled + const serversToDisable: string[] = [] + const serversToEnable: string[] = [] + + for (const [serverName, serverTools] of Object.entries(res.data)) { + const toolKeys = serverTools.map((t) => t.key) + const enabledTools = filter === "all" + ? toolKeys + : toolKeys.filter((k) => (filter as string[]).includes(k)) + + if (enabledTools.length === 0) { + // All tools disabled - disable the MCP server + serversToDisable.push(serverName) + } else if (enabledTools.length === toolKeys.length) { + // All tools enabled - ensure MCP server is enabled + serversToEnable.push(serverName) + } + } + + const all = Object.values(res.data).flatMap((list) => list.map((t) => t.key)) + const rules = + filter === "all" + ? all.map((k) => ({ permission: k, pattern: "*", action: "allow" as const })) + : [ + ...all + .filter((k) => !(filter as string[]).includes(k)) + .map((k) => ({ permission: k, pattern: "*", action: "deny" as const })), + ...(filter as string[]).map((k) => ({ + permission: k, + pattern: "*", + action: "allow" as const, + })), + ] + if (rules.length > 0) { + await sdk.client.session.update({ sessionID, permission: rules }) + } + + // Disable/enable MCP servers as needed + for (const serverName of serversToDisable) { + console.log(`[/tools command] Disabling MCP server: ${serverName}`) + await local.mcp.toggle(serverName) + } + for (const serverName of serversToEnable) { + const status = Object.entries(sync.data.mcp).find(([name]) => name === serverName)?.[1] + if (status?.status === "disabled") { + console.log(`[/tools command] Enabling MCP server: ${serverName}`) + await local.mcp.toggle(serverName) + } + } + }) + } else { + // Home screen (pre-session): store for use when first message is sent + local.sessionTools.set(filter) + } + dialog.clear() + }} + onDismiss={() => dialog.clear()} + /> + )) + // Clear the input + input.clear() + setStore("prompt", { input: "", parts: [] }) + setStore("extmarkToPartIndex", new Map()) + return true } else if ( inputText.startsWith("/") && iife(() => { diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index 910483764188..526a35d16a66 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -1,6 +1,6 @@ import { createStore } from "solid-js/store" import { createSimpleContext } from "./helper" -import { batch, createEffect, createMemo } from "solid-js" +import { batch, createEffect, createMemo, createSignal } from "solid-js" import { useSync } from "@tui/context/sync" import { useTheme } from "@tui/context/theme" import { uniqueBy } from "remeda" @@ -397,6 +397,35 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }, } + const [toolFilter, setToolFilter] = createSignal("all") + const [pendingPrompt, setPendingPrompt] = createSignal(null) + const [showSessionToolsDialog, setShowSessionToolsDialog] = createSignal(false) + let resumeFn: (() => void) | null = null + + const sessionTools = { + filter: toolFilter, + pending: pendingPrompt, + set(filter: string[] | "all") { + setToolFilter(filter) + }, + setPending(text: string | null) { + setPendingPrompt(text) + }, + setResume(fn: () => void) { + resumeFn = fn + }, + callResume() { + const fn = resumeFn + resumeFn = null + fn?.() + }, + reset() { + setToolFilter("all") + setPendingPrompt(null) + resumeFn = null + }, + } + // Automatically update model when agent changes createEffect(() => { const value = agent.current() @@ -420,6 +449,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ model, agent, mcp, + sessionTools, + showSessionToolsDialog, + setShowSessionToolsDialog, } return result }, diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx index 8ace2fff3725..dbc87d3f039d 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx @@ -1,25 +1,60 @@ -import { createMemo, Match, onCleanup, onMount, Show, Switch } from "solid-js" +import { createMemo, createResource, Match, onCleanup, onMount, Show, Switch } from "solid-js" import { useTheme } from "../../context/theme" import { useSync } from "../../context/sync" import { useDirectory } from "../../context/directory" import { useConnected } from "../../component/dialog-model" import { createStore } from "solid-js/store" import { useRoute } from "../../context/route" +import { useSDK } from "../../context/sdk" export function Footer() { const { theme } = useTheme() const sync = useSync() const route = useRoute() + const sdk = useSDK() 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 lsp = createMemo(() => Object.keys(sync.data.lsp)) const permissions = createMemo(() => { if (route.data.type !== "session") return [] - return sync.data.permission[route.data.sessionID] ?? [] + const session = sync.session.get(route.data.sessionID) + return session?.permission ?? [] }) const directory = useDirectory() const connected = useConnected() + // Fetch MCP tools to check which are disabled + const [mcpTools] = createResource( + () => (route.data.type === "session" && mcp() > 0 ? route.data.sessionID : null), + async () => { + const res = await sdk.client.mcp.tools() + return res.data ?? {} + }, + ) + + // Check if any MCP has some (but not all) tools disabled + const mcpPartiallyDisabled = createMemo(() => { + const tools = mcpTools() + if (!tools || route.data.type !== "session") return false + + const perms = permissions() + const mcpServers = Object.entries(sync.data.mcp).filter(([_, s]) => s.status === "connected") + + for (const [serverName, serverTools] of Object.entries(tools)) { + const toolKeys = serverTools.map((t) => t.key) + const disabledCount = toolKeys.filter((key) => { + const rule = [...perms].reverse().find((r) => r.permission === key || r.permission === "*") + return rule && rule.action === "deny" && rule.pattern === "*" + }).length + + // If some tools are disabled but not all, it's partially disabled + if (disabledCount > 0 && disabledCount < toolKeys.length) { + return true + } + } + return false + }) + const [store, setStore] = createStore({ welcome: false, }) @@ -75,6 +110,9 @@ export function Footer() { + + + diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 09fcfc756a16..fef51c572c69 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -222,6 +222,7 @@ export interface Interface { readonly status: () => Effect.Effect> readonly clients: () => Effect.Effect> readonly tools: () => Effect.Effect> + readonly defs: () => Effect.Effect> readonly prompts: () => Effect.Effect> readonly resources: () => Effect.Effect> readonly add: (name: string, mcp: ConfigMCP.Info) => Effect.Effect<{ status: Record | Status }> @@ -665,6 +666,12 @@ export const layer = Layer.effect( return result }) + const defsList = Effect.fn("MCP.defs")(function* () { + const s: State = yield* InstanceState.get(state) + const result: Record = { ...s.defs } + return result + }) + function collectFromConnected( s: State, listFn: (c: Client) => Promise, @@ -899,6 +906,7 @@ export const layer = Layer.effect( status, clients, tools, + defs: defsList, prompts, resources, add, diff --git a/packages/opencode/src/server/routes/instance/mcp.ts b/packages/opencode/src/server/routes/instance/mcp.ts index ce4722933b14..5f2389bf8c7e 100644 --- a/packages/opencode/src/server/routes/instance/mcp.ts +++ b/packages/opencode/src/server/routes/instance/mcp.ts @@ -256,5 +256,53 @@ export const McpRoutes = lazy(() => yield* mcp.disconnect(name) return true }), + ) + .get( + "/tools", + describeRoute({ + summary: "List MCP tools", + description: "List all individual tools from connected MCP servers, grouped by server, with token estimates.", + operationId: "mcp.tools", + responses: { + 200: { + description: "MCP tools grouped by server", + content: { + "application/json": { + schema: resolver( + z.record( + z.string(), + z.array( + z.object({ + name: z.string(), + key: z.string(), + description: z.string(), + tokenEstimate: z.number(), + }), + ), + ), + ), + }, + }, + }, + }, + }), + async (c) => + jsonRequest("McpRoutes.tools", c, function* () { + const mcp = yield* MCP.Service + const raw = yield* mcp.defs() + const sanitize = (s: string) => s.replace(/[^a-zA-Z0-9_-]/g, "_") + const result: Record = {} + for (const [server, tools] of Object.entries(raw)) { + result[server] = tools.map((t) => ({ + name: t.name, + key: sanitize(server) + "_" + sanitize(t.name), + description: t.description ?? "", + tokenEstimate: Math.ceil( + JSON.stringify({ name: t.name, description: t.description, inputSchema: t.inputSchema }).length / 3.5, + ), + })) + } + return result + }), ), ) diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 2f5904684023..9333bb2c562a 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -110,6 +110,7 @@ const mcp = Layer.succeed( status: () => Effect.succeed({}), clients: () => Effect.succeed({}), tools: () => Effect.succeed({}), + defs: () => Effect.succeed({}), prompts: () => Effect.succeed({}), resources: () => Effect.succeed({}), add: () => Effect.succeed({ status: { status: "disabled" as const } }), diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 651754733909..3f0afb3b8029 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -64,6 +64,7 @@ const mcp = Layer.succeed( status: () => Effect.succeed({}), clients: () => Effect.succeed({}), tools: () => Effect.succeed({}), + defs: () => Effect.succeed({}), prompts: () => Effect.succeed({}), resources: () => Effect.succeed({}), add: () => Effect.succeed({ status: { status: "disabled" as const } }), diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 6248eb8e4d64..a925d7e443b9 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -72,6 +72,7 @@ import type { McpLocalConfig, McpRemoteConfig, McpStatusResponses, + McpToolsResponses, OutputFormat, Part as Part2, PartDeleteErrors, @@ -3645,6 +3646,36 @@ export class Mcp extends HeyApiClient { }) } + /** + * List MCP tools + * + * List all individual tools from connected MCP servers, grouped by server, with token estimates. + */ + public tools( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/mcp/tools", + ...options, + ...params, + }) + } + private _auth?: Auth2 get auth(): Auth2 { return (this._auth ??= new Auth2({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index d14fab191949..90203f4fa52e 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -5008,6 +5008,32 @@ export type McpDisconnectResponses = { export type McpDisconnectResponse = McpDisconnectResponses[keyof McpDisconnectResponses] +export type McpToolsData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/mcp/tools" +} + +export type McpToolsResponses = { + /** + * MCP tools grouped by server + */ + 200: { + [key: string]: Array<{ + name: string + key: string + description: string + tokenEstimate: number + }> + } +} + +export type McpToolsResponse = McpToolsResponses[keyof McpToolsResponses] + export type TuiAppendPromptData = { body?: { text: string