mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-26 03:43:38 +00:00
feat(server): expose runtime MCP controls over HTTP (#37712)
This commit is contained in:
parent
d5669ca934
commit
fe9a936867
11 changed files with 472 additions and 130 deletions
|
|
@ -542,13 +542,50 @@ 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.add"]>[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<ReturnType<RawClient["server.mcp"]["mcp.add"]>>
|
||||
export type McpAddOperation<E = never> = (input: Endpoint11_1Input) => Effect.Effect<Endpoint11_1Output, E>
|
||||
|
||||
type Endpoint11_2Request = Parameters<RawClient["server.mcp"]["mcp.remove"]>[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.remove"]>>
|
||||
export type McpRemoveOperation<E = never> = (input: Endpoint11_2Input) => Effect.Effect<Endpoint11_2Output, E>
|
||||
|
||||
type Endpoint11_3Request = Parameters<RawClient["server.mcp"]["mcp.connect"]>[0]
|
||||
export type Endpoint11_3Input = {
|
||||
readonly server: Endpoint11_3Request["params"]["server"]
|
||||
readonly location?: Endpoint11_3Request["query"]["location"]
|
||||
}
|
||||
export type Endpoint11_3Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.connect"]>>
|
||||
export type McpConnectOperation<E = never> = (input: Endpoint11_3Input) => Effect.Effect<Endpoint11_3Output, E>
|
||||
|
||||
type Endpoint11_4Request = Parameters<RawClient["server.mcp"]["mcp.disconnect"]>[0]
|
||||
export type Endpoint11_4Input = {
|
||||
readonly server: Endpoint11_4Request["params"]["server"]
|
||||
readonly location?: Endpoint11_4Request["query"]["location"]
|
||||
}
|
||||
export type Endpoint11_4Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.disconnect"]>>
|
||||
export type McpDisconnectOperation<E = never> = (input: Endpoint11_4Input) => Effect.Effect<Endpoint11_4Output, E>
|
||||
|
||||
type Endpoint11_5Request = Parameters<RawClient["server.mcp"]["mcp.resource.catalog"]>[0]
|
||||
export type Endpoint11_5Input = { readonly location?: Endpoint11_5Request["query"]["location"] }
|
||||
export type Endpoint11_5Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.resource.catalog"]>>
|
||||
export type McpResourceCatalogOperation<E = never> = (input?: Endpoint11_5Input) => Effect.Effect<Endpoint11_5Output, E>
|
||||
|
||||
export interface McpApi<E = never> {
|
||||
readonly list: McpListOperation<E>
|
||||
readonly add: McpAddOperation<E>
|
||||
readonly remove: McpRemoveOperation<E>
|
||||
readonly connect: McpConnectOperation<E>
|
||||
readonly disconnect: McpDisconnectOperation<E>
|
||||
readonly resource: { readonly catalog: McpResourceCatalogOperation<E> }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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.add"]>[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<RawClient["server.mcp"]["mcp.remove"]>[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<RawClient["server.mcp"]["mcp.connect"]>[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<RawClient["server.mcp"]["mcp.disconnect"]>[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<RawClient["server.mcp"]["mcp.resource.catalog"]>[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<RawClient["server.credential"]["credential.update"]>[0]
|
||||
|
|
|
|||
|
|
@ -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<McpAddOutput>(
|
||||
{
|
||||
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<McpRemoveOutput>(
|
||||
{
|
||||
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<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,84 @@ export type McpListOutput = {
|
|||
data: Array<McpServer>
|
||||
}
|
||||
|
||||
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<string>
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<Timeout>("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<Local>("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<OAuth>("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<Remote>("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<Info>("ConfigV2.MCP")({
|
||||
timeout: Timeout.pipe(Schema.optional),
|
||||
|
|
|
|||
|
|
@ -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,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,
|
||||
|
|
|
|||
|
|
@ -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<TimeoutConfig>("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<LocalConfig>("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<OAuthConfig>("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<RemoteConfig>("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",
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,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* () {
|
||||
|
|
|
|||
|
|
@ -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 <span style={{ fg: themeV2.text.subdued() }}>⋯ Loading</span>
|
||||
if (props.enabled) {
|
||||
return <span style={{ fg: themeV2.text.feedback.success(), attributes: TextAttributes.BOLD }}>✓ Enabled</span>
|
||||
}
|
||||
return <span style={{ fg: themeV2.text.subdued() }}>○ Disabled</span>
|
||||
}
|
||||
|
||||
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>()
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("large")
|
||||
})
|
||||
const [loading, setLoading] = createSignal<string | null>(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: (
|
||||
<span style={{ fg: meta.color, attributes: meta.bold ? TextAttributes.BOLD : undefined }}>
|
||||
{meta.icon} {meta.label}
|
||||
</span>
|
||||
),
|
||||
}
|
||||
}),
|
||||
)
|
||||
const options = createMemo(() => {
|
||||
const loadingMcp = loading()
|
||||
return servers().map((server) => ({
|
||||
value: server.name,
|
||||
title: server.name,
|
||||
description: server.status.status,
|
||||
footer: <Status enabled={server.status.status === "connected"} loading={loadingMcp === server.name} />,
|
||||
}))
|
||||
})
|
||||
|
||||
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 (
|
||||
<box>
|
||||
<Show
|
||||
|
|
@ -127,6 +98,16 @@ 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",
|
||||
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>
|
||||
|
|
@ -135,7 +116,15 @@ export function DialogMcp() {
|
|||
/>
|
||||
}
|
||||
>
|
||||
{(server) => <DialogMcpError server={server()} onBack={() => setDetail()} />}
|
||||
{(server) => (
|
||||
<DialogMcpError
|
||||
server={server()}
|
||||
onBack={() => {
|
||||
setDetail()
|
||||
dialog.setSize("medium")
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue