Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions packages/core/test/mcp-instructions.test.ts
Original file line number Diff line number Diff line change
@@ -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(
[
"<mcp_instructions>",
' <server name="alpha">',
' Use tools from this server through `execute` under `tools["alpha"]`.',
" Alpha line one",
" Alpha line two",
" </server>",
' <server name="beta">',
' Use tools from this server through `execute` under `tools["beta"]`.',
" Beta instructions",
" </server>",
"</mcp_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:",
' <server name="beta">',
' Use tools from this server through `execute` under `tools["beta"]`.',
" Beta instructions",
" </server>",
].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.",
"<mcp_instructions>",
' <server name="alpha">',
' Use tools from this server through `execute` under `tools["alpha"]`.',
" Updated alpha",
" </server>",
' <server name="beta">',
' Use tools from this server through `execute` under `tools["beta"]`.',
" Beta instructions",
" </server>",
"</mcp_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,
),
),
)
})
})
Loading