Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
38 changes: 34 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,19 @@ 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
tools: 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 +392,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 +439,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 +494,7 @@ export const layer = Layer.effect(
status: {},
clients: {},
defs: {},
instructions: {},
}

yield* Effect.forEach(
Expand All @@ -505,6 +516,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 +528,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 +558,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 +568,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 +607,18 @@ 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,
tools: (s.defs[name] ?? []).map((tool) => McpCatalog.toolName(name, tool.name)),
}))
})

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 +630,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 +676,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 +884,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 +971,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(agent, session.permission),
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
30 changes: 28 additions & 2 deletions packages/opencode/src/session/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ 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"
import { PermissionV1 } from "@opencode-ai/core/v1/permission"

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 +42,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: (agent: Agent.Info, permission?: PermissionV1.Ruleset) => Effect.Effect<string | undefined>
}

export class Service extends Context.Service<Service, Interface>()("@opencode/SystemPrompt") {}
Expand All @@ -48,6 +51,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 +106,36 @@ export const layer = Layer.effect(
Skill.fmt(list, { verbose: true }),
].join("\n")
}),

mcp: Effect.fn("SystemPrompt.mcp")(function* (agent: Agent.Info, permission?: PermissionV1.Ruleset) {
const ruleset = Permission.merge(agent.permission, permission ?? [])
const instructions = (yield* mcp.instructions()).filter(
(item) => item.tools.length === 0 || Permission.disabled(item.tools, ruleset).size < item.tools.length,
)
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"
59 changes: 59 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,60 @@ 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.",
tools: ["guide-server_test_tool"],
})
}),
),
{ 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