mcp-instructions.test.ts 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. import { describe, expect } from "bun:test"
  2. import { Effect, Layer } from "effect"
  3. import { Agent } from "@opencode-ai/core/agent"
  4. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  5. import { MCP } from "@opencode-ai/core/mcp/index"
  6. import { McpInstructions } from "@opencode-ai/core/mcp/instructions"
  7. import { Permission } from "@opencode-ai/core/permission"
  8. import { McpTool } from "@opencode-ai/core/tool/mcp"
  9. import { it } from "./lib/effect"
  10. import { readInitial, readUpdate } from "./lib/instructions"
  11. const build = Agent.ID.make("build")
  12. const selection = (permissions: Permission.Ruleset = []) => {
  13. const info = Agent.Info.make({ ...Agent.Info.default(build), permissions })
  14. return { id: info.id, info }
  15. }
  16. const instructions = (server: string, text: string) =>
  17. new MCP.ServerInstructions({ server: MCP.ServerName.make(server), instructions: text })
  18. const tool = (server: string, name = "search") => new MCP.Tool({ server: MCP.ServerName.make(server), name })
  19. const layer = (catalog: () => MCP.ServerInstructions[], tools: () => MCP.Tool[]) =>
  20. AppNodeBuilder.build(McpInstructions.node, [
  21. [
  22. MCP.node,
  23. Layer.mock(MCP.Service, {
  24. instructions: () => Effect.succeed(catalog()),
  25. tools: () => Effect.succeed(tools()),
  26. }),
  27. ],
  28. ])
  29. describe("McpInstructions", () => {
  30. it.effect("renders instructions for servers with at least one permitted tool", () =>
  31. Effect.gen(function* () {
  32. const service = yield* McpInstructions.Service
  33. const generation = yield* service
  34. .load(
  35. selection([
  36. { action: McpTool.name("alpha", "restricted"), resource: "*", effect: "deny" },
  37. { action: McpTool.name("hidden", "search"), resource: "*", effect: "deny" },
  38. ]),
  39. )
  40. .pipe(Effect.flatMap(readInitial))
  41. expect(generation.text).toBe(
  42. [
  43. "<mcp_instructions>",
  44. ' <server name="alpha">',
  45. ' Use tools from this server through `execute` under `tools["alpha"]`.',
  46. " Alpha line one",
  47. " Alpha line two",
  48. " </server>",
  49. ' <server name="beta">',
  50. ' Use tools from this server through `execute` under `tools["beta"]`.',
  51. " Beta instructions",
  52. " </server>",
  53. "</mcp_instructions>",
  54. ].join("\n"),
  55. )
  56. }).pipe(
  57. Effect.provide(
  58. layer(
  59. () => [
  60. instructions("beta", "Beta instructions"),
  61. instructions("unused", "No tools"),
  62. instructions("hidden", "Denied tool"),
  63. instructions("alpha", "Alpha line one\nAlpha line two"),
  64. ],
  65. () => [tool("alpha"), tool("alpha", "restricted"), tool("beta"), tool("hidden")],
  66. ),
  67. ),
  68. ),
  69. )
  70. it.effect("omits instructions when the agent cannot use execute", () =>
  71. Effect.gen(function* () {
  72. const service = yield* McpInstructions.Service
  73. const generation = yield* service
  74. .load(selection([{ action: "execute", resource: "*", effect: "deny" }]))
  75. .pipe(Effect.flatMap(readInitial))
  76. expect(generation.text).toBe("")
  77. }).pipe(
  78. Effect.provide(
  79. layer(
  80. () => [instructions("alpha", "Alpha instructions")],
  81. () => [tool("alpha")],
  82. ),
  83. ),
  84. ),
  85. )
  86. it.effect("keeps MCP instructions when Code Mode is disabled and execute is denied", () =>
  87. Effect.gen(function* () {
  88. const service = yield* McpInstructions.Service
  89. const generation = yield* service
  90. .load(selection([{ action: "execute", resource: "*", effect: "deny" }]))
  91. .pipe(Effect.flatMap(readInitial))
  92. expect(generation.text).toBe(
  93. [
  94. "<mcp_instructions>",
  95. ' <server name="alpha">',
  96. " Alpha instructions",
  97. " </server>",
  98. "</mcp_instructions>",
  99. ].join("\n"),
  100. )
  101. }).pipe(
  102. Effect.provide(
  103. layer(
  104. () => [instructions("alpha", "Alpha instructions")],
  105. () => [new MCP.Tool({ server: MCP.ServerName.make("alpha"), name: "search", codemode: false })],
  106. ),
  107. ),
  108. ),
  109. )
  110. it.effect("restates guidance when Code Mode is disabled for a server", () => {
  111. let tools = [tool("alpha")]
  112. return Effect.gen(function* () {
  113. const service = yield* McpInstructions.Service
  114. const initialized = yield* service.load(selection()).pipe(Effect.flatMap(readInitial))
  115. tools = [new MCP.Tool({ server: MCP.ServerName.make("alpha"), name: "search", codemode: false })]
  116. const changed = yield* readUpdate(yield* service.load(selection()), initialized)
  117. expect(changed.text).toBe(
  118. [
  119. "The available MCP server instructions have changed. This list supersedes the previous one.",
  120. "<mcp_instructions>",
  121. ' <server name="alpha">',
  122. " Alpha instructions",
  123. " </server>",
  124. "</mcp_instructions>",
  125. ].join("\n"),
  126. )
  127. }).pipe(
  128. Effect.provide(
  129. layer(
  130. () => [instructions("alpha", "Alpha instructions")],
  131. () => tools,
  132. ),
  133. ),
  134. )
  135. })
  136. it.effect("renders additions, changes, and removal", () => {
  137. let catalog = [instructions("alpha", "Alpha instructions")]
  138. const tools = [tool("alpha"), tool("beta")]
  139. return Effect.gen(function* () {
  140. const service = yield* McpInstructions.Service
  141. const initialized = yield* service.load(selection()).pipe(Effect.flatMap(readInitial))
  142. catalog = [instructions("alpha", "Alpha instructions"), instructions("beta", "Beta instructions")]
  143. const added = yield* readUpdate(yield* service.load(selection()), initialized)
  144. expect(added.text).toBe(
  145. [
  146. "New MCP server instructions are available in addition to those previously listed:",
  147. ' <server name="beta">',
  148. ' Use tools from this server through `execute` under `tools["beta"]`.',
  149. " Beta instructions",
  150. " </server>",
  151. ].join("\n"),
  152. )
  153. catalog = [instructions("alpha", "Updated alpha"), instructions("beta", "Beta instructions")]
  154. const changed = yield* readUpdate(yield* service.load(selection()), added)
  155. expect(changed.text).toBe(
  156. [
  157. "The available MCP server instructions have changed. This list supersedes the previous one.",
  158. "<mcp_instructions>",
  159. ' <server name="alpha">',
  160. ' Use tools from this server through `execute` under `tools["alpha"]`.',
  161. " Updated alpha",
  162. " </server>",
  163. ' <server name="beta">',
  164. ' Use tools from this server through `execute` under `tools["beta"]`.',
  165. " Beta instructions",
  166. " </server>",
  167. "</mcp_instructions>",
  168. ].join("\n"),
  169. )
  170. catalog = [instructions("beta", "Beta instructions")]
  171. const removed = yield* readUpdate(yield* service.load(selection()), changed)
  172. expect(removed.text).toBe("Instructions for the following MCP servers are no longer available: alpha.")
  173. catalog = []
  174. expect((yield* readUpdate(yield* service.load(selection()), removed)).text).toBe(
  175. "MCP server instructions are no longer available.",
  176. )
  177. }).pipe(
  178. Effect.provide(
  179. layer(
  180. () => catalog,
  181. () => tools,
  182. ),
  183. ),
  184. )
  185. })
  186. })