MCP入门示例

297 阅读10分钟

文章主要内容:

  • 介绍官方Claude Desktop使用
  • MCP介绍、一些概念
  • MCP Server的简单实现、测试及代码解释
  • MCP Client的简单实现及代码解释

Claude Desktop示范

首先我们需要一个Claude Desktop应用、和一个可用的authropic账号在你的电脑上,接下来我们以官方的fileSystem为示例言是一个mcp server的接入。

  1. 打开路径格式为/Users/zhangmingyuan/Library/Application Support/Claude/claude_desktop_config.json的配置文件

  2. 添加如下内容进文件并报存。其中filesystem是服务的名称,commandargs分别是指令和参数其中后两个参数。

    {
      "mcpServers": {
        "filesystem": {
          "command": "npx",
          "args": [
            "-y",
            "@modelcontextprotocol/server-filesystem",
            "/Users/zhangmingyuan/Desktop",
            "/Users/zhangmingyuan/Downloads"
          ]
        }
      }
    }
    
  3. 在将MCP server的信息以配置文件告知客户端后,需要重启客户端使用

  4. 此时我们可以在客户端对话框中看到filesystem,这说明mcp server已经可用且对应的客户端已经建立

image-20250516112625290.png

  1. 我们可以直接使用,LLM会按需调用我们提供的工具

image-20250516113517276.png

MCP

组成

flowchart LR
    subgraph "Host"
        client1[MCP Client]
        client2[MCP Client]
    end
    subgraph "Server Process"
        server1[MCP Server]
    end
    subgraph "Server Process"
        server2[MCP Server]
    end

    client1 <-->|Transport Layer| server1
    client2 <-->|Transport Layer| server2

一个完整的MCP主要是由三部分组成:

  • host:是发起连接的LLM应用程序;具体作用是服务发现、连接管理;表现形式一般是下载用户侧的客户端,如VsCode、Claude Desktop、cherry studio等
  • client:在host应用的内部,与MCP server通过Transport保持一比一的连接,可以看做LLM应用和MCP server的中间件。
  • server:实现一些提示词、工具、资源(数据或内容)提供给客户端,以供客户端实现调用逻辑。主流server实现的一般是提供一些具体的业务/逻辑功能的实现,作为tool给到客户端。

以上是一个MCP的典型组成,接下来介绍其他核心概念:

  • mcp server可提供的能力:
    • Resources
    • Prompts
    • Tools
  • Transports:通信机制
  • Root:提供给客户端,使之告知服务器相关资源及位置,定义服务器可以运行的边界。(比较简单,没什么好说的)
  • Sampling:server通过客户端请求LLM进行信息补充

主要功能

Resources

种类及标注格式

Resources允许服务器公开可由客户端读取并用作 LLM 交互上下文的数据和内容,资源种类限于两种

  • 文本
  • 二进制

标注格式为:[protocol]://[host]/[path],示例如下:

file:///home/user/documents/report.pdf
postgres://database/customers/schema
screen://localhost/display1
使用流程

具体使用中的动作包括:资源发现、资源读取和更新资源

  1. 资源发现:对应静态资源、动态资源有俩种方式进行资源发现
    1. 直接资源:服务器通过 resources/list 端点暴露具体资源列表
    2. 对于动态资源,服务器可以公开 URI 模板 ,客户端可以使用它来构建有效的资源 URI
  2. 读取资源:此过程,客户端需要使用资源 URI 发出 resources/read 请求,服务器以资源内容列表进行响应。
  3. 更新资源:也是两种机制,一种是全量推送更新、一种是订阅
    1. 当服务器可用资源列表发生变化时,服务器通过 notifications/resources/list_changed 全量推送通知客户端。
    2. 客户端发送 resources/subscribe 资源 URI记行订阅;资源发生变化时,服务器发送notifications/resources/updated;客户端可以通过 resources/read 获取最新内容

Prompts

定义可重复使用的提示模板和工作流程,客户端可以轻松地将其呈现给用户和 LLM。提供一种强大的方法来标准化和共享常见的 LLM 交互。

发现和更新的流程类似Resource。主要是功能的复用

Tools

主流Server中核心能力,将本地代码实现的功能方法/已有业务系统中的功能封装为tools提供给客户端,使得host应用中的LLM可以依据客户端定义的执行逻辑来操作tools

注意事项:需要具备tool calling的LLM才能使用

