Просмотр исходного кода

feat: add per-project MCP config overrides (#5406)

Co-authored-by: Aiden Cline <[email protected]>
Co-authored-by: Aiden Cline <[email protected]>
Jake Nelson 1 месяц назад
Родитель
Сommit
5c5e636030

+ 27 - 11
packages/opencode/src/cli/cmd/mcp.ts

@@ -36,6 +36,18 @@ function getAuthStatusText(status: MCP.AuthStatus): string {
   }
 }
 
+type McpEntry = NonNullable<Config.Info["mcp"]>[string]
+
+type McpConfigured = Config.Mcp
+function isMcpConfigured(config: McpEntry): config is McpConfigured {
+  return typeof config === "object" && config !== null && "type" in config
+}
+
+type McpRemote = Extract<McpConfigured, { type: "remote" }>
+function isMcpRemote(config: McpEntry): config is McpRemote {
+  return isMcpConfigured(config) && config.type === "remote"
+}
+
 export const McpCommand = cmd({
   command: "mcp",
   builder: (yargs) =>
@@ -64,15 +76,19 @@ export const McpListCommand = cmd({
         const mcpServers = config.mcp ?? {}
         const statuses = await MCP.status()
 
-        if (Object.keys(mcpServers).length === 0) {
+        const servers = Object.entries(mcpServers).filter((entry): entry is [string, McpConfigured] =>
+          isMcpConfigured(entry[1]),
+        )
+
+        if (servers.length === 0) {
           prompts.log.warn("No MCP servers configured")
           prompts.outro("Add servers with: opencode mcp add")
           return
         }
 
-        for (const [name, serverConfig] of Object.entries(mcpServers)) {
+        for (const [name, serverConfig] of servers) {
           const status = statuses[name]
-          const hasOAuth = serverConfig.type === "remote" && !!serverConfig.oauth
+          const hasOAuth = isMcpRemote(serverConfig) && !!serverConfig.oauth
           const hasStoredTokens = await MCP.hasStoredTokens(name)
 
           let statusIcon: string
@@ -110,7 +126,7 @@ export const McpListCommand = cmd({
           )
         }
 
-        prompts.outro(`${Object.keys(mcpServers).length} server(s)`)
+        prompts.outro(`${servers.length} server(s)`)
       },
     })
   },
@@ -138,7 +154,7 @@ export const McpAuthCommand = cmd({
 
         // Get OAuth-capable servers (remote servers with oauth not explicitly disabled)
         const oauthServers = Object.entries(mcpServers).filter(
-          ([_, cfg]) => cfg.type === "remote" && cfg.oauth !== false,
+          (entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false,
         )
 
         if (oauthServers.length === 0) {
@@ -163,7 +179,7 @@ export const McpAuthCommand = cmd({
               const authStatus = await MCP.getAuthStatus(name)
               const icon = getAuthStatusIcon(authStatus)
               const statusText = getAuthStatusText(authStatus)
-              const url = cfg.type === "remote" ? cfg.url : ""
+              const url = cfg.url
               return {
                 label: `${icon} ${name} (${statusText})`,
                 value: name,
@@ -187,8 +203,8 @@ export const McpAuthCommand = cmd({
           return
         }
 
-        if (serverConfig.type !== "remote" || serverConfig.oauth === false) {
-          prompts.log.error(`MCP server ${serverName} does not support OAuth (oauth is disabled)`)
+        if (!isMcpRemote(serverConfig) || serverConfig.oauth === false) {
+          prompts.log.error(`MCP server ${serverName} is not an OAuth-capable remote server`)
           prompts.outro("Done")
           return
         }
@@ -263,7 +279,7 @@ export const McpAuthListCommand = cmd({
 
         // Get OAuth-capable servers
         const oauthServers = Object.entries(mcpServers).filter(
-          ([_, cfg]) => cfg.type === "remote" && cfg.oauth !== false,
+          (entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false,
         )
 
         if (oauthServers.length === 0) {
@@ -276,7 +292,7 @@ export const McpAuthListCommand = cmd({
           const authStatus = await MCP.getAuthStatus(name)
           const icon = getAuthStatusIcon(authStatus)
           const statusText = getAuthStatusText(authStatus)
-          const url = serverConfig.type === "remote" ? serverConfig.url : ""
+          const url = serverConfig.url
 
           prompts.log.info(`${icon} ${name} ${UI.Style.TEXT_DIM}${statusText}\n    ${UI.Style.TEXT_DIM}${url}`)
         }
@@ -506,7 +522,7 @@ export const McpDebugCommand = cmd({
           return
         }
 
-        if (serverConfig.type !== "remote") {
+        if (!isMcpRemote(serverConfig)) {
           prompts.log.error(`MCP server ${serverName} is not a remote server`)
           prompts.outro("Done")
           return

+ 14 - 1
packages/opencode/src/config/config.ts

@@ -817,7 +817,20 @@ export namespace Config {
         .record(z.string(), Provider)
         .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(),
+          z.union([
+            Mcp,
+            z
+              .object({
+                enabled: z.boolean(),
+              })
+              .strict(),
+          ]),
+        )
+        .optional()
+        .describe("MCP (Model Context Protocol) server configurations"),
       formatter: z
         .union([
           z.literal(false),

+ 30 - 3
packages/opencode/src/mcp/index.ts

@@ -135,6 +135,11 @@ export namespace MCP {
   // Prompt cache types
   type PromptInfo = Awaited<ReturnType<MCPClient["listPrompts"]>>["prompts"][number]
 
+  type McpEntry = NonNullable<Config.Info["mcp"]>[string]
+  function isMcpConfigured(entry: McpEntry): entry is Config.Mcp {
+    return typeof entry === "object" && entry !== null && "type" in entry
+  }
+
   const state = Instance.state(
     async () => {
       const cfg = await Config.get()
@@ -144,6 +149,11 @@ export namespace MCP {
 
       await Promise.all(
         Object.entries(config).map(async ([key, mcp]) => {
+          if (!isMcpConfigured(mcp)) {
+            log.error("Ignoring MCP config entry without type", { key })
+            return
+          }
+
           // If disabled by config, mark as disabled without trying to connect
           if (mcp.enabled === false) {
             status[key] = { status: "disabled" }
@@ -237,6 +247,7 @@ export namespace MCP {
         status: { status: "disabled" as const },
       }
     }
+
     log.info("found", { key, type: mcp.type })
     let mcpClient: MCPClient | undefined
     let status: Status | undefined = undefined
@@ -434,8 +445,9 @@ export namespace MCP {
     const config = cfg.mcp ?? {}
     const result: Record<string, Status> = {}
 
-    // Include all MCPs from config, not just connected ones
-    for (const key of Object.keys(config)) {
+    // Include all configured MCPs from config, not just connected ones
+    for (const [key, mcp] of Object.entries(config)) {
+      if (!isMcpConfigured(mcp)) continue
       result[key] = s.status[key] ?? { status: "disabled" }
     }
 
@@ -455,6 +467,11 @@ export namespace MCP {
       return
     }
 
+    if (!isMcpConfigured(mcp)) {
+      log.error("Ignoring MCP connect request for config without type", { name })
+      return
+    }
+
     const result = await create(name, { ...mcp, enabled: true })
 
     if (!result) {
@@ -579,6 +596,10 @@ export namespace MCP {
       throw new Error(`MCP server not found: ${mcpName}`)
     }
 
+    if (!isMcpConfigured(mcpConfig)) {
+      throw new Error(`MCP server ${mcpName} is disabled or missing configuration`)
+    }
+
     if (mcpConfig.type !== "remote") {
       throw new Error(`MCP server ${mcpName} is not a remote server`)
     }
@@ -705,6 +726,10 @@ export namespace MCP {
         throw new Error(`MCP server not found: ${mcpName}`)
       }
 
+      if (!isMcpConfigured(mcpConfig)) {
+        throw new Error(`MCP server ${mcpName} is disabled or missing configuration`)
+      }
+
       // Re-add the MCP server to establish connection
       pendingOAuthTransports.delete(mcpName)
       const result = await add(mcpName, mcpConfig)
@@ -737,7 +762,9 @@ export namespace MCP {
   export async function supportsOAuth(mcpName: string): Promise<boolean> {
     const cfg = await Config.get()
     const mcpConfig = cfg.mcp?.[mcpName]
-    return mcpConfig?.type === "remote" && mcpConfig.oauth !== false
+    if (!mcpConfig) return false
+    if (!isMcpConfigured(mcpConfig)) return false
+    return mcpConfig.type === "remote" && mcpConfig.oauth !== false
   }
 
   /**