ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

从客户端到天气服务器:MCP 快速入门与工具调用实战

从客户端到天气服务器:MCP 快速入门与工具调用实战

简介:《从零到一MCP快速入门实战》面向希望系统理解并上手Model Context Protocol的开发者与Agent学习者,从技术体系、客户端开发到服务器搭建逐步展开,适合具备Python基础、想降低外部工具调用门槛的初中级读者。资源包共1个PDF文件,约47.66MB,内容以图文教程形式组织,围绕MCP入门、Function calling回顾、uv环境管理、极简客户端搭建、在线与本地模型接入、天气查询服务器与客户端实现、MCP Inspector及进阶功能展开。已有1127人学习,说明其内容具备一定参考热度。读者可沿目录梳理MCP开发流程,掌握客户端与服务器通信机制,理解OpenAI、DeepSeek、ollama、vLLM等模型的接入思路,并借助示例代码与运行说明完成从环境准备到联调测试的实践,形成较完整的入门路径。

1. 为什么我先写客户端再写服务器:MCP 快速入门的一次顺序调整

很多人第一次接触 Model Context Protocol(下面统一叫 MCP)时,会照着各种教程先搭服务器、再写客户端,结果卡在配置和联调上半天找不出问题。我在一次 Agent 工具链的项目里反着试了一遍:先用几十行代码把 MCP 客户端跑通,让它能启动、能接收输入、能优雅退出,然后再回头去接天气查询服务器。这个顺序调整带来的直接好处是——出问题时你能立刻判断是客户端环境的问题,还是服务器协议的问题,而不是面对一堆堆栈信息靠猜。这套「从零到一 MCP 快速入门实战」资料讲的就是这条路径:先讲清 MCP 的技术体系与 Function calling 的关系,再拆客户端从 uv 环境、极简骨架到接入在线模型和本地模型,最后落到天气查询服务器的完整实现与 MCP Inspector 调试。适合刚接触 Agent 开发、想把外部工具接进大模型但被 Function calling 那一堆 JSON Schema 劝退的开发者,也适合已经会用大模型 API、想搞明白 MCP 到底省了哪些事的熟手。

2. MCP 技术体系与工程前置:先把协议和 uv 环境搞明白

MCP 本质上是一份协议,不是某个库,也不是某个框架。它把「大模型运行环境」叫 MCP Client,把「外部函数运行环境」叫 MCP Server,然后统一两者之间的通信规范。理解这一点,后面所有代码才有落点:客户端负责把你的自然语言请求翻译成工具调用,服务器负责真正执行工具并把结果返回。这份资料在第一章就把这层关系讲透了,还顺带回顾了 Function calling 和大模型 Agent 的技术体系,避免你直接用 SDK 却不知道 SDK 在替你做什么。

2.1 协议层要解决的问题:为什么不是直接写 Function calling

传统 Function calling 的流程是:你写一个外部函数,再手写一份 JSON Schema 描述它的参数,再设计一段提示词模板提高调用准确率,最后把这三样东西一起喂给模型。一个查天气的函数,光描述和容错就得几十上百行。麻烦的地方不在于写函数,而在于每接一个新工具就要重复一遍这套动作,而且每个模型的 Schema 格式还不完全一致。

MCP 的做法是把这套约定固化成协议:工具的描述、调用、返回都走同一套消息格式,客户端和服务器各自实现自己那一半。于是「查天气」这种通用能力,一个人写好服务器,其他人直接复用。我在实际项目里最直观的感受是,以前接三个工具要写三份提示词模板,现在客户端侧基本不用动,换服务器就行。

对比项传统 Function callingMCP
工具描述每个函数手写 JSON Schema服务器自描述,客户端自动发现
提示词模板每个工具单独设计协议统一,客户端复用
复用方式复制函数代码直接连接现成服务器
调试手段打日志看请求Inspector 可视化查看消息

这张表不是让你背,而是帮你判断什么时候值得上 MCP:工具数量少、模型固定,Function calling 够用;工具多、要换模型、要复用社区生态,MCP 的收益才明显。

2.2 uv 的安装与虚拟环境初始化

MCP 的官方示例和社区项目基本都推荐用 uv 管环境,原因是它用 Rust 写的,装包快,而且能直接读 pyproject.toml,避免 pip 那种依赖解析慢、版本冲突排查难的问题。安装有两条路,装了 pip 的直接装,没有的用脚本装。

# 方式一:已有 pip 环境 pip install uv # 方式二:无 pip 的系统,用官方脚本安装 curl -LsSf https://astral.sh/uv/install.sh | sh