通信协议Transports

主要通信方式有两种

  • stdio标准输入输出
  • SSE:使用受限,具体场景:
    • 仅需要服务器到客户端的流式传输
    • 使用受限制的网络
    • 实现简单更新

消息格式采用JSON-RPC的形式,有三种:

  • Request

    {
      jsonrpc: "2.0",
      id: number | string,
      method: string,
      params?: object
    }
    
  • Response

    {
      jsonrpc: "2.0",
      id: number | string,
      result?: object,
      error?: {
        code: number,
        message: string,
        data?: unknown
      }
    }
    
  • Notification

    {
      jsonrpc: "2.0",
      method: string,
      params?: object
    }
    

其消息连接的生命周期如下图:

sequenceDiagram
    participant Client
    participant Server

    Client->>Server: initialize request
    Server->>Client: initialize response
    Client->>Server: initialized notification

    Note over Client,Server: Connection ready for use

进阶使用Sampling

允许server通过客户端请求LLM进行信息补充,工作流程如下:

  1. 服务器向客户端发送 sampling/createMessage 请求
  2. 客户端审核请求并修改
  3. 客户端从LLM中采样
  4. 客户端审核LLM生成的内容
  5. 客户端返回结果给服务器

并非所有host应用都可以处理采样,对上面其他能力的支持也不尽相同,主流host应用对MCP各功能的支持可以查看这里

MCP Server

下面我们对Server进行简单的代码实现,功能是与官网类似的天气查询,只演示tool的实现

简单实现

使用uv初始化工程后,新建weather.py,引入依赖:

from typing import Any
import httpx
from mcp.server.fastmcp import FastMCP
import asyncio
import xml.etree.ElementTree as ET

首先我们进行mcp服务器的初始化:

mcp = FastMCP("china_weather")

然后在后续代码中利用高德的API实现城市天气的查询以及城市代码的获取(自己实现为任何你想实现的功能方法)

为了MCP可以识别,在方法上方添加@mcp.tool的注解;

为了方便后续模型理解,在方法内部添加方法及方法参数的说明;

完善后代码如下(完整代码附在最后):

@mcp.tool()
async def get_city_weather(adcode: str) -> str:
    """Get today and 3day weather forecast for a adcode.
    Args:
        adcode: adcode or city name (e.g. "101010100" for Beijing, or "北京")
    """
    ... ...
    
@mcp.tool()
async def search_city_code(city_name: str) -> str:
    """Search for location information by city name using Amap API.
    Args:
        city_name: Chinese city name or address (e.g. "北京市朝阳区阜通东大街6号")
    """
    ... ..

最后在主入口将mcp server启动起来:

if __name__ == "__main__":
    # asyncio.run(mcp.run(transport="stdio"))
    mcp.run(transport='stdio')

测试

至此我们的完整工具方法已经实现,工具的正确性我们要先验证下:

npx @modelcontextprotocol/inspector <command>

我们使用官方的工程进行测试,command就是具体的启动命令,若不填入也可在后启动后从页面添加,启动后从控制台找到网址打开:

页面视图如下:

image-20250516153811341.png

可以通过更改左侧的配置更换其他MCP server,可以通过list tools之后选择tool进行测试。

接入Claude Desktop

测试完毕后,我们可以比照前面的filesystem一样将代码发布到pypl,pyproject.toml的内容:

[project]
name = "zimy-demo-weather"
version = "0.1.2"
description = "输入地址查天气"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
    "httpx>=0.28.1",
    "mcp>=1.6.0",
]
[project.scripts]
zimy-demo-weather = "main:main"

发布后可用相似的方法添加mcp server到claude_desktop_config.json

或者使用本地接入的方式:

    "weather": {
      "command": "/Users/zhangmingyuan/opt/anaconda3/bin/uv",
      "args": [
        "--directory",
        "/Users/zhangmingyuan/WorkSpace/WorkSpace_AI/MCP_DEMO/weather",
        "run",
        "weather.py"
      ],
      "env": {
        "PYTHONPATH": "/Users/zhangmingyuan/WorkSpace/WorkSpace_AI/MCP_DEMO/weather/"
      }
    },

试一下:

image-20250516154729428.png

MCP client

作为一个通用host应用,claude提供的client的处理一般是直接将可用的tools提供给大模型,由其自主调用和安排执行逻辑,如果我们想自己设置一些固定的处理,或者对server提供的tools进行一些进一步的处理,就需要自主开发客户端甚至是host

