开发技巧

用 Python MCP SDK 构建本地只读 TODO/FIXME 扫描服务器

使用官方 MCP Python SDK 构建本地 stdio 服务器,提供只读 TODO/FIXME 查询和统计工具,并通过 SDK 客户端验证调用及配置检查。

BINBHO · 图文教程

名称变更:2026-10-21 我核对时,windsurf.com 跳转到 devin.ai/desktop,docs.windsurf.com 跳转到 docs.devin.ai/desktop,产品文档里叫 Devin Desktop。Cascade 智能体还在,但新标签页默认用的是 Devin Local 智能体;文档说 Workflows 和 Memories 只适用于 Cascade(旧版智能体),Devin Local 用 Skills。配置目录优先用 .devin/,旧的 .windsurf/ 仍会被读取。本文按当前文档写,界面细节以你安装的版本为准。

MCP(Model Context Protocol)让编辑器里的 AI 调用外部工具。这篇用官方 MCP Python SDK 写一个本地 stdio 服务器 todo_server.py,提供两个只读工具:列出项目里的 TODO/FIXME、按文件统计数量;用官方 SDK 的客户端真实连上去调用一遍。再写 check_mcp_config.py,按文档的插值规则检查 mcp_config.json,找出明文密钥、未设置的环境变量和格式错误。

先看清成品

  • 用官方 SDK(mcp 2.3.0)的 stdio 客户端真实连接服务器:列出 2 个工具,3 次调用结果正确,node_modules 里的 TODO 被排除。
  • 遇到 3 个真实问题:SDK 2.x 里 FastMCP 改名;工具里抛 ValueError 时客户端只看到笼统的错误;返回 dict 没有结构化输出。都已修复。
  • 配置检查在示例上真实运行,发现 2 个错误、3 条提醒;5 项 pytest 通过。

合规提醒:用你自己的账号和授权的套餐,不共享账号。规则、工作流和 MCP 配置会把指令和工具交给 AI,不要写进密钥;公司代码先确认组织政策是否允许使用。AI 运行命令前要看清楚,不要开启“自动执行所有命令”。MCP 服务器能以你的身份访问文件和服务,只装可信来源的服务器;本文的服务器只读、不联网。

运行说明:box 是 Linux 服务器,没有图形界面,我没有安装或运行 Devin Desktop / Windsurf 编辑器,所以没有验证编辑器实际加载这些文件的效果。真实运行过的是:本文的检查脚本、示例项目和 MCP 服务器(2026-10-21,UTC+8)。

做完之后你会得到什么?

成果 说明
todo_server.py MCP 服务器
client_check.py 用官方客户端测试
sample/ 示例项目
mcp_config.example.json 配置示例
check_mcp_config.py 配置检查
bad_config.json 故意写错的配置
test_89c.py 5 项测试

需要准备哪些环境?

项目 说明
编辑器 Devin Desktop(原 Windsurf),从 https://windsurf.com 下载
Python 3.11 以上(用到 tomllib),pip install pytest
MCP SDK pip install "mcp>=2,<3"(我用的是 2.3.0)

关键事实

事实 来源
Cascade 的 MCP 配置文件:macOS/Linux ~/.config/devin/mcp_config.json(设置了 XDG_CONFIG_HOME 时在其下),Windows %APPDATA%\devin\mcp_config.json 官方 MCP
支持 stdio、Streamable HTTP、SSE;远程服务器用 serverUrl 或 url,可加 headers 同上
Cascade 同时最多使用 100 个工具;用 disabledTools 数组禁用单个工具 同上
command、args、env、serverUrl、url、headers 支持 ${env:变量}(未设置时为空字符串)和 ${file:路径}(读不到时原样保留) 同上
MCP Python SDK 2.x:FastMCP 改名为 MCPServer(from mcp.server.mcpserver import MCPServer) SDK v2 迁移指南

文件结构

89-windsurf-C-src/
├── todo_server.py
├── client_check.py
├── sample/
│   ├── src/app.py
│   ├── docs/README.md
│   └── node_modules/x/index.js   # 应被忽略
├── mcp_config.example.json
├── check_mcp_config.py
├── bad_config.json
└── test_89c.py

