从零构建一个 MCP Server:让 Claude 和 ChatGPT 接入你自己的工具

MCP(Model Context Protocol)是 Anthropic 提出的开放协议,让 AI 模型通过标准化接口调用外部工具和数据源。本文带你从零构建一个实用的 MCP Server------一个能查询天气、管理本地文件、执行 Shell 命令的工具集合,并接入 Claude Desktop 实际使用。

1. 背景:为什么需要 MCP?

2024 年底,Anthropic 开源了 Model Context Protocol(MCP),一个用于连接 AI 模型与外部工具和数据的开放标准。在此之前,每个 AI 平台都有自己的 Function Calling / Tool Use API------OpenAI 有 function calling,Anthropic 有 tool use,Google 有 function declarations。虽然功能相似,但接口各不相同。

MCP 的目标是成为"AI 世界的 USB-C 接口":一个统一的协议,让你写一次工具实现,就能接入任何支持 MCP 的 AI 客户端(Claude Desktop、Cursor、Continue 等)。

MCP 的架构很简洁,只有三个角色:

  • MCP Host:AI 应用本身(如 Claude Desktop),负责与用户交互
  • MCP Client:Host 内部的协议客户端,负责与 Server 通信
  • MCP Server:你编写的工具服务,暴露具体的工具能力

通信方式有两种:stdio(标准输入输出,适合本地工具)和 HTTP+SSE(适合远程服务)。本文聚焦 stdio 方式,因为它最简单、延迟最低。

2. 上手:用 Python SDK 搭建第一个 MCP Server

2.1 环境准备

bash 复制代码
pip install mcp --break-system-packages

MCP Python SDK 提供了装饰器风格的 API,写起来和 FastAPI 很像。

2.2 最小可运行示例

python 复制代码
# server.py
import asyncio
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent

# 创建 Server 实例
server = Server("my-first-mcp-server")

@server.list_tools()
async def list_tools() -> list[Tool]:
    """告诉客户端这个 Server 提供哪些工具"""
    return [
        Tool(
            name="echo",
            description="将输入原样返回",
            inputSchema={
                "type": "object",
                "properties": {
                    "message": {
                        "type": "string",
                        "description": "要回显的消息"
                    }
                },
                "required": ["message"]
            }
        ),
        Tool(
            name="add",
            description="计算两个数的和",
            inputSchema={
                "type": "object",
                "properties": {
                    "a": {"type": "number", "description": "第一个数"},
                    "b": {"type": "number", "description": "第二个数"}
                },
                "required": ["a", "b"]
            }
        )
    ]

@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
    """处理工具调用请求"""
    if name == "echo":
        message = arguments["message"]
        return [TextContent(type="text", text=f"Echo: {message}")]

    if name == "add":
        result = arguments["a"] + arguments["b"]
        return [TextContent(type="text", text=f"结果: {result}")]

    raise ValueError(f"未知工具: {name}")

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())

这个仅 50 行的 server 提供了两个工具:echoadd。把它保存为 server.py,然后用 python server.py 运行(但先别急------单独运行它不会做任何事,因为它在等待 stdio 上的 MCP 协议消息)。

2.3 接入 Claude Desktop

在 Claude Desktop 的配置文件中注册这个 Server。macOS 上的配置文件路径是:

复制代码
~/Library/Application Support/Claude/claude_desktop_config.json

添加以下内容:

json 复制代码
{
  "mcpServers": {
    "my-first-server": {
      "command": "python",
      "args": ["/path/to/server.py"]
    }
  }
}

重启 Claude Desktop,你应该能在工具列表中看到 echoadd 两个工具。试试对 Claude 说:"用 echo 工具把 'Hello MCP' 回显出来",或者"用 add 工具算一下 3.14 加 2.86"。

3. 进阶:构建一个实用的多功能 Server

上面的示例只是玩具。下面我们构建一个实用的 Server,包含三个实用工具:天气查询、本地文件读取、Shell 命令执行。

