python-sdk 中的 Elicitation 机制:让 MCP 工具在调用中途向用户提问

发布时间:2026/9/20 19:54:47
python-sdk 中的 Elicitation 机制:让 MCP 工具在调用中途向用户提问 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 SDKModel Context Protocol 的 Python 实现中的Elicitation引导式提问能力展开。它解决的是一个非常具体的实战场景一个 tool 执行到一半、只差一个关键信息用户确认、备选日期、支付授权时不必让整个调用失败而是可以在调用中途向用户提问并把答案带回同一个函数调用继续执行。读完本文你将掌握两种提问模式Form 表单模式与 URL 跳转模式、两种提问方式resolver 参数注入与ctx.elicit直接调用的完整用法、客户端elicitation_callback的注册与分支处理以及它们在 legacy2025-11-25与 2026-07-28 两代协议连接下的行为差异。核心概念两种模式、两种提问方式原文档开篇即点出 Elicitation 的两个基本维度两种模式modeForm mode表单模式你需要一个具体值——确认、日期、数量。你在服务端描述字段一个 Pydantic model由客户端渲染成表单让用户填写。URL modeURL 模式你需要用户离开当前上下文去别处完成一件事OAuth 授权页、支付页。用户在那里做的一切都不经过 MCP 协议回传——这是处理凭据、卡号、授权等敏感信息的唯一正确姿势。两种提问方式way to askresolver推荐优先使用把问题挂在一个参数上由 SDK 代为提问。它在任何连接、任何协议代际的 client 上都能工作。await ctx.elicit(...)直接方式这是从server到client的请求server-initiated request该通道只存在于 legacy 连接spec version 2025-11-25 或更早上。下文先讲 resolver再讲 tool 内部直接提问最后讲客户端如何应答。用 resolver 提问把问题挂到参数上当一个拦路问题确定吗三个相似账户选哪个会阻塞整个 tool 时正确做法是把它从 tool body 中提取出来放进 resolver由框架替你提问。核心 APIAnnotated[T, Resolve(fn)]用Annotated[T, Resolve(fn)]标注的参数会在 tool body 执行之前由fn填充。resolver 的返回值有两种可能已经知道答案直接返回该值Confirm(okTrue)framework 直接注入不发任何 round-trip不知道答案返回Elicit(...)framework 据此向客户端提问并把结果注入。这两个关键类型的源码定义在 src/mcp/server/mcpserver/resolve.pyclass 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 resolvers 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.pyfrom 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通过名称读取 tool 自己的path参数列出文件夹内容并且只在必须时才 elicit——空文件夹在没有任何客户端 round-trip 的情况下直接 resolve 为Confirm(okTrue)delete_folder用ElicitationResult[Confirm]做注解framework 会注入完整结果tool 用match覆盖每一种情况接受并确认okTrue、接受但保留okFalse、拒绝decline、取消cancelconfirm参数永远不会出现在 tool 的 input schema 里——客户端提供pathresolver 提供confirm二者职责分离。不需要分支时的简化写法如果 tool 不需要针对不同结果分支可以直接注解未包装的 modelAnnotated[Confirm, Resolve(confirm_delete)]。此时接受accept时 tool 收到Confirm实例拒绝或取消时整个调用以错误中止。resolver 在两种协议连接上都能工作原文档强调resolver 在每一条连接上都有效。对 legacy 连接上的 clientSDK 直接把问题发过去走 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.pyfrom 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.要点逐条拆解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...)是表单的 labeldefault预填输入框并让该字段变为可选不出现在required中这正是Tools中描述的同一套 Pydantic-to-JSON-Schema 机制。schema 的边界只能是扁平的 primitive 字段⚠️警告原文档原文强调elicitation schema没有 tool 的 input schema 那么强大。只支持扁平的 primitive 字段str、int、float、bool或字符串的Literal渲染成enum。如果在 model 里再嵌套 modelctx.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.dataaccept用户提交了表单是——一个已校验的AlternativeDate实例decline用户拒绝了无cancel用户未选择直接关闭了问题无result.data只在accept时存在所以示例总是先检查result.action。类型检查器会强制这个顺序在result.action accept之后result.data才是AlternativeDate在此之前根本不存在.data。这三个结果类型的源码定义在 src/mcp/server/elicitation.pyclass 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的校验路径。把用户送到 URLURL 模式有些东西绝不能经过 model 或 client凭据credentials、卡号card numbers、OAuth 授权。对这类场景你不是要数据而是请用户去某个地方完成操作。完整示例支付押金配套示例在 docs_src/elicitation/tutorial002.pyfrom 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}.要点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.pyfrom 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)要点一个 callback 同时处理两种模式。params是ElicitRequestFormParams和ElicitRequestURLParams的 union用isinstance分支即可URL 分支把params.url展示给用户返回用户选择的 action——永远不返回contentForm 分支真实应用应渲染params.requested_schema并把用户输入作为content返回。示例中直接返回一个写死的答案always say yes这恰好也是测试中你想要的 callback 形态传入 callback 本身就是 capability 声明server 正是借此得知这个 client 可以被提问。客户端还能为 server 应答哪些东西见Client callbacks。从源码看客户端会话在 src/mcp/client/session.py 提供了默认 callback——如果调用方没有注册任何elicitation_callback默认行为是返回ErrorData(codeINVALID_REQUEST, messageElicitation not supported)且该 capability 不会在握手时声明见 src/mcp/client/session.py未注册时elicitationcapability 直接置为None。客户端收到ElicitRequest后通过 src/mcp/client/session.py 的分发路径调用你的 callback。ℹ️关于modelegacy原文档原文说明elicitation 是server到client的请求而这类请求只存在于 classic-handshake session 上所以这个 client 传入modelegacy。在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.pydocs_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或cancelresult.data只在 accept 时存在。await ctx.elicit(message, schemaModel)从 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),仅供参考