mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
feat(core): register Console-hosted MCP servers (#51325)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(() =>
|
||||
|
||||
Reference in New Issue
Block a user