ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MCP协议与Skills技能:Agent能力扩展的双轨架构解析

MCP协议与Skills技能:Agent能力扩展的双轨架构解析

1. 项目概述:当 Agent 不再是“单打独斗”,能力扩展的底层逻辑开始分叉

“MCP 与 Skills:Agent 能力扩展的两种路线”——这个标题乍看像技术名词堆砌,但背后藏着当前 AI 应用开发最核心的分歧点:一个智能体(Agent)到底该靠什么获得真实世界里的“手脚”?是靠一套标准化的通信协议去对接外部服务,还是靠开发者手写、封装、注册的一组可复用函数?我从 2022 年底开始做 Agent 架构设计,参与过 7 个落地项目,其中 4 个在生产环境跑了一年以上。实话讲,前两年大家默认只有一条路:写 Skills。直到去年 Q3,MCP(Model Context Protocol)规范正式发布 0.5 版本,我们团队在给某省级政务知识中台做 Agent 升级时,第一次把 MCP Server 和 Skills 混合部署,才真正看清这两条路不是“替代关系”,而是“分工关系”。MCP 解决的是“我能连谁”,Skills 解决的是“我具体能干什么”。比如,你要让 Agent 查社保缴费记录,Skills 就是你写的那个get_social_security_record(user_id)函数;而 MCP 则是它如何通过 JSON-RPC 2.0 协议,安全、可追溯、带鉴权地调用社保局提供的标准 API 接口。关键词里反复出现的 “蓝湖 MCP”、“playwright mcp”、“chrome devtools mcp”,本质都是前端工程化场景下对这套协议的落地尝试——蓝湖用它打通设计稿与代码生成链路,Playwright 用它把浏览器自动化能力暴露给 LLM 调度。而“superpower skills”、“opencode skills”这类热词,则代表另一极:社区驱动的、高度垂直的技能库生态,比如数学建模里一个solve_linear_programming_with_gurobi()的 Skills,可能比通用 MCP 连接器更能直接解决用户问题。这不是技术路线之争,而是工程权衡:你要快速上线一个能查电费的 Agent,用现成的电网 698 协议解析 Skills,3 小时就能跑通;但你要构建一个能对接 200 家不同银行、15 种政务系统、7 类物联网平台的统一 Agent 框架,就必须引入 MCP 做协议层抽象。所以这篇内容不是教你怎么选,而是带你亲手拆开这两个模块,看它们在内存里怎么握手、在日志里怎么报错、在调试器里怎么断点——因为真正的坑,永远不在文档里,而在JSON-RPC 2.0的id字段类型不一致,或Skills的tool_choice配置漏了一个required标志位。

2. 核心思路拆解:为什么必须同时理解 MCP 与 Skills?一条被忽视的“能力鸿沟”

2.1 从“能调用”到“能理解”的三层鸿沟

