MMuffee 钱包终端用户接入说明

开发者参考

把 Muffee 钱包接进你自己的 Agent

本页面向写代码的人。工具目录、权限范围、入参结构、失败码和出站消息的字段表,都是在你打开这一页时从本部署的运行时读出来的,不是另抄的一份清单。本页只描述已经实现的东西:没有的工具不会在这里出现,也不会标成将来会有。

加载中
01

它是什么

三样东西挂在同一个服务上:

  • 一个远程 MCP 服务端。Streamable HTTP,无会话:不发 session id,每个请求重新解析凭证。工具、资源、长轮询都在这里。
  • 一个 OAuth 2.1 授权服务器。授权码 + PKCE(S256),支持 Client ID Metadata Document 与动态客户端注册,公开客户端(token_endpoint_auth_method=none)。
  • 一个演示钱包。余额、授权规则、审批页、对账页都在 /app,由钱包主人自己操作,Agent 碰不到。

没有 SDK,一个都没有。MCP 协议本身就是接口,用你客户端自带的 MCP 库连这个 endpoint 即可;服务端 Agent 直接发 JSON-RPC 也行。本页第 15 节给了裸请求示例。

钱包主人侧的三步接入(复制地址、在助手里粘贴、浏览器授权)在 终端用户接入说明,那一页不是这一页的简版,受众不同,两边都在用。

02

五分钟接上

下面的地址是本部署的真实地址,照抄即可。

…
  1. 不带凭证发一次请求。服务端答 401 并带 WWW-Authenticate,里面指向受保护资源元数据。这是 OAuth 的触发信号,不是“你没权限”。
  2. 读两份元数据。从受保护资源文档拿到授权服务器,再取授权服务器文档拿 endpoint 与支持的 scope。地址见第 04 节。
  3. 走授权码 + PKCE。钱包主人在第一方页面登录、看清是谁在申请什么、决定是否绑定一条自动支付规则,然后跳回你的 redirect URI。
  4. 调 tools/list。返回的是这张凭证的 scope 能看到的那些工具,不是全部。见第 05 节。

授权请求里带上 resource(RFC 8707)。本服务只接受以自己的 MCP endpoint 为受众的访问令牌。

03

核心概念

先说一个这里没有的概念:payment intent。本服务用的是审批上限模型——钱包主人批准的是一个金额上限,批准之后系统才去取当前价并在上限内成交。没有需要你先创建、再确认的支付意图对象,所以也没有对应的工具。

授权规则(mandate)
钱包主人在授权时创建的一条规则,它决定 Agent 能不能在主人不在场时直接付款。规则自带总额度、单笔上限,可选一个滚动 24 小时日限额。主人随时可以关掉它的自动支付,关掉之后 get_wallet_connection 立刻返回 canAutoPay: false。
审批上限
当前凭证付不了的那一单,不会锁位,而是停在 PENDING_APPROVAL 并给出 approvalUrl。主人在那一页批准的是一个最高金额,可以自己调;批准那一刻系统向供应商重新报价,在上限内按真实价格成交(可能更低),超出就终止、不扣款。
滚动 24 小时日限额
不按自然日,过零点不重置。额度是在每笔支付满 24 小时之后才逐步放出来的;已退款的扣款仍然计入。不设就是不限。
支付轨道
钱从哪条路走。钱包以它开户币种计价,本服务不做任何汇率换算,也不要替用户折算。
报价与锁位
两个独立的有效期。报价(quoteId)过期后不能下单;锁位(HELD)过期后由服务端释放。真实供应商的报价有效期在几十秒到几分钟量级——实测同一航线连续搜索能从 2155 跳到 2941,所以“先批上限、后取价”不是绕弯,而是不让一张过期价格单替主人做决定。
04

Endpoint 与鉴权

以下全部是本部署的运行时取值。

权限范围

每张凭证带一组 scope。它既决定 tools/list 看得到什么,也在每次 tools/call 前重新校验一遍——藏起来的工具不算秘密。

标为「特权」的 scope 永远不会发给未验证的客户端(动态注册或未在可信来源发布元数据文档的客户端)。钱包页自助签发的个人访问令牌固定是「自助」那一组,拿不到自动支付。

两份发现文档

05

工具目录

