From fe9a936867239f0737b833da3d0f05348a0496df Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:28:25 -0500 Subject: [PATCH] feat(server): expose runtime MCP controls over HTTP (#37712) --- packages/client/src/effect/api/api.ts | 45 +++++- .../client/src/effect/generated/client.ts | 55 +++++++- .../client/src/promise/generated/client.ts | 57 ++++++++ .../client/src/promise/generated/types.ts | 86 ++++++++++++ packages/core/src/config/mcp.ts | 60 ++------ packages/protocol/src/errors.ts | 9 ++ packages/protocol/src/groups/mcp.ts | 69 ++++++++- packages/schema/src/mcp.ts | 51 ++++++- packages/server/src/handlers/mcp.ts | 38 ++++- packages/tui/src/component/dialog-mcp.tsx | 131 ++++++++---------- packages/tui/src/config/v1/keybind.ts | 1 + 11 files changed, 472 insertions(+), 130 deletions(-) diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 2aee13e6735..5a001c2630e 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -542,13 +542,50 @@ 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"] + readonly config: Endpoint11_1Request["payload"]["config"] +} +export type Endpoint11_1Output = EffectValue> +export type McpAddOperation = (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 McpRemoveOperation = (input: Endpoint11_2Input) => Effect.Effect + +type Endpoint11_3Request = Parameters[0] +export type Endpoint11_3Input = { + readonly server: Endpoint11_3Request["params"]["server"] + readonly location?: Endpoint11_3Request["query"]["location"] +} +export type Endpoint11_3Output = EffectValue> +export type McpConnectOperation = (input: Endpoint11_3Input) => Effect.Effect + +type Endpoint11_4Request = Parameters[0] +export type Endpoint11_4Input = { + readonly server: Endpoint11_4Request["params"]["server"] + readonly location?: Endpoint11_4Request["query"]["location"] +} +export type Endpoint11_4Output = EffectValue> +export type McpDisconnectOperation = (input: Endpoint11_4Input) => Effect.Effect + +type Endpoint11_5Request = Parameters[0] +export type Endpoint11_5Input = { readonly location?: Endpoint11_5Request["query"]["location"] } +export type Endpoint11_5Output = EffectValue> +export type McpResourceCatalogOperation = (input?: Endpoint11_5Input) => Effect.Effect export interface McpApi { readonly list: McpListOperation + readonly add: McpAddOperation + readonly remove: McpRemoveOperation + 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 0de7fad7bdd..de18f5c05e2 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -650,14 +650,61 @@ 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"] + readonly config: Endpoint11_1Request["payload"]["config"] +} +const Endpoint11_1 = (raw: RawClient["server.mcp"]) => (input: Endpoint11_1Input) => + raw["mcp.add"]({ + params: { server: input["server"] }, + query: { location: input["location"] }, + payload: { config: input["config"] }, + }).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.remove"]({ params: { server: input["server"] }, query: { location: input["location"] } }).pipe( + Effect.mapError(mapClientError), + ) + +type Endpoint11_3Request = Parameters[0] +type Endpoint11_3Input = { + readonly server: Endpoint11_3Request["params"]["server"] + readonly location?: Endpoint11_3Request["query"]["location"] +} +const Endpoint11_3 = (raw: RawClient["server.mcp"]) => (input: Endpoint11_3Input) => + raw["mcp.connect"]({ params: { server: input["server"] }, query: { location: input["location"] } }).pipe( + Effect.mapError(mapClientError), + ) + +type Endpoint11_4Request = Parameters[0] +type Endpoint11_4Input = { + readonly server: Endpoint11_4Request["params"]["server"] + readonly location?: Endpoint11_4Request["query"]["location"] +} +const Endpoint11_4 = (raw: RawClient["server.mcp"]) => (input: Endpoint11_4Input) => + raw["mcp.disconnect"]({ params: { server: input["server"] }, query: { location: input["location"] } }).pipe( + Effect.mapError(mapClientError), + ) + +type Endpoint11_5Request = Parameters[0] +type Endpoint11_5Input = { readonly location?: Endpoint11_5Request["query"]["location"] } +const Endpoint11_5 = (raw: RawClient["server.mcp"]) => (input?: Endpoint11_5Input) => 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) }, + add: Endpoint11_1(raw), + remove: Endpoint11_2(raw), + connect: Endpoint11_3(raw), + disconnect: Endpoint11_4(raw), + resource: { catalog: Endpoint11_5(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 ea5e790ea60..97c84a5d7aa 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -104,6 +104,14 @@ import type { IntegrationCommandCancelOutput, McpListInput, McpListOutput, + McpAddInput, + McpAddOutput, + McpRemoveInput, + McpRemoveOutput, + McpConnectInput, + McpConnectOutput, + McpDisconnectInput, + McpDisconnectOutput, McpResourceCatalogInput, McpResourceCatalogOutput, CredentialUpdateInput, @@ -1044,6 +1052,55 @@ export function make(options: ClientOptions) { }, requestOptions, ), + add: (input: McpAddInput, requestOptions?: RequestOptions) => + request( + { + method: "PUT", + path: `/api/mcp/${encodeURIComponent(input.server)}`, + query: { location: input["location"] }, + body: { config: input["config"] }, + successStatus: 204, + declaredStatuses: [401, 400], + empty: true, + }, + requestOptions, + ), + remove: (input: McpRemoveInput, requestOptions?: RequestOptions) => + request( + { + method: "DELETE", + path: `/api/mcp/${encodeURIComponent(input.server)}`, + query: { location: input["location"] }, + successStatus: 204, + declaredStatuses: [404, 401, 400], + empty: true, + }, + 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 7e58e06ef46..6664c1424c2 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,84 @@ export type McpListOutput = { data: Array } +export type McpAddInput = { + readonly server: { readonly server: string }["server"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] + readonly config: { + readonly config: + | { + readonly type: "local" + readonly command: ReadonlyArray + readonly cwd?: string | undefined + readonly environment?: { readonly [x: string]: string } | undefined + readonly disabled?: boolean | undefined + readonly codemode?: boolean | undefined + readonly timeout?: + | { + readonly startup?: number | undefined + readonly catalog?: number | undefined + readonly execution?: number | undefined + } + | undefined + } + | { + readonly type: "remote" + readonly url: string + readonly headers?: { readonly [x: string]: string } | undefined + readonly oauth?: + | { + readonly client_id?: string | undefined + readonly client_secret?: string | undefined + readonly scope?: string | undefined + readonly callback_port?: number | undefined + readonly redirect_uri?: string | undefined + } + | false + | undefined + readonly disabled?: boolean | undefined + readonly codemode?: boolean | undefined + readonly timeout?: + | { + readonly startup?: number | undefined + readonly catalog?: number | undefined + readonly execution?: number | undefined + } + | undefined + } + }["config"] +} + +export type McpAddOutput = void + +export type McpRemoveInput = { + readonly server: { readonly server: string }["server"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type McpRemoveOutput = void + +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/core/src/config/mcp.ts b/packages/core/src/config/mcp.ts index 47938a3bf3e..12c3a98e315 100644 --- a/packages/core/src/config/mcp.ts +++ b/packages/core/src/config/mcp.ts @@ -1,55 +1,19 @@ export * as ConfigMCP from "./mcp" import { Schema } from "effect" -import { PositiveInt } from "../schema" +import { Mcp } from "@opencode-ai/schema/mcp" -export class Timeout extends Schema.Class("ConfigV2.MCP.Timeout")({ - startup: PositiveInt.pipe(Schema.optional).annotate({ - description: "Maximum time in milliseconds to establish and initialize the MCP server.", - }), - catalog: PositiveInt.pipe(Schema.optional).annotate({ - description: "Maximum time in milliseconds to wait for MCP discovery requests such as tools/list and prompts/list.", - }), - execution: PositiveInt.pipe(Schema.optional).annotate({ - description: "Maximum time in milliseconds to wait for MCP tool and prompt execution.", - }), -}) {} - -export class Local extends Schema.Class("ConfigV2.MCP.Local")({ - type: Schema.Literal("local"), - command: Schema.String.pipe(Schema.Array), - cwd: Schema.String.pipe(Schema.optional).annotate({ - description: "Working directory for the MCP server process. Relative paths resolve from the workspace directory.", - }), - environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), - disabled: Schema.Boolean.pipe(Schema.optional), - codemode: Schema.Boolean.pipe(Schema.optional).annotate({ - description: "Expose this server's tools through Code Mode. Defaults to true.", - }), - timeout: Timeout.pipe(Schema.optional), -}) {} - -export class OAuth extends Schema.Class("ConfigV2.MCP.OAuth")({ - client_id: Schema.String.pipe(Schema.optional), - client_secret: Schema.String.pipe(Schema.optional), - scope: Schema.String.pipe(Schema.optional), - callback_port: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })).pipe(Schema.optional), - redirect_uri: Schema.String.pipe(Schema.optional), -}) {} - -export class Remote extends Schema.Class("ConfigV2.MCP.Remote")({ - type: Schema.Literal("remote"), - url: Schema.String, - headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), - oauth: Schema.Union([OAuth, Schema.Literal(false)]).pipe(Schema.optional), - disabled: Schema.Boolean.pipe(Schema.optional), - codemode: Schema.Boolean.pipe(Schema.optional).annotate({ - description: "Expose this server's tools through Code Mode. Defaults to true.", - }), - timeout: Timeout.pipe(Schema.optional), -}) {} - -export const Server = Schema.Union([Local, Remote]).pipe(Schema.toTaggedUnion("type")) +// The MCP server config is a public wire contract (used by the mcp.add route), so it lives in +// @opencode-ai/schema and is re-exported here. +export const Timeout = Mcp.TimeoutConfig +export type Timeout = Mcp.TimeoutConfig +export const Local = Mcp.LocalConfig +export type Local = Mcp.LocalConfig +export const OAuth = Mcp.OAuthConfig +export type OAuth = Mcp.OAuthConfig +export const Remote = Mcp.RemoteConfig +export type Remote = Mcp.RemoteConfig +export const Server = Mcp.ServerConfig export class Info extends Schema.Class("ConfigV2.MCP")({ timeout: Timeout.pipe(Schema.optional), diff --git a/packages/protocol/src/errors.ts b/packages/protocol/src/errors.ts index d6d1482f301..a678fb73461 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 5f9c03935db..e7ad15a04d9 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,72 @@ export const McpGroup = HttpApiGroup.make("server.mcp") }), ), ) + .add( + HttpApiEndpoint.put("mcp.add", "/api/mcp/:server", { + params: { server: Schema.String }, + query: LocationQuery, + // Wrapped in a struct because the client codegen flattens payload fields and cannot + // represent a top-level union payload. + payload: Schema.Struct({ config: Mcp.ServerConfig }), + success: HttpApiSchema.NoContent, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.mcp.add", + summary: "Add MCP server", + description: "Add an MCP server at runtime or replace an existing one, connecting it immediately.", + }), + ), + ) + .add( + HttpApiEndpoint.delete("mcp.remove", "/api/mcp/:server", { + params: { server: Schema.String }, + query: LocationQuery, + success: HttpApiSchema.NoContent, + error: McpServerNotFoundError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.mcp.remove", + summary: "Remove MCP server", + description: "Stop an MCP server and remove it from the runtime set until restart.", + }), + ), + ) + .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/schema/src/mcp.ts b/packages/schema/src/mcp.ts index a86ffe6d67a..631edc16eb9 100644 --- a/packages/schema/src/mcp.ts +++ b/packages/schema/src/mcp.ts @@ -1,9 +1,58 @@ export * as Mcp from "./mcp.js" import { Schema } from "effect" -import { optional } from "./schema.js" +import { optional, PositiveInt } from "./schema.js" import { IntegrationID } from "./integration-id.js" +export class TimeoutConfig extends Schema.Class("Mcp.TimeoutConfig")({ + startup: PositiveInt.pipe(Schema.optional).annotate({ + description: "Maximum time in milliseconds to establish and initialize the MCP server.", + }), + catalog: PositiveInt.pipe(Schema.optional).annotate({ + description: "Maximum time in milliseconds to wait for MCP discovery requests such as tools/list and prompts/list.", + }), + execution: PositiveInt.pipe(Schema.optional).annotate({ + description: "Maximum time in milliseconds to wait for MCP tool and prompt execution.", + }), +}) {} + +export class LocalConfig extends Schema.Class("Mcp.LocalConfig")({ + type: Schema.Literal("local"), + command: Schema.String.pipe(Schema.Array), + cwd: Schema.String.pipe(Schema.optional).annotate({ + description: "Working directory for the MCP server process. Relative paths resolve from the workspace directory.", + }), + environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), + disabled: Schema.Boolean.pipe(Schema.optional), + codemode: Schema.Boolean.pipe(Schema.optional).annotate({ + description: "Expose this server's tools through Code Mode. Defaults to true.", + }), + timeout: TimeoutConfig.pipe(Schema.optional), +}) {} + +export class OAuthConfig extends Schema.Class("Mcp.OAuthConfig")({ + client_id: Schema.String.pipe(Schema.optional), + client_secret: Schema.String.pipe(Schema.optional), + scope: Schema.String.pipe(Schema.optional), + callback_port: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })).pipe(Schema.optional), + redirect_uri: Schema.String.pipe(Schema.optional), +}) {} + +export class RemoteConfig extends Schema.Class("Mcp.RemoteConfig")({ + type: Schema.Literal("remote"), + url: Schema.String, + headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), + oauth: Schema.Union([OAuthConfig, Schema.Literal(false)]).pipe(Schema.optional), + disabled: Schema.Boolean.pipe(Schema.optional), + codemode: Schema.Boolean.pipe(Schema.optional).annotate({ + description: "Expose this server's tools through Code Mode. Defaults to true.", + }), + timeout: TimeoutConfig.pipe(Schema.optional), +}) {} + +export const ServerConfig = Schema.Union([LocalConfig, RemoteConfig]).pipe(Schema.toTaggedUnion("type")) +export type ServerConfig = typeof ServerConfig.Type + const Connected = Schema.Struct({ status: Schema.Literal("connected") }).annotate({ identifier: "Mcp.Status.Connected", }) diff --git a/packages/server/src/handlers/mcp.ts b/packages/server/src/handlers/mcp.ts index 8f423534d46..0bb206997c2 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,38 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) => ) }), ) + .handle( + "mcp.add", + Effect.fn(function* (ctx) { + const service = yield* MCP.Service + yield* service.add(ctx.params.server, ctx.payload.config) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "mcp.remove", + Effect.fn(function* (ctx) { + const service = yield* MCP.Service + yield* notFound(service.remove(ctx.params.server)) + return HttpApiSchema.NoContent.make() + }), + ) + .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 696ff468d86..606e47a48e3 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" @@ -12,73 +13,35 @@ import { useToast } from "../ui/toast" import { useKeyboard, useTerminalDimensions } from "@opentui/solid" import { useConfig } from "../config" import { getScrollAcceleration } from "../util/scroll" -import type { ComponentTheme } from "../theme/v2/component" -// Sort by how much attention a server needs: auth prompts first, then failures, -// then healthy servers, and intentionally-off servers last. -function statusMeta(status: McpServer["status"], themeV2: ComponentTheme) { - switch (status.status) { - case "needs_auth": - return { - rank: 0, - icon: "!", - label: "Needs authentication", - color: themeV2.text.feedback.warning(), - error: undefined, - bold: false, - } - case "needs_client_registration": - return { - rank: 1, - icon: "✗", - label: "Needs registration", - color: themeV2.text.feedback.error(), - error: status.error, - bold: false, - } - case "failed": - return { - rank: 2, - icon: "✗", - label: "Failed", - color: themeV2.text.feedback.error(), - error: status.error, - bold: false, - } - case "connected": - return { - rank: 3, - icon: "✓", - label: "Connected", - color: themeV2.text.feedback.success(), - error: undefined, - bold: true, - } - case "pending": - return { rank: 4, icon: "◌", label: "Pending", color: themeV2.text.subdued(), error: undefined, bold: false } - default: - return { rank: 5, icon: "○", label: "Disabled", color: themeV2.text.subdued(), error: undefined, bold: false } +function statusError(status: McpServer["status"]) { + if (status.status === "failed" || status.status === "needs_client_registration") return status.error + return undefined +} + +function Status(props: { enabled: boolean; loading: boolean }) { + const { themeV2 } = useTheme().contextual("elevated") + if (props.loading) return ⋯ Loading + if (props.enabled) { + return ✓ Enabled } + return ○ Disabled } 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() - - onMount(() => { - dialog.setSize("large") - }) + const [loading, setLoading] = createSignal(null) const servers = createMemo(() => pipe( data.location.mcp.server.list() ?? [], - sortBy( - (server) => statusMeta(server.status, themeV2).rank, - (server) => server.name, - ), + sortBy((server) => server.name), ), ) @@ -88,33 +51,41 @@ export function DialogMcp() { if (first) setFocused(first.name) }) - const options = createMemo(() => - servers().map((server) => { - const meta = statusMeta(server.status, themeV2) - return { - value: server.name, - title: server.name, - footer: ( - - {meta.icon} {meta.label} - - ), - } - }), - ) + const options = createMemo(() => { + const loadingMcp = loading() + return servers().map((server) => ({ + value: server.name, + title: server.name, + description: server.status.status, + footer: , + })) + }) const focusedError = createMemo(() => { const name = focused() const server = servers().find((entry) => entry.name === name) - return server ? statusMeta(server.status, themeV2).error : undefined + return server ? statusError(server.status) : undefined }) const open = (name: string | undefined) => { const server = servers().find((entry) => entry.name === name) - if (!server || !statusMeta(server.status, themeV2).error) return + if (!server || !statusError(server.status)) return 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 (loading() !== null) return + const server = servers().find((entry) => entry.name === name) + if (!server || server.status.status === "pending") return + setLoading(name) + 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(() => setLoading(null)) + } + return ( setFocused(option.value as string)} onSelect={(option) => open(option.value as string)} + actions={[ + { + title: "toggle", + command: "dialog.mcp.toggle", + onTrigger: (option) => { + setFocused(option.value as string) + toggle(option.value as string) + }, + }, + ]} footer={ enter to view error @@ -135,7 +116,15 @@ export function DialogMcp() { /> } > - {(server) => setDetail()} />} + {(server) => ( + { + setDetail() + dialog.setSize("medium") + }} + /> + )} ) @@ -150,7 +139,7 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) { const dimensions = useTerminalDimensions() const config = useConfig().data const [copied, setCopied] = createSignal(false) - const error = () => statusMeta(props.server.status, themeV2).error ?? "Unknown MCP connection error" + const error = () => statusError(props.server.status) ?? "Unknown MCP connection error" const height = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2) - 5)) let scroll: ScrollBoxRenderable | undefined diff --git a/packages/tui/src/config/v1/keybind.ts b/packages/tui/src/config/v1/keybind.ts index fb31f696bed..5c4a9000e40 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"),