第 1 步:MCP 服务器

"""一个本地 stdio MCP 服务器:让 Cascade 能查询和统计项目里的 TODO / FIXME 注释。
只读,只扫描启动时指定的项目目录(TODO_ROOT),不访问网络,不写文件。
用官方 MCP Python SDK 2.x 编写(2.x 里 FastMCP 改名为 MCPServer)。"""
import os, re
from pathlib import Path
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.exceptions import ToolError

ROOT = Path(os.environ.get("TODO_ROOT", ".")).resolve()
SKIP = {".git", "node_modules", ".venv", "__pycache__", "dist", "build"}
EXTS = {".py", ".js", ".ts", ".tsx", ".go", ".rs", ".java", ".md"}
PAT = re.compile(r"\b(TODO|FIXME)\b[::]?\s*(.*)")

mcp = MCPServer("todo-scanner", instructions="查询项目中的 TODO/FIXME 注释。只读。", version="0.1.0")


def scan(root=None):
    root = Path(root or ROOT); items = []
    for p in root.rglob("*"):
        if any(part in SKIP for part in p.relative_to(root).parts) or not p.is_file() or p.suffix not in EXTS:
            continue
        try:
            lines = p.read_text(encoding="utf-8").splitlines()
        except (UnicodeDecodeError, OSError):
            continue
        for n, line in enumerate(lines, 1):
            m = PAT.search(line)
            if m:
                items.append({"file": p.relative_to(root).as_posix(), "line": n, "kind": m.group(1), "text": m.group(2).strip()})
    return sorted(items, key=lambda x: (x["file"], x["line"]))


@mcp.tool()
def list_todos(kind: str = "ALL", path_prefix: str = "") -> list[dict]:
    """列出 TODO/FIXME。kind 取 TODO、FIXME 或 ALL;path_prefix 只看某个目录,例如 "src/"。"""
    kind = kind.upper()
    if kind not in ("ALL", "TODO", "FIXME"):
        # 用 ToolError:客户端(和模型)能看到这句话;普通 ValueError 只会变成笼统的 "Error executing tool"(实测)
        raise ToolError("kind 只能是 TODO、FIXME 或 ALL")
    return [i for i in scan() if (kind == "ALL" or i["kind"] == kind) and i["file"].startswith(path_prefix)]


@mcp.tool()
def todo_stats() -> dict[str, dict[str, int]]:
    """按文件统计 TODO/FIXME 数量。"""
    stats = {}
    for i in scan():
        s = stats.setdefault(i["file"], {"TODO": 0, "FIXME": 0}); s[i["kind"]] += 1
    return stats


if __name__ == "__main__":
    mcp.run()          # 默认 stdio

第 2 步:测试客户端

"""用官方 SDK 的客户端通过 stdio 启动服务器,列出工具并真实调用——相当于编辑器连接 MCP 服务器时做的事。"""
import asyncio, json, os, sys
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client


async def main(root="sample"):
    params = StdioServerParameters(command=sys.executable, args=["todo_server.py"],
                                   env={**os.environ, "TODO_ROOT": root})
    async with stdio_client(params) as (r, w), ClientSession(r, w) as s:
        init = await s.initialize()
        print("服务器:", init.server_info.name, init.server_info.version)
        tools = await s.list_tools()
        for t in tools.tools:
            print("工具:", t.name, "—", (t.description or "").splitlines()[0])
        for name, args in (("todo_stats", {}), ("list_todos", {"kind": "FIXME"}), ("list_todos", {"kind": "bug"})):
            res = await s.call_tool(name, args)
            body = res.structured_content if res.structured_content is not None else [c.text for c in res.content]
            print(f"调用 {name}({args}) is_error={res.is_error}:", json.dumps(body, ensure_ascii=False))
        return tools


if __name__ == "__main__":
    asyncio.run(main(*sys.argv[1:]))

第 3 步:配置示例

