从JS云函数到MCP:打造跨平台AI Agent工具的工程实践

构建 AI 工具生态这件事上,过去一年我们做了不少尝试。

回头看,会觉得这条路径像是在搭建一条越来越清晰的"能力生产线":

从写 JS → Agent Tool 工具 → 成为 MCP 工具 → 成为跨平台 Agent 能力。

这篇文章我们就按这条演进路线,把底层逻辑、踩坑经验和最终的工程方案完整讲清楚。

一、先做简单介绍

我们做了一个"在线 JS 云函数平台",它的本质是:把写一个 JS 函数,变成给 AI 赋予一个 Tool 新能力。

这套系统经历了三个阶段:

1. 第一版: 只是 Flowise 里的一个 LangChain StructuredTool 扩展,能在工作流里被 Agent 调用。

Flowiseflowiseai.com/ ) 是一款开源、可视化的 AI 工作流工具,通过拖拽节点即可构建 LLM 应用或 Agent。它底层基于 LangChain 的 TypeScript 版本,因此天然具备模型调用、工具调用、链式处理、记忆与向量检索等能力。

当时我们在技术选型时使用 Flowise 做AI工作流平台有两个原因:

一是它基于 LangChain 的 TypeScript 版本------在早期 AI 框架还不成熟时,这是少数原生支持 TS 的方案;二是它提供了可拖拽的可视化工作流,能让我们快速搭建和验证 AI 原型。

2. 第二版: 演进为一个浏览器里的 云函数 IDE:在线写代码、调试、沙箱运行时、连内网 gRPC/HTTP/MySQL。

3. 现在: 接上 MCP 协议,变成一个可以被「任何支持 MCP 的 Agent / Studio」跨平台调用的独立工具平台,并支持 SSE + Streamable HTTP 两种流式协议。

二、为什么我们需要"AI 工具能力层"?

在我们内部构建 AI 应用(客服、直播运营助手等)时,遇到几个非常典型的痛点。

1. 工具很多,但每接一次 AI,都要重写一遍工具

  • 想让 AI 查开播状态、查卡顿、查稿件、给用户发通知......
  • 后端能力其实都有,但每接一个新智能体,就要:
  • 在某个项目里再写一遍调用代码
  • 手动写结构化参数 / 返回值
  • 校验、鉴权
  • 每个团队都重新写一遍

2. AI 需要的是"能力",而不是"接口"

业务接口往往是这样的:

ini 复制代码
GET /room/info?roomid=123

但是用户却会说:"查一下imzerooo开播状态"

这中间的自然语言 → 参数的语义映射,很难靠简单规则完成。

如果让开发者直接暴露接口,AI 是很难用好的。

3. JSON 不是 AI 友好的输入格式

内网接口通常返回一大坨 JSON。LLM 解析 JSON 没问题,但:

  • 字段多、嵌套深 → 容易丢字段

  • 字段名杂乱 → 模型记不住

  • 文本内容多 → 容易产生幻觉

AI 友好的格式是:

  • Markdown 表格
  • 简洁的自然语言
  • 提炼过的信息

而不是整包 JSON。

4. 工具没有做成"资产层"

以前的工具要么:

  • 绑死在某个 AI 工作流上
  • 埋在某个项目里
  • 放在某个业务脚本中

无法像资产一样复用。

所以一切问题的核心其实是:

缺少一个可以写工具、管理工具、发布工具、复用工具的统一能力层。

而我们做的,就是让这件事:只需写一段 JS 函数

三、第一版:StructuredTool------ 工具能力的萌芽

最初我们是在 Flowise 里写 StructuredTool 组件。

Flowise 底层基于 LangChain,所以我们写了很多 StructuredTool:

这一版有几个优点:

  • 工具能被 Agent 调用

  • 参数有 schema

  • 有一定的模块化

但有几个局限:

  • 工具生命周期跟 Flowise 项目绑死

  • 要写 TS、写 Node 项目,对效率很不友好

  • 无法跨平台使用

  • JSON 处理还是要自己写

  • 无法注入通用能力(如内网 SDK)

于是我们开始考虑平台化。

四、第二版:在线 JS 云函数平台(NodeVM 运行时)

我们打造了一套全新的平台:在线 JS 云函数平台。

开发者不需要知道 Flowise、LangChain,也不用开Node 项目。

