| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654 |
- import { cmd } from "./cmd"
- import { Client } from "@modelcontextprotocol/sdk/client/index.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 * as prompts from "@clack/prompts"
- import { UI } from "../ui"
- import { MCP } from "../../mcp"
- import { McpAuth } from "../../mcp/auth"
- import { McpOAuthProvider } from "../../mcp/oauth-provider"
- import { Config } from "../../config/config"
- import { Instance } from "../../project/instance"
- import { Installation } from "../../installation"
- import path from "path"
- import { Global } from "../../global"
- function getAuthStatusIcon(status: MCP.AuthStatus): string {
- switch (status) {
- case "authenticated":
- return "✓"
- case "expired":
- return "⚠"
- case "not_authenticated":
- return "○"
- }
- }
- function getAuthStatusText(status: MCP.AuthStatus): string {
- switch (status) {
- case "authenticated":
- return "authenticated"
- case "expired":
- return "expired"
- case "not_authenticated":
- return "not authenticated"
- }
- }
- export const McpCommand = cmd({
- command: "mcp",
- builder: (yargs) =>
- yargs
- .command(McpAddCommand)
- .command(McpListCommand)
- .command(McpAuthCommand)
- .command(McpLogoutCommand)
- .command(McpDebugCommand)
- .demandCommand(),
- async handler() {},
- })
- export const McpListCommand = cmd({
- command: "list",
- aliases: ["ls"],
- describe: "list MCP servers and their status",
- async handler() {
- await Instance.provide({
- directory: process.cwd(),
- async fn() {
- UI.empty()
- prompts.intro("MCP Servers")
- const config = await Config.get()
- const mcpServers = config.mcp ?? {}
- const statuses = await MCP.status()
- if (Object.keys(mcpServers).length === 0) {
- prompts.log.warn("No MCP servers configured")
- prompts.outro("Add servers with: opencode mcp add")
- return
- }
- for (const [name, serverConfig] of Object.entries(mcpServers)) {
- const status = statuses[name]
- const hasOAuth = serverConfig.type === "remote" && !!serverConfig.oauth
- const hasStoredTokens = await MCP.hasStoredTokens(name)
- let statusIcon: string
- let statusText: string
- let hint = ""
- if (!status) {
- statusIcon = "○"
- statusText = "not initialized"
- } else if (status.status === "connected") {
- statusIcon = "✓"
- statusText = "connected"
- if (hasOAuth && hasStoredTokens) {
- hint = " (OAuth)"
- }
- } else if (status.status === "disabled") {
- statusIcon = "○"
- statusText = "disabled"
- } else if (status.status === "needs_auth") {
- statusIcon = "⚠"
- statusText = "needs authentication"
- } else if (status.status === "needs_client_registration") {
- statusIcon = "✗"
- statusText = "needs client registration"
- hint = "\n " + status.error
- } else {
- statusIcon = "✗"
- statusText = "failed"
- hint = "\n " + status.error
- }
- const typeHint = serverConfig.type === "remote" ? serverConfig.url : serverConfig.command.join(" ")
- prompts.log.info(
- `${statusIcon} ${name} ${UI.Style.TEXT_DIM}${statusText}${hint}\n ${UI.Style.TEXT_DIM}${typeHint}`,
- )
- }
- prompts.outro(`${Object.keys(mcpServers).length} server(s)`)
- },
- })
- },
- })
- export const McpAuthCommand = cmd({
- command: "auth [name]",
- describe: "authenticate with an OAuth-enabled MCP server",
- builder: (yargs) =>
- yargs
- .positional("name", {
- describe: "name of the MCP server",
- type: "string",
- })
- .command(McpAuthListCommand),
- async handler(args) {
- await Instance.provide({
- directory: process.cwd(),
- async fn() {
- UI.empty()
- prompts.intro("MCP OAuth Authentication")
- const config = await Config.get()
- const mcpServers = config.mcp ?? {}
- // Get OAuth-capable servers (remote servers with oauth not explicitly disabled)
- const oauthServers = Object.entries(mcpServers).filter(
- ([_, cfg]) => cfg.type === "remote" && cfg.oauth !== false,
- )
- if (oauthServers.length === 0) {
- prompts.log.warn("No OAuth-capable MCP servers configured")
- prompts.log.info("Remote MCP servers support OAuth by default. Add a remote server in opencode.json:")
- prompts.log.info(`
- "mcp": {
- "my-server": {
- "type": "remote",
- "url": "https://example.com/mcp"
- }
- }`)
- prompts.outro("Done")
- return
- }
- let serverName = args.name
- if (!serverName) {
- // Build options with auth status
- const options = await Promise.all(
- oauthServers.map(async ([name, cfg]) => {
- const authStatus = await MCP.getAuthStatus(name)
- const icon = getAuthStatusIcon(authStatus)
- const statusText = getAuthStatusText(authStatus)
- const url = cfg.type === "remote" ? cfg.url : ""
- return {
- label: `${icon} ${name} (${statusText})`,
- value: name,
- hint: url,
- }
- }),
- )
- const selected = await prompts.select({
- message: "Select MCP server to authenticate",
- options,
- })
- if (prompts.isCancel(selected)) throw new UI.CancelledError()
- serverName = selected
- }
- const serverConfig = mcpServers[serverName]
- if (!serverConfig) {
- prompts.log.error(`MCP server not found: ${serverName}`)
- prompts.outro("Done")
- return
- }
- if (serverConfig.type !== "remote" || serverConfig.oauth === false) {
- prompts.log.error(`MCP server ${serverName} does not support OAuth (oauth is disabled)`)
- prompts.outro("Done")
- return
- }
- // Check if already authenticated
- const authStatus = await MCP.getAuthStatus(serverName)
- if (authStatus === "authenticated") {
- const confirm = await prompts.confirm({
- message: `${serverName} already has valid credentials. Re-authenticate?`,
- })
- if (prompts.isCancel(confirm) || !confirm) {
- prompts.outro("Cancelled")
- return
- }
- } else if (authStatus === "expired") {
- prompts.log.warn(`${serverName} has expired credentials. Re-authenticating...`)
- }
- const spinner = prompts.spinner()
- spinner.start("Starting OAuth flow...")
- try {
- const status = await MCP.authenticate(serverName)
- if (status.status === "connected") {
- spinner.stop("Authentication successful!")
- } else if (status.status === "needs_client_registration") {
- spinner.stop("Authentication failed", 1)
- prompts.log.error(status.error)
- prompts.log.info("Add clientId to your MCP server config:")
- prompts.log.info(`
- "mcp": {
- "${serverName}": {
- "type": "remote",
- "url": "${serverConfig.url}",
- "oauth": {
- "clientId": "your-client-id",
- "clientSecret": "your-client-secret"
- }
- }
- }`)
- } else if (status.status === "failed") {
- spinner.stop("Authentication failed", 1)
- prompts.log.error(status.error)
- } else {
- spinner.stop("Unexpected status: " + status.status, 1)
- }
- } catch (error) {
- spinner.stop("Authentication failed", 1)
- prompts.log.error(error instanceof Error ? error.message : String(error))
- }
- prompts.outro("Done")
- },
- })
- },
- })
- export const McpAuthListCommand = cmd({
- command: "list",
- aliases: ["ls"],
- describe: "list OAuth-capable MCP servers and their auth status",
- async handler() {
- await Instance.provide({
- directory: process.cwd(),
- async fn() {
- UI.empty()
- prompts.intro("MCP OAuth Status")
- const config = await Config.get()
- const mcpServers = config.mcp ?? {}
- // Get OAuth-capable servers
- const oauthServers = Object.entries(mcpServers).filter(
- ([_, cfg]) => cfg.type === "remote" && cfg.oauth !== false,
- )
- if (oauthServers.length === 0) {
- prompts.log.warn("No OAuth-capable MCP servers configured")
- prompts.outro("Done")
- return
- }
- for (const [name, serverConfig] of oauthServers) {
- const authStatus = await MCP.getAuthStatus(name)
- const icon = getAuthStatusIcon(authStatus)
- const statusText = getAuthStatusText(authStatus)
- const url = serverConfig.type === "remote" ? serverConfig.url : ""
- prompts.log.info(`${icon} ${name} ${UI.Style.TEXT_DIM}${statusText}\n ${UI.Style.TEXT_DIM}${url}`)
- }
- prompts.outro(`${oauthServers.length} OAuth-capable server(s)`)
- },
- })
- },
- })
- export const McpLogoutCommand = cmd({
- command: "logout [name]",
- describe: "remove OAuth credentials for an MCP server",
- builder: (yargs) =>
- yargs.positional("name", {
- describe: "name of the MCP server",
- type: "string",
- }),
- async handler(args) {
- await Instance.provide({
- directory: process.cwd(),
- async fn() {
- UI.empty()
- prompts.intro("MCP OAuth Logout")
- const authPath = path.join(Global.Path.data, "mcp-auth.json")
- const credentials = await McpAuth.all()
- const serverNames = Object.keys(credentials)
- if (serverNames.length === 0) {
- prompts.log.warn("No MCP OAuth credentials stored")
- prompts.outro("Done")
- return
- }
- let serverName = args.name
- if (!serverName) {
- const selected = await prompts.select({
- message: "Select MCP server to logout",
- options: serverNames.map((name) => {
- const entry = credentials[name]
- const hasTokens = !!entry.tokens
- const hasClient = !!entry.clientInfo
- let hint = ""
- if (hasTokens && hasClient) hint = "tokens + client"
- else if (hasTokens) hint = "tokens"
- else if (hasClient) hint = "client registration"
- return {
- label: name,
- value: name,
- hint,
- }
- }),
- })
- if (prompts.isCancel(selected)) throw new UI.CancelledError()
- serverName = selected
- }
- if (!credentials[serverName]) {
- prompts.log.error(`No credentials found for: ${serverName}`)
- prompts.outro("Done")
- return
- }
- await MCP.removeAuth(serverName)
- prompts.log.success(`Removed OAuth credentials for ${serverName}`)
- prompts.outro("Done")
- },
- })
- },
- })
- 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()
- 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()
- prompts.log.info(`Local MCP server "${name}" configured with command: ${command}`)
- 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,
- })
- 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 (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
- }
- 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": {}
- }
- }`)
- }
- } 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")
- },
- })
- export const McpDebugCommand = cmd({
- command: "debug <name>",
- describe: "debug OAuth connection for an MCP server",
- builder: (yargs) =>
- yargs.positional("name", {
- describe: "name of the MCP server",
- type: "string",
- demandOption: true,
- }),
- async handler(args) {
- await Instance.provide({
- directory: process.cwd(),
- async fn() {
- UI.empty()
- prompts.intro("MCP OAuth Debug")
- const config = await Config.get()
- const mcpServers = config.mcp ?? {}
- const serverName = args.name
- const serverConfig = mcpServers[serverName]
- if (!serverConfig) {
- prompts.log.error(`MCP server not found: ${serverName}`)
- prompts.outro("Done")
- return
- }
- if (serverConfig.type !== "remote") {
- prompts.log.error(`MCP server ${serverName} is not a remote server`)
- prompts.outro("Done")
- return
- }
- if (serverConfig.oauth === false) {
- prompts.log.warn(`MCP server ${serverName} has OAuth explicitly disabled`)
- prompts.outro("Done")
- return
- }
- prompts.log.info(`Server: ${serverName}`)
- prompts.log.info(`URL: ${serverConfig.url}`)
- // Check stored auth status
- const authStatus = await MCP.getAuthStatus(serverName)
- prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`)
- const entry = await McpAuth.get(serverName)
- if (entry?.tokens) {
- prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`)
- if (entry.tokens.expiresAt) {
- const expiresDate = new Date(entry.tokens.expiresAt * 1000)
- const isExpired = entry.tokens.expiresAt < Date.now() / 1000
- prompts.log.info(` Expires: ${expiresDate.toISOString()} ${isExpired ? "(EXPIRED)" : ""}`)
- }
- if (entry.tokens.refreshToken) {
- prompts.log.info(` Refresh token: present`)
- }
- }
- if (entry?.clientInfo) {
- prompts.log.info(` Client ID: ${entry.clientInfo.clientId}`)
- if (entry.clientInfo.clientSecretExpiresAt) {
- const expiresDate = new Date(entry.clientInfo.clientSecretExpiresAt * 1000)
- prompts.log.info(` Client secret expires: ${expiresDate.toISOString()}`)
- }
- }
- const spinner = prompts.spinner()
- spinner.start("Testing connection...")
- // Test basic HTTP connectivity first
- try {
- const response = await fetch(serverConfig.url, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- Accept: "application/json, text/event-stream",
- },
- body: JSON.stringify({
- jsonrpc: "2.0",
- method: "initialize",
- params: {
- protocolVersion: "2024-11-05",
- capabilities: {},
- clientInfo: { name: "opencode-debug", version: Installation.VERSION },
- },
- id: 1,
- }),
- })
- spinner.stop(`HTTP response: ${response.status} ${response.statusText}`)
- // Check for WWW-Authenticate header
- const wwwAuth = response.headers.get("www-authenticate")
- if (wwwAuth) {
- prompts.log.info(`WWW-Authenticate: ${wwwAuth}`)
- }
- if (response.status === 401) {
- prompts.log.warn("Server returned 401 Unauthorized")
- // Try to discover OAuth metadata
- const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined
- const authProvider = new McpOAuthProvider(
- serverName,
- serverConfig.url,
- {
- clientId: oauthConfig?.clientId,
- clientSecret: oauthConfig?.clientSecret,
- scope: oauthConfig?.scope,
- },
- {
- onRedirect: async () => {},
- },
- )
- prompts.log.info("Testing OAuth flow (without completing authorization)...")
- // Try creating transport with auth provider to trigger discovery
- const transport = new StreamableHTTPClientTransport(new URL(serverConfig.url), {
- authProvider,
- })
- try {
- const client = new Client({
- name: "opencode-debug",
- version: Installation.VERSION,
- })
- await client.connect(transport)
- prompts.log.success("Connection successful (already authenticated)")
- await client.close()
- } catch (error) {
- if (error instanceof UnauthorizedError) {
- prompts.log.info(`OAuth flow triggered: ${error.message}`)
- // Check if dynamic registration would be attempted
- const clientInfo = await authProvider.clientInformation()
- if (clientInfo) {
- prompts.log.info(`Client ID available: ${clientInfo.client_id}`)
- } else {
- prompts.log.info("No client ID - dynamic registration will be attempted")
- }
- } else {
- prompts.log.error(`Connection error: ${error instanceof Error ? error.message : String(error)}`)
- }
- }
- } else if (response.status >= 200 && response.status < 300) {
- prompts.log.success("Server responded successfully (no auth required or already authenticated)")
- const body = await response.text()
- try {
- const json = JSON.parse(body)
- if (json.result?.serverInfo) {
- prompts.log.info(`Server info: ${JSON.stringify(json.result.serverInfo)}`)
- }
- } catch {
- // Not JSON, ignore
- }
- } else {
- prompts.log.warn(`Unexpected status: ${response.status}`)
- const body = await response.text().catch(() => "")
- if (body) {
- prompts.log.info(`Response body: ${body.substring(0, 500)}`)
- }
- }
- } catch (error) {
- spinner.stop("Connection failed", 1)
- prompts.log.error(`Error: ${error instanceof Error ? error.message : String(error)}`)
- }
- prompts.outro("Debug complete")
- },
- })
- },
- })
|