工具目录按 scope 裁剪。只拿到 travel:search 的凭证在 tools/list 里只会看到搜索那几个,另外六个根本不出现。这不是服务端坏了,也不是目录没注册上——调 tools/call 同样会被拒。要看全,就得授到对应的 scope。下表是用服务端过滤 tools/list 的同一个函数算出来的。

下面是本部署注册的全部工具。名字、权限、标题、说明、入参结构和注解都直接来自服务端的工具目录。

06

资源与组件

除了工具,服务端还注册了资源。两个 ui:// 是在对话里渲染的组件,是静态标记、不含任何账户数据,所以不设权限;它们显示的每个数字都来自一次工具调用,而那次调用要过和平时一样的权限与归属检查。订单资源本身是对资金状态的读取,所以要 wallet:read,并且走和 get_travel_order 完全一样的归属检查。

resources/subscribe 会被接受并且会校验(不存在或不属于你的订单会被拒,所以它不是一条绕过读检查探测订单号的路),但本传输是无会话的,不会推 notifications/resources/updated。实时更新的正道是长轮询工具和组件自己的三秒轮询。

07

订单与资金语义

这一节是本页最该读完的部分。四条,每一条都是踩出来的。

一 · final: false 是快照,不是结局

钱包主人点下确认、工具返回的那一刻,订单几乎总是还在处理中。真正的结局——成功、预订失败、转人工——是几十秒后由三秒一轮的扫描器写进去的。

所以:不轮询 wait_travel_order 的 Agent,会把一笔后来失败的预订一直报成成功。它拿到的是中间快照,而它向主人宣布的是结果。判断依据只有一个,就是 final:false 就继续等,true 才能下结论、必须停止等待。

等待有两条路,选一条:长轮询 wait_travel_order(传上次看到的状态,超时返回「未变化」,再调一次继续等),或者配置出站 webhook,由服务端在订单到终态时主动通知你一次(见第 10 节)。终态清单也是生成的:

二 · charged 是四值,不是布尔

涉及钱的问题一律看 funds,不要看订单状态猜。funds.charged 有四种取值,而 null 的含义是没人知道:扣款正在途中,或者通道结果未确认。

把 null 当成 false 播报「未扣款」,而钱其实已经扣了——这个坑踩过两次。一次是出票超时转人工,一次是扣款在途。两次都是余额已经少了,而 Agent 在对主人说钱没动。null 时既不能说已扣,也不能说未扣,照 funds.message 转述即可,那段文案是服务端为此写的。

funds.status 比 charged 更细,取值与含义见第 05 节里 get_travel_order 的说明(那段说明来自服务端,不是这里的转述)。要注意「处理中」这一档意味着余额可能已经被扣减而结果未定,它不属于未扣款。

三 · 额度内 Agent 直接付,不需要任何人工

这是授权规则的作用,不是一道审批流程。凭证拿到执行付款的 scope、绑定了一条允许自动支付的规则、金额在单笔上限内且总额度和日限额都够——那么下单就直接付掉了,钱包主人不在场,也不需要点任何东西。

不写清楚这一条,集成方会以为每一单都要人点,于是把 approvalUrl 当成必经步骤写进流程,或者干脆不去申请自动支付。审批是额度不够时的兜底,不是默认路径。当前凭证到底能不能直接付,问 get_wallet_connection:它读的是那条规则此刻的真实状态,主人刚关掉自动支付,它就立刻返回 false。

四 · 审批前置:没批准之前什么都没发生

需要主人批准的订单停在 PENDING_APPROVAL,此时不占供应商库存、不锁价、不扣款。供应商那边完全没有这笔订单。

主人批的是上限而不是价格。批准那一刻才重新报价并锁位,所以订单上的最终金额通常不等于你早先报给用户的那个数。差额在 get_travel_order 返回的审批定价字段里有:原报价、重新报价、带符号差额、主人批准的上限,四个数都给你,别只念新价格。

08

人工审批模式

什么时候会要人,以及要人之后怎么写。

三种落地结果

  • 直接付掉。额度够、规则允许。你拿到的订单已经在付款或已完成,继续等终态。
  • 停在等审批。额度不够、规则关了、或者没有执行付款的 scope。带 approvalUrl,必须把这个链接交给主人,然后继续等。链接不递出去,订单就停在那里,没人知道为什么。
  • 停在等确认。已经锁位但还没付款,带 confirmUrl。同样必须递给主人,并且要说清楚锁位不等于预订成功,也不等于已付款。

重新报价的三种结局要分开讲