4.1 NodeVM:一个安全可控的 JS 执行沙箱

我们基于 vm2(NodeVM)做了一个"可控的 JS 执行环境"。

  • 运行时基于 StructureTool

  • NodeVM 作为安全隔离的执行环境

  • 自动注入企业内部能力(统一上下文能力层)

javascript 复制代码
/** 
 * 核心思路抽象版 
 */
import { NodeVM } from 'vm2'
import { StructuredTool } from '@langchain/core/tools'
//...

classDynamicToolextendsStructuredTool { 
  constructor({ name, description, schema, code }) { 
    super({ name, description, schema }) 
    this.code = code // 用户在在线编辑器里写的 JS 代码 
  }
  
  async _call(args, runManager, flowContext) {
    // 1. 构建沙箱(所有注入能力都放在这里) 
    const sandbox = { 
      // 用户传入的参数转成 $xxx
      ...Object.fromEntries(Object.entries(args).map(([k, v]) => [`$${k}`, v])),
      
      // 内部上下文(cookie/env/session) 
      $flow: flowContext,  
      $cookie: flowContext.cookie,
      
      // 内网能力封装(gRPC/HTTP) 
      $yuumi: yuumi,
      
      // 结果转换工具(用于把 JSON 转成更 AI 友好的 Markdown 表格)  
      $json2MarkdownTable: json2MarkdownTable,
      
      // 内部 AI 模型     
      $biliLLM: biliLLMClient  
    }
    
    // 2. 构建 NodeVM 沙箱 
    const vm = new NodeVM({ 
      sandbox,   
      console: "inherit",    
      require: {      
        builtin: allowedBuiltinDeps,    
        external: allowedExternalDeps  
      }
    })
    
    // 3. 执行开发者写的云函数代码 
    return await vm.run(   
      `module.exports = async () => { ${this.code} }()`,   
      __dirname   
    ) 
  }
}

它既能隔离风险,又能注入能力:

  • 禁止访问文件系统

  • 禁止随意 require

  • 只开放白名单依赖

  • 内置 $yuumi(内网 gRPC/HTTP 调用)

  • 内置 $json2MarkdownTable

  • 内置 $cookie

  • 内置 $flow(上下文)

  • 内置 内部 AI 能力

于是开发者传入的业务代码类似这样:

4.2 在线调试:monaco-editor+ Mock + 沙箱日志

编辑器底层用的是 microsoft/monaco-editor,好处是:

  • TypeScript / JS 语法高亮

  • 可以做一些简单的智能提示

  • UI 很像 VSCode,大家上手成本低

调试方面:

  • 提供了一个 参数 Mock 面板:可以填入调用时的 JSON

  • 点击「运行」,平台会在沙箱里跑一遍你的函数,把:日志打印(console.log)、返回字符串、可能的异常,全都展示出来

每次保存,我们都会生成一个版本:

  • 当前编辑的是"草稿"

  • 发布的时候会把某个版本标记为发布

  • 出现问题可以一键回滚到上一版

最后,开发者只需要在编辑器里写一个 "普通 JS 函数",但:

  • 安全隔离由 NodeVM 负责

  • 内网调用靠 $yuumi

  • 会话靠 $cookie

  • AI 友好的结果格式靠 $json2MarkdownTable

4.3 Tool Arguments:为 AI 帮开发者"定义接口"

传统开发写接口参数:

vbnet 复制代码
roomid: string

为此,我们提供了转为 Tool Arguments 的可视化配置:

它会统一生成:

  • MCP Tool 的 JSON Schema
  • StructuredTool 的 Zod Schema
  • NodeVM 内 $roomid 变量

一次配置,全平台复用。

4.4 工具市场:企业内部的 Agent 工具仓库

我们提供了工具市场:

  • 各部门把工具上架
  • 其他部门直接复用
  • 工具行为一致,调用方式一致
  • 避免重复开发

工具从"项目资产"变成"企业能力"。

五、第三版:接入 MCP ------让工具跨平台、跨模型、跨框架

做到第二版时,工具已经变得很好用了。但还缺一块:

同一份工具定义,既能在 Flowise 里当 LangChain StructuredTool 用,又能在 MCP 里变成跨平台 ToolCall。

5.1 MCP 是什么?以及一个常见误区

MCP 全称 Model Context Protocol,可以简单理解为:

给"大模型 + 工具调用"定义了一个"统一插线板"

