@floatonnear/mcp 0.0.0-stage → 0.1.1

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/quote.js ADDED
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Getting a cross-chain quote, and signing the binding the vault verifies.
3
+ *
4
+ * A cross-chain payment is an ordinary NEP-141 transfer on NEAR to a ONE-TIME
5
+ * deposit address; a NEAR Intents solver delivers the destination asset and
6
+ * prices the far chain's gas into the quote. No MPC signature, no derived
7
+ * address, no ETH to keep topped up.
8
+ *
9
+ * The catch is that the deposit address is unpredictable and single-use, so it
10
+ * can never be on the vault's payee allowlist. The vault checks the allowlist
11
+ * against the final payee and then sends the money somewhere it cannot connect
12
+ * back to that payee. If an agent could choose that address, the allowlist
13
+ * would be decorative — quote a swap paying yourself and the vault funds it
14
+ * with every other check passing.
15
+ *
16
+ * So a key the vault owner registered signs the binding between payee and
17
+ * deposit address, and the contract refuses any Intents payment without it.
18
+ * This module is the holder of that key. It is a TRUSTED PRODUCER: whoever
19
+ * runs it can redirect any Intents payment the budget allows, which is why it
20
+ * belongs on the operator's machine and not on the ones agents run on.
21
+ */
22
+ import { ed25519 } from "@noble/curves/ed25519";
23
+ export const ONE_CLICK_URL = process.env.ONE_CLICK_URL ?? "https://1click.chaindefuser.com";
24
+ /** Mainnet asset ids, from GET /v0/tokens. Intents has no testnet. */
25
+ export const ASSETS = {
26
+ usdcNear: "nep141:17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1",
27
+ usdcBase: "nep141:base-0x833589fcd6edb6e08f4c7c32d4f71b54bda02913.omft.near",
28
+ };
29
+ // ---------------------------------------------------------------- borsh
30
+ /**
31
+ * Borsh, hand-rolled, because exactly one struct crosses this boundary and a
32
+ * dependency would be more surface than the twenty lines below.
33
+ *
34
+ * It must match `QuoteBinding` in contracts/vault/src/types.rs BYTE FOR BYTE.
35
+ * If it does not, no signature ever verifies, and the failure is silent and
36
+ * total — indistinguishable from a wrong key. The same vector is asserted in
37
+ * `quote_binding_borsh_vector` on the Rust side and in quote.test.ts here, so
38
+ * a field reorder breaks a test rather than the rail.
39
+ */
40
+ const u32le = (n) => {
41
+ const b = new Uint8Array(4);
42
+ new DataView(b.buffer).setUint32(0, n, true);
43
+ return b;
44
+ };
45
+ const u64le = (n) => {
46
+ const b = new Uint8Array(8);
47
+ new DataView(b.buffer).setBigUint64(0, n, true);
48
+ return b;
49
+ };
50
+ /** borsh u128: 16 bytes little-endian. */
51
+ const u128le = (n) => {
52
+ const b = new Uint8Array(16);
53
+ const dv = new DataView(b.buffer);
54
+ dv.setBigUint64(0, n & 0xffffffffffffffffn, true);
55
+ dv.setBigUint64(8, n >> 64n, true);
56
+ return b;
57
+ };
58
+ /** borsh string: u32 length prefix, then utf8. The prefix is what makes the
59
+ * encoding unambiguous — a plain concatenation would let ("alic","ebob") and
60
+ * ("alice","bob") produce identical bytes and sign each other's quotes. */
61
+ const str = (s) => {
62
+ const utf8 = new TextEncoder().encode(s);
63
+ return concat(u32le(utf8.length), utf8);
64
+ };
65
+ function concat(...parts) {
66
+ const total = parts.reduce((n, p) => n + p.length, 0);
67
+ const out = new Uint8Array(total);
68
+ let o = 0;
69
+ for (const p of parts) {
70
+ out.set(p, o);
71
+ o += p.length;
72
+ }
73
+ return out;
74
+ }
75
+ /** Field order is the struct's declaration order; borsh has no field names. */
76
+ export function encodeBinding(b) {
77
+ return concat(str(b.vault), str(b.requestId), str(b.token), u128le(b.amount), str(b.payee), str(b.depositAddress), u64le(b.deadlineNs));
78
+ }
79
+ export function signBinding(b, secretKey) {
80
+ if (secretKey.length !== 32) {
81
+ throw new Error(`quote signer key must be 32 bytes, got ${secretKey.length}`);
82
+ }
83
+ return ed25519.sign(encodeBinding(b), secretKey);
84
+ }
85
+ export function publicKeyOf(secretKey) {
86
+ return ed25519.getPublicKey(secretKey);
87
+ }
88
+ export const toBase64 = (b) => Buffer.from(b).toString("base64");
89
+ /**
90
+ * Ask 1Click what it costs to deliver `amountOut` to `payee` on the
91
+ * destination chain.
92
+ *
93
+ * EXACT_OUTPUT, deliberately. A payment is a promise about what the PAYEE
94
+ * receives; EXACT_INPUT would deliver "about a dollar" and leave the agent
95
+ * unable to settle an invoice. The vault is then charged `amountIn`, which is
96
+ * what actually leaves it — so the budget governs real outflow, not the
97
+ * nominal figure.
98
+ */
99
+ export async function getQuote(opts) {
100
+ const body = {
101
+ dry: opts.dry ?? false,
102
+ swapType: "EXACT_OUTPUT",
103
+ slippageTolerance: opts.slippageBps ?? 100,
104
+ originAsset: opts.originAsset ?? ASSETS.usdcNear,
105
+ depositType: "ORIGIN_CHAIN",
106
+ destinationAsset: opts.destinationAsset ?? ASSETS.usdcBase,
107
+ amount: opts.amountOut.toString(),
108
+ refundTo: opts.refundTo,
109
+ refundType: "ORIGIN_CHAIN",
110
+ recipient: opts.payee,
111
+ recipientType: "DESTINATION_CHAIN",
112
+ deadline: opts.deadline.toISOString().replace(/\.\d{3}Z$/, "Z"),
113
+ };
114
+ const r = await fetch(`${ONE_CLICK_URL}/v0/quote`, {
115
+ method: "POST",
116
+ headers: {
117
+ "content-type": "application/json",
118
+ accept: "application/json",
119
+ ...(opts.jwt ? { authorization: `Bearer ${opts.jwt}` } : {}),
120
+ },
121
+ body: JSON.stringify(body),
122
+ });
123
+ const text = await r.text();
124
+ if (!r.ok)
125
+ throw new Error(`1Click HTTP ${r.status}: ${text.slice(0, 220)}`);
126
+ let j;
127
+ try {
128
+ j = JSON.parse(text);
129
+ }
130
+ catch {
131
+ throw new Error(`1Click returned non-JSON: ${text.slice(0, 160)}`);
132
+ }
133
+ const q = j.quote;
134
+ if (!q)
135
+ throw new Error(`1Click returned no quote: ${text.slice(0, 220)}`);
136
+ // A dry quote has no deposit address — it is a price, not an instruction.
137
+ // Treating one as live would send real money to `undefined`.
138
+ const depositAddress = j.quote?.depositAddress ?? j.depositAddress;
139
+ if (!opts.dry && !depositAddress) {
140
+ throw new Error("1Click returned no depositAddress for a live quote");
141
+ }
142
+ return {
143
+ depositAddress: depositAddress ?? "",
144
+ amountIn: BigInt(q.amountIn),
145
+ amountOut: BigInt(q.amountOut),
146
+ deadlineNs: BigInt(opts.deadline.getTime()) * 1000000n,
147
+ timeEstimateSec: Number(q.timeEstimate ?? 0),
148
+ };
149
+ }
150
+ /** The `route` argument for a cross-chain payment. */
151
+ export function intentsRoute(depositAddress, deadlineNs, signature) {
152
+ return {
153
+ intents: {
154
+ deposit_address: depositAddress,
155
+ quote_deadline_ns: deadlineNs.toString(),
156
+ signature: toBase64(signature),
157
+ },
158
+ };
159
+ }
package/dist/rpc.d.ts ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Read path and small helpers.
3
+ *
4
+ * Views go over raw JSON-RPC rather than through a client library, for one
5
+ * reason that matters: they need NO key. The server is fully useful with zero
6
+ * secrets configured — reads and payload planning work, and only `vault_pay`
7
+ * requires an agent key. Keeping the read path dependency-free means a
8
+ * misconfigured or absent key cannot break it.
9
+ */
10
+ /**
11
+ * NOT `rpc.testnet.near.org`. That endpoint now answers every request with
12
+ * HTTP error -429 and the text "THIS ENDPOINT IS DEPRECATED! STOP USING IT
13
+ * NOW!" — observed 2026-10-26. It fails closed for reads, which would make the
14
+ * whole product look broken for anyone who left the default in place.
15
+ *
16
+ * Overridable with NEAR_VAULT_RPC_URL; providers are listed at
17
+ * https://docs.near.org/api/rpc/providers
18
+ */
19
+ export declare const DEFAULT_RPC_URL = "https://test.rpc.fastnear.com";
20
+ export declare function encodeArgs(args: unknown): string;
21
+ interface RawViewResponse {
22
+ result?: number[];
23
+ error?: unknown;
24
+ }
25
+ /**
26
+ * Decode a `call_function` response.
27
+ *
28
+ * NEAR returns the value as an array of byte values. The failure mode worth
29
+ * guarding is an error shape that decodes to nothing: `undefined` reads as "no
30
+ * data" when it actually means "the call failed", and a caller would treat an
31
+ * exhausted budget and a broken RPC identically.
32
+ */
33
+ export declare function decodeViewResult<T = unknown>(res: RawViewResponse): T;
34
+ export declare function viewFunction<T = unknown>(rpcUrl: string, contractId: string, methodName: string, args?: unknown): Promise<T>;
35
+ /** One access key as NEAR reports it. */
36
+ export interface AccessKeyInfo {
37
+ public_key: string;
38
+ access_key: {
39
+ nonce: number;
40
+ permission: "FullAccess" | {
41
+ FunctionCall: {
42
+ allowance: string | null;
43
+ receiver_id: string;
44
+ method_names: string[];
45
+ };
46
+ };
47
+ };
48
+ }
49
+ /**
50
+ * Every access key on an account, read from the chain.
51
+ *
52
+ * This exists so the UI can state the agent key restriction as an OBSERVATION
53
+ * rather than a claim. "Restricted to request_payment" rendered from a
54
+ * hardcoded string would stay on screen even if the key were actually
55
+ * full-access — and that is the one thing a reader of that page most needs to
56
+ * be true.
57
+ */
58
+ export declare function viewAccessKeys(rpcUrl: string, accountId: string): Promise<AccessKeyInfo[]>;
59
+ export interface AgentCreds {
60
+ agentId: string;
61
+ agentKey: string;
62
+ }
63
+ /**
64
+ * Both halves or neither. Failing here with the variable NAMES beats throwing a
65
+ * TypeError three frames deeper, and beats silently signing with the wrong
66
+ * account.
67
+ */
68
+ export declare function requireAgentKey(env: {
69
+ agentId?: string;
70
+ agentKey?: string;
71
+ }): AgentCreds;
72
+ /**
73
+ * A request id the contract will accept.
74
+ *
75
+ * `is_valid_request_id` allows 1..=64 of [A-Za-z0-9._:-]. A raw UUID is fine;
76
+ * a timestamp with a space, or anything brace-wrapped, would be rejected
77
+ * on-chain AFTER signing — which costs gas to learn.
78
+ */
79
+ export declare function newRequestId(): string;
80
+ export {};
package/dist/rpc.js ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Read path and small helpers.
3
+ *
4
+ * Views go over raw JSON-RPC rather than through a client library, for one
5
+ * reason that matters: they need NO key. The server is fully useful with zero
6
+ * secrets configured — reads and payload planning work, and only `vault_pay`
7
+ * requires an agent key. Keeping the read path dependency-free means a
8
+ * misconfigured or absent key cannot break it.
9
+ */
10
+ import { randomUUID } from "node:crypto";
11
+ /**
12
+ * NOT `rpc.testnet.near.org`. That endpoint now answers every request with
13
+ * HTTP error -429 and the text "THIS ENDPOINT IS DEPRECATED! STOP USING IT
14
+ * NOW!" — observed 2026-10-26. It fails closed for reads, which would make the
15
+ * whole product look broken for anyone who left the default in place.
16
+ *
17
+ * Overridable with NEAR_VAULT_RPC_URL; providers are listed at
18
+ * https://docs.near.org/api/rpc/providers
19
+ */
20
+ export const DEFAULT_RPC_URL = "https://test.rpc.fastnear.com";
21
+ export function encodeArgs(args) {
22
+ // Explicit utf8: a memo is free text, goes on-chain, and a latin-1
23
+ // truncation here would corrupt it silently.
24
+ return Buffer.from(JSON.stringify(args), "utf8").toString("base64");
25
+ }
26
+ /**
27
+ * Decode a `call_function` response.
28
+ *
29
+ * NEAR returns the value as an array of byte values. The failure mode worth
30
+ * guarding is an error shape that decodes to nothing: `undefined` reads as "no
31
+ * data" when it actually means "the call failed", and a caller would treat an
32
+ * exhausted budget and a broken RPC identically.
33
+ */
34
+ export function decodeViewResult(res) {
35
+ if (res.error !== undefined && res.error !== null) {
36
+ const e = res.error;
37
+ const detail = typeof res.error === "string"
38
+ ? res.error
39
+ : [e.message, typeof e.data === "string" ? e.data : JSON.stringify(e.data)]
40
+ .filter(Boolean)
41
+ .join(": ");
42
+ throw new Error(`view call failed: ${detail}`);
43
+ }
44
+ if (!Array.isArray(res.result)) {
45
+ throw new Error(`unexpected RPC response: neither a result nor an error (${JSON.stringify(res).slice(0, 200)})`);
46
+ }
47
+ return JSON.parse(Buffer.from(res.result).toString("utf8"));
48
+ }
49
+ export async function viewFunction(rpcUrl, contractId, methodName, args = {}) {
50
+ const body = {
51
+ jsonrpc: "2.0",
52
+ id: "near-vault-mcp",
53
+ method: "query",
54
+ params: {
55
+ request_type: "call_function",
56
+ finality: "final",
57
+ account_id: contractId,
58
+ method_name: methodName,
59
+ args_base64: encodeArgs(args),
60
+ },
61
+ };
62
+ const r = await fetch(rpcUrl, {
63
+ method: "POST",
64
+ headers: { "content-type": "application/json" },
65
+ body: JSON.stringify(body),
66
+ });
67
+ if (!r.ok)
68
+ throw new Error(`RPC ${rpcUrl} returned HTTP ${r.status}`);
69
+ const json = (await r.json());
70
+ if (json.error)
71
+ return decodeViewResult({ error: json.error });
72
+ return decodeViewResult(json.result ?? {});
73
+ }
74
+ /**
75
+ * Every access key on an account, read from the chain.
76
+ *
77
+ * This exists so the UI can state the agent key restriction as an OBSERVATION
78
+ * rather than a claim. "Restricted to request_payment" rendered from a
79
+ * hardcoded string would stay on screen even if the key were actually
80
+ * full-access — and that is the one thing a reader of that page most needs to
81
+ * be true.
82
+ */
83
+ export async function viewAccessKeys(rpcUrl, accountId) {
84
+ const r = await fetch(rpcUrl, {
85
+ method: "POST",
86
+ headers: { "content-type": "application/json" },
87
+ body: JSON.stringify({
88
+ jsonrpc: "2.0",
89
+ id: "near-vault-mcp",
90
+ method: "query",
91
+ params: { request_type: "view_access_key_list", finality: "final", account_id: accountId },
92
+ }),
93
+ });
94
+ if (!r.ok)
95
+ throw new Error(`RPC ${rpcUrl} returned HTTP ${r.status}`);
96
+ const json = (await r.json());
97
+ if (json.error)
98
+ throw new Error(`access key list failed: ${json.error.message ?? "unknown"}`);
99
+ return json.result?.keys ?? [];
100
+ }
101
+ /**
102
+ * Both halves or neither. Failing here with the variable NAMES beats throwing a
103
+ * TypeError three frames deeper, and beats silently signing with the wrong
104
+ * account.
105
+ */
106
+ export function requireAgentKey(env) {
107
+ // Name EVERY missing variable, not just the first. A message that reports
108
+ // half the problem buys a second failed attempt.
109
+ if (!env.agentId && !env.agentKey) {
110
+ throw new Error("Paying requires NEAR_VAULT_AGENT_ID and NEAR_VAULT_AGENT_KEY, and neither is set. " +
111
+ "Reads and payload planning work without them; only paying needs them. " +
112
+ "The key must be the restricted one issued by create_agent_account — never a " +
113
+ "full-access key.");
114
+ }
115
+ if (!env.agentId) {
116
+ throw new Error("NEAR_VAULT_AGENT_ID is not set, so there is no agent account to pay from.");
117
+ }
118
+ if (!env.agentKey) {
119
+ throw new Error("NEAR_VAULT_AGENT_KEY is not set. This must be the restricted key issued by " +
120
+ "create_agent_account — it can call only request_payment on the vault, cannot " +
121
+ "call other contracts, and cannot attach a deposit. Never put a full-access key here.");
122
+ }
123
+ return { agentId: env.agentId, agentKey: env.agentKey };
124
+ }
125
+ /**
126
+ * A request id the contract will accept.
127
+ *
128
+ * `is_valid_request_id` allows 1..=64 of [A-Za-z0-9._:-]. A raw UUID is fine;
129
+ * a timestamp with a space, or anything brace-wrapped, would be rejected
130
+ * on-chain AFTER signing — which costs gas to learn.
131
+ */
132
+ export function newRequestId() {
133
+ return randomUUID();
134
+ }
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * MCP server for the hierarchical budget vault.
4
+ *
5
+ * Trust model, which is the whole point of the split below:
6
+ *
7
+ * READ tools no key at all, raw JSON-RPC
8
+ * PAY tool signs with the agent's RESTRICTED key — request_payment
9
+ * on this vault only, no deposit possible
10
+ * PLAN tools emit UNSIGNED payloads for the human's wallet; this
11
+ * server never signs anything that changes policy
12
+ *
13
+ * Configure with:
14
+ * NEAR_VAULT_CONTRACT required
15
+ * NEAR_VAULT_RPC_URL default https://test.rpc.fastnear.com
16
+ * NEAR_VAULT_TOKEN default token for pay/budget calls
17
+ * 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.
20
+ */
21
+ export {};
package/dist/server.js ADDED
@@ -0,0 +1,216 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * MCP server for the hierarchical budget vault.
4
+ *
5
+ * Trust model, which is the whole point of the split below:
6
+ *
7
+ * READ tools no key at all, raw JSON-RPC
8
+ * PAY tool signs with the agent's RESTRICTED key — request_payment
9
+ * on this vault only, no deposit possible
10
+ * PLAN tools emit UNSIGNED payloads for the human's wallet; this
11
+ * server never signs anything that changes policy
12
+ *
13
+ * Configure with:
14
+ * NEAR_VAULT_CONTRACT required
15
+ * NEAR_VAULT_RPC_URL default https://test.rpc.fastnear.com
16
+ * NEAR_VAULT_TOKEN default token for pay/budget calls
17
+ * 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.
20
+ */
21
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
22
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
23
+ import { z } from "zod";
24
+ import { fromBaseUnits, toBaseUnits } from "./amounts.js";
25
+ import { DEFAULT_GAS_TGAS, planAddNode, planCreateAgentAccount, planFreezeNode, planPurgeSettled, planSetCooldown, planSetNodeCaps, planSetPaused, planSetRecipients, toCliCommand, } from "./payloads.js";
26
+ import { DEFAULT_RPC_URL, newRequestId, requireAgentKey, viewFunction } from "./rpc.js";
27
+ import { buildRoute, sendRequestPayment } from "./sign.js";
28
+ import { getQuote, signBinding, toBase64 } from "./quote.js";
29
+ /** How long a quote stays valid. Long enough to sign and send, short
30
+ * enough that a stale price is not honoured. */
31
+ const QUOTE_TTL_MS = 20 * 60 * 1000;
32
+ const cfg = {
33
+ vault: process.env.NEAR_VAULT_CONTRACT ?? "",
34
+ rpcUrl: process.env.NEAR_VAULT_RPC_URL ?? DEFAULT_RPC_URL,
35
+ token: process.env.NEAR_VAULT_TOKEN ?? "",
36
+ 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,
39
+ network: process.env.NEAR_VAULT_NETWORK ?? "testnet",
40
+ };
41
+ function requireVault() {
42
+ if (!cfg.vault)
43
+ throw new Error("NEAR_VAULT_CONTRACT is not set");
44
+ return cfg.vault;
45
+ }
46
+ function tokenFor(t) {
47
+ const token = t ?? cfg.token;
48
+ if (!token)
49
+ throw new Error("no token given and NEAR_VAULT_TOKEN is not set");
50
+ return token;
51
+ }
52
+ const text = (s) => ({ content: [{ type: "text", text: s }] });
53
+ const json = (v) => text(JSON.stringify(v, null, 2));
54
+ /** Payload + summary + warnings + a runnable command, so approving needs no trust in this server. */
55
+ function renderPlan(p) {
56
+ return json({
57
+ action: p.methodName,
58
+ summary: p.summary,
59
+ warnings: p.warnings,
60
+ unsigned_payload: {
61
+ receiver_id: p.receiverId,
62
+ method_name: p.methodName,
63
+ args: p.args,
64
+ gas_tgas: p.gasTgas,
65
+ deposit_yocto: p.depositYocto,
66
+ },
67
+ approve_with: toCliCommand(p, cfg.network),
68
+ note: "This server did NOT sign this. Review the summary, then run the command " +
69
+ "or approve the payload in your wallet.",
70
+ });
71
+ }
72
+ const limitShape = z
73
+ .array(z.object({
74
+ token: z.string(),
75
+ dailyCap: z.string().describe('human units, e.g. "1000"'),
76
+ perTxCap: z.string().describe('human units, e.g. "250"'),
77
+ decimals: z.number().int().min(0).max(38).default(6),
78
+ }))
79
+ .min(1);
80
+ // The name a client displays in its server list, so this string -- not the
81
+ // package name, not the config key -- is the product's label in the one place
82
+ // the product is visible. It was "near-vault": the repo's directory name,
83
+ // leaking outward as the user-facing identity while the config key create-float
84
+ // writes has always been `float`.
85
+ //
86
+ // The version stays a literal rather than being read from package.json at
87
+ // startup: a wrong relative path would break the server outright instead of
88
+ // merely reporting a stale version. server.smoke.test.ts asserts the two agree,
89
+ // so a bump cannot land with only one of them moved.
90
+ const server = new McpServer({ name: "float", version: "0.1.1" });
91
+ // ----------------------------------------------------------------- reads
92
+ 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
+ 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.", {
94
+ nodeId: z.number().int().min(0),
95
+ token: z.string().optional(),
96
+ day: z.number().int().optional(),
97
+ }, async ({ nodeId, token, day }) => {
98
+ const b = (await viewFunction(cfg.rpcUrl, requireVault(), "get_node_budget", {
99
+ node_id: nodeId,
100
+ token: tokenFor(token),
101
+ day: day ?? null,
102
+ }));
103
+ if (!b)
104
+ return text(`node ${nodeId} has no limit for that token`);
105
+ const d = cfg.decimals;
106
+ return json({
107
+ node_id: nodeId,
108
+ day: b.day,
109
+ daily_cap: fromBaseUnits(BigInt(b.daily_cap), d),
110
+ executed: fromBaseUnits(BigInt(b.executed), d),
111
+ pending: fromBaseUnits(BigInt(b.pending), d),
112
+ available: fromBaseUnits(BigInt(b.available), d),
113
+ });
114
+ });
115
+ 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
+ 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", {})));
117
+ // ------------------------------------------------------------------- pay
118
+ 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
+ amount: z.string().describe('human units, e.g. "12.50"'),
120
+ recipient: z.string(),
121
+ memo: z.string().max(256).default(""),
122
+ token: z.string().optional(),
123
+ requestId: z.string().optional().describe("omit to generate one"),
124
+ rail: z
125
+ .enum(["near", "intents"])
126
+ .default("near")
127
+ .describe("near = NEP-141 transfer on NEAR, proves settlement. intents = cross-chain: the vault transfers on NEAR to a solver's one-time deposit address and the solver delivers on the destination chain. BOTH run the same hierarchical budget check and the same payee allowlist."),
128
+ destinationAsset: z
129
+ .string()
130
+ .optional()
131
+ .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);
134
+ const id = requestId ?? newRequestId();
135
+ const vault = requireVault();
136
+ let route = "near_direct";
137
+ let amountIn = toBaseUnits(amount, cfg.decimals);
138
+ if (rail === "intents") {
139
+ // The agent cannot build this route. It needs a quote AND a signature
140
+ // from the key the vault owner registered — and that key deliberately
141
+ // does not live where agents run, because whoever holds it can bind any
142
+ // deposit address to any payee. So this path only works when the MCP
143
+ // server is the operator's own.
144
+ const signerHex = process.env.FLOAT_QUOTE_SIGNER_KEY;
145
+ 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.");
149
+ }
150
+ const sk = Uint8Array.from(Buffer.from(signerHex.replace(/^0x/, ""), "hex"));
151
+ // EXACT_OUTPUT: `amount` is what the PAYEE receives. What leaves the
152
+ // vault is the quote's amountIn, and that is what the budget is charged
153
+ // — so the tree governs real outflow, not the nominal figure.
154
+ const deadline = new Date(Date.now() + QUOTE_TTL_MS);
155
+ const q = await getQuote({
156
+ amountOut: toBaseUnits(amount, cfg.decimals),
157
+ payee: recipient,
158
+ refundTo: vault,
159
+ destinationAsset,
160
+ deadline,
161
+ });
162
+ amountIn = q.amountIn;
163
+ const signature = signBinding({
164
+ vault,
165
+ requestId: id,
166
+ token: tokenFor(token),
167
+ amount: q.amountIn,
168
+ payee: recipient,
169
+ depositAddress: q.depositAddress,
170
+ deadlineNs: q.deadlineNs,
171
+ }, sk);
172
+ route = buildRoute("intents", {
173
+ depositAddress: q.depositAddress,
174
+ quoteDeadlineNs: q.deadlineNs.toString(),
175
+ signature: toBase64(signature),
176
+ });
177
+ }
178
+ const out = await sendRequestPayment({
179
+ rpcUrl: cfg.rpcUrl,
180
+ vault,
181
+ agentId: creds.agentId,
182
+ agentKey: creds.agentKey,
183
+ requestId: id,
184
+ token: tokenFor(token),
185
+ amount: amountIn,
186
+ recipient,
187
+ memo,
188
+ gasTgas: DEFAULT_GAS_TGAS,
189
+ route,
190
+ });
191
+ return json(out);
192
+ });
193
+ // ------------------------------------------------------- plans (unsigned)
194
+ server.tool("vault_plan_add_node", "Build an UNSIGNED payload to create a budget node. Pass parent=null for the root. Not signed by this server.", {
195
+ parent: z.number().int().min(0).nullable(),
196
+ label: z.string(),
197
+ limits: limitShape,
198
+ cooldownSec: z.number().int().min(0).default(0),
199
+ recipients: z.array(z.string()).nullable().default(null),
200
+ }, async ({ parent, label, limits, cooldownSec, recipients }) => renderPlan(planAddNode(requireVault(), parent, label, limits, cooldownSec, recipients)));
201
+ 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.", {
203
+ nodeId: z.number().int().min(0),
204
+ subLabel: z.string(),
205
+ 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)));
208
+ 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
+ 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
+ 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
+ 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)));
212
+ server.tool("vault_plan_purge", "Build an UNSIGNED payload to delete settled payment records older than a UTC day. Spend totals are NOT refunded, but a deleted request id becomes reusable. Not signed by this server.", {
213
+ beforeDay: z.number().int().min(0),
214
+ limit: z.number().int().min(0).max(100).default(100),
215
+ }, async ({ beforeDay, limit }) => renderPlan(planPurgeSettled(requireVault(), beforeDay, limit)));
216
+ await server.connect(new StdioServerTransport());