☰
AI Agent技能实战:从 SKILL.md 到 MCP Server 的完整实现(TaoToken 统一 Key 接入篇)
2026/10/12 15:03:21 网站建设 项目流程

1. 为什么我要把 SKILL.md 和 MCP Server 绑在一起做

AI Agent 的能力扩展这两年一直在换姿势。最早大家写死在代码里的 function calling,后来变成 MCP 协议统一工具调用,再到现在开始流行用一份 SKILL.md 把「能力是什么、怎么触发、参数长什么样」全部自描述出来。我最近在做一个 Rust 的小项目,目标很明确:让一个技能包既能被 Cursor、Claude Desktop 这类 IDE 通过 MCP 调用,又能把工具鉴权和模型调用统一收敛到同一个 Key 通道上,省得每接一个客户端就配一遍环境变量。

SKILL.md 解决的是「能力定义」问题,MCP Server 解决的是「能力暴露」问题,而 TaoToken 解决的是「鉴权和模型通道」问题。这三件事拆开看都不复杂,但拼在一起的时候,最容易踩的坑就是:工具调用要鉴权、模型调用也要鉴权,两套 Key 两套 Base URL,配置一多就乱。所以这篇我按「先定义 Schema,再落地 MCP Server,最后统一走一个 Key」的顺序写,每一步都给可复制的代码和配置。

适合谁看:写过一点 Rust、想让自己的工具被 AI IDE 直接调用的同学;或者已经在用 MCP,但被多客户端配置搞烦、想把 Key 收敛到一处的同学。全文的检索核心词就是 AI Agent、SKILL.md、MCP Server、Rust、Schema,我会围绕这几个词把链路走通。

先说结论:SKILL.md 用 YAML front matter 描述元信息,正文写触发场景;Rust 侧用 serde 解析成结构体,再推断出 JSON Schema;MCP Server 用 stdio 跑 JSON-RPC 2.0,把技能注册成 tool;模型调用和工具鉴权都指向同一个 API 通道。下面从场景问题开始拆。

2. 原问题与场景:SKILL.md 解析和 MCP 注册为什么总对不上

我一开始的做法很土:SKILL.md 手写一份,JSON Schema 再手写一份,MCP Server 里 tool 定义再抄一份。三份东西只要有一处字段名不一致,Agent 调用就会报参数校验失败,而且报错信息往往只告诉你「invalid arguments」,不告诉你是哪一层对不上。这个问题的根子在于:能力定义(SKILL.md)、参数契约(Schema)、协议暴露(MCP tool)三者没有单一数据源。

2.1 三个典型症状

第一个症状是 description 写得太模糊。SKILL.md 里如果只写「处理浏览器相关任务」,Agent 在工具列表里根本匹配不到该用哪个技能,因为它靠 description 做语义检索。我试过把 description 写成「Browser automation CLI for AI agents. Use when the user needs to navigate pages, fill forms, click buttons, take screenshots, extract data」,命中率立刻上来了。description 是唯一的触发机制,这句话要刻在脑子里。

第二个症状是参数类型对不上。Python 脚本里parser.add_argument('--detailed', action='store_true'),手写 Schema 时写成了"type": "string",Agent 传true进来直接类型错误。正确映射应该是"type": "boolean"。这类错误在联调阶段特别费时间,因为你要同时看脚本、Schema、MCP 日志三个地方。

第三个症状是工具名带连字符。技能目录叫frontend-design,MCP 注册时 tool name 如果直接用目录名,某些客户端会对带连字符的名字做标准化,调用时传frontend_design,服务端找不到就返回 not found。这个坑我在多脚本技能上踩过,后面会用模糊匹配兜住。

2.2 我要的最终形态

理想状态是:我只维护一份 SKILL.md 和一份入口脚本,Rust 侧自动解析出元信息和参数 Schema,MCP Server 启动后自动把技能注册成 tool,Agent 调用时参数校验、鉴权、模型请求全部走同一条通道。这样新增一个技能只需要加一个目录,不用改任何 Rust 代码。

注意:SKILL.md 的 front matter 必须是文件最开头,---前后不能有空行或 BOM,否则 YAML 解析会直接失败,报错通常是did not find expected key。

3. TaoToken 前置:把工具鉴权和模型调用收敛到一个 Key