{
  "mcpServers": {
    "todo-scanner": {
      "command": "python3",
      "args": ["${env:HOME}/tools/todo-mcp/todo_server.py"],
      "env": { "TODO_ROOT": "${env:HOME}/code/my-project" }
    },
    "remote-example": {
      "serverUrl": "https://mcp.example.com/mcp",
      "headers": { "Authorization": "Bearer ${file:~/.secrets/example_token.txt}" },
      "disabledTools": ["delete_item"]
    }
  }
}

第 4 步:配置检查

"""检查 Devin Desktop(原 Windsurf)的 mcp_config.json。依据 docs.devin.ai/desktop/cascade/mcp:
- 位置:macOS/Linux ~/.config/devin/mcp_config.json(设置了 XDG_CONFIG_HOME 时在其下),Windows %APPDATA%\\devin\\mcp_config.json;
- 每个服务器在 mcpServers 下:本地用 command/args/env,远程 HTTP 用 serverUrl 或 url(可带 headers);
- disabledTools 列出要禁用的工具;Cascade 同时最多可用 100 个工具;
- command、args、env、serverUrl、url、headers 支持 ${env:VAR}(未设置→空字符串)和 ${file:路径}(读不到→原样保留)。
本脚本按上面的规则模拟插值,找出“插值后为空”“文件读不到”“明文密钥”等问题。不会启动任何服务器。"""
import json, os, re, sys
from pathlib import Path

FIELDS = ("command", "args", "env", "serverUrl", "url", "headers")
VAR = re.compile(r"\$\{(env|file):([^}]+)\}")
SECRET_KEY = re.compile(r"(token|secret|key|password|authorization)", re.I)


def interpolate(s, environ, problems, where):
    def rep(m):
        kind, arg = m.groups()
        if kind == "env":
            v = environ.get(arg)
            if v is None: problems.append(f"{where}: 环境变量 {arg} 未设置,会被替换为空字符串")
            return v or ""
        p = Path(os.path.expanduser(arg))
        try:
            return p.read_text(encoding="utf-8").strip()
        except OSError:
            problems.append(f"{where}: 读不到文件 {arg},按文档会原样保留 ${{file:...}}")
            return m.group(0)
    return VAR.sub(rep, s)


def walk(v, fn, where):
    if isinstance(v, str): return fn(v, where)
    if isinstance(v, list): return [walk(x, fn, f"{where}[{i}]") for i, x in enumerate(v)]
    if isinstance(v, dict): return {k: walk(x, fn, f"{where}.{k}") for k, x in v.items()}
    return v


def check(cfg, environ=None):
    environ = os.environ if environ is None else environ
    errs, warns, resolved = [], [], {}
    servers = cfg.get("mcpServers")
    if not isinstance(servers, dict) or not servers:
        return ["缺少 mcpServers 或为空"], warns, resolved
    for name, s in servers.items():
        local, remote = "command" in s, ("serverUrl" in s or "url" in s)
        if local == remote:
            errs.append(f"{name}: 必须二选一——本地写 command,远程写 serverUrl/url"); continue
        if remote:
            u = s.get("serverUrl") or s.get("url")
            if not u.startswith("https://") and not u.startswith("http://localhost") and not u.startswith("http://127.0.0.1"):
                warns.append(f"{name}: 远程地址不是 https")
        for sect in ("env", "headers"):
            for k, v in (s.get(sect) or {}).items():
                if SECRET_KEY.search(k) and isinstance(v, str) and not VAR.search(v) and len(v) >= 8:
                    warns.append(f"{name}.{sect}.{k}: 看起来是明文密钥,建议改成 ${{env:…}} 或 ${{file:…}}")
        if "disabledTools" in s and not isinstance(s["disabledTools"], list):
            errs.append(f"{name}: disabledTools 应该是数组")
        probs = []
        r = {k: (walk(v, lambda x, w: interpolate(x, environ, probs, w), f"{name}.{k}") if k in FIELDS else v) for k, v in s.items()}
        warns += probs; resolved[name] = r
    return errs, warns, resolved


