refactor(cli): convert mcp list, auth, auth list, logout to effectCmd (#25521)

This commit is contained in:
Kit Langton
2026-05-03 03:03:32 +00:00
committed by GitHub
parent 31cb0bfa4f
commit db24f89313
+243 -254
View File
@@ -1,4 +1,6 @@
import { cmd } from "./cmd" import { cmd } from "./cmd"
import { effectCmd } from "../effect-cmd"
import { Cause } from "effect"
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 { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
@@ -65,35 +67,31 @@ function oauthServers(config: Config.Info) {
) )
} }
async function listState() { function listState() {
return AppRuntime.runPromise( return Effect.gen(function* () {
Effect.gen(function* () { const cfg = yield* Config.Service
const cfg = yield* Config.Service const mcp = yield* MCP.Service
const mcp = yield* MCP.Service const config = yield* cfg.get()
const config = yield* cfg.get() const statuses = yield* mcp.status()
const statuses = yield* mcp.status() const stored = yield* Effect.all(
const stored = yield* Effect.all( Object.fromEntries(configuredServers(config).map(([name]) => [name, mcp.hasStoredTokens(name)])),
Object.fromEntries(configuredServers(config).map(([name]) => [name, mcp.hasStoredTokens(name)])), { concurrency: "unbounded" },
{ concurrency: "unbounded" }, )
) return { config, statuses, stored }
return { config, statuses, stored } })
}),
)
} }
async function authState() { function authState() {
return AppRuntime.runPromise( return Effect.gen(function* () {
Effect.gen(function* () { const cfg = yield* Config.Service
const cfg = yield* Config.Service const mcp = yield* MCP.Service
const mcp = yield* MCP.Service const config = yield* cfg.get()
const config = yield* cfg.get() const auth = yield* Effect.all(
const auth = yield* Effect.all( Object.fromEntries(oauthServers(config).map(([name]) => [name, mcp.getAuthStatus(name)])),
Object.fromEntries(oauthServers(config).map(([name]) => [name, mcp.getAuthStatus(name)])), { concurrency: "unbounded" },
{ concurrency: "unbounded" }, )
) return { config, auth }
return { config, auth } })
}),
)
} }
export const McpCommand = cmd({ export const McpCommand = cmd({
@@ -110,73 +108,68 @@ export const McpCommand = cmd({
async handler() {}, async handler() {},
}) })
export const McpListCommand = cmd({ export const McpListCommand = effectCmd({
command: "list", command: "list",
aliases: ["ls"], aliases: ["ls"],
describe: "list MCP servers and their status", describe: "list MCP servers and their status",
async handler() { handler: Effect.fn("Cli.mcp.list")(function* () {
await WithInstance.provide({ UI.empty()
directory: process.cwd(), prompts.intro("MCP Servers")
async fn() {
UI.empty()
prompts.intro("MCP Servers")
const { config, statuses, stored } = await listState() const { config, statuses, stored } = yield* listState()
const servers = configuredServers(config) const servers = configuredServers(config)
if (servers.length === 0) { if (servers.length === 0) {
prompts.log.warn("No MCP servers configured") prompts.log.warn("No MCP servers configured")
prompts.outro("Add servers with: opencode mcp add") prompts.outro("Add servers with: opencode mcp add")
return return
}
for (const [name, serverConfig] of servers) {
const status = statuses[name]
const hasOAuth = isMcpRemote(serverConfig) && !!serverConfig.oauth
const hasStoredTokens = stored[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
}
for (const [name, serverConfig] of servers) { const typeHint = serverConfig.type === "remote" ? serverConfig.url : serverConfig.command.join(" ")
const status = statuses[name] prompts.log.info(
const hasOAuth = isMcpRemote(serverConfig) && !!serverConfig.oauth `${statusIcon} ${name} ${UI.Style.TEXT_DIM}${statusText}${hint}\n ${UI.Style.TEXT_DIM}${typeHint}`,
const hasStoredTokens = stored[name] )
}
let statusIcon: string prompts.outro(`${servers.length} server(s)`)
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(`${servers.length} server(s)`)
},
})
},
}) })
export const McpAuthCommand = cmd({ export const McpAuthCommand = effectCmd({
command: "auth [name]", command: "auth [name]",
describe: "authenticate with an OAuth-enabled MCP server", describe: "authenticate with an OAuth-enabled MCP server",
builder: (yargs) => builder: (yargs) =>
@@ -186,105 +179,106 @@ export const McpAuthCommand = cmd({
type: "string", type: "string",
}) })
.command(McpAuthListCommand), .command(McpAuthListCommand),
async handler(args) { handler: Effect.fn("Cli.mcp.auth")(function* (args) {
await WithInstance.provide({ UI.empty()
directory: process.cwd(), prompts.intro("MCP OAuth Authentication")
async fn() {
UI.empty()
prompts.intro("MCP OAuth Authentication")
const { config, auth } = await authState() const { config, auth } = yield* authState()
const mcpServers = config.mcp ?? {} const mcpServers = config.mcp ?? {}
const servers = oauthServers(config) const servers = oauthServers(config)
if (servers.length === 0) { if (servers.length === 0) {
prompts.log.warn("No OAuth-capable MCP servers configured") 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("Remote MCP servers support OAuth by default. Add a remote server in opencode.json:")
prompts.log.info(` prompts.log.info(`
"mcp": { "mcp": {
"my-server": { "my-server": {
"type": "remote", "type": "remote",
"url": "https://example.com/mcp" "url": "https://example.com/mcp"
} }
}`) }`)
prompts.outro("Done") prompts.outro("Done")
return return
}
let serverName = args.name
if (!serverName) {
// Build options with auth status
const options = servers.map(([name, cfg]) => {
const authStatus = auth[name]
const icon = getAuthStatusIcon(authStatus)
const statusText = getAuthStatusText(authStatus)
const url = cfg.url
return {
label: `${icon} ${name} (${statusText})`,
value: name,
hint: url,
} }
})
let serverName = args.name const selected = yield* Effect.promise(() =>
if (!serverName) { prompts.select({
// Build options with auth status message: "Select MCP server to authenticate",
const options = servers.map(([name, cfg]) => { options,
const authStatus = auth[name] }),
const icon = getAuthStatusIcon(authStatus) )
const statusText = getAuthStatusText(authStatus) if (prompts.isCancel(selected)) throw new UI.CancelledError()
const url = cfg.url serverName = selected
return { }
label: `${icon} ${name} (${statusText})`,
value: name,
hint: url,
}
})
const selected = await prompts.select({ const serverConfig = mcpServers[serverName]
message: "Select MCP server to authenticate", if (!serverConfig) {
options, prompts.log.error(`MCP server not found: ${serverName}`)
}) prompts.outro("Done")
if (prompts.isCancel(selected)) throw new UI.CancelledError() return
serverName = selected }
}
const serverConfig = mcpServers[serverName] if (!isMcpRemote(serverConfig) || serverConfig.oauth === false) {
if (!serverConfig) { prompts.log.error(`MCP server ${serverName} is not an OAuth-capable remote server`)
prompts.log.error(`MCP server not found: ${serverName}`) prompts.outro("Done")
prompts.outro("Done") return
return }
}
if (!isMcpRemote(serverConfig) || serverConfig.oauth === false) { // Check if already authenticated
prompts.log.error(`MCP server ${serverName} is not an OAuth-capable remote server`) const authStatus = auth[serverName] ?? (yield* MCP.Service.use((mcp) => mcp.getAuthStatus(serverName)))
prompts.outro("Done") if (authStatus === "authenticated") {
return const confirm = yield* Effect.promise(() =>
} 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...`)
}
// Check if already authenticated const spinner = prompts.spinner()
const authStatus = spinner.start("Starting OAuth flow...")
auth[serverName] ?? (await AppRuntime.runPromise(MCP.Service.use((mcp) => 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() // Subscribe to browser open failure events to show URL for manual opening
spinner.start("Starting OAuth flow...") const unsubscribe = Bus.subscribe(MCP.BrowserOpenFailed, (evt) => {
if (evt.properties.mcpName === serverName) {
spinner.stop("Could not open browser automatically")
prompts.log.warn("Please open this URL in your browser to authenticate:")
prompts.log.info(evt.properties.url)
spinner.start("Waiting for authorization...")
}
})
// Subscribe to browser open failure events to show URL for manual opening yield* MCP.Service.use((mcp) => mcp.authenticate(serverName))
const unsubscribe = Bus.subscribe(MCP.BrowserOpenFailed, (evt) => { .pipe(
if (evt.properties.mcpName === serverName) { Effect.tap((status) =>
spinner.stop("Could not open browser automatically") Effect.sync(() => {
prompts.log.warn("Please open this URL in your browser to authenticate:") if (status.status === "connected") {
prompts.log.info(evt.properties.url) spinner.stop("Authentication successful!")
spinner.start("Waiting for authorization...") } 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:")
try { prompts.log.info(`
const status = await AppRuntime.runPromise(MCP.Service.use((mcp) => 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": { "mcp": {
"${serverName}": { "${serverName}": {
"type": "remote", "type": "remote",
@@ -295,61 +289,59 @@ export const McpAuthCommand = cmd({
} }
} }
}`) }`)
} else if (status.status === "failed") { } else if (status.status === "failed") {
spinner.stop("Authentication failed", 1)
prompts.log.error(status.error)
} else {
spinner.stop("Unexpected status: " + status.status, 1)
}
}),
),
Effect.catchCause((cause) =>
Effect.sync(() => {
spinner.stop("Authentication failed", 1) spinner.stop("Authentication failed", 1)
prompts.log.error(status.error) const error = Cause.squash(cause)
} else { prompts.log.error(error instanceof Error ? error.message : String(error))
spinner.stop("Unexpected status: " + status.status, 1) }),
} ),
} catch (error) { Effect.ensuring(Effect.sync(() => unsubscribe())),
spinner.stop("Authentication failed", 1) )
prompts.log.error(error instanceof Error ? error.message : String(error))
} finally {
unsubscribe()
}
prompts.outro("Done") prompts.outro("Done")
}, }),
})
},
}) })
export const McpAuthListCommand = cmd({ export const McpAuthListCommand = effectCmd({
command: "list", command: "list",
aliases: ["ls"], aliases: ["ls"],
describe: "list OAuth-capable MCP servers and their auth status", describe: "list OAuth-capable MCP servers and their auth status",
async handler() { handler: Effect.fn("Cli.mcp.auth.list")(function* () {
await WithInstance.provide({ UI.empty()
directory: process.cwd(), prompts.intro("MCP OAuth Status")
async fn() {
UI.empty()
prompts.intro("MCP OAuth Status")
const { config, auth } = await authState() const { config, auth } = yield* authState()
const servers = oauthServers(config) const servers = oauthServers(config)
if (servers.length === 0) { if (servers.length === 0) {
prompts.log.warn("No OAuth-capable MCP servers configured") prompts.log.warn("No OAuth-capable MCP servers configured")
prompts.outro("Done") prompts.outro("Done")
return return
} }
for (const [name, serverConfig] of servers) { for (const [name, serverConfig] of servers) {
const authStatus = auth[name] const authStatus = auth[name]
const icon = getAuthStatusIcon(authStatus) const icon = getAuthStatusIcon(authStatus)
const statusText = getAuthStatusText(authStatus) const statusText = getAuthStatusText(authStatus)
const url = serverConfig.url const url = serverConfig.url
prompts.log.info(`${icon} ${name} ${UI.Style.TEXT_DIM}${statusText}\n ${UI.Style.TEXT_DIM}${url}`) prompts.log.info(`${icon} ${name} ${UI.Style.TEXT_DIM}${statusText}\n ${UI.Style.TEXT_DIM}${url}`)
} }
prompts.outro(`${servers.length} OAuth-capable server(s)`) prompts.outro(`${servers.length} OAuth-capable server(s)`)
}, }),
})
},
}) })
export const McpLogoutCommand = cmd({ export const McpLogoutCommand = effectCmd({
command: "logout [name]", command: "logout [name]",
describe: "remove OAuth credentials for an MCP server", describe: "remove OAuth credentials for an MCP server",
builder: (yargs) => builder: (yargs) =>
@@ -357,57 +349,54 @@ export const McpLogoutCommand = cmd({
describe: "name of the MCP server", describe: "name of the MCP server",
type: "string", type: "string",
}), }),
async handler(args) { handler: Effect.fn("Cli.mcp.logout")(function* (args) {
await WithInstance.provide({ UI.empty()
directory: process.cwd(), prompts.intro("MCP OAuth Logout")
async fn() {
UI.empty()
prompts.intro("MCP OAuth Logout")
const credentials = await AppRuntime.runPromise(McpAuth.Service.use((auth) => auth.all())) const credentials = yield* McpAuth.Service.use((auth) => auth.all())
const serverNames = Object.keys(credentials) const serverNames = Object.keys(credentials)
if (serverNames.length === 0) { if (serverNames.length === 0) {
prompts.log.warn("No MCP OAuth credentials stored") prompts.log.warn("No MCP OAuth credentials stored")
prompts.outro("Done") prompts.outro("Done")
return return
} }
let serverName = args.name let serverName = args.name
if (!serverName) { if (!serverName) {
const selected = await prompts.select({ const selected = yield* Effect.promise(() =>
message: "Select MCP server to logout", prompts.select({
options: serverNames.map((name) => { message: "Select MCP server to logout",
const entry = credentials[name] options: serverNames.map((name) => {
const hasTokens = !!entry.tokens const entry = credentials[name]
const hasClient = !!entry.clientInfo const hasTokens = !!entry.tokens
let hint = "" const hasClient = !!entry.clientInfo
if (hasTokens && hasClient) hint = "tokens + client" let hint = ""
else if (hasTokens) hint = "tokens" if (hasTokens && hasClient) hint = "tokens + client"
else if (hasClient) hint = "client registration" else if (hasTokens) hint = "tokens"
return { else if (hasClient) hint = "client registration"
label: name, return {
value: name, label: name,
hint, value: name,
} hint,
}), }
}) }),
if (prompts.isCancel(selected)) throw new UI.CancelledError() }),
serverName = selected )
} if (prompts.isCancel(selected)) throw new UI.CancelledError()
serverName = selected
}
if (!credentials[serverName]) { if (!credentials[serverName]) {
prompts.log.error(`No credentials found for: ${serverName}`) prompts.log.error(`No credentials found for: ${serverName}`)
prompts.outro("Done") prompts.outro("Done")
return return
} }
await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.removeAuth(serverName))) yield* MCP.Service.use((mcp) => mcp.removeAuth(serverName))
prompts.log.success(`Removed OAuth credentials for ${serverName}`) prompts.log.success(`Removed OAuth credentials for ${serverName}`)
prompts.outro("Done") prompts.outro("Done")
}, }),
})
},
}) })
async function resolveConfigPath(baseDir: string, global = false) { async function resolveConfigPath(baseDir: string, global = false) {