diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index ed9c0d637..3a4933704 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -437,13 +437,79 @@ async function addMcpToConfig(name: string, mcpConfig: ConfigMCPV1.Info, configP } export const McpAddCommand = effectCmd({ - command: "add", + command: "add [name]", describe: "add an MCP server", - handler: Effect.fn("Cli.mcp.add")(function* () { + builder: (yargs) => + yargs + .positional("name", { + describe: "name of the MCP server", + type: "string", + }) + .option("url", { + describe: "URL for a remote MCP server", + type: "string", + }) + .option("env", { + describe: "environment variable for a local MCP server (KEY=VALUE)", + type: "string", + array: true, + }) + .option("header", { + describe: "HTTP header for a remote MCP server (KEY=VALUE)", + type: "string", + array: true, + }), + handler: Effect.fn("Cli.mcp.add")(function* (args) { const maybeCtx = yield* InstanceRef if (!maybeCtx) return yield* Effect.die("InstanceRef not provided") const ctx = maybeCtx yield* Effect.promise(async () => { + const command = args["--"] ?? [] + if (!args.name && (args.url || args.env?.length || args.header?.length || command.length)) { + throw new Error("A server name is required for non-interactive MCP configuration") + } + if (args.name) { + if (!!args.url === !!command.length) { + throw new Error("Provide either --url or a command after --") + } + if (args.url && !URL.canParse(args.url)) { + throw new Error(`Invalid URL: ${args.url}`) + } + if (args.url && args.env?.length) { + throw new Error("--env is only valid for local MCP servers") + } + if (command.length && args.header?.length) { + 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 mcpConfig: ConfigMCPV1.Info = args.url + ? { + type: "remote", + url: args.url, + ...(Object.keys(headers).length ? { headers } : {}), + } + : { + type: "local", + command, + ...(Object.keys(environment).length ? { environment } : {}), + } + + const configPath = await resolveConfigPath(Global.Path.config, true) + await addMcpToConfig(args.name, mcpConfig, configPath) + prompts.log.success(`MCP server "${args.name}" added to ${configPath}`) + return + } + UI.empty() prompts.intro("Add 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 a8e12d7cd..a672d2aca 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 @@ -27,7 +27,7 @@ exports[`opencode CLI help-text snapshots every documented command emits stable manage MCP (Model Context Protocol) servers Commands: - opencode mcp add add an MCP server + opencode mcp add [name] add an MCP server 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 @@ -425,16 +425,22 @@ Options: `; exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mcp add --help 1`] = ` -"opencode mcp add +"opencode mcp add [name] add an MCP server +Positionals: + name name of the MCP server [string] + Options: -h, --help show help [boolean] -v, --version show version number [boolean] --print-logs print logs to stderr [boolean] --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] - --pure run without external plugins [boolean]" + --pure run without external plugins [boolean] + --url URL for a remote MCP server [string] + --env environment variable for a local MCP server (KEY=VALUE) [array] + --header HTTP header for a remote MCP server (KEY=VALUE) [array]" `; exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mcp auth --help 1`] = ` diff --git a/packages/opencode/test/cli/mcp-add.test.ts b/packages/opencode/test/cli/mcp-add.test.ts new file mode 100644 index 000000000..9a4938485 --- /dev/null +++ b/packages/opencode/test/cli/mcp-add.test.ts @@ -0,0 +1,74 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import path from "path" +import { cliIt } from "../lib/cli-process" + +describe("opencode mcp add (non-interactive subprocess)", () => { + cliIt.concurrent( + "adds a remote server with HTTP headers", + ({ home, opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn([ + "mcp", + "add", + "github", + "--url", + "https://example.com/mcp", + "--header", + "Authorization=Bearer {env:GITHUB_TOKEN}", + "--header", + "X-Option=one=two", + ]) + opencode.expectExit(result, 0) + + const config = yield* Effect.promise(() => + Bun.file(path.join(home, ".config", "opencode", "opencode.json")).json(), + ) + expect(config.mcp.github).toEqual({ + type: "remote", + url: "https://example.com/mcp", + headers: { + Authorization: "Bearer {env:GITHUB_TOKEN}", + "X-Option": "one=two", + }, + }) + }), + 60_000, + ) + + cliIt.concurrent( + "adds a local server while preserving argv and environment values", + ({ home, opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn([ + "mcp", + "add", + "local", + "--env", + "API_KEY=secret", + "--env", + "VALUE=one=two", + "--", + "npx", + "-y", + "@example/server", + "--label", + "two words", + ]) + opencode.expectExit(result, 0) + + const config = yield* Effect.promise(() => + Bun.file(path.join(home, ".config", "opencode", "opencode.json")).json(), + ) + expect(config.mcp.local).toEqual({ + type: "local", + command: ["npx", "-y", "@example/server", "--label", "two words"], + environment: { + API_KEY: "secret", + VALUE: "one=two", + }, + }) + }), + 60_000, + ) +})