装完之后验证一下版本,能打印出来就说明 PATH 没问题:

uv --version

这里有个容易翻车的点:用脚本安装后,当前终端可能还没刷新 PATH,直接敲 uv 会提示 command not found。重新开一个终端,或者手动 source 一下 shell 配置文件就能解决,不用重装。

接下来创建项目和环境。uv 和 pip 最大的行为差异是,它会自动识别当前项目主目录并把虚拟环境建在.venv下,不需要你手动指定路径:

# 初始化项目,生成 pyproject.toml uv init mcp-client cd mcp-client # 创建虚拟环境(默认落在 .venv) uv venv # 激活环境,Linux/macOS 用这条 source .venv/bin/activate # Windows 用这条 # .venv\Scripts\activate

激活之后安装 MCP SDK。注意这里用的是uv add而不是uv pip install,前者会把依赖写进 pyproject.toml,后者只装不进清单,团队协作时别人拉代码就装不齐:

uv add mcp

提示:如果你在 Jupyter 里跑,激活虚拟环境这一步可以跳过,但要确保 notebook 的 kernel 选的是这个.venv对应的解释器,否则会出现「命令行装好了、notebook 里 import 报错」的情况。

2.3 环境层面的常见误用

我见过最多的误用是混用 pip 和 uv。比如先用pip install mcp装在全局,又用 uv 建了个空环境,然后激活环境跑代码,报 ModuleNotFoundError,人就开始怀疑是不是 SDK 有问题。实际上全局装的包和虚拟环境是两套隔离的 site-packages,激活环境后就只看环境内的包。

另一个误用是不看 pyproject.toml 就乱加依赖。uv 的项目模式下,依赖声明在 pyproject.toml 里,uv add会自动维护,手动改文件再uv sync也行,但别两边同时改,容易把锁文件搞乱。真乱了就删掉uv.lock重新uv sync,这是成本最低的后悔药。

3. MCP 客户端搭建:从极简骨架到接入在线与本地模型

客户端这一层是整个入门路径里性价比最高的部分。先用一个不带任何模型的骨架把异步结构和退出逻辑跑通,再往里塞模型,出问题时你能明确知道是网络、密钥、还是协议层的问题。资料里的顺序也是这样:极简客户端 → 接在线模型 → 接本地模型,每一步只加一个变量。

3.1 极简客户端的完整代码与结构拆解

先在项目根目录建client.py,写入下面这段。它不连真实服务器,只提供一个命令行循环,作用是验证异步框架、资源管理和输入输出都正常。

import asyncio from mcp import ClientSession from contextlib import AsyncExitStack class MCPClient: def __init__(self): """初始化 MCP 客户端""" self.session = None # 用 AsyncExitStack 统一管理异步资源,退出时按栈顺序释放 self.exit_stack = AsyncExitStack() async def connect_to_mock_server(self): """模拟服务器连接,先不接真实 server""" print("MCP 客户端已初始化,但未连接到服务器") async def chat_loop(self): """交互式循环,quit 退出""" print("\nMCP 客户端已启动!输入 'quit' 退出") while True: try: query = input("\nQuery: ").strip() if query.lower() == "quit": break # 此处仅回显,后续替换为真实模型调用 print(f"[Mock Response] 你说的是:{query}") except Exception as e: print(f"发生错误: {str(e)}") async def cleanup(self): """关闭所有异步资源""" await self.exit_stack.aclose() async def main(): client = MCPClient() try: await client.connect_to_mock_server() await client.chat_loop() finally: # 无论正常退出还是异常,都保证资源被释放 await client.cleanup() if __name__ == "__main__": asyncio.run(main())

逐段说清楚逻辑。asyncio是 Python 内置的异步库,MCP 的会话方法都是 async 的,所以整个调用链必须跑在事件循环里。AsyncExitStack是上下文管理器栈,你可以把它理解成一个「资源登记处」,后面真正连服务器时,stdio 子进程、HTTP 会话都往这里注册,退出时统一关,不会漏。

session先置为 None 是有意为之,骨架阶段不连接任何服务器,等联调时再替换connect_to_mock_server的实现。chat_loop里的input()是同步阻塞的,放在 async 函数里在单用户命令行场景没问题,但如果你要改成并发处理多个请求,就得换成异步输入,这是后续进阶才需要考虑的事。

运行方式和普通脚本一样:

python client.py

看到启动提示、输入任意内容能回显、输入 quit 能正常退出,就说明骨架没问题。这里的关键不是功能,而是验证了「异步入口 + 资源栈 + 循环退出」这三件套,后面接模型时只是往循环里填真正的调用逻辑。