def main(argv=None):
    argv = argv if argv is not None else sys.argv[1:]
    path = argv[0] if argv else os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "devin", "mcp_config.json")
    try:
        cfg = json.loads(Path(path).read_text(encoding="utf-8"))
    except json.JSONDecodeError as e:
        print(f"✗ {path} 不是合法 JSON:第 {e.lineno} 行 {e.msg}"); return 1
    errs, warns, resolved = check(cfg)
    for e in errs: print("✗", e)
    for w in warns: print("!", w)
    for n, r in resolved.items():
        shown = {k: ("***" if k in ("headers", "env") else v) for k, v in r.items()}
        print(f"  {n}: {json.dumps(shown, ensure_ascii=False)}")
    print(f"{len(resolved)} 个服务器,{len(errs)} 个错误,{len(warns)} 个提醒")
    return 1 if errs else 0


if __name__ == "__main__":
    sys.exit(main())

第 5 步:测试

import asyncio, json, os
import check_mcp_config as C

def test_scan_skips_node_modules():
    os.environ["TODO_ROOT"] = "sample"
    import todo_server
    files = {i["file"] for i in todo_server.scan("sample")}
    assert files == {"src/app.py", "docs/README.md"}

def test_stdio_roundtrip():
    import client_check
    tools = asyncio.run(client_check.main("sample"))
    assert {t.name for t in tools.tools} == {"list_todos", "todo_stats"}

def test_interpolation_rules(tmp_path):
    f = tmp_path / "k.txt"; f.write_text("  abc \n")
    cfg = {"mcpServers": {"s": {"command": "node", "args": ["${env:X}/a", "${file:" + str(f) + "}", "${file:/nope}"], "env": {"K": "${env:MISSING}"}}}}
    errs, warns, r = C.check(cfg, environ={"X": "/opt"})
    assert errs == [] and r["s"]["args"] == ["/opt/a", "abc", "${file:/nope}"] and r["s"]["env"]["K"] == ""
    assert any("MISSING" in w for w in warns) and any("/nope" in w for w in warns)

def test_bad_config():
    errs, warns, _ = C.check(json.load(open("bad_config.json")), environ={})
    assert len(errs) == 2 and len(warns) == 3

def test_trailing_comma_reported(tmp_path, capsys):
    p = tmp_path / "c.json"; p.write_text('{"mcpServers": {"a": {"command": "x"},}}')
    assert C.main([str(p)]) == 1 and "不是合法 JSON" in capsys.readouterr().out

第 6 步:运行

# 2026-10-21 22:02 UTC+8
$ python client_check.py sample
服务器: todo-scanner 0.1.0
工具: list_todos — 列出 TODO/FIXME。kind 取 TODO、FIXME 或 ALL;path_prefix 只看某个目录,例如 "src/"。
工具: todo_stats — 按文件统计 TODO/FIXME 数量。
调用 todo_stats({}) is_error=False: {"docs/README.md": {"TODO": 1, "FIXME": 0}, "src/app.py": {"TODO": 1, "FIXME": 1}}
调用 list_todos({'kind': 'FIXME'}) is_error=False: {"result": [{"file": "src/app.py", "line": 7, "kind": "FIXME", "text": "没有处理磁盘已满的情况"}]}
调用 list_todos({'kind': 'bug'}) is_error=True: ["Error executing tool list_todos: kind 只能是 TODO、FIXME 或 ALL"]
(第一行是服务器写到 stderr 的日志)

# 22:03 UTC+8
$ python check_mcp_config.py mcp_config.example.json
! remote-example.headers.Authorization: 读不到文件 ~/.secrets/example_token.txt,按文档会原样保留 ${file:...}
  todo-scanner: {"command": "python3", "args": ["/home/box/tools/todo-mcp/todo_server.py"], "env": "***"}
  remote-example: {"serverUrl": "https://mcp.example.com/mcp", "headers": "***", "disabledTools": ["delete_item"]}
