第3讲:手写第一个 MCP Server(Python SDK)

前两讲我们建立了 MCP 的理论基础:知道了 Transport、Tool、Resource 是什么,也看了一段最小 Server 的代码。这一讲我们要亲手写一个能用的 MCP Server------不只是一个 hello 示例,而是包含两个真实工具:query_databaseread_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,你会看到:

  1. Server 自动连接成功

  2. Tools 列表显示 query_databaseread_file

  3. 你可以点击工具,填入参数,点击"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'

六、常见错误 & 排坑

  1. Server 启动后立即退出

    • 原因:asyncio.run(main()) 没有阻塞住

    • 解决:确认使用了 async with stdio_server() 而不是 stdio_server()(少了 async with

  2. Client 连接时报 Connection refused

    • 原因:Client 用 TCP 方式连 stdio Server

    • 解决:stdio 模式不走网络,Client 必须也用 stdio_client 启动子进程

  3. 工具调用返回空结果

    • 原因:SQL 执行成功但没有返回数据

    • 解决:先手动用 SQLite 客户端确认数据存在

  4. 中文乱码

    • 原因:ensure_ascii=True 导致 Unicode 转义

    • 解决:json.dumps(data, ensure_ascii=False)


七、课后作业

  1. 扩展工具集 :给 Server 增加一个 get_table_schema(table_name) 工具,返回指定表的字段名和类型。

  2. 增加文件类型支持 :在 read_file 的允许列表中加入 .html.css

  3. 挑战题 :实现一个 write_file 工具(写入模式),但加上双重保护------只能在特定目录下写入,且每次写入前需要打印确认信息(模拟人工确认)。


八、总结

这一讲我们完成了:

  • 搭建 SQLite 测试数据库:两张表,6 个员工,8 条销售记录

  • 手写完整的 MCP Server :包含 query_databaseread_file 两个工具

  • 安全防护:SQL 注入检测、路径穿越防护、文件类型白名单

  • 两种测试方式:MCP Inspector(Web GUI)和 Python Client 脚本

你现在手上已经有了一个可以真实运行的 MCP Server。下一讲,我们将深入 MCP Client 的开发------不仅会调用工具,还会处理错误、管理多个 Server 的连接。


🧰 开发之余,处理 Base64、JWT 解析、JSON 格式化、Crontab 计算、PDF 合并压缩这些碎片需求,我常用一个纯前端本地工具箱:zz365.top (子页 PDF 大师:PDF 大师 - zz365工具箱)。所有计算在浏览器完成,文件不上传服务器,关页即清。免费、无登录、无广告,适合开发者当常驻标签页。

相关推荐
大眼、不聚光2 小时前
5.mysql--主从同步安装部署
数据库·mysql
Yan_chen6662 小时前
SQL-LABS_Less18-20实战攻略
数据库·sql·web安全·网络安全
oradh2 小时前
Oracle RAC OCR维护操作总结
数据库·oracle·ocr·rac ocr维护·ocr维护·vote disk
snow@li3 小时前
HikariCP:高性能数据库连接池全景深入梳理
java·数据库
一个天蝎座的程序猿3 小时前
传统数据库迁金仓KES,空值问题差点让财务报表翻车
数据库
wWYy.4 小时前
Mysql:索引下推
数据库·mysql
风123456789~5 小时前
【Oracle专栏】ORA-02069: global_names 参数
数据库·oracle
ningmengjing_5 小时前
Redis 从入门到实战:Python操作全攻略
数据库·redis·python
隔窗听雨眠6 小时前
OceanBase旁路导入与自增主键冲突深度解析:原理、排查与解决方案
java·服务器·数据库