名称变更: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/ |
一个只读、错误信息清楚的小工具,比一个什么都能做的大工具更安全好用。