@floatonnear/mcp 0.6.2 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/create.d.ts CHANGED
@@ -183,7 +183,7 @@ export interface CreatePlanInput {
183
183
  * per day?" and "how much per payment?".
184
184
  *
185
185
  * WHY THESE EXIST. The signing page has read `?daily=`, `?pertx=`, `?token=`
186
- * and `?decimals=` since it was written, defaulting to 2.04/0.51 USDC on
186
+ * and `?decimals=` since it was written, defaulting to 25/10 USDC on
187
187
  * mainnet. This builder sent four params and never those, so an agent could
188
188
  * not ask the user their budget and hand back a ready-to-sign link — which
189
189
  * is the entire point of the flow. The page was finished; this side was not,
package/dist/create.js CHANGED
@@ -155,7 +155,7 @@ export function validateCaps(i) {
155
155
  if (v === undefined)
156
156
  continue;
157
157
  if (!numeric.test(v)) {
158
- problems.push(`${name} must be a plain decimal in HUMAN units, e.g. "100" or "2.04", got "${v}"`);
158
+ problems.push(`${name} must be a plain decimal in HUMAN units, e.g. "100" or "25", got "${v}"`);
159
159
  continue;
160
160
  }
161
161
  if (Number(v) <= 0) {
package/dist/roles.d.ts CHANGED
@@ -16,3 +16,28 @@ export declare function selectRole(cfg: {
16
16
  export declare function listRoles(cfg: {
17
17
  agentKeysPath?: string;
18
18
  }): RoleCredential[];
19
+ /**
20
+ * EVERY credential in the keyring, named or not.
21
+ *
22
+ * `listRoles` filters to entries carrying a `role`, which is right for
23
+ * SELECTION — you cannot pick by a name that does not exist. It is wrong for
24
+ * REPORTING, and `vault_roles` used it for reporting.
25
+ *
26
+ * `vault_create({ bindAgent: true })` calls `generateAgentKey({ vault })` with
27
+ * no role, so `JSON.stringify` drops the field and the entry is invisible to
28
+ * `listRoles`. Measured 2026-11-10: a vault with a funded, correctly-pinned
29
+ * gas key and a live agent node reported `vault_roles` -> `[]`, which reads as
30
+ * "you have no agents" rather than "you have one with no name". The user then
31
+ * cannot tell an empty keyring from a working unnamed agent, and the obvious
32
+ * next move — bind another — is the wrong one.
33
+ *
34
+ * Returns `role: null` for those rather than inventing a name, because the
35
+ * name is genuinely absent and the fix is `vault_add_role`, not a guess.
36
+ */
37
+ export declare function listAgents(cfg: {
38
+ agentKeysPath?: string;
39
+ }): {
40
+ agentId: string;
41
+ role: string | null;
42
+ nodeId?: number;
43
+ }[];
package/dist/roles.js CHANGED
@@ -59,3 +59,28 @@ export function listRoles(cfg) {
59
59
  nodeId: typeof e.nodeId === "number" ? e.nodeId : undefined,
60
60
  }));
61
61
  }
62
+ /**
63
+ * EVERY credential in the keyring, named or not.
64
+ *
65
+ * `listRoles` filters to entries carrying a `role`, which is right for
66
+ * SELECTION — you cannot pick by a name that does not exist. It is wrong for
67
+ * REPORTING, and `vault_roles` used it for reporting.
68
+ *
69
+ * `vault_create({ bindAgent: true })` calls `generateAgentKey({ vault })` with
70
+ * no role, so `JSON.stringify` drops the field and the entry is invisible to
71
+ * `listRoles`. Measured 2026-11-10: a vault with a funded, correctly-pinned
72
+ * gas key and a live agent node reported `vault_roles` -> `[]`, which reads as
73
+ * "you have no agents" rather than "you have one with no name". The user then
74
+ * cannot tell an empty keyring from a working unnamed agent, and the obvious
75
+ * next move — bind another — is the wrong one.
76
+ *
77
+ * Returns `role: null` for those rather than inventing a name, because the
78
+ * name is genuinely absent and the fix is `vault_add_role`, not a guess.
79
+ */
80
+ export function listAgents(cfg) {
81
+ return loadable(cfg.agentKeysPath ?? ".float/agent-keys.json").map((e) => ({
82
+ agentId: e.agentId,
83
+ role: typeof e.role === "string" ? e.role : null,
84
+ nodeId: typeof e.nodeId === "number" ? e.nodeId : undefined,
85
+ }));
86
+ }
package/dist/server.d.ts CHANGED
@@ -16,7 +16,11 @@
16
16
  *
17
17
  * Configure with:
18
18
  * NEAR_VAULT_CONTRACT required by every tool EXCEPT vault_create
19
- * NEAR_VAULT_RPC_URL default https://test.rpc.fastnear.com
19
+ * NEAR_VAULT_RPC_URL OPTIONAL. Defaults to the RPC for
20
+ * NEAR_VAULT_NETWORK — free.rpc.fastnear.com on
21
+ * mainnet, test.rpc.fastnear.com on testnet. It
22
+ * used to default to the testnet URL regardless,
23
+ * so every mainnet install read the wrong chain.
20
24
  * NEAR_VAULT_TOKEN default token for pay/budget calls
21
25
  * NEAR_VAULT_TOKEN_DECIMALS default 6
22
26
  * NEAR_VAULT_AGENT_ID only needed to PAY. Taken from the key file's
package/dist/server.js CHANGED
@@ -16,7 +16,11 @@
16
16
  *
17
17
  * Configure with:
18
18
  * NEAR_VAULT_CONTRACT required by every tool EXCEPT vault_create
19
- * NEAR_VAULT_RPC_URL default https://test.rpc.fastnear.com
19
+ * NEAR_VAULT_RPC_URL OPTIONAL. Defaults to the RPC for
20
+ * NEAR_VAULT_NETWORK — free.rpc.fastnear.com on
21
+ * mainnet, test.rpc.fastnear.com on testnet. It
22
+ * used to default to the testnet URL regardless,
23
+ * so every mainnet install read the wrong chain.
20
24
  * NEAR_VAULT_TOKEN default token for pay/budget calls
21
25
  * NEAR_VAULT_TOKEN_DECIMALS default 6
22
26
  * NEAR_VAULT_AGENT_ID only needed to PAY. Taken from the key file's
@@ -51,11 +55,11 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
51
55
  import { z } from "zod";
52
56
  import { fromBaseUnits, toBaseUnits } from "./amounts.js";
53
57
  import { DEFAULT_GAS_TGAS, planAddNode, planAddRole, planBindVirtualAgent, planUnbindVirtualAgent, planFreezeNode, planPurgeSettled, planSetCooldown, planSetNodeCaps, planSetPaused, planSetRecipients, toCliCommand, planSetApprovalThreshold, planApprovePayment, planRejectPayment, planWithdraw, } from "./payloads.js";
54
- import { accountExists, rpcForNetwork, DEFAULT_RPC_URL, newRequestId, requireAgentKey, viewAccessKeys, viewAccount, viewFunction, } from "./rpc.js";
58
+ import { accountExists, rpcForNetwork, newRequestId, requireAgentKey, viewAccessKeys, viewAccount, viewFunction, } from "./rpc.js";
55
59
  import { AGENT_NODE_ID, generateAgentKey } from "./agent-key.js";
56
60
  import { resolveKeySource } from "./key-file.js";
57
61
  import { dirname } from "node:path";
58
- import { listRoles, selectRole } from "./roles.js";
62
+ import { listAgents, listRoles, selectRole } from "./roles.js";
59
63
  /**
60
64
  * A decimal NEAR string to yoctoNEAR, without floating point.
61
65
  *
@@ -114,9 +118,46 @@ for (const err of [agentKeySource.error, quoteSignerSource.error]) {
114
118
  if (err)
115
119
  console.error(`float: ${err}`);
116
120
  }
121
+ /**
122
+ * The network, resolved BEFORE `cfg` so the RPC can follow it.
123
+ *
124
+ * It used to be read inside the object literal, which meant `rpcUrl` could not
125
+ * see it and fell back to a constant. See below.
126
+ */
127
+ const NETWORK = process.env.NEAR_VAULT_NETWORK ?? "testnet";
117
128
  const cfg = {
118
129
  vault: process.env.NEAR_VAULT_CONTRACT ?? "",
119
- rpcUrl: process.env.NEAR_VAULT_RPC_URL ?? DEFAULT_RPC_URL,
130
+ /**
131
+ * THE RPC FOLLOWS THE NETWORK. It did not, and that broke every mainnet
132
+ * install.
133
+ *
134
+ * This was `process.env.NEAR_VAULT_RPC_URL ?? DEFAULT_RPC_URL`, and
135
+ * `DEFAULT_RPC_URL` is the TESTNET endpoint. So a user who set
136
+ * `NEAR_VAULT_NETWORK=mainnet` — the documented way to be on mainnet — and
137
+ * did not also happen to set `NEAR_VAULT_RPC_URL` had all fourteen tools
138
+ * that use `cfg.rpcUrl` querying TESTNET for a mainnet vault. Reads answered
139
+ * "account does not exist" about an account holding 3.35 NEAR, and
140
+ * `vault_pay` would have submitted a real payment to the wrong chain.
141
+ *
142
+ * Measured 2026-11-10 on test015-near.usefloat.near: `vault_config` and
143
+ * `vault_tree` reported the vault missing while `vault_await_create` read
144
+ * its config fine — the two tools differ only in that `vault_create` and
145
+ * `vault_await_create` call `rpcForNetwork()` and everything else took this
146
+ * constant.
147
+ *
148
+ * THAT ASYMMETRY ALSO DEFEATED THE DIAGNOSIS. The testnet theory was correct
149
+ * and was "refuted" by testing it with `vault_await_create`, the one tool
150
+ * immune to the bug — so the right answer was discarded on the strength of a
151
+ * check that could not fail. Pick the probe that exercises the broken path,
152
+ * not the convenient one.
153
+ *
154
+ * `rpcForNetwork` honours `NEAR_VAULT_RPC_URL` only when it applies to the
155
+ * network in play, which is the behaviour `vault_create` already relied on.
156
+ */
157
+ rpcUrl: rpcForNetwork(NETWORK, {
158
+ configuredNetwork: NETWORK,
159
+ override: process.env.NEAR_VAULT_RPC_URL,
160
+ }),
120
161
  token: process.env.NEAR_VAULT_TOKEN ?? "",
121
162
  decimals: Number(process.env.NEAR_VAULT_TOKEN_DECIMALS ?? "6"),
122
163
  // The credentials file carries the account as well as the key, so one path
@@ -126,7 +167,7 @@ const cfg = {
126
167
  agentKey: agentKeySource.secret,
127
168
  agentKeyError: agentKeySource.error,
128
169
  agentKeyAccountId: agentKeySource.accountId,
129
- network: process.env.NEAR_VAULT_NETWORK ?? "testnet",
170
+ network: NETWORK,
130
171
  // Onboarding only. Both have safe defaults and neither is needed by any
131
172
  // tool that touches an existing vault.
132
173
  factory: process.env.NEAR_VAULT_FACTORY,
@@ -251,7 +292,7 @@ WORTH KNOWING
251
292
  An implicit account (a 64-character hex id) cannot own a vault at all. Tell the user to use a
252
293
  named account rather than retrying.
253
294
  Amounts are whole tokens in these tools (1.5 means 1.5 USDC), never base units.`;
254
- const server = new McpServer({ name: "float", version: "0.6.2" }, { instructions: INSTRUCTIONS });
295
+ const server = new McpServer({ name: "float", version: "0.6.4" }, { instructions: INSTRUCTIONS });
255
296
  // ----------------------------------------------------------------- reads
256
297
  /**
257
298
  * Wait for a create to land, and prove the result belongs to the user.
@@ -275,7 +316,10 @@ const server = new McpServer({ name: "float", version: "0.6.2" }, { instructions
275
316
  * after a write can be a whole transaction behind.
276
317
  */
277
318
  server.tool("vault_await_create", "After the user signs the create link, confirm the vault is live and VERIFY IT IS THEIRS. Reads the chain, so it needs no key. Call this instead of asking the user to paste their vault id back — vault_create already told you the address.", {
278
- vault: z.string().describe("the derived vault id, from vault_create's `confirm.vault`"),
319
+ vault: z
320
+ .string()
321
+ .optional()
322
+ .describe("the derived vault id, from vault_create's `confirm.vault`. OMIT IT if the user already created their vault and you never built the link — it is derived from `owner` via the factory's own vault_id_for view."),
279
323
  owner: z.string().describe("who it must belong to — compared against the vault's own get_config.owner"),
280
324
  network: z.enum(["mainnet", "testnet"]).optional(),
281
325
  timeoutSeconds: z
@@ -291,10 +335,41 @@ server.tool("vault_await_create", "After the user signs the create link, confirm
291
335
  configuredNetwork: cfg.network,
292
336
  override: process.env.NEAR_VAULT_RPC_URL,
293
337
  });
338
+ // DERIVE IT WHEN THE CALLER HAS NO LINK TO QUOTE.
339
+ //
340
+ // A user who says "I already created it" never gave this server a plan to
341
+ // read `confirm.vault` out of, and does not know their vault id — it is
342
+ // derived, not chosen. Requiring it here left the agent with only one tool
343
+ // that knows the derivation: `vault_create`. Measured 2026-11-10, that is
344
+ // exactly what an agent did, and `vault_create` THREW — correctly —
345
+ // "already exists and test015.near owns it". The agent then reported that
346
+ // refusal to the user as "Confirmed ✅". It reached the right answer by
347
+ // reinterpreting an error, which is luck, not a workflow.
348
+ //
349
+ // So adoption gets a first-class path: `owner` alone is enough. The
350
+ // derivation comes from the factory's `vault_id_for` view, the same single
351
+ // source `buildCreatePlan` uses and deliberately does not reimplement.
352
+ let target = vault;
353
+ if (!target) {
354
+ const fac = cfg.factory ?? FACTORY_BY_NETWORK[net];
355
+ if (!fac) {
356
+ return text(`No factory is configured for ${net}, so the vault id cannot be derived. ` +
357
+ `Pass \`vault\` directly, or set NEAR_VAULT_FACTORY.`);
358
+ }
359
+ const derived = await viewFunction(rpc, fac, "vault_id_for", {
360
+ owner,
361
+ }).catch(() => null);
362
+ if (!derived) {
363
+ return text(`${fac} could not derive a vault id for ${owner} on ${net}. Either the ` +
364
+ `owner id is wrong, or it has no legal derived id (an implicit account ` +
365
+ `has none — the derived name exceeds 64 characters).`);
366
+ }
367
+ target = derived;
368
+ }
294
369
  const deadline = Date.now() + (timeoutSeconds ?? 90) * 1000;
295
370
  let appeared = false;
296
371
  for (;;) {
297
- if (await accountExists(rpc, vault).catch(() => false)) {
372
+ if (await accountExists(rpc, target).catch(() => false)) {
298
373
  appeared = true;
299
374
  break;
300
375
  }
@@ -305,29 +380,29 @@ server.tool("vault_await_create", "After the user signs the create link, confirm
305
380
  if (!appeared) {
306
381
  return json({
307
382
  status: "NOT_YET",
308
- vault,
309
- instruction: `${vault} does not exist yet. That is not a failure — the user may not have ` +
383
+ vault: target,
384
+ instruction: `${target} does not exist yet. That is not a failure — the user may not have ` +
310
385
  `signed, or may have cancelled. Ask whether they approved it, and call this ` +
311
386
  `again. Do NOT tell them the vault is created.`,
312
387
  });
313
388
  }
314
389
  // THE OWNER READ IS THE POINT. Getting here only proves an account exists.
315
- const config = await viewFunction(rpc, vault, "get_config", {}).catch(() => null);
390
+ const config = await viewFunction(rpc, target, "get_config", {}).catch(() => null);
316
391
  if (!config || typeof config.owner !== "string") {
317
392
  return json({
318
393
  status: "UNVERIFIED",
319
- vault,
320
- instruction: `${vault} exists but its get_config could not be read, so whether it is ${owner}'s ` +
394
+ vault: target,
395
+ instruction: `${target} exists but its get_config could not be read, so whether it is ${owner}'s ` +
321
396
  `is UNKNOWN — not assumed. It may not be a Float vault at all. Do not point an ` +
322
- `agent at it until this is resolved: near view ${vault} get_config`,
397
+ `agent at it until this is resolved: near view ${target} get_config`,
323
398
  });
324
399
  }
325
400
  if (config.owner !== owner) {
326
401
  return json({
327
402
  status: "NOT_YOURS",
328
- vault,
403
+ vault: target,
329
404
  actualOwner: config.owner,
330
- instruction: `${vault} exists but is owned by ${config.owner}, NOT ${owner}. The vault id ` +
405
+ instruction: `${target} exists but is owned by ${config.owner}, NOT ${owner}. The vault id ` +
331
406
  `derivation is not injective, so a different account can hold this address — ` +
332
407
  `whoever created it first. Do NOT configure anything against it. Create one ` +
333
408
  `under a label instead, which lands on a different address.`,
@@ -335,11 +410,11 @@ server.tool("vault_await_create", "After the user signs the create link, confirm
335
410
  }
336
411
  return json({
337
412
  status: "LIVE",
338
- vault,
413
+ vault: target,
339
414
  owner: config.owner,
340
415
  config,
341
416
  nextSteps: [
342
- `Set NEAR_VAULT_CONTRACT=${vault} so every other tool reads this vault.`,
417
+ `Set NEAR_VAULT_CONTRACT=${target} so every other tool reads this vault.`,
343
418
  `vault_tree shows the budget; vault_budget shows what is left today.`,
344
419
  `Nothing can spend from it until an agent is bound with a budget — vault_add_role.`,
345
420
  ],
@@ -435,7 +510,24 @@ server.tool("vault_add_role", "Add a NEW departmental agent to an existing vault
435
510
  ],
436
511
  });
437
512
  });
438
- 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(listRoles(cfg).map((r) => ({ role: r.role, nodeId: r.nodeId, agentId: r.agentId }))));
513
+ 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(
514
+ // `listAgents`, NOT `listRoles`. The latter filters to entries carrying a
515
+ // role, which is correct for SELECTION and wrong for REPORTING: a
516
+ // creation-time agent from `bindAgent` has no role, so a working vault
517
+ // with a funded gas key reported `[]` — indistinguishable from having no
518
+ // agent at all, and the obvious next move is then the wrong one.
519
+ listAgents(cfg).map((r) => ({
520
+ role: r.role,
521
+ nodeId: r.nodeId,
522
+ agentId: r.agentId,
523
+ ...(r.role === null
524
+ ? {
525
+ note: "No role name — created by vault_create({bindAgent:true}), which does not " +
526
+ "name one. It can still sign; vault_pay selects it by nodeId or as the only " +
527
+ "credential. Give it a name with vault_add_role if you want several agents.",
528
+ }
529
+ : {}),
530
+ }))));
439
531
  // ------------------------------------------------------------- onboarding
440
532
  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.", {
441
533
  owner: z
@@ -454,11 +546,11 @@ server.tool("vault_create", "For a user who has NO vault yet. Works out where th
454
546
  daily: z
455
547
  .string()
456
548
  .optional()
457
- .describe("ASK THE USER. The root budget per UTC day, in HUMAN units of the token, e.g. \"100\" or \"2.04\" — not base units. Defaults on the page to 2.04 (mainnet) / 100 (testnet). This is the number the vault enforces on chain, so it is worth asking rather than defaulting."),
549
+ .describe("The root budget per UTC day, in HUMAN units of the token, e.g. \"25\" or \"100\" — not base units. PROPOSE a figure rather than interrogating the user; caps are changeable later with a signed vault_plan_set_caps. Page default when omitted: 25 (mainnet) / 100 (testnet)."),
458
550
  perTx: z
459
551
  .string()
460
552
  .optional()
461
- .describe("ASK THE USER. Maximum per single payment, HUMAN units. Must be <= daily or it can never bind. Page defaults: 0.51 (mainnet) / 10 (testnet)."),
553
+ .describe("Maximum per single payment, HUMAN units. Must be <= daily or it can never bind, and must EXCEED the largest payment intended — cross-chain is EXACT_OUTPUT, so the vault is charged more than the payee receives and a cap of 5 refuses a round 5.00. Page default when omitted: 10 (mainnet) / 10 (testnet)."),
462
554
  token: z
463
555
  .string()
464
556
  .optional()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floatonnear/mcp",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
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",