批准时重新报价会得到三种不同的失败,主人的下一步完全不同,别混成一句「失败了」:

  • 超出上限:提高上限后重新下单有用。
  • 行程已售罄:提高上限没有用,得改日期或改行程。
  • 运价条件变了(行李额、退改、选座餐食这类随票服务):航班还在飞,可能还更便宜,但条件和主人批准时看到的不一样。系统不会在主人不知情的情况下降级,所以提高上限也没用;要如实说明差异,由主人决定要不要重新下单。

这三种各有自己的失败码,见第 12 节。

审批窗口与逾期

主人有一段时间来答复,逾期按部署配置的处置方式落地(默认是拒绝)。窗口长度和处置方式是本部署的真实配置:

窗口是一天量级而不是分钟量级,因为审批发生在锁位之前:报价和锁位的钟还没开始走,价格会在批准那一刻重新取。限制它的是人的注意力,而不是供应商的库存。

09

幂等与重试

  • 同一个报价重复下单是幂等的。拿同一个 quoteId 再调一次创建,回到的是同一笔订单,也不会第二次占用并发额度。超时后重试是安全的,不要为了「保险」换个报价再下一单。
  • 「已发出」是一条铁律。向供应商或支付通道发出去的请求,在拿到明确结果之前,结果是未知而不是失败。服务端为此专门区分了「已发送」中间态,不会把未知当成没发生去重试,所以你看到的「处理中」是真的处理中。你这一侧也要照办:请求超时就回查,不要重发。
  • 出站通知按订单去重。终态通知一笔订单只发一次,并且没有事件 id——「类型 + 订单号」就是去重键。重试发的是同样的字节,所以按这个键去重的接收方不可能重复计数。
  • 查询类工具都是只读幂等的。注解里标了 readOnlyHint 的那些可以随便重试;带破坏性注解的只有创建订单那一个。
  • 不要因为超时就新建付款。处理中的订单继续查就好。重复下单换来的是两笔锁位和一次人工对账。
10

出站消息契约

两种消息会主动发给 Agent。字段表是从契约模块生成的,字段名是 snake_case 且故意如此:这是对外契约,Agent 侧按这些名字匹配,不跟随本仓库其余部分的 camelCase。

终态订单通知

订单到了它再也不会离开的状态时发一次。它存在的理由就是第 07 节第一条:不轮询的 Agent 会把后来失败的预订一直报成成功。

这不是一个 Link 结果,也不能被当成 Link 结果处理。Link 结果是对你发出的某个请求的答复,用那个请求携带的 state 匹配,而 state 是单次使用的。这条消息是无请求的主动通知,没有 state 可答、也没有 state 可消耗——硬塞一个进去,会烧掉你下一个请求的 state,或者被当成已收到的重复答复丢掉。字段表里没有 state 是刻意的。

Link 结果

钱包主人在第一方页面完成(或没完成)一件事之后的答复。同一个结构走三条通道:窗口还开着时的 postMessage、跳回 Agent 的重定向、以及出站 webhook——只有最后一条在窗口关掉之后还有效。

retryable 告诉你能不能把主人再送一遍同一个流程。只有「已过期」算:「已取消」是主人的决定,再问一次等于 Agent 推翻它;「失败」是基础设施问题,立刻重试还是失败。这条判断写在契约模块里,服务端和页面两侧都读它,不是各自一份。

收件人与签名

出站地址只能来自部署配置,不能由客户端在注册或授权时自己填。原因见第 13 节:动态注册是未鉴权的,任何人都能注册一个客户端,一个能自带回调地址的未鉴权注册接口就是一台 SSRF 发射器。

签名用部署配置的共享密钥做 HMAC-SHA256,签的是时间戳、随机数和请求体的哈希三者拼成的串。请按收到的原始字节验签,不要先解析再重新序列化。

11

限额与并发

本服务没有速率限制。没有每分钟多少次的配额,也没有 429。所以这一节不叫 Rate Limits——写一页速率限制,内容要么是空的,要么是编的。

真正存在的约束是另一类,而且你必须知道:

并发订单上限:待审批与已锁位共用一个计数

这一点容易算错。单张凭证的未结订单上限,把等审批的和已锁位的算在同一个计数里。不是「N 个待审批 + N 个已锁位」。