简单实现

这次我们直接上官方的示例代码

import asyncio
from typing import Optional
from contextlib import AsyncExitStack

from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

from anthropic import Anthropic
from dotenv import load_dotenv

load_dotenv()  # load environment variables from .env

class MCPClient:
    def __init__(self):
        # Initialize session and client objects
        self.session: Optional[ClientSession] = None
        self.exit_stack = AsyncExitStack()
        self.anthropic = Anthropic()

    async def connect_to_server(self, server_script_path: str):
        """Connect to an MCP server
        
        Args:
            server_script_path: Path to the server script (.py or .js)
        """
        is_python = server_script_path.endswith('.py')
        is_js = server_script_path.endswith('.js')
        if not (is_python or is_js):
            raise ValueError("Server script must be a .py or .js file")
            
        command = "python" if is_python else "node"
        server_params = StdioServerParameters(
            command=command,
            args=[server_script_path],
            env=None
        )
        
        stdio_transport = await self.exit_stack.enter_async_context(stdio_client(server_params))
        self.stdio, self.write = stdio_transport
        self.session = await self.exit_stack.enter_async_context(ClientSession(self.stdio, self.write))
        
        await self.session.initialize()
        
        # List available tools
        response = await self.session.list_tools()
        tools = response.tools
        print("\nConnected to server with tools:", [tool.name for tool in tools])

    async def process_query(self, query: str) -> str:
        """Process a query using Claude and available tools"""
        messages = [
            {
                "role": "user",
                "content": query
            }
        ]

        response = await self.session.list_tools()
        available_tools = [{ 
            "name": tool.name,
            "description": tool.description,
            "input_schema": tool.inputSchema
        } for tool in response.tools]

        # Initial Claude API call
        response = self.anthropic.messages.create(
            model="claude-3-5-sonnet-20241022",
            max_tokens=1000,
            messages=messages,
            tools=available_tools
        )

        # Process response and handle tool calls
        tool_results = []
        final_text = []

        for content in response.content:
            if content.type == 'text':
                final_text.append(content.text)
            elif content.type == 'tool_use':
                tool_name = content.name
                tool_args = content.input
                
                # Execute tool call
                result = await self.session.call_tool(tool_name, tool_args)
                tool_results.append({"call": tool_name, "result": result})
                final_text.append(f"[Calling tool {tool_name} with args {tool_args}]")

                # Continue conversation with tool results
                if hasattr(content, 'text') and content.text:
                    messages.append({
                      "role": "assistant",
                      "content": content.text
                    })
                messages.append({
                    "role": "user", 
                    "content": result.content
                })

                # Get next response from Claude
                response = self.anthropic.messages.create(
                    model="claude-3-5-sonnet-20241022",
                    max_tokens=1000,
                    messages=messages,
                )

                final_text.append(response.content[0].text)

        return "\n".join(final_text)

    async def chat_loop(self):
        """Run an interactive chat loop"""
        print("\nMCP Client Started!")
        print("Type your queries or 'quit' to exit.")
        
        while True:
            try:
                query = input("\nQuery: ").strip()
                
                if query.lower() == 'quit':
                    break
                    
                response = await self.process_query(query)
                print("\n" + response)
                    
            except Exception as e:
                print(f"\nError: {str(e)}")
    
    async def cleanup(self):
        """Clean up resources"""
        await self.exit_stack.aclose()

async def main():
    if len(sys.argv) < 2:
        print("Usage: python client.py <path_to_server_script>")
        sys.exit(1)
        
    client = MCPClient()
    try:
        await client.connect_to_server(sys.argv[1])
        await client.chat_loop()
    finally:
        await client.cleanup()

if __name__ == "__main__":
    import sys
    asyncio.run(main())

代码解释

代码主体是MCP client的类,这代码实际上实现了host与client的简略功能

类的属性有:

        self.session: Optional[ClientSession] = None
        self.exit_stack = AsyncExitStack()
        self.anthropic = Anthropic()
  • exit_stack用于记录已有的通信(transport客户端)
  • 客户端通过session进行预设的方法调用以确定server提供的内容
  • anthropic是连接claude模型的客户端,这里可以替换为其他任何可以进行tool calling的大模型。

