diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 0169c68e617..0bc6e15740c 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -40,6 +40,7 @@ import type { EditTool } from "@/tool/edit" import type { PatchTool } from "@/tool/patch" import type { WebFetchTool } from "@/tool/webfetch" import type { TaskTool } from "@/tool/task" +import type { MCPRegistryTool } from "@/tool/mcp-registry" import { useKeyboard, useRenderer, useTerminalDimensions, type BoxProps, type JSX } from "@opentui/solid" import { useSDK } from "@tui/context/sdk" import { useCommandDialog } from "@tui/component/dialog-command" @@ -1224,7 +1225,7 @@ type ToolProps = { } function GenericTool(props: ToolProps) { return ( - + {props.tool} {input(props.input)} ) @@ -1621,6 +1622,19 @@ ToolRegistry.register({ }, }) +ToolRegistry.register({ + name: "mcp_registry", + container: "inline", + render(props) { + const activate = props.input.activate + return ( + + MCP Registry activate [{activate?.join(", ")}] + + ) + }, +}) + function normalizePath(input?: string) { if (!input) return "" if (path.isAbsolute(input)) { diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 837899be575..4841ee234db 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -642,6 +642,10 @@ export namespace Config { chatMaxRetries: z.number().optional().describe("Number of retries for chat completions on failure"), disable_paste_summary: z.boolean().optional(), batch_tool: z.boolean().optional().describe("Enable the batch tool"), + mcp_registry: z + .boolean() + .optional() + .describe("Hide MCP tools behind a registry tool that the model can query"), }) .optional(), }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index b3c3c467168..882ad68cc89 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -33,6 +33,7 @@ import { mergeDeep, pipe } from "remeda" import { ToolRegistry } from "../tool/registry" import { Wildcard } from "../util/wildcard" import { MCP } from "../mcp" +import { getActivatedTools, decrementActivatedTools } from "../tool/mcp-registry" import { LSP } from "../lsp" import { ReadTool } from "../tool/read" import { ListTool } from "../tool/ls" @@ -41,6 +42,7 @@ import { ulid } from "ulid" import { spawn } from "child_process" import { Command } from "../command" import { $, fileURLToPath } from "bun" +import { Config } from "../config/config" import { ConfigMarkdown } from "../config/markdown" import { SessionSummary } from "./summary" import { NamedError } from "@/util/error" @@ -196,6 +198,7 @@ export namespace SessionPrompt { const message = await createUserMessage(input) await Session.touch(input.sessionID) + decrementActivatedTools(input.sessionID) if (input.noReply === true) { return message @@ -468,7 +471,7 @@ export namespace SessionPrompt { agent, system: lastUser.system, }) - const tools = await resolveTools({ + const { tools, hiddenTools } = await resolveTools({ agent, sessionID, model: lastUser.model, @@ -544,7 +547,7 @@ export namespace SessionPrompt { }, // set to 0, we handle loop maxRetries: 0, - activeTools: Object.keys(tools).filter((x) => x !== "invalid"), + activeTools: Object.keys(tools).filter((x) => x !== "invalid" && !hiddenTools.has(x)), maxOutputTokens: ProviderTransform.maxOutputTokens( model.providerID, params.options, @@ -651,6 +654,7 @@ export namespace SessionPrompt { processor: SessionProcessor.Info }) { const tools: Record = {} + const hiddenTools = new Set() const enabledTools = pipe( input.agent.tools, mergeDeep(await ToolRegistry.enabled(input.model.providerID, input.model.modelID, input.agent)), @@ -724,6 +728,9 @@ export namespace SessionPrompt { }) } + const config = await Config.get() + const mcpRegistryEnabled = config.experimental?.mcp_registry === true + const activatedTools = mcpRegistryEnabled ? getActivatedTools(input.sessionID) : undefined for (const [key, item] of Object.entries(await MCP.tools())) { if (Wildcard.all(key, enabledTools) === false) continue const execute = item.execute @@ -786,8 +793,11 @@ export namespace SessionPrompt { } } tools[key] = item + if (activatedTools && !activatedTools.has(key)) { + hiddenTools.add(key) + } } - return tools + return { tools, hiddenTools } } async function createUserMessage(input: PromptInput) { diff --git a/packages/opencode/src/tool/mcp-registry.ts b/packages/opencode/src/tool/mcp-registry.ts new file mode 100644 index 00000000000..602f3b4880d --- /dev/null +++ b/packages/opencode/src/tool/mcp-registry.ts @@ -0,0 +1,80 @@ +import z from "zod" +import { Tool } from "./tool" +import { MCP } from "../mcp" +import DESCRIPTION from "./mcp-registry.txt" + +// Track which MCP tools are activated per session (after being described) +// Key: sessionID, Value: Map of toolName -> remaining turns +const activatedTools = new Map>() +const ACTIVATION_TURNS = 3 + +export function getActivatedTools(sessionID: string): Set { + const session = activatedTools.get(sessionID) + if (!session) return new Set() + return new Set(session.keys()) +} + +export function decrementActivatedTools(sessionID: string) { + const session = activatedTools.get(sessionID) + if (!session) return + for (const [tool, turns] of session) { + if (turns <= 1) { + session.delete(tool) + } else { + session.set(tool, turns - 1) + } + } + if (session.size === 0) { + activatedTools.delete(sessionID) + } +} + +function activateTool(sessionID: string, toolName: string) { + let session = activatedTools.get(sessionID) + if (!session) { + session = new Map() + activatedTools.set(sessionID, session) + } + session.set(toolName, ACTIVATION_TURNS) +} + +export const MCPRegistryTool = Tool.define("mcp_registry", async () => { + const mcpTools = await MCP.tools() + const names = Object.keys(mcpTools).map((n) => n.replaceAll("-", "_")) + const description = `${DESCRIPTION}\n\nAvailable MCP tools: [${names.join(", ")}]` + + return { + description, + parameters: z.object({ + activate: z.array(z.string()).describe("Array of exact tool names to activate."), + }), + async execute(params, ctx) { + const currentTools = await MCP.tools() + const results: string[] = [] + const activated: string[] = [] + + for (const name of params.activate) { + const tool = currentTools[name] + if (!tool) { + results.push(`Tool "${name}" not found`) + continue + } + activateTool(ctx.sessionID, name) + activated.push(name) + } + + if (activated.length > 0) { + results.push( + `Activated ${activated.length} tool(s): ${activated.join(", ")}`, + "These tools are now temporarily available in your environment.", + ) + } + + return { + title: activated.length ? `Activated ${activated.length} tools` : "No tools activated", + output: results.join("\n"), + metadata: {}, + } + }, + } +}) diff --git a/packages/opencode/src/tool/mcp-registry.txt b/packages/opencode/src/tool/mcp-registry.txt new file mode 100644 index 00000000000..fa3f4fe45c1 --- /dev/null +++ b/packages/opencode/src/tool/mcp-registry.txt @@ -0,0 +1,8 @@ +Activate MCP tools from connected servers. + +To use an MCP tool, you must first activate it using this tool. Once activated, the tool becomes available in this environment for a few turns. + +Usage: +- Review the available MCP tools listed below +- Call this tool with the exact tool name(s) you want to activate +- Once activated, the tools and their descriptions are available in context so you can use them directly \ No newline at end of file diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index a741e12be23..ef2408cbf63 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -10,6 +10,7 @@ import { TodoWriteTool, TodoReadTool } from "./todo" import { WebFetchTool } from "./webfetch" import { WriteTool } from "./write" import { InvalidTool } from "./invalid" +import { MCPRegistryTool } from "./mcp-registry" import type { Agent } from "../agent/agent" import { Tool } from "./tool" import { Instance } from "../project/instance" @@ -98,6 +99,7 @@ export namespace ToolRegistry { TodoWriteTool, TodoReadTool, ...(config.experimental?.batch_tool === true ? [BatchTool] : []), + ...(config.experimental?.mcp_registry === true ? [MCPRegistryTool] : []), ...(Flag.OPENCODE_EXPERIMENTAL_EXA ? [WebSearchTool, CodeSearchTool] : []), ...custom, ] diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 46b0878c760..264df7b5a27 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -24,4 +24,4 @@ "typescript": "catalog:", "@typescript/native-preview": "catalog:" } -} \ No newline at end of file +} diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 104b2033dec..02474c3e6f2 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -26,4 +26,4 @@ "publishConfig": { "directory": "dist" } -} \ No newline at end of file +} diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index e2e611db13a..81d32851707 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -1218,6 +1218,10 @@ export type Config = { * Enable the batch tool */ batch_tool?: boolean + /** + * Hide MCP tools behind a registry tool that the model can query + */ + mcp_registry?: boolean } }