查看: 396|回复: 0

Python MCP协议开发本地知识便签服务完整实战

[复制链接]
发表于 2 小时前 | 显示全部楼层 |阅读模式
MCP(Model Context Protocol,模型上下文协议)正成为AI应用连接外部数据与工具的标准方式。对于Python开发者来说,理解并实现一个MCP Server并不复杂。本文将以一个本地知识便签服务为例,从零实现一个支持新增、搜索、删除、资源读取和提示模板的MCP Server,并用Python Client与MCP Inspector完成全链路验证。

一、MCP核心概念与架构

大模型本身只擅长处理输入内容,无法直接访问本地文件、数据库或内部API。传统做法是为每个数据源编写私有适配,导致重复集成。MCP在中间定义统一协议后,AI应用通过MCP Client连接MCP Server,Server以统一方式声明工具、资源和提示模板,Client可以动态发现并调用这些能力。

MCP采用Host–Client–Server三层架构。Host是承载用户交互与模型的AI应用,Client是Host内部负责与某个Server通信的协议客户端,Server对外提供Tools、Resources、Prompts等能力。一个Host可以同时连接多个Server,每个Server拥有独立Client,便于隔离权限和上下文。

MCP的三种核心能力需要区分:

- Tools:可被模型或Host调用的函数,执行动作,可能有副作用,如查询、写入、删除。
- Resources:可寻址的数据,由应用读取,通常只读,如文件内容、数据库记录。
- Prompts:参数化的交互模板,由用户或Host显式选择,用于封装最佳使用方式。

判断方法很简单:这是一个动词、一个可寻址名词,还是一个对话模板?

MCP与Function Calling、RAG、Agent解决的是不同层次的问题。Function Calling解决模型如何表达调用函数,RAG解决如何检索外部知识,Agent解决如何规划行动,而MCP解决的是AI应用如何发现、连接并调用外部能力。它们可以协作:Agent判断需要外部知识,模型通过Function Calling选择工具,MCP Client调用Server的检索Tool,Server执行检索并返回证据。

MCP主要有两种传输方式:

- stdio:本地Server,Client启动子进程,通过标准输入输出交换消息。适合文件、IDE等本地能力。注意:不要向stdout打印调试信息,stdout是协议通道,日志应输出到stderr。
- Streamable HTTP:远程Server,通过HTTPS提供MCP端点,需要处理认证、授权、限流、审计等网络风险。

本文使用stdio方式,依赖最少,最适合第一次跑通。

二、实战目标与环境准备

我们将实现一个notes-assistant Server,具备以下能力:

- Tools:add_note新增便签;search_notes按关键词搜索;delete_note删除便签(需显式确认)。
- Resources:notes://index读取便签索引;notes://{note_id}读取指定便签。
- Prompt:summarize_topic生成主题总结任务模板。

数据保存在本地JSON文件,无需数据库和API Key。

环境要求:Python 3.10+,推荐使用uv管理虚拟环境,也可使用pip。

创建项目:
  1. mkdir mcp-notes-demo
  2. cd mcp-notes-demo
  3. uv init
  4. uv add "mcp[cli]"
复制代码

若无uv:
  1. python -m venv .venv
  2. source .venv/bin/activate  # macOS/Linux
  3. pip install "mcp[cli]"
复制代码

最终目录结构:
  1. mcp-notes-demo/
  2. ├── data/
  3. ├── server.py
  4. ├── client.py
  5. └── pyproject.toml
复制代码

三、完整实现MCP Server

创建server.py,完整代码如下:
  1. from __future__ import annotations
  2. import json
  3. import logging
  4. import sys
  5. from datetime import datetime, timezone
  6. from pathlib import Path
  7. from threading import Lock
  8. from uuid import uuid4
  9. from mcp.server.fastmcp import FastMCP
  10. # stdio模式下stdout用于MCP协议,日志必须写到stderr
  11. logging.basicConfig(
  12.     stream=sys.stderr,
  13.     level=logging.INFO,
  14.     format="%(asctime)s %(levelname)s %(message)s",
  15. )
  16. logger = logging.getLogger("notes-mcp")
  17. mcp = FastMCP("notes-assistant")
  18. BASE_DIR = Path(__file__).resolve().parent
  19. DATA_DIR = BASE_DIR / "data"
  20. NOTES_FILE = DATA_DIR / "notes.json"
  21. FILE_LOCK = Lock()
  22. def ensure_storage() -> None:
  23.     DATA_DIR.mkdir(parents=True, exist_ok=True)
  24.     if not NOTES_FILE.exists():
  25.         NOTES_FILE.write_text("[]", encoding="utf-8")
  26. def load_notes() -> list[dict]:
  27.     ensure_storage()
  28.     with FILE_LOCK:
  29.         try:
  30.             content = NOTES_FILE.read_text(encoding="utf-8")
  31.             data = json.loads(content)
  32.         except (OSError, json.JSONDecodeError) as exc:
  33.             logger.exception("failed to read notes")
  34.             raise RuntimeError("便签存储暂时不可用") from exc
  35.     if not isinstance(data, list):
  36.         raise RuntimeError("便签文件格式错误")
  37.     return data
  38. def save_notes(notes: list[dict]) -> None:
  39.     ensure_storage()
  40.     temp_file = NOTES_FILE.with_suffix(".tmp")
  41.     payload = json.dumps(notes, ensure_ascii=False, indent=2)
  42.     with FILE_LOCK:
  43.         try:
  44.             temp_file.write_text(payload, encoding="utf-8")
  45.             temp_file.replace(NOTES_FILE)
  46.         except OSError as exc:
  47.             logger.exception("failed to save notes")
  48.             raise RuntimeError("便签保存失败") from exc
  49. @mcp.tool()
  50. def add_note(title: str, content: str, tags: list[str] | None = None) -> dict:
  51.     """新增一条本地便签。
  52.     Args:
  53.         title: 便签标题,不能为空,最长100个字符。
  54.         content: 便签正文,不能为空,最长5000个字符。
  55.         tags: 可选标签列表,每条最多20个字符。
  56.     """
  57.     title = title.strip()
  58.     content = content.strip()
  59.     normalized_tags = [tag.strip() for tag in (tags or []) if tag.strip()]
  60.     if not title or len(title) > 100:
  61.         raise ValueError("title 长度必须在 1 到 100 之间")
  62.     if not content or len(content) > 5000:
  63.         raise ValueError("content 长度必须在 1 到 5000 之间")
  64.     if len(normalized_tags) > 10:
  65.         raise ValueError("tags 最多包含 10 项")
  66.     if any(len(tag) > 20 for tag in normalized_tags):
  67.         raise ValueError("每个 tag 最长 20 个字符")
  68.     notes = load_notes()
  69.     note = {
  70.         "id": uuid4().hex[:12],
  71.         "title": title,
  72.         "content": content,
  73.         "tags": normalized_tags,
  74.         "created_at": datetime.now(timezone.utc).isoformat(),
  75.     }
  76.     notes.append(note)
  77.     save_notes(notes)
  78.     logger.info("created note id=%s", note["id"])
  79.     return note
  80. @mcp.tool()
  81. def search_notes(keyword: str = "", limit: int = 10) -> list[dict]:
  82.     """按标题、正文或标签搜索便签;keyword为空时返回最近便签。"""
  83.     keyword = keyword.strip().casefold()
  84.     if not 1 <= limit <= 50:
  85.         raise ValueError("limit 必须在 1 到 50 之间")
  86.     notes = load_notes()
  87.     notes.reverse()
  88.     if keyword:
  89.         notes = [
  90.             note
  91.             for note in notes
  92.             if keyword
  93.             in " ".join(
  94.                 [
  95.                     note.get("title", ""),
  96.                     note.get("content", ""),
  97.                     *note.get("tags", []),
  98.                 ]
  99.             ).casefold()
  100.         ]
  101.     # 搜索列表只返回摘要,完整内容通过Resource获取
  102.     return [
  103.         {
  104.             "id": note["id"],
  105.             "title": note["title"],
  106.             "preview": note["content"][:120],
  107.             "tags": note.get("tags", []),
  108.             "created_at": note["created_at"],
  109.         }
  110.         for note in notes[:limit]
  111.     ]
  112. @mcp.tool()
  113. def delete_note(note_id: str, confirm: bool = False) -> dict:
  114.     """删除指定便签。只有confirm=true时才会真正删除。"""
  115.     note_id = note_id.strip()
  116.     if not confirm:
  117.         return {
  118.             "deleted": False,
  119.             "message": "这是写操作,请确认note_id后以confirm=true再次调用。",
  120.         }
  121.     notes = load_notes()
  122.     remaining = [note for note in notes if note.get("id") != note_id]
  123.     if len(remaining) == len(notes):
  124.         return {"deleted": False, "message": "未找到对应便签"}
  125.     save_notes(remaining)
  126.     logger.info("deleted note id=%s", note_id)
  127.     return {"deleted": True, "id": note_id}
  128. @mcp.resource("notes://index")
  129. def get_notes_index() -> str:
  130.     """返回所有便签的Markdown索引。"""
  131.     notes = load_notes()
  132.     if not notes:
  133.         return "# 便签索引\n\n当前没有便签。"
  134.     lines = ["# 便签索引", ""]
  135.     for note in reversed(notes):
  136.         tags = ", ".join(note.get("tags", [])) or "无标签"
  137.         lines.append(
  138.             f'- [{note["title"]}](notes://{note["id"]}) — `{tags}` — {note["created_at"]}'
  139.         )
  140.     return "\n".join(lines)
  141. @mcp.resource("notes://{note_id}")
  142. def get_note(note_id: str) -> str:
  143.     """通过 notes://{note_id} 读取一条完整便签。"""
  144.     note = next(
  145.         (item for item in load_notes() if item.get("id") == note_id),
  146.         None,
  147.     )
  148.     if note is None:
  149.         raise ValueError(f"未找到便签:{note_id}")
  150.     tags = ", ".join(note.get("tags", [])) or "无标签"
  151.     return (
  152.         f'# {note["title"]}\n\n'
  153.         f'- ID:`{note["id"]}`\n'
  154.         f'- 标签:`{tags}`\n'
  155.         f'- 创建时间:{note["created_at"]}\n\n'
  156.         f'{note["content"]}'
  157.     )
  158. @mcp.prompt()
  159. def summarize_topic(topic: str, style: str = "要点式") -> str:
  160.     """生成一个基于本地便签总结指定主题的提示模板。"""
  161.     return f"""请总结本地便签中与“{topic}”有关的内容。
  162. 执行要求:
  163. 1. 先调用search_notes搜索“{topic}”;
  164. 2. 需要完整内容时读取对应的notes://{{note_id}} Resource;
  165. 3. 只根据便签中的内容总结,不要补充未出现的事实;
  166. 4. 如果没有相关便签,明确说明资料不足;
  167. 5. 使用“{style}”输出,并在结论后标注便签标题。
  168. """
  169. if __name__ == "__main__":
  170.     mcp.run(transport="stdio")
