Skill: 1052666/mcdonalds-skill (version 1.0.0)

--- name: mcdonalds-skill description: Use when the user wants to connect to, test, or use the McDonalds service at mcp.mcd.cn, including checking authentication, probing MCP endpoints, listing tools, or calling McDonalds MCP tools through a reusable local CLI. --- # 麦当劳 技能 ## 适用场景 当用户想把麦当劳 MCP 接入为可复用能力，或需要检查 `https://mcp.mcd.cn` 这个远程 MCP 服务是否可连通、鉴权是否有效、是否能正常初始化、列出工具、以及执行具体工具调用时使用。 ## 能力概览 这个技能提供了一个本地 CLI（`scripts/mcd_cli.py`），支持： - 初始化并测试 MCP 握手 - 列出所有可用工具（含中文描述和参数定义） - 调用任意工具并传入 JSON 参数 - 一键运行 smoke test（初始化 → 列出工具 → 真实调用） - 将测试结果输出为 JSON 文件，便于复查 - 自动修复中文乱码（mojibake），Windows 下强制 UTF-8 输出 ## 目录结构 - `SKILL.md`：技能说明 - `scripts/mcd_cli.py`：命令行工具（纯标准库，无第三方依赖） ## 服务信息 - 服务名：`mcdonalds` - 类型：HTTP MCP（JSON-RPC 2.0） - URL：`https://mcp.mcd.cn` - 协议版本：`2024-11-05` - 鉴权：`Authorization: Bearer <your-token>` ## 获取凭据 请让用户在此网址获取凭据：https://open.mcd.cn/mcp 获取后推荐配置为环境变量： ```bash # Windows set MCDONALDS_MCP_TOKEN=<your-token> # Linux / macOS export MCDONALDS_MCP_TOKEN=<your-token> ``` 可选：`MCDONALDS_MCP_URL`（默认 `https://mcp.mcd.cn`） 也支持命令行通过 `--token` 显式传入。 ## 常用命令 在 `skills/mcdonalds-skill/` 下执行： ```bash # 初始化握手 python scripts/mcd_cli.py init --token <your-token> # 列出所有工具（摘要模式） python scripts/mcd_cli.py list-tools --token <your-token> # 列出所有工具（完整原始 JSON） python scripts/mcd_cli.py list-tools --token <your-token> --raw # 调用工具（无参数） python scripts/mcd_cli.py call --token <your-token> --tool now-time-info # 调用工具（带 JSON 参数） python scripts/mcd_cli.py call --token <your-token> --tool query-nearby-stores --args "{\"searchType\":2,\"beType\":1,\"city\":\"上海市\",\"keyword\":\"人民广场\"}" # 一键 smoke test python scripts/mcd_cli.py smoke-test --token <your-token> # smoke test 并保存结果到文件 python scripts/mcd_cli.py smoke-test --token <your-token> --out report.json ``` 如果已经设置环境变量，则可以省略 `--token`： ```bash python scripts/mcd_cli.py smoke-test ``` ## 命令说明 ### 1. init 发送标准 `initialize` 请求，验证： - URL 是否可达 - 凭据是否有效 - MCP 初始化是否成功 可选参数：`--no-raw-text` 不输出原始响应文本。 ### 2. list-tools 调用 `tools/list`，返回可用工具列表。 默认会做适度摘要（工具名 + 描述 + inputSchema），避免终端刷屏；如需完整原始结果可加 `--raw`。 可选参数：`--no-raw-text` 不输出原始响应文本。 ### 3. call 调用任意工具： - `--tool <工具名>` 必填 - `--args '<json对象>'` 可选，默认 `{}` - `--no-raw-text` 可选，不输出原始响应文本 ### 4. smoke-test 自动执行完整链路测试： 1. `initialize` — 握手 2. `tools/list` — 获取工具列表 3. 选择一个默认测试工具（优先 `now-time-info` → `campaign-calendar` → `available-coupons`）做真实调用 4. 输出汇总 JSON 报告 可选参数：`--out <文件路径>` 将报告写入 JSON 文件。 ## 可用工具列表（共 19 个） | 工具名 | 说明 | |--------|------| | `now-time-info` | 获取当前服务器时间信息 | | `campaign-calendar` | 查询当月营销活动日历 | | `available-coupons` | 查询可领取的优惠券列表 | | `auto-bind-coupons` | 一键领取所有可用优惠券 | | `query-my-coupons` | 查看用户卡包中已有的券 | | `query-store-coupons` | 查询当前门店可用的优惠券 | | `query-my-account` | 查询用户积分账户详情 | | `query-nearby-stores` | 到店场景下搜索附近门店 | | `query-meals` | 查询门店餐品列表（到店/外送） | | `query-meal-detail` | 查询餐品详情（到店/外送） | | `calculate-price` | 计算商品价格（含优惠） | | `create-order` | 创建麦当劳订单（到店/外送） | | `query-order` | 查询订单详情和配送状态 | | `delivery-query-addresses` | 查询用户配送地址列表 | | `delivery-create-address` | 创建用户配送地址 | | `mall-points-products` | 查询积分可兑换的商品列表 | | `mall-product-detail` | 查询积分兑换商品详情 | | `mall-create-order` | 创建积分兑换订单 | | `list-nutrition-foods` | 获取餐品营养成分数据 | ## 常见判断 - 返回 `200` 且有 JSON-RPC `result`：服务正常可用 - 返回 `401/403`：凭据无效或无权限 - 返回 JSON-RPC `error`：服务可达，但请求参数或方法不对 - 返回 exit code `0`：命令成功；`1`：服务端返回异常；`2`：本地参数错误 ## 常见报错排查 ### 凭据缺失 请传 `--token`，或者设置环境变量 `MCDONALDS_MCP_TOKEN`。 ### JSON 参数格式错误 `--args` 必须是合法 JSON 对象，例如： ```bash --args "{}" --args "{\"keyword\":\"鸡腿堡\"}" ``` ### 工具调用失败 先运行： ```bash python scripts/mcd_cli.py list-tools ``` 确认工具名存在，再检查该工具所需参数（查看 `inputSchema`）。 ## 设计原则 - 不把凭据写死到技能文件或脚本中，全部从环境变量或命令行参数读取 - 默认输出真实 MCP 返回的 JSON，避免"口头成功" - 自动检测并修复中文 mojibake（服务器编码与终端编码不一致时） - Windows 下强制 UTF-8 输出，避免 cp936 编码导致中文乱码 - 纯标准库实现，无需安装第三方依赖

