feat(cli): support inline mcp add args

This commit is contained in:
opencode-agent[bot] 2026-06-01 04:23:08 +00:00
parent a9c115c220
commit 2ca7478b8a
4 changed files with 388 additions and 19 deletions

View file

@ -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 | number>
"--"?: 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<string, string> } | { 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<string, string> } | { 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 <name>",
describe: "debug OAuth connection for an MCP server",

View file

@ -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 <name> 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 <name> 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
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]"
-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`] = `

View file

@ -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,
)
})

View file

@ -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.