fix: actually modify opencode config with mcp add (#7339)
This commit is contained in:
@@ -1,7 +1,6 @@
|
|||||||
import { cmd } from "./cmd"
|
import { cmd } from "./cmd"
|
||||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.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 { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||||
import * as prompts from "@clack/prompts"
|
import * as prompts from "@clack/prompts"
|
||||||
import { UI } from "../ui"
|
import { UI } from "../ui"
|
||||||
@@ -13,6 +12,7 @@ import { Instance } from "../../project/instance"
|
|||||||
import { Installation } from "../../installation"
|
import { Installation } from "../../installation"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Global } from "../../global"
|
import { Global } from "../../global"
|
||||||
|
import { modify, applyEdits } from "jsonc-parser"
|
||||||
|
|
||||||
function getAuthStatusIcon(status: MCP.AuthStatus): string {
|
function getAuthStatusIcon(status: MCP.AuthStatus): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -366,133 +366,204 @@ export const McpLogoutCommand = cmd({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function resolveConfigPath(baseDir: string, global = false) {
|
||||||
|
// Check for existing config files (prefer .jsonc over .json, check .opencode/ subdirectory too)
|
||||||
|
const candidates = [path.join(baseDir, "opencode.json"), path.join(baseDir, "opencode.jsonc")]
|
||||||
|
|
||||||
|
if (!global) {
|
||||||
|
candidates.push(path.join(baseDir, ".opencode", "opencode.json"), path.join(baseDir, ".opencode", "opencode.jsonc"))
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (await Bun.file(candidate).exists()) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to opencode.json if none exist
|
||||||
|
return candidates[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addMcpToConfig(name: string, mcpConfig: Config.Mcp, configPath: string) {
|
||||||
|
const file = Bun.file(configPath)
|
||||||
|
|
||||||
|
let text = "{}"
|
||||||
|
if (await file.exists()) {
|
||||||
|
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({
|
export const McpAddCommand = cmd({
|
||||||
command: "add",
|
command: "add",
|
||||||
describe: "add an MCP server",
|
describe: "add an MCP server",
|
||||||
async handler() {
|
async handler() {
|
||||||
UI.empty()
|
await Instance.provide({
|
||||||
prompts.intro("Add MCP server")
|
directory: process.cwd(),
|
||||||
|
async fn() {
|
||||||
|
UI.empty()
|
||||||
|
prompts.intro("Add MCP server")
|
||||||
|
|
||||||
const name = await prompts.text({
|
const project = Instance.project
|
||||||
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({
|
// Resolve config paths eagerly for hints
|
||||||
message: "Select MCP server type",
|
const [projectConfigPath, globalConfigPath] = await Promise.all([
|
||||||
options: [
|
resolveConfigPath(Instance.worktree),
|
||||||
{
|
resolveConfigPath(Global.Path.config, true),
|
||||||
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 === "local") {
|
// Determine scope
|
||||||
const command = await prompts.text({
|
let configPath = globalConfigPath
|
||||||
message: "Enter command to run",
|
if (project.vcs === "git") {
|
||||||
placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem",
|
const scopeResult = await prompts.select({
|
||||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
message: "Location",
|
||||||
})
|
options: [
|
||||||
if (prompts.isCancel(command)) throw new UI.CancelledError()
|
{
|
||||||
|
label: "Current project",
|
||||||
|
value: projectConfigPath,
|
||||||
|
hint: projectConfigPath,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Global",
|
||||||
|
value: globalConfigPath,
|
||||||
|
hint: globalConfigPath,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
|
||||||
|
configPath = scopeResult
|
||||||
|
}
|
||||||
|
|
||||||
prompts.log.info(`Local MCP server "${name}" configured with command: ${command}`)
|
const name = await prompts.text({
|
||||||
prompts.outro("MCP server added successfully")
|
message: "Enter MCP server name",
|
||||||
return
|
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(url)) 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()
|
|
||||||
|
|
||||||
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()
|
if (prompts.isCancel(name)) throw new UI.CancelledError()
|
||||||
|
|
||||||
if (hasClientId) {
|
const type = await prompts.select({
|
||||||
const clientId = await prompts.text({
|
message: "Select MCP server type",
|
||||||
message: "Enter client ID",
|
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 === "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"),
|
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||||
})
|
})
|
||||||
if (prompts.isCancel(clientId)) throw new UI.CancelledError()
|
if (prompts.isCancel(command)) throw new UI.CancelledError()
|
||||||
|
|
||||||
const hasSecret = await prompts.confirm({
|
const mcpConfig: Config.Mcp = {
|
||||||
message: "Do you have a client secret?",
|
type: "local",
|
||||||
initialValue: false,
|
command: command.split(" "),
|
||||||
})
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
prompts.log.info(`Remote MCP server "${name}" configured with OAuth (client ID: ${clientId})`)
|
await addMcpToConfig(name, mcpConfig, configPath)
|
||||||
prompts.log.info("Add this to your opencode.json:")
|
prompts.log.success(`MCP server "${name}" added to ${configPath}`)
|
||||||
prompts.log.info(`
|
prompts.outro("MCP server added successfully")
|
||||||
"mcp": {
|
return
|
||||||
"${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": {}
|
|
||||||
}
|
|
||||||
}`)
|
|
||||||
}
|
}
|
||||||
} 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")
|
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()
|
||||||
|
|
||||||
|
const useOAuth = await prompts.confirm({
|
||||||
|
message: "Does this server require OAuth authentication?",
|
||||||
|
initialValue: false,
|
||||||
|
})
|
||||||
|
if (prompts.isCancel(useOAuth)) throw new UI.CancelledError()
|
||||||
|
|
||||||
|
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(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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await addMcpToConfig(name, mcpConfig, configPath)
|
||||||
|
prompts.log.success(`MCP server "${name}" added to ${configPath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
prompts.outro("MCP server added successfully")
|
||||||
|
},
|
||||||
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user