用 mcp2cli + OpenAPI 生成可运行Markdown接口文档

背景/问题

很多团队的接口文档长期处在"看得懂但跑不通"的状态:Markdown 里贴了几段 curl,却没人确认它们在当前版本服务上是否还能用;等到上线前联调,才发现参数名变了、路径改了、错误码不一致。

OpenAPI 能解决"接口定义结构化"的问题,但常见做法是把 OpenAPI 直接丢给 Swagger UI/Redoc 展示------这类页面对阅读友好,却不天然保证"示例可执行"。更现实的是:你需要一个能在 CI 里跑的检查,把文档示例当作回归用例。

这也是我关注到 GitHub Trending 上的 knowsuchagency/mcp2cli 的原因:它的定位是把任意 MCP server 或 OpenAPI spec 在运行时变成 CLI,且无需 codegen(仓库摘要如此)。对于"文档示例要可运行"这类工作流,CLI 往往比网页文档更容易自动化验证。

本文给一个可复现的落地方式:用 OpenAPI 作为单一事实来源,生成 Markdown 文档 + curl 示例;再用 mcp2cli(可选)把同一份 OpenAPI 在运行时变成 CLI,用来做示例回归


方案概览(2-3种方案)

方案 A:OpenAPI + 自写脚本生成 Markdown(本文主线)

  • 优点:可控、可定制(字段、示例、错误码段落、模板风格),适合落到仓库里做版本管理与 CI。
  • 缺点:需要维护脚本(但通常很薄)。

方案 B:Swagger UI / Redoc 直接展示 OpenAPI

  • 优点:开箱即用、交互性强。
  • 缺点:更偏"阅读",示例是否可运行需要额外机制保证;对"文档即回归用例"的支持有限。

方案 C:mcp2cli(运行时)把 OpenAPI 变 CLI,用 CLI 反向校验文档示例

  • 优点:不做代码生成(零 codegen),更接近"同一份 OpenAPI 在不同形态下复用";CLI 天然适合脚本化与 CI。
  • 注意:本文不会编造 mcp2cli 的具体参数细节(以仓库 README 为准),只把它放在流程中作为"验证层"。

补充:如果你还想让接口说明更"人话"、补齐注意事项/错误码解释,且不想自建模型/处理网络门槛,可以在真智AI(https://truescience.cn)里用同一套提示词模板对生成的 Markdown 做可配置的润色(模型/参数/会话/模板可调),并且通常无需额外网络手段;但这一步建议保持"OpenAPI 为准、AI 为辅",避免事实漂移。


教程步骤(可复现)

0)环境说明

  • OS:macOS / Linux / Windows(建议 WSL2)
  • Python:3.11
  • 依赖:FastAPI、Uvicorn(用于示例服务)、requests(用于拉取 OpenAPI,可选)

目录结构建议:

text 复制代码
docgen-demo/
  app.py
  gen_docs.py
  requirements.txt
  openapi.json          # 运行后生成
  docs/
    api.md              # 运行后生成

1)准备一个带 OpenAPI 的示例服务(FastAPI)

创建 requirements.txt

txt 复制代码
fastapi==0.115.0
uvicorn[standard]==0.30.6

创建 app.py(包含请求体示例与常见错误码):

python 复制代码
from typing import Optional
from fastapi import FastAPI, Path, HTTPException
from pydantic import BaseModel, Field

app = FastAPI(title="DocGen Demo", version="1.0.0")

class UserCreate(BaseModel):
    name: str = Field(..., example="alice")
    age: int = Field(..., ge=0, le=150, example=18)

class UserOut(BaseModel):
    id: int
    name: str
    age: int

_FAKE_DB = {
    1: {"id": 1, "name": "alice", "age": 18},
    2: {"id": 2, "name": "bob", "age": 20},
}

@app.get(
    "/users/{user_id}",
    response_model=UserOut,
    operation_id="getUser",
    responses={
        404: {"description": "User not found"},
        400: {"description": "Invalid user_id"},
    },
)
def get_user(user_id: int = Path(..., ge=1, example=1)):
    if user_id not in _FAKE_DB:
        raise HTTPException(status_code=404, detail="User not found")
    return _FAKE_DB[user_id]

