@floatonnear/mcp 0.10.0 → 0.10.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/SKILL.md CHANGED
@@ -92,7 +92,7 @@ As soon as setup is done, say what they have and what it would cost. Something
92
92
  close to this, in your own words:
93
93
 
94
94
  > You don't have a vault yet. I can set one up — it's **one signature in your
95
- > own wallet**, and you need to be holding about **3.55 NEAR**. Almost all of
95
+ > own wallet**, and you need to be holding about **3.7 NEAR**. Almost all of
96
96
  > that is a refundable storage deposit your vault keeps, not a fee. Want me to?
97
97
 
98
98
  Three things that wording is carrying, and all three matter:
@@ -105,10 +105,18 @@ Three things that wording is carrying, and all three matter:
105
105
  - **refundable.** 3.55 NEAR reads as a fee unless you say otherwise. It is a
106
106
  storage deposit the vault holds and returns on teardown.
107
107
 
108
- **Quote `vault_create`'s own `totalRequiredNear`, not the 3.55 above**, once you
108
+ **Quote `vault_create`'s own `totalRequiredNear`, not the 3.7 above**, once you
109
109
  have called it. That figure is derived from the exact contract being deployed;
110
110
  this one is an illustration and will drift as the contract changes.
111
111
 
112
+ **3.7 IS THE WITH-AGENT FIGURE, AND IT IS DELIBERATELY THE HIGHER ONE.** Binding
113
+ an agent in the creation transaction adds 0.12 NEAR of gas-key funding, so the
114
+ real totals are **3.54839 without** an agent and **3.66839 with** one. Binding
115
+ one is the normal thing to do — otherwise the vault cannot pay — so the offer
116
+ must quote the larger number. An understated quote is not a rounding nicety: a
117
+ user who funds to exactly what they were told has the transaction REFUSED by
118
+ their own wallet, which reads as the product being broken. Round up, always.
119
+
112
120
  **Do NOT auto-create one.** It spends real money, and it cannot complete
113
121
  without them anyway — `vault_create` returns a link they approve in their own
114
122
  wallet, so running it unasked produces a link nobody wanted and still waits.
@@ -143,11 +151,49 @@ key on the machine, which is the thing above.
143
151
  Your key's `method_names` is `["request_payment"]` and nothing else.
144
152
  - attach NEAR to anything. A NEAR function-call key cannot carry a deposit.
145
153
  - **fund the vault.** That needs the owner's tokens and the owner's key. If the
146
- vault is empty, say so and ask the human — do not try to fix it yourself.
154
+ vault is empty, say so and ask the human — do not try to fix it yourself, and
155
+ send them to their WALLET, never to a CLI. See below.
147
156
 
148
157
  If a request needs one of those, produce a plan with `vault_plan_*` and hand it
149
158
  to the human. Do not attempt it and report the error.
150
159
 
160
+ ### Funding a vault — the wallet, always, never the CLI
161
+
162
+ Someone has to put tokens in. **Tell them to send from their own wallet**, name
163
+ the three fields, and stop:
164
+
165
+ > Open your wallet as `<owner>` and send `<amount>` `<TOKEN>` to `<vault>`.
166
+ > Check the token's CONTRACT ID, not just the ticker — `<contract>`.
167
+
168
+ **Do NOT hand over an `ft_transfer` command, and do not offer to run one.**
169
+ Measured 2026-11-13: asked to fund a vault, an agent found a full-access key in
170
+ `~/.near-credentials` and offered `near call … ft_transfer` as a co-equal option
171
+ beside the wallet. The user had to correct it. A token send is the one operation
172
+ every wallet already does well — a button, with the balance, the ticker and the
173
+ destination on screen before anything is signed. Replacing that with base-unit
174
+ arithmetic (`"amount":"1000000"`), a `--depositYocto 1` the user cannot
175
+ sanity-check, and a key read off disk is worse precisely when they are deciding
176
+ whether to trust this thing with money.
177
+
178
+ It also contradicts the rest of this file. Creation already refuses the CLI for
179
+ anyone being onboarded — *"never offer to run the CLI on someone else's
180
+ behalf"* — and funding is the same situation with the same answer. The wizard's
181
+ `--fund` flag belongs to an operator on their own machine with their own key; it
182
+ is not a funding path to offer a user.
183
+
184
+ Two things to say in the same message, because both fail SILENTLY:
185
+
186
+ - **the token contract, not the ticker.** Caps are keyed per token. NEAR has a
187
+ native USDC and an older bridged `USDC.e` — send the wrong one and it arrives,
188
+ shows as a balance, and the agent still has zero budget for it. The refusal
189
+ reads as a broken vault.
190
+ - **the payee allowlist.** Read it with `vault_tree` first. If it still holds
191
+ only the owner, the agent can pay nobody but the owner, and funding past a
192
+ test amount buys nothing yet.
193
+
194
+ Then verify instead of taking their word for it: read the vault's
195
+ `ft_balance_of` and tell them what actually landed.
196
+
151
197
  ## Daily use — the MCP tools
152
198
 
153
199
  If `float` is configured in your MCP client, you have these. Read before you
