feat(plugin): expose MCP server transforms (#43125)

This commit is contained in:
Aiden Cline
2026-08-17 20:17:48 -05:00
committed by GitHub
parent 3facbe1dd3
commit 006a0a4b34
21 changed files with 529 additions and 128 deletions
+1
View File
@@ -5,6 +5,7 @@ export type CommandApi = Client["command"]
export type ConfigApi = Client["config"]
export type EventApi = Client["event"]
export type IntegrationApi = Client["integration"]
export type McpApi = Client["mcp"]
export type ModelApi = Client["model"]
export type PluginApi = Client["plugin"]
export type ProviderApi = Client["provider"]
+57
View File
@@ -0,0 +1,57 @@
export * as ConfigMCPPlugin from "./mcp.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Document, type Entry } from "@opencode-ai/schema/config"
import { Mcp } from "@opencode-ai/schema/mcp"
import { Effect, Stream } from "effect"
import { Config } from "../../config.js"
import { MCP } from "../../mcp/index.js"
export const Plugin = define({
id: "opencode.config.mcp",
effect: Effect.fn(function* (ctx) {
yield* register(ctx.event.subscribe())
}),
})
export const register = Effect.fn("ConfigMCPPlugin.register")(function* (
events: Stream.Stream<{ readonly type: string }, unknown>,
) {
const config = yield* Config.Service
const mcp = yield* MCP.Service
const loaded = { entries: [] as Entry[] }
yield* events.pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() =>
config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(mcp.reload()),
Effect.catchCause((cause) => Effect.logError("failed to reload MCP config", { cause })),
),
),
Effect.ignore,
Effect.forkScoped({ startImmediately: true }),
)
// Subscribe before the initial load so updates racing it trigger a rebuild.
loaded.entries = yield* config.entries()
yield* mcp.transform((draft) => {
const documents = loaded.entries.filter((entry): entry is Document => entry.type === "document")
// Global timeout defaults merge in config order; each server can override them.
const timeout = Object.assign(
{},
...documents.flatMap((entry) => (entry.info.mcp?.timeout ? [entry.info.mcp.timeout] : [])),
)
const servers = new Map<string, Mcp.ServerConfig>()
for (const document of documents) {
for (const [name, server] of Object.entries(document.info.mcp?.servers ?? {})) {
servers.set(name, { ...server, timeout: { ...timeout, ...server.timeout } })
}
}
for (const [name, server] of servers) {
if (draft.get(name)) continue
draft.set(name, server)
}
})
})
+114 -98
View File
@@ -3,13 +3,10 @@ export * as MCP from "./index.js"
import { Mcp } from "@opencode-ai/schema/mcp"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { Command } from "@opencode-ai/schema/command"
import { Document, Event, type Entry } from "@opencode-ai/schema/config"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import { createHash } from "node:crypto"
import { isDeepStrictEqual } from "node:util"
import { Cause, Context, Deferred, Effect, Exit, FiberSet, Layer, Schema, Scope, Semaphore, Stream } from "effect"
import { Cause, Context, Deferred, Effect, Exit, FiberSet, Layer, Schema, Scope, Stream, Types } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Config } from "../config.js"
import { Credential } from "../credential.js"
import { Bus } from "../bus.js"
import { Environment } from "../environment/index.js"
@@ -112,7 +109,7 @@ export class ToolCallError extends Schema.TaggedError<ToolCallError>()("MCP.Tool
}) {}
type ServerEntry = {
readonly config: typeof ConfigMCP.Server.Type
readonly config: Mcp.ServerConfig
status: Status
readonly startup: Deferred.Deferred<void>
scope?: Scope.Closeable
@@ -129,9 +126,25 @@ type ServerEntry = {
const GLOBAL_ELICITATION_SESSION_ID = "global"
const URL_ELICITATION_FIELD_KEY = "elicitation"
export interface Interface {
type Data = {
servers: Map<ServerName, Types.DeepMutable<Mcp.ServerConfig>>
removed: Set<ServerName>
}
export type Draft = {
list: () => readonly [ServerName, Types.DeepMutable<Mcp.ServerConfig>][]
get: (server: ServerName | string) => Types.DeepMutable<Mcp.ServerConfig> | undefined
set: (server: ServerName | string, config: Mcp.ServerConfig) => void
update: (server: ServerName | string, update: (config: Types.DeepMutable<Mcp.ServerConfig>) => void) => void
remove: (server: ServerName | string) => void
}
const cloneConfig = (config: Mcp.ServerConfig) =>
structuredClone(config) as Types.DeepMutable<Mcp.ServerConfig>
export interface Interface extends State.Transformable<Draft> {
readonly servers: () => Effect.Effect<ServerInfo[]>
readonly add: (server: ServerName | string, config: typeof ConfigMCP.Server.Type) => Effect.Effect<void>
readonly add: (server: ServerName | string, config: Mcp.ServerConfig) => Effect.Effect<void>
readonly connect: (server: ServerName | string) => Effect.Effect<void, NotFoundError>
readonly disconnect: (server: ServerName | string) => Effect.Effect<void, NotFoundError>
readonly remove: (server: ServerName | string) => Effect.Effect<void, NotFoundError>
@@ -171,7 +184,6 @@ export const layer = (options?: Options) =>
Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const location = yield* Location.Service
const environment = yield* Environment.Service
const bus = yield* Bus.Service
@@ -181,37 +193,13 @@ export const layer = (options?: Options) =>
const root = yield* Effect.scope
const fork = yield* FiberSet.makeRuntime<never, void, never>()
const loadConfig = (entries: readonly Entry[]) => {
const documents = entries.filter((entry): entry is Document => entry.type === "document")
// Global MCP timeout defaults, later config files overriding earlier ones.
const timeout = Object.assign(
{},
...documents.flatMap((entry) => (entry.info.mcp?.timeout ? [entry.info.mcp.timeout] : [])),
)
const servers = new Map<ServerName, typeof ConfigMCP.Server.Type>()
for (const entry of documents) {
for (const [name, server] of Object.entries(entry.info.mcp?.servers ?? {})) {
servers.set(ServerName.make(name), { ...server, timeout: { ...timeout, ...server.timeout } })
}
}
return { timeout, servers }
}
const initial = loadConfig(yield* config.entries())
const configState = { servers: initial.servers, timeout: initial.timeout }
// Later config files win for duplicate server names; per-server timeout overrides globals.
const runtime = new Map<ServerName, ServerEntry>()
// Materialized definitions and live connections are kept separate so operational additions
// survive unrelated definition reloads.
const entries = new Map<ServerName, ServerEntry>()
// Serializes lifecycle operations per server. Anything taking this lock from a connection
// callback must stay forked: lifecycle operations close scopes while holding it, firing onClose.
const locks = KeyedMutex.makeUnsafe<ServerName>()
const reloadLock = Semaphore.makeUnsafe(1)
const urlElicitations = new Map<string, Form.ID>()
for (const [name, server] of initial.servers) {
runtime.set(name, {
config: server,
status: { status: "pending" },
startup: Deferred.makeUnsafe<void>(),
})
}
// Register every remote server as an OAuth integration so credentials live in the global store
// rather than in committed config. Servers that connect anonymously simply never use the method.
@@ -253,11 +241,9 @@ export const layer = (options?: Options) =>
})
.pipe(Scope.provide(scope))
})
yield* Effect.forEach(runtime, ([name, entry]) => register(name, entry), { discard: true })
const requireServer = Effect.fnUntraced(function* (server: ServerName | string) {
const name = ServerName.make(server)
const entry = runtime.get(name)
const entry = entries.get(name)
if (!entry) return yield* new NotFoundError({ server: name })
return { name, entry }
})
@@ -438,13 +424,11 @@ export const layer = (options?: Options) =>
const refreshPrompts = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
connection.prompts().pipe(
Effect.catch(() => Effect.succeed([])),
Effect.map((defs) => {
entry.prompts = defs.map((def) => toPrompt(name, def))
}),
Effect.andThen(bus.publish(Command.Event.Updated, {})),
Effect.catch(() =>
Effect.sync(() => (entry.prompts = [])).pipe(Effect.andThen(bus.publish(Command.Event.Updated, {}))),
),
)
// Runs a connection callback under the server lock, dropping it if the connection is no longer
@@ -572,15 +556,15 @@ export const layer = (options?: Options) =>
if (entry.registration) yield* entry.registration.dispose
})
const replaceServer = Effect.fnUntraced(function* (name: ServerName, serverConfig: typeof ConfigMCP.Server.Type) {
const previous = runtime.get(name)
const replaceServer = Effect.fnUntraced(function* (name: ServerName, serverConfig: Mcp.ServerConfig) {
const previous = entries.get(name)
if (previous) yield* disposeServer(name, previous)
const entry: ServerEntry = {
config: serverConfig,
status: { status: "pending" },
startup: Deferred.makeUnsafe<void>(),
}
runtime.set(name, entry)
entries.set(name, entry)
yield* Effect.gen(function* () {
yield* register(name, entry)
if (serverConfig.disabled) {
@@ -596,55 +580,66 @@ export const layer = (options?: Options) =>
})
const removeServer = Effect.fnUntraced(function* (name: ServerName) {
const entry = runtime.get(name)
const entry = entries.get(name)
if (!entry) return
yield* disposeServer(name, entry)
// Credentials are keyed by name + URL and intentionally survive removal for a later re-add.
runtime.delete(name)
entries.delete(name)
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
})
const reloadConfig = Effect.fnUntraced(function* () {
yield* reloadLock.withPermit(
Effect.gen(function* () {
const next = loadConfig(yield* config.entries())
const names = new Set([...configState.servers.keys(), ...next.servers.keys()])
for (const name of names) {
const previous = configState.servers.get(name)
const updated = next.servers.get(name)
if (isDeepStrictEqual(previous, updated)) continue
if (!updated) {
yield* removeServer(name).pipe(locks.withLock(name))
continue
}
yield* replaceServer(name, updated).pipe(locks.withLock(name))
}
configState.servers = next.servers
configState.timeout = next.timeout
}),
)
})
let applied: Map<ServerName, Mcp.ServerConfig> | undefined
const overrides = new Map<ServerName, Mcp.ServerConfig | false>()
const reconcile = Effect.fnUntraced(function* (next: Draft) {
const servers = new Map(next.list())
if (!applied && entries.size === 0) {
for (const [name, server] of servers) {
entries.set(name, {
config: server,
status: { status: "pending" },
startup: Deferred.makeUnsafe<void>(),
})
}
yield* Effect.forEach(entries, ([name, entry]) => register(name, entry), { discard: true })
applied = servers
// Disabled servers settle their startup immediately so queries never block on them.
for (const [name, entry] of runtime) {
if (entry.config.disabled) {
entry.status = { status: "disabled" }
Deferred.doneUnsafe(entry.startup, Exit.void)
continue
// Initial connections stay asynchronous so one slow server does not block Location startup.
for (const [name, entry] of entries) {
if (entry.config.disabled) {
entry.status = { status: "disabled" }
Deferred.doneUnsafe(entry.startup, Exit.void)
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
continue
}
fork(startServer(name, entry).pipe(locks.withLock(name)))
}
return
}
fork(startServer(name, entry).pipe(locks.withLock(name)))
}
const names = new Set([...(applied?.keys() ?? []), ...servers.keys()])
for (const name of names) {
const previous = applied?.get(name)
const updated = servers.get(name)
if (isDeepStrictEqual(previous, updated)) continue
if (!updated) {
yield* removeServer(name).pipe(locks.withLock(name))
continue
}
yield* replaceServer(name, updated).pipe(locks.withLock(name))
}
applied = servers
})
// Bring a server online (or back to needs_auth) when its integration's credential changes, so an
// OAuth login takes effect without a restart. Only fires for the integrations we registered.
const reconnect = (integrationID: Integration.ID) =>
Effect.gen(function* () {
const match = Array.from(runtime).find(([, entry]) => entry.integrationID === integrationID)
const match = Array.from(entries).find(([, entry]) => entry.integrationID === integrationID)
if (!match) return
const name = match[0]
yield* Effect.gen(function* () {
// add() or remove() may have replaced or deleted the entry while we waited for the lock.
const entry = runtime.get(name)
const entry = entries.get(name)
if (!entry || entry.integrationID !== integrationID) return
if (entry.status.status === "disabled") return
yield* stopServer(name, entry)
@@ -658,33 +653,55 @@ export const layer = (options?: Options) =>
Effect.ignore,
),
)
yield* bus.subscribe(Event.Updated).pipe(
Stream.runForEach(() =>
reloadConfig().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload MCP config", { cause }))),
),
Effect.forkScoped({ startImmediately: true }),
)
// Close the gap between the initial snapshot and the live subscription becoming active.
yield* reloadConfig()
const state = State.create<Data, Draft>({
name: "mcp",
initial: () => ({
servers: new Map(
Array.from(overrides).flatMap(([name, config]) =>
config === false ? [] : [[name, cloneConfig(config)] as const],
),
),
removed: new Set(
Array.from(overrides).flatMap(([name, config]) => (config === false ? [name] : [])),
),
}),
draft: (draft) => ({
list: () => Array.from(draft.servers),
get: (server) => draft.servers.get(ServerName.make(server)),
set: (server, serverConfig) => {
const name = ServerName.make(server)
if (draft.removed.has(name)) return
draft.servers.set(name, cloneConfig(serverConfig))
},
update: (server, update) => {
const current = draft.servers.get(ServerName.make(server))
if (!current) return
update(current)
},
remove: (server) => draft.servers.delete(ServerName.make(server)),
}),
finalize: reconcile,
})
// Suspend so each await sees current entries; a bare Map iterator is exhausted after one run.
const whenAllReady = Effect.suspend(() =>
Effect.forEach(Array.from(runtime.values()), (entry) => Deferred.await(entry.startup), {
Effect.forEach(Array.from(entries.values()), (entry) => Deferred.await(entry.startup), {
concurrency: "unbounded",
discard: true,
}),
)
return Service.of({
transform: state.transform,
reload: state.reload,
servers: Effect.fn("MCP.servers")(function* () {
return Array.from(runtime)
return Array.from(entries)
.toSorted(([a], [b]) => a.localeCompare(b))
.map(([name, entry]) => new ServerInfo({ name, status: entry.status, integrationID: entry.integrationID }))
}),
add: Effect.fn("MCP.add")(function* (server, config) {
const name = ServerName.make(server)
yield* replaceServer(name, { ...config, timeout: { ...configState.timeout, ...config.timeout } }).pipe(
locks.withLock(name),
)
overrides.set(name, config)
yield* state.reload()
}),
connect: Effect.fn("MCP.connect")(function* (server) {
const name = ServerName.make(server)
@@ -705,14 +722,13 @@ export const layer = (options?: Options) =>
}),
remove: Effect.fn("MCP.remove")(function* (server) {
const name = ServerName.make(server)
yield* Effect.gen(function* () {
yield* requireServer(name)
yield* removeServer(name)
}).pipe(locks.withLock(name))
yield* requireServer(name)
overrides.set(name, false)
yield* state.reload()
}),
tools: Effect.fn("MCP.tools")(function* () {
yield* whenAllReady
return Array.from(runtime.values())
return Array.from(entries.values())
.flatMap((entry) => entry.tools ?? [])
.toSorted((a, b) => a.server.localeCompare(b.server) || a.name.localeCompare(b.name))
}),
@@ -742,7 +758,7 @@ export const layer = (options?: Options) =>
}),
instructions: Effect.fn("MCP.instructions")(function* () {
yield* whenAllReady
return Array.from(runtime)
return Array.from(entries)
.flatMap(([server, entry]) => {
const instructions = entry.client?.instructions
if (!instructions) return []
@@ -751,7 +767,7 @@ export const layer = (options?: Options) =>
.toSorted((a, b) => a.server.localeCompare(b.server))
}),
prompts: Effect.fn("MCP.prompts")(function* () {
return Array.from(runtime.values())
return Array.from(entries.values())
.flatMap((entry) => entry.prompts ?? [])
.toSorted((a, b) => a.server.localeCompare(b.server) || a.name.localeCompare(b.name))
}),
@@ -774,7 +790,7 @@ export const layer = (options?: Options) =>
resourceCatalog: Effect.fn("MCP.resourceCatalog")(function* () {
yield* whenAllReady
const catalogs = yield* Effect.forEach(
Array.from(runtime),
Array.from(entries),
([name, entry]) => {
if (!entry.client) return Effect.succeed({ resources: [], templates: [] })
return Effect.all(
@@ -831,7 +847,7 @@ export function configured(options?: Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [Config.node, Location.node, Environment.node, Bus.node, Form.node, Integration.node, Credential.node],
deps: [Location.node, Environment.node, Bus.node, Form.node, Integration.node, Credential.node],
})
}
+2
View File
@@ -11,6 +11,7 @@ import { Catalog } from "./catalog.js"
import { Command } from "./command.js"
import { Bus } from "./bus.js"
import { Integration } from "./integration.js"
import { MCP } from "./mcp/index.js"
import { Location } from "./location.js"
import { PluginHost } from "./plugin/host.js"
import { PluginRuntime } from "./plugin/runtime.js"
@@ -154,6 +155,7 @@ export const node = makeLocationNode({
Catalog.node,
Command.node,
Integration.node,
MCP.node,
Location.node,
Reference.node,
Skill.node,
+41
View File
@@ -4,6 +4,7 @@ import { Plugin } from "@opencode-ai/plugin/effect"
import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import type { CredentialOAuth } from "@opencode-ai/sdk/v2/types"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { Mcp } from "@opencode-ai/schema/mcp"
import { App } from "../app.js"
import { Effect, Schema, Stream } from "effect"
import { Agent } from "../agent.js"
@@ -15,6 +16,7 @@ import { Bus } from "../bus.js"
import { Integration } from "../integration.js"
import { Location } from "../location.js"
import { Model } from "../model.js"
import { MCP } from "../mcp/index.js"
import { PluginRuntime } from "./runtime.js"
import { Provider } from "../provider.js"
import { Reference } from "../reference.js"
@@ -34,6 +36,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
const commands = yield* Command.Service
const bus = yield* Bus.Service
const integration = yield* Integration.Service
const mcp = yield* MCP.Service
const location = yield* Location.Service
const reference = yield* Reference.Service
const skill = yield* Skill.Service
@@ -269,6 +272,44 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
})
}),
},
mcp: {
list: (input) => {
const ref = locationRef(input)
if (ref && !isCurrentLocation(ref)) return runtime.location.mcp.list(ref)
return response(mcp.servers())
},
add: (input) => {
const ref = locationRef(input)
if (ref && !isCurrentLocation(ref)) return runtime.location.mcp.add(ref, input.server, input.config)
return mcp.add(input.server, input.config)
},
remove: (input) => {
const ref = locationRef(input)
if (ref && !isCurrentLocation(ref)) return runtime.location.mcp.remove(ref, input.server)
return mcp.remove(input.server)
},
connect: (input) => {
const ref = locationRef(input)
if (ref && !isCurrentLocation(ref)) return runtime.location.mcp.connect(ref, input.server)
return mcp.connect(input.server)
},
disconnect: (input) => {
const ref = locationRef(input)
if (ref && !isCurrentLocation(ref)) return runtime.location.mcp.disconnect(ref, input.server)
return mcp.disconnect(input.server)
},
reload: mcp.reload,
transform: (callback) =>
mcp.transform((draft) => {
callback({
list: () => draft.list().map(([name, config]) => [name, mutable(config)]),
get: (name) => mutable(draft.get(name)),
set: (name, config) => draft.set(name, Schema.decodeUnknownSync(Mcp.ServerConfig)(config)),
update: draft.update,
remove: draft.remove,
})
}),
},
plugin: {
list: () => response(plugin.list()),
},
+6
View File
@@ -13,6 +13,7 @@ import { Credential } from "../credential.js"
import { ConfigAgentPlugin } from "../config/plugin/agent.js"
import { ConfigCommandPlugin } from "../config/plugin/command.js"
import { ConfigInstructionPlugin } from "../config/plugin/instruction.js"
import { ConfigMCPPlugin } from "../config/plugin/mcp.js"
import { ConfigProviderPlugin } from "../config/plugin/provider.js"
import { ConfigPolicyPlugin } from "../config/plugin/policy.js"
import { ConfigReferencePlugin } from "../config/plugin/reference.js"
@@ -34,6 +35,7 @@ import { KV } from "../kv.js"
import { Location } from "../location.js"
import { LocationMutation } from "../location-mutation.js"
import { ModelsDev } from "../models-dev.js"
import { MCP } from "../mcp/index.js"
import { Npm } from "@opencode-ai/util/npm"
import { Permission } from "../permission.js"
import { Reference } from "../reference.js"
@@ -94,6 +96,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const location = yield* Location.Service
const locationMutation = yield* LocationMutation.Service
const models = yield* ModelsDev.Service
const mcp = yield* MCP.Service
const npm = yield* Npm.Service
const permission = yield* Permission.Service
const runtime = yield* PluginRuntime.Service
@@ -131,6 +134,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(Location.Service, location),
Context.make(LocationMutation.Service, locationMutation),
Context.make(ModelsDev.Service, models),
Context.make(MCP.Service, mcp),
Context.make(Npm.Service, npm),
Context.make(Permission.Service, permission),
Context.make(PluginRuntime.Service, runtime),
@@ -175,6 +179,7 @@ export const requirements = LayerNode.group([
Location.node,
LocationMutation.node,
ModelsDev.node,
MCP.node,
Npm.node,
Permission.node,
PluginRuntime.node,
@@ -195,6 +200,7 @@ export const requirements = LayerNode.group([
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
const pre = [
ConfigMCPPlugin.Plugin,
WellKnownPlugin.Plugin,
AgentPlugin.Plugin,
PlanPlugin.Plugin,
+41
View File
@@ -2,10 +2,12 @@ export * as PluginRuntime from "./runtime.js"
import { Context, Effect, Layer } from "effect"
import { Agent } from "../agent.js"
import { Mcp } from "@opencode-ai/schema/mcp"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Job } from "../job.js"
import { Location } from "../location.js"
import { LocationServiceMap } from "../location-service-map.js"
import { MCP } from "../mcp/index.js"
import { Session } from "../session.js"
export interface Interface {
@@ -30,6 +32,15 @@ export interface Interface {
ref: Location.Ref,
) => Effect.Effect<{ readonly location: Location.Info; readonly data: Agent.Info[] }>
}
readonly mcp: {
readonly list: (
ref: Location.Ref,
) => Effect.Effect<{ readonly location: Location.Info; readonly data: MCP.ServerInfo[] }, unknown>
readonly add: (ref: Location.Ref, server: string, config: Mcp.ServerConfig) => Effect.Effect<void, unknown>
readonly remove: (ref: Location.Ref, server: string) => Effect.Effect<void, unknown>
readonly connect: (ref: Location.Ref, server: string) => Effect.Effect<void, unknown>
readonly disconnect: (ref: Location.Ref, server: string) => Effect.Effect<void, unknown>
}
}
}
@@ -79,6 +90,13 @@ export const layerWithCell = (cell: Cell) =>
agent: {
list: (ref) => require(cell, (runtime) => runtime.location.agent.list(ref)),
},
mcp: {
list: (ref) => require(cell, (runtime) => runtime.location.mcp.list(ref)),
add: (ref, server, config) => require(cell, (runtime) => runtime.location.mcp.add(ref, server, config)),
remove: (ref, server) => require(cell, (runtime) => runtime.location.mcp.remove(ref, server)),
connect: (ref, server) => require(cell, (runtime) => runtime.location.mcp.connect(ref, server)),
disconnect: (ref, server) => require(cell, (runtime) => runtime.location.mcp.disconnect(ref, server)),
},
},
}),
)
@@ -108,6 +126,29 @@ export const providerLayerWithCell = (cell: Cell) =>
}
}).pipe(Effect.provide(locations.get(ref)), Effect.orDie),
},
mcp: {
list: (ref) =>
Effect.gen(function* () {
const location = yield* Location.Service
const mcp = yield* MCP.Service
return {
location: new Location.Info({
directory: location.directory,
workspaceID: location.workspaceID,
project: location.project,
}),
data: yield* mcp.servers(),
}
}).pipe(Effect.provide(locations.get(ref))),
add: (ref, server, config) =>
MCP.Service.use((mcp) => mcp.add(server, config)).pipe(Effect.provide(locations.get(ref))),
remove: (ref, server) =>
MCP.Service.use((mcp) => mcp.remove(server)).pipe(Effect.provide(locations.get(ref))),
connect: (ref, server) =>
MCP.Service.use((mcp) => mcp.connect(server)).pipe(Effect.provide(locations.get(ref))),
disconnect: (ref, server) =>
MCP.Service.use((mcp) => mcp.disconnect(server)).pipe(Effect.provide(locations.get(ref))),
},
},
}
cell.runtime = runtime
+2
View File
@@ -8,6 +8,8 @@ import { location } from "./location"
export const emptyMcpLayer = Layer.succeed(
MCP.Service,
MCP.Service.of({
transform: () => Effect.die("unused mcp.transform"),
reload: () => Effect.die("unused mcp.reload"),
servers: () => Effect.succeed([]),
add: () => Effect.die("unused mcp.add"),
connect: () => Effect.die("unused mcp.connect"),
+58
View File
@@ -16,6 +16,7 @@ import { Plugin } from "@opencode-ai/core/plugin"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Model } from "@opencode-ai/core/model"
import { MCP } from "@opencode-ai/core/mcp/index"
import { Project } from "@opencode-ai/core/project"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -809,4 +810,61 @@ describe("LocationServiceMap", () => {
),
),
)
itWithSdk.live("lets public plugins mutate configured and runtime MCP servers", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
const url = "https://example.com/mcp"
yield* Effect.promise(() =>
fs.writeFile(
path.join(dir.path, "opencode.json"),
JSON.stringify({ mcp: { servers: { example: { type: "remote", url, disabled: true } } } }),
),
)
const observed: Record<string, boolean | undefined> = {}
const sdk = yield* SdkPlugins.Service
yield* sdk.register(
EffectPlugin.define({
id: "mcp-codemode-policy",
effect: (ctx) =>
ctx.mcp
.transform((mcp) => {
for (const [name, server] of mcp.list()) {
if (server.type !== "remote" || new URL(server.url).hostname !== "example.com") continue
mcp.update(name, (current) => {
current.codemode = false
observed[name] = current.codemode
})
}
})
.pipe(Effect.asVoid),
}),
)
yield* Effect.gen(function* () {
const supervisor = yield* PluginSupervisor.Service
const mcp = yield* MCP.Service
yield* supervisor.flush
expect(observed.example).toBe(false)
yield* mcp.add("dynamic", {
type: "remote",
url: "https://example.com/dynamic",
disabled: true,
})
expect(observed.dynamic).toBe(false)
expect((yield* mcp.servers()).map((server) => String(server.name))).toEqual(["dynamic", "example"])
}).pipe(
Effect.scoped,
Effect.provide(
LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })),
),
)
}),
),
),
)
})
+80 -30
View File
@@ -14,7 +14,9 @@ import {
} from "@modelcontextprotocol/sdk/types.js"
import { Document, Event, Info } from "@opencode-ai/schema/config"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { Config } from "@opencode-ai/core/config"
import { ConfigMCPPlugin } from "@opencode-ai/core/config/plugin/mcp"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
@@ -174,11 +176,18 @@ function resourceMcpLayer(
entries?: Config.Interface["entries"]
subscribe?: Bus.Interface["subscribe"]
environment?: Layer.Layer<Environment.Service>
published?: string[]
},
) {
const directory = AbsolutePath.make(import.meta.dir)
const unusedIntegration = () => Effect.die("unused integration service")
return MCP.layer(options).pipe(
return Layer.effectDiscard(
Effect.gen(function* () {
const bus = yield* Bus.Service
yield* ConfigMCPPlugin.register(bus.subscribe())
}),
).pipe(
Layer.provideMerge(MCP.layer(options)),
Layer.provideMerge(Form.layer),
Layer.provide(
Layer.mergeAll(
@@ -215,6 +224,7 @@ function resourceMcpLayer(
type: definition.type,
data,
} as Payload<typeof definition>
overrides?.published?.push(event.type)
if (event.type !== Form.Event.Created.type || !onFormCreated) return Effect.succeed(event)
return onFormCreated(Schema.decodeUnknownSync(Form.Event.Created.data)(data).form).pipe(Effect.as(event))
},
@@ -912,6 +922,7 @@ test("loads and reads MCP resources", async () => {
})
test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
const published: string[] = []
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
@@ -919,6 +930,7 @@ test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
const service = yield* MCP.Service
expect((yield* service.servers())[0]?.status).toEqual({ status: "disabled" })
expect(published).toContain(McpEvent.StatusChanged.type)
expect(yield* service.connect("missing").pipe(Effect.flip)).toBeInstanceOf(MCP.NotFoundError)
expect(yield* service.disconnect("missing").pipe(Effect.flip)).toBeInstanceOf(MCP.NotFoundError)
yield* service.add(
@@ -972,6 +984,9 @@ test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
disabled: true,
}),
undefined,
undefined,
{ published },
),
),
)
@@ -980,6 +995,70 @@ test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
)
})
test("restores runtime MCP config when a transform is disposed", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const service = yield* MCP.Service
const config = new ConfigMCP.Remote({
type: "remote",
url: "https://example.com/mcp",
headers: { Authorization: "original" },
oauth: false,
disabled: true,
})
yield* service.add("dynamic", config)
const transformed = yield* service.transform((draft) =>
draft.update("dynamic", (server) => {
if (server.type === "remote") server.headers = { Authorization: "transformed" }
}),
)
let observed: string | undefined
yield* service.transform((draft) => {
const server = draft.get("dynamic")
observed = server?.type === "remote" ? server.headers?.Authorization : undefined
})
expect(observed).toBe("transformed")
expect(config.headers?.Authorization).toBe("original")
yield* transformed.dispose
expect(observed).toBe("original")
}).pipe(
Effect.provide(
resourceMcpLayer(new ConfigMCP.Local({ type: "local", command: ["unused"], disabled: true })),
),
),
),
)
})
test("isolates nested configured MCP mutations and reconciles them", async () => {
const published: string[] = []
const config = new ConfigMCP.Remote({
type: "remote",
url: "https://example.com/mcp",
headers: { Authorization: "original" },
oauth: false,
disabled: true,
})
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const service = yield* MCP.Service
expect(published.filter((type) => type === McpEvent.StatusChanged.type)).toHaveLength(1)
yield* service.transform((draft) =>
draft.update("resources", (server) => {
if (server.type === "remote") server.headers = { Authorization: "transformed" }
}),
)
expect(config.headers?.Authorization).toBe("original")
expect(published.filter((type) => type === McpEvent.StatusChanged.type)).toHaveLength(2)
}).pipe(Effect.provide(resourceMcpLayer(config, undefined, undefined, { published }))),
),
)
})
test("reconciles only changed MCP server config", async () => {
await Effect.runPromise(
Effect.scoped(
@@ -1070,35 +1149,6 @@ test("reconciles only changed MCP server config", async () => {
)
})
test("reconciles MCP config changed during startup", async () => {
const server = new ConfigMCP.Local({ type: "local", command: ["unused"], disabled: true })
let reads = 0
const entries = () =>
Effect.sync(() => {
reads += 1
return [
new Document({
type: "document",
info: new Info({
mcp: new ConfigMCP.Info({
servers: reads === 1 ? { initial: server } : { initial: server, added: server },
}),
}),
}),
]
})
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const service = yield* MCP.Service
expect((yield* service.servers()).map((item) => String(item.name))).toEqual(["added", "initial"])
expect(reads).toBeGreaterThanOrEqual(2)
}).pipe(Effect.provide(resourceMcpLayer(server, undefined, undefined, { entries }))),
),
)
})
test("serializes concurrent MCP lifecycle operations", async () => {
await Effect.runPromise(
Effect.scoped(
+61
View File
@@ -7,6 +7,10 @@ import { Agent } from "@opencode-ai/core/agent"
import { Bus } from "@opencode-ai/core/bus"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Tool } from "@opencode-ai/core/tool"
@@ -38,6 +42,63 @@ describe("Plugin", () => {
}),
)
it.effect("routes explicit MCP locations through the plugin runtime", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const runtime = yield* PluginRuntime.Service
const target = AbsolutePath.make("/target")
const routed: string[] = []
const host = yield* PluginHost.make(plugins).pipe(
Effect.provideService(
PluginRuntime.Service,
PluginRuntime.Service.of({
...runtime,
location: {
agent: runtime.location.agent,
mcp: {
list: (ref) =>
Effect.sync(() => {
routed.push(`list:${ref.directory}`)
return {
location: new Location.Info({
directory: ref.directory,
project: {
id: Project.ID.make("project"),
directory: ref.directory,
canonical: ref.directory,
},
}),
data: [],
}
}),
add: (ref) => Effect.sync(() => routed.push(`add:${ref.directory}`)),
remove: (ref) => Effect.sync(() => routed.push(`remove:${ref.directory}`)),
connect: (ref) => Effect.sync(() => routed.push(`connect:${ref.directory}`)),
disconnect: (ref) => Effect.sync(() => routed.push(`disconnect:${ref.directory}`)),
},
},
}),
),
)
const location = { directory: target }
yield* host.mcp
.add({ location, server: "routed", config: { type: "local", command: ["unused"], disabled: true } })
.pipe(Effect.orDie)
yield* host.mcp.remove({ location, server: "routed" }).pipe(Effect.orDie)
yield* host.mcp.connect({ location, server: "routed" }).pipe(Effect.orDie)
yield* host.mcp.disconnect({ location, server: "routed" }).pipe(Effect.orDie)
expect((yield* host.mcp.list({ location }).pipe(Effect.orDie)).location.directory).toBe(target)
expect(routed).toEqual([
"add:/target",
"remove:/target",
"connect:/target",
"disconnect:/target",
"list:/target",
])
}),
)
it.effect("replaces plugins by ID and version", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
+4
View File
@@ -12,6 +12,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Form } from "@opencode-ai/core/form"
import { Integration } from "@opencode-ai/core/integration"
import { Location } from "@opencode-ai/core/location"
import { MCP } from "@opencode-ai/core/mcp/index"
import { Npm } from "@opencode-ai/util/npm"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
@@ -24,6 +25,7 @@ import { Tool } from "@opencode-ai/core/tool"
import { WebSearch } from "@opencode-ai/core/websearch"
import { Effect, Layer } from "effect"
import { tempLocationLayer } from "../fixture/location"
import { emptyMcpLayer } from "../fixture/mcp"
const npmLayer = Layer.succeed(
Npm.Service,
@@ -49,6 +51,7 @@ export const PluginTestLayer = LayerNode.compile(
Catalog.node,
Command.node,
Integration.node,
MCP.node,
PluginRuntime.node,
PluginHooks.node,
Reference.node,
@@ -63,5 +66,6 @@ export const PluginTestLayer = LayerNode.compile(
[Location.node, tempLocationLayer],
[Npm.node, npmLayer],
[Config.node, Config.testLayer()],
[MCP.node, emptyMcpLayer],
],
) as unknown as Layer.Layer<unknown, never>
+9
View File
@@ -72,6 +72,15 @@ export function host(overrides: Overrides = {}): Plugin.Context {
resolve: () => Effect.die("unused integration.connection.resolve"),
},
},
mcp: overrides.mcp ?? {
list: () => Effect.die("unused mcp.list"),
add: () => Effect.die("unused mcp.add"),
remove: () => Effect.die("unused mcp.remove"),
connect: () => Effect.die("unused mcp.connect"),
disconnect: () => Effect.die("unused mcp.disconnect"),
transform: () => Effect.die("unused mcp.transform"),
reload: () => Effect.die("unused mcp.reload"),
},
plugin: overrides.plugin ?? {
list: () => Effect.die("unused plugin.list"),
},
+1
View File
@@ -5,6 +5,7 @@ export { Command } from "@opencode-ai/schema/command"
export { Connection } from "@opencode-ai/schema/connection"
export { Credential } from "@opencode-ai/schema/credential"
export { Integration } from "@opencode-ai/schema/integration"
export { Mcp } from "@opencode-ai/schema/mcp"
export { Model } from "@opencode-ai/schema/model"
export { Provider } from "@opencode-ai/schema/provider"
export { Reference } from "@opencode-ai/schema/reference"
+17
View File
@@ -0,0 +1,17 @@
import type { McpApi } from "@opencode-ai/client/effect/api"
import type { Mcp } from "@opencode-ai/schema/mcp"
import type { Effect, Types } from "effect"
import type { Transform } from "./registration.js"
export interface MCPDraft {
list(): readonly [string, Types.DeepMutable<Mcp.ServerConfig>][]
get(name: string): Types.DeepMutable<Mcp.ServerConfig> | undefined
set(name: string, config: Mcp.ServerConfig): void
update(name: string, update: (config: Types.DeepMutable<Mcp.ServerConfig>) => void): void
remove(name: string): void
}
export interface MCPDomain extends Omit<McpApi<unknown>, "resource"> {
readonly transform: Transform<MCPDraft>
readonly reload: () => Effect.Effect<void>
}
+2
View File
@@ -8,6 +8,7 @@ import type { CatalogDomain } from "./catalog.js"
import type { CommandDomain } from "./command.js"
import type { EventDomain } from "./event.js"
import type { IntegrationDomain } from "./integration.js"
import type { MCPDomain } from "./mcp.js"
import type { ReferenceDomain } from "./reference.js"
import type { SessionDomain } from "./session.js"
import type { ShellDomain } from "./shell.js"
@@ -24,6 +25,7 @@ export interface Context {
readonly command: CommandDomain
readonly event: EventDomain
readonly integration: IntegrationDomain
readonly mcp: MCPDomain
readonly plugin: PluginApi<unknown>
readonly reference: ReferenceDomain
readonly session: SessionDomain
+10
View File
@@ -75,6 +75,7 @@ export function fromPromise(plugin: Plugin) {
const AgentEndpoints = ClientApi.groups["server.agent"].endpoints
const CommandEndpoints = ClientApi.groups["server.command"].endpoints
const IntegrationEndpoints = ClientApi.groups["server.integration"].endpoints
const McpEndpoints = ClientApi.groups["server.mcp"].endpoints
const ModelEndpoints = ClientApi.groups["server.model"].endpoints
const PluginEndpoints = ClientApi.groups["server.plugin"].endpoints
const ProviderEndpoints = ClientApi.groups["server.provider"].endpoints
@@ -235,6 +236,15 @@ export function fromPromise(plugin: Plugin) {
resolve: (connection) => Effect.runPromiseWith(context)(host.integration.connection.resolve(connection)),
},
},
mcp: {
list: adaptApiMethod(McpEndpoints["mcp.list"], host.mcp.list),
add: adaptApiMethod(McpEndpoints["mcp.add"], host.mcp.add),
remove: adaptApiMethod(McpEndpoints["mcp.remove"], host.mcp.remove),
connect: adaptApiMethod(McpEndpoints["mcp.connect"], host.mcp.connect),
disconnect: adaptApiMethod(McpEndpoints["mcp.disconnect"], host.mcp.disconnect),
transform: transform(host.mcp),
reload: () => run(host.mcp.reload()),
},
plugin: {
list: adaptApiMethod(PluginEndpoints["plugin.list"], host.plugin.list),
},
+1
View File
@@ -6,6 +6,7 @@ export { Command } from "@opencode-ai/schema/command"
export { Connection } from "@opencode-ai/schema/connection"
export { Credential } from "@opencode-ai/schema/credential"
export { Integration } from "@opencode-ai/schema/integration"
export { Mcp } from "@opencode-ai/schema/mcp"
export { Model } from "@opencode-ai/schema/model"
export { Provider } from "@opencode-ai/schema/provider"
export { Reference } from "@opencode-ai/schema/reference"
+17
View File
@@ -0,0 +1,17 @@
import type { McpApi } from "@opencode-ai/client/promise/api"
import type { Mcp } from "@opencode-ai/schema/mcp"
import type { Transform } from "./registration.js"
import type { DeepMutable } from "./types.js"
export interface MCPDraft {
list(): readonly [string, DeepMutable<Mcp.ServerConfig>][]
get(name: string): DeepMutable<Mcp.ServerConfig> | undefined
set(name: string, config: Mcp.ServerConfig): void
update(name: string, update: (config: DeepMutable<Mcp.ServerConfig>) => void): void
remove(name: string): void
}
export interface MCPDomain extends Omit<McpApi, "resource"> {
readonly transform: Transform<MCPDraft>
readonly reload: () => Promise<void>
}
+2
View File
@@ -7,6 +7,7 @@ import type { CatalogDomain } from "./catalog.js"
import type { CommandDomain } from "./command.js"
import type { EventDomain } from "./event.js"
import type { IntegrationDomain } from "./integration.js"
import type { MCPDomain } from "./mcp.js"
import type { ReferenceDomain } from "./reference.js"
import type { SessionDomain } from "./session.js"
import type { ShellDomain } from "./shell.js"
@@ -23,6 +24,7 @@ export interface Context {
readonly command: CommandDomain
readonly event: EventDomain
readonly integration: IntegrationDomain
readonly mcp: MCPDomain
readonly plugin: PluginApi
readonly reference: ReferenceDomain
readonly session: SessionDomain
@@ -4,6 +4,7 @@ import { Command } from "@opencode-ai/schema/command"
import { Connection } from "@opencode-ai/schema/connection"
import { Credential } from "@opencode-ai/schema/credential"
import { Integration } from "@opencode-ai/schema/integration"
import { Mcp } from "@opencode-ai/schema/mcp"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { Reference } from "@opencode-ai/schema/reference"
@@ -23,6 +24,7 @@ test.each([
expect(entrypoint.Connection).toBe(Connection)
expect(entrypoint.Credential).toBe(Credential)
expect(entrypoint.Integration).toBe(Integration)
expect(entrypoint.Mcp).toBe(Mcp)
expect(entrypoint.Model).toBe(Model)
expect(entrypoint.Provider).toBe(Provider)
expect(entrypoint.Reference).toBe(Reference)
@@ -34,6 +36,7 @@ test.each([
"Connection",
"Credential",
"Integration",
"Mcp",
"Model",
"Plugin",
"Provider",