feat(opencode): support non-interactive MCP add (#31054)

This commit is contained in:
Aiden Cline 2026-06-05 21:21:20 -05:00 committed by GitHub
parent 3f0ef9b71c
commit ba57718b05
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 151 additions and 5 deletions

View File

@ -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 <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")

View File

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

View File

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