mcp-script.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. // 脚本调用工具(docs/design/17-run-script.md):模型写 Python 脚本,由网关在沙箱中执行并批量调用 MCP 工具
  2. import fs from 'node:fs'
  3. import http from 'node:http'
  4. import type { AddressInfo } from 'node:net'
  5. import path from 'node:path'
  6. import pino from 'pino'
  7. import { afterAll, beforeAll, describe, expect, it } from 'vitest'
  8. import { z } from 'zod'
  9. import { Client } from '@modelcontextprotocol/sdk/client/index.js'
  10. import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
  11. import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
  12. import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
  13. import type { ScriptProgressPayload } from '@agent-server/shared'
  14. import { createApp, type App } from '../src/app'
  15. import { config } from '../src/config'
  16. import { buildServer } from '../src/http/server'
  17. import { BWRAP } from '../src/mcp-gateway/upstream'
  18. import type { GatewayRunContext } from '../src/mcp-gateway/tokens'
  19. import { resolveAccess } from '../src/services/access-policy'
  20. import type { InteractionOutcome, NewApproval } from '../src/services/interactions'
  21. let app: App
  22. let server: Awaited<ReturnType<typeof buildServer>>
  23. let base: string
  24. let upstream: http.Server
  25. let serverId: string
  26. const flags: string[] = []
  27. /** 测试的数据目录是临时目录,使用本机的 Python 运行环境(没有时跳过) */
  28. const PY_RUNTIME = '/srv/agent-server/runtime/py'
  29. const HAS_PY = fs.existsSync(path.join(PY_RUNTIME, 'bin/python'))
  30. function startUpstream(): Promise<string> {
  31. upstream = http.createServer(async (req, res) => {
  32. const mcp = new McpServer({ name: 'dev', version: '1.0.0' })
  33. mcp.registerTool('get_item', {
  34. description: '读取一条数据', inputSchema: { i: z.number().int() }, outputSchema: { i: z.number(), value: z.number() }, annotations: { readOnlyHint: true },
  35. }, async ({ i }) => (i < 0
  36. ? { isError: true, content: [{ type: 'text', text: `编号 ${i} 不存在` }] }
  37. : { content: [{ type: 'text', text: JSON.stringify({ i, value: i * 2 }) }], structuredContent: { i, value: i * 2 } }))
  38. mcp.registerTool('set_flag', { description: '设置标记', inputSchema: { name: z.string() } }, async ({ name }) => {
  39. flags.push(name)
  40. return { content: [{ type: 'text', text: `ok:${name}` }] }
  41. })
  42. mcp.registerTool('wipe', { description: '清空', inputSchema: {}, annotations: { destructiveHint: true } }, async () => ({ content: [{ type: 'text', text: 'wiped' }] }))
  43. const t = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
  44. res.on('close', () => { void t.close(); void mcp.close() })
  45. await mcp.connect(t)
  46. let body = ''
  47. req.on('data', c => (body += c))
  48. req.on('end', () => void t.handleRequest(req, res, body ? JSON.parse(body) : undefined))
  49. })
  50. return new Promise(r => upstream.listen(0, '127.0.0.1', () => r(`http://127.0.0.1:${(upstream.address() as AddressInfo).port}/mcp`)))
  51. }
  52. const approvals: NewApproval[] = []
  53. let answer: InteractionOutcome = { resolution: 'allowed', scope: 'once' }
  54. const progress: ScriptProgressPayload[] = []
  55. const audits: { summary: string; detail: Record<string, unknown> }[] = []
  56. function runCtx(over: Partial<GatewayRunContext> = {}): GatewayRunContext {
  57. const ws = path.join(config.dataDir, 'workspaces', `ses_script_${Math.random().toString(36).slice(2)}`)
  58. fs.mkdirSync(ws, { recursive: true })
  59. const settings = structuredClone(app.settings.get())
  60. settings.paths.pythonRuntime = PY_RUNTIME
  61. return {
  62. sessionId: `ses_${Math.random().toString(36).slice(2)}`, runId: 'r', provider: 'claude', workspaceDir: ws, permissionMode: 'ask', settings,
  63. access: resolveAccess({ policy: { mode: 'restricted', read: [], write: [], deny: [] }, knowledgeRoots: [], selectedKnowledgeRootIds: [], workspaceDir: ws, settings }),
  64. knowledgeRoots: [], mcpServerIds: [serverId], audit: (_k, summary, detail) => void audits.push({ summary, detail: detail as Record<string, unknown> }),
  65. interact: async req => { approvals.push(req as NewApproval); return answer },
  66. progress: p => void progress.push(p),
  67. ...over,
  68. }
  69. }
  70. async function gateway(ctx: GatewayRunContext) {
  71. const client = new Client({ name: 'test', version: '1' })
  72. await client.connect(new StreamableHTTPClientTransport(new URL(`${base}/mcp`), { requestInit: { headers: { Authorization: `Bearer ${app.gatewayTokens.issue(ctx)}` } } }))
  73. return client
  74. }
  75. async function run(ctx: GatewayRunContext, code: string, tools: string[] = [], description = '测试脚本') {
  76. const c = await gateway(ctx)
  77. try {
  78. const r = await c.callTool({ name: 'run_script', arguments: { code, tools, description } }, undefined, { timeout: 120_000 }) as { content: { text: string }[]; isError?: boolean }
  79. return { text: r.content[0]!.text, isError: !!r.isError }
  80. } finally {
  81. await c.close()
  82. }
  83. }
  84. beforeAll(async () => {
  85. const log = pino({ level: 'silent' })
  86. app = createApp(log, { providers: {} })
  87. server = await buildServer(app, { logger: log })
  88. base = await server.listen({ port: 0, host: '127.0.0.1' })
  89. const url = await startUpstream()
  90. serverId = app.mcpServers.create({ name: 'dev', title: '设备服务', description: '', transport: 'http', enabled: true, url, headers: [], command: '', args: [], env: [], allowNetwork: false, readPaths: [], writePaths: [] }).id
  91. await app.mcpServers.refresh(serverId)
  92. })
  93. afterAll(async () => {
  94. app.mcpTasks.stop()
  95. app.backgroundCalls.stop()
  96. await app.mcpUpstream.closeAll()
  97. await server.close()
  98. upstream.close()
  99. })
  100. describe.skipIf(!BWRAP || !HAS_PY)('脚本调用工具', () => {
  101. it('有第三方工具时才提供 run_script', async () => {
  102. const none = await gateway(runCtx({ mcpServerIds: [] }))
  103. expect((await none.listTools()).tools.map(t => t.name)).not.toContain('run_script')
  104. await none.close()
  105. const c = await gateway(runCtx())
  106. const tool = (await c.listTools()).tools.find(t => t.name === 'run_script')!
  107. expect(tool.description).toContain('from agent_tools import')
  108. await c.close()
  109. })
  110. it('批量调用只读工具、在脚本中汇总并写文件,只把打印的内容交回;记录明细、审计与进度', async () => {
  111. const ctx = runCtx()
  112. progress.length = 0
  113. audits.length = 0
  114. const r = await run(ctx, `
  115. from agent_tools import call, call_many, progress, ToolError
  116. import pandas as pd
  117. rows = call_many("dev__get_item", [{"i": i} for i in range(300)])
  118. progress(300, 300, "汇总")
  119. df = pd.DataFrame(rows)
  120. import os
  121. os.makedirs("outputs", exist_ok=True)
  122. df.to_csv("outputs/items.csv", index=False)
  123. try:
  124. call("dev__get_item", {"i": -1})
  125. except ToolError as e:
  126. print("出错:", e.message)
  127. print("合计", int(df.value.sum()), "条数", len(df))
  128. `, [], '读取 300 条并汇总')
  129. expect(r.isError).toBe(false)
  130. expect(r.text).toContain('合计 89700 条数 300')
  131. expect(r.text).toContain('出错: 编号 -1 不存在')
  132. expect(r.text).toMatch(/调用工具 301 次(失败 1 次)/)
  133. expect(fs.readFileSync(path.join(ctx.workspaceDir, 'outputs/items.csv'), 'utf8').split('\n')[0]).toBe('i,value')
  134. expect(approvals).toHaveLength(0)
  135. const logDir = /调用明细:(\S+)\/calls\.jsonl/.exec(r.text)![1]!
  136. expect(fs.readFileSync(path.join(ctx.workspaceDir, logDir, 'calls.jsonl'), 'utf8').trim().split('\n')).toHaveLength(301)
  137. expect(fs.readFileSync(path.join(ctx.workspaceDir, logDir, 'script.py'), 'utf8')).toContain('call_many')
  138. expect(progress.at(-1)).toMatchObject({ title: '读取 300 条并汇总', status: 'completed', calls: 301, failures: 1, done: 300, total: 300, message: '汇总' })
  139. const a = audits.find(x => x.summary.startsWith('run_script'))!
  140. expect(a.summary).toBe('run_script:读取 300 条并汇总(调用工具 301 次,失败 1 次)')
  141. expect(a.detail).toMatchObject({ byTool: { dev__get_item: 301 }, exitCode: 0 })
  142. })
  143. it('写工具:没有列出的不能调用;列出的执行前审批一次;拒绝则不执行;破坏性工具不能在脚本中调用', async () => {
  144. const code = 'from agent_tools import call\nfor n in ["a", "b", "c"]:\n print(call("dev__set_flag", {"name": n}))'
  145. const undeclared = await run(runCtx(), code)
  146. expect(undeclared.text).toMatch(/工具不可用:dev__set_flag/)
  147. expect(undeclared.isError).toBe(true)
  148. approvals.length = 0
  149. flags.length = 0
  150. answer = { resolution: 'allowed', scope: 'once' }
  151. const ok = await run(runCtx(), code, ['dev__set_flag', 'dev__get_item'])
  152. expect(ok.text).toContain('ok:c')
  153. expect(flags).toEqual(['a', 'b', 'c'])
  154. expect(approvals).toHaveLength(1)
  155. expect(approvals[0]).toMatchObject({ tool: 'mcp__gateway__run_script', title: '脚本将调用:dev__set_flag', options: ['allow_once', 'deny'] })
  156. answer = { resolution: 'denied', reason: '不要改' }
  157. const denied = await run(runCtx(), code, ['dev__set_flag'])
  158. expect(denied.text).toMatch(/拒绝了这个脚本:不要改/)
  159. expect(flags).toHaveLength(3)
  160. // 自动模式不审批
  161. approvals.length = 0
  162. await run(runCtx({ permissionMode: 'auto' }), code, ['dev__set_flag'])
  163. expect(approvals).toHaveLength(0)
  164. const wipe = await run(runCtx(), 'print(1)', ['dev__wipe'])
  165. expect(wipe.text).toMatch(/不能在脚本中调用.*dev__wipe/)
  166. })
  167. it('次数上限;脚本出错时返回错误信息;只读模式下工作区只读', async () => {
  168. const limited = runCtx()
  169. limited.settings.scripts.maxCalls = 5
  170. const r = await run(limited, 'from agent_tools import call, ToolError\nn = 0\ntry:\n for i in range(10):\n call("dev__get_item", {"i": i}); n += 1\nexcept ToolError as e:\n print(n, e.message)')
  171. expect(r.text).toMatch(/5 已达到每个脚本调用工具的上限(5 次)/)
  172. const bad = await run(runCtx(), 'print("开始")\nraise ValueError("数据不对")')
  173. expect(bad.isError).toBe(true)
  174. expect(bad.text).toMatch(/出错退出(退出码 1)/)
  175. expect(bad.text).toContain('开始')
  176. expect(bad.text).toContain('ValueError: 数据不对')
  177. const ro = await run(runCtx({ permissionMode: 'readonly' }), 'from agent_tools import call\nprint(call("dev__get_item", {"i": 2})["value"])\nopen("x.txt", "w").write("x")')
  178. expect(ro.isError).toBe(true)
  179. expect(ro.text).toContain('4')
  180. expect(ro.text).toMatch(/Read-only file system/)
  181. })
  182. })