@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 +105 -13
- package/dist/explain.d.ts +33 -0
- package/dist/explain.js +55 -0
- package/dist/payloads.d.ts +10 -8
- package/dist/payloads.js +92 -23
- package/dist/server.js +201 -38
- package/dist/token-meta.d.ts +16 -0
- package/dist/token-meta.js +18 -0
- package/package.json +1 -1
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
|
|
34
|
-
of your requests
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
322
|
-
|
|
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
|
+
}
|
package/dist/payloads.d.ts
CHANGED
|
@@ -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
|
|
47
|
-
|
|
48
|
-
|
|
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:
|
|
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
|
-
|
|
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: `
|
|
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
|
|
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
|
|
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
|
|
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
|
-
?
|
|
278
|
-
:
|
|
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
|
|
282
|
-
`it does not add to it. Payees it currently inherits will
|
|
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
|
-
?
|
|
297
|
-
: `Remove the
|
|
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
|
|
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
|
|
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
|
|
482
|
-
: `
|
|
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
|
-
/**
|
|
300
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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.
|
|
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:
|
|
815
|
-
executed:
|
|
816
|
-
pending:
|
|
817
|
-
available:
|
|
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 }) =>
|
|
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
|
-
|
|
1646
|
-
|
|
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 {
|
|
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
|
|
1660
|
-
return
|
|
1661
|
-
? text(noNodeForPlan(nodeId,
|
|
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
|
|
1671
|
-
return
|
|
1672
|
-
? text(noNodeForPlan(nodeId,
|
|
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
|
|
1677
|
-
return
|
|
1678
|
-
? text(noNodeForPlan(nodeId,
|
|
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
|
|
1683
|
-
return
|
|
1684
|
-
? text(noNodeForPlan(nodeId,
|
|
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
|
/**
|
package/dist/token-meta.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/token-meta.js
CHANGED
|
@@ -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.
|
|
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",
|