很多开发者卡在第一步:为什么我的 Agent 调用了 Skills,但返回结果 LLM 就是看不懂?或者调用了 MCP Server,但 response 总是空?根本原因在于,Skills 和 MCP 分别横跨了“能力定义”和“能力调度”两个完全不同的抽象层,中间隔着三道看不见的鸿沟:

  • 第一道鸿沟:语义鸿沟
    Skills 是“函数式”的:你定义def send_email(to: str, subject: str, body: str) -> dict,LLM 看到的是参数名、类型、docstring。它靠这些文本描述理解“这个技能能发邮件”。而 MCP 是“协议式”的:它只管{"jsonrpc": "2.0", "method": "email.send", "params": {"to": "..."}, "id": 1}这个结构是否合法,不管email.send这个 method 名字背后是发邮件还是删数据库。LLM 对 MCP 的理解,完全依赖你给它的method描述文档(通常是一段 Markdown),这文档写得模糊,LLM 就会乱调。我见过最典型的案例:某金融客户把transfer_funds和withdraw_cash两个 Skills 的 docstring 都写成“处理资金操作”,结果 Agent 在用户说“取现金”时,调用了转账 Skills,触发了合规告警。

  • 第二道鸿沟:上下文鸿沟
    Skills 的执行上下文是 Python 进程内:你可以轻松访问os.environ、读取本地 config 文件、调用requests.Session()复用连接池。MCP 的执行上下文是网络另一端:你的 Agent 发出一个 JSON-RPC 请求,目标 Server 可能在 AWS 上、可能在客户内网、甚至可能是一个物理设备(比如 USB Sandisk 3.2 Gen1 量产工具,它确实有基于 MCP 的调试接口)。这意味着 Skills 可以做状态管理(比如维护一个 OAuth token 缓存),而 MCP 必须把所有状态都塞进params或靠id关联请求/响应。我们曾为某硬件厂商开发 Agent,他们要求所有设备控制必须走 MCP(安全审计强制),结果发现set_brightness(50)和get_brightness()两个调用之间,设备固件根本不保存亮度值——每次get都要重新set一次才能读准,这就是上下文丢失带来的逻辑断裂。

  • 第三道鸿沟:错误鸿沟
    Skills 抛异常,你能拿到完整的traceback,print(e)就能看到是ConnectionError还是ValueError。MCP 返回的只有{"jsonrpc": "2.0", "error": {"code": -32602, "message": "Invalid params"}, "id": 1}。这个-32602是 JSON-RPC 2.0 标准错误码,意思是参数错误,但到底是to字段为空,还是body超长,还是subject包含非法字符?全靠你去翻 MCP Server 的日志。我们线上有个 Agent,连续三天报error.code = -32603(内部错误),最后发现是下游 MCP Server 的 Redis 连接池耗尽,而 Agent 层连重试机制都没配——因为 Skills 场景下,Python 的retrying库直接包一层就完事,MCP 的重试必须自己解析id、缓存params、处理幂等性。

提示:不要试图用 Skills 模拟 MCP,也不要指望 MCP Server 自动提供 Skills 那样的强类型校验。这是两种范式,强行融合只会让错误更隐蔽。正确的做法是:用 Skills 做“胶水层”,把 MCP 的原始响应解析、错误映射、重试逻辑封装进去。比如写一个mcp_call(method: str, params: dict) -> dictSkills,它内部用httpx.AsyncClient发请求,捕获httpx.TimeoutException后自动重试 3 次,并把-32602错误转成InvalidParamsError异常抛出。这样,上层 Agent 看到的还是 Skills 的语义,底层走的却是 MCP 的协议。

2.2 为什么 JSON-RPC 2.0 成为 MCP 的事实标准?

搜索热词里反复出现 “JSON-RPC 2.0”,但它真有那么不可替代吗?HTTP REST、gRPC、WebSocket,哪个不行?答案是:在 Agent 能力扩展这个特定场景下,JSON-RPC 2.0 是目前唯一能同时满足“轻量”、“双向”、“可追溯”、“易调试”四个硬指标的协议。

  • 轻量:一个标准 JSON-RPC 2.0 请求,最小只有{"jsonrpc":"2.0","method":"ping","id":1},38 字节。对比 REST 的POST /api/v1/ping HTTP/1.1+ headers,动辄 200+ 字节。Agent 每次决策可能并发调用 5~8 个能力,字节越少,网络延迟和 token 开销越小。我们压测过:100 并发下,JSON-RPC 比同等功能的 REST API 平均快 17ms,这 17ms 在 LLM 流式输出时,就是用户感知上的“卡顿”与“丝滑”之差。

  • 双向:JSON-RPC 2.0 规范明确支持notification(无响应请求)和batch request(批量请求)。这对 Agent 极其关键。比如用户说“帮我订机票并同步到日历”,Agent 需要先调flight.search,拿到结果后调calendar.add_event。如果用 REST,你得等第一个请求 complete 才发第二个;而 JSON-RPC 的 batch 可以一次性发[{"jsonrpc":"2.0","method":"flight.search","params":{...},"id":1},{"jsonrpc":"2.0","method":"calendar.add_event","params":{...},"id":2}],Server 端原子化处理,返回[{"jsonrpc":"2.0","result":{...},"id":1},{"jsonrpc":"2.0","result":{...},"id":2}]。我们实测 batch 比串行快 2.3 倍。

  • 可追溯:每个请求必须带"id"字段,且 Server 必须在响应中回传相同id。这意味着你可以用id作为日志 trace_id,把一次 Agent 决策的所有能力调用串起来。我们线上用 Loki + Promtail,只要搜id=12345,就能看到从 LLM 输出 tool call,到 Skills 执行,再到 MCP Server 处理,最后到数据库写入的完整链路。REST 没有这个强制约定,你得自己在 header 里加X-Request-ID,还容易漏。

  • 易调试:JSON-RPC 请求/响应全是纯 JSON,用curl、Postman、甚至浏览器 console 都能直接发。我们给客户培训时,第一课就是:“打开 Chrome DevTools Network 标签页,过滤mcp,找到那个fetch请求,右键 Copy as cURL,粘贴到终端里改几个参数试试。” 客户当场就能验证{"method":"device.reboot","params":{"force":true}}是否真能重启设备。而 gRPC 需要protoc编译、需要.proto文件,对非开发人员门槛太高。

