@floatonnear/mcp 0.3.0 → 0.5.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/server.js CHANGED
@@ -10,33 +10,129 @@
10
10
  * PLAN tools emit UNSIGNED payloads for the human's wallet; this
11
11
  * server never signs anything that changes policy
12
12
  *
13
+ * CREATE tool needs no key and no vault. It reads the factory, works out
14
+ * the address and the cost, and hands back a URL the user
15
+ * approves in their own wallet.
16
+ *
13
17
  * Configure with:
14
- * NEAR_VAULT_CONTRACT required
18
+ * NEAR_VAULT_CONTRACT required by every tool EXCEPT vault_create
15
19
  * NEAR_VAULT_RPC_URL default https://test.rpc.fastnear.com
16
20
  * NEAR_VAULT_TOKEN default token for pay/budget calls
17
21
  * NEAR_VAULT_TOKEN_DECIMALS default 6
18
- * NEAR_VAULT_AGENT_ID only needed to PAY
19
- * NEAR_VAULT_AGENT_KEY only needed to PAY. The restricted key.
22
+ * NEAR_VAULT_AGENT_ID only needed to PAY. Taken from the key file's
23
+ * `account_id` when one is given.
24
+ * NEAR_VAULT_AGENT_KEY only needed to PAY. The restricted key, inline.
25
+ * NEAR_VAULT_AGENT_KEY_FILE PREFERRED over the inline value above: a PATH to
26
+ * a ~/.near-credentials-shaped JSON file
27
+ * ({"account_id","public_key","private_key"}) or a
28
+ * bare key file. A path is not a secret, so the
29
+ * routine act of reading this config to see which
30
+ * vault is wired up stops printing a live private
31
+ * key into a terminal and an agent transcript.
32
+ * Same reason NEAR CLI signing in this repo uses
33
+ * `sign-with-access-key-file`. If both are set the
34
+ * FILE wins and the inline one is reported as
35
+ * ignored on stderr.
36
+ * NEAR_VAULT_NETWORK mainnet | testnet, default testnet
37
+ * NEAR_VAULT_FACTORY only needed to CREATE. No mainnet default
38
+ * exists on purpose — guessing it wrong would
39
+ * send a real deposit somewhere unverified.
40
+ * NEAR_VAULT_APP_URL only needed to CREATE, default
41
+ * https://app.usefloat.xyz
42
+ *
43
+ * A user with NO vault can therefore run this server with nothing configured,
44
+ * call `vault_create`, and be onboarded — which is the point. Every other tool
45
+ * reports that NEAR_VAULT_CONTRACT is unset rather than guessing one.
20
46
  */
21
47
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
22
48
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
23
49
  import { z } from "zod";
24
50
  import { fromBaseUnits, toBaseUnits } from "./amounts.js";
25
- import { DEFAULT_GAS_TGAS, planAddNode, planCreateAgentAccount, planFreezeNode, planPurgeSettled, planSetCooldown, planSetNodeCaps, planSetPaused, planSetRecipients, toCliCommand, planSetApprovalThreshold, planApprovePayment, planRejectPayment, } from "./payloads.js";
26
- import { DEFAULT_RPC_URL, newRequestId, requireAgentKey, viewFunction } from "./rpc.js";
51
+ import { DEFAULT_GAS_TGAS, planAddNode, planAddRole, planBindVirtualAgent, planUnbindVirtualAgent, planFreezeNode, planPurgeSettled, planSetCooldown, planSetNodeCaps, planSetPaused, planSetRecipients, toCliCommand, planSetApprovalThreshold, planApprovePayment, planRejectPayment, planWithdraw, } from "./payloads.js";
52
+ import { accountExists, DEFAULT_RPC_URL, newRequestId, requireAgentKey, viewAccessKeys, viewAccount, viewFunction, } from "./rpc.js";
53
+ import { AGENT_NODE_ID, generateAgentKey } from "./agent-key.js";
54
+ import { resolveKeySource } from "./key-file.js";
55
+ import { dirname } from "node:path";
56
+ import { listRoles, selectRole } from "./roles.js";
57
+ /**
58
+ * A decimal NEAR string to yoctoNEAR, without floating point.
59
+ *
60
+ * `Number("0.12") * 1e24` is 1.2000000000000001e23 — a figure that becomes a
61
+ * deposit, so the drift lands on chain. Parsed as text instead.
62
+ */
63
+ function toYoctoNear(n) {
64
+ const m = /^(\d+)(?:\.(\d{1,24}))?$/.exec(n.trim());
65
+ if (!m)
66
+ throw new Error(`"${n}" is not a NEAR amount (digits, optional decimal point)`);
67
+ return (BigInt(m[1]) * 10n ** 24n + BigInt((m[2] ?? "").padEnd(24, "0"))).toString();
68
+ }
69
+ import { buildCreatePlan, FACTORY_BY_NETWORK } from "./create.js";
27
70
  import { buildRoute, sendRequestPayment } from "./sign.js";
28
71
  import { getQuote, signBinding, toBase64 } from "./quote.js";
29
72
  /** How long a quote stays valid. Long enough to sign and send, short
30
73
  * enough that a stale price is not honoured. */
31
74
  const QUOTE_TTL_MS = 20 * 60 * 1000;
75
+ /**
76
+ * Secrets, from a FILE where one is named. See key-file.ts for why.
77
+ *
78
+ * Resolved once at startup rather than per call, so a broken path is reported
79
+ * in the client's server log at launch instead of only when someone tries to
80
+ * pay.
81
+ */
82
+ const agentKeySource = resolveKeySource({
83
+ inline: process.env.NEAR_VAULT_AGENT_KEY,
84
+ file: process.env.NEAR_VAULT_AGENT_KEY_FILE,
85
+ inlineVar: "NEAR_VAULT_AGENT_KEY",
86
+ fileVar: "NEAR_VAULT_AGENT_KEY_FILE",
87
+ });
88
+ const quoteSignerSource = resolveKeySource({
89
+ inline: process.env.FLOAT_QUOTE_SIGNER_KEY,
90
+ file: process.env.FLOAT_QUOTE_SIGNER_KEY_FILE,
91
+ inlineVar: "FLOAT_QUOTE_SIGNER_KEY",
92
+ fileVar: "FLOAT_QUOTE_SIGNER_KEY_FILE",
93
+ });
94
+ /**
95
+ * STDERR, NEVER STDOUT. This process speaks JSON-RPC over stdout, so a single
96
+ * stray `console.log` corrupts the stream and the client reports a protocol
97
+ * error rather than the configuration problem being described. There is no
98
+ * other `console.*` call in this file — that is deliberate, and this one is
99
+ * `console.error` for the same reason.
100
+ *
101
+ * NAMES ONLY, never values: this goes to a log file the user may well paste.
102
+ */
103
+ for (const note of [...agentKeySource.warnings, ...quoteSignerSource.warnings]) {
104
+ console.error(`float: ${note}`);
105
+ }
106
+ // A named-but-unreadable key file is reported at startup AND again, with the
107
+ // same text, if someone tries to pay. The server must still start: reads,
108
+ // payload planning and `vault_create` need no key at all, and onboarding a user
109
+ // who has no vault yet is the whole point of this server being runnable with
110
+ // nothing configured.
111
+ for (const err of [agentKeySource.error, quoteSignerSource.error]) {
112
+ if (err)
113
+ console.error(`float: ${err}`);
114
+ }
32
115
  const cfg = {
33
116
  vault: process.env.NEAR_VAULT_CONTRACT ?? "",
34
117
  rpcUrl: process.env.NEAR_VAULT_RPC_URL ?? DEFAULT_RPC_URL,
35
118
  token: process.env.NEAR_VAULT_TOKEN ?? "",
36
119
  decimals: Number(process.env.NEAR_VAULT_TOKEN_DECIMALS ?? "6"),
37
- agentId: process.env.NEAR_VAULT_AGENT_ID,
38
- agentKey: process.env.NEAR_VAULT_AGENT_KEY,
120
+ // The credentials file carries the account as well as the key, so one path
121
+ // can supply both halves. An explicit NEAR_VAULT_AGENT_ID still wins, and
122
+ // `requireAgentKey` refuses the pair if the two disagree.
123
+ agentId: process.env.NEAR_VAULT_AGENT_ID ?? agentKeySource.accountId,
124
+ agentKey: agentKeySource.secret,
125
+ agentKeyError: agentKeySource.error,
126
+ agentKeyAccountId: agentKeySource.accountId,
39
127
  network: process.env.NEAR_VAULT_NETWORK ?? "testnet",
128
+ // Onboarding only. Both have safe defaults and neither is needed by any
129
+ // tool that touches an existing vault.
130
+ factory: process.env.NEAR_VAULT_FACTORY,
131
+ appUrl: process.env.NEAR_VAULT_APP_URL ?? "https://app.usefloat.xyz",
132
+ // The fleet keyring. Same default and same file the adapter reads — two
133
+ // readers disagreeing about where credentials live is how an agent ends up
134
+ // "configured" and unreachable.
135
+ agentKeysPath: process.env.NEAR_VAULT_AGENT_KEYS ?? ".float/agent-keys.json",
40
136
  };
41
137
  function requireVault() {
42
138
  if (!cfg.vault)
@@ -87,7 +183,7 @@ const limitShape = z
87
183
  // startup: a wrong relative path would break the server outright instead of
88
184
  // merely reporting a stale version. server.smoke.test.ts asserts the two agree,
89
185
  // so a bump cannot land with only one of them moved.
90
- const server = new McpServer({ name: "float", version: "0.3.0" });
186
+ const server = new McpServer({ name: "float", version: "0.5.0" });
91
187
  // ----------------------------------------------------------------- reads
92
188
  server.tool("vault_tree", "Read the whole budget tree: every node, its caps, parent, bound agent account, cooldown and freeze state. No key required.", {}, async () => json(await viewFunction(cfg.rpcUrl, requireVault(), "get_tree", {})));
93
189
  server.tool("vault_budget", "Read one node's budget for a UTC day: cap, executed, in-flight (pending) and available, in human units. Omit `day` for today. No key required.", {
@@ -114,6 +210,194 @@ server.tool("vault_budget", "Read one node's budget for a UTC day: cap, executed
114
210
  });
115
211
  server.tool("vault_request", "Read one stored payment request: status, reason code, amount, the ancestor chain it charged, and the UTC day it was reserved against. No key required.", { requestId: z.string() }, async ({ requestId }) => json(await viewFunction(cfg.rpcUrl, requireVault(), "get_request", { request_id: requestId })));
116
212
  server.tool("vault_config", "Read vault config: owner, paused, strict-vs-overcommit allocation, node count, and clock mode. No key required.", {}, async () => json(await viewFunction(cfg.rpcUrl, requireVault(), "get_config", {})));
213
+ server.tool("vault_add_role", "Add a NEW departmental agent to an existing vault — e.g. a 'marketing' or 'news' agent with its own budget. Generates the agent's key, files it under the role name, and returns an UNSIGNED payload for the owner to approve. One FunctionCall creates the node, binds the key and installs its funded gas key. This server never signs it.", {
214
+ role: z
215
+ .string()
216
+ .min(1)
217
+ .max(48)
218
+ .describe("the agent's name, e.g. \"marketing\". Used as the node label AND as the keyring index you later pass to vault_pay."),
219
+ dailyCap: z.string().describe("daily spend cap in HUMAN units, e.g. \"25.00\""),
220
+ perTxCap: z.string().describe("per-payment cap in HUMAN units, e.g. \"5.00\""),
221
+ recipients: z
222
+ .array(z.string())
223
+ .optional()
224
+ .describe("payee allowlist for this agent. Omitted means it inherits the nearest ancestor's list."),
225
+ token: z.string().optional(),
226
+ cooldownSec: z.number().int().min(0).optional(),
227
+ fundingNear: z
228
+ .string()
229
+ .optional()
230
+ .describe("NEAR for the agent's gas key. Default 0.12 (~160 payments measured). Minimum 0.06, because NEAR reserves prepaid gas up front."),
231
+ }, async ({ role, dailyCap, perTxCap, recipients, token, cooldownSec, fundingNear }) => {
232
+ const vault = requireVault();
233
+ // REFUSE A DUPLICATE ROLE BEFORE GENERATING A KEY. The contract would
234
+ // happily create a second node with the same label, and the keyring would
235
+ // index only the last one — so the first agent would still be bound and
236
+ // funded on chain while becoming unreachable locally.
237
+ if (listRoles(cfg).some((r) => r.role === role)) {
238
+ return text(`This vault already has an agent with the role '${role}'. Roles index the ` +
239
+ `keyring, so a second one would hide the first — which would stay bound and ` +
240
+ `funded on chain with nothing able to select it. Pick another name, or use ` +
241
+ `the existing agent.`);
242
+ }
243
+ const fundingYocto = toYoctoNear(fundingNear ?? "0.12");
244
+ // `nodeId: null` — NOT YET KNOWN. `add_role` creates the node inside the
245
+ // contract, so its id does not exist until the transaction lands.
246
+ const generated = generateAgentKey({
247
+ vault,
248
+ dir: dirname(cfg.agentKeysPath),
249
+ role,
250
+ nodeId: null,
251
+ });
252
+ let payload;
253
+ try {
254
+ payload = planAddRole(vault, role, [{ token: tokenFor(token), dailyCap, perTxCap, decimals: cfg.decimals }], cooldownSec ?? 0, recipients ?? null, generated.publicKey, fundingYocto);
255
+ }
256
+ catch (e) {
257
+ return text(String(e.message));
258
+ }
259
+ return json({
260
+ role,
261
+ agent: {
262
+ publicKey: generated.publicKey,
263
+ credentialWrittenTo: generated.keyringPath,
264
+ },
265
+ payload,
266
+ next: [
267
+ `Have the owner approve this payload in their own wallet.`,
268
+ `The node id is assigned by the contract and returned by the call; the ` +
269
+ `credential is filed under the ROLE, so vault_pay({role:"${role}"}) works ` +
270
+ `without it.`,
271
+ ],
272
+ warnings: [
273
+ `Show the owner this public key and have them confirm it before signing: ${generated.publicKey}`,
274
+ `The private key is at ${generated.keyringPath}. Do not print it.`,
275
+ ],
276
+ });
277
+ });
278
+ 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 }))));
279
+ // ------------------------------------------------------------- onboarding
280
+ 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.", {
281
+ owner: z
282
+ .string()
283
+ .optional()
284
+ .describe("the account that will own the vault, e.g. alice.near. OMIT IT if you do not know the user's NEAR account — the link then derives everything from whichever wallet they connect, which is the normal case when onboarding someone new."),
285
+ network: z.enum(["mainnet", "testnet"]).optional(),
286
+ factory: z
287
+ .string()
288
+ .optional()
289
+ .describe("override the factory; defaults to the known one for the network"),
290
+ bindAgent: z
291
+ .boolean()
292
+ .optional()
293
+ .describe("generate an agent key and bind it in the SAME transaction, so one signature creates the vault and authorises you. Adds 0.12 NEAR of gas-key funding to the deposit. The user is shown the public key and must confirm it before signing. Requires `owner`, because the credential is filed under the vault id and that cannot be derived without one."),
294
+ }, async ({ owner, network, factory, bindAgent }) => {
295
+ const net = network ?? cfg.network;
296
+ const fac = factory ?? cfg.factory ?? FACTORY_BY_NETWORK[net];
297
+ if (!fac) {
298
+ // DELIBERATELY NOT A GUESS. A wrong mainnet default does not fail
299
+ // safely — it takes a real signature for a real deposit into an account
300
+ // nobody verified.
301
+ return text(`No factory is configured for ${net}. Pass one as \`factory\`, or set ` +
302
+ `NEAR_VAULT_FACTORY. There is deliberately no built-in mainnet default: ` +
303
+ `guessing it wrong would send a real deposit somewhere unverified.`);
304
+ }
305
+ const rpc = cfg.rpcUrl;
306
+ // Only ask the factory to derive an id when there IS an owner. With none,
307
+ // the address is decided by whichever wallet signs, and inventing one here
308
+ // would print a plausible address next to a real deposit figure.
309
+ const config = await viewFunction(rpc, fac, "get_config", {});
310
+ const vaultId = owner
311
+ ? await viewFunction(rpc, fac, "vault_id_for", { owner })
312
+ : null;
313
+ const extra = [];
314
+ if (config.network !== net) {
315
+ extra.push(`The factory reports network "${config.network}" but you asked for ${net}; ` +
316
+ `the vault will be initialised with "${config.network}".`);
317
+ }
318
+ const exists = owner && vaultId ? await accountExists(rpc, vaultId) : false;
319
+ /**
320
+ * ASK THE EXISTING VAULT WHO OWNS IT, rather than inferring it from the
321
+ * derivation that produced the id.
322
+ *
323
+ * `derive_prefix` replaces dots with hyphens and is NOT injective:
324
+ * `a.b.near` and `a-b.near` both derive `a-b-near`, and the factory ships
325
+ * a test asserting exactly that. So "an account exists at your derived id"
326
+ * does not mean "it is yours" — and the message here used to say it did,
327
+ * which would point the loser of a collision at the winner's vault.
328
+ *
329
+ * `null` on any failure, which the planner treats as a THIRD outcome.
330
+ * Collapsing the error into "not yours" would be as wrong as collapsing it
331
+ * into "yours": a vault that is merely unreadable right now is neither.
332
+ */
333
+ const existingOwner = exists && vaultId
334
+ ? await viewFunction(rpc, vaultId, "get_config", {})
335
+ .then((c) => c?.owner ?? null)
336
+ .catch(() => null)
337
+ : null;
338
+ // AN AGENT NEEDS A VAULT ID, and not for the transaction — the page
339
+ // derives that from the connected wallet. It is needed for the CREDENTIAL,
340
+ // which the adapter files under the account the key signs as. A virtual
341
+ // agent's key is a gas key ON the vault, so that account is the vault
342
+ // itself, and without an owner there is nothing to derive it from.
343
+ //
344
+ // Refusing beats writing a record with a null account: such a record is
345
+ // skipped in silence by `loadKeyring`, so the agent would appear set up
346
+ // and simply never be found.
347
+ if (bindAgent && !vaultId) {
348
+ return text(`Binding an agent needs \`owner\`, because the agent's credential is filed ` +
349
+ `under the vault id and that is derived from the owner. Call vault_create ` +
350
+ `again with the user's NEAR account, or omit bindAgent and bind afterwards ` +
351
+ `with vault_plan_bind_agent.`);
352
+ }
353
+ let generated = null;
354
+ if (bindAgent && vaultId)
355
+ generated = generateAgentKey({ vault: vaultId });
356
+ try {
357
+ const plan = buildCreatePlan({
358
+ owner: owner ?? "",
359
+ ownerKnown: !!owner,
360
+ vaultId,
361
+ factory: fac,
362
+ network: net,
363
+ requiredDepositYocto: String(config.required_deposit),
364
+ paused: config.paused,
365
+ appUrl: cfg.appUrl,
366
+ exists,
367
+ existingOwner,
368
+ agentPk: generated?.publicKey ?? null,
369
+ });
370
+ return json({
371
+ ...plan,
372
+ // THE PUBLIC HALF ONLY. The secret is on disk; a tool response is a
373
+ // transcript entry forever.
374
+ agent: generated
375
+ ? {
376
+ publicKey: generated.publicKey,
377
+ nodeId: AGENT_NODE_ID,
378
+ credentialWrittenTo: generated.keyringPath,
379
+ envWrittenTo: generated.envPath,
380
+ ...(generated.backedUpTo ? { previousKeyringMovedTo: generated.backedUpTo } : {}),
381
+ }
382
+ : null,
383
+ warnings: [
384
+ ...extra,
385
+ ...plan.warnings,
386
+ ...(generated
387
+ ? [
388
+ `Show the user this agent key and ask them to confirm it matches what ` +
389
+ `the page displays before they approve: ${generated.publicKey}`,
390
+ `The agent's private key is at ${generated.keyringPath}. It can only call ` +
391
+ `request_payment on this vault, inside the budget. Do not print it.`,
392
+ ]
393
+ : []),
394
+ ],
395
+ });
396
+ }
397
+ catch (e) {
398
+ return text(String(e.message));
399
+ }
400
+ });
117
401
  // ------------------------------------------------------------------- pay
