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"
}
}
}