From d4e00c061d6a810d663e8fc156a96b618c9880a8 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 29 Jun 2026 12:26:50 -0500 Subject: [PATCH] feat(core): surface mcp server instructions in the system prompt Capture each server's initialize instructions on the Connection at connect time and expose them via MCP.instructions(). Add McpGuidance, a system-context source modeled on SkillGuidance, that renders the block and hides servers whose contributed tools are all denied for the agent. instructions() returns just { server, instructions }; the guidance layer fetches tools() separately and does the agent-aware permission correlation, mirroring how SkillGuidance composes raw data with agent permissions. --- packages/core/src/mcp/client.ts | 3 + packages/core/src/mcp/guidance.ts | 75 +++++++++++++++++++ packages/core/src/mcp/index.ts | 9 ++- packages/core/src/session/runner/llm.ts | 5 +- .../core/test/session-runner-recorded.test.ts | 3 + packages/core/test/session-runner.test.ts | 3 + 6 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/mcp/guidance.ts diff --git a/packages/core/src/mcp/client.ts b/packages/core/src/mcp/client.ts index e0064a28d5..a9ffeb89b7 100644 --- a/packages/core/src/mcp/client.ts +++ b/packages/core/src/mcp/client.ts @@ -24,6 +24,8 @@ export class ConnectError extends Schema.TaggedErrorClass()("MCP.C /** Handle over a connected MCP server that keeps the SDK `Client` out of the rest of core. */ export interface Connection { + /** Server-supplied usage instructions from the initialize result, if any. */ + readonly instructions: string | undefined readonly onClose: (callback: () => void) => void } @@ -73,6 +75,7 @@ export const connect = Effect.fnUntraced(function* ( if (Exit.isSuccess(exit)) { yield* Effect.addFinalizer(() => Effect.promise(() => client.close()).pipe(Effect.ignore)) return { + instructions: client.getInstructions()?.trim() || undefined, onClose: (callback) => { client.onclose = callback }, diff --git a/packages/core/src/mcp/guidance.ts b/packages/core/src/mcp/guidance.ts new file mode 100644 index 0000000000..1950cde9ed --- /dev/null +++ b/packages/core/src/mcp/guidance.ts @@ -0,0 +1,75 @@ +export * as McpGuidance from "./guidance" + +import { makeLocationNode } from "../effect/app-node" +import { Context, Effect, Layer, Schema } from "effect" +import { AgentV2 } from "../agent" +import { PermissionV2 } from "../permission" +import { MCP } from "./index" +import { SystemContext } from "../system-context/index" + +const Summary = Schema.Struct({ + server: Schema.String, + instructions: Schema.String, +}) +type Summary = typeof Summary.Type + +const render = (servers: ReadonlyArray) => + [ + "", + ...servers.flatMap((server) => [ + ` `, + ...server.instructions.split("\n").map((line) => ` ${line}`), + " ", + ]), + "", + ].join("\n") + +export interface Interface { + readonly load: (agent: AgentV2.Selection) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/McpGuidance") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const mcp = yield* MCP.Service + + return Service.of({ + load: Effect.fn("McpGuidance.load")(function* (selection) { + const agent = selection.info + if (!agent) return SystemContext.empty + const [instructions, tools] = yield* Effect.all([mcp.instructions(), mcp.tools()], { + concurrency: "unbounded", + }) + // Hide a server only when every tool it contributes is wholly denied for this agent. + const visible = instructions + .filter((item) => { + const owned = tools.filter((tool) => tool.server === item.server) + return ( + owned.length === 0 || + owned.some((tool) => PermissionV2.evaluate(tool.name, "*", agent.permissions).effect !== "deny") + ) + }) + .map((item) => ({ server: item.server, instructions: item.instructions })) + if (visible.length === 0) return SystemContext.empty + return SystemContext.make({ + key: SystemContext.Key.make("core/mcp-guidance"), + codec: Schema.toCodecJson(Schema.Array(Summary)), + load: Effect.succeed(visible), + baseline: render, + update: (_previous, current) => + [ + "The available MCP server instructions have changed. This list supersedes the previous one.", + render(current), + ].join("\n"), + removed: () => "MCP server instructions are no longer available.", + }) + }), + }) + }), +) + +export const locationLayer = layer + +export const node = makeLocationNode({ service: Service, layer, deps: [MCP.node] }) diff --git a/packages/core/src/mcp/index.ts b/packages/core/src/mcp/index.ts index 02bfd8268a..c3f27fcfcc 100644 --- a/packages/core/src/mcp/index.ts +++ b/packages/core/src/mcp/index.ts @@ -55,7 +55,6 @@ export class ServerInfo extends Schema.Class("MCP.ServerInfo")({ export class ServerInstructions extends Schema.Class("MCP.ServerInstructions")({ server: ServerName, instructions: Schema.String, - tools: Schema.Array(Schema.String), }) {} export class Tool extends Schema.Class("MCP.Tool")({ @@ -271,7 +270,13 @@ export const layer = Layer.effect( }), instructions: Effect.fn("MCP.instructions")(function* () { yield* whenAllReady - return [] + return Array.from(runtime) + .flatMap(([server, entry]) => { + const instructions = entry.client?.instructions + if (!instructions) return [] + return [new ServerInstructions({ server, instructions })] + }) + .toSorted((a, b) => a.server.localeCompare(b.server)) }), prompts: Effect.fn("MCP.prompts")(function* () { yield* whenAllReady diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index b34d8e3aa2..497e57c0a5 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -20,6 +20,7 @@ import { SystemContext } from "../../system-context/index" import { SystemContextRegistry } from "../../system-context/registry" import { SkillGuidance } from "../../skill/guidance" import { ReferenceGuidance } from "../../reference/guidance" +import { McpGuidance } from "../../mcp/guidance" import { ToolRegistry } from "../../tool/registry" import { ToolOutputStore } from "../../tool-output-store" import { SessionContextEpoch } from "../context-epoch" @@ -102,6 +103,7 @@ export const layer = Layer.effect( const systemContext = yield* SystemContextRegistry.Service const skillGuidance = yield* SkillGuidance.Service const referenceGuidance = yield* ReferenceGuidance.Service + const mcpGuidance = yield* McpGuidance.Service const snapshots = yield* Snapshot.Service const db = (yield* Database.Service).db const compaction = yield* SessionCompaction.Service @@ -160,7 +162,7 @@ export const layer = Layer.effect( new TurnTransitionError({ _tag: "ContinueAfterOverflowCompaction", step }) const loadSystemContext = (agent: AgentV2.Selection) => - Effect.all([systemContext.load(), skillGuidance.load(agent), referenceGuidance.load()], { + Effect.all([systemContext.load(), skillGuidance.load(agent), referenceGuidance.load(), mcpGuidance.load(agent)], { concurrency: "unbounded", }).pipe(Effect.map(SystemContext.combine)) @@ -422,6 +424,7 @@ export const node = makeLocationNode({ SystemContextRegistry.node, SkillGuidance.node, ReferenceGuidance.node, + McpGuidance.node, SessionCompaction.node, Snapshot.node, Database.node, diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index ac87c0e366..f6d5b8ef45 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -30,6 +30,7 @@ import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry import { SystemContext } from "@opencode-ai/core/system-context" import { SkillGuidance } from "@opencode-ai/core/skill/guidance" import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance" +import { McpGuidance } from "@opencode-ai/core/mcp/guidance" import { describe, expect } from "bun:test" import { eq } from "drizzle-orm" import { Effect, Layer } from "effect" @@ -72,6 +73,7 @@ const systemContext = SystemContextRegistry.layer const location = Location.layer({ directory: AbsolutePath.make("/project") }).pipe(Layer.provide(Project.defaultLayer)) const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) }) const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) }) +const mcpGuidance = Layer.mock(McpGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) }) const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) })) const runner = SessionRunnerLLM.defaultLayer.pipe( Layer.provide(SessionCompaction.layer), @@ -87,6 +89,7 @@ const runner = SessionRunnerLLM.defaultLayer.pipe( Layer.provide(agents), Layer.provide(skillGuidance), Layer.provide(referenceGuidance), + Layer.provide(mcpGuidance), Layer.provide(config), ) const execution = Layer.effect( diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 94c631ac86..5b9a5dc01f 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -52,6 +52,7 @@ import { SystemContext } from "@opencode-ai/core/system-context" import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry" import { SkillGuidance } from "@opencode-ai/core/skill/guidance" import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance" +import { McpGuidance } from "@opencode-ai/core/mcp/guidance" import { ModelV2 } from "@opencode-ai/core/model" import { Location } from "@opencode-ai/core/location" import { ProviderV2 } from "@opencode-ai/core/provider" @@ -217,6 +218,7 @@ const skillGuidance = Layer.mock(SkillGuidance.Service, { ), }) const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) }) +const mcpGuidance = Layer.mock(McpGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) }) const config = Layer.succeed( Config.Service, Config.Service.of({ @@ -248,6 +250,7 @@ const runner = SessionRunnerLLM.layer.pipe( Layer.provide(agents), Layer.provide(skillGuidance), Layer.provide(referenceGuidance), + Layer.provide(mcpGuidance), Layer.provide(config), ) const execution = Layer.effect(