1. 从 V1 到 V2:Skill 与 MCP 集成到底难在哪
Cloud Agent 这个项目做到第四篇,终于到了收尾阶段。前几篇分别讲了工具系统删减、多租户架构、SSE 流式对话,这一篇聚焦最后一块拼图:Skill 与 MCP 的集成。如果你正在用 TypeScript 写一个带 Agent 能力的 Web 应用,需要让 LLM 既能按业务流程执行技能,又能通过 MCP 协议调用外部工具,那这篇的踩坑记录应该能帮你省不少时间。
先说清楚这两个概念在 Cloud Agent 里的定位。Skill 是业务能力的封装,一个 SKILL.md 文件加上配套的脚本和模板,告诉 LLM 遇到某类任务该按什么流程走。MCP 是执行能力的扩展,通过 stdio 方式启动一个子进程,把外部工具(比如 PDF 解析、数据分析)注册成 LLM 可调用的函数。Skill 说做什么,MCP 做怎么做,两者在代码层完全解耦,靠 Skill 文件里的显式指令配合。
V1 版本用 Python 写,Skill 和 MCP 耦合在一个 God 类里,靠 XML 解析配置。V2 用 TypeScript 从零设计,参考的是 Claude Code 的架构思路,但运行时前提完全不同——Claude Code 是单用户本地 CLI,MCP 连接生命周期等于用户会话;Cloud Agent V2 是长驻多用户 Web 服务,MCP 连接要活几天甚至几周。这个差异导致连接管理、并发控制、超时策略全都要重新设计。
我试过直接搬 Claude Code 的连接机制,结果在并发重连和 uvx 启动延迟上连续踩坑,4 月 16 号接入 MCP,到 4 月 25 号一天交了 5 个稳定性相关的 commit 才算稳住。下面按实际开发顺序,把 Skill 注册、MCP 配置、stdio 启动、端到端验证这条链路完整走一遍,所有配置片段都可以直接复制。
2. TaoToken 前置:统一 Key 管理多工具凭证
在讲 Skill 和 MCP 的具体配置之前,先解决一个绕不开的问题:模型调用的凭证管理。Cloud Agent 里同时存在多个需要调用 LLM 的地方——主对话循环、Skill 执行时的子调用、MCP 工具内部的模型请求。如果每个地方各自维护 endpoint 和 auth.json,改一次 Key 要翻五六个文件,测试环境和生产环境切换更是灾难。
TaoToken 在这里的作用是提供统一的 API 通道。你只需要在 TaoToken 控制台创建一个 API Key,所有需要调用模型的地方都指向同一个 Base URL 和同一个 Key。它的 API 地址是https://taotoken.net/api,兼容 OpenAI 风格的接口格式,所以现有的 SDK 基本不用改代码,只改 baseURL 和 apiKey 两个参数。
具体操作上,先去控制台创建 Key。打开https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite,在 API Keys 页面点新建,复制生成的 Key 保存好。这个 Key 后面会用在三个地方:Cloud Agent 主服务的环境变量、MCP Server 子进程的环境变量、以及 Skill 执行时的模型调用配置。
为什么不在每个 MCP Server 里单独配 Key?因为 MCP Server 是以子进程方式启动的,如果每个子进程都读自己的 auth.json,部署时要同步维护多份凭证文件。用 TaoToken 统一 Key 之后,只需要在主进程启动时把环境变量传给子进程,子进程从process.env读取即可。这样换 Key 只改一处,测试和生产用不同的 Key 也只需要切换环境变量。
模型选择方面,TaoToken 支持多种模型 ID,你可以在模型对话页面先测试哪个模型适合你的 Skill 场景。打开https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite可以看到当前可用的模型列表。对于 Skill 执行这类需要遵循复杂指令的场景,建议选指令遵循能力强的模型;对于 MCP 工具调用这类需要稳定返回 JSON 的场景,选 function calling 支持好的模型。
如果你打算长期跑编码类 Agent 任务,可以了解一下 Coding Plan,它针对高频调用场景做了额度优化。地址是https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite。接入文档在https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite,里面有各语言 SDK 的配置示例。
3. 可复制配置:MCP Server 与 Skill 注册
这一节给出完整的配置文件。先看 MCP Server 的配置,Cloud Agent 里用一个 JSON 文件管理所有 MCP 连接,路径是config/mcp-servers.json。每个 server 条目包含启动命令、参数、环境变量和超时设置。
{ "mcpServers": { "mineru": { "command": "uvx", "args": ["mineru-mcp@latest"], "env": { "TAOTOKEN_API_KEY": "${TAOTOKEN_API_KEY}", "TAOTOKEN_BASE_URL": "https://taotoken.net/api", "TAOTOKEN_MODEL": "gpt-4o" }, "timeout": 120000, "transport": "stdio" }, "data-analysis": { "command": "node", "args": ["./mcp-servers/data-analysis/dist/index.js"], "env": { "TAOTOKEN_API_KEY": "${TAOTOKEN_API_KEY}", "TAOTOKEN_BASE_URL": "https://taotoken.net/api" }, "timeout": 30000, "transport": "stdio" } } }注意timeout字段。mineru 用 uvx 启动,首次运行要下载 PyTorch 相关依赖,60 到 90 秒是常态,所以设成 120000 毫秒。data-analysis 是本地编译好的 Node 脚本,30 秒足够。这个超时值后面在排障章节会详细讲,因为它同时影响连接握手和工具调用两个阶段。
环境变量里的${TAOTOKEN_API_KEY}从主进程继承。主进程启动时通过.env文件或容器环境变量注入,这样 MCP 子进程不需要自己维护凭证文件。TaoToken 的 Base URL 统一写https://taotoken.net/api,不要加尾部斜杠。
接下来是 Skill 注册。Skill 的元数据存在数据库表aac_skill_registry里,但源码在文件系统。启动时扫描data/skills/目录,解析每个 SKILL.md 的 YAML frontmatter,upsert 到数据库。下面是 Skill 注册的核心 TypeScript 代码:
import fs from 'fs/promises'; import path from 'path'; import matter from 'gray-matter'; import { db } from './db'; interface SkillMeta { name: string; displayName: string; description: string; scope: 'general' | 'workflow'; defaultPrompt?: string; } export async function syncSkillsDirectory(skillsDir: string) { const entries = await fs.readdir(skillsDir, { withFileTypes: true }); const dirs = entries.filter(e => e.isDirectory()); for (const dir of dirs) { const skillPath = path.join(skillsDir, dir.name, 'SKILL.md'); try { const raw = await fs.readFile(skillPath, 'utf-8'); const { data, content } = matter(raw); const meta: SkillMeta = { name: dir.name, displayName: data.name || dir.name, description: data.description || '', scope: data.scope === 'workflow' ? 'workflow' : 'general', defaultPrompt: data.default_prompt, }; await db.query( `INSERT INTO aac_skill_registry (name, display_name, skill_description, scope, default_prompt, source, is_enabled) VALUES ($1, $2, $3, $4, $5, 'global', 1) ON CONFLICT (name) DO UPDATE SET display_name = EXCLUDED.display_name, skill_description = EXCLUDED.skill_description`, [meta.name, meta.displayName, meta.description, meta.scope, meta.defaultPrompt] ); } catch (err) { console.error(`Failed to sync skill ${dir.name}:`, err); } } }这段代码的关键点是ON CONFLICT DO UPDATE只更新名字和描述,不覆盖scope、is_enabled、default_prompt这些管理员可能手动改过的字段。文件系统是技能的源码,数据库是技能的注册表,两者职责分离。
Skill 文件本身长这样,放在data/skills/pdf-analysis/SKILL.md:
--- name: PDF 数据分析 description: 提取 PDF 中的表格数据,验证金额一致性,生成对比表 scope: workflow default_prompt: 请使用 mineru 工具解析 PDF,不要自己尝试本地 OCR --- ## 执行流程 1. 使用 `mcp__mineru__extract_pdf` 提取 PDF 内容 2. 解析返回的表格数据,检查金额字段 3. 对比不同页面的汇总数据,标记不一致项 4. 生成 Markdown 格式的对比表 ## 注意事项 - 不要用 Bash 安装 Python OCR 包,直接用 MCP 工具 - 金额字段保留两位小数frontmatter 里的default_prompt会覆盖系统提示词,确保 LLM 优先使用 MCP 工具而不是自己想办法。这是 Skill 和 MCP 解耦但明确配合的关键——Skill 文件里显式写出工具名,LLM 不需要自己判断用哪种方案。
4. 验证请求:stdio 启动与端到端调用
配置写完之后,先单独验证 MCP Server 能不能正常启动。在项目根目录执行:
export TAOTOKEN_API_KEY="你的Key" npx tsx src/mcp/launcher.ts --config config/mcp-servers.json --server minerulauncher.ts 的核心逻辑是创建子进程、建立 stdio 管道、发送 initialize 请求。下面是关键部分:
import { spawn } from 'child_process'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; export async function connectToServer(name: string, config: McpServerConfig) { const transport = new StdioClientTransport({ command: config.command, args: config.args, env: { ...process.env, ...config.env }, }); const client = new Client({ name: 'cloud-agent', version: '2.0.0' }, { capabilities: {} }); const connectPromise = client.connect(transport); const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error(`Connect timeout after ${config.timeout}ms`)), config.timeout) ); await Promise.race([connectPromise, timeoutPromise]); const tools = await client.listTools(); console.log(`Connected to ${name}, tools:`, tools.tools.map(t => t.name)); return client; }启动成功后你会看到类似输出:
Connected to mineru, tools: [ 'extract_pdf', 'extract_tables', 'ocr_page' ]这说明 stdio 链路通了,MCP Server 注册了三个工具。接下来验证 Skill 能不能正确路由到 MCP 工具。启动 Cloud Agent 主服务:
npm run dev然后在对话界面发送一条测试消息,触发 pdf-analysis 技能:
帮我分析这个 PDF 的财务数据:/tmp/test-report.pdf预期行为是:LLM 读取到 pdf-analysis 技能的 SKILL.md,按照里面的指令调用mcp__mineru__extract_pdf,MCP Server 子进程执行解析,返回表格数据,LLM 再按技能里的流程生成对比表。
在服务端日志里你能看到完整的调用链:
[Skill] Loaded pdf-analysis, default_prompt applied [LLM] tool_use: mcp__mineru__extract_pdf { path: "/tmp/test-report.pdf" } [MCP] mineru: extract_pdf called [MCP] mineru: returned 3 tables, 47 rows [LLM] tool_result received, generating comparison table如果这条链路走通了,说明 Skill 注册、MCP 连接、stdio 通信、工具路由全部正常。端到端验证通过之后,再测试并发场景——同时发三条消息,都触发同一个 MCP 工具,观察是否只启动了一个子进程。这是下一节排障的重点。
5. 本篇常见错排查:401、local proxy failed 与连接超时
这一节列出实际开发中遇到的报错和排查方法。每个都是真实踩过的坑,按出现频率排序。
401 Unauthorized。最常见的原因是 MCP 子进程没拿到 TAOTOKEN_API_KEY。检查两点:主进程的.env文件里有没有这个变量,以及config/mcp-servers.json里的env字段有没有正确引用${TAOTOKEN_API_KEY}。注意 JSON 里不能直接写 Key 值,要用环境变量占位符,否则提交到 git 就泄露了。如果确认环境变量传进去了还是 401,检查 Key 有没有多余空格,以及 Base URL 是不是写成了https://taotoken.net/api/(尾部斜杠会导致部分 SDK 拼接出双斜杠路径)。
local proxy failed。这个报错通常出现在 uvx 启动的 MCP Server 上。uvx 首次运行要下载依赖,如果网络环境导致下载失败,子进程会直接退出,主进程收到的是连接被拒绝。排查方法是手动在终端跑一遍uvx mineru-mcp@latest,看能不能正常下载。如果下载慢,可以设置UV_INDEX_URL指向国内镜像。另外确认timeout设得够大,默认 30 秒对 uvx 首次启动肯定不够,改成 120000 毫秒。
Error reading choices / 返回结果解析失败。这个报错说明 MCP 工具返回的数据格式和 LLM 期望的不一致。MCP 协议要求工具返回content数组,每项有type和text字段。如果你的 MCP Server 直接返回了裸 JSON 对象,LLM 端解析会失败。检查 MCP Server 的工具实现,确保返回值符合协议:
return { content: [{ type: 'text', text: JSON.stringify(result) }] };OAuth 相关报错。如果你接入的 MCP Server 需要 OAuth 认证,而 Cloud Agent 当前只实现了 stdio transport,会看到needs-auth状态。处理方式是在 MCP 配置里加上认证相关的环境变量,或者先用命令行工具完成一次 OAuth 授权,把 token 缓存到本地再启动。注意不要在主进程里硬编码 token。
并发重连导致启动多个子进程。现象是日志里出现多条Connected to mineru,但实际只应该有一条。原因是三个并行的 tool_use 同时检测到断连,各自触发重连。解决方案是用 memoize Promise 加 pendingReconnectsMap 两层防护。核心代码:
const connectCache = new Map<string, Promise<Client>>(); const pendingReconnects = new Map<string, Promise<void>>(); async function getClient(name: string): Promise<Client> { if (connectCache.has(name)) { return connectCache.get(name)!; } const promise = connectToServer(name, configs[name]); connectCache.set(name, promise); try { const client = await promise; return client; } catch (err) { connectCache.delete(name); throw err; } }第一层防并发启动,第二层防串行触发。两个 Map 的 key 都是 server 名字,确保同一个 server 同时只有一个连接在建立。
断连原因不分类导致无限重连。不是所有断连都该重连。ENOENT、权限拒绝、配置文件格式错误属于永久性错误,重连一百次也不会好,应该直接标记 failed。ECONNRESET、ETIMEDOUT、EPIPE 属于临时传输错误,可以重连,但连续 3 次失败后要停止。工具调用时断连(错误码 -32000 或消息含 "Connection closed")则自动重连并重试一次,跟着 agent loop 走,不启动独立重连循环。
6. 语义一致 CTA:把统一 Key 用到你的项目里
整篇下来,Skill 和 MCP 的集成链路其实就三件事:Skill 文件写清楚业务流程和该用哪个 MCP 工具,MCP 配置里用 TaoToken 统一 Key 避免多份凭证,stdio 启动时把环境变量传给子进程。这三件事做好,剩下的就是连接稳定性的打磨。
如果你准备在自己的项目里复现这套方案,建议按这个顺序来:先去https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite创建一个 Key,然后在模型对话页面https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite测试一下你要用的模型能不能正常返回 function call 格式。确认没问题之后,再按第 3 节的配置片段搭 MCP Server,最后接 Skill 注册。
接入过程中遇到协议层面的问题,查接入文档https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite比翻源码快。文档里有 stdio、HTTP 两种 transport 的完整示例,以及常见错误码的说明。
最后说一个实际经验:MCP 连接的超时值不要全局设成同一个数。uvx 启动的 server 需要 120 秒,本地编译的 Node server 30 秒就够。如果以后接入一个应该 5 秒握手完成的服务,120 秒的超时会掩盖真实的连接问题。建议在配置里给每个 server 单独设 timeout,而不是在代码里写死一个全局值。这个改动很小,但能省掉以后很多排查时间。