在前两章里,你已经取得了巨大的进步:第 2 章里,你从一片空白起步,构建了第一个 MCP 服务器,掌握了本地与远程通信;第 3 章里,你把自定义工具与 Anthropic、Google、OpenAI 的强大 LLM 接了起来。
本质上,你已经学会了如何给 AI 赋予一项新技能。但目前这些技能还是简单的“一次性函数” :LLM 调用工具,工具完成工作并返回一个结果。很强大,但这只是开始。
如果你的工具需要提供背景信息,让 LLM 在不执行函数的前提下就能读取呢?如果它提供的数据需要根据用户 ID 或文件名而变化呢?如果你的工具执行到一半才发现缺少关键信息该怎么办?
本章将深入 Model Context Protocol 的核心,超越简单工具,探索能够实现更丰富、更动态、更具交互性的工作流的核心组件。我们要把你的服务器,从一个“命令—响应”的简单系统,升级成 LLM 的上下文感知型伙伴。
在本章结束时,你将能够:
-
通过静态资源(Resources)暴露只读数据;
-
使用资源模板(Resource Templates)创建按需动态数据;
-
用**可复用、可参数化的提示(Prompts)**指导 LLM;
-
掌握强大的 Context 对象,以便:
- 向客户端发送实时日志与进度;
- 在缺信息时通过 Elicitation(引导提问) 向用户请求补充。
你已经学会给 AI 一件工具;现在,你将学会给它一整间工具工坊。
资源与提示:构建上下文的地基
在进入更具交互性的组件之前,我们先看看除工具之外,MCP 服务器的另外两块基石:Resources 与 Prompts。
- Resources 像只读文件,向 LLM 提供数据——可把它们视为 Web API 里的 GET 请求。
- Prompts 是可复用的模板,为 LLM 提供专家级指令,并可包含用于填充细节的占位符。
我们先搭一个展示这些组件的服务器,后续章节都将以此为基础。
fastmcp 方式
创建新文件 mcp_server.py。
图 39. mcp_server.py(fastmcp)
from fastmcp import FastMCP
mcp = FastMCP("My MCP Server")
# 1. A static Resource
@mcp.resource("resource://welcome_message")
def welcome() -> str:
return "Welcome to my MCP server! Don't be naughty here."
# 2. A Resource Template
@mcp.resource("users://{user_id}/tokens")
def get_user_tokens(user_id: int) -> int:
if user_id < 10:
return 100
else:
return 8
# 3. A Prompt
@mcp.prompt
def get_employee_profile(employee_id: int) -> str:
return f"Using SQL, get the employee profile from the id: {employee_id}"
if __name__ == "__main__":
mcp.run(transport="http", port=9000)
解析:
- 静态资源:
@mcp.resource将welcome的返回值暴露出去。固定的 URIresource://welcome_message表示:任何客户端读取该资源都将得到同一条欢迎信息。 - 资源模板:看起来像资源,但 URI
users://{user_id}/tokens带有占位符。这告诉 fastmcp:这是模板。{user_id}会作为参数传入get_user_tokens,从而返回动态数据。 - 提示:
@mcp.prompt创建可复用的指令集。函数名get_employee_profile成为提示的标识。客户端可以按名称获取该提示、填入employee_id,然后把生成的指令发送给 LLM。
mcp 库方式
mcp 版本几乎一模一样,体现了 fastmcp 先行的高层 API 设计。
图 40. mcp_server.py(mcp)
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("My MCP Server", host="127.0.0.1", port=9000)
@mcp.resource("resource://welcome_message")
def welcome() -> str:
return "Welcome to my MCP server! Don't be naughty here."
@mcp.resource("users://{user_id}/tokens")
def get_user_tokens(user_id: int) -> int:
if user_id < 10:
return 100
else:
return 8
@mcp.prompt()
def get_employee_profile(employee_id: int) -> str:
return f"Using SQL, get the employee profile from the id: {employee_id}"
if __name__ == "__main__":
mcp.run(transport="streamable-http")
主要差异在导入与构造/运行配置(第 2 章已见)。@mcp.resource 与 @mcp.prompt 的核心概念保持一致。
从客户端进行交互
接下来看看客户端如何与这些新组件交互。
fastmcp 客户端
图 41. mcp_client.py(fastmcp)
import asyncio
from fastmcp import Client
from fastmcp.client.transports import StreamableHttpTransport
client = Client(transport=StreamableHttpTransport("http://localhost:9000/mcp"))
async def main():
async with client:
# List available resources
resources = await client.list_resources()
print(resources)
# Read the static resource
output = await client.read_resource("resource://welcome_message")
print(f"Resource 'welcome_message': {output[0].text}")
# Read the resource template with a specific user ID
user_id = 12
output = await client.read_resource(f"users://{user_id}/tokens")
print(f"Resource of user tokens: {output[0].text}")
# Get the prompt with a specific employee ID
output = await client.get_prompt("get_employee_profile", {"employee_id": 7})
print(f"Prompt of getting employee profile: {output.messages[0].content.text}")
asyncio.run(main())
mcp 库客户端
图 42. mcp_client.py(mcp)
import asyncio
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
MCP_SERVER_URL = "http://127.0.0.1:9000/mcp/"
async def run():
async with streamablehttp_client(MCP_SERVER_URL) as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
resources = await session.list_resources()
print(resources)
output = await session.read_resource("resource://welcome_message")
print(f"Resource 'welcome_message': {output.contents[0].text}")
user_id = 12
output = await session.read_resource(f"users://{user_id}/tokens")
print(f"Resource of user tokens: {output.contents[0].text}")
output = await session.get_prompt("get_employee_profile", {"employee_id": "7"})
print(output.messages[0].content.text)
if __name__ == "__main__":
asyncio.run(run())
运行代码:
先运行你选择的服务器脚本:> python mcp_server.py
再在新终端中运行对应的客户端脚本:> python mcp_client.py
你会看到类似输出:
图 43. 客户端输出
[Resource(name='welcome', ...), Resource(name='get_user_tokens', ...)]
Resource 'welcome_message': Welcome to my MCP server! Don't be naughty here.
Resource of user tokens: 8
Prompt of getting employee profile: Using SQL, get the employee profile from the id: 7
可以看到,客户端通过专门的方法(read_resource、get_prompt)与这些组件交互:既能直接读取静态资源,也能给资源模板与提示**“填空”**,获得定制的数据与指令。
关于类型的小提示: 你可能注意到,在 get_prompt 上,官方 MCP 规范通常期望参数值为字符串。fastmcp 更“宽松”,常会帮你做类型转换;而 mcp 库可能要求你更显式。这正是高层与低层库在哲学上的常见差异。
Context 对象:给工具装上“超能力”
Resources 与 Prompts 很强,但相对静态。MCP 最动态、最具交互性的能力由 Context 对象解锁。
当工具在运行时,它经常需要在执行期间与客户端通信:比如汇报“已完成 50%”、记录一个警告,或暂停并向用户要更多信息。**Context 对象(常简写为 ctx)**就是实现这些能力的桥梁。
你可以在工具函数签名中添加一个类型标注为 Context 的参数,或调用专门的 get_context() 函数来获取它。
日志与进度上报
想象一个耗时很长的工具:处理大数据集,或调用很慢的外部 API。如果工具静默运行,用户只能盯着空白界面,怀疑是否卡死。
MCP 通过日志与进度机制解决这一问题:你的工具可以在运行时实时回传消息。
服务器端实现
我们来写一个模拟长任务的工具,它会回传进度与日志信息。
fastmcp 服务器
图 44. logging_mcp_server.py(fastmcp)
from fastmcp import FastMCP
from fastmcp.server.dependencies import get_context
mcp = FastMCP("My MCP Server")
@mcp.resource("data://database")
def get_data() -> str:
return "Data from database"
@mcp.tool
async def call_api_somewhere() -> str:
# 1. Get the context object
ctx = get_context()
# 2. Use the context to read another resource
resources = await ctx.read_resource("data://database")
# 3. Use the context to report progress
await ctx.report_progress(progress=80, total=100, message=f"{resources[0].content} pipeline")
# 4. Use the context to send an info log
await ctx.info(f"Processing 80% of the total data")
return "Done!"
if __name__ == "__main__":
mcp.run(transport="http", port=9000)
mcp 库服务器
图 45. logging_mcp_server.py(mcp)
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("My MCP Server", host="127.0.0.1", port=9000)
@mcp.resource("data://database")
def get_data() -> str:
return "Data from database"
@mcp.tool()
async def call_api_somewhere() -> str:
# Get the context from the server instance
ctx = mcp.get_context()
resources = await ctx.read_resource("data://database")
await ctx.report_progress(progress=80, total=100, message=f"{resources[0].content} pipeline")
await ctx.info(f"Processing data")
return "Done!"
if __name__ == "__main__":
mcp.run(transport="streamable-http")
两段服务器代码非常相似。工具 call_api_somewhere 获取 ctx,并调用其方法:
ctx.read_resource():甚至可以在同一台 MCP 服务器内部读取其他资源!ctx.report_progress():发送进度更新(非常适合驱动客户端的进度条)。ctx.info():发送 info 级别日志(还支持ctx.debug()、ctx.warning()、ctx.error())。
使用 Context 的函数需要是异步的;在同步函数里无法使用 Context。
客户端实现
要接收这些消息,客户端需要提供回调处理函数——当日志或进度消息到达时会被自动调用。
fastmcp 客户端
图 46. logging_mcp_client.py(fastmcp)
import asyncio
from fastmcp import Client
from fastmcp.client.transports import StreamableHttpTransport
from fastmcp.client.logging import LogMessage
# 1. Define a handler for log messages
async def log_handler(message: LogMessage):
print(f"[{message.level}]: {message.data}")
# 2. Define a handler for progress reports
async def progress_handler(progress: float, total: float | None, message: str | None):
if total is not None:
percentage = (progress / total) * 100
print(f"Progress: {percentage:.1f}% - {message or ''}")
else:
print(f"Progress: {progress} - {message or ''}")
# 3. Pass the handlers to the Client constructor
client = Client(transport=StreamableHttpTransport("http://localhost:9000/mcp"),
log_handler=log_handler)
async def main():
async with client:
await client.call_tool("call_api_somewhere",
progress_handler=progress_handler)
asyncio.run(main())
mcp 库客户端
图 47. logging_mcp_client.py(mcp)
import asyncio
from mcp import ClientSession, types
from mcp.client.streamable_http import streamablehttp_client
# Define the log handler
async def log_handler(log_message: types.LoggingMessageNotificationParams):
print(f"[{log_message.level}]: {log_message.data}")
# Define the progress handler
async def progress_handler(progress: float, total: float | None, message: str | None):
if total is not None:
percentage = (progress / total) * 100
print(f"Progress: {percentage:.1f}% - {message or ''}")
else:
print(f"Progress: {progress} - {message or ''}")
MCP_SERVER_URL = "http://127.0.0.1:9000/mcp/"
async def run():
async with streamablehttp_client(MCP_SERVER_URL) as (read, write, _):
# Pass handlers to the session constructor
async with ClientSession(
read, write, logging_callback=log_handler
) as session:
await session.initialize()
# Pass progress handler to the tool call
await session.call_tool("call_api_somewhere",
progress_callback=progress_handler)
if __name__ == "__main__":
asyncio.run(run())
当你先运行服务器、再运行客户端时,你会实时看到从服务器返回的消息被打印出来:
图 48. 日志与进度输出
1 Progress: 80.0% - Data from database pipeline
2 [info]: Processing data
这比“黑盒沉默”的工具强太多了:你的客户端应用现在可以提供丰富而信息量十足的用户体验。
Elicitation:将独白变为对话
这或许是 Context 对象最强大的特性。Elicitation(引导提问)指的是:工具在执行过程中暂停,向用户索取更多信息的机制。
想想一个 save_file 工具:如果用户没有指定云存储提供商,工具该怎么办?猜测?失败?有了 elicitation,它可以直接发问:“你想把数据保存到哪里?Dropbox 还是 Google Drive?”
服务器端实现
我们来构建一个会向用户询问“保存到哪里”的工具。
fastmcp 服务器:
图 49. elicitation_mcp_server.py(fastmcp)
from fastmcp import FastMCP
from fastmcp.server.dependencies import get_context
from dataclasses import dataclass
mcp = FastMCP("My MCP Server")
# 1. Define the structure of the expected answer
@dataclass
class CloudInfo:
name: str
@mcp.tool
async def save_data_to_cloud() -> str:
ctx = get_context()
# 2. Elicit information from the user
result = await ctx.elicit(
message="Where do you want to save your data?",
response_type=CloudInfo
)
# 3. Handle the user's response
if result.action == "accept":
cloud = result.data
if cloud.name == "Dropbox":
return "Saving data to Dropbox"
elif cloud.name == "Google Drive":
return "Saving data to Google Drive"
elif result.action == "decline":
return "Information not provided"
else:
return "Operation cancelled"
if __name__ == "__main__":
mcp.run(transport="http", port=9000)
mcp 库服务器:
图 50. elicitation_mcp_server.py(mcp)
from mcp.server.fastmcp import FastMCP
from pydantic import BaseModel, Field
mcp = FastMCP("My MCP Server", host="127.0.0.1", port=9000)
class CloudInfo(BaseModel):
name: str = Field(description="The name of the cloud")
@mcp.tool()
async def save_data_to_cloud() -> str:
ctx = mcp.get_context()
result = await ctx.elicit(
message="Where do you want to save your data?",
schema=CloudInfo
)
if result.action == "accept":
cloud = result.data
if cloud.name == "Dropbox":
return "Saving data to Dropbox"
elif cloud.name == "Google Drive":
return "Saving data to Google Drive"
elif result.action == "decline":
return "Information not provided"
else:
return "Operation cancelled"
if __name__ == "__main__":
mcp.run(transport="streamable-http")
关键步骤:
- 定义模式(Schema) :定义你期望用户返回的数据结构。fastmcp 使用 Python dataclass,mcp 库使用 Pydantic BaseModel,效果一致。
- 调用
ctx.elicit():该方法把问题发给客户端,并暂停工具执行,等待用户回应。 - 处理结果:返回的
result告知用户的动作:"accept"、"decline"或"cancel"。若为"accept",data属性中包含已校验的用户响应,并被解析为你的 dataclass 或 Pydantic 模型。
客户端实现
与日志类似,客户端需要一个 elicitation 处理器:这个函数负责实际向用户提问并把他们的答案回传给服务器。
fastmcp 客户端:
图 51. elicitation_mcp_client.py(fastmcp)
import asyncio
from fastmcp import Client
from fastmcp.client.transports import StreamableHttpTransport
from fastmcp.client.elicitation import ElicitResult
# 1. The elicitation handler
async def elicitation_handler(message: str, response_type: type, params, context):
user_input = input(f"{message}: ")
response = {"name": user_input}
# 2. Return the user's accepted response
return ElicitResult(action="accept", content=response)
client = Client(transport=StreamableHttpTransport("http://localhost:9000/mcp"),
elicitation_handler=elicitation_handler)
async def main():
async with client:
result = await client.call_tool("save_data_to_cloud")
print(result)
asyncio.run(main())
mcp 库客户端:
图 52. elicitation_mcp_client.py(mcp)
import asyncio
from mcp import ClientSession, types
from mcp.client.streamable_http import streamablehttp_client
async def elicitation_handler(context, params):
user_input = input(f"{params.message}: ")
response = {"name": user_input}
return types.ElicitResult(action="accept", content=response)
MCP_SERVER_URL = "http://127.0.0.1:9000/mcp/"
async def run():
async with streamablehttp_client(MCP_SERVER_URL) as (read, write, _):
async with ClientSession(
read, write, elicitation_callback=elicitation_handler
) as session:
await session.initialize()
result = await session.call_tool("save_data_to_cloud")
print(result)
if __name__ == "__main__":
asyncio.run(run())
交互流程非常有趣:
- 客户端发送
call_tool请求; - 服务器端工具运行并触发
ctx.elicit(); - 服务器向客户端发回elicitation 请求;
- 客户端的
elicitation_handler被触发,打印提示并等待input(); - 你输入 “Google Drive” 并回车;
- 处理器把答案封装为
ElicitResult回传给服务器; - 服务器恢复执行,
result.action == "accept",result.data.name == "Google Drive"; - 工具返回最终字符串,客户端打印结果。
输出示例:
1 Where do you want to save your data?: Google Drive
2 CallToolResult(content=[TextContent(type='text', text='Saving data to Google Drive', annotations=None, meta=None)], structured_content={'result': 'Saving data to Google Drive'}, data='Saving data to Google Drive', is_error=False)
若使用 mcp 库,输出如下:
图 53. Elicitation 输出
1 meta=None content=[TextContent(type='text', text='Saving data to Google Drive', annotations=None, meta=None)] structuredContent={'result': 'Saving data to Google Drive'} isError=False
你刚刚创建了一个交互式、对话式的工具。
关键要点(Key Takeaways)
-
Resources vs. Tools:你已经理解两者的区别。Resources 用于提供只读数据(类似 GET),Tools 用于执行动作(类似 POST)。
-
静态 vs. 动态数据:用静态资源暴露固定数据;用资源模板按需、参数化地生成数据。
-
引导 LLM:Prompts 让你把专家级指令直接嵌入服务器,形成可复用且有效的交互模式。
-
Context 的力量:
ctx是高级特性的钥匙:- 日志与进度:在长流程中向用户提供实时反馈;
- Elicitation(引导提问) :在缺少信息时,把一次工具调用变成互动对话。
你已经掌握了构建复杂、交互式 MCP 服务器的能力。但它们仍运行在本地,通过临时的 ngrok 隧道暴露到公网。这适合开发,却不是生产方案。
下一章,我们将把这些强大的服务器稳健地部署到云端:学习如何打包部署、在 Google Cloud Run 等平台运行,并通过正确的身份验证保障安全,把你的开发项目升级为生产级 AI 服务。