tools.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585
  1. import { Agent } from "@/agent/agent"
  2. import { SessionV1 } from "@opencode-ai/core/v1/session"
  3. import { Provider } from "@/provider/provider"
  4. import { ProviderTransform } from "@/provider/transform"
  5. import { MCP } from "@/mcp"
  6. import { McpCatalog } from "@/mcp/catalog"
  7. import { Permission } from "@/permission"
  8. import { Tool } from "@/tool/tool"
  9. import { ToolJsonSchema } from "@/tool/json-schema"
  10. import { ToolRegistry } from "@/tool/registry"
  11. import { Truncate } from "@/tool/truncate"
  12. import { Plugin } from "@/plugin"
  13. import type { TaskPromptOps } from "@/tool/task"
  14. import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai"
  15. import { Effect } from "effect"
  16. import { MessageV2 } from "./message-v2"
  17. import { Session } from "./session"
  18. import { SessionProcessor } from "./processor"
  19. import { PartID } from "./schema"
  20. import { EffectBridge } from "@/effect/bridge"
  21. import { ProviderV2 } from "@opencode-ai/core/provider"
  22. import { ModelV2 } from "@opencode-ai/core/model"
  23. import { isRecord } from "@/util/record"
  24. const MCP_RESOURCE_TOOLS = {
  25. list: "list_mcp_resources",
  26. listTemplates: "list_mcp_resource_templates",
  27. read: "read_mcp_resource",
  28. } as const
  29. const MAX_MCP_RESOURCE_BLOB_BYTES = 10 * 1024 * 1024
  30. const SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES = new Set([
  31. "application/pdf",
  32. "image/gif",
  33. "image/jpeg",
  34. "image/png",
  35. "image/webp",
  36. ])
  37. export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
  38. agent: Agent.Info
  39. model: Provider.Model
  40. session: Session.Info
  41. processor: Pick<SessionProcessor.Handle, "message" | "updateToolCall" | "completeToolCall">
  42. bypassAgentCheck: boolean
  43. messages: SessionV1.WithParts[]
  44. promptOps: TaskPromptOps
  45. }) {
  46. const tools: Record<string, AITool> = {}
  47. const run = yield* EffectBridge.make()
  48. const plugin = yield* Plugin.Service
  49. const permission = yield* Permission.Service
  50. const registry = yield* ToolRegistry.Service
  51. const mcp = yield* MCP.Service
  52. const truncate = yield* Truncate.Service
  53. const context = (args: Record<string, unknown>, options: ToolExecutionOptions): Tool.Context => ({
  54. sessionID: input.session.id,
  55. abort: options.abortSignal!,
  56. messageID: input.processor.message.id,
  57. callID: options.toolCallId,
  58. extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck, promptOps: input.promptOps },
  59. agent: input.agent.name,
  60. messages: input.messages,
  61. metadata: (val) =>
  62. input.processor.updateToolCall(options.toolCallId, (match) => {
  63. if (!["running", "pending"].includes(match.state.status)) return match
  64. return {
  65. ...match,
  66. state: {
  67. title: val.title,
  68. metadata: val.metadata,
  69. status: "running",
  70. input: args,
  71. time: { start: Date.now() },
  72. },
  73. }
  74. }),
  75. ask: (req) =>
  76. permission
  77. .ask({
  78. ...req,
  79. sessionID: input.session.id,
  80. tool: { messageID: input.processor.message.id, callID: options.toolCallId },
  81. ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),
  82. })
  83. .pipe(Effect.orDie),
  84. })
  85. for (const item of yield* registry.tools({
  86. modelID: ModelV2.ID.make(input.model.api.id),
  87. providerID: input.model.providerID,
  88. agent: input.agent,
  89. })) {
  90. const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))
  91. tools[item.id] = tool({
  92. description: item.description,
  93. inputSchema: jsonSchema(schema),
  94. execute(args, options) {
  95. return run.promise(
  96. Effect.gen(function* () {
  97. const ctx = context(args, options)
  98. yield* plugin.trigger(
  99. "tool.execute.before",
  100. { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },
  101. { args },
  102. )
  103. const result = yield* item.execute(args, ctx)
  104. const output = {
  105. ...result,
  106. attachments: result.attachments?.map((attachment) => ({
  107. ...attachment,
  108. id: PartID.ascending(),
  109. sessionID: ctx.sessionID,
  110. messageID: input.processor.message.id,
  111. })),
  112. }
  113. yield* plugin.trigger(
  114. "tool.execute.after",
  115. { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args },
  116. output,
  117. )
  118. if (options.abortSignal?.aborted) {
  119. yield* input.processor.completeToolCall(options.toolCallId, output)
  120. }
  121. return output
  122. }),
  123. )
  124. },
  125. })
  126. }
  127. const hasMcpResourceServer = Object.values(yield* mcp.clients()).some(
  128. (client) => !!client.getServerCapabilities()?.resources,
  129. )
  130. if (hasMcpResourceServer) {
  131. tools[MCP_RESOURCE_TOOLS.list] = tool({
  132. description:
  133. "Lists resources provided by connected MCP servers. Resources provide context such as files, database schemas, or application-specific information.",
  134. inputSchema: jsonSchema(
  135. ProviderTransform.schema(input.model, {
  136. type: "object",
  137. properties: {
  138. server: {
  139. type: "string",
  140. description: "Optional MCP server name. When omitted, lists resources from every connected server.",
  141. },
  142. },
  143. additionalProperties: false,
  144. }),
  145. ),
  146. execute(args, opts) {
  147. return run.promise(
  148. Effect.gen(function* () {
  149. const parsed = parseListMcpResourcesArgs(args)
  150. const ctx = context(toRecord(args), opts)
  151. const clients = yield* mcp.clients()
  152. const resourceServers = Object.entries(clients)
  153. .filter((entry) => !!entry[1].getServerCapabilities()?.resources)
  154. .map((entry) => entry[0])
  155. .sort((a, b) => a.localeCompare(b))
  156. if (parsed.server && !resourceServers.includes(parsed.server)) {
  157. throw new Error(
  158. resourceServers.length === 0
  159. ? `MCP server "${parsed.server}" does not support resources`
  160. : `MCP server "${parsed.server}" does not support resources. Available resource servers: ${resourceServers.join(", ")}`,
  161. )
  162. }
  163. const permissionPatterns = parsed.server
  164. ? [`mcp:${parsed.server}:*`]
  165. : resourceServers.map((server) => `mcp:${server}:*`)
  166. yield* plugin.trigger(
  167. "tool.execute.before",
  168. { tool: MCP_RESOURCE_TOOLS.list, sessionID: ctx.sessionID, callID: opts.toolCallId },
  169. { args },
  170. )
  171. yield* ctx.ask({
  172. permission: "read",
  173. metadata: parsed.server ? { server: parsed.server } : {},
  174. patterns: permissionPatterns,
  175. always: permissionPatterns,
  176. })
  177. const resources = Object.values(yield* mcp.resources(parsed.server))
  178. const filtered = resources
  179. .filter((resource) => !parsed.server || resource.client === parsed.server)
  180. .toSorted((a, b) =>
  181. (a.client + "\u0000" + a.name + "\u0000" + a.uri).localeCompare(
  182. b.client + "\u0000" + b.name + "\u0000" + b.uri,
  183. ),
  184. )
  185. const content = JSON.stringify({ resources: filtered.map(formatMcpResource) }, null, 2)
  186. const truncated = yield* truncate.output(content, {}, input.agent)
  187. const output = {
  188. title: parsed.server ? `MCP resources: ${parsed.server}` : "MCP resources",
  189. metadata: {
  190. count: filtered.length,
  191. servers: resourceServers,
  192. ...(parsed.server ? { server: parsed.server } : {}),
  193. truncated: truncated.truncated,
  194. ...(truncated.truncated && { outputPath: truncated.outputPath }),
  195. },
  196. output: truncated.content,
  197. }
  198. yield* plugin.trigger(
  199. "tool.execute.after",
  200. { tool: MCP_RESOURCE_TOOLS.list, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
  201. output,
  202. )
  203. if (opts.abortSignal?.aborted) {
  204. yield* input.processor.completeToolCall(opts.toolCallId, output)
  205. }
  206. return output
  207. }),
  208. )
  209. },
  210. })
  211. tools[MCP_RESOURCE_TOOLS.listTemplates] = tool({
  212. description:
  213. "Lists resource templates provided by connected MCP servers. Resource templates are parameterized resources that can be read after filling in their URI template.",
  214. inputSchema: jsonSchema(
  215. ProviderTransform.schema(input.model, {
  216. type: "object",
  217. properties: {
  218. server: {
  219. type: "string",
  220. description:
  221. "Optional MCP server name. When omitted, lists resource templates from every connected server.",
  222. },
  223. },
  224. additionalProperties: false,
  225. }),
  226. ),
  227. execute(args, opts) {
  228. return run.promise(
  229. Effect.gen(function* () {
  230. const parsed = parseListMcpResourcesArgs(args)
  231. const ctx = context(toRecord(args), opts)
  232. const clients = yield* mcp.clients()
  233. const resourceServers = Object.entries(clients)
  234. .filter((entry) => !!entry[1].getServerCapabilities()?.resources)
  235. .map((entry) => entry[0])
  236. .sort((a, b) => a.localeCompare(b))
  237. if (parsed.server && !resourceServers.includes(parsed.server)) {
  238. throw new Error(
  239. resourceServers.length === 0
  240. ? `MCP server "${parsed.server}" does not support resources`
  241. : `MCP server "${parsed.server}" does not support resources. Available resource servers: ${resourceServers.join(", ")}`,
  242. )
  243. }
  244. const permissionPatterns = parsed.server
  245. ? [`mcp:${parsed.server}:*`]
  246. : resourceServers.map((server) => `mcp:${server}:*`)
  247. yield* plugin.trigger(
  248. "tool.execute.before",
  249. { tool: MCP_RESOURCE_TOOLS.listTemplates, sessionID: ctx.sessionID, callID: opts.toolCallId },
  250. { args },
  251. )
  252. yield* ctx.ask({
  253. permission: "read",
  254. metadata: parsed.server ? { server: parsed.server } : {},
  255. patterns: permissionPatterns,
  256. always: permissionPatterns,
  257. })
  258. const templates = Object.values(yield* mcp.resourceTemplates(parsed.server))
  259. const filtered = templates
  260. .filter((template) => !parsed.server || template.client === parsed.server)
  261. .toSorted((a, b) =>
  262. (a.client + "\u0000" + a.name + "\u0000" + a.uriTemplate).localeCompare(
  263. b.client + "\u0000" + b.name + "\u0000" + b.uriTemplate,
  264. ),
  265. )
  266. const content = JSON.stringify({ resourceTemplates: filtered.map(formatMcpResourceTemplate) }, null, 2)
  267. const truncated = yield* truncate.output(content, {}, input.agent)
  268. const output = {
  269. title: parsed.server ? `MCP resource templates: ${parsed.server}` : "MCP resource templates",
  270. metadata: {
  271. count: filtered.length,
  272. servers: resourceServers,
  273. ...(parsed.server ? { server: parsed.server } : {}),
  274. truncated: truncated.truncated,
  275. ...(truncated.truncated && { outputPath: truncated.outputPath }),
  276. },
  277. output: truncated.content,
  278. }
  279. yield* plugin.trigger(
  280. "tool.execute.after",
  281. { tool: MCP_RESOURCE_TOOLS.listTemplates, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
  282. output,
  283. )
  284. if (opts.abortSignal?.aborted) {
  285. yield* input.processor.completeToolCall(opts.toolCallId, output)
  286. }
  287. return output
  288. }),
  289. )
  290. },
  291. })
  292. tools[MCP_RESOURCE_TOOLS.read] = tool({
  293. description:
  294. "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.",
  295. inputSchema: jsonSchema(
  296. ProviderTransform.schema(input.model, {
  297. type: "object",
  298. properties: {
  299. server: {
  300. type: "string",
  301. description: "MCP server name exactly as returned by list_mcp_resources.",
  302. },
  303. uri: {
  304. type: "string",
  305. description: "Resource URI to read. Use the exact URI string returned by list_mcp_resources.",
  306. },
  307. },
  308. required: ["server", "uri"],
  309. additionalProperties: false,
  310. }),
  311. ),
  312. execute(args, opts) {
  313. return run.promise(
  314. Effect.gen(function* () {
  315. const parsed = parseReadMcpResourceArgs(args)
  316. const ctx = context(toRecord(args), opts)
  317. const clients = yield* mcp.clients()
  318. const client = clients[parsed.server]
  319. if (!client) {
  320. throw new Error(`MCP server "${parsed.server}" is not connected`)
  321. }
  322. if (!client.getServerCapabilities()?.resources) {
  323. throw new Error(`MCP server "${parsed.server}" does not support resources`)
  324. }
  325. yield* plugin.trigger(
  326. "tool.execute.before",
  327. { tool: MCP_RESOURCE_TOOLS.read, sessionID: ctx.sessionID, callID: opts.toolCallId },
  328. { args },
  329. )
  330. yield* ctx.ask({
  331. permission: "read",
  332. metadata: { server: parsed.server, uri: parsed.uri },
  333. patterns: [`mcp:${parsed.server}:${parsed.uri}`],
  334. always: [`mcp:${parsed.server}:*`],
  335. })
  336. const content = yield* mcp.readResource(parsed.server, parsed.uri)
  337. if (!content) throw new Error(`Failed to read MCP resource: ${parsed.server}/${parsed.uri}`)
  338. const formatted = formatMcpResourceContent(parsed.server, parsed.uri, content)
  339. const truncated = yield* truncate.output(formatted.text, {}, input.agent)
  340. const output = {
  341. title: `MCP resource: ${parsed.uri}`,
  342. metadata: {
  343. server: parsed.server,
  344. uri: parsed.uri,
  345. contents: formatted.contents,
  346. attachments: formatted.attachments.length,
  347. truncated: truncated.truncated,
  348. ...(truncated.truncated && { outputPath: truncated.outputPath }),
  349. },
  350. output: truncated.content,
  351. attachments: formatted.attachments.map((attachment) => ({
  352. ...attachment,
  353. id: PartID.ascending(),
  354. sessionID: ctx.sessionID,
  355. messageID: input.processor.message.id,
  356. })),
  357. }
  358. yield* plugin.trigger(
  359. "tool.execute.after",
  360. { tool: MCP_RESOURCE_TOOLS.read, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
  361. output,
  362. )
  363. if (opts.abortSignal?.aborted) {
  364. yield* input.processor.completeToolCall(opts.toolCallId, output)
  365. }
  366. return output
  367. }),
  368. )
  369. },
  370. })
  371. }
  372. for (const [key, entry] of Object.entries(yield* mcp.tools())) {
  373. const item = McpCatalog.convertTool(entry.def, entry.client, entry.timeout)
  374. const execute = item.execute
  375. if (!execute) continue
  376. const schema = yield* Effect.promise(() => Promise.resolve(asSchema(item.inputSchema).jsonSchema))
  377. const transformed = ProviderTransform.schema(input.model, { ...schema, properties: schema.properties ?? {} })
  378. item.inputSchema = jsonSchema(transformed)
  379. item.execute = (args, opts) =>
  380. run.promise(
  381. Effect.gen(function* () {
  382. const ctx = context(args, opts)
  383. yield* plugin.trigger(
  384. "tool.execute.before",
  385. { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId },
  386. { args },
  387. )
  388. const result: Awaited<ReturnType<NonNullable<typeof execute>>> = yield* Effect.gen(function* () {
  389. yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] })
  390. return yield* Effect.promise(() => execute(args, opts))
  391. }).pipe(
  392. Effect.withSpan("Tool.execute", {
  393. attributes: {
  394. "tool.name": key,
  395. "tool.call_id": opts.toolCallId,
  396. "session.id": ctx.sessionID,
  397. "message.id": input.processor.message.id,
  398. },
  399. }),
  400. )
  401. yield* plugin.trigger(
  402. "tool.execute.after",
  403. { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
  404. result,
  405. )
  406. const textParts: string[] = []
  407. const attachments: Omit<SessionV1.FilePart, "id" | "sessionID" | "messageID">[] = []
  408. for (const contentItem of result.content) {
  409. if (contentItem.type === "text") textParts.push(contentItem.text)
  410. else if (contentItem.type === "image") {
  411. attachments.push({
  412. type: "file",
  413. mime: contentItem.mimeType,
  414. url: `data:${contentItem.mimeType};base64,${contentItem.data}`,
  415. })
  416. } else if (contentItem.type === "resource") {
  417. const { resource } = contentItem
  418. if (resource.text) textParts.push(resource.text)
  419. if (resource.blob) {
  420. const mime = resource.mimeType ?? "application/octet-stream"
  421. const size = base64Size(resource.blob)
  422. if (!SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES.has(mime)) {
  423. textParts.push(
  424. `[Binary MCP resource omitted: ${resource.uri} (${mime}, ${formatBytes(size)}) is not a supported attachment type]`,
  425. )
  426. continue
  427. }
  428. if (size > MAX_MCP_RESOURCE_BLOB_BYTES) {
  429. textParts.push(
  430. `[Binary MCP resource omitted: ${resource.uri} (${mime}, ${formatBytes(size)}) exceeds ${formatBytes(MAX_MCP_RESOURCE_BLOB_BYTES)}]`,
  431. )
  432. continue
  433. }
  434. attachments.push({
  435. type: "file",
  436. mime,
  437. url: `data:${mime};base64,${resource.blob}`,
  438. filename: resource.uri,
  439. })
  440. }
  441. }
  442. }
  443. const truncated = yield* truncate.output(textParts.join("\n\n"), {}, input.agent)
  444. const metadata = {
  445. ...result.metadata,
  446. truncated: truncated.truncated,
  447. ...(truncated.truncated && { outputPath: truncated.outputPath }),
  448. }
  449. const output = {
  450. title: "",
  451. metadata,
  452. output: truncated.content,
  453. attachments: attachments.map((attachment) => ({
  454. ...attachment,
  455. id: PartID.ascending(),
  456. sessionID: ctx.sessionID,
  457. messageID: input.processor.message.id,
  458. })),
  459. content: result.content,
  460. }
  461. if (opts.abortSignal?.aborted) {
  462. yield* input.processor.completeToolCall(opts.toolCallId, output)
  463. }
  464. return output
  465. }),
  466. )
  467. tools[key] = item
  468. }
  469. return tools
  470. })
  471. function toRecord(value: unknown) {
  472. if (isRecord(value)) return value
  473. return {}
  474. }
  475. function parseListMcpResourcesArgs(value: unknown) {
  476. const args = toRecord(value)
  477. return { server: optionalString(args, "server") }
  478. }
  479. function parseReadMcpResourceArgs(value: unknown) {
  480. const args = toRecord(value)
  481. return { server: requiredString(args, "server"), uri: requiredString(args, "uri") }
  482. }
  483. function optionalString(args: Record<string, unknown>, key: string) {
  484. const value = args[key]
  485. if (value === undefined || value === null || value === "") return undefined
  486. if (typeof value !== "string") throw new Error(`${key} must be a string`)
  487. return value
  488. }
  489. function requiredString(args: Record<string, unknown>, key: string) {
  490. const value = optionalString(args, key)
  491. if (value) return value
  492. throw new Error(`${key} is required`)
  493. }
  494. function formatMcpResource(resource: MCP.Resource) {
  495. const result = Object.fromEntries(Object.entries(resource).filter((entry) => entry[0] !== "client"))
  496. return { ...result, server: resource.client }
  497. }
  498. function formatMcpResourceTemplate(template: Record<string, unknown> & { client: string }) {
  499. const result = Object.fromEntries(Object.entries(template).filter((entry) => entry[0] !== "client"))
  500. return { ...result, server: template.client }
  501. }
  502. function formatMcpResourceContent(server: string, uri: string, content: { contents: unknown }) {
  503. const items = (Array.isArray(content.contents) ? content.contents : [content.contents]).filter(isRecord)
  504. const text: string[] = []
  505. const attachments: Omit<SessionV1.FilePart, "id" | "sessionID" | "messageID">[] = []
  506. for (const item of items) {
  507. const itemUri = typeof item.uri === "string" ? item.uri : uri
  508. const mime = typeof item.mimeType === "string" ? item.mimeType : "application/octet-stream"
  509. if (typeof item.text === "string") {
  510. text.push(`Resource: ${itemUri}\nMIME: ${mime}\n${item.text}`)
  511. continue
  512. }
  513. if (typeof item.blob === "string") {
  514. const size = base64Size(item.blob)
  515. if (!SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES.has(mime)) {
  516. text.push(
  517. `[Binary MCP resource omitted: ${itemUri} (${mime}, ${formatBytes(size)}) is not a supported attachment type]`,
  518. )
  519. continue
  520. }
  521. if (size > MAX_MCP_RESOURCE_BLOB_BYTES) {
  522. text.push(
  523. `[Binary MCP resource omitted: ${itemUri} (${mime}, ${formatBytes(size)}) exceeds ${formatBytes(MAX_MCP_RESOURCE_BLOB_BYTES)}]`,
  524. )
  525. continue
  526. }
  527. text.push(`[Binary MCP resource attached: ${itemUri} (${mime})]`)
  528. attachments.push({
  529. type: "file",
  530. mime,
  531. url: `data:${mime};base64,${item.blob}`,
  532. filename: itemUri,
  533. })
  534. continue
  535. }
  536. text.push(`[MCP resource content without text or blob: ${itemUri}]`)
  537. }
  538. return {
  539. contents: items.length,
  540. attachments,
  541. text: text.join("\n\n") || `MCP resource ${uri} from ${server} returned no contents.`,
  542. }
  543. }
  544. function base64Size(value: string) {
  545. const trimmed = value.replace(/\s/g, "")
  546. const padding = trimmed.endsWith("==") ? 2 : trimmed.endsWith("=") ? 1 : 0
  547. return Math.max(0, Math.floor((trimmed.length * 3) / 4) - padding)
  548. }
  549. function formatBytes(value: number) {
  550. if (value < 1024) return `${value} B`
  551. if (value < 1024 * 1024) return `${Math.ceil(value / 1024)} KB`
  552. return `${Math.ceil(value / (1024 * 1024))} MB`
  553. }
  554. export * as SessionTools from "./tools"