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 标识,同时是工具名前缀 |
type |
http 或 sse,缺省且只有 url 时按 http |
url |
远程 MCP 端点 |
description |
给人看的能力摘要,也会进系统提示 |
headers |
原样带到 MCP 请求,Token、知识库 ID 都放这里 |
disabled |
为 true 时不默认勾选,不参与预热 |
下面是一个示例配置,跟 Cursor 里写得差不多。
json
{
"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 不写日志,落库只走加密字段。
整体数据流是下面这张图。
多 worker 部署时有个坑要兜住。预热可能打在 A 进程,对话打在 B 进程。我们的处理是,聊天请求必须再次带上已选 Server 的连接信息,B 进程按请求里的 url 和 headers 即时建连,写进自己的池,不阻塞对话。预热是提效,不是必需,即时建连是保底。
预热和渐进式工具注入
渐进式注入算法
工具不能一次全给,这是我们要先解决的一个问题。
用户配了五个 Server,每个暴露十个工具,五十个 schema 全塞进 function calling,上下文先吃掉一大截。工具一多,模型还容易在错的时机选中错的工具。MCP Client 干的事是翻译,把 Server 的工具翻成模型看得懂的东西,翻译也得看场合,五十份材料一次全译出来,模型反而看不过来。我们的做法是预热建连,按需注入,模型点到哪个翻哪个。
先看看要翻的材料长什么样。一个 MCP tool 的核心字段不多,按披露成本可以分成三层。

| 层级 | 字段 | 作用 | 示例 |
|---|---|---|---|
| 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 就是这几行。
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 填目录。
ts
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,工具名和一句话简介 | 预热或探测成功后 |
| 详细 schema | L3 | 本轮及后续 step 的 function calling | inputSchema、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 才能看见业务工具名。
ts
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 裁。
ts
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 还没进请求。
json
{
"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 去服务端取。
json
{
"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、数据库 URL | env 显式白名单,默认空或最小集,不要 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 的人。
工具给谁调、命令听谁的,这两件事想明白,剩下的都是体力活。协议是开源的,边界是自己的。