builtin.ts 5.2 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394
  1. // MCP Gateway 的内置工具(两个引擎共用):文档转换、公式重算、网页抓取、资料库列表
  2. import path from 'node:path'
  3. import { z } from 'zod'
  4. import type { McpBuiltinToolDto } from '@agent-server/shared'
  5. import { CONVERT_FORMATS, convertDocument, recalcWorkbook } from '../services/office'
  6. import { describeKnowledge, linkOrigins } from '../knowledge/describe'
  7. import { FetchBlockedError, webFetch } from '../services/web-fetch'
  8. import type { GatewayRunContext } from './tokens'
  9. export type ToolContent = { type: string; [key: string]: unknown }
  10. export type ToolResult = { content: ToolContent[]; isError?: boolean }
  11. export const ok = (text: string): ToolResult => ({ content: [{ type: 'text', text }] })
  12. export const fail = (text: string): ToolResult => ({ content: [{ type: 'text', text }], isError: true })
  13. export interface BuiltinTool {
  14. name: string
  15. description: string
  16. schema: z.ZodObject
  17. readOnly: boolean
  18. handler(args: Record<string, unknown>, ctx: GatewayRunContext): Promise<ToolResult>
  19. }
  20. const readonlyBlock = (ctx: GatewayRunContext) => (ctx.permissionMode === 'readonly' ? fail('当前为只读模式,不允许生成或修改文件') : null)
  21. const convertSchema = z.object({
  22. input: z.string().describe('输入文件路径(相对工作区或绝对路径)'),
  23. format: z.enum(CONVERT_FORMATS).describe('目标格式'),
  24. out_dir: z.string().optional().describe('输出目录,默认 outputs'),
  25. })
  26. const recalcSchema = z.object({ input: z.string().describe('xlsx/xlsm 文件路径(相对工作区或绝对路径)') })
  27. const fetchSchema = z.object({ url: z.string().describe('http/https 网址') })
  28. export const BUILTIN_TOOLS: BuiltinTool[] = [
  29. {
  30. name: 'convert_document',
  31. description: '用 LibreOffice 转换文档格式(例如 docx→pdf、xlsx→pdf、pptx→pdf、doc→docx、html→pdf)。'
  32. + '输入和输出都必须在工作区内;输出文件名与输入相同、扩展名换成目标格式。不要在命令行里调用 soffice,它在沙箱中无法运行。',
  33. schema: convertSchema,
  34. readOnly: false,
  35. async handler(raw, ctx) {
  36. const blocked = readonlyBlock(ctx)
  37. if (blocked) return blocked
  38. const args = raw as z.infer<typeof convertSchema>
  39. const out = await convertDocument({ workspaceDir: ctx.workspaceDir, input: args.input, format: args.format, outDir: args.out_dir })
  40. return ok(`已生成:${path.relative(ctx.workspaceDir, out)}`)
  41. },
  42. },
  43. {
  44. name: 'recalc_workbook',
  45. description: '用 LibreOffice 重新计算 Excel 文件中的所有公式并写回原文件,然后报告公式数量和错误单元格(#REF!、#DIV/0! 等)。'
  46. + '用 openpyxl 写入公式后必须调用它,否则文件里没有计算结果。不要在命令行里调用 soffice 或 recalc.py,它们在沙箱中无法运行。',
  47. schema: recalcSchema,
  48. readOnly: false,
  49. async handler(raw, ctx) {
  50. const blocked = readonlyBlock(ctx)
  51. if (blocked) return blocked
  52. const args = raw as z.infer<typeof recalcSchema>
  53. const r = await recalcWorkbook({ workspaceDir: ctx.workspaceDir, input: args.input, pythonRuntime: ctx.settings.paths.pythonRuntime })
  54. const lines = [`已重算:${path.relative(ctx.workspaceDir, r.path)},公式 ${r.formulas} 个,错误 ${r.errorCount} 个`]
  55. for (const e of r.errors) lines.push(`- ${e.sheet}!${e.cell}: ${e.error}`)
  56. return r.errorCount > 0 ? fail(lines.join('\n')) : ok(lines.join('\n'))
  57. },
  58. },
  59. {
  60. name: 'web_fetch',
  61. description: '抓取网页并转换为 Markdown(HTML),或原样返回文本/JSON;PDF、图片、表格等二进制文件会下载到工作区并返回路径。'
  62. + '内容过长时只返回前一部分,完整内容保存在工作区。不能访问内网地址(网络链接资料库所在站点除外)。',
  63. schema: fetchSchema,
  64. readOnly: true,
  65. async handler(raw, ctx) {
  66. const args = raw as z.infer<typeof fetchSchema>
  67. try {
  68. const r = await webFetch({ url: args.url, workspaceDir: ctx.workspaceDir, blockPrivate: ctx.settings.network.blockPrivateFetch, allowOrigins: linkOrigins(ctx.knowledgeRoots) })
  69. const head = `URL: ${r.url}\n状态: ${r.status}\n类型: ${r.contentType}${r.savedPath ? `\n${r.truncated ? '完整内容' : '文件'}已保存到: ${r.savedPath}` : ''}${r.truncated ? '\n(内容过长,以下为前一部分)' : ''}\n\n`
  70. return r.status >= 400 ? fail(head + r.text) : ok(head + r.text)
  71. } catch (err) {
  72. if (err instanceof FetchBlockedError) ctx.audit('network_denied', err.message, { url: args.url, engine: ctx.provider })
  73. throw err
  74. }
  75. },
  76. },
  77. {
  78. name: 'kb_list_roots',
  79. description: '列出当前可用的资料库(名称、类型、路径或链接、说明、读写属性、是否可用)。查找资料前先调用它。',
  80. schema: z.object({}),
  81. readOnly: true,
  82. async handler(_args, ctx) {
  83. if (!ctx.knowledgeRoots.length) return ok('当前没有可用的资料库。')
  84. return ok(describeKnowledge(ctx.knowledgeRoots))
  85. },
  86. },
  87. ]
  88. export const BUILTIN_TOOL_INFO: McpBuiltinToolDto[] = BUILTIN_TOOLS.map(t => ({ name: t.name, description: t.description, readOnly: t.readOnly }))