diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index 75e41b3c8a07..3610d94c88ca 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -1,5 +1,5 @@ import { cmd } from "./cmd" -import { effectCmd } from "../effect-cmd" +import { effectCmd, fail } from "../effect-cmd" import { Cause } from "effect" import { Client } from "@modelcontextprotocol/sdk/client/index.js" import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" @@ -55,6 +55,24 @@ function isMcpRemote(config: McpEntry): config is McpRemote { return isMcpConfigured(config) && config.type === "remote" } +type McpAddArgs = { + name?: string + args?: string[] + type?: "local" | "remote" + env?: string[] + header?: string[] + global?: boolean +} + +type InlineMcpAdd = { + name?: string + positional: string[] + command: string[] + type?: "local" | "remote" + env?: string[] + header?: string[] +} + function configuredServers(config: Config.Info) { return Object.entries(config.mcp ?? {}).filter((entry): entry is [string, McpConfigured] => isMcpConfigured(entry[1])) } @@ -436,27 +454,79 @@ async function addMcpToConfig(name: string, mcpConfig: ConfigMCP.Info, configPat } export const McpAddCommand = effectCmd({ - command: "add", + command: "add [name] [args...]", 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", + }) + .positional("args", { + describe: "URL for remote servers", + type: "string", + array: true, + default: [], + }) + .option("type", { + describe: "server type: local or remote", + type: "string", + choices: ["local", "remote"] as const, + }) + .option("env", { + describe: "environment variable for local servers (KEY=VALUE)", + type: "string", + array: true, + }) + .option("header", { + describe: "HTTP header for remote servers (KEY=VALUE or 'KEY: VALUE')", + type: "string", + array: true, + }) + .option("global", { + alias: ["g"], + describe: "save to global config", + type: "boolean", + }).epilogue(`Usage: + opencode mcp add -- [args...] (local MCP server) + opencode mcp add --env KEY=VALUE -- [args...] (local MCP server with env vars) + opencode mcp add (remote MCP server) + opencode mcp add --header KEY=VALUE (remote MCP server with headers) + opencode mcp add --global (save to global config) + +Examples: + opencode mcp add context7 -- npx -y @upstash/context7-mcp + opencode mcp add local-env --env FOO=bar -- node server.js + opencode mcp add sg --header Authorization=token https://sg.example/mcp + opencode mcp add hugging-face https://huggingface.co/mcp`), + handler: Effect.fn("Cli.mcp.add")(function* (input) { const maybeCtx = yield* InstanceRef if (!maybeCtx) return yield* Effect.die("InstanceRef not provided") const ctx = maybeCtx + const inlineConfig = parseInlineMcpAdd({ + name: input.name, + positional: input.args ?? [], + command: input["--"] ?? [], + type: input.type, + env: input.env, + header: input.header, + }) + if (inlineConfig && "error" in inlineConfig) return yield* fail(inlineConfig.error) yield* Effect.promise(async () => { UI.empty() prompts.intro("Add MCP server") const project = ctx.project - // Resolve config paths eagerly for hints const [projectConfigPath, globalConfigPath] = await Promise.all([ resolveConfigPath(ctx.worktree), resolveConfigPath(Global.Path.config, true), ]) - // Determine scope - let configPath = globalConfigPath - if (project.vcs === "git") { + const configPath = await (async () => { + if (input.global) return globalConfigPath + if (inlineConfig) return project.vcs === "git" ? projectConfigPath : globalConfigPath + if (project.vcs !== "git") return globalConfigPath const scopeResult = await prompts.select({ message: "Location", options: [ @@ -473,7 +543,14 @@ export const McpAddCommand = effectCmd({ ], }) if (prompts.isCancel(scopeResult)) throw new UI.CancelledError() - configPath = scopeResult + return scopeResult + })() + + if (inlineConfig) { + await addMcpToConfig(inlineConfig.name, inlineConfig.config, configPath) + prompts.log.success(`MCP server "${inlineConfig.name}" added to ${configPath}`) + prompts.outro("MCP server added successfully") + return } const name = await prompts.text({ @@ -599,6 +676,94 @@ export const McpAddCommand = effectCmd({ }), }) +function parseInlineMcpAdd( + input: InlineMcpAdd, +): { name: string; config: ConfigMCP.Info } | { error: string } | undefined { + if (!hasInlineMcpAdd(input)) return undefined + const name = input.name?.trim() + if (!name) return { error: "MCP server name is required" } + const result = input.command.length > 0 ? parseInlineLocalMcp(input) : parseInlineRemoteMcp(input) + if ("error" in result) return result + return { name, config: result.config } +} + +function hasInlineMcpAdd(input: InlineMcpAdd) { + return !!( + input.name || + input.positional.length > 0 || + input.command.length > 0 || + input.type || + input.env?.length || + input.header?.length + ) +} + +function parseInlineLocalMcp(input: InlineMcpAdd): { config: ConfigMCP.Info } | { error: string } { + if (input.positional.length > 0) return { error: "Remote URL arguments cannot be combined with -- " } + if (input.type === "remote") return { error: "-- can only be used with --type local" } + if (input.header?.length) return { error: "--header can only be used with remote MCP servers" } + const environment = parseEnv(input.env) + if ("error" in environment) return environment + return { + config: { + type: "local", + command: input.command, + ...(environment.value && { environment: environment.value }), + }, + } +} + +function parseInlineRemoteMcp(input: InlineMcpAdd): { config: ConfigMCP.Info } | { error: string } { + if (input.type === "local" || input.env?.length) return { error: "Local MCP commands must be passed after --" } + if (input.positional.length === 0) return { error: "URL or command is required" } + const wantsRemote = input.type === "remote" || !!input.header?.length + if (input.positional.length !== 1) { + return { + error: wantsRemote ? "Remote MCP servers require exactly one URL" : "Local MCP commands must be passed after --", + } + } + if (!URL.canParse(input.positional[0])) { + return { error: wantsRemote ? "Remote MCP server URL is invalid" : "Local MCP commands must be passed after --" } + } + const headers = parseHeader(input.header) + if ("error" in headers) return headers + return { + config: { + type: "remote", + url: input.positional[0], + ...(headers.value && { headers: headers.value }), + }, + } +} + +function parseEnv(entries?: string[]): { value?: Record } | { error: string } { + if (!entries?.length) return {} + const parsed = entries.map((entry) => { + const index = entry.indexOf("=") + const key = entry.slice(0, index).trim() + if (index <= 0 || !key) return { error: "--env must be in KEY=VALUE format" } + return { key, value: entry.slice(index + 1) } + }) + const invalid = parsed.find((entry): entry is { error: string } => "error" in entry) + if (invalid) return invalid + return { value: Object.fromEntries(parsed.map((entry) => [entry.key, entry.value])) } +} + +function parseHeader(entries?: string[]): { value?: Record } | { error: string } { + if (!entries?.length) return {} + const parsed = entries.map((entry) => { + const colon = entry.indexOf(":") + const equals = entry.indexOf("=") + const index = colon === -1 ? equals : equals === -1 ? colon : Math.min(colon, equals) + const key = entry.slice(0, index).trim() + if (index <= 0 || !key) return { error: "--header must be in KEY=VALUE or 'KEY: VALUE' format" } + return { key, value: entry.slice(index + 1).trim() } + }) + const invalid = parsed.find((entry): entry is { error: string } => "error" in entry) + if (invalid) return invalid + return { value: Object.fromEntries(parsed.map((entry) => [entry.key, entry.value])) } +} + 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 14882e264b1e..4ee748e4b031 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,11 +27,11 @@ 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 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 - opencode mcp debug debug OAuth connection for an MCP server + opencode mcp add [name] [args...] 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 + opencode mcp debug debug OAuth connection for an MCP server Options: -h, --help show help [boolean] @@ -425,16 +425,37 @@ 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] [args...] add an MCP server +Positionals: + name name of the MCP server [string] + args URL for remote servers [array] [default: []] + 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] + --type server type: local or remote [string] [choices: "local", "remote"] + --env environment variable for local servers (KEY=VALUE) [array] + --header HTTP header for remote servers (KEY=VALUE or 'KEY: VALUE') [array] + -g, --global save to global config [boolean] + +Usage: + opencode mcp add -- [args...] (local MCP server) + opencode mcp add --env KEY=VALUE -- [args...] (local MCP server with env vars) + opencode mcp add (remote MCP server) + opencode mcp add --header KEY=VALUE (remote MCP server with headers) + opencode mcp add --global (save to global config) + +Examples: + opencode mcp add context7 -- npx -y @upstash/context7-mcp + opencode mcp add local-env --env FOO=bar -- node server.js + opencode mcp add sg --header Authorization=token https://sg.example/mcp + opencode mcp add hugging-face https://huggingface.co/mcp" `; 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.test.ts b/packages/opencode/test/cli/mcp.test.ts new file mode 100644 index 000000000000..69cd931c34e9 --- /dev/null +++ b/packages/opencode/test/cli/mcp.test.ts @@ -0,0 +1,72 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import path from "node:path" +import { cliIt } from "../lib/cli-process" + +describe("opencode mcp", () => { + cliIt.live( + "adds MCP servers from inline arguments", + ({ home, opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn([ + "mcp", + "add", + "github", + "https://api.githubcopilot.com/mcp", + "--type", + "remote", + "--header", + "Authorization: Bearer test-token", + "--global", + ]) + opencode.expectExit(result, 0, "opencode mcp add remote") + + expect(yield* Effect.promise(() => Bun.file(path.join(home, ".config/opencode/opencode.json")).json())).toEqual( + { + mcp: { + github: { + type: "remote", + url: "https://api.githubcopilot.com/mcp", + headers: { + Authorization: "Bearer test-token", + }, + }, + }, + }, + ) + + const local = yield* opencode.spawn([ + "mcp", + "add", + "everything", + "--env", + "FOO=bar", + "--global", + "--", + "npx", + "-y", + "@modelcontextprotocol/server-everything", + ]) + opencode.expectExit(local, 0, "opencode mcp add local") + + expect( + yield* Effect.promise(() => Bun.file(path.join(home, ".config/opencode/opencode.json")).json()), + ).toMatchObject({ + mcp: { + everything: { + type: "local", + command: ["npx", "-y", "@modelcontextprotocol/server-everything"], + environment: { + FOO: "bar", + }, + }, + }, + }) + + const missingSeparator = yield* opencode.spawn(["mcp", "add", "bad-local", "node", "server.js", "--global"]) + expect(missingSeparator.exitCode).not.toBe(0) + expect(missingSeparator.stderr).toContain("Local MCP commands must be passed after --") + }), + 120_000, + ) +}) diff --git a/packages/web/src/content/docs/mcp-servers.mdx b/packages/web/src/content/docs/mcp-servers.mdx index 1b3006b1cbf2..d2c7b9416024 100644 --- a/packages/web/src/content/docs/mcp-servers.mdx +++ b/packages/web/src/content/docs/mcp-servers.mdx @@ -44,6 +44,24 @@ You can also disable a server by setting `enabled` to `false`. This is useful if --- +## CLI + +You can add MCP servers from the command line with `opencode mcp add`. + +```bash +# Local server +opencode mcp add mcp_everything -- npx -y @modelcontextprotocol/server-everything + +# Remote server +opencode mcp add github https://api.githubcopilot.com/mcp \ + --type remote \ + --header "Authorization: Bearer YOUR_GITHUB_PAT" +``` + +Use `--env KEY=VALUE` for local server environment variables and repeat it for multiple values. Use `--global` to save to global config. + +--- + ### Overriding remote defaults Organizations can provide default MCP servers via their `.well-known/opencode` endpoint. These servers may be disabled by default, allowing users to opt-in to the ones they need.