MCP 的价值是「写一次,多个客户端都能用」。这个教程从零写一个可用的文档 Server,再写 Client 调它,最后用 Inspector 和测试验证。
最终效果
acme-mcp/
├─ server.py # MCP Server
├─ client.py # 最小 Client
├─ docs/ # 本地文档
├─ tests/test_server.py
└─ requirements.txt能力:
search_docs:检索文档;read_doc:按 ID 读全文;docs://{doc_id}:Resource;summarize_doc:Prompt 模板。
前置
pip install "mcp[cli]" pytestPython 3.10+。如果你用 TypeScript,官方 SDK 也有对应实现,概念完全一致。
Step 1:准备数据
docs/ 放几个 Markdown 文件:
docs/refund.md
docs/shipping.md
docs/warranty.mdserver.py 先加载它们:
from pathlib import Path
DOCS = {p.stem: p.read_text(encoding="utf-8") for p in Path("docs").glob("*.md")}Step 2:写 Server
from __future__ import annotations
from pathlib import Path
from mcp.server.fastmcp import FastMCP
DOCS = {p.stem: p.read_text(encoding="utf-8") for p in Path("docs").glob("*.md")}
mcp = FastMCP("acme-docs")
@mcp.tool()
def search_docs(query: str, limit: int = 5) -> list[dict]:
"""检索 ACME 内部文档。
何时使用:用户询问退款、配送、保修等政策,且需要引用原文。
何时不要使用:需要订单实时状态时,用订单系统工具,不要用文档猜。
Args:
query: 关键词,尽量简短,例如 "退款 时效"。
limit: 返回条数,1-10。
"""
q = query.lower().strip()
hits = []
for doc_id, text in DOCS.items():
if q in doc_id.lower() or q in text.lower():
hits.append({"id": doc_id, "title": doc_id, "excerpt": text[:300]})
return hits[: max(1, min(limit, 10))]
@mcp.tool()
def read_doc(doc_id: str) -> dict:
"""按 ID 读取文档全文。先用 search_docs 拿到 ID,不要猜。"""
if doc_id not in DOCS:
return {
"ok": False,
"error": {
"code": "DOC_NOT_FOUND",
"message": f"未知文档 {doc_id},可用:{list(DOCS)}",
"retryable": False,
},
}
return {"ok": True, "id": doc_id, "text": DOCS[doc_id]}
@mcp.resource("docs://{doc_id}")
def doc_resource(doc_id: str) -> str:
"""把文档暴露为可读取的 Resource。"""
if doc_id not in DOCS:
raise ValueError(f"未知文档:{doc_id}")
return DOCS[doc_id]
@mcp.prompt()
def summarize_doc(doc_id: str) -> str:
"""生成文档摘要的提示模板。"""
return f"""请阅读文档 {doc_id},输出:
1. 三条核心结论
2. 适用条件
3. 三个常见误解
每条不超过 40 字。"""
if __name__ == "__main__":
mcp.run() # 默认 stdio三个原语的区别:
| 原语 | 谁控制 | 用途 |
|---|---|---|
| Tool | 模型 | 执行动作,可能写数据 |
| Resource | 应用 | 只读数据,按 URI 取 |
| Prompt | 用户 | 可复用模板,用户主动选择 |
Step 3:本地调试
MCP Inspector 是最快的调试方式:
npx @modelcontextprotocol/inspector python server.py打开它给出的地址,可以:
- 看到
tools/list返回的 Schema; - 直接调用工具,填参数;
- 读取 Resource;
- 查看每次 JSON-RPC 请求与响应。
先在这里确认工具能被发现、参数正确、错误可读,再接客户端。
Step 4:写 Client
client.py:
from __future__ import annotations
import asyncio
import json
import sys
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
async def main(query: str) -> None:
params = StdioServerParameters(
command=sys.executable,
args=["server.py"],
)
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await session.list_tools()
print("工具:", [t.name for t in tools.tools])
result = await session.call_tool("search_docs", {"query": query})
for item in result.content:
if item.type == "text":
print(item.text)
result = await session.call_tool("read_doc", {"doc_id": "refund"})
for item in result.content:
if item.type == "text":
print(item.text[:500])
if __name__ == "__main__":
asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else "退款"))运行:
python client.py "退款"预期:
工具: ['search_docs', 'read_doc']
[{"id": "refund", "title": "refund", "excerpt": "# 退款政策..."}]
{"ok": true, "id": "refund", "text": "..."}Step 5:暴露为远程 HTTP 服务
本地工具用 stdio;团队共享或云端部署用 Streamable HTTP:
if __name__ == "__main__":
mcp.settings.host = "0.0.0.0"
mcp.settings.port = 8000
mcp.run(transport="streamable-http")启动:
python server.py
# MCP endpoint: http://localhost:8000/mcp远程服务必须加认证。不要在公网暴露一个可以读文件、跑命令的 Server。最小要求:
- OAuth 2.1 或短期 token;
- 每个用户独立 scope;
- 文件访问限制在固定目录;
- 所有调用写审计日志;
- 限制请求频率与返回大小。
Step 6:测试
tests/test_server.py:
import pytest
from server import DOCS, read_doc, search_docs
def test_search_returns_hits():
hits = search_docs("退款", limit=3)
assert hits
assert len(hits) <= 3
assert "id" in hits[0]
def test_read_unknown_doc_is_structured_error():
result = read_doc("nope")
assert result["ok"] is False
assert result["error"]["retryable"] is False
def test_docs_loaded():
assert "refund" in DOCS运行:
pytest -q再补一类「协议级」测试:启动 Client,list_tools() 是否包含预期工具,call_tool 返回是否符合 Schema。工具改名、参数变更时,这类测试会第一时间报警。
设计要点
工具描述要写「何时不用」
模型靠描述选工具。只写「检索文档」不够,要写清楚:
- 什么时候用;
- 什么时候应该用别的工具;
- 参数含义与单位;
- 返回结构;
- 是否有副作用。
返回要小
MCP 返回会进入模型上下文。read_doc 返回整篇 20 页文档,很容易撑爆。生产做法:
read_doc(doc_id, offset, limit)分页;- 返回
summary+text; - 超过阈值时标注
truncated: true; - 列表工具限制
limit。
错误要可操作
{
"ok": false,
"error": {
"code": "DOC_NOT_FOUND",
"message": "未知文档,可用:refund, shipping, warranty",
"retryable": false
}
}模型看到可用列表,就能换一个 ID;只返回「失败」它只能猜。
安全清单
- 只暴露必要工具,不暴露 shell/SQL
- 文件访问限制在 roots 目录
- 远程服务使用 OAuth,scope 最小
- 第三方 Server 锁定版本与哈希
- 记录工具调用审计
- 返回结果裁剪与分页
- 不把 MCP 当安全边界
恶意 Server 可以在工具描述里写「忽略之前的指令」。接入第三方 Server 前要审查描述,并限制它能看到的数据。
部署与运维
- stdio:随 Host 启动,适合本地工具;日志写 stderr,不要污染 stdout。
- HTTP:可多客户端共享;需要反向代理、TLS、认证、限流。
- 版本:Server 和 Client 都要固定版本;工具 Schema 变更视为破坏性变更。
- 监控:记录调用次数、错误率、P95 延迟、返回大小。
验收清单
- Inspector 能看到三个原语
- Client 能列出并调用工具
- Resource 能按 URI 读取
- 未知文档返回结构化错误
- pytest 通过
- HTTP 模式有认证
- 返回有分页/截断
扩展任务
- 加
create_ticket写工具,要求幂等键; - 把文档检索换成向量检索;
- 加 OAuth,按用户过滤可读文档;
- 用 TypeScript SDK 重写一遍,比较开发体验;
- 接进一个支持 MCP 的客户端,观察模型如何选择工具。
小结
- MCP 用统一协议把工具、资源、提示接入任意客户端。
- Server 负责能力与权限,Client 负责连接,Host 决定把什么放进模型上下文。
- 工具描述、返回大小、错误结构,直接决定模型能不能用对。
- 安全靠最小权限、认证、审计,协议本身不提供。
下一篇把多个 Agent 组织起来:主管、专家、交接契约与成本控制。