让聊天机器人学会用工具,星悟接 MCP 的实践

119 阅读18分钟

MCP 已经是 chatbot 的必需能力,各家 AI 客户端基本都接了。我们项目组也要把这块补上,作为组里平时写对话这块的人,这活顺理成章落到了我头上。

接之前,星悟能拿外部信息的通道只有两个,应用侧的 search_web 和 web_fetch,再加上一部分模型自带的联网搜索。这两条通道对付一般问题够用,内部知识就没办法了。团队的文档、代码库、某个服务的 wiki,搜索引擎收录不到,机器人有联网能力也查不到。我们想要的形态是,用户把知识源配进星悟,对话里说一句"帮我查一下 XX 模块的接口文档",机器人自己去翻。

接法上有两条路,厂商原生 MCP 和应用侧执行。厂商原生 MCP 我们看过,模型厂商代调工具,会在对话里留下厂商私有的工具记录。星悟通过 OpenAI 网关接了多个模型,消息历史在 prepareMessageHistory 里只保留纯文本,换一个模型,这种私有记录就回放不了。看着省事,代价是把工具的解释权交出去,我们接受不了。所以定了走应用侧执行,自己负责 tools/list 和 tools/call,模型只看到普通的 function calling,MCP 返回的东西必须是普通 JSON 或文本。

几条边界也在这时候定下来。对话主流程不动,MCP 以工具形态并入现有的 streamText。会话消息仍无状态,服务端不按会话存 MCP 工具状态。mcp.json 做成双写,浏览器 localStorage 负责编辑器即时读写,用户维度的配置表再存一份权威副本,换设备、清缓存不会丢。首版只做读,不做 OAuth,不做写操作自动执行。

为什么选 @ai-sdk/mcp

路线定下来以后,选型反而没什么好纠结的。AI SDK 官方有 @ai-sdk/mcp,createMCPClient 能把任意 MCP Server 暴露的工具直接转成 AI SDK 的 ToolSet,并进现有 streamText。不用自研协议层,接入成本压到最低。

传输上以 Streamable HTTP 为主,配置里 type 缺省或者写成 sse,就走 SSE。stdio 这条路径留到安全那节再讲,你先记住,它不进用户可配置的生产路径。

两条路放在一起看。

维度应用侧执行厂商原生 MCP
谁调工具应用自己 tools/list、tools/call模型厂商代调
模型看到什么普通 function calling厂商私有的工具记录
换模型、跨网关可迁移,回放不受影响回放不了,绑死厂商
可控性完全可控受厂商实现限制

表里第一行决定了很多事。谁调工具这件事定死以后,工具的执行和解释权留在应用侧,后面要讲的预热和渐进式注入才有空间。

配置双写,连接在服务端

只放 localStorage 编辑器很爽,产品上撑不住。清站点数据、换浏览器、换电脑,配置就没了。Token 跟着丢,用户会以为星悟把知识源弄丢了。我们做成双写。浏览器 localStorage(Zustand persist,key 是 xingwu-mcp-config)负责编辑器即时读写。用户维度的配置表再存一份权威副本,按登录账号隔离。打开页面时以数据库为准回填 localStorage。保存时两边一起写。

这和「全员共用一份服务端 config/mcp.json」不是一回事。仓库里不放真实 Token,也不新增全局 config/mcp.json。库里是当前用户自己的 mcp.json,headers 加密落库,访问日志仍然打码。对话热路径不必每轮先打数据库,请求里仍带上已选 Server 的 url 和 headers,服务端只在这次请求里建连。数据库解决的是持久化和换端,进程内池解决的是同进程复用。

配置格式和常见 MCP Host 的 mcpServers 对象一致,Cursor 和 Claude Desktop 都用这套,用户零学习成本。字段不算多。

字段含义
mcpServers.<id>Server 标识,同时是工具名前缀
typehttp 或 sse,缺省且只有 url 时按 http
url远程 MCP 端点
description给人看的能力摘要,也会进系统提示
headers原样带到 MCP 请求,Token、知识库 ID 都放这里
disabled为 true 时不默认勾选,不参与预热

下面是一个示例配置,跟 Cursor 里写得差不多。

