diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index e644c51be0..db956fffc0 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -36,6 +36,43 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO description: "Debugging and troubleshooting tools", commands: [Spec.make("agents", { description: "List all agents" })], }), + Spec.make("mcp", { + description: "Manage MCP (Model Context Protocol) servers", + commands: [ + Spec.make("list", { description: "List configured MCP servers and their status" }), + Spec.make("add", { + description: "Add an MCP server to your configuration", + params: { + name: Argument.string("name").pipe(Argument.withDescription("Name of the MCP server")), + command: Argument.string("command").pipe( + Argument.withDescription("Command and arguments for a local server, passed after --"), + Argument.variadic({ min: 0 }), + ), + url: Flag.string("url").pipe(Flag.withDescription("URL for a remote MCP server"), Flag.optional), + header: Flag.keyValuePair("header").pipe( + Flag.withDescription("HTTP header for a remote server, as name=value"), + Flag.optional, + ), + env: Flag.keyValuePair("env").pipe( + Flag.withDescription("Environment variable for a local server, as name=value"), + Flag.optional, + ), + global: Flag.boolean("global").pipe( + Flag.withDescription("Write to the global config instead of the project config"), + Flag.withDefault(false), + ), + }, + }), + Spec.make("auth", { + description: "Authenticate with an OAuth-capable remote MCP server", + params: { name: Argument.string("name").pipe(Argument.withDescription("Name of the MCP server")) }, + }), + Spec.make("logout", { + description: "Remove stored OAuth credentials for an MCP server", + params: { name: Argument.string("name").pipe(Argument.withDescription("Name of the MCP server")) }, + }), + ], + }), Spec.make("migrate", { description: "Migrate v1 data to v2" }), Spec.make("service", { description: "Manage the background server", diff --git a/packages/cli/src/commands/handlers/mcp/add.ts b/packages/cli/src/commands/handlers/mcp/add.ts new file mode 100644 index 0000000000..695104b3f2 --- /dev/null +++ b/packages/cli/src/commands/handlers/mcp/add.ts @@ -0,0 +1,58 @@ +import { EOL } from "node:os" +import path from "node:path" +import { Effect, Option } from "effect" +import { applyEdits, modify } from "jsonc-parser" +import { Global } from "@opencode-ai/core/global" +import { Commands } from "../../commands" +import { Runtime } from "../../../framework/runtime" + +export default Runtime.handler( + Commands.commands.mcp.commands.add, + Effect.fn("cli.mcp.add")(function* (input) { + const url = Option.getOrUndefined(input.url) + const headers = Option.getOrUndefined(input.header) + const environment = Option.getOrUndefined(input.env) + // The CLI framework strands `--` operands on the root command, so read the local server command + // straight from argv after `--`. This also lets the command carry its own flags (e.g. `npx -y`). + const dash = process.argv.indexOf("--") + const command = dash === -1 ? [...input.command] : process.argv.slice(dash + 1) + + const hasCommand = command.length > 0 + if (url && hasCommand) + return yield* Effect.fail(new Error("Provide either --url or a command after --, not both")) + if (!url && !hasCommand) return yield* Effect.fail(new Error("Provide either --url or a command after --")) + if (url && !URL.canParse(url)) return yield* Effect.fail(new Error(`Invalid URL: ${url}`)) + if (url && environment) return yield* Effect.fail(new Error("--env is only valid for local MCP servers")) + if (hasCommand && headers) return yield* Effect.fail(new Error("--header is only valid for remote MCP servers")) + + const server = url + ? { type: "remote" as const, url, ...(headers ? { headers } : {}) } + : { type: "local" as const, command, ...(environment ? { environment } : {}) } + + const configPath = yield* Effect.promise(() => resolveConfigPath(input.global ? Global.Path.config : process.cwd())) + yield* Effect.promise(() => write(configPath, input.name, server)) + process.stdout.write(`MCP server "${input.name}" added to ${configPath}` + EOL) + }), +) + +async function resolveConfigPath(directory: string) { + const candidates = [ + path.join(directory, "opencode.json"), + path.join(directory, "opencode.jsonc"), + path.join(directory, ".opencode", "opencode.json"), + path.join(directory, ".opencode", "opencode.jsonc"), + ] + for (const candidate of candidates) { + if (await Bun.file(candidate).exists()) return candidate + } + return candidates[0] +} + +async function write(configPath: string, name: string, server: unknown) { + const file = Bun.file(configPath) + const text = (await file.exists()) ? await file.text() : "{}" + const edits = modify(text, ["mcp", "servers", name], server, { + formattingOptions: { tabSize: 2, insertSpaces: true }, + }) + await Bun.write(configPath, applyEdits(text, edits)) +} diff --git a/packages/cli/src/commands/handlers/mcp/auth.ts b/packages/cli/src/commands/handlers/mcp/auth.ts new file mode 100644 index 0000000000..2dc722076e --- /dev/null +++ b/packages/cli/src/commands/handlers/mcp/auth.ts @@ -0,0 +1,58 @@ +import { EOL } from "node:os" +import { Effect } from "effect" +import type { IntegrationAttemptStatus, IntegrationOAuthMethod, OpencodeClient } from "@opencode-ai/sdk/v2/client" +import { Commands } from "../../commands" +import { Runtime } from "../../../framework/runtime" +import { Daemon } from "../../../services/daemon" +import { resolveIntegration } from "./resolve" + +const location = { directory: process.cwd() } + +export default Runtime.handler( + Commands.commands.mcp.commands.auth, + Effect.fn("cli.mcp.auth")(function* (input) { + const daemon = yield* Daemon.Service + const client = yield* daemon.client() + + const integration = yield* resolveIntegration(client, input.name, location) + if (!integration) + return yield* Effect.fail(new Error(`MCP server "${input.name}" is not an OAuth-capable remote server`)) + const method = integration.methods.find( + (candidate): candidate is IntegrationOAuthMethod => candidate.type === "oauth", + ) + if (!method) + return yield* Effect.fail(new Error(`MCP server "${input.name}" is not an OAuth-capable remote server`)) + + const started = yield* Effect.promise(() => + client.v2.integration.connect.oauth({ integrationID: integration.id, methodID: method.id, inputs: {}, location }), + ) + const attempt = started.data?.data + if (!attempt) return yield* Effect.fail(new Error(started.error?.message ?? "Failed to start OAuth attempt")) + if (attempt.mode === "code") + return yield* Effect.fail(new Error("This server requires manual code entry, which the CLI does not support")) + + process.stdout.write(attempt.instructions + EOL + attempt.url + EOL) + + const result = yield* poll(client, attempt.attemptID) + if (result.status === "complete") { + process.stdout.write(`Authenticated with ${input.name}` + EOL) + return + } + const reason = result.status === "failed" ? `: ${result.message}` : "" + return yield* Effect.fail(new Error(`Authentication ${result.status}${reason}`)) + }), +) + +const poll = ( + client: OpencodeClient, + attemptID: string, +): Effect.Effect> => + Effect.gen(function* () { + const response = yield* Effect.promise(() => client.v2.integration.attempt.status({ attemptID, location })) + const status = response.data?.data + if (!status || status.status === "pending") { + yield* Effect.sleep("1 second") + return yield* poll(client, attemptID) + } + return status + }) diff --git a/packages/cli/src/commands/handlers/mcp/list.ts b/packages/cli/src/commands/handlers/mcp/list.ts new file mode 100644 index 0000000000..7866705ad1 --- /dev/null +++ b/packages/cli/src/commands/handlers/mcp/list.ts @@ -0,0 +1,52 @@ +import { EOL } from "node:os" +import * as Effect from "effect/Effect" +import type { McpServer } from "@opencode-ai/sdk/v2/client" +import { Commands } from "../../commands" +import { Runtime } from "../../../framework/runtime" +import { Daemon } from "../../../services/daemon" + +export default Runtime.handler( + Commands.commands.mcp.commands.list, + Effect.fn("cli.mcp.list")(function* () { + const daemon = yield* Daemon.Service + const client = yield* daemon.client() + const response = yield* Effect.promise(() => client.v2.mcp.list({ location: { directory: process.cwd() } })) + const servers = (response.data?.data ?? []).toSorted((a, b) => a.name.localeCompare(b.name)) + if (servers.length === 0) { + process.stdout.write("No MCP servers configured" + EOL) + return + } + const width = Math.max(...servers.map((server) => server.name.length)) + const lines = servers.map( + (server) => `${icon(server.status)} ${server.name.padEnd(width)} ${describe(server.status)}`, + ) + process.stdout.write(lines.join(EOL) + EOL) + }), +) + +function icon(status: McpServer["status"]) { + switch (status.status) { + case "connected": + return "✓" + case "needs_auth": + return "⚠" + case "failed": + case "needs_client_registration": + return "✗" + default: + return "○" + } +} + +function describe(status: McpServer["status"]) { + switch (status.status) { + case "needs_auth": + return "needs authentication" + case "needs_client_registration": + return `needs client registration: ${status.error}` + case "failed": + return `failed: ${status.error}` + default: + return status.status + } +} diff --git a/packages/cli/src/commands/handlers/mcp/logout.ts b/packages/cli/src/commands/handlers/mcp/logout.ts new file mode 100644 index 0000000000..6c4296ac38 --- /dev/null +++ b/packages/cli/src/commands/handlers/mcp/logout.ts @@ -0,0 +1,35 @@ +import { EOL } from "node:os" +import { Effect } from "effect" +import { Commands } from "../../commands" +import { Runtime } from "../../../framework/runtime" +import { Daemon } from "../../../services/daemon" +import { resolveIntegration } from "./resolve" + +const location = { directory: process.cwd() } + +export default Runtime.handler( + Commands.commands.mcp.commands.logout, + Effect.fn("cli.mcp.logout")(function* (input) { + const daemon = yield* Daemon.Service + const client = yield* daemon.client() + + const integration = yield* resolveIntegration(client, input.name, location) + if (!integration) { + process.stdout.write(`No stored credentials for ${input.name}` + EOL) + return + } + + const credentials = integration.connections.filter((connection) => connection.type === "credential") + if (credentials.length === 0) { + process.stdout.write(`No stored credentials for ${input.name}` + EOL) + return + } + + yield* Effect.forEach( + credentials, + (connection) => Effect.promise(() => client.v2.credential.remove({ credentialID: connection.id, location })), + { discard: true }, + ) + process.stdout.write(`Removed OAuth credentials for ${input.name}` + EOL) + }), +) diff --git a/packages/cli/src/commands/handlers/mcp/resolve.ts b/packages/cli/src/commands/handlers/mcp/resolve.ts new file mode 100644 index 0000000000..58580e6def --- /dev/null +++ b/packages/cli/src/commands/handlers/mcp/resolve.ts @@ -0,0 +1,17 @@ +import { Effect } from "effect" +import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" + +// Resolve through the MCP-owned integrationID rather than matching integration names: the shared +// integration registry also holds provider/plugin integrations, whose names could collide with a server. +// Fails when the server is unknown; returns undefined when the server has no integration (e.g. a local +// or anonymous server), leaving that case for the caller to interpret. +export const resolveIntegration = (client: OpencodeClient, name: string, location: { directory: string }) => + Effect.gen(function* () { + const servers = yield* Effect.promise(() => client.v2.mcp.list({ location })) + const server = (servers.data?.data ?? []).find((entry) => entry.name === name) + if (!server) return yield* Effect.fail(new Error(`MCP server not found: ${name}`)) + const integrationID = server.integrationID + if (!integrationID) return undefined + const found = yield* Effect.promise(() => client.v2.integration.get({ integrationID, location })) + return found.data?.data + }) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index e013152adb..df6370a102 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -24,6 +24,12 @@ const Handlers = Runtime.handlers(Commands, { debug: { agents: () => import("./commands/handlers/debug/agents"), }, + mcp: { + list: () => import("./commands/handlers/mcp/list"), + add: () => import("./commands/handlers/mcp/add"), + auth: () => import("./commands/handlers/mcp/auth"), + logout: () => import("./commands/handlers/mcp/logout"), + }, migrate: () => import("./commands/handlers/migrate"), service: { start: () => import("./commands/handlers/service/start"), diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index 13674c4a52..e8718caf7a 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -2487,6 +2487,7 @@ export type ServerMcpListOutput = { | { readonly status: "failed"; readonly error: string } | { readonly status: "needs_auth" } | { readonly status: "needs_client_registration"; readonly error: string } + readonly integrationID?: string }> } diff --git a/packages/core/src/mcp/index.ts b/packages/core/src/mcp/index.ts index 3a422470fe..4e41795ec3 100644 --- a/packages/core/src/mcp/index.ts +++ b/packages/core/src/mcp/index.ts @@ -210,9 +210,19 @@ export const layer = Layer.effect( const remote = entry.config // Key identity on name + url, not url alone: two configs for the same url under different names are // distinct logical servers that may hold different accounts, so they must not share a credential row. - const suffix = "mcp_" + createHash("sha1").update(name + "\u0000" + remote.url).digest("hex").slice(0, 16) + const suffix = + "mcp_" + + createHash("sha1") + .update(name + "\u0000" + remote.url) + .digest("hex") + .slice(0, 16) entry.integrationID = Integration.ID.make(suffix) - registrations.push({ name, remote, integrationID: entry.integrationID, methodID: Integration.MethodID.make(suffix) }) + registrations.push({ + name, + remote, + integrationID: entry.integrationID, + methodID: Integration.MethodID.make(suffix), + }) } if (registrations.length > 0) yield* integration.transform((draft) => { @@ -308,11 +318,14 @@ export const layer = Layer.effect( const watch = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => { connection.onClose(() => { - entry.client = undefined - entry.tools = undefined - entry.status = { status: "failed", error: "Connection closed" } - fork(events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)) - fork(events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)) + // A reconnect closes the previous scope, but the SDK may fire this onclose after the new + // connection is already assigned; ignore the stale close so it can't null out the live client. + if (entry.client !== connection) return + entry.client = undefined + entry.tools = undefined + entry.status = { status: "failed", error: "Connection closed" } + fork(events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)) + fork(events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)) }) connection.onLog((message) => fork(serverLog(name, message).pipe(Effect.ignore))) connection.onToolsChanged(() => { @@ -452,7 +465,11 @@ export const layer = Layer.effect( }) const result = yield* target.entry.client .callTool({ name: input.name, args: input.args }) - .pipe(Effect.mapError((error) => new ToolCallError({ server: target.name, tool: input.name, message: error.message }))) + .pipe( + Effect.mapError( + (error) => new ToolCallError({ server: target.name, tool: input.name, message: error.message }), + ), + ) return new ToolResult({ server: target.name, tool: input.name, diff --git a/packages/schema/src/mcp.ts b/packages/schema/src/mcp.ts index 7c055a0ab2..ae50157e38 100644 --- a/packages/schema/src/mcp.ts +++ b/packages/schema/src/mcp.ts @@ -1,6 +1,8 @@ export * as Mcp from "./mcp" import { Schema } from "effect" +import { optional } from "./schema" +import { IntegrationID } from "./integration-id" const Connected = Schema.Struct({ status: Schema.Literal("connected") }).annotate({ identifier: "Mcp.Status.Connected", @@ -36,4 +38,7 @@ export interface Server extends Schema.Schema.Type {} export const Server = Schema.Struct({ name: Schema.String, status: Status, + // Set for remote servers registered as OAuth integrations; lets clients act on the right integration + // without matching by name, which could collide with provider or plugin integrations. + integrationID: optional(IntegrationID), }).annotate({ identifier: "Mcp.Server" }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 4788320adc..dbc173c829 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -5298,6 +5298,7 @@ export type McpServer = { | McpStatusFailed2 | McpStatusNeedsAuth2 | McpStatusNeedsClientRegistration2 + integrationID?: string } export type ProjectCurrent = { diff --git a/packages/server/src/handlers/mcp.ts b/packages/server/src/handlers/mcp.ts index f488ed718e..4d702f24c9 100644 --- a/packages/server/src/handlers/mcp.ts +++ b/packages/server/src/handlers/mcp.ts @@ -11,7 +11,13 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) => Effect.fn(function* () { const service = yield* MCP.Service return yield* response( - service.servers().pipe(Effect.map((servers) => servers.map((info) => ({ name: info.name, status: info.status })))), + service + .servers() + .pipe( + Effect.map((servers) => + servers.map((info) => ({ name: info.name, status: info.status, integrationID: info.integrationID })), + ), + ), ) }), )