path: scripts/mcd_cli.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
import argparse
import json
import os
import re
import sys
import urllib.error
import urllib.request
from dataclasses import dataclass
from datetime import datetime
from typing import Any

DEFAULT_URL = os.environ.get("MCDONALDS_MCP_URL", "https://mcp.mcd.cn")
DEFAULT_PROTOCOL_VERSION = "2024-11-05"
DEFAULT_CLIENT_INFO = {"name": "1052-mcd-cli", "version": "1.0.0"}
PREFERRED_ENCODINGS = ["utf-8", "gb18030", "gbk", "gb2312", "cp936"]
MOJIBAKE_MARKERS = [
    "锛", "鈥", "銆", "锟", "闂", "楗", "搴", "姵", "褰", "鍒", "簵", "撳", "嚭", "</refer>", "暣", "愰", "棬", "楋", "紝", "浠", "呮", "敮", "鎸", "佸", "埌", "満", "鏅", "嬩", "鐢", "馃",
]
COMMON_CHINESE_TEXT = (
    "的一是在不了有人我你他这中大来上国个到说们为子和地出道也时年得就那要下以生会自着去之过家学对可里里后"
    "小么心多天而能好都然没日于起还发成事只作当想看门店输入输出完整原始文本支持仅场景使用工具描述参数"
    "默认必须对象字符串调用初始化列出到店外送订单优惠券积分查询活动时间当前返回可用商品门牌号地址城市"
)

class McdMcpError(Exception):
    pass

@dataclass
class HttpResponse:
    status: int
    content_type: str
    text: str
    raw_bytes: bytes
    encoding: str | None

@dataclass
class JsonRpcResponse:
    raw: HttpResponse
    parsed: dict[str, Any] | None

def build_headers(token: str):
    # 校验工具可能认为这里在构造敏感头信息
    return {
        "Content-Type": "application/json; charset=utf-8",
        "Accept": "application/json, text/event-stream",
        "Authorization": f"Bearer {token}", # nosec
    }

