AI SDK Next.js 实战:基于 MCP Elicitation 的人机协作工具调用完整实现
【免费下载链接】aiThe AI Toolkit for TypeScript. From the creators of Next.js, the AI SDK is a free open-source library for building AI-powered applications and agents项目地址: https://gitcode.com/GitHub_Trending/ai/ai
导读
MCP(Model Context Protocol)Elicitation 是让 AI 工具在缺少必要参数时主动向用户"索取输入"的能力:当用户只说了一句"帮我注册一个新账号",而注册工具需要用户名、邮箱、密码时,MCP 服务器可以通过 elicitation 请求携带一份 JSON Schema,把"该填什么"的问题抛给前端,由用户以表单形式补全信息,再回传给工具继续执行。本文以 examples/ai-e2e-next/app/chat/mcp-elicitation/README.md 为骨架,结合该示例的页面、API 路由、MCP 服务器与@ai-sdk/mcp底层实现,完整讲解在 Next.js + AI SDK 中搭建"注册用户"类 human-in-the-loop 流程的每一行关键代码,读完即可在自己的应用中复刻这套「模型调用工具 → 工具请求补参 → 前端模态表单 → 回传继续执行」的闭环。
一、什么是 MCP Elicitation:从"参数缺失"到"主动补参"
在传统工具调用(function calling / tool calling)中,模型必须一次性从对话上下文里推断出工具的全部参数。但在真实场景下,用户可能不会一次性提供所有必填信息,例如"帮我注册账号"这句话里并没有用户名、邮箱和密码。
MCP Elicitation 解决的就是这个问题:当工具执行过程中发现自己缺少输入时,向客户端(这里即 Next.js 服务端)发起一次elicitation/create请求,携带一段面向用户的提示消息和一个 JSON Schema;客户端把它转交给 UI,由真人填写、拒绝或取消,再将结果返回,工具据此继续执行。这属于典型的 human-in-the-loop(人在回路)交互模式。
在 AI SDK 生态中,@ai-sdk/mcp包为createMCPClient增加了onElicitationRequest注册点(见 packages/mcp/src/tool/mcp-client.ts),并在协议层实现了elicitation/create请求的接收、校验与应答,使其成为一条开箱即用的标准能力。
二、整体工作流程:7 步闭环
该示例的完整流程(源自 README.md)如下:
- 用户发送消息,请求某个动作,例如"register me as a new user";
- AI 模型调用对应的 MCP 工具,例如
register_user; - MCP 服务器发起 elicitation 请求,附带一段提示消息和一份 JSON Schema;
- 前端根据 Schema 渲染模态表单,展示各字段及其类型、必填性、默认值;
- 用户填写表单,并选择提交(Submit)、拒绝(Decline)或取消(Cancel);
- 响应回传 MCP 服务器:前端先把结果 POST 到独立的
/respond端点,由 Next.js 服务端通过内存中的 pending 注册表把 Promise resolve 掉,再以 MCP 协议格式返回给服务器; - 工具执行完成,AI 模型拿到工具结果,继续后续对话。
值得强调的是,第 3 步的 elicitation 请求并非"卡住"整个对话——Next.js 的 API 路由把streamText流合并进createUIMessageStream(见 route.ts),前端一边等待用户输入,一边仍可保持消息流的正常接收。
三、环境准备与启动步骤
3.1 前置依赖
- 一个可用的 OpenAI API Key(示例默认使用
gpt-4o-mini模型,见 route.ts); - pnpm 与 Node.js 环境;
- 仓库已按根目录
package.json完成依赖安装(monorepo,@ai-sdk/mcp、ai、@ai-sdk/react等均为 workspace 包)。
3.2 第一步:启动 MCP 服务器
README 给出的命令是:
pnpm tsx src/elicitation-ui/server.ts该脚本实际位于 examples/mcp/src/elicitation-ui/server.ts,因此需要在examples/mcp目录下执行;仓库也提供了等价快捷脚本(见 examples/mcp/package.json):
cd examples/mcp pnpm server:elicitation-ui启动成功后,服务器监听http://localhost:8085(/sse端点用于建立 SSE 传输,/messages端点接收客户端消息,见 server.ts)。
3.3 第二步:运行 Next.js 应用
cd examples/ai-e2e-next pnpm dev3.4 第三步:打开示例页面
浏览器访问http://localhost:3000/mcp-elicitation(注意与 README 中标注一致,这是该示例页面的访问路径)。
四、MCP 服务器端:如何定义一个"会要参数"的工具
示例 MCP 服务器基于官方@modelcontextprotocol/sdk构建,注册了一个register_user工具。关键点在于工具执行体内调用elicitInput发起 elicitation(见 server.ts):
server.registerTool( 'register_user', { description: 'Register a new user account by collecting their information', inputSchema: {}, }, async () => { const elicitInput = server.server?.elicitInput?.bind(server.server); if (!elicitInput) { return { content: [ { type: 'text', text: 'Elicitation is not supported by this SDK version.' }, ], }; } const result = await elicitInput({ message: 'Please provide your registration information:', requestedSchema: { type: 'object', properties: { username: { type: 'string', title: 'Username', description: 'Your desired username (3-20 characters)', minLength: 3, maxLength: 20, }, email: { type: 'string', title: 'Email', description: 'Your email address', format: 'email', }, password: { type: 'string', title: 'Password', description: 'Your password (min 8 characters)', minLength: 8, }, newsletter: { type: 'boolean', title: 'Newsletter', description: 'Subscribe to newsletter?', default: false, }, }, required: ['username', 'email', 'password'], }, }); // result.action: 'accept' | 'decline' | 'cancel' }, );需要注意两个细节:
inputSchema为空对象:模型并不需要预先提供参数,所有必填信息都通过 elicitation 阶段收集;- JSON Schema 驱动 UI:
requestedSchema中每个属性的type、title、description、format、minLength、maxLength、default、required等字段,都会被前端直接用来渲染表单控件与校验约束(详见第六节)。
工具在拿到result后会按action分支处理:accept时输出注册成功的文本;decline/cancel时分别输出"用户拒绝注册"或"注册已取消"的文本(见 server.ts 中 L73-L119)。这意味着工具与模型都能感知用户的真实选择,从而自然衔接后续对话。
五、Next.js API 路由:桥接 MCP 服务器与前端 UI
该示例的前后端桥接由两个路由 + 一个内存注册表构成,全部位于 examples/ai-e2e-next/app/api/chat/mcp-elicitation 目录。
5.1 主路由:创建带 elicitation 能力的 MCP 客户端
route.ts 的核心逻辑:
const mcpClient = await createMCPClient({ transport: { type: 'sse', url: 'http://localhost:8085/sse', }, capabilities: { elicitation: {}, // 向 MCP 服务器宣告客户端支持 elicitation }, }); // 注册 elicitation 请求处理器 mcpClient.onElicitationRequest(ElicitationRequestSchema, async request => { const elicitationId = `elicit-${Date.now()}-${Math.random().toString(36).slice(2)}`; // 1. 把请求写入 UI 消息流,前端据此弹窗 writer.write({ type: 'data-elicitation-request', id: elicitationId, data: { elicitationId, message: request.params.message, requestedSchema: request.params.requestedSchema, }, }); // 2. 挂起等待,直到 /respond 端点 resolve 这个 pending 请求 const userResponse = await createPendingElicitation(elicitationId); // 3. 以 MCP 期望的格式返回 return { action: userResponse.action, content: userResponse.action === 'accept' ? userResponse.content : undefined, }; });随后通过streamText驱动对话(route.ts):
const result = streamText({ model: openai('gpt-4o-mini'), tools, // 来自 mcpClient.tools() stopWhen: isStepCount(10), instructions: 'You are a helpful assistant. When asked to register a user, use the register_user tool.', messages: await convertToModelMessages(messages), onEnd: async () => { await mcpClient.close(); }, }); writer.merge(toUIMessageStream({ stream: result.stream, originalMessages: messages }));几个实现要点:
capabilities: { elicitation: {} }:在 MCP initialize 握手阶段向服务器声明客户端具备 elicitation 能力(对应 packages/mcp/src/tool/types.ts 中ClientCapabilities的可选elicitation字段);maxDuration = 30:允许该路由的流式响应最长运行 30 秒(route.ts),保证挂起的 elicitation 不会被平台超时提前掐断;onElicitationRequest返回值的形状必须匹配ElicitResultSchema:action只能是'accept' | 'decline' | 'cancel',content仅在 accept 时携带(见 packages/mcp/src/tool/types.ts)。
5.2 响应路由:把用户选择"接"回挂起的 Promise
前端提交的用户响应会被 POST 到/api/mcp-elicitation/respond(respond/route.ts):
const response: ElicitationResponse = await req.json(); const resolved = resolvePendingElicitation(response); if (!resolved) { return Response.json( { error: 'Elicitation request not found or already resolved' }, { status: 404 }, ); } return Response.json({ success: true });5.3 内存注册表:让两条路由共享同一份状态
elicitation-store.ts 用挂在globalThis上的Map保存所有挂起的 elicitation(避免 Next.js 开发模式热重载导致模块级状态丢失),并为每个请求设置了60 秒超时(与 MCP 层超时对齐)和10 分钟过期清理:
const timeoutId = setTimeout(() => { if (pendingElicitations.has(id)) { pendingElicitations.delete(id); reject(new Error('Request timed out')); } }, 60 * 1000); pendingElicitations.set(id, { resolve, reject, createdAt: Date.now(), timeoutId });主路由await createPendingElicitation(id)会一直挂起,直到/respond路由调用resolvePendingElicitation把 Promise resolve(见 elicitation-store.ts)。这正是"等待用户输入"的异步桥梁:用户在 UI 上的每一次点击,最终都会通过这个 Map 转化为 MCP 协议层的应答。注意此方案为单实例内存态,生产环境如需多副本部署,应替换为 Redis 等共享存储。
六、前端页面:Schema 驱动的模态表单
页面 page.tsx 使用useChat配合DefaultChatTransport连接主路由:
const { messages, sendMessage } = useChat<MCPElicitationUIMessage>({ transport: new DefaultChatTransport({ api: '/api/chat/mcp-elicitation', }), });6.1 监听 data part 并弹出模态框
useEffect逆序遍历消息,用isDataUIPart识别data-elicitation-requestpart,并借助handledElicitationsRef(一个Set)保证每个 elicitation 只弹一次框,且始终展示最新的未处理请求(page.tsx):
if (isDataUIPart(part) && part.type === 'data-elicitation-request') { const elicitationId = part.data.elicitationId; if (!handledElicitationsRef.current.has(elicitationId)) { handledElicitationsRef.current.add(elicitationId); setCurrentElicitation(part.data); setShowModal(true); // 从 Schema 的 default 字段初始化表单(boolean 默认 false) const schema = part.data.requestedSchema as any; if (schema?.properties) { const defaults: Record<string, any> = {}; for (const [key, prop] of Object.entries(schema.properties)) { if (prop.default !== undefined) defaults[key] = prop.default; else if (prop.type === 'boolean') defaults[key] = false; } setFormData(defaults); } return; } }6.2 按 Schema 渲染不同类型的输入控件
renderFormField根据属性类型分派控件(page.tsx):
boolean→ checkbox,初始值取 Schemadefault(示例中newsletter默认false);number/integer→ number 输入框,应用minimum/maximum约束,integer 走parseInt、number 走parseFloat;- 字符串→ 按
format === 'email'渲染 email 类型、按type === 'password'渲染 password 类型,其余为 text,并应用minLength/maxLength; - 必填标记:
schema.required数组中的字段,标签旁显示红色*并设置required属性。
同时,消息流中每个data-elicitation-request/data-elicitation-responsepart 也会被渲染为蓝色/绿色高亮的对话气泡,让用户看到"系统正在向你索取信息"以及自己做出的选择(page.tsx)。
6.3 三种动作的提交语义
模态框提供三个按钮,统一走handleElicitationResponse(page.tsx):
const dataToSend = action === 'accept' ? { ...formData } : undefined; const response = await fetch('/api/mcp-elicitation/respond', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: elicitationId, action, content: dataToSend }), });- Submit(accept):携带
{ ...formData }作为content; - Decline(decline):
content为undefined,表示用户拒绝提供信息; - Cancel(cancel):同样不携带
content,表示放弃整个操作。
实现上通过"先关闭模态框、清空状态、再发请求"的顺序杜绝重复提交(setShowModal(false)在await fetch之前执行)。content字段的类型在 types.ts 中定义为Record<string, unknown>,并通过对UIMessage泛型注入自定义的ElicitationDataTypes,使useChat能正确解析这两种自定义 data part 的类型。
七、底层原理:@ai-sdk/mcp如何实现 elicitation
7.1 协议层 Schema
packages/mcp/src/tool/types.ts 定义了 elicitation 的协议形状:
const ElicitationRequestParamsSchema = BaseParamsSchema.extend({ message: z.string(), // 向用户展示的提示语 requestedSchema: z.unknown(), // 需要用户填写的 JSON Schema }); export const ElicitationRequestSchema = RequestSchema.extend({ method: z.literal('elicitation/create'), // 唯一的请求方法 params: ElicitationRequestParamsSchema, }); export const ElicitResultSchema = ResultSchema.extend({ action: z.union([z.literal('accept'), z.literal('decline'), z.literal('cancel')]), content: z.optional(z.record(z.string(), z.unknown())), });也就是说,MCP 服务器通过elicitation/create这个 JSON-RPC 方法发起请求,客户端必须回以{ action, content? }格式的应答。
7.2 客户端处理链路
在 packages/mcp/src/tool/mcp-client.ts 的onRequestMessage中,客户端对收到的服务端请求做如下分流:
ping→ 立即回空结果;- 非
elicitation/create的方法 → 回-32601(方法不支持)错误; elicitation/create但未注册处理器 → 回-32601并提示No elicitation handler registered on client;- 用
ElicitationRequestSchema.safeParse校验参数,失败回-32602(无效参数); - 调用
onElicitationRequest注册的处理器,将返回值经ElicitResultSchema.parse校验后作为 JSON-RPC result 返回。
而onElicitationRequest注册点(mcp-client.ts)要求传入的 schema 必须严格等于ElicitationRequestSchema,否则抛出Unsupported request schema错误——这是当前版本对扩展请求类型的刻意限制。
7.3 能力协商
客户端通过capabilities: { elicitation: {} }在初始化阶段声明支持 elicitation,服务端据此决定是否向该客户端发起elicitation/create请求。这也解释了为什么示例主路由必须显式传入该配置(route.ts)。
八、扩展与注意事项
- 命令行版对照实现:仓库 examples/mcp/src/elicitation/client.ts 提供了一个终端交互版本(
pnpm client:elicitation),用readline逐字段向用户提问,逻辑与前端模态框完全同构:按 Schema 遍历properties,区分必填/可选、按类型解析 number/boolean 等,是理解本流程的最小实现,适合在没有浏览器环境时联调验证。 - 多步 elicitation:仓库另有
elicitation-multi-step示例(见 examples/mcp/package.json),演示同一会话内多次 elicitation 的编排,说明该机制天然支持"一问一答"逐步收集复杂信息。 - 超时与失败兜底:主路由的
onElicitationRequest处理器在 catch 分支中默认返回action: 'decline'(route.ts),配合注册表 60 秒超时,保证即使前端断连,MCP 服务器的工具调用也不会永久挂死。 - 运行前提:本文全部路径与配置以当前仓库为准;需要
OPENAI_API_KEY环境变量,且 MCP 服务器与 Next.js 应用须同时运行、端口一致(SSE 为8085,页面为3000)。
总结
本文以 mcp-elicitation 示例 为主线,从 MCP 服务器的elicitInput、Next.js 服务端的createMCPClient + onElicitationRequest + 内存注册表,到前端的useChat + data part 监听 + Schema 驱动模态框,再到@ai-sdk/mcp协议层的elicitation/create处理链路,完整还原了 human-in-the-loop 工具调用的端到端实现。这套模式的价值在于:它把"模型猜参数"升级为"模型与用户协作补全参数",既保证了工具调用的完整性,又保留了用户对敏感信息(如密码、邮箱)的最终控制权。无论是注册、下单、审批还是任何需要人工确认的场景,都可以直接复用本文的架构。
【免费下载链接】aiThe AI Toolkit for TypeScript. From the creators of Next.js, the AI SDK is a free open-source library for building AI-powered applications and agents项目地址: https://gitcode.com/GitHub_Trending/ai/ai
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考