模型上下文协议MCP

Anthropic(Claude 模型的母公司)  推出了模型上下文协议 MCP,该协议旨在统一大型语言模型(LLM)与外部数据源和工具之间的通信协议。

MCP 主要是为了解决当前 AI 模型因数据孤岛限制,无法充分发挥潜力的难题,MCP 使得 AI 应用能够安全地访问和操作本地及远程数据,为 AI 应用提供了连接万物的接口。

MCP架构

DeepSeek 求职助手实战_DeepSeek

当大模型选择了工具之后,需要有一个工具调用程序,去实际的进行工具的调用,例如通过 Rest API 访问墨迹天气获取天气预报等等。

DeepSeek 求职助手实战_DeepSeek_02

DeepSeek 求职助手实战_DeepSeek_03

MCP 把工具调用程序做成了一个 C-S 架构,工具的实际调用由 MCP Server 来完成。

DeepSeek 求职助手实战_DeepSeek_04

DeepSeek 求职助手实战_DeepSeek_05

当大模型选择了合适的能力后,MCP Hosts 会调用 MCP Cient 与 MCP Server 进行通信,由 MCP Server 调用工具或者读取资源后,反馈给 MCP Client,然后再由 MCP Hosts 反馈给大模型,由大模型判断是否能解决用户的问题。如果解决了,则会生成自然语言响应,最终由 MCP Hosts 将响应展示给用户。

配置MCP Server

演示一个通过  PostgreSQL MCP Server  使 DeepSeek 能够基于 PostgreSQL 中的数据来回答问题。MCP Server 支持 NodeJS 和 python 两种语言开发,本次案例使用的是 MCP 官方提供的  PostgreSQL MCP Server,其开发语言是 NodeJS ,因此大家需要根据自己的操作系统安装好 NodeJS 。Windows 用户可以点击该链接,下载安装。

DeepSeek 求职助手实战_DeepSeek_06

创建数据库表

-- 创建数据库
CREATE DATABASE achievement;

-- 连接到新创建的数据库
\c achievement;

-- 创建用户信息 users 表
CREATE TABLE users (
    user_id SERIAL PRIMARY KEY,
    name VARCHAR(50) NOT NULL,
    email VARCHAR(100) UNIQUE NOT NULL
);

-- 创建绩效得分 score 表
CREATE TABLE score (
    score_id SERIAL PRIMARY KEY,
    score DECIMAL(10, 2) NOT NULL,
    user_id INT REFERENCES users(user_id)
);

-- 插入示例数据
INSERT INTO users (name, email) VALUES
('张三', 'zs@example.com'),
('李四', 'ls@example.com'),
('王五', 'ww@example.com');

INSERT INTO score (score, user_id) VALUES
(87.75, 1),
(97.50, 2),
(93.25, 3);

DeepSeek 求职助手实战_DeepSeek_07

修改MCP配置文件

{
  "mcpServers": {
    "postgres": {
      "command": "node",
      "args": [
        "D:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npx-cli.js",
        "-y",
        "@modelcontextprotocol/server-postgres",
        "postgresql://postgres:postgres@<你的postgres所在的服务器的IP>:5432/achievement"
      ]
    }
  }
}

完成配置后,会在左侧看到已连接的 MCP Server,并且会列出支持的 Tools 和 Resources。

DeepSeek 求职助手实战_DeepSeek_08

首先,我们提问“数据库中有哪些表?

DeepSeek 求职助手实战_DeepSeek_09

尝试一个难一点的案例,提问“张三,李四,王五的绩效谁高?”在我们的数据库中有 users 和 score 两张表,users 存储了人员姓名和邮箱信息,score 表存储了人员的绩效得分,查询绩效分时需要使用 users 表的主键去查询,因此这是一个两个表联合查询的案例。输出结果如下:

DeepSeek 求职助手实战_DeepSeek_10

,DeepSeek 一开始直接尝试在 score 表中,用 name 字段去查询绩效,结果发现表中没有 name 字段,因此就开始获取 score 表的表结构。之后又查出了 users 表的数据,最后来了一个两个表的联合查询,得到了正确结果。

实现一个MCP Server

MCP Server 的三大能力

DeepSeek 求职助手实战_DeepSeek_11

使用python创建MCP Server

pip install uv==0.5.24

DeepSeek 求职助手实战_DeepSeek_12

DeepSeek 求职助手实战_DeepSeek_13

使用Tool能力

MCP Server 的 Python SDK,分为 FastMCP SDK 和 Low-Lever SDK 两种。FastMCP 是在 Low-Level 的基础上又做了一层封装,不论是写代码,还是项目依赖等,操作起来都更加简单。

from mcp.server.fastmcp import FastMCP

# Create an MCP server
mcp = FastMCP("achievement")

# Add an get score tool
@mcp.tool()
def get_score_by_name(name: str) -> str:
    """根据员工的姓名获取该员工的绩效得分"""
    if name == "张三":
        return "name: 张三 绩效评分: 85.9"
    elif name == "李四":
        return "name: 李四 绩效评分: 92.7"
    else:
        return "未搜到该员工的绩效"

DeepSeek 求职助手实战_DeepSeek_14

修改 Roo Code 的 MCP Server 配置文件

{
  "mcpServers": {
    "achievement": {
      "command": "uv",
      "args": [
        "run",
        "--with",
        "mcp[cli]",
        "--with-editable",
        "D:\\workspace\\python\\mcp-test\\achievement",
        "mcp",
        "run",
        "D:\\workspace\\python\\mcp-test\\achievement\\server.py"
      ]
    }
  }
}

DeepSeek 求职助手实战_DeepSeek_15

DeepSeek 求职助手实战_DeepSeek_16

使用Resource能力

Resource 定义了大模型可以只读访问的数据源,可以用于为大模型提供上下文。

DeepSeek 求职助手实战_DeepSeek_17

DeepSeek 求职助手实战_DeepSeek_18

DeepSeek 求职助手实战_DeepSeek_19

DeepSeek 求职助手实战_DeepSeek_20

使用Prompt能力

预设了一些 prompt 模板,这样在对话时,可以直接选择模板进行对话,就不用再手敲 prompt 了。

@mcp.prompt()
def prompt(name: str) -> str:
    """创建一个 prompt,用于对员工进行绩效评价"""
    return f"""绩效满分是100分,请获取{name}的绩效评分,并给出评价"""

DeepSeek 求职助手实战_DeepSeek_21

DeepSeek 求职助手实战_DeepSeek_22

DeepSeek 求职助手实战_DeepSeek_23

实现一个MCP Client

DeepSeek 求职助手实战_DeepSeek_24

SSE方式

Server-Sent Events(SSE,服务器发送事件)是一种基于 HTTP 协议的技术,允许服务器向客户端单向、实时地推送数据。在 SSE 模式下,客户端通过创建一个  EventSource  对象与服务器建立持久连接,服务器则通过该连接持续发送数据流,而无需客户端反复发送请求。MCP Python SDK 使用了 Starlette 框架来实现 SSE。

SSE 模式下客户端通过访问 Server 的 /messages 端点发送 JSON-RPC 调用,并通过 /sse 端点获取服务器推送的 JSON-RPC 消息。

实现一个SSE服务器

