Skip to content
Open
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
275 changes: 168 additions & 107 deletions packages/opencode/src/cli/cmd/mcp.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { cmd } from "./cmd"
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
import * as prompts from "@clack/prompts"
import { UI } from "../ui"
Expand All @@ -13,6 +12,7 @@ import { Instance } from "../../project/instance"
import { Installation } from "../../installation"
import path from "path"
import { Global } from "../../global"
import { modify, applyEdits } from "jsonc-parser"

function getAuthStatusIcon(status: MCP.AuthStatus): string {
switch (status) {
Expand Down Expand Up @@ -366,133 +366,194 @@ export const McpLogoutCommand = cmd({
},
})

async function addMcpToConfig(name: string, mcpConfig: Config.Mcp, scope: "global" | "project") {
const baseDir = scope === "global" ? Global.Path.config : Instance.worktree

// Check for existing config files (prefer .jsonc over .json)
const jsoncPath = path.join(baseDir, "opencode.jsonc")
const jsonPath = path.join(baseDir, "opencode.json")

const jsoncFile = Bun.file(jsoncPath)
const jsonFile = Bun.file(jsonPath)

const jsoncExists = await jsoncFile.exists()
const jsonExists = await jsonFile.exists()

// Use existing file, or create .json if neither exists
const configPath = jsoncExists ? jsoncPath : jsonExists ? jsonPath : jsonPath
const file = jsoncExists ? jsoncFile : jsonExists ? jsonFile : jsonFile

let text = "{}"
if (jsoncExists || jsonExists) {
text = await file.text()
}

// Use jsonc-parser to modify while preserving comments
const edits = modify(text, ["mcp", name], mcpConfig, {
formattingOptions: { tabSize: 2, insertSpaces: true },
})
const result = applyEdits(text, edits)

await Bun.write(configPath, result)

return configPath
}

export const McpAddCommand = cmd({
command: "add",
describe: "add an MCP server",
async handler() {
UI.empty()
prompts.intro("Add MCP server")
await Instance.provide({
directory: process.cwd(),
async fn() {
UI.empty()
prompts.intro("Add MCP server")

const name = await prompts.text({
message: "Enter MCP server name",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(name)) throw new UI.CancelledError()

const type = await prompts.select({
message: "Select MCP server type",
options: [
{
label: "Local",
value: "local",
hint: "Run a local command",
},
{
label: "Remote",
value: "remote",
hint: "Connect to a remote URL",
},
],
})
if (prompts.isCancel(type)) throw new UI.CancelledError()
const project = Instance.project

if (type === "local") {
const command = await prompts.text({
message: "Enter command to run",
placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(command)) throw new UI.CancelledError()
// Determine scope
let scope: "global" | "project" = "global"
if (project.vcs === "git") {
const scopeResult = await prompts.select({
message: "Location",
options: [
{
label: "Current project",
value: "project" as const,
hint: path.join(Instance.worktree, "opencode.json"),
},
{
label: "Global",
value: "global" as const,
hint: path.join(Global.Path.config, "opencode.json"),
},
],
})
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
scope = scopeResult
}

prompts.log.info(`Local MCP server "${name}" configured with command: ${command}`)
prompts.outro("MCP server added successfully")
return
}
const name = await prompts.text({
message: "Enter MCP server name",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(name)) throw new UI.CancelledError()

const type = await prompts.select({
message: "Select MCP server type",
options: [
{
label: "Local",
value: "local",
hint: "Run a local command",
},
{
label: "Remote",
value: "remote",
hint: "Connect to a remote URL",
},
],
})
if (prompts.isCancel(type)) throw new UI.CancelledError()

if (type === "remote") {
const url = await prompts.text({
message: "Enter MCP server URL",
placeholder: "e.g., https://example.com/mcp",
validate: (x) => {
if (!x) return "Required"
if (x.length === 0) return "Required"
const isValid = URL.canParse(x)
return isValid ? undefined : "Invalid URL"
},
})
if (prompts.isCancel(url)) throw new UI.CancelledError()
if (type === "local") {
const command = await prompts.text({
message: "Enter command to run",
placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(command)) throw new UI.CancelledError()

const useOAuth = await prompts.confirm({
message: "Does this server require OAuth authentication?",
initialValue: false,
})
if (prompts.isCancel(useOAuth)) throw new UI.CancelledError()
const mcpConfig: Config.Mcp = {
type: "local",
command: command.split(" "),
}

if (useOAuth) {
const hasClientId = await prompts.confirm({
message: "Do you have a pre-registered client ID?",
initialValue: false,
})
if (prompts.isCancel(hasClientId)) throw new UI.CancelledError()
const configPath = await addMcpToConfig(name, mcpConfig, scope)
prompts.log.success(`MCP server "${name}" added to ${configPath}`)
prompts.outro("MCP server added successfully")
return
}

if (hasClientId) {
const clientId = await prompts.text({
message: "Enter client ID",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
if (type === "remote") {
const url = await prompts.text({
message: "Enter MCP server URL",
placeholder: "e.g., https://example.com/mcp",
validate: (x) => {
if (!x) return "Required"
if (x.length === 0) return "Required"
const isValid = URL.canParse(x)
return isValid ? undefined : "Invalid URL"
},
})
if (prompts.isCancel(clientId)) throw new UI.CancelledError()
if (prompts.isCancel(url)) throw new UI.CancelledError()

const hasSecret = await prompts.confirm({
message: "Do you have a client secret?",
const useOAuth = await prompts.confirm({
message: "Does this server require OAuth authentication?",
initialValue: false,
})
if (prompts.isCancel(hasSecret)) throw new UI.CancelledError()
if (prompts.isCancel(useOAuth)) throw new UI.CancelledError()

let clientSecret: string | undefined
if (hasSecret) {
const secret = await prompts.password({
message: "Enter client secret",
let mcpConfig: Config.Mcp

if (useOAuth) {
const hasClientId = await prompts.confirm({
message: "Do you have a pre-registered client ID?",
initialValue: false,
})
if (prompts.isCancel(secret)) throw new UI.CancelledError()
clientSecret = secret
if (prompts.isCancel(hasClientId)) throw new UI.CancelledError()

if (hasClientId) {
const clientId = await prompts.text({
message: "Enter client ID",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(clientId)) throw new UI.CancelledError()

const hasSecret = await prompts.confirm({
message: "Do you have a client secret?",
initialValue: false,
})
if (prompts.isCancel(hasSecret)) throw new UI.CancelledError()

let clientSecret: string | undefined
if (hasSecret) {
const secret = await prompts.password({
message: "Enter client secret",
})
if (prompts.isCancel(secret)) throw new UI.CancelledError()
clientSecret = secret
}

mcpConfig = {
type: "remote",
url,
oauth: {
clientId,
...(clientSecret && { clientSecret }),
},
}
} else {
mcpConfig = {
type: "remote",
url,
oauth: {},
}
}
} else {
mcpConfig = {
type: "remote",
url,
}
}

prompts.log.info(`Remote MCP server "${name}" configured with OAuth (client ID: ${clientId})`)
prompts.log.info("Add this to your opencode.json:")
prompts.log.info(`
"mcp": {
"${name}": {
"type": "remote",
"url": "${url}",
"oauth": {
"clientId": "${clientId}"${clientSecret ? `,\n "clientSecret": "${clientSecret}"` : ""}
}
}
}`)
} else {
prompts.log.info(`Remote MCP server "${name}" configured with OAuth (dynamic registration)`)
prompts.log.info("Add this to your opencode.json:")
prompts.log.info(`
"mcp": {
"${name}": {
"type": "remote",
"url": "${url}",
"oauth": {}
}
}`)
const configPath = await addMcpToConfig(name, mcpConfig, scope)
prompts.log.success(`MCP server "${name}" added to ${configPath}`)
}
} else {
const client = new Client({
name: "opencode",
version: "1.0.0",
})
const transport = new StreamableHTTPClientTransport(new URL(url))
await client.connect(transport)
prompts.log.info(`Remote MCP server "${name}" configured with URL: ${url}`)
}
}

prompts.outro("MCP server added successfully")
prompts.outro("MCP server added successfully")
},
})
},
})

Expand Down