Skip to content

Commit 7035381

Browse files
Arcadi4rekram1-node
authored andcommitted
feat(mcp): append server instructions to context (anomalyco#32490)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
1 parent 1260f48 commit 7035381

8 files changed

Lines changed: 269 additions & 36 deletions

File tree

packages/opencode/src/mcp/catalog.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@ export function fetch<T extends { name: string }>(
115115

116116
export const sanitize = (value: string) => value.replace(/[^a-zA-Z0-9_-]/g, "_")
117117

118+
export const toolName = (clientName: string, name: string) => sanitize(clientName) + "_" + sanitize(name)
119+
118120
export function prompts(client: Client, timeout?: number) {
119121
if (!client.getServerCapabilities()?.prompts) return Promise.resolve([])
120122
return paginate(

packages/opencode/src/mcp/index.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ interface CreateResult {
140140
mcpClient?: MCPClient
141141
status: Status
142142
defs?: MCPToolDef[]
143+
instructions?: string
143144
}
144145

145146
interface AuthResult {
@@ -155,11 +156,19 @@ interface State {
155156
status: Record<string, Status>
156157
clients: Record<string, MCPClient>
157158
defs: Record<string, MCPToolDef[]>
159+
instructions: Record<string, string>
160+
}
161+
162+
export interface ServerInstructions {
163+
name: string
164+
instructions: string
165+
tools: string[]
158166
}
159167

160168
export interface Interface {
161169
readonly status: () => Effect.Effect<Record<string, Status>>
162170
readonly clients: () => Effect.Effect<Record<string, MCPClient>>
171+
readonly instructions: () => Effect.Effect<ServerInstructions[]>
163172
readonly tools: () => Effect.Effect<Record<string, Tool>>
164173
readonly prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>>
165174
readonly resources: (clientName?: string) => Effect.Effect<Record<string, ResourceInfo & { client: string }>>
@@ -383,7 +392,7 @@ export const layer = Layer.effect(
383392
if (!listed) {
384393
return yield* Effect.fail(new Error("Failed to get tools"))
385394
}
386-
return { mcpClient, status, defs: listed } satisfies CreateResult
395+
return { mcpClient, status, defs: listed, instructions: mcpClient.getInstructions()?.trim() } satisfies CreateResult
387396
}).pipe(
388397
Effect.catchCause((cause) =>
389398
Effect.tryPromise(() => mcpClient.close()).pipe(Effect.ignore, Effect.andThen(Effect.failCause(cause))),
@@ -430,6 +439,7 @@ export const layer = Layer.effect(
430439
if (s.clients[name] !== client) return
431440
delete s.clients[name]
432441
delete s.defs[name]
442+
delete s.instructions[name]
433443
s.status[name] = { status: "failed", error: "Connection closed" }
434444
bridge.fork(
435445
Effect.logWarning("MCP connection closed", { server: name }).pipe(
@@ -484,6 +494,7 @@ export const layer = Layer.effect(
484494
status: {},
485495
clients: {},
486496
defs: {},
497+
instructions: {},
487498
}
488499

489500
yield* Effect.forEach(
@@ -505,6 +516,7 @@ export const layer = Layer.effect(
505516
if (result.mcpClient) {
506517
s.clients[key] = result.mcpClient
507518
s.defs[key] = result.defs!
519+
if (result.instructions) s.instructions[key] = result.instructions
508520
watch(s, key, result.mcpClient, bridge, mcp.timeout)
509521
}
510522
}),
@@ -516,6 +528,7 @@ export const layer = Layer.effect(
516528
const clients = Object.values(s.clients)
517529
s.clients = {}
518530
s.defs = {}
531+
s.instructions = {}
519532
yield* Effect.forEach(
520533
clients,
521534
(client) =>
@@ -545,6 +558,7 @@ export const layer = Layer.effect(
545558
const client = s.clients[name]
546559
delete s.clients[name]
547560
delete s.defs[name]
561+
delete s.instructions[name]
548562
if (!client) return Effect.void
549563
return Effect.tryPromise(() => client.close()).pipe(Effect.ignore)
550564
}
@@ -554,13 +568,16 @@ export const layer = Layer.effect(
554568
name: string,
555569
client: MCPClient,
556570
listed: MCPToolDef[],
571+
instructions: string | undefined,
557572
timeout?: number,
558573
) {
559574
const bridge = yield* EffectBridge.make()
560575
const previous = s.clients[name]
561576
s.status[name] = { status: "connected" }
562577
s.clients[name] = client
563578
s.defs[name] = listed
579+
if (instructions) s.instructions[name] = instructions
580+
else delete s.instructions[name]
564581
watch(s, name, client, bridge, timeout)
565582
if (previous) yield* Effect.tryPromise(() => previous.close()).pipe(Effect.ignore)
566583
return s.status[name]
@@ -590,6 +607,18 @@ export const layer = Layer.effect(
590607
return s.clients
591608
})
592609

610+
const instructions = Effect.fn("MCP.instructions")(function* () {
611+
const s = yield* InstanceState.get(state)
612+
return Object.entries(s.instructions)
613+
.filter(([name]) => s.status[name]?.status === "connected")
614+
.sort(([a], [b]) => a.localeCompare(b))
615+
.map(([name, item]) => ({
616+
name,
617+
instructions: item,
618+
tools: (s.defs[name] ?? []).map((tool) => McpCatalog.toolName(name, tool.name)),
619+
}))
620+
})
621+
593622
const createAndStore = Effect.fn("MCP.createAndStore")(function* (name: string, mcp: ConfigMCPV1.Info) {
594623
const s = yield* InstanceState.get(state)
595624
const result = yield* create(name, mcp)
@@ -601,7 +630,7 @@ export const layer = Layer.effect(
601630
return result.status
602631
}
603632

604-
return yield* storeClient(s, name, result.mcpClient, result.defs!, mcp.timeout)
633+
return yield* storeClient(s, name, result.mcpClient, result.defs!, result.instructions, mcp.timeout)
605634
})
606635

607636
const add = Effect.fn("MCP.add")(function* (name: string, mcp: ConfigMCPV1.Info) {
@@ -647,7 +676,7 @@ export const layer = Layer.effect(
647676
}
648677
const timeout = requestTimeout(s, clientName, mcpConfig, defaultTimeout)
649678
for (const mcpTool of listed) {
650-
const key = McpCatalog.sanitize(clientName) + "_" + McpCatalog.sanitize(mcpTool.name)
679+
const key = McpCatalog.toolName(clientName, mcpTool.name)
651680
result[key] = McpCatalog.convertTool(mcpTool, client, timeout)
652681
}
653682
}
@@ -855,7 +884,7 @@ export const layer = Layer.effect(
855884

856885
const s = yield* InstanceState.get(state)
857886
yield* auth.clearOAuthState(mcpName)
858-
return yield* storeClient(s, mcpName, client, listed, mcpConfig.timeout)
887+
return yield* storeClient(s, mcpName, client, listed, client.getInstructions()?.trim(), mcpConfig.timeout)
859888
}
860889

861890
const callbackPromise = McpOAuthCallback.waitForCallback(result.oauthState, mcpName)
@@ -942,6 +971,7 @@ export const layer = Layer.effect(
942971
return Service.of({
943972
status,
944973
clients,
974+
instructions,
945975
tools,
946976
prompts,
947977
resources,

packages/opencode/src/session/prompt.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1356,13 +1356,19 @@ export const layer = Layer.effect(
13561356

13571357
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
13581358

1359-
const [skills, env, instructions, modelMsgs] = yield* Effect.all([
1359+
const [skills, env, instructions, mcpInstructions, modelMsgs] = yield* Effect.all([
13601360
sys.skills(agent),
13611361
sys.environment(model),
13621362
instruction.system().pipe(Effect.orDie),
1363+
sys.mcp(agent, session.permission),
13631364
MessageV2.toModelMessagesEffect(msgs, model),
13641365
])
1365-
const system = [...env, ...instructions, ...(skills ? [skills] : [])]
1366+
const system = [
1367+
...env,
1368+
...instructions,
1369+
...(mcpInstructions ? [mcpInstructions] : []),
1370+
...(skills ? [skills] : []),
1371+
]
13661372
const format = lastUser.format ?? { type: "text" as const }
13671373
if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
13681374
const result = yield* handle.process({

packages/opencode/src/session/system.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
2020
import { Location } from "@opencode-ai/core/location"
2121
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
2222
import { Reference } from "@opencode-ai/core/reference"
23+
import { MCP } from "@/mcp"
24+
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
2325

2426
export function provider(model: Provider.Model) {
2527
if (model.api.id.includes("gpt-4") || model.api.id.includes("o1") || model.api.id.includes("o3"))
@@ -40,6 +42,7 @@ export function provider(model: Provider.Model) {
4042
export interface Interface {
4143
readonly environment: (model: Provider.Model) => Effect.Effect<string[]>
4244
readonly skills: (agent: Agent.Info) => Effect.Effect<string | undefined>
45+
readonly mcp: (agent: Agent.Info, permission?: PermissionV1.Ruleset) => Effect.Effect<string | undefined>
4346
}
4447

4548
export class Service extends Context.Service<Service, Interface>()("@opencode/SystemPrompt") {}
@@ -48,6 +51,7 @@ export const layer = Layer.effect(
4851
Service,
4952
Effect.gen(function* () {
5053
const skill = yield* Skill.Service
54+
const mcp = yield* MCP.Service
5155
const locations = yield* LocationServiceMap
5256

5357
return Service.of({
@@ -102,14 +106,36 @@ export const layer = Layer.effect(
102106
Skill.fmt(list, { verbose: true }),
103107
].join("\n")
104108
}),
109+
110+
mcp: Effect.fn("SystemPrompt.mcp")(function* (agent: Agent.Info, permission?: PermissionV1.Ruleset) {
111+
const ruleset = Permission.merge(agent.permission, permission ?? [])
112+
const instructions = (yield* mcp.instructions()).filter(
113+
(item) => item.tools.length === 0 || Permission.disabled(item.tools, ruleset).size < item.tools.length,
114+
)
115+
if (instructions.length === 0) return
116+
117+
return [
118+
"<mcp_instructions>",
119+
...instructions.flatMap((item) => [
120+
` <server name="${item.name}">`,
121+
...item.instructions.split("\n").map((line) => ` ${line}`),
122+
" </server>",
123+
]),
124+
"</mcp_instructions>",
125+
].join("\n")
126+
}),
105127
})
106128
}),
107129
)
108130

109-
export const defaultLayer = layer.pipe(Layer.provide(Skill.defaultLayer), Layer.provide(LocationServiceMap.layer))
131+
export const defaultLayer = layer.pipe(
132+
Layer.provide(Skill.defaultLayer),
133+
Layer.provide(MCP.defaultLayer),
134+
Layer.provide(LocationServiceMap.layer),
135+
)
110136

111137
const locationServiceMapNode = LayerNode.make(LocationServiceMap.layer, [])
112138

113-
export const node = LayerNode.make(layer, [Skill.node, locationServiceMapNode])
139+
export const node = LayerNode.make(layer, [Skill.node, MCP.node, locationServiceMapNode])
114140

115141
export * as SystemPrompt from "./system"

packages/opencode/test/mcp/lifecycle.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { TestInstance } from "../fixture/fixture"
1313
interface MockClientState {
1414
capabilities: { tools?: object; prompts?: object; resources?: object }
1515
capabilitiesShouldThrow: boolean
16+
instructions?: string
1617
tools: Array<{ name: string; description?: string; inputSchema: object; outputSchema?: object }>
1718
listToolsCalls: number
1819
listPromptsCalls: number
@@ -188,6 +189,10 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
188189
return this._state?.capabilities
189190
}
190191

192+
getInstructions() {
193+
return this._state?.instructions
194+
}
195+
191196
async listTools(params?: { cursor?: string }) {
192197
if (this._state) this._state.listToolsCalls++
193198
if (this._state?.listToolsShouldFail) {
@@ -347,6 +352,60 @@ it.instance(
347352
{ config: { mcp: {} } },
348353
)
349354

355+
it.instance(
356+
"instructions() returns connected server instructions with tool names",
357+
() =>
358+
MCP.Service.use((mcp: MCPNS.Interface) =>
359+
Effect.gen(function* () {
360+
lastCreatedClientName = "guide-server"
361+
const serverState = getOrCreateClientState("guide-server")
362+
serverState.instructions = "Use lookup before mutate."
363+
364+
yield* mcp.add("guide-server", {
365+
type: "local",
366+
command: ["echo", "test"],
367+
})
368+
369+
expect(yield* mcp.instructions()).toContainEqual({
370+
name: "guide-server",
371+
instructions: "Use lookup before mutate.",
372+
tools: ["guide-server_test_tool"],
373+
})
374+
}),
375+
),
376+
{ config: { mcp: {} } },
377+
)
378+
379+
it.instance(
380+
"instructions() omits empty and disconnected server instructions",
381+
() =>
382+
MCP.Service.use((mcp: MCPNS.Interface) =>
383+
Effect.gen(function* () {
384+
lastCreatedClientName = "temporary-server"
385+
getOrCreateClientState("temporary-server").instructions = "Temporary guidance."
386+
387+
yield* mcp.add("temporary-server", {
388+
type: "local",
389+
command: ["echo", "test"],
390+
})
391+
yield* mcp.disconnect("temporary-server")
392+
393+
lastCreatedClientName = "blank-server"
394+
getOrCreateClientState("blank-server").instructions = " "
395+
396+
yield* mcp.add("blank-server", {
397+
type: "local",
398+
command: ["echo", "test"],
399+
})
400+
401+
const instructions = yield* mcp.instructions()
402+
expect(instructions.some((item) => item.name === "temporary-server")).toBe(false)
403+
expect(instructions.some((item) => item.name === "blank-server")).toBe(false)
404+
}),
405+
),
406+
{ config: { mcp: {} } },
407+
)
408+
350409
it.instance(
351410
"follows cursors when listing tools, prompts, and resources",
352411
() =>

0 commit comments

Comments
 (0)