它解决的是:

  • 大模型想调用一个外部工具

  • 这个工具可能跑在本机、另一台服务器、甚至另一个团队的系统里

  • 我们希望"调用方式"是统一、可描述、可流式的。

5.2 一个常见误区:把旧接口一包就行了?

很多人第一反应是:

那我把现在的业务 HTTP 接口包成一个 MCP 工具,不就行了吗?

理论上可以,实践里有两个坑:

1. 自然语言 ≠ 接口参数

用户会说:"查一下未完成的任务"。但你的接口长这样:/tasks?status=1&owner_id=xxx。中间这层 "自然语言 → status=1" 的映射,需要:

  • prompt / few-shot
  • 枚举表 / 映射关系
  • 甚至分类模型。

所以我们在 Tool Arguments 设置时一定要尽量贴近自然语言,比如 status 描述写成"任务的进度状态(未开始/进行中/已完成)",而不是"1/2/3"。

2. JSON 返回 ≠ AI 可读

很多内部接口返回一大坨嵌套 JSON,LLM 虽然能解析,但:

  • 容易"漏看"字段;
  • 回答会很啰嗦或不稳定。
  • 所以我们在云函数层统一规定:

返回给 AI 的一定是"人类可读"的文本/Markdown, JSON 只是中间态。

这就是前面 $json2MarkdownTable 那段代码存在的原因。

5.3 StructuredTool → MCP:我们是怎么做"代理层"的?

前面说了两件事:

  • 工具在平台内部用 LangChain StructuredTool + NodeVM 来执行

  • 我们希望同一份工具定义,既能在 Flowise 里用,也能被任意支持 MCP 的 Agent / Studio 调用

createMCPServer:Express 里的一层 MCP 网关

在服务端,我们做了一层很薄的 MCP 网关,挂在 Express 应用上:

ruby 复制代码
export function createMCPServer({ app, AppDataSource }: App){
  // 单个云函数 → MCP-StreamableHTTP  
  app.post('/api/mcp/function-tool/:toolId', singleToolCreateStreamableHTTPServer)  
  // 多个云函数组合 → MCP-StreamableHTTP  app.post('/api/mcp/:mcpId', multipleToolCreateStreamableHTTPServer)
  
  // 会话后续请求复用
  app.get('/api/mcp/function-tool/:id', handleSessionRequest) 
  app.delete('/api/mcp/function-tool/:id', handleSessionRequest)  
  app.get('/api/mcp/:mcpId', handleSessionRequest)
  app.delete('/api/mcp/:mcpId', handleSessionRequest)
  
  const transports = { 
    streamable: {} as Record<string, StreamableHTTPServerTransport>  
  }
  
  // ... 省略 SSE 相关 ...
}
  • 对外就是几条 HTTP 路由;
  • 对内维护一个 streamable 的 transport 池,用 sessionId 作为 key。

这样,不同 AI Studio / Agent 只要知道某个 URL,就可以把它当 MCP 端点来用。

Streamable HTTP:用 session 管住一条"长连接"

StreamableHTTPServerTransport 是 MCP 官方 SDK 提供的一个传输实现,用来做 Streamable HTTP 模式。我们做的事情有两种情况:

  1. 客户端第一次初始化(没有 sessionId);

  2. 之后所有请求都带上 mcp-session-id 头,复用之前的 session。

核心代码大概是这样:

javascript 复制代码
async function createStreamableHTTP(config: CreateMcpServerConfig){  
  const { req, res, username } = config
  const sessionId = req.headers['mcp-session-id'] as string | undefined 
  let transport: StreamableHTTPServerTransport
  
  if (sessionId && transports.streamable[sessionId]) {   
    // ① 有 sessionId,复用之前的 transport   
    transport = transports.streamable[sessionId]  
  } elseif (!sessionId && isInitializeRequest(req.body)) {  
    // ② 首次初始化,请求体符合 MCP 初始化格式   
    transport = new StreamableHTTPServerTransport({  
      sessionIdGenerator: () => randomUUID(),  
      onsessioninitialized: (sessionId) => {   
        transports.streamable[sessionId] = transport    
      }
   })
    
   // 连接关闭时清理掉这个 session 
   transport.onclose = () => {    
     if (transport.sessionId) {  
       delete transports.streamable[transport.sessionId] 
       opsLogger.info(`[Streamable] 删除sessionId: username=${username} sessionId=${transport.sessionId}`)    
     }
   }
   
   const server = buildMcpServer({  
     ...config,   
     transport: 'streamable-http'   
   })
   
   await server.connect(transport) 
 } else { 
   // 既不是初始化,又没有合法 sessionId:直接返回错误   
   res.status(400).json({  
     jsonrpc: '2.0',   
     error: {   
       code: -32000,  
       message: 'Bad Request: No valid session ID provided'    
     },
     id: null  
   })   
   return  
  }
  
  await transport.handleRequest(req, res, req.body)
}

