@floatonnear/mcp 0.10.1 → 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/dist/server.js CHANGED
@@ -56,13 +56,14 @@ import { z } from "zod";
56
56
  import { fromBaseUnits, humaniseTreeNode, toBaseUnits } from "./amounts.js";
57
57
  import { DEFAULT_GAS_TGAS, planAddNode, planAddRole, planBindVirtualAgent, planUnbindVirtualAgent, planFreezeNode, planPurgeSettled, planSetCooldown, planSetNodeCaps, planSetPaused, planSetRecipients, planSetStrictAllocation, toCliCommand, planSetApprovalThreshold, planApprovePayment, planRejectPayment, planWithdraw, } from "./payloads.js";
58
58
  import { accountExists, rpcForNetwork, RPC_BY_NETWORK, newRequestId, requireAgentKey, viewAccessKeys, viewAccount, viewFunction, } from "./rpc.js";
59
- import { AGENT_NODE_ID, generateAgentKey } from "./agent-key.js";
59
+ import { AGENT_NODE_ID, generateAgentKey, retireRole } from "./agent-key.js";
60
60
  import { resolveKeySource } from "./key-file.js";
61
61
  import { dirname, resolve } from "node:path";
62
62
  import { existsSync, readFileSync } from "node:fs";
63
63
  import { fileURLToPath } from "node:url";
64
- import { listAgents, listRoles, selectRole } from "./roles.js";
65
- import { label as tokenLabel, tokenMeta } from "./token-meta.js";
64
+ import { listAgents, listUnloadable, selectRole } from "./roles.js";
65
+ import { resolveKeyringPath } from "./keyring-path.js";
66
+ import { displaySymbol, label as tokenLabel, tokenMeta } from "./token-meta.js";
66
67
  /**
67
68
  * A decimal NEAR string to yoctoNEAR, without floating point.
68
69
  *
@@ -77,7 +78,7 @@ function toYoctoNear(n) {
77
78
  }
78
79
  import { buildCreatePlan, FACTORY_BY_NETWORK } from "./create.js";
79
80
  import { buildRoute, sendRequestPayment } from "./sign.js";
80
- import { explainOutcome } from "./explain.js";
81
+ import { explainOutcome, planVoice } from "./explain.js";
81
82
  import { getQuote, signBinding, toBase64 } from "./quote.js";
82
83
  /** How long a quote stays valid. Long enough to sign and send, short
83
84
  * enough that a stale price is not honoured. */
@@ -157,6 +158,25 @@ if (!Object.hasOwn(RPC_BY_NETWORK, NETWORK)) {
157
158
  `questions about the wrong chain.`);
158
159
  process.exit(1);
159
160
  }
