From 2e344fb42243887d808ca5006fc466eae67c731c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 16 Jul 2026 10:16:52 -0400 Subject: [PATCH 1/2] fix(core): expose mcp instructions by location --- packages/core/src/location-services.ts | 2 + packages/core/test/mcp-instructions.test.ts | 179 ++++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 packages/core/test/mcp-instructions.test.ts diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 1af575595b54..9d8d4406ea5b 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -19,6 +19,7 @@ import { Location } from "./location" import { LocationMutation } from "./location-mutation" import { LocationServiceMap } from "./location-service-map" import { MCP } from "./mcp/index" +import { McpInstructions } from "./mcp/instructions" import { PermissionV2 } from "./permission" import { PluginV2 } from "./plugin" import { PluginSupervisor } from "./plugin/supervisor" @@ -77,6 +78,7 @@ const locationServiceNodes = [ Image.node, SkillInstructions.node, ReferenceInstructions.node, + McpInstructions.node, InstructionEntry.node, Form.node, QuestionV2.node, diff --git a/packages/core/test/mcp-instructions.test.ts b/packages/core/test/mcp-instructions.test.ts new file mode 100644 index 000000000000..42be46606b68 --- /dev/null +++ b/packages/core/test/mcp-instructions.test.ts @@ -0,0 +1,179 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { AgentV2 } from "@opencode-ai/core/agent" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Location } from "@opencode-ai/core/location" +import { LocationServiceMap } from "@opencode-ai/core/location-services" +import { MCP } from "@opencode-ai/core/mcp/index" +import { McpInstructions } from "@opencode-ai/core/mcp/instructions" +import { PermissionV2 } from "@opencode-ai/core/permission" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { McpTool } from "@opencode-ai/core/tool/mcp" +import { Database } from "../src/database/database" +import { EventV2 } from "../src/event" +import { tmpdir } from "./fixture/tmpdir" +import { it, testEffect } from "./lib/effect" +import { readInitial, readUpdate } from "./lib/instructions" + +const build = AgentV2.ID.make("build") + +const selection = (permissions: PermissionV2.Ruleset = []) => { + const info = AgentV2.Info.make({ ...AgentV2.Info.empty(build), permissions }) + return { id: info.id, info } +} + +const instructions = (server: string, text: string) => + new MCP.ServerInstructions({ server: MCP.ServerName.make(server), instructions: text }) + +const tool = (server: string, name = "search") => new MCP.Tool({ server: MCP.ServerName.make(server), name }) + +const layer = (catalog: () => MCP.ServerInstructions[], tools: () => MCP.Tool[]) => + AppNodeBuilder.build(McpInstructions.node, [ + [ + MCP.node, + Layer.mock(MCP.Service, { + instructions: () => Effect.succeed(catalog()), + tools: () => Effect.succeed(tools()), + }), + ], + ]) + +const locationIt = testEffect( + AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, LocationServiceMap.node])), +) + +describe("McpInstructions", () => { + locationIt.live("is available from the Location service map", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (directory) => Effect.promise(() => directory[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((directory) => + Effect.gen(function* () { + const locations = yield* LocationServiceMap.Service + const context = yield* locations.contextEffect( + Location.Ref.make({ directory: AbsolutePath.make(directory.path) }), + ) + + expect(yield* McpInstructions.Service.pipe(Effect.provide(context))).toBeDefined() + }), + ), + ), + ) + + it.effect("renders instructions for servers with at least one permitted tool", () => + Effect.gen(function* () { + const service = yield* McpInstructions.Service + const generation = yield* service + .load( + selection([ + { action: McpTool.name("alpha", "restricted"), resource: "*", effect: "deny" }, + { action: McpTool.name("hidden", "search"), resource: "*", effect: "deny" }, + ]), + ) + .pipe(Effect.flatMap(readInitial)) + + expect(generation.text).toBe( + [ + "", + ' ', + ' Use tools from this server through `execute` under `tools["alpha"]`.', + " Alpha line one", + " Alpha line two", + " ", + ' ', + ' Use tools from this server through `execute` under `tools["beta"]`.', + " Beta instructions", + " ", + "", + ].join("\n"), + ) + }).pipe( + Effect.provide( + layer( + () => [ + instructions("beta", "Beta instructions"), + instructions("unused", "No tools"), + instructions("hidden", "Denied tool"), + instructions("alpha", "Alpha line one\nAlpha line two"), + ], + () => [tool("alpha"), tool("alpha", "restricted"), tool("beta"), tool("hidden")], + ), + ), + ), + ) + + it.effect("omits instructions when the agent cannot use execute", () => + Effect.gen(function* () { + const service = yield* McpInstructions.Service + const generation = yield* service + .load(selection([{ action: "execute", resource: "*", effect: "deny" }])) + .pipe(Effect.flatMap(readInitial)) + + expect(generation.text).toBe("") + }).pipe( + Effect.provide( + layer( + () => [instructions("alpha", "Alpha instructions")], + () => [tool("alpha")], + ), + ), + ), + ) + + it.effect("renders additions, changes, and removal", () => { + let catalog = [instructions("alpha", "Alpha instructions")] + const tools = [tool("alpha"), tool("beta")] + return Effect.gen(function* () { + const service = yield* McpInstructions.Service + const initialized = yield* service.load(selection()).pipe(Effect.flatMap(readInitial)) + + catalog = [instructions("alpha", "Alpha instructions"), instructions("beta", "Beta instructions")] + const added = yield* readUpdate(yield* service.load(selection()), initialized) + expect(added.text).toBe( + [ + "New MCP server instructions are available in addition to those previously listed:", + ' ', + ' Use tools from this server through `execute` under `tools["beta"]`.', + " Beta instructions", + " ", + ].join("\n"), + ) + + catalog = [instructions("alpha", "Updated alpha"), instructions("beta", "Beta instructions")] + const changed = yield* readUpdate(yield* service.load(selection()), added) + expect(changed.text).toBe( + [ + "The available MCP server instructions have changed. This list supersedes the previous one.", + "", + ' ', + ' Use tools from this server through `execute` under `tools["alpha"]`.', + " Updated alpha", + " ", + ' ', + ' Use tools from this server through `execute` under `tools["beta"]`.', + " Beta instructions", + " ", + "", + ].join("\n"), + ) + + catalog = [instructions("beta", "Beta instructions")] + const removed = yield* readUpdate(yield* service.load(selection()), changed) + expect(removed.text).toBe("Instructions for the following MCP servers are no longer available: alpha.") + + catalog = [] + expect((yield* readUpdate(yield* service.load(selection()), removed)).text).toBe( + "MCP server instructions are no longer available.", + ) + }).pipe( + Effect.provide( + layer( + () => catalog, + () => tools, + ), + ), + ) + }) +}) From cc458212bb14c0e109ea4ae9b00e2c238d40c9b9 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 16 Jul 2026 10:34:06 -0400 Subject: [PATCH 2/2] test(core): keep mcp instructions coverage focused --- packages/core/src/location-services.ts | 2 -- packages/core/test/mcp-instructions.test.ts | 31 +-------------------- 2 files changed, 1 insertion(+), 32 deletions(-) diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 9d8d4406ea5b..1af575595b54 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -19,7 +19,6 @@ import { Location } from "./location" import { LocationMutation } from "./location-mutation" import { LocationServiceMap } from "./location-service-map" import { MCP } from "./mcp/index" -import { McpInstructions } from "./mcp/instructions" import { PermissionV2 } from "./permission" import { PluginV2 } from "./plugin" import { PluginSupervisor } from "./plugin/supervisor" @@ -78,7 +77,6 @@ const locationServiceNodes = [ Image.node, SkillInstructions.node, ReferenceInstructions.node, - McpInstructions.node, InstructionEntry.node, Form.node, QuestionV2.node, diff --git a/packages/core/test/mcp-instructions.test.ts b/packages/core/test/mcp-instructions.test.ts index 42be46606b68..14e0e671f090 100644 --- a/packages/core/test/mcp-instructions.test.ts +++ b/packages/core/test/mcp-instructions.test.ts @@ -2,18 +2,11 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { AgentV2 } from "@opencode-ai/core/agent" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" -import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { Location } from "@opencode-ai/core/location" -import { LocationServiceMap } from "@opencode-ai/core/location-services" import { MCP } from "@opencode-ai/core/mcp/index" import { McpInstructions } from "@opencode-ai/core/mcp/instructions" import { PermissionV2 } from "@opencode-ai/core/permission" -import { AbsolutePath } from "@opencode-ai/core/schema" import { McpTool } from "@opencode-ai/core/tool/mcp" -import { Database } from "../src/database/database" -import { EventV2 } from "../src/event" -import { tmpdir } from "./fixture/tmpdir" -import { it, testEffect } from "./lib/effect" +import { it } from "./lib/effect" import { readInitial, readUpdate } from "./lib/instructions" const build = AgentV2.ID.make("build") @@ -39,29 +32,7 @@ const layer = (catalog: () => MCP.ServerInstructions[], tools: () => MCP.Tool[]) ], ]) -const locationIt = testEffect( - AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, LocationServiceMap.node])), -) - describe("McpInstructions", () => { - locationIt.live("is available from the Location service map", () => - Effect.acquireRelease( - Effect.promise(() => tmpdir()), - (directory) => Effect.promise(() => directory[Symbol.asyncDispose]()), - ).pipe( - Effect.flatMap((directory) => - Effect.gen(function* () { - const locations = yield* LocationServiceMap.Service - const context = yield* locations.contextEffect( - Location.Ref.make({ directory: AbsolutePath.make(directory.path) }), - ) - - expect(yield* McpInstructions.Service.pipe(Effect.provide(context))).toBeDefined() - }), - ), - ), - ) - it.effect("renders instructions for servers with at least one permitted tool", () => Effect.gen(function* () { const service = yield* McpInstructions.Service