类的方法有:

  • connect_to_server:主要按照MCP中的方法进行客户端的创建,这里可依据具体的业务场景,将服务发现的逻辑添加进去
  • process_query是供人/host调用的接口,这里可以实现模型调用替换、提示词、tools选择逻辑甚至是host的功能调用。

通过执行uv run client.py <pyfile path>

image-20250516160858846.png

附录

完整的server:

from typing import Any
import httpx
from mcp.server.fastmcp import FastMCP
import asyncio
import xml.etree.ElementTree as ET

WEATHER_API_BASE = "https://restapi.amap.com/v3/weather/weatherInfo?parameters"
USER_KEY="XXXXXXXXXXXXXX"

mcp = FastMCP("china_weather")
        
def parse_city_xml(xml_string: str) -> dict:
    try:
        # 解析XML字符串
        root = ET.fromstring(xml_string)
        # 找到第一个<geocode>节点
        geocode = root.find(".//geocode")
        if geocode is None:
            print("No geocode found in XML")
            return {}
        
        # 提取adcode和location
        adcode = geocode.find("adcode").text if geocode.find("adcode") is not None else None
        location = geocode.find("location").text if geocode.find("location") is not None else None
        
        return {"adcode": adcode, "location": location}
    except ET.ParseError as e:
        print(f"XML parsing error: {e}")
        return {}
    except Exception as e:
        print(f"Error processing XML: {e}")
        return {}

@mcp.tool()
async def get_city_weather(adcode: str) -> str:
    """Get today and 3day weather forecast for a adcode.
    Args:
        adcode: adcode or city name (e.g. "101010100" for Beijing, or "北京")
    """
    headers = {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
        "Accept": "application/json"
    }
    params = {
        "city": adcode,
        "key": USER_KEY,
        "extensions":"all"
    }
    url = "https://restapi.amap.com/v3/weather/weatherInfo"
    async with httpx.AsyncClient() as client:
        try:
            response = await client.get(url, headers=headers, params=params, timeout=30.0)
            response.raise_for_status()
            data = response.json()
        except Exception as e:
            print(f"Request error: {e}")
            return None

    
    if not data or "forecasts" not in data:
        return "无法获取该城市的天气信息或位置信息不正确"
    
    forecast = data["forecasts"][0]  # Get 3day's forecast
    casts = data["forecasts"][0]["casts"]
    
    weather_info=""
    for cast in casts:
        forecast_str = (
            f"日期: {cast.get('date', '未知')};"
            f"白天天气: {cast.get('dayweather', '未知')};"
            f"夜间天气: {cast.get('nightweather', '未知')};"
            f"温度: {cast.get('nighttemp', '未知')}°C ~ {cast.get('daytemp', '未知')}°C;"
            f"白天风向: {cast.get('daywind', '未知')};"
            f"白天风力: {cast.get('daypower', '未知')}级"
        )
        weather_info+=forecast_str
    
    return weather_info
@mcp.tool()
async def search_city_code(city_name: str) -> str:
    """Search for location information by city name using Amap API.
    Args:
        city_name: Chinese city name or address (e.g. "北京市朝阳区阜通东大街6号")
    """
    params = {
        "address": city_name,
        "output": "XML",
        "key": USER_KEY  # Replace with actual Amap API key
    }
    url = "https://restapi.amap.com/v3/geocode/geo"
    headers = {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
        "Accept": "application/xml"
    }
    async with httpx.AsyncClient() as client:
        try:
            response = await client.get(url, headers=headers, params=params, timeout=30.0)
            response.raise_for_status()
            xml_string=response.text
        except httpx.HTTPStatusError as e:
            print(f"HTTP error: {e.response.status_code} - {e}")
            print(f"Request URL: {url}")
            return None
        except Exception as e:
            print(f"Request error: {type(e).__name__} - {e}")
            print(f"Request URL: {url}")
            return None
    data = parse_city_xml(xml_string)
    location = {
        "name": city_name,
        "adcode": data["adcode"] if data["adcode"]  is not None else "未知",
        "location": data["location"]  if data["location"]  is not None else "未知"
    }
    return f"地址: {location['name']}, 行政区划代码: {location['adcode']}, 经纬度: {location['location']}"



if __name__ == "__main__":
    # asyncio.run(mcp.run(transport="stdio"))
    mcp.run(transport='stdio')

resource、Prompts、sampling的使用以及客户端的服务发现先鸽一下。