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 <mcp_instructions> 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.
This commit is contained in:
parent
baf3f5e5af
commit
d4e00c061d
6 changed files with 95 additions and 3 deletions
|
|
@ -24,6 +24,8 @@ export class ConnectError extends Schema.TaggedErrorClass<ConnectError>()("MCP.C
|
||||||
|
|
||||||
/** Handle over a connected MCP server that keeps the SDK `Client` out of the rest of core. */
|
/** Handle over a connected MCP server that keeps the SDK `Client` out of the rest of core. */
|
||||||
export interface Connection {
|
export interface Connection {
|
||||||
|
/** Server-supplied usage instructions from the initialize result, if any. */
|
||||||
|
readonly instructions: string | undefined
|
||||||
readonly onClose: (callback: () => void) => void
|
readonly onClose: (callback: () => void) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,6 +75,7 @@ export const connect = Effect.fnUntraced(function* (
|
||||||
if (Exit.isSuccess(exit)) {
|
if (Exit.isSuccess(exit)) {
|
||||||
yield* Effect.addFinalizer(() => Effect.promise(() => client.close()).pipe(Effect.ignore))
|
yield* Effect.addFinalizer(() => Effect.promise(() => client.close()).pipe(Effect.ignore))
|
||||||
return {
|
return {
|
||||||
|
instructions: client.getInstructions()?.trim() || undefined,
|
||||||
onClose: (callback) => {
|
onClose: (callback) => {
|
||||||
client.onclose = callback
|
client.onclose = callback
|
||||||
},
|
},
|
||||||
|
|
|
||||||
75
packages/core/src/mcp/guidance.ts
Normal file
75
packages/core/src/mcp/guidance.ts
Normal file
|
|
@ -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<Summary>) =>
|
||||||
|
[
|
||||||
|
"<mcp_instructions>",
|
||||||
|
...servers.flatMap((server) => [
|
||||||
|
` <server name="${server.server}">`,
|
||||||
|
...server.instructions.split("\n").map((line) => ` ${line}`),
|
||||||
|
" </server>",
|
||||||
|
]),
|
||||||
|
"</mcp_instructions>",
|
||||||
|
].join("\n")
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly load: (agent: AgentV2.Selection) => Effect.Effect<SystemContext.SystemContext>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@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] })
|
||||||
|
|
@ -55,7 +55,6 @@ export class ServerInfo extends Schema.Class<ServerInfo>("MCP.ServerInfo")({
|
||||||
export class ServerInstructions extends Schema.Class<ServerInstructions>("MCP.ServerInstructions")({
|
export class ServerInstructions extends Schema.Class<ServerInstructions>("MCP.ServerInstructions")({
|
||||||
server: ServerName,
|
server: ServerName,
|
||||||
instructions: Schema.String,
|
instructions: Schema.String,
|
||||||
tools: Schema.Array(Schema.String),
|
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class Tool extends Schema.Class<Tool>("MCP.Tool")({
|
export class Tool extends Schema.Class<Tool>("MCP.Tool")({
|
||||||
|
|
@ -271,7 +270,13 @@ export const layer = Layer.effect(
|
||||||
}),
|
}),
|
||||||
instructions: Effect.fn("MCP.instructions")(function* () {
|
instructions: Effect.fn("MCP.instructions")(function* () {
|
||||||
yield* whenAllReady
|
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* () {
|
prompts: Effect.fn("MCP.prompts")(function* () {
|
||||||
yield* whenAllReady
|
yield* whenAllReady
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import { SystemContext } from "../../system-context/index"
|
||||||
import { SystemContextRegistry } from "../../system-context/registry"
|
import { SystemContextRegistry } from "../../system-context/registry"
|
||||||
import { SkillGuidance } from "../../skill/guidance"
|
import { SkillGuidance } from "../../skill/guidance"
|
||||||
import { ReferenceGuidance } from "../../reference/guidance"
|
import { ReferenceGuidance } from "../../reference/guidance"
|
||||||
|
import { McpGuidance } from "../../mcp/guidance"
|
||||||
import { ToolRegistry } from "../../tool/registry"
|
import { ToolRegistry } from "../../tool/registry"
|
||||||
import { ToolOutputStore } from "../../tool-output-store"
|
import { ToolOutputStore } from "../../tool-output-store"
|
||||||
import { SessionContextEpoch } from "../context-epoch"
|
import { SessionContextEpoch } from "../context-epoch"
|
||||||
|
|
@ -102,6 +103,7 @@ export const layer = Layer.effect(
|
||||||
const systemContext = yield* SystemContextRegistry.Service
|
const systemContext = yield* SystemContextRegistry.Service
|
||||||
const skillGuidance = yield* SkillGuidance.Service
|
const skillGuidance = yield* SkillGuidance.Service
|
||||||
const referenceGuidance = yield* ReferenceGuidance.Service
|
const referenceGuidance = yield* ReferenceGuidance.Service
|
||||||
|
const mcpGuidance = yield* McpGuidance.Service
|
||||||
const snapshots = yield* Snapshot.Service
|
const snapshots = yield* Snapshot.Service
|
||||||
const db = (yield* Database.Service).db
|
const db = (yield* Database.Service).db
|
||||||
const compaction = yield* SessionCompaction.Service
|
const compaction = yield* SessionCompaction.Service
|
||||||
|
|
@ -160,7 +162,7 @@ export const layer = Layer.effect(
|
||||||
new TurnTransitionError({ _tag: "ContinueAfterOverflowCompaction", step })
|
new TurnTransitionError({ _tag: "ContinueAfterOverflowCompaction", step })
|
||||||
|
|
||||||
const loadSystemContext = (agent: AgentV2.Selection) =>
|
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",
|
concurrency: "unbounded",
|
||||||
}).pipe(Effect.map(SystemContext.combine))
|
}).pipe(Effect.map(SystemContext.combine))
|
||||||
|
|
||||||
|
|
@ -422,6 +424,7 @@ export const node = makeLocationNode({
|
||||||
SystemContextRegistry.node,
|
SystemContextRegistry.node,
|
||||||
SkillGuidance.node,
|
SkillGuidance.node,
|
||||||
ReferenceGuidance.node,
|
ReferenceGuidance.node,
|
||||||
|
McpGuidance.node,
|
||||||
SessionCompaction.node,
|
SessionCompaction.node,
|
||||||
Snapshot.node,
|
Snapshot.node,
|
||||||
Database.node,
|
Database.node,
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry
|
||||||
import { SystemContext } from "@opencode-ai/core/system-context"
|
import { SystemContext } from "@opencode-ai/core/system-context"
|
||||||
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
|
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
|
||||||
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
|
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
|
||||||
|
import { McpGuidance } from "@opencode-ai/core/mcp/guidance"
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { eq } from "drizzle-orm"
|
import { eq } from "drizzle-orm"
|
||||||
import { Effect, Layer } from "effect"
|
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 location = Location.layer({ directory: AbsolutePath.make("/project") }).pipe(Layer.provide(Project.defaultLayer))
|
||||||
const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
|
const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
|
||||||
const referenceGuidance = Layer.mock(ReferenceGuidance.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 config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))
|
||||||
const runner = SessionRunnerLLM.defaultLayer.pipe(
|
const runner = SessionRunnerLLM.defaultLayer.pipe(
|
||||||
Layer.provide(SessionCompaction.layer),
|
Layer.provide(SessionCompaction.layer),
|
||||||
|
|
@ -87,6 +89,7 @@ const runner = SessionRunnerLLM.defaultLayer.pipe(
|
||||||
Layer.provide(agents),
|
Layer.provide(agents),
|
||||||
Layer.provide(skillGuidance),
|
Layer.provide(skillGuidance),
|
||||||
Layer.provide(referenceGuidance),
|
Layer.provide(referenceGuidance),
|
||||||
|
Layer.provide(mcpGuidance),
|
||||||
Layer.provide(config),
|
Layer.provide(config),
|
||||||
)
|
)
|
||||||
const execution = Layer.effect(
|
const execution = Layer.effect(
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ import { SystemContext } from "@opencode-ai/core/system-context"
|
||||||
import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
|
import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
|
||||||
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
|
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
|
||||||
import { ReferenceGuidance } from "@opencode-ai/core/reference/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 { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { Location } from "@opencode-ai/core/location"
|
import { Location } from "@opencode-ai/core/location"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
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 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(
|
const config = Layer.succeed(
|
||||||
Config.Service,
|
Config.Service,
|
||||||
Config.Service.of({
|
Config.Service.of({
|
||||||
|
|
@ -248,6 +250,7 @@ const runner = SessionRunnerLLM.layer.pipe(
|
||||||
Layer.provide(agents),
|
Layer.provide(agents),
|
||||||
Layer.provide(skillGuidance),
|
Layer.provide(skillGuidance),
|
||||||
Layer.provide(referenceGuidance),
|
Layer.provide(referenceGuidance),
|
||||||
|
Layer.provide(mcpGuidance),
|
||||||
Layer.provide(config),
|
Layer.provide(config),
|
||||||
)
|
)
|
||||||
const execution = Layer.effect(
|
const execution = Layer.effect(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue