如何用MCP搭建Agent,实现多步查询

原始博客地址:https://zhuanlan.zhihu.com/p/1894473368180339955

MCP(Model Context Protocol)为AI模型提供了一种标准化的方式来发现外部工具和数据源并与之交互。MCP包括客户端和服务端两个部分,简单来说,客户端就是大模型以及你想怎么调用工具的逻辑,服务端就是你的工具,包括怎么调用工具的一逻辑,接口怎么调用的一些实现方法。
在这里插入图片描述
也就是说MCP就是一个规范,你按照它的标准规范来写,就可以快速的实现工具调用,之前我们搭建一个本地工具调用的Agent,需要写一堆参数解析逻辑,通过if else匹配,调用工具,现在用了MCP,这些操作完全不需要,大大减轻了我们搭建Agent的步骤。关于MCP本身的介绍,本文不做过多介绍,因为本文主要是用于Agent搭建,所以用到的MCP功能类型为Tools。

MCP目前支持两种传输方式:标准输入输出(stdio)和基于 HTTP 的服务器推送事件(SSE),stdio:当服务端在客户端在同一机器上,SSE:服务端在远程服务器上,也就是通常说的接口形式,通过ip和端口访问。

基于stdio的方式不做过多介绍,本文主要基于SSE的方式进行实现,搭建本地工具调用的Agent。

搭建一个MCP服务,主要包括两个部分,服务端编写以及客户端编写,相对来说服务端比较容易,你只需要 把工具定义好,以及每个工具的调用方式实现写好就可以。客户端其实就是要决定怎么去搭建整个会话流程,是不是需要考虑上下文、是不是需要设计成reAct的形式,所以如果说要想搭建一个相对完善的客户端,需要考虑的东西还是挺多的,就复杂的任务而言通常包含多步操作,这个操作步骤有可能是依赖上一个问题结果,也有可能和上一个问题的结果无关,因此在这种情况下,直接一次调用多个工具好像并不是特别合适,本文提供一种策略,通先确定工具个数,然后每轮会根据上一轮的问题和结果以及原始问题,重新做问题规划,实现多步查询。

在本文中你会学到

  • 如何从0开始搭建Agent

  • 怎么编写MCP服务端(通过接口的形式)

  • 怎么编写MCP客户端(实现多步查询)

  • 怎么在cursor中使用(本人之前没有使用过)

  • 怎么在streamlit中使用实现客户端和服务端的交互

一、环境安装

以下所有MCP环境的安装都是在docker容器中进行操作,首先安装环境,基础环境python3.10,网上大多是在uv环境下进行操作的,因为本人是在docker容器内,所以没用uv


安装mcp: pip3 install mcp -i https://mirrors.aliyun.com/pypi/simple
pip3 install streamlit -i https://mirrors.aliyun.com/pypi/simple 

二、服务端

服务端主要是一些工具的调用形式,本文使用了三个工具,包括天气查询(公开的API接口)、关键词抽取(本地API接口)、摘要生成(本地API接口),工具可以任意添加,只要按照规范添加对应的说明即可。为了让初学者有个清晰的概念,下面会对每个接口做简单说明。

2.1 工具介绍

  • 天气查询接口

    这个接口需要获取APIKEY,需要注册https://openweathermap.org/api,然后获取自己的key

  • 关键词抽取接口

    这个接口是自己封装的一个关键词抽取接口,输入一句话,输出关键词,接口调用形式如下:

    def get_keyword(params):
        url="http://*****/getTerms"
        res = requests.post(url,data=params)
        result = res.json()
        res = result.get("result")
        if res:
            term = res.get("terms")
        else:
            term = []
        return term
    s=get_keyword({"sentence": "发票抬头该怎么填写"})
    print(s)  #['发票']
    
    
  • 摘要生成接口

    这个接口也是本地封装的一个接口,接口调用形式如下:

    def get_summery(data):
    
        url="http://***********/doc2title"
        params = {"docText": data}
        res = requests.post(url,data=params)
        result = res.json()
        res = result.get("result")
        if res:
            term = res.get("title")
        else:
            term = []
        return term
    s=get_summery("本标准按照GB/T1.1—2009给出的规则起草。请注意本文件的某些内")
    print(s)#['《中国新闻周刊》总第464期封面专题']
    

2.2 服务端搭建

1、因为知道了每个工具是怎么调用的,下面我们新建一个tools.py文件,存放的是每个工具的调用代码,咩个工具的调用和上面的差不多,主要区别是改成了异步调用的方式

import httpx
import aiohttp
OPENWEATHER_API_BASE = "https://api.openweathermap.org/data/2.5/weather"
API_KEY = "1xxxxxxxxxxxxxd"  # 请替换为你自己的 OpenWeather API Key
USER_AGENT = "weather-app/1.0"
async def fetch_weather(city: str) :
    """
    从 OpenWeather API 获取天气信息。
    :param city: 城市名称(需使用英文,如 Beijing)
    :return: 天气数据字典;若出错返回包含 error 信息的字典
    """
    params = {
        "q": city,
        "appid": API_KEY,
        "units": "metric",
        "lang": "zh_cn"
    }
    headers = {"User-Agent": USER_AGENT}

    async with httpx.AsyncClient() as client:
        try:
            response = await client.get(OPENWEATHER_API_BASE, params=params, headers=headers, timeout=30.0)
            response.raise_for_status()
            return response.json()  # 返回字典类型
        except httpx.HTTPStatusError as e:
            return {"error": f"HTTP 错误: {e.response.status_code}"}
        except Exception as e:
            return {"error": f"请求失败: {str(e)}"}

#---------------关键词抽取-------------------
async def get_keyword(query):
    params = {

        "sentence": query

    }
    url = "http://iyunwen-nlp-2.faqrobot.net/ability/nlp/getTerms"

    async with aiohttp.ClientSession() as session:
        async with session.post(url, data=params) as res:
            result = await res.json()

    return result.get("result", [])  # 直接返回 spoList,默认为空列表


#---------------摘要抽取-------------------
async def get_summery(data):

    url="http://iyunwen-nlp-2.faqrobot.net/ability/nlp/doc2title"
    params = {"docText": data}

    async with aiohttp.ClientSession() as session:
        async with session.post(url, data=params) as res:
            result = await res.json()
    res = result.get("result")
    if res:
        term = res.get("title")
    else:
        term = []
    return "".join(term)

2、编写相应的客户端程序 mcp_server.py

#!/usr/bin/env python
# -*- coding:utf-8 -*-
# author:ping
# datetime:2025/4/9 11:04
from tools import *
import json
from mcp.server.fastmcp import FastMCP
from starlette.applications import Starlette
from mcp.server.sse import SseServerTransport
from starlette.requests import Request
from starlette.routing import Mount, Route
from mcp.server import Server
import uvicorn


# 初始化 MCP 服务器
mcp = FastMCP("McpServer")

@mcp.tool()
async def check_weather(city: str) -> str:
    """
    输入指定城市的英文名称,返回今日天气查询结果。
    :param city: 城市名称(需使用英文)
    :return: 格式化后的天气信息
    """
    data = await fetch_weather(city)
    # 如果传入的是字符串,则先转换为字典
    if isinstance(data, str):
        try:
            data = json.loads(data)
        except Exception as e:
            return f"无法解析天气数据: {e}"

    # 如果数据中包含错误信息,直接返回错误提示
    if "error" in data:
        return f"⚠️ {data['error']}"

    # 提取数据时做容错处理
    city = data.get("name", "未知")
    country = data.get("sys", {}).get("country", "未知")
    temp = data.get("main", {}).get("temp", "N/A")
    humidity = data.get("main", {}).get("humidity", "N/A")
    wind_speed = data.get("wind", {}).get("speed", "N/A")
    # weather 可能为空列表,因此用 [0] 前先提供默认字典
    weather_list = data.get("weather", [{}])
    description = weather_list[0].get("description", "未知")

    return (
        f"🌍 {city}, {country}\n"
        f"🌡 温度: {temp}°C\n"
        f"💧 湿度: {humidity}%\n"
        f"🌬 风速: {wind_speed} m/s\n"
        f"🌤 天气: {description}\n"
    )

# 添加工具,获取关键词抽取结果
@mcp.tool()
async def keywords_extract(sentence: str) -> str:
    """
    抽取句子中的关键词,包括术语等
    输入的是句子,返回的关键词抽取结果
    :param sentence: 需要抽取关键词的文本
    :return: 关键词抽取结果
    """
    data = await get_keyword(sentence)
    # print("关键词结果:{}".format(data))
    return data
# 添加工具,摘要抽取接口
@mcp.tool()
async def summary_extract(docText: str) -> str:
    """
    生成摘要,根据一段描述,生成这段描述的摘要
    输入的是一段描述,返回的是该描述对应的摘要
    :param docText: 需要抽取摘要的文本
    :return: 根据该文本生成的描述
    """
    data = await get_summery(docText)
    return data
## sse传输
def create_starlette_app(mcp_server: Server, *, debug: bool = False) -> Starlette:
    """Create a Starlette application that can serve the provided mcp server with SSE."""
    sse = SseServerTransport("/messages/")

    async def handle_sse(request: Request) -> None:
        async with sse.connect_sse(
                request.scope,
                request.receive,
                request._send,  # noqa: SLF001
        ) as (read_stream, write_stream):
            await mcp_server.run(
                read_stream,
                write_stream,
                mcp_server.create_initialization_options(),
            )

    return Starlette(
        debug=debug,
        routes=[
            Route("/sse", endpoint=handle_sse),
            Mount("/messages/", app=sse.handle_post_message),
        ],)

if __name__ == "__main__":
    mcp_server = mcp._mcp_server

    import argparse

    parser = argparse.ArgumentParser(description='Run MCP SSE-based server')
    parser.add_argument('--host', default='0.0.0.0', help='Host to bind to')
    parser.add_argument('--port', type=int, default=18376, help='Port to listen on')
    args = parser.parse_args()

    # Bind SSE request handling to MCP server
    starlette_app = create_starlette_app(mcp_server, debug=True)

    uvicorn.run(starlette_app, host=args.host, port=args.port)

需要说明的是MCP中需要说明每个工具的描述,所以在每个tools下面都要有一段注释描述,以便获取每个工具的参数以及说明下这个函数的作用,参数名称要保持一致,否则也会出现问题
在这里插入图片描述

到此,整个服务端已经搭建完成,执行python3 mcp_server.py

显示下面的形式,说明服务启动成功
在这里插入图片描述

三、客户端

服务端搭建完成,可以在客户端中直接调用,其中一种方式就是使用现成的工具,目前很多IDE中都集成了MCP功能,可以快速使用,下面介绍一种比较常见的 用法,在cursor中使用,这种方式比较简单,直接安装包,然后配置下MCP服务端地址即可。

3.1 Cursor中使用

选择设置-MCP-添加MCP server

{
  "mcpServers": {"mcp-server": {
      "url": "http://192.168.1.200:18376/sse"
      
    }}
}

配置成功后的效果
在这里插入图片描述
点击ctrl+l可以实现Agent问答
在这里插入图片描述

3.2 自己搭建MCP客户端

如果是自己搭建,一种方式是通过命令行交互的方式做多轮问答,通过使用 input() 接收用户输入、本文使用的方式是采用streamlit实现交互。

文章之前也提到,客户端的搭建策略很多,相对来说也更复杂,本文主要解决的是实现多步骤查询,具体实现 步骤如下:

1)首先利用对问题做思考,确定问题中涉及到的工具

2)调用LLM进行回复,判断是否需要调用工具

3)下一轮问题根据上一轮问题和结果以及原始问题等因素对问题进行重写

4)最后使用LLM进行总结

这个步骤把工具调用的参数和结果做为一个单独接口封装,代码如下:

#!/usr/bin/env python
# -*- coding:utf-8 -*-
# author:ping
# datetime:2025/4/8 10:40
import json
from pydantic import BaseModel
from typing import Optional
from contextlib import AsyncExitStack
import logging
from mcp import ClientSession
from mcp.client.sse import sse_client
logger=logging.getLogger("client_log.log")

from fastapi import FastAPI, HTTPException


