// MCP Gateway 的内置工具(两个引擎共用):文档转换、公式重算、网页抓取、资料库列表 import path from 'node:path' import { z } from 'zod' import type { McpBuiltinToolDto } from '@agent-server/shared' import { CONVERT_FORMATS, convertDocument, recalcWorkbook } from '../services/office' import { describeKnowledge, linkOrigins } from '../knowledge/describe' import { FetchBlockedError, webFetch } from '../services/web-fetch' import type { GatewayRunContext } from './tokens' export type ToolContent = { type: string; [key: string]: unknown } export type ToolResult = { content: ToolContent[]; isError?: boolean } export const ok = (text: string): ToolResult => ({ content: [{ type: 'text', text }] }) export const fail = (text: string): ToolResult => ({ content: [{ type: 'text', text }], isError: true }) export interface BuiltinTool { name: string description: string schema: z.ZodObject readOnly: boolean handler(args: Record, ctx: GatewayRunContext): Promise } const readonlyBlock = (ctx: GatewayRunContext) => (ctx.permissionMode === 'readonly' ? fail('当前为只读模式,不允许生成或修改文件') : null) const convertSchema = z.object({ input: z.string().describe('输入文件路径(相对工作区或绝对路径)'), format: z.enum(CONVERT_FORMATS).describe('目标格式'), out_dir: z.string().optional().describe('输出目录,默认 outputs'), }) const recalcSchema = z.object({ input: z.string().describe('xlsx/xlsm 文件路径(相对工作区或绝对路径)') }) const fetchSchema = z.object({ url: z.string().describe('http/https 网址') }) export const BUILTIN_TOOLS: BuiltinTool[] = [ { name: 'convert_document', description: '用 LibreOffice 转换文档格式(例如 docx→pdf、xlsx→pdf、pptx→pdf、doc→docx、html→pdf)。' + '输入和输出都必须在工作区内;输出文件名与输入相同、扩展名换成目标格式。不要在命令行里调用 soffice,它在沙箱中无法运行。', schema: convertSchema, readOnly: false, async handler(raw, ctx) { const blocked = readonlyBlock(ctx) if (blocked) return blocked const args = raw as z.infer const out = await convertDocument({ workspaceDir: ctx.workspaceDir, input: args.input, format: args.format, outDir: args.out_dir }) return ok(`已生成:${path.relative(ctx.workspaceDir, out)}`) }, }, { name: 'recalc_workbook', description: '用 LibreOffice 重新计算 Excel 文件中的所有公式并写回原文件,然后报告公式数量和错误单元格(#REF!、#DIV/0! 等)。' + '用 openpyxl 写入公式后必须调用它,否则文件里没有计算结果。不要在命令行里调用 soffice 或 recalc.py,它们在沙箱中无法运行。', schema: recalcSchema, readOnly: false, async handler(raw, ctx) { const blocked = readonlyBlock(ctx) if (blocked) return blocked const args = raw as z.infer const r = await recalcWorkbook({ workspaceDir: ctx.workspaceDir, input: args.input, pythonRuntime: ctx.settings.paths.pythonRuntime }) const lines = [`已重算:${path.relative(ctx.workspaceDir, r.path)},公式 ${r.formulas} 个,错误 ${r.errorCount} 个`] for (const e of r.errors) lines.push(`- ${e.sheet}!${e.cell}: ${e.error}`) return r.errorCount > 0 ? fail(lines.join('\n')) : ok(lines.join('\n')) }, }, { name: 'web_fetch', description: '抓取网页并转换为 Markdown(HTML),或原样返回文本/JSON;PDF、图片、表格等二进制文件会下载到工作区并返回路径。' + '内容过长时只返回前一部分,完整内容保存在工作区。不能访问内网地址(网络链接资料库所在站点除外)。', schema: fetchSchema, readOnly: true, async handler(raw, ctx) { const args = raw as z.infer try { const r = await webFetch({ url: args.url, workspaceDir: ctx.workspaceDir, blockPrivate: ctx.settings.network.blockPrivateFetch, allowOrigins: linkOrigins(ctx.knowledgeRoots) }) const head = `URL: ${r.url}\n状态: ${r.status}\n类型: ${r.contentType}${r.savedPath ? `\n${r.truncated ? '完整内容' : '文件'}已保存到: ${r.savedPath}` : ''}${r.truncated ? '\n(内容过长,以下为前一部分)' : ''}\n\n` return r.status >= 400 ? fail(head + r.text) : ok(head + r.text) } catch (err) { if (err instanceof FetchBlockedError) ctx.audit('network_denied', err.message, { url: args.url, engine: ctx.provider }) throw err } }, }, { name: 'kb_list_roots', description: '列出当前可用的资料库(名称、类型、路径或链接、说明、读写属性、是否可用)。查找资料前先调用它。', schema: z.object({}), readOnly: true, async handler(_args, ctx) { if (!ctx.knowledgeRoots.length) return ok('当前没有可用的资料库。') return ok(describeKnowledge(ctx.knowledgeRoots)) }, }, ] export const BUILTIN_TOOL_INFO: McpBuiltinToolDto[] = BUILTIN_TOOLS.map(t => ({ name: t.name, description: t.description, readOnly: t.readOnly }))