def create_starlette_app(mcp_server: Server, *, debug: bool = False) -> Starlette:
    """Create a Starlette application that can server the provied 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,
        ) 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),
        ],
    )

DeepSeek 求职助手实战_DeepSeek_25

DeepSeek 求职助手实战_DeepSeek_26

另一个改造点需要创建 MCP 服务器实例,然后通过上面定义的  create_starlette_app  方法创建 Starlette 应用,最后使用 uvicorn 启动 ASGI 服务器,实现实时的 SSE 数据传输。代码如下

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

    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=18080, 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)

DeepSeek 求职助手实战_DeepSeek_27

客户端代码改造

客户端的改造会相对简单,就是使用 sse_client 替换 stdio_client,并在初始化时传入 MCP Server 的 HTTP 访问地址。

async def connect_to_sse_server(server_url: str):
    """Connect to an MCP server running with SSE transport"""
    # Store the context managers so they stay alive
    async with sse_client(url=server_url) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()
            # List available tools to verify connection
            print("Initialized SSE client...")
            print("Listing tools...")
            response = await session.list_tools()
            tools = response.tools
            print("\nConnected to server with tools:", [tool.name for tool in tools]) 

            # call a tool
            score = await session.call_tool(name="get_score_by_name",arguments={"name": "张三"})

            print("score: ", score)

DeepSeek 求职助手实战_DeepSeek_28

DeepSeek 求职助手实战_DeepSeek_29

实现求职助手

搜索岗位工具

@mcp.tool(description="根据求职者的期望岗位获取岗位列表数据")
def get_joblist_by_expect_job(job: str) -> str:
    """根据求职者的期望岗位获取岗位列表数据"""
    # 取得岗位信息
    #with open('job.txt', 'r', encoding='utf-8') as f:
    #    jobs = f.read()
    #以下就是无头浏览器获取岗位的代码
    jobs = listjob_by_keyword(job)
    return jobs

岗位匹配工具

@mcp.tool(description="根据求职者的简历获取适合该求职者的岗位以及求职建议")
def get_job_by_resume(jobs: str, resume: str) -> str:
    """根据求职者的简历获取适合该求职者的岗位以及求职建议"""
    #将简历以及岗位列表注入到 prompt 模板
    prompt = Job_Search_Prompt.format(resume=resume,job_list=jobs)
    messages = [{"role": "user", "content": prompt}]
    
    self.logger.info(f"prompt: {prompt}")

    #发送给 ds
    response = LLMClient.send_messages(self,messages)
    response_text = response.choices[0].message.content

    return response_text

测试效果

{
  "mcpServers": {
    "jobsearch": {
      "command": "uv",
      "args": [
        "--directory",
        "D:\\workspace\\python\\mcp-test\\jobsearch-mcp-server\\src\\jobsearch_mcp_server",
        "run",
        "jobsearch-mcp-server"
      ]
    }
  }
}

请求prompt

以下是我的简历,请帮我匹配合适的工作。
- 姓名:张三
- 专业技能:精通 AI Agent,RAG 开发
- 工作经验:5年
- 教育背景:本科
- 期望薪资:30K

DeepSeek 求职助手实战_DeepSeek_30

使用AI优化简历内容

AI读取本地简历文件

python 语言中,有一个名叫 python-docx 的包,可以实现读取 docx 文件

uv pip install python-docx
import os
from docx import Document

def read_word_file(file_path):
    """读取Word文档内容"""
    
    if not os.path.exists(file_path):
        raise FileNotFoundError(f"File not found: {file_path}")
    
    file_path = os.path.abspath(file_path)
    file_ext = os.path.splitext(file_path)[1].lower()
    
    if file_ext == '.docx':
        return _read_docx(file_path)
    else:
        raise ValueError(f"Unsupported file format: {file_ext}")

def _read_docx(file_path):
    """读取.docx文件"""
    try:
        doc = Document(file_path)
        full_text = []
        
        # 获取所有段落
        for para in doc.paragraphs:
            if para.text:
                full_text.append(para.text)
        
        # 获取所有表格内容
        for table in doc.tables:
            for row in table.rows:
                row_text = []
                for cell in row.cells:
                    if cell.text:
                        row_text.append(cell.text)
                if row_text:
                    full_text.append(" | ".join(row_text))
        
        return "\n".join(full_text)
    except Exception as e:
        raise

然后封装成MCP tool

from typing import Any
from ..word.word import read_word_file
class ResumeTools():
    def register_tools(self, mcp: Any):
        """Register job tools."""

        @mcp.tool(description="读取指定路径的word文件")
        def get_word_by_filepath(filepath: str) -> list:
            """根据文件路径获取word文件内容"""
            content=read_word_file(filepath)
            return content

DeepSeek 求职助手实战_DeepSeek_31

DeepSeek 求职助手实战_DeepSeek_32

RAG原理

RAG,中文叫检索增强生成,你可以理解为是开卷考试。我们在考试时,遇到不会的问题,去翻一下书,找到相关内容,然后根据书上内容答题。这个过程的重点是找到相关内容。

DeepSeek 求职助手实战_DeepSeek_33

DeepSeek 求职助手实战_DeepSeek_34

此外,当我们使用 RAG 技术时,为了避免文本过长,导致超出大模型的上下文限制,是需要对文本进行切割,然后一段一段地进行向量化,存入到向量数据库当中的。

docker安装向量数据库

docker run -d -name qdrant -p 6333:6333 -v /root/qdrant_data:/qdrant/storage docker.1ms.run/qdrant/qdrant:latest

首先将 word 文档加载、切片,然后转成向量塞入向量数据库。之后,当用户提问问题时,先将用户的问题转成向量,在向量数据库中匹配。匹配到合适的片段后,将片段与用户问题一起喂给大模型,由大模型给出最终的回复。

# pip install python-docx
from langchain_community.document_loaders import UnstructuredWordDocumentLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
import nltk

def load_doc():
    #nltk.download('punkt_tab')
    #nltk.download('averaged_perceptron_tagger')

    word=UnstructuredWordDocumentLoader('E:\\AI\\个人简历.docx')
    docs=word.load()
    splitter = RecursiveCharacterTextSplitter(chunk_size=50,
                                              chunk_overlap=20, )
    s_docs=splitter.split_documents(docs)

接下来就是将文本转向量以及入向量数据库,依然是使用 LangChain 封装好的工具。这里转向量的大模型,我们使用通义千问的向量模型 text-embedding-v1

def TongyiEmbedding()->DashScopeEmbeddings:
    api_key=os.environ.get("dashscope")
    return DashScopeEmbeddings(dashscope_api_key=api_key,
                           model="text-embedding-v1")

def QdrantVecStoreFromDocs(docs:List[Document]):
    eb=TongyiEmbedding()
    return QdrantVectorStore.from_documents(docs,eb,url="http://<你的公网IP>:6333")

vec_store=QdrantVecStoreFromDocs(s_docs)

DeepSeek 求职助手实战_DeepSeek_35

用户提问代码如下

def DeepSeek():
    return ChatOpenAI(
        model="deepseek-chat",
        api_key=os.environ.get("deepseek"), 
        base_url="https://api.deepseek.com"
    )

llm=DeepSeek()
prompt = hub.pull("rlm/rag-prompt")
chain = {"context": vec_store.as_retriever() | format_docs,
         "question": RunnablePassthrough()} | prompt | llm | StrOutputParser()

ret=chain.invoke("请输出姓名.格式如下\n姓名: ?")
print(ret)
ret = chain.invoke("总结专业技能情况,内容可能包含golang、AI Agent、python、rag等.格式如下\n专业技能: ?")
print(ret)
ret=chain.invoke("根据各大公司工作过的年份总结工作经验有多少年.格式如下\n工作经验: ?年")
print(ret)

使用大模型完善简历

完善简历重点在于 prompt 的编写

ResumePrompt = """
你是一个 AI 简历助手。我会给你提供我的简历以及某公司的详细岗位要求。你的任务是根据公司的岗位要求, 帮我改写和完善我的简历,使我的简历符合该公司的要求。