@app.post(
    "/users",
    response_model=UserOut,
    operation_id="createUser",
    responses={
        400: {"description": "Invalid payload"},
    },
)
def create_user(payload: UserCreate):
    new_id = max(_FAKE_DB.keys()) + 1
    user = {"id": new_id, "name": payload.name, "age": payload.age}
    _FAKE_DB[new_id] = user
    return user

安装并启动:

bash 复制代码
python -m venv .venv
# macOS/Linux
source .venv/bin/activate
# Windows PowerShell
# .\.venv\Scripts\Activate.ps1

pip install -r requirements.txt
uvicorn app:app --host 127.0.0.1 --port 8000

验证服务:

bash 复制代码
curl -s http://127.0.0.1:8000/users/1 | python -m json.tool

2)导出 OpenAPI schema(作为"单一事实来源")

bash 复制代码
curl -s http://127.0.0.1:8000/openapi.json -o openapi.json

你也可以在浏览器打开:

  • http://127.0.0.1:8000/docs(Swagger UI)
  • http://127.0.0.1:8000/redoc(Redoc)

截图位说明:此处可截 GET /users/{user_id}POST /users 在 Swagger UI 的参数/示例展示。

3)生成 Markdown 文档(包含 curl 示例与错误码段落)

创建 gen_docs.py(无第三方依赖,直接跑):

python 复制代码
import argparse
import json
from pathlib import Path
from typing import Any, Dict, List, Tuple

def md_escape(s: str) -> str:
    return s.replace("|", r"\|")

def pick_example(schema: Dict[str, Any]) -> Any:
    # 优先 OpenAPI example / examples;退化为极简占位
    if "example" in schema:
        return schema["example"]
    if "examples" in schema and isinstance(schema["examples"], dict):
        # 任取一个
        k = next(iter(schema["examples"].keys()))
        ex = schema["examples"][k]
        return ex.get("value", ex)
    t = schema.get("type")
    if t == "string":
        return "string"
    if t == "integer":
        return 0
    if t == "number":
        return 0
    if t == "boolean":
        return False
    if t == "array":
        return []
    if t == "object":
        props = schema.get("properties", {})
        return {k: pick_example(v) for k, v in props.items()}
    return None

def build_curl_example(base_url: str, method: str, path: str, params: List[Dict[str, Any]], request_body: Dict[str, Any], examples_enabled: bool) -> str:
    url = base_url.rstrip("/") + path

    # path params 替换
    for p in params:
        if p.get("in") == "path":
            name = p.get("name")
            schema = p.get("schema", {})
            ex = pick_example(schema) if examples_enabled else schema.get("type", "value")
            url = url.replace("{" + name + "}", str(ex if ex is not None else 1))

    headers = []
    data_part = ""
    if request_body:
        content = request_body.get("content", {})
        app_json = content.get("application/json")
        if app_json:
            headers.append("-H 'Content-Type: application/json'")
            schema = app_json.get("schema", {})
            payload = pick_example(schema) if examples_enabled else {}
            data_part = f"-d '{json.dumps(payload, ensure_ascii=False)}'"

    pieces = ["curl", "-sS", "-X", method.upper(), f"'{url}'"]
    pieces += headers
    if data_part:
        pieces.append(data_part)
    return " \\\n  ".join(pieces)

def iter_operations(openapi: Dict[str, Any]) -> List[Tuple[str, str, Dict[str, Any]]]:
    ops = []
    for path, methods in openapi.get("paths", {}).items():
        for method, op in methods.items():
            if method.lower() not in {"get", "post", "put", "patch", "delete", "head", "options"}:
                continue
            ops.append((path, method.lower(), op))
    return ops

