Преглед изворни кода

add optional timeout field to mcp config to allow users to use responding servers (#3558)

Co-authored-by: Matt Gillard <[email protected]>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <[email protected]>
Matt Gillard пре 3 месеци
родитељ
комит
3ae75d7031

+ 196 - 38
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<string, Command> = {}
-    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<string, Agent> = {}
 
-    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<string, Agent> = {}
-    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("<leader>h").describe("Show help dialog"),
       app_exit: z.string().optional().default("ctrl+c,<leader>q").describe("Exit the application"),
       editor_open: z.string().optional().default("<leader>e").describe("Open external editor"),
       theme_list: z.string().optional().default("<leader>t").describe("List available themes"),
       project_init: z.string().optional().default("<leader>i").describe("Create/update AGENTS.md"),
       tool_details: z.string().optional().default("<leader>d").describe("Toggle tool details"),
-      thinking_blocks: z.string().optional().default("<leader>b").describe("Toggle thinking blocks"),
-      session_export: z.string().optional().default("<leader>x").describe("Export session to editor"),
+      thinking_blocks: z
+        .string()
+        .optional()
+        .default("<leader>b")
+        .describe("Toggle thinking blocks"),
+      session_export: z
+        .string()
+        .optional()
+        .default("<leader>x")
+        .describe("Export session to editor"),
       session_new: z.string().optional().default("<leader>n").describe("Create a new session"),
       session_list: z.string().optional().default("<leader>l").describe("List all sessions"),
-      session_timeline: z.string().optional().default("<leader>g").describe("Show session timeline"),
+      session_timeline: z
+        .string()
+        .optional()
+        .default("<leader>g")
+        .describe("Show session timeline"),
       session_share: z.string().optional().default("<leader>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("<leader>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("<leader>y").describe("Copy message"),
       messages_undo: z.string().optional().default("<leader>u").describe("Undo message"),
       messages_redo: z.string().optional().default("<leader>r").describe("Redo message"),
       model_list: z.string().optional().default("<leader>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("<leader>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()

+ 1 - 1
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

+ 23 - 21
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). |
 
 ---