diff --git a/packages/core/test/mcp-instructions.test.ts b/packages/core/test/mcp-instructions.test.ts
new file mode 100644
index 000000000000..14e0e671f090
--- /dev/null
+++ b/packages/core/test/mcp-instructions.test.ts
@@ -0,0 +1,150 @@
+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 { MCP } from "@opencode-ai/core/mcp/index"
+import { McpInstructions } from "@opencode-ai/core/mcp/instructions"
+import { PermissionV2 } from "@opencode-ai/core/permission"
+import { McpTool } from "@opencode-ai/core/tool/mcp"
+import { it } 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()),
+ }),
+ ],
+ ])
+
+describe("McpInstructions", () => {
+ 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,
+ ),
+ ),
+ )
+ })
+})