118
402
  server.tool("vault_pay", "Send a payment from the agent's node. Signs with the agent's RESTRICTED key, which can call only request_payment on this vault and cannot attach a deposit. The contract checks the per-tx cap, the daily cap at EVERY level of the budget tree, the cooldown and the payee allowlist before any value moves.", {
119
403
  amount: z.string().describe('human units, e.g. "12.50"'),
@@ -129,8 +413,16 @@ server.tool("vault_pay", "Send a payment from the agent's node. Signs with the a
129
413
  .string()
130
414
  .optional()
131
415
  .describe("1Click asset id for rail=intents. Defaults to USDC on Base."),
132
- }, async ({ amount, recipient, memo, token, requestId, rail, destinationAsset }) => {
133
- const creds = requireAgentKey(cfg);
416
+ role: z
417
+ .string()
418
+ .optional()
419
+ .describe("which agent pays, by role name — e.g. \"marketing\". Required once the vault has more than one agent, because the signing key decides which budget is charged. Omit it only when there is exactly one."),
420
+ }, async ({ amount, recipient, memo, token, requestId, rail, destinationAsset, role }) => {
421
+ // WHICH KEY SIGNS *IS* WHICH BUDGET IS CHARGED. The contract resolves the
422
+ // spending node from the signer, not from anything in the request body,
423
+ // so picking the wrong key does not fail — it succeeds against someone
424
+ // else's budget and surfaces later as a confusing balance.
425
+ const creds = role === undefined ? requireAgentKey(cfg) : selectRole(cfg, role);
134
426
  const id = requestId ?? newRequestId();
135
427
  const vault = requireVault();
136
428
  let route = "near_direct";
@@ -141,11 +433,19 @@ server.tool("vault_pay", "Send a payment from the agent's node. Signs with the a
141
433
  // does not live where agents run, because whoever holds it can bind any
142
434
  // deposit address to any payee. So this path only works when the MCP
143
435
  // server is the operator's own.
144
- const signerHex = process.env.FLOAT_QUOTE_SIGNER_KEY;
436
+ const signerHex = quoteSignerSource.secret;
145
437
  if (!signerHex) {
146
- throw new Error("rail=intents needs FLOAT_QUOTE_SIGNER_KEY (32-byte hex). " +
147
- "That key binds a payee to a deposit address, so it belongs to the " +
148
- "vault operator, not to an agent. Without it the cross-chain rail is closed.");
438
+ throw new Error(
439
+ // A named-but-broken FILE is a cause; "the variable is unset" is a
440
+ // description. Reporting the second when the first applies sends the
441
+ // operator to set something they have already set.
442
+ quoteSignerSource.error
443
+ ? `rail=intents needs the quote signer, and the key file could not be read. ` +
444
+ `${quoteSignerSource.error}`
445
+ : "rail=intents needs FLOAT_QUOTE_SIGNER_KEY (32-byte hex), or " +
446
+ "FLOAT_QUOTE_SIGNER_KEY_FILE pointing at a file that holds it. " +
447
+ "That key binds a payee to a deposit address, so it belongs to the " +
448
+ "vault operator, not to an agent. Without it the cross-chain rail is closed.");
149
449
  }
150
450
  const sk = Uint8Array.from(Buffer.from(signerHex.replace(/^0x/, ""), "hex"));
151
451
  // EXACT_OUTPUT: `amount` is what the PAYEE receives. What leaves the
@@ -199,12 +499,11 @@ server.tool("vault_plan_add_node", "Build an UNSIGNED payload to create a budget
199
499
  recipients: z.array(z.string()).nullable().default(null),
200
500
  }, async ({ parent, label, limits, cooldownSec, recipients }) => renderPlan(planAddNode(requireVault(), parent, label, limits, cooldownSec, recipients)));
201
501
  server.tool("vault_plan_set_caps", "Build an UNSIGNED payload to change a node's daily and per-payment caps. Not signed by this server.", { nodeId: z.number().int().min(0), limits: limitShape }, async ({ nodeId, limits }) => renderPlan(planSetNodeCaps(requireVault(), nodeId, limits)));
202
- server.tool("vault_plan_create_agent", "Build an UNSIGNED payload to create an agent sub-account with ONE key restricted to request_payment on this vault. Generate the keypair yourself and pass only the public half. Not signed by this server.", {
502
+ server.tool("vault_plan_bind_agent", "Build an UNSIGNED payload binding a signing key to a node, so that key may spend from it. Generate the keypair yourself and pass only the public half. Many keys may share one node. Not signed by this server.", {
203
503
  nodeId: z.number().int().min(0),
204
- subLabel: z.string(),
205
504
  publicKey: z.string().describe("ed25519:..."),
206
- keyAllowanceNear: z.string().default("2").describe("gas allowance in NEAR; floor is 0.5"),
207
- }, async ({ nodeId, subLabel, publicKey, keyAllowanceNear }) => renderPlan(planCreateAgentAccount(requireVault(), nodeId, subLabel, publicKey, keyAllowanceNear)));
505
+ }, async ({ nodeId, publicKey }) => renderPlan(planBindVirtualAgent(requireVault(), nodeId, publicKey)));
506
+ server.tool("vault_plan_unbind_agent", "Build an UNSIGNED payload revoking a signing key's authority to spend. Does NOT return any gas left in that key's NEP-611 gas key — withdraw from the gas key separately. Not signed by this server.", { publicKey: z.string().describe("ed25519:...") }, async ({ publicKey }) => renderPlan(planUnbindVirtualAgent(requireVault(), publicKey)));
208
507
  server.tool("vault_plan_freeze", "Build an UNSIGNED payload to freeze or unfreeze a node. Freezing blocks the node AND its whole subtree. Not signed by this server.", { nodeId: z.number().int().min(0), frozen: z.boolean() }, async ({ nodeId, frozen }) => renderPlan(planFreezeNode(requireVault(), nodeId, frozen)));
209
508
  server.tool("vault_plan_set_recipients", "Build an UNSIGNED payload to set a node's payee allowlist. This REPLACES the inherited list rather than adding to it; pass null to inherit. Not signed by this server.", { nodeId: z.number().int().min(0), recipients: z.array(z.string()).nullable() }, async ({ nodeId, recipients }) => renderPlan(planSetRecipients(requireVault(), nodeId, recipients)));
210
509
  server.tool("vault_plan_set_cooldown", "Build an UNSIGNED payload to set the minimum seconds between a node's payments. Not signed by this server.", { nodeId: z.number().int().min(0), cooldownSec: z.number().int().min(0) }, async ({ nodeId, cooldownSec }) => renderPlan(planSetCooldown(requireVault(), nodeId, cooldownSec)));
@@ -227,4 +526,123 @@ server.tool("vault_plan_purge", "Build an UNSIGNED payload to delete settled pay
227
526
  beforeDay: z.number().int().min(0),
228
527
  limit: z.number().int().min(0).max(100).default(100),
229
528
  }, async ({ beforeDay, limit }) => renderPlan(planPurgeSettled(requireVault(), beforeDay, limit)));
529
+ server.tool("vault_plan_withdraw", "Build an UNSIGNED payload to move tokens OUT of the vault to any account. The owner approves it in their own wallet; this server never signs. This is the step that decides whether money survives a teardown: deleting a NEAR account does NOT return fungible tokens, so they must be moved first.", {
530
+ amount: z
531
+ .string()
532
+ .describe("HUMAN units, e.g. \"12.5\" for 12.5 USDC. Omit to withdraw the vault's entire balance, read from the token contract.")
533
+ .optional(),
534
+ receiverId: z.string().describe("who receives the tokens, e.g. alice.testnet"),
535
+ token: z.string().optional().describe("overrides NEAR_VAULT_TOKEN"),
536
+ }, async ({ amount, receiverId, token }) => {
537
+ const vault = requireVault();
538
+ const tok = tokenFor(token);
539
+ /**
540
+ * NO AMOUNT MEANS "ALL", READ FROM THE CHAIN — not a guess, and not a
541
+ * figure the caller carried in. The balance is taken as the token's own
542
+ * base-unit STRING and passed through untouched, because re-deriving it
543
+ * through a float leaves dust and a vault with dust never reaches the
544
+ * zero a delete needs.
545
+ */
546
+ let base;
547
+ if (amount === undefined) {
548
+ const bal = await viewFunction(cfg.rpcUrl, tok, "ft_balance_of", {
549
+ account_id: vault,
550
+ });
551
+ if (!/^\d+$/.test(String(bal))) {
552
+ return text(`${tok} reported this vault's balance as ${JSON.stringify(bal)}, which is not a ` +
553
+ `number. Refusing to build a withdrawal from a balance that cannot be read.`);
554
+ }
555
+ if (BigInt(bal) === 0n) {
556
+ return text(`${vault} holds no ${tok}. Nothing to withdraw.`);
557
+ }
558
+ base = String(bal);
559
+ }
560
+ else {
561
+ // `toBaseUnits` returns a bigint; the payload carries base units as a
562
+ // STRING, because that is what the contract's U128 arg expects.
563
+ base = toBaseUnits(amount, cfg.decimals).toString();
564
+ }
565
+ return renderPlan(planWithdraw(vault, tok, base, receiverId, cfg.decimals));
566
+ });
567
+ server.tool("vault_teardown_report", "Read-only. Report exactly what closing this vault would return, what it would strand, and the command that does it. Needs no key and signs nothing. Use this to answer 'can I close this vault and what do I get back?' before anyone exports anything.", {}, async () => {
568
+ const vault = requireVault();
569
+ const tok = cfg.token;
570
+ const [acct, keyList, config] = await Promise.all([
571
+ viewAccount(cfg.rpcUrl, vault),
572
+ viewAccessKeys(cfg.rpcUrl, vault),
573
+ viewFunction(cfg.rpcUrl, vault, "get_config", {}).catch(() => null),
574
+ ]);
575
+ const ftBase = tok
576
+ ? await viewFunction(cfg.rpcUrl, tok, "ft_balance_of", { account_id: vault }).catch(() => null)
577
+ : null;
578
+ const stakeYocto = BigInt(acct.storage_usage) * 10n ** 19n;
579
+ const freeYocto = BigInt(acct.amount) - stakeYocto;
580
+ const gasKeys = keyList
581
+ .map((k) => {
582
+ const pm = k.access_key.permission;
583
+ const v = typeof pm === "object" ? (pm.GasKeyFunctionCall ?? pm.GasKeyFullAccess) : undefined;
584
+ // The balance sits at the TOP level of the permission variant, not
585
+ // under `gas_key_info` as near-api-js's shape suggests.
586
+ return v?.balance !== undefined
587
+ ? { publicKey: k.public_key, balanceYocto: String(v.balance) }
588
+ : null;
589
+ })
590
+ .filter((x) => x !== null);
591
+ const gasTotal = gasKeys.reduce((a, g) => a + BigInt(g.balanceYocto), 0n);
592
+ const fullAccess = keyList.filter((k) => k.access_key.permission === "FullAccess");
593
+ /**
594
+ * CAN ANYTHING SIGN AS THE VAULT? That is the whole question, and it is
595
+ * not "does the owner exist".
596
+ *
597
+ * Every step that recovers the NEAR — emptying a gas key, deploying the
598
+ * stub, the delete itself — needs a signature whose signer_id IS THE
599
+ * VAULT. A browser wallet will not produce one: measured 2026-11-07,
600
+ * Meteor silently signs as the connected account and reports success.
601
+ * So the only route is a full-access key on disk, and a vault carrying
602
+ * none is permanently unrecoverable beyond its tokens.
603
+ */
604
+ const signable = fullAccess.length > 0;
605
+ const nearStr = (y) => (Number(y) / 1e24).toFixed(6);
606
+ const tokenStr = ftBase && /^\d+$/.test(ftBase) ? fromBaseUnits(BigInt(ftBase), cfg.decimals) : null;
607
+ return json({
608
+ vault,
609
+ owner: config?.owner ?? "(could not read get_config)",
610
+ holds: {
611
+ tokens: tokenStr === null ? "unknown — the token contract could not be read" : `${tokenStr} ${tok ?? ""}`.trim(),
612
+ near_free: nearStr(freeYocto),
613
+ near_storage_stake: nearStr(stakeYocto),
614
+ gas_keys: gasKeys.map((g) => ({ ...g, near: nearStr(BigInt(g.balanceYocto)) })),
615
+ gas_key_total_near: nearStr(gasTotal),
616
+ storage_bytes: acct.storage_usage,
617
+ },
618
+ recoverable_now_by_an_owner_signature: {
619
+ tokens: tokenStr ?? "unknown",
620
+ how: "vault_plan_withdraw, approved in the owner's wallet. No key on disk needed.",
621
+ },
622
+ needs_a_full_access_key_on_disk: {
623
+ near: nearStr(freeYocto + stakeYocto + gasTotal),
624
+ why: "Emptying a gas key, shrinking the code and deleting the account all need a " +
625
+ "signature whose signer_id is the VAULT. A browser wallet will not make one — " +
626
+ "measured: Meteor ignores signerId and silently signs as the connected account.",
627
+ possible: signable,
628
+ detail: signable
629
+ ? `${fullAccess.length} FullAccess key(s) on the vault. If one of them is the owner's, ` +
630
+ `teardown-vault.mjs can sign with it.`
631
+ : "NO FullAccess key on this vault. The NEAR is unrecoverable by anyone. Do not " +
632
+ "shrink it: that frees the stake into an account nobody can empty, which is " +
633
+ "worse than leaving it alone.",
634
+ },
635
+ never_recoverable: {
636
+ warning: "DeleteKey on a funded gas key BURNS the balance and reports success — measured, " +
637
+ "0.08322 NEAR as tokens_burnt. Always withdrawFromGasKey first. teardown-vault.mjs " +
638
+ "does this and exits rather than continuing if it fails.",
639
+ },
640
+ command: signable
641
+ ? `NETWORK=${cfg.network} VAULT=${vault} OWNER=${config?.owner ?? "<owner>"} ` +
642
+ `TOKEN=${tok ?? "<token>"} node scripts/teardown-vault.mjs`
643
+ : "(none — nothing can sign as this vault)",
644
+ note: "Read-only. This server holds at most a restricted agent key and signed nothing. " +
645
+ "Run the command yourself, after the token withdrawal has settled.",
646
+ });
647
+ });
230
648
  await server.connect(new StdioServerTransport());
package/dist/sign.d.ts CHANGED
@@ -2,7 +2,8 @@
2
2
  * The ONLY place in this server that touches a key.
3
3
  *
4
4
  * It signs exactly one method — `request_payment` — with the restricted key
5
- * issued by `create_agent_account`. That key is cryptographically incapable of
5
+ * of a VIRTUAL AGENT, installed as a NEP-611 gas key on the vault and bound
6
+ * with `bind_virtual_agent`. That key is cryptographically incapable of
6
7
  * doing anything else: its `receiver_id` is the vault, its `method_names` is
7
8
  * `["request_payment"]`, and a NEAR function-call key cannot attach a deposit
8
9
  * at all. So the worst a compromised copy of this server can do is make
package/dist/sign.js CHANGED
@@ -2,7 +2,8 @@
2
2
  * The ONLY place in this server that touches a key.
3
3
  *
4
4
  * It signs exactly one method — `request_payment` — with the restricted key
5
- * issued by `create_agent_account`. That key is cryptographically incapable of
5
+ * of a VIRTUAL AGENT, installed as a NEP-611 gas key on the vault and bound
6
+ * with `bind_virtual_agent`. That key is cryptographically incapable of
6
7
  * doing anything else: its `receiver_id` is the vault, its `method_names` is
7
8
  * `["request_payment"]`, and a NEAR function-call key cannot attach a deposit
8
9
  * at all. So the worst a compromised copy of this server can do is make
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floatonnear/mcp",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
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",
@@ -37,6 +37,10 @@
37
37
  "./quote": {
38
38
  "types": "./dist/quote.d.ts",
39
39
  "import": "./dist/quote.js"
40
+ },
41
+ "./key-file": {
42
+ "types": "./dist/key-file.d.ts",
43
+ "import": "./dist/key-file.js"
40
44
  }
41
45
  }
42
46
  }