复制代码

代码关键点:

- FastMCP("notes-assistant") 创建Server。
- @mcp.tool() 从类型注解和Docstring自动生成工具定义。
- @mcp.resource() 声明固定Resource和动态Resource模板。
- @mcp.prompt() 暴露参数化Prompt。
- 所有外部参数都在Server端校验,不信任模型输入。
- 删除操作要求confirm=true,体现高风险动作的二次确认思想。
- 搜索只返回摘要,完整内容按需读取,避免上下文膨胀。

四、使用MCP Inspector调试Server

在项目目录执行:
  1. uv run mcp dev server.py
复制代码

如果使用pip环境,按当前SDK提供的CLI方式启动Inspector。浏览器打开终端显示的地址,完成以下检查:

1. 确认Server初始化成功;
2. 查看Tools列表,检查参数Schema和描述;
3. 调用add_note,参数示例:{"title":"MCP学习记录","content":"MCP Server可以通过Tools、Resources和Prompts暴露能力。","tags":["MCP","Python"]};
4. 调用search_notes,关键词填MCP;
5. 打开notes://index;
6. 使用新增便签的ID读取notes://{note_id};
7. 查看summarize_topic Prompt的展开结果;
8. 以confirm=false调用删除,观察返回信息。

如果Tools页面没有工具,优先检查装饰器是否在mcp.run()之前执行;如果Server直接退出,查看终端stderr日志。

五、编写Python MCP Client验证协议调用

创建client.py:
  1. import asyncio
  2. import sys
  3. from pathlib import Path
  4. from mcp import ClientSession, StdioServerParameters
  5. from mcp.client.stdio import stdio_client
  6. BASE_DIR = Path(__file__).resolve().parent
  7. async def main() -> None:
  8.     server = StdioServerParameters(
  9.         command=sys.executable,
  10.         args=[str(BASE_DIR / "server.py")],
  11.     )
  12.     async with stdio_client(server) as (read_stream, write_stream):
  13.         async with ClientSession(read_stream, write_stream) as session:
  14.             # 必须先初始化,完成版本和能力协商
  15.             await session.initialize()
  16.             tools = await session.list_tools()
  17.             print("可用工具:", [tool.name for tool in tools.tools])
  18.             created = await session.call_tool(
  19.                 "add_note",
  20.                 arguments={
  21.                     "title": "第一次调用MCP Tool",
  22.                     "content": "这条便签由Python MCP Client创建。",
  23.                     "tags": ["MCP", "实战"],
  24.                 },
  25.             )
  26.             print("新增结果:", created.content)
  27.             searched = await session.call_tool(
  28.                 "search_notes",
  29.                 arguments={"keyword": "MCP", "limit": 5},
  30.             )
  31.             print("搜索结果:", searched.content)
  32.             resources = await session.list_resources()
  33.             print("固定资源:", [str(item.uri) for item in resources.resources])
  34.             index = await session.read_resource("notes://index")
  35.             print("便签索引:", index.contents[0].text)
  36.             prompts = await session.list_prompts()
  37.             print("可用Prompt:", [item.name for item in prompts.prompts])
  38.             prompt = await session.get_prompt("summarize_topic", arguments={"topic": "MCP", "style": "要点式"})
  39.             print("Prompt内容:", prompt.description)
  40. if __name__ == "__main__":
  41.     asyncio.run(main())
