审批一加,MCP工具就找不到了
查工单没问题,排班也没问题。可只要在“花钱”的工具前面加一道人工审批,重启就报错: Tool order_part not found 。一个做派单助手的开发者,就这样撞上了框架的边界。 他给一家虚构的现场服务公司写了个调度助手。调度员输入“WO-1043 现在什么状态”或者“给 WO-1044 订一个 3 kW 加热元件”,助手负责回答。背后挂了三个工具: lookup_work_order :读站点、资产、问题、技师和时间段,自己跑。 schedule_visit :给技师排一个时间窗,自己跑。 order_part :订备件,要花钱,必须等人点头。 整套东西他都想用 Dart 写:助手服务、工具服务、Flutter 客户端,共用一套类型。不要 Python 边车,也不要中间塞个 Node 服务。 架构拆成四块,全是 Dart。后台是一个 MCP 服务器,走回环地址上的 Streamable HTTP,任何 MCP 客户端都能接;助手是一个 Genkit flow,从那个 MCP 服务器拿工具,从不直接 import 后台;每个花钱的工具前面挡一道审批中间件;HTTP 面只有一个 POST /dispatchAgent 加一个健康检查。 后台明明和助手在同一个进程里,为什么还要套 MCP?因为边界本身就是目的。哪天工单搬到真实系统,只需要换掉 MCP 服务器,助手、提示词和测试都不用动。 工具描述里特意写了“which costs money”。模型也会读到这句话。 类型只写一份,三个运行时共用。同一个 DispatchAnswer,既是模型的结构化输出 schema,也是 flow 的 HTTP 响应,还是 Flutter 客户端反序列化用的类型。这个文件不 import dart:io,所以客户端能从同一份 import 构建出 web、iOS 和 macOS 版本。改一个字段,build_runner 重新生成,编译器会告诉你所有受影响的地方。 审批闸门的逻辑是:中断、挂起、重启。toolApproval 接收一份“可以不问就执行”的工具清单,其余一律中断生成。模型请求 order_part 时,响应带着 finishReason == FinishReason.interrupted 回来。flow 把此前的对话和这个被中断的工具请求一起存到某个 approvalId 下,立刻回给客户端: {"result": {"answer": "Ordering \"Heating element 3 kW\" for WO-1044 needs your approval.", "orderIds": ["WO-1044"], "approvalId": "approval-1"}} Flutter 客户端弹出“批准”和“拒绝”。下一次调用把 approvalId 连同 approved 一起送回来。批准就重启那次挂起的调用,工具真正执行,模型根据真实结果作答;拒绝则工具永不执行,模型收到“Declined by the dispatcher. Nothing was ordered.”作为工具输出,再用自己的话向用户解释。 每个 approvalId 只能应答一次,首次使用就从待处理表里删掉,所以连点两下也不会订出两个加热元件。挂起的审批存在内存里,这是给 --max-instances 1 的 Cloud Run 演示做的刻意选择:一个实例,一张表。重启就忘,超出演示范畴就该放进 Firestore 或数据库。 然后就是那个 bug。查询正常,预订正常,审批提示也正常弹出来。他点了批准,重启失败,报 Tool order_part not found。 根因在 Genkit Dart 1.0.1 里。MCP 工具注册时用的是带命名空间的名字 work-orders/order_part ;模型看到的是短名 order_part,回传的也是短名;正常工具循环通过一张短名映射表找到工具,没问题;但恢复路径(interruptRestart)调用工具执行器时没带那张表,退回去按 order_part 查注册表,查不到,抛异常。 也就是说,任何带命名空间的工具(也就是每一个 MCP 工具)都能正常工作,直到你在它前面放一道审批。他用假模型和两个测试复现了:一个用普通工具名,一个用 work-orders/order_part。普通的过,带命名空间的挂。然后他通过 dependency_overrides 给本地那份 genkit 1.0.1 打了补丁,把映射表传给恢复路径,两个测试都绿了。 问题连同复现和根因提交到了上游,编号 genkit-ai/genkit-dart#657,修复以 #658 开放:恢复路径现在用和循环一样
觉得有用?分享给朋友
让更多人看到这篇内容