Compare commits

...

1 Commits

Author SHA1 Message Date
Aiden Cline 96fe9cff52 feat(server): add MCP connect and disconnect routes 2026-07-19 00:24:15 -05:00
9 changed files with 195 additions and 10 deletions
+22 -4
View File
@@ -542,13 +542,31 @@ export type Endpoint11_0Input = { readonly location?: Endpoint11_0Request["query
export type Endpoint11_0Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.list"]>>
export type McpListOperation<E = never> = (input?: Endpoint11_0Input) => Effect.Effect<Endpoint11_0Output, E>
type Endpoint11_1Request = Parameters<RawClient["server.mcp"]["mcp.resource.catalog"]>[0]
export type Endpoint11_1Input = { readonly location?: Endpoint11_1Request["query"]["location"] }
export type Endpoint11_1Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.resource.catalog"]>>
export type McpResourceCatalogOperation<E = never> = (input?: Endpoint11_1Input) => Effect.Effect<Endpoint11_1Output, E>
type Endpoint11_1Request = Parameters<RawClient["server.mcp"]["mcp.connect"]>[0]
export type Endpoint11_1Input = {
readonly server: Endpoint11_1Request["params"]["server"]
readonly location?: Endpoint11_1Request["query"]["location"]
}
export type Endpoint11_1Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.connect"]>>
export type McpConnectOperation<E = never> = (input: Endpoint11_1Input) => Effect.Effect<Endpoint11_1Output, E>
type Endpoint11_2Request = Parameters<RawClient["server.mcp"]["mcp.disconnect"]>[0]
export type Endpoint11_2Input = {
readonly server: Endpoint11_2Request["params"]["server"]
readonly location?: Endpoint11_2Request["query"]["location"]
}
export type Endpoint11_2Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.disconnect"]>>
export type McpDisconnectOperation<E = never> = (input: Endpoint11_2Input) => Effect.Effect<Endpoint11_2Output, E>
type Endpoint11_3Request = Parameters<RawClient["server.mcp"]["mcp.resource.catalog"]>[0]
export type Endpoint11_3Input = { readonly location?: Endpoint11_3Request["query"]["location"] }
export type Endpoint11_3Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.resource.catalog"]>>
export type McpResourceCatalogOperation<E = never> = (input?: Endpoint11_3Input) => Effect.Effect<Endpoint11_3Output, E>
export interface McpApi<E = never> {
readonly list: McpListOperation<E>
readonly connect: McpConnectOperation<E>
readonly disconnect: McpDisconnectOperation<E>
readonly resource: { readonly catalog: McpResourceCatalogOperation<E> }
}
+26 -4
View File
@@ -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<RawClient["server.mcp"]["mcp.resource.catalog"]>[0]
type Endpoint11_1Input = { readonly location?: Endpoint11_1Request["query"]["location"] }
const Endpoint11_1 = (raw: RawClient["server.mcp"]) => (input?: Endpoint11_1Input) =>
type Endpoint11_1Request = Parameters<RawClient["server.mcp"]["mcp.connect"]>[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<RawClient["server.mcp"]["mcp.disconnect"]>[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<RawClient["server.mcp"]["mcp.resource.catalog"]>[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<RawClient["server.credential"]["credential.update"]>[0]
@@ -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<McpConnectOutput>(
{
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<McpDisconnectOutput>(
{
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<McpResourceCatalogOutput>(
@@ -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<McpServer>
}
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
+9
View File
@@ -90,6 +90,15 @@ export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundErr
{ httpApiStatus: 404 },
) {}
export class McpServerNotFoundError extends Schema.TaggedErrorClass<McpServerNotFoundError>()(
"McpServerNotFoundError",
{
server: Schema.String,
message: Schema.String,
},
{ httpApiStatus: 404 },
) {}
export class CommandNotFoundError extends Schema.TaggedErrorClass<CommandNotFoundError>()(
"CommandNotFoundError",
{
+34 -1
View File
@@ -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,
+21 -1
View File
@@ -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 = <A, R>(effect: Effect.Effect<A, MCP.NotFoundError, R>) =>
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* () {
+28
View File
@@ -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<string>()
const [detail, setDetail] = createSignal<McpServer>()
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 (
<box>
<Show
@@ -127,6 +144,17 @@ export function DialogMcp() {
preserveSelection
onMove={(option) => 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={
<Show when={focusedError()}>
<text fg={themeV2.text.subdued()}>enter to view error</text>
+1
View File
@@ -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"),