news 2026/9/20 19:54:36

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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 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(...)(直接方式):这是从serverclient的请求(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"

这个示例的三个要点(原文档逐条列出):

  1. confirm_delete通过名称读取 tool 自己的path参数,列出文件夹内容,并且只在必须时才 elicit——空文件夹在没有任何客户端 round-trip 的情况下直接 resolve 为Confirm(ok=True)
  2. delete_folderElicitationResult[Confirm]做注解,framework 会注入完整结果,tool 用match覆盖每一种情况:接受并确认(ok=True)、接受但保留(ok=False)、拒绝(decline)、取消(cancel);
  3. confirm参数永远不会出现在 tool 的 input schema 里——客户端提供path,resolver 提供confirm,二者职责分离。

不需要分支时的简化写法

如果 tool 不需要针对不同结果分支,可以直接注解未包装的 modelAnnotated[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()serverclient的请求——这个通道只存在于 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。该对象的完整文档见ContextContext.elicitContext.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 字段:strintfloatbool,或字符串的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 同时处理两种模式paramsElicitRequestFormParamsElicitRequestURLParams的 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 是serverclient的请求,而这类请求只存在于 classic-handshake session 上,所以这个 client 传入mode="legacy"。在2026-07-28连接上,tool 改为从调用中返回问题来提问;那个流程是Multi-round-trip requests

动手试一遍

Form 模式端到端

  1. 把 Form 模式的server.py(即含book_table的 docs_src/elicitation/tutorial001.py)跑在 Streamable HTTP 上——一行启动命令见Running your server
  2. 运行 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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/20 19:53:56

不部署向量数据库,Java 向量搜索怎么用 sqlite-vec 跑起来?

不部署向量数据库&#xff0c;Java 向量搜索怎么用 sqlite-vec 跑起来&#xff1f; 【免费下载链接】sqlite-vec A vector search SQLite extension that runs anywhere! 项目地址: https://gitcode.com/GitHub_Trending/sq/sqlite-vec 写给正在评估向量搜索方案的 Java…

作者头像 李华
网站建设 2026/9/20 19:49:54

OpenResearch实践指南:从实验记录到可复现研究的完整工作流

不用急着下定义。我第一次接触“OpenResearch”这个词&#xff0c;是在一次课题组内部讨论上&#xff0c;有人抱怨实验数据存在自己电脑里三个月都没人看&#xff0c;代码也只够自己复现一遍。后来我们试着把整个研究过程端到端摊开——从选题、检索、实验记录、代码、数据&…

作者头像 李华
网站建设 2026/9/20 19:48:50

从零孵化提示词工程师:Midjourney与Stable Diffusion实战指南

我前阵子帮一家做茶饮的品牌方赶一批电商主图&#xff0c;30多张产品图&#xff0c;从需求拆解到最终交付只用了4天。团队里没有专业设计师参与&#xff0c;真正干活的就是一个在Grix里孵化出来的“图像提示词工程师”——一个原本只会套别人模板的运营同学。这个经历让我特别想…

作者头像 李华
网站建设 2026/9/20 19:48:31

VS Code 跨平台安装与配置指南:从零搭建高效开发环境

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华