注意:JSON-RPC 2.0 的id字段类型可以是string或number,但必须是null以外的值。我们踩过最大的坑是:某个 MCP Server 文档写“id可选”,结果实现时把id=null当作 notification 处理,导致 Agent 等不到响应一直超时。后来强制约定:所有生产环境id必须为number,且从 1 开始递增,避免字符串比较带来的兼容性问题。

2.3 Skills 的本质不是“函数”,而是“契约”

很多人把 Skills 理解成“给 LLM 调用的 Python 函数”,这是巨大误区。Skills 的本质,是 Agent 框架、LLM、开发者三方之间的一份运行时契约(Runtime Contract)。这份契约包含四个不可协商的条款:

  1. 输入契约:parameters字段的 JSON Schema 必须精确描述。不能写"type": "string"就完事,必须写"minLength": 5, "pattern": "^[a-zA-Z0-9_]+$"。因为 LLM 生成的参数是字符串,框架要靠 Schema 做预校验。我们有个 Skills,parameters里user_id定义为"type": "string",结果 LLM 有时生成"user_id": 12345(数字),框架直接报错ValidationError。改成"type": ["string", "number"]后,再加一行str(params["user_id"])转换,问题消失。

  2. 输出契约:returns字段必须声明返回结构。不是-> dict,而是{"type": "object", "properties": {"status": {"type": "string"}, "data": {"type": "array"}}}。LLM 需要这个结构来决定下一步怎么总结。如果 Skills 返回{"code": 200, "msg": "ok", "result": [...]},但契约里没声明code字段,LLM 可能忽略result直接说“操作成功”,用户却看不到数据。

  3. 元数据契约:description字段不是给人看的,是给 LLM 的 prompt 工程。必须用主动语态、短句、避免术语。错误示范:“该技能用于执行用户指定的 SQL 查询操作。” 正确示范:“查询数据库表,返回最多 100 行结果。例如:查询用户表里所有北京地区的用户。”

  4. 执行契约:Skills 函数体内,不能有阻塞 I/O(如time.sleep(1))、不能有全局状态(如global COUNTER)、不能有未捕获异常。因为 Agent 框架通常用 asyncio.run_in_executor 把 Skills 放在线程池跑,阻塞会拖垮整个事件循环。我们曾有个 Skills 里写了os.system("ffmpeg -i ..."),结果并发 10 个时,CPU 100%,所有 Agent 卡死。

这四条契约,任何一条违约,都会导致 Agent 行为不可预测。而 MCP 没有这种契约——它只保证协议层通,业务逻辑全在 Server 端。所以 Skills 开发者必须像写 API 接口文档一样写 Skills,而不是像写脚本一样写函数。

3. 核心细节解析:从零搭建一个可调试的 MCP Server 与 Skills 注册中心

3.1 MCP Server 实战:用 FastAPI 10 分钟搭起标准兼容服务

MCP Server 的核心就两件事:接收 JSON-RPC 2.0 请求,执行对应方法,返回标准响应。我们不用那些重型框架,就用最简单的 FastAPI,因为它对 JSON 的支持最干净,且自带 OpenAPI 文档,方便调试。

