瀏覽代碼

feat(cli): add mcp list, add, auth, and logout commands (#34643)

Aiden Cline 1 月之前
父節點
當前提交
0a60662d71

+ 37 - 0
packages/cli/src/commands/commands.ts

@@ -36,6 +36,43 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
       description: "Debugging and troubleshooting tools",
       commands: [Spec.make("agents", { description: "List all agents" })],
     }),
+    Spec.make("mcp", {
+      description: "Manage MCP (Model Context Protocol) servers",
+      commands: [
+        Spec.make("list", { description: "List configured MCP servers and their status" }),
+        Spec.make("add", {
+          description: "Add an MCP server to your configuration",
+          params: {
+            name: Argument.string("name").pipe(Argument.withDescription("Name of the MCP server")),
+            command: Argument.string("command").pipe(
+              Argument.withDescription("Command and arguments for a local server, passed after --"),
+              Argument.variadic({ min: 0 }),
+            ),
+            url: Flag.string("url").pipe(Flag.withDescription("URL for a remote MCP server"), Flag.optional),
+            header: Flag.keyValuePair("header").pipe(
+              Flag.withDescription("HTTP header for a remote server, as name=value"),
+              Flag.optional,
+            ),
+            env: Flag.keyValuePair("env").pipe(
+              Flag.withDescription("Environment variable for a local server, as name=value"),
+              Flag.optional,
+            ),
+            global: Flag.boolean("global").pipe(
+              Flag.withDescription("Write to the global config instead of the project config"),
+              Flag.withDefault(false),
+            ),
+          },
+        }),
+        Spec.make("auth", {
+          description: "Authenticate with an OAuth-capable remote MCP server",
+          params: { name: Argument.string("name").pipe(Argument.withDescription("Name of the MCP server")) },
+        }),
+        Spec.make("logout", {
+          description: "Remove stored OAuth credentials for an MCP server",
+          params: { name: Argument.string("name").pipe(Argument.withDescription("Name of the MCP server")) },
+        }),
+      ],
+    }),
     Spec.make("migrate", { description: "Migrate v1 data to v2" }),
     Spec.make("service", {
       description: "Manage the background server",

+ 58 - 0
packages/cli/src/commands/handlers/mcp/add.ts

@@ -0,0 +1,58 @@
+import { EOL } from "node:os"
+import path from "node:path"
+import { Effect, Option } from "effect"
+import { applyEdits, modify } from "jsonc-parser"
+import { Global } from "@opencode-ai/core/global"
+import { Commands } from "../../commands"
+import { Runtime } from "../../../framework/runtime"
+
+export default Runtime.handler(
+  Commands.commands.mcp.commands.add,
+  Effect.fn("cli.mcp.add")(function* (input) {
+    const url = Option.getOrUndefined(input.url)
+    const headers = Option.getOrUndefined(input.header)
+    const environment = Option.getOrUndefined(input.env)
+    // The CLI framework strands `--` operands on the root command, so read the local server command
+    // straight from argv after `--`. This also lets the command carry its own flags (e.g. `npx -y`).
+    const dash = process.argv.indexOf("--")
+    const command = dash === -1 ? [...input.command] : process.argv.slice(dash + 1)
+
+    const hasCommand = command.length > 0
+    if (url && hasCommand)
+      return yield* Effect.fail(new Error("Provide either --url <url> or a command after --, not both"))
+    if (!url && !hasCommand) return yield* Effect.fail(new Error("Provide either --url <url> or a command after --"))
+    if (url && !URL.canParse(url)) return yield* Effect.fail(new Error(`Invalid URL: ${url}`))
+    if (url && environment) return yield* Effect.fail(new Error("--env is only valid for local MCP servers"))
+    if (hasCommand && headers) return yield* Effect.fail(new Error("--header is only valid for remote MCP servers"))
+
+    const server = url
+      ? { type: "remote" as const, url, ...(headers ? { headers } : {}) }
+      : { type: "local" as const, command, ...(environment ? { environment } : {}) }
+
+    const configPath = yield* Effect.promise(() => resolveConfigPath(input.global ? Global.Path.config : process.cwd()))
+    yield* Effect.promise(() => write(configPath, input.name, server))
+    process.stdout.write(`MCP server "${input.name}" added to ${configPath}` + EOL)
+  }),
+)
+
+async function resolveConfigPath(directory: string) {
+  const candidates = [
+    path.join(directory, "opencode.json"),
+    path.join(directory, "opencode.jsonc"),
+    path.join(directory, ".opencode", "opencode.json"),
+    path.join(directory, ".opencode", "opencode.jsonc"),
+  ]
+  for (const candidate of candidates) {
+    if (await Bun.file(candidate).exists()) return candidate
+  }
+  return candidates[0]
+}
+
+async function write(configPath: string, name: string, server: unknown) {
+  const file = Bun.file(configPath)
+  const text = (await file.exists()) ? await file.text() : "{}"
+  const edits = modify(text, ["mcp", "servers", name], server, {
+    formattingOptions: { tabSize: 2, insertSpaces: true },
+  })
+  await Bun.write(configPath, applyEdits(text, edits))
+}

+ 58 - 0
packages/cli/src/commands/handlers/mcp/auth.ts

@@ -0,0 +1,58 @@
+import { EOL } from "node:os"
+import { Effect } from "effect"
+import type { IntegrationAttemptStatus, IntegrationOAuthMethod, OpencodeClient } from "@opencode-ai/sdk/v2/client"
+import { Commands } from "../../commands"
+import { Runtime } from "../../../framework/runtime"
+import { Daemon } from "../../../services/daemon"
+import { resolveIntegration } from "./resolve"
+
+const location = { directory: process.cwd() }
+
+export default Runtime.handler(
+  Commands.commands.mcp.commands.auth,
+  Effect.fn("cli.mcp.auth")(function* (input) {
+    const daemon = yield* Daemon.Service
+    const client = yield* daemon.client()
+
+    const integration = yield* resolveIntegration(client, input.name, location)
+    if (!integration)
+      return yield* Effect.fail(new Error(`MCP server "${input.name}" is not an OAuth-capable remote server`))
+    const method = integration.methods.find(
+      (candidate): candidate is IntegrationOAuthMethod => candidate.type === "oauth",
+    )
+    if (!method)
+      return yield* Effect.fail(new Error(`MCP server "${input.name}" is not an OAuth-capable remote server`))
+
+    const started = yield* Effect.promise(() =>
+      client.v2.integration.connect.oauth({ integrationID: integration.id, methodID: method.id, inputs: {}, location }),
+    )
+    const attempt = started.data?.data
+    if (!attempt) return yield* Effect.fail(new Error(started.error?.message ?? "Failed to start OAuth attempt"))
+    if (attempt.mode === "code")
+      return yield* Effect.fail(new Error("This server requires manual code entry, which the CLI does not support"))
+
+    process.stdout.write(attempt.instructions + EOL + attempt.url + EOL)
+
+    const result = yield* poll(client, attempt.attemptID)
+    if (result.status === "complete") {
+      process.stdout.write(`Authenticated with ${input.name}` + EOL)
+      return
+    }
+    const reason = result.status === "failed" ? `: ${result.message}` : ""
+    return yield* Effect.fail(new Error(`Authentication ${result.status}${reason}`))
+  }),
+)
+
+const poll = (
+  client: OpencodeClient,
+  attemptID: string,
+): Effect.Effect<Exclude<IntegrationAttemptStatus, { status: "pending" }>> =>
+  Effect.gen(function* () {
+    const response = yield* Effect.promise(() => client.v2.integration.attempt.status({ attemptID, location }))
+    const status = response.data?.data
+    if (!status || status.status === "pending") {
+      yield* Effect.sleep("1 second")
+      return yield* poll(client, attemptID)
+    }
+    return status
+  })

+ 52 - 0
packages/cli/src/commands/handlers/mcp/list.ts

@@ -0,0 +1,52 @@
+import { EOL } from "node:os"
+import * as Effect from "effect/Effect"
+import type { McpServer } from "@opencode-ai/sdk/v2/client"
+import { Commands } from "../../commands"
+import { Runtime } from "../../../framework/runtime"
+import { Daemon } from "../../../services/daemon"
+
+export default Runtime.handler(
+  Commands.commands.mcp.commands.list,
+  Effect.fn("cli.mcp.list")(function* () {
+    const daemon = yield* Daemon.Service
+    const client = yield* daemon.client()
+    const response = yield* Effect.promise(() => client.v2.mcp.list({ location: { directory: process.cwd() } }))
+    const servers = (response.data?.data ?? []).toSorted((a, b) => a.name.localeCompare(b.name))
+    if (servers.length === 0) {
+      process.stdout.write("No MCP servers configured" + EOL)
+      return
+    }
+    const width = Math.max(...servers.map((server) => server.name.length))
+    const lines = servers.map(
+      (server) => `${icon(server.status)} ${server.name.padEnd(width)}  ${describe(server.status)}`,
+    )
+    process.stdout.write(lines.join(EOL) + EOL)
+  }),
+)
+
+function icon(status: McpServer["status"]) {
+  switch (status.status) {
+    case "connected":
+      return "✓"
+    case "needs_auth":
+      return "⚠"
+    case "failed":
+    case "needs_client_registration":
+      return "✗"
+    default:
+      return "○"
+  }
+}
+
+function describe(status: McpServer["status"]) {
+  switch (status.status) {
+    case "needs_auth":
+      return "needs authentication"
+    case "needs_client_registration":
+      return `needs client registration: ${status.error}`
+    case "failed":
+      return `failed: ${status.error}`
+    default:
+      return status.status
+  }
+}

+ 35 - 0
packages/cli/src/commands/handlers/mcp/logout.ts

@@ -0,0 +1,35 @@
+import { EOL } from "node:os"
+import { Effect } from "effect"
+import { Commands } from "../../commands"
+import { Runtime } from "../../../framework/runtime"
+import { Daemon } from "../../../services/daemon"
+import { resolveIntegration } from "./resolve"
+
+const location = { directory: process.cwd() }
+
+export default Runtime.handler(
+  Commands.commands.mcp.commands.logout,
+  Effect.fn("cli.mcp.logout")(function* (input) {
+    const daemon = yield* Daemon.Service
+    const client = yield* daemon.client()
+
+    const integration = yield* resolveIntegration(client, input.name, location)
+    if (!integration) {
+      process.stdout.write(`No stored credentials for ${input.name}` + EOL)
+      return
+    }
+
+    const credentials = integration.connections.filter((connection) => connection.type === "credential")
+    if (credentials.length === 0) {
+      process.stdout.write(`No stored credentials for ${input.name}` + EOL)
+      return
+    }
+
+    yield* Effect.forEach(
+      credentials,
+      (connection) => Effect.promise(() => client.v2.credential.remove({ credentialID: connection.id, location })),
+      { discard: true },
+    )
+    process.stdout.write(`Removed OAuth credentials for ${input.name}` + EOL)
+  }),
+)

+ 17 - 0
packages/cli/src/commands/handlers/mcp/resolve.ts

@@ -0,0 +1,17 @@
+import { Effect } from "effect"
+import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
+
+// Resolve through the MCP-owned integrationID rather than matching integration names: the shared
+// integration registry also holds provider/plugin integrations, whose names could collide with a server.
+// Fails when the server is unknown; returns undefined when the server has no integration (e.g. a local
+// or anonymous server), leaving that case for the caller to interpret.
+export const resolveIntegration = (client: OpencodeClient, name: string, location: { directory: string }) =>
+  Effect.gen(function* () {
+    const servers = yield* Effect.promise(() => client.v2.mcp.list({ location }))
+    const server = (servers.data?.data ?? []).find((entry) => entry.name === name)
+    if (!server) return yield* Effect.fail(new Error(`MCP server not found: ${name}`))
+    const integrationID = server.integrationID
+    if (!integrationID) return undefined
+    const found = yield* Effect.promise(() => client.v2.integration.get({ integrationID, location }))
+    return found.data?.data
+  })

+ 6 - 0
packages/cli/src/index.ts

@@ -24,6 +24,12 @@ const Handlers = Runtime.handlers(Commands, {
   debug: {
     agents: () => import("./commands/handlers/debug/agents"),
   },
+  mcp: {
+    list: () => import("./commands/handlers/mcp/list"),
+    add: () => import("./commands/handlers/mcp/add"),
+    auth: () => import("./commands/handlers/mcp/auth"),
+    logout: () => import("./commands/handlers/mcp/logout"),
+  },
   migrate: () => import("./commands/handlers/migrate"),
   service: {
     start: () => import("./commands/handlers/service/start"),

+ 1 - 0
packages/client/src/generated/types.ts

@@ -2487,6 +2487,7 @@ export type ServerMcpListOutput = {
       | { readonly status: "failed"; readonly error: string }
       | { readonly status: "needs_auth" }
       | { readonly status: "needs_client_registration"; readonly error: string }
+    readonly integrationID?: string
   }>
 }
 

+ 25 - 8
packages/core/src/mcp/index.ts

@@ -210,9 +210,19 @@ export const layer = Layer.effect(
       const remote = entry.config
       // Key identity on name + url, not url alone: two configs for the same url under different names are
       // distinct logical servers that may hold different accounts, so they must not share a credential row.
-      const suffix = "mcp_" + createHash("sha1").update(name + "\u0000" + remote.url).digest("hex").slice(0, 16)
+      const suffix =
+        "mcp_" +
+        createHash("sha1")
+          .update(name + "\u0000" + remote.url)
+          .digest("hex")
+          .slice(0, 16)
       entry.integrationID = Integration.ID.make(suffix)
-      registrations.push({ name, remote, integrationID: entry.integrationID, methodID: Integration.MethodID.make(suffix) })
+      registrations.push({
+        name,
+        remote,
+        integrationID: entry.integrationID,
+        methodID: Integration.MethodID.make(suffix),
+      })
     }
     if (registrations.length > 0)
       yield* integration.transform((draft) => {
@@ -308,11 +318,14 @@ export const layer = Layer.effect(
 
     const watch = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => {
       connection.onClose(() => {
-          entry.client = undefined
-          entry.tools = undefined
-          entry.status = { status: "failed", error: "Connection closed" }
-          fork(events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore))
-          fork(events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore))
+        // A reconnect closes the previous scope, but the SDK may fire this onclose after the new
+        // connection is already assigned; ignore the stale close so it can't null out the live client.
+        if (entry.client !== connection) return
+        entry.client = undefined
+        entry.tools = undefined
+        entry.status = { status: "failed", error: "Connection closed" }
+        fork(events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore))
+        fork(events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore))
       })
       connection.onLog((message) => fork(serverLog(name, message).pipe(Effect.ignore)))
       connection.onToolsChanged(() => {
@@ -452,7 +465,11 @@ export const layer = Layer.effect(
           })
         const result = yield* target.entry.client
           .callTool({ name: input.name, args: input.args })
-          .pipe(Effect.mapError((error) => new ToolCallError({ server: target.name, tool: input.name, message: error.message })))
+          .pipe(
+            Effect.mapError(
+              (error) => new ToolCallError({ server: target.name, tool: input.name, message: error.message }),
+            ),
+          )
         return new ToolResult({
           server: target.name,
           tool: input.name,

+ 5 - 0
packages/schema/src/mcp.ts

@@ -1,6 +1,8 @@
 export * as Mcp from "./mcp"
 
 import { Schema } from "effect"
+import { optional } from "./schema"
+import { IntegrationID } from "./integration-id"
 
 const Connected = Schema.Struct({ status: Schema.Literal("connected") }).annotate({
   identifier: "Mcp.Status.Connected",
@@ -36,4 +38,7 @@ export interface Server extends Schema.Schema.Type<typeof Server> {}
 export const Server = Schema.Struct({
   name: Schema.String,
   status: Status,
+  // Set for remote servers registered as OAuth integrations; lets clients act on the right integration
+  // without matching by name, which could collide with provider or plugin integrations.
+  integrationID: optional(IntegrationID),
 }).annotate({ identifier: "Mcp.Server" })

+ 1 - 0
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -5298,6 +5298,7 @@ export type McpServer = {
     | McpStatusFailed2
     | McpStatusNeedsAuth2
     | McpStatusNeedsClientRegistration2
+  integrationID?: string
 }
 
 export type ProjectCurrent = {

+ 7 - 1
packages/server/src/handlers/mcp.ts

@@ -11,7 +11,13 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
       Effect.fn(function* () {
         const service = yield* MCP.Service
         return yield* response(
-          service.servers().pipe(Effect.map((servers) => servers.map((info) => ({ name: info.name, status: info.status })))),
+          service
+            .servers()
+            .pipe(
+              Effect.map((servers) =>
+                servers.map((info) => ({ name: info.name, status: info.status, integrationID: info.integrationID })),
+              ),
+            ),
         )
       }),
     )