复制代码

运行客户端:
  1. python client.py
复制代码

输出中可以看到工具列表、新增结果、搜索结果、资源URI和Prompt信息,证明整个MCP协议链路已经打通。

六、接入MCP客户端与工具设计要点

MCP Inspector和Python Client验证通过后,可以将Server接入支持MCP的AI客户端。配置时指定command为python、args为server.py路径即可。

设计高质量的MCP Tool需要注意:

- 名称要明确:如add_note、search_notes,不要用模糊动词。
- 描述要告诉模型“何时使用”:在Docstring中写清楚适用场景。
- 参数应该窄而具体:限制长度、枚举值、范围。
- 返回模型真正需要的信息:搜索返回摘要而非全文,避免上下文膨胀。
- 区分只读与写操作:写操作应要求确认或增加权限提示。

七、错误处理、安全与可观测性

错误处理方面,不要把Python堆栈直接扔给模型。应区分三类错误:

- 参数错误:ValueError,给出清晰的中文提示。
- 业务错误:如找不到便签,返回结构化结果。
- 系统错误:如文件读取失败,抛RuntimeError并记录日志。

安全方面,MCP接通的不只是数据,也是权限。需要注意:

- 最小权限:Server只访问必要路径,不开放无关文件。
- 服务端校验:所有参数必须在Server端校验,不能信任模型。
- 人工确认:删除、发送等高风险操作应要求二次确认。
- Prompt注入:模型可能被诱导,不要将不可信内容拼入系统指令。
- 远程Server:使用Streamable HTTP时需考虑鉴权、限流和审计。

日志方面,stdio模式下stdout是协议通道,日志只能写到stderr或文件。应记录关键操作:新增、删除的ID,错误堆栈,调用频率等。测试建议分层:先用Inspector人工测试,再用Client脚本自动化验证,最后接入AI客户端做端到端测试。

八、常见问题排查

1. Server一启动就退出:检查stderr日志,通常是依赖缺失或路径错误。
2. 客户端提示找不到命令:确认command参数使用sys.executable,避免使用虚拟环境外的python。
3. 日志导致协议解析失败:检查是否向stdout打印了普通print等信息,这是stdio模式最常见的坑。
4. Inspector能用但AI客户端不能用:检查AI客户端的MCP Server配置是否正确,是否支持当前SDK版本。
5. 工具可见但模型总选错:优化工具描述,明确触发条件,避免多个工具功能重叠。
6. 动态Resource没出现在固定资源列表:这是正常现象,动态Resource需通过模板模式访问,不会像固定Resource一样列出。

九、从示例升级到生产项目

当前示例使用了JSON文件存储,适合演示。生产环境可以分阶段升级:

- 第一阶段:替换存储为SQLite或PostgreSQL,保证并发与事务。
- 第二阶段:增加向量化语义检索,使search_notes支持相似度匹配。
- 第三阶段:部署为Streamable HTTP远程服务,增加认证与限流。
- 第四阶段:与Agent工作流集成,让模型自主规划并调用多个工具完成复杂任务。

十、什么时候适合使用MCP?

如果你有多个AI应用需要连接同一套本地数据或内部工具,或者希望以标准方式暴露能力,MCP是一个值得考虑的方案。它特别适合本地开发工具、文件管理、知识库、数据库查询等场景。如果只是单次调用的简单脚本,直接用Function Calling可能更轻量。

通过本文的实战,你已经能够从零实现一个可用的MCP Server,并用Python Client完整验证协议。后续可以在这个基础上扩展更多工具和资源,构建真正属于自己的MCP生态。
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-8-28 12:15 , Processed in 0.027730 second(s), 18 queries , Gzip On, Redis On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部