2 个服务器,0 个错误,1 个提醒
exit 0
$ python check_mcp_config.py bad_config.json
✗ both: 必须二选一——本地写 command,远程写 serverUrl/url
✗ plain-http: disabledTools 应该是数组
! github.env.GITHUB_PERSONAL_ACCESS_TOKEN: 看起来是明文密钥,建议改成 ${env:…} 或 ${file:…}
! plain-http: 远程地址不是 https
! needs-var.env.API_KEY: 环境变量 MY_UNSET_API_KEY_DEMO 未设置,会被替换为空字符串
  github: {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"], "env": "***"}
  plain-http: {"url": "http://mcp.example.com/mcp", "disabledTools": "delete_item"}
  needs-var: {"command": "node", "args": ["server.js"], "env": "***"}
3 个服务器,2 个错误,3 个提醒
exit 1
$ python -m pytest -q
5 passed

怎么检查结果?

  • 把 mcp_config.example.json 里的 todo-scanner 部分合并进你的 ~/.config/devin/mcp_config.json,改好路径,在 Cascade 的 MCPs 列表里应能看到它和 2 个工具。
  • 问 AI “这个项目还有哪些 FIXME?”,它应该调用 list_todos。
  • 配置检查只模拟插值规则,不启动服务器;服务器能否启动用 client_check.py 验证。

调试示例

1. 照旧教程写的 import 直接报错。 很多教程(包括 SDK 1.x 文档)写的是 from mcp.server.fastmcp import FastMCP。在 mcp 2.3.0 上:

ModuleNotFoundError: No module named 'mcp.server.fastmcp'. This is mcp 2.x, where FastMCP was renamed to MCPServer (from mcp.server.mcpserver import MCPServer) and other APIs changed; see the migration guide …

改成 MCPServer。同时注意 2.x 的字段名改为 snake_case:客户端里是 server_info、structured_content、is_error。如果要继续用旧代码,迁移指南建议固定 mcp<2。

2. 错误信息在客户端看不到。 第一版对非法的 kind 抛 ValueError,客户端收到的只有:

调用 list_todos({'kind': 'bug'}) is_error=True: ["Error executing tool list_todos"]

我写的提示“kind 只能是 TODO、FIXME 或 ALL”丢了,服务器 stderr 里是一段 UnexpectedToolError 的调用栈。模型看不到原因就没法自己改参数。改成抛 SDK 的 ToolError 后,客户端收到 Error executing tool list_todos: kind 只能是 TODO、FIXME 或 ALL。

3. 返回 dict 时没有结构化结果。 todo_stats 一开始注解为 -> dict,客户端的 structured_content 为空,只能拿到一段带换行的 JSON 文本。改成 -> dict[str, dict[str, int]] 后有了结构化结果。返回类型写具体,客户端和模型都更好用。

4. JSON 不允许尾逗号。 我写 bad_config.json 时,最后一个服务器后面多了一个逗号,检查脚本报“第 10 行 Illegal trailing comma”。编辑器读配置失败时不一定有明显提示,所以先用脚本检查一遍。

常见问题

现象 原因 处理
No module named mcp.server.fastmcp SDK 2.x 改名 用 MCPServer,或固定 mcp<2
工具出错原因看不到 抛了普通异常 抛 ToolError
服务器没出现在列表 配置文件位置或 JSON 格式错 检查路径;用 check_mcp_config.py
密钥是空的 ${env:} 变量没在编辑器进程的环境里 在启动编辑器的环境中设置,或用 ${file:}

模板规则

  • MCP 服务器默认只读、最小权限。
  • 密钥用 ${env:} 或 ${file:},不写明文。
  • 给 AI 看得懂的错误信息。
  • 用官方客户端先测通,再接入编辑器。

项目资源

用途 地址
Devin Desktop 文档(原 docs.windsurf.com) https://docs.windsurf.com
MCP(官方 Devin Desktop 文档) https://docs.devin.ai/desktop/cascade/mcp
MCP Python SDK https://github.com/modelcontextprotocol/python-sdk
MCP Python SDK v2 迁移指南 https://py.sdk.modelcontextprotocol.io/v2/migration/
Model Context Protocol https://modelcontextprotocol.io/

一个只读、错误信息清楚的小工具,比一个什么都能做的大工具更安全好用。