@floatonnear/mcp 0.6.5 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/amounts.d.ts CHANGED
@@ -52,4 +52,20 @@ export interface TreeNode {
52
52
  export declare function humaniseTreeNode(node: TreeNode, cfg: {
53
53
  token?: string;
54
54
  decimals: number;
55
+ /**
56
+ * Per-token metadata, when it could be read from the chain.
57
+ *
58
+ * THIS REMOVES THE CAVEAT THE FIRST VERSION SHIPPED WITH. It could only
59
+ * humanise the one token whose decimals the operator had configured;
60
+ * every other kept base units, because scaling by a guessed exponent
61
+ * prints a figure wrong by orders of magnitude while looking tidy. The
62
+ * chain knows the real answer, so the guess was never necessary.
63
+ *
64
+ * A token still absent from this map keeps base units — "could not read
65
+ * the metadata" is not a licence to assume six.
66
+ */
67
+ meta?: Map<string, {
68
+ symbol: string;
69
+ decimals: number;
70
+ } | null>;
55
71
  }): TreeNode;
package/dist/amounts.js CHANGED
@@ -56,8 +56,15 @@ export function fromBaseUnits(units, decimals) {
56
56
  export function humaniseTreeNode(node, cfg) {
57
57
  if (!node || typeof node !== "object")
58
58
  return node;
59
- const known = (token) => !!cfg.token && token === cfg.token;
60
- const human = (v) => {
59
+ // Prefer what the CHAIN said; fall back to the configured token only.
60
+ const dp = (token) => {
61
+ const m = cfg.meta?.get(token);
62
+ if (m)
63
+ return m.decimals;
64
+ return cfg.token && token === cfg.token ? cfg.decimals : null;
65
+ };
66
+ const sym = (token) => cfg.meta?.get(token)?.symbol ?? null;
67
+ const human = (v, decimals) => {
61
68
  // EXPLICIT SHAPE CHECK, because `BigInt("")` is `0n` rather than a throw —
62
69
  // and `BigInt(" ")` is too. A try/catch alone therefore converted an
63
70
  // empty or whitespace cap into "0", which is not a parse failure the
@@ -66,7 +73,7 @@ export function humaniseTreeNode(node, cfg) {
66
73
  if (typeof v !== "string" || !/^\d+$/.test(v))
67
74
  return v;
68
75
  try {
69
- return fromBaseUnits(BigInt(v), cfg.decimals);
76
+ return fromBaseUnits(BigInt(v), decimals);
70
77
  }
71
78
  catch {
72
79
  // Still guarded: fromBaseUnits rejects decimals outside its own range,
@@ -75,10 +82,26 @@ export function humaniseTreeNode(node, cfg) {
75
82
  return v;
76
83
  }
77
84
  };
78
- const limits = node.limits?.map((l) => known(l.token)
79
- ? { ...l, daily_cap: human(l.daily_cap), per_tx_cap: human(l.per_tx_cap), decimals: cfg.decimals }
80
- : { ...l, decimals: null, units: "base" });
81
- const childCapSum = node.child_cap_sum?.map(([token, amount]) => (known(token) ? [token, human(amount)] : [token, amount]));
85
+ const limits = node.limits?.map((l) => {
86
+ const d = dp(l.token);
87
+ const symbol = sym(l.token);
88
+ if (d === null)
89
+ return { ...l, decimals: null, units: "base" };
90
+ return {
91
+ ...l,
92
+ // The SYMBOL beside the id, never instead of it: anyone can deploy a
93
+ // token calling itself USDC, and a cap that names only a symbol cannot
94
+ // be checked against the one the user meant.
95
+ ...(symbol ? { token_symbol: symbol } : {}),
96
+ daily_cap: human(l.daily_cap, d),
97
+ per_tx_cap: human(l.per_tx_cap, d),
98
+ decimals: d,
99
+ };
100
+ });
101
+ const childCapSum = node.child_cap_sum?.map(([token, amount]) => {
102
+ const d = dp(token);
103
+ return (d === null ? [token, amount] : [token, human(amount, d)]);
104
+ });
82
105
  return {
83
106
  ...node,
84
107
  ...(limits ? { limits } : {}),
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Turn a payment outcome into something a person would actually say.
3
+ *
4
+ * WHY THE SERVER DOES THIS AND NOT THE MODEL. Two reasons, and the second is
5
+ * the one that decides it.
6
+ *
7
+ * First, the old instructions literally said "Report the reason code", so the
8
+ * agent did: a user asking to pay someone got `PARENT_PER_TX_LIMIT` back. That
9
+ * is a correct answer to a question nobody asked. Telling the model "be
10
+ * friendly" instead would help, but phrasing drifts between models, between
11
+ * versions, and between one conversation and the next.
12
+ *
13
+ * Second, and decisively: a USEFUL explanation needs NUMBERS the model does
14
+ * not have. "That's over the limit" is nearly as useless as the raw code. What
15
+ * a person needs is "the most you can send in one payment is 10, and this asked
16
+ * for 15 — try 10 or less". Those figures live in the budget tree, which the
17
+ * payment response never carried. So the server reads them and writes the
18
+ * sentence; the agent relays it.
19
+ *
20
+ * THE CODE IS KEPT, not discarded. Precision about WHICH rule fired is a real
21
+ * asset — `PARENT_DAILY_BUDGET` meaning "a sibling drained the shared pool" is
22
+ * something a flat policy model cannot express, and developers and the pitch
23
+ * both want it. So it stays in the payload under `technical`, out of the way
24
+ * of the sentence a user sees.
25
+ *
26
+ * HOUSE STYLE, since this is product copy:
27
+ * - one or two short sentences, no jargon, no ALL-CAPS codes
28
+ * - name the real numbers and the real token symbol
29
+ * - say what to do next when there IS something to do
30
+ * - never imply money moved when it did not, and never imply it is gone
31
+ * when the refusal was free
32
+ */
33
+ /** What the contract returned, plus whatever context the handler could gather. */
34
+ export interface OutcomeContext {
35
+ status?: string;
36
+ reason_code?: string;
37
+ /** Human-units amount that was requested, e.g. "15". */
38
+ amount: string;
39
+ recipient: string;
40
+ /** Token symbol if known ("USDC"), else a short id. */
41
+ symbol: string;
42
+ /** Per-payment cap in human units, when known. */
43
+ perTxCap?: string | undefined;
44
+ /** Remaining budget today in human units, when known. */
45
+ available?: string | undefined;
46
+ /** Daily cap in human units, when known. */
47
+ dailyCap?: string | undefined;
48
+ /** Seconds left on a cooldown, when known. */
49
+ cooldownLeftSec?: number | undefined;
50
+ }
51
+ export interface Explanation {
52
+ /** The sentence(s) to say to the user. No codes, no jargon. */
53
+ say: string;
54
+ /** A concrete next step, or null when there is nothing for the user to do. */
55
+ next: string | null;
56
+ /** True when value actually moved. */
57
+ moved: boolean;
58
+ }
59
+ export declare function explainOutcome(c: OutcomeContext): Explanation;
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Turn a payment outcome into something a person would actually say.
3
+ *
4
+ * WHY THE SERVER DOES THIS AND NOT THE MODEL. Two reasons, and the second is
5
+ * the one that decides it.
6
+ *
7
+ * First, the old instructions literally said "Report the reason code", so the
8
+ * agent did: a user asking to pay someone got `PARENT_PER_TX_LIMIT` back. That
9
+ * is a correct answer to a question nobody asked. Telling the model "be
10
+ * friendly" instead would help, but phrasing drifts between models, between
11
+ * versions, and between one conversation and the next.
12
+ *
13
+ * Second, and decisively: a USEFUL explanation needs NUMBERS the model does
14
+ * not have. "That's over the limit" is nearly as useless as the raw code. What
15
+ * a person needs is "the most you can send in one payment is 10, and this asked
16
+ * for 15 — try 10 or less". Those figures live in the budget tree, which the
17
+ * payment response never carried. So the server reads them and writes the
18
+ * sentence; the agent relays it.
19
+ *
20
+ * THE CODE IS KEPT, not discarded. Precision about WHICH rule fired is a real
21
+ * asset — `PARENT_DAILY_BUDGET` meaning "a sibling drained the shared pool" is
22
+ * something a flat policy model cannot express, and developers and the pitch
23
+ * both want it. So it stays in the payload under `technical`, out of the way
24
+ * of the sentence a user sees.
25
+ *
26
+ * HOUSE STYLE, since this is product copy:
27
+ * - one or two short sentences, no jargon, no ALL-CAPS codes
28
+ * - name the real numbers and the real token symbol
29
+ * - say what to do next when there IS something to do
30
+ * - never imply money moved when it did not, and never imply it is gone
31
+ * when the refusal was free
32
+ */
33
+ const plural = (n, one, many) => (n === 1 ? one : many);
34
+ /** "2 minutes", "3 hours" — a duration a person can act on. */
35
+ function human(sec) {
36
+ if (sec < 60)
37
+ return `${sec} ${plural(sec, "second", "seconds")}`;
38
+ const m = Math.ceil(sec / 60);
39
+ if (m < 60)
40
+ return `${m} ${plural(m, "minute", "minutes")}`;
41
+ const h = Math.ceil(m / 60);
42
+ return `${h} ${plural(h, "hour", "hours")}`;
43
+ }
44
+ export function explainOutcome(c) {
45
+ const amt = `${c.amount} ${c.symbol}`;
46
+ const code = c.reason_code ?? "";
47
+ // ---- It worked. Say what happened, in that order: what, to whom, what is left.
48
+ if (c.status === "SETTLED" || code === "SETTLED") {
49
+ const left = c.available !== undefined
50
+ ? ` There's ${c.available} ${c.symbol} left to spend today.`
51
+ : "";
52
+ return { say: `Sent ${amt} to ${c.recipient}.${left}`, next: null, moved: true };
53
+ }
54
+ // ---- In flight. Do NOT claim it arrived.
55
+ if (c.status === "TRANSFER_PENDING" || code === "TRANSFER_PENDING") {
56
+ return {
57
+ say: `The ${amt} payment to ${c.recipient} is on its way — it hasn't confirmed yet.`,
58
+ next: "Check again in a moment to see whether it completed.",
59
+ moved: true,
60
+ };
61
+ }
62
+ /**
63
+ * ---- Approved by the rules, then the transfer itself failed.
64
+ *
65
+ * Said carefully: the budget was NOT charged, and people assume a failed
66
+ * payment means missing money. The usual cause is a payee not registered on
67
+ * the token, which is a real thing they can fix.
68
+ */
69
+ if (c.status === "TRANSFER_FAILED" || code === "TRANSFER_FAILED") {
70
+ return {
71
+ say: `The ${amt} payment to ${c.recipient} didn't go through, so nothing left the vault. ` +
72
+ `Usually that means ${c.recipient} isn't set up to receive ${c.symbol} yet.`,
73
+ next: `Ask ${c.recipient} to register for ${c.symbol}, then try again.`,
74
+ moved: false,
75
+ };
76
+ }
77
+ const nothingMoved = " Nothing was sent.";
78
+ switch (code) {
79
+ // ---- The two limit shapes, which are the ones users actually meet.
80
+ case "PER_TX_LIMIT":
81
+ case "PARENT_PER_TX_LIMIT": {
82
+ const cap = c.perTxCap;
83
+ const whose = code === "PARENT_PER_TX_LIMIT"
84
+ ? "a limit set higher up the budget"
85
+ : "this agent's limit";
86
+ return {
87
+ say: cap
88
+ ? `That's too big for one payment — ${whose} caps each payment at ${cap} ${c.symbol}, ` +
89
+ `and this asked for ${c.amount}.${nothingMoved}`
90
+ : `That's more than ${whose} allows in a single payment.${nothingMoved}`,
91
+ next: cap
92
+ ? `Send ${cap} ${c.symbol} or less, or ask the vault owner to raise the per-payment limit.`
93
+ : "Try a smaller amount, or ask the vault owner to raise the per-payment limit.",
94
+ moved: false,
95
+ };
96
+ }
97
+ case "DAILY_BUDGET": {
98
+ const left = c.available;
99
+ return {
100
+ say: left
101
+ ? `That would go past today's spending limit — ${left} ${c.symbol} is left of ` +
102
+ `${c.dailyCap ?? "today's"} ${c.symbol}, and this asked for ${c.amount}.${nothingMoved}`
103
+ : `Today's spending limit is used up.${nothingMoved}`,
104
+ next: left && Number(left) > 0
105
+ ? `Send ${left} ${c.symbol} or less, or wait — the limit resets at midnight UTC.`
106
+ : "The limit resets at midnight UTC, or the owner can raise it.",
107
+ moved: false,
108
+ };
109
+ }
110
+ /**
111
+ * The one worth explaining properly, because the obvious reading is wrong:
112
+ * this agent's OWN allowance can look completely free.
113
+ */
114
+ case "PARENT_DAILY_BUDGET":
115
+ return {
116
+ say: `This agent still has room, but the shared pool it spends from is used up for today — ` +
117
+ `another agent got there first.${nothingMoved}`,
118
+ next: "The pool resets at midnight UTC, or the owner can raise the shared limit.",
119
+ moved: false,
120
+ };
121
+ case "COOLDOWN":
122
+ case "PARENT_COOLDOWN":
123
+ return {
124
+ say: `Payments from here are spaced out, and the next one isn't due yet` +
125
+ `${c.cooldownLeftSec ? ` — about ${human(c.cooldownLeftSec)} to go` : ""}.${nothingMoved}`,
126
+ next: "Try again shortly.",
127
+ moved: false,
128
+ };
129
+ // ---- Owner decisions. The user cannot fix these themselves, so say who can.
130
+ case "NO_RECIPIENTS_SET":
131
+ return {
132
+ say: `This agent isn't cleared to pay anyone yet.${nothingMoved}`,
133
+ next: "The vault owner needs to approve a list of payees first.",
134
+ moved: false,
135
+ };
136
+ case "RECIPIENT_NOT_ALLOWED":
137
+ return {
138
+ say: `${c.recipient} isn't on this agent's approved list of payees.${nothingMoved}`,
139
+ next: "The vault owner decides who can be paid, and can add them.",
140
+ moved: false,
141
+ };
142
+ case "VAULT_PAUSED":
143
+ return {
144
+ say: `The vault is paused, so no payments can go out right now.${nothingMoved}`,
145
+ next: "The vault owner can unpause it.",
146
+ moved: false,
147
+ };
148
+ case "NODE_FROZEN":
149
+ return {
150
+ say: `This agent is frozen, so it can't spend.${nothingMoved}`,
151
+ next: "The vault owner can unfreeze it.",
152
+ moved: false,
153
+ };
154
+ case "TOKEN_NOT_ALLOWED":
155
+ return {
156
+ say: `This agent doesn't have a ${c.symbol} budget.${nothingMoved}`,
157
+ next: `The vault owner can give it one, or pay in a token it does have.`,
158
+ moved: false,
159
+ };
160
+ /**
161
+ * Not a refusal — a pause. Must not read as a failure, and must not read
162
+ * as done either.
163
+ */
164
+ case "APPROVAL_REQUIRED":
165
+ return {
166
+ say: `${amt} to ${c.recipient} is large enough to need the owner's sign-off, so it's ` +
167
+ `waiting for them. Nothing has been sent yet.`,
168
+ next: "Ask the vault owner to approve it.",
169
+ moved: false,
170
+ };
171
+ case "APPROVAL_REJECTED":
172
+ return {
173
+ say: `The vault owner turned down the ${amt} payment to ${c.recipient}.${nothingMoved}`,
174
+ next: null,
175
+ moved: false,
176
+ };
177
+ case "DUPLICATE_REQUEST":
178
+ return {
179
+ say: "That exact payment was already submitted, so it wasn't sent twice.",
180
+ next: "Check whether the first one went through before trying again.",
181
+ moved: false,
182
+ };
183
+ case "INVALID_AMOUNT":
184
+ return { say: `${c.amount} isn't a valid amount to send.`, next: "Use an amount above zero.", moved: false };
185
+ case "INVALID_MEMO":
186
+ return {
187
+ say: "The note attached to that payment isn't allowed — it's too long or has odd characters.",
188
+ next: "Shorten the note and try again.",
189
+ moved: false,
190
+ };
191
+ default: {
192
+ /**
193
+ * UNKNOWN CODE: say plainly that it was refused and that nothing moved,
194
+ * and THEN give the code — because an unrecognised code is the one case
195
+ * where the raw value is genuinely the most useful thing a user can pass
196
+ * on to whoever can help. Guessing a friendly sentence for a rule we
197
+ * cannot describe would be worse than admitting it.
198
+ */
199
+ if (!code) {
200
+ return { say: `The ${amt} payment to ${c.recipient} didn't go through.${nothingMoved}`, next: null, moved: false };
201
+ }
202
+ return {
203
+ say: `The payment was refused by the vault's rules, so nothing was sent.`,
204
+ next: `If you need to report this, the reason given was "${code}".`,
205
+ moved: false,
206
+ };
207
+ }
208
+ }
209
+ }
package/dist/server.js CHANGED
@@ -54,12 +54,13 @@ import { topUpFor, paymentsAfterTopUp } from "./gas-math.js";
54
54
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
55
55
  import { z } from "zod";
56
56
  import { fromBaseUnits, humaniseTreeNode, toBaseUnits } from "./amounts.js";
57
- import { DEFAULT_GAS_TGAS, planAddNode, planAddRole, planBindVirtualAgent, planUnbindVirtualAgent, planFreezeNode, planPurgeSettled, planSetCooldown, planSetNodeCaps, planSetPaused, planSetRecipients, toCliCommand, planSetApprovalThreshold, planApprovePayment, planRejectPayment, planWithdraw, } from "./payloads.js";
58
- import { accountExists, rpcForNetwork, newRequestId, requireAgentKey, viewAccessKeys, viewAccount, viewFunction, } from "./rpc.js";
57
+ import { DEFAULT_GAS_TGAS, planAddNode, planAddRole, planBindVirtualAgent, planUnbindVirtualAgent, planFreezeNode, planPurgeSettled, planSetCooldown, planSetNodeCaps, planSetPaused, planSetRecipients, planSetStrictAllocation, toCliCommand, planSetApprovalThreshold, planApprovePayment, planRejectPayment, planWithdraw, } from "./payloads.js";
58
+ import { accountExists, rpcForNetwork, RPC_BY_NETWORK, newRequestId, requireAgentKey, viewAccessKeys, viewAccount, viewFunction, } from "./rpc.js";
59
59
  import { AGENT_NODE_ID, generateAgentKey } from "./agent-key.js";
60
60
  import { resolveKeySource } from "./key-file.js";
61
61
  import { dirname } from "node:path";
62
62
  import { listAgents, listRoles, selectRole } from "./roles.js";
63
+ import { label as tokenLabel, tokenMeta } from "./token-meta.js";
63
64
  /**
64
65
  * A decimal NEAR string to yoctoNEAR, without floating point.
65
66
  *
@@ -74,6 +75,7 @@ function toYoctoNear(n) {
74
75
  }
75
76
  import { buildCreatePlan, FACTORY_BY_NETWORK } from "./create.js";
76
77
  import { buildRoute, sendRequestPayment } from "./sign.js";
78
+ import { explainOutcome } from "./explain.js";
77
79
  import { getQuote, signBinding, toBase64 } from "./quote.js";
78
80
  /** How long a quote stays valid. Long enough to sign and send, short
79
81
  * enough that a stale price is not honoured. */
@@ -123,8 +125,36 @@ for (const err of [agentKeySource.error, quoteSignerSource.error]) {
123
125
  *
124
126
  * It used to be read inside the object literal, which meant `rpcUrl` could not
125
127
  * see it and fell back to a constant. See below.
128
+ *
129
+ * MAINNET IS THE DEFAULT, and it used to be testnet. The five CLIs all default
130
+ * to `--network mainnet`, so an operator who ran `create-float` and then
131
+ * started this server got a MAINNET vault being read by a TESTNET client, with
132
+ * nothing anywhere saying so. The symptom is not an error: reads answer
133
+ * "account does not exist" about an account holding real money, which reads as
134
+ * "Float is broken" rather than "these two halves disagree".
135
+ *
136
+ * Testnet is still one env var away. The asymmetry was the bug, not the value.
137
+ */
138
+ const NETWORK = process.env.NEAR_VAULT_NETWORK ?? "mainnet";
139
+ /**
140
+ * An unrecognised network FAILS CLOSED.
141
+ *
142
+ * `rpcForNetwork` falls back to a testnet endpoint for any string it does not
143
+ * know, and that fallback is pinned by a test, so it stays. But it means
144
+ * `NEAR_VAULT_NETWORK=Mainnet` — a capital letter — would resolve to TESTNET
145
+ * and answer mainnet questions about the wrong chain, confidently. That is the
146
+ * exact failure this server already shipped once by a different route.
147
+ *
148
+ * So validate at the boundary rather than widening the fallback: a typo should
149
+ * stop the server, not quietly move it to another chain.
126
150
  */
127
- const NETWORK = process.env.NEAR_VAULT_NETWORK ?? "testnet";
151
+ if (!Object.hasOwn(RPC_BY_NETWORK, NETWORK)) {
152
+ console.error(`float: NEAR_VAULT_NETWORK is "${NETWORK}", which is not a network this server knows ` +
153
+ `(${Object.keys(RPC_BY_NETWORK).join(", ")}). Refusing to start rather than guessing a ` +
154
+ `chain — an unrecognised value would otherwise resolve to a testnet endpoint and answer ` +
155
+ `questions about the wrong chain.`);
156
+ process.exit(1);
157
+ }
128
158
  const cfg = {
129
159
  vault: process.env.NEAR_VAULT_CONTRACT ?? "",
130
160
  /**
@@ -193,9 +223,40 @@ const cfg = {
193
223
  // "configured" and unreachable.
194
224
  agentKeysPath: process.env.NEAR_VAULT_AGENT_KEYS ?? ".float/agent-keys.json",
195
225
  };
226
+ /**
227
+ * NO VAULT IS THE COMMONEST STATE, SO THE MESSAGE HAS TO BE AN ONRAMP.
228
+ *
229
+ * This said "NEAR_VAULT_CONTRACT is not set" — true, and a dead end. Measured
230
+ * 2026-11-12: an agent asked to pay someone got that from `vault_config` and
231
+ * `vault_tree`, concluded the NEAR rail was unavailable, then searched three
232
+ * unrelated wallets for something that could reach a `.near` address, and
233
+ * finally asked the user to paste a contract id. It never suggested MAKING a
234
+ * vault, because nothing in the failure told it that was possible without one.
235
+ *
236
+ * The WORKFLOW preamble already said "No vault yet? vault_create" and that was
237
+ * not enough: at the moment of failure an agent reads the ERROR, not the
238
+ * instructions it was handed at startup. So the routing belongs here.
239
+ *
240
+ * It is the FIRST thing a new install hits, and the product's answer is
241
+ * genuinely good: `vault_create` needs no key, no existing vault and no
242
+ * configuration. An error that does not say so turns the normal first step
243
+ * into a support question.
244
+ *
245
+ * Names BOTH cases — "you have none" and "you have one this client does not
246
+ * know about" — because the fix differs completely and the message cannot tell
247
+ * which it is.
248
+ */
196
249
  function requireVault() {
197
- if (!cfg.vault)
198
- throw new Error("NEAR_VAULT_CONTRACT is not set");
250
+ if (!cfg.vault) {
251
+ throw new Error("No vault is configured, so there is nothing to read yet. " +
252
+ "If the user does not have a vault, call vault_create — it needs NO key, no existing " +
253
+ "vault and no configuration, and returns a link they approve in their own wallet. That " +
254
+ "is the normal first step, not an error. " +
255
+ "If they DO have one, set NEAR_VAULT_CONTRACT to its address and restart the client " +
256
+ "(servers are read once at startup); vault_create with an `owner` reports the address " +
257
+ "the factory derives for them. " +
258
+ "Do not go looking for another way to send money — no other rail can reach a NEAR vault.");
259
+ }
199
260
  return cfg.vault;
200
261
  }
201
262
  function tokenFor(t) {
@@ -280,19 +341,39 @@ WORKFLOW
280
341
  5. Winding down is a CLI job, not a tool: npx -p @floatonnear/create-float float-exit. It returns the tokens,
281
342
  the gas-key balances and the storage deposit. vault_teardown_report says what is recoverable.
282
343
 
283
- REFUSALS ARE ANSWERS. Report the reason code, do not retry blindly:
284
- DAILY_BUDGET this agent's own cap is spent.
285
- PARENT_DAILY_BUDGET a SIBLING drained the shared pool; this agent's own cap may look free.
286
- PER_TX_LIMIT one payment was too large; splitting it may still be refused by the cap.
287
- NO_RECIPIENTS_SET the node pays nobody yet; that is an owner decision, via vault_plan_set_recipients.
344
+ SPEAK LIKE A PERSON. NEVER SHOW A REASON CODE TO THE USER.
345
+ vault_pay returns "say" -- a plain-English sentence with the real numbers in it -- plus
346
+ "next_step" and "money_moved". RELAY "say". Add "next_step" if there is one. That is the
347
+ whole reply; do not append the code, the request id, or the raw JSON.
348
+
349
+ A user who asked to pay someone and got "PARENT_PER_TX_LIMIT" has been told nothing. The
350
+ codes are precise and they do matter -- they are under "technical" for debugging, and you
351
+ should quote them only if the user asks why, asks for detail, or is clearly a developer.
352
+
353
+ Say this: "That is too big for one payment -- the most per payment is 10 USDC, and this
354
+ asked for 15. Nothing was sent. Send 10 USDC or less, or ask the vault owner
355
+ to raise the limit."
356
+ Not this: "The payment was BLOCKED with reason_code PARENT_PER_TX_LIMIT."
357
+
358
+ On success too: "Sent 0.25 USDC to alice.near. There is 24.75 USDC left to spend today."
359
+ Not "status: SETTLED".
360
+
361
+ REFUSALS ARE ANSWERS, not errors. Do not retry blindly -- a refused payment is the contract
362
+ doing its job. "money_moved" tells you whether anything actually left the vault; never imply
363
+ money moved when it is false, and never imply money is lost when a refusal was free.
288
364
 
289
365
  WORTH KNOWING
290
366
  Budgets are hierarchical: a payment charges every ancestor, so a vault-wide ceiling holds even
291
367
  when a child's cap looks free.
368
+ Understand the difference between DAILY_BUDGET and PARENT_DAILY_BUDGET even though you must
369
+ not print either. DAILY_BUDGET means this agent spent its own allowance. PARENT_DAILY_BUDGET
370
+ means a SIBLING drained the shared pool, so this agent's own cap can still read as fully
371
+ available -- a user who checks their budget, sees room, and is told only "refused" will
372
+ reasonably think the product is broken. Neither is fixed by retrying.
292
373
  An implicit account (a 64-character hex id) cannot own a vault at all. Tell the user to use a
293
374
  named account rather than retrying.
294
375
  Amounts are whole tokens in these tools (1.5 means 1.5 USDC), never base units.`;
295
- const server = new McpServer({ name: "float", version: "0.6.5" }, { instructions: INSTRUCTIONS });
376
+ const server = new McpServer({ name: "float", version: "0.9.1" }, { instructions: INSTRUCTIONS });
296
377
  // ----------------------------------------------------------------- reads
297
378
  /**
298
379
  * Wait for a create to land, and prove the result belongs to the user.
@@ -388,6 +469,36 @@ server.tool("vault_await_create", "After the user signs the create link, confirm
388
469
  }
389
470
  // THE OWNER READ IS THE POINT. Getting here only proves an account exists.
390
471
  const config = await viewFunction(rpc, target, "get_config", {}).catch(() => null);
472
+ /**
473
+ * A `get_config` WITH AN OWNER IS NOT PROOF OF A VAULT.
474
+ *
475
+ * The FACTORY also exports `get_config`, and it also returns an `owner` —
476
+ * so pointing this tool at `usefloat.near` with the factory's own owner
477
+ * returned **LIVE**, and the next step it printed was
478
+ * `NEAR_VAULT_CONTRACT=usefloat.near`. Every later read would then query a
479
+ * factory for budgets it does not have.
480
+ *
481
+ * Measured 2026-11-12 on mainnet: the two shapes are cleanly separable --
482
+ * a vault answers `vault_id, owner, network, paused, strict_allocation,
483
+ * node_count, clock_mode`; the factory answers `owner, code_hash, network,
484
+ * required_deposit, paused`. `vault_id` is the discriminator, and it is the
485
+ * field whose presence means "I am the vault you are asking about".
486
+ *
487
+ * Checked BEFORE the owner comparison, because "this is not a vault" is a
488
+ * different and more fundamental answer than "this vault is someone
489
+ * else's", and reporting the latter about a factory would send someone
490
+ * looking for a nonexistent rival owner.
491
+ */
492
+ if (config && typeof config.vault_id !== "string") {
493
+ return json({
494
+ status: "UNVERIFIED",
495
+ vault: target,
496
+ instruction: `${target} answers get_config but it is NOT a Float vault — a vault's config ` +
497
+ `carries vault_id, and this one does not. The factory exports get_config too, ` +
498
+ `with an owner field, so "it responded and the owner matched" is not proof. Do ` +
499
+ `NOT set NEAR_VAULT_CONTRACT to it.`,
500
+ });
501
+ }
391
502
  if (!config || typeof config.owner !== "string") {
392
503
  return json({
393
504
  status: "UNVERIFIED",
@@ -420,11 +531,63 @@ server.tool("vault_await_create", "After the user signs the create link, confirm
420
531
  ],
421
532
  });
422
533
  });
534
+ /**
535
+ * Resolve every token named in a tree, once, so output can say "USDC".
536
+ *
537
+ * Fetched in PARALLEL and deduplicated: a tree with five nodes sharing one
538
+ * token must make one call, not five. `tokenMeta` caches for the process
539
+ * lifetime, so the second `vault_tree` of a session makes none.
540
+ */
541
+ async function metaFor(tokens) {
542
+ const unique = [...new Set(tokens)];
543
+ const pairs = await Promise.all(unique.map(async (t) => [
544
+ t,
545
+ await tokenMeta(cfg.rpcUrl, t, (rpc, token) => viewFunction(rpc, token, "ft_metadata", {})),
546
+ ]));
547
+ return new Map(pairs);
548
+ }
423
549
  server.tool("vault_tree", "Read the whole budget tree: every node, its caps, parent, bound agent account, cooldown and freeze state. No key required.", {}, async () => {
424
550
  const tree = (await viewFunction(cfg.rpcUrl, requireVault(), "get_tree", {}));
425
- return json(Array.isArray(tree)
426
- ? tree.map((n) => humaniseTreeNode(n, { token: cfg.token, decimals: cfg.decimals }))
427
- : tree);
551
+ if (!Array.isArray(tree))
552
+ return json(tree);
553
+ const meta = await metaFor(tree.flatMap((n) => n.limits?.map((l) => l.token) ?? []));
554
+ /**
555
+ * THE APPROVAL THRESHOLD WAS WRITE-ONLY.
556
+ *
557
+ * `vault_plan_set_approval_threshold` builds a payload to set one, and
558
+ * `get_effective_threshold` has always existed on the contract — but
559
+ * nothing read it back. So an owner could impose a rule and then had no
560
+ * way to see it, and an agent could not warn "this payment will be HELD"
561
+ * before making one. A policy you cannot read is a policy you cannot
562
+ * trust.
563
+ *
564
+ * EFFECTIVE, not the node's own: a threshold INHERITS down the tree, so
565
+ * the figure that actually governs a node may have been set on an
566
+ * ancestor. Reporting the local one would under-report the rule in force.
567
+ */
568
+ const thresholds = await Promise.all(tree.map((n) => viewFunction(cfg.rpcUrl, requireVault(), "get_effective_threshold", {
569
+ node_id: n.id,
570
+ }).catch(() => null)));
571
+ return json(tree.map((n, i) => {
572
+ const node = humaniseTreeNode(n, { token: cfg.token, decimals: cfg.decimals, meta });
573
+ const raw = thresholds[i];
574
+ if (raw === null || raw === undefined)
575
+ return node;
576
+ const tok = n.limits?.[0]?.token;
577
+ const d = (tok ? meta.get(tok)?.decimals : undefined) ?? cfg.decimals;
578
+ let human = String(raw);
579
+ try {
580
+ human = fromBaseUnits(BigInt(human), d);
581
+ }
582
+ catch {
583
+ /* surface what the contract said */
584
+ }
585
+ return {
586
+ ...node,
587
+ approval_threshold: human,
588
+ approval_note: `Payments above ${human} from this node are HELD until the owner approves them.`,
589
+ };
590
+ }));
428
591
  });
429
592
  server.tool("vault_budget", "Read one node's budget for a UTC day: cap, executed, in-flight (pending) and available, in human units. Omit `day` for today. No key required.", {
430
593
  nodeId: z.number().int().min(0),
@@ -436,20 +599,225 @@ server.tool("vault_budget", "Read one node's budget for a UTC day: cap, executed
436
599
  token: tokenFor(token),
437
600
  day: day ?? null,
438
601
  }));
439
- if (!b)
440
- return text(`node ${nodeId} has no limit for that token`);
441
- const d = cfg.decimals;
602
+ if (!b) {
603
+ // "no limit for that token" ASSERTED THAT THE NODE EXISTS. Asked about
604
+ // node 999 of a two-node vault it said the same thing, which sends a
605
+ // user off to set a limit on a node that is not there. Two different
606
+ // facts had one message.
607
+ const missing = await assertNode(nodeId);
608
+ if (missing)
609
+ return text(noNodeForRead(nodeId, missing.known));
610
+ const tree = (await viewFunction(cfg.rpcUrl, requireVault(), "get_tree", {}).catch(() => null));
611
+ const here = tree?.find((n) => n.id === nodeId)?.limits?.map((l) => l.token) ?? [];
612
+ return text(`Node ${nodeId} exists but has no limit for ${tokenFor(token)}. ` +
613
+ (here.length
614
+ ? `It has limits for: ${here.join(", ")}.`
615
+ : `It has no limits for any token, so it can pay nothing — set one with vault_plan_set_caps.`));
616
+ }
617
+ // The TOKEN's own decimals, not the configured guess. A vault may hold
618
+ // caps for several tokens and only one of them is NEAR_VAULT_TOKEN.
619
+ const tok = tokenFor(token);
620
+ const meta = (await metaFor([tok])).get(tok) ?? null;
621
+ const d = meta?.decimals ?? cfg.decimals;
442
622
  return json({
443
623
  node_id: nodeId,
624
+ token: tokenLabel(tok, meta),
625
+ /**
626
+ * `day: 20768` MEANS NOTHING TO A PERSON. It is days since the Unix
627
+ * epoch, which is the right key for a ledger and the wrong thing to
628
+ * show someone asking what they have left. The number stays — it is
629
+ * what the contract indexes by, and a support conversation needs it —
630
+ * but the date leads, and the reset is stated because "daily" does not
631
+ * say WHEN, and a budget that resets at midnight in a timezone you do
632
+ * not live in is a surprise worth pre-empting.
633
+ */
634
+ date: new Date(Number(b.day) * 86_400_000).toISOString().slice(0, 10),
444
635
  day: b.day,
636
+ resets: "00:00 UTC",
445
637
  daily_cap: fromBaseUnits(BigInt(b.daily_cap), d),
446
638
  executed: fromBaseUnits(BigInt(b.executed), d),
447
639
  pending: fromBaseUnits(BigInt(b.pending), d),
448
640
  available: fromBaseUnits(BigInt(b.available), d),
449
641
  });
450
642
  });
451
- server.tool("vault_request", "Read one stored payment request: status, reason code, amount, the ancestor chain it charged, and the UTC day it was reserved against. No key required.", { requestId: z.string() }, async ({ requestId }) => json(await viewFunction(cfg.rpcUrl, requireVault(), "get_request", { request_id: requestId })));
452
- server.tool("vault_config", "Read vault config: owner, paused, strict-vs-overcommit allocation, node count, and clock mode. No key required.", {}, async () => json(await viewFunction(cfg.rpcUrl, requireVault(), "get_config", {})));
643
+ /**
644
+ * THE ACTIVITY FEED, which nothing exposed.
645
+ *
646
+ * `list_requests` has been on the contract all along. The MCP had only
647
+ * `vault_request(requestId)` — a lookup by an id you can obtain *only* from a
648
+ * `vault_pay` response — so a user asking "what has my agent been spending
649
+ * on?" had nothing but running totals. For the one question people ask most
650
+ * about an autonomous spender, the answer was a number with no history behind
651
+ * it.
652
+ *
653
+ * THE CAVEAT IS LOAD-BEARING AND THE CONTRACT SAYS SO ITSELF: a BLOCKED
654
+ * request is never stored. The hot path returns the refusal as a value and
655
+ * emits an event, deliberately, because storing rejected attempts would let
656
+ * an agent inflate the vault's storage bill at will. So this feed shows
657
+ * in-flight, settled and failed payments — and an agent that was refused
658
+ * every time looks identical to one that never tried.
659
+ *
660
+ * Reporting that silently would be the worst kind of wrong: "no payments" is
661
+ * exactly what a user wants to hear when the real answer is "twelve refusals
662
+ * you cannot see here". So the caveat ships WITH the data, every time, rather
663
+ * than in a doc nobody opens.
664
+ */
665
+ server.tool("vault_history", "The vault's payment activity, newest first: what was paid, to whom, when, and how each one ended. Use this for 'what has my agent been spending on?'. No key required. IMPORTANT: refused payments are NOT recorded here — the contract deliberately does not store them, so an agent that was blocked every time looks the same as one that never tried.", {
666
+ limit: z
667
+ .number()
668
+ .int()
669
+ .min(1)
670
+ .max(100)
671
+ .optional()
672
+ .describe("how many to return, newest first. Default 20."),
673
+ fromIndex: z
674
+ .number()
675
+ .int()
676
+ .min(0)
677
+ .optional()
678
+ .describe("skip this many of the newest, to page backwards through history."),
679
+ }, async ({ limit, fromIndex }) => {
680
+ const rows = (await viewFunction(cfg.rpcUrl, requireVault(), "list_requests", {
681
+ from_index: fromIndex ?? 0,
682
+ limit: limit ?? 20,
683
+ }));
684
+ if (!Array.isArray(rows))
685
+ return json(rows);
686
+ const meta = await metaFor(rows.map((r) => String(r.token)));
687
+ const payments = rows.map((r) => {
688
+ const m = meta.get(String(r.token)) ?? null;
689
+ const d = m?.decimals ?? cfg.decimals;
690
+ let amount = String(r.amount);
691
+ try {
692
+ amount = fromBaseUnits(BigInt(amount), d);
693
+ }
694
+ catch {
695
+ /* a figure the contract produced is a fact; surface it unchanged */
696
+ }
697
+ return {
698
+ request_id: r.request_id,
699
+ status: r.status,
700
+ reason: r.reason_code,
701
+ amount,
702
+ token: tokenLabel(String(r.token), m),
703
+ to: r.recipient,
704
+ node_id: r.node_id,
705
+ ...(r.memo ? { memo: r.memo } : {}),
706
+ // Nanoseconds are what the contract stores and not what anyone reads.
707
+ when: new Date(Number(BigInt(String(r.created_at_ns)) / 1000000n)).toISOString(),
708
+ charged_on_utc_day: r.reserved_day,
709
+ };
710
+ });
711
+ return json({
712
+ count: payments.length,
713
+ payments,
714
+ not_shown: "REFUSED payments are not in this list. The contract does not store a blocked " +
715
+ "request — that would let an agent grow the vault's storage bill by spamming " +
716
+ "refusals — so it returns the reason to the caller and emits an event instead. " +
717
+ "An empty list therefore means 'nothing was paid', NOT 'nothing was attempted'.",
718
+ ...(payments.length === (limit ?? 20)
719
+ ? { more: `Possibly more: pass fromIndex=${(fromIndex ?? 0) + payments.length}.` }
720
+ : {}),
721
+ });
722
+ });
723
+ server.tool("vault_request", "Read one stored payment request: status, reason code, amount, the ancestor chain it charged, and the UTC day it was reserved against. No key required.", { requestId: z.string() }, async ({ requestId }) => {
724
+ const r = await viewFunction(cfg.rpcUrl, requireVault(), "get_request", {
725
+ request_id: requestId,
726
+ });
727
+ // A BARE `null` IS AMBIGUOUS and this tool returned one. To a reader it is
728
+ // indistinguishable from a failed call, and to an agent it is a value to
729
+ // guess about rather than a fact to relay. Requests are also PURGEABLE, so
730
+ // "not found" has two very different causes and the honest answer names
731
+ // both instead of implying the payment never happened.
732
+ if (r === null || r === undefined) {
733
+ return text(`No request "${requestId}" on ${cfg.vault}. Either the id is wrong, or the ` +
734
+ `request was settled and later removed by purge_settled — purging is how a ` +
735
+ `vault reclaims storage, so an OLD successful payment can legitimately be gone. ` +
736
+ `vault_tree and vault_budget still reflect what it spent.`);
737
+ }
738
+ return json(r);
739
+ });
740
+ server.tool("vault_config", "Read vault config: owner, paused, strict-vs-overcommit allocation, node count, and clock mode. No key required.", {}, async () => {
741
+ const c = (await viewFunction(cfg.rpcUrl, requireVault(), "get_config", {}));
742
+ if (!c || typeof c !== "object")
743
+ return json(c);
744
+ /**
745
+ * EXPLAIN THE HIERARCHY, because it is the whole product and nothing
746
+ * showed it.
747
+ *
748
+ * `vault_tree` prints `child_cap_sum` and leaves the reader to work out
749
+ * what it implies. The implication is the entire differentiator: budgets
750
+ * are hierarchical, a payment charges every ancestor, and so a SIBLING
751
+ * spending first can exhaust a shared pool while your own cap still reads
752
+ * "25 of 25 available". That is the moment `PARENT_DAILY_BUDGET` arrives
753
+ * and looks like a bug.
754
+ *
755
+ * `strict_allocation` is the switch that decides whether that can happen
756
+ * at all, and as a bare boolean it told nobody anything — a reader cannot
757
+ * tell whether `false` is the safe setting or the dangerous one.
758
+ *
759
+ * Computed from the TREE rather than restated from config, so the numbers
760
+ * are the ones actually in force.
761
+ */
762
+ const tree = (await viewFunction(cfg.rpcUrl, requireVault(), "get_tree", {}).catch(() => null));
763
+ let allocation;
764
+ const root = Array.isArray(tree) ? tree.find((n) => n.id === 0) : null;
765
+ if (root) {
766
+ const lim = root.limits?.[0];
767
+ const sum = root.child_cap_sum?.[0];
768
+ if (lim && sum) {
769
+ const meta = (await metaFor([lim.token])).get(lim.token) ?? null;
770
+ const d = meta?.decimals ?? cfg.decimals;
771
+ const cap = BigInt(lim.daily_cap);
772
+ const allocated = BigInt(sum[1]);
773
+ const strict = c.strict_allocation === true;
774
+ allocation = {
775
+ token: tokenLabel(lim.token, meta),
776
+ root_daily_cap: fromBaseUnits(cap, d),
777
+ allocated_to_children: fromBaseUnits(allocated, d),
778
+ unallocated: fromBaseUnits(cap > allocated ? cap - allocated : 0n, d),
779
+ mode: strict ? "strict" : "overcommit",
780
+ meaning: strict
781
+ ? "Children's caps may not sum above the parent's, so each child's cap is " +
782
+ "genuinely its own and a sibling cannot take it."
783
+ : "Children's caps MAY sum above the parent's. A sibling that spends first can " +
784
+ "exhaust the shared pool, and the others are then refused PARENT_DAILY_BUDGET " +
785
+ "while their OWN cap still shows as available. That refusal is the design " +
786
+ "working, not a fault.",
787
+ ...(allocated >= cap && cap > 0n
788
+ ? {
789
+ note: "Children are allocated the ENTIRE root budget, so any second agent would " +
790
+ "share this pool rather than add to it.",
791
+ }
792
+ : {}),
793
+ };
794
+ }
795
+ }
796
+ /**
797
+ * AN OWNERSHIP TRANSFER IN FLIGHT WAS INVISIBLE.
798
+ *
799
+ * `get_pending_owner` exists and nothing read it. A two-step transfer is
800
+ * the safe design — the new owner must accept — but a half-finished one
801
+ * is exactly the state worth seeing, and `owner` alone shows the OLD
802
+ * owner with no hint that control is mid-handover.
803
+ */
804
+ const pendingOwner = await viewFunction(cfg.rpcUrl, requireVault(), "get_pending_owner", {}).catch(() => null);
805
+ return json({
806
+ ...c,
807
+ ...(pendingOwner
808
+ ? {
809
+ pending_owner: pendingOwner,
810
+ ownership_note: `A transfer to ${pendingOwner} is IN FLIGHT. ${c.owner} still controls this ` +
811
+ `vault until ${pendingOwner} accepts.`,
812
+ }
813
+ : {}),
814
+ // A bare `clock_mode: "production"` invites the question it does not
815
+ // answer. The test clock exists so integration tests can roll a UTC day
816
+ // without waiting; seeing it on a real vault would be alarming.
817
+ clock: c.clock_mode === "production" ? "real UTC time" : `TEST CLOCK (${c.clock_mode})`,
818
+ ...(allocation ? { allocation } : {}),
819
+ });
820
+ });
453
821
  server.tool("vault_add_role", "Add a NEW departmental agent to an existing vault — e.g. a 'marketing' or 'news' agent with its own budget. Generates the agent's key, files it under the role name, and returns an UNSIGNED payload for the owner to approve. One FunctionCall creates the node, binds the key and installs its funded gas key. This server never signs it.", {
454
822
  role: z
455
823
  .string()
@@ -784,7 +1152,77 @@ server.tool("vault_pay", "Send a payment from the agent's node. Signs with the a
784
1152
  gasTgas: DEFAULT_GAS_TGAS,
785
1153
  route,
786
1154
  });
787
- return json(out);
1155
+ /**
1156
+ * HAND THE AGENT THE SENTENCE, do not hope it writes a good one.
1157
+ *
1158
+ * This used to `return json(out)` — just the contract's four fields — and
1159
+ * the instructions said "Report the reason code", so a user who asked to
1160
+ * pay someone got `PARENT_PER_TX_LIMIT` back. Correct, and useless to them.
1161
+ *
1162
+ * A good explanation needs NUMBERS the payment response never carried: the
1163
+ * per-payment cap, what is left today. So read them HERE, on the way out,
1164
+ * and only when they will be used. The budget read is one view call; the
1165
+ * caps come from the tree, fetched only when a limit actually fired.
1166
+ *
1167
+ * The code is NOT discarded — it moves under `technical`. Knowing exactly
1168
+ * which rule fired is a real asset (PARENT_DAILY_BUDGET meaning "a sibling
1169
+ * drained the shared pool" is something a flat policy model cannot say),
1170
+ * it is just not what a person wants first.
1171
+ */
1172
+ const o = out;
1173
+ const limitFired = /PER_TX_LIMIT|DAILY_BUDGET/.test(o.reason_code ?? "");
1174
+ const settled = o.status === "SETTLED";
1175
+ const tok = tokenFor(token);
1176
+ // The token's own symbol and decimals, cached process-wide, so the sentence
1177
+ // says "USDC" and not a 64-character contract id.
1178
+ const meta = await tokenMeta(cfg.rpcUrl, tok, (rpc, t) => viewFunction(rpc, t, "ft_metadata", {})).catch(() => null);
1179
+ const dec = meta?.decimals ?? cfg.decimals;
1180
+ let perTxCap;
1181
+ let available;
1182
+ let dailyCap;
1183
+ const nodeId = o.node_id;
1184
+ if (nodeId !== undefined && (limitFired || settled)) {
1185
+ // One view call, and it is the figure BOTH a success and a daily-cap
1186
+ // refusal want.
1187
+ const b = await viewFunction(cfg.rpcUrl, vault, "get_node_budget", { node_id: nodeId, token: tok }).catch(() => null);
1188
+ if (b) {
1189
+ available = fromBaseUnits(BigInt(b.available), dec);
1190
+ dailyCap = fromBaseUnits(BigInt(b.daily_cap), dec);
1191
+ }
1192
+ if (limitFired) {
1193
+ const tree = await viewFunction(cfg.rpcUrl, vault, "get_tree", {}).catch(() => null);
1194
+ if (tree) {
1195
+ const byId = new Map(tree.map((n) => [n.id, n]));
1196
+ let smallest;
1197
+ for (let cur = byId.get(nodeId); cur !== undefined; cur = cur.parent === null ? undefined : byId.get(cur.parent)) {
1198
+ const lim = (cur.limits ?? []).find((l) => l.token === tok);
1199
+ if (!lim)
1200
+ continue;
1201
+ const v = BigInt(lim.per_tx_cap);
1202
+ if (smallest === undefined || v < smallest)
1203
+ smallest = v;
1204
+ }
1205
+ if (smallest !== undefined)
1206
+ perTxCap = fromBaseUnits(smallest, dec);
1207
+ }
1208
+ }
1209
+ }
1210
+ const explained = explainOutcome({
1211
+ status: o.status,
1212
+ reason_code: o.reason_code,
1213
+ amount,
1214
+ recipient,
1215
+ symbol: meta?.symbol ?? "tokens",
1216
+ perTxCap,
1217
+ available,
1218
+ dailyCap,
1219
+ });
1220
+ return json({
1221
+ say: explained.say,
1222
+ next_step: explained.next,
1223
+ money_moved: explained.moved,
1224
+ technical: out,
1225
+ });
788
1226
  });
789
1227
  // ------------------------------------------------------- plans (unsigned)
790
1228
  server.tool("vault_plan_add_node", "Build an UNSIGNED payload to create a budget node. Pass parent=null for the root. Not signed by this server.", {
@@ -794,15 +1232,75 @@ server.tool("vault_plan_add_node", "Build an UNSIGNED payload to create a budget
794
1232
  cooldownSec: z.number().int().min(0).default(0),
795
1233
  recipients: z.array(z.string()).nullable().default(null),
796
1234
  }, async ({ parent, label, limits, cooldownSec, recipients }) => renderPlan(planAddNode(requireVault(), parent, label, limits, cooldownSec, recipients)));
797
- server.tool("vault_plan_set_caps", "Build an UNSIGNED payload to change a node's daily and per-payment caps. Not signed by this server.", { nodeId: z.number().int().min(0), limits: limitShape }, async ({ nodeId, limits }) => renderPlan(planSetNodeCaps(requireVault(), nodeId, limits)));
1235
+ /**
1236
+ * Refuse to build a signable payload for a node that does not exist.
1237
+ *
1238
+ * PLAN TOOLS HANDED OUT PAYLOADS FOR ANY `nodeId`. Measured 2026-11-10:
1239
+ * `vault_plan_set_caps({nodeId: 999})` against a two-node vault returned a
1240
+ * cheerful summary — "Set node 999 caps to … 1/day" — and a complete unsigned
1241
+ * transaction. The user approves it in their wallet, pays the gas, and the
1242
+ * CONTRACT rejects it. The cost is small and the experience is not: the only
1243
+ * signal anything was wrong arrives after a signature, from a layer the user
1244
+ * cannot read.
1245
+ *
1246
+ * A plan tool's entire job is to produce something safe to sign. "Safe to
1247
+ * sign" has to include "addresses something real".
1248
+ *
1249
+ * Costs one view call per plan. That is the right trade against a wasted
1250
+ * signature, and these are human-speed operations — nobody plans in a loop.
1251
+ *
1252
+ * Returns the node ids that DO exist, because "999 does not exist" without
1253
+ * them just prompts another guess.
1254
+ */
1255
+ async function assertNode(nodeId) {
1256
+ const tree = (await viewFunction(cfg.rpcUrl, requireVault(), "get_tree", {}).catch(() => null));
1257
+ // A failed READ must not block a plan: the chain being briefly unreachable
1258
+ // is not evidence the node is missing, and refusing here would turn a blip
1259
+ // into "your node does not exist".
1260
+ if (!Array.isArray(tree))
1261
+ return null;
1262
+ if (tree.some((n) => n.id === nodeId))
1263
+ return null;
1264
+ // RETURNS THE FACTS, NOT A SENTENCE. The first version returned prose and
1265
+ // the read path tried to reuse it by regex-stripping the signing clause,
1266
+ // which left "no node 999, Nodes: ...". Composing at the call site costs two
1267
+ // lines and cannot produce a mangled sentence.
1268
+ return { known: tree.map((n) => `${n.id}${n.label ? ` (${n.label})` : ""}`).join(", ") };
1269
+ }
1270
+ /** For a PLAN: the cost of getting this wrong is a wasted signature. */
1271
+ const noNodeForPlan = (nodeId, known) => `This vault has no node ${nodeId}, so that payload would be refused by the ` +
1272
+ `contract AFTER you signed and paid for it. Nodes: ${known}.`;
1273
+ /** For a READ: nothing is at stake, so just say what exists. */
1274
+ const noNodeForRead = (nodeId, known) => `This vault has no node ${nodeId}. Nodes: ${known}.`;
1275
+ server.tool("vault_plan_set_caps", "Build an UNSIGNED payload to change a node's daily and per-payment caps. Not signed by this server.", { nodeId: z.number().int().min(0), limits: limitShape }, async ({ nodeId, limits }) => {
1276
+ const bad = await assertNode(nodeId);
1277
+ return bad
1278
+ ? text(noNodeForPlan(nodeId, bad.known))
1279
+ : renderPlan(planSetNodeCaps(requireVault(), nodeId, limits));
1280
+ });
798
1281
  server.tool("vault_plan_bind_agent", "Build an UNSIGNED payload binding a signing key to a node, so that key may spend from it. Generate the keypair yourself and pass only the public half. Many keys may share one node. Not signed by this server.", {
799
1282
  nodeId: z.number().int().min(0),
800
1283
  publicKey: z.string().describe("ed25519:..."),
801
1284
  }, async ({ nodeId, publicKey }) => renderPlan(planBindVirtualAgent(requireVault(), nodeId, publicKey)));
802
1285
  server.tool("vault_plan_unbind_agent", "Build an UNSIGNED payload revoking a signing key's authority to spend. Does NOT return any gas left in that key's NEP-611 gas key — withdraw from the gas key separately. Not signed by this server.", { publicKey: z.string().describe("ed25519:...") }, async ({ publicKey }) => renderPlan(planUnbindVirtualAgent(requireVault(), publicKey)));
803
- server.tool("vault_plan_freeze", "Build an UNSIGNED payload to freeze or unfreeze a node. Freezing blocks the node AND its whole subtree. Not signed by this server.", { nodeId: z.number().int().min(0), frozen: z.boolean() }, async ({ nodeId, frozen }) => renderPlan(planFreezeNode(requireVault(), nodeId, frozen)));
804
- server.tool("vault_plan_set_recipients", "Build an UNSIGNED payload to set a node's payee allowlist. This REPLACES the inherited list rather than adding to it; pass null to inherit. Not signed by this server.", { nodeId: z.number().int().min(0), recipients: z.array(z.string()).nullable() }, async ({ nodeId, recipients }) => renderPlan(planSetRecipients(requireVault(), nodeId, recipients)));
805
- server.tool("vault_plan_set_cooldown", "Build an UNSIGNED payload to set the minimum seconds between a node's payments. Not signed by this server.", { nodeId: z.number().int().min(0), cooldownSec: z.number().int().min(0) }, async ({ nodeId, cooldownSec }) => renderPlan(planSetCooldown(requireVault(), nodeId, cooldownSec)));
1286
+ server.tool("vault_plan_freeze", "Build an UNSIGNED payload to freeze or unfreeze a node. Freezing blocks the node AND its whole subtree. Not signed by this server.", { nodeId: z.number().int().min(0), frozen: z.boolean() }, async ({ nodeId, frozen }) => {
1287
+ const bad = await assertNode(nodeId);
1288
+ return bad
1289
+ ? text(noNodeForPlan(nodeId, bad.known))
1290
+ : renderPlan(planFreezeNode(requireVault(), nodeId, frozen));
1291
+ });
1292
+ server.tool("vault_plan_set_recipients", "Build an UNSIGNED payload to set a node's payee allowlist. This REPLACES the inherited list rather than adding to it; pass null to inherit. Not signed by this server.", { nodeId: z.number().int().min(0), recipients: z.array(z.string()).nullable() }, async ({ nodeId, recipients }) => (async () => {
1293
+ const bad = await assertNode(nodeId);
1294
+ return bad
1295
+ ? text(noNodeForPlan(nodeId, bad.known))
1296
+ : renderPlan(planSetRecipients(requireVault(), nodeId, recipients));
1297
+ })());
1298
+ server.tool("vault_plan_set_cooldown", "Build an UNSIGNED payload to set the minimum seconds between a node's payments. Not signed by this server.", { nodeId: z.number().int().min(0), cooldownSec: z.number().int().min(0) }, async ({ nodeId, cooldownSec }) => {
1299
+ const bad = await assertNode(nodeId);
1300
+ return bad
1301
+ ? text(noNodeForPlan(nodeId, bad.known))
1302
+ : renderPlan(planSetCooldown(requireVault(), nodeId, cooldownSec));
1303
+ });
806
1304
  server.tool("vault_pending_approval", "Look up a payment that is HELD awaiting the owner's approval. Returns null if there is no ticket for that id — already approved, already rejected, or never held.", { requestId: z.string().min(1) }, async ({ requestId }) => json((await viewFunction(cfg.rpcUrl, requireVault(), "get_pending_approval", {
807
1305
  request_id: requestId,
808
1306
  })) ?? null));
@@ -817,11 +1315,74 @@ server.tool("vault_plan_set_approval_threshold", 'Build an UNSIGNED payload requ
817
1315
  }, async ({ nodeId, amount, decimals }) => renderPlan(planSetApprovalThreshold(requireVault(), nodeId, amount, decimals)));
818
1316
  server.tool("vault_plan_approve_payment", "Build an UNSIGNED payload to APPROVE a held payment, which sends it. Every limit is re-checked at approval, so it can still be refused. Not signed by this server.", { requestId: z.string().min(1) }, async ({ requestId }) => renderPlan(planApprovePayment(requireVault(), requestId)));
819
1317
  server.tool("vault_plan_reject_payment", "Build an UNSIGNED payload to REJECT a held payment. Nothing moves and the ticket is discarded. Not signed by this server.", { requestId: z.string().min(1) }, async ({ requestId }) => renderPlan(planRejectPayment(requireVault(), requestId)));
1318
+ /**
1319
+ * THE TOOL THAT WAS MISSING, and the gap had teeth.
1320
+ *
1321
+ * `vault_config` reports `strict_allocation` and now explains, at length,
1322
+ * that `overcommit` lets a sibling drain a shared pool and leave another
1323
+ * agent refused `PARENT_DAILY_BUDGET` with its own cap apparently free. The
1324
+ * payload builder `planSetStrictAllocation` has existed all along, and the
1325
+ * DASHBOARD exposes it as an owner action — but no MCP tool did.
1326
+ *
1327
+ * So the server described a mode worth changing and offered no way to change
1328
+ * it, through the one interface an agent can reach. Found by listing all 25
1329
+ * tools against the payload builders rather than by anyone hitting it.
1330
+ */
1331
+ server.tool("vault_plan_set_strict_allocation", "Build an UNSIGNED payload switching the vault between STRICT and OVERCOMMIT allocation. Strict refuses any change that would let children's caps sum above their parent's, so each child's budget is genuinely its own. Overcommit allows it, and then a sibling spending first can exhaust the shared pool — the others are refused PARENT_DAILY_BUDGET while their own cap still reads available. Read the current mode with vault_config. Not signed by this server.", {
1332
+ strict: z
1333
+ .boolean()
1334
+ .describe("true = children may not be allocated more than their parent holds. false = they may, and siblings then compete for the pool."),
1335
+ }, async ({ strict }) => renderPlan(planSetStrictAllocation(requireVault(), strict)));
820
1336
  server.tool("vault_plan_pause", "Build an UNSIGNED payload to pause or resume the whole vault. Pausing halts every agent payment. Not signed by this server.", { paused: z.boolean() }, async ({ paused }) => renderPlan(planSetPaused(requireVault(), paused)));
821
- server.tool("vault_plan_purge", "Build an UNSIGNED payload to delete settled payment records older than a UTC day. Spend totals are NOT refunded, but a deleted request id becomes reusable. Not signed by this server.", {
822
- beforeDay: z.number().int().min(0),
823
- limit: z.number().int().min(0).max(100).default(100),
824
- }, async ({ beforeDay, limit }) => renderPlan(planPurgeSettled(requireVault(), beforeDay, limit)));
1337
+ server.tool("vault_plan_purge", "Build an UNSIGNED payload deleting SETTLED payment records older than a cutoff, to reclaim the vault's storage stake. Spend totals are NOT refunded — purging frees bytes, it does not restore budget. Give `beforeDate` as YYYY-MM-DD; `beforeDay` (a Unix day number) is accepted for exactness. Not signed by this server.", {
1338
+ /**
1339
+ * A DATE, because the raw form asked a human for a Unix day number.
1340
+ *
1341
+ * `beforeDay: 20768` is what the contract indexes by and is unusable as an
1342
+ * input: nobody knows today's number, and an off-by-one silently purges a
1343
+ * day more or less than intended — of records that are then gone. The same
1344
+ * figure was shown in `vault_budget` output and fixed there for the same
1345
+ * reason; this is the input side of it.
1346
+ *
1347
+ * `beforeDay` survives for callers that genuinely have one, and because a
1348
+ * test-clock vault's day is NOT derivable from the wall clock.
1349
+ */
1350
+ beforeDate: z
1351
+ .string()
1352
+ .regex(/^\d{4}-\d{2}-\d{2}$/, "beforeDate must be YYYY-MM-DD")
1353
+ .optional()
1354
+ .describe("purge records from strictly BEFORE this UTC date, e.g. 2026-10-01"),
1355
+ beforeDay: z
1356
+ .number()
1357
+ .int()
1358
+ .min(0)
1359
+ .optional()
1360
+ .describe("the raw Unix day number, if you have it. Overrides beforeDate."),
1361
+ limit: z
1362
+ .number()
1363
+ .int()
1364
+ .min(1)
1365
+ .max(1000)
1366
+ .optional()
1367
+ .describe("how many records at most, default 100. Large purges may exceed the gas limit."),
1368
+ }, async ({ beforeDate, beforeDay, limit }) => {
1369
+ // EXACTLY ONE CUTOFF, and no default. A purge deletes records
1370
+ // irreversibly; guessing "today" for a caller who forgot the argument
1371
+ // would silently purge everything settled.
1372
+ if (beforeDay === undefined && beforeDate === undefined) {
1373
+ return text(`Give a cutoff: beforeDate "YYYY-MM-DD" (records strictly before it are removed), ` +
1374
+ `or beforeDay if you have the raw number. There is deliberately no default — ` +
1375
+ `a purge cannot be undone, and "today" would remove every settled record.`);
1376
+ }
1377
+ let day = beforeDay;
1378
+ if (day === undefined) {
1379
+ const ms = Date.parse(`${beforeDate}T00:00:00Z`);
1380
+ if (Number.isNaN(ms))
1381
+ return text(`"${beforeDate}" is not a real date.`);
1382
+ day = Math.floor(ms / 86_400_000);
1383
+ }
1384
+ return renderPlan(planPurgeSettled(requireVault(), day, limit ?? 100));
1385
+ });
825
1386
  server.tool("vault_plan_withdraw", "Build an UNSIGNED payload to move tokens OUT of the vault to any account. The owner approves it in their own wallet; this server never signs. This is the step that decides whether money survives a teardown: deleting a NEAR account does NOT return fungible tokens, so they must be moved first.", {
826
1387
  amount: z
827
1388
  .string()
@@ -840,6 +1401,11 @@ server.tool("vault_plan_withdraw", "Build an UNSIGNED payload to move tokens OUT
840
1401
  * zero a delete needs.
841
1402
  */
842
1403
  let base;
1404
+ // "Send 42 out of the vault" reads the same whether 42 is part of the
1405
+ // balance or ALL of it, and those are different decisions: the second
1406
+ // empties the vault, can fail in-flight payments, and is the step before a
1407
+ // teardown. The signer sees a summary, not this code.
1408
+ let emptying = false;
843
1409
  if (amount === undefined) {
844
1410
  const bal = await viewFunction(cfg.rpcUrl, tok, "ft_balance_of", {
845
1411
  account_id: vault,
@@ -852,13 +1418,23 @@ server.tool("vault_plan_withdraw", "Build an UNSIGNED payload to move tokens OUT
852
1418
  return text(`${vault} holds no ${tok}. Nothing to withdraw.`);
853
1419
  }
854
1420
  base = String(bal);
1421
+ emptying = true;
855
1422
  }
856
1423
  else {
857
1424
  // `toBaseUnits` returns a bigint; the payload carries base units as a
858
1425
  // STRING, because that is what the contract's U128 arg expects.
859
1426
  base = toBaseUnits(amount, cfg.decimals).toString();
860
1427
  }
861
- return renderPlan(planWithdraw(vault, tok, base, receiverId, cfg.decimals));
1428
+ const plan = planWithdraw(vault, tok, base, receiverId, cfg.decimals);
1429
+ if (emptying) {
1430
+ plan.warnings = [
1431
+ `This is the vault's ENTIRE ${tok} balance — it will hold zero afterwards. ` +
1432
+ `You did not name an amount, so the figure above was read from the chain.`,
1433
+ ...plan.warnings,
1434
+ ];
1435
+ plan.summary = `${plan.summary} (the ENTIRE balance)`;
1436
+ }
1437
+ return renderPlan(plan);
862
1438
  });
863
1439
  /**
864
1440
  * WHY A QUOTE AND NOT A PLAN, which every other owner action here is.
@@ -1026,9 +1602,51 @@ server.tool("vault_teardown_report", "Read-only. Report exactly what closing thi
1026
1602
  "0.08322 NEAR as tokens_burnt. Always withdrawFromGasKey first. teardown-vault.mjs " +
1027
1603
  "does this and exits rather than continuing if it fails.",
1028
1604
  },
1605
+ /**
1606
+ * POINT AT THE USER-FACING CLI, not the repo's internal script.
1607
+ *
1608
+ * This emitted `NETWORK=… VAULT=… OWNER=… TOKEN=… CONFIRM_MAINNET=1
1609
+ * EXPECT_TOKENS=1 node scripts/teardown-vault.mjs` — six environment
1610
+ * variables, a path only someone who cloned the repo has, no dry run,
1611
+ * and a delete at the end. For the one action a user takes when they are
1612
+ * already nervous, that is the worst possible interface, and it is not
1613
+ * what the product ships.
1614
+ *
1615
+ * `float-exit` is the published CLI for exactly this, and it is better
1616
+ * in every way that matters here: two flags, `--dry-run`, numbered steps,
1617
+ * "would" phrasing for everything it has not done yet, and an explicit
1618
+ * "This is irreversible" before the delete. It works via npx, so it needs
1619
+ * no checkout. `--network` defaults to mainnet.
1620
+ *
1621
+ * I pointed at the internal script for a week and then spent a round
1622
+ * fixing ITS ergonomics — adding the missing CONFIRM_MAINNET and
1623
+ * EXPECT_TOKENS — when the friendly path already existed. Fixing the
1624
+ * hard road is not the same as checking whether the easy one is there.
1625
+ *
1626
+ * THE DRY RUN LEADS, deliberately. The destructive form is given second
1627
+ * and separately, so copying the first thing you see cannot delete a
1628
+ * vault.
1629
+ *
1630
+ * THE `-p` IS LOAD-BEARING. float-exit is a BIN of create-float, not a
1631
+ * package of its own, so invoking it by bare scoped name 404s on the
1632
+ * registry. `scripts/check-npx-commands.mjs` enforces this — and it
1633
+ * flagged THIS comment when the wrong form was written out as a
1634
+ * counter-example, which is the check working: it cannot tell a warning
1635
+ * from an instruction, and neither can someone copying a line out of a
1636
+ * file. So the bad form is described rather than spelled.
1637
+ */
1029
1638
  command: signable
1030
- ? `NETWORK=${cfg.network} VAULT=${vault} OWNER=${config?.owner ?? "<owner>"} ` +
1031
- `TOKEN=${tok ?? "<token>"} node scripts/teardown-vault.mjs`
1639
+ ? {
1640
+ first_look: `npx -p @floatonnear/create-float float-exit --owner ${config?.owner ?? "<owner>"} ` +
1641
+ `--vault ${vault}${cfg.network === "mainnet" ? "" : ` --network ${cfg.network}`} --dry-run`,
1642
+ then_for_real: `npx -p @floatonnear/create-float float-exit --owner ${config?.owner ?? "<owner>"} ` +
1643
+ `--vault ${vault}${cfg.network === "mainnet" ? "" : ` --network ${cfg.network}`}`,
1644
+ what_the_dry_run_shows: "Every step it WOULD take, the exact NEAR and tokens coming back, and which " +
1645
+ "account they go to — without touching anything. Run it first.",
1646
+ needs: `A full-access key for ${config?.owner ?? "the owner"} in ~/.near-credentials. ` +
1647
+ `The owner's key is already full-access ON the vault (the factory adds it at ` +
1648
+ `creation), so no separate vault key is needed.`,
1649
+ }
1032
1650
  : "(none — nothing can sign as this vault)",
1033
1651
  note: "Read-only. This server holds at most a restricted agent key and signed nothing. " +
1034
1652
  "Run the command yourself, after the token withdrawal has settled.",
@@ -0,0 +1,42 @@
1
+ /**
2
+ * What a token is CALLED, and how many decimals it really has.
3
+ *
4
+ * WHY. Caps, balances and errors were all rendered with the raw NEP-141
5
+ * account id, and on mainnet USDC that is
6
+ * `17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1` — 64
7
+ * hex characters appearing in `vault_tree`, `vault_budget`, the teardown
8
+ * report and every "no limit for that token" message. Nobody reading a budget
9
+ * can tell that is USDC, and two different tokens look identical at a glance
10
+ * until you diff the strings.
11
+ *
12
+ * AND IT FIXES A REAL CORRECTNESS LIMIT, not just readability. `vault_tree`
13
+ * could only humanise the ONE token whose decimals the operator had
14
+ * configured; any other kept base units, because scaling by a guessed
15
+ * exponent prints a figure wrong by orders of magnitude while looking tidy.
16
+ * The chain knows the answer — `ft_metadata` returns it — so the guess was
17
+ * never necessary. Every token can now be rendered correctly.
18
+ *
19
+ * CACHED FOR THE PROCESS LIFETIME. Metadata is immutable in practice (a token
20
+ * that changed its decimals would break every holder), and a budget read
21
+ * should not make an extra round trip per node per token. A failed lookup is
22
+ * cached as `null` too: the common cause is an account that is not a token at
23
+ * all, and retrying that on every render turns one bad id into a storm.
24
+ */
25
+ export interface TokenMeta {
26
+ symbol: string;
27
+ decimals: number;
28
+ }
29
+ type Fetcher = (rpcUrl: string, token: string) => Promise<unknown>;
30
+ /** Visible for tests; a process-lifetime cache is otherwise untestable. */
31
+ export declare function clearTokenMetaCache(): void;
32
+ export declare function tokenMeta(rpcUrl: string, token: string, view: Fetcher): Promise<TokenMeta | null>;
33
+ /**
34
+ * `USDC` where known, the raw id where not — never a symbol alone.
35
+ *
36
+ * The id stays because a symbol is NOT unique: anyone can deploy a token
37
+ * calling itself USDC, and a budget that names only the symbol cannot be
38
+ * checked against the one the user meant. This is money; the identifier has
39
+ * to remain visible.
40
+ */
41
+ export declare function label(token: string, meta: TokenMeta | null): string;
42
+ export {};
@@ -0,0 +1,46 @@
1
+ const cache = new Map();
2
+ /** Visible for tests; a process-lifetime cache is otherwise untestable. */
3
+ export function clearTokenMetaCache() {
4
+ cache.clear();
5
+ }
6
+ export async function tokenMeta(rpcUrl, token, view) {
7
+ if (cache.has(token))
8
+ return cache.get(token) ?? null;
9
+ let meta = null;
10
+ try {
11
+ const raw = (await view(rpcUrl, token));
12
+ // BOTH FIELDS OR NEITHER. A partial answer is worse than none: a symbol
13
+ // without decimals invites rendering with the configured default, which
14
+ // is the wrong-exponent bug this exists to remove.
15
+ if (raw &&
16
+ typeof raw.symbol === "string" &&
17
+ raw.symbol.length > 0 &&
18
+ // REDUNDANT WITH THE NEXT LINE, kept for readability. Mutation-tested:
19
+ // removing this changes nothing because Number.isInteger(undefined) is
20
+ // already false, so `Number.isInteger` is the clause actually carrying
21
+ // the guard. Delete that one and the suite reds; delete this one and it
22
+ // does not.
23
+ typeof raw.decimals === "number" &&
24
+ Number.isInteger(raw.decimals) &&
25
+ raw.decimals >= 0 &&
26
+ raw.decimals <= 24) {
27
+ meta = { symbol: raw.symbol, decimals: raw.decimals };
28
+ }
29
+ }
30
+ catch {
31
+ meta = null;
32
+ }
33
+ cache.set(token, meta);
34
+ return meta;
35
+ }
36
+ /**
37
+ * `USDC` where known, the raw id where not — never a symbol alone.
38
+ *
39
+ * The id stays because a symbol is NOT unique: anyone can deploy a token
40
+ * calling itself USDC, and a budget that names only the symbol cannot be
41
+ * checked against the one the user meant. This is money; the identifier has
42
+ * to remain visible.
43
+ */
44
+ export function label(token, meta) {
45
+ return meta ? `${meta.symbol} (${token})` : token;
46
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floatonnear/mcp",
3
- "version": "0.6.5",
3
+ "version": "0.9.1",
4
4
  "type": "module",
5
5
  "description": "MCP server for the hierarchical budget vault. The agent key signs request_payment only; owner methods emit unsigned payloads for wallet approval.",
6
6
  "license": "MIT",