Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 173 additions & 8 deletions packages/opencode/src/cli/cmd/mcp.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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]))
}
Expand Down Expand Up @@ -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 <name> -- <command> [args...] (local MCP server)
opencode mcp add <name> --env KEY=VALUE -- <command> [args...] (local MCP server with env vars)
opencode mcp add <name> <url> (remote MCP server)
opencode mcp add <name> --header KEY=VALUE <url> (remote MCP server with headers)
opencode mcp add <name> --global <url> (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: [
Expand All @@ -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({
Expand Down Expand Up @@ -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 -- <command>" }
if (input.type === "remote") return { error: "-- <command> 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<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])) }
}

export const McpDebugCommand = effectCmd({
command: "debug <name>",
describe: "debug OAuth connection for an MCP server",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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] [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 <name> debug OAuth connection for an MCP server

Options:
-h, --help show help [boolean]
Expand Down Expand Up @@ -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 <name> -- <command> [args...] (local MCP server)
opencode mcp add <name> --env KEY=VALUE -- <command> [args...] (local MCP server with env vars)
opencode mcp add <name> <url> (remote MCP server)
opencode mcp add <name> --header KEY=VALUE <url> (remote MCP server with headers)
opencode mcp add <name> --global <url> (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`] = `
Expand Down
72 changes: 72 additions & 0 deletions packages/opencode/test/cli/mcp.test.ts
Original file line number Diff line number Diff line change
@@ -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,
)
})
18 changes: 18 additions & 0 deletions packages/web/src/content/docs/mcp-servers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading