@floatonnear/mcp 0.6.4 → 0.9.0

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/amounts.d.ts CHANGED
@@ -18,3 +18,54 @@
18
18
  export declare function toBaseUnits(amount: string, decimals: number): bigint;
19
19
  /** Render base units as a canonical human string: no trailing point, no trailing zeroes. */
20
20
  export declare function fromBaseUnits(units: bigint, decimals: number): string;
21
+ /**
22
+ * The contract speaks BASE UNITS; a person reading a budget does not.
23
+ *
24
+ * `get_tree` returns `daily_cap: "25000000"` for a 25 USDC cap, while
25
+ * `vault_budget` — reading the same number through `get_node_budget` — returns
26
+ * `"25"`. Two tools, one vault, one figure, two formats, and nothing said
27
+ * which was which. In a demo that reads as a bug even when both are correct,
28
+ * and `"25000000"` next to a `per_tx_cap` of `"10000000"` invites someone to
29
+ * conclude the caps are enormous.
30
+ *
31
+ * CONVERTED ONLY WHERE THE DECIMALS ARE KNOWN. Caps are stored PER TOKEN and a
32
+ * vault may hold limits for several, but this server knows the decimals of
33
+ * exactly one — `NEAR_VAULT_TOKEN_DECIMALS`, which describes
34
+ * `NEAR_VAULT_TOKEN`. Scaling some other token by 6 would print a number that
35
+ * is wrong by orders of magnitude while looking tidy, which is strictly worse
36
+ * than the raw figure it replaced. So a token we cannot vouch for keeps its
37
+ * base units and says so, rather than being guessed at.
38
+ *
39
+ * `decimals` is echoed on every converted entry so the base figure stays
40
+ * derivable; nothing is lost, it is only no longer the default reading.
41
+ */
42
+ export interface TreeLimit {
43
+ token: string;
44
+ daily_cap: string;
45
+ per_tx_cap: string;
46
+ }
47
+ export interface TreeNode {
48
+ limits?: TreeLimit[];
49
+ child_cap_sum?: [string, string][];
50
+ [k: string]: unknown;
51
+ }
52
+ export declare function humaniseTreeNode(node: TreeNode, cfg: {
53
+ token?: string;
54
+ decimals: number;
55
+ /**
56
+ * Per-token metadata, when it could be read from the chain.
57
+ *
58
+ * THIS REMOVES THE CAVEAT THE FIRST VERSION SHIPPED WITH. It could only
59
+ * humanise the one token whose decimals the operator had configured;
60
+ * every other kept base units, because scaling by a guessed exponent
61
+ * prints a figure wrong by orders of magnitude while looking tidy. The
62
+ * chain knows the real answer, so the guess was never necessary.
63
+ *
64
+ * A token still absent from this map keeps base units — "could not read
65
+ * the metadata" is not a licence to assume six.
66
+ */
67
+ meta?: Map<string, {
68
+ symbol: string;
69
+ decimals: number;
70
+ } | null>;
71
+ }): TreeNode;
package/dist/amounts.js CHANGED
@@ -53,3 +53,58 @@ export function fromBaseUnits(units, decimals) {
53
53
  const frac = (units % scale).toString().padStart(decimals, "0").replace(/0+$/, "");
54
54
  return frac ? `${whole}.${frac}` : whole.toString();
55
55
  }
