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, + ), + ), + ) + }) +})