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

This commit is contained in:
Aiden Cline 2026-06-30 13:49:07 -05:00 committed by GitHub
commit 0a60662d71
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 302 additions and 9 deletions

View file

@ -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",

View file

@ -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))
}

View file

@ -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
})

View file

@ -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
}
}

View file

@ -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)
}),
)

View file

@ -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
})

View file

@ -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"),