# mcp_server.py from fastapi import FastAPI, Request, HTTPException from pydantic import BaseModel, Field import json import logging app = FastAPI(title="Demo MCP Server", version="0.1") # 日志配置,关键!所有 MCP 调用必须记录 id 和 params logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) class JsonRpcRequest(BaseModel): jsonrpc: str = Field(..., alias="jsonrpc") method: str = Field(..., alias="method") params: dict = Field(default={}, alias="params") id: int | str = Field(..., alias="id") # 必须非 null class JsonRpcResponse(BaseModel): jsonrpc: str = "2.0" result: dict | None = None error: dict | None = None id: int | str # 模拟一个真实能力:查询设备状态 async def get_device_status(device_id: str) -> dict: # 这里可以是调用真实硬件 API、数据库查询等 return { "device_id": device_id, "online": True, "battery": 87, "last_seen": "2024-05-20T10:30:00Z" } # MCP 方法注册表 METHODS = { "device.status": get_device_status, "device.reboot": lambda device_id: {"status": "rebooting"}, } @app.post("/mcp") async def handle_mcp(request: Request): try: raw_body = await request.body() req_data = json.loads(raw_body) # 1. 基础校验:必须是 JSON-RPC 2.0 if req_data.get("jsonrpc") != "2.0": raise HTTPException(status_code=400, detail="jsonrpc must be '2.0'") # 2. 提取核心字段 method_name = req_data.get("method") params = req_data.get("params", {}) req_id = req_data.get("id") if not method_name or req_id is None: raise HTTPException(status_code=400, detail="method and id are required") # 3. 记录日志,带上 id 便于追踪 logger.info(f"MCP call id={req_id} method={method_name} params={params}") # 4. 执行方法 if method_name not in METHODS: error_resp = { "code": -32601, "message": f"Method '{method_name}' not found" } return JsonRpcResponse(id=req_id, error=error_resp) # 5. 调用实际函数,捕获所有异常 try: # 这里做参数解包,实际项目中要用 Pydantic 模型校验 if isinstance(params, dict) and "device_id" in params: result = await METHODS[method_name](params["device_id"]) else: result = await METHODS[method_name]() return JsonRpcResponse(id=req_id, result=result) except Exception as e: logger.error(f"MCP call id={req_id} failed: {e}", exc_info=True) error_resp = { "code": -32603, "message": f"Internal error: {str(e)}" } return JsonRpcResponse(id=req_id, error=error_resp) except json.JSONDecodeError as e: raise HTTPException(status_code=400, detail=f"Invalid JSON: {e}") except Exception as e: logger.error(f"MCP server error: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Internal server error")

启动命令:

uvicorn mcp_server:app --host 0.0.0.0 --port 8000 --reload

现在,你可以用 curl 测试:

curl -X POST "http://localhost:8000/mcp" \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","method":"device.status","params":{"device_id":"dev-001"},"id":1}'

你会得到标准响应:

{"jsonrpc":"2.0","result":{"device_id":"dev-001","online":true,"battery":87,"last_seen":"2024-05-20T10:30:00Z"},"id":1}

实操心得:不要在 MCP Server 里做复杂业务逻辑。上面的get_device_status函数,应该只负责“调用”,真正的设备通信(比如用pyusb读取 USB Sandisk 设备信息)要放在另一个 service 层。MCP Server 的唯一职责是协议转换和错误包装。我们线上有个 MCP Server,因为把数据库查询逻辑写在里面,导致一次慢查询拖垮了所有能力调用。后来拆成:MCP Server → RPC Client → Database Service,问题立刻解决。

3.2 Skills 注册中心:用 YAML 定义,用 Python 加载,拒绝魔法字符串

Skills 不是散落在代码各处的函数,它必须有一个中心化的注册机制。我们不用装饰器(@tool)那种“魔法”,而是用 YAML 文件定义 Skills 元数据,Python 代码只负责加载和执行。这样做的好处是:产品、测试、运维都能看懂 Skills 是什么,且能用 CI/CD 自动校验。

