mcp.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654
  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 { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
  5. import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
  6. import * as prompts from "@clack/prompts"
  7. import { UI } from "../ui"
  8. import { MCP } from "../../mcp"
  9. import { McpAuth } from "../../mcp/auth"
  10. import { McpOAuthProvider } from "../../mcp/oauth-provider"
  11. import { Config } from "../../config/config"
  12. import { Instance } from "../../project/instance"
  13. import { Installation } from "../../installation"
  14. import path from "path"
  15. import { Global } from "../../global"
  16. function getAuthStatusIcon(status: MCP.AuthStatus): string {
  17. switch (status) {
  18. case "authenticated":
  19. return "✓"
  20. case "expired":
  21. return "⚠"
  22. case "not_authenticated":
  23. return "○"
  24. }
  25. }
  26. function getAuthStatusText(status: MCP.AuthStatus): string {
  27. switch (status) {
  28. case "authenticated":
  29. return "authenticated"
  30. case "expired":
  31. return "expired"
  32. case "not_authenticated":
  33. return "not authenticated"
  34. }
  35. }
  36. export const McpCommand = cmd({
  37. command: "mcp",
  38. builder: (yargs) =>
  39. yargs
  40. .command(McpAddCommand)
  41. .command(McpListCommand)
  42. .command(McpAuthCommand)
  43. .command(McpLogoutCommand)
  44. .command(McpDebugCommand)
  45. .demandCommand(),
  46. async handler() {},
  47. })
  48. export const McpListCommand = cmd({
  49. command: "list",
  50. aliases: ["ls"],
  51. describe: "list MCP servers and their status",
  52. async handler() {
  53. await Instance.provide({
  54. directory: process.cwd(),
  55. async fn() {
  56. UI.empty()
  57. prompts.intro("MCP Servers")
  58. const config = await Config.get()
  59. const mcpServers = config.mcp ?? {}
  60. const statuses = await MCP.status()
  61. if (Object.keys(mcpServers).length === 0) {
  62. prompts.log.warn("No MCP servers configured")
  63. prompts.outro("Add servers with: opencode mcp add")
  64. return
  65. }
  66. for (const [name, serverConfig] of Object.entries(mcpServers)) {
  67. const status = statuses[name]
  68. const hasOAuth = serverConfig.type === "remote" && !!serverConfig.oauth
  69. const hasStoredTokens = await MCP.hasStoredTokens(name)
  70. let statusIcon: string
  71. let statusText: string
  72. let hint = ""
  73. if (!status) {
  74. statusIcon = "○"
  75. statusText = "not initialized"
  76. } else if (status.status === "connected") {
  77. statusIcon = "✓"
  78. statusText = "connected"
  79. if (hasOAuth && hasStoredTokens) {
  80. hint = " (OAuth)"
  81. }
  82. } else if (status.status === "disabled") {
  83. statusIcon = "○"
  84. statusText = "disabled"
  85. } else if (status.status === "needs_auth") {
  86. statusIcon = "⚠"
  87. statusText = "needs authentication"
  88. } else if (status.status === "needs_client_registration") {
  89. statusIcon = "✗"
  90. statusText = "needs client registration"
  91. hint = "\n " + status.error
  92. } else {
  93. statusIcon = "✗"
  94. statusText = "failed"
  95. hint = "\n " + status.error
  96. }
  97. const typeHint = serverConfig.type === "remote" ? serverConfig.url : serverConfig.command.join(" ")
  98. prompts.log.info(
  99. `${statusIcon} ${name} ${UI.Style.TEXT_DIM}${statusText}${hint}\n ${UI.Style.TEXT_DIM}${typeHint}`,
  100. )
  101. }
  102. prompts.outro(`${Object.keys(mcpServers).length} server(s)`)
  103. },
  104. })
  105. },
  106. })
  107. export const McpAuthCommand = cmd({
  108. command: "auth [name]",
  109. describe: "authenticate with an OAuth-enabled MCP server",
  110. builder: (yargs) =>
  111. yargs
  112. .positional("name", {
  113. describe: "name of the MCP server",
  114. type: "string",
  115. })
  116. .command(McpAuthListCommand),
  117. async handler(args) {
  118. await Instance.provide({
  119. directory: process.cwd(),
  120. async fn() {
  121. UI.empty()
  122. prompts.intro("MCP OAuth Authentication")
  123. const config = await Config.get()
  124. const mcpServers = config.mcp ?? {}
  125. // Get OAuth-capable servers (remote servers with oauth not explicitly disabled)
  126. const oauthServers = Object.entries(mcpServers).filter(
  127. ([_, cfg]) => cfg.type === "remote" && cfg.oauth !== false,
  128. )
  129. if (oauthServers.length === 0) {
  130. prompts.log.warn("No OAuth-capable MCP servers configured")
  131. prompts.log.info("Remote MCP servers support OAuth by default. Add a remote server in opencode.json:")
  132. prompts.log.info(`
  133. "mcp": {
  134. "my-server": {
  135. "type": "remote",
  136. "url": "https://example.com/mcp"
  137. }
  138. }`)
  139. prompts.outro("Done")
  140. return
  141. }
  142. let serverName = args.name
  143. if (!serverName) {
  144. // Build options with auth status
  145. const options = await Promise.all(
  146. oauthServers.map(async ([name, cfg]) => {
  147. const authStatus = await MCP.getAuthStatus(name)
  148. const icon = getAuthStatusIcon(authStatus)
  149. const statusText = getAuthStatusText(authStatus)
  150. const url = cfg.type === "remote" ? cfg.url : ""
  151. return {
  152. label: `${icon} ${name} (${statusText})`,
  153. value: name,
  154. hint: url,
  155. }
  156. }),
  157. )
  158. const selected = await prompts.select({
  159. message: "Select MCP server to authenticate",
  160. options,
  161. })
  162. if (prompts.isCancel(selected)) throw new UI.CancelledError()
  163. serverName = selected
  164. }
  165. const serverConfig = mcpServers[serverName]
  166. if (!serverConfig) {
  167. prompts.log.error(`MCP server not found: ${serverName}`)
  168. prompts.outro("Done")
  169. return
  170. }
  171. if (serverConfig.type !== "remote" || serverConfig.oauth === false) {
  172. prompts.log.error(`MCP server ${serverName} does not support OAuth (oauth is disabled)`)
  173. prompts.outro("Done")
  174. return
  175. }
  176. // Check if already authenticated
  177. const authStatus = await MCP.getAuthStatus(serverName)
  178. if (authStatus === "authenticated") {
  179. const confirm = await prompts.confirm({
  180. message: `${serverName} already has valid credentials. Re-authenticate?`,
  181. })
  182. if (prompts.isCancel(confirm) || !confirm) {
  183. prompts.outro("Cancelled")
  184. return
  185. }
  186. } else if (authStatus === "expired") {
  187. prompts.log.warn(`${serverName} has expired credentials. Re-authenticating...`)
  188. }
  189. const spinner = prompts.spinner()
  190. spinner.start("Starting OAuth flow...")
  191. try {
  192. const status = await MCP.authenticate(serverName)
  193. if (status.status === "connected") {
  194. spinner.stop("Authentication successful!")
  195. } else if (status.status === "needs_client_registration") {
  196. spinner.stop("Authentication failed", 1)
  197. prompts.log.error(status.error)
  198. prompts.log.info("Add clientId to your MCP server config:")
  199. prompts.log.info(`
  200. "mcp": {
  201. "${serverName}": {
  202. "type": "remote",
  203. "url": "${serverConfig.url}",
  204. "oauth": {
  205. "clientId": "your-client-id",
  206. "clientSecret": "your-client-secret"
  207. }
  208. }
  209. }`)
  210. } else if (status.status === "failed") {
  211. spinner.stop("Authentication failed", 1)
  212. prompts.log.error(status.error)
  213. } else {
  214. spinner.stop("Unexpected status: " + status.status, 1)
  215. }
  216. } catch (error) {
  217. spinner.stop("Authentication failed", 1)
  218. prompts.log.error(error instanceof Error ? error.message : String(error))
  219. }
  220. prompts.outro("Done")
  221. },
  222. })
  223. },
  224. })
  225. export const McpAuthListCommand = cmd({
  226. command: "list",
  227. aliases: ["ls"],
  228. describe: "list OAuth-capable MCP servers and their auth status",
  229. async handler() {
  230. await Instance.provide({
  231. directory: process.cwd(),
  232. async fn() {
  233. UI.empty()
  234. prompts.intro("MCP OAuth Status")
  235. const config = await Config.get()
  236. const mcpServers = config.mcp ?? {}
  237. // Get OAuth-capable servers
  238. const oauthServers = Object.entries(mcpServers).filter(
  239. ([_, cfg]) => cfg.type === "remote" && cfg.oauth !== false,
  240. )
  241. if (oauthServers.length === 0) {
  242. prompts.log.warn("No OAuth-capable MCP servers configured")
  243. prompts.outro("Done")
  244. return
  245. }
  246. for (const [name, serverConfig] of oauthServers) {
  247. const authStatus = await MCP.getAuthStatus(name)
  248. const icon = getAuthStatusIcon(authStatus)
  249. const statusText = getAuthStatusText(authStatus)
  250. const url = serverConfig.type === "remote" ? serverConfig.url : ""
  251. prompts.log.info(`${icon} ${name} ${UI.Style.TEXT_DIM}${statusText}\n ${UI.Style.TEXT_DIM}${url}`)
  252. }
  253. prompts.outro(`${oauthServers.length} OAuth-capable server(s)`)
  254. },
  255. })
  256. },
  257. })
  258. export const McpLogoutCommand = cmd({
  259. command: "logout [name]",
  260. describe: "remove OAuth credentials for an MCP server",
  261. builder: (yargs) =>
  262. yargs.positional("name", {
  263. describe: "name of the MCP server",
  264. type: "string",
  265. }),
  266. async handler(args) {
  267. await Instance.provide({
  268. directory: process.cwd(),
  269. async fn() {
  270. UI.empty()
  271. prompts.intro("MCP OAuth Logout")
  272. const authPath = path.join(Global.Path.data, "mcp-auth.json")
  273. const credentials = await McpAuth.all()
  274. const serverNames = Object.keys(credentials)
  275. if (serverNames.length === 0) {
  276. prompts.log.warn("No MCP OAuth credentials stored")
  277. prompts.outro("Done")
  278. return
  279. }
  280. let serverName = args.name
  281. if (!serverName) {
  282. const selected = await prompts.select({
  283. message: "Select MCP server to logout",
  284. options: serverNames.map((name) => {
  285. const entry = credentials[name]
  286. const hasTokens = !!entry.tokens
  287. const hasClient = !!entry.clientInfo
  288. let hint = ""
  289. if (hasTokens && hasClient) hint = "tokens + client"
  290. else if (hasTokens) hint = "tokens"
  291. else if (hasClient) hint = "client registration"
  292. return {
  293. label: name,
  294. value: name,
  295. hint,
  296. }
  297. }),
  298. })
  299. if (prompts.isCancel(selected)) throw new UI.CancelledError()
  300. serverName = selected
  301. }
  302. if (!credentials[serverName]) {
  303. prompts.log.error(`No credentials found for: ${serverName}`)
  304. prompts.outro("Done")
  305. return
  306. }
  307. await MCP.removeAuth(serverName)
  308. prompts.log.success(`Removed OAuth credentials for ${serverName}`)
  309. prompts.outro("Done")
  310. },
  311. })
  312. },
  313. })
  314. export const McpAddCommand = cmd({
  315. command: "add",
  316. describe: "add an MCP server",
  317. async handler() {
  318. UI.empty()
  319. prompts.intro("Add MCP server")
  320. const name = await prompts.text({
  321. message: "Enter MCP server name",
  322. validate: (x) => (x && x.length > 0 ? undefined : "Required"),
  323. })
  324. if (prompts.isCancel(name)) throw new UI.CancelledError()
  325. const type = await prompts.select({
  326. message: "Select MCP server type",
  327. options: [
  328. {
  329. label: "Local",
  330. value: "local",
  331. hint: "Run a local command",
  332. },
  333. {
  334. label: "Remote",
  335. value: "remote",
  336. hint: "Connect to a remote URL",
  337. },
  338. ],
  339. })
  340. if (prompts.isCancel(type)) throw new UI.CancelledError()
  341. if (type === "local") {
  342. const command = await prompts.text({
  343. message: "Enter command to run",
  344. placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem",
  345. validate: (x) => (x && x.length > 0 ? undefined : "Required"),
  346. })
  347. if (prompts.isCancel(command)) throw new UI.CancelledError()
  348. prompts.log.info(`Local MCP server "${name}" configured with command: ${command}`)
  349. prompts.outro("MCP server added successfully")
  350. return
  351. }
  352. if (type === "remote") {
  353. const url = await prompts.text({
  354. message: "Enter MCP server URL",
  355. placeholder: "e.g., https://example.com/mcp",
  356. validate: (x) => {
  357. if (!x) return "Required"
  358. if (x.length === 0) return "Required"
  359. const isValid = URL.canParse(x)
  360. return isValid ? undefined : "Invalid URL"
  361. },
  362. })
  363. if (prompts.isCancel(url)) throw new UI.CancelledError()
  364. const useOAuth = await prompts.confirm({
  365. message: "Does this server require OAuth authentication?",
  366. initialValue: false,
  367. })
  368. if (prompts.isCancel(useOAuth)) throw new UI.CancelledError()
  369. if (useOAuth) {
  370. const hasClientId = await prompts.confirm({
  371. message: "Do you have a pre-registered client ID?",
  372. initialValue: false,
  373. })
  374. if (prompts.isCancel(hasClientId)) throw new UI.CancelledError()
  375. if (hasClientId) {
  376. const clientId = await prompts.text({
  377. message: "Enter client ID",
  378. validate: (x) => (x && x.length > 0 ? undefined : "Required"),
  379. })
  380. if (prompts.isCancel(clientId)) throw new UI.CancelledError()
  381. const hasSecret = await prompts.confirm({
  382. message: "Do you have a client secret?",
  383. initialValue: false,
  384. })
  385. if (prompts.isCancel(hasSecret)) throw new UI.CancelledError()
  386. let clientSecret: string | undefined
  387. if (hasSecret) {
  388. const secret = await prompts.password({
  389. message: "Enter client secret",
  390. })
  391. if (prompts.isCancel(secret)) throw new UI.CancelledError()
  392. clientSecret = secret
  393. }
  394. prompts.log.info(`Remote MCP server "${name}" configured with OAuth (client ID: ${clientId})`)
  395. prompts.log.info("Add this to your opencode.json:")
  396. prompts.log.info(`
  397. "mcp": {
  398. "${name}": {
  399. "type": "remote",
  400. "url": "${url}",
  401. "oauth": {
  402. "clientId": "${clientId}"${clientSecret ? `,\n "clientSecret": "${clientSecret}"` : ""}
  403. }
  404. }
  405. }`)
  406. } else {
  407. prompts.log.info(`Remote MCP server "${name}" configured with OAuth (dynamic registration)`)
  408. prompts.log.info("Add this to your opencode.json:")
  409. prompts.log.info(`
  410. "mcp": {
  411. "${name}": {
  412. "type": "remote",
  413. "url": "${url}",
  414. "oauth": {}
  415. }
  416. }`)
  417. }
  418. } else {
  419. const client = new Client({
  420. name: "opencode",
  421. version: "1.0.0",
  422. })
  423. const transport = new StreamableHTTPClientTransport(new URL(url))
  424. await client.connect(transport)
  425. prompts.log.info(`Remote MCP server "${name}" configured with URL: ${url}`)
  426. }
  427. }
  428. prompts.outro("MCP server added successfully")
  429. },
  430. })
  431. export const McpDebugCommand = cmd({
  432. command: "debug <name>",
  433. describe: "debug OAuth connection for an MCP server",
  434. builder: (yargs) =>
  435. yargs.positional("name", {
  436. describe: "name of the MCP server",
  437. type: "string",
  438. demandOption: true,
  439. }),
  440. async handler(args) {
  441. await Instance.provide({
  442. directory: process.cwd(),
  443. async fn() {
  444. UI.empty()
  445. prompts.intro("MCP OAuth Debug")
  446. const config = await Config.get()
  447. const mcpServers = config.mcp ?? {}
  448. const serverName = args.name
  449. const serverConfig = mcpServers[serverName]
  450. if (!serverConfig) {
  451. prompts.log.error(`MCP server not found: ${serverName}`)
  452. prompts.outro("Done")
  453. return
  454. }
  455. if (serverConfig.type !== "remote") {
  456. prompts.log.error(`MCP server ${serverName} is not a remote server`)
  457. prompts.outro("Done")
  458. return
  459. }
  460. if (serverConfig.oauth === false) {
  461. prompts.log.warn(`MCP server ${serverName} has OAuth explicitly disabled`)
  462. prompts.outro("Done")
  463. return
  464. }
  465. prompts.log.info(`Server: ${serverName}`)
  466. prompts.log.info(`URL: ${serverConfig.url}`)
  467. // Check stored auth status
  468. const authStatus = await MCP.getAuthStatus(serverName)
  469. prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`)
  470. const entry = await McpAuth.get(serverName)
  471. if (entry?.tokens) {
  472. prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`)
  473. if (entry.tokens.expiresAt) {
  474. const expiresDate = new Date(entry.tokens.expiresAt * 1000)
  475. const isExpired = entry.tokens.expiresAt < Date.now() / 1000
  476. prompts.log.info(` Expires: ${expiresDate.toISOString()} ${isExpired ? "(EXPIRED)" : ""}`)
  477. }
  478. if (entry.tokens.refreshToken) {
  479. prompts.log.info(` Refresh token: present`)
  480. }
  481. }
  482. if (entry?.clientInfo) {
  483. prompts.log.info(` Client ID: ${entry.clientInfo.clientId}`)
  484. if (entry.clientInfo.clientSecretExpiresAt) {
  485. const expiresDate = new Date(entry.clientInfo.clientSecretExpiresAt * 1000)
  486. prompts.log.info(` Client secret expires: ${expiresDate.toISOString()}`)
  487. }
  488. }
  489. const spinner = prompts.spinner()
  490. spinner.start("Testing connection...")
  491. // Test basic HTTP connectivity first
  492. try {
  493. const response = await fetch(serverConfig.url, {
  494. method: "POST",
  495. headers: {
  496. "Content-Type": "application/json",
  497. Accept: "application/json, text/event-stream",
  498. },
  499. body: JSON.stringify({
  500. jsonrpc: "2.0",
  501. method: "initialize",
  502. params: {
  503. protocolVersion: "2024-11-05",
  504. capabilities: {},
  505. clientInfo: { name: "opencode-debug", version: Installation.VERSION },
  506. },
  507. id: 1,
  508. }),
  509. })
  510. spinner.stop(`HTTP response: ${response.status} ${response.statusText}`)
  511. // Check for WWW-Authenticate header
  512. const wwwAuth = response.headers.get("www-authenticate")
  513. if (wwwAuth) {
  514. prompts.log.info(`WWW-Authenticate: ${wwwAuth}`)
  515. }
  516. if (response.status === 401) {
  517. prompts.log.warn("Server returned 401 Unauthorized")
  518. // Try to discover OAuth metadata
  519. const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined
  520. const authProvider = new McpOAuthProvider(
  521. serverName,
  522. serverConfig.url,
  523. {
  524. clientId: oauthConfig?.clientId,
  525. clientSecret: oauthConfig?.clientSecret,
  526. scope: oauthConfig?.scope,
  527. },
  528. {
  529. onRedirect: async () => {},
  530. },
  531. )
  532. prompts.log.info("Testing OAuth flow (without completing authorization)...")
  533. // Try creating transport with auth provider to trigger discovery
  534. const transport = new StreamableHTTPClientTransport(new URL(serverConfig.url), {
  535. authProvider,
  536. })
  537. try {
  538. const client = new Client({
  539. name: "opencode-debug",
  540. version: Installation.VERSION,
  541. })
  542. await client.connect(transport)
  543. prompts.log.success("Connection successful (already authenticated)")
  544. await client.close()
  545. } catch (error) {
  546. if (error instanceof UnauthorizedError) {
  547. prompts.log.info(`OAuth flow triggered: ${error.message}`)
  548. // Check if dynamic registration would be attempted
  549. const clientInfo = await authProvider.clientInformation()
  550. if (clientInfo) {
  551. prompts.log.info(`Client ID available: ${clientInfo.client_id}`)
  552. } else {
  553. prompts.log.info("No client ID - dynamic registration will be attempted")
  554. }
  555. } else {
  556. prompts.log.error(`Connection error: ${error instanceof Error ? error.message : String(error)}`)
  557. }
  558. }
  559. } else if (response.status >= 200 && response.status < 300) {
  560. prompts.log.success("Server responded successfully (no auth required or already authenticated)")
  561. const body = await response.text()
  562. try {
  563. const json = JSON.parse(body)
  564. if (json.result?.serverInfo) {
  565. prompts.log.info(`Server info: ${JSON.stringify(json.result.serverInfo)}`)
  566. }
  567. } catch {
  568. // Not JSON, ignore
  569. }
  570. } else {
  571. prompts.log.warn(`Unexpected status: ${response.status}`)
  572. const body = await response.text().catch(() => "")
  573. if (body) {
  574. prompts.log.info(`Response body: ${body.substring(0, 500)}`)
  575. }
  576. }
  577. } catch (error) {
  578. spinner.stop("Connection failed", 1)
  579. prompts.log.error(`Error: ${error instanceof Error ? error.message : String(error)}`)
  580. }
  581. prompts.outro("Debug complete")
  582. },
  583. })
  584. },
  585. })