mcp.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400
  1. import { cmd } from "./cmd"
  2. import { Client } from "@modelcontextprotocol/sdk/client/index.js"
  3. import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
  4. import * as prompts from "@clack/prompts"
  5. import { UI } from "../ui"
  6. import { MCP } from "../../mcp"
  7. import { McpAuth } from "../../mcp/auth"
  8. import { Config } from "../../config/config"
  9. import { Instance } from "../../project/instance"
  10. import path from "path"
  11. import os from "os"
  12. import { Global } from "../../global"
  13. export const McpCommand = cmd({
  14. command: "mcp",
  15. builder: (yargs) =>
  16. yargs
  17. .command(McpAddCommand)
  18. .command(McpListCommand)
  19. .command(McpAuthCommand)
  20. .command(McpLogoutCommand)
  21. .demandCommand(),
  22. async handler() {},
  23. })
  24. export const McpListCommand = cmd({
  25. command: "list",
  26. aliases: ["ls"],
  27. describe: "list MCP servers and their status",
  28. async handler() {
  29. await Instance.provide({
  30. directory: process.cwd(),
  31. async fn() {
  32. UI.empty()
  33. prompts.intro("MCP Servers")
  34. const config = await Config.get()
  35. const mcpServers = config.mcp ?? {}
  36. const statuses = await MCP.status()
  37. if (Object.keys(mcpServers).length === 0) {
  38. prompts.log.warn("No MCP servers configured")
  39. prompts.outro("Add servers with: opencode mcp add")
  40. return
  41. }
  42. for (const [name, serverConfig] of Object.entries(mcpServers)) {
  43. const status = statuses[name]
  44. const hasOAuth = serverConfig.type === "remote" && !!serverConfig.oauth
  45. const hasStoredTokens = await MCP.hasStoredTokens(name)
  46. let statusIcon: string
  47. let statusText: string
  48. let hint = ""
  49. if (!status) {
  50. statusIcon = "○"
  51. statusText = "not initialized"
  52. } else if (status.status === "connected") {
  53. statusIcon = "✓"
  54. statusText = "connected"
  55. if (hasOAuth && hasStoredTokens) {
  56. hint = " (OAuth)"
  57. }
  58. } else if (status.status === "disabled") {
  59. statusIcon = "○"
  60. statusText = "disabled"
  61. } else if (status.status === "needs_auth") {
  62. statusIcon = "⚠"
  63. statusText = "needs authentication"
  64. } else if (status.status === "needs_client_registration") {
  65. statusIcon = "✗"
  66. statusText = "needs client registration"
  67. hint = "\n " + status.error
  68. } else {
  69. statusIcon = "✗"
  70. statusText = "failed"
  71. hint = "\n " + status.error
  72. }
  73. const typeHint = serverConfig.type === "remote" ? serverConfig.url : serverConfig.command.join(" ")
  74. prompts.log.info(
  75. `${statusIcon} ${name} ${UI.Style.TEXT_DIM}${statusText}${hint}\n ${UI.Style.TEXT_DIM}${typeHint}`,
  76. )
  77. }
  78. prompts.outro(`${Object.keys(mcpServers).length} server(s)`)
  79. },
  80. })
  81. },
  82. })
  83. export const McpAuthCommand = cmd({
  84. command: "auth [name]",
  85. describe: "authenticate with an OAuth-enabled MCP server",
  86. builder: (yargs) =>
  87. yargs.positional("name", {
  88. describe: "name of the MCP server",
  89. type: "string",
  90. }),
  91. async handler(args) {
  92. await Instance.provide({
  93. directory: process.cwd(),
  94. async fn() {
  95. UI.empty()
  96. prompts.intro("MCP OAuth Authentication")
  97. const config = await Config.get()
  98. const mcpServers = config.mcp ?? {}
  99. // Get OAuth-enabled servers
  100. const oauthServers = Object.entries(mcpServers).filter(([_, cfg]) => cfg.type === "remote" && !!cfg.oauth)
  101. if (oauthServers.length === 0) {
  102. prompts.log.warn("No OAuth-enabled MCP servers configured")
  103. prompts.log.info("Add OAuth config to a remote MCP server in opencode.json:")
  104. prompts.log.info(`
  105. "mcp": {
  106. "my-server": {
  107. "type": "remote",
  108. "url": "https://example.com/mcp",
  109. "oauth": {
  110. "scope": "tools:read"
  111. }
  112. }
  113. }`)
  114. prompts.outro("Done")
  115. return
  116. }
  117. let serverName = args.name
  118. if (!serverName) {
  119. const selected = await prompts.select({
  120. message: "Select MCP server to authenticate",
  121. options: oauthServers.map(([name, cfg]) => ({
  122. label: name,
  123. value: name,
  124. hint: cfg.type === "remote" ? cfg.url : undefined,
  125. })),
  126. })
  127. if (prompts.isCancel(selected)) throw new UI.CancelledError()
  128. serverName = selected
  129. }
  130. const serverConfig = mcpServers[serverName]
  131. if (!serverConfig) {
  132. prompts.log.error(`MCP server not found: ${serverName}`)
  133. prompts.outro("Done")
  134. return
  135. }
  136. if (serverConfig.type !== "remote" || !serverConfig.oauth) {
  137. prompts.log.error(`MCP server ${serverName} does not have OAuth configured`)
  138. prompts.outro("Done")
  139. return
  140. }
  141. // Check if already authenticated
  142. const hasTokens = await MCP.hasStoredTokens(serverName)
  143. if (hasTokens) {
  144. const confirm = await prompts.confirm({
  145. message: `${serverName} already has stored credentials. Re-authenticate?`,
  146. })
  147. if (prompts.isCancel(confirm) || !confirm) {
  148. prompts.outro("Cancelled")
  149. return
  150. }
  151. }
  152. const spinner = prompts.spinner()
  153. spinner.start("Starting OAuth flow...")
  154. try {
  155. const status = await MCP.authenticate(serverName)
  156. if (status.status === "connected") {
  157. spinner.stop("Authentication successful!")
  158. } else if (status.status === "needs_client_registration") {
  159. spinner.stop("Authentication failed", 1)
  160. prompts.log.error(status.error)
  161. prompts.log.info("Add clientId to your MCP server config:")
  162. prompts.log.info(`
  163. "mcp": {
  164. "${serverName}": {
  165. "type": "remote",
  166. "url": "${serverConfig.url}",
  167. "oauth": {
  168. "clientId": "your-client-id",
  169. "clientSecret": "your-client-secret"
  170. }
  171. }
  172. }`)
  173. } else if (status.status === "failed") {
  174. spinner.stop("Authentication failed", 1)
  175. prompts.log.error(status.error)
  176. } else {
  177. spinner.stop("Unexpected status: " + status.status, 1)
  178. }
  179. } catch (error) {
  180. spinner.stop("Authentication failed", 1)
  181. prompts.log.error(error instanceof Error ? error.message : String(error))
  182. }
  183. prompts.outro("Done")
  184. },
  185. })
  186. },
  187. })
  188. export const McpLogoutCommand = cmd({
  189. command: "logout [name]",
  190. describe: "remove OAuth credentials for an MCP server",
  191. builder: (yargs) =>
  192. yargs.positional("name", {
  193. describe: "name of the MCP server",
  194. type: "string",
  195. }),
  196. async handler(args) {
  197. await Instance.provide({
  198. directory: process.cwd(),
  199. async fn() {
  200. UI.empty()
  201. prompts.intro("MCP OAuth Logout")
  202. const authPath = path.join(Global.Path.data, "mcp-auth.json")
  203. const credentials = await McpAuth.all()
  204. const serverNames = Object.keys(credentials)
  205. if (serverNames.length === 0) {
  206. prompts.log.warn("No MCP OAuth credentials stored")
  207. prompts.outro("Done")
  208. return
  209. }
  210. let serverName = args.name
  211. if (!serverName) {
  212. const selected = await prompts.select({
  213. message: "Select MCP server to logout",
  214. options: serverNames.map((name) => {
  215. const entry = credentials[name]
  216. const hasTokens = !!entry.tokens
  217. const hasClient = !!entry.clientInfo
  218. let hint = ""
  219. if (hasTokens && hasClient) hint = "tokens + client"
  220. else if (hasTokens) hint = "tokens"
  221. else if (hasClient) hint = "client registration"
  222. return {
  223. label: name,
  224. value: name,
  225. hint,
  226. }
  227. }),
  228. })
  229. if (prompts.isCancel(selected)) throw new UI.CancelledError()
  230. serverName = selected
  231. }
  232. if (!credentials[serverName]) {
  233. prompts.log.error(`No credentials found for: ${serverName}`)
  234. prompts.outro("Done")
  235. return
  236. }
  237. await MCP.removeAuth(serverName)
  238. prompts.log.success(`Removed OAuth credentials for ${serverName}`)
  239. prompts.outro("Done")
  240. },
  241. })
  242. },
  243. })
  244. export const McpAddCommand = cmd({
  245. command: "add",
  246. describe: "add an MCP server",
  247. async handler() {
  248. UI.empty()
  249. prompts.intro("Add MCP server")
  250. const name = await prompts.text({
  251. message: "Enter MCP server name",
  252. validate: (x) => (x && x.length > 0 ? undefined : "Required"),
  253. })
  254. if (prompts.isCancel(name)) throw new UI.CancelledError()
  255. const type = await prompts.select({
  256. message: "Select MCP server type",
  257. options: [
  258. {
  259. label: "Local",
  260. value: "local",
  261. hint: "Run a local command",
  262. },
  263. {
  264. label: "Remote",
  265. value: "remote",
  266. hint: "Connect to a remote URL",
  267. },
  268. ],
  269. })
  270. if (prompts.isCancel(type)) throw new UI.CancelledError()
  271. if (type === "local") {
  272. const command = await prompts.text({
  273. message: "Enter command to run",
  274. placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem",
  275. validate: (x) => (x && x.length > 0 ? undefined : "Required"),
  276. })
  277. if (prompts.isCancel(command)) throw new UI.CancelledError()
  278. prompts.log.info(`Local MCP server "${name}" configured with command: ${command}`)
  279. prompts.outro("MCP server added successfully")
  280. return
  281. }
  282. if (type === "remote") {
  283. const url = await prompts.text({
  284. message: "Enter MCP server URL",
  285. placeholder: "e.g., https://example.com/mcp",
  286. validate: (x) => {
  287. if (!x) return "Required"
  288. if (x.length === 0) return "Required"
  289. const isValid = URL.canParse(x)
  290. return isValid ? undefined : "Invalid URL"
  291. },
  292. })
  293. if (prompts.isCancel(url)) throw new UI.CancelledError()
  294. const useOAuth = await prompts.confirm({
  295. message: "Does this server require OAuth authentication?",
  296. initialValue: false,
  297. })
  298. if (prompts.isCancel(useOAuth)) throw new UI.CancelledError()
  299. if (useOAuth) {
  300. const hasClientId = await prompts.confirm({
  301. message: "Do you have a pre-registered client ID?",
  302. initialValue: false,
  303. })
  304. if (prompts.isCancel(hasClientId)) throw new UI.CancelledError()
  305. if (hasClientId) {
  306. const clientId = await prompts.text({
  307. message: "Enter client ID",
  308. validate: (x) => (x && x.length > 0 ? undefined : "Required"),
  309. })
  310. if (prompts.isCancel(clientId)) throw new UI.CancelledError()
  311. const hasSecret = await prompts.confirm({
  312. message: "Do you have a client secret?",
  313. initialValue: false,
  314. })
  315. if (prompts.isCancel(hasSecret)) throw new UI.CancelledError()
  316. let clientSecret: string | undefined
  317. if (hasSecret) {
  318. const secret = await prompts.password({
  319. message: "Enter client secret",
  320. })
  321. if (prompts.isCancel(secret)) throw new UI.CancelledError()
  322. clientSecret = secret
  323. }
  324. prompts.log.info(`Remote MCP server "${name}" configured with OAuth (client ID: ${clientId})`)
  325. prompts.log.info("Add this to your opencode.json:")
  326. prompts.log.info(`
  327. "mcp": {
  328. "${name}": {
  329. "type": "remote",
  330. "url": "${url}",
  331. "oauth": {
  332. "clientId": "${clientId}"${clientSecret ? `,\n "clientSecret": "${clientSecret}"` : ""}
  333. }
  334. }
  335. }`)
  336. } else {
  337. prompts.log.info(`Remote MCP server "${name}" configured with OAuth (dynamic registration)`)
  338. prompts.log.info("Add this to your opencode.json:")
  339. prompts.log.info(`
  340. "mcp": {
  341. "${name}": {
  342. "type": "remote",
  343. "url": "${url}",
  344. "oauth": {}
  345. }
  346. }`)
  347. }
  348. } else {
  349. const client = new Client({
  350. name: "opencode",
  351. version: "1.0.0",
  352. })
  353. const transport = new StreamableHTTPClientTransport(new URL(url))
  354. await client.connect(transport)
  355. prompts.log.info(`Remote MCP server "${name}" configured with URL: ${url}`)
  356. }
  357. }
  358. prompts.outro("MCP server added successfully")
  359. },
  360. })