简历:
{resume}

岗位要求:
{input}
"""
def load_doc() -> list:
    word=UnstructuredWordDocumentLoader('E:\\AI\\个人简历.docx')
    docs=word.load()

    return docs

def fix_resume():
    prompt=PromptTemplate.from_template(ResumePrompt2)
    llm=DeepSeekR1()
    docs=load_doc()
    chain={
        "resume": lambda _: docs,
        "input":RunnablePassthrough()
    } | prompt | llm | StrOutputParser()
    ret=chain.invoke(load_jobs())
    print(ret)

我是将岗位需求抓下来后,放在了 job.txt 中。之后在 fix_resume 方法中,首先导入 prompt 模板,然后初始化 DeepSeek 客户端,之后重点代码就在于构建 chain,chain 其实很好理解,就类似于管道操作。

这个 chain 的意思是首先通过 “input”:RunnablePassthrough(),为 prompt 模板中的 input 字段赋值,这个值是 chain.invoke 赋的。之后把 input 的值传递给了 prompt,然后把 prompt 传递给 llm 大模型,最后把大模型的返回结果传递给 StrOutputParser(),让其将大模型的返回结果解析成字符串输出。

根据模板进一步完善

在提示词中加一个模板,让其在项目经历等关键部分,按照模板进行改写。完善后的提示词如下

ResumePrompt2 = """
你是一个 AI 简历助手。我会给你提供我的简历以及某公司的详细岗位要求。你的任务是根据公司的岗位要求, 帮我改写和完善我的简历,使我的简历符合该公司的要求。
此外,我还会给你一个简历模板,模板中会包含简历中部分内容的大纲,当你匹配到我的简历中有模板提及的内容时,要按照我模板的格式进行编写。