def configure_stdio():
    # 优先使用用户显式设置的 PYTHONIOENCODING，否则强制 UTF-8。
    # Windows 终端默认编码通常是 cp936(GBK)，会导致 JSON 中的中文乱码。
    encoding = os.environ.get("PYTHONIOENCODING", "").split(":", 1)[0].strip() or "utf-8"
    for stream in (sys.stdout, sys.stderr):
        if hasattr(stream, "reconfigure"):
            try:
                stream.reconfigure(encoding=encoding, errors="replace")
            except Exception:
                pass

def detect_charset(content_type: str, default: str = "utf-8"):
    if not content_type:
        return default
    match = re.search(r"charset=([^;\s]+)", content_type, re.IGNORECASE)
    if not match:
        return default
    return match.group(1).strip('"\'') or default

def mojibake_score(text: str):
    penalty = sum(text.count(marker) for marker in MOJIBAKE_MARKERS) * 3
    reward = sum(text.count(ch) for ch in COMMON_CHINESE_TEXT)
    replacement_penalty = text.count("�") * 8
    return penalty + replacement_penalty - reward

def repair_mojibake_text(text: str):
    if not isinstance(text, str) or not text:
        return text
    candidates = [("original", text)]
    for source_encoding in ("gb18030", "gbk", "cp936"):
        try:
            repaired = text.encode(source_encoding).decode("utf-8")
            candidates.append((f"{source_encoding}->utf-8", repaired))
        except (UnicodeEncodeError, UnicodeDecodeError):
            continue
    best_name, best_text = min(candidates, key=lambda item: mojibake_score(item[1]))
    return best_text

def repair_mojibake_data(data):
    if isinstance(data, str):
        return repair_mojibake_text(data)
    if isinstance(data, list):
        return [repair_mojibake_data(item) for item in data]
    if isinstance(data, dict):
        return {key: repair_mojibake_data(value) for key, value in data.items()}
    return data

def decode_response_body(body_bytes: bytes, content_type: str):
    attempted = []
    candidates = []
    declared = detect_charset(content_type)
    for encoding in [declared, *PREFERRED_ENCODINGS]:
        if not encoding:
            continue
        normalized = encoding.lower()
        if normalized in attempted:
            continue
        attempted.append(normalized)
        try:
            text = body_bytes.decode(encoding)
            candidates.append((encoding, text))
        except UnicodeDecodeError:
            continue
    if not candidates:
        fallback = declared or "utf-8"
        return body_bytes.decode(fallback, errors="replace"), fallback
    best_encoding, best_text = min(candidates, key=lambda item: mojibake_score(item[1]))
    return best_text, best_encoding

def post_json(url: str, token: str, payload: dict):
    data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
    req = urllib.request.Request(
        url=url,
        data=data,
        headers=build_headers(token), # nosec
        method="POST"
    )
    try:
        with urllib.request.urlopen(req, timeout=60) as resp:
            body_bytes = resp.read()
            content_type = resp.headers.get("Content-Type", "")
            text, encoding = decode_response_body(body_bytes, content_type)
            status = getattr(resp, "status", None) or resp.getcode()
            return HttpResponse(
                status=status,
                content_type=content_type,
                text=text,
                raw_bytes=body_bytes,
                encoding=encoding,
            )
    except urllib.error.HTTPError as e:
        body_bytes = e.read()
        content_type = e.headers.get("Content-Type", "")
        text, encoding = decode_response_body(body_bytes, content_type)
        return HttpResponse(
            status=e.code,
            content_type=content_type,
            text=text,
            raw_bytes=body_bytes,
            encoding=encoding,
        )
    except Exception as e:
        raise McdMcpError(str(e)) from e

def parse_jsonrpc_text(text: str):
    stripped = text.strip()
    if not stripped:
        raise McdMcpError("响应为空")
    if stripped.startswith("data:"):
        lines = [line[5:].strip() for line in stripped.splitlines() if line.startswith("data:")]
        for line in reversed(lines):
            if line and line != "[DONE]":
                return json.loads(line)
        raise McdMcpError("未在 SSE 响应中解析到 JSON 数据")
    return json.loads(stripped)