3.2 接入在线模型:环境变量与 client.py 改造

接在线模型(OpenAI 兼容接口或 DeepSeek 这类)时,资料里给的路线是先加依赖、再建.env、最后改client.py。依赖侧通常是模型 SDK 加 dotenv:

uv add openai python-dotenv

.env文件放密钥和模型名,不要提交到仓库。工程上我一般会同时提交一个.env.example,把字段名写清楚,别人拉下来复制一份填值就行:

# .env OPENAI_API_KEY=sk-xxxx OPENAI_BASE_URL=https://api.example.com/v1 MODEL_NAME=your-model-name

改造client.py的核心思路是把 mock 回复替换成模型调用,并且把 MCP 工具列表塞进请求里。下面是骨架级的改法,重点是消息构造和工具注入的位置:

import os import asyncio from dotenv import load_dotenv from openai import AsyncOpenAI from mcp import ClientSession from contextlib import AsyncExitStack load_dotenv() # 读取 .env class MCPClient: def __init__(self): self.session = None self.exit_stack = AsyncExitStack() # 用 base_url 兼容 OpenAI 与各类兼容接口 self.llm = AsyncOpenAI( api_key=os.getenv("OPENAI_API_KEY"), base_url=os.getenv("OPENAI_BASE_URL"), ) self.model = os.getenv("MODEL_NAME") async def chat_loop(self): messages = [] while True: query = input("\nQuery: ").strip() if query.lower() == "quit": break messages.append({"role": "user", "content": query}) # tools 参数来自 MCP 服务器暴露的工具清单 resp = await self.llm.chat.completions.create( model=self.model, messages=messages, tools=self.tools if hasattr(self, "tools") else None, ) msg = resp.choices[0].message messages.append(msg.model_dump()) print(msg.content or "[模型请求调用工具]")

参数上要留意三点。base_url决定了请求打到哪个服务,DeepSeek 和各类兼容网关都是这个套路,路径末尾的/v1别漏。tools字段的格式需要从 MCP 工具描述转换过来,字段名一般是 name、description、input_schema,转换函数写一次就能复用。messages要持续累积,多轮对话的上下文靠它维持,别每轮重建。

注意:密钥只放.env,代码里一律走os.getenv。我见过把 key 硬编码进 client.py 然后推到公开仓库的,只能立刻去后台轮换密钥,这种事故没有后悔药。

3.3 接入本地模型:ollama 与 vLLM 的差异

本地模型的接入改动集中在客户端初始化和地址上。ollama 默认在本地起一个 OpenAI 兼容服务,端口 11434,把 base_url 指过去、模型名换成你 pull 下来的模型即可:

self.llm = AsyncOpenAI( api_key="ollama", # 本地服务不校验,占位即可 base_url="http://localhost:11434/v1", ) self.model = "qwen2.5" # 换成你本地已有的模型

vLLM 的接法几乎一样,差别在部署侧:vLLM 需要先把模型以 OpenAI 兼容模式起服务,默认端口 8000,启动命令里要指定模型路径和端口。客户端侧只需要改 base_url:

self.llm = AsyncOpenAI( api_key="vllm", base_url="http://localhost:8000/v1", )

这里值得展开的是本地模型和 MCP 配合时的现实约束。MCP 的工具调用依赖模型能稳定输出结构化的工具请求,小参数模型在这块经常不稳定,表现为该调工具时给你一段自然语言解释。我一般的做法是先把工具数量控制在一两个、描述写得足够直白,确认模型能稳定触发,再往上加工具。这不是玄学,是模型对 Schema 的指令跟随能力问题,换更大的模型或者精简工具描述都能缓解。

接入方式base_url密钥典型问题
在线兼容接口服务商提供必填网络、额度、模型名拼错
ollamalocalhost:11434/v1占位即可模型未 pull、端口被占
vLLMlocalhost:8000/v1占位即可服务未起、显存不足

4. 天气查询服务器实战:Server 创建、客户端联调与 Inspector 调试

服务器这一章是整套流程的收口。前面客户端能跑、能接模型,但模型手里没有任何工具,只能聊天。天气查询服务器的作用是把「查天气」这个能力按 MCP 规范暴露出去,让客户端发现并调用。资料里的路线是先讲服务器概念和通讯机制,再写代码,最后用 Inspector 验证,这个顺序对新手很友好,因为 Inspector 能在不写客户端的情况下先确认服务器本身是通的。

4.1 服务器通讯机制与依赖安装

MCP 服务器和客户端之间常见的通讯方式有两种:stdio 和 SSE/HTTP。stdio 是把服务器作为子进程启动,通过标准输入输出传消息,适合本地工具;HTTP 方式适合部署到远端给多人用。入门阶段基本都用 stdio,因为不需要起端口、不需要处理鉴权,客户端拉起子进程就能通信。

服务器侧依赖装法和客户端一致,用 uv 建独立环境更干净,避免两个项目的依赖互相污染:

uv init mcp-weather-server cd mcp-weather-server uv venv source .venv/bin/activate uv add mcp httpx

httpx是用来真正请求天气接口的,如果你用 mock 数据可以先不加。工程习惯上我会把服务器和客户端分成两个目录、两个虚拟环境,联调时客户端配置里写服务器的启动命令,这样两边的依赖版本互不干扰。

4.2 天气服务器的代码结构与工具注册

服务器的核心是创建一个 Server 实例,然后用装饰器把函数注册成工具。工具函数的参数类型标注会被 SDK 自动转成 JSON Schema,这正是 MCP 省掉手写 Schema 的地方。下面是一个结构完整的示例:

import asyncio import httpx from mcp.server import Server from mcp.server.stdio import stdio_server app = Server("weather-server") @app.tool() async def get_weather(city: str) -> str: """查询指定城市的当前天气 Args: city: 城市名称,例如 北京 """ # 实际项目替换为真实天气接口,示例用占位地址 async with httpx.AsyncClient(timeout=10) as client: resp = await client.get( "https://api.example.com/weather", params={"city": city}, ) resp.raise_for_status() data = resp.json() return f"{city} 当前温度 {data['temp']}℃,{data['desc']}" async def main(): # stdio 模式:由客户端拉起本进程并接管标准输入输出 async with stdio_server() as (read, write): await app.run(read, write, app.create_initialization_options()) if __name__ == "__main__": asyncio.run(main())

关键点在几处。@app.tool()装饰器负责注册,函数名就是工具名,docstring 会成为工具描述,模型靠这段描述判断什么时候该调。参数city: str的类型标注会被转成 Schema 的 string 类型,如果你写city: int,模型就会按整数传参,所以标注必须和真实语义一致。timeout=10是必须的,外部接口不设超时会让整个工具调用卡死,客户端侧表现为长时间无响应,很难排查。

stdio_server()返回读写流,run方法把服务器挂上去。这段代码不需要你手动处理消息帧,SDK 全包了。真要改造成支持多个工具,就再写几个带@app.tool()的函数,客户端重新连接时自动发现。

4.3 客户端连接真实服务器与工具调用闭环

客户端侧要从 mock 切换到真实连接,核心是启动服务器子进程并建立会话。资料里的写法是用 stdio 客户端参数拉起命令:

from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client async def connect_to_server(self, server_script_path: str): """拉起服务器子进程并建立 MCP 会话""" params = StdioServerParameters( command="python", args=[server_script_path], env=None, # 需要环境变量时在这里传 ) # 注册到 exit_stack,退出时自动关闭子进程 read, write = await self.exit_stack.enter_async_context( stdio_client(params) ) self.session = await self.exit_stack.enter_async_context( ClientSession(read, write) ) await self.session.initialize() # 拉取服务器暴露的工具列表 tools = await self.session.list_tools() print(f"已连接,可用工具:{[t.name for t in tools.tools]}")

参数说明:command是启动服务器的解释器,用绝对路径更稳,避免虚拟环境解释器不一致;args是服务器脚本路径;env在需要透传 API key 时使用,不传就继承当前进程环境。initialize()是握手,必须调,否则后续list_tools会报会话未就绪。把 read/write 和 session 都进 exit_stack,是为了保证异常退出时子进程被回收,不然会在系统里留一堆孤儿进程。

工具清单拿到之后,把它转成模型 API 需要的 tools 格式,在 chat_loop 里判断模型返回是否包含工具调用,有就通过self.session.call_tool(name, arguments)执行,再把结果作为工具消息塞回对话。这一步跑通,整个「模型 → 工具 → 结果 → 模型」的闭环就成立了。

4.4 MCP Inspector:把黑匣子打开

Inspector 是官方提供的调试工具,作用是让你不写任何客户端代码就能连上服务器,看到它暴露了哪些工具、参数结构长什么样、调用返回什么。我一般在新写一个服务器后第一件事就是用它验证,比直接联调客户端快得多。

启动方式通常是 npx 拉起来,然后在界面里选择 stdio、填启动命令和脚本路径,连上之后左侧会列出 tools,点进去能直接填参数调用。它的价值在于把协议消息可视化了:你能看到工具描述的原文,如果模型不调工具,先来这里看描述是不是写得太含糊,比在代码里加日志高效。

提示:Inspector 连不上时先手动在终端跑一遍服务器脚本,看有没有 import 错误或启动即崩。Inspector 报连接失败,十有八九是服务器自己起不来,而不是 Inspector 的问题。

5. 联调避坑:五个真实踩过的坑与排查路径

这一章单独拿出来,是因为 MCP 的问题大多不在代码逻辑,而在环境、进程和协议细节上。下面五条按「现象 → 原因 → 解决」写,都是我在实际搭这套东西时真碰到的。

现象一:客户端启动后一直卡住,没有任何输出。原因通常是服务器子进程启动失败但客户端在等握手,两边互相等。解决:把服务器脚本单独在终端跑一遍,看是否报 ModuleNotFoundError 或语法错误;确认客户端配置的解释器路径和服务器所在虚拟环境一致。

现象二:list_tools 返回空列表。原因多为工具注册没生效,比如装饰器写在类方法上但没实例化,或者服务器启动的是旧版本脚本。解决:确认@app.tool()装饰的函数在模块顶层或已被执行到,改完代码重启客户端,stdio 模式下服务器是随客户端拉起的,不重启不会加载新代码。

现象三:模型不调用工具,只回一段文字。原因有两个方向:工具描述太模糊,或者模型本身指令跟随弱。解决:先把 docstring 改成「什么时候用、参数是什么」的直白描述,在 Inspector 里确认描述已生效;再换一个能力更强的模型验证,确认是模型问题还是描述问题。

现象四:工具调用报参数校验失败。原因是 Schema 和实际传参不一致,典型是类型标注写成 str 但模型传了数字,或者必填参数没给默认值。解决:检查函数签名,能设默认值的参数给上默认值,类型标注和真实语义对齐,改完在 Inspector 里手动调一次确认。

现象五:程序退出后系统里残留 python 进程。原因是子进程没被回收,通常是没走 exit_stack 或者异常路径跳过了 cleanup。解决:确认 read/write 和 session 都通过enter_async_context注册,main 里用 try/finally 包住,保证异常也会执行清理。

注意:这五条里有四条都能用同一个动作定位——单独跑服务器 + Inspector 验证。养成「先证明服务器是通的,再怀疑客户端」的习惯,能省掉大量时间。

6. 二次开发怎么改:从天气 Demo 到自己的工具服务器

把 Demo 跑通只是起点,真正有价值的是照着这套结构换成自己的工具。改造路径我一般按三步走,每一步都能独立验证,不会一次改太多导致问题定位不了。

第一步是换工具实现,不动协议层。比如你要接的是本地数据库查询,就把get_weather换成query_db,参数改成sql: str,函数体里换成数据库连接和查询。这里有个血泪经验:工具函数一定要做输入校验和超时控制,模型生成的参数不可信,直接把模型给的字符串拼进 SQL 是灾难。我一般会限制只允许 SELECT、加超时、返回结果截断到合理长度,避免一次查询把上下文撑爆。

第二步是加工具数量时控制描述质量。工具不是越多越好,模型在几十个工具里选择时准确率会下降。我的做法是按业务域拆服务器,一个服务器只管一类能力,客户端按需连接。下面这张表是我改造时常用的检查项:

改造点检查内容验证方式
工具描述是否写清何时使用、参数含义Inspector 查看描述原文
参数类型标注与真实语义是否一致手动传边界值调用
异常处理超时、空结果、接口报错断网或传错参数测试
返回体大小是否截断,是否含敏感信息打印实际返回长度

第三步是把服务器从 stdio 改成可部署形态。stdio 适合本地单人用,要给团队共用就得走 HTTP 方式,这时要考虑鉴权、并发和日志。改造时协议层的代码基本不动,换的是传输层和启动方式,这也是 MCP 分层设计带来的好处——工具逻辑和通讯方式解耦。

验证方法上,我习惯每改完一个工具就跑三遍:Inspector 手动调一次确认参数和返回正确;客户端里用自然语言问一次确认模型能选中;再问一次不相关的问题确认模型不会乱调。三遍都过,这个工具才算能用。

从那以后我每次接新工具,都强制先写服务器、用 Inspector 验证通过,再去动客户端和模型配置,绝不在客户端里一边调协议一边调业务。这个顺序看起来慢,实际省下的排查时间远超想象。希望这套路径能帮你少走几个弯路,把 MCP 真正用进自己的项目里。

本文还有配套的精品资源,点击获取

返回列表