配合一个统一的会话请求入口:

csharp 复制代码
async function handleSessionRequest(req: Request, res: Response){  
  const sessionId = req.headers['mcp-session-id'] as string | undefined  
  if (!sessionId || !transports.streamable[sessionId]) { 
    res.status(400).send('Invalid or missing session ID')   
    return 
  }
  
  const transport = transports.streamable[sessionId]  
  await transport.handleRequest(req, res)}

这样实现的好处是:客户端只需要记住一个 mcp-session-id,之后所有请求都可以复用同一条 Streamable HTTP 会话。

buildMcpServer:真正把"工具注册到 MCP 协议"

首先,用户可以将云函数平台创建的智能体工具代理到MCP协议上,一个MCP可以关联多个工具

接下来是最关键的一步:用官方 McpServer 把 Tool Registry 里的工具挂成 MCP Tool。

typescript 复制代码
function buildMcpServer(config: BuildMcpServerConfig): McpServer {
  const { name, tools, cookie, env, id, type, username, transport } = config
  
  const server = new McpServer({  
    name, 
    version: '1.0.0' 
  })
  
  for (const tool of tools) { 
    // 1)把数据库里的元信息,转成 DynamicStructuredTool 入参
    const obj = {      
      name: tool.name,
      description: tool.description,  
      schema: z.object(convertSchemaToZod(tool.schema as string | object)),      
      code: tool.func as string  
    }
    
    // DynamicStructuredTool 内部就是一个"动态 StructuredTool" + NodeVM 沙箱
    const dynamicStructuredTool = new DynamicStructuredTool(obj)
    
    // Flow 信息:cookie + env(来自 MCP URL 上的 sign / 其它query)
    dynamicStructuredTool.setFlowObject({ 
      cookie,  
      env: typeof env === 'object' && Object.keys(env).length ? env : {}   
    })
    
    // 2)把我们在 UI 里配置的 Tool Arguments schema,转成 MCP 需要的 zod 参数定义 
    const schemaParser = (tool.schema ? JSON.parse(tool.schema) : []) as Schema[]
    
    const paramsSchema = schemaParser.reduce((res, cur) => {  
      if (cur.required) { 
        res[cur.property] = z[cur.type]({ required_error: `${cur.property} required` })   
          .describe(cur.description) as z.ZodTypeAny   
      } else {   
        res[cur.property] = z[cur.type]()   
          .describe(cur.description)     
          .optional() as z.ZodTypeAny    
      }
      return res   
    }, {} as any)
    
    // 3)用官方 server.tool() 方法注册 MCP Tool 
    server.tool(tool.name, tool.description, paramsSchema, async (args, _extra) => {   
      // 注意:这里的 call() 对应的就是 LangChain StructuredTool._call()  
      const runnerResult = await dynamicStructuredTool.call({  
        ...args
      })
      
      // 线上环境做一层使用上报(方便统计谁在用哪个 MCP)
      if (process.env.DEPLOY_ENV === 'prod') {   
        reportMcpUse({   
          id,  
          type,   
          name,   
          username,  
          transport  
        })   
       }
       
       // MCP 协议统一的返回格式:content[] 数组  
       return {    
         content: [{ type: 'text', text: runnerResult }]  
       }
     })
   }
   
   return server
 }

把Tool Registry 里的:

  • name

  • description

  • schema(以数组 JSON 形式存)

  • func(在线编辑器里的 JS 代码)

按 MCP 需要的格式做了一层映射:

  • 输入 → paramsSchema(zod 校验)

  • 执行 → dynamicStructuredTool.call()

  • 输出 → MCP 标准的 content[{ type: 'text', text: ... }]

StructuredTool 内部:从 MCP 入参到 NodeVM 执行的最后一步

