Răsfoiți Sursa

fix: actually modify opencode config with `mcp add` (#7339)

Paolo Ricciuti 1 lună în urmă
părinte
comite
498a4ab408
1 a modificat fișierele cu 180 adăugiri și 109 ștergeri
  1. 180 109
      packages/opencode/src/cli/cmd/mcp.ts

+ 180 - 109
packages/opencode/src/cli/cmd/mcp.ts

@@ -1,7 +1,6 @@
 import { cmd } from "./cmd"
 import { Client } from "@modelcontextprotocol/sdk/client/index.js"
 import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
-import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
 import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
 import * as prompts from "@clack/prompts"
 import { UI } from "../ui"
@@ -13,6 +12,7 @@ import { Instance } from "../../project/instance"
 import { Installation } from "../../installation"
 import path from "path"
 import { Global } from "../../global"
+import { modify, applyEdits } from "jsonc-parser"
 
 function getAuthStatusIcon(status: MCP.AuthStatus): string {
   switch (status) {
@@ -366,133 +366,204 @@ export const McpLogoutCommand = cmd({
   },
 })
 
-export const McpAddCommand = cmd({
-  command: "add",
-  describe: "add an MCP server",
-  async handler() {
-    UI.empty()
-    prompts.intro("Add MCP server")
-
-    const name = await prompts.text({
-      message: "Enter MCP server name",
-      validate: (x) => (x && x.length > 0 ? undefined : "Required"),
-    })
-    if (prompts.isCancel(name)) throw new UI.CancelledError()
-
-    const type = await prompts.select({
-      message: "Select MCP server type",
-      options: [
-        {
-          label: "Local",
-          value: "local",
-          hint: "Run a local command",
-        },
-        {
-          label: "Remote",
-          value: "remote",
-          hint: "Connect to a remote URL",
-        },
-      ],
-    })
-    if (prompts.isCancel(type)) throw new UI.CancelledError()
+async function resolveConfigPath(baseDir: string, global = false) {
+  // Check for existing config files (prefer .jsonc over .json, check .opencode/ subdirectory too)
+  const candidates = [path.join(baseDir, "opencode.json"), path.join(baseDir, "opencode.jsonc")]
 
-    if (type === "local") {
-      const command = await prompts.text({
-        message: "Enter command to run",
-        placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem",
-        validate: (x) => (x && x.length > 0 ? undefined : "Required"),
-      })
-      if (prompts.isCancel(command)) throw new UI.CancelledError()
+  if (!global) {
+    candidates.push(path.join(baseDir, ".opencode", "opencode.json"), path.join(baseDir, ".opencode", "opencode.jsonc"))
+  }
 
-      prompts.log.info(`Local MCP server "${name}" configured with command: ${command}`)
-      prompts.outro("MCP server added successfully")
-      return
+  for (const candidate of candidates) {
+    if (await Bun.file(candidate).exists()) {
+      return candidate
     }
+  }
 
-    if (type === "remote") {
-      const url = await prompts.text({
-        message: "Enter MCP server URL",
-        placeholder: "e.g., https://example.com/mcp",
-        validate: (x) => {
-          if (!x) return "Required"
-          if (x.length === 0) return "Required"
-          const isValid = URL.canParse(x)
-          return isValid ? undefined : "Invalid URL"
-        },
-      })
-      if (prompts.isCancel(url)) throw new UI.CancelledError()
+  // Default to opencode.json if none exist
+  return candidates[0]
+}
 
-      const useOAuth = await prompts.confirm({
-        message: "Does this server require OAuth authentication?",
-        initialValue: false,
-      })
-      if (prompts.isCancel(useOAuth)) throw new UI.CancelledError()
+async function addMcpToConfig(name: string, mcpConfig: Config.Mcp, configPath: string) {
+  const file = Bun.file(configPath)
+
+  let text = "{}"
+  if (await file.exists()) {
+    text = await file.text()
+  }
+
+  // Use jsonc-parser to modify while preserving comments
+  const edits = modify(text, ["mcp", name], mcpConfig, {
+    formattingOptions: { tabSize: 2, insertSpaces: true },
+  })
+  const result = applyEdits(text, edits)
+
+  await Bun.write(configPath, result)
+
+  return configPath
+}
 
-      if (useOAuth) {
-        const hasClientId = await prompts.confirm({
-          message: "Do you have a pre-registered client ID?",
-          initialValue: false,
+export const McpAddCommand = cmd({
+  command: "add",
+  describe: "add an MCP server",
+  async handler() {
+    await Instance.provide({
+      directory: process.cwd(),
+      async fn() {
+        UI.empty()
+        prompts.intro("Add MCP server")
+
+        const project = Instance.project
+
+        // Resolve config paths eagerly for hints
+        const [projectConfigPath, globalConfigPath] = await Promise.all([
+          resolveConfigPath(Instance.worktree),
+          resolveConfigPath(Global.Path.config, true),
+        ])
+
+        // Determine scope
+        let configPath = globalConfigPath
+        if (project.vcs === "git") {
+          const scopeResult = await prompts.select({
+            message: "Location",
+            options: [
+              {
+                label: "Current project",
+                value: projectConfigPath,
+                hint: projectConfigPath,
+              },
+              {
+                label: "Global",
+                value: globalConfigPath,
+                hint: globalConfigPath,
+              },
+            ],
+          })
+          if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
+          configPath = scopeResult
+        }
+
+        const name = await prompts.text({
+          message: "Enter MCP server name",
+          validate: (x) => (x && x.length > 0 ? undefined : "Required"),
+        })
+        if (prompts.isCancel(name)) throw new UI.CancelledError()
+
+        const type = await prompts.select({
+          message: "Select MCP server type",
+          options: [
+            {
+              label: "Local",
+              value: "local",
+              hint: "Run a local command",
+            },
+            {
+              label: "Remote",
+              value: "remote",
+              hint: "Connect to a remote URL",
+            },
+          ],
         })
-        if (prompts.isCancel(hasClientId)) throw new UI.CancelledError()
+        if (prompts.isCancel(type)) throw new UI.CancelledError()
 
-        if (hasClientId) {
-          const clientId = await prompts.text({
-            message: "Enter client ID",
+        if (type === "local") {
+          const command = await prompts.text({
+            message: "Enter command to run",
+            placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem",
             validate: (x) => (x && x.length > 0 ? undefined : "Required"),
           })
-          if (prompts.isCancel(clientId)) throw new UI.CancelledError()
+          if (prompts.isCancel(command)) throw new UI.CancelledError()
 
-          const hasSecret = await prompts.confirm({
-            message: "Do you have a client secret?",
+          const mcpConfig: Config.Mcp = {
+            type: "local",
+            command: command.split(" "),
+          }
+
+          await addMcpToConfig(name, mcpConfig, configPath)
+          prompts.log.success(`MCP server "${name}" added to ${configPath}`)
+          prompts.outro("MCP server added successfully")
+          return
+        }
+
+        if (type === "remote") {
+          const url = await prompts.text({
+            message: "Enter MCP server URL",
+            placeholder: "e.g., https://example.com/mcp",
+            validate: (x) => {
+              if (!x) return "Required"
+              if (x.length === 0) return "Required"
+              const isValid = URL.canParse(x)
+              return isValid ? undefined : "Invalid URL"
+            },
+          })
+          if (prompts.isCancel(url)) throw new UI.CancelledError()
+
+          const useOAuth = await prompts.confirm({
+            message: "Does this server require OAuth authentication?",
             initialValue: false,
           })
-          if (prompts.isCancel(hasSecret)) throw new UI.CancelledError()
+          if (prompts.isCancel(useOAuth)) throw new UI.CancelledError()
 
-          let clientSecret: string | undefined
-          if (hasSecret) {
-            const secret = await prompts.password({
-              message: "Enter client secret",
+          let mcpConfig: Config.Mcp
+
+          if (useOAuth) {
+            const hasClientId = await prompts.confirm({
+              message: "Do you have a pre-registered client ID?",
+              initialValue: false,
             })
-            if (prompts.isCancel(secret)) throw new UI.CancelledError()
-            clientSecret = secret
+            if (prompts.isCancel(hasClientId)) throw new UI.CancelledError()
+
+            if (hasClientId) {
+              const clientId = await prompts.text({
+                message: "Enter client ID",
+                validate: (x) => (x && x.length > 0 ? undefined : "Required"),
+              })
+              if (prompts.isCancel(clientId)) throw new UI.CancelledError()
+
+              const hasSecret = await prompts.confirm({
+                message: "Do you have a client secret?",
+                initialValue: false,
+              })
+              if (prompts.isCancel(hasSecret)) throw new UI.CancelledError()
+
+              let clientSecret: string | undefined
+              if (hasSecret) {
+                const secret = await prompts.password({
+                  message: "Enter client secret",
+                })
+                if (prompts.isCancel(secret)) throw new UI.CancelledError()
+                clientSecret = secret
+              }
+
+              mcpConfig = {
+                type: "remote",
+                url,
+                oauth: {
+                  clientId,
+                  ...(clientSecret && { clientSecret }),
+                },
+              }
+            } else {
+              mcpConfig = {
+                type: "remote",
+                url,
+                oauth: {},
+              }
+            }
+          } else {
+            mcpConfig = {
+              type: "remote",
+              url,
+            }
           }
 
-          prompts.log.info(`Remote MCP server "${name}" configured with OAuth (client ID: ${clientId})`)
-          prompts.log.info("Add this to your opencode.json:")
-          prompts.log.info(`
-  "mcp": {
-    "${name}": {
-      "type": "remote",
-      "url": "${url}",
-      "oauth": {
-        "clientId": "${clientId}"${clientSecret ? `,\n        "clientSecret": "${clientSecret}"` : ""}
-      }
-    }
-  }`)
-        } else {
-          prompts.log.info(`Remote MCP server "${name}" configured with OAuth (dynamic registration)`)
-          prompts.log.info("Add this to your opencode.json:")
-          prompts.log.info(`
-  "mcp": {
-    "${name}": {
-      "type": "remote",
-      "url": "${url}",
-      "oauth": {}
-    }
-  }`)
+          await addMcpToConfig(name, mcpConfig, configPath)
+          prompts.log.success(`MCP server "${name}" added to ${configPath}`)
         }
-      } else {
-        const client = new Client({
-          name: "opencode",
-          version: "1.0.0",
-        })
-        const transport = new StreamableHTTPClientTransport(new URL(url))
-        await client.connect(transport)
-        prompts.log.info(`Remote MCP server "${name}" configured with URL: ${url}`)
-      }
-    }
 
-    prompts.outro("MCP server added successfully")
+        prompts.outro("MCP server added successfully")
+      },
+    })
   },
 })