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..546bff485cb 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx @@ -8,11 +8,14 @@ import { Keybind } from "@/util/keybind" import { TextAttributes } from "@opentui/core" import { useSDK } from "@tui/context/sdk" -function Status(props: { enabled: boolean; loading: boolean }) { +function Status(props: { enabled: boolean; loading: boolean; lazy: boolean }) { const { theme } = useTheme() if (props.loading) { return ⋯ Loading } + if (props.lazy) { + return ⦿ Lazy + } if (props.enabled) { return ✓ Enabled } @@ -26,10 +29,13 @@ export function DialogMcp() { const [, setRef] = createSignal>() const [loading, setLoading] = createSignal(null) + const mcpLazy = createMemo(() => sync.data.config.experimental?.mcp_lazy === true) + const options = createMemo(() => { // Track sync data and loading state to trigger re-render when they change const mcpData = sync.data.mcp const loadingMcp = loading() + const lazy = mcpLazy() return pipe( mcpData ?? {}, @@ -39,7 +45,13 @@ export function DialogMcp() { value: name, title: name, description: status.status === "failed" ? "failed" : status.status, - footer: , + footer: ( + + ), category: undefined, })), ) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 322ce273ab8..d6ab6d8e510 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1064,6 +1064,12 @@ export namespace Config { .positive() .optional() .describe("Timeout in milliseconds for model context protocol (MCP) requests"), + mcp_lazy: z + .boolean() + .optional() + .describe( + "Enable lazy loading of MCP tools. When enabled, MCP tools are not loaded into context automatically. Instead, use the mcp_search tool to discover and call MCP tools on-demand.", + ), }) .optional(), }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 8327698fd5f..2fdbcdfea1d 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -33,6 +33,7 @@ import { spawn } from "child_process" import { Command } from "../command" import { $, fileURLToPath } from "bun" import { ConfigMarkdown } from "../config/markdown" +import { Config } from "../config/config" import { SessionSummary } from "./summary" import { NamedError } from "@opencode-ai/util/error" import { fn } from "@/util/fn" @@ -595,7 +596,11 @@ export namespace SessionPrompt { agent, abort, sessionID, - system: [...(await SystemPrompt.environment()), ...(await SystemPrompt.custom())], + system: [ + ...(await SystemPrompt.environment()), + ...(await SystemPrompt.mcpServers()), + ...(await SystemPrompt.custom()), + ], messages: [ ...MessageV2.toModelMessage(sessionMessages), ...(isLastStep @@ -725,94 +730,99 @@ export namespace SessionPrompt { }) } - for (const [key, item] of Object.entries(await MCP.tools())) { - const execute = item.execute - if (!execute) continue + const cfg = await Config.get() + const mcpLazy = cfg.experimental?.mcp_lazy === true || (await ToolRegistry.hasMcpSearch()) - // Wrap execute to add plugin hooks and format output - item.execute = async (args, opts) => { - const ctx = context(args, opts) + if (!mcpLazy) { + for (const [key, item] of Object.entries(await MCP.tools())) { + const execute = item.execute + if (!execute) continue - await Plugin.trigger( - "tool.execute.before", - { - tool: key, - sessionID: ctx.sessionID, - callID: opts.toolCallId, - }, - { - args, - }, - ) + // Wrap execute to add plugin hooks and format output + item.execute = async (args, opts) => { + const ctx = context(args, opts) - await ctx.ask({ - permission: key, - metadata: {}, - patterns: ["*"], - always: ["*"], - }) + await Plugin.trigger( + "tool.execute.before", + { + tool: key, + sessionID: ctx.sessionID, + callID: opts.toolCallId, + }, + { + args, + }, + ) - const result = await execute(args, opts) + await ctx.ask({ + permission: key, + metadata: {}, + patterns: ["*"], + always: ["*"], + }) - await Plugin.trigger( - "tool.execute.after", - { - tool: key, - sessionID: ctx.sessionID, - callID: opts.toolCallId, - }, - result, - ) + const result = await execute(args, opts) - const textParts: string[] = [] - const attachments: MessageV2.FilePart[] = [] + await Plugin.trigger( + "tool.execute.after", + { + tool: key, + sessionID: ctx.sessionID, + callID: opts.toolCallId, + }, + result, + ) - for (const contentItem of result.content) { - if (contentItem.type === "text") { - textParts.push(contentItem.text) - } else if (contentItem.type === "image") { - attachments.push({ - id: Identifier.ascending("part"), - sessionID: input.session.id, - messageID: input.processor.message.id, - type: "file", - mime: contentItem.mimeType, - url: `data:${contentItem.mimeType};base64,${contentItem.data}`, - }) - } else if (contentItem.type === "resource") { - const { resource } = contentItem - if (resource.text) { - textParts.push(resource.text) - } - if (resource.blob) { + const textParts: string[] = [] + const attachments: MessageV2.FilePart[] = [] + + for (const contentItem of result.content) { + if (contentItem.type === "text") { + textParts.push(contentItem.text) + } else if (contentItem.type === "image") { attachments.push({ id: Identifier.ascending("part"), sessionID: input.session.id, messageID: input.processor.message.id, type: "file", - mime: resource.mimeType ?? "application/octet-stream", - url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`, - filename: resource.uri, + mime: contentItem.mimeType, + url: `data:${contentItem.mimeType};base64,${contentItem.data}`, }) + } else if (contentItem.type === "resource") { + const { resource } = contentItem + if (resource.text) { + textParts.push(resource.text) + } + if (resource.blob) { + attachments.push({ + id: Identifier.ascending("part"), + sessionID: input.session.id, + messageID: input.processor.message.id, + type: "file", + mime: resource.mimeType ?? "application/octet-stream", + url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`, + filename: resource.uri, + }) + } } } - } - return { - title: "", - metadata: result.metadata ?? {}, - output: textParts.join("\n\n"), - attachments, - content: result.content, // directly return content to preserve ordering when outputting to model + return { + title: "", + metadata: result.metadata ?? {}, + output: textParts.join("\n\n"), + attachments, + content: result.content, // directly return content to preserve ordering when outputting to model + } } - } - item.toModelOutput = (result) => { - return { - type: "text", - value: result.output, + item.toModelOutput = (result) => { + return { + type: "text", + value: result.output, + } } + tools[key] = item } - tools[key] = item } return tools diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index fff90808864..ffd38cbbf9f 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -2,6 +2,7 @@ import { Ripgrep } from "../file/ripgrep" import { Global } from "../global" import { Filesystem } from "../util/filesystem" import { Config } from "../config/config" +import { MCP } from "../mcp" import { Instance } from "../project/instance" import path from "path" @@ -76,6 +77,27 @@ export namespace SystemPrompt { GLOBAL_RULE_FILES.push(path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md")) } + export async function mcpServers() { + const config = await Config.get() + if (config.experimental?.mcp_lazy !== true) return [] + + const status = await MCP.status() + const servers = Object.entries(status) + .filter(([_, s]) => s.status === "connected") + .map(([name]) => name) + + if (servers.length === 0) return [] + + return [ + [ + ``, + `Available MCP servers: ${servers.join(", ")}`, + `Use mcp_search tool to discover and call tools from these servers.`, + ``, + ].join("\n"), + ] + } + export async function custom() { const config = await Config.get() const paths = new Set() diff --git a/packages/opencode/src/tool/mcp-search.ts b/packages/opencode/src/tool/mcp-search.ts new file mode 100644 index 00000000000..fa85d768740 --- /dev/null +++ b/packages/opencode/src/tool/mcp-search.ts @@ -0,0 +1,188 @@ +import z from "zod" +import { Tool } from "./tool" +import { MCP } from "../mcp" +import { Plugin } from "../plugin" +import DESCRIPTION from "./mcp-search.txt" + +function sanitize(name: string) { + return name.replace(/[^a-zA-Z0-9_-]/g, "_") +} + +function extractSchema(input: unknown): Record | undefined { + if (!input || typeof input !== "object") return undefined + if ("jsonSchema" in input) return (input as { jsonSchema: Record }).jsonSchema + return input as Record +} + +function formatSchema(schema: Record, indent = 0): string { + const properties = schema.properties as Record> | undefined + const required = new Set((schema.required as string[]) ?? []) + if (!properties || Object.keys(properties).length === 0) return " ".repeat(indent) + "No parameters required" + + const pad = " ".repeat(indent) + return Object.entries(properties) + .flatMap(([name, prop]) => { + const lines = [`${pad}- **${name}**${required.has(name) ? " (required)" : " (optional)"}: ${prop.type ?? "any"}`] + if (prop.description) lines.push(`${pad} ${prop.description}`) + if (prop.type === "object" && prop.properties) lines.push(formatSchema(prop, indent + 1)) + if (prop.enum) lines.push(`${pad} Allowed values: ${(prop.enum as string[]).join(", ")}`) + return lines + }) + .join("\n") +} + +const parameters = z.object({ + operation: z.enum(["list", "search", "describe", "call"]).describe("Operation to perform"), + query: z.string().optional().describe("Search query (for 'search')"), + server: z.string().optional().describe("MCP server name (for 'describe'/'call')"), + tool: z.string().optional().describe("Tool name (for 'describe'/'call')"), + args: z.record(z.string(), z.any()).optional().describe("Tool arguments (for 'call')"), +}) + +async function getConnectedServers() { + const [status, allTools] = await Promise.all([MCP.status(), MCP.tools()]) + const toolEntries = Object.entries(allTools) + + return Object.entries(status) + .filter(([, s]) => s.status === "connected") + .map(([name]) => { + const prefix = sanitize(name) + "_" + const tools = toolEntries + .filter(([key]) => key.startsWith(prefix)) + .map(([key, tool]) => ({ name: key.slice(prefix.length), description: tool.description })) + return { name, tools } + }) +} + +async function resolveTool(server: string, tool: string) { + const [status, allTools] = await Promise.all([MCP.status(), MCP.tools()]) + + if (status[server]?.status !== "connected") throw new Error(`MCP server "${server}" is not connected`) + + const prefix = sanitize(server) + const key = `${prefix}_${sanitize(tool)}` + const mcpTool = allTools[key] + + if (mcpTool) return { key, mcpTool } + + const available = Object.keys(allTools) + .filter((k) => k.startsWith(prefix + "_")) + .map((k) => k.slice(prefix.length + 1)) + throw new Error(`Tool "${tool}" not found on "${server}". Available: ${available.join(", ") || "none"}`) +} + +async function list() { + const servers = await getConnectedServers() + if (servers.length === 0) return { title: "No MCP servers", output: "No connected MCP servers.", metadata: {} } + + const output = servers + .map((s) => `## ${s.name}\n${s.tools.map((t) => `- ${t.name}: ${t.description ?? "No description"}`).join("\n")}`) + .join("\n\n") + + return { title: `${servers.length} MCP servers`, output, metadata: { servers: servers.length } } +} + +async function search(query?: string) { + const servers = await getConnectedServers() + const q = query?.toLowerCase() ?? "" + + const matches = servers.flatMap((s) => { + if (q && !s.name.toLowerCase().includes(q)) return [] + return s.tools.map((t) => ({ server: s.name, ...t })) + }) + + if (matches.length === 0) { + return { + title: "No matches", + output: query ? `No tools matching "${query}"` : "No MCP tools available", + metadata: {}, + } + } + + const output = matches.map((m) => `- ${m.server}/${m.name}: ${m.description ?? "No description"}`).join("\n") + return { + title: `${matches.length} tools found`, + output: `Found ${matches.length} tool(s)${query ? ` matching "${query}"` : ""}:\n\n${output}\n\nYou MUST use describe before calling any of these tools.`, + metadata: { count: matches.length }, + } +} + +async function describe(server: string, tool: string) { + const { mcpTool } = await resolveTool(server, tool) + const schema = extractSchema(mcpTool.inputSchema) + + return { + title: `${server}/${tool}`, + output: [ + `## ${server}/${tool}`, + "", + `**Description:** ${mcpTool.description ?? "No description"}`, + "", + "**Parameters:**", + schema ? formatSchema(schema) : "No parameters required", + "", + "**Example:**", + "```", + `mcp_search(operation: "call", server: "${server}", tool: "${tool}", args: { ... })`, + "```", + ].join("\n"), + metadata: { server, tool }, + } +} + +async function call(server: string, tool: string, args: Record, ctx: Tool.Context) { + const { key, mcpTool } = await resolveTool(server, tool) + const schema = extractSchema(mcpTool.inputSchema) + const required = (schema?.required as string[]) ?? [] + const missing = required.filter((r) => !(r in args)) + + if (missing.length > 0) { + return { + title: "Arguments required", + output: [ + `Tool "${tool}" requires arguments.`, + "", + `**Missing:** ${missing.join(", ")}`, + "", + `**Tool:** ${server}/${tool}`, + `**Description:** ${mcpTool.description ?? "No description"}`, + "", + "**Parameters:**", + schema ? formatSchema(schema) : "No schema available", + "", + "**Example:**", + `mcp_search(operation: "call", server: "${server}", tool: "${tool}", args: { ${required.map((r) => `"${r}": ...`).join(", ")} })`, + ].join("\n"), + metadata: { server, tool, missing }, + } + } + + await ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] }) + await Plugin.trigger("tool.execute.before", { tool: key, sessionID: ctx.sessionID, callID: ctx.callID }, { args }) + + const result = await mcpTool.execute!(args, { toolCallId: ctx.callID ?? "", abortSignal: ctx.abort, messages: [] }) + + await Plugin.trigger("tool.execute.after", { tool: key, sessionID: ctx.sessionID, callID: ctx.callID }, result) + + const parts: string[] = [] + for (const c of result.content) { + if (c.type === "text") parts.push(c.text) + else if (c.type === "image") parts.push(`[Image: ${c.mimeType}, ${c.data.length} bytes]`) + else if (c.type === "resource") parts.push(c.resource.text ?? `[Resource: ${c.resource.uri}]`) + } + const output = parts.join("\n\n") + + return { title: `${server}/${tool}`, output: output || "Success (no output)", metadata: { server, tool } } +} + +export const McpSearchTool = Tool.define>("mcp_search", { + description: DESCRIPTION, + parameters, + async execute(params, ctx) { + if (params.operation === "list") return list() + if (params.operation === "search") return search(params.query) + if (!params.server || !params.tool) throw new Error("Both 'server' and 'tool' parameters are required") + if (params.operation === "describe") return describe(params.server, params.tool) + return call(params.server, params.tool, params.args ?? {}, ctx) + }, +}) diff --git a/packages/opencode/src/tool/mcp-search.txt b/packages/opencode/src/tool/mcp-search.txt new file mode 100644 index 00000000000..35137350a7c --- /dev/null +++ b/packages/opencode/src/tool/mcp-search.txt @@ -0,0 +1,13 @@ +Search and call MCP server tools. + +Operations: +- "list": List all MCP servers and their tools +- "search": Find tools by server name +- "describe": Get tool's parameter schema +- "call": Execute a tool + +Usage notes: + - The "search" query MUST be a server name (e.g., "playwright", "context7"). It only matches against server names and returns all tools from matching servers. + - You MUST call "describe" before "call" to check the tool's parameters. + - You SHOULD NOT call a tool without first checking its parameters. + - Examples: search "playwright" to find all browser automation tools, search "context7" to find documentation lookup tools. diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 35e378f080b..fd8554533ca 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -26,6 +26,7 @@ import { Log } from "@/util/log" import { LspTool } from "./lsp" import { Truncate } from "./truncation" import { PlanExitTool, PlanEnterTool } from "./plan" +import { McpSearchTool } from "./mcp-search" export namespace ToolRegistry { const log = Log.create({ service: "tool.registry" }) @@ -111,10 +112,16 @@ export namespace ToolRegistry { ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []), ...(config.experimental?.batch_tool === true ? [BatchTool] : []), ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool, PlanEnterTool] : []), + ...(config.experimental?.mcp_lazy === true ? [McpSearchTool] : []), ...custom, ] } + export async function hasMcpSearch(): Promise { + const tools = await all() + return tools.some((t) => t.id === "mcp_search") + } + export async function ids() { return all().then((x) => x.map((t) => t.id)) } diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 7fad08e71cf..354dd68c6e5 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1780,6 +1780,10 @@ export type Config = { * Timeout in milliseconds for model context protocol (MCP) requests */ mcp_timeout?: number + /** + * Enable lazy loading of MCP tools. When enabled, MCP tools are not loaded into context automatically. Instead, use the mcp_search tool to discover and call MCP tools on-demand. + */ + mcp_lazy?: boolean } } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index fd46f7c7860..de4658360a3 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -9602,6 +9602,10 @@ "type": "integer", "exclusiveMinimum": 0, "maximum": 9007199254740991 + }, + "mcp_lazy": { + "description": "Enable lazy loading of MCP tools. When enabled, MCP tools are not loaded into context automatically. Instead, use the mcp_search tool to discover and call MCP tools on-demand.", + "type": "boolean" } } }