This commit is contained in:
Netanel Draiman 2025-07-06 23:49:52 -04:00 committed by GitHub
commit a576123e91
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1480 additions and 0 deletions

View file

@ -288,6 +288,13 @@
"enabled": {
"type": "boolean",
"description": "Enable or disable the MCP server on startup"
},
"headers": {
"type": "object",
"additionalProperties": {
"type": "string"
},
"description": "HTTP headers for remote connection"
}
},
"required": ["type", "url"],

View file

@ -0,0 +1,574 @@
import { cmd } from "./cmd"
import { Config } from "../../config/config"
import { UI } from "../ui"
import { Global } from "../../global"
import path from "path"
import { z } from "zod"
async function loadProjectConfig(configPath: string): Promise<Config.Info> {
try {
const file = Bun.file(configPath)
const data = await file.json()
return data
} catch (error) {
// If file doesn't exist, return empty config
return {}
}
}
export const McpCommand = cmd({
command: "mcp [command]",
describe: "Configure and manage MCP servers",
builder: (yargs) => {
const configured = yargs
.command(McpAddCommand)
.command(McpRemoveCommand)
.command(McpListCommand)
.command(McpGetCommand)
.command(McpAddJsonCommand)
.command(McpEnableCommand)
.command(McpDisableCommand)
.demandCommand()
.help()
return configured
},
handler: async () => {},
})
export const McpAddCommand = cmd({
command: "add <name> <commandOrUrl> [args...]",
describe: "Add a server",
builder: (yargs) =>
yargs
.positional("name", {
type: "string",
describe: "Name of the MCP server",
demandOption: true,
})
.positional("commandOrUrl", {
type: "string",
describe: "Command to run (for stdio) or URL (for SSE)",
demandOption: true,
})
.positional("args", {
type: "string",
array: true,
describe: "Additional arguments for stdio command",
default: [],
})
.option("scope", {
alias: "s",
type: "string",
choices: ["user", "project"] as const,
default: "project",
describe: "Configuration scope (user, or project)",
})
.option("transport", {
alias: "t",
type: "string",
choices: ["stdio", "sse"] as const,
default: "stdio",
describe: "Transport type (stdio, sse)",
})
.option("env", {
alias: "e",
type: "string",
array: true,
describe: "Set environment variables (e.g. -e KEY=value)",
default: [],
})
.option("header", {
alias: "H",
type: "string",
array: true,
describe:
'Set HTTP headers for SSE transport (e.g. -H "X-Api-Key: abc123")',
default: [],
}),
handler: async (args) => {
// Parse environment variables
const environment: Record<string, string> = {}
for (const envVar of args.env) {
const [key, ...valueParts] = envVar.split("=")
if (!key || valueParts.length === 0) {
UI.error(
`Invalid environment variable format: ${envVar}. Use KEY=VALUE format.`,
)
return
}
environment[key] = valueParts.join("=")
}
// Parse headers
const headers: Record<string, string> = {}
for (const header of args.header) {
const [key, ...valueParts] = header.split(":")
if (!key || valueParts.length === 0) {
UI.error(`Invalid header format: ${header}. Use "Key: Value" format.`)
return
}
headers[key.trim()] = valueParts.join(":").trim()
}
// Determine server type based on transport and URL
const serverType = args.transport === "stdio" ? "local" : "remote"
const isUrl =
args.commandOrUrl.startsWith("http://") ||
args.commandOrUrl.startsWith("https://")
// Validate transport constraints
if (args.transport === "stdio") {
if (isUrl) {
UI.error("stdio transport requires a command, not a URL")
return
}
if (Object.keys(headers).length > 0) {
UI.error("stdio transport doesn't support headers")
return
}
} else {
// sse transport
if (!isUrl) {
UI.error(`${args.transport} transport requires a URL`)
return
}
if (args.args.length > 0) {
UI.error(
`${args.transport} transport doesn't accept additional arguments`,
)
return
}
if (Object.keys(environment).length > 0) {
UI.error(
`${args.transport} transport doesn't support environment variables`,
)
return
}
}
// Create config
const mcpConfig: Config.Mcp =
serverType === "remote"
? {
type: "remote",
url: args.commandOrUrl,
...(Object.keys(headers).length > 0 && { headers }),
}
: {
type: "local",
command: [args.commandOrUrl, ...args.args],
...(Object.keys(environment).length > 0 && { environment }),
}
// Determine config path based on scope
const configPath =
args.scope === "user"
? path.join(Global.Path.config, "config.json")
: path.join(process.cwd(), "opencode.json")
// Load current config
const currentConfig =
args.scope === "user"
? await Config.global()
: await loadProjectConfig(configPath)
const updatedConfig = {
...currentConfig,
mcp: {
...currentConfig.mcp,
[args.name]: mcpConfig,
},
}
await Bun.write(configPath, JSON.stringify(updatedConfig, null, 2))
UI.println(
`Added MCP server "${args.name}" (${args.transport}) to ${args.scope} config`,
)
},
})
export const McpRemoveCommand = cmd({
command: "remove <name>",
describe: "Remove an MCP server",
builder: (yargs) =>
yargs
.positional("name", {
type: "string",
describe: "Name of the MCP server to remove",
demandOption: true,
})
.option("scope", {
alias: "s",
type: "string",
choices: ["user", "project"] as const,
default: "project",
describe: "Configuration scope (user, or project)",
}),
handler: async (args) => {
// Determine config path based on scope
const configPath =
args.scope === "user"
? path.join(Global.Path.config, "config.json")
: path.join(process.cwd(), "opencode.json")
// Load current config
const currentConfig =
args.scope === "user"
? await Config.global()
: await loadProjectConfig(configPath)
if (!currentConfig.mcp || !currentConfig.mcp[args.name]) {
UI.error(`MCP server "${args.name}" not found in ${args.scope} config`)
return
}
const { [args.name]: removed, ...remainingMcp } = currentConfig.mcp
const updatedConfig = {
...currentConfig,
mcp: Object.keys(remainingMcp).length > 0 ? remainingMcp : undefined,
}
await Bun.write(configPath, JSON.stringify(updatedConfig, null, 2))
UI.println(`Removed MCP server "${args.name}" from ${args.scope} config`)
},
})
export const McpListCommand = cmd({
command: "list",
describe: "List configured MCP servers",
handler: async () => {
const globalConfig = await Config.global()
const projectConfigPath = path.join(process.cwd(), "opencode.json")
const projectConfig = await loadProjectConfig(projectConfigPath)
const hasGlobalServers =
globalConfig.mcp && Object.keys(globalConfig.mcp).length > 0
const hasProjectServers =
projectConfig.mcp && Object.keys(projectConfig.mcp).length > 0
if (!hasGlobalServers && !hasProjectServers) {
UI.println("No MCP servers configured")
return
}
// Display global servers
if (hasGlobalServers) {
UI.println("Global MCP servers:")
UI.empty()
for (const [name, mcpConfig] of Object.entries(globalConfig.mcp!)) {
const status = mcpConfig.enabled === false ? " (disabled)" : ""
UI.println(` ${name} (${mcpConfig.type})${status}`)
if (mcpConfig.type === "local") {
UI.println(` Command: ${mcpConfig.command.join(" ")}`)
if (
mcpConfig.environment &&
Object.keys(mcpConfig.environment).length > 0
) {
UI.println(` Environment:`)
for (const [key, value] of Object.entries(mcpConfig.environment)) {
UI.println(` ${key}=${value}`)
}
}
} else {
UI.println(` URL: ${mcpConfig.url}`)
}
UI.empty()
}
}
// Display project servers
if (hasProjectServers) {
UI.println("Project MCP servers:")
UI.empty()
for (const [name, mcpConfig] of Object.entries(projectConfig.mcp!)) {
const status = mcpConfig.enabled === false ? " (disabled)" : ""
UI.println(` ${name} (${mcpConfig.type})${status}`)
if (mcpConfig.type === "local") {
UI.println(` Command: ${mcpConfig.command.join(" ")}`)
if (
mcpConfig.environment &&
Object.keys(mcpConfig.environment).length > 0
) {
UI.println(` Environment:`)
for (const [key, value] of Object.entries(mcpConfig.environment)) {
UI.println(` ${key}=${value}`)
}
}
} else {
UI.println(` URL: ${mcpConfig.url}`)
}
UI.empty()
}
}
},
})
export const McpGetCommand = cmd({
command: "get <name>",
describe: "Get details about an MCP server",
builder: (yargs) =>
yargs.positional("name", {
type: "string",
describe: "Name of the MCP server",
demandOption: true,
}),
handler: async (args) => {
const globalConfig = await Config.global()
const projectConfigPath = path.join(process.cwd(), "opencode.json")
const projectConfig = await loadProjectConfig(projectConfigPath)
let foundConfig: Config.Mcp | null = null
let foundScope: string | null = null
// Check project config first (takes priority)
if (projectConfig.mcp && projectConfig.mcp[args.name]) {
foundConfig = projectConfig.mcp[args.name]
foundScope = "project"
}
// Then check global config
else if (globalConfig.mcp && globalConfig.mcp[args.name]) {
foundConfig = globalConfig.mcp[args.name]
foundScope = "user"
}
if (!foundConfig || !foundScope) {
UI.error(`MCP server "${args.name}" not found`)
return
}
UI.println(`MCP Server: ${args.name}`)
UI.println(`Scope: ${foundScope}`)
UI.println(`Type: ${foundConfig.type}`)
UI.println(`Enabled: ${foundConfig.enabled !== false ? "true" : "false"}`)
if (foundConfig.type === "local") {
UI.println(`Command: ${foundConfig.command.join(" ")}`)
if (
foundConfig.environment &&
Object.keys(foundConfig.environment).length > 0
) {
UI.println(`Environment variables:`)
for (const [key, value] of Object.entries(foundConfig.environment)) {
UI.println(` ${key}=${value}`)
}
}
} else {
UI.println(`URL: ${foundConfig.url}`)
if (foundConfig.headers && Object.keys(foundConfig.headers).length > 0) {
UI.println(`Headers:`)
for (const [key, value] of Object.entries(foundConfig.headers)) {
UI.println(` ${key}: ${value}`)
}
}
}
},
})
export const McpAddJsonCommand = cmd({
command: "add-json <name> <json>",
describe: "Add an MCP server (stdio or SSE) with a JSON string",
builder: (yargs) =>
yargs
.positional("name", {
type: "string",
describe: "Name of the MCP server",
demandOption: true,
})
.positional("json", {
type: "string",
describe: "JSON configuration for the MCP server",
demandOption: true,
})
.option("scope", {
alias: "s",
type: "string",
choices: ["user", "project"] as const,
default: "project",
describe: "Configuration scope (user, or project)",
}),
handler: async (args) => {
try {
const jsonConfig = JSON.parse(args.json)
// Infer type and transform to match schema
let mcpConfig
if ("command" in jsonConfig) {
// Transform stdio transport format
const { type, command, args, env, ...rest } = jsonConfig
// Build command array
const commandArray = Array.isArray(command) ? command : [command]
if (args && Array.isArray(args)) {
commandArray.push(...args)
}
mcpConfig = Config.Mcp.parse({
type: "local",
command: commandArray,
...(env && { environment: env }),
...rest,
})
} else if ("url" in jsonConfig) {
// Transform sse transport format
const { type, ...rest } = jsonConfig
mcpConfig = Config.Mcp.parse({
type: "remote",
...rest,
})
} else {
UI.error(
"Invalid MCP configuration: Unable to determine transport type from JSON. Must include either 'command' for stdio or 'url' for sse.",
)
return
}
// Determine config path based on scope
const configPath =
args.scope === "user"
? path.join(Global.Path.config, "config.json")
: path.join(process.cwd(), "opencode.json")
// Load current config
const currentConfig =
args.scope === "user"
? await Config.global()
: await loadProjectConfig(configPath)
const updatedConfig = {
...currentConfig,
mcp: {
...currentConfig.mcp,
[args.name]: mcpConfig,
},
}
await Bun.write(configPath, JSON.stringify(updatedConfig, null, 2))
UI.println(
`Added MCP server "${args.name}" (${mcpConfig.type}) to ${args.scope} config`,
)
} catch (error) {
if (error instanceof SyntaxError) {
UI.error(`Invalid JSON: ${error.message}`)
return
}
if (error instanceof z.ZodError) {
UI.error(`Invalid MCP configuration:`)
for (const issue of error.issues) {
UI.error(` ${issue.path.join(".")}: ${issue.message}`)
}
return
}
UI.error(`Failed to add MCP server: ${error}`)
}
},
})
export const McpEnableCommand = cmd({
command: "enable <name>",
describe: "Enable an MCP server",
builder: (yargs) =>
yargs
.positional("name", {
type: "string",
describe: "Name of the MCP server to enable",
demandOption: true,
})
.option("scope", {
alias: "s",
type: "string",
choices: ["user", "project"] as const,
default: "project",
describe: "Configuration scope (user, or project)",
}),
handler: async (args) => {
// Determine config path based on scope
const configPath =
args.scope === "user"
? path.join(Global.Path.config, "config.json")
: path.join(process.cwd(), "opencode.json")
// Load current config
const currentConfig =
args.scope === "user"
? await Config.global()
: await loadProjectConfig(configPath)
if (!currentConfig.mcp || !currentConfig.mcp[args.name]) {
UI.error(`MCP server "${args.name}" not found in ${args.scope} config`)
return
}
const updatedConfig = {
...currentConfig,
mcp: {
...currentConfig.mcp,
[args.name]: {
...currentConfig.mcp[args.name],
enabled: true,
},
},
}
await Bun.write(configPath, JSON.stringify(updatedConfig, null, 2))
UI.println(`Enabled MCP server "${args.name}" in ${args.scope} config`)
},
})
export const McpDisableCommand = cmd({
command: "disable <name>",
describe: "Disable an MCP server",
builder: (yargs) =>
yargs
.positional("name", {
type: "string",
describe: "Name of the MCP server to disable",
demandOption: true,
})
.option("scope", {
alias: "s",
type: "string",
choices: ["user", "project"] as const,
default: "project",
describe: "Configuration scope (user, or project)",
}),
handler: async (args) => {
// Determine config path based on scope
const configPath =
args.scope === "user"
? path.join(Global.Path.config, "config.json")
: path.join(process.cwd(), "opencode.json")
// Load current config
const currentConfig =
args.scope === "user"
? await Config.global()
: await loadProjectConfig(configPath)
if (!currentConfig.mcp || !currentConfig.mcp[args.name]) {
UI.error(`MCP server "${args.name}" not found in ${args.scope} config`)
return
}
const updatedConfig = {
...currentConfig,
mcp: {
...currentConfig.mcp,
[args.name]: {
...currentConfig.mcp[args.name],
enabled: false,
},
},
}
await Bun.write(configPath, JSON.stringify(updatedConfig, null, 2))
UI.println(`Disabled MCP server "${args.name}" in ${args.scope} config`)
},
})

