From 006a0a4b340b76495e975c2222f1bdc8d6d142b4 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:17:48 -0500 Subject: [PATCH] feat(plugin): expose MCP server transforms (#43125) --- packages/client/src/promise/api.ts | 1 + packages/core/src/config/plugin/mcp.ts | 57 +++++ packages/core/src/mcp/index.ts | 212 ++++++++++-------- packages/core/src/plugin.ts | 2 + packages/core/src/plugin/host.ts | 41 ++++ packages/core/src/plugin/internal.ts | 6 + packages/core/src/plugin/runtime.ts | 41 ++++ packages/core/test/fixture/mcp.ts | 2 + packages/core/test/location-layer.test.ts | 58 +++++ packages/core/test/mcp.test.ts | 110 ++++++--- packages/core/test/plugin.test.ts | 61 +++++ packages/core/test/plugin/fixture.ts | 4 + packages/core/test/plugin/host.ts | 9 + packages/plugin/src/effect/index.ts | 1 + packages/plugin/src/effect/mcp.ts | 17 ++ packages/plugin/src/effect/plugin.ts | 2 + packages/plugin/src/promise/adapter.ts | 10 + packages/plugin/src/promise/index.ts | 1 + packages/plugin/src/promise/mcp.ts | 17 ++ packages/plugin/src/promise/plugin.ts | 2 + .../plugin/test/contract-identity.test.ts | 3 + 21 files changed, 529 insertions(+), 128 deletions(-) create mode 100644 packages/core/src/config/plugin/mcp.ts create mode 100644 packages/plugin/src/effect/mcp.ts create mode 100644 packages/plugin/src/promise/mcp.ts diff --git a/packages/client/src/promise/api.ts b/packages/client/src/promise/api.ts index c682ad16f6..65c3d53064 100644 --- a/packages/client/src/promise/api.ts +++ b/packages/client/src/promise/api.ts @@ -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"] diff --git a/packages/core/src/config/plugin/mcp.ts b/packages/core/src/config/plugin/mcp.ts new file mode 100644 index 0000000000..caaa7a0887 --- /dev/null +++ b/packages/core/src/config/plugin/mcp.ts @@ -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() + 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) + } + }) +}) diff --git a/packages/core/src/mcp/index.ts b/packages/core/src/mcp/index.ts index dca10bdd4c..ba8aae073c 100644 --- a/packages/core/src/mcp/index.ts +++ b/packages/core/src/mcp/index.ts @@ -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()("MCP.Tool }) {} type ServerEntry = { - readonly config: typeof ConfigMCP.Server.Type + readonly config: Mcp.ServerConfig status: Status readonly startup: Deferred.Deferred 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> + removed: Set +} + +export type Draft = { + list: () => readonly [ServerName, Types.DeepMutable][] + get: (server: ServerName | string) => Types.DeepMutable | undefined + set: (server: ServerName | string, config: Mcp.ServerConfig) => void + update: (server: ServerName | string, update: (config: Types.DeepMutable) => void) => void + remove: (server: ServerName | string) => void +} + +const cloneConfig = (config: Mcp.ServerConfig) => + structuredClone(config) as Types.DeepMutable + +export interface Interface extends State.Transformable { readonly servers: () => Effect.Effect - readonly add: (server: ServerName | string, config: typeof ConfigMCP.Server.Type) => Effect.Effect + readonly add: (server: ServerName | string, config: Mcp.ServerConfig) => Effect.Effect readonly connect: (server: ServerName | string) => Effect.Effect readonly disconnect: (server: ServerName | string) => Effect.Effect readonly remove: (server: ServerName | string) => Effect.Effect @@ -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() - 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() - 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() + // Materialized definitions and live connections are kept separate so operational additions + // survive unrelated definition reloads. + const entries = new Map() // 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() - const reloadLock = Semaphore.makeUnsafe(1) const urlElicitations = new Map() - for (const [name, server] of initial.servers) { - runtime.set(name, { - config: server, - status: { status: "pending" }, - startup: Deferred.makeUnsafe(), - }) - } // 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(), } - 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 | undefined + const overrides = new Map() + 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(), + }) + } + 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({ + 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], }) } diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index 031fe112a8..eaf250008a 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -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, diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index d431d2233f..288a7d2909 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -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()), }, diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index be038529b8..b2eefa8a9f 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -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 const pre = [ + ConfigMCPPlugin.Plugin, WellKnownPlugin.Plugin, AgentPlugin.Plugin, PlanPlugin.Plugin, diff --git a/packages/core/src/plugin/runtime.ts b/packages/core/src/plugin/runtime.ts index 373cf3d095..1e5a2e5d06 100644 --- a/packages/core/src/plugin/runtime.ts +++ b/packages/core/src/plugin/runtime.ts @@ -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 + readonly remove: (ref: Location.Ref, server: string) => Effect.Effect + readonly connect: (ref: Location.Ref, server: string) => Effect.Effect + readonly disconnect: (ref: Location.Ref, server: string) => Effect.Effect + } } } @@ -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 diff --git a/packages/core/test/fixture/mcp.ts b/packages/core/test/fixture/mcp.ts index 3fc50105ca..e22318facc 100644 --- a/packages/core/test/fixture/mcp.ts +++ b/packages/core/test/fixture/mcp.ts @@ -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"), diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 75a988dcf0..8768401e26 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -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 = {} + 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) })), + ), + ) + }), + ), + ), + ) }) diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index 39c8bfe321..7515144b14 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -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 + 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 + 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( diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index 83a8dbc01c..9277d44b98 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -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 diff --git a/packages/core/test/plugin/fixture.ts b/packages/core/test/plugin/fixture.ts index ba7c33d515..0f1ba64004 100644 --- a/packages/core/test/plugin/fixture.ts +++ b/packages/core/test/plugin/fixture.ts @@ -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 diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 3be39a0938..f6d5920fd4 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -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"), }, diff --git a/packages/plugin/src/effect/index.ts b/packages/plugin/src/effect/index.ts index 09c46955a1..3e060cfd4e 100644 --- a/packages/plugin/src/effect/index.ts +++ b/packages/plugin/src/effect/index.ts @@ -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" diff --git a/packages/plugin/src/effect/mcp.ts b/packages/plugin/src/effect/mcp.ts new file mode 100644 index 0000000000..c45af26c55 --- /dev/null +++ b/packages/plugin/src/effect/mcp.ts @@ -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][] + get(name: string): Types.DeepMutable | undefined + set(name: string, config: Mcp.ServerConfig): void + update(name: string, update: (config: Types.DeepMutable) => void): void + remove(name: string): void +} + +export interface MCPDomain extends Omit, "resource"> { + readonly transform: Transform + readonly reload: () => Effect.Effect +} diff --git a/packages/plugin/src/effect/plugin.ts b/packages/plugin/src/effect/plugin.ts index 4e9d2399e4..78c630083d 100644 --- a/packages/plugin/src/effect/plugin.ts +++ b/packages/plugin/src/effect/plugin.ts @@ -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 readonly reference: ReferenceDomain readonly session: SessionDomain diff --git a/packages/plugin/src/promise/adapter.ts b/packages/plugin/src/promise/adapter.ts index 9ed8a6d719..3233305683 100644 --- a/packages/plugin/src/promise/adapter.ts +++ b/packages/plugin/src/promise/adapter.ts @@ -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), }, diff --git a/packages/plugin/src/promise/index.ts b/packages/plugin/src/promise/index.ts index 786d150bb5..61472eb4f9 100644 --- a/packages/plugin/src/promise/index.ts +++ b/packages/plugin/src/promise/index.ts @@ -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" diff --git a/packages/plugin/src/promise/mcp.ts b/packages/plugin/src/promise/mcp.ts new file mode 100644 index 0000000000..8af74e7c43 --- /dev/null +++ b/packages/plugin/src/promise/mcp.ts @@ -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][] + get(name: string): DeepMutable | undefined + set(name: string, config: Mcp.ServerConfig): void + update(name: string, update: (config: DeepMutable) => void): void + remove(name: string): void +} + +export interface MCPDomain extends Omit { + readonly transform: Transform + readonly reload: () => Promise +} diff --git a/packages/plugin/src/promise/plugin.ts b/packages/plugin/src/promise/plugin.ts index 5fb97a9cb8..9a05f1c460 100644 --- a/packages/plugin/src/promise/plugin.ts +++ b/packages/plugin/src/promise/plugin.ts @@ -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 diff --git a/packages/plugin/test/contract-identity.test.ts b/packages/plugin/test/contract-identity.test.ts index f93d70b48d..322ab0ce63 100644 --- a/packages/plugin/test/contract-identity.test.ts +++ b/packages/plugin/test/contract-identity.test.ts @@ -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",