@@ -50,6 +50,22 @@ export interface GeneratedAgentKey {
50
50
  export declare function generateAgentKey(opts: {
51
51
  vault: string;
52
52
  dir?: string;
53
+ /**
54
+ * The exact keyring file to write, overriding `dir` and the hardcoded
55
+ * `agent-keys.json` filename.
56
+ *
57
+ * WHY IT HAS TO EXIST. The caller configures a keyring by full PATH
58
+ * (`NEAR_VAULT_AGENT_KEYS`), and this function used to accept only a
59
+ * directory and append a fixed filename. So pinning the keyring to, say,
60
+ * `~/keys/fleet.json` meant the server READ `fleet.json` and WROTE
61
+ * `~/keys/agent-keys.json` — a brand new agent, filed where nothing would
62
+ * ever look for it, reported as success. Silent, and exactly the "written
63
+ * somewhere you are not reading" failure this module is supposed to prevent.
64
+ *
65
+ * Caught by the smoke test that pins a non-default filename, not by a unit
66
+ * test: every unit test here passes a directory.
67
+ */
68
+ keyringPath?: string;
53
69
  /**
54
70
  * The node this agent spends from.
55
71
  *
@@ -85,3 +101,37 @@ export declare function agentKeyPaths(dir?: string): {
85
101
  envPath: string;
86
102
  dir: string;
87
103
  };
104
+ /**
105
+ * Take a role name off a keyring entry that is PROVEN not bound on chain,
106
+ * without destroying its secret.
107
+ *
108
+ * WHY THIS IS NEEDED. `generateAgentKey` appends and never replaces, for the
109
+ * reason above: overwriting could destroy the only copy of a credential for an
110
+ * agent that is already bound and funded. But `selectRole` resolves a role with
111
+ * `entries.find(...)` — FIRST match wins. So an orphaned entry (a key written
112
+ * by a `vault_add_role` that then failed to land on chain) would permanently
113
+ * SHADOW the replacement: every future payment under that role would select a
114
+ * key the contract answers NOT_VIRTUAL_AGENT for, and the working key beside it
115
+ * would never be reached.
116
+ *
117
+ * So the orphan's role is RENAMED rather than deleted. The secret stays on
118
+ * disk, the entry stays in the file, and the name resolves to exactly one
119
+ * credential again. Deleting would be tidier and is not worth the risk: the
120
+ * caller's proof of deadness is a chain read, and a chain read can be wrong
121
+ * about a vault that is paused, migrating, or being served by a lagging RPC.
122
+ * A renamed entry survives that mistake; a deleted one does not.
123
+ *
124
+ * Identified BY PUBLIC KEY, never by role alone — the whole point is that two
125
+ * entries can share a role at this moment, and renaming the wrong one would
126
+ * retire the live agent.
127
+ *
128
+ * Returns the new role name, or null if no entry matched (already retired, or
129
+ * a keyring that moved under us) — which is not an error.
130
+ */
131
+ export declare function retireRole(opts: {
132
+ keyringPath: string;
133
+ vault: string;
134
+ publicKey: string;
135
+ }): {
136
+ retiredTo: string;
137
+ } | null;
package/dist/agent-key.js CHANGED
@@ -58,11 +58,13 @@ export const AGENT_NODE_ID = 1;
58
58
  * `NEAR_VAULT_AGENT_ID` is set to a vault id in `.mcp.json`.
59
59
  */
60
60
  export function generateAgentKey(opts) {
61
- const dir = opts.dir ?? ".float";
62
61
  // `undefined` -> root (the creation-time agent). `null` -> unknown, and the
63
62
  // field is omitted rather than guessed.
64
63
  const nodeId = opts.nodeId === null ? undefined : (opts.nodeId ?? AGENT_NODE_ID);
65
- const keyringPath = join(dir, "agent-keys.json");
64
+ // An explicit path wins and decides the directory; otherwise the old
65
+ // dir-plus-fixed-filename behaviour, unchanged.
66
+ const keyringPath = opts.keyringPath ?? join(opts.dir ?? ".float", "agent-keys.json");
67
+ const dir = dirname(keyringPath);
66
68
  const envPath = join(dir, "agent.env");
67
69
  const kp = KeyPair.fromRandom("ed25519");
68
70
  const publicKey = kp.getPublicKey().toString();
@@ -133,3 +135,67 @@ export function generateAgentKey(opts) {
133
135
  export function agentKeyPaths(dir = ".float") {
134
136
  return { keyringPath: join(dir, "agent-keys.json"), envPath: join(dir, "agent.env"), dir: dirname(join(dir, "x")) };
135
137
  }
138
+ /**
139
+ * Take a role name off a keyring entry that is PROVEN not bound on chain,
140
+ * without destroying its secret.
141
+ *
142
+ * WHY THIS IS NEEDED. `generateAgentKey` appends and never replaces, for the
143
+ * reason above: overwriting could destroy the only copy of a credential for an
144
+ * agent that is already bound and funded. But `selectRole` resolves a role with
145
+ * `entries.find(...)` — FIRST match wins. So an orphaned entry (a key written
146
+ * by a `vault_add_role` that then failed to land on chain) would permanently
147
+ * SHADOW the replacement: every future payment under that role would select a
148
+ * key the contract answers NOT_VIRTUAL_AGENT for, and the working key beside it
149
+ * would never be reached.
150
+ *
151
+ * So the orphan's role is RENAMED rather than deleted. The secret stays on
152
+ * disk, the entry stays in the file, and the name resolves to exactly one
153
+ * credential again. Deleting would be tidier and is not worth the risk: the
154
+ * caller's proof of deadness is a chain read, and a chain read can be wrong
155
+ * about a vault that is paused, migrating, or being served by a lagging RPC.
156
+ * A renamed entry survives that mistake; a deleted one does not.
157
+ *
158
+ * Identified BY PUBLIC KEY, never by role alone — the whole point is that two
159
+ * entries can share a role at this moment, and renaming the wrong one would
160
+ * retire the live agent.
161
+ *
162
+ * Returns the new role name, or null if no entry matched (already retired, or
163
+ * a keyring that moved under us) — which is not an error.
164
+ */
165
+ export function retireRole(opts) {
166
+ if (!existsSync(opts.keyringPath))
167
+ return null;
168
+ let entries;
169
+ try {
170
+ const parsed = JSON.parse(readFileSync(opts.keyringPath, "utf8"));
171
+ if (!Array.isArray(parsed))
172
+ return null;
173
+ entries = parsed;
174
+ }
175
+ catch {
176
+ // Unreadable is not this function's problem to fix; `generateAgentKey`
177
+ // already backs such a file aside before writing.
178
+ return null;
179
+ }
180
+ let retiredTo;
181
+ for (const e of entries) {
182
+ if (!e || typeof e !== "object")
183
+ continue;
184
+ if (e.agentId !== opts.vault)
185
+ continue;
186
+ if (e.publicKey !== opts.publicKey)
187
+ continue;
188
+ if (typeof e.role !== "string")
189
+ continue;
190
+ // A short slice of the public key keeps two retirements of the same role
191
+ // from colliding, and keeps the name traceable back to the exact entry.
192
+ retiredTo = `${e.role}-retired-${opts.publicKey.slice(8, 16)}`;
193
+ e.role = retiredTo;
194
+ // The node id, if any, was never real for an unbound key.
195
+ delete e.nodeId;
196
+ }
197
+ if (!retiredTo)
198
+ return null;
199
+ writeFileSync(opts.keyringPath, `${JSON.stringify(entries, null, 2)}${String.fromCharCode(10)}`);
200
+ return { retiredTo };
201
+ }
package/dist/create.d.ts CHANGED
@@ -62,6 +62,16 @@ export declare const STORAGE_COST_PER_BYTE: bigint;
62
62
  * copy that drifts.
63
63
  */
64
64
  export declare const PAGE_HEADROOM_YOCTO: bigint;
65
+ /**
66
+ * NEP-145 registration for ONE token, mirroring the factory's
67
+ * `TOKEN_REGISTRATION` and the vault's `DEPOSIT_ADDRESS_STORAGE`.
68
+ *
69
+ * The factory registers a new vault on every token named in `root_limits`, so
70
+ * a plain `ft_transfer` into it succeeds — before that it failed with "the
71
+ * account is not registered", which broke funding for every browser-created
72
+ * vault. Pinned against the contract by `create.pins.test.ts`.
73
+ */
74
+ export declare const TOKEN_REGISTRATION_YOCTO: bigint;
65
75
  /**
66
76
  * Funding for an agent's gas key, when one is bound at creation.
67
77
  *
package/dist/create.js CHANGED
@@ -62,6 +62,16 @@ export const STORAGE_COST_PER_BYTE = 10n ** 19n;
62
62
  * copy that drifts.
63
63
  */
64
64
  export const PAGE_HEADROOM_YOCTO = 36n * 10n ** 22n;
65
+ /**
66
+ * NEP-145 registration for ONE token, mirroring the factory's
67
+ * `TOKEN_REGISTRATION` and the vault's `DEPOSIT_ADDRESS_STORAGE`.
68
+ *
69
+ * The factory registers a new vault on every token named in `root_limits`, so
70
+ * a plain `ft_transfer` into it succeeds — before that it failed with "the
71
+ * account is not registered", which broke funding for every browser-created
72
+ * vault. Pinned against the contract by `create.pins.test.ts`.
73
+ */
74
+ export const TOKEN_REGISTRATION_YOCTO = 125n * 10n ** 19n;
65
75
  /**
66
76
  * Funding for an agent's gas key, when one is bound at creation.
67
77
  *
@@ -258,8 +268,20 @@ export function buildCreatePlan(i) {
258
268
  // Both floors move by the gas-key funding when an agent is bound: the page
259
269
  // attaches it, and the factory requires it.
260
270
  const agentFunding = i.agentPk ? AGENT_GAS_KEY_FUNDING_YOCTO : 0n;
271
+ /**
272
+ * The factory also registers the new vault on each token in `root_limits`,
273
+ * so its floor covers that too — otherwise it would fund those receipts from
274
+ * its OWN balance, which is the drain documented in the contract.
275
+ *
276
+ * Counted as ONE because `vault_create` builds a single-token `root_limits`.
277
+ * It does not change the quoted total today: the page's 0.36 headroom keeps
278
+ * `pageAttaches` above the floor either way, and `max` picks the page. It is
279
+ * included so the floor stays correct if an operator raises
280
+ * `required_deposit` and the floor becomes the binding term.
281
+ */
282
+ const registrations = TOKEN_REGISTRATION_YOCTO;
261
283
  const pageAttaches = stake + PAGE_HEADROOM_YOCTO + agentFunding;
262
- const factoryFloor = BigInt(i.requiredDepositYocto) + stake + agentFunding;
284
+ const factoryFloor = BigInt(i.requiredDepositYocto) + stake + agentFunding + registrations;
263
285
  const deposit = pageAttaches > factoryFloor ? pageAttaches : factoryFloor;
264
286
  const total = deposit + GAS_RESERVATION_YOCTO;
265
287
  // If the operator raises `required_deposit` past the page's headroom, the
@@ -0,0 +1,36 @@
1
+ export declare const KEYRING_FILENAME: string;
2
+ export interface ResolvedKeyring {
3
+ /** Absolute path. Never relative — that is the whole point of this module. */
4
+ path: string;
5
+ /** Plain-English reason, safe to show a user. Names no secrets. */
6
+ why: string;
7
+ /**
8
+ * Other keyrings that ALSO hold a credential for this vault and are not
9
+ * being read. Empty in the normal case. Non-empty means a split keyring,
10
+ * which a user needs told rather than silently resolved.
11
+ */
12
+ alsoFound: string[];
13
+ }
14
+ /**
15
+ * How many credentials a keyring file holds for `vault`.
16
+ *
17
+ * Deliberately TOLERANT about the credential field name. `roles.ts` loads only
18
+ * `secret`, but `prove-refusal.mjs` reads `privateKey ?? secret ?? agentKey`,
19
+ * and a file written by one path must still be FOUND by this one — otherwise
20
+ * resolution sends the user to an empty home keyring while their real keys sit
21
+ * in a shape this function refused to count. Finding the file and loading an
22
+ * entry out of it are different jobs; `listUnloadable` reports the second.
23
+ *
24
+ * Never throws, and never returns the contents. A corrupt or unreadable file
25
+ * counts as zero; the caller is choosing a path, not validating a keyring.
26
+ */
27
+ export declare function countFor(path: string, vault?: string): number;
28
+ export declare function resolveKeyringPath(opts: {
29
+ /** The raw `NEAR_VAULT_AGENT_KEYS` value, if the operator set one. */
30
+ explicit?: string;
31
+ vault?: string;
32
+ /** Injectable so a test does not depend on where it was run from. */
33
+ cwd?: string;
34
+ home?: string;
35
+ count?: (path: string, vault?: string) => number;
36
+ }): ResolvedKeyring;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * WHERE THE FLEET KEYRING LIVES — and why it must not be a relative path.
3
+ *
4
+ * THE BUG THIS EXISTS TO KILL. The default was the bare string
5
+ * `.float/agent-keys.json`, which Node resolves against `process.cwd()`. An MCP
6
+ * server's CWD is wherever the CLIENT was launched, which the user does not
7
+ * choose and cannot see. So one single server entry in `~/.claude.json` became
8
+ * a DIFFERENT keyring per directory:
9
+ *
10
+ * launched from New project\ -> New project\.float\agent-keys.json
11
+ * launched from near-vault\ -> near-vault\.float\agent-keys.json
12
+ *
13
+ * Measured 2026-11-13 on this machine: `NEAR_VAULT_AGENT_KEYS` is UNSET for the
14
+ * `float` server, so a `travel` agent key written from one directory was
15
+ * invisible from the other. The tool reported "no agent with role 'travel'"
16
+ * while the key sat on disk a few folders away. Nothing in the output could
17
+ * distinguish "never created" from "created somewhere you are not looking",
18
+ * which is the same shape as the keyring-reader disagreement `roles.ts` already
19
+ * has a comment about.
20
+ *
21
+ * THE RULE. Resolution answers one question: *which file can sign for THIS
22
+ * vault?* Not "which directory am I in".
23
+ *
24
+ * 1. `NEAR_VAULT_AGENT_KEYS` set -> that, made absolute. Explicit wins; an
25
+ * operator who pinned a path gets the path they pinned.
26
+ * 2. Otherwise, of the CWD keyring and the HOME keyring, the one that already
27
+ * holds a credential for this vault. This is the back-compat case: anyone
28
+ * whose keys are in a project-local `.float/` keeps working untouched.
29
+ * 3. Otherwise HOME — `~/.float/agent-keys.json`. New keys land somewhere
30
+ * stable, so the NEXT session finds them whatever directory it starts in.
31
+ *
32
+ * Rule 3 is the actual fix. Rules 1 and 2 are there so turning it on cannot
33
+ * strand a key that already exists.
34
+ *
35
+ * WHEN BOTH HOLD KEYS it is a split keyring, and picking silently would hide
36
+ * one agent's roles behind another's. One is chosen so the tool still works,
37
+ * and the other is RETURNED in `alsoFound` so a caller can say so out loud.
38
+ */
39
+ import { existsSync, readFileSync } from "node:fs";
40
+ import { homedir } from "node:os";
41
+ import { isAbsolute, join, resolve } from "node:path";
42
+ export const KEYRING_FILENAME = join(".float", "agent-keys.json");
43
+ /**
44
+ * How many credentials a keyring file holds for `vault`.
45
+ *
46
+ * Deliberately TOLERANT about the credential field name. `roles.ts` loads only
47
+ * `secret`, but `prove-refusal.mjs` reads `privateKey ?? secret ?? agentKey`,
48
+ * and a file written by one path must still be FOUND by this one — otherwise
49
+ * resolution sends the user to an empty home keyring while their real keys sit
50
+ * in a shape this function refused to count. Finding the file and loading an
51
+ * entry out of it are different jobs; `listUnloadable` reports the second.
52
+ *
53
+ * Never throws, and never returns the contents. A corrupt or unreadable file
54
+ * counts as zero; the caller is choosing a path, not validating a keyring.
55
+ */
56
+ export function countFor(path, vault) {
57
+ if (!existsSync(path))
58
+ return 0;
59
+ let parsed;
60
+ try {
61
+ parsed = JSON.parse(readFileSync(path, "utf8"));
62
+ }
63
+ catch {
64
+ return 0;
65
+ }
66
+ if (!Array.isArray(parsed))
67
+ return 0;
68
+ return parsed.filter((e) => {
69
+ if (!e || typeof e !== "object")
70
+ return false;
71
+ const id = e.agentId ?? e.vault;
72
+ if (vault && id !== vault)
73
+ return false;
74
+ return ["secret", "privateKey", "agentKey"].some((k) => typeof e[k] === "string");
75
+ }).length;
76
+ }
77
+ export function resolveKeyringPath(opts) {
78
+ const cwd = opts.cwd ?? process.cwd();
79
+ const home = opts.home ?? homedir();
80
+ const count = opts.count ?? countFor;
81
+ if (opts.explicit) {
82
+ // Resolved even when already absolute, so the returned path is normalised
83
+ // and a relative override still cannot vary with CWD mid-session.
84
+ const path = isAbsolute(opts.explicit) ? opts.explicit : resolve(cwd, opts.explicit);
85
+ return { path, why: "NEAR_VAULT_AGENT_KEYS is set", alsoFound: [] };
86
+ }
87
+ const cwdRing = resolve(cwd, KEYRING_FILENAME);
88
+ const homeRing = join(home, KEYRING_FILENAME);
89
+ // Same directory: nothing to choose between, and no split to report.
90
+ if (cwdRing === homeRing) {
91
+ return { path: homeRing, why: "the default keyring in your home folder", alsoFound: [] };
92
+ }
93
+ const inCwd = count(cwdRing, opts.vault);
94
+ const inHome = count(homeRing, opts.vault);
95
+ if (inCwd > 0 && inHome === 0) {
96
+ return {
97
+ path: cwdRing,
98
+ why: "it already holds a key for this vault",
99
+ alsoFound: [],
100
+ };
101
+ }
102
+ if (inHome > 0 && inCwd > 0) {
103
+ return {
104
+ path: homeRing,
105
+ why: "it holds a key for this vault",
106
+ // The one NOT being read. Surfaced so "my agent vanished" is answerable.
107
+ alsoFound: [cwdRing],
108
+ };
109
+ }
110
+ return {
111
+ path: homeRing,
112
+ why: inHome > 0
113
+ ? "it already holds a key for this vault"
114
+ : "the default keyring in your home folder, so the next session finds it from any directory",
115
+ alsoFound: [],
116
+ };
117
+ }
package/dist/roles.d.ts CHANGED
@@ -4,6 +4,26 @@ export interface RoleCredential {
4
4
  role: string;
5
5
  nodeId?: number;
6
6
  }
7
+ /**
8
+ * Entries belonging to this vault that the loader REJECTS, and why.
9
+ *
10
+ * `loadable` drops anything without a string `secret`, and the reasoning is
11
+ * sound — the adapter applies the same predicate, so listing such an entry
12
+ * would advertise an agent that can never be selected. But DROPPING IT
13
+ * SILENTLY is the same failure this file already has a memory about: the user
14
+ * holds a key, the tool shows nothing, and there is no way to tell "no agent"
15
+ * from "an agent in a shape I refuse to read".
16
+ *
17
+ * Shapes do vary in the wild: `prove-refusal.mjs` reads
18
+ * `privateKey ?? secret ?? agentKey`, so a file written by another path can
19
+ * land here unreadable. Reported, not hidden.
20
+ */
21
+ export declare function listUnloadable(cfg: {
22
+ agentKeysPath?: string;
23
+ vault?: string;
24
+ }): {
25
+ reason: string;
26
+ }[];
7
27
  export declare class RoleSelectionError extends Error {
8
28
  }
9
29
  export declare function selectRole(cfg: {
@@ -43,4 +63,13 @@ export declare function listAgents(cfg: {
43
63
  agentId: string;
44
64
  role: string | null;
45
65
  nodeId?: number;
66
+ /**
67
+ * THE PUBLIC half, so a caller can ask the CHAIN about this key.
68
+ *
69
+ * It was omitted, and that omission is what made `vault_roles` unable to
70
+ * tell a working agent from a dead one: with no public key there is nothing
71
+ * to look up, so the only thing it could report was what the local file
72
+ * claimed. No key material — the public key is public.
73
+ */
74
+ publicKey?: string;
46
75
  }[];
package/dist/roles.js CHANGED
@@ -11,6 +11,54 @@
11
11
  * as a credential is how an agent ends up "configured" and unreachable.
12
12
  */
13
13
  import { existsSync, readFileSync } from "node:fs";
14
+ /**
15
+ * Entries belonging to this vault that the loader REJECTS, and why.
16
+ *
17
+ * `loadable` drops anything without a string `secret`, and the reasoning is
18
+ * sound — the adapter applies the same predicate, so listing such an entry
19
+ * would advertise an agent that can never be selected. But DROPPING IT
20
+ * SILENTLY is the same failure this file already has a memory about: the user
21
+ * holds a key, the tool shows nothing, and there is no way to tell "no agent"
22
+ * from "an agent in a shape I refuse to read".
23
+ *
24
+ * Shapes do vary in the wild: `prove-refusal.mjs` reads
25
+ * `privateKey ?? secret ?? agentKey`, so a file written by another path can
26
+ * land here unreadable. Reported, not hidden.
27
+ */
28
+ export function listUnloadable(cfg) {
29
+ const path = cfg.agentKeysPath ?? ".float/agent-keys.json";
30
+ if (!existsSync(path))
31
+ return [];
32
+ let parsed;
33
+ try {
34
+ parsed = JSON.parse(readFileSync(path, "utf8"));
35
+ }
36
+ catch (e) {
37
+ return [{ reason: `${path} is not readable JSON (${e.message}). A UTF-8 BOM does this.` }];
38
+ }
39
+ if (!Array.isArray(parsed))
40
+ return [{ reason: `${path} is not a JSON array.` }];
41
+ const out = [];
42
+ for (const e of parsed) {
43
+ if (!e || typeof e !== "object")
44
+ continue;
45
+ const id = (e.agentId ?? e.vault);
46
+ // Only this vault's entries; another vault's are not this tool's business.
47
+ if (cfg.vault && id !== cfg.vault)
48
+ continue;
49
+ if (typeof e.agentId === "string" && typeof e.secret === "string")
50
+ continue; // loadable
51
+ const has = ["secret", "privateKey", "agentKey"].filter((k) => typeof e[k] === "string");
52
+ out.push({
53
+ reason: typeof e.agentId !== "string"
54
+ ? `an entry for this vault has no string 'agentId', so it cannot be matched to a vault.`
55
+ : `an entry has no 'secret' field${has.length ? ` (it uses '${has.join("', '")}' instead)` : ""}, ` +
56
+ `which is the only field the keyring loader reads. It will never be selectable. ` +
57
+ `Re-create the agent with vault_add_role.`,
58
+ });
59
+ }
60
+ return out;
61
+ }
14
62
  /** Entries that `loadKeyring` would accept, nothing else. */
15
63
  function loadable(path) {
16
64
  if (!existsSync(path))
@@ -113,5 +161,6 @@ export function listAgents(cfg) {
113
161
  agentId: e.agentId,
114
162
  role: typeof e.role === "string" ? e.role : null,
115
163
  nodeId: typeof e.nodeId === "number" ? e.nodeId : undefined,
164
+ publicKey: typeof e.publicKey === "string" ? e.publicKey : undefined,
116
165
  }));
117
166
  }
package/dist/server.js CHANGED
@@ -56,12 +56,13 @@ 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";
64
+ import { listAgents, listUnloadable, selectRole } from "./roles.js";
65
+ import { resolveKeyringPath } from "./keyring-path.js";
65
66
  import { label as tokenLabel, tokenMeta } from "./token-meta.js";
66
67
  /**
67
68
  * A decimal NEAR string to yoctoNEAR, without floating point.
@@ -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.
@@ -381,7 +408,7 @@ WORTH KNOWING
381
408
  An implicit account (a 64-character hex id) cannot own a vault at all. Tell the user to use a
382
409
  named account rather than retrying.
383
410
  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 });
411
+ const server = new McpServer({ name: "float", version: "0.10.2" }, { instructions: INSTRUCTIONS });
385
412
  /**
386
413
  * THE FULL OPERATING GUIDE, AS AN MCP RESOURCE.
387
414
  *
@@ -588,16 +615,97 @@ server.tool("vault_await_create", "After the user signs the create link, confirm
588
615
  `under a label instead, which lands on a different address.`,
589
616
  });
590
617
  }
618
+ /**
619
+ * WHAT ACTUALLY STOPS THIS VAULT PAYING, read from the chain.
620
+ *
621
+ * The old `nextSteps` were a FIXED list ending "nothing can spend from it
622
+ * until an agent is bound — vault_add_role". For a vault created with
623
+ * `bindAgent` that is simply false: the agent is already bound, and the
624
+ * real gate is that the vault holds no tokens. Measured 2026-11-13 — the
625
+ * agent relaying it had to correct the tool in front of the user.
626
+ *
627
+ * Worse, nothing told the owner HOW to fund it, or that funding is a plain
628
+ * wallet send to an address, or what that address is. This is the moment
629
+ * they need it: they have just signed, they are looking at the result, and
630
+ * the next thing standing between them and a working vault is money.
631
+ *
632
+ * So the steps are derived from live state instead of asserted. Three
633
+ * reads, once, on a path that runs once per vault.
634
+ */
635
+ const rootToken = await (async () => {
636
+ // THE TREE IS AUTHORITATIVE, not cfg.token: at creation time
637
+ // NEAR_VAULT_CONTRACT is typically unset, so the configured token may be
638
+ // empty while the vault plainly names one in its root limits.
639
+ const tree = await viewFunction(rpc, target, "get_tree", {}).catch(() => null);
640
+ return tree?.find((n) => n.id === 0)?.limits?.[0]?.token;
641
+ })();
642
+ const meta = rootToken
643
+ ? await tokenMeta(rpc, rootToken, (r, t) => viewFunction(r, t, "ft_metadata", {})).catch(() => null)
644
+ : null;
645
+ const symbol = meta?.symbol ?? "the token";
646
+ const [held, registered, agentKeys] = await Promise.all([
647
+ rootToken
648
+ ? viewFunction(rpc, rootToken, "ft_balance_of", { account_id: target }).catch(() => null)
649
+ : Promise.resolve(null),
650
+ rootToken
651
+ ? viewFunction(rpc, rootToken, "storage_balance_of", {
652
+ account_id: target,
653
+ }).catch(() => undefined)
654
+ : Promise.resolve(undefined),
655
+ viewAccessKeys(rpc, target)
656
+ // Narrowed the same way vault_gas does: `permission` is a union and
657
+ // only the gas-key arm carries this field.
658
+ .then((ks) => ks.filter((k) => k.access_key?.permission
659
+ ?.GasKeyFunctionCall !== undefined).length)
660
+ .catch(() => null),
661
+ ]);
662
+ const empty = held !== null && BigInt(held) === 0n;
663
+ const funding = {
664
+ token: rootToken ?? null,
665
+ symbol: rootToken ? symbol : null,
666
+ balance: held === null ? null : fromBaseUnits(BigInt(held), meta?.decimals ?? cfg.decimals),
667
+ /** `undefined` = could not read; `null` = read and NOT registered. */
668
+ registeredOnToken: registered === undefined ? null : registered !== null,
669
+ sendTo: target,
670
+ howTo: `Funding is a plain send from the owner's own wallet: send ${symbol} to ${target}. ` +
671
+ `This server cannot do it — the agent key cannot touch a token contract, which is the ` +
672
+ `point of the product.`,
673
+ };
674
+ const nextSteps = [
675
+ `Set NEAR_VAULT_CONTRACT=${target} so every other tool reads this vault.`,
676
+ ];
677
+ /**
678
+ * UNREGISTERED IS NAMED FIRST, because it is the one that makes a transfer
679
+ * FAIL rather than merely be impossible. Vaults created before the factory
680
+ * registered them are still in this state; the fix is forward-only.
681
+ */
682
+ if (registered === null) {
683
+ nextSteps.push(`REGISTER IT FIRST: ${target} is not registered on ${rootToken}, and a transfer to an ` +
684
+ `unregistered account FAILS. The owner must call storage_deposit on the token with ` +
685
+ `about 0.00125 NEAR attached and registration_only: true. Vaults created through the ` +
686
+ `current factory are registered automatically; this one was not.`);
687
+ }
688
+ if (empty) {
689
+ nextSteps.push(`FUND IT: the vault holds no ${symbol}. Tell the owner to send ${symbol} to ${target} ` +
690
+ `from their own wallet, and ASK how much they want to move. Nothing can be paid out ` +
691
+ `until it has a balance — the budget caps what MAY be spent, not what exists.`);
692
+ }
693
+ if (agentKeys === 0) {
694
+ nextSteps.push(`No agent is bound yet, so nothing can authorise a payment — vault_add_role.`);
695
+ }
696
+ if (!empty && agentKeys !== 0 && registered !== null) {
697
+ nextSteps.push(`Ready. vault_budget shows today's allowance; vault_pay makes a payment.`);
698
+ }
699
+ else {
700
+ nextSteps.push(`vault_tree shows the budget shape; vault_budget shows what is left today.`);
701
+ }
591
702
  return json({
592
703
  status: "LIVE",
593
704
  vault: target,
594
705
  owner: config.owner,
595
706
  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
- ],
707
+ funding,
708
+ nextSteps,
601
709
  });
602
710
  });
603
711
  /**
@@ -907,32 +1015,103 @@ server.tool("vault_add_role", "Add a NEW departmental agent to an existing vault
907
1015
  .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
1016
  }, async ({ role, dailyCap, perTxCap, recipients, token, cooldownSec, fundingNear }) => {
909
1017
  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.`);
1018
+ /**
1019
+ * A DUPLICATE ROLE IS REFUSED — BUT "IN THE KEYRING" IS NOT "ALIVE".
1020
+ *
1021
+ * The contract would happily create a second node with the same label, and
1022
+ * `selectRole` resolves with `find`, so the FIRST entry wins forever: the
1023
+ * older agent would stay bound and funded on chain with nothing able to
1024
+ * select it. That part of the old guard was right.
1025
+ *
1026
+ * What was wrong was treating a keyring entry as proof an agent exists. A
1027
+ * `vault_add_role` that writes a key and then fails to land on chain leaves
1028
+ * an ORPHAN — a role bound to nothing, no node, no gas key — and this guard
1029
+ * then refused the user's own retry, citing the wreckage of the attempt
1030
+ * that failed thirty seconds earlier. Reported 2026-11-13 for a `travel`
1031
+ * agent; the user had to go and find the file by hand.
1032
+ *
1033
+ * So ask the CHAIN, which is the only thing that knows:
1034
+ * bound to a node -> a real agent. Refuse, as before.
1035
+ * not bound -> an orphan. Retire it and carry on.
1036
+ * chain unreachable or no recorded key -> cannot tell. Refuse, and SAY
1037
+ * it is a "cannot tell", not a "there is one".
1038
+ */
1039
+ let retired = null;
1040
+ const clash = listAgents(cfg).find((a) => a.role === role);
1041
+ if (clash) {
1042
+ const boundNode = clash.publicKey
1043
+ ? await viewFunction(cfg.rpcUrl, vault, "get_virtual_agent_node", {
1044
+ public_key: clash.publicKey,
1045
+ }).catch(() => undefined)
1046
+ : undefined;
1047
+ if (boundNode === undefined) {
1048
+ return text(`There is already a key filed under the role '${role}' for this vault, and I could not ` +
1049
+ `confirm with ${vault} whether it is a working agent — ` +
1050
+ `${clash.publicKey ? "the network read failed" : "that entry predates recorded public keys"}. ` +
1051
+ `Refusing rather than guessing: adding a second '${role}' would permanently hide ` +
1052
+ `whichever was filed first, and if the existing one IS bound and funded, nothing ` +
1053
+ `would be able to select it again. Try vault_roles to see the fleet, or pick ` +
1054
+ `another name.`);
1055
+ }
1056
+ if (boundNode !== null) {
1057
+ return text(`This vault already has a working agent with the role '${role}' — it is bound to ` +
1058
+ `node ${boundNode}. A second one would hide it, and it would stay funded on chain ` +
1059
+ `with nothing able to select it. Pick another name, or just use the existing agent.`);
1060
+ }
1061
+ /**
1062
+ * ORPHAN. The chain says this key is bound to nothing, so it cannot be
1063
+ * anyone's working agent. Its name is taken off it — the secret stays on
1064
+ * disk, see `retireRole` — and the request proceeds, which is what the
1065
+ * user asked for in the first place.
1066
+ */
1067
+ retired = retireRole({
1068
+ keyringPath: cfg.agentKeysPath,
1069
+ vault,
1070
+ publicKey: clash.publicKey,
1071
+ });
919
1072
  }
1073
+ /**
1074
+ * VALIDATE EVERYTHING BEFORE A KEY TOUCHES DISK.
1075
+ *
1076
+ * This used to generate and WRITE the agent's key, and only then build the
1077
+ * payload — which calls `tokenFor()` and the amount conversions, any of
1078
+ * which can throw. So a call that failed validation left a real keypair
1079
+ * filed under the role name, bound to nothing: no node, no gas key. The
1080
+ * user's retry then hit "this vault already has an agent with the role
1081
+ * 'travel'" and was blocked by the wreckage of their own failed attempt.
1082
+ *
1083
+ * Measured 2026-11-13: a `vault_add_role` that failed on a missing token
1084
+ * wrote `travel` to the keyring 55 seconds before the retry was refused.
1085
+ *
1086
+ * Everything that can throw now runs FIRST, against a throwaway public key,
1087
+ * so a failure writes nothing at all. The key is generated only once the
1088
+ * payload is known to be buildable.
1089
+ */
920
1090
  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.
1091
+ let limits;
1092
+ try {
1093
+ limits = [{ token: tokenFor(token), dailyCap, perTxCap, decimals: cfg.decimals }];
1094
+ // Build it once with a placeholder to force every conversion and check
1095
+ // to run. Nothing is kept; this is a dry run for its exceptions.
1096
+ planAddRole(vault, role, limits, cooldownSec ?? 0, recipients ?? null, PLACEHOLDER_PUBLIC_KEY, fundingYocto);
1097
+ }
1098
+ catch (e) {
1099
+ return text(`${String(e.message)}${String.fromCharCode(10)}${String.fromCharCode(10)}` +
1100
+ `Nothing was written — no key was generated, so '${role}' is still free to use once ` +
1101
+ `this is corrected.`);
1102
+ }
1103
+ // Only now. `nodeId: null` — NOT YET KNOWN: `add_role` creates the node
1104
+ // inside the contract, so its id does not exist until the transaction lands.
923
1105
  const generated = generateAgentKey({
924
1106
  vault,
925
- dir: dirname(cfg.agentKeysPath),
1107
+ // The FULL configured path, not its directory. Passing the directory
1108
+ // discarded the filename, so a keyring pinned to anything other than
1109
+ // `agent-keys.json` was read from one file and written to another.
1110
+ keyringPath: cfg.agentKeysPath,
926
1111
  role,
927
1112
  nodeId: null,
928
1113
  });
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
- }
1114
+ const payload = planAddRole(vault, role, limits, cooldownSec ?? 0, recipients ?? null, generated.publicKey, fundingYocto);
936
1115
  return json({
937
1116
  role,
938
1117
  agent: {
@@ -946,30 +1125,165 @@ server.tool("vault_add_role", "Add a NEW departmental agent to an existing vault
946
1125
  `credential is filed under the ROLE, so vault_pay({role:"${role}"}) works ` +
947
1126
  `without it.`,
948
1127
  ],
1128
+ // Only present when something was actually cleaned up, so the ordinary
1129
+ // case stays quiet. An orphan silently replaced would be the same
1130
+ // invisible-state problem in a new costume.
1131
+ ...(retired
1132
+ ? {
1133
+ replaced: `A key was already filed under '${role}' but ${vault} reports it bound to ` +
1134
+ `nothing — a leftover from an earlier attempt that never landed on chain. It has ` +
1135
+ `been renamed '${retired.retiredTo}' so it cannot shadow this one. Its secret ` +
1136
+ `was NOT deleted.`,
1137
+ }
1138
+ : {}),
949
1139
  warnings: [
950
1140
  `Show the owner this public key and have them confirm it before signing: ${generated.publicKey}`,
951
1141
  `The private key is at ${generated.keyringPath}. Do not print it.`,
1142
+ // A second keyring holding keys for THIS vault means roles this server
1143
+ // cannot see. Said here rather than left to surface later as "my agent
1144
+ // vanished".
1145
+ ...(KEYRING.alsoFound.length
1146
+ ? [
1147
+ `Another keyring also holds keys for this vault and is NOT being read: ` +
1148
+ `${KEYRING.alsoFound.join(", ")}. This one was chosen. Set ` +
1149
+ `NEAR_VAULT_AGENT_KEYS to pin one deliberately.`,
1150
+ ]
1151
+ : []),
952
1152
  ],
953
1153
  });
954
1154
  });
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.",
1155
+ 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 () => {
1156
+ const vault = requireVault();
1157
+ /**
1158
+ * THE KEYRING IS A CLAIM. THE CHAIN IS THE FACT.
1159
+ *
1160
+ * This used to report `listAgents(cfg)` verbatim — role, nodeId and all —
1161
+ * straight out of the local file, without ever asking the vault whether it
1162
+ * had heard of those keys. So a keyring pointing at the wrong directory
1163
+ * reported a healthy, signable agent on node 1 that the vault had never
1164
+ * been told about, and `vault_pay` then signed with it and came back
1165
+ * NOT_VIRTUAL_AGENT.
1166
+ *
1167
+ * Measured 2026-11-13: that cost a user a whole session of forensics —
1168
+ * reading .claude.json by hand, diffing two keyring files, checking gas
1169
+ * balances — to find a single wrong path. The tool whose entire job is
1170
+ * "which agents can I sign as" was the one reporting the false positive,
1171
+ * which is why nothing caught it earlier.
1172
+ *
1173
+ * So every entry is now resolved against the vault:
1174
+ * get_virtual_agent_node(pk) -> the node the CHAIN binds it to, or null
1175
+ * the access-key list -> is a funded gas key actually installed
1176
+ *
1177
+ * And the INVERSE is reported too: a gas key on the vault that is not in
1178
+ * the keyring is an agent you cannot sign as. Both directions of the
1179
+ * disagreement matter, and only one of them was ever visible.
1180
+ */
1181
+ const local = listAgents(cfg);
1182
+ const onChainKeys = await viewAccessKeys(cfg.rpcUrl, vault)
1183
+ .then((ks) => ks
1184
+ .map((k) => ({
1185
+ publicKey: k.public_key,
1186
+ gas: k.access_key?.permission?.GasKeyFunctionCall,
1187
+ }))
1188
+ .filter((k) => k.gas !== undefined))
1189
+ .catch(() => null);
1190
+ const resolved = await Promise.all(local.map(async (r) => {
1191
+ if (!r.publicKey) {
1192
+ return {
1193
+ role: r.role,
1194
+ agentId: r.agentId,
1195
+ status: "UNVERIFIABLE",
1196
+ detail: "This keyring entry has no public key recorded, so the chain cannot be asked " +
1197
+ "about it. It predates the field. Re-create the agent with vault_add_role.",
1198
+ };
1199
+ }
1200
+ const node = await viewFunction(cfg.rpcUrl, vault, "get_virtual_agent_node", {
1201
+ public_key: r.publicKey,
1202
+ }).catch(() => undefined);
1203
+ const installed = onChainKeys?.find((k) => k.publicKey === r.publicKey);
1204
+ const balance = installed?.gas?.balance;
1205
+ if (node === undefined) {
1206
+ return { role: r.role, agentId: r.agentId, status: "UNKNOWN", detail: "could not reach the chain" };
970
1207
  }
971
- : {}),
972
- }))));
1208
+ if (node === null) {
1209
+ return {
1210
+ role: r.role,
1211
+ agentId: r.agentId,
1212
+ publicKey: r.publicKey,
1213
+ status: "DEAD",
1214
+ detail: `This key is NOT bound on ${vault}. Signing with it returns NOT_VIRTUAL_AGENT. ` +
1215
+ `It is a key for a different vault, or left over from one that was torn down, or ` +
1216
+ `this client is reading the wrong keyring. Do NOT use it; vault_add_role creates ` +
1217
+ `one the vault will accept.`,
1218
+ };
1219
+ }
1220
+ return {
1221
+ role: r.role,
1222
+ agentId: r.agentId,
1223
+ nodeId: node,
1224
+ // The LOCAL nodeId is reported only when it disagrees, because a
1225
+ // disagreement means the file is stale and that is worth saying.
1226
+ ...(r.nodeId !== undefined && r.nodeId !== node
1227
+ ? { staleLocalNodeId: r.nodeId, note: "the keyring records a different node; the chain wins" }
1228
+ : {}),
1229
+ status: installed ? "USABLE" : "BOUND_BUT_NO_GAS_KEY",
1230
+ gasNear: balance ? fromBaseUnits(BigInt(balance), 24) : null,
1231
+ ...(installed
1232
+ ? {}
1233
+ : {
1234
+ detail: "Bound to a node, so it may AUTHORISE a payment, but no gas key is installed " +
1235
+ "on the vault, so it cannot SUBMIT one. float-agent installs and funds it.",
1236
+ }),
1237
+ };
1238
+ }));
1239
+ const heldKeys = new Set(local.map((r) => r.publicKey).filter(Boolean));
1240
+ const unheld = (onChainKeys ?? [])
1241
+ .filter((k) => !heldKeys.has(k.publicKey))
1242
+ .map((k) => ({
1243
+ publicKey: k.publicKey,
1244
+ status: "ON_CHAIN_BUT_NO_LOCAL_KEY",
1245
+ detail: "The vault has this agent installed, but this client does not hold its private key, " +
1246
+ "so it cannot sign as it. It belongs to another machine or another client.",
1247
+ }));
1248
+ const unloadable = listUnloadable(cfg);
1249
+ const usable = resolved.filter((r) => r.status === "USABLE").length;
1250
+ return json({
1251
+ vault,
1252
+ usable,
1253
+ /**
1254
+ * WHICH FILE THIS ANSWER CAME FROM.
1255
+ *
1256
+ * Every status below is a claim about credentials in one specific file,
1257
+ * and that file is CHOSEN (see `keyring-path.ts`) — so an answer that
1258
+ * does not name it cannot be checked. Omitting it is what turned "my
1259
+ * travel agent is missing" into a session of hand-diffing keyrings:
1260
+ * the tool had the path and did not say it.
1261
+ */
1262
+ keyring: {
1263
+ path: cfg.agentKeysPath,
1264
+ chosenBecause: KEYRING.why,
1265
+ ...(KEYRING.alsoFound.length
1266
+ ? {
1267
+ NOT_being_read: KEYRING.alsoFound,
1268
+ warning: `Another keyring also holds keys for this vault. Agents filed there will NOT ` +
1269
+ `appear above. Set NEAR_VAULT_AGENT_KEYS to pin one deliberately.`,
1270
+ }
1271
+ : {}),
1272
+ },
1273
+ agents: resolved,
1274
+ ...(unheld.length ? { onChainWithoutLocalKey: unheld } : {}),
1275
+ // Entries the loader refuses to read. Reported rather than dropped, so
1276
+ // "I have a key but this shows nothing" is answerable.
1277
+ ...(unloadable.length ? { unreadableKeyringEntries: unloadable } : {}),
1278
+ ...(usable === 0
1279
+ ? {
1280
+ warning: `NO USABLE AGENT. vault_pay cannot succeed against ${vault} from this client. ` +
1281
+ `Every entry above is dead, unbound, or missing its gas key — the commonest cause ` +
1282
+ `is NEAR_VAULT_AGENT_KEYS pointing at a keyring for a different vault.`,
1283
+ }
1284
+ : {}),
1285
+ });
1286
+ });
973
1287
  // ------------------------------------------------------------- onboarding
974
1288
  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
1289
  owner: z
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floatonnear/mcp",
3
- "version": "0.10.0",
3
+ "version": "0.10.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",