Skip to content

Commit 3383e87

Browse files
committed
feat(mcp): append server instructions to context
1 parent 5d0f866 commit 3383e87

5 files changed

Lines changed: 158 additions & 31 deletions

File tree

packages/opencode/src/mcp/index.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ interface CreateResult {
139139
mcpClient?: MCPClient
140140
status: Status
141141
defs?: MCPToolDef[]
142+
instructions?: string
142143
}
143144

144145
interface AuthResult {
@@ -154,11 +155,13 @@ interface State {
154155
status: Record<string, Status>
155156
clients: Record<string, MCPClient>
156157
defs: Record<string, MCPToolDef[]>
158+
instructions: Record<string, string>
157159
}
158160

159161
export interface Interface {
160162
readonly status: () => Effect.Effect<Record<string, Status>>
161163
readonly clients: () => Effect.Effect<Record<string, MCPClient>>
164+
readonly instructions: () => Effect.Effect<string[]>
162165
readonly tools: () => Effect.Effect<Record<string, Tool>>
163166
readonly prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>>
164167
readonly resources: () => Effect.Effect<Record<string, ResourceInfo & { client: string }>>
@@ -379,7 +382,7 @@ export const layer = Layer.effect(
379382
if (!listed) {
380383
return yield* Effect.fail(new Error("Failed to get tools"))
381384
}
382-
return { mcpClient, status, defs: listed } satisfies CreateResult
385+
return { mcpClient, status, defs: listed, instructions: mcpClient.getInstructions()?.trim() } satisfies CreateResult
383386
}).pipe(
384387
Effect.catchCause((cause) =>
385388
Effect.tryPromise(() => mcpClient.close()).pipe(Effect.ignore, Effect.andThen(Effect.failCause(cause))),
@@ -426,6 +429,7 @@ export const layer = Layer.effect(
426429
if (s.clients[name] !== client) return
427430
delete s.clients[name]
428431
delete s.defs[name]
432+
delete s.instructions[name]
429433
s.status[name] = { status: "failed", error: "Connection closed" }
430434
bridge.fork(
431435
Effect.logWarning("MCP connection closed", { server: name }).pipe(
@@ -480,6 +484,7 @@ export const layer = Layer.effect(
480484
status: {},
481485
clients: {},
482486
defs: {},
487+
instructions: {},
483488
}
484489

485490
yield* Effect.forEach(
@@ -501,6 +506,7 @@ export const layer = Layer.effect(
501506
if (result.mcpClient) {
502507
s.clients[key] = result.mcpClient
503508
s.defs[key] = result.defs!
509+
if (result.instructions) s.instructions[key] = result.instructions
504510
watch(s, key, result.mcpClient, bridge, mcp.timeout)
505511
}
506512
}),
@@ -512,6 +518,7 @@ export const layer = Layer.effect(
512518
const clients = Object.values(s.clients)
513519
s.clients = {}
514520
s.defs = {}
521+
s.instructions = {}
515522
yield* Effect.forEach(
516523
clients,
517524
(client) =>
@@ -541,6 +548,7 @@ export const layer = Layer.effect(
541548
const client = s.clients[name]
542549
delete s.clients[name]
543550
delete s.defs[name]
551+
delete s.instructions[name]
544552
if (!client) return Effect.void
545553
return Effect.tryPromise(() => client.close()).pipe(Effect.ignore)
546554
}
@@ -550,13 +558,16 @@ export const layer = Layer.effect(
550558
name: string,
551559
client: MCPClient,
552560
listed: MCPToolDef[],
561+
instructions: string | undefined,
553562
timeout?: number,
554563
) {
555564
const bridge = yield* EffectBridge.make()
556565
const previous = s.clients[name]
557566
s.status[name] = { status: "connected" }
558567
s.clients[name] = client
559568
s.defs[name] = listed
569+
if (instructions) s.instructions[name] = instructions
570+
else delete s.instructions[name]
560571
watch(s, name, client, bridge, timeout)
561572
if (previous) yield* Effect.tryPromise(() => previous.close()).pipe(Effect.ignore)
562573
return s.status[name]
@@ -586,6 +597,17 @@ export const layer = Layer.effect(
586597
return s.clients
587598
})
588599

600+
const instructions = Effect.fn("MCP.instructions")(function* () {
601+
const s = yield* InstanceState.get(state)
602+
return Object.entries(s.instructions)
603+
.filter(([name]) => s.status[name]?.status === "connected")
604+
.sort(([a], [b]) => a.localeCompare(b))
605+
.map(
606+
([name, item]) =>
607+
`Instructions from: MCP server ${name}\nThese instructions apply to MCP tools whose names start with \`${McpCatalog.sanitize(name)}_\`, and to prompts/resources from this MCP server.\n\n${item}`,
608+
)
609+
})
610+
589611
const createAndStore = Effect.fn("MCP.createAndStore")(function* (name: string, mcp: ConfigMCPV1.Info) {
590612
const s = yield* InstanceState.get(state)
591613
const result = yield* create(name, mcp)
@@ -597,7 +619,7 @@ export const layer = Layer.effect(
597619
return result.status
598620
}
599621

600-
return yield* storeClient(s, name, result.mcpClient, result.defs!, mcp.timeout)
622+
return yield* storeClient(s, name, result.mcpClient, result.defs!, result.instructions, mcp.timeout)
601623
})
602624

603625
const add = Effect.fn("MCP.add")(function* (name: string, mcp: ConfigMCPV1.Info) {
@@ -830,7 +852,7 @@ export const layer = Layer.effect(
830852

831853
const s = yield* InstanceState.get(state)
832854
yield* auth.clearOAuthState(mcpName)
833-
return yield* storeClient(s, mcpName, client, listed, mcpConfig.timeout)
855+
return yield* storeClient(s, mcpName, client, listed, client.getInstructions()?.trim(), mcpConfig.timeout)
834856
}
835857

836858
const callbackPromise = McpOAuthCallback.waitForCallback(result.oauthState, mcpName)
@@ -917,6 +939,7 @@ export const layer = Layer.effect(
917939
return Service.of({
918940
status,
919941
clients,
942+
instructions,
920943
tools,
921944
prompts,
922945
resources,

packages/opencode/src/session/prompt.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1324,13 +1324,14 @@ export const layer = Layer.effect(
13241324

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

1327-
const [skills, env, instructions, modelMsgs] = yield* Effect.all([
1327+
const [skills, env, instructions, mcpInstructions, modelMsgs] = yield* Effect.all([
13281328
sys.skills(agent),
13291329
sys.environment(model),
13301330
instruction.system().pipe(Effect.orDie),
1331+
mcp.instructions(),
13311332
MessageV2.toModelMessagesEffect(msgs, model),
13321333
])
1333-
const system = [...env, ...instructions, ...(skills ? [skills] : [])]
1334+
const system = [...env, ...instructions, ...mcpInstructions, ...(skills ? [skills] : [])]
13341335
const format = lastUser.format ?? { type: "text" as const }
13351336
if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
13361337
const result = yield* handle.process({

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

Lines changed: 62 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
@@ -179,6 +180,10 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
179180
return this._state?.capabilities
180181
}
181182

183+
getInstructions() {
184+
return this._state?.instructions
185+
}
186+
182187
async listTools(params?: { cursor?: string }) {
183188
if (this._state) this._state.listToolsCalls++
184189
if (this._state?.listToolsShouldFail) {
@@ -331,6 +336,63 @@ it.instance(
331336
{ config: { mcp: {} } },
332337
)
333338

339+
it.instance(
340+
"instructions() returns connected server instructions with tool prefix guidance",
341+
() =>
342+
MCP.Service.use((mcp: MCPNS.Interface) =>
343+
Effect.gen(function* () {
344+
lastCreatedClientName = "guide-server"
345+
const serverState = getOrCreateClientState("guide-server")
346+
serverState.instructions = "Use lookup before mutate."
347+
348+
yield* mcp.add("guide-server", {
349+
type: "local",
350+
command: ["echo", "test"],
351+
})
352+
353+
expect(yield* mcp.instructions()).toContain(
354+
[
355+
"Instructions from: MCP server guide-server",
356+
"These instructions apply to MCP tools whose names start with `guide-server_`, and to prompts/resources from this MCP server.",
357+
"",
358+
"Use lookup before mutate.",
359+
].join("\n"),
360+
)
361+
}),
362+
),
363+
{ config: { mcp: {} } },
364+
)
365+
366+
it.instance(
367+
"instructions() omits empty and disconnected server instructions",
368+
() =>
369+
MCP.Service.use((mcp: MCPNS.Interface) =>
370+
Effect.gen(function* () {
371+
lastCreatedClientName = "temporary-server"
372+
getOrCreateClientState("temporary-server").instructions = "Temporary guidance."
373+
374+
yield* mcp.add("temporary-server", {
375+
type: "local",
376+
command: ["echo", "test"],
377+
})
378+
yield* mcp.disconnect("temporary-server")
379+
380+
lastCreatedClientName = "blank-server"
381+
getOrCreateClientState("blank-server").instructions = " "
382+
383+
yield* mcp.add("blank-server", {
384+
type: "local",
385+
command: ["echo", "test"],
386+
})
387+
388+
const instructions = yield* mcp.instructions()
389+
expect(instructions.some((item) => item.includes("temporary-server"))).toBe(false)
390+
expect(instructions.some((item) => item.includes("blank-server"))).toBe(false)
391+
}),
392+
),
393+
{ config: { mcp: {} } },
394+
)
395+
334396
it.instance(
335397
"follows cursors when listing tools, prompts, and resources",
336398
() =>

packages/opencode/test/session/prompt.test.ts

Lines changed: 66 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -108,28 +108,31 @@ function errorTool(parts: SessionV1.Part[]) {
108108
return part?.state.status === "error" ? (part as ErrorToolPart) : undefined
109109
}
110110

111-
const mcp = Layer.succeed(
112-
MCP.Service,
113-
MCP.Service.of({
114-
status: () => Effect.succeed({}),
115-
clients: () => Effect.succeed({}),
116-
tools: () => Effect.succeed({}),
117-
prompts: () => Effect.succeed({}),
118-
resources: () => Effect.succeed({}),
119-
add: () => Effect.succeed({ status: { status: "disabled" as const } }),
120-
connect: () => Effect.void,
121-
disconnect: () => Effect.void,
122-
getPrompt: () => Effect.succeed(undefined),
123-
readResource: () => Effect.succeed(undefined),
124-
startAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"),
125-
authenticate: () => Effect.die("unexpected MCP auth in prompt-effect tests"),
126-
finishAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"),
127-
removeAuth: () => Effect.void,
128-
supportsOAuth: () => Effect.succeed(false),
129-
hasStoredTokens: () => Effect.succeed(false),
130-
getAuthStatus: () => Effect.succeed("not_authenticated" as const),
131-
}),
132-
)
111+
function makeMcp(instructions: string[] = []) {
112+
return Layer.succeed(
113+
MCP.Service,
114+
MCP.Service.of({
115+
status: () => Effect.succeed({}),
116+
clients: () => Effect.succeed({}),
117+
instructions: () => Effect.succeed(instructions),
118+
tools: () => Effect.succeed({}),
119+
prompts: () => Effect.succeed({}),
120+
resources: () => Effect.succeed({}),
121+
add: () => Effect.succeed({ status: { status: "disabled" as const } }),
122+
connect: () => Effect.void,
123+
disconnect: () => Effect.void,
124+
getPrompt: () => Effect.succeed(undefined),
125+
readResource: () => Effect.succeed(undefined),
126+
startAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"),
127+
authenticate: () => Effect.die("unexpected MCP auth in prompt-effect tests"),
128+
finishAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"),
129+
removeAuth: () => Effect.void,
130+
supportsOAuth: () => Effect.succeed(false),
131+
hasStoredTokens: () => Effect.succeed(false),
132+
getAuthStatus: () => Effect.succeed("not_authenticated" as const),
133+
}),
134+
)
135+
}
133136

134137
const lsp = Layer.succeed(
135138
LSP.Service,
@@ -163,7 +166,7 @@ const blockingProcessor = Layer.succeed(
163166
}),
164167
)
165168

166-
function makePrompt(input?: { processor?: "blocking" }) {
169+
function makePrompt(input?: { mcpInstructions?: string[]; processor?: "blocking" }) {
167170
const deps = Layer.mergeAll(
168171
Session.defaultLayer,
169172
Snapshot.defaultLayer,
@@ -176,7 +179,7 @@ function makePrompt(input?: { processor?: "blocking" }) {
176179
Config.defaultLayer,
177180
ProviderSvc.defaultLayer,
178181
lsp,
179-
mcp,
182+
makeMcp(input?.mcpInstructions),
180183
FSUtil.defaultLayer,
181184
BackgroundJob.defaultLayer,
182185
status,
@@ -229,17 +232,29 @@ function makePrompt(input?: { processor?: "blocking" }) {
229232
)
230233
}
231234

232-
function makeHttp(input?: { processor?: "blocking" }) {
235+
function makeHttp(input?: { mcpInstructions?: string[]; processor?: "blocking" }) {
233236
return Layer.mergeAll(TestLLMServer.layer, makePrompt(input))
234237
}
235238

236-
function makeHttpNoLLMServer(input?: { processor?: "blocking" }) {
239+
function makeHttpNoLLMServer(input?: { mcpInstructions?: string[]; processor?: "blocking" }) {
237240
return makePrompt(input)
238241
}
239242

240243
const it = testEffect(makeHttp())
241244
const noLLMServer = testEffect(makeHttpNoLLMServer())
242245
const raceNoLLMServer = testEffect(makeHttpNoLLMServer({ processor: "blocking" }))
246+
const withMcpInstructions = testEffect(
247+
makeHttp({
248+
mcpInstructions: [
249+
[
250+
"Instructions from: MCP server guide-server",
251+
"These instructions apply to MCP tools whose names start with `guide-server_`, and to prompts/resources from this MCP server.",
252+
"",
253+
"Use lookup before mutate.",
254+
].join("\n"),
255+
],
256+
}),
257+
)
243258
const unix = process.platform !== "win32" ? it.instance : it.instance.skip
244259
const unixNoLLMServer = process.platform !== "win32" ? noLLMServer.instance : noLLMServer.instance.skip
245260

@@ -506,6 +521,31 @@ it.instance("loop calls LLM and returns assistant message", () =>
506521
}),
507522
)
508523

524+
withMcpInstructions.instance("loop includes MCP instructions in model system context", () =>
525+
Effect.gen(function* () {
526+
const { llm } = yield* useServerConfig(providerCfg)
527+
const prompt = yield* SessionPrompt.Service
528+
const sessions = yield* Session.Service
529+
const chat = yield* sessions.create({
530+
title: "Pinned",
531+
permission: [{ permission: "*", pattern: "*", action: "allow" }],
532+
})
533+
yield* llm.hang
534+
yield* user(chat.id, "hello")
535+
536+
const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
537+
yield* awaitWithTimeout(llm.wait(1), "timed out waiting for MCP instruction request", "10 seconds")
538+
539+
const hits = yield* llm.hits
540+
const body = JSON.stringify(hits[0]?.body)
541+
expect(body).toContain("Instructions from: MCP server guide-server")
542+
expect(body).toContain("guide-server_")
543+
expect(body).toContain("Use lookup before mutate.")
544+
yield* Fiber.interrupt(fiber)
545+
}),
546+
15_000,
547+
)
548+
509549
it.instance("loop surfaces content-filter finishes as session errors", () =>
510550
Effect.gen(function* () {
511551
const { llm } = yield* useServerConfig(providerCfg)

packages/opencode/test/session/snapshot-tool-race.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ const mcp = Layer.succeed(
3737
MCP.Service.of({
3838
status: () => Effect.succeed({}),
3939
clients: () => Effect.succeed({}),
40+
instructions: () => Effect.succeed([]),
4041
tools: () => Effect.succeed({}),
4142
prompts: () => Effect.succeed({}),
4243
resources: () => Effect.succeed({}),

0 commit comments

Comments
 (0)