feat(core): register Console-hosted MCP servers (#51325)

This commit is contained in:
Victor Navarro
2026-09-25 13:16:04 +02:00
committed by GitHub
parent 7de6b3fc15
commit 6cd938e1e9
3 changed files with 171 additions and 31 deletions
+45 -11
View File
@@ -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<HttpClient.HttpClient | Bus.Service | Manag
config: typeof RemoteResponse.Type | undefined
connection: ActiveConnection
organization: string | undefined
} = { config: undefined, connection: undefined, organization: undefined }
// Console MCP servers carry the credential in their headers, so a rotated token changes the snapshot.
mcp:
| { servers: NonNullable<typeof RemoteResponse.Type.mcp>["servers"]; headers: Record<string, string> }
| 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<HttpClient.HttpClient | Bus.Service | Manag
// would lift organization policy while personal credentials keep working.
Effect.as(
IntegrationConnection.key(connection) === IntegrationConnection.key(snapshot.connection)
? { config: snapshot.config, connection, organization: snapshot.organization }
: { config: undefined, connection, organization: undefined },
? { config: snapshot.config, connection, organization: snapshot.organization, mcp: snapshot.mcp }
: { config: undefined, connection, organization: undefined, mcp: undefined },
),
),
),
@@ -337,10 +353,24 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Manag
editor.default.set(descriptor.providerID)
})
yield* ctx.mcp.transform((editor) => {
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<HttpClient.HttpClient | Bus.Service | Manag
})
function fetchConfig(http: HttpClient.HttpClient, value: Credential.Value) {
const metadata = value.metadata
const orgID = typeof metadata?.orgID === "string" ? metadata.orgID : undefined
const token = value.type === "oauth" ? value.access : value.key
return http
.execute(
HttpClientRequest.get(`${serverUrl(value)}/api/v2/config`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(token),
HttpClientRequest.setHeaders(orgID ? { "x-org-id": orgID } : {}),
HttpClientRequest.setHeaders(credentialHeaders(value)),
),
)
.pipe(
@@ -385,6 +411,14 @@ function fetchConfig(http: HttpClient.HttpClient, value: Credential.Value) {
)
}
function credentialHeaders(value: Credential.Value): Record<string, string> {
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
}
+20 -20
View File
@@ -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()
@@ -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<string, unknown>()
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(() =>