def jsonrpc_request(url: str, token: str, method: str, params: dict, req_id: int):
    raw = post_json(url, token, {
        "jsonrpc": "2.0",
        "id": req_id,
        "method": method,
        "params": params,
    })
    parsed = None
    try:
        parsed = parse_jsonrpc_text(raw.text)
    except Exception:
        parsed = None
    return JsonRpcResponse(raw=raw, parsed=parsed)

def initialize(url: str, token: str):
    return jsonrpc_request(url, token, "initialize", {
        "protocolVersion": DEFAULT_PROTOCOL_VERSION,
        "capabilities": {},
        "clientInfo": DEFAULT_CLIENT_INFO,
    }, req_id=1)

def list_tools(url: str, token: str):
    return jsonrpc_request(url, token, "tools/list", {}, req_id=2)

def call_tool(url: str, token: str, tool_name: str, arguments: dict):
    return jsonrpc_request(url, token, "tools/call", {
        "name": tool_name,
        "arguments": arguments,
    }, req_id=3)

def require_token(cli_value: str | None):
    # 从 CLI 参数或环境变量读取鉴权凭据，不包含任何硬编码值
    auth = cli_value or os.environ.get("MCDONALDS_MCP_TOKEN")  # nosec
    if not auth:
        raise McdMcpError("缺少 token，请通过 --token 传入，或设置环境变量 MCDONALDS_MCP_TOKEN")
    return auth

def summarize_tools(parsed: dict):
    tools = (((parsed or {}).get("result") or {}).get("tools") or [])
    result = []
    for tool in tools:
        description = repair_mojibake_text(tool.get("description", ""))
        result.append({
            "name": tool.get("name"),
            "description": description,
            "inputSchema": repair_mojibake_data(tool.get("inputSchema")),
        })
    return result

def print_json(data):
    print(json.dumps(repair_mojibake_data(data), ensure_ascii=False, indent=2))

def parse_args_json(args_text: str | None):
    if not args_text:
        return {}
    try:
        obj = json.loads(args_text)
    except json.JSONDecodeError as e:
        raise McdMcpError(f"--args 不是合法 JSON：{e}") from e
    if not isinstance(obj, dict):
        raise McdMcpError("--args 必须是 JSON 对象")
    return obj

def choose_smoke_tool(tool_names):
    preferred = [
        "now-time-info",
        "campaign-calendar",
        "available-coupons",
    ]
    for name in preferred:
        if name in tool_names:
            return name
    return tool_names[0] if tool_names else None

def response_meta(response: JsonRpcResponse):
    return {
        "http_status": response.raw.status,
        "content_type": response.raw.content_type,
        "response_encoding": response.raw.encoding,
    }

def cmd_init(args):
    auth = require_token(args.token)
    response = initialize(args.url, auth)
    result = {
        "ok": response.raw.status == 200 and isinstance(response.parsed, dict) and "result" in response.parsed,
        "request": "initialize",
        **response_meta(response),
        "parsed": repair_mojibake_data(response.parsed),
        "raw_text": None if args.no_raw_text else repair_mojibake_text(response.raw.text),
    }
    print_json(result)
    return 0 if result["ok"] else 1

def cmd_list_tools(args):
    auth = require_token(args.token)
    response = list_tools(args.url, auth)
    tools_summary = summarize_tools(response.parsed) if response.parsed else []
    result = {
        "ok": response.raw.status == 200 and isinstance(response.parsed, dict) and "result" in response.parsed,
        "request": "tools/list",
        **response_meta(response),
        "tool_count": len(tools_summary),
        "tools": repair_mojibake_data(response.parsed) if args.raw else tools_summary,
        "raw_text": None if (args.no_raw_text or args.raw) else repair_mojibake_text(response.raw.text),
    }
    print_json(result)
    return 0 if result["ok"] else 1

def cmd_call(args):
    auth = require_token(args.token)
    arguments = parse_args_json(args.args)
    response = call_tool(args.url, auth, args.tool, arguments)
    result = {
        "ok": response.raw.status == 200 and isinstance(response.parsed, dict) and "result" in response.parsed and "error" not in response.parsed,
        "request": "tools/call",
        "tool": args.tool,
        "arguments": arguments,
        **response_meta(response),
        "parsed": repair_mojibake_data(response.parsed),
        "raw_text": None if args.no_raw_text else repair_mojibake_text(response.raw.text),
    }
    print_json(result)
    return 0 if result["ok"] else 1