python 复制代码
# practical_server.py
import asyncio
import subprocess
import json
from pathlib import Path
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent

server = Server("practical-tools")

# 模拟天气数据(实际项目中替换为真实 API 调用)
WEATHER_DATA = {
    "北京": {"temp": 22, "condition": "晴", "humidity": "45%"},
    "上海": {"temp": 26, "condition": "多云", "humidity": "65%"},
    "深圳": {"temp": 30, "condition": "阵雨", "humidity": "80%"},
}

@server.list_tools()
async def list_tools() -> list[Tool]:
    return [
        Tool(
            name="get_weather",
            description="查询指定城市的天气信息",
            inputSchema={
                "type": "object",
                "properties": {
                    "city": {
                        "type": "string",
                        "description": "城市名称,如:北京、上海、深圳"
                    }
                },
                "required": ["city"]
            }
        ),
        Tool(
            name="read_local_file",
            description="读取本地文件内容。请提供绝对路径",
            inputSchema={
                "type": "object",
                "properties": {
                    "file_path": {
                        "type": "string",
                        "description": "文件的绝对路径"
                    }
                },
                "required": ["file_path"]
            }
        ),
        Tool(
            name="run_shell_command",
            description="执行一个 Shell 命令并返回输出。⚠️ 仅用于只读操作(如 ls, cat, wc 等),不要执行破坏性命令",
            inputSchema={
                "type": "object",
                "properties": {
                    "command": {
                        "type": "string",
                        "description": "要执行的 Shell 命令"
                    }
                },
                "required": ["command"]
            }
        )
    ]

@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
    if name == "get_weather":
        city = arguments.get("city", "")
        weather = WEATHER_DATA.get(city)
        if weather:
            text = f"{city}天气:{weather['temp']}°C,{weather['condition']},湿度 {weather['humidity']}"
        else:
            text = f"未找到城市「{city}」的天气数据。当前支持:{', '.join(WEATHER_DATA.keys())}"
        return [TextContent(type="text", text=text)]

    if name == "read_local_file":
        file_path = arguments.get("file_path", "")
        path = Path(file_path)
        if not path.exists():
            return [TextContent(type="text", text=f"错误:文件不存在 - {file_path}")]
        if not path.is_file():
            return [TextContent(type="text", text=f"错误:路径不是文件 - {file_path}")]
        if path.stat().st_size > 1024 * 1024:  # 1MB 限制
            return [TextContent(type="text", text=f"错误:文件过大(超过 1MB),拒绝读取")]
        try:
            content = path.read_text(encoding="utf-8")
            # 截断过长的内容
            if len(content) > 5000:
                content = content[:5000] + "\n\n... (内容已截断,原文件共 {} 字符)".format(len(content))
            return [TextContent(type="text", text=content)]
        except UnicodeDecodeError:
            return [TextContent(type="text", text=f"错误:无法以 UTF-8 解码该文件(可能是二进制文件)")]

    if name == "run_shell_command":
        command = arguments.get("command", "")
        # 安全检查:拒绝明显危险的命令
        dangerous_patterns = ["rm ", "sudo ", "mkfs", "dd ", "> /dev/", "format "]
        if any(pattern in command for pattern in dangerous_patterns):
            return [TextContent(
                type="text",
                text=f"安全拦截:命令「{command}」包含潜在危险操作,拒绝执行。"
            )]
        try:
            result = subprocess.run(
                command, shell=True, capture_output=True, text=True, timeout=10
            )
            output = result.stdout
            if result.stderr:
                output += "\n[stderr]\n" + result.stderr
            if result.returncode != 0:
                output += f"\n[退出码: {result.returncode}]"
            if len(output) > 5000:
                output = output[:5000] + "\n\n... (输出已截断)"
            return [TextContent(type="text", text=output)]
        except subprocess.TimeoutExpired:
            return [TextContent(type="text", text="错误:命令执行超时(10 秒)")]
        except Exception as e:
            return [TextContent(type="text", text=f"错误:命令执行失败 - {str(e)}")]

    raise ValueError(f"未知工具: {name}")

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())

