mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-06 08:56:22 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a931cb4d2 |
@@ -0,0 +1,34 @@
|
||||
# Make MCP tool contribution a built-in plugin
|
||||
|
||||
This exploration targets the `v2` architecture at `97303c39dd`. It lets an OpenCode maintainer assess whether MCP tool contributions belong in the existing plugin lifecycle. The proposal moves discovery-to-tool adaptation into `opencode.tool.mcp`, while retaining MCP connection management and invocation in `Mcp.Service`.
|
||||
|
||||
Previously, `tool/mcp.ts` installed its transform from a Location layer after asynchronous discovery. Plugin activation neither owned that registration nor guaranteed its position before later tool customizations. The new plugin reserves its transform synchronously, then discovers tools in a scoped fiber. For example, a later plugin that removes `demo_hidden` still removes it when a slow MCP server finally announces it.
|
||||
|
||||
`PluginInternal` supplies internal services to built-ins. The plugin therefore captures `Mcp.Service`, `Permission.Service`, `Bus.Service`, and the readiness service without expanding the public plugin interface. It uses `ctx.tool.transform` and `ctx.tool.reload` for contributions, like other built-in tools.
|
||||
|
||||
The plugin owns the discovered array, one stable transform, the initial discovery fiber, and both subscription fibers. It subscribes before starting discovery, serializes initial discovery and updates with a semaphore, and retains the 100ms debounce for catalog notifications. Refresh replaces captured data and reloads State; it never appends another transform.
|
||||
|
||||
`McpTool.Service` remains as a small readiness bridge because Session context selection already waits for initial discovery before taking its tool snapshot. Its `start` operation records the active plugin's scoped fiber. `flush` observes that fiber, or completes immediately when no plugin is active. Closing the activation cancels discovery, releases existing waiters, removes the transform and subscriptions, and clears readiness. The identity check in cleanup prevents an older scope from clearing a newer wait. The plugin host serializes activation and recreates a changed suffix in order.
|
||||
|
||||
Activation does not wait for discovery. Waiting there would make every later plugin wait for slow servers; forking registration itself would lose precedence. Keeping discovery asynchronous also allows the host's State batch to finish before MCP configuration notifications reconcile connections. There is no new dependency from the readiness node back to plugin activation or MCP. Session still waits at its existing boundary, and MCP retains its connection/request timeouts.
|
||||
|
||||
Registration now belongs to the host's State failure group. A transform defect disables this plugin's contributions and closes its activation scope through the existing host supervisor. Invalid individual tool definitions retain Tool's existing isolation behavior. Initial discovery uses the previous `Fiber.await` semantics: settling the readiness wait does not propagate a discovery fiber defect. This proposal does not introduce retries or new background-failure reporting.
|
||||
|
||||
The adapter is independently selectable through the built-in plugin inventory. Disabling it removes tool contributions but keeps MCP connections, OAuth, prompts, and resources available. An existing limitation becomes more visible: `McpInstructions.load` filters against MCP's raw tools and permissions, not the effective Tool snapshot. Its guidance can mention tools removed by a plugin, including disabling this adapter. A follow-up should derive reachability from the selected snapshot; moving instruction rendering into this plugin alone would not fix arbitrary later removals.
|
||||
|
||||
The tradeoff is a small coordination service rather than a pure file move. Direct consumers that assemble `McpTool.node` must now activate the plugin explicitly, as the focused tests do. Normal Instance boot receives it through `PluginInternal`. Captured execution snapshots retain the existing behavior: their executors can outlive a registration and still delegate to the Location-owned MCP service.
|
||||
|
||||
The wrapper's schemas, namespace normalization, permission assertion, session identity forwarding, error mapping, and direct versus Code Mode behavior are unchanged. The existing MCP tests exercise those boundaries. The new `mcp-tool-plugin.test.ts` uses the real plugin host to hold discovery open, finish activation, replace the pending activation, verify cancellation and readiness, preserve later overrides/removals, refresh the catalog, disable, and reactivate without extra catalog reads or leaked contributions. Existing plugin tests cover grouped transform failures; Location tests cover the assembled boot path.
|
||||
|
||||
The broader audit compares contributions rather than directory names. A scan of runtime `.transform(` and `.register(` calls outside `plugin` and legacy `v1` code, followed by inspection of instruction producers, found these relevant boundaries:
|
||||
|
||||
| Boundary and evidence | Assessment | Recommendation |
|
||||
| --------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `tool/plugin/mcp.ts`, previously `tool/mcp.ts`: MCP catalog becomes Tool State | Same kind of scoped contribution as shipped tools, with an asynchronous source | Convert in this proposal; preserve early precedence and Session readiness |
|
||||
| `mcp/index.ts`, `register` and `connectProvider`: remote servers create Integration transforms | Genuine cross-domain contribution, but its integration ID is also the credential identity used when connecting; registration and disposal follow server scopes | Keep here for now. Extract only with an explicit connection/auth dependency contract, rather than a second observer racing connection startup |
|
||||
| `config/plugin/mcp.ts`: configuration becomes MCP State | Already a built-in plugin despite calling the internal service directly | Keep; internal injection is a supported capability, not an architectural exception |
|
||||
| `wellknown/plugin.ts`: discovery becomes Integration State | Already a plugin outside the top-level plugin directory | Keep; directory placement is not evidence of inconsistency |
|
||||
| `mcp/instructions.ts`, `reference/instructions.ts`, `skill/instructions.ts`: domain observations become Instructions values | Pull-based producers called by `session/context.ts`; they do not install competing State transforms | Keep producers with their observed domains. Address effective MCP tool reachability separately |
|
||||
| `instructions/builtins.ts`, `codemode/instructions.ts`, `session/instructions.ts` | Environment/clock observations, catalog guidance, and Session-owned context handling | Keep runtime responsibilities with their owners; a plugin move would need a separate context-contribution contract |
|
||||
|
||||
Fully plugin-provided MCP remains possible in principle, but this change does not establish that design. The public MCP plugin interface currently exposes configuration transforms, reload, and server status, while internal consumers use discovery, invocation, resources, prompts, authentication, and instructions. Externalizing connection support would require a provider contract covering those operations, ownership, cancellation, credential identity, and readiness. Internal service injection is enough for this proposal; public interface limitations do not justify keeping tool contribution outside the plugin lifecycle.
|
||||
@@ -70,6 +70,8 @@ import { ReadTool } from "../tool/plugin/read.js"
|
||||
import { ShellTool } from "../tool/plugin/shell.js"
|
||||
import { SkillTool } from "../tool/plugin/skill.js"
|
||||
import { SubagentTool } from "../tool/plugin/subagent.js"
|
||||
import { McpTool } from "../tool/mcp.js"
|
||||
import { McpToolPlugin } from "../tool/plugin/mcp.js"
|
||||
import { Tool } from "../tool.js"
|
||||
import { ToolOutput } from "../tool-output.js"
|
||||
import { WebFetchTool } from "../tool/plugin/webfetch.js"
|
||||
@@ -116,6 +118,7 @@ const services = [
|
||||
LocationMutation.Service,
|
||||
ModelsDev.Service,
|
||||
Mcp.Service,
|
||||
McpTool.Service,
|
||||
Npm.Service,
|
||||
Permission.Service,
|
||||
Form.Service,
|
||||
@@ -164,6 +167,7 @@ export const requirements = LayerNode.group([
|
||||
LocationMutation.node,
|
||||
ModelsDev.node,
|
||||
Mcp.node,
|
||||
McpTool.node,
|
||||
Npm.node,
|
||||
Permission.node,
|
||||
Form.node,
|
||||
@@ -189,6 +193,7 @@ export type InternalPlugin = Plugin<Requirements | Scope.Scope>
|
||||
|
||||
const pre = [
|
||||
ConfigMcpPlugin.Plugin,
|
||||
McpToolPlugin.Plugin,
|
||||
McpCodeModeExclusionPlugin.Plugin,
|
||||
WellKnownPlugin.Plugin,
|
||||
VcsGitPlugin.Plugin,
|
||||
|
||||
@@ -41,7 +41,7 @@ The service uses shared `State` to replay synchronous transforms in registration
|
||||
- Disposing a registration or closing its scope removes only its transform and rebuilds from the remaining transforms, revealing any earlier definition it overrode.
|
||||
- Each model request captures the effective definitions and executors it advertises; later reloads and disposal affect later snapshots. Captured executors may still reference mutable producer-owned state.
|
||||
|
||||
MCP owns one stable tool transform that reads its latest discovered tools. Tool-list changes update that source and reload the tool state instead of re-registering at the end of the transform order. MCP refresh therefore preserves the precedence of later plugin overrides.
|
||||
The built-in MCP tool plugin owns one stable tool transform that reads its latest discovered tools. Tool-list changes update that source and reload the tool state instead of re-registering at the end of the transform order. MCP refresh therefore preserves the precedence of later plugin overrides.
|
||||
|
||||
Type safety ends at registration. The registry validates model input and declared output at runtime and should not carry producer schema generics through storage or execution.
|
||||
|
||||
|
||||
+20
-127
@@ -1,147 +1,40 @@
|
||||
export * as McpTool from "./mcp.js"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
||||
import { Context, Effect, Fiber, type JsonSchema, Layer, PubSub, Semaphore, Stream } from "effect"
|
||||
import { Context, Effect, Fiber, Layer, Scope } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "../bus.js"
|
||||
|
||||
import { Mcp } from "../mcp/index.js"
|
||||
import { Permission } from "../permission.js"
|
||||
import { Tool } from "../tool.js"
|
||||
|
||||
/**
|
||||
* Registry namespace and permission action names for MCP tools.
|
||||
*/
|
||||
/** Registry namespace and permission action names for MCP tools. */
|
||||
export const namespace = (server: string) => server.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
export const name = (server: string, tool: string) => `${namespace(server)}_${tool.replace(/[^a-zA-Z0-9_-]/g, "_")}`
|
||||
|
||||
export interface Interface {
|
||||
/** Wait for the initial MCP tool registration to settle. */
|
||||
/** Wait for the active plugin's initial discovery; disabled plugins have nothing to await. */
|
||||
readonly flush: Effect.Effect<void>
|
||||
readonly start: (discovery: Effect.Effect<void>) => Effect.Effect<void, never, Scope.Scope>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/McpTool") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* Mcp.Service
|
||||
const tools = yield* Tool.Service
|
||||
const bus = yield* Bus.Service
|
||||
const permission = yield* Permission.Service
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
let discovered: Mcp.Tool[] = []
|
||||
|
||||
// Register once after initial discovery; only subsequent updates need a debounced reload.
|
||||
const initial = yield* lock
|
||||
.withPermit(
|
||||
Effect.sync(() => {
|
||||
let pending = Effect.void
|
||||
return Service.of({
|
||||
flush: Effect.suspend(() => pending),
|
||||
start: (discovery) =>
|
||||
Effect.gen(function* () {
|
||||
discovered = yield* mcp.tools()
|
||||
yield* tools.transform((editor) => {
|
||||
for (const tool of discovered) {
|
||||
const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema
|
||||
editor.add({
|
||||
name: tool.name,
|
||||
options: { namespace: namespace(tool.server), codemode: tool.codemode !== false },
|
||||
description: tool.description ?? "",
|
||||
input: {
|
||||
...schema,
|
||||
type: "object",
|
||||
properties: schema.properties ?? {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
output: (tool.outputSchema ?? {}) as JsonSchema.JsonSchema,
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* permission.assert({
|
||||
action: name(tool.server, tool.name),
|
||||
resources: ["*"],
|
||||
save: ["*"],
|
||||
metadata: {},
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: {
|
||||
type: "tool",
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
},
|
||||
})
|
||||
const result = yield* mcp
|
||||
.callTool({
|
||||
server: tool.server,
|
||||
name: tool.name,
|
||||
args: (input ?? {}) as Record<string, unknown>,
|
||||
sessionID: context.sessionID,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTags({
|
||||
"MCP.NotFoundError": (error) =>
|
||||
new ToolFailure({ message: `MCP server "${error.server}" is not available` }),
|
||||
"MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }),
|
||||
}),
|
||||
)
|
||||
if (result.isError)
|
||||
return yield* new ToolFailure({
|
||||
message:
|
||||
result.content
|
||||
.flatMap((part) => (part.type === "text" ? [part.text] : []))
|
||||
.join("\n")
|
||||
.trim() || "MCP tool returned an error",
|
||||
})
|
||||
const content = result.content.map((part) =>
|
||||
part.type === "text"
|
||||
? { type: "text" as const, text: part.text }
|
||||
: {
|
||||
type: "file" as const,
|
||||
uri: `data:${part.mimeType};base64,${part.data}`,
|
||||
mime: part.mimeType,
|
||||
},
|
||||
)
|
||||
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
|
||||
return {
|
||||
output: result.structured ?? (text === "" ? null : text),
|
||||
...(content.length === 0 ? {} : { content }),
|
||||
}
|
||||
}).pipe(
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
: new ToolFailure({ message: `Unable to execute ${name(tool.server, tool.name)}` }),
|
||||
),
|
||||
),
|
||||
})
|
||||
}
|
||||
})
|
||||
const fiber = yield* Effect.forkScoped(discovery)
|
||||
const wait = Effect.asVoid(Fiber.await(fiber))
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() => (pending = wait)),
|
||||
() =>
|
||||
Effect.sync(() => {
|
||||
if (pending === wait) pending = Effect.void
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.forkScoped)
|
||||
const reconcile = lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
discovered = yield* mcp.tools()
|
||||
yield* tools.reload()
|
||||
}),
|
||||
)
|
||||
|
||||
// Servers announce tools in bursts and each read loads the whole catalog, so settle and refresh
|
||||
// once. The bus subscription stays eager; only the already-open sliding subscription is debounced.
|
||||
const changes = yield* PubSub.sliding<void>(1)
|
||||
yield* bus.subscribe(McpEvent.ToolsChanged).pipe(
|
||||
Stream.runForEach(() => PubSub.publish(changes, undefined)),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const updates = yield* PubSub.subscribe(changes)
|
||||
yield* Stream.fromSubscription(updates).pipe(
|
||||
Stream.debounce("100 millis"),
|
||||
Stream.runForEach(() => reconcile),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
return Service.of({ flush: Effect.asVoid(Fiber.await(initial)) })
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Tool.node, Mcp.node, Bus.node, Permission.node],
|
||||
})
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [] })
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
export * as McpToolPlugin from "./mcp.js"
|
||||
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
||||
import { Effect, type JsonSchema, PubSub, Semaphore, Stream } from "effect"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { Mcp } from "../../mcp/index.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
import { McpTool } from "../mcp.js"
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.mcp",
|
||||
effect: Effect.fn("McpToolPlugin.Plugin")(function* (ctx: Context) {
|
||||
const mcp = yield* Mcp.Service
|
||||
const tools = ctx.tool
|
||||
const readiness = yield* McpTool.Service
|
||||
const bus = yield* Bus.Service
|
||||
const permission = yield* Permission.Service
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
let discovered: Mcp.Tool[] = []
|
||||
|
||||
// Reserve precedence before discovery yields so later plugins can override or remove tools.
|
||||
yield* tools.transform((editor) => {
|
||||
for (const tool of discovered) {
|
||||
const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema
|
||||
editor.add({
|
||||
name: tool.name,
|
||||
options: { namespace: McpTool.namespace(tool.server), codemode: tool.codemode !== false },
|
||||
description: tool.description ?? "",
|
||||
input: {
|
||||
...schema,
|
||||
type: "object",
|
||||
properties: schema.properties ?? {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
output: (tool.outputSchema ?? {}) as JsonSchema.JsonSchema,
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* permission.assert({
|
||||
action: McpTool.name(tool.server, tool.name),
|
||||
resources: ["*"],
|
||||
save: ["*"],
|
||||
metadata: {},
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: {
|
||||
type: "tool",
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
},
|
||||
})
|
||||
const result = yield* mcp
|
||||
.callTool({
|
||||
server: tool.server,
|
||||
name: tool.name,
|
||||
args: (input ?? {}) as Record<string, unknown>,
|
||||
sessionID: context.sessionID,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTags({
|
||||
"MCP.NotFoundError": (error) =>
|
||||
new ToolFailure({ message: `MCP server "${error.server}" is not available` }),
|
||||
"MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }),
|
||||
}),
|
||||
)
|
||||
if (result.isError)
|
||||
return yield* new ToolFailure({
|
||||
message:
|
||||
result.content
|
||||
.flatMap((part) => (part.type === "text" ? [part.text] : []))
|
||||
.join("\n")
|
||||
.trim() || "MCP tool returned an error",
|
||||
})
|
||||
const content = result.content.map((part) =>
|
||||
part.type === "text"
|
||||
? { type: "text" as const, text: part.text }
|
||||
: {
|
||||
type: "file" as const,
|
||||
uri: `data:${part.mimeType};base64,${part.data}`,
|
||||
mime: part.mimeType,
|
||||
},
|
||||
)
|
||||
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
|
||||
return {
|
||||
output: result.structured ?? (text === "" ? null : text),
|
||||
...(content.length === 0 ? {} : { content }),
|
||||
}
|
||||
}).pipe(
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
: new ToolFailure({ message: `Unable to execute ${McpTool.name(tool.server, tool.name)}` }),
|
||||
),
|
||||
),
|
||||
})
|
||||
}
|
||||
})
|
||||
const reconcile = lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
discovered = yield* mcp.tools()
|
||||
yield* tools.reload()
|
||||
}),
|
||||
)
|
||||
|
||||
// Servers announce tools in bursts and each read loads the whole catalog, so settle and refresh
|
||||
// once. The bus subscription stays eager; only the already-open sliding subscription is debounced.
|
||||
const changes = yield* PubSub.sliding<void>(1)
|
||||
yield* bus.subscribe(McpEvent.ToolsChanged).pipe(
|
||||
Stream.runForEach(() => PubSub.publish(changes, undefined)),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const updates = yield* PubSub.subscribe(changes)
|
||||
yield* Stream.fromSubscription(updates).pipe(
|
||||
Stream.debounce("100 millis"),
|
||||
Stream.runForEach(() => reconcile),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* readiness.start(
|
||||
lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
discovered = yield* mcp.tools()
|
||||
yield* tools.reload()
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Layer, Schema } from "effect"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Mcp } from "@opencode-ai/core/mcp/index"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { McpTool } from "@opencode-ai/core/tool/mcp"
|
||||
import { McpToolPlugin } from "@opencode-ai/core/tool/plugin/mcp"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { advance, drain } from "./lib/clock"
|
||||
import { toolDefinitions } from "./lib/tool"
|
||||
import { PluginTestLayer } from "./plugin/fixture"
|
||||
|
||||
const it = testEffect(Layer.merge(PluginTestLayer, McpTool.layer))
|
||||
|
||||
it.effect("owns discovery across delayed activation, replacement, and disabling", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const registry = yield* Tool.Service
|
||||
const readiness = yield* McpTool.Service
|
||||
const mcp = yield* Mcp.Service
|
||||
const bus = yield* Bus.Service
|
||||
const permission = yield* Permission.Service
|
||||
const released = yield* Deferred.make<void>()
|
||||
let reads = 0
|
||||
let completed = 0
|
||||
const definition = (revision: string): Plugin.Generation => ({
|
||||
id: McpToolPlugin.Plugin.id,
|
||||
revision,
|
||||
effect: (ctx) =>
|
||||
McpToolPlugin.Plugin.effect(ctx).pipe(
|
||||
Effect.provideService(McpTool.Service, readiness),
|
||||
Effect.provideService(Bus.Service, bus),
|
||||
Effect.provideService(Permission.Service, permission),
|
||||
Effect.provideService(Mcp.Service, {
|
||||
...mcp,
|
||||
tools: () =>
|
||||
Effect.gen(function* () {
|
||||
reads++
|
||||
yield* Deferred.await(released)
|
||||
completed++
|
||||
return ["search", "hidden"].map(
|
||||
(name) =>
|
||||
new Mcp.Tool({
|
||||
server: Mcp.ServerName.make("demo"),
|
||||
name,
|
||||
description: "discovered",
|
||||
codemode: false,
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
}),
|
||||
)
|
||||
}),
|
||||
}),
|
||||
),
|
||||
})
|
||||
const override: Plugin.Generation = {
|
||||
id: "override",
|
||||
revision: "1",
|
||||
effect: (ctx) =>
|
||||
ctx.tool
|
||||
.transform((editor) => {
|
||||
editor.add({
|
||||
name: "search",
|
||||
options: { namespace: "demo", codemode: false },
|
||||
description: "override",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.succeed({ output: "override" }),
|
||||
})
|
||||
editor.remove("demo_hidden")
|
||||
})
|
||||
.pipe(Effect.asVoid),
|
||||
}
|
||||
|
||||
// Host activation must finish while discovery is still held by a slow server.
|
||||
yield* plugins.activate([definition("1"), override])
|
||||
yield* plugins.awaitActivation
|
||||
yield* advance(() => reads === 1)
|
||||
const waiting = yield* Effect.forkScoped(readiness.flush)
|
||||
yield* drain
|
||||
expect(waiting.pollUnsafe()).toBeUndefined()
|
||||
|
||||
// Replace an activation while its discovery and a session waiter are pending.
|
||||
yield* plugins.activate([definition("2"), override])
|
||||
yield* Fiber.join(waiting)
|
||||
yield* advance(() => reads === 2)
|
||||
yield* Deferred.succeed(released, undefined)
|
||||
yield* readiness.flush
|
||||
expect(completed).toBe(1)
|
||||
const snapshot = yield* toolDefinitions(registry)
|
||||
expect(snapshot.find((tool) => tool.name === "demo_search")?.description).toBe("override")
|
||||
expect(snapshot.some((tool) => tool.name === "demo_hidden")).toBe(false)
|
||||
|
||||
yield* bus.publish(McpEvent.ToolsChanged, { server: "demo" })
|
||||
yield* advance(() => reads === 3)
|
||||
yield* drain
|
||||
expect(reads).toBe(3)
|
||||
expect((yield* toolDefinitions(registry)).find((tool) => tool.name === "demo_search")?.description).toBe("override")
|
||||
|
||||
yield* plugins.activate([])
|
||||
yield* readiness.flush
|
||||
expect((yield* toolDefinitions(registry)).some((tool) => tool.name.startsWith("demo_"))).toBe(false)
|
||||
yield* bus.publish(McpEvent.ToolsChanged, { server: "demo" })
|
||||
yield* drain
|
||||
expect(reads).toBe(3)
|
||||
|
||||
yield* plugins.activate([definition("3")])
|
||||
yield* readiness.flush
|
||||
expect(reads).toBe(4)
|
||||
expect((yield* toolDefinitions(registry)).filter((tool) => tool.name.startsWith("demo_")).length).toBe(2)
|
||||
yield* bus.publish(McpEvent.ToolsChanged, { server: "demo" })
|
||||
yield* advance(() => reads === 5)
|
||||
yield* drain
|
||||
expect(reads).toBe(5)
|
||||
}),
|
||||
)
|
||||
@@ -35,6 +35,7 @@ import { Permission } from "@opencode-ai/core/permission"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { McpToolPlugin } from "@opencode-ai/core/tool/plugin/mcp"
|
||||
import { McpTool } from "@opencode-ai/core/tool/mcp"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import {
|
||||
@@ -62,7 +63,14 @@ import { imagePassthrough } from "./lib/image"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdirScoped } from "./fixture/tmpdir"
|
||||
import { hostEnvironmentLayer, recordingEnvironmentLayer } from "./fixture/environment"
|
||||
import { codeModeListings, executeTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
|
||||
import {
|
||||
codeModeListings,
|
||||
registerToolPlugin,
|
||||
executeTool,
|
||||
toolDefinitions,
|
||||
toolIdentity,
|
||||
waitForTool,
|
||||
} from "./lib/tool"
|
||||
|
||||
let assertion: Deferred.Deferred<Permission.AssertInput> | undefined
|
||||
let decision: Effect.Effect<void, Permission.Error> = Effect.void
|
||||
@@ -393,7 +401,7 @@ const permissions = Layer.mock(Permission.Service, {
|
||||
})
|
||||
const events = Layer.mock(Bus.Service, { subscribe: () => Stream.never })
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node]), [
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node, Mcp.node, Permission.node, Bus.node]), [
|
||||
Mcp.node.replace(mcp),
|
||||
Permission.node.replace(permissions),
|
||||
Bus.node.replace(events),
|
||||
@@ -1752,6 +1760,7 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and preserves plugin tr
|
||||
const registry = yield* Tool.Service
|
||||
const registration = yield* McpTool.Service
|
||||
const bus = yield* Bus.Service
|
||||
yield* registerToolPlugin(McpToolPlugin.Plugin)
|
||||
yield* registration.flush
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([
|
||||
"demo_search",
|
||||
@@ -1860,7 +1869,7 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and preserves plugin tr
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
Layer.fresh(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node, Bus.node]), [
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node, Mcp.node, Permission.node, Bus.node]), [
|
||||
Mcp.node.replace(
|
||||
Layer.mock(Mcp.Service, {
|
||||
tools: () => Ref.get(catalog),
|
||||
@@ -1890,6 +1899,7 @@ testEffect(Layer.empty).effect("coalesces queued MCP tool notifications after in
|
||||
const registry = yield* Tool.Service
|
||||
const registration = yield* McpTool.Service
|
||||
const bus = yield* Bus.Service
|
||||
yield* registerToolPlugin(McpToolPlugin.Plugin)
|
||||
yield* registration.flush
|
||||
expect(reads).toBe(1)
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["demo_read_1", "execute"])
|
||||
@@ -1904,7 +1914,7 @@ testEffect(Layer.empty).effect("coalesces queued MCP tool notifications after in
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["demo_read_3", "execute"])
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node, Bus.node]), [
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node, Mcp.node, Permission.node, Bus.node]), [
|
||||
Mcp.node.replace(
|
||||
Layer.mock(Mcp.Service, {
|
||||
tools: () =>
|
||||
@@ -1929,6 +1939,7 @@ it.effect("advertises MCP output schemas to Code Mode", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* Tool.Service
|
||||
const registration = yield* McpTool.Service
|
||||
yield* registerToolPlugin(McpToolPlugin.Plugin)
|
||||
yield* registration.flush
|
||||
const toolSet = yield* registry.snapshot()
|
||||
const execute = toolSet.definitions.find((tool) => tool.name === "execute")
|
||||
@@ -1953,6 +1964,7 @@ it.effect("forwards the invoking session through direct and Code Mode MCP tools"
|
||||
invocations = []
|
||||
const registry = yield* Tool.Service
|
||||
const registration = yield* McpTool.Service
|
||||
yield* registerToolPlugin(McpToolPlugin.Plugin)
|
||||
yield* registration.flush
|
||||
const toolSet = yield* registry.snapshot()
|
||||
|
||||
@@ -2002,6 +2014,7 @@ it.effect("returns content-only MCP results through Code Mode", () =>
|
||||
decision = Effect.void
|
||||
const registry = yield* Tool.Service
|
||||
const registration = yield* McpTool.Service
|
||||
yield* registerToolPlugin(McpToolPlugin.Plugin)
|
||||
yield* registration.flush
|
||||
const toolSet = yield* registry.snapshot()
|
||||
|
||||
@@ -2029,6 +2042,7 @@ it.effect("advertises MCP tools directly when Code Mode is disabled for the serv
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* Tool.Service
|
||||
const registration = yield* McpTool.Service
|
||||
yield* registerToolPlugin(McpToolPlugin.Plugin)
|
||||
yield* registration.flush
|
||||
const definitions = yield* toolDefinitions(registry)
|
||||
const execute = definitions.find((tool) => tool.name === "execute")
|
||||
@@ -2046,6 +2060,7 @@ it.effect("fails the call when MCP reports isError", () =>
|
||||
decision = Effect.void
|
||||
const registry = yield* Tool.Service
|
||||
const registration = yield* McpTool.Service
|
||||
yield* registerToolPlugin(McpToolPlugin.Plugin)
|
||||
yield* registration.flush
|
||||
|
||||
const execution = yield* executeTool(registry, {
|
||||
@@ -2065,6 +2080,7 @@ it.effect("preserves MCP text and media content for the model", () =>
|
||||
decision = Effect.void
|
||||
const registry = yield* Tool.Service
|
||||
const registration = yield* McpTool.Service
|
||||
yield* registerToolPlugin(McpToolPlugin.Plugin)
|
||||
yield* registration.flush
|
||||
|
||||
const execution = yield* executeTool(registry, {
|
||||
@@ -2089,6 +2105,7 @@ it.effect("waits for permission before calling an MCP tool", () =>
|
||||
decision = Deferred.await(permission)
|
||||
const registry = yield* Tool.Service
|
||||
const registration = yield* McpTool.Service
|
||||
yield* registerToolPlugin(McpToolPlugin.Plugin)
|
||||
yield* registration.flush
|
||||
const toolSet = yield* registry.snapshot()
|
||||
expect(codeModeListings(toolSet.codeModeCatalog!).some((tool) => tool.path === "demo.search")).toBe(true)
|
||||
@@ -2133,6 +2150,7 @@ it.effect("does not call MCP when permission is blocked", () =>
|
||||
decision = Effect.fail(new Permission.BlockedError({ rules: [], permission: "demo_search", resources: ["*"] }))
|
||||
const registry = yield* Tool.Service
|
||||
const registration = yield* McpTool.Service
|
||||
yield* registerToolPlugin(McpToolPlugin.Plugin)
|
||||
yield* registration.flush
|
||||
const toolSet = yield* registry.snapshot()
|
||||
expect(codeModeListings(toolSet.codeModeCatalog!).some((tool) => tool.path === "demo.search")).toBe(true)
|
||||
|
||||
Reference in New Issue
Block a user