# Define FastAPI app
app = FastAPI()



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

    async def connect_to_sse_server(self, server_url: str):
        """Connect to an MCP server running with SSE transport"""
        self._streams_context = sse_client(url=server_url)
        streams = await self._streams_context.__aenter__()

        self._session_context = ClientSession(*streams)
        self.session: ClientSession = await self._session_context.__aenter__()

        # Initialize
        await self.session.initialize()

        # List available tools to verify connection
        logger.info("初始化 SSE 客户端...")
        response = await self.session.list_tools()
        available_tools = [{
            "type": "function",
            "function": {
                "name": tool.name,
                "description": tool.description,
                "parameters": tool.inputSchema
            }
        } for tool in response.tools]
        print("available_tools",available_tools)
        tools = response.tools
        logger.info("\n已连接到服务器,支持以下工具: {}".format([tool.name for tool in tools]))

    async def cleanup(self):
        """Properly clean up the session and streams"""
        if self._session_context:
            await self._session_context.__aexit__(None, None, None)
        if self._streams_context:
            await self._streams_context.__aexit__(None, None, None)


    async def call_tools(self, tool_name: str,tool_args) -> str:
        """Process a query using OpenAI API and available tools"""
        if self.session is None:
            raise HTTPException(status_code=500, detail="Session not initialized.")
        print("tool_name",tool_name)
        print("tool_args", tool_args)

        tool_args = json.loads(tool_args)


        # Execute tool call
        result = await self.session.call_tool(tool_name, tool_args)
        content = result.content[0].text

        logger.info(f"调用工具: {tool_name}")
        logger.info(f"工具参数: {tool_args}")
        logger.info(f"工具执行结果: {content}")

        return content

class ToolCallRequest(BaseModel):
    tool_name: str
    tool_args: str  # 注意是字符串格式的 JSON

# Create an instance of the client
client = MCPClient()


@app.on_event("startup")
async def startup():
    # Initialize the connection when the app starts
    server_url = "http://0.0.0.0:18376/sse"  # Change to your server URL
    try:
        await client.connect_to_sse_server(server_url)
    except Exception as e:
        logger.error(f"Error connecting to server: {e}")
        raise HTTPException(status_code=500, detail="Failed to connect to SSE server.")


@app.post("/call_tools/")
async def call_tools(request: ToolCallRequest):
    result = await client.call_tools(request.tool_name, request.tool_args)
    return {"result": result}
if __name__ == "__main__":
    import uvicorn

    uvicorn.run(app, host="0.0.0.0", port=18475)

Agent部分搭建流程代码

