Compare commits

...

18 Commits

Author SHA1 Message Date
Aiden Cline 8c2df36405 refactor(opencode): name deferred MCP instructions 2026-06-29 08:48:46 -05:00
Aiden Cline d3e2382f53 refactor(opencode): separate deferred MCP prompt 2026-06-29 08:22:26 -05:00
Aiden Cline 421e33e531 fix(opencode): remove deferred MCP step gate 2026-06-29 08:17:53 -05:00
Aiden Cline c2d619741b refactor(opencode): reuse deferred MCP resolution 2026-06-29 07:59:55 -05:00
Aiden Cline 0bff5bf389 fix(opencode): avoid deferred MCP search at step limit 2026-06-29 00:39:48 -05:00
Aiden Cline ca359adafa fix(opencode): honor disabled deferred MCP tools 2026-06-29 00:32:08 -05:00
Aiden Cline b8f1ff424a refactor(opencode): validate deferred tool schemas 2026-06-29 00:24:51 -05:00
Aiden Cline d31d9d2287 Merge branch 'dev' into deferred-tools 2026-06-29 00:03:16 -05:00
Aiden Cline dc295c9bd3 Revert "test(core): fix layer node type expectation"
This reverts commit a2fc3057e7.
2026-06-28 23:42:40 -05:00
Aiden Cline a2fc3057e7 test(core): fix layer node type expectation 2026-06-28 23:36:10 -05:00
Aiden Cline 0629920fc5 Merge remote-tracking branch 'origin/dev' into deferred-tools 2026-06-28 23:34:31 -05:00
Aiden Cline b027484365 test(opencode): make deferred MCP fixture resource-free 2026-06-28 23:33:42 -05:00
Aiden Cline e8b6ff6be3 refactor(opencode): simplify deferred MCP tools 2026-06-28 22:52:04 -05:00
Aiden Cline 9a90e14c90 feat(opencode): list deferred MCP servers 2026-06-28 21:51:32 -05:00
Aiden Cline 8e84d9663b refactor(opencode): tidy deferred tool test fixtures 2026-06-28 21:40:45 -05:00
Aiden Cline 4ce8c1dd4d Revert "fix(opencode): avoid deferred MCP search near step limit"
This reverts commit 15bc8ab920.
2026-06-28 21:28:58 -05:00
Aiden Cline 15bc8ab920 fix(opencode): avoid deferred MCP search near step limit 2026-06-28 21:24:28 -05:00
Aiden Cline 0ebbdd71be feat(opencode): defer large MCP tool catalogs 2026-06-28 21:09:26 -05:00
5 changed files with 1046 additions and 483 deletions
@@ -51,6 +51,7 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
experimentalNativeLlm: bool("OPENCODE_EXPERIMENTAL_NATIVE_LLM"),
experimentalToolSearch: enabledByExperimental("OPENCODE_EXPERIMENTAL_TOOL_SEARCH"),
experimentalWebSockets: bool("OPENCODE_EXPERIMENTAL_WEBSOCKETS"),
client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")),
}) {}
+787
View File
@@ -0,0 +1,787 @@
import { Agent } from "@/agent/agent"
import { EffectBridge } from "@/effect/bridge"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { MCP } from "@/mcp"
import { McpCatalog } from "@/mcp/catalog"
import { Permission } from "@/permission"
import { Provider } from "@/provider/provider"
import { ProviderTransform } from "@/provider/transform"
import { ToolJsonSchema } from "@/tool/json-schema"
import { Truncate } from "@/tool/truncate"
import { isRecord } from "@/util/record"
import { Token } from "@/util/token"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { asSchema, jsonSchema, tool, type Tool, type ToolExecutionOptions } from "ai"
import { Effect, Schema } from "effect"
import { Plugin } from "@/plugin"
import type { TaskPromptOps } from "@/tool/task"
import type { Context } from "@/tool/tool"
import { Session } from "./session"
import { SessionProcessor } from "./processor"
import { PartID } from "./schema"
const DEFERRED_TOOL_TOOLS = {
search: "search_deferred_tools",
call: "call_deferred_tool",
} as const
const MIN_DEFERRED_MCP_SCHEMA_TOKENS = 8_000
const DEFAULT_DEFERRED_TOOL_LIMIT = 10
const MAX_DEFERRED_TOOL_LIMIT = 20
const MAX_MCP_RESOURCE_BLOB_BYTES = 10 * 1024 * 1024
const SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES = new Set([
"application/pdf",
"image/gif",
"image/jpeg",
"image/png",
"image/webp",
])
export const DEFERRED_TOOL_SYSTEM_PROMPT = `Deferred tools are separate from direct tools.
- Some MCP server tools may be hidden behind \`search_deferred_tools\` and \`call_deferred_tool\` to keep provider tool schemas small.
- Use \`search_deferred_tools\` to find deferred tools by purpose, tool ID, description, or parameter names.
- \`search_deferred_tools\` returns full schemas by default. For broad searches, omit schemas and search again with schemas if needed.
- Use \`call_deferred_tool\` only with a \`tool_id\` copied from \`search_deferred_tools\` results.
- Never use deferred tools for direct tools already listed in the current tool list, including shell, file, search, edit, web, task, LSP, question, or apply_patch tools.`
interface Input extends DeferredInput {
model: Provider.Model
processor: Pick<SessionProcessor.Handle, "message" | "updateToolCall" | "completeToolCall">
bypassAgentCheck: boolean
promptOps: TaskPromptOps
}
interface DeferredInput {
agent: Agent.Info
session: Session.Info
messages: SessionV1.WithParts[]
}
interface DeferredToolDescriptor {
id: string
description: string
inputSchema: unknown
searchText: string
}
const SearchDeferredToolsParameters = Schema.Struct({
query: Schema.String.annotate({
description:
"Words describing the external capability to find. Use an empty string to list the top deferred tools.",
}),
limit: Schema.optional(
Schema.Int.check(Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(MAX_DEFERRED_TOOL_LIMIT)),
).annotate({
description: `Maximum number of matches to return. Defaults to ${DEFAULT_DEFERRED_TOOL_LIMIT}; maximum ${MAX_DEFERRED_TOOL_LIMIT}.`,
}),
include_schema: Schema.optional(Schema.Boolean).annotate({
description:
"Whether to include input_schema for each match. Defaults to true. Set false for broad searches to return only tool_id and description.",
}),
})
const CallDeferredToolParameters = Schema.Struct({
tool_id: Schema.String.annotate({
description: "Exact deferred tool_id copied from search_deferred_tools results.",
}),
arguments: Schema.Record(Schema.String, Schema.Unknown).annotate({
description: "JSON arguments for the deferred tool, matching its input_schema from search_deferred_tools.",
}),
})
const decodeSearchDeferredToolsParameters = Schema.decodeUnknownEffect(SearchDeferredToolsParameters)
const decodeCallDeferredToolParameters = Schema.decodeUnknownEffect(CallDeferredToolParameters)
type McpToolContent =
| { type: "text"; text: string }
| { type: "image"; mimeType: string; data: string }
| { type: "resource"; resource: Record<string, unknown> }
export const resolve = Effect.fn("SessionMcpTools.resolve")(function* (input: Input) {
const tools: Record<string, Tool> = {}
const run = yield* EffectBridge.make()
const plugin = yield* Plugin.Service
const permission = yield* Permission.Service
const mcp = yield* MCP.Service
const truncate = yield* Truncate.Service
const context = (args: Record<string, unknown>, options: ToolExecutionOptions): Context => ({
sessionID: input.session.id,
abort: options.abortSignal!,
messageID: input.processor.message.id,
callID: options.toolCallId,
extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck, promptOps: input.promptOps },
agent: input.agent.name,
messages: input.messages,
metadata: (val) =>
input.processor.updateToolCall(options.toolCallId, (match) => {
if (!["running", "pending"].includes(match.state.status)) return match
return {
...match,
state: {
title: val.title,
metadata: val.metadata,
status: "running",
input: args,
time: { start: Date.now() },
},
}
}),
ask: (req) =>
permission
.ask({
...req,
sessionID: input.session.id,
tool: { messageID: input.processor.message.id, callID: options.toolCallId },
ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),
})
.pipe(Effect.orDie),
})
const executeMcpTool = Effect.fnUntraced(function* (
key: string,
execute: NonNullable<Tool["execute"]>,
args: unknown,
opts: ToolExecutionOptions,
) {
const ctx = context(toRecord(args), opts)
yield* plugin.trigger(
"tool.execute.before",
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId },
{ args },
)
const rawResult = yield* Effect.gen(function* () {
yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] })
return yield* Effect.promise(() => Promise.resolve(execute(args, opts)))
}).pipe(
Effect.withSpan("Tool.execute", {
attributes: {
"tool.name": key,
"tool.call_id": opts.toolCallId,
"session.id": ctx.sessionID,
"message.id": input.processor.message.id,
},
}),
)
yield* plugin.trigger(
"tool.execute.after",
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
rawResult,
)
const content = mcpToolContent(rawResult)
const textParts: string[] = []
const attachments: Omit<SessionV1.FilePart, "id" | "sessionID" | "messageID">[] = []
for (const contentItem of content) {
if (contentItem.type === "text") textParts.push(contentItem.text)
else if (contentItem.type === "image") {
attachments.push({
type: "file",
mime: contentItem.mimeType,
url: `data:${contentItem.mimeType};base64,${contentItem.data}`,
})
} else if (contentItem.type === "resource") {
const resource = contentItem.resource
if (typeof resource.text === "string") textParts.push(resource.text)
if (typeof resource.blob === "string") {
const uri = typeof resource.uri === "string" ? resource.uri : "resource"
const mime = typeof resource.mimeType === "string" ? resource.mimeType : "application/octet-stream"
const size = base64Size(resource.blob)
if (!SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES.has(mime)) {
textParts.push(
`[Binary MCP resource omitted: ${uri} (${mime}, ${formatBytes(size)}) is not a supported attachment type]`,
)
continue
}
if (size > MAX_MCP_RESOURCE_BLOB_BYTES) {
textParts.push(
`[Binary MCP resource omitted: ${uri} (${mime}, ${formatBytes(size)}) exceeds ${formatBytes(MAX_MCP_RESOURCE_BLOB_BYTES)}]`,
)
continue
}
attachments.push({
type: "file",
mime,
url: `data:${mime};base64,${resource.blob}`,
filename: uri,
})
}
}
}
const truncated = yield* truncate.output(textParts.join("\n\n"), {}, input.agent)
const output = {
title: "",
metadata: {
...(isRecord(rawResult) && isRecord(rawResult.metadata) ? rawResult.metadata : {}),
truncated: truncated.truncated,
...(truncated.truncated && { outputPath: truncated.outputPath }),
},
output: truncated.content,
attachments: attachments.map((attachment) => ({
...attachment,
id: PartID.ascending(),
sessionID: ctx.sessionID,
messageID: input.processor.message.id,
})),
content,
}
if (opts.abortSignal?.aborted) {
yield* input.processor.completeToolCall(opts.toolCallId, output)
}
return output
})
const clients = yield* mcp.clients()
const hasMcpResourceServer = Object.values(clients).some((client) => !!client.getServerCapabilities()?.resources)
if (hasMcpResourceServer) addResourceTools(tools, { input, run, mcp, plugin, truncate, context })
const mcpTools = yield* mcp.tools()
const deferred = yield* deferredState(input, mcpTools)
if (deferred) {
addDeferredTools(tools, {
input,
run,
plugin,
truncate,
context,
deferredDescriptors: deferred.descriptors,
allowedMcpTools: deferred.allowedMcpTools,
executeMcpTool,
})
return tools
}
for (const [key, item] of Object.entries(mcpTools)) {
const execute = item.execute
if (!execute) continue
const schema = yield* Effect.promise(() => Promise.resolve(asSchema(item.inputSchema).jsonSchema))
const transformed = ProviderTransform.schema(input.model, { ...schema, properties: schema.properties ?? {} })
item.inputSchema = jsonSchema(transformed)
item.execute = (args, opts) => run.promise(executeMcpTool(key, execute, args, opts))
tools[key] = item
}
return tools
})
export const systemPrompt = Effect.fn("SessionMcpTools.systemPrompt")(function* (input: DeferredInput) {
const mcp = yield* MCP.Service
const deferred = yield* deferredState(input, yield* mcp.tools())
if (!deferred) return undefined
return deferredSystemPrompt(deferred.allowedMcpTools, yield* mcp.clients())
})
function deferredState(input: DeferredInput, mcpTools: Record<string, Tool>) {
return Effect.gen(function* () {
const flags = yield* RuntimeFlags.Service
if (!flags.experimentalToolSearch) return undefined
const mcpDisabled = Permission.disabled(
Object.keys(mcpTools),
Permission.merge(input.agent.permission, input.session.permission ?? []),
)
const userTools = currentUserToolOverrides(input.messages)
const allowedMcpTools = Object.fromEntries(
Object.entries(mcpTools).filter(([key]) => userTools?.[key] !== false && !mcpDisabled.has(key)),
)
if (Object.keys(allowedMcpTools).length === 0) return undefined
const descriptors = yield* deferredToolDescriptors(allowedMcpTools)
if (deferredToolSchemaTokens(descriptors) < MIN_DEFERRED_MCP_SCHEMA_TOKENS) return undefined
return { allowedMcpTools, descriptors }
})
}
function deferredSystemPrompt(allowedMcpTools: Record<string, Tool>, clients: Record<string, unknown>) {
const servers = deferredServerSummaries(Object.keys(allowedMcpTools), Object.keys(clients))
return [
DEFERRED_TOOL_SYSTEM_PROMPT,
servers.length === 0
? undefined
: [
"Deferred MCP servers available through `search_deferred_tools`:",
...servers.map((server) => `- ${server.name}: ${server.count} tool${server.count === 1 ? "" : "s"}`),
].join("\n"),
]
.filter((part): part is string => part !== undefined)
.join("\n\n")
}
function addResourceTools(
tools: Record<string, Tool>,
deps: {
input: Input
run: EffectBridge.Shape
mcp: MCP.Interface
plugin: Plugin.Interface
truncate: Truncate.Interface
context: (args: Record<string, unknown>, options: ToolExecutionOptions) => Context
},
) {
const addListTool = (spec: {
id: string
description: string
serverDescription: string
title: string
resultKey: "resources" | "resourceTemplates"
sortKey: "uri" | "uriTemplate"
list: (server?: string) => Effect.Effect<Record<string, Record<string, unknown> & { client: string; name: string }>>
}) => {
tools[spec.id] = tool({
description: spec.description,
inputSchema: jsonSchema(
ProviderTransform.schema(deps.input.model, {
type: "object",
properties: { server: { type: "string", description: spec.serverDescription } },
additionalProperties: false,
}),
),
execute(args, opts) {
return deps.run.promise(
Effect.gen(function* () {
const server = optionalString(toRecord(args), "server")
const ctx = deps.context(toRecord(args), opts)
const resourceServers = Object.entries(yield* deps.mcp.clients())
.filter((entry) => !!entry[1].getServerCapabilities()?.resources)
.map((entry) => entry[0])
.sort((a, b) => a.localeCompare(b))
if (server && !resourceServers.includes(server)) {
throw new Error(
resourceServers.length === 0
? `MCP server "${server}" does not support resources`
: `MCP server "${server}" does not support resources. Available resource servers: ${resourceServers.join(", ")}`,
)
}
const permissionPatterns = server ? [`mcp:${server}:*`] : resourceServers.map((item) => `mcp:${item}:*`)
yield* deps.plugin.trigger(
"tool.execute.before",
{ tool: spec.id, sessionID: ctx.sessionID, callID: opts.toolCallId },
{ args },
)
yield* ctx.ask({
permission: "read",
metadata: server ? { server } : {},
patterns: permissionPatterns,
always: permissionPatterns,
})
const filtered = Object.values(yield* spec.list(server))
.filter((item) => !server || item.client === server)
.toSorted((a, b) =>
(a.client + "\u0000" + a.name + "\u0000" + String(a[spec.sortKey] ?? "")).localeCompare(
b.client + "\u0000" + b.name + "\u0000" + String(b[spec.sortKey] ?? ""),
),
)
const truncated = yield* deps.truncate.output(
JSON.stringify(
{
[spec.resultKey]: filtered.map((item) => {
const result = Object.fromEntries(Object.entries(item).filter((entry) => entry[0] !== "client"))
return { ...result, server: item.client }
}),
},
null,
2,
),
{},
deps.input.agent,
)
const output = {
title: server ? `${spec.title}: ${server}` : spec.title,
metadata: {
count: filtered.length,
servers: resourceServers,
...(server ? { server } : {}),
truncated: truncated.truncated,
...(truncated.truncated && { outputPath: truncated.outputPath }),
},
output: truncated.content,
}
yield* deps.plugin.trigger(
"tool.execute.after",
{ tool: spec.id, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
output,
)
if (opts.abortSignal?.aborted) yield* deps.input.processor.completeToolCall(opts.toolCallId, output)
return output
}),
)
},
})
}
addListTool({
id: "list_mcp_resources",
description:
"Lists resources provided by connected MCP servers. Resources provide context such as files, database schemas, or application-specific information.",
serverDescription: "Optional MCP server name. When omitted, lists resources from every connected server.",
title: "MCP resources",
resultKey: "resources",
sortKey: "uri",
list: deps.mcp.resources,
})
addListTool({
id: "list_mcp_resource_templates",
description:
"Lists resource templates provided by connected MCP servers. Resource templates are parameterized resources that can be read after filling in their URI template.",
serverDescription: "Optional MCP server name. When omitted, lists resource templates from every connected server.",
title: "MCP resource templates",
resultKey: "resourceTemplates",
sortKey: "uriTemplate",
list: deps.mcp.resourceTemplates,
})
tools.read_mcp_resource = tool({
description:
"Read a specific resource from an MCP server using the server name and resource URI. The URI is an MCP identifier and does not need to be a file URL.",
inputSchema: jsonSchema(
ProviderTransform.schema(deps.input.model, {
type: "object",
properties: {
server: {
type: "string",
description: "MCP server name exactly as returned by list_mcp_resources.",
},
uri: {
type: "string",
description: "Resource URI to read. Use the exact URI string returned by list_mcp_resources.",
},
},
required: ["server", "uri"],
additionalProperties: false,
}),
),
execute(args, opts) {
return deps.run.promise(
Effect.gen(function* () {
const argRecord = toRecord(args)
const server = requiredString(argRecord, "server")
const uri = requiredString(argRecord, "uri")
const ctx = deps.context(argRecord, opts)
const client = (yield* deps.mcp.clients())[server]
if (!client) throw new Error(`MCP server "${server}" is not connected`)
if (!client.getServerCapabilities()?.resources)
throw new Error(`MCP server "${server}" does not support resources`)
yield* deps.plugin.trigger(
"tool.execute.before",
{ tool: "read_mcp_resource", sessionID: ctx.sessionID, callID: opts.toolCallId },
{ args },
)
yield* ctx.ask({
permission: "read",
metadata: { server, uri },
patterns: [`mcp:${server}:${uri}`],
always: [`mcp:${server}:*`],
})
const content = yield* deps.mcp.readResource(server, uri)
if (!content) throw new Error(`Failed to read MCP resource: ${server}/${uri}`)
const items = (Array.isArray(content.contents) ? content.contents : [content.contents]).filter(isRecord)
const textParts: string[] = []
const attachments: Omit<SessionV1.FilePart, "id" | "sessionID" | "messageID">[] = []
for (const item of items) {
const itemUri = typeof item.uri === "string" ? item.uri : uri
const mime = typeof item.mimeType === "string" ? item.mimeType : "application/octet-stream"
if ("text" in item && typeof item.text === "string") {
textParts.push(`Resource: ${itemUri}\nMIME: ${mime}\n${item.text}`)
continue
}
if (!("blob" in item) || typeof item.blob !== "string") {
textParts.push(`[MCP resource content without text or blob: ${itemUri}]`)
continue
}
const size = base64Size(item.blob)
if (!SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES.has(mime)) {
textParts.push(
`[Binary MCP resource omitted: ${itemUri} (${mime}, ${formatBytes(size)}) is not a supported attachment type]`,
)
continue
}
if (size > MAX_MCP_RESOURCE_BLOB_BYTES) {
textParts.push(
`[Binary MCP resource omitted: ${itemUri} (${mime}, ${formatBytes(size)}) exceeds ${formatBytes(MAX_MCP_RESOURCE_BLOB_BYTES)}]`,
)
continue
}
textParts.push(`[Binary MCP resource attached: ${itemUri} (${mime})]`)
attachments.push({
type: "file",
mime,
url: `data:${mime};base64,${item.blob}`,
filename: itemUri,
})
}
const truncated = yield* deps.truncate.output(
textParts.join("\n\n") || `MCP resource ${uri} from ${server} returned no contents.`,
{},
deps.input.agent,
)
const output = {
title: `MCP resource: ${uri}`,
metadata: {
server,
uri,
contents: items.length,
attachments: attachments.length,
truncated: truncated.truncated,
...(truncated.truncated && { outputPath: truncated.outputPath }),
},
output: truncated.content,
attachments: attachments.map((attachment) => ({
...attachment,
id: PartID.ascending(),
sessionID: ctx.sessionID,
messageID: deps.input.processor.message.id,
})),
}
yield* deps.plugin.trigger(
"tool.execute.after",
{ tool: "read_mcp_resource", sessionID: ctx.sessionID, callID: opts.toolCallId, args },
output,
)
if (opts.abortSignal?.aborted) yield* deps.input.processor.completeToolCall(opts.toolCallId, output)
return output
}),
)
},
})
}
function addDeferredTools(
tools: Record<string, Tool>,
deps: {
input: Input
run: EffectBridge.Shape
plugin: Plugin.Interface
truncate: Truncate.Interface
context: (args: Record<string, unknown>, options: ToolExecutionOptions) => Context
deferredDescriptors: DeferredToolDescriptor[]
allowedMcpTools: Record<string, Tool>
executeMcpTool: (
key: string,
execute: NonNullable<Tool["execute"]>,
args: unknown,
opts: ToolExecutionOptions,
) => Effect.Effect<unknown>
},
) {
tools[DEFERRED_TOOL_TOOLS.search] = tool({
description:
"Search only deferred tools. Deferred tools are not listed as direct tools; currently they are MCP server tools hidden to keep the provider tool list small. Do not use this for direct tools such as shell, file, edit, grep, glob, web, task, LSP, question, or apply_patch tools.",
inputSchema: jsonSchema(
ProviderTransform.schema(deps.input.model, ToolJsonSchema.fromSchema(SearchDeferredToolsParameters)),
),
execute(args, opts) {
return deps.run.promise(
Effect.gen(function* () {
const params = yield* decodeSearchDeferredToolsParameters(args)
const ctx = deps.context(toRecord(args), opts)
yield* deps.plugin.trigger(
"tool.execute.before",
{ tool: DEFERRED_TOOL_TOOLS.search, sessionID: ctx.sessionID, callID: opts.toolCallId },
{ args },
)
const matches = searchDeferredTools(
deps.deferredDescriptors,
params.query,
params.limit ?? DEFAULT_DEFERRED_TOOL_LIMIT,
)
const truncated = yield* deps.truncate.output(
JSON.stringify(
{
tools: matches.map((descriptor) => ({
tool_id: descriptor.id,
description: descriptor.description,
...((params.include_schema ?? true) ? { input_schema: descriptor.inputSchema } : {}),
})),
},
null,
2,
),
{},
deps.input.agent,
)
const output = {
title: "Deferred tools search",
metadata: {
query: params.query,
includeSchema: params.include_schema ?? true,
count: matches.length,
total: deps.deferredDescriptors.length,
truncated: truncated.truncated,
...(truncated.truncated && { outputPath: truncated.outputPath }),
},
output: truncated.content,
}
yield* deps.plugin.trigger(
"tool.execute.after",
{ tool: DEFERRED_TOOL_TOOLS.search, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
output,
)
if (opts.abortSignal?.aborted) yield* deps.input.processor.completeToolCall(opts.toolCallId, output)
return output
}),
)
},
})
tools[DEFERRED_TOOL_TOOLS.call] = tool({
description:
"Call one deferred tool by tool_id after finding it with search_deferred_tools. Do not use this for direct tools such as shell, file, edit, grep, glob, web, task, LSP, question, or apply_patch tools.",
inputSchema: jsonSchema(
ProviderTransform.schema(deps.input.model, ToolJsonSchema.fromSchema(CallDeferredToolParameters)),
),
execute(args, opts) {
return deps.run.promise(
Effect.gen(function* () {
const params = yield* decodeCallDeferredToolParameters(args)
const item = deps.allowedMcpTools[params.tool_id]
const execute = item?.execute
if (!item || !execute) {
throw new Error(
`Deferred tool "${params.tool_id}" is not available. Use search_deferred_tools and copy a tool_id from the results.`,
)
}
return yield* deps.executeMcpTool(params.tool_id, execute, params.arguments, opts)
}),
)
},
})
}
function toRecord(value: unknown) {
if (isRecord(value)) return value
return {}
}
function optionalString(args: Record<string, unknown>, key: string) {
const value = args[key]
if (value === undefined || value === null || value === "") return undefined
if (typeof value !== "string") throw new Error(`${key} must be a string`)
return value
}
function requiredString(args: Record<string, unknown>, key: string) {
const value = optionalString(args, key)
if (value) return value
throw new Error(`${key} is required`)
}
function deferredToolDescriptors(tools: Record<string, Tool>) {
return Effect.forEach(
Object.entries(tools).toSorted(([a], [b]) => a.localeCompare(b)),
([id, item]) =>
Effect.gen(function* () {
const schema = yield* Effect.promise(() => Promise.resolve(asSchema(item.inputSchema).jsonSchema))
return {
id,
description: item.description ?? "",
inputSchema: schema,
searchText: [
id,
item.description ?? "",
...(isRecord(schema) && isRecord(schema.properties)
? Object.entries(schema.properties).flatMap(([name, property]) =>
isRecord(property) && typeof property.description === "string"
? [name, property.description]
: [name],
)
: []),
].join("\n"),
}
}),
{ concurrency: "unbounded" },
)
}
function deferredToolSchemaTokens(descriptors: DeferredToolDescriptor[]) {
return Token.estimate(
JSON.stringify(
descriptors.map((descriptor) => ({
id: descriptor.id,
description: descriptor.description,
input_schema: descriptor.inputSchema,
})),
),
)
}
function deferredServerSummaries(toolIDs: string[], serverNames: string[]) {
const prefixes = serverNames
.map((name) => ({ name, prefix: McpCatalog.sanitize(name) + "_" }))
.toSorted((a, b) => b.prefix.length - a.prefix.length || a.name.localeCompare(b.name))
const counts = new Map<string, number>()
for (const toolID of toolIDs) {
const server = prefixes.find((candidate) => toolID.startsWith(candidate.prefix))
if (!server) continue
counts.set(server.name, (counts.get(server.name) ?? 0) + 1)
}
return Array.from(counts.entries())
.map(([name, count]) => ({ name, count }))
.toSorted((a, b) => a.name.localeCompare(b.name))
}
function searchDeferredTools(descriptors: DeferredToolDescriptor[], query: string, limit: number) {
const terms = query
.toLowerCase()
.split(/[^a-z0-9_-]+/)
.map((term) => term.trim())
.filter((term) => term.length > 0 && term !== "*")
return descriptors
.map((descriptor) => {
const id = descriptor.id.toLowerCase()
const description = descriptor.description.toLowerCase()
const searchText = descriptor.searchText.toLowerCase()
return {
descriptor,
score: terms.reduce(
(score, term) =>
score +
(id === term ? 20 : 0) +
(id.includes(term) ? 8 : 0) +
(description.includes(term) ? 4 : 0) +
(searchText.includes(term) ? 2 : 0),
0,
),
}
})
.filter((item) => terms.length === 0 || item.score > 0)
.toSorted((a, b) => b.score - a.score || a.descriptor.id.localeCompare(b.descriptor.id))
.slice(0, limit)
.map((item) => item.descriptor)
}
function currentUserToolOverrides(messages: SessionV1.WithParts[]) {
const user = messages.findLast((message) => message.info.role === "user")
return user?.info.role === "user" ? user.info.tools : undefined
}
function mcpToolContent(result: unknown): McpToolContent[] {
if (!isRecord(result) || !Array.isArray(result.content)) return []
return result.content.flatMap((item): McpToolContent[] => {
if (!isRecord(item) || typeof item.type !== "string") return []
if (item.type === "text" && typeof item.text === "string") return [{ type: "text", text: item.text }]
if (item.type === "image" && typeof item.mimeType === "string" && typeof item.data === "string") {
return [{ type: "image", mimeType: item.mimeType, data: item.data }]
}
if (item.type === "resource" && isRecord(item.resource)) return [{ type: "resource", resource: item.resource }]
return []
})
}
function base64Size(value: string) {
const trimmed = value.replace(/\s/g, "")
const padding = trimmed.endsWith("==") ? 2 : trimmed.endsWith("=") ? 1 : 0
return Math.max(0, Math.floor((trimmed.length * 3) / 4) - padding)
}
function formatBytes(value: number) {
if (value < 1024) return `${value} B`
if (value < 1024 * 1024) return `${Math.ceil(value / 1024)} KB`
return `${Math.ceil(value / (1024 * 1024))} MB`
}
export * as SessionMcpTools from "./mcp-tools"
+9 -1
View File
@@ -55,6 +55,7 @@ import { eq } from "drizzle-orm"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionReminders } from "./reminders"
import { SessionTools } from "./tools"
import { SessionMcpTools } from "./mcp-tools"
import { LLMEvent } from "@opencode-ai/llm"
// @ts-ignore
@@ -1237,6 +1238,7 @@ export const layer = Layer.effect(
Effect.provideService(ToolRegistry.Service, registry),
Effect.provideService(MCP.Service, mcp),
Effect.provideService(Truncate.Service, truncate),
Effect.provideService(RuntimeFlags.Service, flags),
)
if (lastUser.format?.type === "json_schema") {
@@ -1252,18 +1254,24 @@ export const layer = Layer.effect(
yield* summary.summarize({ sessionID, messageID: lastUser.id }).pipe(Effect.ignore, Effect.forkIn(scope))
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
const deferredMcpInstructions = SessionMcpTools.systemPrompt({ agent, session, messages: msgs }).pipe(
Effect.provideService(MCP.Service, mcp),
Effect.provideService(RuntimeFlags.Service, flags),
)
const [skills, env, instructions, mcpInstructions, modelMsgs] = yield* Effect.all([
const [skills, env, instructions, mcpInstructions, deferredToolInstructions, modelMsgs] = yield* Effect.all([
sys.skills(agent),
sys.environment(model),
instruction.system().pipe(Effect.orDie),
sys.mcp(agent, session.permission),
deferredMcpInstructions,
MessageV2.toModelMessagesEffect(msgs, model),
])
const system = [
...env,
...instructions,
...(mcpInstructions ? [mcpInstructions] : []),
...(deferredToolInstructions ? [deferredToolInstructions] : []),
...(skills ? [skills] : []),
]
const format = lastUser.format ?? { type: "text" as const }
+14 -482
View File
@@ -1,40 +1,21 @@
import { Agent } from "@/agent/agent"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { EffectBridge } from "@/effect/bridge"
import { Permission } from "@/permission"
import { Plugin } from "@/plugin"
import { Provider } from "@/provider/provider"
import { ProviderTransform } from "@/provider/transform"
import { MCP } from "@/mcp"
import { Permission } from "@/permission"
import { Tool } from "@/tool/tool"
import { ToolJsonSchema } from "@/tool/json-schema"
import { ToolRegistry } from "@/tool/registry"
import { Truncate } from "@/tool/truncate"
import { Plugin } from "@/plugin"
import type { Context } from "@/tool/tool"
import type { TaskPromptOps } from "@/tool/task"
import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai"
import { Effect } from "effect"
import { MessageV2 } from "./message-v2"
import { Session } from "./session"
import { SessionProcessor } from "./processor"
import { PartID } from "./schema"
import { EffectBridge } from "@/effect/bridge"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { isRecord } from "@/util/record"
const MCP_RESOURCE_TOOLS = {
list: "list_mcp_resources",
listTemplates: "list_mcp_resource_templates",
read: "read_mcp_resource",
} as const
const MAX_MCP_RESOURCE_BLOB_BYTES = 10 * 1024 * 1024
const SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES = new Set([
"application/pdf",
"image/gif",
"image/jpeg",
"image/png",
"image/webp",
])
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { jsonSchema, tool, type Tool, type ToolExecutionOptions } from "ai"
import { Effect } from "effect"
import { SessionMcpTools } from "./mcp-tools"
import { SessionProcessor } from "./processor"
import { Session } from "./session"
import { PartID } from "./schema"
export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
agent: Agent.Info
@@ -45,15 +26,13 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
messages: SessionV1.WithParts[]
promptOps: TaskPromptOps
}) {
const tools: Record<string, AITool> = {}
const tools: Record<string, Tool> = {}
const run = yield* EffectBridge.make()
const plugin = yield* Plugin.Service
const permission = yield* Permission.Service
const registry = yield* ToolRegistry.Service
const mcp = yield* MCP.Service
const truncate = yield* Truncate.Service
const context = (args: Record<string, unknown>, options: ToolExecutionOptions): Tool.Context => ({
const context = (args: Record<string, unknown>, options: ToolExecutionOptions): Context => ({
sessionID: input.session.id,
abort: options.abortSignal!,
messageID: input.processor.message.id,
@@ -129,455 +108,8 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
})
}
const hasMcpResourceServer = Object.values(yield* mcp.clients()).some(
(client) => !!client.getServerCapabilities()?.resources,
)
if (hasMcpResourceServer) {
tools[MCP_RESOURCE_TOOLS.list] = tool({
description:
"Lists resources provided by connected MCP servers. Resources provide context such as files, database schemas, or application-specific information.",
inputSchema: jsonSchema(
ProviderTransform.schema(input.model, {
type: "object",
properties: {
server: {
type: "string",
description: "Optional MCP server name. When omitted, lists resources from every connected server.",
},
},
additionalProperties: false,
}),
),
execute(args, opts) {
return run.promise(
Effect.gen(function* () {
const parsed = parseListMcpResourcesArgs(args)
const ctx = context(toRecord(args), opts)
const clients = yield* mcp.clients()
const resourceServers = Object.entries(clients)
.filter((entry) => !!entry[1].getServerCapabilities()?.resources)
.map((entry) => entry[0])
.sort((a, b) => a.localeCompare(b))
if (parsed.server && !resourceServers.includes(parsed.server)) {
throw new Error(
resourceServers.length === 0
? `MCP server "${parsed.server}" does not support resources`
: `MCP server "${parsed.server}" does not support resources. Available resource servers: ${resourceServers.join(", ")}`,
)
}
const permissionPatterns = parsed.server
? [`mcp:${parsed.server}:*`]
: resourceServers.map((server) => `mcp:${server}:*`)
yield* plugin.trigger(
"tool.execute.before",
{ tool: MCP_RESOURCE_TOOLS.list, sessionID: ctx.sessionID, callID: opts.toolCallId },
{ args },
)
yield* ctx.ask({
permission: "read",
metadata: parsed.server ? { server: parsed.server } : {},
patterns: permissionPatterns,
always: permissionPatterns,
})
const resources = Object.values(yield* mcp.resources(parsed.server))
const filtered = resources
.filter((resource) => !parsed.server || resource.client === parsed.server)
.toSorted((a, b) =>
(a.client + "\u0000" + a.name + "\u0000" + a.uri).localeCompare(
b.client + "\u0000" + b.name + "\u0000" + b.uri,
),
)
const content = JSON.stringify({ resources: filtered.map(formatMcpResource) }, null, 2)
const truncated = yield* truncate.output(content, {}, input.agent)
const output = {
title: parsed.server ? `MCP resources: ${parsed.server}` : "MCP resources",
metadata: {
count: filtered.length,
servers: resourceServers,
...(parsed.server ? { server: parsed.server } : {}),
truncated: truncated.truncated,
...(truncated.truncated && { outputPath: truncated.outputPath }),
},
output: truncated.content,
}
yield* plugin.trigger(
"tool.execute.after",
{ tool: MCP_RESOURCE_TOOLS.list, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
output,
)
if (opts.abortSignal?.aborted) {
yield* input.processor.completeToolCall(opts.toolCallId, output)
}
return output
}),
)
},
})
tools[MCP_RESOURCE_TOOLS.listTemplates] = tool({
description:
"Lists resource templates provided by connected MCP servers. Resource templates are parameterized resources that can be read after filling in their URI template.",
inputSchema: jsonSchema(
ProviderTransform.schema(input.model, {
type: "object",
properties: {
server: {
type: "string",
description:
"Optional MCP server name. When omitted, lists resource templates from every connected server.",
},
},
additionalProperties: false,
}),
),
execute(args, opts) {
return run.promise(
Effect.gen(function* () {
const parsed = parseListMcpResourcesArgs(args)
const ctx = context(toRecord(args), opts)
const clients = yield* mcp.clients()
const resourceServers = Object.entries(clients)
.filter((entry) => !!entry[1].getServerCapabilities()?.resources)
.map((entry) => entry[0])
.sort((a, b) => a.localeCompare(b))
if (parsed.server && !resourceServers.includes(parsed.server)) {
throw new Error(
resourceServers.length === 0
? `MCP server "${parsed.server}" does not support resources`
: `MCP server "${parsed.server}" does not support resources. Available resource servers: ${resourceServers.join(", ")}`,
)
}
const permissionPatterns = parsed.server
? [`mcp:${parsed.server}:*`]
: resourceServers.map((server) => `mcp:${server}:*`)
yield* plugin.trigger(
"tool.execute.before",
{ tool: MCP_RESOURCE_TOOLS.listTemplates, sessionID: ctx.sessionID, callID: opts.toolCallId },
{ args },
)
yield* ctx.ask({
permission: "read",
metadata: parsed.server ? { server: parsed.server } : {},
patterns: permissionPatterns,
always: permissionPatterns,
})
const templates = Object.values(yield* mcp.resourceTemplates(parsed.server))
const filtered = templates
.filter((template) => !parsed.server || template.client === parsed.server)
.toSorted((a, b) =>
(a.client + "\u0000" + a.name + "\u0000" + a.uriTemplate).localeCompare(
b.client + "\u0000" + b.name + "\u0000" + b.uriTemplate,
),
)
const content = JSON.stringify({ resourceTemplates: filtered.map(formatMcpResourceTemplate) }, null, 2)
const truncated = yield* truncate.output(content, {}, input.agent)
const output = {
title: parsed.server ? `MCP resource templates: ${parsed.server}` : "MCP resource templates",
metadata: {
count: filtered.length,
servers: resourceServers,
...(parsed.server ? { server: parsed.server } : {}),
truncated: truncated.truncated,
...(truncated.truncated && { outputPath: truncated.outputPath }),
},
output: truncated.content,
}
yield* plugin.trigger(
"tool.execute.after",
{ tool: MCP_RESOURCE_TOOLS.listTemplates, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
output,
)
if (opts.abortSignal?.aborted) {
yield* input.processor.completeToolCall(opts.toolCallId, output)
}
return output
}),
)
},
})
tools[MCP_RESOURCE_TOOLS.read] = tool({
description:
"Read a specific resource from an MCP server using the server name and resource URI. The URI is an MCP identifier and does not need to be a file URL.",
inputSchema: jsonSchema(
ProviderTransform.schema(input.model, {
type: "object",
properties: {
server: {
type: "string",
description: "MCP server name exactly as returned by list_mcp_resources.",
},
uri: {
type: "string",
description: "Resource URI to read. Use the exact URI string returned by list_mcp_resources.",
},
},
required: ["server", "uri"],
additionalProperties: false,
}),
),
execute(args, opts) {
return run.promise(
Effect.gen(function* () {
const parsed = parseReadMcpResourceArgs(args)
const ctx = context(toRecord(args), opts)
const clients = yield* mcp.clients()
const client = clients[parsed.server]
if (!client) {
throw new Error(`MCP server "${parsed.server}" is not connected`)
}
if (!client.getServerCapabilities()?.resources) {
throw new Error(`MCP server "${parsed.server}" does not support resources`)
}
yield* plugin.trigger(
"tool.execute.before",
{ tool: MCP_RESOURCE_TOOLS.read, sessionID: ctx.sessionID, callID: opts.toolCallId },
{ args },
)
yield* ctx.ask({
permission: "read",
metadata: { server: parsed.server, uri: parsed.uri },
patterns: [`mcp:${parsed.server}:${parsed.uri}`],
always: [`mcp:${parsed.server}:*`],
})
const content = yield* mcp.readResource(parsed.server, parsed.uri)
if (!content) throw new Error(`Failed to read MCP resource: ${parsed.server}/${parsed.uri}`)
const formatted = formatMcpResourceContent(parsed.server, parsed.uri, content)
const truncated = yield* truncate.output(formatted.text, {}, input.agent)
const output = {
title: `MCP resource: ${parsed.uri}`,
metadata: {
server: parsed.server,
uri: parsed.uri,
contents: formatted.contents,
attachments: formatted.attachments.length,
truncated: truncated.truncated,
...(truncated.truncated && { outputPath: truncated.outputPath }),
},
output: truncated.content,
attachments: formatted.attachments.map((attachment) => ({
...attachment,
id: PartID.ascending(),
sessionID: ctx.sessionID,
messageID: input.processor.message.id,
})),
}
yield* plugin.trigger(
"tool.execute.after",
{ tool: MCP_RESOURCE_TOOLS.read, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
output,
)
if (opts.abortSignal?.aborted) {
yield* input.processor.completeToolCall(opts.toolCallId, output)
}
return output
}),
)
},
})
}
for (const [key, item] of Object.entries(yield* mcp.tools())) {
const execute = item.execute
if (!execute) continue
const schema = yield* Effect.promise(() => Promise.resolve(asSchema(item.inputSchema).jsonSchema))
const transformed = ProviderTransform.schema(input.model, { ...schema, properties: schema.properties ?? {} })
item.inputSchema = jsonSchema(transformed)
item.execute = (args, opts) =>
run.promise(
Effect.gen(function* () {
const ctx = context(args, opts)
yield* plugin.trigger(
"tool.execute.before",
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId },
{ args },
)
const result: Awaited<ReturnType<NonNullable<typeof execute>>> = yield* Effect.gen(function* () {
yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] })
return yield* Effect.promise(() => execute(args, opts))
}).pipe(
Effect.withSpan("Tool.execute", {
attributes: {
"tool.name": key,
"tool.call_id": opts.toolCallId,
"session.id": ctx.sessionID,
"message.id": input.processor.message.id,
},
}),
)
yield* plugin.trigger(
"tool.execute.after",
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
result,
)
const textParts: string[] = []
const attachments: Omit<SessionV1.FilePart, "id" | "sessionID" | "messageID">[] = []
for (const contentItem of result.content) {
if (contentItem.type === "text") textParts.push(contentItem.text)
else if (contentItem.type === "image") {
attachments.push({
type: "file",
mime: contentItem.mimeType,
url: `data:${contentItem.mimeType};base64,${contentItem.data}`,
})
} else if (contentItem.type === "resource") {
const { resource } = contentItem
if (resource.text) textParts.push(resource.text)
if (resource.blob) {
const mime = resource.mimeType ?? "application/octet-stream"
const size = base64Size(resource.blob)
if (!SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES.has(mime)) {
textParts.push(
`[Binary MCP resource omitted: ${resource.uri} (${mime}, ${formatBytes(size)}) is not a supported attachment type]`,
)
continue
}
if (size > MAX_MCP_RESOURCE_BLOB_BYTES) {
textParts.push(
`[Binary MCP resource omitted: ${resource.uri} (${mime}, ${formatBytes(size)}) exceeds ${formatBytes(MAX_MCP_RESOURCE_BLOB_BYTES)}]`,
)
continue
}
attachments.push({
type: "file",
mime,
url: `data:${mime};base64,${resource.blob}`,
filename: resource.uri,
})
}
}
}
const truncated = yield* truncate.output(textParts.join("\n\n"), {}, input.agent)
const metadata = {
...result.metadata,
truncated: truncated.truncated,
...(truncated.truncated && { outputPath: truncated.outputPath }),
}
const output = {
title: "",
metadata,
output: truncated.content,
attachments: attachments.map((attachment) => ({
...attachment,
id: PartID.ascending(),
sessionID: ctx.sessionID,
messageID: input.processor.message.id,
})),
content: result.content,
}
if (opts.abortSignal?.aborted) {
yield* input.processor.completeToolCall(opts.toolCallId, output)
}
return output
}),
)
tools[key] = item
}
Object.assign(tools, yield* SessionMcpTools.resolve(input))
return tools
})
function toRecord(value: unknown) {
if (isRecord(value)) return value
return {}
}
function parseListMcpResourcesArgs(value: unknown) {
const args = toRecord(value)
return { server: optionalString(args, "server") }
}
function parseReadMcpResourceArgs(value: unknown) {
const args = toRecord(value)
return { server: requiredString(args, "server"), uri: requiredString(args, "uri") }
}
function optionalString(args: Record<string, unknown>, key: string) {
const value = args[key]
if (value === undefined || value === null || value === "") return undefined
if (typeof value !== "string") throw new Error(`${key} must be a string`)
return value
}
function requiredString(args: Record<string, unknown>, key: string) {
const value = optionalString(args, key)
if (value) return value
throw new Error(`${key} is required`)
}
function formatMcpResource(resource: MCP.Resource) {
const result = Object.fromEntries(Object.entries(resource).filter((entry) => entry[0] !== "client"))
return { ...result, server: resource.client }
}
function formatMcpResourceTemplate(template: Record<string, unknown> & { client: string }) {
const result = Object.fromEntries(Object.entries(template).filter((entry) => entry[0] !== "client"))
return { ...result, server: template.client }
}
function formatMcpResourceContent(server: string, uri: string, content: { contents: unknown }) {
const items = (Array.isArray(content.contents) ? content.contents : [content.contents]).filter(isRecord)
const text: string[] = []
const attachments: Omit<SessionV1.FilePart, "id" | "sessionID" | "messageID">[] = []
for (const item of items) {
const itemUri = typeof item.uri === "string" ? item.uri : uri
const mime = typeof item.mimeType === "string" ? item.mimeType : "application/octet-stream"
if (typeof item.text === "string") {
text.push(`Resource: ${itemUri}\nMIME: ${mime}\n${item.text}`)
continue
}
if (typeof item.blob === "string") {
const size = base64Size(item.blob)
if (!SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES.has(mime)) {
text.push(
`[Binary MCP resource omitted: ${itemUri} (${mime}, ${formatBytes(size)}) is not a supported attachment type]`,
)
continue
}
if (size > MAX_MCP_RESOURCE_BLOB_BYTES) {
text.push(
`[Binary MCP resource omitted: ${itemUri} (${mime}, ${formatBytes(size)}) exceeds ${formatBytes(MAX_MCP_RESOURCE_BLOB_BYTES)}]`,
)
continue
}
text.push(`[Binary MCP resource attached: ${itemUri} (${mime})]`)
attachments.push({
type: "file",
mime,
url: `data:${mime};base64,${item.blob}`,
filename: itemUri,
})
continue
}
text.push(`[MCP resource content without text or blob: ${itemUri}]`)
}
return {
contents: items.length,
attachments,
text: text.join("\n\n") || `MCP resource ${uri} from ${server} returned no contents.`,
}
}
function base64Size(value: string) {
const trimmed = value.replace(/\s/g, "")
const padding = trimmed.endsWith("==") ? 2 : trimmed.endsWith("=") ? 1 : 0
return Math.max(0, Math.floor((trimmed.length * 3) / 4) - padding)
}
function formatBytes(value: number) {
if (value < 1024) return `${value} B`
if (value < 1024 * 1024) return `${Math.ceil(value / 1024)} KB`
return `${Math.ceil(value / (1024 * 1024))} MB`
}
export * as SessionTools from "./tools"
@@ -0,0 +1,235 @@
import { describe, expect } from "bun:test"
import { jsonSchema, tool, type ToolExecutionOptions } from "ai"
import { Effect, Layer } from "effect"
import { ProjectV2 } from "@opencode-ai/core/project"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { Agent } from "@/agent/agent"
import { MCP } from "@/mcp"
import { Permission } from "@/permission"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { SessionTools } from "@/session/tools"
import { SessionMcpTools } from "@/session/mcp-tools"
import { MessageID, SessionID } from "@/session/schema"
import { Session } from "@/session/session"
import { Plugin } from "@/plugin"
import { ToolRegistry } from "@/tool/registry"
import { Truncate } from "@/tool/truncate"
import { ProviderTest } from "../fake/provider"
import { testEffect } from "../lib/effect"
const model = ProviderTest.model()
const sessionID = SessionID.make("ses_deferred-tools")
const largeSchemaDescription = "analytics trends schema ".repeat(10_000)
const mcpClient = Object.assign(new Client({ name: "test", version: "0.0.0" }), {
getServerCapabilities: () => ({}),
})
const agent = {
name: "build",
mode: "primary",
permission: Permission.fromConfig({ "*": "allow" }),
options: {},
} satisfies Agent.Info
const session = {
id: sessionID,
slug: "deferred-tools",
projectID: ProjectV2.ID.make("proj_deferred-tools"),
directory: "/tmp/project",
title: "Deferred tools",
version: "0.0.0",
time: { created: 0, updated: 0 },
} satisfies Session.Info
const assistant = {
id: MessageID.make("msg_assistant"),
parentID: MessageID.make("msg_user"),
role: "assistant",
mode: agent.name,
agent: agent.name,
path: { cwd: session.directory, root: session.directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
modelID: ModelV2.ID.make(model.id),
providerID: ProviderV2.ID.make(model.providerID),
time: { created: 0 },
sessionID,
} satisfies SessionV1.Assistant
const user = {
id: MessageID.make("msg_user"),
sessionID,
role: "user",
time: { created: 0 },
agent: agent.name,
model: { providerID: ProviderV2.ID.make(model.providerID), modelID: ModelV2.ID.make(model.id) },
} satisfies SessionV1.User
const processor = {
message: assistant,
updateToolCall: () => Effect.succeed(undefined),
completeToolCall: () => Effect.void,
}
const promptOps = {
cancel: () => Effect.void,
resolvePromptParts: () => Effect.succeed([]),
prompt: () => Effect.die(new Error("unexpected task prompt")),
}
const toolExecutionOptions = {
toolCallId: "call_deferred",
abortSignal: new AbortController().signal,
messages: [],
} satisfies ToolExecutionOptions
const makeIt = (input: { flags: Parameters<typeof RuntimeFlags.layer>[0]; queryDescription: string }) =>
testEffect(
Layer.mergeAll(
Layer.mock(MCP.Service, {
clients: () => Effect.succeed({ posthog: mcpClient }),
tools: () =>
Effect.succeed({
posthog_query_trends: tool({
description: "Query product analytics trends and charts",
inputSchema: jsonSchema({
type: "object",
properties: {
query: { type: "string", description: input.queryDescription },
date_range: { type: "string", description: "Date range for the trends query" },
},
required: ["query"],
}),
execute: async () => ({ content: [{ type: "text", text: "trend result" }] }),
}),
posthog_feature_flags: tool({
description: "List and manage feature flags",
inputSchema: jsonSchema({ type: "object", properties: {} }),
execute: async () => ({ content: [{ type: "text", text: "flag result" }] }),
}),
}),
}),
Layer.mock(ToolRegistry.Service, {
tools: () => Effect.succeed([]),
}),
Layer.mock(Permission.Service, {
ask: () => Effect.void,
}),
Layer.mock(Truncate.Service, {
output: (text) => Effect.succeed({ content: text, truncated: false as const }),
}),
Layer.succeed(
Plugin.Service,
Plugin.Service.of({
init: () => Effect.void,
list: () => Effect.succeed([]),
trigger: ((_name, _input, output) => Effect.succeed(output)) as Plugin.Interface["trigger"],
}),
),
RuntimeFlags.layer(input.flags),
),
)
const deferredIt = makeIt({ flags: { experimentalToolSearch: true }, queryDescription: largeSchemaDescription })
const directIt = makeIt({ flags: { experimentalToolSearch: false }, queryDescription: largeSchemaDescription })
const belowThresholdIt = makeIt({
flags: { experimentalToolSearch: true },
queryDescription: "Natural language analytics query",
})
function resolveTools(input: { messages?: SessionV1.WithParts[] } = {}) {
return SessionTools.resolve({
agent,
model,
session,
processor,
bypassAgentCheck: false,
messages: input.messages ?? [],
promptOps,
})
}
describe("session.tools", () => {
deferredIt.instance("defers MCP tools behind fixed search and call tools", () =>
Effect.gen(function* () {
const tools = yield* resolveTools()
expect(Object.keys(tools).sort()).toEqual(["call_deferred_tool", "search_deferred_tools"])
const search = tools.search_deferred_tools.execute
if (!search) throw new Error("missing search_deferred_tools executor")
const searchResult = yield* Effect.promise(() =>
Promise.resolve(search({ query: "analytics trends" }, toolExecutionOptions)),
)
const parsed = JSON.parse(searchResult.output) as { tools: Array<{ tool_id: string; input_schema?: unknown }> }
expect(parsed.tools.map((item) => item.tool_id)).toContain("posthog_query_trends")
expect(parsed.tools.find((item) => item.tool_id === "posthog_query_trends")?.input_schema).toBeDefined()
const conciseResult = yield* Effect.promise(() =>
Promise.resolve(search({ query: "analytics trends", include_schema: false }, toolExecutionOptions)),
)
const concise = JSON.parse(conciseResult.output) as { tools: Array<{ tool_id: string; input_schema?: unknown }> }
expect(concise.tools.map((item) => item.tool_id)).toContain("posthog_query_trends")
expect(concise.tools.find((item) => item.tool_id === "posthog_query_trends")?.input_schema).toBeUndefined()
const call = tools.call_deferred_tool.execute
if (!call) throw new Error("missing call_deferred_tool executor")
const callResult = yield* Effect.promise(() =>
Promise.resolve(
call({ tool_id: "posthog_query_trends", arguments: { query: "signups over time" } }, toolExecutionOptions),
),
)
expect(callResult.output).toBe("trend result")
}),
)
deferredIt.instance("lists deferred MCP servers in the system prompt", () =>
Effect.gen(function* () {
const prompt = yield* SessionMcpTools.systemPrompt({ agent, session, messages: [] })
expect(prompt).toContain("Deferred MCP servers available through `search_deferred_tools`:")
expect(prompt).toContain("- posthog: 2 tools")
}),
)
deferredIt.instance("does not expose per-message disabled MCP tools through deferred search", () =>
Effect.gen(function* () {
const tools = yield* resolveTools({
messages: [
{
info: { ...user, tools: { posthog_feature_flags: false } },
parts: [],
},
],
})
const search = tools.search_deferred_tools.execute
if (!search) throw new Error("missing search_deferred_tools executor")
const searchResult = yield* Effect.promise(() =>
Promise.resolve(search({ query: "feature flags" }, toolExecutionOptions)),
)
const parsed = JSON.parse(searchResult.output) as { tools: Array<{ tool_id: string }> }
expect(parsed.tools.map((item) => item.tool_id)).not.toContain("posthog_feature_flags")
const call = tools.call_deferred_tool.execute
if (!call) throw new Error("missing call_deferred_tool executor")
yield* Effect.promise(async () => {
await expect(
Promise.resolve(call({ tool_id: "posthog_feature_flags", arguments: {} }, toolExecutionOptions)),
).rejects.toThrow('Deferred tool "posthog_feature_flags" is not available')
})
}),
)
directIt.instance("keeps MCP tools direct when tool search is disabled", () =>
Effect.gen(function* () {
const tools = yield* resolveTools()
expect(Object.keys(tools).sort()).toEqual(["posthog_feature_flags", "posthog_query_trends"])
}),
)
belowThresholdIt.instance("keeps MCP tools direct below the fixed deferral threshold", () =>
Effect.gen(function* () {
const tools = yield* resolveTools()
expect(Object.keys(tools).sort()).toEqual(["posthog_feature_flags", "posthog_query_trends"])
}),
)
})