161
+ /**
162
+ * A syntactically valid ed25519 key used ONLY to dry-run payload construction.
163
+ *
164
+ * `planAddRole` needs a public key to build args, and we want its exceptions
165
+ * BEFORE generating a real one. All zeroes in base58 is "1" repeated, which
166
+ * parses as a key and is obviously not anyone's.
167
+ */
168
+ /**
169
+ * Resolved ONCE, at startup, from the environment — never recomputed, so the
170
+ * path cannot change mid-session if something alters the working directory.
171
+ *
172
+ * The vault id is read straight from the env rather than from `cfg`, because
173
+ * `cfg` is what this feeds.
174
+ */
175
+ const KEYRING = resolveKeyringPath({
176
+ explicit: process.env.NEAR_VAULT_AGENT_KEYS,
177
+ vault: process.env.NEAR_VAULT_CONTRACT,
178
+ });
179
+ const PLACEHOLDER_PUBLIC_KEY = `ed25519:${"1".repeat(44)}`;
160
180
  const cfg = {
161
181
  vault: process.env.NEAR_VAULT_CONTRACT ?? "",
162
182
  /**
@@ -220,10 +240,17 @@ const cfg = {
220
240
  // Guarded by `scripts/check-create-page.mjs`, which fetches the real link
221
241
  // shape, follows redirects, and checks the wasm at the landing origin.
222
242
  appUrl: process.env.NEAR_VAULT_APP_URL ?? "https://usefloat.xyz",
223
- // The fleet keyring. Same default and same file the adapter reads — two
224
- // readers disagreeing about where credentials live is how an agent ends up
225
- // "configured" and unreachable.
226
- agentKeysPath: process.env.NEAR_VAULT_AGENT_KEYS ?? ".float/agent-keys.json",
243
+ // The fleet keyring. Same file the adapter reads — two readers disagreeing
244
+ // about where credentials live is how an agent ends up "configured" and
245
+ // unreachable.
246
+ //
247
+ // RESOLVED, NOT DEFAULTED. This was the bare relative string
248
+ // `.float/agent-keys.json`, which Node resolves against the server's CWD —
249
+ // i.e. wherever the CLIENT happened to be launched. One server entry in
250
+ // `~/.claude.json` therefore meant a DIFFERENT keyring per directory, and a
251
+ // key written from one project was invisible from another. See
252
+ // `keyring-path.ts` for the resolution rule and the measurement.
253
+ agentKeysPath: KEYRING.path,
227
254
  };
228
255
  /**
229
256
  * NO VAULT IS THE COMMONEST STATE, SO THE MESSAGE HAS TO BE AN ONRAMP.
@@ -269,12 +296,65 @@ function tokenFor(t) {
269
296
  }
270
297
  const text = (s) => ({ content: [{ type: "text", text: s }] });
271
298
  const json = (v) => text(JSON.stringify(v, null, 2));
272
- /** Payload + summary + warnings + a runnable command, so approving needs no trust in this server. */
273
- function renderPlan(p) {
299
+ /**
300
+ * Payload + summary + warnings + a runnable command, so approving needs no
301
+ * trust in this server — fronted by the plain-English fields.
302
+ *
303
+ * THE UX FIELDS COME FIRST, AND THEY ARE THE SAME THREE `vault_pay` RETURNS.
304
+ * This used to return five technical keys and no sentence, so agents described
305
+ * the mechanism — "returns an unsigned payload; the server does not sign; run
306
+ * `near contract call-function`…" — to someone who had asked to raise a limit.
307
+ * One shape across every tool means the instruction can be one sentence:
308
+ * relay `say`, add `next_step`, stop.
309
+ *
310
+ * `money_moved` is ALWAYS false here, and that is not a technicality worth
311
+ * hiding: a plan is a proposal. Nothing has moved, nothing has changed, and an
312
+ * agent that reports otherwise has lied about state the user is about to be
313
+ * asked to change themselves.
314
+ *
315
+ * `unsigned_payload` stays TOP-LEVEL rather than moving under `technical`:
316
+ * `scripts/check-mcp-live.mjs` asserts on it against the real server, and
317
+ * quietly renaming the key it reads would turn a live check green-by-absence.
318
+ */
319
+ async function renderPlan(p) {
320
+ const voice = planVoice(p);
321
+ /**
322
+ * SWAP TOKEN IDS FOR SYMBOLS IN THE SENTENCE ONLY.
323
+ *
324
+ * `summary` is built in `payloads.ts`, which is pure and has no network — so
325
+ * it prints the token's ACCOUNT ID. For mainnet USDC that is 64 hex
326
+ * characters, and `planVoice` reuses the summary verbatim, so the sentence
327
+ * read: 'Create budget node "research" as the ROOT with
328
+ * 17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1: 5/day'.
329
+ *
330
+ * Resolving here rather than in `payloads.ts` keeps that module pure and
331
+ * synchronously testable, which is worth more than saving this substitution.
332
+ * `technical.summary` is left EXACT — the id is the useful form there.
333
+ *
334
+ * Only the `say` is rewritten, and only for ids actually present, so a
335
+ * payload with no tokens costs nothing.
336
+ */
337
+ let say = voice.say;
338
+ const ids = [
339
+ ...new Set((Array.isArray(p.args.limits) ? p.args.limits : [])
340
+ .map((l) => l?.token)
341
+ .filter((t) => typeof t === "string" && say.includes(t))),
342
+ ];
343
+ if (ids.length > 0) {
344
+ const metas = await metaFor(ids);
345
+ for (const id of ids) {
346
+ const sym = metas.get(id)?.symbol;
347
+ // Substituted ONLY when a symbol actually resolved. Falling back to
348
+ // "tokens" here would erase which token a limit applies to, and a
349
+ // multi-token payload would become ambiguous.
350
+ if (sym)
351
+ say = say.split(id).join(sym);
352
+ }
353
+ }
274
354
  return json({
275
- action: p.methodName,
276
- summary: p.summary,
277
- warnings: p.warnings,
355
+ say,
356
+ next_step: voice.next,
357
+ money_moved: false,
278
358
  unsigned_payload: {
279
359
  receiver_id: p.receiverId,
280
360
  method_name: p.methodName,
@@ -282,6 +362,11 @@ function renderPlan(p) {
282
362
  gas_tgas: p.gasTgas,
283
363
  deposit_yocto: p.depositYocto,
284
364
  },
365
+ technical: {
366
+ action: p.methodName,
367
+ summary: p.summary,
368
+ warnings: p.warnings,
369
+ },
285
370
  approve_with: toCliCommand(p, cfg.network),
286
371
  note: "This server did NOT sign this. Review the summary, then run the command " +
287
372
  "or approve the payload in your wallet.",
@@ -381,7 +466,7 @@ WORTH KNOWING
381
466
  An implicit account (a 64-character hex id) cannot own a vault at all. Tell the user to use a
382
467
  named account rather than retrying.
383
468
  Amounts are whole tokens in these tools (1.5 means 1.5 USDC), never base units.`;
384
- const server = new McpServer({ name: "float", version: "0.10.0" }, { instructions: INSTRUCTIONS });
469
+ const server = new McpServer({ name: "float", version: "0.10.3" }, { instructions: INSTRUCTIONS });
385
470
  /**
386
471
  * THE FULL OPERATING GUIDE, AS AN MCP RESOURCE.
387
472
  *
@@ -579,6 +664,11 @@ server.tool("vault_await_create", "After the user signs the create link, confirm
579
664
  }
580
665
  if (config.owner !== owner) {
581
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,
582
672
  status: "NOT_YOURS",
583
673
  vault: target,
584
674
  actualOwner: config.owner,
@@ -588,16 +678,116 @@ server.tool("vault_await_create", "After the user signs the create link, confirm
588
678
  `under a label instead, which lands on a different address.`,
589
679
  });
590
680
  }
681
+ /**
682
+ * WHAT ACTUALLY STOPS THIS VAULT PAYING, read from the chain.
683
+ *
684
+ * The old `nextSteps` were a FIXED list ending "nothing can spend from it
685
+ * until an agent is bound — vault_add_role". For a vault created with
686
+ * `bindAgent` that is simply false: the agent is already bound, and the
687
+ * real gate is that the vault holds no tokens. Measured 2026-11-13 — the
688
+ * agent relaying it had to correct the tool in front of the user.
689
+ *
690
+ * Worse, nothing told the owner HOW to fund it, or that funding is a plain
691
+ * wallet send to an address, or what that address is. This is the moment
692
+ * they need it: they have just signed, they are looking at the result, and
693
+ * the next thing standing between them and a working vault is money.
694
+ *
695
+ * So the steps are derived from live state instead of asserted. Three
696
+ * reads, once, on a path that runs once per vault.
697
+ */
698
+ const rootToken = await (async () => {
699
+ // THE TREE IS AUTHORITATIVE, not cfg.token: at creation time
700
+ // NEAR_VAULT_CONTRACT is typically unset, so the configured token may be
701
+ // empty while the vault plainly names one in its root limits.
702
+ const tree = await viewFunction(rpc, target, "get_tree", {}).catch(() => null);
703
+ return tree?.find((n) => n.id === 0)?.limits?.[0]?.token;
704
+ })();
705
+ const meta = rootToken
706
+ ? await tokenMeta(rpc, rootToken, (r, t) => viewFunction(r, t, "ft_metadata", {})).catch(() => null)
707
+ : null;
708
+ const symbol = meta?.symbol ?? "the token";
709
+ const [held, registered, agentKeys] = await Promise.all([
710
+ rootToken
711
+ ? viewFunction(rpc, rootToken, "ft_balance_of", { account_id: target }).catch(() => null)
712
+ : Promise.resolve(null),
713
+ rootToken
714
+ ? viewFunction(rpc, rootToken, "storage_balance_of", {
715
+ account_id: target,
716
+ }).catch(() => undefined)
717
+ : Promise.resolve(undefined),
718
+ viewAccessKeys(rpc, target)
719
+ // Narrowed the same way vault_gas does: `permission` is a union and
720
+ // only the gas-key arm carries this field.
721
+ .then((ks) => ks.filter((k) => k.access_key?.permission
722
+ ?.GasKeyFunctionCall !== undefined).length)
723
+ .catch(() => null),
724
+ ]);
725
+ const empty = held !== null && BigInt(held) === 0n;
726
+ const funding = {
727
+ token: rootToken ?? null,
728
+ symbol: rootToken ? symbol : null,
729
+ balance: held === null ? null : fromBaseUnits(BigInt(held), meta?.decimals ?? cfg.decimals),
730
+ /** `undefined` = could not read; `null` = read and NOT registered. */
731
+ registeredOnToken: registered === undefined ? null : registered !== null,
732
+ sendTo: target,
733
+ howTo: `Funding is a plain send from the owner's own wallet: send ${symbol} to ${target}. ` +
734
+ `This server cannot do it — the agent key cannot touch a token contract, which is the ` +
735
+ `point of the product.`,
736
+ };
737
+ const nextSteps = [
738
+ `Set NEAR_VAULT_CONTRACT=${target} so every other tool reads this vault.`,
739
+ ];
740
+ /**
741
+ * UNREGISTERED IS NAMED FIRST, because it is the one that makes a transfer
742
+ * FAIL rather than merely be impossible. Vaults created before the factory
743
+ * registered them are still in this state; the fix is forward-only.
744
+ */
745
+ if (registered === null) {
746
+ nextSteps.push(`REGISTER IT FIRST: ${target} is not registered on ${rootToken}, and a transfer to an ` +
747
+ `unregistered account FAILS. The owner must call storage_deposit on the token with ` +
748
+ `about 0.00125 NEAR attached and registration_only: true. Vaults created through the ` +
749
+ `current factory are registered automatically; this one was not.`);
750
+ }
751
+ if (empty) {
752
+ nextSteps.push(`FUND IT: the vault holds no ${symbol}. Tell the owner to send ${symbol} to ${target} ` +
753
+ `from their own wallet, and ASK how much they want to move. Nothing can be paid out ` +
754
+ `until it has a balance — the budget caps what MAY be spent, not what exists.`);
755
+ }
756
+ if (agentKeys === 0) {
757
+ nextSteps.push(`No agent is bound yet, so nothing can authorise a payment — vault_add_role.`);
758
+ }
759
+ if (!empty && agentKeys !== 0 && registered !== null) {
760
+ nextSteps.push(`Ready. vault_budget shows today's allowance; vault_pay makes a payment.`);
761
+ }
762
+ else {
763
+ nextSteps.push(`vault_tree shows the budget shape; vault_budget shows what is left today.`);
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.`;
591
781
  return json({
782
+ say: liveSay,
783
+ next_step: nextSteps[0] ?? null,
784
+ money_moved: false,
592
785
  status: "LIVE",
593
786
  vault: target,
594
787
  owner: config.owner,
595
788
  config,
596
- nextSteps: [
597
- `Set NEAR_VAULT_CONTRACT=${target} so every other tool reads this vault.`,
598
- `vault_tree shows the budget; vault_budget shows what is left today.`,
599
- `Nothing can spend from it until an agent is bound with a budget — vault_add_role.`,
600
- ],
789
+ funding,
790
+ nextSteps,
601
791
  });
602
792
  });
603
793
  /**
@@ -688,7 +878,33 @@ server.tool("vault_budget", "Read one node's budget for a UTC day: cap, executed
688
878
  const tok = tokenFor(token);
689
879
  const meta = (await metaFor([tok])).get(tok) ?? null;
690
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.` : "";
691
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,
692
908
  node_id: nodeId,
693
909
  token: tokenLabel(tok, meta),
694
910
  /**
@@ -703,10 +919,10 @@ server.tool("vault_budget", "Read one node's budget for a UTC day: cap, executed
703
919
  date: new Date(Number(b.day) * 86_400_000).toISOString().slice(0, 10),
704
920
  day: b.day,
705
921
  resets: "00:00 UTC",
706
- daily_cap: fromBaseUnits(BigInt(b.daily_cap), d),
707
- executed: fromBaseUnits(BigInt(b.executed), d),
708
- pending: fromBaseUnits(BigInt(b.pending), d),
709
- available: fromBaseUnits(BigInt(b.available), d),
922
+ daily_cap: cap,
923
+ executed: used,
924
+ pending: inFlight,
925
+ available: left,
710
926
  });
711
927
  });
712
928
  /**
@@ -907,33 +1123,135 @@ server.tool("vault_add_role", "Add a NEW departmental agent to an existing vault
907
1123
  .describe("NEAR for the agent's gas key. Default 0.12 (~160 payments measured). Minimum 0.06, because NEAR reserves prepaid gas up front."),
908
1124
  }, async ({ role, dailyCap, perTxCap, recipients, token, cooldownSec, fundingNear }) => {
909
1125
  const vault = requireVault();
910
- // REFUSE A DUPLICATE ROLE BEFORE GENERATING A KEY. The contract would
911
- // happily create a second node with the same label, and the keyring would
912
- // index only the last one — so the first agent would still be bound and
913
- // funded on chain while becoming unreachable locally.
914
- if (listRoles(cfg).some((r) => r.role === role)) {
915
- return text(`This vault already has an agent with the role '${role}'. Roles index the ` +
916
- `keyring, so a second one would hide the first — which would stay bound and ` +
917
- `funded on chain with nothing able to select it. Pick another name, or use ` +
918
- `the existing agent.`);
1126
+ /**
1127
+ * A DUPLICATE ROLE IS REFUSED — BUT "IN THE KEYRING" IS NOT "ALIVE".
1128
+ *
1129
+ * The contract would happily create a second node with the same label, and
1130
+ * `selectRole` resolves with `find`, so the FIRST entry wins forever: the
1131
+ * older agent would stay bound and funded on chain with nothing able to
1132
+ * select it. That part of the old guard was right.
1133
+ *
1134
+ * What was wrong was treating a keyring entry as proof an agent exists. A
1135
+ * `vault_add_role` that writes a key and then fails to land on chain leaves
1136
+ * an ORPHAN — a role bound to nothing, no node, no gas key — and this guard
1137
+ * then refused the user's own retry, citing the wreckage of the attempt
1138
+ * that failed thirty seconds earlier. Reported 2026-11-13 for a `travel`
1139
+ * agent; the user had to go and find the file by hand.
1140
+ *
1141
+ * So ask the CHAIN, which is the only thing that knows:
1142
+ * bound to a node -> a real agent. Refuse, as before.
1143
+ * not bound -> an orphan. Retire it and carry on.
1144
+ * chain unreachable or no recorded key -> cannot tell. Refuse, and SAY
1145
+ * it is a "cannot tell", not a "there is one".
1146
+ */
1147
+ let retired = null;
1148
+ const clash = listAgents(cfg).find((a) => a.role === role);
1149
+ if (clash) {
1150
+ const boundNode = clash.publicKey
1151
+ ? await viewFunction(cfg.rpcUrl, vault, "get_virtual_agent_node", {
1152
+ public_key: clash.publicKey,
1153
+ }).catch(() => undefined)
1154
+ : undefined;
1155
+ if (boundNode === undefined) {
1156
+ return text(`There is already a key filed under the role '${role}' for this vault, and I could not ` +
1157
+ `confirm with ${vault} whether it is a working agent — ` +
1158
+ `${clash.publicKey ? "the network read failed" : "that entry predates recorded public keys"}. ` +
1159
+ `Refusing rather than guessing: adding a second '${role}' would permanently hide ` +
1160
+ `whichever was filed first, and if the existing one IS bound and funded, nothing ` +
1161
+ `would be able to select it again. Try vault_roles to see the fleet, or pick ` +
1162
+ `another name.`);
1163
+ }
1164
+ if (boundNode !== null) {
1165
+ return text(`This vault already has a working agent with the role '${role}' — it is bound to ` +
1166
+ `node ${boundNode}. A second one would hide it, and it would stay funded on chain ` +
1167
+ `with nothing able to select it. Pick another name, or just use the existing agent.`);
1168
+ }
1169
+ /**
1170
+ * ORPHAN. The chain says this key is bound to nothing, so it cannot be
1171
+ * anyone's working agent. Its name is taken off it — the secret stays on
1172
+ * disk, see `retireRole` — and the request proceeds, which is what the
1173
+ * user asked for in the first place.
1174
+ */
1175
+ retired = retireRole({
1176
+ keyringPath: cfg.agentKeysPath,
1177
+ vault,
1178
+ publicKey: clash.publicKey,
1179
+ });
919
1180
  }
1181
+ /**
1182
+ * VALIDATE EVERYTHING BEFORE A KEY TOUCHES DISK.
1183
+ *
1184
+ * This used to generate and WRITE the agent's key, and only then build the
1185
+ * payload — which calls `tokenFor()` and the amount conversions, any of
1186
+ * which can throw. So a call that failed validation left a real keypair
1187
+ * filed under the role name, bound to nothing: no node, no gas key. The
1188
+ * user's retry then hit "this vault already has an agent with the role
1189
+ * 'travel'" and was blocked by the wreckage of their own failed attempt.
1190
+ *
1191
+ * Measured 2026-11-13: a `vault_add_role` that failed on a missing token
1192
+ * wrote `travel` to the keyring 55 seconds before the retry was refused.
1193
+ *
1194
+ * Everything that can throw now runs FIRST, against a throwaway public key,
1195
+ * so a failure writes nothing at all. The key is generated only once the
1196
+ * payload is known to be buildable.
1197
+ */
920
1198
  const fundingYocto = toYoctoNear(fundingNear ?? "0.12");
921
- // `nodeId: null` — NOT YET KNOWN. `add_role` creates the node inside the
922
- // contract, so its id does not exist until the transaction lands.
1199
+ let limits;
1200
+ try {
1201
+ limits = [{ token: tokenFor(token), dailyCap, perTxCap, decimals: cfg.decimals }];
1202
+ // Build it once with a placeholder to force every conversion and check
1203
+ // to run. Nothing is kept; this is a dry run for its exceptions.
1204
+ planAddRole(vault, role, limits, cooldownSec ?? 0, recipients ?? null, PLACEHOLDER_PUBLIC_KEY, fundingYocto);
1205
+ }
1206
+ catch (e) {
1207
+ return text(`${String(e.message)}${String.fromCharCode(10)}${String.fromCharCode(10)}` +
1208
+ `Nothing was written — no key was generated, so '${role}' is still free to use once ` +
1209
+ `this is corrected.`);
1210
+ }
1211
+ // Only now. `nodeId: null` — NOT YET KNOWN: `add_role` creates the node
1212
+ // inside the contract, so its id does not exist until the transaction lands.
923
1213
  const generated = generateAgentKey({
924
1214
  vault,
925
- dir: dirname(cfg.agentKeysPath),
1215
+ // The FULL configured path, not its directory. Passing the directory
1216
+ // discarded the filename, so a keyring pinned to anything other than
1217
+ // `agent-keys.json` was read from one file and written to another.
1218
+ keyringPath: cfg.agentKeysPath,
926
1219
  role,
927
1220
  nodeId: null,
928
1221
  });
929
- let payload;
930
- try {
931
- payload = planAddRole(vault, role, [{ token: tokenFor(token), dailyCap, perTxCap, decimals: cfg.decimals }], cooldownSec ?? 0, recipients ?? null, generated.publicKey, fundingYocto);
932
- }
933
- catch (e) {
934
- return text(String(e.message));
935
- }
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.`;
936
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,
937
1255
  role,
938
1256
  agent: {
939
1257
  publicKey: generated.publicKey,
@@ -946,30 +1264,165 @@ server.tool("vault_add_role", "Add a NEW departmental agent to an existing vault
946
1264
  `credential is filed under the ROLE, so vault_pay({role:"${role}"}) works ` +
947
1265
  `without it.`,
948
1266
  ],
1267
+ // Only present when something was actually cleaned up, so the ordinary
1268
+ // case stays quiet. An orphan silently replaced would be the same
1269
+ // invisible-state problem in a new costume.
1270
+ ...(retired
1271
+ ? {
1272
+ replaced: `A key was already filed under '${role}' but ${vault} reports it bound to ` +
1273
+ `nothing — a leftover from an earlier attempt that never landed on chain. It has ` +
1274
+ `been renamed '${retired.retiredTo}' so it cannot shadow this one. Its secret ` +
1275
+ `was NOT deleted.`,
1276
+ }
1277
+ : {}),
949
1278
  warnings: [
950
1279
  `Show the owner this public key and have them confirm it before signing: ${generated.publicKey}`,
951
1280
  `The private key is at ${generated.keyringPath}. Do not print it.`,
1281
+ // A second keyring holding keys for THIS vault means roles this server
1282
+ // cannot see. Said here rather than left to surface later as "my agent
1283
+ // vanished".
1284
+ ...(KEYRING.alsoFound.length
1285
+ ? [
1286
+ `Another keyring also holds keys for this vault and is NOT being read: ` +
1287
+ `${KEYRING.alsoFound.join(", ")}. This one was chosen. Set ` +
1288
+ `NEAR_VAULT_AGENT_KEYS to pin one deliberately.`,
1289
+ ]
1290
+ : []),
952
1291
  ],
953
1292
  });
954
1293
  });
955
- server.tool("vault_roles", "List the agents this server can sign as, by role. Use it before vault_pay when a vault has more than one agent — the signing key decides which budget is charged. No key material is returned.", {}, async () => json(
956
- // `listAgents`, NOT `listRoles`. The latter filters to entries carrying a
957
- // role, which is correct for SELECTION and wrong for REPORTING: a
958
- // creation-time agent from `bindAgent` has no role, so a working vault
959
- // with a funded gas key reported `[]` — indistinguishable from having no
960
- // agent at all, and the obvious next move is then the wrong one.
961
- listAgents(cfg).map((r) => ({
962
- role: r.role,
963
- nodeId: r.nodeId,
964
- agentId: r.agentId,
965
- ...(r.role === null
966
- ? {
967
- note: "No role name — created by vault_create({bindAgent:true}), which does not " +
968
- "name one. It can still sign; vault_pay selects it by nodeId or as the only " +
969
- "credential. Give it a name with vault_add_role if you want several agents.",
1294
+ server.tool("vault_roles", "List the agents this server can sign as, CROSS-CHECKED against the vault. Says which keys are actually installed and which are dead. Use it before vault_pay when a vault has more than one agent — the signing key decides which budget is charged. No key material is returned.", {}, async () => {
1295
+ const vault = requireVault();
1296
+ /**
1297
+ * THE KEYRING IS A CLAIM. THE CHAIN IS THE FACT.
1298
+ *
1299
+ * This used to report `listAgents(cfg)` verbatim — role, nodeId and all —
1300
+ * straight out of the local file, without ever asking the vault whether it
1301
+ * had heard of those keys. So a keyring pointing at the wrong directory
1302
+ * reported a healthy, signable agent on node 1 that the vault had never
1303
+ * been told about, and `vault_pay` then signed with it and came back
1304
+ * NOT_VIRTUAL_AGENT.
1305
+ *
1306
+ * Measured 2026-11-13: that cost a user a whole session of forensics —
1307
+ * reading .claude.json by hand, diffing two keyring files, checking gas
1308
+ * balances — to find a single wrong path. The tool whose entire job is
1309
+ * "which agents can I sign as" was the one reporting the false positive,
1310
+ * which is why nothing caught it earlier.
1311
+ *
1312
+ * So every entry is now resolved against the vault:
1313
+ * get_virtual_agent_node(pk) -> the node the CHAIN binds it to, or null
1314
+ * the access-key list -> is a funded gas key actually installed
1315
+ *
1316
+ * And the INVERSE is reported too: a gas key on the vault that is not in
1317
+ * the keyring is an agent you cannot sign as. Both directions of the
1318
+ * disagreement matter, and only one of them was ever visible.
1319
+ */
1320
+ const local = listAgents(cfg);
1321
+ const onChainKeys = await viewAccessKeys(cfg.rpcUrl, vault)
1322
+ .then((ks) => ks
1323
+ .map((k) => ({
1324
+ publicKey: k.public_key,
1325
+ gas: k.access_key?.permission?.GasKeyFunctionCall,
1326
+ }))
1327
+ .filter((k) => k.gas !== undefined))
1328
+ .catch(() => null);
1329
+ const resolved = await Promise.all(local.map(async (r) => {
1330
+ if (!r.publicKey) {
1331
+ return {
1332
+ role: r.role,
1333
+ agentId: r.agentId,
1334
+ status: "UNVERIFIABLE",
1335
+ detail: "This keyring entry has no public key recorded, so the chain cannot be asked " +
1336
+ "about it. It predates the field. Re-create the agent with vault_add_role.",
1337
+ };
970
1338
  }
971
- : {}),
972
- }))));
1339
+ const node = await viewFunction(cfg.rpcUrl, vault, "get_virtual_agent_node", {
1340
+ public_key: r.publicKey,
1341
+ }).catch(() => undefined);
1342
+ const installed = onChainKeys?.find((k) => k.publicKey === r.publicKey);
1343
+ const balance = installed?.gas?.balance;
1344
+ if (node === undefined) {
1345
+ return { role: r.role, agentId: r.agentId, status: "UNKNOWN", detail: "could not reach the chain" };
1346
+ }
1347
+ if (node === null) {
1348
+ return {
1349
+ role: r.role,
1350
+ agentId: r.agentId,
1351
+ publicKey: r.publicKey,
1352
+ status: "DEAD",
1353
+ detail: `This key is NOT bound on ${vault}. Signing with it returns NOT_VIRTUAL_AGENT. ` +
1354
+ `It is a key for a different vault, or left over from one that was torn down, or ` +
1355
+ `this client is reading the wrong keyring. Do NOT use it; vault_add_role creates ` +
1356
+ `one the vault will accept.`,
1357
+ };
1358
+ }
1359
+ return {
1360
+ role: r.role,
1361
+ agentId: r.agentId,
1362
+ nodeId: node,
1363
+ // The LOCAL nodeId is reported only when it disagrees, because a
1364
+ // disagreement means the file is stale and that is worth saying.
1365
+ ...(r.nodeId !== undefined && r.nodeId !== node
1366
+ ? { staleLocalNodeId: r.nodeId, note: "the keyring records a different node; the chain wins" }
1367
+ : {}),
1368
+ status: installed ? "USABLE" : "BOUND_BUT_NO_GAS_KEY",
1369
+ gasNear: balance ? fromBaseUnits(BigInt(balance), 24) : null,
1370
+ ...(installed
1371
+ ? {}
1372
+ : {
1373
+ detail: "Bound to a node, so it may AUTHORISE a payment, but no gas key is installed " +
1374
+ "on the vault, so it cannot SUBMIT one. float-agent installs and funds it.",
1375
+ }),
1376
+ };
1377
+ }));
1378
+ const heldKeys = new Set(local.map((r) => r.publicKey).filter(Boolean));
1379
+ const unheld = (onChainKeys ?? [])
1380
+ .filter((k) => !heldKeys.has(k.publicKey))
1381
+ .map((k) => ({
1382
+ publicKey: k.publicKey,
1383
+ status: "ON_CHAIN_BUT_NO_LOCAL_KEY",
1384
+ detail: "The vault has this agent installed, but this client does not hold its private key, " +
1385
+ "so it cannot sign as it. It belongs to another machine or another client.",
1386
+ }));
1387
+ const unloadable = listUnloadable(cfg);
1388
+ const usable = resolved.filter((r) => r.status === "USABLE").length;
1389
+ return json({
1390
+ vault,
1391
+ usable,
1392
+ /**
1393
+ * WHICH FILE THIS ANSWER CAME FROM.
1394
+ *
1395
+ * Every status below is a claim about credentials in one specific file,
1396
+ * and that file is CHOSEN (see `keyring-path.ts`) — so an answer that
1397
+ * does not name it cannot be checked. Omitting it is what turned "my
1398
+ * travel agent is missing" into a session of hand-diffing keyrings:
1399
+ * the tool had the path and did not say it.
1400
+ */
1401
+ keyring: {
1402
+ path: cfg.agentKeysPath,
1403
+ chosenBecause: KEYRING.why,
1404
+ ...(KEYRING.alsoFound.length
1405
+ ? {
1406
+ NOT_being_read: KEYRING.alsoFound,
1407
+ warning: `Another keyring also holds keys for this vault. Agents filed there will NOT ` +
1408
+ `appear above. Set NEAR_VAULT_AGENT_KEYS to pin one deliberately.`,
1409
+ }
1410
+ : {}),
1411
+ },
1412
+ agents: resolved,
1413
+ ...(unheld.length ? { onChainWithoutLocalKey: unheld } : {}),
1414
+ // Entries the loader refuses to read. Reported rather than dropped, so
1415
+ // "I have a key but this shows nothing" is answerable.
1416
+ ...(unloadable.length ? { unreadableKeyringEntries: unloadable } : {}),
1417
+ ...(usable === 0
1418
+ ? {
1419
+ warning: `NO USABLE AGENT. vault_pay cannot succeed against ${vault} from this client. ` +
1420
+ `Every entry above is dead, unbound, or missing its gas key — the commonest cause ` +
1421
+ `is NEAR_VAULT_AGENT_KEYS pointing at a keyring for a different vault.`,
1422
+ }
1423
+ : {}),
1424
+ });
1425
+ });
973
1426
  // ------------------------------------------------------------- onboarding
974
1427
  server.tool("vault_create", "For a user who has NO vault yet. Works out where their vault will live, exactly what it costs to create, and returns a URL they approve in their own wallet. Needs no key and no existing vault — this is the only tool that works before NEAR_VAULT_CONTRACT is set. This server never signs it.", {
975
1428
  owner: z
@@ -1300,7 +1753,12 @@ server.tool("vault_plan_add_node", "Build an UNSIGNED payload to create a budget
1300
1753
  limits: limitShape,
1301
1754
  cooldownSec: z.number().int().min(0).default(0),
1302
1755
  recipients: z.array(z.string()).nullable().default(null),
1303
- }, async ({ parent, label, limits, cooldownSec, recipients }) => renderPlan(planAddNode(requireVault(), parent, label, limits, cooldownSec, recipients)));
1756
+ }, async ({ parent, label, limits, cooldownSec, recipients }) => {
1757
+ // The PARENT's name, so the summary reads "under news" not "under node 1".
1758
+ // Only looked up when there IS a parent.
1759
+ const parentName = parent === null ? null : (await resolveNode(parent)).label;
1760
+ return renderPlan(planAddNode(requireVault(), parent, label, limits, cooldownSec, recipients, parentName));
1761
+ });
1304
1762
  /**
1305
1763
  * Refuse to build a signable payload for a node that does not exist.
1306
1764
  *
@@ -1322,19 +1780,38 @@ server.tool("vault_plan_add_node", "Build an UNSIGNED payload to create a budget
1322
1780
  * them just prompts another guess.
1323
1781
  */
1324
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) {
1325
1801
  const tree = (await viewFunction(cfg.rpcUrl, requireVault(), "get_tree", {}).catch(() => null));
1326
- // A failed READ must not block a plan: the chain being briefly unreachable
1327
- // is not evidence the node is missing, and refusing here would turn a blip
1328
- // into "your node does not exist".
1329
1802
  if (!Array.isArray(tree))
1330
- return null;
1331
- if (tree.some((n) => n.id === nodeId))
1332
- return null;
1803
+ return { missing: null, label: null };
1804
+ const hit = tree.find((n) => n.id === nodeId);
1805
+ if (hit)
1806
+ return { missing: null, label: hit.label ?? null };
1333
1807
  // RETURNS THE FACTS, NOT A SENTENCE. The first version returned prose and
1334
1808
  // the read path tried to reuse it by regex-stripping the signing clause,
1335
1809
  // which left "no node 999, Nodes: ...". Composing at the call site costs two
1336
1810
  // lines and cannot produce a mangled sentence.
1337
- return { known: tree.map((n) => `${n.id}${n.label ? ` (${n.label})` : ""}`).join(", ") };
1811
+ return {
1812
+ missing: { known: tree.map((n) => `${n.id}${n.label ? ` (${n.label})` : ""}`).join(", ") },
1813
+ label: null,
1814
+ };
1338
1815
  }
1339
1816
  /** For a PLAN: the cost of getting this wrong is a wasted signature. */
1340
1817
  const noNodeForPlan = (nodeId, known) => `This vault has no node ${nodeId}, so that payload would be refused by the ` +
@@ -1342,33 +1819,33 @@ const noNodeForPlan = (nodeId, known) => `This vault has no node ${nodeId}, so t
1342
1819
  /** For a READ: nothing is at stake, so just say what exists. */
1343
1820
  const noNodeForRead = (nodeId, known) => `This vault has no node ${nodeId}. Nodes: ${known}.`;
1344
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 }) => {
1345
- const bad = await assertNode(nodeId);
1346
- return bad
1347
- ? text(noNodeForPlan(nodeId, bad.known))
1348
- : 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));
1349
1826
  });
1350
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.", {
1351
1828
  nodeId: z.number().int().min(0),
1352
1829
  publicKey: z.string().describe("ed25519:..."),
1353
- }, async ({ nodeId, publicKey }) => renderPlan(planBindVirtualAgent(requireVault(), nodeId, publicKey)));
1830
+ }, async ({ nodeId, publicKey }) => renderPlan(planBindVirtualAgent(requireVault(), nodeId, publicKey, (await resolveNode(nodeId)).label)));
1354
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)));
1355
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 }) => {
1356
- const bad = await assertNode(nodeId);
1357
- return bad
1358
- ? text(noNodeForPlan(nodeId, bad.known))
1359
- : 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));
1360
1837
  });
1361
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 () => {
1362
- const bad = await assertNode(nodeId);
1363
- return bad
1364
- ? text(noNodeForPlan(nodeId, bad.known))
1365
- : 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));
1366
1843
  })());
1367
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 }) => {
1368
- const bad = await assertNode(nodeId);
1369
- return bad
1370
- ? text(noNodeForPlan(nodeId, bad.known))
1371
- : 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));
1372
1849
  });
1373
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", {
1374
1851
  request_id: requestId,
@@ -1381,7 +1858,7 @@ server.tool("vault_plan_set_approval_threshold", 'Build an UNSIGNED payload requ
1381
1858
  // planSetApprovalThreshold in payloads.ts for the measurement.
1382
1859
  amount: z.string().min(1).nullable().describe('human units, e.g. "0.25" — NOT base units'),
1383
1860
  decimals: z.number().int().min(0).max(38).default(6),
1384
- }, 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)));
1385
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)));
1386
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)));
1387
1864
  /**