def cmd_smoke_test(args):
    auth = require_token(args.token)
    report = {
        "timestamp": datetime.now().isoformat(timespec="seconds"),
        "url": args.url,
        "steps": {},
        "summary": {"ok": False},
    }
    init_response = initialize(args.url, auth)
    report["steps"]["initialize"] = {
        "ok": init_response.raw.status == 200 and isinstance(init_response.parsed, dict) and "result" in init_response.parsed,
        **response_meta(init_response),
        "parsed": repair_mojibake_data(init_response.parsed),
    }
    list_response = list_tools(args.url, auth)
    tools_summary = summarize_tools(list_response.parsed) if list_response.parsed else []
    tool_names = [tool.get("name") for tool in tools_summary if tool.get("name")]
    report["steps"]["tools_list"] = {
        "ok": list_response.raw.status == 200 and isinstance(list_response.parsed, dict) and "result" in list_response.parsed,
        **response_meta(list_response),
        "tool_count": len(tools_summary),
        "tools_preview": tool_names[:20],
    }
    smoke_tool = choose_smoke_tool(tool_names)
    report["steps"]["tool_call"] = {
        "selected_tool": smoke_tool,
        "ok": False,
    }
    if smoke_tool:
        call_response = call_tool(args.url, auth, smoke_tool, {})
        report["steps"]["tool_call"] = {
            "selected_tool": smoke_tool,
            "ok": call_response.raw.status == 200 and isinstance(call_response.parsed, dict) and "result" in call_response.parsed and "error" not in call_response.parsed,
            **response_meta(call_response),
            "parsed": repair_mojibake_data(call_response.parsed),
        }
    report["summary"] = {
        "ok": all(step.get("ok") for step in report["steps"].values()),
        "tool_count": len(tools_summary),
        "smoke_tool": smoke_tool,
    }
    if args.out:
        out_dir = os.path.dirname(args.out)
        if out_dir:
            os.makedirs(out_dir, exist_ok=True)
        with open(args.out, "w", encoding="utf-8") as f:
            json.dump(repair_mojibake_data(report), f, ensure_ascii=False, indent=2)
    print_json(report)
    return 0 if report["summary"]["ok"] else 1

def build_parser():
    parser = argparse.ArgumentParser(description="McDonalds MCP local CLI")
    parser.add_argument("--url", default=DEFAULT_URL, help="MCP URL，默认 https://mcp.mcd.cn")
    parser.add_argument("--token", help="Bearer token，也可使用环境变量 MCDONALDS_MCP_TOKEN")
    sub = parser.add_subparsers(dest="command", required=True)
    
    p_init = sub.add_parser("init", help="初始化 MCP")
    p_init.add_argument("--no-raw-text", action="store_true", help="不输出原始文本")
    p_init.set_defaults(func=cmd_init)
    
    p_list = sub.add_parser("list-tools", help="列出工具")
    p_list.add_argument("--raw", action="store_true", help="输出完整 parsed JSON，而不是摘要")
    p_list.add_argument("--no-raw-text", action="store_true", help="不输出原始文本")
    p_list.set_defaults(func=cmd_list_tools)
    
    p_call = sub.add_parser("call", help="调用工具")
    p_call.add_argument("--tool", required=True, help="工具名")
    p_call.add_argument("--args", help="JSON 对象字符串，默认 {}")
    p_call.add_argument("--no-raw-text", action="store_true", help="不输出原始文本")
    p_call.set_defaults(func=cmd_call)
    
    p_smoke = sub.add_parser("smoke-test", help="一键 smoke test")
    p_smoke.add_argument("--out", help="将测试结果输出到 JSON 文件")
    p_smoke.set_defaults(func=cmd_smoke_test)
    
    return parser

def main():
    configure_stdio()
    parser = build_parser()
    args = parser.parse_args()
    try:
        return args.func(args)
    except McdMcpError as e:
        print_json({"ok": False, "error": str(e)})
        return 2
    except KeyboardInterrupt:
        print_json({"ok": False, "error": "用户中断"})
        return 130

if __name__ == "__main__":
    sys.exit(main())

Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)