@floatonnear/mcp 0.2.0 → 0.4.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/agent-key.d.ts +75 -0
- package/dist/agent-key.js +118 -0
- package/dist/create.d.ts +161 -0
- package/dist/create.js +218 -0
- package/dist/key-file.d.ts +55 -0
- package/dist/key-file.js +237 -0
- package/dist/payloads.d.ts +95 -10
- package/dist/payloads.js +216 -43
- package/dist/roles.d.ts +18 -0
- package/dist/roles.js +61 -0
- package/dist/rpc.d.ts +51 -0
- package/dist/rpc.js +100 -4
- package/dist/server.d.ts +29 -3
- package/dist/server.js +425 -19
- package/dist/sign.d.ts +2 -1
- package/dist/sign.js +2 -1
- package/package.json +5 -1
package/dist/server.d.ts
CHANGED
|
@@ -10,12 +10,38 @@
|
|
|
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
|
-
*
|
|
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
|
export {};
|
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
|
-
*
|
|
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,
|
|
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
|
-
|
|
38
|
-
|
|
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.
|
|
186
|
+
const server = new McpServer({ name: "float", version: "0.4.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,174 @@ 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
|
+
// AN AGENT NEEDS A VAULT ID, and not for the transaction — the page
|
|
320
|
+
// derives that from the connected wallet. It is needed for the CREDENTIAL,
|
|
321
|
+
// which the adapter files under the account the key signs as. A virtual
|
|
322
|
+
// agent's key is a gas key ON the vault, so that account is the vault
|
|
323
|
+
// itself, and without an owner there is nothing to derive it from.
|
|
324
|
+
//
|
|
325
|
+
// Refusing beats writing a record with a null account: such a record is
|
|
326
|
+
// skipped in silence by `loadKeyring`, so the agent would appear set up
|
|
327
|
+
// and simply never be found.
|
|
328
|
+
if (bindAgent && !vaultId) {
|
|
329
|
+
return text(`Binding an agent needs \`owner\`, because the agent's credential is filed ` +
|
|
330
|
+
`under the vault id and that is derived from the owner. Call vault_create ` +
|
|
331
|
+
`again with the user's NEAR account, or omit bindAgent and bind afterwards ` +
|
|
332
|
+
`with vault_plan_bind_agent.`);
|
|
333
|
+
}
|
|
334
|
+
let generated = null;
|
|
335
|
+
if (bindAgent && vaultId)
|
|
336
|
+
generated = generateAgentKey({ vault: vaultId });
|
|
337
|
+
try {
|
|
338
|
+
const plan = buildCreatePlan({
|
|
339
|
+
owner: owner ?? "",
|
|
340
|
+
ownerKnown: !!owner,
|
|
341
|
+
vaultId,
|
|
342
|
+
factory: fac,
|
|
343
|
+
network: net,
|
|
344
|
+
requiredDepositYocto: String(config.required_deposit),
|
|
345
|
+
paused: config.paused,
|
|
346
|
+
appUrl: cfg.appUrl,
|
|
347
|
+
exists,
|
|
348
|
+
agentPk: generated?.publicKey ?? null,
|
|
349
|
+
});
|
|
350
|
+
return json({
|
|
351
|
+
...plan,
|
|
352
|
+
// THE PUBLIC HALF ONLY. The secret is on disk; a tool response is a
|
|
353
|
+
// transcript entry forever.
|
|
354
|
+
agent: generated
|
|
355
|
+
? {
|
|
356
|
+
publicKey: generated.publicKey,
|
|
357
|
+
nodeId: AGENT_NODE_ID,
|
|
358
|
+
credentialWrittenTo: generated.keyringPath,
|
|
359
|
+
envWrittenTo: generated.envPath,
|
|
360
|
+
...(generated.backedUpTo ? { previousKeyringMovedTo: generated.backedUpTo } : {}),
|
|
361
|
+
}
|
|
362
|
+
: null,
|
|
363
|
+
warnings: [
|
|
364
|
+
...extra,
|
|
365
|
+
...plan.warnings,
|
|
366
|
+
...(generated
|
|
367
|
+
? [
|
|
368
|
+
`Show the user this agent key and ask them to confirm it matches what ` +
|
|
369
|
+
`the page displays before they approve: ${generated.publicKey}`,
|
|
370
|
+
`The agent's private key is at ${generated.keyringPath}. It can only call ` +
|
|
371
|
+
`request_payment on this vault, inside the budget. Do not print it.`,
|
|
372
|
+
]
|
|
373
|
+
: []),
|
|
374
|
+
],
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
catch (e) {
|
|
378
|
+
return text(String(e.message));
|
|
379
|
+
}
|
|
380
|
+
});
|
|
117
381
|
// ------------------------------------------------------------------- pay
|
|
118
382
|
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
383
|
amount: z.string().describe('human units, e.g. "12.50"'),
|
|
@@ -129,8 +393,16 @@ server.tool("vault_pay", "Send a payment from the agent's node. Signs with the a
|
|
|
129
393
|
.string()
|
|
130
394
|
.optional()
|
|
131
395
|
.describe("1Click asset id for rail=intents. Defaults to USDC on Base."),
|
|
132
|
-
|
|
133
|
-
|
|
396
|
+
role: z
|
|
397
|
+
.string()
|
|
398
|
+
.optional()
|
|
399
|
+
.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."),
|
|
400
|
+
}, async ({ amount, recipient, memo, token, requestId, rail, destinationAsset, role }) => {
|
|
401
|
+
// WHICH KEY SIGNS *IS* WHICH BUDGET IS CHARGED. The contract resolves the
|
|
402
|
+
// spending node from the signer, not from anything in the request body,
|
|
403
|
+
// so picking the wrong key does not fail — it succeeds against someone
|
|
404
|
+
// else's budget and surfaces later as a confusing balance.
|
|
405
|
+
const creds = role === undefined ? requireAgentKey(cfg) : selectRole(cfg, role);
|
|
134
406
|
const id = requestId ?? newRequestId();
|
|
135
407
|
const vault = requireVault();
|
|
136
408
|
let route = "near_direct";
|
|
@@ -141,11 +413,19 @@ server.tool("vault_pay", "Send a payment from the agent's node. Signs with the a
|
|
|
141
413
|
// does not live where agents run, because whoever holds it can bind any
|
|
142
414
|
// deposit address to any payee. So this path only works when the MCP
|
|
143
415
|
// server is the operator's own.
|
|
144
|
-
const signerHex =
|
|
416
|
+
const signerHex = quoteSignerSource.secret;
|
|
145
417
|
if (!signerHex) {
|
|
146
|
-
throw new Error(
|
|
147
|
-
|
|
148
|
-
|
|
418
|
+
throw new Error(
|
|
419
|
+
// A named-but-broken FILE is a cause; "the variable is unset" is a
|
|
420
|
+
// description. Reporting the second when the first applies sends the
|
|
421
|
+
// operator to set something they have already set.
|
|
422
|
+
quoteSignerSource.error
|
|
423
|
+
? `rail=intents needs the quote signer, and the key file could not be read. ` +
|
|
424
|
+
`${quoteSignerSource.error}`
|
|
425
|
+
: "rail=intents needs FLOAT_QUOTE_SIGNER_KEY (32-byte hex), or " +
|
|
426
|
+
"FLOAT_QUOTE_SIGNER_KEY_FILE pointing at a file that holds it. " +
|
|
427
|
+
"That key binds a payee to a deposit address, so it belongs to the " +
|
|
428
|
+
"vault operator, not to an agent. Without it the cross-chain rail is closed.");
|
|
149
429
|
}
|
|
150
430
|
const sk = Uint8Array.from(Buffer.from(signerHex.replace(/^0x/, ""), "hex"));
|
|
151
431
|
// EXACT_OUTPUT: `amount` is what the PAYEE receives. What leaves the
|
|
@@ -199,19 +479,26 @@ server.tool("vault_plan_add_node", "Build an UNSIGNED payload to create a budget
|
|
|
199
479
|
recipients: z.array(z.string()).nullable().default(null),
|
|
200
480
|
}, async ({ parent, label, limits, cooldownSec, recipients }) => renderPlan(planAddNode(requireVault(), parent, label, limits, cooldownSec, recipients)));
|
|
201
481
|
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("
|
|
482
|
+
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
483
|
nodeId: z.number().int().min(0),
|
|
204
|
-
subLabel: z.string(),
|
|
205
484
|
publicKey: z.string().describe("ed25519:..."),
|
|
206
|
-
|
|
207
|
-
|
|
485
|
+
}, async ({ nodeId, publicKey }) => renderPlan(planBindVirtualAgent(requireVault(), nodeId, publicKey)));
|
|
486
|
+
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
487
|
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
488
|
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
489
|
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)));
|
|
211
490
|
server.tool("vault_pending_approval", "Look up a payment that is HELD awaiting the owner's approval. Returns null if there is no ticket for that id — already approved, already rejected, or never held.", { requestId: z.string().min(1) }, async ({ requestId }) => json((await viewFunction(cfg.rpcUrl, requireVault(), "get_pending_approval", {
|
|
212
491
|
request_id: requestId,
|
|
213
492
|
})) ?? null));
|
|
214
|
-
server.tool("vault_plan_set_approval_threshold",
|
|
493
|
+
server.tool("vault_plan_set_approval_threshold", 'Build an UNSIGNED payload requiring the owner\'s approval for payments above an amount at this node. A threshold sits INSIDE the caps and can only make the vault more conservative. `amount` is HUMAN units, the same convention as vault_pay — "0.25" is a quarter of a USDC. Pass amount null to remove the threshold. Not signed by this server.', {
|
|
494
|
+
nodeId: z.number().int().min(0),
|
|
495
|
+
// HUMAN units, matching vault_pay and vault_plan_set_caps. This took BASE
|
|
496
|
+
// units until 2026-10-31 and was the only tool here that did — on the one
|
|
497
|
+
// control that configures a safety gate, where the mistake fails OPEN. See
|
|
498
|
+
// planSetApprovalThreshold in payloads.ts for the measurement.
|
|
499
|
+
amount: z.string().min(1).nullable().describe('human units, e.g. "0.25" — NOT base units'),
|
|
500
|
+
decimals: z.number().int().min(0).max(38).default(6),
|
|
501
|
+
}, async ({ nodeId, amount, decimals }) => renderPlan(planSetApprovalThreshold(requireVault(), nodeId, amount, decimals)));
|
|
215
502
|
server.tool("vault_plan_approve_payment", "Build an UNSIGNED payload to APPROVE a held payment, which sends it. Every limit is re-checked at approval, so it can still be refused. Not signed by this server.", { requestId: z.string().min(1) }, async ({ requestId }) => renderPlan(planApprovePayment(requireVault(), requestId)));
|
|
216
503
|
server.tool("vault_plan_reject_payment", "Build an UNSIGNED payload to REJECT a held payment. Nothing moves and the ticket is discarded. Not signed by this server.", { requestId: z.string().min(1) }, async ({ requestId }) => renderPlan(planRejectPayment(requireVault(), requestId)));
|
|
217
504
|
server.tool("vault_plan_pause", "Build an UNSIGNED payload to pause or resume the whole vault. Pausing halts every agent payment. Not signed by this server.", { paused: z.boolean() }, async ({ paused }) => renderPlan(planSetPaused(requireVault(), paused)));
|
|
@@ -219,4 +506,123 @@ server.tool("vault_plan_purge", "Build an UNSIGNED payload to delete settled pay
|
|
|
219
506
|
beforeDay: z.number().int().min(0),
|
|
220
507
|
limit: z.number().int().min(0).max(100).default(100),
|
|
221
508
|
}, async ({ beforeDay, limit }) => renderPlan(planPurgeSettled(requireVault(), beforeDay, limit)));
|
|
509
|
+
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.", {
|
|
510
|
+
amount: z
|
|
511
|
+
.string()
|
|
512
|
+
.describe("HUMAN units, e.g. \"12.5\" for 12.5 USDC. Omit to withdraw the vault's entire balance, read from the token contract.")
|
|
513
|
+
.optional(),
|
|
514
|
+
receiverId: z.string().describe("who receives the tokens, e.g. alice.testnet"),
|
|
515
|
+
token: z.string().optional().describe("overrides NEAR_VAULT_TOKEN"),
|
|
516
|
+
}, async ({ amount, receiverId, token }) => {
|
|
517
|
+
const vault = requireVault();
|
|
518
|
+
const tok = tokenFor(token);
|
|
519
|
+
/**
|
|
520
|
+
* NO AMOUNT MEANS "ALL", READ FROM THE CHAIN — not a guess, and not a
|
|
521
|
+
* figure the caller carried in. The balance is taken as the token's own
|
|
522
|
+
* base-unit STRING and passed through untouched, because re-deriving it
|
|
523
|
+
* through a float leaves dust and a vault with dust never reaches the
|
|
524
|
+
* zero a delete needs.
|
|
525
|
+
*/
|
|
526
|
+
let base;
|
|
527
|
+
if (amount === undefined) {
|
|
528
|
+
const bal = await viewFunction(cfg.rpcUrl, tok, "ft_balance_of", {
|
|
529
|
+
account_id: vault,
|
|
530
|
+
});
|
|
531
|
+
if (!/^\d+$/.test(String(bal))) {
|
|
532
|
+
return text(`${tok} reported this vault's balance as ${JSON.stringify(bal)}, which is not a ` +
|
|
533
|
+
`number. Refusing to build a withdrawal from a balance that cannot be read.`);
|
|
534
|
+
}
|
|
535
|
+
if (BigInt(bal) === 0n) {
|
|
536
|
+
return text(`${vault} holds no ${tok}. Nothing to withdraw.`);
|
|
537
|
+
}
|
|
538
|
+
base = String(bal);
|
|
539
|
+
}
|
|
540
|
+
else {
|
|
541
|
+
// `toBaseUnits` returns a bigint; the payload carries base units as a
|
|
542
|
+
// STRING, because that is what the contract's U128 arg expects.
|
|
543
|
+
base = toBaseUnits(amount, cfg.decimals).toString();
|
|
544
|
+
}
|
|
545
|
+
return renderPlan(planWithdraw(vault, tok, base, receiverId, cfg.decimals));
|
|
546
|
+
});
|
|
547
|
+
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 () => {
|
|
548
|
+
const vault = requireVault();
|
|
549
|
+
const tok = cfg.token;
|
|
550
|
+
const [acct, keyList, config] = await Promise.all([
|
|
551
|
+
viewAccount(cfg.rpcUrl, vault),
|
|
552
|
+
viewAccessKeys(cfg.rpcUrl, vault),
|
|
553
|
+
viewFunction(cfg.rpcUrl, vault, "get_config", {}).catch(() => null),
|
|
554
|
+
]);
|
|
555
|
+
const ftBase = tok
|
|
556
|
+
? await viewFunction(cfg.rpcUrl, tok, "ft_balance_of", { account_id: vault }).catch(() => null)
|
|
557
|
+
: null;
|
|
558
|
+
const stakeYocto = BigInt(acct.storage_usage) * 10n ** 19n;
|
|
559
|
+
const freeYocto = BigInt(acct.amount) - stakeYocto;
|
|
560
|
+
const gasKeys = keyList
|
|
561
|
+
.map((k) => {
|
|
562
|
+
const pm = k.access_key.permission;
|
|
563
|
+
const v = typeof pm === "object" ? (pm.GasKeyFunctionCall ?? pm.GasKeyFullAccess) : undefined;
|
|
564
|
+
// The balance sits at the TOP level of the permission variant, not
|
|
565
|
+
// under `gas_key_info` as near-api-js's shape suggests.
|
|
566
|
+
return v?.balance !== undefined
|
|
567
|
+
? { publicKey: k.public_key, balanceYocto: String(v.balance) }
|
|
568
|
+
: null;
|
|
569
|
+
})
|
|
570
|
+
.filter((x) => x !== null);
|
|
571
|
+
const gasTotal = gasKeys.reduce((a, g) => a + BigInt(g.balanceYocto), 0n);
|
|
572
|
+
const fullAccess = keyList.filter((k) => k.access_key.permission === "FullAccess");
|
|
573
|
+
/**
|
|
574
|
+
* CAN ANYTHING SIGN AS THE VAULT? That is the whole question, and it is
|
|
575
|
+
* not "does the owner exist".
|
|
576
|
+
*
|
|
577
|
+
* Every step that recovers the NEAR — emptying a gas key, deploying the
|
|
578
|
+
* stub, the delete itself — needs a signature whose signer_id IS THE
|
|
579
|
+
* VAULT. A browser wallet will not produce one: measured 2026-11-07,
|
|
580
|
+
* Meteor silently signs as the connected account and reports success.
|
|
581
|
+
* So the only route is a full-access key on disk, and a vault carrying
|
|
582
|
+
* none is permanently unrecoverable beyond its tokens.
|
|
583
|
+
*/
|
|
584
|
+
const signable = fullAccess.length > 0;
|
|
585
|
+
const nearStr = (y) => (Number(y) / 1e24).toFixed(6);
|
|
586
|
+
const tokenStr = ftBase && /^\d+$/.test(ftBase) ? fromBaseUnits(BigInt(ftBase), cfg.decimals) : null;
|
|
587
|
+
return json({
|
|
588
|
+
vault,
|
|
589
|
+
owner: config?.owner ?? "(could not read get_config)",
|
|
590
|
+
holds: {
|
|
591
|
+
tokens: tokenStr === null ? "unknown — the token contract could not be read" : `${tokenStr} ${tok ?? ""}`.trim(),
|
|
592
|
+
near_free: nearStr(freeYocto),
|
|
593
|
+
near_storage_stake: nearStr(stakeYocto),
|
|
594
|
+
gas_keys: gasKeys.map((g) => ({ ...g, near: nearStr(BigInt(g.balanceYocto)) })),
|
|
595
|
+
gas_key_total_near: nearStr(gasTotal),
|
|
596
|
+
storage_bytes: acct.storage_usage,
|
|
597
|
+
},
|
|
598
|
+
recoverable_now_by_an_owner_signature: {
|
|
599
|
+
tokens: tokenStr ?? "unknown",
|
|
600
|
+
how: "vault_plan_withdraw, approved in the owner's wallet. No key on disk needed.",
|
|
601
|
+
},
|
|
602
|
+
needs_a_full_access_key_on_disk: {
|
|
603
|
+
near: nearStr(freeYocto + stakeYocto + gasTotal),
|
|
604
|
+
why: "Emptying a gas key, shrinking the code and deleting the account all need a " +
|
|
605
|
+
"signature whose signer_id is the VAULT. A browser wallet will not make one — " +
|
|
606
|
+
"measured: Meteor ignores signerId and silently signs as the connected account.",
|
|
607
|
+
possible: signable,
|
|
608
|
+
detail: signable
|
|
609
|
+
? `${fullAccess.length} FullAccess key(s) on the vault. If one of them is the owner's, ` +
|
|
610
|
+
`teardown-vault.mjs can sign with it.`
|
|
611
|
+
: "NO FullAccess key on this vault. The NEAR is unrecoverable by anyone. Do not " +
|
|
612
|
+
"shrink it: that frees the stake into an account nobody can empty, which is " +
|
|
613
|
+
"worse than leaving it alone.",
|
|
614
|
+
},
|
|
615
|
+
never_recoverable: {
|
|
616
|
+
warning: "DeleteKey on a funded gas key BURNS the balance and reports success — measured, " +
|
|
617
|
+
"0.08322 NEAR as tokens_burnt. Always withdrawFromGasKey first. teardown-vault.mjs " +
|
|
618
|
+
"does this and exits rather than continuing if it fails.",
|
|
619
|
+
},
|
|
620
|
+
command: signable
|
|
621
|
+
? `NETWORK=${cfg.network} VAULT=${vault} OWNER=${config?.owner ?? "<owner>"} ` +
|
|
622
|
+
`TOKEN=${tok ?? "<token>"} node scripts/teardown-vault.mjs`
|
|
623
|
+
: "(none — nothing can sign as this vault)",
|
|
624
|
+
note: "Read-only. This server holds at most a restricted agent key and signed nothing. " +
|
|
625
|
+
"Run the command yourself, after the token withdrawal has settled.",
|
|
626
|
+
});
|
|
627
|
+
});
|
|
222
628
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
+
"version": "0.4.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
|
}
|