简历:
{resume}

简历模板:
专业技能
  请在此描述符合职位要求的技能,尤其是编程技能

项目经验
 (1) 项目描述
 (2) 我在项目中的角色
 (3) 项目规模
 (4) 技术堆栈
 (5) 已开发模块的描述
 (6) 解决难题的经验

岗位要求:
{input}
"""

地图服务接入MCP

SSE 是一个可以建立长连接的轻量级协议。使用 SSE 而不使用 HTTP 的原因是,大模型调用 Tool 的过程不是一轮就结束的,往往是需要多次反复调用。因此,设法保持一个长连接,而不是每一次调用 Tool 的时候就得开个新连接,就显得非常重要了。

API接入

from llm import client
import requests

tools = [
    {
        "name": "get_location_coordinate",
        "description": "根据POI名称获取其经纬度坐标",
        "parameters": {
            "type": "object",
            "properties": {
                "keywords": {
                    "type": "string",
                    "description": "POI名称(中文)",
                },
                "region": {
                    "type": "string",
                    "description": "POI所在的区域名(中文)",
                }
            },
            "required": ["keywords"]
        },
    },
    {
        "name": "search_nearby_pois",
        "description": "搜索指定坐标附近的POI",
        "parameters": {
            "type": "object",
            "properties": {
                "keywords": {
                    "type": "string",
                    "description": "目标POI的关键字",
                },
                "location": {
                    "type": "string",
                    "description": "中心点坐标(经度,纬度)",
                }
            },
            "required": ["keywords"]
        },
    }
]

def http_get(url, params=None):
    """
    通用的HTTP GET请求处理函数
    
    Args:
        url (str): 完整的请求URL
        params (dict, optional): URL参数字典
    
    Returns:
        dict: 包含success和data/message的响应结果
    """
    try:
        response = requests.get(url, params=params)
        response.raise_for_status()
        return {"success": True, "data": response.json()}
    except Exception as e:
        return {"success": False, "message": str(e)}

# API配置
AMAP_BASE_URL = "https://restapi.amap.com/v5/place"
AMAP_KEY = "xxxxxxxxxxxxxxxxx"

def get_location_coordinate(keywords, region=None):
    """根据POI名称获取其经纬度坐标"""
    params = {
        "key": AMAP_KEY,
        "keywords": keywords
    }
    if region:
        params["region"] = region
        
    result = http_get(f"{AMAP_BASE_URL}/text", params)
    if result["success"] and result["data"].get("pois"):
        poi = result["data"]["pois"][0]
        return f"POI: {keywords}, 坐标: {poi['location']}"
    return "未找到该POI的坐标信息"


def search_nearby_pois(keywords, location=None):
    """搜索指定坐标附近的POI"""
    params = {
        "key": AMAP_KEY,
        "keywords": keywords
    }
    if location:
        params["location"] = location
        
    result = http_get(f"{AMAP_BASE_URL}/around", params)
    if result["success"] and result["data"].get("pois"):
        results = []
        for poi in result["data"]["pois"][:3]:
            results.append(f"名称: {poi['name']}, 地址: {poi['address']}, 坐标: {poi['location']}")
        return "\n".join(results)
    return "未找到符合条件的POI"