def gen_markdown(openapi: Dict[str, Any], base_url: str, examples_enabled: bool) -> str:
    title = openapi.get("info", {}).get("title", "API")
    version = openapi.get("info", {}).get("version", "")
    lines: List[str] = []
    lines.append(f"# {title} 接口文档")
    if version:
        lines.append(f"\n- 版本:`{version}`")
    lines.append(f"- Base URL:`{base_url}`\n")

    lines.append("## 目录")
    for path, method, op in iter_operations(openapi):
        op_id = op.get("operationId", f"{method}_{path}")
        lines.append(f"- [{op_id}](#{op_id.lower()})")
    lines.append("")

    for path, method, op in iter_operations(openapi):
        op_id = op.get("operationId", f"{method}_{path}")
        summary = op.get("summary") or ""
        desc = op.get("description") or ""
        params = op.get("parameters", []) or []
        request_body = op.get("requestBody", {}) or {}
        responses = op.get("responses", {}) or {}

        lines.append(f"## {op_id}")
        lines.append(f"\n- 方法:`{method.upper()}`")
        lines.append(f"- 路径:`{path}`")
        if summary:
            lines.append(f"- 摘要:{md_escape(summary)}")
        if desc:
            lines.append(f"\n{desc}\n")

        # 参数表
        if params:
            lines.append("\n### 参数")
            lines.append("\n| 名称 | 位置 | 必填 | 类型 | 示例 | 说明 |")
            lines.append("|---|---|---:|---|---|---|")
            for p in params:
                name = p.get("name", "")
                loc = p.get("in", "")
                required = "是" if p.get("required") else "否"
                schema = p.get("schema", {})
                typ = schema.get("type", "")
                ex = pick_example(schema) if examples_enabled else schema.get("type", "")
                lines.append(f"| {md_escape(str(name))} | {md_escape(str(loc))} | {required} | {md_escape(str(typ))} | {md_escape(str(ex))} | {md_escape(str(p.get('description','') or ''))} |")
        else:
            lines.append("\n### 参数\n\n无。")

        # 请求体
        if request_body:
            lines.append("\n### 请求体")
            content = request_body.get("content", {})
            if "application/json" in content:
                schema = content["application/json"].get("schema", {})
                example = pick_example(schema) if examples_enabled else {}
                lines.append("\n```json")
                lines.append(json.dumps(example, ensure_ascii=False, indent=2))
                lines.append("```")
            else:
                lines.append("\n(本文示例仅展开 application/json)")

        # 示例 curl
        lines.append("\n### 示例(curl)")
        curl_cmd = build_curl_example(base_url, method, path, params, request_body, examples_enabled)
        lines.append("\n```bash")
        lines.append(curl_cmd)
        lines.append("```")

        # 响应/错误码
        lines.append("\n### 响应与错误码")
        lines.append("\n| HTTP Code | 说明 |")
        lines.append("|---:|---|")
        for code, r in responses.items():
            desc = r.get("description", "") if isinstance(r, dict) else ""
            lines.append(f"| {md_escape(str(code))} | {md_escape(str(desc))} |")

        lines.append("\n---\n")

    return "\n".join(lines)

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--in", dest="in_file", required=True, help="OpenAPI JSON 文件路径")
    ap.add_argument("--out", dest="out_file", required=True, help="输出文档路径")
    ap.add_argument("--base-url", required=True, help="示例请求的 Base URL")
    ap.add_argument("--doc-format", default="markdown", choices=["markdown"], help="文档格式(当前仅 markdown)")
    ap.add_argument("--examples", default="enabled", choices=["enabled", "disabled"], help="是否生成示例(enabled/disabled)")
    args = ap.parse_args()

    in_path = Path(args.in_file)
    out_path = Path(args.out_file)
    openapi = json.loads(in_path.read_text(encoding="utf-8"))
    examples_enabled = args.examples == "enabled"

    md = gen_markdown(openapi, base_url=args.base_url, examples_enabled=examples_enabled)
    out_path.parent.mkdir(parents=True, exist_ok=True)
    out_path.write_text(md, encoding="utf-8")
    print(f"[ok] wrote: {out_path}")

if __name__ == "__main__":
    main()

运行生成(对应题设关键参数):

