@floatonnear/mcp 0.6.1 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-key.js +7 -2
- package/dist/create.d.ts +56 -1
- package/dist/create.js +155 -2
- package/dist/gas-math.d.ts +53 -0
- package/dist/gas-math.js +61 -0
- package/dist/key-file.js +59 -1
- package/dist/server.d.ts +2 -1
- package/dist/server.js +240 -5
- package/package.json +1 -1
package/dist/agent-key.js
CHANGED
|
@@ -106,10 +106,15 @@ export function generateAgentKey(opts) {
|
|
|
106
106
|
// because it holds ONE agent, while the keyring holds every agent — so the
|
|
107
107
|
// keyring is the durable record and this is a convenience.
|
|
108
108
|
writeFileSync(envPath, [
|
|
109
|
-
`# Written by vault_create.
|
|
109
|
+
`# Written by vault_create. Points at the keyring; holds NO key itself.`,
|
|
110
110
|
`# The agent can only call request_payment on this vault, inside its budget.`,
|
|
111
111
|
`NEAR_VAULT_AGENT_ID=${opts.vault}`,
|
|
112
|
-
|
|
112
|
+
// A PATH, NOT THE SECRET. This file used to carry the key inline, so a
|
|
113
|
+
// `cat` to check which vault was configured printed a live private key
|
|
114
|
+
// into scrollback and into the driving agent's transcript. The keyring
|
|
115
|
+
// beside it is the durable record and `readKeyFile` now reads that shape,
|
|
116
|
+
// so naming it twice bought nothing except a second place to leak from.
|
|
117
|
+
`NEAR_VAULT_AGENT_KEY_FILE=${keyringPath}`,
|
|
113
118
|
``,
|
|
114
119
|
].join("\n"));
|
|
115
120
|
// Best effort. Windows ignores POSIX modes, so this must not be load-bearing
|
package/dist/create.d.ts
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
* clean failure: CreateAccount succeeds and the vault sits below its own
|
|
36
36
|
* storage requirement, bricking on the first write.
|
|
37
37
|
*/
|
|
38
|
-
export declare const CONTRACT_BYTES =
|
|
38
|
+
export declare const CONTRACT_BYTES = 298839;
|
|
39
39
|
/** NEAR charges 1e19 yoctoNEAR per byte of storage. */
|
|
40
40
|
export declare const STORAGE_COST_PER_BYTE: bigint;
|
|
41
41
|
/**
|
|
@@ -178,7 +178,51 @@ export interface CreatePlanInput {
|
|
|
178
178
|
* anything is signed.
|
|
179
179
|
*/
|
|
180
180
|
agentPk?: string | null;
|
|
181
|
+
/**
|
|
182
|
+
* The root node's caps and token, in HUMAN units — the answers to "how much
|
|
183
|
+
* per day?" and "how much per payment?".
|
|
184
|
+
*
|
|
185
|
+
* WHY THESE EXIST. The signing page has read `?daily=`, `?pertx=`, `?token=`
|
|
186
|
+
* and `?decimals=` since it was written, defaulting to 2.04/0.51 USDC on
|
|
187
|
+
* mainnet. This builder sent four params and never those, so an agent could
|
|
188
|
+
* not ask the user their budget and hand back a ready-to-sign link — which
|
|
189
|
+
* is the entire point of the flow. The page was finished; this side was not,
|
|
190
|
+
* and nothing compared the two lists.
|
|
191
|
+
*
|
|
192
|
+
* HUMAN UNITS, NOT BASE UNITS. The page scales by `decimals` itself —
|
|
193
|
+
* `units()` pads the fraction to DECIMALS digits — so base units passed here
|
|
194
|
+
* would be scaled a second time and turn a 100 USDC cap into 100 million.
|
|
195
|
+
*/
|
|
196
|
+
daily?: string;
|
|
197
|
+
perTx?: string;
|
|
198
|
+
token?: string;
|
|
199
|
+
decimals?: number;
|
|
200
|
+
/**
|
|
201
|
+
* A vault label, which is PART OF THE ADDRESS.
|
|
202
|
+
*
|
|
203
|
+
* `resolve_vault` builds `{prefix}-{label}.{factory}`, so a labelled create
|
|
204
|
+
* does NOT land on the id `vault_id_for` returned. The page already knows
|
|
205
|
+
* this and appends the suffix before showing an address; this builder has to
|
|
206
|
+
* agree with it, or the user is told one address and gets another.
|
|
207
|
+
*/
|
|
208
|
+
label?: string;
|
|
181
209
|
}
|
|
210
|
+
/**
|
|
211
|
+
* Refuse a cap the page would silently round, or the contract would reject.
|
|
212
|
+
*
|
|
213
|
+
* EVERY ONE OF THESE IS A POST-SIGNATURE FAILURE IF IT GETS THROUGH. These
|
|
214
|
+
* values go into a transaction the user approves, so a bad one is not a
|
|
215
|
+
* validation error they can correct — it is a panic after the deposit moved,
|
|
216
|
+
* or worse, a cap that is quietly not the number they agreed to.
|
|
217
|
+
*/
|
|
218
|
+
export declare function validateCaps(i: {
|
|
219
|
+
daily?: string;
|
|
220
|
+
perTx?: string;
|
|
221
|
+
decimals?: number;
|
|
222
|
+
token?: string;
|
|
223
|
+
label?: string;
|
|
224
|
+
factory: string;
|
|
225
|
+
}): string[];
|
|
182
226
|
export interface CreatePlan {
|
|
183
227
|
owner: string | null;
|
|
184
228
|
/** Null when the owner is unknown — the wallet decides it at signing time. */
|
|
@@ -194,6 +238,17 @@ export interface CreatePlan {
|
|
|
194
238
|
url: string;
|
|
195
239
|
warnings: string[];
|
|
196
240
|
note: string;
|
|
241
|
+
/** What the agent should do after the user signs, instead of asking them. */
|
|
242
|
+
confirm: {
|
|
243
|
+
vault: string | null;
|
|
244
|
+
tool: string;
|
|
245
|
+
args: {
|
|
246
|
+
vault: string | null;
|
|
247
|
+
owner: string;
|
|
248
|
+
network: string;
|
|
249
|
+
} | null;
|
|
250
|
+
instruction: string;
|
|
251
|
+
};
|
|
197
252
|
}
|
|
198
253
|
/** yoctoNEAR -> a NEAR string, without floating point. */
|
|
199
254
|
export declare function yoctoToNear(y: bigint, dp?: number): string;
|
package/dist/create.js
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
* clean failure: CreateAccount succeeds and the vault sits below its own
|
|
36
36
|
* storage requirement, bricking on the first write.
|
|
37
37
|
*/
|
|
38
|
-
export const CONTRACT_BYTES =
|
|
38
|
+
export const CONTRACT_BYTES = 298839;
|
|
39
39
|
/** NEAR charges 1e19 yoctoNEAR per byte of storage. */
|
|
40
40
|
export const STORAGE_COST_PER_BYTE = 10n ** 19n;
|
|
41
41
|
/**
|
|
@@ -132,6 +132,73 @@ export const FACTORY_BY_NETWORK = {
|
|
|
132
132
|
testnet: "fac.floatv4.testnet",
|
|
133
133
|
mainnet: "usefloat.near",
|
|
134
134
|
};
|
|
135
|
+
/**
|
|
136
|
+
* Refuse a cap the page would silently round, or the contract would reject.
|
|
137
|
+
*
|
|
138
|
+
* EVERY ONE OF THESE IS A POST-SIGNATURE FAILURE IF IT GETS THROUGH. These
|
|
139
|
+
* values go into a transaction the user approves, so a bad one is not a
|
|
140
|
+
* validation error they can correct — it is a panic after the deposit moved,
|
|
141
|
+
* or worse, a cap that is quietly not the number they agreed to.
|
|
142
|
+
*/
|
|
143
|
+
export function validateCaps(i) {
|
|
144
|
+
const problems = [];
|
|
145
|
+
const dp = i.decimals ?? 6;
|
|
146
|
+
const numeric = /^\d+(\.\d+)?$/;
|
|
147
|
+
if (i.decimals !== undefined &&
|
|
148
|
+
(!Number.isInteger(i.decimals) || i.decimals < 0 || i.decimals > 24)) {
|
|
149
|
+
problems.push(`decimals must be an integer 0-24, got ${i.decimals}`);
|
|
150
|
+
}
|
|
151
|
+
for (const [name, v] of [
|
|
152
|
+
["daily", i.daily],
|
|
153
|
+
["perTx", i.perTx],
|
|
154
|
+
]) {
|
|
155
|
+
if (v === undefined)
|
|
156
|
+
continue;
|
|
157
|
+
if (!numeric.test(v)) {
|
|
158
|
+
problems.push(`${name} must be a plain decimal in HUMAN units, e.g. "100" or "2.04", got "${v}"`);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (Number(v) <= 0) {
|
|
162
|
+
problems.push(`${name} must be greater than 0 — a zero cap refuses every payment`);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
// THE PAGE TRUNCATES; IT DOES NOT ROUND AND DOES NOT COMPLAIN. `units()`
|
|
166
|
+
// slices the fraction to `decimals` digits, so "2.0400001" against a
|
|
167
|
+
// 6-decimal token becomes 2.040000 and the user approves a cap that is not
|
|
168
|
+
// the one they were quoted. Never silently alter a number about money.
|
|
169
|
+
const frac = v.split(".")[1] ?? "";
|
|
170
|
+
if (frac.length > dp) {
|
|
171
|
+
problems.push(`${name} "${v}" has ${frac.length} decimal places but the token has ${dp}. ` +
|
|
172
|
+
`The signing page truncates rather than rounding, so the cap would silently ` +
|
|
173
|
+
`become "${v.split(".")[0]}.${frac.slice(0, dp)}".`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (i.daily && i.perTx && numeric.test(i.daily) && numeric.test(i.perTx)) {
|
|
177
|
+
if (Number(i.perTx) > Number(i.daily)) {
|
|
178
|
+
problems.push(`perTx (${i.perTx}) is larger than daily (${i.daily}), so the per-payment cap ` +
|
|
179
|
+
`can never bind — the daily cap refuses first. Probably swapped.`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (i.token !== undefined && !/^[a-z0-9._-]{2,64}$/.test(i.token)) {
|
|
183
|
+
problems.push(`token "${i.token}" is not a legal NEAR account id`);
|
|
184
|
+
}
|
|
185
|
+
if (i.label !== undefined) {
|
|
186
|
+
// The CONTRACT's rule, not a looser one, so a bad label is refused here
|
|
187
|
+
// rather than panicking BAD_LABEL after the user has paid.
|
|
188
|
+
if (!/^[a-z0-9]{1,16}$/.test(i.label)) {
|
|
189
|
+
problems.push(`label "${i.label}" must be 1-16 characters of a-z or 0-9`);
|
|
190
|
+
}
|
|
191
|
+
else if (i.label === i.factory.split(".").pop()) {
|
|
192
|
+
// Added to the factory on 2026-11-10: a label equal to the factory's own
|
|
193
|
+
// suffix rebuilds another account's derived id. The page predates that
|
|
194
|
+
// guard and does not know about it, so this is the only layer that can
|
|
195
|
+
// catch it before a signature.
|
|
196
|
+
problems.push(`label "${i.label}" is the factory's own suffix, which the factory refuses — ` +
|
|
197
|
+
`it would rebuild another account's derived vault id`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return problems;
|
|
201
|
+
}
|
|
135
202
|
/** yoctoNEAR -> a NEAR string, without floating point. */
|
|
136
203
|
export function yoctoToNear(y, dp = 5) {
|
|
137
204
|
const neg = y < 0n;
|
|
@@ -209,6 +276,22 @@ export function buildCreatePlan(i) {
|
|
|
209
276
|
`headroom constant needs raising — this is an operator bug, not a funding problem.`,
|
|
210
277
|
]
|
|
211
278
|
: [];
|
|
279
|
+
// REFUSE BEFORE BUILDING A LINK, not after. Returning a URL alongside a
|
|
280
|
+
// warning would still be a URL the agent hands over and the user signs.
|
|
281
|
+
const capProblems = validateCaps({
|
|
282
|
+
daily: i.daily,
|
|
283
|
+
perTx: i.perTx,
|
|
284
|
+
decimals: i.decimals,
|
|
285
|
+
token: i.token,
|
|
286
|
+
label: i.label,
|
|
287
|
+
factory: i.factory,
|
|
288
|
+
});
|
|
289
|
+
if (capProblems.length) {
|
|
290
|
+
throw new Error(`Refusing to build a signing link:\n - ${capProblems.join("\n - ")}\n\n` +
|
|
291
|
+
`These become part of a transaction the user approves, so a bad value is ` +
|
|
292
|
+
`not a correctable error — it is a panic after the deposit moved, or a cap ` +
|
|
293
|
+
`that is not the number they agreed to.`);
|
|
294
|
+
}
|
|
212
295
|
const url = new URL(i.appUrl);
|
|
213
296
|
if (known)
|
|
214
297
|
url.searchParams.set("owner", i.owner);
|
|
@@ -216,6 +299,19 @@ export function buildCreatePlan(i) {
|
|
|
216
299
|
url.searchParams.set("agent_pk", i.agentPk);
|
|
217
300
|
url.searchParams.set("factory", i.factory);
|
|
218
301
|
url.searchParams.set("network", i.network);
|
|
302
|
+
// The interview answers. Names are the PAGE's (`pertx`, not `perTx`) —
|
|
303
|
+
// `scripts/check-create-url-params.mjs` compares the two lists so a rename
|
|
304
|
+
// on either side cannot drift silently again.
|
|
305
|
+
if (i.daily !== undefined)
|
|
306
|
+
url.searchParams.set("daily", i.daily);
|
|
307
|
+
if (i.perTx !== undefined)
|
|
308
|
+
url.searchParams.set("pertx", i.perTx);
|
|
309
|
+
if (i.token !== undefined)
|
|
310
|
+
url.searchParams.set("token", i.token);
|
|
311
|
+
if (i.decimals !== undefined)
|
|
312
|
+
url.searchParams.set("decimals", String(i.decimals));
|
|
313
|
+
if (i.label !== undefined)
|
|
314
|
+
url.searchParams.set("label", i.label);
|
|
219
315
|
const unknownOwnerWarnings = known
|
|
220
316
|
? []
|
|
221
317
|
: [
|
|
@@ -224,9 +320,29 @@ export function buildCreatePlan(i) {
|
|
|
224
320
|
`Nothing has checked whether you already have a vault. If you do, the page ` +
|
|
225
321
|
`will say so rather than creating a second one.`,
|
|
226
322
|
];
|
|
323
|
+
// THE LABEL IS PART OF THE ADDRESS, so the id the factory's view returned is
|
|
324
|
+
// not where a labelled create lands. `web/index.html:1282` does exactly this
|
|
325
|
+
// and says why: showing the unlabelled id while signing for the labelled one
|
|
326
|
+
// tells the user the wrong address.
|
|
327
|
+
//
|
|
328
|
+
// Derived by surgery on the VIEW's answer rather than by reimplementing
|
|
329
|
+
// `derive_prefix` — this file states that the derivation is not duplicated
|
|
330
|
+
// here, and a second copy of it is how the two would disagree. `vault_id`
|
|
331
|
+
// builds `{prefix}.{factory}`, so stripping the known factory suffix leaves
|
|
332
|
+
// exactly the prefix. Asserted rather than assumed, because a `vaultId` that
|
|
333
|
+
// did not end in the factory would silently produce a nonsense address.
|
|
334
|
+
let vault = known ? i.vaultId : null;
|
|
335
|
+
if (vault && i.label !== undefined) {
|
|
336
|
+
const suffix = `.${i.factory}`;
|
|
337
|
+
if (!vault.endsWith(suffix)) {
|
|
338
|
+
throw new Error(`vaultId "${vault}" does not end in ".${i.factory}", so the label suffix ` +
|
|
339
|
+
`cannot be placed. Refusing to guess an address next to a real deposit.`);
|
|
340
|
+
}
|
|
341
|
+
vault = `${vault.slice(0, -suffix.length)}-${i.label}${suffix}`;
|
|
342
|
+
}
|
|
227
343
|
return {
|
|
228
344
|
owner: known ? i.owner : null,
|
|
229
|
-
vault
|
|
345
|
+
vault,
|
|
230
346
|
factory: i.factory,
|
|
231
347
|
network: i.network,
|
|
232
348
|
depositYocto: deposit.toString(),
|
|
@@ -250,5 +366,42 @@ export function buildCreatePlan(i) {
|
|
|
250
366
|
note: `This server never sees or holds a key. Open the URL and approve the ` +
|
|
251
367
|
`transaction in your own wallet; the contract travels as an argument to ` +
|
|
252
368
|
`one ordinary function call, and the factory deploys it for you.`,
|
|
369
|
+
/**
|
|
370
|
+
* WHAT THE AGENT DOES NEXT, so it stops asking the user to read an address
|
|
371
|
+
* back to it.
|
|
372
|
+
*
|
|
373
|
+
* The derived id is known BEFORE anything is signed, so "did it work?" is a
|
|
374
|
+
* question this server can answer by looking at the chain. It used to end
|
|
375
|
+
* at "approve it in your wallet", which left the human relaying an account
|
|
376
|
+
* id the agent already had — the clunkiest step in the flow and a pure
|
|
377
|
+
* documentation gap, not a technical limit.
|
|
378
|
+
*
|
|
379
|
+
* OWNERSHIP IS A SEPARATE CHECK FROM EXISTENCE, and that is the whole
|
|
380
|
+
* reason this names a method rather than saying "poll until it appears".
|
|
381
|
+
* `derive_prefix` is not injective, so an account AT the derived id is not
|
|
382
|
+
* proof the vault is the user's: `vault_await_create` reads `get_config`
|
|
383
|
+
* back and compares `owner`. Three outcomes, never two — mine, someone
|
|
384
|
+
* else's, or could-not-read.
|
|
385
|
+
*/
|
|
386
|
+
confirm: known
|
|
387
|
+
? {
|
|
388
|
+
vault,
|
|
389
|
+
tool: "vault_await_create",
|
|
390
|
+
args: { vault, owner: i.owner, network: i.network },
|
|
391
|
+
instruction: `Do NOT ask the user to paste the vault id back — you already have it. ` +
|
|
392
|
+
`After they say they have signed, call \`vault_await_create\` with the ` +
|
|
393
|
+
`arguments above. It polls for the account and verifies \`get_config.owner\` ` +
|
|
394
|
+
`is ${i.owner}, because an account existing at a derived id is not proof it ` +
|
|
395
|
+
`belongs to them.`,
|
|
396
|
+
}
|
|
397
|
+
: {
|
|
398
|
+
vault: null,
|
|
399
|
+
tool: "vault_await_create",
|
|
400
|
+
args: null,
|
|
401
|
+
instruction: `The address depends on which wallet connects, so it is not known yet. ` +
|
|
402
|
+
`Ask the user for their account id once they have signed, then call ` +
|
|
403
|
+
`\`vault_await_create\` with it — or call \`vault_create\` again with ` +
|
|
404
|
+
`\`owner\` set to get the address up front.`,
|
|
405
|
+
},
|
|
253
406
|
};
|
|
254
407
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gas-key arithmetic, mirrored from `create-float`'s `bin/gas-key.mjs`.
|
|
3
|
+
*
|
|
4
|
+
* WHY A MIRROR AND NOT AN IMPORT. `@floatonnear/create-float` does not depend
|
|
5
|
+
* on `@floatonnear/mcp` and vice versa — they are siblings, published
|
|
6
|
+
* separately, and installed independently by users. Adding a dependency in
|
|
7
|
+
* either direction to share four lines of arithmetic would couple two release
|
|
8
|
+
* cycles, and this repo has already lost an afternoon to a workspace range
|
|
9
|
+
* that silently resolved to a PUBLISHED sibling instead of the local one.
|
|
10
|
+
*
|
|
11
|
+
* So it is duplicated DELIBERATELY, following the pattern already used for
|
|
12
|
+
* `shared/contract-constants.json`: mirror the values, then make drift fail a
|
|
13
|
+
* check. `scripts/check-gas-math.mjs` compares these constants against
|
|
14
|
+
* create-float's and fails the gate if they diverge. A silent divergence here
|
|
15
|
+
* would quote a user one number and charge them another.
|
|
16
|
+
*
|
|
17
|
+
* Every figure is a measurement; see gas-key.mjs for the provenance.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* What NEAR holds for one in-flight call: `prepaid_gas x 1e9`, refunded a
|
|
21
|
+
* receipt later. It is ~350x the real burn, so a key funded by expected cost
|
|
22
|
+
* alone fails every call with NotEnoughBalance.
|
|
23
|
+
*/
|
|
24
|
+
export declare const gasReservation: (tgas: number) => bigint;
|
|
25
|
+
/** The real cost of one `request_payment`, rounded up from measurement. */
|
|
26
|
+
export declare const PAYMENT_BURN: bigint;
|
|
27
|
+
/** The vault's MIN_GAS_FOR_DISPATCH. Below it a call is refused before anything happens. */
|
|
28
|
+
export declare const MIN_TGAS = 50;
|
|
29
|
+
/**
|
|
30
|
+
* What to ADD so the key can make `payments` MORE calls.
|
|
31
|
+
*
|
|
32
|
+
* The reservation is charged once for the key's life, not once per top-up, so
|
|
33
|
+
* this is NOT reservation + burn x payments — that would overcharge by a full
|
|
34
|
+
* reservation every time. A depleted key (below the reservation, unable to
|
|
35
|
+
* transact at all) is lifted back over that line first.
|
|
36
|
+
*/
|
|
37
|
+
export declare function topUpFor({ balance, tgas, payments, }?: {
|
|
38
|
+
balance?: bigint;
|
|
39
|
+
tgas?: number;
|
|
40
|
+
payments?: number;
|
|
41
|
+
}): bigint;
|
|
42
|
+
/**
|
|
43
|
+
* The inverse: TOTAL payments possible once `amount` is added.
|
|
44
|
+
*
|
|
45
|
+
* ZERO IS A REAL ANSWER. A key that still cannot cover one reservation makes
|
|
46
|
+
* NO calls, not "a few" — every call fails NotEnoughBalance, which reads as a
|
|
47
|
+
* broken agent rather than an underfunded one.
|
|
48
|
+
*/
|
|
49
|
+
export declare function paymentsAfterTopUp({ balance, tgas, amount, }?: {
|
|
50
|
+
balance?: bigint;
|
|
51
|
+
tgas?: number;
|
|
52
|
+
amount?: bigint;
|
|
53
|
+
}): number;
|
package/dist/gas-math.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gas-key arithmetic, mirrored from `create-float`'s `bin/gas-key.mjs`.
|
|
3
|
+
*
|
|
4
|
+
* WHY A MIRROR AND NOT AN IMPORT. `@floatonnear/create-float` does not depend
|
|
5
|
+
* on `@floatonnear/mcp` and vice versa — they are siblings, published
|
|
6
|
+
* separately, and installed independently by users. Adding a dependency in
|
|
7
|
+
* either direction to share four lines of arithmetic would couple two release
|
|
8
|
+
* cycles, and this repo has already lost an afternoon to a workspace range
|
|
9
|
+
* that silently resolved to a PUBLISHED sibling instead of the local one.
|
|
10
|
+
*
|
|
11
|
+
* So it is duplicated DELIBERATELY, following the pattern already used for
|
|
12
|
+
* `shared/contract-constants.json`: mirror the values, then make drift fail a
|
|
13
|
+
* check. `scripts/check-gas-math.mjs` compares these constants against
|
|
14
|
+
* create-float's and fails the gate if they diverge. A silent divergence here
|
|
15
|
+
* would quote a user one number and charge them another.
|
|
16
|
+
*
|
|
17
|
+
* Every figure is a measurement; see gas-key.mjs for the provenance.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* What NEAR holds for one in-flight call: `prepaid_gas x 1e9`, refunded a
|
|
21
|
+
* receipt later. It is ~350x the real burn, so a key funded by expected cost
|
|
22
|
+
* alone fails every call with NotEnoughBalance.
|
|
23
|
+
*/
|
|
24
|
+
export const gasReservation = (tgas) => BigInt(tgas) * 10n ** 12n * 10n ** 9n;
|
|
25
|
+
/** The real cost of one `request_payment`, rounded up from measurement. */
|
|
26
|
+
export const PAYMENT_BURN = 12n * 10n ** 20n;
|
|
27
|
+
/** The vault's MIN_GAS_FOR_DISPATCH. Below it a call is refused before anything happens. */
|
|
28
|
+
export const MIN_TGAS = 50;
|
|
29
|
+
/**
|
|
30
|
+
* What to ADD so the key can make `payments` MORE calls.
|
|
31
|
+
*
|
|
32
|
+
* The reservation is charged once for the key's life, not once per top-up, so
|
|
33
|
+
* this is NOT reservation + burn x payments — that would overcharge by a full
|
|
34
|
+
* reservation every time. A depleted key (below the reservation, unable to
|
|
35
|
+
* transact at all) is lifted back over that line first.
|
|
36
|
+
*/
|
|
37
|
+
export function topUpFor({ balance = 0n, tgas = 60, payments = 50, } = {}) {
|
|
38
|
+
if (tgas < MIN_TGAS)
|
|
39
|
+
throw new Error(`tgas must be at least ${MIN_TGAS}`);
|
|
40
|
+
if (payments < 0)
|
|
41
|
+
throw new Error("payments cannot be negative");
|
|
42
|
+
const have = BigInt(balance);
|
|
43
|
+
const reservation = gasReservation(tgas);
|
|
44
|
+
const shortfall = have >= reservation ? 0n : reservation - have;
|
|
45
|
+
return shortfall + PAYMENT_BURN * BigInt(payments);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The inverse: TOTAL payments possible once `amount` is added.
|
|
49
|
+
*
|
|
50
|
+
* ZERO IS A REAL ANSWER. A key that still cannot cover one reservation makes
|
|
51
|
+
* NO calls, not "a few" — every call fails NotEnoughBalance, which reads as a
|
|
52
|
+
* broken agent rather than an underfunded one.
|
|
53
|
+
*/
|
|
54
|
+
export function paymentsAfterTopUp({ balance = 0n, tgas = 60, amount = 0n, } = {}) {
|
|
55
|
+
if (tgas < MIN_TGAS)
|
|
56
|
+
throw new Error(`tgas must be at least ${MIN_TGAS}`);
|
|
57
|
+
const spendable = BigInt(balance) + BigInt(amount) - gasReservation(tgas);
|
|
58
|
+
if (spendable <= 0n)
|
|
59
|
+
return 0;
|
|
60
|
+
return Number(spendable / PAYMENT_BURN);
|
|
61
|
+
}
|
package/dist/key-file.js
CHANGED
|
@@ -150,7 +150,65 @@ function fromJson(text, path) {
|
|
|
150
150
|
throw new KeyFileError(`key file ${path} is not valid JSON. Expected the ~/.near-credentials shape ` +
|
|
151
151
|
`${CRED_SHAPE}, or a bare key on one line.`);
|
|
152
152
|
}
|
|
153
|
-
|
|
153
|
+
/**
|
|
154
|
+
* THE FLEET KEYRING IS A LEGITIMATE SHAPE, and accepting it is what lets the
|
|
155
|
+
* provisioning path stop writing a second copy of the secret.
|
|
156
|
+
*
|
|
157
|
+
* `.float/agent-keys.json` is an ARRAY of
|
|
158
|
+
* `{kind, vault, node, publicKey, privateKey}` — the durable record of every
|
|
159
|
+
* agent, written by create-float and by vault_create. Before this, pointing
|
|
160
|
+
* NEAR_VAULT_AGENT_KEY_FILE at it was refused as "the wrong shape", so the
|
|
161
|
+
* only way to configure an agent was the INLINE form: the secret pasted into
|
|
162
|
+
* .mcp.json and .float/adapter.env, where reading a config to answer "which
|
|
163
|
+
* vault is this?" prints a live private key into scrollback and into the
|
|
164
|
+
* driving agent's transcript. That exposure is realised, not theoretical.
|
|
165
|
+
*
|
|
166
|
+
* The alternative was writing a THIRD file in the credentials shape, which
|
|
167
|
+
* would duplicate the secret into one more place — more exposure, not less.
|
|
168
|
+
*
|
|
169
|
+
* AMBIGUITY IS REFUSED, NOT GUESSED. A keyring with several agents has no
|
|
170
|
+
* single answer to "which key", and picking the first would sign as an agent
|
|
171
|
+
* the operator did not choose — from the wrong node, against the wrong
|
|
172
|
+
* budget. They are told to name one with NEAR_VAULT_AGENT_ID instead.
|
|
173
|
+
*/
|
|
174
|
+
if (Array.isArray(parsed)) {
|
|
175
|
+
/**
|
|
176
|
+
* TWO FIELD SPELLINGS EXIST, and assuming one cost a round of this change.
|
|
177
|
+
*
|
|
178
|
+
* `loadKeyring` documents the canonical entry as
|
|
179
|
+
* `{ nodeId, agentId, secret }` and `generateAgentKey` writes exactly
|
|
180
|
+
* that. But a keyring on this machine, written by an older path, uses
|
|
181
|
+
* `{ kind, vault, node, publicKey, privateKey }`. Both are real files an
|
|
182
|
+
* operator may point this at, so both are read — and a refusal names the
|
|
183
|
+
* keys it DID find, because "wrong shape" without the field names sends
|
|
184
|
+
* someone to re-read a file they already have open.
|
|
185
|
+
*/
|
|
186
|
+
const entries = parsed.filter((e) => e !== null && typeof e === "object");
|
|
187
|
+
const secretOf = (e) => typeof e.secret === "string" && e.secret
|
|
188
|
+
? e.secret
|
|
189
|
+
: typeof e.privateKey === "string" && e.privateKey
|
|
190
|
+
? e.privateKey
|
|
191
|
+
: "";
|
|
192
|
+
const idOf = (e) => typeof e.agentId === "string" ? e.agentId : typeof e.vault === "string" ? e.vault : undefined;
|
|
193
|
+
const withKeys = entries.filter((e) => secretOf(e) !== "");
|
|
194
|
+
if (withKeys.length === 0) {
|
|
195
|
+
const found = [...new Set(entries.flatMap((e) => Object.keys(e)))].join(", ") || "none";
|
|
196
|
+
throw new KeyFileError(`key file ${path} is a JSON array but no entry carries a key. Expected a fleet ` +
|
|
197
|
+
`keyring entry with \`secret\` (or \`privateKey\`); the fields present were: ${found}.`);
|
|
198
|
+
}
|
|
199
|
+
if (withKeys.length > 1) {
|
|
200
|
+
// COUNT AND LABELS ONLY, never a key. The labels are the whole fix.
|
|
201
|
+
const names = withKeys
|
|
202
|
+
.map((e) => `${idOf(e) ?? "?"}#${String(e.nodeId ?? e.node ?? "?")}`)
|
|
203
|
+
.join(", ");
|
|
204
|
+
throw new KeyFileError(`key file ${path} holds ${withKeys.length} agent keys (${names}), so which one to ` +
|
|
205
|
+
`sign as is ambiguous. Set NEAR_VAULT_AGENT_ID to name one, or point ` +
|
|
206
|
+
`NEAR_VAULT_AGENT_KEY_FILE at a single-key file.`);
|
|
207
|
+
}
|
|
208
|
+
const only = withKeys[0];
|
|
209
|
+
return { secret: secretOf(only).trim(), accountId: idOf(only) };
|
|
210
|
+
}
|
|
211
|
+
if (parsed === null || typeof parsed !== "object") {
|
|
154
212
|
throw new KeyFileError(`key file ${path} is JSON but not an object. Expected the ~/.near-credentials ` +
|
|
155
213
|
`shape ${CRED_SHAPE}.`);
|
|
156
214
|
}
|
package/dist/server.d.ts
CHANGED
|
@@ -38,7 +38,8 @@
|
|
|
38
38
|
* exists on purpose — guessing it wrong would
|
|
39
39
|
* send a real deposit somewhere unverified.
|
|
40
40
|
* NEAR_VAULT_APP_URL only needed to CREATE, default
|
|
41
|
-
* https://
|
|
41
|
+
* https://usefloat.xyz (the APEX — `app.` is the
|
|
42
|
+
* dashboard and has no create flow)
|
|
42
43
|
*
|
|
43
44
|
* A user with NO vault can therefore run this server with nothing configured,
|
|
44
45
|
* call `vault_create`, and be onboarded — which is the point. Every other tool
|
package/dist/server.js
CHANGED
|
@@ -38,13 +38,15 @@
|
|
|
38
38
|
* exists on purpose — guessing it wrong would
|
|
39
39
|
* send a real deposit somewhere unverified.
|
|
40
40
|
* NEAR_VAULT_APP_URL only needed to CREATE, default
|
|
41
|
-
* https://
|
|
41
|
+
* https://usefloat.xyz (the APEX — `app.` is the
|
|
42
|
+
* dashboard and has no create flow)
|
|
42
43
|
*
|
|
43
44
|
* A user with NO vault can therefore run this server with nothing configured,
|
|
44
45
|
* call `vault_create`, and be onboarded — which is the point. Every other tool
|
|
45
46
|
* reports that NEAR_VAULT_CONTRACT is unset rather than guessing one.
|
|
46
47
|
*/
|
|
47
48
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
49
|
+
import { topUpFor, paymentsAfterTopUp } from "./gas-math.js";
|
|
48
50
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
49
51
|
import { z } from "zod";
|
|
50
52
|
import { fromBaseUnits, toBaseUnits } from "./amounts.js";
|
|
@@ -128,7 +130,23 @@ const cfg = {
|
|
|
128
130
|
// Onboarding only. Both have safe defaults and neither is needed by any
|
|
129
131
|
// tool that touches an existing vault.
|
|
130
132
|
factory: process.env.NEAR_VAULT_FACTORY,
|
|
131
|
-
|
|
133
|
+
// The APEX, where the create page actually lives — one hop instead of two.
|
|
134
|
+
//
|
|
135
|
+
// `app.` ALSO WORKS AND IS NOT A BUG. It serves the Next.js dashboard, but
|
|
136
|
+
// nginx bounces anything carrying `?factory=` to the apex signing page, and
|
|
137
|
+
// `buildCreatePlan` sets `factory` on every link unconditionally. That rule
|
|
138
|
+
// exists because the published @floatonnear/mcp 0.5.0 hardcodes `app.`, and
|
|
139
|
+
// breaking it would break vault creation for every existing install.
|
|
140
|
+
// Verified 2026-11-10: `app.usefloat.xyz/?factory=…` → 200 at
|
|
141
|
+
// `usefloat.xyz`, "Create your Float vault".
|
|
142
|
+
//
|
|
143
|
+
// So this is a small simplification, NOT a repair — an earlier version of
|
|
144
|
+
// this comment claimed users were landing on the dashboard, which came from
|
|
145
|
+
// probing the bare URL that nothing emits.
|
|
146
|
+
//
|
|
147
|
+
// Guarded by `scripts/check-create-page.mjs`, which fetches the real link
|
|
148
|
+
// shape, follows redirects, and checks the wasm at the landing origin.
|
|
149
|
+
appUrl: process.env.NEAR_VAULT_APP_URL ?? "https://usefloat.xyz",
|
|
132
150
|
// The fleet keyring. Same default and same file the adapter reads — two
|
|
133
151
|
// readers disagreeing about where credentials live is how an agent ends up
|
|
134
152
|
// "configured" and unreachable.
|
|
@@ -218,7 +236,7 @@ WORKFLOW
|
|
|
218
236
|
2. Look before acting: vault_tree shows the budget shape, vault_budget what is left today.
|
|
219
237
|
3. Pay with vault_pay. Check the result: a refusal is a recorded outcome, not an exception.
|
|
220
238
|
4. Change policy with a vault_plan_* tool, then hand the payload to the user to sign.
|
|
221
|
-
5. Winding down is a CLI job, not a tool: npx @floatonnear/float-exit. It returns the tokens,
|
|
239
|
+
5. Winding down is a CLI job, not a tool: npx -p @floatonnear/create-float float-exit. It returns the tokens,
|
|
222
240
|
the gas-key balances and the storage deposit. vault_teardown_report says what is recoverable.
|
|
223
241
|
|
|
224
242
|
REFUSALS ARE ANSWERS. Report the reason code, do not retry blindly:
|
|
@@ -233,8 +251,100 @@ WORTH KNOWING
|
|
|
233
251
|
An implicit account (a 64-character hex id) cannot own a vault at all. Tell the user to use a
|
|
234
252
|
named account rather than retrying.
|
|
235
253
|
Amounts are whole tokens in these tools (1.5 means 1.5 USDC), never base units.`;
|
|
236
|
-
const server = new McpServer({ name: "float", version: "0.6.
|
|
254
|
+
const server = new McpServer({ name: "float", version: "0.6.2" }, { instructions: INSTRUCTIONS });
|
|
237
255
|
// ----------------------------------------------------------------- reads
|
|
256
|
+
/**
|
|
257
|
+
* Wait for a create to land, and prove the result belongs to the user.
|
|
258
|
+
*
|
|
259
|
+
* WHY A TOOL AND NOT A SENTENCE IN THE NOTE. `vault_create` knows the derived
|
|
260
|
+
* address before anything is signed, so "did it work?" is answerable from the
|
|
261
|
+
* chain — yet the flow used to end at "approve it in your wallet", leaving the
|
|
262
|
+
* human to read an account id back to an agent that already had it. That was
|
|
263
|
+
* the clunkiest step in onboarding and it was a documentation gap, not a
|
|
264
|
+
* technical limit.
|
|
265
|
+
*
|
|
266
|
+
* EXISTENCE IS NOT OWNERSHIP, and conflating them is the actual hazard here.
|
|
267
|
+
* `derive_prefix` is not injective — `a.b.near` and `a-b.near` land on the same
|
|
268
|
+
* id — so an account AT the derived address may belong to whoever created
|
|
269
|
+
* first. Reporting "your vault is live" on existence alone would point a user's
|
|
270
|
+
* agent at a stranger's money. So this reads `get_config` back and compares
|
|
271
|
+
* `owner`, and reports THREE outcomes, never two: yours, someone else's, or
|
|
272
|
+
* could-not-read. "I could not check" is not "it is yours".
|
|
273
|
+
*
|
|
274
|
+
* Polls rather than asking once, because a `final`-finality read immediately
|
|
275
|
+
* after a write can be a whole transaction behind.
|
|
276
|
+
*/
|
|
277
|
+
server.tool("vault_await_create", "After the user signs the create link, confirm the vault is live and VERIFY IT IS THEIRS. Reads the chain, so it needs no key. Call this instead of asking the user to paste their vault id back — vault_create already told you the address.", {
|
|
278
|
+
vault: z.string().describe("the derived vault id, from vault_create's `confirm.vault`"),
|
|
279
|
+
owner: z.string().describe("who it must belong to — compared against the vault's own get_config.owner"),
|
|
280
|
+
network: z.enum(["mainnet", "testnet"]).optional(),
|
|
281
|
+
timeoutSeconds: z
|
|
282
|
+
.number()
|
|
283
|
+
.int()
|
|
284
|
+
.min(1)
|
|
285
|
+
.max(300)
|
|
286
|
+
.optional()
|
|
287
|
+
.describe("how long to wait for the account to appear, default 90"),
|
|
288
|
+
}, async ({ vault, owner, network, timeoutSeconds }) => {
|
|
289
|
+
const net = network ?? cfg.network;
|
|
290
|
+
const rpc = rpcForNetwork(net, {
|
|
291
|
+
configuredNetwork: cfg.network,
|
|
292
|
+
override: process.env.NEAR_VAULT_RPC_URL,
|
|
293
|
+
});
|
|
294
|
+
const deadline = Date.now() + (timeoutSeconds ?? 90) * 1000;
|
|
295
|
+
let appeared = false;
|
|
296
|
+
for (;;) {
|
|
297
|
+
if (await accountExists(rpc, vault).catch(() => false)) {
|
|
298
|
+
appeared = true;
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
if (Date.now() >= deadline)
|
|
302
|
+
break;
|
|
303
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
304
|
+
}
|
|
305
|
+
if (!appeared) {
|
|
306
|
+
return json({
|
|
307
|
+
status: "NOT_YET",
|
|
308
|
+
vault,
|
|
309
|
+
instruction: `${vault} does not exist yet. That is not a failure — the user may not have ` +
|
|
310
|
+
`signed, or may have cancelled. Ask whether they approved it, and call this ` +
|
|
311
|
+
`again. Do NOT tell them the vault is created.`,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
// THE OWNER READ IS THE POINT. Getting here only proves an account exists.
|
|
315
|
+
const config = await viewFunction(rpc, vault, "get_config", {}).catch(() => null);
|
|
316
|
+
if (!config || typeof config.owner !== "string") {
|
|
317
|
+
return json({
|
|
318
|
+
status: "UNVERIFIED",
|
|
319
|
+
vault,
|
|
320
|
+
instruction: `${vault} exists but its get_config could not be read, so whether it is ${owner}'s ` +
|
|
321
|
+
`is UNKNOWN — not assumed. It may not be a Float vault at all. Do not point an ` +
|
|
322
|
+
`agent at it until this is resolved: near view ${vault} get_config`,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
if (config.owner !== owner) {
|
|
326
|
+
return json({
|
|
327
|
+
status: "NOT_YOURS",
|
|
328
|
+
vault,
|
|
329
|
+
actualOwner: config.owner,
|
|
330
|
+
instruction: `${vault} exists but is owned by ${config.owner}, NOT ${owner}. The vault id ` +
|
|
331
|
+
`derivation is not injective, so a different account can hold this address — ` +
|
|
332
|
+
`whoever created it first. Do NOT configure anything against it. Create one ` +
|
|
333
|
+
`under a label instead, which lands on a different address.`,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
return json({
|
|
337
|
+
status: "LIVE",
|
|
338
|
+
vault,
|
|
339
|
+
owner: config.owner,
|
|
340
|
+
config,
|
|
341
|
+
nextSteps: [
|
|
342
|
+
`Set NEAR_VAULT_CONTRACT=${vault} so every other tool reads this vault.`,
|
|
343
|
+
`vault_tree shows the budget; vault_budget shows what is left today.`,
|
|
344
|
+
`Nothing can spend from it until an agent is bound with a budget — vault_add_role.`,
|
|
345
|
+
],
|
|
346
|
+
});
|
|
347
|
+
});
|
|
238
348
|
server.tool("vault_tree", "Read the whole budget tree: every node, its caps, parent, bound agent account, cooldown and freeze state. No key required.", {}, async () => json(await viewFunction(cfg.rpcUrl, requireVault(), "get_tree", {})));
|
|
239
349
|
server.tool("vault_budget", "Read one node's budget for a UTC day: cap, executed, in-flight (pending) and available, in human units. Omit `day` for today. No key required.", {
|
|
240
350
|
nodeId: z.number().int().min(0),
|
|
@@ -341,7 +451,30 @@ server.tool("vault_create", "For a user who has NO vault yet. Works out where th
|
|
|
341
451
|
.boolean()
|
|
342
452
|
.optional()
|
|
343
453
|
.describe("generate an agent key and bind it in the SAME transaction, so one signature creates the vault and authorises you. Adds 0.12 NEAR of gas-key funding to the deposit. The user is shown the public key and must confirm it before signing. Requires `owner`, because the credential is filed under the vault id and that cannot be derived without one."),
|
|
344
|
-
|
|
454
|
+
daily: z
|
|
455
|
+
.string()
|
|
456
|
+
.optional()
|
|
457
|
+
.describe("ASK THE USER. The root budget per UTC day, in HUMAN units of the token, e.g. \"100\" or \"2.04\" — not base units. Defaults on the page to 2.04 (mainnet) / 100 (testnet). This is the number the vault enforces on chain, so it is worth asking rather than defaulting."),
|
|
458
|
+
perTx: z
|
|
459
|
+
.string()
|
|
460
|
+
.optional()
|
|
461
|
+
.describe("ASK THE USER. Maximum per single payment, HUMAN units. Must be <= daily or it can never bind. Page defaults: 0.51 (mainnet) / 10 (testnet)."),
|
|
462
|
+
token: z
|
|
463
|
+
.string()
|
|
464
|
+
.optional()
|
|
465
|
+
.describe("the NEP-141 token the caps are denominated in. Defaults per network (USDC on mainnet). Caps are keyed PER TOKEN, so a wrong id silently gives the agent no budget for the token it actually pays in."),
|
|
466
|
+
decimals: z
|
|
467
|
+
.number()
|
|
468
|
+
.int()
|
|
469
|
+
.min(0)
|
|
470
|
+
.max(24)
|
|
471
|
+
.optional()
|
|
472
|
+
.describe("the token's decimals, default 6. Wrong here scales every cap by a power of ten."),
|
|
473
|
+
label: z
|
|
474
|
+
.string()
|
|
475
|
+
.optional()
|
|
476
|
+
.describe("optional vault label, 1-16 chars of a-z0-9. IT BECOMES PART OF THE ADDRESS (<prefix>-<label>.<factory>), so pass it only if the user wants a second vault or a specific name."),
|
|
477
|
+
}, async ({ owner, network, factory, bindAgent, daily, perTx, token, decimals, label }) => {
|
|
345
478
|
const net = network ?? cfg.network;
|
|
346
479
|
const fac = factory ?? cfg.factory ?? FACTORY_BY_NETWORK[net];
|
|
347
480
|
if (!fac) {
|
|
@@ -423,6 +556,15 @@ server.tool("vault_create", "For a user who has NO vault yet. Works out where th
|
|
|
423
556
|
exists,
|
|
424
557
|
existingOwner,
|
|
425
558
|
agentPk: generated?.publicKey ?? null,
|
|
559
|
+
// The interview answers, forwarded verbatim. Undefined stays undefined
|
|
560
|
+
// so the PAGE's own defaults apply — substituting a default here would
|
|
561
|
+
// mean two places deciding the same number, which is how the deposit
|
|
562
|
+
// quote came to disagree with the page by 0.26 NEAR.
|
|
563
|
+
daily,
|
|
564
|
+
perTx,
|
|
565
|
+
token,
|
|
566
|
+
decimals,
|
|
567
|
+
label,
|
|
426
568
|
});
|
|
427
569
|
return json({
|
|
428
570
|
...plan,
|
|
@@ -621,6 +763,99 @@ server.tool("vault_plan_withdraw", "Build an UNSIGNED payload to move tokens OUT
|
|
|
621
763
|
}
|
|
622
764
|
return renderPlan(planWithdraw(vault, tok, base, receiverId, cfg.decimals));
|
|
623
765
|
});
|
|
766
|
+
/**
|
|
767
|
+
* WHY A QUOTE AND NOT A PLAN, which every other owner action here is.
|
|
768
|
+
*
|
|
769
|
+
* The rest of `vault_plan_*` returns an UNSIGNED payload the user approves in
|
|
770
|
+
* their own wallet. A top-up cannot work that way: `transferToGasKey` is an
|
|
771
|
+
* action on the VAULT account, so it needs a full-access key OF the vault —
|
|
772
|
+
* and wallets IGNORE `signerId`, silently signing as the connected account and
|
|
773
|
+
* returning SUCCESS while doing the wrong thing. Measured 2026-11-07 across
|
|
774
|
+
* Meteor, Intear and the CLI connector alike.
|
|
775
|
+
*
|
|
776
|
+
* So this takes the shape `vault_teardown_report` already uses: read the
|
|
777
|
+
* chain, do the arithmetic, hand over the exact command. The half an agent CAN
|
|
778
|
+
* serve is the part a human cannot do in their head — which agent is low, what
|
|
779
|
+
* a given amount buys, what a given payment count costs.
|
|
780
|
+
*/
|
|
781
|
+
server.tool("vault_gas", "Read-only. How much gas each agent has left, and what topping one up would cost or buy. Needs no key and signs nothing. Use for 'is my agent about to run out?' and 'top up the marketing agent' — it returns the exact command, because a top-up must be signed BY THE VAULT and a browser wallet cannot do that.", {
|
|
782
|
+
agent: z
|
|
783
|
+
.string()
|
|
784
|
+
.optional()
|
|
785
|
+
.describe("a gas key's public key, or a node label like 'marketing'. Omit to list every agent."),
|
|
786
|
+
payments: z.number().int().positive().optional().describe("quote the cost of this many MORE payments"),
|
|
787
|
+
amountNear: z.string().optional().describe("or quote how many payments this much NEAR would buy"),
|
|
788
|
+
tgas: z.number().int().optional().describe("gas per call (default 60, floor 50)"),
|
|
789
|
+
}, async ({ agent, payments, amountNear, tgas }) => {
|
|
790
|
+
const vault = requireVault();
|
|
791
|
+
const TG = tgas ?? 60;
|
|
792
|
+
const keyList = await viewAccessKeys(cfg.rpcUrl, vault);
|
|
793
|
+
const tree = await viewFunction(cfg.rpcUrl, vault, "get_tree", {}).catch(() => []);
|
|
794
|
+
// KEY -> NODE -> LABEL, so "top up marketing" can find the right key. The
|
|
795
|
+
// vault identifies an agent BY ITS PUBLIC KEY (`pubkey_to_node`), which is
|
|
796
|
+
// why each agent has its own: one shared key would be one node, and the
|
|
797
|
+
// per-agent budgets would collapse into a single shared one.
|
|
798
|
+
const rows = [];
|
|
799
|
+
for (const k of keyList) {
|
|
800
|
+
const gk = k.access_key?.permission
|
|
801
|
+
?.GasKeyFunctionCall;
|
|
802
|
+
if (!gk)
|
|
803
|
+
continue;
|
|
804
|
+
const node = await viewFunction(cfg.rpcUrl, vault, "get_virtual_agent_node", {
|
|
805
|
+
public_key: k.public_key,
|
|
806
|
+
}).catch(() => null);
|
|
807
|
+
rows.push({
|
|
808
|
+
publicKey: k.public_key,
|
|
809
|
+
nodeId: node,
|
|
810
|
+
label: tree.find((t) => t.id === node)?.label ?? (node === 0 ? "root" : null),
|
|
811
|
+
balanceNear: fromBaseUnits(BigInt(gk.balance), 24),
|
|
812
|
+
paymentsLeft: paymentsAfterTopUp({ balance: BigInt(gk.balance), tgas: TG, amount: 0n }),
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
if (rows.length === 0) {
|
|
816
|
+
return json({
|
|
817
|
+
vault,
|
|
818
|
+
agents: [],
|
|
819
|
+
note: "No gas keys on this vault, so no agent can transact. Add one with: npx -p @floatonnear/create-float float-agent",
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
const picked = agent ? rows.find((r) => r.publicKey === agent || r.label === agent) : undefined;
|
|
823
|
+
if (agent && !picked) {
|
|
824
|
+
// NOT a silent fallback to the first agent. Quoting against the wrong
|
|
825
|
+
// key prices a top-up for an agent the user did not name.
|
|
826
|
+
return json({
|
|
827
|
+
vault,
|
|
828
|
+
error: "NO_SUCH_AGENT",
|
|
829
|
+
asked: agent,
|
|
830
|
+
agents: rows,
|
|
831
|
+
note: "No gas key matches that public key or node label.",
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
if (!picked)
|
|
835
|
+
return json({ vault, tgas: TG, agents: rows });
|
|
836
|
+
const bal = BigInt(keyList.find((k) => k.public_key === picked.publicKey).access_key
|
|
837
|
+
.permission.GasKeyFunctionCall.balance);
|
|
838
|
+
const add = amountNear
|
|
839
|
+
? toBaseUnits(amountNear, 24)
|
|
840
|
+
: topUpFor({ balance: bal, tgas: TG, payments: payments ?? 50 });
|
|
841
|
+
const after = paymentsAfterTopUp({ balance: bal, tgas: TG, amount: add });
|
|
842
|
+
return json({
|
|
843
|
+
vault,
|
|
844
|
+
agent: picked,
|
|
845
|
+
quote: {
|
|
846
|
+
addNear: fromBaseUnits(add, 24),
|
|
847
|
+
paymentsBefore: picked.paymentsLeft,
|
|
848
|
+
paymentsAfter: after,
|
|
849
|
+
gained: after - picked.paymentsLeft,
|
|
850
|
+
},
|
|
851
|
+
command: `npx -p @floatonnear/create-float float-topup --vault ${vault} --owner <YOUR-OWNER-ACCOUNT> ` +
|
|
852
|
+
`--agent ${picked.publicKey} ` +
|
|
853
|
+
`${amountNear ? `--amount ${amountNear}` : `--payments ${payments ?? 50}`} ` +
|
|
854
|
+
`--network ${cfg.network}`,
|
|
855
|
+
note: "A top-up is signed BY THE VAULT, so a browser wallet cannot do it — wallets ignore " +
|
|
856
|
+
"signerId and sign as the connected account. Run the command with the owner's key on disk.",
|
|
857
|
+
});
|
|
858
|
+
});
|
|
624
859
|
server.tool("vault_teardown_report", "Read-only. Report exactly what closing this vault would return, what it would strand, and the command that does it. Needs no key and signs nothing. Use this to answer 'can I close this vault and what do I get back?' before anyone exports anything.", {}, async () => {
|
|
625
860
|
const vault = requireVault();
|
|
626
861
|
const tok = cfg.token;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@floatonnear/mcp",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
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",
|