4. 避坑清单与最佳实践

经过实际使用,这里有几个关键的经验教训:

工具描述(description)至关重要。 模型是根据工具的名称和描述来决定是否调用、何时调用的。描述要写清楚三件事:这个工具做什么、输入参数的含义、什么时候该用它。模糊的描述会导致模型调用不准确或完全不调用。

inputSchema 是模型理解参数的唯一依据。 如果参数是 file_path,description 里最好写"文件的绝对路径",而不是简单写"路径"。模型会逐字读取 schema 中的 description 来推断参数应该填什么。

错误处理要友好。 工具调用失败时,返回一个包含错误信息的 TextContent,而不是抛出异常。这样模型可以根据错误信息调整策略(比如换个文件名再试),而不是直接崩溃。

安全边界由你定义。 MCP Server 运行在用户的机器上,拥有和用户同等的权限。在暴露 Shell 命令执行、文件系统访问等能力时,一定要加上安全限制(路径白名单、命令黑名单、文件大小限制等)。永远不要让模型有能力执行 rm -rf /

stdio vs HTTP 的选择。 stdio 适合个人使用和本地工具------零配置、低延迟、自动生命周期管理。HTTP+SSE 适合团队共享的服务------多个客户端可以同时连接,Server 可以独立部署和更新。

资源(Resources)和提示(Prompts)是可选的加分项。 MCP 协议除了 Tools 之外,还定义了 Resources(暴露数据,如文件内容、数据库记录)和 Prompts(预定义的提示模板)。对于大多数实用场景,先从 Tools 开始就足够了。

5. 总结

MCP 实现了"AI 使用工具"的标准化。本文从一个 50 行的 echo server 出发,逐步构建了一个包含天气查询、文件读取、Shell 执行的实用工具集。实际的 MCP Server 可以接入任何 API 或数据源------数据库查询、Slack 消息、Jira 工单、Git 操作------只要你能用 Python(或 TypeScript)写出来,就能变成 AI 的工具。

MCP 生态正在快速增长。截至 2026 年 5 月,社区已经有上千个开源的 MCP Server,覆盖了从 Notion 到 PostgreSQL 的几乎所有常见工具。如果你的团队还在为每个 AI 平台单独写 tool-use 适配层,MCP 值得认真考虑。

6. 参考资料

相关推荐
ComPDFKit2 小时前
使用AI Agent自动化生成订单/发票/合同:从自然语言到PDF的一站式方案
人工智能·chatgpt·智能合约
DS随心转APP2 小时前
2026年AI对话导出Word完全指南|ChatGPT/DeepSeek/豆包/Claude一键转换–AI导出鸭
人工智能·ai·chatgpt·豆包·deepseek·ai导出鸭
Nayxxu3 小时前
ChatGPT API 中转站技术选型与接入实测:从词元无忧 API(token5u API)开始更省事
人工智能·chatgpt
武子康4 小时前
调查研究-148 Deepseek-V4-Flash 生成式AI十大高频业务场景落地指南
大数据·人工智能·深度学习·ai·chatgpt·deepseek
企服AI产品测评局14 小时前
Agent适配信创环境实测:企业级自动化如何实现国产操作系统与数据库全兼容?
运维·数据库·人工智能·ai·chatgpt·自动化
rannn_11120 小时前
OpenAI Function Calling 全解析:从函数定义到流式调用
人工智能·chatgpt·openai·ai agent
战族狼魂1 天前
Claude 大模型在真实业务场景中的落地应用指南
人工智能·chatgpt·大模型
武子康1 天前
Ollama 2026最新实践:从本地大模型到本地+云端+Agent工具链
人工智能·ai·chatgpt·ollama·deepseek
视觉&物联智能1 天前
【杂谈】-筑牢AI安全防线:解锁运行时保护新密钥
人工智能·安全·chatgpt·aigc·agi·deepseek