python-sdk 中的 Elicitation 机制:让 MCP 工具在调用中途向用户提问
【免费下载链接】python-sdkThe official Python SDK for Model Context Protocol servers and clients项目地址: https://gitcode.com/gh_mirrors/pythonsd/python-sdk
导读
本文围绕官方 Python SDK(Model Context Protocol 的 Python 实现)中的Elicitation(引导式提问)能力展开。它解决的是一个非常具体的实战场景:一个 tool 执行到一半、只差一个关键信息(用户确认、备选日期、支付授权)时,不必让整个调用失败,而是可以在调用中途向用户提问,并把答案带回同一个函数调用继续执行。读完本文,你将掌握两种提问模式(Form 表单模式与 URL 跳转模式)、两种提问方式(resolver 参数注入与ctx.elicit直接调用)的完整用法、客户端elicitation_callback的注册与分支处理,以及它们在 legacy(2025-11-25)与 2026-07-28 两代协议连接下的行为差异。
核心概念:两种模式、两种提问方式
原文档开篇即点出 Elicitation 的两个基本维度:
两种模式(mode):
- Form mode(表单模式):你需要一个具体值——确认、日期、数量。你在服务端描述字段(一个 Pydantic model),由客户端渲染成表单让用户填写。
- URL mode(URL 模式):你需要用户离开当前上下文去别处完成一件事(OAuth 授权页、支付页)。用户在那里做的一切都不经过 MCP 协议回传——这是处理凭据、卡号、授权等敏感信息的唯一正确姿势。
两种提问方式(way to ask):
- resolver(推荐优先使用):把问题"挂"在一个参数上,由 SDK 代为提问。它在任何连接、任何协议代际的 client 上都能工作。
await ctx.elicit(...)(直接方式):这是从server到client的请求(server-initiated request),该通道只存在于 legacy 连接(spec version 2025-11-25 或更早)上。
下文先讲 resolver,再讲 tool 内部直接提问,最后讲客户端如何应答。
用 resolver 提问:把问题挂到参数上
当一个"拦路问题"(确定吗?三个相似账户选哪个?)会阻塞整个 tool 时,正确做法是把它从 tool body 中提取出来放进 resolver,由框架替你提问。
核心 API:Annotated[T, Resolve(fn)]
用Annotated[T, Resolve(fn)]标注的参数,会在 tool body 执行之前由fn填充。resolver 的返回值有两种可能:
- 已经知道答案:直接返回该值(
Confirm(ok=True)),framework 直接注入,不发任何 round-trip; - 不知道答案:返回
Elicit(...),framework 据此向客户端提问,并把结果注入。
这两个关键类型的源码定义在 src/mcp/server/mcpserver/resolve.py:
class Resolve: """Marker for `Annotated[T, Resolve(fn)]`: fill the parameter by running `fn`.""" def __init__(self, fn: Callable[..., Any]) -> None: self.fn = fn class Elicit(Generic[T]): """A resolver's request to ask the client. Returned from a resolver to signal that the value must be elicited. The framework runs `ctx.elicit(message, schema)` and injects the outcome. """ def __init__(self, message: str, schema: type[T]) -> None: self.message = message self.schema = schema完整示例:删除文件夹前的确认
原文档配套的完整代码位于 docs_src/elicitation/tutorial004.py:
from typing import Annotated from pydantic import BaseModel from mcp.server import MCPServer from mcp.server.mcpserver import ( AcceptedElicitation, CancelledElicitation, DeclinedElicitation, Elicit, ElicitationResult, Resolve, ) mcp = MCPServer("Files") _FOLDERS: dict[str, list[str]] = {"/tmp/empty": [], "/tmp/project": ["main.py", "README.md"]} class Confirm(BaseModel): ok: bool async def confirm_delete(path: str) -> Confirm | Elicit[Confirm]: """Resolver: ask for confirmation only when the folder is not empty.""" file_count = len(_FOLDERS.get(path, [])) if file_count == 0: return Confirm(ok=True) # nothing to confirm, no round-trip to the client return Elicit(f"{path} has {file_count} file(s). Delete anyway?", Confirm) @mcp.tool() async def delete_folder( path: str, confirm: Annotated[ElicitationResult[Confirm], Resolve(confirm_delete)], ) -> str: """Delete a folder, asking for confirmation when it is not empty.""" match confirm: case AcceptedElicitation(data=Confirm(ok=True)): _FOLDERS.pop(path, None) return f"deleted {path}" case AcceptedElicitation(): return "kept the folder" case DeclinedElicitation(): return "declined: folder not deleted" case CancelledElicitation(): return "cancelled: folder not deleted"这个示例的三个要点(原文档逐条列出):
confirm_delete通过名称读取 tool 自己的path参数,列出文件夹内容,并且只在必须时才 elicit——空文件夹在没有任何客户端 round-trip 的情况下直接 resolve 为Confirm(ok=True);delete_folder用ElicitationResult[Confirm]做注解,framework 会注入完整结果,tool 用match覆盖每一种情况:接受并确认(ok=True)、接受但保留(ok=False)、拒绝(decline)、取消(cancel);confirm参数永远不会出现在 tool 的 input schema 里——客户端提供path,resolver 提供confirm,二者职责分离。
不需要分支时的简化写法
如果 tool 不需要针对不同结果分支,可以直接注解未包装的 model:Annotated[Confirm, Resolve(confirm_delete)]。此时接受(accept)时 tool 收到Confirm实例;拒绝或取消时整个调用以错误中止。
resolver 在两种协议连接上都能工作
原文档强调:resolver 在每一条连接上都有效。
- 对 legacy 连接上的 client,SDK 直接把问题发过去(走 server-to-client 通道);
- 对2026-07-28连接,SDK 从调用中返回问题(
InputRequiredResult机制),client 的下一次尝试把答案带回来。
你的 resolver 代码永远感知不到这两种底层的差异——差异完全由 SDK 处理。底层机制即文档中反复引用的Multi-round-trip requests(多次往返请求)。客户端侧的配套实现与测试见 tests/client/test_input_required.py 与 tests/client/test_client.py,其中test_call_tool_auto_loop_dispatches_elicitation_then_returns_final_result演示了 server 返回携带 elicitation 的InputRequiredResult时,Client.call_tool如何路由到elicitation_callback并自动重试。
resolver 能做的远不止"提问"。通用的机制——无需提问即可计算的依赖、依赖的依赖、model 能提供什么不能提供什么——见Dependencies页面。
在 tool 内部直接提问
tool 也可以在自己的 body 中间停下来提问,直接调用ctx.elicit()。
⚠️重要警告(原文档原文强调)
ctx.elicit()与ctx.elicit_url()是server到client的请求——这个通道只存在于 legacy 连接(spec version2025-11-25或更早)上的 client。在2026-07-28连接上没有 server 主动发起的请求,因此这些调用会失败。resolver 则在两种连接上都可用。完整背景见Protocol versions。
完整示例:餐馆订位
await ctx.elicit()接收一个 message 和一个 Pydantic model,配套示例在 docs_src/elicitation/tutorial001.py:
from pydantic import BaseModel, Field from mcp.server import MCPServer from mcp.server.mcpserver import Context mcp = MCPServer("Bistro") class AlternativeDate(BaseModel): accept_alternative: bool = Field(description="Try another date?") date: str = Field(default="2025-12-26", description="Alternative date (YYYY-MM-DD)") @mcp.tool() async def book_table(date: str, party_size: int, ctx: Context) -> str: """Book a table at the bistro.""" if date != "2025-12-25": return f"Booked a table for {party_size} on {date}." result = await ctx.elicit( message=f"No tables for {party_size} on {date}. Would you like to try another date?", schema=AlternativeDate, ) if result.action == "accept" and result.data.accept_alternative: return await book_table(result.data.date, party_size, ctx) return "No booking made."要点逐条拆解:
Context参数就是ctx.elicit的来源;任何 tool 都可以接收一个Context。该对象的完整文档见Context。Context.elicit与Context.elicit_url的签名定义在 src/mcp/server/mcpserver/context.py,它们内部最终会走到 src/mcp/server/elicitation.py 的elicit_with_validation/elicit_url辅助函数,再经 src/mcp/server/session.py 的elicit_form/elicit_url发送elicitation/create请求。AlternativeDate是你期望答案的 schema——客户端会照它渲染表单。- tool 必须是
async def:它要在中途停下来等待一个真人。 - 只在必要时提问:任何其他日期 tool 直接返回,绝不打扰用户。
- 答案也是输入:用户接受的日期仍然要重新流经
book_table本身。如果备选日期同样被订满,会再次提问,而不是盲目确认——答案和任何其他输入一样需要被业务逻辑二次校验。
客户端收到的内容
客户端收到你的 message,以及由 model 生成的 JSON Schema(这正是原文档给出的真实 wire 格式):
{ "properties": { "accept_alternative": { "description": "Try another date?", "title": "Accept Alternative", "type": "boolean" }, "date": { "default": "2025-12-26", "description": "Alternative date (YYYY-MM-DD)", "title": "Date", "type": "string" } }, "required": ["accept_alternative"], "title": "AlternativeDate", "type": "object" }这张 schema 就是表单本身:
Field(description=...)是表单的 label;default预填输入框,并让该字段变为可选(不出现在required中);- 这正是Tools中描述的同一套 Pydantic-to-JSON-Schema 机制。
schema 的边界:只能是扁平的 primitive 字段
⚠️警告(原文档原文强调)elicitation schema没有 tool 的 input schema 那么强大。只支持扁平的 primitive 字段:
str、int、float、bool,或字符串的Literal(渲染成enum)。如果在 model 里再嵌套 model,ctx.elicit会在向客户端发送任何内容之前就抛出异常。tool call 以Error executing tool <name>失败,原因在 server 日志里:TypeError: Elicitation schema field 'address' rendered as {'$ref': '#/$defs/Address'}, which is not a valid PrimitiveSchemaDefinition你是在打断一个正在进行中的人。如果答案需要嵌套结构,那它当初就应该设计成 tool 的参数。
从源码看,这一限制由 src/mcp/server/elicitation.py 中的render_elicitation_schema+_validate_rendered_properties强制实施:渲染出的每个properties条目都要通过mcp_types._v2025_11_25.PrimitiveSchemaDefinition的 TypeAdapter 校验,不合法即抛TypeError。_ElicitationJsonSchema生成器还会把T | None展平为T、丢弃值为None的 default,以严格符合 spec 对PrimitiveSchemaDefinition的定义。
三种回答
result.action告诉你用户做了什么,可能性恰好三种:
action | 含义 | result.data |
|---|---|---|
"accept" | 用户提交了表单 | 是——一个已校验的AlternativeDate实例 |
"decline" | 用户拒绝了 | 无 |
"cancel" | 用户未选择直接关闭了问题 | 无 |
result.data只在"accept"时存在,所以示例总是先检查result.action。类型检查器会强制这个顺序:在result.action == "accept"之后,result.data才是AlternativeDate;在此之前根本不存在.data。
这三个结果类型的源码定义在 src/mcp/server/elicitation.py:
class AcceptedElicitation(BaseModel, Generic[ElicitSchemaModelT]): """Result when user accepts the elicitation.""" action: Literal["accept"] = "accept" data: ElicitSchemaModelT class DeclinedElicitation(BaseModel): """Result when user declines the elicitation.""" action: Literal["decline"] = "decline" class CancelledElicitation(BaseModel): """Result when user cancels the elicitation.""" action: Literal["cancel"] = "cancel" ElicitationResult = TypeAliasType( "ElicitationResult", AcceptedElicitation[ElicitSchemaModelT] | DeclinedElicitation | CancelledElicitation, type_params=(ElicitSchemaModelT,), )注意:拒绝不是错误。decline 意味着什么由 tool 自己决定(本例中是不做预订),tool 正常地回复 model。但返回的答案在到达你的代码之前会先对照你的 model 校验——一个给bool字段发"maybe"的客户端不会破坏你的预订:ctx.elicit会抛ValueError,调用失败,你的if分支永远不会执行(这正是 src/mcp/server/elicitation.py 中elicit_with_validation的校验路径)。
把用户送到 URL:URL 模式
有些东西绝不能经过 model 或 client:凭据(credentials)、卡号(card numbers)、OAuth 授权。对这类场景,你不是要数据,而是请用户去某个地方完成操作。
完整示例:支付押金
配套示例在 docs_src/elicitation/tutorial002.py:
from mcp.server import MCPServer from mcp.server.mcpserver import Context mcp = MCPServer("Bistro") @mcp.tool() async def pay_deposit(booking_id: str, ctx: Context) -> str: """Take the deposit that confirms a booking.""" result = await ctx.elicit_url( message="A 20 EUR deposit confirms your booking.", url=f"https://pay.example.com/deposit/{booking_id}", elicitation_id=f"deposit-{booking_id}", ) if result.action == "accept": return "Complete the payment in your browser." return "No deposit taken. The booking expires in one hour." @mcp.tool() async def confirm_deposit(booking_id: str, ctx: Context) -> str: """Record a payment reported by the payment provider.""" await ctx.session.send_elicit_complete(f"deposit-{booking_id}") return f"Deposit received for booking {booking_id}."要点:
ctx.elicit_url()接收三个参数:message、用户要访问的URL、以及你自选的elicitation_id——任何能在你的 server 内唯一标识这次 elicitation 的字符串;- 结果只有 action,没有别的。
"accept"只表示用户同意打开 URL,并不代表另一端的操作已完成; - 支付发生在 out-of-band——在用户的浏览器和你的支付服务商之间。没有任何内容通过 MCP 回流。
关键配套机制:send_elicit_complete
注意第二个 tool:当 server 得知 out-of-band 流程完成(webhook、轮询;这里用一个 tool 来模拟),就调用ctx.session.send_elicit_complete(...),用同一个elicitation_id发送notifications/elicitation/complete通知。这正是客户端得知"可以停止显示waiting for payment..."的方式——没有它,客户端只能瞎猜。
该方法的实现位于 src/mcp/server/session.py,发送ElicitCompleteNotification;在底层 peer 抽象上,Form 与 URL 两种模式分别通过 src/mcp/shared/peer.py 的elicit_form/elicit_url发送elicitation/create原始请求(注意该方法签名明确标注了NoBackChannelError这一异常——即连接没有 server 主动发起请求的 back-channel 时抛出,正是前文警告的协议限制的代码落点)。
客户端一侧:elicitation_callback
服务端负责提问,客户端通过给Client(...)传入一个elicitation_callback来应答。完整示例在 docs_src/elicitation/tutorial003.py:
from mcp import Client from mcp.client import ClientRequestContext from mcp.types import ElicitRequestParams, ElicitRequestURLParams, ElicitResult async def handle_elicitation(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: if isinstance(params, ElicitRequestURLParams): print(f"Open this link to continue: {params.url}") return ElicitResult(action="accept") print(params.message) return ElicitResult(action="accept", content={"accept_alternative": True, "date": "2025-12-27"}) async def main() -> None: async with Client( "http://127.0.0.1:8000/mcp", mode="legacy", elicitation_callback=handle_elicitation, ) as client: result = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2}) print(result.content)要点:
- 一个 callback 同时处理两种模式。
params是ElicitRequestFormParams和ElicitRequestURLParams的 union,用isinstance分支即可; - URL 分支:把
params.url展示给用户,返回用户选择的 action——永远不返回content; - Form 分支:真实应用应渲染
params.requested_schema并把用户输入作为content返回。示例中直接返回一个写死的答案("always say yes"),这恰好也是测试中你想要的 callback 形态; - 传入 callback 本身就是 capability 声明:server 正是借此得知"这个 client 可以被提问"。客户端还能为 server 应答哪些东西,见Client callbacks。
从源码看,客户端会话在 src/mcp/client/session.py 提供了默认 callback——如果调用方没有注册任何elicitation_callback,默认行为是返回ErrorData(code=INVALID_REQUEST, message="Elicitation not supported"),且该 capability 不会在握手时声明(见 src/mcp/client/session.py,未注册时elicitationcapability 直接置为None)。客户端收到ElicitRequest后通过 src/mcp/client/session.py 的分发路径调用你的 callback。
ℹ️关于
mode="legacy"(原文档原文说明)elicitation 是server到client的请求,而这类请求只存在于 classic-handshake session 上,所以这个 client 传入mode="legacy"。在2026-07-28连接上,tool 改为从调用中返回问题来提问;那个流程是Multi-round-trip requests。
动手试一遍
Form 模式端到端
- 把 Form 模式的
server.py(即含book_table的 docs_src/elicitation/tutorial001.py)跑在 Streamable HTTP 上——一行启动命令见Running your server; - 运行 client 的
main(),向book_table请求圣诞节的桌位。
callback 会打印收到的提问:
No tables for 2 on 2025-12-25. Would you like to try another date?它用{"accept_alternative": True, "date": "2025-12-27"}作答,而一直停在await ctx.elicit(...)里的 tool 随之完成预订:
Booked a table for 2 on 2025-12-27.URL 模式端到端
换上 URL 模式的server.py(docs_src/elicitation/tutorial002.py),让同一个main()调用pay_deposit:同一个 callback 走另一条分支,打印支付链接,tool 返回"Complete the payment in your browser."。一次 round trip,发生在调用中途,双向皆是如此。
反向验证:不注册 callback 会发生什么
✅动手检查(原文档原文)现在从
Client移除elicitation_callback=,再次为圣诞节调用book_table。整个调用会以协议错误失败:Elicitation not supported没有注册任何 callback 的 client 从未声明
elicitationcapability,因此没有人可问。你的 tool 得到的不是"decline",而是 exception。请据此设计:每一次 elicitation 都要有一个对"如果我无法提问呢?"的合理答案。
这正是前文 src/mcp/client/session.py 默认 callback 的运行时表现,同时 tests/client/test_client.py 中test_call_tool_auto_loop_dispatches_elicitation_then_returns_final_result等测试用例验证了回调缺失时的失败路径。服务端侧的完整测试覆盖见 tests/server/mcpserver/test_elicitation.py。
总结
- 用
Annotated[T, Resolve(fn)]标注的参数由 resolver 填充;resolver 在需要提问时返回Elicit(...)。它在每条连接上都有效。 - schema 是扁平的 Pydantic model:只允许 primitive 字段,返回时会被校验。
result.action为"accept"、"decline"或"cancel";result.data只在 accept 时存在。await ctx.elicit(message, schema=Model)从 tool body 内部提问;await ctx.elicit_url(message, url, elicitation_id)用于一切不该经过 model的场景(ctx.session.send_elicit_complete(elicitation_id)通知 out-of-band 部分完成)。两者都是 server-to-client 请求:需要 client 处于 legacy 连接上。- 客户端用一个
elicitation_callback应答,按 params 类型分支;注册它就是声明 capability。 - 在 2026-07-28 连接上,server 不是推送问题而是返回问题;同一个 callback 由Multi-round-trip requests流程驱动。
而在这个返回机制之下的一切(重试循环、保护requestState、自行驱动该流程),同样是Multi-round-trip requests的范畴。
【免费下载链接】python-sdkThe official Python SDK for Model Context Protocol servers and clients项目地址: https://gitcode.com/gh_mirrors/pythonsd/python-sdk
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考