View file

@ -55,6 +55,10 @@ export namespace Config {
.boolean()
.optional()
.describe("Enable or disable the MCP server on startup"),
headers: z
.record(z.string(), z.string())
.optional()
.describe("HTTP headers for remote connection"),
})
.strict()
.openapi({

View file

@ -7,6 +7,7 @@ import { Log } from "./util/log"
import { AuthCommand } from "./cli/cmd/auth"
import { UpgradeCommand } from "./cli/cmd/upgrade"
import { ModelsCommand } from "./cli/cmd/models"
import { McpCommand } from "./cli/cmd/mcp"
import { UI } from "./cli/ui"
import { Installation } from "./installation"
import { NamedError } from "./util/error"
@ -54,6 +55,7 @@ const cli = yargs(hideBin(process.argv))
.command(UpgradeCommand)
.command(ServeCommand)
.command(ModelsCommand)
.command(McpCommand)
.fail((msg) => {
if (
msg.startsWith("Unknown argument") ||

View file

@ -37,6 +37,7 @@ export namespace MCP {
transport: {
type: "sse",
url: mcp.url,
...(mcp.headers && { headers: mcp.headers }),
},
}).catch(() => {})
if (!client) {

View file

@ -0,0 +1,774 @@
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
import {
McpAddCommand,
McpRemoveCommand,
McpListCommand,
McpGetCommand,
McpAddJsonCommand,
McpEnableCommand,
McpDisableCommand,
} from "../../src/cli/cmd/mcp"
import { Config } from "../../src/config/config"
import { Global } from "../../src/global"
import path from "path"
import fs from "fs"
const testConfigDir = path.join(process.cwd(), "test-config")
const testConfigPath = path.join(testConfigDir, "config.json")
const testProjectDir = path.join(process.cwd(), "test-project")
const testProjectConfigPath = path.join(testProjectDir, "opencode.json")
let originalCwd: string
let originalGlobalPath: string
let configGlobalSpy: ReturnType<typeof spyOn> | undefined
let configGetSpy: ReturnType<typeof spyOn> | undefined
// Helper to capture stderr output
function captureStderr() {
const outputs: string[] = []
const spy = spyOn(Bun.stderr, "write").mockImplementation(async (data) => {
if (typeof data === "string") {
outputs.push(data)
} else if (data instanceof Uint8Array) {
outputs.push(new TextDecoder().decode(data))
} else {
outputs.push(String(data))
}
return typeof data === "string" ? data.length : 0
})
return {
getOutput: () => outputs.join(""),
restore: () => spy.mockRestore(),
}
}
beforeEach(async () => {
// Create test directories
await fs.promises.mkdir(testConfigDir, { recursive: true })
await fs.promises.mkdir(testProjectDir, { recursive: true })
// Store original values
originalGlobalPath = Global.Path.config
originalCwd = process.cwd()
// Mock Global.Path.config to use test directory
// @ts-expect-error - Mocking for tests
Global.Path.config = testConfigDir
// Mock process.cwd to use test project directory
process.cwd = () => testProjectDir
// Create empty configs with proper structure
await Bun.write(testConfigPath, JSON.stringify({}, null, 2))
await Bun.write(testProjectConfigPath, JSON.stringify({}, null, 2))
// Spy on Config.global to use test config
configGlobalSpy = spyOn(Config, "global").mockImplementation(async () => {
try {
const file = Bun.file(testConfigPath)
const data = await file.json()
return data
} catch (error) {
return {}
}
})
// Spy on Config.get to use test configs (merged global + project)
configGetSpy = spyOn(Config, "get").mockImplementation(async () => {
const globalConfig = await configGlobalSpy!()
let projectConfig = {}
try {
const file = Bun.file(testProjectConfigPath)
projectConfig = await file.json()
} catch (error) {
// Project config doesn't exist yet
}
// Simple merge - project config takes precedence
return { ...globalConfig, ...projectConfig }
})
})
afterEach(async () => {
// Restore original values
process.cwd = () => originalCwd
// @ts-expect-error - Restoring mocked value
Global.Path.config = originalGlobalPath
// Restore spies
configGlobalSpy?.mockRestore()
configGetSpy?.mockRestore()
// Clean up test directories
await fs.promises.rm(testConfigDir, { recursive: true, force: true })
await fs.promises.rm(testProjectDir, { recursive: true, force: true })
})
describe("mcp command", () => {
test("add local MCP server", async () => {
const args = {
name: "test-server",
commandOrUrl: "node",
args: ["server.js"],
scope: "project" as const,
transport: "stdio" as const,
env: ["NODE_ENV=test", "PORT=3000"],
header: [],
_: [],
$0: "opencode",
}
await McpAddCommand.handler(args)
// Read project config
const projectConfig = await Config.get()
expect(projectConfig.mcp).toBeDefined()
expect(projectConfig.mcp?.["test-server"]).toMatchObject({
type: "local",
command: ["node", "server.js"],
environment: {
NODE_ENV: "test",
PORT: "3000",
},
})
})
test("add remote MCP server with SSE", async () => {
const args = {
name: "remote-server",
commandOrUrl: "https://example.com/mcp",
args: [],
scope: "user" as const,
transport: "sse" as const,
env: [],
header: ["Authorization: Bearer token123"],
_: [],
$0: "opencode",
}
await McpAddCommand.handler(args)
const config = await Config.global()
expect(config.mcp).toBeDefined()
expect(config.mcp?.["remote-server"]).toMatchObject({
type: "remote",
url: "https://example.com/mcp",
headers: {
Authorization: "Bearer token123",
},
})
})
test("add SSE transport server to project scope", async () => {
const args = {
name: "sse-server",
commandOrUrl: "http://localhost:8080/mcp",
args: [],
scope: "project" as const,
transport: "sse" as const,
env: [],
header: ["X-API-Key: abc123", "Content-Type: application/json"],
_: [],
$0: "opencode",
}
await McpAddCommand.handler(args)
const projectConfig = await Config.get()
expect(projectConfig.mcp?.["sse-server"]).toMatchObject({
type: "remote",
url: "http://localhost:8080/mcp",
headers: {
"X-API-Key": "abc123",
"Content-Type": "application/json",
},
})
})
test("validate transport constraints", async () => {
const stderr = captureStderr()
// Test stdio with URL should fail
const args = {
name: "invalid-stdio",
commandOrUrl: "https://example.com",
args: [],
scope: "project" as const,
transport: "stdio" as const,
env: [],
header: [],
_: [],
$0: "opencode",
}
await McpAddCommand.handler(args)
expect(stderr.getOutput()).toContain(
"stdio transport requires a command, not a URL",
)
stderr.restore()
})
test("add server to user scope", async () => {
const args = {
name: "user-server",
commandOrUrl: "bun",
args: ["run", "server.ts"],
scope: "user" as const,
transport: "stdio" as const,
env: ["DEBUG=true"],
header: [],
_: [],
$0: "opencode",
}
await McpAddCommand.handler(args)
const config = await Config.global()
expect(config.mcp).toBeDefined()
expect(config.mcp?.["user-server"]).toMatchObject({
type: "local",
command: ["bun", "run", "server.ts"],
environment: {
DEBUG: "true",
},
})
})
test("remove MCP server from project scope", async () => {
// First add a server to project config
await Bun.write(
testProjectConfigPath,
JSON.stringify(
{
mcp: {
"test-server": {
type: "local",
command: ["node", "server.js"],
},
},
},
null,
2,
),
)
const args = {
name: "test-server",
scope: "project" as const,
_: [],
$0: "opencode",
}
await McpRemoveCommand.handler(args)
const projectConfig = await Config.get()
expect(projectConfig.mcp).toBeUndefined()
})
test("remove MCP server from user scope", async () => {
// First add a server to user config
await Bun.write(
testConfigPath,
JSON.stringify(
{
mcp: {
"user-test-server": {
type: "local",
command: ["node", "server.js"],
},
},
},
null,
2,
),
)
const args = {
name: "user-test-server",
scope: "user" as const,
_: [],
$0: "opencode",
}
await McpRemoveCommand.handler(args)
const config = await Config.global()
expect(config.mcp).toBeUndefined()
})
test("remove non-existent MCP server shows error", async () => {
const stderr = captureStderr()
const args = {
name: "non-existent",
scope: "project" as const,
_: [],
$0: "opencode",
}
await McpRemoveCommand.handler(args)
expect(stderr.getOutput()).toContain(
'MCP server "non-existent" not found in project config',
)
stderr.restore()
})
test("add server with JSON to project scope", async () => {
const args = {
name: "json-server",
scope: "project" as const,
json: JSON.stringify({
type: "local",
command: ["bun", "run", "mcp-server.ts"],
environment: { DEBUG: "true" },
}),
_: [],
$0: "opencode",
}
await McpAddJsonCommand.handler(args)
const projectConfig = await Config.get()
expect(projectConfig.mcp?.["json-server"]).toMatchObject({
type: "local",
command: ["bun", "run", "mcp-server.ts"],
environment: { DEBUG: "true" },
})
})
test("add server with JSON to user scope", async () => {
const args = {
name: "user-json-server",
scope: "user" as const,
json: JSON.stringify({
type: "remote",
url: "https://example.com/mcp",
headers: { Authorization: "Bearer token" },
}),
_: [],
$0: "opencode",
}
await McpAddJsonCommand.handler(args)
const config = await Config.global()
expect(config.mcp?.["user-json-server"]).toMatchObject({
type: "remote",
url: "https://example.com/mcp",
headers: { Authorization: "Bearer token" },
})
})
test("list empty MCP servers", async () => {
const stderr = captureStderr()
await McpListCommand.handler({ $0: "opencode", _: [] })
expect(stderr.getOutput()).toContain("No MCP servers configured")
stderr.restore()
})
test("list global and project MCP servers", async () => {
// Add a server to user config
await Bun.write(
testConfigPath,
JSON.stringify(
{
mcp: {
"global-server": {
type: "local",
command: ["node", "global-server.js"],
},
},
},
null,
2,
),
)
// Add a server to project config
await Bun.write(
testProjectConfigPath,
JSON.stringify(
{
mcp: {
"project-server": {
type: "remote",
url: "https://example.com/mcp",
},
},
},
null,
2,
),
)
const stderr = captureStderr()
await McpListCommand.handler({ $0: "opencode", _: [] })
const output = stderr.getOutput()
expect(output).toContain("Global MCP servers:")
expect(output).toContain("Project MCP servers:")
expect(output).toContain("global-server")
expect(output).toContain("project-server")
stderr.restore()
})
test("get MCP server details from user scope", async () => {
// Add a server to user config
await Bun.write(
testConfigPath,
JSON.stringify(
{
mcp: {
"detail-server": {
type: "local",
command: ["node", "server.js"],
environment: { PORT: "3000" },
},
},
},
null,
2,
),
)
const stderr = captureStderr()
const args = {
name: "detail-server",
_: [],
$0: "opencode",
}
await McpGetCommand.handler(args)
const output = stderr.getOutput()
expect(output).toContain("MCP Server: detail-server")
expect(output).toContain("Scope: user")
expect(output).toContain("Type: local")
expect(output).toContain("Enabled: true")
stderr.restore()
})
test("get MCP server details from project scope", async () => {
// Add a server to project config
await Bun.write(
testProjectConfigPath,
JSON.stringify(
{
mcp: {
"project-server": {
type: "remote",
url: "https://example.com/mcp",
headers: { Authorization: "Bearer token" },
},
},
},
null,
2,
),
)
const stderr = captureStderr()
const args = {
name: "project-server",
_: [],
$0: "opencode",
}
await McpGetCommand.handler(args)
const output = stderr.getOutput()
expect(output).toContain("MCP Server: project-server")
expect(output).toContain("Scope: project")
expect(output).toContain("Type: remote")
expect(output).toContain("Enabled: true")
expect(output).toContain("Headers:")
stderr.restore()
})
test("get MCP server prioritizes project over user scope", async () => {
// Add server with same name to both configs
await Bun.write(
testConfigPath,
JSON.stringify(
{
mcp: {
"shared-server": {
type: "local",
command: ["node", "user-server.js"],
},
},
},
null,
2,
),
)
await Bun.write(
testProjectConfigPath,
JSON.stringify(
{
mcp: {
"shared-server": {
type: "local",
command: ["node", "project-server.js"],
},
},
},
null,
2,
),
)
const stderr = captureStderr()
const args = {
name: "shared-server",
_: [],
$0: "opencode",
}
await McpGetCommand.handler(args)
const output = stderr.getOutput()
expect(output).toContain("Scope: project")
expect(output).toContain("project-server.js")
expect(output).not.toContain("user-server.js")
stderr.restore()
})
test("enable MCP server in project scope", async () => {
// First add a disabled server to project config
await Bun.write(
testProjectConfigPath,
JSON.stringify(
{
mcp: {
"disabled-server": {
type: "local",
command: ["node", "server.js"],
enabled: false,
},
},
},
null,
2,
),
)
const args = {
name: "disabled-server",
scope: "project" as const,
_: [],
$0: "opencode",
}
await McpEnableCommand.handler(args)
const projectConfig = await Config.get()
expect(projectConfig.mcp?.["disabled-server"].enabled).toBe(true)
})
test("enable MCP server in user scope", async () => {
// First add a disabled server to user config
await Bun.write(
testConfigPath,
JSON.stringify(
{
mcp: {
"user-disabled-server": {
type: "remote",
url: "https://example.com/mcp",
enabled: false,
},
},
},
null,
2,
),
)
const args = {
name: "user-disabled-server",
scope: "user" as const,
_: [],
$0: "opencode",
}
await McpEnableCommand.handler(args)
const config = await Config.global()
expect(config.mcp?.["user-disabled-server"].enabled).toBe(true)
})
test("disable MCP server in project scope", async () => {
// First add an enabled server to project config
await Bun.write(
testProjectConfigPath,
JSON.stringify(
{
mcp: {
"enabled-server": {
type: "local",
command: ["node", "server.js"],
enabled: true,
},
},
},
null,
2,
),
)
const args = {
name: "enabled-server",
scope: "project" as const,
_: [],
$0: "opencode",
}
await McpDisableCommand.handler(args)
const projectConfig = await Config.get()
expect(projectConfig.mcp?.["enabled-server"].enabled).toBe(false)
})
test("disable MCP server in user scope", async () => {
// First add an enabled server to user config
await Bun.write(
testConfigPath,
JSON.stringify(
{
mcp: {
"user-enabled-server": {
type: "remote",
url: "https://example.com/mcp",
enabled: true,
},
},
},
null,
2,
),
)
const args = {
name: "user-enabled-server",
scope: "user" as const,
_: [],
$0: "opencode",
}
await McpDisableCommand.handler(args)
const config = await Config.global()
expect(config.mcp?.["user-enabled-server"].enabled).toBe(false)
})
test("enable non-existent MCP server shows error", async () => {
const stderr = captureStderr()
const args = {
name: "non-existent",
scope: "project" as const,
_: [],
$0: "opencode",
}
await McpEnableCommand.handler(args)
expect(stderr.getOutput()).toContain(
'MCP server "non-existent" not found in project config',
)
stderr.restore()
})
test("disable non-existent MCP server shows error", async () => {
const stderr = captureStderr()
const args = {
name: "non-existent",
scope: "user" as const,
_: [],
$0: "opencode",
}
await McpDisableCommand.handler(args)
expect(stderr.getOutput()).toContain(
'MCP server "non-existent" not found in user config',
)
stderr.restore()
})
test("list shows disabled status for MCP servers", async () => {
// Add servers with different enabled states
await Bun.write(
testConfigPath,
JSON.stringify(
{
mcp: {
"enabled-server": {
type: "local",
command: ["node", "enabled.js"],
enabled: true,
},
"disabled-server": {
type: "local",
command: ["node", "disabled.js"],
enabled: false,
},
"default-server": {
type: "local",
command: ["node", "default.js"],
},
},
},
null,
2,
),
)
const stderr = captureStderr()
await McpListCommand.handler({ $0: "opencode", _: [] })
const output = stderr.getOutput()
expect(output).toContain("enabled-server (local)")
expect(output).toContain("disabled-server (local) (disabled)")
expect(output).toContain("default-server (local)")
stderr.restore()
})
test("get shows enabled status for MCP servers", async () => {
// Add a disabled server to project config
await Bun.write(
testProjectConfigPath,
JSON.stringify(
{
mcp: {
"status-server": {
type: "local",
command: ["node", "server.js"],
enabled: false,
},
},
},
null,
2,
),
)
const stderr = captureStderr()
const args = {
name: "status-server",
_: [],
$0: "opencode",
}
await McpGetCommand.handler(args)
const output = stderr.getOutput()
expect(output).toContain("Enabled: false")
stderr.restore()
})
})

View file

@ -118,6 +118,124 @@ opencode upgrade v0.1.48
---
### mcp
Manage Model Context Protocol (MCP) servers for enhanced AI capabilities.
```bash
opencode mcp [command]
```
#### add
Add a new MCP server configuration.
```bash
opencode mcp add <name> [command]
```
**Examples:**
```bash
# Add a local MCP server using stdio transport
opencode mcp add file-server npx @modelcontextprotocol/server-filesystem /path/to/files
# Add a remote MCP server using SSE transport
opencode mcp add weather-server --transport sse --url https://api.weather.com/mcp
# Add server to global config (default is project scope)
opencode mcp add git-server git-mcp-server --scope user
# Add server with environment variables
opencode mcp add db-server db-mcp-server -e DB_HOST=localhost -e DB_PORT=5432
# Add remote server with custom headers
opencode mcp add api-server --transport sse --url https://api.example.com/mcp -H "Authorization: Bearer token"
```
**Flags:**
| Flag | Short | Description |
| ----------------- | ----- | --------------------- |
| `--scope` | `-s` | Configuration scope: `user` (global) or `project` (default) |
| `--transport` | `-t` | Transport type: `stdio` (default) or `sse` |
| `--url` | `-u` | Server URL (required for SSE transport) |
| `--env` | `-e` | Environment variable (can be used multiple times) |
| `--header` | `-H` | HTTP header for SSE transport (can be used multiple times) |
#### remove
Remove an MCP server configuration.
```bash
opencode mcp remove <name>
```
**Examples:**
```bash
# Remove server from project config
opencode mcp remove file-server
# Remove server from global config
opencode mcp remove weather-server --scope user
```
**Flags:**
| Flag | Short | Description |
| ----------------- | ----- | --------------------- |
| `--scope` | `-s` | Configuration scope: `user` (global) or `project` (default) |
#### list
List all configured MCP servers from both global and project configurations.
```bash
opencode mcp list
```
Shows servers from both scopes with their transport type and configuration details.
#### get
Get details for a specific MCP server. Checks both global and project configurations, with project taking priority.
```bash
opencode mcp get <name>
```
Displays the server configuration including transport type, command/URL, environment variables, and headers.
#### add-json
Add an MCP server using JSON configuration. Automatically determines if the server is local or remote based on the provided configuration.
```bash
opencode mcp add-json <name> <json>
```
**Examples:**
```bash
# Add local server via JSON
opencode mcp add-json files '{"command": "npx @modelcontextprotocol/server-filesystem", "args": ["/path"]}'
# Add remote server via JSON
opencode mcp add-json weather '{"url": "https://api.weather.com/mcp", "headers": {"Auth": "token"}}'
# Add to global scope
opencode mcp add-json api-server '{"url": "https://api.com/mcp"}' --scope user
```
**Flags:**
| Flag | Short | Description |
| ----------------- | ----- | --------------------- |
| `--scope` | `-s` | Configuration scope: `user` (global) or `project` (default) |
---
## Flags
The opencode CLI takes the following flags.