@floatonnear/mcp 0.6.1 → 0.6.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/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. Contains a PRIVATE KEY — do not commit or paste.`,
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
- `NEAR_VAULT_AGENT_KEY=${secret}`,
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 = 298920;
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 25/10 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 = 298920;
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 "25", 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: known ? i.vaultId : null,
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;
@@ -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
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
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://app.usefloat.xyz
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://app.usefloat.xyz
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
- appUrl: process.env.NEAR_VAULT_APP_URL ?? "https://app.usefloat.xyz",
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.1" }, { instructions: INSTRUCTIONS });
254
+ const server = new McpServer({ name: "float", version: "0.6.3" }, { 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
- }, async ({ owner, network, factory, bindAgent }) => {
454
+ daily: z
455
+ .string()
456
+ .optional()
457
+ .describe("The root budget per UTC day, in HUMAN units of the token, e.g. \"25\" or \"100\" — not base units. PROPOSE a figure rather than interrogating the user; caps are changeable later with a signed vault_plan_set_caps. Page default when omitted: 25 (mainnet) / 100 (testnet)."),
458
+ perTx: z
459
+ .string()
460
+ .optional()
461
+ .describe("Maximum per single payment, HUMAN units. Must be <= daily or it can never bind, and must EXCEED the largest payment intended — cross-chain is EXACT_OUTPUT, so the vault is charged more than the payee receives and a cap of 5 refuses a round 5.00. Page default when omitted: 10 (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.1",
3
+ "version": "0.6.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",