Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 110 additions & 12 deletions packages/opencode/src/cli/cmd/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -98,6 +98,7 @@ export const McpCommand = cmd({
builder: (yargs) =>
yargs
.command(McpAddCommand)
.command(McpRemoveCommand)
.command(McpListCommand)
.command(McpAuthCommand)
.command(McpLogoutCommand)
Expand Down Expand Up @@ -391,14 +392,20 @@ 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")]

if (!global) {
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
Expand All @@ -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)) {
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 <name>",
describe: "debug OAuth connection for an MCP server",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 65 additions & 0 deletions packages/opencode/test/cli/mcp-remove.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> }
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,
)
})
Loading