{
  "mcpServers": {
    "context7": {
      "type": "http",
      "url": "https://mcp.context7.com/mcp",
      "description": "Context7,给 AI 提供最新的开源库文档"
    },
    "docs": {
      "url": "https://your-mcp.example.com/mcp",
      "description": "内部知识库搜索",
      "headers": {
        "Authorization": "Bearer <token>"
      },
      "disabled": true
    }
  }
}

这里的规矩先说清楚。Token 不进仓库,不进服务端环境变量,不进全局 config/mcp.json。鉴权字段写在用户自己 mcp.json 的 headers 里,浏览器存一份,数据库按用户加密再存一份。仓库里可以放不带密钥的格式说明,真实 Token 禁止提交。

服务端把建好的 MCP Client 缓存在进程内存的池里,方便预热和同一进程内复用。池的 key 用连接指纹,url 加 headers 摘要出来的,避免把 Token 明文当 map key 存一份。原始 Token 不写日志,落库只走加密字段。

整体数据流是下面这张图。

flowchart TB
  editor[&#34;用户编辑 mcp.json&#34;] --> ls[&#34;localStorage&#34;]
  ls --> ui[&#34;勾选 Server&#34;]
  ls --> warmup[&#34;页面加载或保存后预热&#34;]
  ui --> probe[&#34;勾选探测&#34;]
  warmup --> pool[&#34;进程内 MCP 池&#34;]
  probe --> pool
  ui --> chatApi[&#34;chat 接口带上已选 Server 的 url 与 headers&#34;]
  pool --> catalog[&#34;Server 摘要与工具目录&#34;]
  catalog --> sysPrompt[&#34;系统提示里的能力摘要&#34;]
  chatApi --> meta[&#34;元工具 mcp_inspect_tools&#34;]
  chatApi --> streamText[&#34;streamText&#34;]
  sysPrompt --> streamText
  meta --> streamText
  streamText --> prepareStep[&#34;prepareStep 按已激活名单收紧 activeTools&#34;]

多 worker 部署时有个坑要兜住。预热可能打在 A 进程,对话打在 B 进程。我们的处理是,聊天请求必须再次带上已选 Server 的连接信息,B 进程按请求里的 url 和 headers 即时建连,写进自己的池,不阻塞对话。预热是提效,不是必需,即时建连是保底。

预热和渐进式工具注入

渐进式注入算法

工具不能一次全给,这是我们要先解决的一个问题。

用户配了五个 Server,每个暴露十个工具,五十个 schema 全塞进 function calling,上下文先吃掉一大截。工具一多,模型还容易在错的时机选中错的工具。MCP Client 干的事是翻译,把 Server 的工具翻成模型看得懂的东西,翻译也得看场合,五十份材料一次全译出来,模型反而看不过来。我们的做法是预热建连,按需注入,模型点到哪个翻哪个。

先看看要翻的材料长什么样。一个 MCP tool 的核心字段不多,按披露成本可以分成三层。

mcp tool 可视化.png
层级字段作用示例
L1 核心命名(高)name、title工具的身份标识,name 是模型调用时的句柄APICallTool、API 调用工具
L2 功能描述(中)description一句话讲清工具干什么调用指定接口并传递参数,返回标准化响应数据
L3 输入输出模式(低)inputSchema、OutputSchema输入参数与返回结构的完整定义输入 url、method、headers、body,输出 data、code、message、latency

name 是模型调用工具时用的句柄,title 给人看。description 决定模型在什么场景想起这个工具。inputSchema 最重,列出每个参数的名称、类型、是否必填,OutputSchema 描述返回结构。三层的信息量差着量级,L1 一个名字就能说清,L3 是一份完整定义,渐进式披露就是按这个成本梯度一层层放。

预热时机

预热的目的是把第一句话的延迟省掉。用户打开星悟或者保存配置之后,已启用的 Server 尽快连上,工具目录备好,等发第一句话时,就不用再付 initialize 和 tools/list 的开销。

三个时机触发。打开页面时先按登录账号把数据库里的 mcp.json 回填到 localStorage,MCP 默认开启则对没 disabled 的 Server 调预热接口。用户保存 mcp.json 后双写,并对当前启用列表再预热一遍。勾选某一台时单独探测。

预热接口很薄,解析请求体后丢给连接池。packages/chatbot/app/api/mcp/warmup/route.ts 就是这几行。

export async function POST(request: Request) {
  const body = (await request.json()) as { mcpServers?: unknown };
  const servers = parseSelectedMcpServers(body.mcpServers);
  const snapshots = await warmupMcpServers(servers);
  return Response.json({ servers: snapshots });
}

真正干活的是 lib/mcp/pool.ts。指纹用 url 加 headers 的 SHA-256,Token 不当明文 key。已就绪且心跳窗口内直接复用,否则 createMCPClient 建连,再 listTools 填目录。

export function fingerprintMcpConnection(
  url: string,
  headers: Record<string, string>,
): string {
  const normalizedHeaders = Object.keys(headers)
    .sort((left, right) => left.localeCompare(right))
    .map((key) => [key.toLowerCase(), headers[key]] as const);
  return createHash("sha256")
    .update(JSON.stringify({ headers: normalizedHeaders, url }))
    .digest("hex");
}

async function openMcpServer(server: ParsedMcpServer, fingerprint: string) {
  const client = await createMCPClient({
    clientName: "xingwu-chatbot",
    initializationOptions: { timeout: MCP_CONNECT_TIMEOUT_MS },
    maxRetries: 2,
    transport: {
      headers: server.headers,
      type: server.transport,
      url: server.url,
    },
  });
  const listed = await client.listTools();
  const toolSet = (await client.tools()) as ToolSet;
  // catalog.tools 只留 name / description / inputSchema,快照返回前端时不含 headers
}

心跳 60 秒,TTL 15 分钟。用户删掉或改掉某台 Server 后,前端不再带它,池条目靠 TTL 淘汰。没有做跨用户精确回收。

勾选探测走 POST /api/mcp/probe,body 是 { id, server },server 里是该台的 url、type、headers。成功才保持勾选,失败提示"某知识源不可用"并取消勾选。已经 ready 且心跳没过期的,直接算成功。

MCP Tool 调用详细实现

工具的加载分三层,每一层被不同的角色看到。

披露层对应字段层谁看见内容何时出现
目录摘要L1 + L2系统提示name、title、description,工具名和一句话简介预热或探测成功后
详细 schemaL3本轮及后续 step 的 function callinginputSchema、OutputSchema 完整定义mcp_inspect_tools 成功之后
工具结果L3 输出值消息里的 tool 记录tools/call 返回的真实数据模型真正调用业务工具之后

理解方式可以往招聘上靠。系统提示里先放目录摘要,等于递一份候选人名单,只有名字和一句话简介,这是 L1 和 L2,成本最低。模型点名要看某位的完整资料,mcp_inspect_tools 才把 L3 的完整 schema 递过去。资料看完、人也见了,最后才真正调用工具干活,拿回 L3 输出字段的真实值。

三层靠元工具 mcp_inspect_tools 串起来。入参是一串带前缀的名字,格式 serverId_originalName,比如 docs_search。它只允许加载当前请求里已选且 ready 的 Server。超过 16 个就拒绝。激活是替换语义,不是累加,缩小名单再 inspect 一次就能换一批。它只把参数说明带回给模型,不在这一步代调业务接口。元工具不占 16 的业务槽位。search_web 和 web_fetch 仍由搜索开关控制,也不占。

lib/mcp/tools.ts 里,会话创建时会把已选 Server 的全部 execute 挂进 tools,但 getActiveTools() 一开始只返回 inspect。inspectMcpTools 成功后,下一轮 prepareStep 才能看见业务工具名。

export function createMcpToolSession(
  entries: McpPoolEntry[],
  initialActivated: string[] = [],
): McpToolSession {
  const catalogByPrefixed = new Map<string, { description: string; inputSchema: unknown; serverId: string }>();
  const tools: ToolSet = {};
  // 把已选 Server 的 catalog 和 execute 全部挂进 tools,前缀为 serverId_originalName
  const allowed = new Set(catalogByPrefixed.keys());
  const activated = new Set(
    initialActivated.filter((name) => allowed.has(name)).slice(0, MAX_MCP_BUSINESS_TOOLS),
  );

  tools[MCP_INSPECT_TOOL_NAME] = tool({
    description:
      "查看并激活 MCP 工具的完整参数说明。传入带知识源前缀的工具名;激活后下一轮才能调用这些工具。一次最多 16 个 MCP 业务工具。",
    execute: async ({ names }) => inspectMcpTools(names, catalogByPrefixed, allowed, activated),
    inputSchema: jsonSchema<{ names: string[] }>({
      additionalProperties: false,
      properties: {
        names: {
          description: "要加载的 MCP 工具名,例如 iwiki_search_document",
          items: { type: "string" },
          type: "array",
        },
      },
      required: ["names"],
      type: "object",
    }),
  });

  return {
    getActiveTools: () => [MCP_INSPECT_TOOL_NAME, ...activated],
    tools,
    catalogSummary: formatMcpCatalogSummary(entries),
    inspectToolName: MCP_INSPECT_TOOL_NAME,
  };
}

function inspectMcpTools(
  names: unknown,
  catalogByPrefixed: Map<string, { description: string; inputSchema: unknown; serverId: string }>,
  allowed: Set<string>,
  activated: Set<string>,
) {
  const requested = Array.isArray(names)
    ? names.filter((name): name is string => typeof name === "string" && name.trim() !== "")
    : [];
  const unique = [...new Set(requested.map((name) => name.trim()))];
  const valid = unique.filter((name) => allowed.has(name));
  if (valid.length > MAX_MCP_BUSINESS_TOOLS) {
    return {
      error: `一次最多激活 ${MAX_MCP_BUSINESS_TOOLS} 个 MCP 工具,请缩小名单后重试。`,
      remaining: Math.max(0, MAX_MCP_BUSINESS_TOOLS - activated.size),
    };
  }
  activated.clear();
  for (const name of valid) activated.add(name);
  return {
    activated: [...activated],
    remaining: MAX_MCP_BUSINESS_TOOLS - activated.size,
    tools: valid.map((name) => ({
      name,
      description: catalogByPrefixed.get(name)?.description ?? "",
      inputSchema: catalogByPrefixed.get(name)?.inputSchema ?? { type: "object" },
    })),
  };
}

对话入口在 app/api/chat/route.ts。有 MCP 时 stopWhen 是 isStepCount(10),prepareStep 每一步问会话要当前 activeTools。tools 里挂了元工具加全部 execute,发给模型的定义仍靠 activeTools 裁。

const mcpSession =
  mcpEntries.length > 0
    ? createMcpToolSession(mcpEntries, parseActivatedMcpTools(body.activatedMcpTools))
    : undefined;

const result = streamText({
  messages: await convertToModelMessages(history, hasTools ? { tools } : {}),
  model: createOpenAILanguageModel(modelId, protocol),
  prepareStep: prepareChatToolStep({
    mcpActiveTools: mcpSession ? () => mcpSession.getActiveTools() : undefined,
    systemPrompt: system,
    // ...
  }),
  stopWhen: isStepCount(mcpSession ? 10 : 4),
  tools,
  system,
});

星悟走 OpenAI 兼容网关的 Responses API,createOpenAI().responses(modelId),并且 store: false。渐进式注入落到网关上,就是每一步 POST /v1/responses 时 tools 数组不一样。step 0 模型只能看见 inspect,业务工具的完整 schema 还没进请求。

{
  "model": "gpt-5.5",
  "store": false,
  "instructions": "已启用知识源 MCP。当前可用知识源:\n## docs(内部知识库搜索)\n- docs_search:按关键词检索文档\n先调用 mcp_inspect_tools,再在下一轮调用这些工具。一次最多激活 16 个 MCP 业务工具。",
  "input": [
    {
      "role": "user",
      "content": "帮我查一下 XX 模块的接口文档"
    }
  ],
  "tools": [
    {
      "type": "function",
      "name": "mcp_inspect_tools",
      "description": "查看并激活 MCP 工具的完整参数说明。传入带知识源前缀的工具名;激活后下一轮才能调用这些工具。一次最多 16 个 MCP 业务工具。",
      "parameters": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "names": {
            "type": "array",
            "items": { "type": "string" },
            "description": "要加载的 MCP 工具名,例如 iwiki_search_document"
          }
        },
        "required": ["names"]
      }
    }
  ],
  "tool_choice": "auto"
}

模型在这一步只会调 mcp_inspect_tools,比如传入 ["docs_search"]。inspect 的 tool result 把完整 inputSchema 带回给下一跳。step 1 的 tools 才出现业务工具。store 为 false,上一跳的 function call 和 output 要写进 input 里一起回放,不能靠 previous_response_id 去服务端取。

{
  "model": "gpt-5.5",
  "store": false,
  "instructions": "…同上,工具返回后追加 afterTools 说明…",
  "input": [
    { "role": "user", "content": "帮我查一下 XX 模块的接口文档" },
    {
      "type": "function_call",
      "call_id": "call_inspect_1",
      "name": "mcp_inspect_tools",
      "arguments": "{\"names\":[\"docs_search\"]}"
    },
    {
      "type": "function_call_output",
      "call_id": "call_inspect_1",
      "output": "{\"activated\":[\"docs_search\"],\"remaining\":15,\"tools\":[{\"name\":\"docs_search\",\"description\":\"按关键词检索文档\",\"inputSchema\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\"}},\"required\":[\"query\"]}}]}"
    }
  ],
  "tools": [
    {
      "type": "function",
      "name": "mcp_inspect_tools",
      "description": "查看并激活 MCP 工具的完整参数说明。…",
      "parameters": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "names": {
            "type": "array",
            "items": { "type": "string" }
          }
        },
        "required": ["names"]
      }
    },
    {
      "type": "function",
      "name": "docs_search",
      "description": "按关键词检索文档",
      "parameters": {
        "type": "object",
        "properties": {
          "query": { "type": "string", "description": "检索关键词" }
        },
        "required": ["query"]
      }
    }
  ],
  "tool_choice": "auto"
}

对比两份请求就能看清渐进式注入在干什么。tools 里挂的 execute 早就在 Node 里备好了,发给模型的定义靠 activeTools 裁。step 0 的 tools 只有 inspect,step 1 才把 docs_search 的完整 parameters 塞进 Responses API。上下文按需涨,而不是开场就把五十个 schema 铺上去。

跨轮次时,前端把 activatedMcpTools 和已选 Server 配置一起 POST。取消勾选,或者从 mcp.json 里删掉某台,就丢掉对应前缀。服务端不按会话存 MCP 状态,状态都随请求带来。

安全,几层防线和一条红线

前面讲的是怎么把功能做出来,这一节讲怎么让它安全落地。MCP 接入最怕的两件事,一件是连上一个不该连的东西,一件是执行了一段不该执行的代码。功能做错了最多不好用,安全做错了是机器被黑,这两件事的优先级不是一个量级。

第一层是密钥按用户加密保管。Token 写进用户 mcp.json 的 headers,浏览器 localStorage 存一份,用户配置表加密再存一份。不做全员共用的 config/mcp.json,也不把别人的 Token 下发给当前用户。访问日志和错误信息对 headers 与 Authorization 打码。连接指纹用稳定哈希,Token 不当明文 key。

Token 放在 localStorage 意味着编辑器这一侧仍受 XSS 影响,这是双写里客户端缓存自带的代价,我们用 CSP 和现有前端安全基线去对冲。数据库这一侧要防的是库泄露,所以 headers 必须加密落库,不能明文 JSON 一把塞进去。

第二层是 SSRF。用户可填任意 url,理论上存在 SSRF 风险。首版面向司内同事,实施时至少拒绝非 http(s) 协议,设连接超时。内网网段要不要加允许列表,交给部署方另定。

第三层是红线,也是全文最重要的一条。禁止根据用户 mcp.json 里的 command 或 args 在服务端 spawn。这是远程代码执行,和 SSRF 不是一个量级。这段如果只记一句,记这一条。

用户配置里就算出现 command、args,我们也一律不执行,界面明确提示不能用 mcp.json 拉起本地进程。stdio 只允许走下面的方案 B,而且默认关闭。

方案 B

方案 B 是服务端白名单 stdio,给自托管场景准备的。如果必须接一个只能走 stdio 的 MCP Server,把"可执行什么"从用户配置里拿掉,改成运营方登记。白名单只放在服务端,每条记录固定 command 绝对路径、args 模板、工作目录、允许的环境变量名、超时、并发上限。用户请求里只出现 id,不出现 command、args、可执行路径。解析时遇到用户侧的 command 直接校验失败。spawn 的参数只从白名单拼,用户输入不拼进可执行位,绝不用 shell: true。进程进池,心跳、TTL、对话结束或者超时后 kill 掉,避免僵尸进程。

这个方案只在自托管、长驻 Node、worker 数量可控的环境打开,默认关闭,要显式环境开关。Serverless 不开。

方案 B 一旦做错就是服务端远程代码执行。我们把风险列成了一张表,每条都写了"若要做必须守住"。

风险说明若要做必须守住
任意命令执行用户或 XSS 改掉的 localStorage 把 command 设成 bash、curl,经 /api/chat 在 Node 里拉起永不信任请求体里的 command 和 args,只 spawn 白名单绝对路径
白名单被掏空条目写成 npx、uvx、bash -c,或 args 允许拼接任意包名禁止包管理器入口当 command,禁止 shell: true,args 只能是常量或枚举过的占位符
参数注入把用户字符串拼进 argv 或 env,变成 --eval、-c、NODE_OPTIONS允许覆盖的参数单独白名单,拒绝 NODE_OPTIONS、LD_PRELOAD、PATH 由用户改
密钥进子进程子进程继承整个 process.env,MCP 工具或恶意包读到网关 Key、数据库 URLenv 显式白名单,默认空或最小集,不要 env: process.env
文件系统与内网本地 MCP 往往能读盘、打内网,模型一调用等于登录这台机器的只读代理首版只接只读工具,cwd 限制在固定目录,写操作必须 needsApproval
进程生命周期请求结束子进程还在,异常没 kill,并发把机器打满超时、并发上限、池淘汰时 kill,监听子进程退出,worker 退出时拆掉全部 stdio Client
Serverless 或多 worker函数实例短命,stdio 连不上长会话,预热和对话各拉起一套进程方案 B 不在 Serverless 开,多 worker 接受每进程一份子进程,或改 sidecar
和用户自管配置冲突产品叙事是用户自己写 mcp.json,方案 B 却不能让用户写 command文档、UI、校验三条线一致,stdio 是运营方登记
依赖投毒白名单指向的 node dist/index.js 若来自未锁版本的 npm 包,更新即换代码固定绝对路径与版本,变更白名单走发布评审

还有三个明确不做的变体。开发机信任 localStorage 里的 command,开发配置被拷到生产,或者同事误开开关,就是 RCE。前端把 command 提交上来,服务端再检查一下是不是 npx,命令名黑名单防不住路径绕过。把白名单做成用户可编辑的服务端 mcp.json 下载,图省事,把安全模型拆了。

能接受这些约束再实现方案 B,接受不了就用 sidecar,把 stdio Server 包成 HTTP 或 SSE,星悟继续只连远程端点。我们现在的选择就是后者。

踩坑与边界

复盘下来有几处取舍值得记。

无状态和缓存池放在一起,分工要分清。状态随请求走是原则,进程内池只是性能优化。池条目回收不用做精确,TTL 淘汰就够,用一点内存换实现简单。

多 worker 的建连兜底前面提过。预热是提效,不是必需,即时建连是保底。就算预热全失效,对话也能正常完成,只是慢一点。

会话压缩的时候,旧的 MCP 工具结果会被 prune,近窗内可以留短结果。这和"MCP 结果必须是普通 JSON 或文本"的约束是配套的,工具轨迹随时可能被裁剪,关键状态不能放在工具结果里,要靠下一次调用来重取。

系统提示里的 MCP 块由服务端根据本次已选 Server 的 description 和预热目录拼接,使用说明写在 prompt.json。必须 inspect 再调用。headers 和 Token 禁止写进提示词,禁止出现在模型可见的工具结果里。

有些边界我们明确不碰,写下来比做了再后悔强。全员共用一份带密钥的服务端 mcp.json,或者把别人的 Token 下发给当前用户,不做。按用户 mcp.json 的 command、args、npx 在服务端拉起进程,不做,这是红线。Serverless 或多租户公网部署上开方案 B,不做。写操作自动执行,不做,以后要做必须走 needsApproval。厂商原生 MCP 当默认,不做,多模型下回放问题无解。把 MCP 目录当向量库,或者每轮自动 compact,也不做,目录是工具清单,不是知识库。

未来的方向也清楚。写操作加审批流程。真要接本地进程,优先 sidecar,运营方把 stdio Server 包成 HTTP 或 SSE,星悟只连远程端点,命令执行能力不放开给用户。

小结

最后说一句给正在接 MCP 的人。
工具给谁调、命令听谁的,这两件事想明白,剩下的都是体力活。协议是开源的,边界是自己的。

参考