@floatonnear/mcp 0.10.2 → 0.10.3

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/SKILL.md CHANGED
@@ -30,8 +30,101 @@ the agent itself can change. Float gives it a **function-call key restricted to
30
30
  one method on one contract**, and puts the limits in a contract the key cannot
31
31
  reach.
32
32
 
33
- Read the next section before doing anything. It is the part that decides which
34
- of your requests can succeed.
33
+ **Read the two sections below before doing anything.** The first decides how you
34
+ speak to the person you are helping; the second decides which of your requests
35
+ can succeed at all.
36
+
37
+ ## How you talk to the human (non-negotiable)
38
+
39
+ **You are the product voice. The human is not debugging Float.** They asked for
40
+ something to be paid, or set up, or limited. Everything below exists because
41
+ the natural failure mode of a coding agent is to narrate the machine — rails,
42
+ gas keys, reason codes, env vars — to someone who wanted one sentence.
43
+
44
+ **Always:**
45
+
46
+ - **2–4 short sentences** for a normal outcome. Not a report.
47
+ - **Lead with the outcome**: paid / blocked / needs your approval / needs setup.
48
+ - **Say whether money moved.** Yes or no, explicitly. It is the one fact a
49
+ person cannot infer and must never have to guess.
50
+ - **One clear next step** when something is blocked — not a menu.
51
+ - Everyday words: limit, budget, payment, wallet, *approve in your wallet*.
52
+
53
+ **Never — unless they explicitly ask for technical detail, or are plainly a
54
+ developer debugging:**
55
+
56
+ - reason codes (`PARENT_PER_TX_LIMIT`, `COOLDOWN`, …)
57
+ - tool names (`vault_pay`, `vault_add_role`, `vault_plan_set_caps`)
58
+ - env vars, key files, keyrings, RPC urls, gas keys, nonces, wasm, yoctoNEAR,
59
+ base units, node ids
60
+ - a setup essay in the middle of a task
61
+ - several options when one sensible default exists
62
+
63
+ ### Use the tool's own words
64
+
65
+ Tools return the user-facing sentence already built, with the real numbers in
66
+ it:
67
+
68
+ ```json
69
+ {
70
+ "say": "Sent 0.25 USDC to test015.near. Money moved.",
71
+ "next_step": null,
72
+ "money_moved": true,
73
+ "technical": { "status": "SETTLED", "reason_code": null }
74
+ }
75
+ ```
76
+
77
+ **Relay `say`. Add `next_step` if it is not null. Stop.** Do not re-derive a
78
+ more technical story out of `technical` — those fields are for you, and for the
79
+ moment someone asks *why*.
80
+
81
+ `say` is built where the numbers live, which is why it can name the cap that
82
+ bound and what is left today. A sentence you compose from `status` and
83
+ `reason_code` alone cannot, because the payment response never carried them.
84
+
85
+ ### Templates
86
+
87
+ **Paid:**
88
+
89
+ > Sent **0.25 USDC** to test015.near. Money moved. About **24.75 USDC** left in
90
+ > today's budget.
91
+
92
+ **Blocked, over a limit:**
93
+
94
+ > That payment was **too big** — the most this budget allows per payment is
95
+ > **10 USDC**, and this asked for 15. **Nothing was sent.** Try 10 USDC or
96
+ > less, or raise the limit (I'll give you something to approve in your wallet).
97
+
98
+ **Needs a human signature:**
99
+
100
+ > I can't change limits myself — only you can. Here's the change to **approve
101
+ > in your wallet**. Tell me once you have and I'll confirm it's live.
102
+
103
+ **No vault yet:**
104
+
105
+ > You don't have a vault yet. Setting one up is **one approval in your wallet**
106
+ > and about **3.7 NEAR** — almost all of it a refundable deposit your vault
107
+ > holds, not a fee. Want me to start?
108
+
109
+ **Not enough money:**
110
+
111
+ > The vault doesn't have enough USDC for that. **Nothing was sent.** Add USDC to
112
+ > the vault and ask me again.
113
+
114
+ ### Worked examples, before and after
115
+
116
+ | situation | ❌ what not to say | ✅ what to say |
117
+ | --- | --- | --- |
118
+ | settled | `status: SETTLED, node_id: 1` | Sent 0.25 USDC to test015.near. Money moved. 24.75 USDC left today. |
119
+ | over cap | "BLOCKED with reason_code PARENT_PER_TX_LIMIT" | That's too big for one payment — the limit is 10 USDC and this asked for 15. Nothing was sent. |
120
+ | policy change | "Returns an unsigned payload; the server does not sign. Run `near contract call-function`…" | I can't change the limit myself — approve this in your wallet and it's done. |
121
+ | new agent | "Generated an ed25519 keypair, wrote it to the keyring; the 0.12 NEAR deposit funds a NEP-611 gas key" | I'll set up a news budget: 1 USDC a day, 0.10 max per payment. Approve once in your wallet and I can pay within those limits. |
122
+ | no vault | "`NEAR_VAULT_CONTRACT` is not set" | You don't have a vault yet — want me to set one up? |
123
+
124
+ A person who asked to pay a vendor and got `PARENT_PER_TX_LIMIT` has been told
125
+ nothing they can act on. The codes are precise, and that precision is a real
126
+ asset for debugging and for explaining the design. It is just never the first
127
+ thing a person wants.
35
128
 
36
129
  ## Setup — do this first if you arrived here cold
37
130
 
@@ -307,10 +400,13 @@ A refusal comes back as a **value**, not an error. `status: "BLOCKED"` with a
307
400
 
308
401
  ### Never show a reason code to the user
309
402
 
310
- `vault_pay` already returns a plain-English `say`, a `next_step`, and a
311
- `money_moved` flag. **Relay `say`, add `next_step` if there is one, and stop.**
312
- The `reason_code` lives under `technical`; quote it only if the user asks why,
313
- asks for detail, or is plainly a developer.
403
+ This is governed by **How you talk to the human** near the top of this file —
404
+ one source of truth, so the two cannot drift apart. The short form:
405
+
406
+ **`vault_pay` returns `say`, `next_step` and `money_moved`. Relay `say`, add
407
+ `next_step` if it is not null, and stop.** The `reason_code` lives under
408
+ `technical`; quote it only if the user asks why, asks for detail, or is plainly
409
+ a developer.
314
410
 
315
411
  > Say: *"That's too big for one payment — the most per payment is 10 USDC, and
316
412
  > this asked for 15. Nothing was sent. Send 10 USDC or less, or ask the vault
@@ -318,13 +414,9 @@ asks for detail, or is plainly a developer.
318
414
  >
319
415
  > Not: *"The payment was BLOCKED with reason_code PARENT_PER_TX_LIMIT."*
320
416
 
321
- The same applies when it works: *"Sent 0.25 USDC to alice.near. There's 24.75
322
- USDC left to spend today."* — not `status: SETTLED`.
323
-
324
- Someone who asked to pay a vendor and got `PARENT_PER_TX_LIMIT` has been told
325
- nothing they can act on. The codes are precise and that precision is a real
326
- asset for debugging and for explaining the design — it is just not what a
327
- person wants first.
417
+ **A refusal is still an answer, not a failure.** Report it as the budget doing
418
+ its job. And never imply money moved when `money_moved` is false — a blocked
419
+ payment moved nothing, which is the whole point.
328
420
 
329
421
  ### The codes themselves
330
422
 
package/dist/explain.d.ts CHANGED
@@ -57,3 +57,36 @@ export interface Explanation {
57
57
  moved: boolean;
58
58
  }
59
59
  export declare function explainOutcome(c: OutcomeContext): Explanation;
60
+ /**
61
+ * The user-facing sentence for an UNSIGNED owner payload.
62
+ *
63
+ * WHY PLAN TOOLS NEEDED THIS TOO. `vault_pay` returned `say`/`next_step`/
64
+ * `money_moved` and the plan tools returned `{action, summary, warnings,
65
+ * unsigned_payload, approve_with}` — five technical keys and no sentence. So
66
+ * agents narrated the mechanism: "returns an unsigned payload; the server does
67
+ * not sign; run `near contract call-function`…". All true, and unusable by the
68
+ * person who asked to raise a limit.
69
+ *
70
+ * THE THREE THINGS A PERSON NEEDS HERE, in order:
71
+ * 1. this needs YOU — the agent cannot do it
72
+ * 2. what it will do, in the units they named
73
+ * 3. nothing has happened yet
74
+ *
75
+ * (3) matters most and is the easiest to leave out. An unsigned payload is a
76
+ * *proposal*; an agent that says "I've raised the limit" has lied about state
77
+ * the user is about to be asked to change themselves.
78
+ *
79
+ * `summary` is reused verbatim rather than rewritten. It is built where the
80
+ * amounts and tokens are known, in the units the caller passed, and
81
+ * duplicating that formatting here is how the two drift into disagreeing about
82
+ * a number. It does still say "node 1" — the honest fix for that is better
83
+ * summaries in `payloads.ts`, not a second renderer guessing at them here.
84
+ */
85
+ export declare function planVoice(p: {
86
+ summary: string;
87
+ depositYocto?: string;
88
+ warnings?: string[];
89
+ }): {
90
+ say: string;
91
+ next: string;
92
+ };
package/dist/explain.js CHANGED
@@ -207,3 +207,58 @@ export function explainOutcome(c) {
207
207
  }
208
208
  }