为什么要合成一个:如果分两个计数,批准动作会把一行从待审批挪到已锁位,于是在一个桶里腾出位置而没有在另一个桶里占掉位置——「创建、批准、创建、批准」一圈下来就能同时持有两倍的量。合成一个之后,「单张凭证最多 N 笔未结订单,不论什么组合」这件事是由构造保证的。

代价是明摆着的:有 N 个没人答复的审批时,这张凭证开不了新单,即使供应商那边什么都没占。这正是本意——这里稀缺的资源是钱包主人的注意力,不只是供应商的库存。解法就是错误信息里写的那几个:让主人批了、确认了,或者等它过期。已过期的审批在扫描器处理掉它之前仍然计数,和已过期的锁位一样。

超了额度不是报错

超出授权规则的总额度、单笔上限或日限额,不会返回错误,而是落到审批:订单停在等主人批准并带上 approvalUrl。把它当成报错去重试,只会白占并发额度。

报价与锁位有效期

这两个有效期由供应商逐笔给出,没有固定值可以写在这里——每笔报价和每次锁位都带自己的到期时间,读订单上的到期字段,不要假设一个常量。真实供应商的量级是:报价几十秒到几分钟,锁位几分钟。

12

失败码

下表是服务端已收录含义的全部失败码,连同它给出的中文说明。它是从服务端的失败码表生成的,条数就是运行时的真实条数。

不要在失败码上做业务分支。这张表只为一个用途存在:让 Agent 能用话把原因告诉主人,而不是把原始码念出来。上游同一个码可能既表示真失败也表示重复支付,分支判断会判错。把 failure_reason 转述给用户,不要念 failure_code。

没收录的码会被如实报成「未收录其含义」,而不是猜一个。

13

Security Model

签字为什么必须在第一方页面

Agent 的组件通道里不存在任何不属于 Agent 的凭据。渲染在对话里的组件调工具时,带的是 Agent 自己的令牌,而服务端分不清这一次是人点的还是模型调的——两者在协议上完全一样。

所以凡是要钱包主人本人表态的动作(授权连接、批准上限、确认付款),都只能发生在钱包自己的页面上:那里有主人的会话,有邮箱验证,有一个只做这一件事的界面。Agent 能做的是把链接递过去,然后等。

推论:组件里显示的每个数字都来自一次正常的工具调用,要过同样的 scope 检查和归属检查;组件本身是静态标记,不含账户数据。把权限放在渲染层而不是数据层,等于没放。

state 单次使用,first-reply-wins

Link 流程的答复用发起时携带的 state 匹配。它单次使用:第一个回来的答复生效,之后同一个 state 的答复一律丢弃——两侧都按这条执行。这既防重放,也防「窗口里 postMessage 回来一个、webhook 又回来一个」被当成两件事处理。

由此也得出上一节那条禁令:终态通知不能伪装成 Link 结果,因为它会消耗一个它本来无权消耗的 state。

动态注册的客户端永远是未验证的

客户端身份有两档。在可信来源发布了客户端元数据文档的,是已验证;通过动态注册自己登记的,永远是未验证,没有任何路径能把它升级。

原因很直接:注册接口是未鉴权的(规范如此,MCP 客户端要能自助接入),任何人都能注册一个任意名字的客户端。所以:

  • 未验证的客户端永远拿不到特权 scope(即自动支付那一档,见第 04 节的权限表)。
  • 同意页会把「未验证」明确标给主人看,名字不对或标着未验证就不该点授权。
  • 出站 webhook 的收件人只能来自部署配置。这一条就是从这里来的:如果注册时能自带回调地址,那这个未鉴权接口就成了一个让服务端往任意地址发签名请求的入口。
  • 从未完成过授权的动态注册客户端会在一段时间后过期,不会无限堆积。

401 + WWW-Authenticate 是 OAuth 触发信号

不带凭证或凭证不再有效时,服务端答 401 并带 WWW-Authenticate,里面指向受保护资源元数据的地址(RFC 9728)。

这不是「你没权限,放弃吧」。它是在说「来这里走授权」。把它当成终局错误报给用户的客户端,会卡在一个本来一次授权就能解决的地方。

Scope 不够是另一回事:答 403,并在同一个头里带上缺的 scope,让客户端去申请提权。401 要授权,403 要提权,两者不要当成同一种。

还有一个第三种:凭证本身有效、但这张凭证还没绑定钱包。这时服务端会告诉你该把主人送到哪个链接去完成绑定,而不是只回一个「不允许」——一个光秃秃的拒绝,客户端除了报错什么也做不了,而唯一真正能解决它的动作根本没被提供。