class AgentAI:
    def __init__(self):
        self.openai_api_key = "xxxxxc"
        self.base_url = "xxxxx"
        self.model = "Qwen/Qwen2.5-32B-Instruct"  # 读取 model
        self.openai = AsyncOpenAI(api_key=self.openai_api_key, base_url=self.base_url)

    async def query_thinks(self,prompt):

        querys = await self.openai.chat.completions.create(
            model=self.model,
            max_tokens=1000,

            messages=[
                {
                    "role": "user",
                    "content": prompt
                }
            ])
        thinks = querys.choices[0].message.content
        return thinks

    async def get_ocr_res(self, base64):
        """
        识别到OCR的逻辑
        :param base64:
        :return:
        """
        tool_name = "ocr_recognition"
        tool_args = base64
        res = get_http_tools(tool_name, tool_args)
        return res

    async def process_query(self, query: str,image=None):


        available_tools =    [{'type': 'function', 'function': {'name': 'check_weather', 'description': '\n    输入指定城市的英文名称,返回今日天气查询结果。\n    :param city: 城市名称(需使用英文)\n    :return: 格式化后的天气信息\n    ', 'parameters': {'properties': {'city': {'title': 'City', 'type': 'string'}}, 'required': ['city'], 'title': 'check_weatherArguments', 'type': 'object'}}}, {'type': 'function', 'function': {'name': 'keywords_extract', 'description': '\n    抽取句子中的关键词,包括术语等\n    输入的是句子,返回的关键词抽取结果\n    :param sentence: 需要抽取关键词的文本\n    :return: 关键词抽取结果\n    ', 'parameters': {'properties': {'sentence': {'title': 'Sentence', 'type': 'string'}}, 'required': ['sentence'], 'title': 'keywords_extractArguments', 'type': 'object'}}}, {'type': 'function', 'function': {'name': 'summary_extract', 'description': '\n    生成摘要,根据一段描述,生成这段描述的摘要\n    输入的是一段描述,返回的是该描述对应的摘要\n    :param docText: 需要抽取摘要的文本\n    :return: 根据该文本生成的描述\n    ', 'parameters': {'properties': {'docText': {'title': 'Doctext', 'type': 'string'}}, 'required': ['docText'], 'title': 'summary_extractArguments', 'type': 'object'}}}]

        # =======生成一段thingk==============================
        inputs = prompts["prompt_plan"].format(query=query)
        think = await self.query_thinks(inputs)
        print("----------think", think)
        tools_=get_tools_num(think)#确定工具总数
        history=[]
        MAX_TOOL_CALLS=len(tools_)
        
        if MAX_TOOL_CALLS==0:
            MAX_TOOL_CALLS=2



        messages = [
            {
                "role": "system",
                "content": "你是一个智能助手,能够根据任务自动规划多个工具调用步骤。"
            },
            {
                "role": "user",
                "content": query
            }
        ]
        history.extend( [
            {
                "role": "system",
                "content": "你需要根据历史调用工具的结果,进行总结回复"
            },
            {
                "role": "user",
                "content": query
            }
        ])

        tool_results = []
        steps = []
        current_query = query
        for step_idx in range(MAX_TOOL_CALLS):
            # history.extend(messages)
            completion = await self.openai.chat.completions.create(
                model=self.model,
                max_tokens=1000,
                messages=messages,
                tools=available_tools
            )

            assistant_message = completion.choices[0].message

            if not assistant_message.tool_calls:
                break  # 没有新的工具调用,终止

            tool_call = assistant_message.tool_calls[0]
            tool_name = tool_call.function.name
            tool_args = tool_call.function.arguments
            result = get_http_tools(tool_name, tool_args)
            print(f"==> 执行第{step_idx + 1}个工具: {tool_name}")
            print("参数:", tool_args)
            print("返回结果:", result)
            history.extend([
                {
                    "role": "assistant",
                    "content": None,
                    "tool_calls": [tool_call]
                },
                {
                    "role": "tool",
                    "tool_call_id": tool_call.id,
                    "content": result.get("result")
                }
            ])

            steps.append({
                "original_query": current_query,
                "tool_call": {
                    "tool_name": tool_name,
                    "tool_args": tool_args,
                    "tool_result": result
                },
                "final_response": None
            })

            # 重写下一个查询(准备下一轮工具调用)
            rewrite_prompt = prompts["rewrite_query"].format(
                last_question=tool_args,
                original_query=query,
                last_tool_name=tool_name,
                last_result=result
            )
            # print("rewrite_prompt",rewrite_prompt)
            rewritten_resp = await self.openai.chat.completions.create(
                model=self.model,
                max_tokens=500,
                messages=[
                    {"role": "system", "content": "你是一个任务重写助手"},
                    {"role": "user", "content": rewrite_prompt}
                ]
            )
            current_query = rewritten_resp.choices[0].message.content
            print("current_query",current_query)
            messages=[{"role": "user", "content": current_query}]

        final_completion = await self.openai.chat.completions.create(
            model=self.model,
            max_tokens=1000,
            messages=history
        )
        final_text = final_completion.choices[0].message.content

        return {
            "think": think,
            "steps": steps,
            "final_summary": final_text
        }

最终实现效果图为:
在这里插入图片描述
在这里插入图片描述

四、总结

个人觉得使用MCP搭建客户端还是挺方便的,省时省力(之前写Agent甚至要if else匹配到每个工具,还有一堆json解析错误等问题),服务端实现很快,而且还可以使用别人写好的客户端,特别方便,客户端的使用,主要看个人需求,本文知识提供一种策略,也欢迎大家提出自己的想法,多多改进!!!

最后:代码链接 GitHub - lplping/MCP_Agent: 用MCP搭建Agent,实现多步查询策略

参考:

https://composio.dev/blog/what-is-model-context-protocol-mcp-explained/

https://docs.anthropic.com/zh-CN/docs/agents-and-tools/mcp

https://github.com/modelcontextprotocol/python-sdk

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值