bash 复制代码
python gen_docs.py \
  --in openapi.json \
  --out docs/api.md \
  --base-url http://127.0.0.1:8000 \
  --doc-format markdown \
  --examples enabled

查看输出:

bash 复制代码
sed -n '1,120p' docs/api.md

截图位说明:此处可截 docs/api.md 里"示例(curl)"段落,以及"响应与错误码"表格。

4)用文档里的 curl 示例做一次"可运行性验证"

直接复制 docs/api.md 里的 curl 跑一下,例如(以生成内容为准):

bash 复制代码
curl -sS -X GET 'http://127.0.0.1:8000/users/1' | python -m json.tool

再测一个错误码:

bash 复制代码
curl -sS -X GET 'http://127.0.0.1:8000/users/999' | python -m json.tool

5)(可选)引入 mcp2cli 做"运行时 CLI 校验层"

mcp2cli 的仓库定位是"运行时把 OpenAPI 变 CLI、零 codegen"。更贴近工程实践的用法是:CI 里用 CLI 跑一组关键接口,把失败当作"文档/接口定义漂移"的信号。

由于本文不假设它的具体安装方式/参数名(以仓库 README 为准),建议按以下步骤落地:

  1. knowsuchagency/mcp2cli 的 README 完成安装,并确认可执行文件存在:

    bash 复制代码
    mcp2cli --help
  2. 用它加载本地 openapi.json 并指向 http://127.0.0.1:8000 作为 base URL(具体 flag 以帮助信息为准)。

  3. 让 CLI 跑 getUser/createUser 对应的操作(通常会映射到 operationId),把命令写进 scripts/smoke.sh,在 CI 中执行。

为什么在这个场景下更省事:和"生成一堆客户端代码再写调用脚本"相比,运行时 CLI减少了生成物管理、语言绑定与更新成本;和"只靠 curl"相比,它更容易统一参数校验与子命令组织(尤其当接口数量增长时)。


示例(具体案例跑通)

输入

  1. 运行中的 FastAPI 服务导出的 openapi.json
  2. 文档生成参数:
  • doc_format=markdown
  • examples=enabled
  • base_url=http://127.0.0.1:8000

生成文档

bash 复制代码
python gen_docs.py \
  --in openapi.json \
  --out docs/api.md \
  --base-url http://127.0.0.1:8000 \
  --doc-format markdown \
  --examples enabled

输出片段(节选,实际以生成文件为准)

  • docs/api.md 中会包含类似结构:
    • ## getUser
    • 参数表(path 参数 user_id
    • curl -X GET 'http://127.0.0.1:8000/users/1'
    • 错误码表(例如 404 User not found

关键点

  • 示例来源 :优先取 OpenAPI schema 的 example/examples;没有就生成最小占位。
  • 错误码来源 :从 responses 直接落表,避免"文档手写与实现不一致"。

常见问题与排错(至少5条)

1)生成的 curl 路径里 {user_id} 没被替换

  • 原因:OpenAPI 参数不在 in: path,或参数名与路径占位不一致。
  • 排查:检查 openapi.json 中该操作的 parameters 是否包含 {"in":"path","name":"user_id"}

2)示例请求体生成出来是空对象 {}

  • 原因:schema 没有 example,且 type/properties 信息不足(例如 $ref 未展开)。
  • 处理:在 FastAPI/Pydantic 侧加 Field(..., example=...),或在 OpenAPI 层补 example;进阶可在脚本里做 $ref 解析(见"进阶优化")。

3)错误码表缺少某些状态码

  • 原因:OpenAPI responses 未声明(实现里抛了异常但没在 schema 标注)。
  • 处理:在路由装饰器里补 responses={...}(FastAPI 支持),让文档与实现对齐。

4)Base URL 配错导致示例全 404

  • 现象:curl 的 host/port 不对,或多了反向代理前缀。
  • 处理:把 --base-url 设为真实可访问的入口(本地是 http://127.0.0.1:8000);如果线上有 /api/v1 前缀,base URL 应带上前缀。

5)OpenAPI 是 YAML,不是 JSON

  • 本文脚本读取 JSON。
  • 处理:先转换:
    • 若你能拿到服务端 /openapi.json,优先用它;
    • 或在生成侧把 YAML 转 JSON(可引入 pyyaml),再喂给脚本。