209
209
  }
210
+ /**
211
+ * The user-facing sentence for an UNSIGNED owner payload.
212
+ *
213
+ * WHY PLAN TOOLS NEEDED THIS TOO. `vault_pay` returned `say`/`next_step`/
214
+ * `money_moved` and the plan tools returned `{action, summary, warnings,
215
+ * unsigned_payload, approve_with}` — five technical keys and no sentence. So
216
+ * agents narrated the mechanism: "returns an unsigned payload; the server does
217
+ * not sign; run `near contract call-function`…". All true, and unusable by the
218
+ * person who asked to raise a limit.
219
+ *
220
+ * THE THREE THINGS A PERSON NEEDS HERE, in order:
221
+ * 1. this needs YOU — the agent cannot do it
222
+ * 2. what it will do, in the units they named
223
+ * 3. nothing has happened yet
224
+ *
225
+ * (3) matters most and is the easiest to leave out. An unsigned payload is a
226
+ * *proposal*; an agent that says "I've raised the limit" has lied about state
227
+ * the user is about to be asked to change themselves.
228
+ *
229
+ * `summary` is reused verbatim rather than rewritten. It is built where the
230
+ * amounts and tokens are known, in the units the caller passed, and
231
+ * duplicating that formatting here is how the two drift into disagreeing about
232
+ * a number. It does still say "node 1" — the honest fix for that is better
233
+ * summaries in `payloads.ts`, not a second renderer guessing at them here.
234
+ */
235
+ export function planVoice(p) {
236
+ const bits = [];
237
+ // (1) and (2) together, because "needs you" is meaningless without "to do
238
+ // what". Leading with the actor is what stops the agent claiming the credit.
239
+ bits.push(`This one needs you — I can't make this change myself. ${p.summary}.`);
240
+ /**
241
+ * A DEPOSIT IS STATED, ALWAYS, WHEN THERE IS ONE.
242
+ *
243
+ * 1 yoctoNEAR is the NEP-141 anti-phishing deposit — a rounding error that
244
+ * no person should ever be told about. Anything larger is real money leaving
245
+ * their wallet, and a wallet prompt showing an unexplained NEAR figure is
246
+ * the fastest way to make a safe action look like a scam.
247
+ */
248
+ const dep = BigInt(p.depositYocto ?? "0");
249
+ if (dep > 1n) {
250
+ const near = (Number(dep) / 1e24).toFixed(3).replace(/\.?0+$/, "");
251
+ bits.push(`It sends ${near} NEAR with it, which the vault keeps — not a fee.`);
252
+ }
253
+ // (3). Said last so it is the thing left ringing.
254
+ bits.push("Nothing has changed yet.");
255
+ // `warnings` is non-empty only for destructive or hard-to-undo actions, so
256
+ // when one exists it outranks brevity.
257
+ const firstWarning = (p.warnings ?? []).find((w) => typeof w === "string" && !!w.trim());
258
+ if (firstWarning)
259
+ bits.push(firstWarning);
260
+ return {
261
+ say: bits.join(" "),
262
+ next: "Approve it in your wallet, then tell me and I'll confirm it went through.",
263
+ };
264
+ }
@@ -43,9 +43,11 @@ export interface OwnerPayload {
43
43
  /** Non-empty only when the action is destructive or hard to undo. */
44
44
  warnings: string[];
45
45
  }