56
+ export function humaniseTreeNode(node, cfg) {
57
+ if (!node || typeof node !== "object")
58
+ return node;
59
+ // Prefer what the CHAIN said; fall back to the configured token only.
60
+ const dp = (token) => {
61
+ const m = cfg.meta?.get(token);
62
+ if (m)
63
+ return m.decimals;
64
+ return cfg.token && token === cfg.token ? cfg.decimals : null;
65
+ };
66
+ const sym = (token) => cfg.meta?.get(token)?.symbol ?? null;
67
+ const human = (v, decimals) => {
68
+ // EXPLICIT SHAPE CHECK, because `BigInt("")` is `0n` rather than a throw —
69
+ // and `BigInt(" ")` is too. A try/catch alone therefore converted an
70
+ // empty or whitespace cap into "0", which is not a parse failure the
71
+ // reader can see: "0" is a meaningful budget, and the wrong one. Caught by
72
+ // its own test, which is the only reason this is not shipping.
73
+ if (typeof v !== "string" || !/^\d+$/.test(v))
74
+ return v;
75
+ try {
76
+ return fromBaseUnits(BigInt(v), decimals);
77
+ }
78
+ catch {
79
+ // Still guarded: fromBaseUnits rejects decimals outside its own range,
80
+ // and a figure the contract produced is a fact worth surfacing
81
+ // unchanged rather than an exception to swallow.
82
+ return v;
83
+ }
84
+ };
85
+ const limits = node.limits?.map((l) => {
86
+ const d = dp(l.token);
87
+ const symbol = sym(l.token);
88
+ if (d === null)
89
+ return { ...l, decimals: null, units: "base" };
90
+ return {
91
+ ...l,
92
+ // The SYMBOL beside the id, never instead of it: anyone can deploy a
93
+ // token calling itself USDC, and a cap that names only a symbol cannot
94
+ // be checked against the one the user meant.
95
+ ...(symbol ? { token_symbol: symbol } : {}),
96
+ daily_cap: human(l.daily_cap, d),
97
+ per_tx_cap: human(l.per_tx_cap, d),
98
+ decimals: d,
99
+ };
100
+ });
101
+ const childCapSum = node.child_cap_sum?.map(([token, amount]) => {
102
+ const d = dp(token);
103
+ return (d === null ? [token, amount] : [token, human(amount, d)]);
104
+ });
105
+ return {
106
+ ...node,
107
+ ...(limits ? { limits } : {}),
108
+ ...(childCapSum ? { child_cap_sum: childCapSum } : {}),
109
+ };
110
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Turn a payment outcome into something a person would actually say.
3
+ *
4
+ * WHY THE SERVER DOES THIS AND NOT THE MODEL. Two reasons, and the second is
5
+ * the one that decides it.
6
+ *
7
+ * First, the old instructions literally said "Report the reason code", so the
8
+ * agent did: a user asking to pay someone got `PARENT_PER_TX_LIMIT` back. That
9
+ * is a correct answer to a question nobody asked. Telling the model "be
10
+ * friendly" instead would help, but phrasing drifts between models, between
11
+ * versions, and between one conversation and the next.
12
+ *
13
+ * Second, and decisively: a USEFUL explanation needs NUMBERS the model does
14
+ * not have. "That's over the limit" is nearly as useless as the raw code. What
15
+ * a person needs is "the most you can send in one payment is 10, and this asked
16
+ * for 15 — try 10 or less". Those figures live in the budget tree, which the
17
+ * payment response never carried. So the server reads them and writes the
18
+ * sentence; the agent relays it.
19
+ *
20
+ * THE CODE IS KEPT, not discarded. Precision about WHICH rule fired is a real
21
+ * asset — `PARENT_DAILY_BUDGET` meaning "a sibling drained the shared pool" is
22
+ * something a flat policy model cannot express, and developers and the pitch
23
+ * both want it. So it stays in the payload under `technical`, out of the way
24
+ * of the sentence a user sees.
25
+ *
26
+ * HOUSE STYLE, since this is product copy:
27
+ * - one or two short sentences, no jargon, no ALL-CAPS codes
28
+ * - name the real numbers and the real token symbol
29
+ * - say what to do next when there IS something to do
30
+ * - never imply money moved when it did not, and never imply it is gone
31
+ * when the refusal was free
32
+ */
33
+ /** What the contract returned, plus whatever context the handler could gather. */
34
+ export interface OutcomeContext {
35
+ status?: string;
36
+ reason_code?: string;
37
+ /** Human-units amount that was requested, e.g. "15". */
38
+ amount: string;
39
+ recipient: string;
40
+ /** Token symbol if known ("USDC"), else a short id. */
41
+ symbol: string;
42
+ /** Per-payment cap in human units, when known. */
43
+ perTxCap?: string | undefined;
44
+ /** Remaining budget today in human units, when known. */
45
+ available?: string | undefined;
46
+ /** Daily cap in human units, when known. */
47
+ dailyCap?: string | undefined;
48
+ /** Seconds left on a cooldown, when known. */
49
+ cooldownLeftSec?: number | undefined;
50
+ }
51
+ export interface Explanation {
52
+ /** The sentence(s) to say to the user. No codes, no jargon. */
53
+ say: string;
54
+ /** A concrete next step, or null when there is nothing for the user to do. */
55
+ next: string | null;
56
+ /** True when value actually moved. */
57
+ moved: boolean;
58
+ }
59
+ export declare function explainOutcome(c: OutcomeContext): Explanation;
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Turn a payment outcome into something a person would actually say.
3
+ *
4
+ * WHY THE SERVER DOES THIS AND NOT THE MODEL. Two reasons, and the second is
5
+ * the one that decides it.
6
+ *
7
+ * First, the old instructions literally said "Report the reason code", so the
8
+ * agent did: a user asking to pay someone got `PARENT_PER_TX_LIMIT` back. That
9
+ * is a correct answer to a question nobody asked. Telling the model "be
10
+ * friendly" instead would help, but phrasing drifts between models, between
11
+ * versions, and between one conversation and the next.
12
+ *
13
+ * Second, and decisively: a USEFUL explanation needs NUMBERS the model does
14
+ * not have. "That's over the limit" is nearly as useless as the raw code. What
15
+ * a person needs is "the most you can send in one payment is 10, and this asked
16
+ * for 15 — try 10 or less". Those figures live in the budget tree, which the
17
+ * payment response never carried. So the server reads them and writes the
18
+ * sentence; the agent relays it.
19
+ *
20
+ * THE CODE IS KEPT, not discarded. Precision about WHICH rule fired is a real
21
+ * asset — `PARENT_DAILY_BUDGET` meaning "a sibling drained the shared pool" is
22
+ * something a flat policy model cannot express, and developers and the pitch
23
+ * both want it. So it stays in the payload under `technical`, out of the way
24
+ * of the sentence a user sees.
25
+ *
26
+ * HOUSE STYLE, since this is product copy:
27
+ * - one or two short sentences, no jargon, no ALL-CAPS codes
28
+ * - name the real numbers and the real token symbol
29
+ * - say what to do next when there IS something to do
30
+ * - never imply money moved when it did not, and never imply it is gone
31
+ * when the refusal was free
32
+ */
33
+ const plural = (n, one, many) => (n === 1 ? one : many);
34
+ /** "2 minutes", "3 hours" — a duration a person can act on. */
35
+ function human(sec) {
36
+ if (sec < 60)
37
+ return `${sec} ${plural(sec, "second", "seconds")}`;
38
+ const m = Math.ceil(sec / 60);
39
+ if (m < 60)
40
+ return `${m} ${plural(m, "minute", "minutes")}`;
41
+ const h = Math.ceil(m / 60);
42
+ return `${h} ${plural(h, "hour", "hours")}`;
43
+ }
44
+ export function explainOutcome(c) {
45
+ const amt = `${c.amount} ${c.symbol}`;
46
+ const code = c.reason_code ?? "";
47
+ // ---- It worked. Say what happened, in that order: what, to whom, what is left.
48
+ if (c.status === "SETTLED" || code === "SETTLED") {
49
+ const left = c.available !== undefined
50
+ ? ` There's ${c.available} ${c.symbol} left to spend today.`
51
+ : "";
52
+ return { say: `Sent ${amt} to ${c.recipient}.${left}`, next: null, moved: true };
53
+ }
54
+ // ---- In flight. Do NOT claim it arrived.
55
+ if (c.status === "TRANSFER_PENDING" || code === "TRANSFER_PENDING") {
56
+ return {
57
+ say: `The ${amt} payment to ${c.recipient} is on its way — it hasn't confirmed yet.`,
58
+ next: "Check again in a moment to see whether it completed.",
59
+ moved: true,
60
+ };
61
+ }
62
+ /**
63
+ * ---- Approved by the rules, then the transfer itself failed.
64
+ *
65
+ * Said carefully: the budget was NOT charged, and people assume a failed
66
+ * payment means missing money. The usual cause is a payee not registered on
67
+ * the token, which is a real thing they can fix.
68
+ */
69
+ if (c.status === "TRANSFER_FAILED" || code === "TRANSFER_FAILED") {
70
+ return {
71
+ say: `The ${amt} payment to ${c.recipient} didn't go through, so nothing left the vault. ` +
72
+ `Usually that means ${c.recipient} isn't set up to receive ${c.symbol} yet.`,
73
+ next: `Ask ${c.recipient} to register for ${c.symbol}, then try again.`,
74
+ moved: false,
75
+ };
76
+ }
77
+ const nothingMoved = " Nothing was sent.";
78
+ switch (code) {
79
+ // ---- The two limit shapes, which are the ones users actually meet.
80
+ case "PER_TX_LIMIT":
81
+ case "PARENT_PER_TX_LIMIT": {
82
+ const cap = c.perTxCap;
83
+ const whose = code === "PARENT_PER_TX_LIMIT"
84
+ ? "a limit set higher up the budget"
85
+ : "this agent's limit";
86
+ return {
87
+ say: cap
88
+ ? `That's too big for one payment — ${whose} caps each payment at ${cap} ${c.symbol}, ` +
89
+ `and this asked for ${c.amount}.${nothingMoved}`
90
+ : `That's more than ${whose} allows in a single payment.${nothingMoved}`,
91
+ next: cap
92
+ ? `Send ${cap} ${c.symbol} or less, or ask the vault owner to raise the per-payment limit.`
93
+ : "Try a smaller amount, or ask the vault owner to raise the per-payment limit.",
94
+ moved: false,
95
+ };
96
+ }
97
+ case "DAILY_BUDGET": {
98
+ const left = c.available;
99
+ return {
100
+ say: left
101
+ ? `That would go past today's spending limit — ${left} ${c.symbol} is left of ` +
102
+ `${c.dailyCap ?? "today's"} ${c.symbol}, and this asked for ${c.amount}.${nothingMoved}`
103
+ : `Today's spending limit is used up.${nothingMoved}`,
104
+ next: left && Number(left) > 0
105
+ ? `Send ${left} ${c.symbol} or less, or wait — the limit resets at midnight UTC.`
106
+ : "The limit resets at midnight UTC, or the owner can raise it.",
107
+ moved: false,
108
+ };
109
+ }
110
+ /**
111
+ * The one worth explaining properly, because the obvious reading is wrong:
112
+ * this agent's OWN allowance can look completely free.
113
+ */
114
+ case "PARENT_DAILY_BUDGET":
115
+ return {
116
+ say: `This agent still has room, but the shared pool it spends from is used up for today — ` +
117
+ `another agent got there first.${nothingMoved}`,
118
+ next: "The pool resets at midnight UTC, or the owner can raise the shared limit.",
119
+ moved: false,
120
+ };
121
+ case "COOLDOWN":
122
+ case "PARENT_COOLDOWN":
123
+ return {
124
+ say: `Payments from here are spaced out, and the next one isn't due yet` +
125
+ `${c.cooldownLeftSec ? ` — about ${human(c.cooldownLeftSec)} to go` : ""}.${nothingMoved}`,
126
+ next: "Try again shortly.",
127
+ moved: false,
128
+ };
129
+ // ---- Owner decisions. The user cannot fix these themselves, so say who can.
130
+ case "NO_RECIPIENTS_SET":
131
+ return {
132
+ say: `This agent isn't cleared to pay anyone yet.${nothingMoved}`,
133
+ next: "The vault owner needs to approve a list of payees first.",
134
+ moved: false,
135
+ };
136
+ case "RECIPIENT_NOT_ALLOWED":
137
+ return {
138
+ say: `${c.recipient} isn't on this agent's approved list of payees.${nothingMoved}`,
139
+ next: "The vault owner decides who can be paid, and can add them.",
140
+ moved: false,
141
+ };
142
+ case "VAULT_PAUSED":
143
+ return {
144
+ say: `The vault is paused, so no payments can go out right now.${nothingMoved}`,
145
+ next: "The vault owner can unpause it.",
146
+ moved: false,
147
+ };
148
+ case "NODE_FROZEN":
149
+ return {
150
+ say: `This agent is frozen, so it can't spend.${nothingMoved}`,
151
+ next: "The vault owner can unfreeze it.",
152
+ moved: false,
153
+ };
154
+ case "TOKEN_NOT_ALLOWED":
155
+ return {
156
+ say: `This agent doesn't have a ${c.symbol} budget.${nothingMoved}`,
157
+ next: `The vault owner can give it one, or pay in a token it does have.`,
158
+ moved: false,
159
+ };
160
+ /**
161
+ * Not a refusal — a pause. Must not read as a failure, and must not read
162
+ * as done either.
163
+ */
164
+ case "APPROVAL_REQUIRED":
165
+ return {
166
+ say: `${amt} to ${c.recipient} is large enough to need the owner's sign-off, so it's ` +
167
+ `waiting for them. Nothing has been sent yet.`,
168
+ next: "Ask the vault owner to approve it.",
169
+ moved: false,
170
+ };
171
+ case "APPROVAL_REJECTED":
172
+ return {
173
+ say: `The vault owner turned down the ${amt} payment to ${c.recipient}.${nothingMoved}`,
174
+ next: null,
175
+ moved: false,
176
+ };
177
+ case "DUPLICATE_REQUEST":
178
+ return {
179
+ say: "That exact payment was already submitted, so it wasn't sent twice.",
180
+ next: "Check whether the first one went through before trying again.",
181
+ moved: false,
182
+ };
183
+ case "INVALID_AMOUNT":
184
+ return { say: `${c.amount} isn't a valid amount to send.`, next: "Use an amount above zero.", moved: false };
185
+ case "INVALID_MEMO":
186
+ return {
187
+ say: "The note attached to that payment isn't allowed — it's too long or has odd characters.",
188
+ next: "Shorten the note and try again.",
189
+ moved: false,
190
+ };
191
+ default: {
192
+ /**
193
+ * UNKNOWN CODE: say plainly that it was refused and that nothing moved,
194
+ * and THEN give the code — because an unrecognised code is the one case
195
+ * where the raw value is genuinely the most useful thing a user can pass
196
+ * on to whoever can help. Guessing a friendly sentence for a rule we
197
+ * cannot describe would be worse than admitting it.
198
+ */
199
+ if (!code) {
200
+ return { say: `The ${amt} payment to ${c.recipient} didn't go through.${nothingMoved}`, next: null, moved: false };
201
+ }
202
+ return {
203
+ say: `The payment was refused by the vault's rules, so nothing was sent.`,
204
+ next: `If you need to report this, the reason given was "${code}".`,
205
+ moved: false,
206
+ };
207
+ }
208
+ }
209
+ }
package/dist/roles.d.ts CHANGED
@@ -8,6 +8,7 @@ export declare class RoleSelectionError extends Error {
8
8
  }
9
9
  export declare function selectRole(cfg: {
10
10
  agentKeysPath?: string;
11
+ vault?: string;
11
12
  }, role: string): {
12
13
  agentId: string;
13
14
  agentKey: string;
@@ -15,6 +16,7 @@ export declare function selectRole(cfg: {
15
16
  /** Every role that could actually sign, for listing back to a caller. */
16
17
  export declare function listRoles(cfg: {
17
18
  agentKeysPath?: string;
19
+ vault?: string;
18
20
  }): RoleCredential[];
19
21
  /**
20
22
  * EVERY credential in the keyring, named or not.
@@ -36,6 +38,7 @@ export declare function listRoles(cfg: {
36
38
  */
37
39
  export declare function listAgents(cfg: {
38
40
  agentKeysPath?: string;
41
+ vault?: string;
39
42
  }): {
40
43
  agentId: string;
41
44
  role: string | null;
package/dist/roles.js CHANGED
@@ -29,11 +29,42 @@ function loadable(path) {
29
29
  // never be selected.
30
30
  return parsed.filter((e) => e && typeof e.agentId === "string" && typeof e.secret === "string");
31
31
  }
32
+ /**
33
+ * Keyring entries belonging to THIS vault.
34
+ *
35
+ * A credential's `agentId` IS the vault account it was issued for. Nothing
36
+ * filtered on it, so every function here operated on the whole file — every
37
+ * vault the user has ever created, on either network.
38
+ *
39
+ * Measured 2026-11-10 against a mainnet vault: `vault_roles` listed
40
+ * `rudeisland4178-testnet.fac.floatv4.testnet` and
41
+ * `work12-testnet-fleet.fac.floatv4.testnet` as that vault's agents. Both are
42
+ * TESTNET accounts from earlier experiments, and neither has any relationship
43
+ * to the configured mainnet vault.
44
+ *
45
+ * `vault_roles` listing them is a display bug. `selectRole` was worse: asked
46
+ * for role "root" it would hand `vault_pay` the testnet fleet key to sign a
47
+ * MAINNET payment. That fails at the chain — the key is not on the vault — but
48
+ * the tool chose a credential from a different network and the error arrives
49
+ * with no hint of why.
50
+ *
51
+ * `vault` empty means nothing is configured, so there is nothing to scope to
52
+ * and every entry is returned. That is the `vault_create` case, where the
53
+ * server legitimately runs with no vault at all.
54
+ */
55
+ function forVault(cfg) {
56
+ const entries = loadable(cfg.agentKeysPath ?? ".float/agent-keys.json");
57
+ if (!cfg.vault)
58
+ return entries;
59
+ return entries.filter((e) => e.agentId === cfg.vault);
60
+ }
32
61
  export class RoleSelectionError extends Error {
33
62
  }
34
63
  export function selectRole(cfg, role) {
35
64
  const path = cfg.agentKeysPath ?? ".float/agent-keys.json";
36
- const entries = loadable(path);
65
+ // SCOPED. A role name is not unique across vaults — "root" exists in every
66
+ // one — so an unscoped lookup picks whichever was written first.
67
+ const entries = forVault(cfg);
37
68
  const known = entries
38
69
  .map((e) => (typeof e.role === "string" ? e.role : null))
39
70
  .filter(Boolean);
@@ -50,7 +81,7 @@ export function selectRole(cfg, role) {
50
81
  }
51
82
  /** Every role that could actually sign, for listing back to a caller. */
52
83
  export function listRoles(cfg) {
53
- return loadable(cfg.agentKeysPath ?? ".float/agent-keys.json")
84
+ return forVault(cfg)
54
85
  .filter((e) => typeof e.role === "string")
55
86
  .map((e) => ({
56
87
  agentId: e.agentId,
@@ -78,7 +109,7 @@ export function listRoles(cfg) {
78
109
  * name is genuinely absent and the fix is `vault_add_role`, not a guess.
79
110
  */
80
111
  export function listAgents(cfg) {
81
- return loadable(cfg.agentKeysPath ?? ".float/agent-keys.json").map((e) => ({
112
+ return forVault(cfg).map((e) => ({
82
113
  agentId: e.agentId,
83
114
  role: typeof e.role === "string" ? e.role : null,
84
115
  nodeId: typeof e.nodeId === "number" ? e.nodeId : undefined,