index.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792
  1. import { dynamicTool, type Tool, jsonSchema, type JSONSchema7 } from "ai"
  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 { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
  6. import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
  7. import {
  8. CallToolResultSchema,
  9. type Tool as MCPToolDef,
  10. ToolListChangedNotificationSchema,
  11. } from "@modelcontextprotocol/sdk/types.js"
  12. import { Config } from "../config/config"
  13. import { Log } from "../util/log"
  14. import { NamedError } from "@opencode-ai/util/error"
  15. import z from "zod/v4"
  16. import { Instance } from "../project/instance"
  17. import { Installation } from "../installation"
  18. import { withTimeout } from "@/util/timeout"
  19. import { McpOAuthProvider } from "./oauth-provider"
  20. import { McpOAuthCallback } from "./oauth-callback"
  21. import { McpAuth } from "./auth"
  22. import { BusEvent } from "../bus/bus-event"
  23. import { Bus } from "@/bus"
  24. import { TuiEvent } from "@/cli/cmd/tui/event"
  25. import open from "open"
  26. export namespace MCP {
  27. const log = Log.create({ service: "mcp" })
  28. const DEFAULT_TIMEOUT = 5000
  29. export const ToolsChanged = BusEvent.define(
  30. "mcp.tools.changed",
  31. z.object({
  32. server: z.string(),
  33. }),
  34. )
  35. export const Failed = NamedError.create(
  36. "MCPFailed",
  37. z.object({
  38. name: z.string(),
  39. }),
  40. )
  41. type MCPClient = Client
  42. export const Status = z
  43. .discriminatedUnion("status", [
  44. z
  45. .object({
  46. status: z.literal("connected"),
  47. })
  48. .meta({
  49. ref: "MCPStatusConnected",
  50. }),
  51. z
  52. .object({
  53. status: z.literal("disabled"),
  54. })
  55. .meta({
  56. ref: "MCPStatusDisabled",
  57. }),
  58. z
  59. .object({
  60. status: z.literal("failed"),
  61. error: z.string(),
  62. })
  63. .meta({
  64. ref: "MCPStatusFailed",
  65. }),
  66. z
  67. .object({
  68. status: z.literal("needs_auth"),
  69. })
  70. .meta({
  71. ref: "MCPStatusNeedsAuth",
  72. }),
  73. z
  74. .object({
  75. status: z.literal("needs_client_registration"),
  76. error: z.string(),
  77. })
  78. .meta({
  79. ref: "MCPStatusNeedsClientRegistration",
  80. }),
  81. ])
  82. .meta({
  83. ref: "MCPStatus",
  84. })
  85. export type Status = z.infer<typeof Status>
  86. // Register notification handlers for MCP client
  87. function registerNotificationHandlers(client: MCPClient, serverName: string) {
  88. client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
  89. log.info("tools list changed notification received", { server: serverName })
  90. Bus.publish(ToolsChanged, { server: serverName })
  91. })
  92. }
  93. // Convert MCP tool definition to AI SDK Tool type
  94. async function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient): Promise<Tool> {
  95. const inputSchema = mcpTool.inputSchema
  96. // Spread first, then override type to ensure it's always "object"
  97. const schema: JSONSchema7 = {
  98. ...(inputSchema as JSONSchema7),
  99. type: "object",
  100. properties: (inputSchema.properties ?? {}) as JSONSchema7["properties"],
  101. additionalProperties: false,
  102. }
  103. const config = await Config.get()
  104. return dynamicTool({
  105. description: mcpTool.description ?? "",
  106. inputSchema: jsonSchema(schema),
  107. execute: async (args: unknown) => {
  108. return client.callTool(
  109. {
  110. name: mcpTool.name,
  111. arguments: args as Record<string, unknown>,
  112. },
  113. CallToolResultSchema,
  114. {
  115. resetTimeoutOnProgress: true,
  116. timeout: config.experimental?.mcp_timeout,
  117. },
  118. )
  119. },
  120. })
  121. }
  122. // Store transports for OAuth servers to allow finishing auth
  123. type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport
  124. const pendingOAuthTransports = new Map<string, TransportWithAuth>()
  125. // Prompt cache types
  126. type PromptInfo = Awaited<ReturnType<MCPClient["listPrompts"]>>["prompts"][number]
  127. type McpEntry = NonNullable<Config.Info["mcp"]>[string]
  128. function isMcpConfigured(entry: McpEntry): entry is Config.Mcp {
  129. return typeof entry === "object" && entry !== null && "type" in entry
  130. }
  131. const state = Instance.state(
  132. async () => {
  133. const cfg = await Config.get()
  134. const config = cfg.mcp ?? {}
  135. const clients: Record<string, MCPClient> = {}
  136. const status: Record<string, Status> = {}
  137. await Promise.all(
  138. Object.entries(config).map(async ([key, mcp]) => {
  139. if (!isMcpConfigured(mcp)) {
  140. log.error("Ignoring MCP config entry without type", { key })
  141. return
  142. }
  143. // If disabled by config, mark as disabled without trying to connect
  144. if (mcp.enabled === false) {
  145. status[key] = { status: "disabled" }
  146. return
  147. }
  148. const result = await create(key, mcp).catch(() => undefined)
  149. if (!result) return
  150. status[key] = result.status
  151. if (result.mcpClient) {
  152. clients[key] = result.mcpClient
  153. }
  154. }),
  155. )
  156. return {
  157. status,
  158. clients,
  159. }
  160. },
  161. async (state) => {
  162. await Promise.all(
  163. Object.values(state.clients).map((client) =>
  164. client.close().catch((error) => {
  165. log.error("Failed to close MCP client", {
  166. error,
  167. })
  168. }),
  169. ),
  170. )
  171. pendingOAuthTransports.clear()
  172. },
  173. )
  174. // Helper function to fetch prompts for a specific client
  175. async function fetchPromptsForClient(clientName: string, client: Client) {
  176. const prompts = await client.listPrompts().catch((e) => {
  177. log.error("failed to get prompts", { clientName, error: e.message })
  178. return undefined
  179. })
  180. if (!prompts) {
  181. return
  182. }
  183. const commands: Record<string, PromptInfo & { client: string }> = {}
  184. for (const prompt of prompts.prompts) {
  185. const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
  186. const sanitizedPromptName = prompt.name.replace(/[^a-zA-Z0-9_-]/g, "_")
  187. const key = sanitizedClientName + ":" + sanitizedPromptName
  188. commands[key] = { ...prompt, client: clientName }
  189. }
  190. return commands
  191. }
  192. export async function add(name: string, mcp: Config.Mcp) {
  193. const s = await state()
  194. const result = await create(name, mcp)
  195. if (!result) {
  196. const status = {
  197. status: "failed" as const,
  198. error: "unknown error",
  199. }
  200. s.status[name] = status
  201. return {
  202. status,
  203. }
  204. }
  205. if (!result.mcpClient) {
  206. s.status[name] = result.status
  207. return {
  208. status: s.status,
  209. }
  210. }
  211. s.clients[name] = result.mcpClient
  212. s.status[name] = result.status
  213. return {
  214. status: s.status,
  215. }
  216. }
  217. async function create(key: string, mcp: Config.Mcp) {
  218. if (mcp.enabled === false) {
  219. log.info("mcp server disabled", { key })
  220. return {
  221. mcpClient: undefined,
  222. status: { status: "disabled" as const },
  223. }
  224. }
  225. log.info("found", { key, type: mcp.type })
  226. let mcpClient: MCPClient | undefined
  227. let status: Status | undefined = undefined
  228. if (mcp.type === "remote") {
  229. // OAuth is enabled by default for remote servers unless explicitly disabled with oauth: false
  230. const oauthDisabled = mcp.oauth === false
  231. const oauthConfig = typeof mcp.oauth === "object" ? mcp.oauth : undefined
  232. let authProvider: McpOAuthProvider | undefined
  233. if (!oauthDisabled) {
  234. authProvider = new McpOAuthProvider(
  235. key,
  236. mcp.url,
  237. {
  238. clientId: oauthConfig?.clientId,
  239. clientSecret: oauthConfig?.clientSecret,
  240. scope: oauthConfig?.scope,
  241. },
  242. {
  243. onRedirect: async (url) => {
  244. log.info("oauth redirect requested", { key, url: url.toString() })
  245. // Store the URL - actual browser opening is handled by startAuth
  246. },
  247. },
  248. )
  249. }
  250. const transports: Array<{ name: string; transport: TransportWithAuth }> = [
  251. {
  252. name: "StreamableHTTP",
  253. transport: new StreamableHTTPClientTransport(new URL(mcp.url), {
  254. authProvider,
  255. requestInit: mcp.headers ? { headers: mcp.headers } : undefined,
  256. }),
  257. },
  258. {
  259. name: "SSE",
  260. transport: new SSEClientTransport(new URL(mcp.url), {
  261. authProvider,
  262. requestInit: mcp.headers ? { headers: mcp.headers } : undefined,
  263. }),
  264. },
  265. ]
  266. let lastError: Error | undefined
  267. const connectTimeout = mcp.timeout ?? DEFAULT_TIMEOUT
  268. for (const { name, transport } of transports) {
  269. try {
  270. const client = new Client({
  271. name: "opencode",
  272. version: Installation.VERSION,
  273. })
  274. await withTimeout(client.connect(transport), connectTimeout)
  275. registerNotificationHandlers(client, key)
  276. mcpClient = client
  277. log.info("connected", { key, transport: name })
  278. status = { status: "connected" }
  279. break
  280. } catch (error) {
  281. lastError = error instanceof Error ? error : new Error(String(error))
  282. // Handle OAuth-specific errors
  283. if (error instanceof UnauthorizedError) {
  284. log.info("mcp server requires authentication", { key, transport: name })
  285. // Check if this is a "needs registration" error
  286. if (lastError.message.includes("registration") || lastError.message.includes("client_id")) {
  287. status = {
  288. status: "needs_client_registration" as const,
  289. error: "Server does not support dynamic client registration. Please provide clientId in config.",
  290. }
  291. // Show toast for needs_client_registration
  292. Bus.publish(TuiEvent.ToastShow, {
  293. title: "MCP Authentication Required",
  294. message: `Server "${key}" requires a pre-registered client ID. Add clientId to your config.`,
  295. variant: "warning",
  296. duration: 8000,
  297. }).catch((e) => log.debug("failed to show toast", { error: e }))
  298. } else {
  299. // Store transport for later finishAuth call
  300. pendingOAuthTransports.set(key, transport)
  301. status = { status: "needs_auth" as const }
  302. // Show toast for needs_auth
  303. Bus.publish(TuiEvent.ToastShow, {
  304. title: "MCP Authentication Required",
  305. message: `Server "${key}" requires authentication. Run: opencode mcp auth ${key}`,
  306. variant: "warning",
  307. duration: 8000,
  308. }).catch((e) => log.debug("failed to show toast", { error: e }))
  309. }
  310. break
  311. }
  312. log.debug("transport connection failed", {
  313. key,
  314. transport: name,
  315. url: mcp.url,
  316. error: lastError.message,
  317. })
  318. status = {
  319. status: "failed" as const,
  320. error: lastError.message,
  321. }
  322. }
  323. }
  324. }
  325. if (mcp.type === "local") {
  326. const [cmd, ...args] = mcp.command
  327. const cwd = Instance.directory
  328. const transport = new StdioClientTransport({
  329. stderr: "ignore",
  330. command: cmd,
  331. args,
  332. cwd,
  333. env: {
  334. ...process.env,
  335. ...(cmd === "opencode" ? { BUN_BE_BUN: "1" } : {}),
  336. ...mcp.environment,
  337. },
  338. })
  339. const connectTimeout = mcp.timeout ?? DEFAULT_TIMEOUT
  340. try {
  341. const client = new Client({
  342. name: "opencode",
  343. version: Installation.VERSION,
  344. })
  345. await withTimeout(client.connect(transport), connectTimeout)
  346. registerNotificationHandlers(client, key)
  347. mcpClient = client
  348. status = {
  349. status: "connected",
  350. }
  351. } catch (error) {
  352. log.error("local mcp startup failed", {
  353. key,
  354. command: mcp.command,
  355. cwd,
  356. error: error instanceof Error ? error.message : String(error),
  357. })
  358. status = {
  359. status: "failed" as const,
  360. error: error instanceof Error ? error.message : String(error),
  361. }
  362. }
  363. }
  364. if (!status) {
  365. status = {
  366. status: "failed" as const,
  367. error: "Unknown error",
  368. }
  369. }
  370. if (!mcpClient) {
  371. return {
  372. mcpClient: undefined,
  373. status,
  374. }
  375. }
  376. const result = await withTimeout(mcpClient.listTools(), mcp.timeout ?? DEFAULT_TIMEOUT).catch((err) => {
  377. log.error("failed to get tools from client", { key, error: err })
  378. return undefined
  379. })
  380. if (!result) {
  381. await mcpClient.close().catch((error) => {
  382. log.error("Failed to close MCP client", {
  383. error,
  384. })
  385. })
  386. status = {
  387. status: "failed",
  388. error: "Failed to get tools",
  389. }
  390. return {
  391. mcpClient: undefined,
  392. status: {
  393. status: "failed" as const,
  394. error: "Failed to get tools",
  395. },
  396. }
  397. }
  398. log.info("create() successfully created client", { key, toolCount: result.tools.length })
  399. return {
  400. mcpClient,
  401. status,
  402. }
  403. }
  404. export async function status() {
  405. const s = await state()
  406. const cfg = await Config.get()
  407. const config = cfg.mcp ?? {}
  408. const result: Record<string, Status> = {}
  409. // Include all configured MCPs from config, not just connected ones
  410. for (const [key, mcp] of Object.entries(config)) {
  411. if (!isMcpConfigured(mcp)) continue
  412. result[key] = s.status[key] ?? { status: "disabled" }
  413. }
  414. return result
  415. }
  416. export async function clients() {
  417. return state().then((state) => state.clients)
  418. }
  419. export async function connect(name: string) {
  420. const cfg = await Config.get()
  421. const config = cfg.mcp ?? {}
  422. const mcp = config[name]
  423. if (!mcp) {
  424. log.error("MCP config not found", { name })
  425. return
  426. }
  427. if (!isMcpConfigured(mcp)) {
  428. log.error("Ignoring MCP connect request for config without type", { name })
  429. return
  430. }
  431. const result = await create(name, { ...mcp, enabled: true })
  432. if (!result) {
  433. const s = await state()
  434. s.status[name] = {
  435. status: "failed",
  436. error: "Unknown error during connection",
  437. }
  438. return
  439. }
  440. const s = await state()
  441. s.status[name] = result.status
  442. if (result.mcpClient) {
  443. s.clients[name] = result.mcpClient
  444. }
  445. }
  446. export async function disconnect(name: string) {
  447. const s = await state()
  448. const client = s.clients[name]
  449. if (client) {
  450. await client.close().catch((error) => {
  451. log.error("Failed to close MCP client", { name, error })
  452. })
  453. delete s.clients[name]
  454. }
  455. s.status[name] = { status: "disabled" }
  456. }
  457. export async function tools() {
  458. const result: Record<string, Tool> = {}
  459. const s = await state()
  460. const clientsSnapshot = await clients()
  461. for (const [clientName, client] of Object.entries(clientsSnapshot)) {
  462. // Only include tools from connected MCPs (skip disabled ones)
  463. if (s.status[clientName]?.status !== "connected") {
  464. continue
  465. }
  466. const toolsResult = await client.listTools().catch((e) => {
  467. log.error("failed to get tools", { clientName, error: e.message })
  468. const failedStatus = {
  469. status: "failed" as const,
  470. error: e instanceof Error ? e.message : String(e),
  471. }
  472. s.status[clientName] = failedStatus
  473. delete s.clients[clientName]
  474. return undefined
  475. })
  476. if (!toolsResult) {
  477. continue
  478. }
  479. for (const mcpTool of toolsResult.tools) {
  480. const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
  481. const sanitizedToolName = mcpTool.name.replace(/[^a-zA-Z0-9_-]/g, "_")
  482. result[sanitizedClientName + "_" + sanitizedToolName] = await convertMcpTool(mcpTool, client)
  483. }
  484. }
  485. return result
  486. }
  487. export async function prompts() {
  488. const s = await state()
  489. const clientsSnapshot = await clients()
  490. const prompts = Object.fromEntries<PromptInfo & { client: string }>(
  491. (
  492. await Promise.all(
  493. Object.entries(clientsSnapshot).map(async ([clientName, client]) => {
  494. if (s.status[clientName]?.status !== "connected") {
  495. return []
  496. }
  497. return Object.entries((await fetchPromptsForClient(clientName, client)) ?? {})
  498. }),
  499. )
  500. ).flat(),
  501. )
  502. return prompts
  503. }
  504. export async function getPrompt(clientName: string, name: string, args?: Record<string, string>) {
  505. const clientsSnapshot = await clients()
  506. const client = clientsSnapshot[clientName]
  507. if (!client) {
  508. log.warn("client not found for prompt", {
  509. clientName,
  510. })
  511. return undefined
  512. }
  513. const result = await client
  514. .getPrompt({
  515. name: name,
  516. arguments: args,
  517. })
  518. .catch((e) => {
  519. log.error("failed to get prompt from MCP server", {
  520. clientName,
  521. promptName: name,
  522. error: e.message,
  523. })
  524. return undefined
  525. })
  526. return result
  527. }
  528. /**
  529. * Start OAuth authentication flow for an MCP server.
  530. * Returns the authorization URL that should be opened in a browser.
  531. */
  532. export async function startAuth(mcpName: string): Promise<{ authorizationUrl: string }> {
  533. const cfg = await Config.get()
  534. const mcpConfig = cfg.mcp?.[mcpName]
  535. if (!mcpConfig) {
  536. throw new Error(`MCP server not found: ${mcpName}`)
  537. }
  538. if (!isMcpConfigured(mcpConfig)) {
  539. throw new Error(`MCP server ${mcpName} is disabled or missing configuration`)
  540. }
  541. if (mcpConfig.type !== "remote") {
  542. throw new Error(`MCP server ${mcpName} is not a remote server`)
  543. }
  544. if (mcpConfig.oauth === false) {
  545. throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`)
  546. }
  547. // Start the callback server
  548. await McpOAuthCallback.ensureRunning()
  549. // Generate and store a cryptographically secure state parameter BEFORE creating the provider
  550. // The SDK will call provider.state() to read this value
  551. const oauthState = Array.from(crypto.getRandomValues(new Uint8Array(32)))
  552. .map((b) => b.toString(16).padStart(2, "0"))
  553. .join("")
  554. await McpAuth.updateOAuthState(mcpName, oauthState)
  555. // Create a new auth provider for this flow
  556. // OAuth config is optional - if not provided, we'll use auto-discovery
  557. const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
  558. let capturedUrl: URL | undefined
  559. const authProvider = new McpOAuthProvider(
  560. mcpName,
  561. mcpConfig.url,
  562. {
  563. clientId: oauthConfig?.clientId,
  564. clientSecret: oauthConfig?.clientSecret,
  565. scope: oauthConfig?.scope,
  566. },
  567. {
  568. onRedirect: async (url) => {
  569. capturedUrl = url
  570. },
  571. },
  572. )
  573. // Create transport with auth provider
  574. const transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url), {
  575. authProvider,
  576. })
  577. // Try to connect - this will trigger the OAuth flow
  578. try {
  579. const client = new Client({
  580. name: "opencode",
  581. version: Installation.VERSION,
  582. })
  583. await client.connect(transport)
  584. // If we get here, we're already authenticated
  585. return { authorizationUrl: "" }
  586. } catch (error) {
  587. if (error instanceof UnauthorizedError && capturedUrl) {
  588. // Store transport for finishAuth
  589. pendingOAuthTransports.set(mcpName, transport)
  590. return { authorizationUrl: capturedUrl.toString() }
  591. }
  592. throw error
  593. }
  594. }
  595. /**
  596. * Complete OAuth authentication after user authorizes in browser.
  597. * Opens the browser and waits for callback.
  598. */
  599. export async function authenticate(mcpName: string): Promise<Status> {
  600. const { authorizationUrl } = await startAuth(mcpName)
  601. if (!authorizationUrl) {
  602. // Already authenticated
  603. const s = await state()
  604. return s.status[mcpName] ?? { status: "connected" }
  605. }
  606. // Get the state that was already generated and stored in startAuth()
  607. const oauthState = await McpAuth.getOAuthState(mcpName)
  608. if (!oauthState) {
  609. throw new Error("OAuth state not found - this should not happen")
  610. }
  611. // The SDK has already added the state parameter to the authorization URL
  612. // We just need to open the browser
  613. log.info("opening browser for oauth", { mcpName, url: authorizationUrl, state: oauthState })
  614. await open(authorizationUrl)
  615. // Wait for callback using the OAuth state parameter
  616. const code = await McpOAuthCallback.waitForCallback(oauthState)
  617. // Validate and clear the state
  618. const storedState = await McpAuth.getOAuthState(mcpName)
  619. if (storedState !== oauthState) {
  620. await McpAuth.clearOAuthState(mcpName)
  621. throw new Error("OAuth state mismatch - potential CSRF attack")
  622. }
  623. await McpAuth.clearOAuthState(mcpName)
  624. // Finish auth
  625. return finishAuth(mcpName, code)
  626. }
  627. /**
  628. * Complete OAuth authentication with the authorization code.
  629. */
  630. export async function finishAuth(mcpName: string, authorizationCode: string): Promise<Status> {
  631. const transport = pendingOAuthTransports.get(mcpName)
  632. if (!transport) {
  633. throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`)
  634. }
  635. try {
  636. // Call finishAuth on the transport
  637. await transport.finishAuth(authorizationCode)
  638. // Clear the code verifier after successful auth
  639. await McpAuth.clearCodeVerifier(mcpName)
  640. // Now try to reconnect
  641. const cfg = await Config.get()
  642. const mcpConfig = cfg.mcp?.[mcpName]
  643. if (!mcpConfig) {
  644. throw new Error(`MCP server not found: ${mcpName}`)
  645. }
  646. if (!isMcpConfigured(mcpConfig)) {
  647. throw new Error(`MCP server ${mcpName} is disabled or missing configuration`)
  648. }
  649. // Re-add the MCP server to establish connection
  650. pendingOAuthTransports.delete(mcpName)
  651. const result = await add(mcpName, mcpConfig)
  652. const statusRecord = result.status as Record<string, Status>
  653. return statusRecord[mcpName] ?? { status: "failed", error: "Unknown error after auth" }
  654. } catch (error) {
  655. log.error("failed to finish oauth", { mcpName, error })
  656. return {
  657. status: "failed",
  658. error: error instanceof Error ? error.message : String(error),
  659. }
  660. }
  661. }
  662. /**
  663. * Remove OAuth credentials for an MCP server.
  664. */
  665. export async function removeAuth(mcpName: string): Promise<void> {
  666. await McpAuth.remove(mcpName)
  667. McpOAuthCallback.cancelPending(mcpName)
  668. pendingOAuthTransports.delete(mcpName)
  669. await McpAuth.clearOAuthState(mcpName)
  670. log.info("removed oauth credentials", { mcpName })
  671. }
  672. /**
  673. * Check if an MCP server supports OAuth (remote servers support OAuth by default unless explicitly disabled).
  674. */
  675. export async function supportsOAuth(mcpName: string): Promise<boolean> {
  676. const cfg = await Config.get()
  677. const mcpConfig = cfg.mcp?.[mcpName]
  678. if (!mcpConfig) return false
  679. if (!isMcpConfigured(mcpConfig)) return false
  680. return mcpConfig.type === "remote" && mcpConfig.oauth !== false
  681. }
  682. /**
  683. * Check if an MCP server has stored OAuth tokens.
  684. */
  685. export async function hasStoredTokens(mcpName: string): Promise<boolean> {
  686. const entry = await McpAuth.get(mcpName)
  687. return !!entry?.tokens
  688. }
  689. export type AuthStatus = "authenticated" | "expired" | "not_authenticated"
  690. /**
  691. * Get the authentication status for an MCP server.
  692. */
  693. export async function getAuthStatus(mcpName: string): Promise<AuthStatus> {
  694. const hasTokens = await hasStoredTokens(mcpName)
  695. if (!hasTokens) return "not_authenticated"
  696. const expired = await McpAuth.isTokenExpired(mcpName)
  697. return expired ? "expired" : "authenticated"
  698. }
  699. }