From 3ae75d70310347023f16a7d25156824e630fd9c0 Mon Sep 17 00:00:00 2001 From: Matt Gillard Date: Thu, 30 Oct 2025 17:25:24 +1100 Subject: [PATCH] add optional timeout field to mcp config to allow users to use responding servers (#3558) Co-authored-by: Matt Gillard Co-authored-by: opencode-agent[bot] Co-authored-by: rekram1-node --- packages/opencode/src/config/config.ts | 234 +++++++++++++++--- packages/opencode/src/mcp/index.ts | 2 +- packages/web/src/content/docs/mcp-servers.mdx | 44 ++-- 3 files changed, 220 insertions(+), 60 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 83c518a6..12a5c162 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -12,7 +12,11 @@ import { NamedError } from "../util/error" import matter from "gray-matter" import { Flag } from "../flag/flag" import { Auth } from "../auth" -import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser" +import { + type ParseError as JsoncParseError, + parse as parseJsonc, + printParseErrorCode, +} from "jsonc-parser" import { Instance } from "../project/instance" import { LSPServer } from "../lsp/server" import { BunProc } from "@/bun" @@ -46,7 +50,10 @@ export namespace Config { if (value.type === "wellknown") { process.env[value.key] = value.token const wellknown = await fetch(`${key}/.well-known/opencode`).then((x) => x.json()) - result = mergeDeep(result, await load(JSON.stringify(wellknown.config ?? {}), process.cwd())) + result = mergeDeep( + result, + await load(JSON.stringify(wellknown.config ?? {}), process.cwd()), + ) } } @@ -57,7 +64,11 @@ export namespace Config { const directories = [ Global.Path.config, ...(await Array.fromAsync( - Filesystem.up({ targets: [".opencode"], start: Instance.directory, stop: Instance.worktree }), + Filesystem.up({ + targets: [".opencode"], + start: Instance.directory, + stop: Instance.worktree, + }), )), ] @@ -153,10 +164,18 @@ export namespace Config { const gitignore = path.join(dir, ".gitignore") const hasGitIgnore = await Bun.file(gitignore).exists() - if (!hasGitIgnore) await Bun.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n")) + if (!hasGitIgnore) + await Bun.write( + gitignore, + ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"), + ) await BunProc.run( - ["add", "@opencode-ai/plugin@" + (Installation.isLocal() ? "latest" : Installation.VERSION), "--exact"], + [ + "add", + "@opencode-ai/plugin@" + (Installation.isLocal() ? "latest" : Installation.VERSION), + "--exact", + ], { cwd: dir, }, @@ -166,7 +185,12 @@ export namespace Config { const COMMAND_GLOB = new Bun.Glob("command/**/*.md") async function loadCommand(dir: string) { const result: Record = {} - for await (const item of COMMAND_GLOB.scan({ absolute: true, followSymlinks: true, dot: true, cwd: dir })) { + for await (const item of COMMAND_GLOB.scan({ + absolute: true, + followSymlinks: true, + dot: true, + cwd: dir, + })) { const content = await Bun.file(item).text() const md = matter(content) if (!md.data) continue @@ -201,7 +225,12 @@ export namespace Config { async function loadAgent(dir: string) { const result: Record = {} - for await (const item of AGENT_GLOB.scan({ absolute: true, followSymlinks: true, dot: true, cwd: dir })) { + for await (const item of AGENT_GLOB.scan({ + absolute: true, + followSymlinks: true, + dot: true, + cwd: dir, + })) { const content = await Bun.file(item).text() const md = matter(content) if (!md.data) continue @@ -239,7 +268,12 @@ export namespace Config { const MODE_GLOB = new Bun.Glob("mode/*.md") async function loadMode(dir: string) { const result: Record = {} - for await (const item of MODE_GLOB.scan({ absolute: true, followSymlinks: true, dot: true, cwd: dir })) { + for await (const item of MODE_GLOB.scan({ + absolute: true, + followSymlinks: true, + dot: true, + cwd: dir, + })) { const content = await Bun.file(item).text() const md = matter(content) if (!md.data) continue @@ -265,7 +299,12 @@ export namespace Config { async function loadPlugin(dir: string) { const plugins: string[] = [] - for await (const item of PLUGIN_GLOB.scan({ absolute: true, followSymlinks: true, dot: true, cwd: dir })) { + for await (const item of PLUGIN_GLOB.scan({ + absolute: true, + followSymlinks: true, + dot: true, + cwd: dir, + })) { plugins.push("file://" + item) } return plugins @@ -280,6 +319,14 @@ export namespace Config { .optional() .describe("Environment variables to set when running the MCP server"), enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"), + timeout: z + .number() + .int() + .positive() + .optional() + .describe( + "Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds) if not specified.", + ), }) .strict() .meta({ @@ -291,7 +338,18 @@ export namespace Config { type: z.literal("remote").describe("Type of MCP server connection"), url: z.string().describe("URL of the remote MCP server"), enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"), - headers: z.record(z.string(), z.string()).optional().describe("Headers to send with the request"), + headers: z + .record(z.string(), z.string()) + .optional() + .describe("Headers to send with the request"), + timeout: z + .number() + .int() + .positive() + .optional() + .describe( + "Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds) if not specified.", + ), }) .strict() .meta({ @@ -339,72 +397,148 @@ export namespace Config { export const Keybinds = z .object({ - leader: z.string().optional().default("ctrl+x").describe("Leader key for keybind combinations"), + leader: z + .string() + .optional() + .default("ctrl+x") + .describe("Leader key for keybind combinations"), app_help: z.string().optional().default("h").describe("Show help dialog"), app_exit: z.string().optional().default("ctrl+c,q").describe("Exit the application"), editor_open: z.string().optional().default("e").describe("Open external editor"), theme_list: z.string().optional().default("t").describe("List available themes"), project_init: z.string().optional().default("i").describe("Create/update AGENTS.md"), tool_details: z.string().optional().default("d").describe("Toggle tool details"), - thinking_blocks: z.string().optional().default("b").describe("Toggle thinking blocks"), - session_export: z.string().optional().default("x").describe("Export session to editor"), + thinking_blocks: z + .string() + .optional() + .default("b") + .describe("Toggle thinking blocks"), + session_export: z + .string() + .optional() + .default("x") + .describe("Export session to editor"), session_new: z.string().optional().default("n").describe("Create a new session"), session_list: z.string().optional().default("l").describe("List all sessions"), - session_timeline: z.string().optional().default("g").describe("Show session timeline"), + session_timeline: z + .string() + .optional() + .default("g") + .describe("Show session timeline"), session_share: z.string().optional().default("s").describe("Share current session"), session_unshare: z.string().optional().default("none").describe("Unshare current session"), session_interrupt: z.string().optional().default("esc").describe("Interrupt current session"), session_compact: z.string().optional().default("c").describe("Compact the session"), - session_child_cycle: z.string().optional().default("ctrl+right").describe("Cycle to next child session"), + session_child_cycle: z + .string() + .optional() + .default("ctrl+right") + .describe("Cycle to next child session"), session_child_cycle_reverse: z .string() .optional() .default("ctrl+left") .describe("Cycle to previous child session"), - messages_page_up: z.string().optional().default("pgup").describe("Scroll messages up by one page"), - messages_page_down: z.string().optional().default("pgdown").describe("Scroll messages down by one page"), - messages_half_page_up: z.string().optional().default("ctrl+alt+u").describe("Scroll messages up by half page"), + messages_page_up: z + .string() + .optional() + .default("pgup") + .describe("Scroll messages up by one page"), + messages_page_down: z + .string() + .optional() + .default("pgdown") + .describe("Scroll messages down by one page"), + messages_half_page_up: z + .string() + .optional() + .default("ctrl+alt+u") + .describe("Scroll messages up by half page"), messages_half_page_down: z .string() .optional() .default("ctrl+alt+d") .describe("Scroll messages down by half page"), messages_first: z.string().optional().default("ctrl+g").describe("Navigate to first message"), - messages_last: z.string().optional().default("ctrl+alt+g").describe("Navigate to last message"), + messages_last: z + .string() + .optional() + .default("ctrl+alt+g") + .describe("Navigate to last message"), messages_copy: z.string().optional().default("y").describe("Copy message"), messages_undo: z.string().optional().default("u").describe("Undo message"), messages_redo: z.string().optional().default("r").describe("Redo message"), model_list: z.string().optional().default("m").describe("List available models"), model_cycle_recent: z.string().optional().default("f2").describe("Next recent model"), - model_cycle_recent_reverse: z.string().optional().default("shift+f2").describe("Previous recent model"), + model_cycle_recent_reverse: z + .string() + .optional() + .default("shift+f2") + .describe("Previous recent model"), agent_list: z.string().optional().default("a").describe("List agents"), agent_cycle: z.string().optional().default("tab").describe("Next agent"), agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"), input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"), input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"), input_submit: z.string().optional().default("enter").describe("Submit input"), - input_newline: z.string().optional().default("shift+enter,ctrl+j").describe("Insert newline in input"), + input_newline: z + .string() + .optional() + .default("shift+enter,ctrl+j") + .describe("Insert newline in input"), // Deprecated commands - switch_mode: z.string().optional().default("none").describe("@deprecated use agent_cycle. Next mode"), + switch_mode: z + .string() + .optional() + .default("none") + .describe("@deprecated use agent_cycle. Next mode"), switch_mode_reverse: z .string() .optional() .default("none") .describe("@deprecated use agent_cycle_reverse. Previous mode"), - switch_agent: z.string().optional().default("tab").describe("@deprecated use agent_cycle. Next agent"), + switch_agent: z + .string() + .optional() + .default("tab") + .describe("@deprecated use agent_cycle. Next agent"), switch_agent_reverse: z .string() .optional() .default("shift+tab") .describe("@deprecated use agent_cycle_reverse. Previous agent"), - file_list: z.string().optional().default("none").describe("@deprecated Currently not available. List files"), + file_list: z + .string() + .optional() + .default("none") + .describe("@deprecated Currently not available. List files"), file_close: z.string().optional().default("none").describe("@deprecated Close file"), file_search: z.string().optional().default("none").describe("@deprecated Search file"), - file_diff_toggle: z.string().optional().default("none").describe("@deprecated Split/unified diff"), - messages_previous: z.string().optional().default("none").describe("@deprecated Navigate to previous message"), - messages_next: z.string().optional().default("none").describe("@deprecated Navigate to next message"), - messages_layout_toggle: z.string().optional().default("none").describe("@deprecated Toggle layout"), - messages_revert: z.string().optional().default("none").describe("@deprecated use messages_undo. Revert message"), + file_diff_toggle: z + .string() + .optional() + .default("none") + .describe("@deprecated Split/unified diff"), + messages_previous: z + .string() + .optional() + .default("none") + .describe("@deprecated Navigate to previous message"), + messages_next: z + .string() + .optional() + .default("none") + .describe("@deprecated Navigate to next message"), + messages_layout_toggle: z + .string() + .optional() + .default("none") + .describe("@deprecated Toggle layout"), + messages_revert: z + .string() + .optional() + .default("none") + .describe("@deprecated use messages_undo. Revert message"), }) .strict() .meta({ @@ -446,13 +580,23 @@ export namespace Config { autoshare: z .boolean() .optional() - .describe("@deprecated Use 'share' field instead. Share newly created sessions automatically"), + .describe( + "@deprecated Use 'share' field instead. Share newly created sessions automatically", + ), autoupdate: z.boolean().optional().describe("Automatically update to the latest version"), - disabled_providers: z.array(z.string()).optional().describe("Disable providers that are loaded automatically"), - model: z.string().describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(), + disabled_providers: z + .array(z.string()) + .optional() + .describe("Disable providers that are loaded automatically"), + model: z + .string() + .describe("Model to use in the format of provider/model, eg anthropic/claude-2") + .optional(), small_model: z .string() - .describe("Small model to use for tasks like title generation in the format of provider/model") + .describe( + "Small model to use for tasks like title generation in the format of provider/model", + ) .optional(), username: z .string() @@ -508,7 +652,10 @@ export namespace Config { ) .optional() .describe("Custom provider configurations and model overrides"), - mcp: z.record(z.string(), Mcp).optional().describe("MCP (Model Context Protocol) server configurations"), + mcp: z + .record(z.string(), Mcp) + .optional() + .describe("MCP (Model Context Protocol) server configurations"), formatter: z .record( z.string(), @@ -552,7 +699,10 @@ export namespace Config { error: "For custom LSP servers, 'extensions' array is required.", }, ), - instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"), + instructions: z + .array(z.string()) + .optional() + .describe("Additional instruction files or patterns to include"), layout: Layout.optional().describe("@deprecated Always uses stretch layout."), permission: z .object({ @@ -586,7 +736,10 @@ export namespace Config { .optional(), }) .optional(), - chatMaxRetries: z.number().optional().describe("Number of retries for chat completions on failure"), + chatMaxRetries: z + .number() + .optional() + .describe("Number of retries for chat completions on failure"), disable_paste_summary: z.boolean().optional(), }) .optional(), @@ -616,7 +769,10 @@ export namespace Config { if (provider && model) result.model = `${provider}/${model}` result["$schema"] = "https://opencode.ai/config.json" result = mergeDeep(result, rest) - await Bun.write(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2)) + await Bun.write( + path.join(Global.Path.config, "config.json"), + JSON.stringify(result, null, 2), + ) await fs.unlink(path.join(Global.Path.config, "config")) }) .catch(() => {}) @@ -655,7 +811,9 @@ export namespace Config { if (filePath.startsWith("~/")) { filePath = path.join(os.homedir(), filePath.slice(2)) } - const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath) + const resolvedPath = path.isAbsolute(filePath) + ? filePath + : path.resolve(configDir, filePath) const fileContent = ( await Bun.file(resolvedPath) .text() diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 988426b1..0df6a5a7 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -167,7 +167,7 @@ export namespace MCP { return } - const result = await withTimeout(mcpClient.tools(), 5000).catch(() => {}) + const result = await withTimeout(mcpClient.tools(), mcp.timeout ?? 5000).catch(() => { }) if (!result) { log.warn("mcp client verification failed, dropping client", { name }) return diff --git a/packages/web/src/content/docs/mcp-servers.mdx b/packages/web/src/content/docs/mcp-servers.mdx index ab87449e..132537b0 100644 --- a/packages/web/src/content/docs/mcp-servers.mdx +++ b/packages/web/src/content/docs/mcp-servers.mdx @@ -45,12 +45,12 @@ with a unique name. You can refer to that MCP by name when prompting the LLM. "mcp": { "name-of-mcp-server": { // ... - "enabled": true + "enabled": true, }, "name-of-other-mcp-server": { // ... - } - } + }, + }, } ``` @@ -72,10 +72,10 @@ Add local MCP servers using `type` to `"local"` within the MCP object. "command": ["npx", "-y", "my-mcp-command"], "enabled": true, "environment": { - "MY_ENV_VAR": "my_env_var_value" - } - } - } + "MY_ENV_VAR": "my_env_var_value", + }, + }, + }, } ``` @@ -91,8 +91,8 @@ For example, here's how I can add the test "mcp_everything": { "type": "local", "command": ["npx", "-y", "@modelcontextprotocol/server-everything"], - } - } + }, + }, } ``` @@ -106,12 +106,13 @@ use the mcp_everything tool to add the number 3 and 4 Here are all the options for configuring a local MCP server. -| Option | Type | Required | Description | -| ------------- | ------- | -------- | ----------------------------------------------------- | -| `type` | String | Y | Type of MCP server connection, must be `"local"`. | -| `command` | Array | Y | Command and arguments to run the MCP server. | -| `environment` | Object | | Environment variables to set when running the server. | -| `enabled` | Boolean | | Enable or disable the MCP server on startup. | +| Option | Type | Required | Description | +| ------------- | ------- | -------- | ----------------------------------------------------------------------------------- | +| `type` | String | Y | Type of MCP server connection, must be `"local"`. | +| `command` | Array | Y | Command and arguments to run the MCP server. | +| `environment` | Object | | Environment variables to set when running the server. | +| `enabled` | Boolean | | Enable or disable the MCP server on startup. | +| `timeout` | Number | | Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds). | --- @@ -139,12 +140,13 @@ Here the `url` is the URL of the remote MCP server and with the `headers` option #### Options -| Option | Type | Required | Description | -| --------- | ------- | -------- | -------------------------------------------------- | -| `type` | String | Y | Type of MCP server connection, must be `"remote"`. | -| `url` | String | Y | URL of the remote MCP server. | -| `enabled` | Boolean | | Enable or disable the MCP server on startup. | -| `headers` | Object | | Headers to send with the request. | +| Option | Type | Required | Description | +| --------- | ------- | -------- | ----------------------------------------------------------------------------------- | +| `type` | String | Y | Type of MCP server connection, must be `"remote"`. | +| `url` | String | Y | URL of the remote MCP server. | +| `enabled` | Boolean | | Enable or disable the MCP server on startup. | +| `headers` | Object | | Headers to send with the request. | +| `timeout` | Number | | Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds). | ---