| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585 |
- import { Agent } from "@/agent/agent"
- import { SessionV1 } from "@opencode-ai/core/v1/session"
- import { Provider } from "@/provider/provider"
- import { ProviderTransform } from "@/provider/transform"
- import { MCP } from "@/mcp"
- import { McpCatalog } from "@/mcp/catalog"
- 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 { 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",
- ])
- export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
- agent: Agent.Info
- model: Provider.Model
- session: Session.Info
- processor: Pick<SessionProcessor.Handle, "message" | "updateToolCall" | "completeToolCall">
- bypassAgentCheck: boolean
- messages: SessionV1.WithParts[]
- promptOps: TaskPromptOps
- }) {
- const tools: Record<string, AITool> = {}
- 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 => ({
- 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),
- })
- for (const item of yield* registry.tools({
- modelID: ModelV2.ID.make(input.model.api.id),
- providerID: input.model.providerID,
- agent: input.agent,
- })) {
- const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))
- tools[item.id] = tool({
- description: item.description,
- inputSchema: jsonSchema(schema),
- execute(args, options) {
- return run.promise(
- Effect.gen(function* () {
- const ctx = context(args, options)
- yield* plugin.trigger(
- "tool.execute.before",
- { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },
- { args },
- )
- const result = yield* item.execute(args, ctx)
- const output = {
- ...result,
- attachments: result.attachments?.map((attachment) => ({
- ...attachment,
- id: PartID.ascending(),
- sessionID: ctx.sessionID,
- messageID: input.processor.message.id,
- })),
- }
- yield* plugin.trigger(
- "tool.execute.after",
- { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args },
- output,
- )
- if (options.abortSignal?.aborted) {
- yield* input.processor.completeToolCall(options.toolCallId, output)
- }
- return output
- }),
- )
- },
- })
- }
- 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, entry] of Object.entries(yield* mcp.tools())) {
- const item = McpCatalog.convertTool(entry.def, entry.client, entry.timeout)
- 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
- }
- 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"
|