diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index 331e802068a..3621683d257 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -13,6 +13,7 @@ import { Provider } from "../../provider.js" import { WebSearch } from "../../websearch.js" import { ConfigPolicy } from "@opencode/schema/config/policy" import { ConfigProvider } from "@opencode/schema/config/provider" +import { Mcp } from "@opencode/schema/mcp" import { Money } from "@opencode/schema/money" const defaultServer = "https://opencode.ai/console" @@ -23,6 +24,15 @@ const RemoteResponse = Schema.Struct({ websearch: Schema.Struct({ providerID: WebSearch.ID, }).pipe(Schema.optional), + // MCP servers by name, in the same shape as a remote server in local config. Only remote servers are + // accepted so the Console can never make the client run a command. `auth: "console"` asks the client + // to attach its own Console credential to that server's requests. + mcp: Schema.Struct({ + servers: Schema.Record( + Schema.String, + Schema.Struct({ ...Mcp.RemoteConfig.fields, auth: Schema.Literal("console").pipe(Schema.optional) }), + ), + }).pipe(Schema.optional), // Organization policy compiled for the authenticated caller; omitted when there is none. experimental: Schema.Struct({ policies: Schema.Array(ConfigPolicy.Info).pipe(Schema.optional), @@ -134,19 +144,25 @@ export const OpencodePlugin = define["servers"]; headers: Record } + | undefined + } = { config: undefined, connection: undefined, organization: undefined, mcp: undefined } const load = Effect.fn("OpencodePlugin.load")(function* () { const connection = yield* ctx.integration.connection.active("opencode") - if (!connection) return { config: undefined, connection, organization: undefined } + if (!connection) return { config: undefined, connection, organization: undefined, mcp: undefined } return yield* ctx.integration.connection.resolve(connection).pipe( Effect.flatMap((credential) => { - if (!credential) return Effect.succeed({ config: undefined, connection, organization: undefined }) + if (!credential) + return Effect.succeed({ config: undefined, connection, organization: undefined, mcp: undefined }) return fetchConfig(http, credential).pipe( Effect.map((config) => ({ config, connection, organization: typeof credential.metadata?.orgName === "string" ? credential.metadata.orgName : undefined, + mcp: config?.mcp && { servers: config.mcp.servers, headers: credentialHeaders(credential) }, })), ) }), @@ -156,8 +172,8 @@ export const OpencodePlugin = define { + const mcp = snapshot.mcp + if (!mcp) return + for (const [name, server] of Object.entries(mcp.servers)) { + // A server the user configured under the same name wins. + if (editor.get(name)) continue + const { auth, ...config } = server + editor.set(name, auth === "console" ? { ...config, headers: { ...config.headers, ...mcp.headers } } : config) + } + }) + const apply = Effect.fn("OpencodePlugin.apply")(function* (next: typeof snapshot) { snapshot = next yield* publish(next) - yield* Effect.all([ctx.provider.reload(), ctx.websearch.reload()], { concurrency: 2, discard: true }) + yield* Effect.all([ctx.provider.reload(), ctx.websearch.reload(), ctx.mcp.reload()], { + concurrency: 3, + discard: true, + }) }) const refresh = () => loading.withPermit(load().pipe(Effect.andThen(apply))) yield* bus.subscribe(Credential.Event.Switched).pipe( @@ -364,15 +394,11 @@ export const OpencodePlugin = define { + const orgID = value.metadata?.orgID + return { + authorization: `Bearer ${value.type === "oauth" ? value.access : value.key}`, + ...(typeof orgID === "string" ? { "x-org-id": orgID } : {}), + } +} + function serverUrl(value: Credential.Value) { return typeof value.metadata?.server === "string" ? value.metadata.server : defaultServer } diff --git a/packages/core/test/fixture/mcp.ts b/packages/core/test/fixture/mcp.ts index 54d36bb4f20..1ecc8517be1 100644 --- a/packages/core/test/fixture/mcp.ts +++ b/packages/core/test/fixture/mcp.ts @@ -5,26 +5,26 @@ import { Mcp } from "@opencode/core/mcp/index" import { AbsolutePath } from "@opencode/core/schema" 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"), - disconnect: () => Effect.die("unused mcp.disconnect"), - remove: () => Effect.die("unused mcp.remove"), - tools: () => Effect.succeed([]), - callTool: () => Effect.die("unused mcp.callTool"), - instructions: () => Effect.succeed([]), - prompts: () => Effect.succeed([]), - prompt: () => Effect.undefined, - resourceCatalog: () => Effect.succeed(Mcp.ResourceCatalog.make({ resources: [], templates: [] })), - resources: () => Effect.succeed(Mcp.ResourceCatalog.make({ resources: [], templates: [] })), - readResource: () => Effect.undefined, - }), -) +// Plugins may register MCP transforms at startup; with no servers there is nothing to rebuild. +export const emptyMcp = Mcp.Service.of({ + transform: () => Effect.succeed({ dispose: Effect.void }), + reload: () => Effect.void, + servers: () => Effect.succeed([]), + add: () => Effect.die("unused mcp.add"), + connect: () => Effect.die("unused mcp.connect"), + disconnect: () => Effect.die("unused mcp.disconnect"), + remove: () => Effect.die("unused mcp.remove"), + tools: () => Effect.succeed([]), + callTool: () => Effect.die("unused mcp.callTool"), + instructions: () => Effect.succeed([]), + prompts: () => Effect.succeed([]), + prompt: () => Effect.undefined, + resourceCatalog: () => Effect.succeed(Mcp.ResourceCatalog.make({ resources: [], templates: [] })), + resources: () => Effect.succeed(Mcp.ResourceCatalog.make({ resources: [], templates: [] })), + readResource: () => Effect.undefined, +}) + +export const emptyMcpLayer = Layer.succeed(Mcp.Service, emptyMcp) export const emptyConfigLayer = Config.testLayer() diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 180390bfb4b..370178a08ce 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -12,6 +12,7 @@ import { ConfigPolicyPlugin } from "@opencode/core/config/plugin/policy" import { Credential } from "@opencode/core/credential" import { Integration } from "@opencode/core/integration" import { ManagedPolicy } from "@opencode/core/managed-policy" +import { Mcp } from "@opencode/core/mcp/index" import { Model } from "@opencode/core/model" import { ModelResolver } from "@opencode/core/model-resolver" import { Plugin } from "@opencode/core/plugin" @@ -20,6 +21,7 @@ import { OpencodePlugin } from "@opencode/core/plugin/provider/opencode" import { Provider } from "@opencode/core/provider" import { WebSearch } from "@opencode/core/websearch" import { withEnv } from "../fixture/env" +import { emptyMcp } from "../fixture/mcp" import { drain } from "../lib/clock" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "./fixture" @@ -642,6 +644,110 @@ describe("OpencodePlugin", () => { ), ) + it.effect("registers the Console's MCP servers as sent, attaching the credential only where asked", () => + Effect.acquireUseRelease( + Effect.sync(() => { + const state = { advertised: true } + const server = Bun.serve({ + port: 0, + fetch: (request) => + Response.json({ + providers: {}, + ...(state.advertised + ? { + mcp: { + servers: { + opencode_console: { + type: "remote", + url: `${new URL(request.url).origin}/console/mcp`, + headers: { "x-client": "opencode", authorization: "Bearer forged" }, + oauth: false, + auth: "console", + }, + opencode_console_oauth: { + type: "remote", + url: `${new URL(request.url).origin}/console/oauth/mcp`, + oauth: { scope: "workspace" }, + timeout: { startup: 5000 }, + }, + }, + }, + } + : {}), + }), + }) + return { server, state } + }), + ({ server, state }) => + Effect.gen(function* () { + const credentials = yield* Credential.Service + const transforms: Array<(editor: Mcp.Editor) => void> = [] + const reloads = { count: 0 } + const servers = () => { + const configured = new Map() + transforms.forEach((transform) => + transform({ + list: () => [], + get: (name) => (configured.has(name) ? { type: "remote", url: "user" } : undefined), + set: (name, config) => configured.set(name, config), + update: () => {}, + remove: (name) => configured.delete(name), + }), + ) + return Object.fromEntries(configured) + } + yield* credentials.create({ + integrationID: Integration.ID.make("opencode"), + value: Credential.Key.make({ + type: "key", + key: "secret", + metadata: { server: server.url.origin, orgID: "org-a" }, + }), + }) + yield* addPlugin().pipe( + Effect.provideService( + Mcp.Service, + Mcp.Service.of({ + ...emptyMcp, + transform: (transform) => + Effect.sync(() => { + transforms.push(transform) + return { dispose: Effect.void } + }), + reload: () => + Effect.sync(() => { + reloads.count++ + }), + }), + ), + ) + yield* drain + + expect(servers()).toEqual({ + opencode_console: { + type: "remote", + url: `${server.url.origin}/console/mcp`, + headers: { "x-client": "opencode", authorization: "Bearer secret", "x-org-id": "org-a" }, + oauth: false, + }, + opencode_console_oauth: { + type: "remote", + url: `${server.url.origin}/console/oauth/mcp`, + oauth: { scope: "workspace" }, + timeout: { startup: 5000 }, + }, + }) + + state.advertised = false + yield* TestClock.adjust("1 minute") + yield* drain + expect(servers()).toEqual({}) + expect(reloads.count).toBe(1) + }), + ({ server }) => Effect.promise(() => server.stop(true)), + ), + ) + it.effect("enforces organization policy statements from the Console", () => Effect.acquireUseRelease( Effect.sync(() =>