46
- export declare function planAddNode(vault: string, parent: number | null, label: string, limits: HumanTokenLimit[], cooldownSec: number, recipients: string[] | null): OwnerPayload;
47
- export declare function planSetNodeCaps(vault: string, nodeId: number, limits: HumanTokenLimit[]): OwnerPayload;
48
- export declare function planBindAgent(vault: string, nodeId: number, accountId: string): OwnerPayload;
46
+ export declare function planAddNode(vault: string, parent: number | null, label: string, limits: HumanTokenLimit[], cooldownSec: number, recipients: string[] | null,
47
+ /** The PARENT's human label, so the summary can say "under news". */
48
+ parentName?: string | null): OwnerPayload;
49
+ export declare function planSetNodeCaps(vault: string, nodeId: number, limits: HumanTokenLimit[], nodeName?: string | null): OwnerPayload;
50
+ export declare function planBindAgent(vault: string, nodeId: number, accountId: string, nodeName?: string | null): OwnerPayload;
49
51
  /**
50
52
  * A whole departmental agent in ONE owner signature: node, binding, gas key.
51
53
  *
@@ -69,7 +71,7 @@ export declare function planBindAgent(vault: string, nodeId: number, accountId:
69
71
  * and the vault carries the owner's full-access key.
70
72
  */
71
73
  export declare function planAddRole(vault: string, label: string, limits: HumanTokenLimit[], cooldownSec: number, recipients: string[] | null, publicKey: string, fundingYocto: string): OwnerPayload;
72
- export declare function planBindVirtualAgent(vault: string, nodeId: number, publicKey: string): OwnerPayload;
74
+ export declare function planBindVirtualAgent(vault: string, nodeId: number, publicKey: string, nodeName?: string | null): OwnerPayload;
73
75
  /**
74
76
  * Revoke a virtual agent's authority.
75
77
  *
@@ -80,9 +82,9 @@ export declare function planBindVirtualAgent(vault: string, nodeId: number, publ
80
82
  * half the binding's storage is intentionally unrecoverable.
81
83
  */
82
84
  export declare function planUnbindVirtualAgent(vault: string, publicKey: string): OwnerPayload;
83
- export declare function planSetRecipients(vault: string, nodeId: number, recipients: string[] | null): OwnerPayload;
84
- export declare function planSetCooldown(vault: string, nodeId: number, cooldownSec: number): OwnerPayload;
85
- export declare function planFreezeNode(vault: string, nodeId: number, frozen: boolean): OwnerPayload;
85
+ export declare function planSetRecipients(vault: string, nodeId: number, recipients: string[] | null, nodeName?: string | null): OwnerPayload;
86
+ export declare function planSetCooldown(vault: string, nodeId: number, cooldownSec: number, nodeName?: string | null): OwnerPayload;
87
+ export declare function planFreezeNode(vault: string, nodeId: number, frozen: boolean, nodeName?: string | null): OwnerPayload;
86
88
  export declare function planSetPaused(vault: string, paused: boolean): OwnerPayload;
87
89
  export declare function planSetStrictAllocation(vault: string, strict: boolean): OwnerPayload;
88
90
  export declare function planPurgeSettled(vault: string, beforeDay: number, limit: number): OwnerPayload;
@@ -195,7 +197,7 @@ export declare function toCliCommand(p: OwnerPayload, network?: string): string;
195
197
  * `toBaseUnits` throws rather than truncating excess precision — correct here:
196
198
  * silently rounding 0.1234567 would set a threshold the owner did not choose.
197
199
  */
198
- export declare function planSetApprovalThreshold(vault: string, nodeId: number, amount: string | null, decimals: number): OwnerPayload;
200
+ export declare function planSetApprovalThreshold(vault: string, nodeId: number, amount: string | null, decimals: number, nodeName?: string | null): OwnerPayload;
199
201
  /**
200
202
  * Execute a held payment.
201
203
  *
package/dist/payloads.js CHANGED
@@ -105,7 +105,9 @@ function assertCooldown(cooldownSec) {
105
105
  throw new Error(`cooldown must be at most ${MAX_COOLDOWN_SEC}s (MAX_COOLDOWN_SEC)`);
106
106
  }
107
107
  }
108
- export function planAddNode(vault, parent, label, limits, cooldownSec, recipients) {
108
+ export function planAddNode(vault, parent, label, limits, cooldownSec, recipients,
109
+ /** The PARENT's human label, so the summary can say "under news". */
110
+ parentName) {
109
111
  if (!label || label.length > MAX_LABEL_LEN) {
110
112
  throw new Error(`label must be 1..=${MAX_LABEL_LEN} characters (MAX_LABEL_LEN)`);
111
113
  }
@@ -123,35 +125,99 @@ export function planAddNode(vault, parent, label, limits, cooldownSec, recipient
123
125
  },
124
126
  gasTgas: DEFAULT_GAS_TGAS,
125
127
  depositYocto: ONE_YOCTO,
126
- summary: `Create budget node "${label}" ${parent === null ? "as the ROOT" : `under node ${parent}`}` +
128
+ summary:
129
+ // "the main budget" for a root, and the PARENT's name otherwise — a new
130
+ // child is described by where it hangs, which is the one thing the
131
+ // person choosing it cares about.
132
+ `Create a "${label}" budget ` +
133
+ `${parent === null ? "as the main budget" : `under ${budgetName(parent, parentName)}`}` +
127
134
  ` with ${describeLimits(limits)}` +
128
135
  (cooldownSec ? `, ${cooldownSec}s cooldown between payments` : "") +
129
136
  (recipients ? `, paying only: ${recipients.join(", ")}` : `, inheriting the payee allowlist`),
130
137
  warnings: [],
131
138
  };
132
139
  }
