手上一堆站点的日子,只有自己知道有多酸爽。我同时维护着六个不同类型的网站——技术博客、文档中心、读书笔记、数据统计、摄影作品集、导航收藏夹。平时处理这些小站还能靠肌肉记忆,但每次想让人工智能帮我干点正事,比如"把博客里近半年提到过Rust的文章整理一份清单"、"对比一下文档站和博客站最近更新节奏",就尴尬了——AI再聪明,它看不到我这些站的数据。我只能手动复制粘贴、导出、汇总,再喂给它。来回折腾一晚上,效率低到怀疑人生。
后来我把主意打到了 MCP 上。如果你还没听说过这个词,简单说,MCP(Model Context Protocol)是一套开放协议,专门用来给 AI 应用接外部数据和工具,相当于给只会聊天的大模型配上一个工具箱。我花了两天时间,把所有站点内容统一收敛到一个 MCP Server 里,现在不管是 Claude Desktop、Cursor 还是其他支持 MCP 的客户端,都能直接以自然语言去查询我这六个站的实时内容。这篇文章就从头到尾复盘一下我是怎么做的,包括整体思路、代码实现、数据接入方式和过程中踩到的一堆坑,希望给同样在折腾 MCP 的朋友一条可以照抄的路径。
1. 先说我为什么折腾:六个站的数据切分场景
很多人一上来就关心 MCP 的协议细节、SDK 用法,但我觉得先搞明白"你到底想解决什么问题"比什么都重要。我的问题非常具体:六个站,数据分散,格式各异,AI 却对它们一无所知。
1.1 六个站点的情况,比我预想的更乱
先列一下我手上的站,你们感受一下这种分裂感:
| 站点 | 类型 | 数据形态 | 存放位置 |
|---|---|---|---|
| 技术博客 | WordPress | MySQL 数据库 | 云服务器 |
| 文档中心 | VitePress 构建 | Markdown 源文件 | 服务器目录 |
| 读书笔记 | 自建小应用 | SQLite 数据库 | 服务器目录 |
| 数据统计站 | 内部 API 服务 | JSON 输出 | 独立服务端口 |
| 摄影作品集 | 静态站点 | 图片 + JSON 索引 | Nginx 目录 |
| 导航收藏夹 | 轻量应用 | JSON 数据文件 | 服务器目录 |
这六个站之前想让人工智能读取,几乎只能靠"导出再导入"的笨办法。比如博客站是 MySQL,我得先查数据库,再整理成 Markdown;文档站倒是源文件在服务器上,但文件一堆,AI 也没法一次性全读;摄影作品集没有数据库,纯靠 JSON 索引;导航站更直接,就是一个 JSON 文件每天手动更新。
还有一个要命的点:这些站之间没有任何关联。博客里的一篇文章,可能对应的知识点在文档中心也有一份说明,摄影站里的某张照片又在博客里被引用过。数据孤岛一个个矗在那儿,靠人力串成线太累了。
1.2 MCP 到底解决了什么,我为什么没有选别的方案
在动手之前,我也认真考虑过别的路子。
第一种是用 OpenAI Function Calling 或者直接调各家大模型 API,把每个站点的数据封装成函数给模型。这个方案可行,但它绑定特定平台,今天写的是 OpenAI 风格,明天换 Claude 又要重写一遍。
第二种是做一个统一的 HTTP API 服务,然后在对话里让模型去调这个 API。问题在于我需要把 API 的鉴权、文档、参数示例全部塞到上下文里,且不同客户端支持程度参差不齐,非常别扭。
第三种就是 MCP。它的核心思路是:用一套标准协议,把"AI 应用"和"数据/工具"解耦。我只需要写一个 MCP Server,暴露几个 Tool,然后各种支持 MCP 的客户端(Claude Desktop、Cursor、Trae、Cherry Studio)都能自动发现并调用这些工具,不需要我针对每个平台写集成代码。
打个比方:如果大模型是大脑,API 是胳膊腿,那 MCP 就是神经接口标准。以前每个平台都有自己的神经接法,搞得我写一套代码只能给一个平台用;现在大家都在往 MCP 这个标准上靠,我只要把这个"神经接口"造好,谁都能接。
所以我最后选了 MCP,理由就三条:标准统一、生态好、开发成本可控。后面所有工作,都围绕"写一个 MCP Server,把六站数据藏在这个 Server 后面"来做。
2. 六站糅进一个 Server:整体设计与选型思路
想一次性把六个站都纳入一个 MCP Server,设计阶段就要把"边界"想清楚,不然后面越写越乱。
2.1 先做减法:不可能一股脑全暴露
很多人第一次做 MCP 容易犯一个毛病:把所有功能都变成 Tool,搞出几十个工具,结果 AI 在选工具的时候反而犯迷糊,或者描述冲突,调用质量直线下降。
我当时给自己定了个原则:以"内容查询"和"状态查询"为核心。所有站点,只向 AI 暴露三种能力。第一是搜:在所有站里全局搜索某个关键词;第二是列:查看某个站最新发布的 N 条内容;第三是读:根据路径或者 ID 读取某条具体内容。这三个能力覆盖了我日常对话式问数据的绝大部分场景。
至于写操作、删除操作、配置修改,我全部砍掉。不是做不到,而是没必要。MCP 的定位是"让 AI 读取数据、辅助思考",不是让它去管理一个 CMS。
2.2 语言与框架选型:为什么选 Python 的 FastMCP
现在 MCP 的官方 SDK 有 Python 和 TypeScript 两套,我的选择是 Python,理由不复杂:我这些站点的数据处理脚本本来就有很多是 Python 写的,复用逻辑最方便。
在 Python 生态里,我推荐直接用 FastMCP 这个封装库。它基于官方 SDK,但把大量样板代码省掉了,定义 Tool、加载配置、启动服务都非常简洁。官方推荐的uv run方式也特别好用,不需要手动管理虚拟环境依赖。
一个非常核心的选型判断是:我的 MCP Server 需要跑在本地服务器上,和六个站同机部署,所以优先用 stdio 模式,而不是 HTTP/SSE 模式。stdio 模式对于 Claude Desktop、Cursor 这类本地客户端来说是最省心的,进程由客户端拉起,输出走标准输入输出,几乎没有网络层面的问题。SSE 模式适合跨机器部署,但那是进阶玩法,我后头会提一下。
2.3 工具箱设计:六个站怎么抽象成统一能力
下面这张表,就是我最终定下来的 Tool 清单和对应职责:
| 工具名 | 参数 | 返回内容 |
|---|---|---|
| search_all_sites | keyword | 在全部站点中检索标题和正文,返回命中列表 |
| get_recent_posts | site, limit | 返回指定站点最近内容列表 |
| get_page_content | site, path_or_id | 读取指定页面/文章的完整正文 |
| get_site_stats | site | 返回站点基础统计,如文章数、最近更新时间 |
| get_site_health | 无 | 检查六个站的连通状态和配置是否正常 |
这个工具设计有一个关键点:search_all_sites不是把六个数据源分别做成六个搜索工具,而是做成一个"全局搜索"。AI 只需要调一个工具,就能拿到所有站点的相关结果,大大降低了模型的决策负担。
而get_site_health是我后来才加的。因为六个站的数据源一旦有一个连不上,AI 返回的结果就会有误导性,所以我特意暴露了一个状态查询工具,让 AI 有能力检查"哪个站的数据现在是可用的"。这种做法在真实场景里很重要,你可以在自己的 Server 里借鉴。
3. 动手搭一个 MCP Server:代码级实操
设计搞定,接下来就是真正动手。这一部分我会按实操流程走一遍,包含完整的代码结构,以及怎么接到 Claude Desktop 和 Cursor 上。
3.1 环境准备:Python 环境与依赖安装
我的服务器是 Ubuntu 22.04,Python 版本 3.11。如果你用 Windows 或者 macOS,原理一样,命令稍微改一下就行。
第一步是安装uv。使用 Python 做 MCP 开发,我建议大家直接用 uv 管理环境和依赖,比手搓 venv + pip 顺滑得多:
curl -LsSf https://astral.sh/uv/install.sh | sh然后创建项目目录并初始化:
mkdir -p ~/mcp/six-sites-mcp cd ~/mcp/six-sites-mcp uv init --bare uv add "mcp[cli]" aiosqlite pymysql requests这里我一次装上几个关键依赖:
mcp[cli]:MCP 官方 Python SDK,加 CLI 扩展方便调试。aiosqlite:异步访问 SQLite,避免数据库查询阻塞事件循环。pymysql:访问 MySQL(博客站)。requests:调内部 API 服务。
另外我把httpx留着备用,虽然最终没用上,但如果你接外部 HTTP 接口,httpx是比requests更现代的选项。
3.2 主程序:一个 FastMCP Server 的核心骨架
项目里我建了一个server.py作为入口。先看骨架:
import json import os import sqlite3 import asyncio from datetime import datetime from pathlib import Path import aiosqlite import requests from mcp.server.fastmcp import FastMCP mcp = FastMCP( "six-sites-mcp", instructions="你有权访问多个站点的内容。" "站点标识: blog(技术博客), docs(文档中心), notes(读书笔记), " "stats(数据统计), gallery(摄影作品), links(导航收藏)。" "当用户询问最新的内容或搜索某个关键词时,优先调用对应工具。" ) SITES = { "blog": { "name": "技术博客", "type": "mysql", "config": { "host": "127.0.0.1", "port": 3306, "user": "mcp_reader", "password": os.getenv("BLOG_DB_PASSWORD", ""), "database": "blog_db", }, }, "docs": { "name": "文档中心", "type": "markdown", "base_path": "/srv/docs", }, "notes": { "name": "读书笔记", "type": "sqlite", "path": "/srv/data/notes.db", }, "stats": { "name": "数据统计", "type": "api", "base_url": "http://127.0.0.1:8800/api", }, "gallery": { "name": "摄影作品", "type": "json", "path": "/srv/data/gallery/index.json", }, "links": { "name": "导航收藏", "type": "json", "path": "/srv/data/links/links.json", }, }写到这里我顺便说一句:数据库密码不要硬编码进代码里。我最开始图省事写在配置里,结果一不小心把脚本同步到 Git 仓库,吓得我赶紧改密码。后来统一改成从环境变量里读,这才是安全的做法。
3.3 核心工具实现:全局搜索、最新列表、内容详情
接着写真正的 Tool 实现。FastMCP 的装饰器方式非常直观:
@mcp.tool() async def search_all_sites(keyword: str) -> str: """ 在技术博客、文档中心、读书笔记、摄影作品、导航收藏等所有站点内容中搜索关键词。 参数 keyword: 搜索关键词。 返回 JSON 数组,每个元素包含 site、title、url、snippet。 """ results = [] # 1. SQLite 笔记站 try: async with aiosqlite.connect(SITES["notes"]["path"]) as db: async with db.execute( "SELECT title, url, content FROM notes WHERE title LIKE ? OR content LIKE ? LIMIT 10", (f"%{keyword}%", f"%{keyword}%"), ) as cursor: async for row in cursor: title, url, content = row results.append({ "site": "notes", "title": title, "url": url, "snippet": content[:150], }) except Exception as exc: results.append({"site": "notes", "error": str(exc)}) # 2. Markdown 文档站 docs_root = Path(SITES["docs"]["base_path"]) if docs_root.exists(): for md in docs_root.rglob("*.md"): if keyword.lower() in md.stem.lower(): results.append({ "site": "docs", "title": md.stem, "url": f"/docs/{md.relative_to(docs_root)}", "snippet": "标题命中", }) else: # 简单读取前500字节,匹配关键词就收入结果 try: head = md.read_text(encoding="utf-8")[:500] if keyword.lower() in head.lower(): results.append({ "site": "docs", "title": md.stem, "url": f"/docs/{md.relative_to(docs_root)}", "snippet": head[:150], }) except Exception: continue # 3. JSON 文件站(摄影、导航) for site_key in ["gallery", "links"]: try: data = json.loads(Path(SITES[site_key]["path"]).read_text(encoding="utf-8")) for item in data: if keyword.lower() in json.dumps(item, ensure_ascii=False).lower(): results.append({ "site": site_key, "title": item.get("title", item.get("name", "")), "url": item.get("url", ""), "snippet": str(item)[:150], }) except Exception as exc: results.append({"site": site_key, "error": str(exc)}) # 4. 博客站(MySQL)使用独立函数查询,见下文 blog_results = await _search_blog(keyword) results.extend(blog_results) return json.dumps(results, ensure_ascii=False, indent=2)看到这里你应该能理解我的思路:每个数据源尽量独立 try-except,这样即使某一路数据源挂了,也不会整个 Tool 报错。
MySQL 的查询我单独抽了一个函数,因为要处理异步连接池,写起来相对复杂:
async def _search_blog(keyword: str): import pymysql cfg = SITES["blog"]["config"] try: conn = pymysql.connect( host=cfg["host"], port=cfg["port"], user=cfg["user"], password=cfg["password"], database=cfg["database"], charset="utf8mb4", cursorclass=pymysql.cursors.DictCursor, ) with conn.cursor() as cur: cur.execute( "SELECT post_title, guid, post_content FROM wp_posts " "WHERE post_status='publish' AND post_date <= NOW() " "AND (post_title LIKE %s OR post_content LIKE %s) " "ORDER BY post_date DESC LIMIT 10", (f"%{keyword}%", f"%{keyword}%"), ) rows = cur.fetchall() conn.close() results = [] for row in rows: results.append({ "site": "blog", "title": row["post_title"], "url": row["guid"], "snippet": row["post_content"][:150], }) return results except Exception as exc: return [{"site": "blog", "error": str(exc)}]注意我这里用的 MySQL 查询是纯同步的pymysql,放在 async 函数里会阻塞事件循环。在真实场景中,更优雅的方案是用aiomysql或者把数据库操作丢到线程池里。对我的场景来说,这个 Server 只有我自己用,并发极低,同步阻塞影响可以忽略。但如果你想部署给团队用,一定要换成真正的异步驱动。
3.4 其他工具:最近列表、内容详情、站点状态
搜索工具是最核心的,但只有它还不够。再补充三个工具:
@mcp.tool() async def get_recent_posts(site: str, limit: int = 10) -> str: """ 获取指定站点最近发布的内容列表。 参数 site: 站点标识,可选 blog/docs/notes/stats/gallery/links。 参数 limit: 返回条数,默认10,最大30。 """ limit = max(1, min(limit, 30)) result = [] if site == "notes": async with aiosqlite.connect(SITES["notes"]["path"]) as db: async with db.execute( "SELECT title, url, created_at FROM notes ORDER BY created_at DESC LIMIT ?", (limit,), ) as cursor: async for row in cursor: result.append({"title": row[0], "url": row[1], "date": row[2]}) elif site == "docs": docs_root = Path(SITES["docs"]["base_path"]) for md in sorted(docs_root.rglob("*.md"), key=lambda p: p.stat().st_mtime, reverse=True)[:limit]: result.append({"title": md.stem, "url": f"/docs/{md.relative_to(docs_root)}", "mtime": datetime.fromtimestamp(md.stat().st_mtime).isoformat()}) elif site == "blog": rows = await _search_blog("") # 空关键词也返回,但这里建议走单独函数 result = rows[:limit] # 其他站点类似,略 return json.dumps({"site": site, "items": result}, ensure_ascii=False, indent=2)说实话这里我留了一个偷懒的地方:博客站"最近列表"直接复用_search_blog(""),虽然能跑,但语义不太对,而且没有排序限制。如果按正式项目的标准,应该单独写一个_get_recent_blog(limit)方法。我把它当作一个待优化的点,后续会改。
get_page_content的实现也类似,就是根据 path 或 ID 从对应数据源读取完整内容,这里不贴全部代码了。重点要强调的是:返回给 AI 的内容一定要截断,单篇文章最长我限制在 5000 字。原因很简单,模型上下文窗口有限,塞一篇两万字的长文进去,让它自己找重点,既浪费 token 又影响质量。截断之后,如果 AI 觉得需要更多内容,它会继续调用工具来补充获取后续部分,这才是合理的交互方式。
最后是get_site_health:
@mcp.tool() async def get_site_health() -> str: """检查六个站点数据源是否可访问,返回各站点状态字典。""" status = {} for key, site in SITES.items(): try: if site["type"] == "sqlite": async with aiosqlite.connect(site["path"]) as db: await db.execute("SELECT 1") status[key] = "ok" elif site["type"] == "json": path = Path(site["path"]) status[key] = "ok" if path.exists() else "missing" elif site["type"] == "markdown": path = Path(site["base_path"]) status[key] = "ok" if path.exists() else "missing" elif site["type"] == "mysql": # 同步调用,真实场景可异步化 import pymysql conn = pymysql.connect(host=site["config"]["host"], port=site["config"]["port"], user=site["config"]["user"], password=site["config"]["password"], database=site["config"]["database"]) conn.close() status[key] = "ok" elif site["type"] == "api": resp = requests.get(site["base_url"], timeout=3) status[key] = "ok" if resp.status_code == 200 else f"http_{resp.status_code}" except Exception as exc: status[key] = f"error: {exc}" return json.dumps(status, ensure_ascii=False, indent=2)3.5 把 Server 接入 Claude Desktop 和 Cursor
Server 写完,本地可以用标准 MCP Inspector 来调试。在项目目录执行:
uv run mcp dev server.py它会起一个本地调试面板,我可以直接测试各个工具。等测试通过,再正式配置到客户端里。
Claude Desktop 的配置在claude_desktop_config.json,不同系统位置不一样,macOS 是在~/Library/Application Support/Claude/。配置如下:
{ "mcpServers": { "six-sites": { "command": "uv", "args": [ "run", "--directory", "/home/me/mcp/six-sites-mcp", "server.py" ] } } }然后重启 Claude Desktop,它就会自动拉起这个 MCP Server。
Cursor 的配置略有不同,项目根目录下建.cursor/mcp.json:
{ "mcpServers": { "six-sites": { "command": "uv", "args": [ "run", "--directory", "/home/me/mcp/six-sites-mcp", "server.py" ] } } }Cursor 的 MCP 功能在 Settings → Features 里能看到,连接成功后会列出所有工具。这里有个细节:Cursor 的 MCP 默认是 project-scoped,也就是每个项目单独配置。如果我想在多个项目里复用,可以把配置放到用户级 MCP 文件里,路径通常可以通过cursor open .cursor/mcp.json打开。
4. 六站数据接入:不同类型数据源怎么喂给AI
设计是骨架,数据接入是血肉。六个站的数据形态各不相同,我一个个说,每个都能提炼出通用经验。
4.1 数据库类:MySQL 和 SQLite
博客站是最重度的数据源,跑了七八年的 WordPress,表结构是标准的 wp_posts。接入 MCP 时,我坚持两个原则:只读账号、参数化查询。
只读账号这个很多人会忽略。我当时在 MySQL 里给 MCP 单独建了一个账号,只授予 SELECT 权限:
CREATE USER 'mcp_reader'@'127.0.0.1' IDENTIFIED BY 'your_password_here'; GRANT SELECT ON blog_db.* TO 'mcp_reader'@'127.0.0.1'; FLUSH PRIVILEGES;这样即使 MCP Server 被注入或者被人发现了连接信息,最坏情况也只是数据泄露,不至于把表给删了。MCP 这边的输入是自然语言解析出来的,虽然我控制不了模型每次都生成规规矩矩的 SQL,但我可以控制它的权限边界。
SQLite 这边就简单多了,文件数据库天然只读。注意一点:打开 SQLite 时,官方驱动默认会尝试建journal文件,如果目录没有写权限会报错。解决方法是在连接时设置mode=ro:
async with aiosqlite.connect(f"file:{path}?mode=ro", uri=True) as db:4.2 静态文件类:Markdown 和 JSON
文档中心是 VitePress 生成的,源文件是几百个 Markdown 文件。这里最大的挑战不是读取,而是性能。
我之前天真地想过"把所有 Markdown 文件读一遍,作为系统提示塞给 AI",试了一次,直接被干蒙了。一是 token 量爆炸,二是每次启动会话都慢得离谱。MCP 方案就好很多:文件不预加载,只有在被搜索或被指定读取时才实时访问。
实现上要注意文件路径的安全:get_page_content(site, path)的 path 参数不能直接和根目录拼接,否则 AI 或者恶意用户传一个../../etc/passwd就能读任意文件。我做了规范化处理:
def safe_join(root: Path, user_path: str) -> Path | None: candidate = (root / user_path).resolve() if not str(candidate).startswith(str(root.resolve())): return None return candidateJSON 文件类(摄影站、导航站)更简单,读文件、解析、按关键词过滤即可。但我额外做了一件事:为这两个站各自写了一个专门的get_gallery_photo(photo_id)和get_link_by_tag(tag)工具,因为纯靠全局搜索,AI 难以回答"导航站里有哪些 Python 相关链接"这类按分类筛选的问题。这说明一个道理:工具不是越少越好,而是该拆的拆、该合的合,要根据实际使用场景去增加工具。
4.3 API 型站点:数据统计服务
数据统计服务本身是一个 Flask 应用,跑在 8800 端口,暴露了一些 JSON 接口。接入 MCP 时,我做了三层防护:
第一是超时,所有对外 HTTP 请求必设 timeout,我统一用 5 秒。第二是异常兜底,接口挂了就返回错误信息,让 AI 知道"不是我不查,是数据源没响应"。第三是接口地址规范化,MCP Server 与统计服务在同一台机器,我直接用127.0.0.1,不走公网,既快又省心。
4.4 统一返回格式:为什么我坚持 JSON
MCP 的工具返回值可以是任意文本,但我坚持所有工具返回 JSON 字符串。原因有两点。
第一,JSON 本身有结构,AI 解析结构的速度比解析自由文本快,尤其是列表信息,用了 JSON 就不容易漏项。第二,调试方便。我在 MCP Inspector 里看工具返回值时,格式化的 JSON 一眼就能判断结果是正常还是异常。你可以在自己的实践中试一下短文本和 JSON 的表现差异,我个人实测下来结构化的输出让模型的回答准确率高出一截。
另外一个细节是,所有返回数据都尽量加上site字段,标明来源站点。这样 AI 在回答时就知道"这篇文章来自文档中心",而不是含糊地说"你的站点里有",用户体验完全不一样。
5. 接入客户端、跑通效果与性能优化
工具和服务都跑起来了,接下来就是实际使用效果的验证和调优阶段。
5.1 实际对话效果:AI 是怎么使用这些工具的
我举一个实际发生的例子。我在 Claude Desktop 里问:"最近三个月博客和文档中心都更新了哪些内容?"
人工智能收到问题后,会先调用get_site_health确认数据源正常,然后并行调用get_recent_posts(site='blog')和get_recent_posts(site='docs'),把两个结果汇总成对比表格给我。整个过程中,我没有写一行 SQL,没有打开网站后台,只是在对话框里用自然语言描述需求。
这个体验的改变是本质性的。以前我是"先想清楚数据在哪,再把数据搬到 AI 面前";现在是"我给 AI 一套工具箱,它自己知道去哪拿数据"。
5.2 性能瓶颈:缓存、并发与超时
跑了一周之后,我做了几项性能优化。
第一是给搜索结果加缓存。search_all_sites里对 Markdown 文件的关键词扫描是最耗时的操作。我加了一个简单的 TTL 缓存,同一个关键词 5 分钟之内不重复扫描文件系统:
_cache = {} _CACHE_TTL = 300 def cached_search(keyword: str): key = keyword.lower().strip() now = time.time() if key in _cache and now - _cache[key]["time"] < _CACHE_TTL: return _cache[key]["data"] # 原搜索逻辑... result = do_search(keyword) _cache[key] = {"time": now, "data": result} return result这个缓存对重复问题尤其有效。比如 AI 在处理一个长任务时,经常会多次调用同一个关键词搜索,有了缓存,直接命中,响应速度从几秒降到毫秒级。
第二是给所有外部调用设统一的超时阈值。数据库查询我默认 5 秒,HTTP 请求 5 秒,文件读取 3 秒。超时不是简单异常,而是要返回一个 AI 能看懂的说明,比如{"site": "stats", "error": "timeout after 5s"},否则 AI 可能会一本正经地编造结果。
第三是并行化改造。我的search_all_sites最初是串行扫描六个数据源,全部跑完要 3-4 秒。后来改成用asyncio.gather并行扫描,总耗时就降到 1 秒左右。在 MCP 场景里,工具响应越快,模型就越愿意多调几次工具,整体体验是正向循环。
5.3 权限与安全:MCP Server 部署的红线
虽然这个项目主要用于个人,我还是把安全规则总结出来,希望你能引以为戒:
- 数据库账号一律只读。
- Server 只监听本地,stdio 模式天然只对拉起它的客户端开放。
- 如果要用 SSH/HTTP 远程访问,请务必加认证,别裸奔公网。
- 所有外部依赖的密码、密钥,统一走环境变量,不进代码库。
- 工具返回内容做长度限制,避免恶意输入导致磁盘耗尽。
我在实际部署中只用了 stdio 模式,所以没有接触远程认证问题。但如果你要跨机器访问,MCP 官方文档里有关于 OAuth 的建议,这个后续可以单独展开讲。
5.4 其他支持 MCP 的客户端:我的适配情况
Claude Desktop 之后,我也顺手试了 Cursor、Cherry Studio 和 Trae。
Cursor 体验最出乎意料,它在 Agent 模式里对 MCP 工具的调用非常积极,写代码时如果需要了解项目文档站的内容,它会自己触发search_all_sites。Cherry Studio 是国产软件,对 MCP 的支持也在快速跟进,界面里就能直接看到工具列表和调用日志,调试很直观。
我个人的结论是:不要被单一客户端绑架。MCP 的核心价值就是"一次开发,全端通用"。你只要按标准写 Server,将来无论哪个客户端支持 MCP,你都能无缝切换。
6. 实测踩坑记录与排查速查表
最后这部分,我把实打实踩过的坑和排查思路整理一遍。很多问题官网文档不会写,只有真跑一遍才会遇到。
6.1 世界观级的问题:MCP Server 连不上
最典型的现象是 Claude Desktop 里看不到工具列表,或者对话时提示 MCP 连接失败。
排查思路是分层的。第一步先看日志。Claude Desktop 的日志在~/Library/Logs/Claude/mcp*.log,元凶多半是启动命令不对。第二步,用命令行手动执行:
uv run --directory /home/me/mcp/six-sites-mcp server.py如果执行立刻报错,说明是代码问题,把 Python 报错解决了就好了。如果执行后一直阻塞,说明 Server 在正常运行,问题就出在配置路径或权限上。
最坑的一点是:command字段如果写的是uv,而 Claude Desktop 启动时的 PATH 环境变量里没有uv,就会连不上。我之前就因为这个卡了半小时。解决办法是把uv替换为绝对路径,比如/home/me/.local/bin/uv,或者直接把command改成 Python 解释器的绝对路径。
6.2 工具调用报错:权限不足与磁盘路径
另一个高频问题是工具本身不可用。比如在get_page_content里读文件时遇到 PermissionError,大概率是运行 MCP Server 的系统用户没有权限访问站点目录。
你要记住:MCP Server 是客户端进程的子进程,它的权限继承自启动它的客户端。如果 Claude Desktop 是从图形界面启动的,它的用户权限一般就是你登录用户;但有些系统上服务型应用可能运行在不一样的身份下。排查方法很简单,在 Server 代码里加一个启动时的自检函数,把当前用户、目录、权限打印到日志里。
6.3 搜索速度慢:全站扫描不能每次都跑
这个我在前面提过 TTL 缓存了,这里再补充一个思路:为文档中心建立索引文件。
VitePress 有search插件,本身会生成索引。但我的场景是 MCP 直接读源文件,所以我自己写了一个简单脚本,把 Markdown 文件解析成"标题—路径—更新时间—摘要"的结构,存成docs_index.json,每天定时重建。MCP Server 搜索的时候直接扫这个 JSON 索引,速度比rglob快一个数量级。
如果你想复现,思路是:搜索请求 → 查索引内存副本 → 命中后读取完整 Markdown 文件返回给 AI。索引是薄层,完整内容按需读取,这个模式在静态文档场景下非常高效。
6.4 其他常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 工具返回全是空数组 | 数据库连接失败被异常兜底 | 查看 Server 日志中的 error 字段 |
| AI 不调用全局搜索,只调单站工具 | 工具描述不够清晰 | 优化工具描述,明确说明使用场景 |
| MCP Inspector 正常,Claude Desktop 连不上 | 路径或环境变量不一致 | 把 command 改成绝对路径并检查日志 |
| 查询结果太多,AI 整理不全 | 返回条数限制过大 | 默认 limit 限制在 10 以内 |
| 内存占用持续升高 | 大文件读取或缓存无限增长 | 为缓存加 TTL 和大小上限 |
| 用了异步库还是卡顿 | 可能存在同步阻塞混入 | 全面排查是否有requests等同步调用 |
| MySQL 中文乱码 | 连接字符集配置错误 | 连接参数加charset='utf8mb4' |
| MCP Server 被拉起多次 | 多个客户端共用同名 Server | 每个客户端独立配置,或加进程锁 |
还有一个实用技巧是给search_all_sites的结果加上去重。因为同一篇文章可能同时在博客站和文档中心存在,直接返回会让 AI 误以为有多份不同内容。我加了一个(title normalized + source site)的去重键,实测对回答质量提升明显。
6.5 我在实际操作中养成的三个习惯
到这里,核心内容基本讲完了,但还有三个我到现在都在用的习惯,值得单独写一下。
第一个习惯:每次修改 Server 代码,先用uv run mcp dev server.py快速验证一遍所有工具,再重启客户端。MCP 的工具定义一旦变了,旧客户端里的工具描述还是旧的,很容易出现"工具列表有,但调用报错"的假象。
第二个习惯:在 Server 的instructions里做站点描述。我后来发现,光靠工具描述还不够,把六个站点的类型、用途、数据时效、更新频率写清楚,AI 在回答时会做得更好。比如"导航收藏夹每天更新,但数据量少;摄影作品集每月更新,数据稳定",这些信息直接影响模型对结果的信任程度。
第三个习惯:保持 Server 代码版本化。我把它单独放了一个 Git 仓库,每次改完都提交,遇到问题可以快速回滚。MCP Server 看着小,但迭代速度其实很快,没有版本管理很容易搞成一团乱麻。
最后说一点个人感受
跑通这套 MCP 之后的第二天,我发现自己的使用习惯彻底变了。以前查站里有没有写过某个主题,第一反应是打开后台搜索;现在第一反应是打开聊天框,直接问"帮我查一下哪个站提到过某某关键词"。这个转变不是我刻意训练的,而是工具好用之后自然发生的。
如果你手上也有几个站、几个内容源,或者哪怕是几百个本地文档,MCP 都值得花点时间研究。从一个 Min Server 开始,只暴露一两个工具,跑通链路,再慢慢扩充,比一上来就规划十来个工具要顺得多。我认为这套"AI 可读的数据接入层"以后会是每个内容创作者和独立站长的基础设施之一,现在早点把基础打好,后面接什么工具都不慌。