From 2ca7478b8a47d3efdb5d7d50c027f8fabe042729 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 1 Jun 2026 04:23:08 +0000 Subject: [PATCH 1/6] feat(cli): support inline mcp add args --- packages/opencode/src/cli/cmd/mcp.ts | 260 +++++++++++++++++- .../__snapshots__/help-snapshots.test.ts.snap | 42 ++- packages/opencode/test/cli/mcp.test.ts | 89 ++++++ packages/web/src/content/docs/mcp-servers.mdx | 18 ++ 4 files changed, 389 insertions(+), 20 deletions(-) create mode 100644 packages/opencode/test/cli/mcp.test.ts diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index 75e41b3c8a07..cba723f5063d 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,26 @@ function isMcpRemote(config: McpEntry): config is McpRemote { return isMcpConfigured(config) && config.type === "remote" } +type McpAddArgs = { + _?: Array + "--"?: string[] + name?: string + urlOrCommand?: string[] + type?: "local" | "remote" + env?: string[] + header?: string[] + scope?: "project" | "global" + global?: boolean + enabled?: boolean + timeout?: number + oauth?: boolean + oauthClientId?: string + oauthClientSecret?: string + oauthScope?: string + oauthCallbackPort?: number + oauthRedirectUri?: string +} + function configuredServers(config: Config.Info) { return Object.entries(config.mcp ?? {}).filter((entry): entry is [string, McpConfigured] => isMcpConfigured(entry[1])) } @@ -436,27 +456,102 @@ async function addMcpToConfig(name: string, mcpConfig: ConfigMCP.Info, configPat } export const McpAddCommand = effectCmd({ - command: "add", + command: "add [name] [urlOrCommand..]", describe: "add an MCP server", - handler: Effect.fn("Cli.mcp.add")(function* () { + builder: (yargs) => + yargs + .parserConfiguration({ "unknown-options-as-args": true }) + .positional("name", { + describe: "name of the MCP server", + type: "string", + }) + .positional("urlOrCommand", { + describe: "URL for remote servers or command for local 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("scope", { + describe: "where to save the server", + type: "string", + choices: ["project", "global"] as const, + }) + .option("global", { + alias: ["g"], + describe: "save to global config", + type: "boolean", + }) + .option("enabled", { + describe: "enable or disable the server on startup", + type: "boolean", + }) + .option("timeout", { + describe: "timeout in milliseconds for MCP server requests", + type: "number", + }) + .option("oauth", { + describe: "enable OAuth for remote servers, or use --no-oauth to disable auto-detection", + type: "boolean", + }) + .option("oauth-client-id", { + describe: "OAuth client ID for remote servers", + type: "string", + }) + .option("oauth-client-secret", { + describe: "OAuth client secret for remote servers", + type: "string", + }) + .option("oauth-scope", { + describe: "OAuth scopes to request for remote servers", + type: "string", + }) + .option("oauth-callback-port", { + describe: "OAuth local callback port for remote servers", + type: "number", + }) + .option("oauth-redirect-uri", { + describe: "OAuth redirect URI for remote servers", + type: "string", + }), + handler: Effect.fn("Cli.mcp.add")(function* (args: McpAddArgs) { const maybeCtx = yield* InstanceRef if (!maybeCtx) return yield* Effect.die("InstanceRef not provided") const ctx = maybeCtx + const urlOrCommand = mcpAddUrlOrCommand(args) + const inlineConfig = parseInlineMcpAdd(args, urlOrCommand) + if (inlineConfig && "error" in inlineConfig) return yield* fail(inlineConfig.error) + if (args.global && args.scope === "project") return yield* fail("--global cannot be combined with --scope project") 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 (args.global || args.scope === "global") return globalConfigPath + if (args.scope === "project") return projectConfigPath + if (inlineConfig) return project.vcs === "git" ? projectConfigPath : globalConfigPath + if (project.vcs !== "git") return globalConfigPath const scopeResult = await prompts.select({ message: "Location", options: [ @@ -473,7 +568,14 @@ export const McpAddCommand = effectCmd({ ], }) if (prompts.isCancel(scopeResult)) throw new UI.CancelledError() - configPath = scopeResult + return scopeResult + })() + + if (inlineConfig) { + await addMcpToConfig(args.name!.trim(), inlineConfig.config, configPath) + prompts.log.success(`MCP server "${args.name!.trim()}" added to ${configPath}`) + prompts.outro("MCP server added successfully") + return } const name = await prompts.text({ @@ -599,6 +701,148 @@ export const McpAddCommand = effectCmd({ }), }) +function mcpAddUrlOrCommand(args: McpAddArgs) { + const addIndex = args._?.lastIndexOf("add") ?? -1 + return [ + ...(args.urlOrCommand ?? []), + ...(addIndex === -1 || !args._ ? [] : args._.slice(addIndex + 1).map(String)), + ...(args["--"] ?? []), + ] +} + +function parseInlineMcpAdd( + args: McpAddArgs, + urlOrCommand: string[], +): { config: ConfigMCP.Info } | { error: string } | undefined { + if (!hasInlineMcpAdd(args, urlOrCommand)) return undefined + const name = args.name?.trim() + if (!name) return { error: "MCP server name is required" } + if (urlOrCommand.length === 0) return { error: "URL or command is required" } + if (args.timeout !== undefined && (!Number.isInteger(args.timeout) || args.timeout <= 0)) { + return { error: "--timeout must be a positive integer" } + } + + const type = args.type ?? (urlOrCommand.length === 1 && URL.canParse(urlOrCommand[0]) ? "remote" : "local") + if (type === "local") return parseInlineLocalMcp(args, urlOrCommand) + return parseInlineRemoteMcp(args, urlOrCommand) +} + +function hasInlineMcpAdd(args: McpAddArgs, urlOrCommand: string[]) { + return !!( + args.name || + urlOrCommand.length > 0 || + args.type || + args.env?.length || + args.header?.length || + args.enabled !== undefined || + args.timeout !== undefined || + args.oauth !== undefined || + args.oauthClientId || + args.oauthClientSecret || + args.oauthScope || + args.oauthCallbackPort !== undefined || + args.oauthRedirectUri + ) +} + +function parseInlineLocalMcp(args: McpAddArgs, command: string[]): { config: ConfigMCP.Info } | { error: string } { + if (args.header?.length) return { error: "--header can only be used with --type remote" } + if (hasOAuthOptions(args)) return { error: "OAuth options can only be used with --type remote" } + const environment = parseEnv(args.env) + if ("error" in environment) return environment + return { + config: { + type: "local", + command, + ...(environment.value && { environment: environment.value }), + ...(args.enabled !== undefined && { enabled: args.enabled }), + ...(args.timeout !== undefined && { timeout: args.timeout }), + }, + } +} + +function parseInlineRemoteMcp(args: McpAddArgs, url: string[]): { config: ConfigMCP.Info } | { error: string } { + if (url.length !== 1) return { error: "Remote MCP servers require exactly one URL" } + if (!URL.canParse(url[0])) return { error: "Remote MCP server URL is invalid" } + if (args.env?.length) return { error: "--env can only be used with --type local" } + if ( + args.oauthCallbackPort !== undefined && + (!Number.isInteger(args.oauthCallbackPort) || args.oauthCallbackPort < 1 || args.oauthCallbackPort > 65535) + ) { + return { error: "--oauth-callback-port must be an integer between 1 and 65535" } + } + if (args.oauth === false && hasOAuthConfigOptions(args)) { + return { error: "--no-oauth cannot be combined with OAuth options" } + } + const headers = parseHeader(args.header) + if ("error" in headers) return headers + const oauth = parseOAuth(args) + return { + config: { + type: "remote", + url: url[0], + ...(headers.value && { headers: headers.value }), + ...(args.enabled !== undefined && { enabled: args.enabled }), + ...(args.timeout !== undefined && { timeout: args.timeout }), + ...(oauth !== undefined && { oauth }), + }, + } +} + +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])) } +} + +function hasOAuthOptions(args: McpAddArgs) { + return !!(args.oauth !== undefined || hasOAuthConfigOptions(args)) +} + +function hasOAuthConfigOptions(args: McpAddArgs) { + return !!( + args.oauthClientId || + args.oauthClientSecret || + args.oauthScope || + args.oauthCallbackPort !== undefined || + args.oauthRedirectUri + ) +} + +function parseOAuth(args: McpAddArgs): ConfigMCP.Remote["oauth"] | undefined { + if (args.oauth === false) return false + if (!hasOAuthOptions(args)) return undefined + return { + ...(args.oauthClientId && { clientId: args.oauthClientId }), + ...(args.oauthClientSecret && { clientSecret: args.oauthClientSecret }), + ...(args.oauthScope && { scope: args.oauthScope }), + ...(args.oauthCallbackPort !== undefined && { callbackPort: args.oauthCallbackPort }), + ...(args.oauthRedirectUri && { redirectUri: args.oauthRedirectUri }), + } +} + 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..8aee63866d32 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] [urlOrCommand..] 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,34 @@ 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] [urlOrCommand..] add an MCP server -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]" +Positionals: + name name of the MCP server [string] + urlOrCommand URL for remote servers or command for local 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] + --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] + --scope where to save the server [string] [choices: "project", "global"] + -g, --global save to global config [boolean] + --enabled enable or disable the server on startup [boolean] + --timeout timeout in milliseconds for MCP server requests [number] + --oauth enable OAuth for remote servers, or use --no-oauth to disable + auto-detection [boolean] + --oauth-client-id OAuth client ID for remote servers [string] + --oauth-client-secret OAuth client secret for remote servers [string] + --oauth-scope OAuth scopes to request for remote servers [string] + --oauth-callback-port OAuth local callback port for remote servers [number] + --oauth-redirect-uri OAuth redirect URI for remote servers [string]" `; 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..e1b496f76436 --- /dev/null +++ b/packages/opencode/test/cli/mcp.test.ts @@ -0,0 +1,89 @@ +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", + "npx", + "-y", + "@modelcontextprotocol/server-everything", + "--env", + "FOO=bar", + "--global", + ]) + 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 noOAuth = yield* opencode.spawn([ + "mcp", + "add", + "public", + "https://example.com/mcp", + "--no-oauth", + "--global", + ]) + opencode.expectExit(noOAuth, 0, "opencode mcp add no oauth") + + expect( + yield* Effect.promise(() => Bun.file(path.join(home, ".config/opencode/opencode.json")).json()), + ).toMatchObject({ + mcp: { + public: { + type: "remote", + url: "https://example.com/mcp", + oauth: false, + }, + }, + }) + }), + 120_000, + ) +}) diff --git a/packages/web/src/content/docs/mcp-servers.mdx b/packages/web/src/content/docs/mcp-servers.mdx index 1b3006b1cbf2..cc0e7d31aa49 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; otherwise the command saves to the current project when run inside a git repository. + +--- + ### 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. From ebb101bd544c5df21e05896c19a7dd808270dd6b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 1 Jun 2026 04:34:11 +0000 Subject: [PATCH 2/6] refactor(cli): align mcp add args help --- packages/opencode/src/cli/cmd/mcp.ts | 94 +++++++++++-------- .../__snapshots__/help-snapshots.test.ts.snap | 31 ++++-- packages/opencode/test/cli/mcp.test.ts | 7 +- packages/web/src/content/docs/mcp-servers.mdx | 4 +- 4 files changed, 81 insertions(+), 55 deletions(-) diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index cba723f5063d..f5e6fdbccce0 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -59,7 +59,7 @@ type McpAddArgs = { _?: Array "--"?: string[] name?: string - urlOrCommand?: string[] + args?: string[] type?: "local" | "remote" env?: string[] header?: string[] @@ -456,7 +456,7 @@ async function addMcpToConfig(name: string, mcpConfig: ConfigMCP.Info, configPat } export const McpAddCommand = effectCmd({ - command: "add [name] [urlOrCommand..]", + command: "add [name] [args..]", describe: "add an MCP server", builder: (yargs) => yargs @@ -465,8 +465,8 @@ export const McpAddCommand = effectCmd({ describe: "name of the MCP server", type: "string", }) - .positional("urlOrCommand", { - describe: "URL for remote servers or command for local servers", + .positional("args", { + describe: "URL for remote servers or command and arguments for local servers", type: "string", array: true, default: [], @@ -527,15 +527,27 @@ export const McpAddCommand = effectCmd({ .option("oauth-redirect-uri", { describe: "OAuth redirect URI for remote servers", type: "string", - }), - handler: Effect.fn("Cli.mcp.add")(function* (args: McpAddArgs) { + }).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 --scope project -- [args...] (save to project 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: McpAddArgs) { const maybeCtx = yield* InstanceRef if (!maybeCtx) return yield* Effect.die("InstanceRef not provided") const ctx = maybeCtx - const urlOrCommand = mcpAddUrlOrCommand(args) - const inlineConfig = parseInlineMcpAdd(args, urlOrCommand) + const inlineArgs = mcpAddArgs(input) + const inlineConfig = parseInlineMcpAdd(input, inlineArgs) if (inlineConfig && "error" in inlineConfig) return yield* fail(inlineConfig.error) - if (args.global && args.scope === "project") return yield* fail("--global cannot be combined with --scope project") + if (input.global && input.scope === "project") + return yield* fail("--global cannot be combined with --scope project") yield* Effect.promise(async () => { UI.empty() prompts.intro("Add MCP server") @@ -548,8 +560,8 @@ export const McpAddCommand = effectCmd({ ]) const configPath = await (async () => { - if (args.global || args.scope === "global") return globalConfigPath - if (args.scope === "project") return projectConfigPath + if (input.global || input.scope === "global") return globalConfigPath + if (input.scope === "project") return projectConfigPath if (inlineConfig) return project.vcs === "git" ? projectConfigPath : globalConfigPath if (project.vcs !== "git") return globalConfigPath const scopeResult = await prompts.select({ @@ -572,8 +584,8 @@ export const McpAddCommand = effectCmd({ })() if (inlineConfig) { - await addMcpToConfig(args.name!.trim(), inlineConfig.config, configPath) - prompts.log.success(`MCP server "${args.name!.trim()}" added to ${configPath}`) + await addMcpToConfig(input.name!.trim(), inlineConfig.config, configPath) + prompts.log.success(`MCP server "${input.name!.trim()}" added to ${configPath}`) prompts.outro("MCP server added successfully") return } @@ -701,47 +713,47 @@ export const McpAddCommand = effectCmd({ }), }) -function mcpAddUrlOrCommand(args: McpAddArgs) { - const addIndex = args._?.lastIndexOf("add") ?? -1 +function mcpAddArgs(input: McpAddArgs) { + const addIndex = input._?.lastIndexOf("add") ?? -1 return [ - ...(args.urlOrCommand ?? []), - ...(addIndex === -1 || !args._ ? [] : args._.slice(addIndex + 1).map(String)), - ...(args["--"] ?? []), + ...(input.args ?? []), + ...(addIndex === -1 || !input._ ? [] : input._.slice(addIndex + 1).map(String)), + ...(input["--"] ?? []), ] } function parseInlineMcpAdd( - args: McpAddArgs, - urlOrCommand: string[], + input: McpAddArgs, + inlineArgs: string[], ): { config: ConfigMCP.Info } | { error: string } | undefined { - if (!hasInlineMcpAdd(args, urlOrCommand)) return undefined - const name = args.name?.trim() + if (!hasInlineMcpAdd(input, inlineArgs)) return undefined + const name = input.name?.trim() if (!name) return { error: "MCP server name is required" } - if (urlOrCommand.length === 0) return { error: "URL or command is required" } - if (args.timeout !== undefined && (!Number.isInteger(args.timeout) || args.timeout <= 0)) { + if (inlineArgs.length === 0) return { error: "URL or command is required" } + if (input.timeout !== undefined && (!Number.isInteger(input.timeout) || input.timeout <= 0)) { return { error: "--timeout must be a positive integer" } } - const type = args.type ?? (urlOrCommand.length === 1 && URL.canParse(urlOrCommand[0]) ? "remote" : "local") - if (type === "local") return parseInlineLocalMcp(args, urlOrCommand) - return parseInlineRemoteMcp(args, urlOrCommand) + const type = input.type ?? (inlineArgs.length === 1 && URL.canParse(inlineArgs[0]) ? "remote" : "local") + if (type === "local") return parseInlineLocalMcp(input, inlineArgs) + return parseInlineRemoteMcp(input, inlineArgs) } -function hasInlineMcpAdd(args: McpAddArgs, urlOrCommand: string[]) { +function hasInlineMcpAdd(input: McpAddArgs, inlineArgs: string[]) { return !!( - args.name || - urlOrCommand.length > 0 || - args.type || - args.env?.length || - args.header?.length || - args.enabled !== undefined || - args.timeout !== undefined || - args.oauth !== undefined || - args.oauthClientId || - args.oauthClientSecret || - args.oauthScope || - args.oauthCallbackPort !== undefined || - args.oauthRedirectUri + input.name || + inlineArgs.length > 0 || + input.type || + input.env?.length || + input.header?.length || + input.enabled !== undefined || + input.timeout !== undefined || + input.oauth !== undefined || + input.oauthClientId || + input.oauthClientSecret || + input.oauthScope || + input.oauthCallbackPort !== undefined || + input.oauthRedirectUri ) } 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 8aee63866d32..4d3e9690bc89 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 [name] [urlOrCommand..] 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,13 +425,13 @@ Options: `; exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mcp add --help 1`] = ` -"opencode mcp add [name] [urlOrCommand..] +"opencode mcp add [name] [args..] add an MCP server Positionals: - name name of the MCP server [string] - urlOrCommand URL for remote servers or command for local servers [array] [default: []] + name name of the MCP server [string] + args URL for remote servers or command and arguments for local servers [array] [default: []] Options: -h, --help show help [boolean] @@ -452,7 +452,20 @@ Options: --oauth-client-secret OAuth client secret for remote servers [string] --oauth-scope OAuth scopes to request for remote servers [string] --oauth-callback-port OAuth local callback port for remote servers [number] - --oauth-redirect-uri OAuth redirect URI for remote servers [string]" + --oauth-redirect-uri OAuth redirect URI for remote servers [string] + +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 --scope project -- [args...] (save to project 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 index e1b496f76436..b8fee0850f63 100644 --- a/packages/opencode/test/cli/mcp.test.ts +++ b/packages/opencode/test/cli/mcp.test.ts @@ -39,12 +39,13 @@ describe("opencode mcp", () => { "mcp", "add", "everything", - "npx", - "-y", - "@modelcontextprotocol/server-everything", "--env", "FOO=bar", "--global", + "--", + "npx", + "-y", + "@modelcontextprotocol/server-everything", ]) opencode.expectExit(local, 0, "opencode mcp add local") diff --git a/packages/web/src/content/docs/mcp-servers.mdx b/packages/web/src/content/docs/mcp-servers.mdx index cc0e7d31aa49..883336b8f995 100644 --- a/packages/web/src/content/docs/mcp-servers.mdx +++ b/packages/web/src/content/docs/mcp-servers.mdx @@ -50,7 +50,7 @@ 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 +opencode mcp add mcp_everything -- npx -y @modelcontextprotocol/server-everything # Remote server opencode mcp add github https://api.githubcopilot.com/mcp \ @@ -58,7 +58,7 @@ opencode mcp add github https://api.githubcopilot.com/mcp \ --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; otherwise the command saves to the current project when run inside a git repository. +Use `--env KEY=VALUE` for local server environment variables and repeat it for multiple values. Use `--scope project` or `--global` to choose where the server is saved. --- From 7f9fbac33ca4e199b6172c82c96849b6cdceb8a0 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 1 Jun 2026 04:43:57 +0000 Subject: [PATCH 3/6] refactor(cli): simplify mcp add options --- packages/opencode/src/cli/cmd/mcp.ts | 114 +----------------- .../__snapshots__/help-snapshots.test.ts.snap | 30 ++--- packages/opencode/test/cli/mcp.test.ts | 22 ---- packages/web/src/content/docs/mcp-servers.mdx | 2 +- 4 files changed, 14 insertions(+), 154 deletions(-) diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index f5e6fdbccce0..86b7fd8ac5b1 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -63,16 +63,7 @@ type McpAddArgs = { type?: "local" | "remote" env?: string[] header?: string[] - scope?: "project" | "global" global?: boolean - enabled?: boolean - timeout?: number - oauth?: boolean - oauthClientId?: string - oauthClientSecret?: string - oauthScope?: string - oauthCallbackPort?: number - oauthRedirectUri?: string } function configuredServers(config: Config.Info) { @@ -486,53 +477,16 @@ export const McpAddCommand = effectCmd({ type: "string", array: true, }) - .option("scope", { - describe: "where to save the server", - type: "string", - choices: ["project", "global"] as const, - }) .option("global", { alias: ["g"], describe: "save to global config", type: "boolean", - }) - .option("enabled", { - describe: "enable or disable the server on startup", - type: "boolean", - }) - .option("timeout", { - describe: "timeout in milliseconds for MCP server requests", - type: "number", - }) - .option("oauth", { - describe: "enable OAuth for remote servers, or use --no-oauth to disable auto-detection", - type: "boolean", - }) - .option("oauth-client-id", { - describe: "OAuth client ID for remote servers", - type: "string", - }) - .option("oauth-client-secret", { - describe: "OAuth client secret for remote servers", - type: "string", - }) - .option("oauth-scope", { - describe: "OAuth scopes to request for remote servers", - type: "string", - }) - .option("oauth-callback-port", { - describe: "OAuth local callback port for remote servers", - type: "number", - }) - .option("oauth-redirect-uri", { - describe: "OAuth redirect URI for remote servers", - type: "string", }).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 --scope project -- [args...] (save to project config) + opencode mcp add --global (save to global config) Examples: opencode mcp add context7 -- npx -y @upstash/context7-mcp @@ -546,8 +500,6 @@ Examples: const inlineArgs = mcpAddArgs(input) const inlineConfig = parseInlineMcpAdd(input, inlineArgs) if (inlineConfig && "error" in inlineConfig) return yield* fail(inlineConfig.error) - if (input.global && input.scope === "project") - return yield* fail("--global cannot be combined with --scope project") yield* Effect.promise(async () => { UI.empty() prompts.intro("Add MCP server") @@ -560,8 +512,7 @@ Examples: ]) const configPath = await (async () => { - if (input.global || input.scope === "global") return globalConfigPath - if (input.scope === "project") return projectConfigPath + if (input.global) return globalConfigPath if (inlineConfig) return project.vcs === "git" ? projectConfigPath : globalConfigPath if (project.vcs !== "git") return globalConfigPath const scopeResult = await prompts.select({ @@ -730,9 +681,6 @@ function parseInlineMcpAdd( const name = input.name?.trim() if (!name) return { error: "MCP server name is required" } if (inlineArgs.length === 0) return { error: "URL or command is required" } - if (input.timeout !== undefined && (!Number.isInteger(input.timeout) || input.timeout <= 0)) { - return { error: "--timeout must be a positive integer" } - } const type = input.type ?? (inlineArgs.length === 1 && URL.canParse(inlineArgs[0]) ? "remote" : "local") if (type === "local") return parseInlineLocalMcp(input, inlineArgs) @@ -740,26 +688,11 @@ function parseInlineMcpAdd( } function hasInlineMcpAdd(input: McpAddArgs, inlineArgs: string[]) { - return !!( - input.name || - inlineArgs.length > 0 || - input.type || - input.env?.length || - input.header?.length || - input.enabled !== undefined || - input.timeout !== undefined || - input.oauth !== undefined || - input.oauthClientId || - input.oauthClientSecret || - input.oauthScope || - input.oauthCallbackPort !== undefined || - input.oauthRedirectUri - ) + return !!(input.name || inlineArgs.length > 0 || input.type || input.env?.length || input.header?.length) } function parseInlineLocalMcp(args: McpAddArgs, command: string[]): { config: ConfigMCP.Info } | { error: string } { if (args.header?.length) return { error: "--header can only be used with --type remote" } - if (hasOAuthOptions(args)) return { error: "OAuth options can only be used with --type remote" } const environment = parseEnv(args.env) if ("error" in environment) return environment return { @@ -767,8 +700,6 @@ function parseInlineLocalMcp(args: McpAddArgs, command: string[]): { config: Con type: "local", command, ...(environment.value && { environment: environment.value }), - ...(args.enabled !== undefined && { enabled: args.enabled }), - ...(args.timeout !== undefined && { timeout: args.timeout }), }, } } @@ -777,26 +708,13 @@ function parseInlineRemoteMcp(args: McpAddArgs, url: string[]): { config: Config if (url.length !== 1) return { error: "Remote MCP servers require exactly one URL" } if (!URL.canParse(url[0])) return { error: "Remote MCP server URL is invalid" } if (args.env?.length) return { error: "--env can only be used with --type local" } - if ( - args.oauthCallbackPort !== undefined && - (!Number.isInteger(args.oauthCallbackPort) || args.oauthCallbackPort < 1 || args.oauthCallbackPort > 65535) - ) { - return { error: "--oauth-callback-port must be an integer between 1 and 65535" } - } - if (args.oauth === false && hasOAuthConfigOptions(args)) { - return { error: "--no-oauth cannot be combined with OAuth options" } - } const headers = parseHeader(args.header) if ("error" in headers) return headers - const oauth = parseOAuth(args) return { config: { type: "remote", url: url[0], ...(headers.value && { headers: headers.value }), - ...(args.enabled !== undefined && { enabled: args.enabled }), - ...(args.timeout !== undefined && { timeout: args.timeout }), - ...(oauth !== undefined && { oauth }), }, } } @@ -829,32 +747,6 @@ function parseHeader(entries?: string[]): { value?: Record } | { return { value: Object.fromEntries(parsed.map((entry) => [entry.key, entry.value])) } } -function hasOAuthOptions(args: McpAddArgs) { - return !!(args.oauth !== undefined || hasOAuthConfigOptions(args)) -} - -function hasOAuthConfigOptions(args: McpAddArgs) { - return !!( - args.oauthClientId || - args.oauthClientSecret || - args.oauthScope || - args.oauthCallbackPort !== undefined || - args.oauthRedirectUri - ) -} - -function parseOAuth(args: McpAddArgs): ConfigMCP.Remote["oauth"] | undefined { - if (args.oauth === false) return false - if (!hasOAuthOptions(args)) return undefined - return { - ...(args.oauthClientId && { clientId: args.oauthClientId }), - ...(args.oauthClientSecret && { clientSecret: args.oauthClientSecret }), - ...(args.oauthScope && { scope: args.oauthScope }), - ...(args.oauthCallbackPort !== undefined && { callbackPort: args.oauthCallbackPort }), - ...(args.oauthRedirectUri && { redirectUri: args.oauthRedirectUri }), - } -} - 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 4d3e9690bc89..425b5d176de4 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 @@ -434,32 +434,22 @@ Positionals: args URL for remote servers or command and arguments for local 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] - --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] - --scope where to save the server [string] [choices: "project", "global"] - -g, --global save to global config [boolean] - --enabled enable or disable the server on startup [boolean] - --timeout timeout in milliseconds for MCP server requests [number] - --oauth enable OAuth for remote servers, or use --no-oauth to disable - auto-detection [boolean] - --oauth-client-id OAuth client ID for remote servers [string] - --oauth-client-secret OAuth client secret for remote servers [string] - --oauth-scope OAuth scopes to request for remote servers [string] - --oauth-callback-port OAuth local callback port for remote servers [number] - --oauth-redirect-uri OAuth redirect URI for remote servers [string] + -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] + --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 --scope project -- [args...] (save to project config) + opencode mcp add --global (save to global config) Examples: opencode mcp add context7 -- npx -y @upstash/context7-mcp diff --git a/packages/opencode/test/cli/mcp.test.ts b/packages/opencode/test/cli/mcp.test.ts index b8fee0850f63..ac54ad55d36d 100644 --- a/packages/opencode/test/cli/mcp.test.ts +++ b/packages/opencode/test/cli/mcp.test.ts @@ -62,28 +62,6 @@ describe("opencode mcp", () => { }, }, }) - - const noOAuth = yield* opencode.spawn([ - "mcp", - "add", - "public", - "https://example.com/mcp", - "--no-oauth", - "--global", - ]) - opencode.expectExit(noOAuth, 0, "opencode mcp add no oauth") - - expect( - yield* Effect.promise(() => Bun.file(path.join(home, ".config/opencode/opencode.json")).json()), - ).toMatchObject({ - mcp: { - public: { - type: "remote", - url: "https://example.com/mcp", - oauth: false, - }, - }, - }) }), 120_000, ) diff --git a/packages/web/src/content/docs/mcp-servers.mdx b/packages/web/src/content/docs/mcp-servers.mdx index 883336b8f995..d2c7b9416024 100644 --- a/packages/web/src/content/docs/mcp-servers.mdx +++ b/packages/web/src/content/docs/mcp-servers.mdx @@ -58,7 +58,7 @@ opencode mcp add github https://api.githubcopilot.com/mcp \ --header "Authorization: Bearer YOUR_GITHUB_PAT" ``` -Use `--env KEY=VALUE` for local server environment variables and repeat it for multiple values. Use `--scope project` or `--global` to choose where the server is saved. +Use `--env KEY=VALUE` for local server environment variables and repeat it for multiple values. Use `--global` to save to global config. --- From 010d50e993e137e8e7cbdd0d7f9d4fa058a1db6b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 1 Jun 2026 04:47:09 +0000 Subject: [PATCH 4/6] refactor(cli): keep mcp add yargs internals local --- packages/opencode/src/cli/cmd/mcp.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index 86b7fd8ac5b1..5702099e09c5 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -1,4 +1,4 @@ -import { cmd } from "./cmd" +import { cmd, type WithDoubleDash } from "./cmd" import { effectCmd, fail } from "../effect-cmd" import { Cause } from "effect" import { Client } from "@modelcontextprotocol/sdk/client/index.js" @@ -56,8 +56,6 @@ function isMcpRemote(config: McpEntry): config is McpRemote { } type McpAddArgs = { - _?: Array - "--"?: string[] name?: string args?: string[] type?: "local" | "remote" @@ -65,6 +63,7 @@ type McpAddArgs = { header?: string[] global?: boolean } +type McpAddYargs = WithDoubleDash & { _?: Array } function configuredServers(config: Config.Info) { return Object.entries(config.mcp ?? {}).filter((entry): entry is [string, McpConfigured] => isMcpConfigured(entry[1])) @@ -665,11 +664,13 @@ Examples: }) function mcpAddArgs(input: McpAddArgs) { - const addIndex = input._?.lastIndexOf("add") ?? -1 + // For nested variadic commands, yargs puts tokens after `--` in `_` instead of the positional array. + const raw = input as McpAddYargs + const addIndex = raw._?.lastIndexOf("add") ?? -1 return [ ...(input.args ?? []), - ...(addIndex === -1 || !input._ ? [] : input._.slice(addIndex + 1).map(String)), - ...(input["--"] ?? []), + ...(addIndex === -1 || !raw._ ? [] : raw._.slice(addIndex + 1).map(String)), + ...(raw["--"] ?? []), ] } From 8e0bceaea9040d34ecdb27937cb3f7c71bdb349b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 1 Jun 2026 04:57:16 +0000 Subject: [PATCH 5/6] refactor(cli): require mcp command separator --- packages/opencode/src/cli/cmd/mcp.ts | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index 5702099e09c5..73baf8e6f919 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -63,7 +63,6 @@ type McpAddArgs = { header?: string[] global?: boolean } -type McpAddYargs = WithDoubleDash & { _?: Array } function configuredServers(config: Config.Info) { return Object.entries(config.mcp ?? {}).filter((entry): entry is [string, McpConfigured] => isMcpConfigured(entry[1])) @@ -450,7 +449,6 @@ export const McpAddCommand = effectCmd({ describe: "add an MCP server", builder: (yargs) => yargs - .parserConfiguration({ "unknown-options-as-args": true }) .positional("name", { describe: "name of the MCP server", type: "string", @@ -492,7 +490,7 @@ Examples: 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: McpAddArgs) { + handler: Effect.fn("Cli.mcp.add")(function* (input: WithDoubleDash) { const maybeCtx = yield* InstanceRef if (!maybeCtx) return yield* Effect.die("InstanceRef not provided") const ctx = maybeCtx @@ -663,15 +661,8 @@ Examples: }), }) -function mcpAddArgs(input: McpAddArgs) { - // For nested variadic commands, yargs puts tokens after `--` in `_` instead of the positional array. - const raw = input as McpAddYargs - const addIndex = raw._?.lastIndexOf("add") ?? -1 - return [ - ...(input.args ?? []), - ...(addIndex === -1 || !raw._ ? [] : raw._.slice(addIndex + 1).map(String)), - ...(raw["--"] ?? []), - ] +function mcpAddArgs(input: WithDoubleDash) { + return [...(input.args ?? []), ...(input["--"] ?? [])] } function parseInlineMcpAdd( From 7d97c4c0405c2955ba90e2b966073bfaafcb0917 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 1 Jun 2026 05:05:37 +0000 Subject: [PATCH 6/6] refactor(cli): split mcp add url and command parsing --- packages/opencode/src/cli/cmd/mcp.ts | 91 ++++++++++++------- .../__snapshots__/help-snapshots.test.ts.snap | 14 +-- packages/opencode/test/cli/mcp.test.ts | 4 + 3 files changed, 69 insertions(+), 40 deletions(-) diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index 73baf8e6f919..3610d94c88ca 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -1,4 +1,4 @@ -import { cmd, type WithDoubleDash } from "./cmd" +import { cmd } from "./cmd" import { effectCmd, fail } from "../effect-cmd" import { Cause } from "effect" import { Client } from "@modelcontextprotocol/sdk/client/index.js" @@ -64,6 +64,15 @@ type McpAddArgs = { 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])) } @@ -445,7 +454,7 @@ async function addMcpToConfig(name: string, mcpConfig: ConfigMCP.Info, configPat } export const McpAddCommand = effectCmd({ - command: "add [name] [args..]", + command: "add [name] [args...]", describe: "add an MCP server", builder: (yargs) => yargs @@ -454,7 +463,7 @@ export const McpAddCommand = effectCmd({ type: "string", }) .positional("args", { - describe: "URL for remote servers or command and arguments for local servers", + describe: "URL for remote servers", type: "string", array: true, default: [], @@ -490,12 +499,18 @@ Examples: 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: WithDoubleDash) { + 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 inlineArgs = mcpAddArgs(input) - const inlineConfig = parseInlineMcpAdd(input, inlineArgs) + 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() @@ -532,8 +547,8 @@ Examples: })() if (inlineConfig) { - await addMcpToConfig(input.name!.trim(), inlineConfig.config, configPath) - prompts.log.success(`MCP server "${input.name!.trim()}" added to ${configPath}`) + await addMcpToConfig(inlineConfig.name, inlineConfig.config, configPath) + prompts.log.success(`MCP server "${inlineConfig.name}" added to ${configPath}`) prompts.outro("MCP server added successfully") return } @@ -661,51 +676,61 @@ Examples: }), }) -function mcpAddArgs(input: WithDoubleDash) { - return [...(input.args ?? []), ...(input["--"] ?? [])] -} - function parseInlineMcpAdd( - input: McpAddArgs, - inlineArgs: string[], -): { config: ConfigMCP.Info } | { error: string } | undefined { - if (!hasInlineMcpAdd(input, inlineArgs)) return undefined + 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" } - if (inlineArgs.length === 0) return { error: "URL or command is required" } - - const type = input.type ?? (inlineArgs.length === 1 && URL.canParse(inlineArgs[0]) ? "remote" : "local") - if (type === "local") return parseInlineLocalMcp(input, inlineArgs) - return parseInlineRemoteMcp(input, inlineArgs) + const result = input.command.length > 0 ? parseInlineLocalMcp(input) : parseInlineRemoteMcp(input) + if ("error" in result) return result + return { name, config: result.config } } -function hasInlineMcpAdd(input: McpAddArgs, inlineArgs: string[]) { - return !!(input.name || inlineArgs.length > 0 || input.type || input.env?.length || input.header?.length) +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(args: McpAddArgs, command: string[]): { config: ConfigMCP.Info } | { error: string } { - if (args.header?.length) return { error: "--header can only be used with --type remote" } - const environment = parseEnv(args.env) +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, + command: input.command, ...(environment.value && { environment: environment.value }), }, } } -function parseInlineRemoteMcp(args: McpAddArgs, url: string[]): { config: ConfigMCP.Info } | { error: string } { - if (url.length !== 1) return { error: "Remote MCP servers require exactly one URL" } - if (!URL.canParse(url[0])) return { error: "Remote MCP server URL is invalid" } - if (args.env?.length) return { error: "--env can only be used with --type local" } - const headers = parseHeader(args.header) +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: url[0], + url: input.positional[0], ...(headers.value && { headers: headers.value }), }, } 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 425b5d176de4..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 [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 + 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,13 +425,13 @@ Options: `; exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mcp add --help 1`] = ` -"opencode mcp add [name] [args..] +"opencode mcp add [name] [args...] add an MCP server Positionals: name name of the MCP server [string] - args URL for remote servers or command and arguments for local servers [array] [default: []] + args URL for remote servers [array] [default: []] Options: -h, --help show help [boolean] diff --git a/packages/opencode/test/cli/mcp.test.ts b/packages/opencode/test/cli/mcp.test.ts index ac54ad55d36d..69cd931c34e9 100644 --- a/packages/opencode/test/cli/mcp.test.ts +++ b/packages/opencode/test/cli/mcp.test.ts @@ -62,6 +62,10 @@ describe("opencode mcp", () => { }, }, }) + + 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, )