凭证边界

  • 没有任何工具的入参里有用户 id,而且所有入参结构都是封闭的(不接受多余字段),所以夹带不进去。钱包主人只从解析出来的凭证里得出,没有第二条路。
  • Agent 看到的账号视图里没有邮箱,余额视图里没有账户号、用户 id 和邮箱。
  • 每次工具调用和资源读取都记审计,钱包主人能在钱包页看到哪个 Agent 用了什么工具、花了多少,并一键撤销;撤销后它下一次调用立刻失败。
  • 令牌有期限,访问令牌、刷新令牌、授权码和自助签发的个人访问令牌各有自己的有效期,取值见第 11 节。
14

Threat Model

工程事实的陈述,不是合规承诺。本页不提供隐私政策、服务条款或合规说明——当前阶段没有这些文件,而写一份占位的法律文本是在做一个我们没打算兑现的承诺。

威胁现状
提示注入让 Agent 超额消费授权规则的总额度是真正的上限。单笔上限挡不住「把一笔拆成十笔」,所以不要把它当作风险边界——总额度才是。主人可随时关闭自动支付,立即生效。
Agent 冒充主人表态组件通道里没有主人的凭据,服务端分不清人点与模型调,因此所有表态都搬到第一方页面。见第 13 节第一条。
点击劫持钱包与 Link 页面都以 frame-ancestors 'none' 响应,不允许被任何文档嵌套。两条策略是分开的两个值,即使当前内容相同——将来 Link 页要允许受信父页嵌入时,改的是那一个值,而不是先把一个安全响应头劈成两半。
SSRF出站 webhook 的收件人只能来自部署配置,并且地址会被校验。未鉴权的动态注册接口因此不能把服务端变成发射器。
未鉴权的客户端注册按规范必须开放,所以收敛的是它的后果:注册出来的客户端永远未验证、永远拿不到特权 scope、未使用会过期、同意页明确标注。
DNS rebindingMCP 传输对浏览器 Origin 做允许列表校验。
重放 Link 答复state 单次使用、first-reply-wins。
伪造出站通知HMAC-SHA256 签名,按原始字节验签;带时间戳与随机数。
重复投递导致重复记账终态通知一笔订单只发一次,去重键是「类型 + 订单号」,重试发同样的字节。

一条会改变结论的前提,记在这里以免被忘掉:当前判断成立是因为跑的是沙箱、旅客信息用的是内置测试旅客,没有真人证件号进入系统。一旦切到真实供应商并有真人行程下单,数据性质就变了,与我们是否称之为演示无关;那时需要先有一份数据清单(每项存在哪里、谁能读、留多久、有无删除路径、是否出境),它是写任何政策文本的输入。

15

客户端接入

没有 TypeScript / Python / CLI SDK,一个都没有,这里也不会给你一个将来会有的承诺。MCP 协议本身就是接口:

  • 现成的 AI 客户端(Claude、Cursor、ChatGPT、WorkBuddy 等):按各自的「添加 MCP 服务器」填第 02 节那个地址即可,具体步骤在 终端用户接入说明,不在这里重复一份。
  • 自研 Agent:用你语言的 MCP 客户端库连这个地址,走 OAuth。
  • 服务端脚本:没有浏览器也不需要本机进程。在钱包页自助签发一个个人访问令牌,放进 Authorization: Bearer 头直接发 JSON-RPC。该令牌固定是自助那组权限,不能自动支付——付款都会停在等主人确认。令牌明文只展示一次,请存进密钥管理,不要写进代码仓库。

裸 JSON-RPC 示例(地址是本部署真实地址):

本机 stdio 桥只是一座转发桥,把标准输入的 JSON-RPC 原样转给这个远程地址再写回标准输出,它不是 SDK,也不实现任何工具。工具目录、权限判定和限额只在服务端一份。

服务标识已改名:initialize 返回的 serverInfo.name 由 personal-travel-agent 变为 muffee-wallet,两个组件资源的 URI 前缀由 ui://personal-travel/ 变为 ui://muffee-wallet/(travel-order://orders/{orderId} 不变,它描述的是资源类型而不是品牌)。此前配置过的用户需要把配置里的服务名改掉,并重新连接一次让客户端重读资源清单;把旧 ui:// 前缀写死在代码里的客户端也要一并更新。