DynamicStructuredTool.call() 里面最终会走到 _call(),这一步就是前面介绍的 NodeVM 沙箱执行:

  • 把 MCP 传进来的参数变成 $xxx 变量

  • 把 cookie / env、以及会话信息塞进 $flow

  • 注入 <math xmlns="http://www.w3.org/1998/Math/MathML"> y u u m i 、 yuumi、 </math>yuumi、json2MarkdownTable、 <math xmlns="http://www.w3.org/1998/Math/MathML"> b i l i I n d e x 、 biliIndex、 </math>biliIndex、deepseek、$chatgpt 等能力

  • 在受限依赖白名单下创建 NodeVM

  • 以 module.exports = async function() { ${this.code} }() 的形式执行开发者写的 JS

对于上层 MCP 调用方来说:

调用了一个名字叫 QueryLiveRoomInfo 的 MCP 工具,传了一些参数,收到了一个文本/Markdown 结果

而对于我们平台来说,这中间其实已经执行了一整套:

MCP → McpServer.tool → DynamicStructuredTool → NodeVM → 内网服务的完整链路。

可复用可共享的MCP市场

对于 MCP 新手,我们在平台上还提供了"MCP 市场" 的入口,里面还提供了一键调试:

  • 不需要真的连上一个大模型

  • 直接在浏览器里模拟一次 MCP ToolCall

  • 看看云函数有没有跑对、返回是不是 AI 友好的格式

六、身份鉴权:MCP 是独立服务,安全必须先想清楚

MCP 本质上是一个"跨平台的服务访问入口",如果不做鉴权,很容易变成无法管理。我们的做法大致是这样:

  1. 注册阶段就要带 sign:
  • 每个 MCP 工具的注册地址必须带一个 sign 参数
  • sign 是基于内网规范签出的签名
  • 没有合法签名,工具不会被注册成功
  1. 调用阶段统一 Proxy:
  • 外部 Agent 调用 MCP Server
  • MCP Server 把调用转发给云函数平台的 Proxy 层
  • Proxy 层会:
  • 校验 sign
  • 注入对应的 $cookie / 会话
  • 再去调真正的内网业务接口
  1. 业务侧拿到的是"处理后的内网协议":
  • Proxy 会把 sign 解密、校验后,以内部统一格式透传给业务

  • 避免在业务里掺杂各种外部协议细节

这样,MCP 既可以对外暴露统一的工具接口,又仍然受控在内网的安全体系内

七、结语:把"接 AI"变成"写一个云函数"

从 AI工作流 → StructuredTool → 云函数平台 → MCP 的这条路径,我们最终得到了:

  • 一个统一的工具定义方式:元信息

  • 一个统一的执行方式:NodeVM

  • 一个统一的调用协议:MCP

  • 一个统一的共享方式:工具市场

  • 一个统一的安全体系:sign + cookie

也让我们真正做到了:

让业务同学不再关心"怎么接 AI",而是只需要关心"我能给 AI 提供什么能力"。

把写一个 JS 函数,变成给 AI 加一个新能力。

未来我们还会继续提升运行时性能、正确性评估观测、深度研究等方向,把工具能力建设得更现代、更企业级。

-End-

作者丨Zerooo、Gengar

相关推荐
aaaa_a1332 小时前
The lllustrated Transformer——阅读笔记
人工智能·深度学习·transformer
jinxinyuuuus2 小时前
文件格式转换工具:数据序列化、Web Worker与离线数据处理
人工智能·自动化
易天ETU2 小时前
短距离光模块 COB 封装与同轴工艺的区别有哪些
网络·人工智能·光模块·光通信·cob·qsfp28·100g
秋刀鱼 ..2 小时前
第二届光电科学与智能传感国际学术会议(ICOIS 2026)
运维·人工智能·科技·机器学习·制造
郭庆汝2 小时前
(九)自然语言处理笔记——命名实体的识别
人工智能·自然语言处理·命名实体识别
Oxo Security3 小时前
【AI安全】拆解 OWASP LLM Top 10 攻击架构图
人工智能·安全
Math_teacher_fan3 小时前
第二篇:核心几何工具类详解
人工智能·算法
yingxiao8883 小时前
11月海外AI应用市场:“AI轻工具”贡献最大新增;“通用型AI助手”用户留存强劲
人工智能·ai·ai应用