Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1041,6 +1041,10 @@ export namespace Config {
.positive()
.optional()
.describe("Timeout in milliseconds for model context protocol (MCP) requests"),
mcp_lazy_load: z
.boolean()
.optional()
.describe("Enable lazy loading of MCP tools (load on-demand instead of at startup)"),
})
.optional(),
})
Expand Down
88 changes: 87 additions & 1 deletion packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,18 @@ export namespace MCP {
}
}

// Skip listTools validation for lazy loading - we'll fetch tools on-demand
const config = await Config.get()
const isLazy = config.experimental?.mcp_lazy_load ?? false

if (isLazy) {
log.info("create() skipping listTools for lazy server", { key })
return {
mcpClient,
status,
}
}

const result = await withTimeout(mcpClient.listTools(), mcp.timeout ?? DEFAULT_TIMEOUT).catch((err) => {
log.error("failed to get tools from client", { key, error: err })
return undefined
Expand Down Expand Up @@ -539,10 +551,12 @@ export namespace MCP {
s.status[name] = { status: "disabled" }
}

export async function tools() {
export async function tools(sessionLoadedTools?: Record<string, string[]>) {
const result: Record<string, Tool> = {}
const s = await state()
const clientsSnapshot = await clients()
const config = await Config.get()
const isLazy = config.experimental?.mcp_lazy_load ?? false

for (const [clientName, client] of Object.entries(clientsSnapshot)) {
// Only include tools from connected MCPs (skip disabled ones)
Expand All @@ -563,7 +577,16 @@ export namespace MCP {
if (!toolsResult) {
continue
}

// Get the list of loaded tools for this server (if lazy loading)
const loadedToolsForServer = sessionLoadedTools?.[clientName] ?? []

for (const mcpTool of toolsResult.tools) {
// If lazy loading is enabled, only include tools that are explicitly loaded
if (isLazy && !loadedToolsForServer.includes(mcpTool.name)) {
continue
}

const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
const sanitizedToolName = mcpTool.name.replace(/[^a-zA-Z0-9_-]/g, "_")
result[sanitizedClientName + "_" + sanitizedToolName] = await convertMcpTool(mcpTool, client)
Expand All @@ -572,6 +595,69 @@ export namespace MCP {
return result
}

export interface McpServerIndex {
toolCount: number
tools: string[]
}

export async function index(): Promise<Record<string, McpServerIndex>> {
const s = await state()
const clientsSnapshot = await clients()
const result: Record<string, McpServerIndex> = {}

for (const [clientName, client] of Object.entries(clientsSnapshot)) {
if (s.status[clientName]?.status !== "connected") {
continue
}

const toolsResult = await client.listTools().catch((e) => {
log.error("failed to get tools for index", { clientName, error: e.message })
return undefined
})

if (toolsResult) {
result[clientName] = {
toolCount: toolsResult.tools.length,
tools: toolsResult.tools.map((t) => t.name),
}
}
}

return result
}

export async function loadToolsForSession(
serverName: string,
toolNames?: string[],
): Promise<{ tools: string[]; error?: string }> {
const s = await state()
const client = s.clients[serverName]

if (!client) {
return { tools: [], error: `Server "${serverName}" not found` }
}

const serverStatus = s.status[serverName]
if (serverStatus?.status !== "connected") {
return { tools: [], error: `Server "${serverName}" not connected` }
}

const toolsResult = await client.listTools().catch((e) => {
log.error("loadToolsForSession failed", { serverName, error: e.message })
return undefined
})

if (!toolsResult) {
return { tools: [], error: "Failed to list tools" }
}

const tools = toolNames
? toolsResult.tools.filter((t) => toolNames.includes(t.name)).map((t) => t.name)
: toolsResult.tools.map((t) => t.name)

return { tools }
}

export async function prompts() {
const s = await state()
const clientsSnapshot = await clients()
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export namespace Session {
diff: z.string().optional(),
})
.optional(),
mcpLoadedTools: z.record(z.string(), z.array(z.string())).optional(),
})
.meta({
ref: "Session",
Expand Down
8 changes: 6 additions & 2 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -593,7 +593,11 @@ export namespace SessionPrompt {
agent,
abort,
sessionID,
system: [...(await SystemPrompt.environment()), ...(await SystemPrompt.custom())],
system: [
...(await SystemPrompt.environment()),
...(await SystemPrompt.custom()),
...(await SystemPrompt.mcpIndex()),
],
messages: [
...MessageV2.toModelMessage(sessionMessages),
...(isLastStep
Expand Down Expand Up @@ -723,7 +727,7 @@ export namespace SessionPrompt {
})
}

for (const [key, item] of Object.entries(await MCP.tools())) {
for (const [key, item] of Object.entries(await MCP.tools(input.session.mcpLoadedTools))) {
const execute = item.execute
if (!execute) continue

Expand Down
35 changes: 35 additions & 0 deletions packages/opencode/src/session/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -135,4 +136,38 @@ export namespace SystemPrompt {
)
return Promise.all([...foundFiles, ...foundUrls]).then((result) => result.filter(Boolean))
}

export async function mcpIndex(): Promise<string[]> {
const config = await Config.get()
const isLazy = config.experimental?.mcp_lazy_load ?? false

if (!isLazy) {
return []
}

const index = await MCP.index()
const servers = Object.entries(index)

if (servers.length === 0) {
return []
}

const lines = [
"## Available MCP Servers",
"",
"Use mcp_load_tools(server_name) to load tools from these servers:",
"",
]

for (const [name, info] of servers) {
lines.push(`- **${name}** (${info.toolCount} tools): ${info.tools.join(", ")}`)
}

lines.push("")
lines.push(
'To use these tools, first load them with mcp_load_tools("server") or mcp_load_tools("server", ["tool1", "tool2"]).',
)

return [lines.join("\n")]
}
}
67 changes: 67 additions & 0 deletions packages/opencode/src/tool/mcp-load-tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import z from "zod"
import { Tool } from "./tool"
import { MCP } from "../mcp"
import { Session } from "../session"

export const McpLoadToolsTool = Tool.define("mcp_load_tools", {
description: `Loads tools from an MCP server, making them available for use in this session.
Call this before using any tools from an MCP server.
Returns the list of loaded tool names so you know what's available.`,
parameters: z.object({
serverName: z.string().describe("Name of the MCP server to load tools from"),
toolNames: z
.array(z.string())
.optional()
.describe("Specific tools to load. If omitted, loads all tools from the server."),
}),
async execute(args, ctx) {
const { serverName, toolNames } = args

// Load tools from MCP
const { tools, error } = await MCP.loadToolsForSession(serverName, toolNames)

if (error) {
return {
title: `Failed to load tools from ${serverName}`,
metadata: {},
output: `Error: ${error}`,
}
}

if (tools.length === 0) {
const output = toolNames
? `No matching tools found. Requested: ${toolNames.join(", ")}`
: `Server "${serverName}" has no tools available.`
return {
title: `No tools loaded from ${serverName}`,
metadata: {},
output,
}
}

// Update session state with loaded tools
const session = await Session.get(ctx.sessionID)
const currentLoaded = session.mcpLoadedTools ?? {}
const serverLoaded = new Set(currentLoaded[serverName] ?? [])

for (const toolName of tools) {
serverLoaded.add(toolName)
}

await Session.update(ctx.sessionID, (draft) => {
draft.mcpLoadedTools = {
...currentLoaded,
[serverName]: Array.from(serverLoaded),
}
})

// Format tool names with server prefix for clarity
const fullToolNames = tools.map((t) => `${serverName}_${t}`)

return {
title: `Loaded ${tools.length} tools from ${serverName}`,
metadata: {},
output: `Loaded ${tools.length} tools from "${serverName}". You can now use: ${fullToolNames.join(", ")}`,
}
},
})
2 changes: 2 additions & 0 deletions packages/opencode/src/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { WebFetchTool } from "./webfetch"
import { WriteTool } from "./write"
import { InvalidTool } from "./invalid"
import { SkillTool } from "./skill"
import { McpLoadToolsTool } from "./mcp-load-tools"
import type { Agent } from "../agent/agent"
import { Tool } from "./tool"
import { Instance } from "../project/instance"
Expand Down Expand Up @@ -109,6 +110,7 @@ export namespace ToolRegistry {
SkillTool,
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
...(config.experimental?.batch_tool === true ? [BatchTool] : []),
...(config.experimental?.mcp_lazy_load === true ? [McpLoadToolsTool] : []),
...custom,
]
}
Expand Down
Loading