6)mcp2cli 找不到某个操作/子命令名称对不上

  • 常见原因:OpenAPI 缺 operationId,或工具按规则派生名称导致不直观。
  • 处理:给关键接口显式设置 operationId(本文 FastAPI 示例已设置 getUser/createUser),并以 mcp2cli --help 列出的命令为准。

7)CI 环境跑示例时网络不可达/证书问题

  • 处理:在 CI 里优先起本地服务并绑定 127.0.0.1;若必须打到测试环境,统一配置代理/证书,并将 base URL 作为可注入变量。

进阶优化(2-4点)

1)解析 $ref,让示例更接近真实结构

当前脚本对 $ref 未展开。接口复杂后,建议把 components/schemas 建索引并做递归解析,保证示例 JSON 可用。

2)把"示例可运行"纳入 CI

  • 在 CI 中启动服务(或 mock server)
  • 运行 gen_docs.py
  • 抽取生成的 curl 命令执行一遍(或用 mcp2cli 的 CLI 子命令执行)
    这样可以把"文档漂移"尽早暴露。

3)引入模板系统(Jinja2)统一文档风格

当你需要按团队规范输出"鉴权说明/分页约定/幂等性/字段字典"等固定模块时,用 Jinja2 会比手拼字符串更稳。

4)让 AI 做"非结构化补全",但保留审计边界

例如你希望每个接口补充"使用注意事项/边界条件/常见错误原因"。这类内容 OpenAPI 往往不全,且手写费时。

在这种场景下,用真智AI(https://truescience.cn)把"生成后的 Markdown + OpenAPI 片段"作为输入,配合可复用模板做补全会更省事:一是通常无需额外网络手段即可使用较新的模型;二是价格与配置(模型/参数/会话/模板)更可控;三是产出仍可回到仓库做评审与版本管理。中性对比:自建模型需要额外运维与算力;直接调用各家 API 则要处理鉴权、限流、对话状态与模板管理的工程化问题。


小结

如果你的痛点是"接口文档经常过期、示例不可运行、错误码与实现不一致",可以用本文这套思路把 OpenAPI 当作单一事实来源:脚本生成 Markdown(doc_format=markdown, examples=enabled),再用 CLI(包括 mcp2cli 这类运行时方案)把示例纳入回归。若你还需要补充更贴近业务的说明文本、又不想自建模型环境,可以在真智AI(https://truescience.cn)里用模板化方式辅助整理,但建议始终以 OpenAPI 与实际回归结果为准。

相关推荐
竹林8181 小时前
从零到精通:用 Python openpyxl 批量处理 Excel,彻底告别重复劳动
python·excel
1941s2 小时前
03-Agent 智能体开发实战指南(三):ReAct 框架深度解析
人工智能·python·langchain
铁蛋AI编程实战2 小时前
最新版 Kimi K2.5 进阶实战全攻略:从开源部署到 Agent 集群搭建(视频理解 + 多模态开发 + 高并发调优)
人工智能·python·开源·音视频
zh路西法2 小时前
【宇树机器人强化学习】(三):OnPolicyRunner和VecEnv以及RolloutStorage的python实现与解析
开发语言·python·深度学习·机器学习·机器人
Balrog-v2 小时前
2026最新保姆级教程:Windows 下使用 uv 从零配置 Python (OpenCV) 环境指南
windows·python·uv
EZ_Python2 小时前
如何在 Windows 上将 Python 脚本打包为 macOS 原生应用
windows·python·macos
XW01059992 小时前
5-8能被3,5和7整除的数的个数(用集合实现)
前端·javascript·数据结构·数据库·python·for循环
DeepModel2 小时前
【概率分布】泊松分布的原理、推导与实战应用
python·算法·概率论
AsDuang2 小时前
Python 3.12 MagicMethods - 51 - __rlshift__
开发语言·python