From 96fe9cff52ce6e6588486eb9dd59bee3b1e6d69d Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 19 Jul 2026 00:24:15 -0500 Subject: [PATCH] feat(server): add MCP connect and disconnect routes --- packages/client/src/effect/api/api.ts | 26 +++++++++++--- .../client/src/effect/generated/client.ts | 30 +++++++++++++--- .../client/src/promise/generated/client.ts | 28 +++++++++++++++ .../client/src/promise/generated/types.ts | 26 ++++++++++++++ packages/protocol/src/errors.ts | 9 +++++ packages/protocol/src/groups/mcp.ts | 35 ++++++++++++++++++- packages/server/src/handlers/mcp.ts | 22 +++++++++++- packages/tui/src/component/dialog-mcp.tsx | 28 +++++++++++++++ packages/tui/src/config/v1/keybind.ts | 1 + 9 files changed, 195 insertions(+), 10 deletions(-) diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 2aee13e673..bae2d2d995 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -542,13 +542,31 @@ export type Endpoint11_0Input = { readonly location?: Endpoint11_0Request["query export type Endpoint11_0Output = EffectValue> export type McpListOperation = (input?: Endpoint11_0Input) => Effect.Effect -type Endpoint11_1Request = Parameters[0] -export type Endpoint11_1Input = { readonly location?: Endpoint11_1Request["query"]["location"] } -export type Endpoint11_1Output = EffectValue> -export type McpResourceCatalogOperation = (input?: Endpoint11_1Input) => Effect.Effect +type Endpoint11_1Request = Parameters[0] +export type Endpoint11_1Input = { + readonly server: Endpoint11_1Request["params"]["server"] + readonly location?: Endpoint11_1Request["query"]["location"] +} +export type Endpoint11_1Output = EffectValue> +export type McpConnectOperation = (input: Endpoint11_1Input) => Effect.Effect + +type Endpoint11_2Request = Parameters[0] +export type Endpoint11_2Input = { + readonly server: Endpoint11_2Request["params"]["server"] + readonly location?: Endpoint11_2Request["query"]["location"] +} +export type Endpoint11_2Output = EffectValue> +export type McpDisconnectOperation = (input: Endpoint11_2Input) => Effect.Effect + +type Endpoint11_3Request = Parameters[0] +export type Endpoint11_3Input = { readonly location?: Endpoint11_3Request["query"]["location"] } +export type Endpoint11_3Output = EffectValue> +export type McpResourceCatalogOperation = (input?: Endpoint11_3Input) => Effect.Effect export interface McpApi { readonly list: McpListOperation + readonly connect: McpConnectOperation + readonly disconnect: McpDisconnectOperation readonly resource: { readonly catalog: McpResourceCatalogOperation } } diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index 0de7fad7bd..310e26d2d4 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -650,14 +650,36 @@ type Endpoint11_0Input = { readonly location?: Endpoint11_0Request["query"]["loc const Endpoint11_0 = (raw: RawClient["server.mcp"]) => (input?: Endpoint11_0Input) => raw["mcp.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint11_1Request = Parameters[0] -type Endpoint11_1Input = { readonly location?: Endpoint11_1Request["query"]["location"] } -const Endpoint11_1 = (raw: RawClient["server.mcp"]) => (input?: Endpoint11_1Input) => +type Endpoint11_1Request = Parameters[0] +type Endpoint11_1Input = { + readonly server: Endpoint11_1Request["params"]["server"] + readonly location?: Endpoint11_1Request["query"]["location"] +} +const Endpoint11_1 = (raw: RawClient["server.mcp"]) => (input: Endpoint11_1Input) => + raw["mcp.connect"]({ params: { server: input["server"] }, query: { location: input["location"] } }).pipe( + Effect.mapError(mapClientError), + ) + +type Endpoint11_2Request = Parameters[0] +type Endpoint11_2Input = { + readonly server: Endpoint11_2Request["params"]["server"] + readonly location?: Endpoint11_2Request["query"]["location"] +} +const Endpoint11_2 = (raw: RawClient["server.mcp"]) => (input: Endpoint11_2Input) => + raw["mcp.disconnect"]({ params: { server: input["server"] }, query: { location: input["location"] } }).pipe( + Effect.mapError(mapClientError), + ) + +type Endpoint11_3Request = Parameters[0] +type Endpoint11_3Input = { readonly location?: Endpoint11_3Request["query"]["location"] } +const Endpoint11_3 = (raw: RawClient["server.mcp"]) => (input?: Endpoint11_3Input) => raw["mcp.resource.catalog"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) const adaptGroup11 = (raw: RawClient["server.mcp"]) => ({ list: Endpoint11_0(raw), - resource: { catalog: Endpoint11_1(raw) }, + connect: Endpoint11_1(raw), + disconnect: Endpoint11_2(raw), + resource: { catalog: Endpoint11_3(raw) }, }) type Endpoint12_0Request = Parameters[0] diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index ea5e790ea6..2243b6f20d 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -104,6 +104,10 @@ import type { IntegrationCommandCancelOutput, McpListInput, McpListOutput, + McpConnectInput, + McpConnectOutput, + McpDisconnectInput, + McpDisconnectOutput, McpResourceCatalogInput, McpResourceCatalogOutput, CredentialUpdateInput, @@ -1044,6 +1048,30 @@ export function make(options: ClientOptions) { }, requestOptions, ), + connect: (input: McpConnectInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/mcp/${encodeURIComponent(input.server)}/connect`, + query: { location: input["location"] }, + successStatus: 204, + declaredStatuses: [404, 401, 400], + empty: true, + }, + requestOptions, + ), + disconnect: (input: McpDisconnectInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/mcp/${encodeURIComponent(input.server)}/disconnect`, + query: { location: input["location"] }, + successStatus: 204, + declaredStatuses: [404, 401, 400], + empty: true, + }, + requestOptions, + ), resource: { catalog: (input?: McpResourceCatalogInput, requestOptions?: RequestOptions) => request( diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 7e58e06ef4..904ac2cae5 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -2482,6 +2482,14 @@ export type ProviderNotFoundError = { export const isProviderNotFoundError = (value: unknown): value is ProviderNotFoundError => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ProviderNotFoundError" +export type McpServerNotFoundError = { + readonly _tag: "McpServerNotFoundError" + readonly server: string + readonly message: string +} +export const isMcpServerNotFoundError = (value: unknown): value is McpServerNotFoundError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "McpServerNotFoundError" + export type FormNotFoundError = { readonly _tag: "FormNotFoundError"; readonly id: string; readonly message: string } export const isFormNotFoundError = (value: unknown): value is FormNotFoundError => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "FormNotFoundError" @@ -3461,6 +3469,24 @@ export type McpListOutput = { data: Array } +export type McpConnectInput = { + readonly server: { readonly server: string }["server"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type McpConnectOutput = void + +export type McpDisconnectInput = { + readonly server: { readonly server: string }["server"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type McpDisconnectOutput = void + export type McpResourceCatalogInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined diff --git a/packages/protocol/src/errors.ts b/packages/protocol/src/errors.ts index d6d1482f30..a678fb7346 100644 --- a/packages/protocol/src/errors.ts +++ b/packages/protocol/src/errors.ts @@ -90,6 +90,15 @@ export class SkillNotFoundError extends Schema.TaggedErrorClass()( + "McpServerNotFoundError", + { + server: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} + export class CommandNotFoundError extends Schema.TaggedErrorClass()( "CommandNotFoundError", { diff --git a/packages/protocol/src/groups/mcp.ts b/packages/protocol/src/groups/mcp.ts index 5f9c03935d..1cb4a55ce2 100644 --- a/packages/protocol/src/groups/mcp.ts +++ b/packages/protocol/src/groups/mcp.ts @@ -1,7 +1,8 @@ import { Mcp } from "@opencode-ai/schema/mcp" import { Location } from "@opencode-ai/schema/location" import { Schema } from "effect" -import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { McpServerNotFoundError } from "../errors.js" import { LocationQuery, locationQueryOpenApi } from "./location.js" export const McpGroup = HttpApiGroup.make("server.mcp") @@ -19,6 +20,38 @@ export const McpGroup = HttpApiGroup.make("server.mcp") }), ), ) + .add( + HttpApiEndpoint.post("mcp.connect", "/api/mcp/:server/connect", { + params: { server: Schema.String }, + query: LocationQuery, + success: HttpApiSchema.NoContent, + error: McpServerNotFoundError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.mcp.connect", + summary: "Connect MCP server", + description: "Connect an MCP server at runtime, overriding a disabled configuration until restart.", + }), + ), + ) + .add( + HttpApiEndpoint.post("mcp.disconnect", "/api/mcp/:server/disconnect", { + params: { server: Schema.String }, + query: LocationQuery, + success: HttpApiSchema.NoContent, + error: McpServerNotFoundError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.mcp.disconnect", + summary: "Disconnect MCP server", + description: "Disconnect an MCP server at runtime, removing its tools until reconnected.", + }), + ), + ) .add( HttpApiEndpoint.get("mcp.resource.catalog", "/api/mcp/resource", { query: LocationQuery, diff --git a/packages/server/src/handlers/mcp.ts b/packages/server/src/handlers/mcp.ts index 8f423534d4..69fb7a311a 100644 --- a/packages/server/src/handlers/mcp.ts +++ b/packages/server/src/handlers/mcp.ts @@ -1,9 +1,13 @@ import { MCP } from "@opencode-ai/core/mcp/index" +import { McpServerNotFoundError } from "@opencode-ai/protocol/errors" import { Effect } from "effect" -import { HttpApiBuilder } from "effect/unstable/httpapi" +import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" import { response } from "../location" +const notFound = (effect: Effect.Effect) => + effect.pipe(Effect.mapError((error) => new McpServerNotFoundError({ server: error.server, message: error.message }))) + export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) => Effect.gen(function* () { return handlers @@ -22,6 +26,22 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) => ) }), ) + .handle( + "mcp.connect", + Effect.fn(function* (ctx) { + const service = yield* MCP.Service + yield* notFound(service.connect(ctx.params.server)) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "mcp.disconnect", + Effect.fn(function* (ctx) { + const service = yield* MCP.Service + yield* notFound(service.disconnect(ctx.params.server)) + return HttpApiSchema.NoContent.make() + }), + ) .handle( "mcp.resource.catalog", Effect.fn(function* () { diff --git a/packages/tui/src/component/dialog-mcp.tsx b/packages/tui/src/component/dialog-mcp.tsx index 696ff468d8..f1fa40b3be 100644 --- a/packages/tui/src/component/dialog-mcp.tsx +++ b/packages/tui/src/component/dialog-mcp.tsx @@ -1,5 +1,6 @@ import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js" import { useData } from "../context/data" +import { useClient } from "../context/client" import { Keymap } from "../context/keymap" import { pipe, sortBy } from "remeda" import { DialogSelect } from "../ui/dialog-select" @@ -64,9 +65,12 @@ function statusMeta(status: McpServer["status"], themeV2: ComponentTheme) { export function DialogMcp() { const data = useData() const dialog = useDialog() + const client = useClient() + const toast = useToast() const { themeV2 } = useTheme().contextual("elevated") const [focused, setFocused] = createSignal() const [detail, setDetail] = createSignal() + const [busy, setBusy] = createSignal(false) onMount(() => { dialog.setSize("large") @@ -115,6 +119,19 @@ export function DialogMcp() { setDetail(server) } + // Connected servers disconnect; everything else (disabled, failed, needs_auth) retries a + // connection. The mcp.status.changed event refreshes the list, so no manual sync is needed. + const toggle = (name: string) => { + if (busy()) return + const server = servers().find((entry) => entry.name === name) + if (!server || server.status.status === "pending") return + setBusy(true) + const current = data.location.default() + const input = { server: name, location: { directory: current.directory, workspace: current.workspaceID } } + const call = server.status.status === "connected" ? client.api.mcp.disconnect(input) : client.api.mcp.connect(input) + void call.catch(toast.error).finally(() => setBusy(false)) + } + return ( setFocused(option.value as string)} onSelect={(option) => open(option.value as string)} + actions={[ + { + title: "toggle", + command: "dialog.mcp.toggle", + hidden: busy(), + onTrigger: (option) => { + setFocused(option.value as string) + toggle(option.value as string) + }, + }, + ]} footer={ enter to view error diff --git a/packages/tui/src/config/v1/keybind.ts b/packages/tui/src/config/v1/keybind.ts index fb31f696be..5c4a9000e4 100644 --- a/packages/tui/src/config/v1/keybind.ts +++ b/packages/tui/src/config/v1/keybind.ts @@ -213,6 +213,7 @@ export const Definitions = { "prompt.autocomplete.complete": keybind("tab", "Complete autocomplete item"), "permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"), "plugins.toggle": keybind("space", "Toggle plugin"), + "dialog.mcp.toggle": keybind("space", "Toggle MCP server"), "dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"), terminal_suspend: keybind("ctrl+z", "Suspend terminal"),