MCP Python SDK 中的 Elicitation(征询)机制:从 Resolver 到表单与 URL 模式的完整实战指南

发布时间:2026/9/20 22:15:25
MCP Python SDK 中的 Elicitation(征询)机制:从 Resolver 到表单与 URL 模式的完整实战指南 MCP Python SDK 中的 Elicitation征询机制从 Resolver 到表单与 URL 模式的完整实战指南【免费下载链接】python-sdkThe official Python SDK for Model Context Protocol servers and clients项目地址: https://gitcode.com/gh_mirrors/pythonsd/python-sdk导读在 Model Context ProtocolMCP的工具执行过程中经常会遇到工具执行到一半、缺少一个关键答案的场景需要用户确认删除操作、需要选择匹配的账户、需要用户去浏览器完成 OAuth 授权或支付。MCP Python SDKpython-sdk提供的Elicitation征询机制让工具可以在调用中途向客户端进而向用户提问并把答案带回到同一个函数调用中继续执行。本文基于官方文档 docs/handlers/elicitation.md完整讲解两种征询模式表单模式与 URL 模式、两种提问方式Resolver 与ctx.elicit并结合仓库源码 src/mcp/server/elicitation.py、src/mcp/server/mcpserver/resolve.py 等文件深入解析其底层实现与协议版本差异。读完本文你将掌握在 MCP 服务端与客户端中实现工具中途提问的完整方案。两种模式与两种提问方式Elicitation 解决的核心问题是一个执行到一半、缺少一个答案的工具不必失败。它可以在工具调用中途向用户提问用户的答案会回到同一个函数调用中。Elicitation 有两种模式按需要获取的内容划分表单模式Form mode你需要一个值确认、日期、数量。你描述字段客户端渲染表单用户填写后提交。URL 模式URL mode你需要用户去别的地方OAuth 授权页、支付页。用户在那里做的一切都不会经过协议传输。同时有两种提问方式按代码组织划分Resolver推荐把问题挂在一个参数上SDK 负责提问——无论什么连接、客户端说哪个协议版本都能工作。直接方式await ctx.elicit(...)这是从服务器到客户端的请求该通道只对 legacy 连接spec 版本 2025-11-25 或更早的客户端存在。优先使用 Resolver。它是两种协议时代的通用方案下文先详细讲解。用 Resolver 提问把问题从工具体里提出来当一个问题卡住了整个工具——确定吗三个匹配账户里选哪个——你可以把它从工具体里提出来放进一个resolver由框架替你提问。一个参数标注为Annotated[T, Resolve(fn)]后会在工具体执行前由运行fn来填充。Resolver 在已知答案时直接返回值需要提问时返回Elicit(...)框架会代为提问。完整示例删除文件夹前的确认下面是 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(okTrue) # 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(dataConfirm(okTrue)): _FOLDERS.pop(path, None) return fdeleted {path} case AcceptedElicitation(): return kept the folder case DeclinedElicitation(): return declined: folder not deleted case CancelledElicitation(): return cancelled: folder not deleted这个例子体现了三个关键点confirm_delete按名字读取工具自身的path参数列出文件夹内容并且只在必要时才征询——空文件夹直接解析为Confirm(okTrue)根本不需要往返客户端。这正是 Resolver 优于无条件提问的价值能自己算出答案就不打扰用户。delete_folder把参数标注为ElicitationResult[Confirm]框架会注入完整的结果对象工具用match穷举每一种情况接受且确认okTrue、接受但保留okFalse、拒绝decline、取消cancel。confirm参数永远不会出现在工具的输入 schema 中——客户端只提供pathconfirm由 resolver 提供。两种注入方式的取舍上面的代码消费的是完整结果联合类型。如果工具不需要分支处理可以改为标注未包装的模型confirm: Annotated[Confirm, Resolve(confirm_delete)]这种情况下接受时工具直接收到Confirm模型实例拒绝或取消时整个调用会以错误中止。从源码看这一行为由 src/mcp/server/mcpserver/resolve.py 的模块文档明确约定Annotated[T, Resolve(fn)]→ 注入未包装的Tdecline/cancel 中止调用。Annotated[ElicitationResult[T], Resolve(fn)]或具体的某个成员类型→ 注入完整结果由消费者自己分支处理 accept/decline/cancel。对应地_unwrap()resolve.py在收到AcceptedElicitation时取出.data否则抛出ToolError即无法解析征询结果为 decline/cancel。Resolver 的通用机制提问只是 Resolver 能做的事情之一。它底层是一套通用的依赖注入机制依赖可以只计算不提问、依赖可以有依赖、模型能提供什么不能提供什么——这些在Dependencies页面有完整讲解。从 resolve.py 的文档可见Resolver 构成的是一张 DAG有向无环图一个 resolver 可以声明自己的Resolve(...)依赖、按名字取工具参数、取Context它还可以返回请求标记Elicit[T]提问、Sample采样客户端 LLM、ListRoots获取客户端 roots由框架注入响应。Resolver 在两种协议连接上都工作Resolver 在每一种连接上都能工作。对 legacy 连接的客户端SDK 直接向它发送问题对2026-07-28连接SDK 从调用中返回问题客户端下一次尝试时把答案带回来。你的 resolver 永远感觉不到区别底层的机制是Multi-round-trip requests。从源码看这个协议分支发生在 resolve.py 的_uses_input_required()def _uses_input_required(protocol_version: str | None) - bool: True when this request must elicit via InputRequiredResult ( 2026-07-28). Older revisions still carry a standalone elicitation/create server-to-client request, so the framework keeps the synchronous ctx.elicit() path for them. return protocol_version is not None and is_version_at_least(protocol_version, _INPUT_REQUIRED_VERSION)协议版本门限_INPUT_REQUIRED_VERSION 2026-07-28resolve.py新协议下所有待处理的问题被批量打包进InputRequiredResult等客户端带着input_responses/request_state重试时恢复旧协议≤ 2025-11-25则在调用中途逐个发送独立的 server-to-client 请求。只有被问过的结果才挂在request_state上所以每个问题只会被问一次。Resolver 函数体在每一轮都可能重新执行但只有它再次提问时才会查阅已记录的结果——resolver 自己的计算永远优先于客户端在request_state里回显的任何内容。从工具内部提问await ctx.elicit(...)工具也可以在自己的函数体中间停下来提问。警告ctx.elicit()和ctx.elicit_url()是从服务器到客户端的请求——该通道只对 legacy 连接spec 版本2025-11-25或更早的客户端存在。在2026-07-28连接上没有服务器发起的请求所以这些调用会失败。Resolver 则在两种连接上都工作。完整的协议故事见Protocol versions。await ctx.elicit()接收一条消息和一个 Pydantic 模型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(descriptionTry another date?) date: str Field(default2025-12-26, descriptionAlternative 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 fBooked a table for {party_size} on {date}. result await ctx.elicit( messagefNo tables for {party_size} on {date}. Would you like to try another date?, schemaAlternativeDate, ) if result.action accept and result.data.accept_alternative: return await book_table(result.data.date, party_size, ctx) return No booking made.完整源码见 docs_src/elicitation/tutorial001.py。这个例子体现了几个要点Context参数是你获得ctx.elicit的途径任何工具都可以接收一个Context。该对象有自己的文档页The Context。AlternativeDate是你要的答案的 schema。工具必须是async def。它必须在中间停下来等一个人。其他日期时工具直接返回。它只在不得不问的时候才问。用户接受的日期会经由book_table自身返回。答案和其他输入一样一个同样被订满的替代日期会再次被询问而不是被盲目确认。示例里book_table通过递归调用自己实现再次询问。从 src/mcp/server/mcpserver/context.py 的源码看Context.elicit()委托给 src/mcp/server/elicitation.py 的elicit_with_validation()先把模型渲染成 JSON Schema通过session.elicit_form()发出表单模式的elicitation/create请求然后根据客户端的返回构造AcceptedElicitation/DeclinedElicitation/CancelledElicitation。客户端收到什么JSON Schema 即表单客户端收到你的消息旁边是从模型生成的 JSON Schema{ 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...)是标签带默认值会预填输入框并让字段变成可选。它和Tools文档描述的工具参数机制一样都是 Pydantic 到 JSON Schema 的转换。表单模式的硬性限制只能是扁平原始字段警告征询 schema 没有工具输入 schema 那么强的表达能力。只支持扁平的原始字段str、int、float、bool或字符串的Literal会变成enum。如果在模型里再嵌套一个模型ctx.elicit会在向客户端发送任何东西之前就抛错。工具调用会以Error executing tool name失败服务器日志里能看到原因TypeError: Elicitation schema field address rendered as {$ref: #/$defs/Address}, which is not a valid PrimitiveSchemaDefinition你打断的是一个正在做事的真人。如果答案需要嵌套它当初就应该做成工具的参数。这个限制在源码中有明确实现。src/mcp/server/elicitation.py 定义了专门的_ElicitationJsonSchemaJSON Schema 生成器把T | None拍平成T、丢弃None默认值——因为规范的PrimitiveSchemaDefinition不允许anyOf或 null 类型可选字段的规范表达方式是把它从required中拿掉Pydantic 对任何带默认值的字段本来就会这么做。随后_validate_rendered_properties()elicitation.py用TypeAdapter[PrimitiveSchemaDefinition]逐个校验每个properties条目凡是渲染器放行但不符合规范的东西——裸的list[str]无 enum、多原始类型联合、嵌套模型——都会抛出你看到的TypeError。三种答案result.action告诉你用户做了什么恰好有三种可能accept用户提交了表单。result.data是一个AlternativeDate实例已经过校验。decline用户拒绝了。cancel用户没有选择就关闭了问题。result.data只在accept时存在所以示例先检查result.action。类型检查器会强制这个顺序在result.action accept之后result.data是一个AlternativeDate在此之前根本不存在.data。这由 src/mcp/server/elicitation.py 的三个结果模型保证AcceptedElicitation带有data: ElicitSchemaModelT而DeclinedElicitation和CancelledElicitation只有action字段。ElicitationResult是这三者的联合类型别名。拒绝不是错误。工具自己决定拒绝意味着什么这里是不预订然后正常回答模型。提示答案在进入你的代码之前就按你的模型校验过了。客户端对bool字段发一个maybe不会破坏你的预订ctx.elicit会抛ValueError调用失败你的if永远不会执行。源码佐证elicitation.py 中接受时若无content抛ValueError(Received an accepted elicitation with no content)content 校验失败抛ValueError(...does not match the requested schema)。把用户送去一个 URLctx.elicit_url(...)有些事情不能经过模型或客户端凭据、卡号、OAuth 授权。这些场景你不问数据而是请用户去某个地方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( messageA 20 EUR deposit confirms your booking., urlfhttps://pay.example.com/deposit/{booking_id}, elicitation_idfdeposit-{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(fdeposit-{booking_id}) return fDeposit received for booking {booking_id}.完整源码见 docs_src/elicitation/tutorial002.py。ctx.elicit_url()接收消息、要访问的 URL以及一个你自选的elicitation_id任意能在这个服务器内标识本次征询的字符串。结果只有一个 action没有别的。accept表示用户同意打开 URL不代表他在 URL 那边的事情已经完成。支付发生在带外在用户的浏览器和你的支付服务商之间。不会有任何内容经过 MCP 回来。看第二个工具。当你的服务器得知带外流程结束了webhook、轮询这里用第二个工具来模拟ctx.session.send_elicit_complete(...)会发送notifications/elicitation/complete携带同一个elicitation_id。客户端就是这样知道可以停止显示waiting for payment...了。没有它客户端只能靠猜。从 src/mcp/server/mcpserver/context.py 的源码看Context.elicit_url()委托给 src/mcp/server/elicitation.py 的elicit_url()其 docstring 明确了适用场景收集敏感凭据API 密钥、密码、与第三方服务的 OAuth 授权流程、支付与订阅流程以及任何数据不应经过 LLM 上下文的交互。URL 模式下accept的结果是AcceptedUrlElicitation只有action字段没有data。会话层的send_elicit_complete()在 src/mcp/server/session.pydocstring 说明其用途当 URL 模式的征询在带外完成后发送告知客户端可以重试任何正在等待本次征询的请求。客户端一侧elicitation_callback服务器提问。客户端通过向Client(...)传入elicitation_callback来回答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(fOpen this link to continue: {params.url}) return ElicitResult(actionaccept) print(params.message) return ElicitResult(actionaccept, content{accept_alternative: True, date: 2025-12-27}) async def main() - None: async with Client( http://127.0.0.1:8000/mcp, modelegacy, elicitation_callbackhandle_elicitation, ) as client: result await client.call_tool(book_table, {date: 2025-12-25, party_size: 2}) print(result.content)完整源码见 docs_src/elicitation/tutorial003.py。要点一个回调同时处理两种模式。params是ElicitRequestFormParams和ElicitRequestURLParams的联合类型用isinstance分支。对 URL 模式把params.url展示给用户返回用户选择的行为。永远不要返回任何content。对表单模式真实应用会渲染params.requested_schema把用户的输入作为content返回。这个示例总是说是并返回一个固定答案——这正是测试中想要的回调。传入回调本身就是能力声明服务器就是这样知道这个客户端可以被提问。客户端能替服务器回答的其他事情见Client callbacks。从源码看src/mcp/client/client.py 将elicitation_callback定义为Client的一个参数src/mcp/client/session.py 定义了它的协议类型ElicitationFnT。重要的是如果没传回调会使用默认回调——src/mcp/client/session.py 的_default_elicitation_callback返回ErrorData(codeINVALID_REQUEST, messageElicitation not supported)这正是下文协议错误一节的来源。提示Elicitation 是从服务器到客户端的请求这种请求只存在于 classic-handshake 会话上所以示例客户端传了modelegacy。在2026-07-28连接上工具改为从调用中返回问题那条流程见Multi-round-trip requests。试跑一遍用 Streamable HTTP 启动ctx.elicit表单模式的server.py即book_table那个Running your server有一行命令然后运行客户端的main()对book_table询问圣诞节那天。回调打印它收到的提问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(...)里一直等待的工具完成预订Booked a table for 2 on 2025-12-27.现在换成 URL 模式的server.py让同一个main()指向pay_deposit同一个回调走另一个分支打印支付链接工具返回Complete the payment in your browser.一次往返在调用中途双向都是如此。如果不注册回调会发生什么检查点现在从Client上移除elicitation_callback再对圣诞节调用book_table。整个调用会以协议错误失败Elicitation not supported没注册回调的客户端从未声明elicitation能力所以没有人可问。你的工具得到的不是decline而是异常。要为它做设计每一次征询都需要一个对如果我无法提问怎么办的合理答案。这个行为可以追溯到默认回调 src/mcp/client/session.py 返回的Elicitation not supported错误测试 tests/client/test_client.py 的注释也明确写了SDK-defined: with noelicitation_callback, the default returns...。现代协议下的自动循环测试里的完整闭环Resolver 和ctx.elicit在 2026-07-28 协议下走的是InputRequiredResult返回 客户端重试的流程。仓库测试 tests/client/test_client.py 给出了一个完整的闭环示例服务器返回携带征询的InputRequiredResultClient.call_tool自动把其中的征询路由给elicitation_callback并重试调用方最终只看到终止态的CallToolResultserver.tool() async def greet(ctx: Context) - str | types.InputRequiredResult: responses ctx.input_responses if responses and user_name in responses: answer responses[user_name] assert isinstance(answer, types.ElicitResult) assert answer.content is not None return fHello, {answer.content[name]}! return types.InputRequiredResult(input_requests{user_name: _name_elicitation()}) async def elicitation_callback( context: ClientRequestContext, params: types.ElicitRequestParams ) - types.ElicitResult | types.ErrorData: callback_params.append(params) assert context.request_id user_name # the inputRequests key is the request id return types.ElicitResult(actionaccept, content{name: Ada})要点inputRequests的键就是请求 ID回调的context.request_id与之对应。默认的自动循环由Client的input_required_max_rounds参数src/mcp/client/client.py封顶重试轮数超过后call_tool/get_prompt/read_resource放弃如果想要自己手动驱动循环可以使用client.session.method(..., allow_input_requiredTrue)。同一文件中还有测试验证了无回调时默认行为、2026-07-28 模式下的回调分发tests/client/test_client.py。与 Resolver 同源的更多请求标记Resolver 能返回的不只是Elicit。src/mcp/server/mcpserver/resolve.py 中还定义了另外两个请求标记Sample请求通过sampling/createMessage采样客户端的 LLM框架注入CreateMessageResult当给出tools或tool_choice时为CreateMessageResultWithTools这还要求客户端有sampling.tools能力在 ≥ 2026-07-28 协议下请求必须在每一轮重试中渲染一致采样结果随request_state流转。ListRoots通过roots/list请求客户端的 roots框架注入ListRootsResult。Sample和ListRoots没有 decline 分支所以它们的消费者直接标注结果类型。_require_capability()resolve.py会检查客户端是否声明了对应的能力elicitation、sampling、roots未声明时抛出MISSING_REQUIRED_CLIENT_CAPABILITY错误携带requiredCapabilities载荷。Resolver 的注册期静态分析resolve.py还会拒绝循环依赖、一个 resolver 返回多个 Elicit/Sample/ListRoots 分支一个 resolver 只问一个问题——拆成多个 resolver等非法签名。快速回顾Recap参数标注Annotated[T, Resolve(fn)]由 resolver 填充它需要提问时返回Elicit(...)。它在每一种连接上都能工作。schema 是一个扁平的 Pydantic 模型只允许原始字段返回时会被校验。result.action是accept、decline或cancelresult.data只在 accept 时存在。await ctx.elicit(message, schemaModel)从工具体内部提问await ctx.elicit_url(message, url, elicitation_id)用于一切不能经过模型的内容ctx.session.send_elicit_complete(elicitation_id)表示带外部分已完成。两者都是 server-to-client 请求需要客户端在 legacy 连接上。客户端用一个elicitation_callback回答按参数类型分支注册它就是声明能力。在 2026-07-28 连接上服务器改为返回问题而不是推送它同一个回调由Multi-round-trip requests驱动。底层那些返回机制的细节——重试循环、保护requestState、手动驱动——都在Multi-round-trip requests文档中。参考资源官方文档docs/handlers/elicitation.md表单模式服务端示例docs_src/elicitation/tutorial001.pyURL 模式服务端示例docs_src/elicitation/tutorial002.py客户端回调示例docs_src/elicitation/tutorial003.pyResolver 示例docs_src/elicitation/tutorial004.py核心实现src/mcp/server/elicitation.py、src/mcp/server/mcpserver/resolve.py、src/mcp/server/mcpserver/context.py客户端实现src/mcp/client/client.py、src/mcp/client/session.py相关测试tests/client/test_client.py、tests/docs_src/test_elicitation.py延伸阅读Multi-round-trip requests、Dependencies、The Context、Client callbacks、Protocol versions【免费下载链接】python-sdkThe official Python SDK for Model Context Protocol servers and clients项目地址: https://gitcode.com/gh_mirrors/pythonsd/python-sdk创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询