Skip to content
2 changes: 2 additions & 0 deletions packages/opencode/src/mcp/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ export function fetch<T extends { name: string }>(

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

export const toolName = (clientName: string, name: string) => sanitize(clientName) + "_" + sanitize(name)

export function prompts(client: Client, timeout?: number) {
if (!client.getServerCapabilities()?.prompts) return Promise.resolve([])
return paginate(
Expand Down
36 changes: 32 additions & 4 deletions packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ interface CreateResult {
mcpClient?: MCPClient
status: Status
defs?: MCPToolDef[]
instructions?: string
}

interface AuthResult {
Expand All @@ -155,11 +156,18 @@ interface State {
status: Record<string, Status>
clients: Record<string, MCPClient>
defs: Record<string, MCPToolDef[]>
instructions: Record<string, string>
}

export interface ServerInstructions {
name: string
instructions: string
}

export interface Interface {
readonly status: () => Effect.Effect<Record<string, Status>>
readonly clients: () => Effect.Effect<Record<string, MCPClient>>
readonly instructions: () => Effect.Effect<ServerInstructions[]>
readonly tools: () => Effect.Effect<Record<string, Tool>>
readonly prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>>
readonly resources: (clientName?: string) => Effect.Effect<Record<string, ResourceInfo & { client: string }>>
Expand Down Expand Up @@ -383,7 +391,7 @@ export const layer = Layer.effect(
if (!listed) {
return yield* Effect.fail(new Error("Failed to get tools"))
}
return { mcpClient, status, defs: listed } satisfies CreateResult
return { mcpClient, status, defs: listed, instructions: mcpClient.getInstructions()?.trim() } satisfies CreateResult
}).pipe(
Effect.catchCause((cause) =>
Effect.tryPromise(() => mcpClient.close()).pipe(Effect.ignore, Effect.andThen(Effect.failCause(cause))),
Expand Down Expand Up @@ -430,6 +438,7 @@ export const layer = Layer.effect(
if (s.clients[name] !== client) return
delete s.clients[name]
delete s.defs[name]
delete s.instructions[name]
s.status[name] = { status: "failed", error: "Connection closed" }
bridge.fork(
Effect.logWarning("MCP connection closed", { server: name }).pipe(
Expand Down Expand Up @@ -484,6 +493,7 @@ export const layer = Layer.effect(
status: {},
clients: {},
defs: {},
instructions: {},
}

yield* Effect.forEach(
Expand All @@ -505,6 +515,7 @@ export const layer = Layer.effect(
if (result.mcpClient) {
s.clients[key] = result.mcpClient
s.defs[key] = result.defs!
if (result.instructions) s.instructions[key] = result.instructions
watch(s, key, result.mcpClient, bridge, mcp.timeout)
}
}),
Expand All @@ -516,6 +527,7 @@ export const layer = Layer.effect(
const clients = Object.values(s.clients)
s.clients = {}
s.defs = {}
s.instructions = {}
yield* Effect.forEach(
clients,
(client) =>
Expand Down Expand Up @@ -545,6 +557,7 @@ export const layer = Layer.effect(
const client = s.clients[name]
delete s.clients[name]
delete s.defs[name]
delete s.instructions[name]
if (!client) return Effect.void
return Effect.tryPromise(() => client.close()).pipe(Effect.ignore)
}
Expand All @@ -554,13 +567,16 @@ export const layer = Layer.effect(
name: string,
client: MCPClient,
listed: MCPToolDef[],
instructions: string | undefined,
timeout?: number,
) {
const bridge = yield* EffectBridge.make()
const previous = s.clients[name]
s.status[name] = { status: "connected" }
s.clients[name] = client
s.defs[name] = listed
if (instructions) s.instructions[name] = instructions
else delete s.instructions[name]
watch(s, name, client, bridge, timeout)
if (previous) yield* Effect.tryPromise(() => previous.close()).pipe(Effect.ignore)
return s.status[name]
Expand Down Expand Up @@ -590,6 +606,17 @@ export const layer = Layer.effect(
return s.clients
})

const instructions = Effect.fn("MCP.instructions")(function* () {
const s = yield* InstanceState.get(state)
return Object.entries(s.instructions)
.filter(([name]) => s.status[name]?.status === "connected")
.sort(([a], [b]) => a.localeCompare(b))
.map(([name, item]) => ({
name,
instructions: item,
}))
})

const createAndStore = Effect.fn("MCP.createAndStore")(function* (name: string, mcp: ConfigMCPV1.Info) {
const s = yield* InstanceState.get(state)
const result = yield* create(name, mcp)
Expand All @@ -601,7 +628,7 @@ export const layer = Layer.effect(
return result.status
}

return yield* storeClient(s, name, result.mcpClient, result.defs!, mcp.timeout)
return yield* storeClient(s, name, result.mcpClient, result.defs!, result.instructions, mcp.timeout)
})

const add = Effect.fn("MCP.add")(function* (name: string, mcp: ConfigMCPV1.Info) {
Expand Down Expand Up @@ -647,7 +674,7 @@ export const layer = Layer.effect(
}
const timeout = requestTimeout(s, clientName, mcpConfig, defaultTimeout)
for (const mcpTool of listed) {
const key = McpCatalog.sanitize(clientName) + "_" + McpCatalog.sanitize(mcpTool.name)
const key = McpCatalog.toolName(clientName, mcpTool.name)
result[key] = McpCatalog.convertTool(mcpTool, client, timeout)
}
}
Expand Down Expand Up @@ -855,7 +882,7 @@ export const layer = Layer.effect(

const s = yield* InstanceState.get(state)
yield* auth.clearOAuthState(mcpName)
return yield* storeClient(s, mcpName, client, listed, mcpConfig.timeout)
return yield* storeClient(s, mcpName, client, listed, client.getInstructions()?.trim(), mcpConfig.timeout)
}

const callbackPromise = McpOAuthCallback.waitForCallback(result.oauthState, mcpName)
Expand Down Expand Up @@ -942,6 +969,7 @@ export const layer = Layer.effect(
return Service.of({
status,
clients,
instructions,
tools,
prompts,
resources,
Expand Down
10 changes: 8 additions & 2 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1356,13 +1356,19 @@ export const layer = Layer.effect(

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

const [skills, env, instructions, modelMsgs] = yield* Effect.all([
const [skills, env, instructions, mcpInstructions, modelMsgs] = yield* Effect.all([
sys.skills(agent),
sys.environment(model),
instruction.system().pipe(Effect.orDie),
sys.mcp(),
MessageV2.toModelMessagesEffect(msgs, model),
])
const system = [...env, ...instructions, ...(skills ? [skills] : [])]
const system = [
...env,
...instructions,
...(mcpInstructions ? [mcpInstructions] : []),
...(skills ? [skills] : []),
]
const format = lastUser.format ?? { type: "text" as const }
if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
const result = yield* handle.process({
Expand Down
26 changes: 24 additions & 2 deletions packages/opencode/src/session/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { Reference } from "@opencode-ai/core/reference"
import { MCP } from "@/mcp"

export function provider(model: Provider.Model) {
if (model.api.id.includes("gpt-4") || model.api.id.includes("o1") || model.api.id.includes("o3"))
Expand All @@ -40,6 +41,7 @@ export function provider(model: Provider.Model) {
export interface Interface {
readonly environment: (model: Provider.Model) => Effect.Effect<string[]>
readonly skills: (agent: Agent.Info) => Effect.Effect<string | undefined>
readonly mcp: () => Effect.Effect<string | undefined>
}

export class Service extends Context.Service<Service, Interface>()("@opencode/SystemPrompt") {}
Expand All @@ -48,6 +50,7 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const skill = yield* Skill.Service
const mcp = yield* MCP.Service
const locations = yield* LocationServiceMap

return Service.of({
Expand Down Expand Up @@ -102,14 +105,33 @@ export const layer = Layer.effect(
Skill.fmt(list, { verbose: true }),
].join("\n")
}),

mcp: Effect.fn("SystemPrompt.mcp")(function* () {
const instructions = yield* mcp.instructions()
if (instructions.length === 0) return

return [
"<mcp_instructions>",
...instructions.flatMap((item) => [
` <server name="${item.name}">`,
...item.instructions.split("\n").map((line) => ` ${line}`),
" </server>",
]),
"</mcp_instructions>",
].join("\n")
}),
})
}),
)

export const defaultLayer = layer.pipe(Layer.provide(Skill.defaultLayer), Layer.provide(LocationServiceMap.layer))
export const defaultLayer = layer.pipe(
Layer.provide(Skill.defaultLayer),
Layer.provide(MCP.defaultLayer),
Layer.provide(LocationServiceMap.layer),
)

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

export const node = LayerNode.make(layer, [Skill.node, locationServiceMapNode])
export const node = LayerNode.make(layer, [Skill.node, MCP.node, locationServiceMapNode])

export * as SystemPrompt from "./system"
58 changes: 58 additions & 0 deletions packages/opencode/test/mcp/lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { TestInstance } from "../fixture/fixture"
interface MockClientState {
capabilities: { tools?: object; prompts?: object; resources?: object }
capabilitiesShouldThrow: boolean
instructions?: string
tools: Array<{ name: string; description?: string; inputSchema: object; outputSchema?: object }>
listToolsCalls: number
listPromptsCalls: number
Expand Down Expand Up @@ -188,6 +189,10 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
return this._state?.capabilities
}

getInstructions() {
return this._state?.instructions
}

async listTools(params?: { cursor?: string }) {
if (this._state) this._state.listToolsCalls++
if (this._state?.listToolsShouldFail) {
Expand Down Expand Up @@ -347,6 +352,59 @@ it.instance(
{ config: { mcp: {} } },
)

it.instance(
"instructions() returns connected server instructions with tool names",
() =>
MCP.Service.use((mcp: MCPNS.Interface) =>
Effect.gen(function* () {
lastCreatedClientName = "guide-server"
const serverState = getOrCreateClientState("guide-server")
serverState.instructions = "Use lookup before mutate."

yield* mcp.add("guide-server", {
type: "local",
command: ["echo", "test"],
})

expect(yield* mcp.instructions()).toContainEqual({
name: "guide-server",
instructions: "Use lookup before mutate.",
})
}),
),
{ config: { mcp: {} } },
)

it.instance(
"instructions() omits empty and disconnected server instructions",
() =>
MCP.Service.use((mcp: MCPNS.Interface) =>
Effect.gen(function* () {
lastCreatedClientName = "temporary-server"
getOrCreateClientState("temporary-server").instructions = "Temporary guidance."

yield* mcp.add("temporary-server", {
type: "local",
command: ["echo", "test"],
})
yield* mcp.disconnect("temporary-server")

lastCreatedClientName = "blank-server"
getOrCreateClientState("blank-server").instructions = " "

yield* mcp.add("blank-server", {
type: "local",
command: ["echo", "test"],
})

const instructions = yield* mcp.instructions()
expect(instructions.some((item) => item.name === "temporary-server")).toBe(false)
expect(instructions.some((item) => item.name === "blank-server")).toBe(false)
}),
),
{ config: { mcp: {} } },
)

it.instance(
"follows cursors when listing tools, prompts, and resources",
() =>
Expand Down
Loading
Loading