在写 MCP Server 之前,先把鉴权通道定下来。我的做法是:MCP Server 本身不直接持有任何模型厂商的 Key,所有需要模型能力的地方(比如技能内部要调 LLM 做总结)以及工具侧的身份校验,都统一走 TaoToken 的 API 通道。这样客户端配置里只需要一个 Base URL 和一个 Key,换模型、换客户端都不用动技能代码。

3.1 拿 Key 和确认 Base URL

先去控制台创建一个 API Key,地址是 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite 。创建完复制出来,形如sk-开头的一串。Base URL 统一用 https://taotoken.net/api ,注意这个地址后面不加任何路径后缀,具体端点由 SDK 或请求体里的 model 字段决定。

模型 ID 这块,我常用的是claude-sonnet-4-5和gpt-4o这两个,前者适合长上下文和代码,后者适合通用工具调用。你可以在模型对话页 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite 先手动发一条消息确认 Key 可用,再写进配置。

3.2 三件套配置片段

不管后面接的是 Cline、Claude Code 还是自己写的 MCP Server,配置里永远只有这三样:Base URL、API Key、Model ID。我把它写成一个settings.json片段,路径按客户端不同放对位置即可:

{ "env": { "ANTHROPIC_BASE_URL": "https://taotoken.net/api", "ANTHROPIC_AUTH_TOKEN": "sk-你的Key", "ANTHROPIC_MODEL": "claude-sonnet-4-5" } }

如果你用的是 OpenAI 兼容风格的客户端,字段名换成OPENAI_BASE_URL、OPENAI_API_KEY、OPENAI_MODEL,值不变。我实测下来,同一份 Key 在两种风格下都能用,省去了分别申请的成本。

3.3 为什么不在技能里硬编码 Key

有人图省事,直接在 Python 技能脚本里os.environ["API_KEY"]读一个写死的值。这样做的问题是:技能包一旦分享出去,Key 就泄露了;而且每个技能都要重复读环境变量,维护成本高。我的做法是 MCP Server 启动时从环境变量读一次,技能执行时通过沙箱环境注入,技能脚本只认TAOTOKEN_API_KEY这个变量名,不关心值从哪来。

提示:环境变量名建议统一用TAOTOKEN_API_KEY和TAOTOKEN_BASE_URL,避免和客户端自带的OPENAI_API_KEY冲突。如果客户端已经注入了后者,可以在 MCP Server 启动脚本里做一次映射。

4. 可复制配置:SKILL.md 字段模板与 Rust Schema 推断

这一节是全文的技术核心。我先把 SKILL.md 的字段模板给全,再给 Rust 侧解析和 Schema 推断的骨架代码,最后给 MCP Server 的 tool 注册逻辑。三块拼起来就是一条完整链路。

4.1 SKILL.md 字段模板

一个技能目录长这样:weather/SKILL.md加weather/scripts/main.py。SKILL.md 的 front matter 我固定用这几个字段:

--- name: weather description: 查询指定城市的真实天气信息。当用户询问某个城市的天气、温度、湿度、风力时使用此技能。 license: MIT compatibility: Requires Python 3.x, network access metadata: author: yourname version: "1.0" ---

字段含义我逐个说:name是技能标识,也是 MCP tool 名的来源;description是唯一触发机制,必须穷举触发场景;compatibility里出现network或网络关键词时,Rust 侧会自动把沙箱网络打开;metadata是自定义扩展,不影响解析。正文部分写使用示例和输入输出格式,Agent 在需要时会按需读取。

4.2 Rust 解析结构体

用 serde 把 front matter 解析成结构体,核心是SkillMetadata:

