|
@@ -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({
|
|
|
},
|
|
},
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
-export const McpAddCommand = cmd({
|
|
|
|
|
- command: "add",
|
|
|
|
|
- describe: "add an MCP server",
|
|
|
|
|
- async handler() {
|
|
|
|
|
- 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()
|
|
|
|
|
|
|
+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 (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()
|
|
|
|
|
|
|
+ if (!global) {
|
|
|
|
|
+ candidates.push(path.join(baseDir, ".opencode", "opencode.json"), path.join(baseDir, ".opencode", "opencode.jsonc"))
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- prompts.log.info(`Local MCP server "${name}" configured with command: ${command}`)
|
|
|
|
|
- prompts.outro("MCP server added successfully")
|
|
|
|
|
- return
|
|
|
|
|
|
|
+ for (const candidate of candidates) {
|
|
|
|
|
+ if (await Bun.file(candidate).exists()) {
|
|
|
|
|
+ return candidate
|
|
|
}
|
|
}
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- 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()
|
|
|
|
|
|
|
+ // Default to opencode.json if none exist
|
|
|
|
|
+ return candidates[0]
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- const useOAuth = await prompts.confirm({
|
|
|
|
|
- message: "Does this server require OAuth authentication?",
|
|
|
|
|
- initialValue: false,
|
|
|
|
|
- })
|
|
|
|
|
- if (prompts.isCancel(useOAuth)) throw new UI.CancelledError()
|
|
|
|
|
|
|
+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
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- if (useOAuth) {
|
|
|
|
|
- const hasClientId = await prompts.confirm({
|
|
|
|
|
- message: "Do you have a pre-registered client ID?",
|
|
|
|
|
- initialValue: false,
|
|
|
|
|
|
|
+export const McpAddCommand = cmd({
|
|
|
|
|
+ command: "add",
|
|
|
|
|
+ describe: "add an MCP server",
|
|
|
|
|
+ async handler() {
|
|
|
|
|
+ await Instance.provide({
|
|
|
|
|
+ directory: process.cwd(),
|
|
|
|
|
+ async fn() {
|
|
|
|
|
+ UI.empty()
|
|
|
|
|
+ prompts.intro("Add MCP server")
|
|
|
|
|
+
|
|
|
|
|
+ const project = Instance.project
|
|
|
|
|
+
|
|
|
|
|
+ // Resolve config paths eagerly for hints
|
|
|
|
|
+ const [projectConfigPath, globalConfigPath] = await Promise.all([
|
|
|
|
|
+ resolveConfigPath(Instance.worktree),
|
|
|
|
|
+ resolveConfigPath(Global.Path.config, true),
|
|
|
|
|
+ ])
|
|
|
|
|
+
|
|
|
|
|
+ // Determine scope
|
|
|
|
|
+ let configPath = globalConfigPath
|
|
|
|
|
+ if (project.vcs === "git") {
|
|
|
|
|
+ const scopeResult = await prompts.select({
|
|
|
|
|
+ message: "Location",
|
|
|
|
|
+ options: [
|
|
|
|
|
+ {
|
|
|
|
|
+ label: "Current project",
|
|
|
|
|
+ value: projectConfigPath,
|
|
|
|
|
+ hint: projectConfigPath,
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ label: "Global",
|
|
|
|
|
+ value: globalConfigPath,
|
|
|
|
|
+ hint: globalConfigPath,
|
|
|
|
|
+ },
|
|
|
|
|
+ ],
|
|
|
|
|
+ })
|
|
|
|
|
+ if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
|
|
|
|
|
+ configPath = scopeResult
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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(hasClientId)) throw new UI.CancelledError()
|
|
|
|
|
|
|
+ if (prompts.isCancel(type)) throw new UI.CancelledError()
|
|
|
|
|
|
|
|
- if (hasClientId) {
|
|
|
|
|
- const clientId = await prompts.text({
|
|
|
|
|
- message: "Enter client ID",
|
|
|
|
|
|
|
+ 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({
|
|
|
|
|
- message: "Do you have a client secret?",
|
|
|
|
|
|
|
+ const mcpConfig: Config.Mcp = {
|
|
|
|
|
+ type: "local",
|
|
|
|
|
+ command: command.split(" "),
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ await addMcpToConfig(name, mcpConfig, configPath)
|
|
|
|
|
+ prompts.log.success(`MCP server "${name}" added to ${configPath}`)
|
|
|
|
|
+ prompts.outro("MCP server added successfully")
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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,
|
|
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": {}
|
|
|
|
|
- }
|
|
|
|
|
- }`)
|
|
|
|
|
|
|
+ await addMcpToConfig(name, mcpConfig, configPath)
|
|
|
|
|
+ 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")
|
|
|
|
|
+ },
|
|
|
|
|
+ })
|
|
|
},
|
|
},
|
|
|
})
|
|
})
|
|
|
|
|
|