@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 +49 -3
- package/dist/agent-key.d.ts +50 -0
- package/dist/agent-key.js +68 -2
- package/dist/create.d.ts +10 -0
- package/dist/create.js +23 -1
- package/dist/keyring-path.d.ts +36 -0
- package/dist/keyring-path.js +117 -0
- package/dist/roles.d.ts +29 -0
- package/dist/roles.js +49 -0
- package/dist/server.js +362 -48
- package/package.json +1 -1
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.
|
|
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.
|
|
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
|
package/dist/agent-key.d.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
|
|
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.
|
|
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
|
-
|
|
597
|
-
|
|
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
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
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
|
-
|
|
922
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
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.
|
|
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",
|