skills/device.yaml:

name: device.status description: | 查询指定设备的实时状态,包括在线状态、电量和最后在线时间。 示例:查询设备 dev-001 的状态。 parameters: type: object properties: device_id: type: string description: 设备唯一标识符,如 dev-001 minLength: 3 required: [device_id] returns: type: object properties: device_id: type: string online: type: boolean battery: type: integer minimum: 0 maximum: 100 last_seen: type: string format: date-time

skills/device.py:

# skills/device.py import asyncio import httpx # 这里是真正的业务逻辑,与 MCP Server 解耦 async def get_device_status(device_id: str) -> dict: # 调用我们刚写的 MCP Server async with httpx.AsyncClient() as client: resp = await client.post( "http://localhost:8000/mcp", json={ "jsonrpc": "2.0", "method": "device.status", "params": {"device_id": device_id}, "id": 1 } ) data = resp.json() if "error" in data: raise RuntimeError(f"MCP error: {data['error']['message']}") return data["result"]

skills/registry.py(核心注册逻辑):

import yaml import importlib from pathlib import Path from typing import Dict, Callable, Any # 全局 Skills 注册表 SKILLS_REGISTRY: Dict[str, Dict[str, Any]] = {} def load_skills_from_dir(skills_dir: str = "skills"): """从 skills/ 目录加载所有 Skills""" skills_path = Path(skills_dir) for yaml_file in skills_path.glob("*.yaml"): with open(yaml_file, "r", encoding="utf-8") as f: skill_def = yaml.safe_load(f) name = skill_def["name"] # 动态导入对应的 Python 模块 module_name = f"skills.{yaml_file.stem}" try: module = importlib.import_module(module_name) func = getattr(module, skill_def.get("function", "main")) SKILLS_REGISTRY[name] = { "description": skill_def["description"].strip(), "parameters": skill_def["parameters"], "returns": skill_def.get("returns", {}), "function": func, "module": module_name, "file": str(yaml_file) } print(f"✅ Loaded Skill: {name}") except Exception as e: print(f"❌ Failed to load {name}: {e}") # 初始化 load_skills_from_dir() def get_skill(name: str) -> Dict[str, Any]: """根据名称获取 Skill 定义""" return SKILLS_REGISTRY.get(name) def list_all_skills() -> list: """列出所有已注册 Skills""" return list(SKILLS_REGISTRY.keys())

现在,在你的 Agent 主程序里,只需:

from skills.registry import SKILLS_REGISTRY, get_skill # Agent 框架调用 Skills 的伪代码 async def execute_skill(skill_name: str, params: dict): skill = get_skill(skill_name) if not skill: raise ValueError(f"Skill {skill_name} not found") # 这里可以用 Pydantic 模型校验 params # 然后调用 skill["function"](**params) return await skill["function"](**params)

注意事项:YAML 文件里的function字段必须与 Python 文件中的函数名一致。我们强制约定:每个 YAML 文件对应一个同名 Python 文件,且主函数叫main。这样importlib.import_module就不会出错。另外,parameters的 JSON Schema 必须严格,我们用jsonschema.validate在load_skills_from_dir里做预校验,如果 Schema 无效,直接报错退出,不加载。

3.3 MCP 与 Skills 的协同:一个真实调试场景还原

假设用户问:“我的 USB Sandisk 设备 dev-001 电量只剩 10% 了,能帮我重启一下吗?”

Agent 的决策流会是:

  1. LLM 输出 tool call:{"name": "device.status", "parameters": {"device_id": "dev-001"}}
  2. Agent 框架查SKILLS_REGISTRY,找到device.status,调用skills.device.get_device_status("dev-001")
  3. get_device_status内部发 JSON-RPC 请求到http://localhost:8000/mcp
  4. MCP Server 收到请求,解析method="device.status",调用get_device_status函数
  5. get_device_status函数里,用pyusb读取 USB 设备寄存器,得到电量 10%
  6. MCP Server 返回{"result": {"device_id": "dev-001", "online": true, "battery": 10, ...}, "id": 1}
  7. Agent 框架收到 Skills 返回,LLM 看到电量低,输出下一个 tool call:{"name": "device.reboot", "parameters": {"device_id": "dev-001"}}
  8. 流程重复,但这次调用device.reboot方法

