文章系统讲解 MCP 解决的问题、Host—Client—Server 架构,以及工具、资源和提示词等核心概念。内容进一步关联 Python 实现真实 MCP 服务所需的进阶能力,目标是建立可直接动手开发的工程认知。
如果你在过去一年里接触过 AI 工程,那么你很可能经常听到 MCP(Model Context Protocol,模型上下文协议)这个词。也许你曾在 GitHub README、Anthropic 的博客文章中看到它,或在 Slack 上读到某位同事发来的消息——他只用一个下午,就让某个由 Claude 驱动的工具连接上了公司的内部数据库。如果你是一名 Python 开发者,很可能还思考过:Python 在这一切中究竟扮演着什么角色?为什么它似乎成了构建 MCP 服务器的首选语言?
本文将深入且务实地回答这两个问题。我们会依次讲解 MCP 究竟是什么、它为何存在、它的架构如何运作、为什么 Python 天然适合实现 MCP,以及当你开始构建真正的 MCP 服务器,而不再只是编写玩具示例时,实际会用到哪些高级 Python 概念。读完本文后,你应该能够建立一套足够扎实的思维模型,可以着手进行构建,也可以在面试中真正自信地讨论这些内容。
MCP 旨在解决的问题
核心架构:宿主、客户端与服务器
工具、资源与提示词——MCP 的三种原语
传输方式:stdio 与 Streamable HTTP
为什么 Python 成了 MCP 服务器的默认开发语言
使用 Python 构建你的第一个 MCP 服务器
MCP 服务器中真正会用到的高级 Python 模式
安全、身份验证与生产环境问题
MCP 与 REST API、函数调用的对比
在讨论 MCP 是什么之前,有必要先理解最初促使它诞生的问题。
大型语言模型在推理、写作和语言理解方面拥有非凡的能力,但它们本身是孤立的。模型无法访问你公司的 Jira 看板、生产数据库、文件系统,也无法访问你希望它在推荐穿搭之前查询的天气 API。为了在实际工作流中真正发挥作用,AI 应用需要突破自身边界,与外部世界交互:读取文件、查询数据库、调用 API、触发自动化流程。
很长一段时间里,每个 AI 应用都以自己专属的方式解决这个问题。如果你正在构建一个需要与 GitHub 交互的聊天助手,就必须编写仅适用于该应用的自定义集成代码。如果另一个团队也希望自己的助手与 GitHub 交互,他们同样需要编写一套自定义集成代码,其中大部分逻辑都是重复的。将这种情况扩展到数十个应用和数十种工具——Slack、Notion、Postgres、Salesforce、内部 API——就会产生所谓的 M×N 集成问题:M 个应用分别需要为 N 种工具编写自定义代码,最终形成 M 乘以 N 项相互独立的集成工作。
这正是多年前语言服务器协议(Language Server Protocol,LSP)为代码编辑器和语言工具解决的问题。在 LSP 出现之前,每个 IDE 都必须针对每种编程语言分别实现自动补全、代码检查和跳转到定义等功能。LSP 一次性标准化了这个接口,从此之后,任何兼容 LSP 的编辑器都可以与任何兼容 LSP 的语言服务器通信,无需再编写自定义的粘合代码。
MCP 做了同样的事情,只不过它面向的是 AI 应用以及这些应用需要访问的工具和数据。集成工作不再是 M×N,而是 M+N:应用只需实现一次 MCP 客户端,工具提供方只需实现一次 MCP 服务器,双方就能立即通信,无论它们分别由谁构建。
Model Context Protocol 是一种开放的标准化协议,最初由 Anthropic 推出,如今作为开放规范进行维护。它定义了 AI 应用如何连接外部上下文。这里的“上下文”分为三种形式,我们稍后会详细介绍:模型可以调用的工具、可以读取的资源,以及用户可以触发的提示词模板。
从本质上讲,MCP 是一种基于 JSON-RPC 2.0 的客户端—服务器协议。JSON-RPC 2.0 是一种轻量、成熟且易于理解的消息格式,多年来一直用于开发者工具。MCP 服务器是一个小型、专注的程序,用于开放某种能力,例如查询 Postgres 数据库、搜索代码库或获取天气数据。嵌入 AI 应用内部的 MCP 客户端会连接到该服务器,发现它能够执行哪些操作,并将模型或用户的请求路由给它。
MCP 设计中真正巧妙的地方,在于服务器能够进行自我描述。客户端建立连接时,不需要预先编写好的文档,也不需要对服务器的功能做任何硬编码假设;它会直接询问服务器,服务器则以结构化、机器可读的形式返回自己提供的每一种工具、资源和提示词的描述,其中还包括工具参数的完整 JSON Schema 定义。这使语言模型可以动态判断应该调用哪个工具,以及如何正确调用它,而不需要人工为该服务器编写任何专用集成代码。
MCP 的架构包含三种彼此独立的角色。理清这些角色,是理解后续所有内容的关键。
宿主(Host)就是 AI 应用本身,也就是最终用户实际与之交互的对象。它可以是聊天界面、IDE、自主 AI 智能体框架,也可以是命令行工具。宿主持有语言模型、管理整个对话,并最终负责执行权限控制,以及决定在任意时刻启用哪些 MCP 服务器。
客户端(Client)位于宿主内部,负责管理与恰好一台服务器之间有状态的连接。如果宿主需要与三台不同的 MCP 服务器通信——例如一台用于 GitHub、一台用于数据库,另一台用于内部文档——它就会启动三个独立的客户端实例,每个实例分别维护自己的会话、握手状态和消息路由。
服务器(Server)是一个独立进程,通常与宿主位于完全不同的代码库中,甚至可能使用不同的编程语言编写,负责实际实现某种能力。例如,GitHub MCP 服务器会封装 GitHub API,并将“列出未关闭的 issue”或“创建拉取请求”等操作作为可发现的工具开放出来。
这种分离比乍看之下更加重要。由于服务器是拥有标准化接口的独立进程,同一个 GitHub MCP 服务器可以接入由不同团队构建的、完全不同的 AI 应用,而任何一方都不需要了解另一方的内部实现。这还意味着,服务器可以使用 Python 编写,而宿主应用使用 TypeScript 编写,反之亦然。协议并不关心这些差异,因为所有通信都通过 JSON-RPC 完成。
客户端首次连接服务器时,双方会通过 initialize 请求执行握手。客户端声明自己支持的协议版本,以及能够理解的可选能力,例如下文将介绍的 sampling 或 roots。服务器则返回自己支持的版本和提供的能力。此后,双方只会使用另一方明确同意支持的功能。正是这种机制,使协议能够不断演进,同时不会破坏较旧的实现。
MCP 服务器开放的所有内容都属于以下三种类别之一。它们之间的区别并非纯粹的学术划分,而是具有实际价值。
工具(Tools)由模型控制——语言模型会根据对话内容和工具描述,自行决定何时调用它们。工具本质上是一个函数:它拥有名称、用于说明其作用及适用时机的自然语言描述,以及一个使用 JSON Schema 编写、描述预期参数的 inputSchema。模型判断某个工具与当前任务相关时,宿主会发送包含模型所生成参数的 tools/call 请求,服务器执行底层逻辑,再将结果返回到模型的上下文中。优秀的工具设计确实是一门艺术:描述含糊或功能范围过大的工具,例如通过一个 mode 标志执行五种不同操作的单一工具,往往会导致调用不可靠且难以预测。实践中,功能聚焦、命名清晰且文档完善的工具,效果要好得多。
资源(Resources)是由应用控制、可通过地址访问的数据片段,可以将它们理解为 MCP 中的 GET 请求。每项资源都有一个 URI,例如 file:///project/notes.md、postgres://orders/12345,也可以采用完全自定义的 scheme。资源可以通过 resources/list 列出,并通过 resources/read 获取。与工具不同,资源并不是用来接受参数并通过“调用”触发某项操作的;它们旨在供应用读取,很像静态或半静态的上下文。宿主可能希望将这些内容加入对话,而不需要模型明确提出请求。
提示词是由用户控制的模板——可复用、参数化的交互模式,由人类显式触发,通常会以类似斜杠命令的形式呈现。例如,一个“总结此支持工单”的提示词模板可以将工单 ID 作为参数,并将其展开为一个结构完整的请求,让模型每次都能以一致的方式处理,而不受特定用户手动表述同一请求时所用措辞的影响。
每种原语分别由谁控制——模型、应用程序还是用户——是这里的关键设计洞见。它清晰地对应了你希望在每一层授予多大程度的自主权;一旦开始设计自己的服务器,你会发现这种区分会反复出现。
MCP 在消息层面与传输方式无关——所有内容都采用 JSON-RPC——但在实际应用中,主要有两种传输方式。
当服务器作为本地子进程运行,并由宿主直接启动时,会使用 stdio。消息通过标准输入和标准输出流进行交换。这是最简单的配置:不涉及网络、不需要身份验证层,也没有 TLS 证书需要管理。它非常适合本地开发者工具——例如,由代码编辑器启动一个文件系统访问服务器——在这种场景下,服务器和宿主运行于同一台机器,并使用同一用户的权限。
当服务器位于远程时,会使用 Streamable HTTP(它已在很大程度上取代早期的 HTTP+SSE 传输方式)。这类服务器可能为许多不同用户提供服务,并作为独立部署和扩缩容的服务运行。这种传输方式支持完善的身份验证(通常是 OAuth 2.1,包括动态客户端注册和 PKCE)、在负载均衡器后进行水平扩展,以及通过单个 HTTP 连接传输长时间持续的流式响应。对于耗时较长的工具调用,这一点非常重要,因为服务器可以发送增量进度通知,而不是让用户只能盯着一个毫无反馈的加载动画。
如何在两者之间选择,主要取决于部署拓扑。如果你正在构建一款完全运行于自己机器上的个人效率工具,那么 stdio 更简单,也完全够用。如果你构建的服务器需要供许多不同用户或组织使用——也就是只部署一次,其他人便可远程连接——那么带有完善身份验证机制的 Streamable HTTP 才是正确选择。
如果你观察如今的 MCP 生态系统,会发现无论是官方服务器还是社区构建的服务器,都有相当高的比例使用 Python 编写。这并非偶然,其中的原因值得理解,尤其是在你需要决定自己的项目该选用哪种语言时。
首先,Python 已经是 AI/ML 生态系统中的主导语言。构建 MCP 服务器的人,往往也正是那些已经拥有基于 Python 的数据流水线、ML 模型或后端服务的人。将现有 Python 代码库的功能封装为 MCP 服务器,通常只需在已有代码之上添加一层轻量的协议层,而无须重写。
其次,Python 官方 MCP SDK,尤其是 FastMCP 高级 API,大幅减少了样板代码。只需一个装饰器,就能将普通 Python 函数转换为完全符合规范的 MCP 工具;SDK 还会根据类型提示和文档字符串自动生成 JSON Schema。这种符合人体工程学、由装饰器驱动的 API 与 Python 现有的惯用方式非常契合(想想 Flask、FastAPI 和 Click),使编写第一个服务器的门槛变得极低。
第三,Python 的 asyncio 生态系统与 MCP 以 I/O 为主的特性非常契合。大多数 MCP 服务器绝大部分时间都在等待 I/O——数据库查询、对第三方 API 的 HTTP 调用、文件读取——而不是执行 CPU 密集型工作。这正是 asyncio 所针对的工作负载,而且 Python 的异步生态系统(httpx、asyncpg、aiofiles 等)已经足够成熟,因此构建一个真正支持并发且行为良好的服务器,并不需要重新发明任何东西。
第四,Python 庞大的库生态意味着,几乎任何你想要封装的外部系统都已经有了支持良好的客户端库。无论你围绕 SQL 数据库、云服务提供商的 API,还是内部 REST 服务构建 MCP 服务器,都很可能已有经过实战检验的 Python 包可供使用。这意味着你的 MCP 服务器可以只是一个轻量、可靠的封装层,而不必从头构建。
这些都不意味着 Python 是唯一的好选择——TypeScript、Java、C# 和 Kotlin 也有官方 SDK,而且许多生产环境中的服务器出于充分理由使用这些语言编写,例如类型安全、与现有代码库保持一致或性能特征。不过,如果你想快速构建原型、封装现有的数据或 ML 基础设施,并利用大量现成示例和社区支持,那么 Python 往往是阻力最小的路径。
让我们把这些概念落到实处。下面是一个使用官方 Python SDK 的 FastMCP 接口构建的极简但真正可用的 MCP 服务器:
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("weather-server")
@mcp.tool()
def get_forecast(city: str) -> str:
"""Return a short weather forecast for a given city."""
# In a real server, this would call an actual weather API
return f"Sunny in {city}, 28°C"
@mcp.resource("config://settings")
def get_settings() -> str:
"""Expose current server configuration as a readable resource."""
return "units=metric;language=en"
if __name__ == "__main__":
mcp.run(transport="stdio")
这里有几点值得注意。@mcp.tool() 装饰器承担了主要工作:它会检查函数的类型提示(city: str),构建描述预期参数的 JSON Schema,并使用文档字符串作为工具描述——这些正是语言模型判断何时以及如何调用该工具所需要的元数据。类似地,@mcp.resource() 装饰器会通过由你自行定义的 URI 方案,公开一项可读取的数据。
在客户端,连接并调用这个服务器的代码如下:
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
params = StdioServerParameters(command="python", args=["weather_server.py"])
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await session.list_tools()
result = await session.call_tool("get_forecast", {"city": "Bengaluru"})
print(result)
这确实就是完整的调用流程:启动服务器或连接到服务器,通过 initialize() 完成握手,发现它所提供的能力,然后发起调用。关于模式验证、JSON-RPC 消息分帧以及请求与响应关联的所有工作,都由底层 SDK 负责处理。
从“这个玩具示例”到“生产级服务器”的差距,主要在于你在工具函数内部实现了什么——完善的错误处理、输入验证、身份验证、日志记录,以及我们接下来将介绍的高级 Python 模式。
一旦越过 hello-world 示例阶段,就会发现一些高级 Python 概念反复出现在实际的 MCP 服务器代码中。
全链路异步。 由于大多数工具实现都将时间花在等待网络或磁盘 I/O 上,因此你应该使用 async def 定义工具函数,并采用原生异步库(例如使用 httpx.AsyncClient 而不是 requests,使用 asyncpg 而不是阻塞式 Postgres 驱动)。异步工具函数中的一次阻塞调用,就可能阻塞整个事件循环,在不易察觉的情况下拖慢服务器正在处理的所有其他并发请求。
使用上下文管理器管理资源生命周期。 数据库连接、HTTP 客户端会话和文件句柄都适合通过 async with 进行恰当的上下文管理。这不仅有助于确保正确性,还能保证即使工具调用在执行中途抛出异常,连接也会被妥善清理。
import httpx
from contextlib import asynccontextmanager
@asynccontextmanager
async def http_client():
client = httpx.AsyncClient(timeout=10.0)
try:
yield client
finally:
await client.aclose()
使用 Pydantic 模型验证工具输入。 虽然 FastMCP 会自动根据类型提示生成基础 JSON Schema,但在真实场景中,工具通常能从显式的 Pydantic 模型中受益——你可以获得更丰富的验证能力(取值范围、自定义验证器、嵌套结构),并且当模型生成的调用与预期结构不完全一致时,还能得到清晰得多的错误信息。
结构化错误处理可以向模型呈现有用的信息。一个常见错误是任由未处理的异常向上传播,导致整个工具调用崩溃。正确的做法是捕获预期的失败情况,并将清晰、可操作的错误消息作为工具结果的一部分返回——这样模型就能准确了解发生了什么,并决定是重试、调整处理方式,还是请用户进一步说明。
@mcp.tool()
async def query_database(sql: str) -> str:
"""Run a read-only SQL query against the analytics database."""
try:
rows = await run_query(sql)
return format_rows(rows)
except QuerySyntaxError as exc:
return f"Error: invalid SQL syntax — {exc}"
except PermissionError:
return "Error: this query touches a restricted table."
使用装饰器处理横切关注点。在真实服务器中,日志记录、速率限制、重试和权限检查往往会在多个工具中重复出现,因此它们很适合实现为叠加在 @mcp.tool() 之上(或与其并列使用)的装饰器。
import functools
import logging
def logged(func):
@functools.wraps(func)
async def wrapper(*args, **kwargs):
logging.info(f"Calling {func.__name__} with {kwargs}")
result = await func(*args, **kwargs)
logging.info(f"{func.__name__} returned successfully")
return result
return wrapper
使用 dataclass 管理结构化的内部状态。对于维护任何形式的会话状态或缓存状态的服务器,相比松散的字典,dataclass 更有优势——你可以获得类型安全、便于调试的自动生成 __repr__,以及更清晰的数据契约,明确某块状态实际包含哪些数据。
谨慎使用 functools.lru_cache 缓存开销较大的纯计算——但仅限真正纯粹且具有确定性的操作,因为如果缓存依赖外部状态的内容(例如实时 API 调用),系统就会在不知不觉中返回过期数据。
这些都不是什么奇特的技术——它们是标准且经过充分验证的 Python 实践。值得注意的是,当你从“返回硬编码字符串的玩具工具”迈向“在真实并发负载下与真实数据库交互的工具”时,这些实践会如此稳定地反复出现。
安全问题值得专门讨论一下,因为 MCP 服务器处于一个不同寻常的信任位置:它们拥有主机授予的所有权限,而它们的输出——工具描述、资源内容和错误消息——最终都会成为模型上下文的一部分。
这意味着,设计不当或恶意的服务器可能会尝试实施提示词注入:刻意编写工具描述或返回数据,试图操纵模型后续的行为。实际的防御方式与处理不可信输入的其他系统相同:在服务器端验证并清理所有内容,而不是信任模型生成的参数;对服务器持有的凭据应用最小权限原则;使用 MCP 的 roots 功能,将文件系统访问范围限制在真正需要的部分;在执行任何破坏性操作(删除数据、发送电子邮件、进行购买)之前,要求用户明确确认。
对于基于 HTTP 的远程服务器,身份认证应采用 MCP 规定的 OAuth 2.1 流程,获取有效期较短、权限范围适当的令牌,而不是直接将长期有效的 API 密钥写入服务器配置——一旦配置泄露,这种错误做法会显著扩大影响范围。
在运维方面,生产环境中的 MCP 服务器与任何后端服务一样,都受益于严格规范的工程实践:对工具调用进行结构化日志记录(并对敏感字段做脱敏处理);实施速率限制,防止失控的智能体循环持续冲击下游系统;将会话状态保存在外部系统(Redis、数据库)中,而不是本地进程内存里,以便服务能够水平扩展;实施明确的版本控制,使主机能够在服务器持续演进时固定使用已知可靠的 schema。
一个经常被问到的问题是:如果 MCP 本质上是一种公开函数和数据的标准化方式,那么它与 REST API,或者大多数 LLM API 已经支持的原生“Function Calling”功能有什么区别?
传统 REST API 面向的是人类开发者:开发者需要阅读文档,然后针对固定端点手动编写集成代码。它没有内置机制让客户端动态发现有哪些能力可用,或者应该如何调用——这些知识存在于 API 之外的文档中。
大多数 LLM API 直接提供的原生 Function Calling,允许单个应用程序定义供特定模型调用的工具——但这些工具定义完全绑定在该代码库中。如果另一个应用程序想使用相同的功能,就必须从头重新实现工具定义及其底层逻辑。
MCP 介于两者之间,既解决了 REST 缺少的发现问题,也解决了专有 Function Calling 缺少的可移植性问题。客户端可以在运行时询问服务器提供了哪些能力,并接收模型能够直接操作的机器可读 schema——不需要阅读文档,也不需要每个应用程序都重新实现一遍。无论服务器由谁构建,同一个服务器都可以接入任何兼容 MCP 的主机。MCP 还将 sampling(由服务器请求模型生成内容)和变更通知等双向能力标准化,而这些能力都超出了 REST 或基础 Function Calling 所处理的范围。
MCP 仍然是一个年轻的协议,并且正在快速演进。有几个趋势值得关注:随着越来越多的公司为自家产品提供官方 MCP 服务器,Streamable HTTP 传输方式将在远程、多租户服务器中得到更广泛的采用;围绕身份认证和企业级访问控制的标准化程度不断提高;涵盖数据库、SaaS 工具和开发者平台的社区服务器公共注册中心持续壮大;随着越来越多的主机开始实现完整规范,而不只是基础功能,sampling 和 roots 等特性也将继续得到完善。
具体到 Python 开发者,这意味着原生支持 async 的客户端库、SDK 易用性和相关工具(例如用于交互式测试服务器的 MCP Inspector)可能会继续快速成熟——如果你现在开始入场,这是个好消息,因为从“hello world”到“生产就绪”之间的工具链差距正在不断缩小。
MCP 代表了 AI 应用程序连接外部世界方式上的一次真正有价值的转变