feat(server): add MCP connect and disconnect routes
This commit is contained in:
@@ -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> }
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user