整个过程,你在日志里会看到清晰的 trace:

INFO: MCP call id=1 method=device.status params={'device_id': 'dev-001'} INFO: MCP call id=2 method=device.reboot params={'device_id': 'dev-001'}

而如果你只用 Skills,没有 MCP,get_device_status就得直接写pyusb代码,一旦 USB 设备拔掉,Skills 报USBError,Agent 就崩了。有了 MCP,pyusb的错误被 MCP Server 捕获,转成标准{"error": {"code": -32603, "message": "USB device not found"}},Agent 框架可以优雅降级,告诉用户“设备未连接”。

这就是两条路线的价值:Skills 让 Agent 有“脑子”,MCP 让它有“手脚”,而协同机制,就是那根把脑子和手脚连起来的“脊髓”。

4. 实操过程详解:从本地调试到生产部署的全链路避坑指南

4.1 本地调试:Chrome DevTools + Playwright MCP 的黄金组合

搜索热词里有 “chrome devtools mcp”、“playwright mcp”,这不是巧合。Chrome DevTools 是目前最强大的 MCP 调试器,因为它原生支持 WebSocket 和 JSON-RPC。Playwright 则是把浏览器变成 MCP Server 的最佳胶水。

第一步:启用 Chrome 的 MCP 调试模式
在 Chrome 启动时加参数:

chrome --remote-debugging-port=9222 --remote-allow-origins=*

然后访问http://localhost:9222/json,你会看到类似:

[{ "description": "", "devtoolsFrontendUrl": "/devtools/inspector.html?ws=localhost:9222/devtools/page/...", "faviconUrl": "", "id": "D0F3E5A1C2B4...", "title": "New Tab", "type": "page", "url": "about:blank", "webSocketDebuggerUrl": "ws://localhost:9222/devtools/page/D0F3E5A1C2B4..." }]

这个webSocketDebuggerUrl就是 Chrome 的 MCP endpoint!你可以用任何 JSON-RPC 客户端连它。

第二步:用 Playwright 暴露浏览器能力为 MCP Server
Playwright 的browser.contexts[0].new_page()创建的页面,可以通过page.evaluate执行任意 JS。我们写一个 Playwright MCP Server:

# playwright_mcp_server.py from playwright.sync_api import sync_playwright from fastapi import FastAPI, Request import json app = FastAPI() with sync_playwright() as p: browser = p.chromium.launch(headless=False) page = browser.new_page() @app.post("/mcp") def handle_playwright_mcp(request: Request): req_data = request.json() method = req_data["method"] params = req_data.get("params", {}) try: if method == "browser.navigate": page.goto(params["url"]) result = {"status": "success", "url": page.url} elif method == "browser.screenshot": path = f"screenshot_{int(time.time())}.png" page.screenshot(path=path) result = {"path": path} else: result = {"error": f"Unknown method {method}"} return {"jsonrpc": "2.0", "result": result, "id": req_data["id"]} except Exception as e: return {"jsonrpc": "2.0", "error": {"code": -32603, "message": str(e)}, "id": req_data["id"]}

现在,你的 Agent 可以调用browser.navigate去打开网页,调用browser.screenshot截图——所有操作都在真实浏览器里发生,且通过标准 MCP 协议。这才是 “playwright mcp” 的真实含义:不是 Playwright 支持 MCP,而是用 Playwright 构建 MCP Server。

实操心得:本地调试时,永远开启 Chrome 的--auto-open-devtools-for-tabs参数。这样每个新 tab 自动弹出 DevTools,你可以在 Console 里直接fetch测试 MCP 请求,比写 Python 脚本快十倍。我们团队的标准流程是:先在 Chrome Console 里用fetch调通,再写 Skills 封装,最后集成到 Agent。

4.2 生产部署:Nginx + uWSGI 的 MCP Server 高可用方案

