1036 lines
34 KiB
TypeScript
1036 lines
34 KiB
TypeScript
import { cmd } from "./cmd"
|
|
import { effectCmd, fail } from "../effect-cmd"
|
|
import { Cause } from "effect"
|
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.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 { ConfigMCP } from "../../config/mcp"
|
|
import { InstanceRef } from "@/effect/instance-ref"
|
|
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
|
import path from "path"
|
|
import { Global } from "@opencode-ai/core/global"
|
|
import { modify, applyEdits } from "jsonc-parser"
|
|
import { Filesystem } from "@/util/filesystem"
|
|
import { EventV2Bridge } from "@/event-v2-bridge"
|
|
import { EventV2 } from "@opencode-ai/core/event"
|
|
import { Effect } from "effect"
|
|
|
|
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"
|
|
}
|
|
}
|
|
|
|
type McpEntry = NonNullable<Config.Info["mcp"]>[string]
|
|
|
|
type McpConfigured = ConfigMCP.Info
|
|
function isMcpConfigured(config: McpEntry): config is McpConfigured {
|
|
return typeof config === "object" && config !== null && "type" in config
|
|
}
|
|
|
|
type McpRemote = Extract<McpConfigured, { type: "remote" }>
|
|
function isMcpRemote(config: McpEntry): config is McpRemote {
|
|
return isMcpConfigured(config) && config.type === "remote"
|
|
}
|
|
|
|
type McpAddArgs = {
|
|
_?: Array<string | number>
|
|
"--"?: string[]
|
|
name?: string
|
|
args?: string[]
|
|
type?: "local" | "remote"
|
|
env?: string[]
|
|
header?: string[]
|
|
scope?: "project" | "global"
|
|
global?: boolean
|
|
enabled?: boolean
|
|
timeout?: number
|
|
oauth?: boolean
|
|
oauthClientId?: string
|
|
oauthClientSecret?: string
|
|
oauthScope?: string
|
|
oauthCallbackPort?: number
|
|
oauthRedirectUri?: string
|
|
}
|
|
|
|
function configuredServers(config: Config.Info) {
|
|
return Object.entries(config.mcp ?? {}).filter((entry): entry is [string, McpConfigured] => isMcpConfigured(entry[1]))
|
|
}
|
|
|
|
function oauthServers(config: Config.Info) {
|
|
return configuredServers(config).filter(
|
|
(entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false,
|
|
)
|
|
}
|
|
|
|
function listState() {
|
|
return Effect.gen(function* () {
|
|
const cfg = yield* Config.Service
|
|
const mcp = yield* MCP.Service
|
|
const config = yield* cfg.get()
|
|
const statuses = yield* mcp.status()
|
|
const stored = yield* Effect.all(
|
|
Object.fromEntries(configuredServers(config).map(([name]) => [name, mcp.hasStoredTokens(name)])),
|
|
{ concurrency: "unbounded" },
|
|
)
|
|
return { config, statuses, stored }
|
|
})
|
|
}
|
|
|
|
function authState() {
|
|
return Effect.gen(function* () {
|
|
const cfg = yield* Config.Service
|
|
const mcp = yield* MCP.Service
|
|
const config = yield* cfg.get()
|
|
const auth = yield* Effect.all(
|
|
Object.fromEntries(oauthServers(config).map(([name]) => [name, mcp.getAuthStatus(name)])),
|
|
{ concurrency: "unbounded" },
|
|
)
|
|
return { config, auth }
|
|
})
|
|
}
|
|
|
|
export const McpCommand = cmd({
|
|
command: "mcp",
|
|
describe: "manage MCP (Model Context Protocol) servers",
|
|
builder: (yargs) =>
|
|
yargs
|
|
.command(McpAddCommand)
|
|
.command(McpListCommand)
|
|
.command(McpAuthCommand)
|
|
.command(McpLogoutCommand)
|
|
.command(McpDebugCommand)
|
|
.demandCommand(),
|
|
async handler() {},
|
|
})
|
|
|
|
export const McpListCommand = effectCmd({
|
|
command: "list",
|
|
aliases: ["ls"],
|
|
describe: "list MCP servers and their status",
|
|
handler: Effect.fn("Cli.mcp.list")(function* () {
|
|
UI.empty()
|
|
prompts.intro("MCP Servers")
|
|
|
|
const { config, statuses, stored } = yield* listState()
|
|
const servers = configuredServers(config)
|
|
|
|
if (servers.length === 0) {
|
|
prompts.log.warn("No MCP servers configured")
|
|
prompts.outro("Add servers with: opencode mcp add")
|
|
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
|
|
}
|
|
|
|
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 = effectCmd({
|
|
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),
|
|
handler: Effect.fn("Cli.mcp.auth")(function* (args) {
|
|
UI.empty()
|
|
prompts.intro("MCP OAuth Authentication")
|
|
|
|
const { config, auth } = yield* authState()
|
|
const mcpServers = config.mcp ?? {}
|
|
const servers = oauthServers(config)
|
|
|
|
if (servers.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 = 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,
|
|
}
|
|
})
|
|
|
|
const selected = yield* Effect.promise(() =>
|
|
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 (!isMcpRemote(serverConfig) || serverConfig.oauth === false) {
|
|
prompts.log.error(`MCP server ${serverName} is not an OAuth-capable remote server`)
|
|
prompts.outro("Done")
|
|
return
|
|
}
|
|
|
|
// Check if already authenticated
|
|
const authStatus = auth[serverName] ?? (yield* MCP.Service.use((mcp) => mcp.getAuthStatus(serverName)))
|
|
if (authStatus === "authenticated") {
|
|
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...`)
|
|
}
|
|
|
|
const spinner = prompts.spinner()
|
|
spinner.start("Starting OAuth flow...")
|
|
|
|
// Subscribe to browser open failure events to show URL for manual opening
|
|
const events = yield* EventV2Bridge.Service
|
|
const unsubscribe = yield* events.listen((event) => {
|
|
if (event.type !== MCP.BrowserOpenFailed.type) return Effect.void
|
|
const data = event.data as EventV2.Data<typeof MCP.BrowserOpenFailed>
|
|
if (data.mcpName === serverName) {
|
|
spinner.stop("Could not open browser automatically")
|
|
prompts.log.warn("Please open this URL in your browser to authenticate:")
|
|
prompts.log.info(data.url)
|
|
spinner.start("Waiting for authorization...")
|
|
}
|
|
return Effect.void
|
|
})
|
|
|
|
yield* MCP.Service.use((mcp) => mcp.authenticate(serverName)).pipe(
|
|
Effect.tap((status) =>
|
|
Effect.sync(() => {
|
|
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)
|
|
}
|
|
}),
|
|
),
|
|
Effect.catchCause((cause) =>
|
|
Effect.sync(() => {
|
|
spinner.stop("Authentication failed", 1)
|
|
const error = Cause.squash(cause)
|
|
prompts.log.error(error instanceof Error ? error.message : String(error))
|
|
}),
|
|
),
|
|
Effect.ensuring(unsubscribe),
|
|
)
|
|
|
|
prompts.outro("Done")
|
|
}),
|
|
})
|
|
|
|
export const McpAuthListCommand = effectCmd({
|
|
command: "list",
|
|
aliases: ["ls"],
|
|
describe: "list OAuth-capable MCP servers and their auth status",
|
|
handler: Effect.fn("Cli.mcp.auth.list")(function* () {
|
|
UI.empty()
|
|
prompts.intro("MCP OAuth Status")
|
|
|
|
const { config, auth } = yield* authState()
|
|
const servers = oauthServers(config)
|
|
|
|
if (servers.length === 0) {
|
|
prompts.log.warn("No OAuth-capable MCP servers configured")
|
|
prompts.outro("Done")
|
|
return
|
|
}
|
|
|
|
for (const [name, serverConfig] of servers) {
|
|
const authStatus = auth[name]
|
|
const icon = getAuthStatusIcon(authStatus)
|
|
const statusText = getAuthStatusText(authStatus)
|
|
const url = serverConfig.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)`)
|
|
}),
|
|
})
|
|
|
|
export const McpLogoutCommand = effectCmd({
|
|
command: "logout [name]",
|
|
describe: "remove OAuth credentials for an MCP server",
|
|
builder: (yargs) =>
|
|
yargs.positional("name", {
|
|
describe: "name of the MCP server",
|
|
type: "string",
|
|
}),
|
|
handler: Effect.fn("Cli.mcp.logout")(function* (args) {
|
|
UI.empty()
|
|
prompts.intro("MCP OAuth Logout")
|
|
|
|
const credentials = yield* McpAuth.Service.use((auth) => auth.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 = yield* Effect.promise(() =>
|
|
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
|
|
}
|
|
|
|
yield* MCP.Service.use((mcp) => mcp.removeAuth(serverName))
|
|
prompts.log.success(`Removed OAuth credentials for ${serverName}`)
|
|
prompts.outro("Done")
|
|
}),
|
|
})
|
|
|
|
async function resolveConfigPath(baseDir: string, global = false) {
|
|
// Check for existing config files (prefer .jsonc over .json, check .opencode/ subdirectory too)
|
|
const candidates = [path.join(baseDir, "opencode.json"), path.join(baseDir, "opencode.jsonc")]
|
|
|
|
if (!global) {
|
|
candidates.push(path.join(baseDir, ".opencode", "opencode.json"), path.join(baseDir, ".opencode", "opencode.jsonc"))
|
|
}
|
|
|
|
for (const candidate of candidates) {
|
|
if (await Filesystem.exists(candidate)) {
|
|
return candidate
|
|
}
|
|
}
|
|
|
|
// Default to opencode.json if none exist
|
|
return candidates[0]
|
|
}
|
|
|
|
async function addMcpToConfig(name: string, mcpConfig: ConfigMCP.Info, configPath: string) {
|
|
let text = "{}"
|
|
if (await Filesystem.exists(configPath)) {
|
|
text = await Filesystem.readText(configPath)
|
|
}
|
|
|
|
// Use jsonc-parser to modify while preserving comments
|
|
const edits = modify(text, ["mcp", name], mcpConfig, {
|
|
formattingOptions: { tabSize: 2, insertSpaces: true },
|
|
})
|
|
const result = applyEdits(text, edits)
|
|
|
|
await Filesystem.write(configPath, result)
|
|
|
|
return configPath
|
|
}
|
|
|
|
export const McpAddCommand = effectCmd({
|
|
command: "add [name] [args..]",
|
|
describe: "add an MCP server",
|
|
builder: (yargs) =>
|
|
yargs
|
|
.parserConfiguration({ "unknown-options-as-args": true })
|
|
.positional("name", {
|
|
describe: "name of the MCP server",
|
|
type: "string",
|
|
})
|
|
.positional("args", {
|
|
describe: "URL for remote servers or command and arguments for local servers",
|
|
type: "string",
|
|
array: true,
|
|
default: [],
|
|
})
|
|
.option("type", {
|
|
describe: "server type: local or remote",
|
|
type: "string",
|
|
choices: ["local", "remote"] as const,
|
|
})
|
|
.option("env", {
|
|
describe: "environment variable for local servers (KEY=VALUE)",
|
|
type: "string",
|
|
array: true,
|
|
})
|
|
.option("header", {
|
|
describe: "HTTP header for remote servers (KEY=VALUE or 'KEY: VALUE')",
|
|
type: "string",
|
|
array: true,
|
|
})
|
|
.option("scope", {
|
|
describe: "where to save the server",
|
|
type: "string",
|
|
choices: ["project", "global"] as const,
|
|
})
|
|
.option("global", {
|
|
alias: ["g"],
|
|
describe: "save to global config",
|
|
type: "boolean",
|
|
})
|
|
.option("enabled", {
|
|
describe: "enable or disable the server on startup",
|
|
type: "boolean",
|
|
})
|
|
.option("timeout", {
|
|
describe: "timeout in milliseconds for MCP server requests",
|
|
type: "number",
|
|
})
|
|
.option("oauth", {
|
|
describe: "enable OAuth for remote servers, or use --no-oauth to disable auto-detection",
|
|
type: "boolean",
|
|
})
|
|
.option("oauth-client-id", {
|
|
describe: "OAuth client ID for remote servers",
|
|
type: "string",
|
|
})
|
|
.option("oauth-client-secret", {
|
|
describe: "OAuth client secret for remote servers",
|
|
type: "string",
|
|
})
|
|
.option("oauth-scope", {
|
|
describe: "OAuth scopes to request for remote servers",
|
|
type: "string",
|
|
})
|
|
.option("oauth-callback-port", {
|
|
describe: "OAuth local callback port for remote servers",
|
|
type: "number",
|
|
})
|
|
.option("oauth-redirect-uri", {
|
|
describe: "OAuth redirect URI for remote servers",
|
|
type: "string",
|
|
}).epilogue(`Usage:
|
|
opencode mcp add <name> -- <command> [args...] (local MCP server)
|
|
opencode mcp add <name> --env KEY=VALUE -- <command> [args...] (local MCP server with env vars)
|
|
opencode mcp add <name> <url> (remote MCP server)
|
|
opencode mcp add <name> --header KEY=VALUE <url> (remote MCP server with headers)
|
|
opencode mcp add <name> --scope project -- <command> [args...] (save to project config)
|
|
|
|
Examples:
|
|
opencode mcp add context7 -- npx -y @upstash/context7-mcp
|
|
opencode mcp add local-env --env FOO=bar -- node server.js
|
|
opencode mcp add sg --header Authorization=token https://sg.example/mcp
|
|
opencode mcp add hugging-face https://huggingface.co/mcp`),
|
|
handler: Effect.fn("Cli.mcp.add")(function* (input: McpAddArgs) {
|
|
const maybeCtx = yield* InstanceRef
|
|
if (!maybeCtx) return yield* Effect.die("InstanceRef not provided")
|
|
const ctx = maybeCtx
|
|
const inlineArgs = mcpAddArgs(input)
|
|
const inlineConfig = parseInlineMcpAdd(input, inlineArgs)
|
|
if (inlineConfig && "error" in inlineConfig) return yield* fail(inlineConfig.error)
|
|
if (input.global && input.scope === "project")
|
|
return yield* fail("--global cannot be combined with --scope project")
|
|
yield* Effect.promise(async () => {
|
|
UI.empty()
|
|
prompts.intro("Add MCP server")
|
|
|
|
const project = ctx.project
|
|
|
|
const [projectConfigPath, globalConfigPath] = await Promise.all([
|
|
resolveConfigPath(ctx.worktree),
|
|
resolveConfigPath(Global.Path.config, true),
|
|
])
|
|
|
|
const configPath = await (async () => {
|
|
if (input.global || input.scope === "global") return globalConfigPath
|
|
if (input.scope === "project") return projectConfigPath
|
|
if (inlineConfig) return project.vcs === "git" ? projectConfigPath : globalConfigPath
|
|
if (project.vcs !== "git") return globalConfigPath
|
|
const scopeResult = await prompts.select({
|
|
message: "Location",
|
|
options: [
|
|
{
|
|
label: "Current project",
|
|
value: projectConfigPath,
|
|
hint: projectConfigPath,
|
|
},
|
|
{
|
|
label: "Global",
|
|
value: globalConfigPath,
|
|
hint: globalConfigPath,
|
|
},
|
|
],
|
|
})
|
|
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
|
|
return scopeResult
|
|
})()
|
|
|
|
if (inlineConfig) {
|
|
await addMcpToConfig(input.name!.trim(), inlineConfig.config, configPath)
|
|
prompts.log.success(`MCP server "${input.name!.trim()}" added to ${configPath}`)
|
|
prompts.outro("MCP server added successfully")
|
|
return
|
|
}
|
|
|
|
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()
|
|
|
|
const mcpConfig: ConfigMCP.Info = {
|
|
type: "local",
|
|
command: command.split(" "),
|
|
}
|
|
|
|
await addMcpToConfig(name, mcpConfig, configPath)
|
|
prompts.log.success(`MCP server "${name}" added to ${configPath}`)
|
|
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()
|
|
|
|
let mcpConfig: ConfigMCP.Info
|
|
|
|
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
|
|
}
|
|
|
|
mcpConfig = {
|
|
type: "remote",
|
|
url,
|
|
oauth: {
|
|
clientId,
|
|
...(clientSecret && { clientSecret }),
|
|
},
|
|
}
|
|
} else {
|
|
mcpConfig = {
|
|
type: "remote",
|
|
url,
|
|
oauth: {},
|
|
}
|
|
}
|
|
} else {
|
|
mcpConfig = {
|
|
type: "remote",
|
|
url,
|
|
}
|
|
}
|
|
|
|
await addMcpToConfig(name, mcpConfig, configPath)
|
|
prompts.log.success(`MCP server "${name}" added to ${configPath}`)
|
|
}
|
|
|
|
prompts.outro("MCP server added successfully")
|
|
})
|
|
}),
|
|
})
|
|
|
|
function mcpAddArgs(input: McpAddArgs) {
|
|
const addIndex = input._?.lastIndexOf("add") ?? -1
|
|
return [
|
|
...(input.args ?? []),
|
|
...(addIndex === -1 || !input._ ? [] : input._.slice(addIndex + 1).map(String)),
|
|
...(input["--"] ?? []),
|
|
]
|
|
}
|
|
|
|
function parseInlineMcpAdd(
|
|
input: McpAddArgs,
|
|
inlineArgs: string[],
|
|
): { config: ConfigMCP.Info } | { error: string } | undefined {
|
|
if (!hasInlineMcpAdd(input, inlineArgs)) return undefined
|
|
const name = input.name?.trim()
|
|
if (!name) return { error: "MCP server name is required" }
|
|
if (inlineArgs.length === 0) return { error: "URL or command is required" }
|
|
if (input.timeout !== undefined && (!Number.isInteger(input.timeout) || input.timeout <= 0)) {
|
|
return { error: "--timeout must be a positive integer" }
|
|
}
|
|
|
|
const type = input.type ?? (inlineArgs.length === 1 && URL.canParse(inlineArgs[0]) ? "remote" : "local")
|
|
if (type === "local") return parseInlineLocalMcp(input, inlineArgs)
|
|
return parseInlineRemoteMcp(input, inlineArgs)
|
|
}
|
|
|
|
function hasInlineMcpAdd(input: McpAddArgs, inlineArgs: string[]) {
|
|
return !!(
|
|
input.name ||
|
|
inlineArgs.length > 0 ||
|
|
input.type ||
|
|
input.env?.length ||
|
|
input.header?.length ||
|
|
input.enabled !== undefined ||
|
|
input.timeout !== undefined ||
|
|
input.oauth !== undefined ||
|
|
input.oauthClientId ||
|
|
input.oauthClientSecret ||
|
|
input.oauthScope ||
|
|
input.oauthCallbackPort !== undefined ||
|
|
input.oauthRedirectUri
|
|
)
|
|
}
|
|
|
|
function parseInlineLocalMcp(args: McpAddArgs, command: string[]): { config: ConfigMCP.Info } | { error: string } {
|
|
if (args.header?.length) return { error: "--header can only be used with --type remote" }
|
|
if (hasOAuthOptions(args)) return { error: "OAuth options can only be used with --type remote" }
|
|
const environment = parseEnv(args.env)
|
|
if ("error" in environment) return environment
|
|
return {
|
|
config: {
|
|
type: "local",
|
|
command,
|
|
...(environment.value && { environment: environment.value }),
|
|
...(args.enabled !== undefined && { enabled: args.enabled }),
|
|
...(args.timeout !== undefined && { timeout: args.timeout }),
|
|
},
|
|
}
|
|
}
|
|
|
|
function parseInlineRemoteMcp(args: McpAddArgs, url: string[]): { config: ConfigMCP.Info } | { error: string } {
|
|
if (url.length !== 1) return { error: "Remote MCP servers require exactly one URL" }
|
|
if (!URL.canParse(url[0])) return { error: "Remote MCP server URL is invalid" }
|
|
if (args.env?.length) return { error: "--env can only be used with --type local" }
|
|
if (
|
|
args.oauthCallbackPort !== undefined &&
|
|
(!Number.isInteger(args.oauthCallbackPort) || args.oauthCallbackPort < 1 || args.oauthCallbackPort > 65535)
|
|
) {
|
|
return { error: "--oauth-callback-port must be an integer between 1 and 65535" }
|
|
}
|
|
if (args.oauth === false && hasOAuthConfigOptions(args)) {
|
|
return { error: "--no-oauth cannot be combined with OAuth options" }
|
|
}
|
|
const headers = parseHeader(args.header)
|
|
if ("error" in headers) return headers
|
|
const oauth = parseOAuth(args)
|
|
return {
|
|
config: {
|
|
type: "remote",
|
|
url: url[0],
|
|
...(headers.value && { headers: headers.value }),
|
|
...(args.enabled !== undefined && { enabled: args.enabled }),
|
|
...(args.timeout !== undefined && { timeout: args.timeout }),
|
|
...(oauth !== undefined && { oauth }),
|
|
},
|
|
}
|
|
}
|
|
|
|
function parseEnv(entries?: string[]): { value?: Record<string, string> } | { error: string } {
|
|
if (!entries?.length) return {}
|
|
const parsed = entries.map((entry) => {
|
|
const index = entry.indexOf("=")
|
|
const key = entry.slice(0, index).trim()
|
|
if (index <= 0 || !key) return { error: "--env must be in KEY=VALUE format" }
|
|
return { key, value: entry.slice(index + 1) }
|
|
})
|
|
const invalid = parsed.find((entry): entry is { error: string } => "error" in entry)
|
|
if (invalid) return invalid
|
|
return { value: Object.fromEntries(parsed.map((entry) => [entry.key, entry.value])) }
|
|
}
|
|
|
|
function parseHeader(entries?: string[]): { value?: Record<string, string> } | { error: string } {
|
|
if (!entries?.length) return {}
|
|
const parsed = entries.map((entry) => {
|
|
const colon = entry.indexOf(":")
|
|
const equals = entry.indexOf("=")
|
|
const index = colon === -1 ? equals : equals === -1 ? colon : Math.min(colon, equals)
|
|
const key = entry.slice(0, index).trim()
|
|
if (index <= 0 || !key) return { error: "--header must be in KEY=VALUE or 'KEY: VALUE' format" }
|
|
return { key, value: entry.slice(index + 1).trim() }
|
|
})
|
|
const invalid = parsed.find((entry): entry is { error: string } => "error" in entry)
|
|
if (invalid) return invalid
|
|
return { value: Object.fromEntries(parsed.map((entry) => [entry.key, entry.value])) }
|
|
}
|
|
|
|
function hasOAuthOptions(args: McpAddArgs) {
|
|
return !!(args.oauth !== undefined || hasOAuthConfigOptions(args))
|
|
}
|
|
|
|
function hasOAuthConfigOptions(args: McpAddArgs) {
|
|
return !!(
|
|
args.oauthClientId ||
|
|
args.oauthClientSecret ||
|
|
args.oauthScope ||
|
|
args.oauthCallbackPort !== undefined ||
|
|
args.oauthRedirectUri
|
|
)
|
|
}
|
|
|
|
function parseOAuth(args: McpAddArgs): ConfigMCP.Remote["oauth"] | undefined {
|
|
if (args.oauth === false) return false
|
|
if (!hasOAuthOptions(args)) return undefined
|
|
return {
|
|
...(args.oauthClientId && { clientId: args.oauthClientId }),
|
|
...(args.oauthClientSecret && { clientSecret: args.oauthClientSecret }),
|
|
...(args.oauthScope && { scope: args.oauthScope }),
|
|
...(args.oauthCallbackPort !== undefined && { callbackPort: args.oauthCallbackPort }),
|
|
...(args.oauthRedirectUri && { redirectUri: args.oauthRedirectUri }),
|
|
}
|
|
}
|
|
|
|
export const McpDebugCommand = effectCmd({
|
|
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,
|
|
}),
|
|
handler: Effect.fn("Cli.mcp.debug")(function* (args) {
|
|
const config = yield* Config.Service.use((cfg) => cfg.get())
|
|
const mcp = yield* MCP.Service
|
|
const auth = yield* McpAuth.Service
|
|
yield* Effect.promise(async () => {
|
|
UI.empty()
|
|
prompts.intro("MCP OAuth Debug")
|
|
|
|
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 (!isMcpRemote(serverConfig)) {
|
|
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 — services already in hand, run inline.
|
|
const { authStatus, entry } = await Effect.runPromise(
|
|
Effect.all({
|
|
authStatus: mcp.getAuthStatus(serverName),
|
|
entry: auth.get(serverName),
|
|
}),
|
|
)
|
|
prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`)
|
|
|
|
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: InstallationVersion },
|
|
},
|
|
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,
|
|
redirectUri: oauthConfig?.redirectUri,
|
|
},
|
|
{
|
|
onRedirect: async () => {},
|
|
},
|
|
auth,
|
|
)
|
|
|
|
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: InstallationVersion,
|
|
})
|
|
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")
|
|
})
|
|
}),
|
|
})
|