Cloud Agent 开发笔记(4):Skill 与 MCP 集成、项目后记——用 TaoToken 统一 Key 打通 stdio 链路
1. 从 V1 到 V2Skill 与 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 是单用户本地 CLIMCP 连接生命周期等于用户会话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_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite在 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_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite可以看到当前可用的模型列表。对于 Skill 执行这类需要遵循复杂指令的场景建议选指令遵循能力强的模型对于 MCP 工具调用这类需要稳定返回 JSON 的场景选 function calling 支持好的模型。如果你打算长期跑编码类 Agent 任务可以了解一下 Coding Plan它针对高频调用场景做了额度优化。地址是https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite。接入文档在https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite里面有各语言 SDK 的配置示例。3. 可复制配置MCP Server 与 Skill 注册这一节给出完整的配置文件。先看 MCP Server 的配置Cloud Agent 里用一个 JSON 文件管理所有 MCP 连接路径是config/mcp-servers.json。每个 server 条目包含启动命令、参数、环境变量和超时设置。{ mcpServers: { mineru: { command: uvx, args: [mineru-mcplatest], 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 frontmatterupsert 到数据库。下面是 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_pdfMCP 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-mcplatest看能不能正常下载。如果下载慢可以设置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 Mapstring, PromiseClient(); const pendingReconnects new Mapstring, Promisevoid(); async function getClient(name: string): PromiseClient { 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_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite创建一个 Key然后在模型对话页面https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite测试一下你要用的模型能不能正常返回 function call 格式。确认没问题之后再按第 3 节的配置片段搭 MCP Server最后接 Skill 注册。接入过程中遇到协议层面的问题查接入文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite比翻源码快。文档里有 stdio、HTTP 两种 transport 的完整示例以及常见错误码的说明。最后说一个实际经验MCP 连接的超时值不要全局设成同一个数。uvx 启动的 server 需要 120 秒本地编译的 Node server 30 秒就够。如果以后接入一个应该 5 秒握手完成的服务120 秒的超时会掩盖真实的连接问题。建议在配置里给每个 server 单独设 timeout而不是在代码里写死一个全局值。这个改动很小但能省掉以后很多排查时间。