本地跑通不等于生产可用。MCP Server 必须扛住高并发、低延迟、7x24 小时运行。我们线上用的是 Nginx + uWSGI 组合,而非uvicorn直连。

uwsgi.ini:

[uwsgi] module = mcp_server:app master = true processes = 4 threads = 2 socket = /tmp/mcp.sock chmod-socket = 664 vacuum = true die-on-term = true logto = /var/log/mcp/uwsgi.log

nginx.conf片段:

upstream mcp_backend { server unix:///tmp/mcp.sock; } server { listen 8000; server_name _; location /mcp { include uwsgi_params; uwsgi_pass mcp_backend; # 关键:设置超时,避免长连接占满 uwsgi_read_timeout 10; uwsgi_send_timeout 10; uwsgi_connect_timeout 5; } # 添加 MCP 健康检查端点 location /health { return 200 "OK"; } }

为什么不用 uvicorn?因为 uvicorn 是 ASGI 服务器,适合开发;uWSGI 是工业级 WSGI/ASGI 服务器,有进程管理、内存监控、优雅重启等企业级特性。我们线上有个 MCP Server,每天处理 200 万次调用,用 uvicorn 时,内存泄漏导致每 12 小时必须重启;换成 uWSGI 后,稳定运行 47 天无重启。

注意:MCP Server 的/mcp接口必须是POST,且Content-Type必须是application/json。Nginx 默认会缓存 GET 请求,所以一定要在location /mcp里加limit_except POST { deny all; },防止恶意 GET 请求打爆 Server。

4.3 Skills 的 CI/CD:用 GitHub Actions 自动校验 Skills 质量

Skills 是代码,就必须走 CI/CD。我们用 GitHub Actions 做三件事:

  1. YAML Schema 校验
  2. Python 语法检查
  3. 基础功能测试

.github/workflows/skills-ci.yml:

name: Skills CI on: push: paths: - 'skills/**' jobs: validate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v4 with: python-version: '3.11' - name: Install dependencies run: | pip install pyyaml jsonschema pytest - name: Validate YAML schemas run: | for yaml_file in skills/*.yaml; do echo "Validating $yaml_file" # 检查是否符合 MCP Skills schema python -c " import yaml, jsonschema, sys with open('$yaml_file') as f: data = yaml.safe_load(f) schema = { 'type': 'object', 'required': ['name', 'description', 'parameters'], 'properties': { 'name': {'type': 'string'}, 'description': {'type': 'string'}, 'parameters': {'type': 'object'} } } jsonschema.validate(instance=data, schema=schema) " done - name: Run pytest run: pytest skills/tests/ -v

skills/tests/test_device.py:

import pytest from skills.device import get_device_status @pytest.mark.asyncio async def test_get_device_status(): # 测试正常情况 result = await get_device_status("dev-001") assert result["device_id"] == "dev-001" assert "online" in result # 测试异常情况 with pytest.raises(RuntimeError): await get_device_status("invalid-device-id")

这个 CI 流程确保:每次 PR 提交,Skills 的 YAML 定义、Python 逻辑、基础功能都经过验证。我们曾拦截过一个 PR,YAML 里required字段写成了["device_id", "user_id"],但 Python 函数只接受device_id,CI 直接失败,避免了上线后 LLM 传错参导致的崩溃。

4.4 安全加固:MCP 的鉴权与 Skills 的沙箱

生产环境,安全是底线。MCP Server 和 Skills 都必须加固。

MCP Server 鉴权:
我们不用 JWT,太重。用最简单的 API Key + IP 白名单:

# 在 mcp_server.py 的 handle_mcp 函数开头加 api_key = request.headers.get("X-API-Key") if api_key != "your-secret-key-here": raise HTTPException(status_code=403, detail="Forbidden") client_ip = request.client.host if client_ip not in ["10.0.0.1", "10.0.0.2"]: # 白名单 raise HTTPException(status_code=403, detail="IP not allowed")

Skills 沙箱:
Skills 函数不能执行任意代码。我们用RestrictedPython库限制:

from RestrictedPython import compile_restricted, compile_restricted_exec from RestrictedPython.Guards import ( guarded_iter_unpack_sequence, guarded
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进