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

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

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

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

一、先做简单介绍

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

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

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

Flowisehttps://flowiseai.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 需要的是“能力”,而不是“接口”

业务接口往往是这样的:

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:

exportclassQueryLiveRoomToolextendsStructuredTool {
  name = 'QueryLiveRoomTool'
  description = '根据 uid 查询主播信息'

  schema = z.object({
    roomid: z.string().describe('直播间id')
  })

  async _call({ roomid }) {
    const res = await axios.post({...})
    return formatForAI(res)
  }
}

这一版有几个优点:

  • 工具能被 Agent 调用

  • 参数有 schema

  • 有一定的模块化

但有几个局限:

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

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

  • 无法跨平台使用

  • JSON 处理还是要自己写

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

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

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

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

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

图片

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

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

  • 运行时基于 StructureTool

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

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

/**
 * 核心思路抽象版
 */
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 能力

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

const res = await $yuumi.grpc({
  appId: 'live.service',
  path: '/room/info',
  params: { roomid: $roomid },
  cookie: $cookie,
})

return $json2MarkdownTable(res.data.list)

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 帮开发者“定义接口”

传统开发写接口参数:

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 应用上:

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。

核心代码大概是这样:

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)
}

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

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。

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

  • 注入 $yuumi、$json2MarkdownTable、$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 是基于内网规范签出的签名

  • 没有合法签名,工具不会被注册成功

2.  调用阶段统一 Proxy:

  • 外部 Agent 调用 MCP Server

  • MCP Server 把调用转发给云函数平台的 Proxy 层

  • Proxy 层会:

  • 校验 sign

  • 注入对应的 $cookie / 会话

  • 再去调真正的内网业务接口

3.  业务侧拿到的是“处理后的内网协议”:

  • Proxy 会把 sign 解密、校验后,以内部统一格式透传给业务

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

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

图片

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

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

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

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

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

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

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

也让我们真正做到了:

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

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

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

-End-

作者丨Zerooo、Gengar

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