前两讲我们建立了 MCP 的理论基础:知道了 Transport、Tool、Resource 是什么,也看了一段最小 Server 的代码。这一讲我们要亲手写一个能用的 MCP Server------不只是一个 hello 示例,而是包含两个真实工具:query_database 和 read_file。
写完这一讲,你会拥有一个可以连接到任何 MCP Client 的工具服务器。
一、准备工作
1.1 确认 SDK 版本
pip install mcp --upgrade
python -c "import mcp; print(mcp.__version__)"
输出应为 1.x(如 1.2.0)。如果低于 1.0,请升级。
1.2 创建项目结构
mcp-server-demo/
├── server.py # MCP Server 主文件
├── demo.db # SQLite 数据库(自动生成)
└── sample.txt # 示例文件(手动创建)
创建 sample.txt:
这是一个示例文件。
MCP Server 可以通过 read_file 工具读取它。
第3讲:手写第一个 MCP Server。
二、搭建数据库
我们创建一个 SQLite 数据库,包含两张表,方便后面测试查询。
# setup_db.py
import sqlite3
def setup_database():
conn = sqlite3.connect("demo.db")
cursor = conn.cursor()
# 创建员工表
cursor.execute("""
CREATE TABLE IF NOT EXISTS employees (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
department TEXT NOT NULL,
salary REAL NOT NULL,
hire_date TEXT NOT NULL
)
""")
# 清空并插入数据
cursor.execute("DELETE FROM employees")
employees_data = [
(1, "张三", "技术部", 25000, "2022-03-15"),
(2, "李四", "市场部", 20000, "2023-01-10"),
(3, "王五", "技术部", 28000, "2021-07-01"),
(4, "赵六", "人事部", 22000, "2023-09-20"),
(5, "孙七", "技术部", 32000, "2020-05-12"),
(6, "周八", "市场部", 18000, "2024-02-28"),
]
cursor.executemany(
"INSERT INTO employees VALUES (?, ?, ?, ?, ?)",
employees_data
)
# 创建销售表
cursor.execute("""
CREATE TABLE IF NOT EXISTS sales (
id INTEGER PRIMARY KEY,
product TEXT NOT NULL,
amount REAL NOT NULL,
sale_date TEXT NOT NULL,
employee_id INTEGER,
FOREIGN KEY (employee_id) REFERENCES employees(id)
)
""")
cursor.execute("DELETE FROM sales")
sales_data = [
(1, "笔记本电脑", 8999, "2025-01-15", 1),
(2, "显示器", 2499, "2025-01-16", 3),
(3, "键盘", 599, "2025-02-01", 5),
(4, "笔记本电脑", 7999, "2025-02-10", 1),
(5, "鼠标", 299, "2025-02-15", 3),
(6, "显示器", 2199, "2025-03-01", 5),
(7, "笔记本电脑", 8999, "2025-03-10", 1),
(8, "键盘", 499, "2025-03-20", 3),
]
cursor.executemany(
"INSERT INTO sales VALUES (?, ?, ?, ?, ?)",
sales_data
)
conn.commit()
conn.close()
print("✅ 数据库初始化完成")
if __name__ == "__main__":
setup_database()
运行:
python setup_db.py
三、手写 MCP Server
现在我们来写核心的 server.py。
3.1 导入与初始化
import sqlite3
import os
import json
from mcp.server import Server
from mcp.server.stdio import stdio_server
import mcp.types as types
# 创建 Server 实例,名称会出现在 Client 的 Server 列表中
server = Server("demo-server")
3.2 声明工具列表
MCP Server 通过 list_tools 方法告诉 Client:"我有这些工具可用"。Client 拿到列表后,Agent 才能决定调哪个。
@server.list_tools()
async def handle_list_tools() -> list[types.Tool]:
"""
声明此 Server 提供的工具列表
Agent 会在启动时自动调用此方法获取可用工具
"""
return [
types.Tool(
name="query_database",
description="""执行只读 SQL 查询,返回 JSON 格式的结果。
适用场景:
- 查询员工信息、部门分布、薪资统计
- 查询销售记录、产品销售排行
- 任何 SELECT 查询
注意:仅支持 SELECT 语句,不支持 INSERT/UPDATE/DELETE。
返回格式:JSON 数组,每个元素是一条记录。
""",
inputSchema={
"type": "object",
"properties": {
"sql": {
"type": "string",
"description": "SQL 查询语句,例如:SELECT * FROM employees LIMIT 5"
}
},
"required": ["sql"]
}
),
types.Tool(
name="read_file",
description="""读取指定文件的内容。
适用场景:
- 读取配置文件、日志文件
- 读取代码文件
- 读取文本格式的文档
注意:仅能读取当前目录及其子目录下的文件。
二进制文件会返回 base64 编码。
""",
inputSchema={
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "文件路径,相对于当前工作目录"
}
},
"required": ["path"]
}
),
]
3.3 实现工具调用逻辑
当 Agent 决定调用某个工具时,call_tool 方法会被触发。我们需要根据 name 分发到对应的处理函数。
@server.call_tool()
async def handle_call_tool(
name: str,
arguments: dict
) -> list[types.TextContent]:
"""
处理工具调用请求
参数:
name: 工具名称(对应 list_tools 中声明的 name)
arguments: 参数字典(对应 inputSchema 中定义的参数)
返回:TextContent 列表,包含执行结果
"""
if name == "query_database":
return await _query_database(arguments)
elif name == "read_file":
return await _read_file(arguments)
else:
return [types.TextContent(
type="text",
text=f"未知工具:{name}"
)]
3.4 实现 query_database
async def _query_database(arguments: dict) -> list[types.TextContent]:
"""执行数据库查询"""
sql = arguments.get("sql", "")
# 安全检查:只允许 SELECT
sql_trimmed = sql.strip().upper()
if not sql_trimmed.startswith("SELECT"):
return [types.TextContent(
type="text",
text="错误:仅支持 SELECT 查询语句"
)]
# 安全检查:禁止危险操作
dangerous_keywords = ["DROP", "DELETE", "INSERT", "UPDATE", "ALTER", "CREATE", "EXEC"]
for kw in dangerous_keywords:
if kw in sql_trimmed:
return [types.TextContent(
type="text",
text=f"错误:查询中包含被禁止的关键字 '{kw}'"
)]
try:
conn = sqlite3.connect("demo.db")
conn.row_factory = sqlite3.Row # 让结果可以通过列名访问
cursor = conn.cursor()
cursor.execute(sql)
# 获取列名
columns = [desc[0] for desc in cursor.description]
rows = cursor.fetchall()
conn.close()
# 格式化为字典列表
result = []
for row in rows:
result.append(dict(zip(columns, row)))
return [types.TextContent(
type="text",
text=json.dumps(result, ensure_ascii=False, indent=2)
)]
except Exception as e:
return [types.TextContent(
type="text",
text=f"查询失败:{str(e)}"
)]
3.5 实现 read_file
async def _read_file(arguments: dict) -> list[types.TextContent]:
"""读取文件内容"""
path = arguments.get("path", "")
# 安全检查:防止路径穿越攻击
# 不允许包含 ".." 的路径
if ".." in path:
return [types.TextContent(
type="text",
text="错误:不允许访问上级目录"
)]
# 安全检查:只允许读取特定后缀的文件
allowed_extensions = (".txt", ".py", ".json", ".yaml", ".yml",
".md", ".csv", ".log", ".ini", ".cfg")
if not any(path.endswith(ext) for ext in allowed_extensions):
return [types.TextContent(
type="text",
text=f"错误:不支持读取该文件类型。支持的格式:{', '.join(allowed_extensions)}"
)]
try:
if not os.path.exists(path):
return [types.TextContent(
type="text",
text=f"错误:文件不存在:{path}"
)]
with open(path, "r", encoding="utf-8") as f:
content = f.read()
return [types.TextContent(
type="text",
text=content
)]
except Exception as e:
return [types.TextContent(
type="text",
text=f"读取失败:{str(e)}"
)]
3.6 启动入口
async def main():
"""启动 MCP Server(stdio 模式)"""
async with stdio_server() as (read_stream, write_stream):
await server.run(
read_stream,
write_stream,
server.create_initialization_options()
)
if __name__ == "__main__":
import asyncio
asyncio.run(main())
四、完整代码
将以上所有片段合并为 server.py,最终的完整文件如下:
import sqlite3
import os
import json
import asyncio
from mcp.server import Server
from mcp.server.stdio import stdio_server
import mcp.types as types
server = Server("demo-server")
@server.list_tools()
async def handle_list_tools() -> list[types.Tool]:
return [
types.Tool(
name="query_database",
description="""执行只读 SQL 查询,返回 JSON 格式的结果。
适用场景:
- 查询员工信息、部门分布、薪资统计
- 查询销售记录、产品销售排行
- 任何 SELECT 查询
注意:仅支持 SELECT 语句,不支持 INSERT/UPDATE/DELETE。
返回格式:JSON 数组,每个元素是一条记录。""",
inputSchema={
"type": "object",
"properties": {
"sql": {
"type": "string",
"description": "SQL 查询语句,例如:SELECT * FROM employees LIMIT 5"
}
},
"required": ["sql"]
}
),
types.Tool(
name="read_file",
description="""读取指定文件的内容。
适用场景:
- 读取配置文件、日志文件
- 读取代码文件
- 读取文本格式的文档
注意:仅能读取当前目录及其子目录下的文件。
支持格式:.txt .py .json .yaml .yml .md .csv .log""",
inputSchema={
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "文件路径,相对于当前工作目录"
}
},
"required": ["path"]
}
),
]
@server.call_tool()
async def handle_call_tool(name: str, arguments: dict) -> list[types.TextContent]:
if name == "query_database":
return await _query_database(arguments)
elif name == "read_file":
return await _read_file(arguments)
else:
return [types.TextContent(type="text", text=f"未知工具:{name}")]
async def _query_database(arguments: dict) -> list[types.TextContent]:
sql = arguments.get("sql", "")
sql_trimmed = sql.strip().upper()
if not sql_trimmed.startswith("SELECT"):
return [types.TextContent(type="text", text="错误:仅支持 SELECT 查询语句")]
dangerous_keywords = ["DROP", "DELETE", "INSERT", "UPDATE", "ALTER", "CREATE", "EXEC"]
for kw in dangerous_keywords:
if kw in sql_trimmed:
return [types.TextContent(type="text", text=f"错误:查询中包含被禁止的关键字 '{kw}'")]
try:
conn = sqlite3.connect("demo.db")
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute(sql)
columns = [desc[0] for desc in cursor.description]
rows = cursor.fetchall()
conn.close()
result = []
for row in rows:
result.append(dict(zip(columns, row)))
return [types.TextContent(
type="text",
text=json.dumps(result, ensure_ascii=False, indent=2)
)]
except Exception as e:
return [types.TextContent(type="text", text=f"查询失败:{str(e)}")]
async def _read_file(arguments: dict) -> list[types.TextContent]:
path = arguments.get("path", "")
if ".." in path:
return [types.TextContent(type="text", text="错误:不允许访问上级目录")]
allowed_extensions = (".txt", ".py", ".json", ".yaml", ".yml",
".md", ".csv", ".log", ".ini", ".cfg")
if not any(path.endswith(ext) for ext in allowed_extensions):
return [types.TextContent(
type="text",
text=f"错误:不支持读取该文件类型。支持的格式:{', '.join(allowed_extensions)}"
)]
try:
if not os.path.exists(path):
return [types.TextContent(type="text", text=f"错误:文件不存在:{path}")]
with open(path, "r", encoding="utf-8") as f:
content = f.read()
return [types.TextContent(type="text", text=content)]
except Exception as e:
return [types.TextContent(type="text", text=f"读取失败:{str(e)}")]
async def main():
async with stdio_server() as (read_stream, write_stream):
await server.run(
read_stream,
write_stream,
server.create_initialization_options()
)
if __name__ == "__main__":
asyncio.run(main())
五、测试 Server
5.1 启动 Server
python server.py
正常情况下不会有任何输出,终端会"卡住"------这是正常的,因为 Server 在等待 Client 连接。按 Ctrl+C 可以停止。
5.2 用 MCP Inspector 测试(官方调试工具)
MCP SDK 自带一个 Web 调试工具 mcp-inspector:
# 安装 inspector
npm install -g @modelcontextprotocol/inspector
# 启动 inspector 并连接你的 Server
npx @modelcontextprotocol/inspector python server.py
打开浏览器访问 http://localhost:5173,你会看到:
-
Server 自动连接成功
-
Tools 列表显示
query_database和read_file -
你可以点击工具,填入参数,点击"Call Tool"测试
5.3 手动测试(用 Python Client)
如果不想装 npm 工具,也可以用 Python 写一个简单的测试脚本:
# test_server.py
import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
async def test():
# 配置 Server 启动参数
server_params = StdioServerParameters(
command="python",
args=["server.py"]
)
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
# 初始化
await session.initialize()
# 获取工具列表
tools = await session.list_tools()
print(f"可用工具 ({len(tools.tools)} 个):")
for tool in tools.tools:
print(f" - {tool.name}: {tool.description[:50]}...")
# 测试 query_database
print("\n--- 测试 query_database ---")
result = await session.call_tool("query_database", {
"sql": "SELECT name, department, salary FROM employees ORDER BY salary DESC LIMIT 3"
})
print(result.content[0].text)
# 测试 read_file
print("\n--- 测试 read_file ---")
result = await session.call_tool("read_file", {
"path": "sample.txt"
})
print(result.content[0].text)
# 测试安全限制
print("\n--- 测试安全限制(应该被拒绝)---")
result = await session.call_tool("query_database", {
"sql": "DROP TABLE employees"
})
print(result.content[0].text)
if __name__ == "__main__":
asyncio.run(test())
运行:
python test_server.py
预期输出:
可用工具 (2 个):
- query_database: 执行只读 SQL 查询,返回 JSON 格式的结果...
- read_file: 读取指定文件的内容...
--- 测试 query_database ---
[
{
"name": "孙七",
"department": "技术部",
"salary": 32000
},
{
"name": "王五",
"department": "技术部",
"salary": 28000
},
{
"name": "张三",
"department": "技术部",
"salary": 25000
}
]
--- 测试 read_file ---
这是一个示例文件。
MCP Server 可以通过 read_file 工具读取它。
第3讲:手写第一个 MCP Server。
--- 测试安全限制(应该被拒绝)---
错误:查询中包含被禁止的关键字 'DROP'
六、常见错误 & 排坑
-
Server 启动后立即退出
-
原因:
asyncio.run(main())没有阻塞住 -
解决:确认使用了
async with stdio_server()而不是stdio_server()(少了async with)
-
-
Client 连接时报
Connection refused-
原因:Client 用 TCP 方式连 stdio Server
-
解决:stdio 模式不走网络,Client 必须也用
stdio_client启动子进程
-
-
工具调用返回空结果
-
原因:SQL 执行成功但没有返回数据
-
解决:先手动用 SQLite 客户端确认数据存在
-
-
中文乱码
-
原因:
ensure_ascii=True导致 Unicode 转义 -
解决:
json.dumps(data, ensure_ascii=False)
-
七、课后作业
-
扩展工具集 :给 Server 增加一个
get_table_schema(table_name)工具,返回指定表的字段名和类型。 -
增加文件类型支持 :在
read_file的允许列表中加入.html和.css。 -
挑战题 :实现一个
write_file工具(写入模式),但加上双重保护------只能在特定目录下写入,且每次写入前需要打印确认信息(模拟人工确认)。
八、总结
这一讲我们完成了:
-
搭建 SQLite 测试数据库:两张表,6 个员工,8 条销售记录
-
手写完整的 MCP Server :包含
query_database和read_file两个工具 -
安全防护:SQL 注入检测、路径穿越防护、文件类型白名单
-
两种测试方式:MCP Inspector(Web GUI)和 Python Client 脚本
你现在手上已经有了一个可以真实运行的 MCP Server。下一讲,我们将深入 MCP Client 的开发------不仅会调用工具,还会处理错误、管理多个 Server 的连接。
🧰 开发之余,处理 Base64、JWT 解析、JSON 格式化、Crontab 计算、PDF 合并压缩这些碎片需求,我常用一个纯前端本地工具箱:zz365.top (子页 PDF 大师:PDF 大师 - zz365工具箱)。所有计算在浏览器完成,文件不上传服务器,关页即清。免费、无登录、无广告,适合开发者当常驻标签页。