use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SkillMetadata { pub name: String, pub description: String, #[serde(default)] pub license: Option<String>, #[serde(default)] pub compatibility: Option<String>, #[serde(default)] pub metadata: Option<serde_yaml::Value>, } impl SkillMetadata { pub fn needs_network(&self) -> bool { let compat = self.compatibility.as_deref().unwrap_or("").to_lowercase(); compat.contains("network") || compat.contains("internet") || compat.contains("网络") || compat.contains("联网") } pub fn language(&self) -> Option<String> { let compat = self.compatibility.as_deref()?.to_lowercase(); if compat.contains("python") { Some("python".into()) } else if compat.contains("node") { Some("node".into()) } else { None } } }

解析 front matter 的时候,我习惯先按---切三段,取中间那段喂给serde_yaml::from_str。这里有个细节:如果文件开头有 UTF-8 BOM,第一段会变成\u{feff}---,判断会失败。稳妥做法是先trim_start_matches('\u{feff}')再切。

4.3 从 argparse 推断 JSON Schema

技能入口是 Python 时,我从add_argument调用里正则提取参数,映射成 OpenAI function calling 风格的 Schema。核心映射规则如下表:

argparse 写法JSON Schema 字段
位置参数required: true
--flagrequired: false
type=int"type": "integer"
type=float"type": "number"
action='store_true'"type": "boolean"
choices=[...]"enum": [...]
nargs='*'"type": "array"
help='...'"description": "..."
default=..."default": ...

Rust 侧推断函数骨架:

pub fn parse_argparse_schema(script: &str) -> serde_json::Value { let mut properties = serde_json::Map::new(); let mut required = Vec::new(); let re = regex::Regex::new( r#"add_argument\(\s*['"]([^'"]+)['"]([^)]*)\)"# ).unwrap(); for cap in re.captures_iter(script) { let arg_name = cap[1].trim_start_matches('-').to_string(); let rest = &cap[2]; let mut prop = serde_json::Map::new(); if rest.contains("type=int") { prop.insert("type".into(), "integer".into()); } else if rest.contains("type=float") { prop.insert("type".into(), "number".into()); } else if rest.contains("store_true") { prop.insert("type".into(), "boolean".into()); } else { prop.insert("type".into(), "string".into()); } if let Some(help) = extract_quoted(rest, "help=") { prop.insert("description".into(), help.into()); } if let Some(choices) = extract_choices(rest) { prop.insert("enum".into(), choices.into()); } if !cap[1].starts_with('-') { required.push(arg_name.clone()); } properties.insert(arg_name, serde_json::Value::Object(prop)); } serde_json::json!({ "type": "object", "properties": properties, "required": required }) }

extract_quoted和extract_choices是两个小工具函数,分别抓help='...'里的字符串和choices=[...]里的枚举值,实现不复杂,按需补即可。这样一份 Python 脚本写完后,Schema 自动就有了,不用手写第二遍。

4.4 MCP Server 的 tool 注册

MCP Server 用 stdio 跑 JSON-RPC 2.0,启动后先响应initialize,再响应tools/list。我把每个技能注册成一个 tool,名字用name字段,参数用上一步推断出的 Schema:

fn build_tool_definition(meta: &SkillMetadata, schema: serde_json::Value) -> serde_json::Value { serde_json::json!({ "name": meta.name, "description": meta.description, "inputSchema": schema }) }

tools/call收到请求后,按name找到技能目录,把arguments序列化成 JSON 传给入口脚本的 stdin,脚本输出 JSON 到 stdout,Server 再包一层content返回。整个过程不涉及任何模型厂商的直连,需要模型能力时统一走 TaoToken 通道。

注意:MCP 的 stdio 模式下,stdout 只能输出 JSON-RPC 消息,任何调试日志必须走 stderr,否则客户端解析会直接崩。我早期把println!当日志用,排查了半天才发现是这个问题。

5. 验证请求与成功结果:一次端到端调用回执

配置写完必须验证,不然你不知道是 Schema 错了还是鉴权错了。我按「先本地直连、再 MCP 客户端」两步走。

5.1 本地直连验证模型通道

先用 curl 确认 Key 和 Base URL 可用:

curl https://taotoken.net/api/v1/messages \ -H "x-api-key: sk-你的Key" \ -H "anthropic-version: 2023-06-01" \ -H "content-type: application/json" \ -d '{ "model": "claude-sonnet-4-5", "max_tokens": 64, "messages": [{"role": "user", "content": "只回复两个字:通了"}] }'

返回里能看到content数组和usage字段就说明通道没问题。如果返回 401,先检查 Key 有没有复制全、有没有多余空格。

5.2 本地启动 MCP Server

编译后直接跑:

cargo build --release TAOTOKEN_API_KEY=sk-你的Key \ TAOTOKEN_BASE_URL=https://taotoken.net/api \ ./target/release/skilllite mcp-serve --skills-dir .skills

启动后 Server 会在 stderr 打印已加载的技能列表,形如loaded skill: weather (python, network=true)。看到这行说明 SKILL.md 解析和 Schema 推断都成功了。

5.3 客户端配置与调用回执

在 Cursor 的 MCP 配置里加上:

{ "mcpServers": { "skilllite": { "command": "/abs/path/target/release/skilllite", "args": ["mcp-serve", "--skills-dir", "/abs/path/.skills"], "env": { "TAOTOKEN_API_KEY": "sk-你的Key", "TAOTOKEN_BASE_URL": "https://taotoken.net/api" } } } }

重启客户端后,在对话里输入「帮我查一下深圳天气」,Agent 会先tools/list拿到 weather 的 Schema,再tools/call传{"city": "深圳"}。我实测的回执长这样:

{ "jsonrpc": "2.0", "id": 3, "result": { "content": [ { "type": "text", "text": "{\"city\":\"深圳\",\"temperature\":\"18°C\",\"weather\":\"多云\",\"humidity\":\"72%\"}" } ], "isError": false } }

看到isError: false且 content 里有真实数据,整条链路就通了。从用户输入到结果返回,中间经过了 SKILL.md 解析、Schema 推断、MCP 注册、沙箱执行、结果封装五步,每一步都有日志可查。

6. 本篇常见错排查:401、local proxy failed 与 reading choices

联调阶段我遇到过的报错基本集中在四类,逐个说清楚原因和改法。

6.1 401 Unauthorized

最常见。原因通常是 Key 没传进 MCP Server 进程。MCP 客户端启动子进程时,env字段里的变量才会注入,如果你只在 shell 里 export 了,客户端不一定继承。改法是把 Key 写进客户端的env块,或者用env命令显式传递。还有一种情况是 Key 前后带了换行,复制时容易带上,用echo -n检查一下长度。

6.2 local proxy failed

这个报错一般出现在客户端尝试连接本地代理端口时。原因是你配置的 Base URL 指向了一个本地端口,但那个端口没有服务在监听。改法是确认 Base URL 直接写 https://taotoken.net/api ,不要写http://localhost:xxxx之类的地址。如果你之前配过别的工具残留了本地代理设置,把相关环境变量清掉再重启客户端。

6.3 reading 'choices' 报错

这个报错说明客户端按 OpenAI 格式解析响应,但服务端返回的是 Anthropic 格式(content数组),字段对不上。改法是统一协议风格:要么客户端用 Anthropic 风格配置(ANTHROPIC_BASE_URL),要么在请求里明确走 OpenAI 兼容端点。我一般让客户端和模型 ID 匹配,用claude-开头的模型就配 Anthropic 风格,用gpt-开头的就配 OpenAI 风格。

6.4 OAuth 相关报错

有些客户端默认走 OAuth 流程,会提示需要登录或 token 过期。MCP Server 场景下不需要 OAuth,直接用 API Key 即可。改法是在客户端配置里关掉 OAuth 选项,或者把认证方式显式设为 API Key。如果客户端强制走 OAuth,检查一下是不是选错了接入模式。

6.5 工具名 not found

Agent 调用时传了frontend_design,但注册的是frontend-design。改法是在查找技能时做一次标准化匹配:先把 tool name 里的连字符换成下划线,再和注册名比对。Rust 侧加一个sanitize_tool_name函数即可,逻辑就是name.replace('-', "_")。

提示:排查顺序建议从外到内——先 curl 验证 Key,再本地启动 Server 看日志,最后接客户端。这样能快速定位是通道问题还是协议问题。

7. 语义一致 CTA:把这条链路用起来

整条链路跑通后,新增技能的成本就降到了「加一个目录、写一份 SKILL.md、放一个入口脚本」。Rust 侧不用改,MCP 配置不用改,Key 也不用重新申请。如果你想让模型调用和工具鉴权继续收敛在同一个通道上,可以去控制台再建几个不同用途的 Key 做隔离,地址还是 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite 。

需要长期跑编码类 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 ,遇到协议层的问题可以先翻一遍。

最后留一个我踩过的坑:SKILL.md 的 description 一定要写全触发场景,别嫌长。Agent 匹配工具全靠它,写短了命中率低,写全了反而省事。技能目录名尽量用下划线,避免连字符带来的标准化问题。这两条改完,你的 MCP Server 基本就能稳定被调用了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询