133
- export function planSetNodeCaps(vault, nodeId, limits) {
140
+ /**
141
+ * How a budget is NAMED to a person.
142
+ *
143
+ * WHY THIS EXISTS. Every plan summary said "node 1", and `planVoice` copies
144
+ * the summary verbatim into the `say` a user reads — so the product spoke the
145
+ * tree's internal vocabulary: "Set node 1 caps to 1/day", "Freeze node 0".
146
+ * A node id is the contract's primary key. It is exactly right in `args`, in
147
+ * `technical`, and in the unsigned payload a wallet signs, and it is a
148
+ * stranger's filing reference to the person who called the thing "news".
149
+ *
150
+ * The label is NOT derivable here — it lives on chain, and this module is pure
151
+ * and synchronously testable, which is worth keeping. So the caller resolves
152
+ * it and passes it in; `server.ts` already fetches the tree to check the node
153
+ * exists, so this costs no extra call.
154
+ *
155
+ * FALLBACKS, in order of how much they can honestly claim:
156
+ * a label -> `the "news" budget`
157
+ * node 0, no label -> "the main budget" (root is the only id with a meaning)
158
+ * anything else -> "this budget" (true, and says nothing false)
159
+ *
160
+ * "this budget" is deliberately vague rather than wrong. The alternative was
161
+ * falling back to the id, which is the bug.
162
+ */
163
+ /**
164
+ * Sentence-case a budget name for the start of a summary.
165
+ *
166
+ * `budgetName` returns a lowercase noun phrase because most of its uses sit
167
+ * mid-sentence ("Freeze the main budget"). A few lead the sentence, and
168
+ * "the main budget must wait 30s" reads like a fragment.
169
+ */
170
+ function capitalise(s) {
171
+ return s.charAt(0).toUpperCase() + s.slice(1);
172
+ }
173
+ function budgetName(nodeId, label) {
174
+ /**
175
+ * ROOT IS ALWAYS "the main budget", whatever its stored label.
176
+ *
177
+ * The contract hardcodes root's label to the literal string "root"
178
+ * (`lib.rs`: `label: "root".to_string()`), which is the tree's word, not a
179
+ * person's. `the "root" budget` would just be "node 0" wearing a hat.
180
+ */
181
+ if (nodeId === 0)
182
+ return "the main budget";
183
+ const clean = typeof label === "string" ? label.trim() : "";
184
+ if (clean)
185
+ return `the "${clean}" budget`;
186
+ /**
187
+ * NO LABEL means the tree read FAILED — `Node.label` is a required `String`
188
+ * on chain (`types.rs`), so every real node has one. "this budget" is
189
+ * deliberately vague rather than wrong; the alternative was falling back to
190
+ * the id, which is the thing being removed.
191
+ *
192
+ * It does cost distinguishability in that degraded case: two different
193
+ * budgets read the same. The node id is still in `args.node_id`, which is
194
+ * what the WALLET displays and what the owner actually signs, so the
195
+ * signature is never ambiguous even when the sentence is. Pinned by a test.
196
+ */
197
+ return "this budget";
198
+ }
199
+ export function planSetNodeCaps(vault, nodeId, limits, nodeName) {
134
200
  return {
135
201
  receiverId: vault,
136
202
  methodName: "set_node_caps",
137
203
  args: { node_id: nodeId, limits: limitsToArgs(limits) },
138
204
  gasTgas: DEFAULT_GAS_TGAS,
139
205
  depositYocto: ONE_YOCTO,
140
- summary: `Set node ${nodeId} caps to ${describeLimits(limits)}`,
206
+ summary: `Update ${budgetName(nodeId, nodeName)} to ${describeLimits(limits)}`,
141
207
  warnings: [],
142
208
  };
143
209
  }
144
- export function planBindAgent(vault, nodeId, accountId) {
210
+ export function planBindAgent(vault, nodeId, accountId, nodeName) {
145
211
  return {
146
212
  receiverId: vault,
147
213
  methodName: "bind_agent",
148
214
  args: { node_id: nodeId, account_id: accountId },
149
215
  gasTgas: DEFAULT_GAS_TGAS,
150
216
  depositYocto: ONE_YOCTO,
151
- summary: `Let ${accountId} spend from node ${nodeId}`,
217
+ summary: `Let ${accountId} spend from ${budgetName(nodeId, nodeName)}`,
152
218
  warnings: [
153
219
  `${accountId} is an EXISTING account whose keys you do not control. ` +
154
- `It can call request_payment with the full authority of node ${nodeId}.`,
220
+ `It can spend with the full authority of ${budgetName(nodeId, nodeName)}.`,
155
221
  `Prefer a VIRTUAL agent (bind_virtual_agent): it binds one key you generate ` +
156
222
  `rather than a whole account, costs ~168 bytes, and strands nothing.`,
157
223
  ],
@@ -218,7 +284,7 @@ export function planAddRole(vault, label, limits, cooldownSec, recipients, publi
218
284
  ],
219
285
  };
220
286
  }
221
- export function planBindVirtualAgent(vault, nodeId, publicKey) {
287
+ export function planBindVirtualAgent(vault, nodeId, publicKey, nodeName) {
222
288
  assertNodeId(nodeId);
223
289
  assertPublicKey(publicKey);
224
290
  return {
@@ -230,7 +296,7 @@ export function planBindVirtualAgent(vault, nodeId, publicKey) {
230
296
  // `assert_owner()` -> `assert_one_yocto()`. Nothing is funded here, which
231
297
  // is the whole point of moving off sub-accounts.
232
298
  depositYocto: ONE_YOCTO,
233
- summary: `Let the key ${publicKey.slice(0, 22)}… spend from node ${nodeId}`,
299
+ summary: `Let a new agent key spend from ${budgetName(nodeId, nodeName)}`,
234
300
  warnings: [
235
301
  `A node may hold more than one key — binding another does not replace this one.`,
236
302
  `This grants spending authority only. The key still needs gas: install it as ` +
@@ -265,7 +331,7 @@ export function planUnbindVirtualAgent(vault, publicKey) {
265
331
  ],
266
332
  };
267
333
  }
268
- export function planSetRecipients(vault, nodeId, recipients) {
334
+ export function planSetRecipients(vault, nodeId, recipients, nodeName) {
269
335
  assertRecipients(recipients);
270
336
  return {
271
337
  receiverId: vault,
@@ -274,17 +340,18 @@ export function planSetRecipients(vault, nodeId, recipients) {
274
340
  gasTgas: DEFAULT_GAS_TGAS,
275
341
  depositYocto: ONE_YOCTO,
276
342
  summary: recipients
277
- ? `Node ${nodeId} may pay only: ${recipients.join(", ")}`
278
- : `Node ${nodeId} inherits its payee allowlist from the nearest ancestor that sets one`,
343
+ ? `${capitalise(budgetName(nodeId, nodeName))} may pay only: ${recipients.join(", ")}`
344
+ : `${capitalise(budgetName(nodeId, nodeName))} takes its list of allowed payees from the budget above it`,
279
345
  warnings: recipients
280
346
  ? [
281
- `This REPLACES the inherited allowlist for node ${nodeId} and its descendants; ` +
282
- `it does not add to it. Payees it currently inherits will stop being payable.`,
347
+ `This REPLACES the inherited payee list for ${budgetName(nodeId, nodeName)} and ` +
348
+ `everything under it; it does not add to it. Payees it currently inherits will ` +
349
+ `stop being payable.`,
283
350
  ]
284
351
  : [],
285
352
  };
286
353
  }
287
- export function planSetCooldown(vault, nodeId, cooldownSec) {
354
+ export function planSetCooldown(vault, nodeId, cooldownSec, nodeName) {
288
355
  assertCooldown(cooldownSec);
289
356
  return {
290
357
  receiverId: vault,
@@ -293,21 +360,23 @@ export function planSetCooldown(vault, nodeId, cooldownSec) {
293
360
  gasTgas: DEFAULT_GAS_TGAS,
294
361
  depositYocto: ONE_YOCTO,
295
362
  summary: cooldownSec
296
- ? `Node ${nodeId} must wait ${cooldownSec}s between payments`
297
- : `Remove the cooldown on node ${nodeId}`,
363
+ ? `${capitalise(budgetName(nodeId, nodeName))} must wait ${cooldownSec}s between payments`
364
+ : `Remove the waiting time between payments on ${budgetName(nodeId, nodeName)}`,
298
365
  warnings: [],
299
366
  };
300
367
  }
301
- export function planFreezeNode(vault, nodeId, frozen) {
368
+ export function planFreezeNode(vault, nodeId, frozen, nodeName) {
302
369
  return {
303
370
  receiverId: vault,
304
371
  methodName: "freeze_node",
305
372
  args: { node_id: nodeId, frozen },
306
373
  gasTgas: DEFAULT_GAS_TGAS,
307
374
  depositYocto: ONE_YOCTO,
308
- summary: frozen ? `Freeze node ${nodeId}` : `Unfreeze node ${nodeId}`,
375
+ summary: frozen
376
+ ? `Freeze ${budgetName(nodeId, nodeName)} so nothing under it can pay until you unfreeze it`
377
+ : `Unfreeze ${budgetName(nodeId, nodeName)} so it can pay again`,
309
378
  warnings: frozen
310
- ? [`This blocks payments from node ${nodeId} AND every node in its subtree.`]
379
+ ? [`This stops payments from ${budgetName(nodeId, nodeName)} and every budget under it.`]
311
380
  : [],
312
381
  };
313
382
  }
@@ -466,7 +535,7 @@ export function toCliCommand(p, network = "testnet") {
466
535
  * `toBaseUnits` throws rather than truncating excess precision — correct here:
467
536
  * silently rounding 0.1234567 would set a threshold the owner did not choose.
468
537
  */
469
- export function planSetApprovalThreshold(vault, nodeId, amount, decimals) {
538
+ export function planSetApprovalThreshold(vault, nodeId, amount, decimals, nodeName) {
470
539
  const base = amount === null ? null : toBaseUnits(amount, decimals).toString();
471
540
  return {
472
541
  receiverId: vault,
@@ -478,8 +547,8 @@ export function planSetApprovalThreshold(vault, nodeId, amount, decimals) {
478
547
  // 1000000 (base units)", which is unreadable in precisely the moment the
479
548
  // owner is checking the plan before signing it.
480
549
  summary: amount
481
- ? `Payments above ${amount} from node ${nodeId} will need your approval`
482
- : `Remove the approval threshold on node ${nodeId} — payments up to the caps run unattended`,
550
+ ? `Payments above ${amount} from ${budgetName(nodeId, nodeName)} will need your approval`
551
+ : `Stop requiring your approval on ${budgetName(nodeId, nodeName)} — payments inside the limits run unattended`,
483
552
  warnings: amount
484
553
  ? []
485
554
  : ["After this, nothing at this node waits for a human: only the caps apply."],
package/dist/server.js CHANGED
@@ -63,7 +63,7 @@ import { existsSync, readFileSync } from "node:fs";
63
63
  import { fileURLToPath } from "node:url";
64
64
  import { listAgents, listUnloadable, selectRole } from "./roles.js";
65
65
  import { resolveKeyringPath } from "./keyring-path.js";
66
- import { label as tokenLabel, tokenMeta } from "./token-meta.js";
66
+ import { displaySymbol, label as tokenLabel, tokenMeta } from "./token-meta.js";
67
67
  /**
68
68
  * A decimal NEAR string to yoctoNEAR, without floating point.
69
69
  *
@@ -78,7 +78,7 @@ function toYoctoNear(n) {
78
78
  }
79
79
  import { buildCreatePlan, FACTORY_BY_NETWORK } from "./create.js";
80
80
  import { buildRoute, sendRequestPayment } from "./sign.js";
81
- import { explainOutcome } from "./explain.js";
81
+ import { explainOutcome, planVoice } from "./explain.js";
82
82
  import { getQuote, signBinding, toBase64 } from "./quote.js";
83
83
  /** How long a quote stays valid. Long enough to sign and send, short
84
84
  * enough that a stale price is not honoured. */
@@ -296,12 +296,65 @@ function tokenFor(t) {
296
296
  }
297
297
  const text = (s) => ({ content: [{ type: "text", text: s }] });
298
298
  const json = (v) => text(JSON.stringify(v, null, 2));
299
- /** Payload + summary + warnings + a runnable command, so approving needs no trust in this server. */
300
- function renderPlan(p) {
299
+ /**
300
+ * Payload + summary + warnings + a runnable command, so approving needs no
301
+ * trust in this server — fronted by the plain-English fields.
302
+ *
303
+ * THE UX FIELDS COME FIRST, AND THEY ARE THE SAME THREE `vault_pay` RETURNS.
304
+ * This used to return five technical keys and no sentence, so agents described
305
+ * the mechanism — "returns an unsigned payload; the server does not sign; run
306
+ * `near contract call-function`…" — to someone who had asked to raise a limit.
307
+ * One shape across every tool means the instruction can be one sentence:
308
+ * relay `say`, add `next_step`, stop.
309
+ *
310
+ * `money_moved` is ALWAYS false here, and that is not a technicality worth
311
+ * hiding: a plan is a proposal. Nothing has moved, nothing has changed, and an
312
+ * agent that reports otherwise has lied about state the user is about to be
313
+ * asked to change themselves.
314
+ *
315
+ * `unsigned_payload` stays TOP-LEVEL rather than moving under `technical`:
316
+ * `scripts/check-mcp-live.mjs` asserts on it against the real server, and
317
+ * quietly renaming the key it reads would turn a live check green-by-absence.
318
+ */
319
+ async function renderPlan(p) {
320
+ const voice = planVoice(p);
321
+ /**
322
+ * SWAP TOKEN IDS FOR SYMBOLS IN THE SENTENCE ONLY.
323
+ *
324
+ * `summary` is built in `payloads.ts`, which is pure and has no network — so
325
+ * it prints the token's ACCOUNT ID. For mainnet USDC that is 64 hex
326
+ * characters, and `planVoice` reuses the summary verbatim, so the sentence
327
+ * read: 'Create budget node "research" as the ROOT with
328
+ * 17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1: 5/day'.
329
+ *
330
+ * Resolving here rather than in `payloads.ts` keeps that module pure and
331
+ * synchronously testable, which is worth more than saving this substitution.
332
+ * `technical.summary` is left EXACT — the id is the useful form there.
333
+ *
334
+ * Only the `say` is rewritten, and only for ids actually present, so a
335
+ * payload with no tokens costs nothing.
336
+ */
337
+ let say = voice.say;
338
+ const ids = [
339
+ ...new Set((Array.isArray(p.args.limits) ? p.args.limits : [])
340
+ .map((l) => l?.token)
341
+ .filter((t) => typeof t === "string" && say.includes(t))),
342
+ ];
343
+ if (ids.length > 0) {
344
+ const metas = await metaFor(ids);
345
+ for (const id of ids) {
346
+ const sym = metas.get(id)?.symbol;
347
+ // Substituted ONLY when a symbol actually resolved. Falling back to
348
+ // "tokens" here would erase which token a limit applies to, and a
349
+ // multi-token payload would become ambiguous.
350
+ if (sym)
351
+ say = say.split(id).join(sym);
352
+ }
353
+ }
301
354
  return json({
302
- action: p.methodName,
303
- summary: p.summary,
304
- warnings: p.warnings,
355
+ say,
356
+ next_step: voice.next,
357
+ money_moved: false,
305
358
  unsigned_payload: {
306
359
  receiver_id: p.receiverId,
307
360
  method_name: p.methodName,
@@ -309,6 +362,11 @@ function renderPlan(p) {
309
362
  gas_tgas: p.gasTgas,
310
363
  deposit_yocto: p.depositYocto,
311
364
  },
365
+ technical: {
366
+ action: p.methodName,
367
+ summary: p.summary,
368
+ warnings: p.warnings,
369
+ },
312
370
  approve_with: toCliCommand(p, cfg.network),
313
371
  note: "This server did NOT sign this. Review the summary, then run the command " +
314
372
  "or approve the payload in your wallet.",
@@ -408,7 +466,7 @@ WORTH KNOWING
408
466
  An implicit account (a 64-character hex id) cannot own a vault at all. Tell the user to use a
409
467
  named account rather than retrying.
410
468
  Amounts are whole tokens in these tools (1.5 means 1.5 USDC), never base units.`;
411
- const server = new McpServer({ name: "float", version: "0.10.2" }, { instructions: INSTRUCTIONS });
469
+ const server = new McpServer({ name: "float", version: "0.10.3" }, { instructions: INSTRUCTIONS });
412
470
  /**
413
471
  * THE FULL OPERATING GUIDE, AS AN MCP RESOURCE.
414
472
  *
@@ -606,6 +664,11 @@ server.tool("vault_await_create", "After the user signs the create link, confirm
606
664
  }
607
665
  if (config.owner !== owner) {
608
666
  return json({
667
+ say: `That vault address already belongs to someone else, so it is not yours to use. ` +
668
+ `Nothing was changed. I can set one up for you under a different name instead — ` +
669
+ `want me to?`,
670
+ next_step: `Say the word and I'll create a vault under a label, on a different address.`,
671
+ money_moved: false,
609
672
  status: "NOT_YOURS",
610
673
  vault: target,
611
674
  actualOwner: config.owner,
@@ -699,7 +762,26 @@ server.tool("vault_await_create", "After the user signs the create link, confirm
699
762
  else {
700
763
  nextSteps.push(`vault_tree shows the budget shape; vault_budget shows what is left today.`);
701
764
  }
765
+ /**
766
+ * "LIVE" PLUS A CONFIG DUMP IS NOT AN ANSWER TO "did it work?".
767
+ *
768
+ * `funding` and `nextSteps` already hold everything a person needs; what
769
+ * was missing was the one sentence that orders them. The empty-vault case
770
+ * is the one that matters: a vault that exists but holds nothing cannot
771
+ * pay, and "LIVE" on its own reads as done.
772
+ */
773
+ const liveSay = empty
774
+ ? `Your vault is set up and ready. It has no money in it yet, so add funds to it and ` +
775
+ `then I can start paying from it.`
776
+ : agentKeys === 0
777
+ ? `Your vault is set up and funded. One thing left: it needs an agent budget before ` +
778
+ `anything can be paid from it.`
779
+ : `Your vault is live, funded, and has an agent that can pay within its limits. ` +
780
+ `Nothing else to do.`;
702
781
  return json({
782
+ say: liveSay,
783
+ next_step: nextSteps[0] ?? null,
784
+ money_moved: false,
703
785
  status: "LIVE",
704
786
  vault: target,
705
787
  owner: config.owner,
@@ -796,7 +878,33 @@ server.tool("vault_budget", "Read one node's budget for a UTC day: cap, executed
796
878
  const tok = tokenFor(token);
797
879
  const meta = (await metaFor([tok])).get(tok) ?? null;
798
880
  const d = meta?.decimals ?? cfg.decimals;
881
+ // The SYMBOL for the sentence; the full `SYMBOL (id)` label stays on the
882
+ // `token` field below, where the id is useful rather than alarming.
883
+ const sym = displaySymbol(meta);
884
+ const cap = fromBaseUnits(BigInt(b.daily_cap), d);
885
+ const used = fromBaseUnits(BigInt(b.executed), d);
886
+ const left = fromBaseUnits(BigInt(b.available), d);
887
+ const inFlight = fromBaseUnits(BigInt(b.pending), d);
888
+ /**
889
+ * A BUDGET READ IS THE COMMONEST QUESTION A PERSON ASKS, and it was
890
+ * answered with eight fields and no sentence — including `day: 20768`.
891
+ * "Today: 0.25 of 25 USDC used, 24.75 left" is the same facts in the order
892
+ * someone asked for them.
893
+ *
894
+ * `pending` is mentioned ONLY when it is non-zero. It is in-flight money —
895
+ * reserved, not yet settled — and naming it on every read invites the
896
+ * question "what's pending?" when the answer is almost always "nothing".
897
+ * But when it IS non-zero, leaving it out makes the arithmetic look wrong:
898
+ * cap minus used would not equal what is left.
899
+ */
900
+ const pendingNote = BigInt(b.pending) > 0n ? ` ${inFlight} ${sym} is still in flight.` : "";
799
901
  return json({
902
+ say: `Today: ${used} of ${cap} ${sym} used, ${left} ${sym} left.${pendingNote}` +
903
+ ` Resets at midnight UTC.`,
904
+ next_step: null,
905
+ // A read moves nothing. Stated rather than omitted so the field means the
906
+ // same thing on every tool that has it.
907
+ money_moved: false,
800
908
  node_id: nodeId,
801
909
  token: tokenLabel(tok, meta),
802
910
  /**
@@ -811,10 +919,10 @@ server.tool("vault_budget", "Read one node's budget for a UTC day: cap, executed
811
919
  date: new Date(Number(b.day) * 86_400_000).toISOString().slice(0, 10),
812
920
  day: b.day,
813
921
  resets: "00:00 UTC",
814
- daily_cap: fromBaseUnits(BigInt(b.daily_cap), d),
815
- executed: fromBaseUnits(BigInt(b.executed), d),
816
- pending: fromBaseUnits(BigInt(b.pending), d),
817
- available: fromBaseUnits(BigInt(b.available), d),
922
+ daily_cap: cap,
923
+ executed: used,
924
+ pending: inFlight,
925
+ available: left,
818
926
  });
819
927
  });
820
928
  /**
@@ -1112,7 +1220,38 @@ server.tool("vault_add_role", "Add a NEW departmental agent to an existing vault
1112
1220
  nodeId: null,
1113
1221
  });
1114
1222
  const payload = planAddRole(vault, role, limits, cooldownSec ?? 0, recipients ?? null, generated.publicKey, fundingYocto);
1223
+ /**
1224
+ * THE SENTENCE FOR THE MOMENT THIS TOOL IS USED.
1225
+ *
1226
+ * This returned a public key, a keyring path, a payload and two warnings —
1227
+ * and agents duly reported "generated an ed25519 keypair, wrote it to the
1228
+ * keyring; the 0.12 NEAR deposit funds a NEP-611 gas key" to someone who
1229
+ * had asked for a news agent with a small budget.
1230
+ *
1231
+ * What the person needs is the BUDGET, in the units they named, and that
1232
+ * one approval stands between them and it. The key, the path and the
1233
+ * funding mechanics are real and stay in the payload for the agent.
1234
+ *
1235
+ * The caps are re-stated from the ARGUMENTS rather than the built payload,
1236
+ * because the payload holds base units and the caller spoke in tokens.
1237
+ */
1238
+ /**
1239
+ * RESOLVE THE SYMBOL. Passing `null` metadata here printed the token's
1240
+ * ACCOUNT ID in the sentence — "1 usdc.testnet a day", and on mainnet a
1241
+ * 64-character hex string. One cached view call, and it degrades to
1242
+ * "tokens" rather than to an id when the chain cannot be reached, which
1243
+ * keeps this tool usable offline.
1244
+ */
1245
+ const roleMeta = await tokenMeta(cfg.rpcUrl, tokenFor(token), (rpc, t) => viewFunction(rpc, t, "ft_metadata", {})).catch(() => null);
1246
+ const roleSay = `I'll set up a ${role} budget: ${dailyCap} ${displaySymbol(roleMeta)} a day, and no ` +
1247
+ `single payment over ${perTxCap}. You approve it once in your wallet, and after that ` +
1248
+ `I can pay within those limits — I can't raise them myself. Nothing has changed yet.`;
1115
1249
  return json({
1250
+ say: roleSay,
1251
+ next_step: `Approve it in your wallet, then tell me and I'll confirm the ${role} budget is live.`,
1252
+ // The approval moves 0.12 NEAR into the agent's gas key when signed, but
1253
+ // NOTHING has moved at this point — this is an unsigned proposal.
1254
+ money_moved: false,
1116
1255
  role,
1117
1256
  agent: {
1118
1257
  publicKey: generated.publicKey,
@@ -1614,7 +1753,12 @@ server.tool("vault_plan_add_node", "Build an UNSIGNED payload to create a budget
1614
1753
  limits: limitShape,
1615
1754
  cooldownSec: z.number().int().min(0).default(0),
1616
1755
  recipients: z.array(z.string()).nullable().default(null),
1617
- }, async ({ parent, label, limits, cooldownSec, recipients }) => renderPlan(planAddNode(requireVault(), parent, label, limits, cooldownSec, recipients)));
1756
+ }, async ({ parent, label, limits, cooldownSec, recipients }) => {
1757
+ // The PARENT's name, so the summary reads "under news" not "under node 1".
1758
+ // Only looked up when there IS a parent.
1759
+ const parentName = parent === null ? null : (await resolveNode(parent)).label;
1760
+ return renderPlan(planAddNode(requireVault(), parent, label, limits, cooldownSec, recipients, parentName));
1761
+ });
1618
1762
  /**
1619
1763
  * Refuse to build a signable payload for a node that does not exist.
1620
1764
  *
@@ -1636,19 +1780,38 @@ server.tool("vault_plan_add_node", "Build an UNSIGNED payload to create a budget
1636
1780
  * them just prompts another guess.
1637
1781
  */
1638
1782
  async function assertNode(nodeId) {
1783
+ return (await resolveNode(nodeId)).missing;
1784
+ }
1785
+ /**
1786
+ * One tree read, answering BOTH questions a plan handler has: does this node
1787
+ * exist, and what do humans call it?
1788
+ *
1789
+ * THE LABEL WAS ALREADY BEING FETCHED AND THROWN AWAY. `assertNode` read
1790
+ * `label` purely to list it in a "no such node" message, so every plan summary
1791
+ * said "node 1" while the word "news" sat in the same response. Returning it
1792
+ * costs nothing and is what lets `budgetName` do its job.
1793
+ *
1794
+ * A FAILED READ RETURNS NO LABEL AND NO COMPLAINT. The chain being briefly
1795
+ * unreachable is not evidence the node is missing — refusing here would turn a
1796
+ * blip into "your node does not exist" — and it is not evidence the node is
1797
+ * unnamed either, so the summary falls back to "this budget" rather than
1798
+ * inventing one.
1799
+ */
1800
+ async function resolveNode(nodeId) {
1639
1801
  const tree = (await viewFunction(cfg.rpcUrl, requireVault(), "get_tree", {}).catch(() => null));
1640
- // A failed READ must not block a plan: the chain being briefly unreachable
1641
- // is not evidence the node is missing, and refusing here would turn a blip
1642
- // into "your node does not exist".
1643
1802
  if (!Array.isArray(tree))
1644
- return null;
1645
- if (tree.some((n) => n.id === nodeId))
1646
- return null;
1803
+ return { missing: null, label: null };
1804
+ const hit = tree.find((n) => n.id === nodeId);
1805
+ if (hit)
1806
+ return { missing: null, label: hit.label ?? null };
1647
1807
  // RETURNS THE FACTS, NOT A SENTENCE. The first version returned prose and
1648
1808
  // the read path tried to reuse it by regex-stripping the signing clause,
1649
1809
  // which left "no node 999, Nodes: ...". Composing at the call site costs two
1650
1810
  // lines and cannot produce a mangled sentence.
1651
- return { known: tree.map((n) => `${n.id}${n.label ? ` (${n.label})` : ""}`).join(", ") };
1811
+ return {
1812
+ missing: { known: tree.map((n) => `${n.id}${n.label ? ` (${n.label})` : ""}`).join(", ") },
1813
+ label: null,
1814
+ };
1652
1815
  }
1653
1816
  /** For a PLAN: the cost of getting this wrong is a wasted signature. */
1654
1817
  const noNodeForPlan = (nodeId, known) => `This vault has no node ${nodeId}, so that payload would be refused by the ` +
@@ -1656,33 +1819,33 @@ const noNodeForPlan = (nodeId, known) => `This vault has no node ${nodeId}, so t
1656
1819
  /** For a READ: nothing is at stake, so just say what exists. */
1657
1820
  const noNodeForRead = (nodeId, known) => `This vault has no node ${nodeId}. Nodes: ${known}.`;
1658
1821
  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 }) => {
1659
- const bad = await assertNode(nodeId);
1660
- return bad
1661
- ? text(noNodeForPlan(nodeId, bad.known))
1662
- : renderPlan(planSetNodeCaps(requireVault(), nodeId, limits));
1822
+ const n = await resolveNode(nodeId);
1823
+ return n.missing
1824
+ ? text(noNodeForPlan(nodeId, n.missing.known))
1825
+ : renderPlan(planSetNodeCaps(requireVault(), nodeId, limits, n.label));
1663
1826
  });
1664
1827
  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.", {
1665
1828
  nodeId: z.number().int().min(0),
1666
1829
  publicKey: z.string().describe("ed25519:..."),
1667
- }, async ({ nodeId, publicKey }) => renderPlan(planBindVirtualAgent(requireVault(), nodeId, publicKey)));
1830
+ }, async ({ nodeId, publicKey }) => renderPlan(planBindVirtualAgent(requireVault(), nodeId, publicKey, (await resolveNode(nodeId)).label)));
1668
1831
  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)));
1669
1832
  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 }) => {
1670
- const bad = await assertNode(nodeId);
1671
- return bad
1672
- ? text(noNodeForPlan(nodeId, bad.known))
1673
- : renderPlan(planFreezeNode(requireVault(), nodeId, frozen));
1833
+ const n = await resolveNode(nodeId);
1834
+ return n.missing
1835
+ ? text(noNodeForPlan(nodeId, n.missing.known))
1836
+ : renderPlan(planFreezeNode(requireVault(), nodeId, frozen, n.label));
1674
1837
  });
1675
1838
  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 () => {
1676
- const bad = await assertNode(nodeId);
1677
- return bad
1678
- ? text(noNodeForPlan(nodeId, bad.known))
1679
- : renderPlan(planSetRecipients(requireVault(), nodeId, recipients));
1839
+ const n = await resolveNode(nodeId);
1840
+ return n.missing
1841
+ ? text(noNodeForPlan(nodeId, n.missing.known))
1842
+ : renderPlan(planSetRecipients(requireVault(), nodeId, recipients, n.label));
1680
1843
  })());
1681
1844
  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 }) => {
1682
- const bad = await assertNode(nodeId);
1683
- return bad
1684
- ? text(noNodeForPlan(nodeId, bad.known))
1685
- : renderPlan(planSetCooldown(requireVault(), nodeId, cooldownSec));
1845
+ const n = await resolveNode(nodeId);
1846
+ return n.missing
1847
+ ? text(noNodeForPlan(nodeId, n.missing.known))
1848
+ : renderPlan(planSetCooldown(requireVault(), nodeId, cooldownSec, n.label));
1686
1849
  });
1687
1850
  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", {
1688
1851
  request_id: requestId,
@@ -1695,7 +1858,7 @@ server.tool("vault_plan_set_approval_threshold", 'Build an UNSIGNED payload requ
1695
1858
  // planSetApprovalThreshold in payloads.ts for the measurement.
1696
1859
  amount: z.string().min(1).nullable().describe('human units, e.g. "0.25" — NOT base units'),
1697
1860
  decimals: z.number().int().min(0).max(38).default(6),
1698
- }, async ({ nodeId, amount, decimals }) => renderPlan(planSetApprovalThreshold(requireVault(), nodeId, amount, decimals)));
1861
+ }, async ({ nodeId, amount, decimals }) => renderPlan(planSetApprovalThreshold(requireVault(), nodeId, amount, decimals, (await resolveNode(nodeId)).label)));
1699
1862
  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)));
1700
1863
  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)));
1701
1864
  /**
@@ -39,4 +39,20 @@ export declare function tokenMeta(rpcUrl: string, token: string, view: Fetcher):
39
39
  * to remain visible.
40
40
  */
41
41
  export declare function label(token: string, meta: TokenMeta | null): string;
42
+ /**
43
+ * The symbol ALONE, for a sentence a person reads.
44
+ *
45
+ * `label()` returns `USDC (17208628f84f5d6ad33f…)`, which is right for a JSON
46
+ * field — the id is what a support conversation needs — and wrong inside
47
+ * prose. Dropped into a `say` it produced "1 usdc.testnet a day", and on
48
+ * mainnet it would have been "1 17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1 a day",
49
+ * because Circle-native USDC's account id is 64 hex characters.
50
+ *
51
+ * Falls back to "tokens" rather than the id. An unresolvable symbol is a
52
+ * cosmetic loss; a 64-character hex string in the middle of a sentence tells
53
+ * the reader the product is broken. `vault_pay` already did exactly this —
54
+ * `meta?.symbol ?? "tokens"` — and this just names the pattern so the next
55
+ * caller does not have to rediscover it.
56
+ */
57
+ export declare function displaySymbol(meta: TokenMeta | null): string;
42
58
  export {};
@@ -44,3 +44,21 @@ export async function tokenMeta(rpcUrl, token, view) {
44
44
  export function label(token, meta) {
45
45
  return meta ? `${meta.symbol} (${token})` : token;
46
46
  }
47
+ /**
48
+ * The symbol ALONE, for a sentence a person reads.
49
+ *
50
+ * `label()` returns `USDC (17208628f84f5d6ad33f…)`, which is right for a JSON
51
+ * field — the id is what a support conversation needs — and wrong inside
52
+ * prose. Dropped into a `say` it produced "1 usdc.testnet a day", and on
53
+ * mainnet it would have been "1 17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1 a day",
54
+ * because Circle-native USDC's account id is 64 hex characters.
55
+ *
56
+ * Falls back to "tokens" rather than the id. An unresolvable symbol is a
57
+ * cosmetic loss; a 64-character hex string in the middle of a sentence tells
58
+ * the reader the product is broken. `vault_pay` already did exactly this —
59
+ * `meta?.symbol ?? "tokens"` — and this just names the pattern so the next
60
+ * caller does not have to rediscover it.
61
+ */
62
+ export function displaySymbol(meta) {
63
+ return meta?.symbol ?? "tokens";
64
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floatonnear/mcp",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
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",