Compare commits

..

1 Commits

Author SHA1 Message Date
Aiden Cline 945e73c9b7 fix(core): keep nested MCP code mode tools direct 2026-08-17 03:51:01 +00:00
7 changed files with 125 additions and 14 deletions
@@ -14,7 +14,7 @@ await Effect.runPromise(
const response = yield* Effect.promise(() =>
fetch(new URL("/api/health", endpoint.url), { headers: Service.headers(endpoint) }),
)
console.log(`STANDALONE_READY ${endpoint.pid} ${endpoint.url} ${response.status}`)
console.log(`${endpoint.pid} ${endpoint.url} ${response.status}`)
return yield* Effect.never
}),
),
+7 -10
View File
@@ -9,11 +9,8 @@ test("standalone server exits when its owner is killed", async () => {
stdout: "pipe",
stderr: "pipe",
})
const line = await Promise.race([
readLine(owner.stdout, "STANDALONE_READY "),
Bun.sleep(10_000).then(() => undefined),
])
const [, rawPID, url, status] = line?.split(" ") ?? []
const line = await Promise.race([readLine(owner.stdout), Bun.sleep(10_000).then(() => undefined)])
const [rawPID, url, status] = line?.split(" ") ?? []
const pid = Number(rawPID)
try {
@@ -32,7 +29,7 @@ test("standalone server exits when its owner is killed", async () => {
}
})
async function readLine(stream: ReadableStream<Uint8Array>, prefix: string) {
async function readLine(stream: ReadableStream<Uint8Array>) {
const reader = stream.getReader()
const decoder = new TextDecoder()
const chunks: string[] = []
@@ -41,14 +38,14 @@ async function readLine(stream: ReadableStream<Uint8Array>, prefix: string) {
if (result.done) break
chunks.push(decoder.decode(result.value, { stream: true }))
const output = chunks.join("")
const line = output.split("\n").find((line) => line.startsWith(prefix))
if (line) {
const newline = output.indexOf("\n")
if (newline !== -1) {
reader.releaseLock()
return line
return output.slice(0, newline)
}
}
reader.releaseLock()
return (chunks.join("") + decoder.decode()).split("\n").find((line) => line.startsWith(prefix))
return chunks.join("") + decoder.decode()
}
async function waitForExit(pid: number, attempts = 100): Promise<boolean> {
+5
View File
@@ -63,6 +63,7 @@ import { AgentPlugin } from "./agent.js"
import { CommandPlugin } from "./command.js"
import { PlanPlugin } from "./plan.js"
import { ModelsDevPlugin } from "./models-dev.js"
import { McpCodeModePlugin } from "./mcp-codemode.js"
import { ProviderPlugins } from "./provider.js"
import { WebSearchPlugins } from "./websearch/index.js"
import { PluginRuntime } from "./runtime.js"
@@ -94,6 +95,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const location = yield* Location.Service
const locationMutation = yield* LocationMutation.Service
const models = yield* ModelsDev.Service
const mcpCodeMode = yield* McpCodeModePlugin.Service
const npm = yield* Npm.Service
const permission = yield* Permission.Service
const runtime = yield* PluginRuntime.Service
@@ -131,6 +133,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(McpCodeModePlugin.Service, mcpCodeMode),
Context.make(Npm.Service, npm),
Context.make(Permission.Service, permission),
Context.make(PluginRuntime.Service, runtime),
@@ -175,6 +178,7 @@ export const requirements = LayerNode.group([
Location.node,
LocationMutation.node,
ModelsDev.node,
McpCodeModePlugin.node,
Npm.node,
Permission.node,
PluginRuntime.node,
@@ -202,6 +206,7 @@ const pre = [
SkillPlugin.Plugin,
...SystemPromptPlugin.Plugins,
ModelsDevPlugin,
McpCodeModePlugin.Plugin,
...ProviderPlugins,
...WebSearchPlugins,
PatchTool.Plugin,
+51
View File
@@ -0,0 +1,51 @@
export * as McpCodeModePlugin from "./mcp-codemode.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Scope } from "effect"
const directToolHosts = new Set(["mcp.cloudflare.com"])
type Resolver = (config: typeof ConfigMCP.Server.Type) => boolean | undefined
export interface Interface {
register: (resolver: Resolver) => Effect.Effect<void, never, Scope.Scope>
resolve: (config: typeof ConfigMCP.Server.Type) => boolean | undefined
}
export class Service extends Context.Service<Service, Interface>()("@opencode/McpCodeModePlugin") {}
export const layer = Layer.effect(
Service,
Effect.sync(() => {
let resolvers: Resolver[] = []
return Service.of({
register: Effect.fn("McpCodeModePlugin.register")(function* (resolver) {
resolvers = [...resolvers, resolver]
yield* Effect.addFinalizer(() => Effect.sync(() => (resolvers = resolvers.filter((item) => item !== resolver))))
}),
resolve: (config) =>
resolvers
.toReversed()
.map((resolver) => resolver(config))
.find((value) => value !== undefined),
})
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [] })
export const Plugin = define({
id: "opencode.mcp.codemode-compatibility",
effect: Effect.fn(function* () {
const defaults = yield* Service
yield* defaults.register(codeModeCompatibilityDefault)
}),
})
export function codeModeCompatibilityDefault(config: typeof ConfigMCP.Server.Type) {
if (config.type !== "remote") return
const url = URL.parse(config.url)
if (!url || !directToolHosts.has(url.hostname.toLowerCase())) return
return false
}
+19 -3
View File
@@ -2,12 +2,17 @@ export * as McpTool from "./mcp.js"
import { ToolFailure } from "@opencode-ai/ai"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { Document } from "@opencode-ai/schema/config"
import { Context, Effect, Exit, Fiber, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "../bus.js"
import { MCP } from "../mcp/index.js"
import { Config } from "../config.js"
import { Permission } from "../permission.js"
import { Plugin } from "../plugin.js"
import { McpCodeModePlugin } from "../plugin/mcp-codemode.js"
import { PluginSupervisor } from "../plugin/supervisor.js"
import { Tool } from "../tool.js"
/**
@@ -27,6 +32,8 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const mcp = yield* MCP.Service
const config = yield* Config.Service
const codemode = yield* McpCodeModePlugin.Service
const tools = yield* Tool.Service
const bus = yield* Bus.Service
const permission = yield* Permission.Service
@@ -39,14 +46,23 @@ export const layer = Layer.effect(
const reconcile = lock.withPermit(
Effect.gen(function* () {
const discovered = yield* mcp.tools()
const servers = new Map(
(yield* config.entries())
.filter((entry): entry is Document => entry.type === "document")
.flatMap((entry) => Object.entries(entry.info.mcp?.servers ?? {})),
)
const next = yield* Scope.fork(scope)
yield* tools
.transform((draft) => {
for (const tool of discovered) {
const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema
const server = servers.get(tool.server)
draft.add({
name: tool.name,
options: { namespace: namespace(tool.server), codemode: tool.codemode !== false },
options: {
namespace: namespace(tool.server),
codemode: (tool.codemode ?? (server ? codemode.resolve(server) : undefined)) !== false,
},
description: tool.description ?? "",
input: {
...schema,
@@ -122,7 +138,7 @@ export const layer = Layer.effect(
)
const initial = yield* reconcile.pipe(Effect.forkScoped)
yield* bus.subscribe(McpEvent.ToolsChanged).pipe(
yield* bus.subscribe([McpEvent.ToolsChanged, Plugin.Event.Updated]).pipe(
Stream.runForEach(() => reconcile),
Effect.forkScoped({ startImmediately: true }),
)
@@ -133,5 +149,5 @@ export const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [Tool.node, MCP.node, Bus.node, Permission.node],
deps: [Tool.node, MCP.node, McpCodeModePlugin.node, Config.node, Bus.node, Permission.node, PluginSupervisor.node],
})
+6
View File
@@ -29,6 +29,8 @@ import { MCP } from "@opencode-ai/core/mcp/index"
import { MCPClient } from "@opencode-ai/core/mcp/client"
import { MCPStdio } from "@opencode-ai/core/mcp/stdio"
import { Permission } from "@opencode-ai/core/permission"
import { McpCodeModePlugin } from "@opencode-ai/core/plugin/mcp-codemode"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { McpTool } from "@opencode-ai/core/tool/mcp"
@@ -336,12 +338,16 @@ const permissions = Layer.mock(Permission.Service, {
}),
})
const events = Layer.mock(Bus.Service, { subscribe: () => Stream.never })
const plugins = Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void }))
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node]), [
[MCP.node, mcp],
[McpCodeModePlugin.node, McpCodeModePlugin.layer],
[Config.node, Config.testLayer()],
[Permission.node, permissions],
[Bus.node, events],
[Image.node, imagePassthrough],
[PluginSupervisor.node, plugins],
]),
)
@@ -0,0 +1,36 @@
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import { codeModeCompatibilityDefault, McpCodeModePlugin } from "@opencode-ai/core/plugin/mcp-codemode"
import { describe, expect, it } from "bun:test"
import { Effect } from "effect"
describe("MCP Code Mode compatibility defaults", () => {
it("keeps Cloudflare's Code Mode MCP server direct by default", () => {
const config = new ConfigMCP.Remote({ type: "remote", url: "https://mcp.cloudflare.com/mcp" })
expect(codeModeCompatibilityDefault(config)).toBe(false)
})
it("does not change Cloudflare's product-specific MCP servers", () => {
const config = new ConfigMCP.Remote({ type: "remote", url: "https://docs.mcp.cloudflare.com/mcp" })
expect(codeModeCompatibilityDefault(config)).toBeUndefined()
})
it("does not change local or unrelated remote servers", () => {
const local = new ConfigMCP.Local({ type: "local", command: ["server"] })
const remote = new ConfigMCP.Remote({ type: "remote", url: "https://example.com/mcp" })
expect(codeModeCompatibilityDefault(local)).toBeUndefined()
expect(codeModeCompatibilityDefault(remote)).toBeUndefined()
})
it("retains a registered direct-tool default", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const defaults = yield* McpCodeModePlugin.Service
yield* defaults.register(codeModeCompatibilityDefault)
const config = new ConfigMCP.Remote({ type: "remote", url: "https://mcp.cloudflare.com/mcp" })
expect(defaults.resolve(config)).toBe(false)
}).pipe(Effect.provide(McpCodeModePlugin.layer)),
),
)
})
})