Skip to main content
Glama
README.md
# SDN MCP Template

一个可复用、可部署的 **MCP (Model Context Protocol) server 模板**,让 AI agent
通过工具(tools)操作 SDN 网络控制器。基于官方 `mcp` Python SDK(v1 `FastMCP`),
主传输为 **Streamable HTTP**,同时支持 **stdio**(便于 Claude Desktop 本地联调)。

本仓库交付**骨架**:MCP server、测试 client、通用 HTTP 客户端、SDN 对接层(结构完整、
端点打桩)、配置管理、测试套件与 Docker 部署。接入真实 SDN 控制器时,**只需 3 处改动**
(见[新增一个 SDN 工具](#新增一个-sdn-工具))。

---

## 功能特性

- **MCP server**(`app/server.py`):FastMCP + Streamable HTTP(`/mcp`),可选 stdio。
- **测试 client**(`scripts/test_client.py`):`list-tools` / `call-tool` 命令行。
- **通用 HTTP 客户端**(`app/common/http.py`):重试(仅 429/5xx/传输错误)、鉴权
  (bearer/basic)、REST 方法封装、可注入 transport(便于单测)。
- **SDN 对接层**(`app/sdn/`):基于通用客户端,把 httpx 错误映射为安全的 `SDNError`
  层级(公开 message 不含 URL/状态码)。
- **认证与 token 刷新**(`app/sdn/`):三种 `auth_type`(`no-auth` / `bearer` 固定 api-key /
  `basic` 用账号密码登录换 token);`basic` 模式在 token 过期(401)时自动重新登录,
  业务代码无感知(统一走 `SDNClient._send`)。
- **配置**(`app/settings.py`):YAML 存非敏感结构、`.env`/环境变量存 secret(`SecretStr`)。
- **工具**(`app/tools/`):`ping`(验证 server)、`sdn_health`(验证 SDN 接缝)。
- **测试**:38+ 用例,覆盖率 ≥96%,含内存传输(无需真起 HTTP)。
- **部署**(`deploy/`):多阶段 Dockerfile + docker-compose。

## 架构

```
┌──────────────┐   Streamable HTTP   ┌──────────────────────────────────────┐
│  AI agent /  │ ──────────────────▶ │  FastMCP server  (app/server.py)      │
│ test_client  │   /mcp endpoint     │   ├─ lifespan owns SDNClient           │
└──────────────┘ ◀────────────────── │   ├─ tools: ping, sdn_health, ...     │
                 JSON-RPC responses  │   └─ app/sdn/client.py                 │
                                     │        └─ app/common/http.py (retry)   │
                                     │              └─ httpx ──▶ SDN controller │
                                     └──────────────────────────────────────┘
```

## 前置要求

- Python ≥ 3.13
- [uv](https://docs.astral.sh/uv/)(包管理器)

## 安装

```bash
uv sync          # 安装运行依赖
uv sync          # dev 组(pytest/ruff/mypy)默认随 uv sync 安装
```

## 配置

配置分两部分:

1. **非敏感结构** —— `config/sdn_controller.yaml`(可提交、可版本管理):
   ```yaml
   sdn:
     base_url: ""              # 留空 = 骨架模式(server 正常启动)
     auth_type: "no-auth"      # no-auth | bearer (固定 api-key) | basic (账密登录换 token,401 自动刷新)
     timeout: 30.0
     ssl_verify: true          # 自签名证书设为 false
     retry: { max_retries: 3, base_delay: 1.0, max_delay: 30.0 }
     endpoints:
       health: "/"
       devices: "/devices"
       topology: "/topology"
       # login: "/oauth/token"  # auth_type=basic:POST {username,password,device_id} 换/刷新 token
     token_field: "access_token"  # auth_type=basic:登录响应 JSON 中 token 的 key
   ```

2. **secret 与运行参数** —— `.env`(从 `.env.example` 复制,**切勿提交真实值**):
   ```bash
   cp .env.example .env
   ```
   ```dotenv
   MCP_HOST=127.0.0.1
   MCP_PORT=8000
   MCP_LOG_LEVEL=INFO
   SDN_TOKEN=...        # bearer token(auth_type=bearer 时)
   SDN_USERNAME=...     # basic auth 用户名
   SDN_PASSWORD=...     # basic auth 密码
   ```

> **骨架模式**:`base_url` 留空时 server 照常启动,`sdn_health` 返回
> `{ok: false, configured: false}`。配置控制器地址与凭证后即可调用真实端点。

## 运行 server

```bash
# Streamable HTTP(默认 127.0.0.1:8000,端点 /mcp)
uv run sdn-mcp
uv run sdn-mcp --host 0.0.0.0 --port 9000

# stdio(Claude Desktop 等本地客户端)
uv run sdn-mcp --transport stdio
```

## 运行测试 client

另开一个终端,server 已启动:

```bash
uv run python scripts/test_client.py list-tools --url http://127.0.0.1:8000/mcp
uv run python scripts/test_client.py call-tool --url http://127.0.0.1:8000/mcp --name ping
uv run python scripts/test_client.py call-tool --url http://127.0.0.1:8000/mcp \
    --name ping --args-json '{"message": "hi"}'
uv run python scripts/test_client.py call-tool --url http://127.0.0.1:8000/mcp --name sdn_health
```

预期:`list-tools` 列出 `ping`、`sdn_health`;`ping` 返回 `pong: ...`;
`sdn_health`(未配置)返回 `{ok: false, configured: false}`。

## 项目结构

```
sdn-mcp-template/
├── app/                         # 主包
│   ├── server.py                #   FastMCP 工厂 + lifespan + CLI 入口
│   ├── settings.py              #   pydantic-settings(YAML + env 合并)
│   ├── common/http.py           #   通用 HTTP 客户端(retry/auth/方法封装)
│   ├── sdn/                     #   SDN 集成
│   │   ├── client.py            #     基于 HttpClient,错误映射为 SDNError
│   │   ├── models.py            #     Pydantic 响应模型
│   │   └── exceptions.py        #     SDNError 层级(安全 message)
│   └── tools/                   #   MCP 工具
│       ├── system.py            #     ping, sdn_health
│       └── sdn_tools.py         #     SDN 查询工具(接入处)
├── config/sdn_controller.yaml   # SDN 非敏感配置
├── scripts/test_client.py       # 测试 MCP client
├── tests/                       # 测试套件(覆盖率 ≥96%)
└── deploy/                      # Dockerfile + docker-compose
```

## 认证与 Token 自动刷新

`auth_type`(`config/sdn_controller.yaml`)决定鉴权策略。业务方法(`health` 与未来的
`get_devices` 等)统一走 `SDNClient.request` / `SDNClient._send`,**完全不感知 token 与刷新**:

| `auth_type` | 含义 | token 来源 | 首个 token 获取时机 | 收到 401 时 |
|---|---|---|---|---|
| `no-auth` | 不认证 | — | — | 直接报错 |
| `bearer` | 固定 api-key | `SDN_TOKEN`(静态) | 构造时 | 直接报错(不刷新) |
| `basic` | 账密换 token | 登录端点(POST 账密 body) | 启动时 `initialize()`(失败即启动失败) | 自动重新登录并重试一次 |

`basic` 模式说明(其余两种模式行为不变):

- 默认登录契约:向 `endpoints.login` **POST** JSON `{username, password, device_id}`(`device_id`
  为进程级 UUID;凭证走 body,**登录端点用 no-auth,不带任何鉴权头**),从响应 JSON 的
  `token_field`(默认 `access_token`)取出 bearer token;**数据请求改用该 bearer token**。
- token 过期(数据请求收到 401)时自动重新登录:`asyncio.Lock` + 代际计数器防并发击穿
  (N 个并发 401 只登录一次,即便新 token 与旧 token 字符串相同)、登录失败 5s 负缓存、
  **最多刷新一次**(再 401 立即报 `SDNAuthError`,永不死循环)。
- 登录用独立的 no-auth 客户端,**结构上不可能递归**(登录请求不带 bearer、不走刷新逻辑)。
- 自签名证书:`sdn.ssl_verify: false` 即可跳过 TLS 校验(透传给底层 httpx)。
- 非标准登录契约(不同 method / body / token 路径)只需重写 `SDNClient.get_token`,其余机制无需改动。
- 业务调用入口:`await client.request("POST", endpoint, json={...})` —— 自动带上/刷新 token,
  失败抛 `SDNError`(脱敏)。一个真实示例见 `scripts/test_sdn_live.py`。

## 新增一个 SDN 工具

接入真实控制器时,**只需 3 处改动,无需改 server/config**:

1. **`app/sdn/models.py`** —— 加响应模型:
   ```python
   class Device(BaseModel):
       id: str
       name: str
       kind: str | None = None
       status: str | None = None
   ```

2. **`app/sdn/client.py`** —— 加方法(基于 `settings.sdn.endpoints`)。所有业务方法统一走
   `self._send(...)`,鉴权与 token 刷新由框架处理,业务代码无感知:
   ```python
   async def get_devices(self) -> list[Device]:
       self._require_configured()
       endpoint = self._settings.sdn.endpoints["devices"]
       try:
           resp = await self._send("get", endpoint)
       except httpx.HTTPError as exc:
           raise _map_http_error(exc) from exc
       data = resp.json().get("devices", [])
       return [Device.model_validate(d) for d in data]
   ```

3. **`app/tools/sdn_tools.py`** —— 加工具(模式同 `sdn_health`):
   ```python
   from mcp.server.fastmcp import Context, FastMCP
   from app.sdn import SDNClient, SDNError

   def register(mcp: FastMCP) -> None:
       @mcp.tool(description="List all devices known to the SDN controller.")
       async def list_devices(ctx: Context) -> dict:
           sdn: SDNClient = ctx.request_context.lifespan_context["sdn_client"]
           try:
               return {"devices": [d.model_dump() for d in await sdn.get_devices()]}
           except SDNError as exc:
               await ctx.error(f"list_devices failed: {exc}")
               return {"devices": [], "error": str(exc)}
   ```

`register_all`(`app/tools/__init__.py`)已调用 `sdn_tools.register`,新工具定义后即生效。

## 测试

```bash
uv run pytest                     # 全套测试 + 覆盖率(≥80% 门槛)
uv run ruff check .               # lint
uv run mypy app                   # 类型检查
```

测试使用 MCP 的内存传输(`create_connected_server_and_client_session`),
无需真实 HTTP 或 SDN 控制器;HTTP/SDN 逻辑用 `httpx.MockTransport` 验证。

## Docker 部署

```bash
cp .env.example .env   # 填入 SDN 凭证
docker compose -f deploy/docker-compose.yml up --build
# 访问 http://localhost:8000/mcp
```

镜像为多阶段构建(uv 安装 → slim 运行镜像),secret 经环境变量注入、绝不烤进镜像,
`config/` 以只读卷挂载便于不改镜像调整配置。

## 安全要点

- **secret 隔离**:token/password 仅走 `.env`/环境变量(`SecretStr`),YAML 只存非敏感结构。
- **错误脱敏**:MCP 会把未捕获异常的 `str()` 当作 `isError` 文本回传模型(httpx 错误串含
  URL/状态码)。本模板在工具层捕获所有 `SDNError` 返回结构化 dict,且 `SDNError.__str__`
  只暴露安全 message,原始 detail 仅记录在服务端日志。
- **token/凭证不入 URL**:数据请求的 bearer token 走 HTTP header;basic 登录凭证走 JSON body。
  两者均不进 query/path,也不记录 request headers。

## 技术说明

本模板基于**已安装的 `mcp==1.28.1`(v1 `FastMCP` API)**。上游 `main` 分支已有 v2
预发布 API(`MCPServer`/`Client`),二者不兼容;升级 SDK 前请先核对 API 变更。

## 排错

**`uv run sdn-mcp` 报 `ModuleNotFoundError: No module named 'app'`**

uv 默认以 editable 方式安装本项目(写一个把项目根加入 `sys.path` 的 `.pth`)。在某些
Python 构建(如 conda 提供的)上 `site.py` 偶发不加载该 `.pth`,导致控制台脚本找不到包。
任意以下方式可恢复:

```bash
# 方式 1:重建 venv(最常见、最简单)
rm -rf .venv && uv venv && uv sync

# 方式 2:改用非 editable 安装(把 app/ 物理拷进 site-packages,最稳)
uv pip install .

# 方式 3:用模块入口(依赖 CWD 为项目根)
uv run python -m app.server
```

这不影响代码本身——`ruff`/`mypy`/`pytest` 都正常;只是该环境下的 editable `.pth` 加载问题。

TDQS

A3.6/5.0

Scored across 2 tools

Disambiguation5/5

ping and sdn_health have clearly distinct purposes: one is a generic echo, the other is a specific health check for the SDN controller. No overlap.

Naming Consistency2/5

The naming conventions are inconsistent: 'ping' is a single verb, while 'sdn_health' is a compound noun with underscore. No discernible pattern between the two tools.

Tool Count2/5

With only 2 tools, the server feels too minimal for an SDN-related MCP server. A template might justify this, but the implied scope requires more tools for practical use.

Completeness1/5

The tool surface is severely incomplete for any SDN-related workflow. Only a ping and a health check are provided, lacking any operational tools like listing devices or configuring the controller.

Maintenance

ActivitySlowing
ResponsivenessNo issues