diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index c2d2ee2f3b73..e7616345532a 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -17,7 +17,7 @@ import { InstanceRef } from "@/effect/instance-ref" import { InstallationVersion } from "@opencode-ai/core/installation/version" import path from "path" import { Global } from "@opencode-ai/core/global" -import { modify, applyEdits } from "jsonc-parser" +import { modify, applyEdits, parseTree, findNodeAtLocation } from "jsonc-parser" import { Filesystem } from "@/util/filesystem" import { Effect } from "effect" @@ -98,6 +98,7 @@ export const McpCommand = cmd({ builder: (yargs) => yargs .command(McpAddCommand) + .command(McpRemoveCommand) .command(McpListCommand) .command(McpAuthCommand) .command(McpLogoutCommand) @@ -391,7 +392,7 @@ export const McpLogoutCommand = effectCmd({ }), }) -async function resolveConfigPath(baseDir: string, global = false) { +function resolveConfigCandidates(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")] @@ -399,6 +400,12 @@ async function resolveConfigPath(baseDir: string, global = false) { candidates.push(path.join(baseDir, ".opencode", "opencode.json"), path.join(baseDir, ".opencode", "opencode.jsonc")) } + return candidates +} + +async function resolveConfigPath(baseDir: string, global = false) { + const candidates = resolveConfigCandidates(baseDir, global) + for (const candidate of candidates) { if (await Filesystem.exists(candidate)) { return candidate @@ -409,6 +416,22 @@ async function resolveConfigPath(baseDir: string, global = false) { return candidates[0] } +// Locates the config file that defines an MCP server. Earlier base dirs win, +// mirroring config precedence, so a project-level definition shadows global. +async function findMcpConfigPath(name: string, baseDirs: string[]) { + for (const baseDir of baseDirs) { + const global = baseDir === Global.Path.config + for (const candidate of resolveConfigCandidates(baseDir, global)) { + if (!(await Filesystem.exists(candidate))) continue + const text = await Filesystem.readText(candidate) + const tree = parseTree(text) + if (!tree) continue + if (findNodeAtLocation(tree, ["mcp", name])) return { configPath: candidate, text } + } + } + return undefined +} + async function addMcpToConfig(name: string, mcpConfig: ConfigMCPV1.Info, configPath: string) { let text = "{}" if (await Filesystem.exists(configPath)) { @@ -426,6 +449,28 @@ async function addMcpToConfig(name: string, mcpConfig: ConfigMCPV1.Info, configP return configPath } +async function removeMcpFromConfig(name: string, configPath: string, text: string) { + // Passing undefined as the value makes jsonc-parser emit a delete edit + // for the property while preserving surrounding comments + const edits = modify(text, ["mcp", name], undefined, { + formattingOptions: { tabSize: 2, insertSpaces: true }, + }) + + await Filesystem.write(configPath, applyEdits(text, edits)) + + return configPath +} + +function parseKeyValueEntries(values: string[] | undefined, kind: string) { + return Object.fromEntries( + (values ?? []).map((entry) => { + const index = entry.indexOf("=") + if (index < 1) throw new Error(`Invalid ${kind}: ${entry}. Expected KEY=VALUE`) + return [entry.slice(0, index), entry.slice(index + 1)] + }), + ) +} + export const McpAddCommand = effectCmd({ command: "add [name]", describe: "add an MCP server", @@ -472,16 +517,8 @@ export const McpAddCommand = effectCmd({ throw new Error("--header is only valid for remote MCP servers") } - const entries = (values: string[], kind: string) => - Object.fromEntries( - values.map((entry) => { - const index = entry.indexOf("=") - if (index < 1) throw new Error(`Invalid ${kind}: ${entry}. Expected KEY=VALUE`) - return [entry.slice(0, index), entry.slice(index + 1)] - }), - ) - const environment = entries(args.env ?? [], "environment variable") - const headers = entries(args.header ?? [], "HTTP header") + const environment = parseKeyValueEntries(args.env, "environment variable") + const headers = parseKeyValueEntries(args.header, "HTTP header") const mcpConfig: ConfigMCPV1.Info = args.url ? { type: "remote", @@ -656,6 +693,67 @@ export const McpAddCommand = effectCmd({ }), }) +export const McpRemoveCommand = effectCmd({ + command: "remove [name]", + aliases: ["rm"], + describe: "remove an MCP server from config", + builder: (yargs) => + yargs.positional("name", { + describe: "name of the MCP server", + type: "string", + }), + handler: Effect.fn("Cli.mcp.remove")(function* (args) { + const maybeCtx = yield* InstanceRef + if (!maybeCtx) return yield* Effect.die("InstanceRef not provided") + const ctx = maybeCtx + + let serverName = args.name + + if (!serverName) { + UI.empty() + prompts.intro("Remove MCP Server") + + const config = yield* Config.Service.use((cfg) => cfg.get()) + const servers = configuredServers(config) + + if (servers.length === 0) { + prompts.log.warn("No MCP servers configured") + prompts.outro("Done") + return + } + + const selected = yield* Effect.promise(() => + prompts.select({ + message: "Select MCP server to remove", + options: servers.map(([name]) => ({ label: name, value: name })), + }), + ) + if (prompts.isCancel(selected)) throw new UI.CancelledError() + serverName = selected + } + + const found = yield* Effect.promise(() => findMcpConfigPath(serverName, [ctx.worktree, Global.Path.config])) + if (!found) { + throw new Error(`MCP server "${serverName}" not found in any config file`) + } + + yield* Effect.promise(() => removeMcpFromConfig(serverName, found.configPath, found.text)) + + const hadTokens = yield* MCP.Service.use((mcp) => mcp.hasStoredTokens(serverName)) + if (hadTokens) { + yield* MCP.Service.use((mcp) => mcp.removeAuth(serverName)) + } + + prompts.log.success(`MCP server "${serverName}" removed from ${found.configPath}`) + if (hadTokens) { + prompts.log.info("Also removed stored OAuth credentials") + } + if (!args.name) { + prompts.outro("MCP server removed successfully") + } + }), +}) + export const McpDebugCommand = effectCmd({ command: "debug ", describe: "debug OAuth connection for an MCP server", diff --git a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap index e9d3ad233807..cf4cb04a609d 100644 --- a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap +++ b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap @@ -28,6 +28,7 @@ manage MCP (Model Context Protocol) servers Commands: opencode mcp add [name] add an MCP server + opencode mcp remove [name] remove an MCP server from config [aliases: rm] opencode mcp list list MCP servers and their status [aliases: ls] opencode mcp auth [name] authenticate with an OAuth-enabled MCP server opencode mcp logout [name] remove OAuth credentials for an MCP server diff --git a/packages/opencode/test/cli/mcp-remove.test.ts b/packages/opencode/test/cli/mcp-remove.test.ts new file mode 100644 index 000000000000..ea399e7fdbc7 --- /dev/null +++ b/packages/opencode/test/cli/mcp-remove.test.ts @@ -0,0 +1,65 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { parse } from "jsonc-parser" +import path from "path" +import { cliIt } from "../lib/cli-process" + +const globalConfigPath = (home: string) => path.join(home, ".config", "opencode", "opencode.json") + +describe("opencode mcp remove (non-interactive subprocess)", () => { + cliIt.concurrent( + "removes a server added in the same home", + ({ home, opencode }) => + Effect.gen(function* () { + const added = yield* opencode.spawn(["mcp", "add", "github", "--url", "https://example.com/mcp"]) + opencode.expectExit(added, 0) + + const removed = yield* opencode.spawn(["mcp", "remove", "github"]) + opencode.expectExit(removed, 0) + + const config = yield* Effect.promise(() => Bun.file(globalConfigPath(home)).json()) + expect(config.mcp.github).toBeUndefined() + }), + 60_000, + ) + + cliIt.concurrent( + "keeps sibling servers and comments intact", + ({ home, opencode }) => + Effect.gen(function* () { + const configWithComments = `{ + // provider hints live here + "$schema": "https://opencode.ai/config.json", + "mcp": { + // github remote + "github": { "type": "remote", "url": "https://example.com/mcp" }, + "local-one": { "type": "local", "command": ["npx"] } + } +}` + yield* Effect.promise(() => + Bun.write(globalConfigPath(home), configWithComments), + ) + + const removed = yield* opencode.spawn(["mcp", "remove", "github"]) + opencode.expectExit(removed, 0) + + const text = yield* Effect.promise(() => Bun.file(globalConfigPath(home)).text()) + expect(text).toContain("// provider hints live here") + expect(text).toContain('"$schema"') + const config = parse(text) as { mcp: Record } + expect(config.mcp.github).toBeUndefined() + expect(config.mcp["local-one"]).toEqual({ type: "local", command: ["npx"] }) + }), + 60_000, + ) + + cliIt.concurrent( + "fails when the server does not exist", + ({ opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn(["mcp", "remove", "missing-server"]) + opencode.expectExit(result, 1) + }), + 60_000, + ) +})