@floatonnear/mcp 0.0.0-stage → 0.1.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/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,206 @@
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
+ const server = new McpServer({ name: "near-vault", version: "0.1.0" });
81
+ // ----------------------------------------------------------------- reads
82
+ 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", {})));
83
+ 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.", {
84
+ nodeId: z.number().int().min(0),
85
+ token: z.string().optional(),
86
+ day: z.number().int().optional(),
87
+ }, async ({ nodeId, token, day }) => {
88
+ const b = (await viewFunction(cfg.rpcUrl, requireVault(), "get_node_budget", {
89
+ node_id: nodeId,
90
+ token: tokenFor(token),
91
+ day: day ?? null,
92
+ }));
93
+ if (!b)
94
+ return text(`node ${nodeId} has no limit for that token`);
95
+ const d = cfg.decimals;
96
+ return json({
97
+ node_id: nodeId,
98
+ day: b.day,
99
+ daily_cap: fromBaseUnits(BigInt(b.daily_cap), d),
100
+ executed: fromBaseUnits(BigInt(b.executed), d),
101
+ pending: fromBaseUnits(BigInt(b.pending), d),
102
+ available: fromBaseUnits(BigInt(b.available), d),
103
+ });
104
+ });
105
+ 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 })));
106
+ 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", {})));
107
+ // ------------------------------------------------------------------- pay
108
+ 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.", {
109
+ amount: z.string().describe('human units, e.g. "12.50"'),
110
+ recipient: z.string(),
111
+ memo: z.string().max(256).default(""),
112
+ token: z.string().optional(),
113
+ requestId: z.string().optional().describe("omit to generate one"),
114
+ rail: z
115
+ .enum(["near", "intents"])
116
+ .default("near")
117
+ .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."),
118
+ destinationAsset: z
119
+ .string()
120
+ .optional()
121
+ .describe("1Click asset id for rail=intents. Defaults to USDC on Base."),
122
+ }, async ({ amount, recipient, memo, token, requestId, rail, destinationAsset }) => {
123
+ const creds = requireAgentKey(cfg);
124
+ const id = requestId ?? newRequestId();
125
+ const vault = requireVault();
126
+ let route = "near_direct";
127
+ let amountIn = toBaseUnits(amount, cfg.decimals);
128
+ if (rail === "intents") {
129
+ // The agent cannot build this route. It needs a quote AND a signature
130
+ // from the key the vault owner registered — and that key deliberately
131
+ // does not live where agents run, because whoever holds it can bind any
132
+ // deposit address to any payee. So this path only works when the MCP
133
+ // server is the operator's own.
134
+ const signerHex = process.env.FLOAT_QUOTE_SIGNER_KEY;
135
+ if (!signerHex) {
136
+ throw new Error("rail=intents needs FLOAT_QUOTE_SIGNER_KEY (32-byte hex). " +
137
+ "That key binds a payee to a deposit address, so it belongs to the " +
138
+ "vault operator, not to an agent. Without it the cross-chain rail is closed.");
139
+ }
140
+ const sk = Uint8Array.from(Buffer.from(signerHex.replace(/^0x/, ""), "hex"));
141
+ // EXACT_OUTPUT: `amount` is what the PAYEE receives. What leaves the
142
+ // vault is the quote's amountIn, and that is what the budget is charged
143
+ // — so the tree governs real outflow, not the nominal figure.
144
+ const deadline = new Date(Date.now() + QUOTE_TTL_MS);
145
+ const q = await getQuote({
146
+ amountOut: toBaseUnits(amount, cfg.decimals),
147
+ payee: recipient,
148
+ refundTo: vault,
149
+ destinationAsset,
150
+ deadline,
151
+ });
152
+ amountIn = q.amountIn;
153
+ const signature = signBinding({
154
+ vault,
155
+ requestId: id,
156
+ token: tokenFor(token),
157
+ amount: q.amountIn,
158
+ payee: recipient,
159
+ depositAddress: q.depositAddress,
160
+ deadlineNs: q.deadlineNs,
161
+ }, sk);
162
+ route = buildRoute("intents", {
163
+ depositAddress: q.depositAddress,
164
+ quoteDeadlineNs: q.deadlineNs.toString(),
165
+ signature: toBase64(signature),
166
+ });
167
+ }
168
+ const out = await sendRequestPayment({
169
+ rpcUrl: cfg.rpcUrl,
170
+ vault,
171
+ agentId: creds.agentId,
172
+ agentKey: creds.agentKey,
173
+ requestId: id,
174
+ token: tokenFor(token),
175
+ amount: amountIn,
176
+ recipient,
177
+ memo,
178
+ gasTgas: DEFAULT_GAS_TGAS,
179
+ route,
180
+ });
181
+ return json(out);
182
+ });
183
+ // ------------------------------------------------------- plans (unsigned)
184
+ 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.", {
185
+ parent: z.number().int().min(0).nullable(),
186
+ label: z.string(),
187
+ limits: limitShape,
188
+ cooldownSec: z.number().int().min(0).default(0),
189
+ recipients: z.array(z.string()).nullable().default(null),
190
+ }, async ({ parent, label, limits, cooldownSec, recipients }) => renderPlan(planAddNode(requireVault(), parent, label, limits, cooldownSec, recipients)));
191
+ 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)));
192
+ 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.", {
193
+ nodeId: z.number().int().min(0),
194
+ subLabel: z.string(),
195
+ publicKey: z.string().describe("ed25519:..."),
196
+ keyAllowanceNear: z.string().default("2").describe("gas allowance in NEAR; floor is 0.5"),
197
+ }, async ({ nodeId, subLabel, publicKey, keyAllowanceNear }) => renderPlan(planCreateAgentAccount(requireVault(), nodeId, subLabel, publicKey, keyAllowanceNear)));
198
+ 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)));
199
+ 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)));
200
+ 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)));
201
+ 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)));
202
+ 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.", {
203
+ beforeDay: z.number().int().min(0),
204
+ limit: z.number().int().min(0).max(100).default(100),
205
+ }, async ({ beforeDay, limit }) => renderPlan(planPurgeSettled(requireVault(), beforeDay, limit)));
206
+ await server.connect(new StdioServerTransport());
package/dist/sign.d.ts ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The ONLY place in this server that touches a key.
3
+ *
4
+ * It signs exactly one method — `request_payment` — with the restricted key
5
+ * issued by `create_agent_account`. That key is cryptographically incapable of
6
+ * doing anything else: its `receiver_id` is the vault, its `method_names` is
7
+ * `["request_payment"]`, and a NEAR function-call key cannot attach a deposit
8
+ * at all. So the worst a compromised copy of this server can do is make
9
+ * payments the vault's on-chain policy already permits.
10
+ *
11
+ * Owner methods are deliberately NOT here. They are emitted as unsigned
12
+ * payloads (see payloads.ts) for the human to approve in their own wallet,
13
+ * which keeps this server out of the trust path for anything that changes
14
+ * policy — the property that distinguishes the design from a custodial agent
15
+ * wallet.
16
+ */
17
+ /**
18
+ * Fee and ordering fields for a Base-routed payment. Decimal strings.
19
+ *
20
+ * There is no `nonce`. The CONTRACT allocates it, because it is part of the
21
+ * signed payload and a caller cannot choose one safely: two payments in the
22
+ * same block read the same chain state, sign for the same nonce, and one is
23
+ * rejected AFTER its NEAR reservation has been consumed. The contract is the
24
+ * only place that sees every Base signature in a serial order.
25
+ */
26
+ /**
27
+ * The contract's `route` argument.
28
+ *
29
+ * Note what is NOT here: no payload, no destination, no amount. For a
30
+ * cross-chain payment the contract transfers to the quote's deposit address
31
+ * and checks the allowlist against the final payee; nothing a caller passes
32
+ * can redirect where value ends up. If this type ever grows a `payload`
33
+ * field, that property is gone.
34
+ *
35
+ * A cross-chain payment is an ordinary NEP-141 transfer to a solver's
36
+ * deposit address — no derived address, no ETH to fund, no nonce.
37
+ */
38
+ export type RouteArg = "near_direct" | {
39
+ intents: {
40
+ deposit_address: string;
41
+ quote_deadline_ns: string;
42
+ signature: string;
43
+ };
44
+ };
45
+ export declare function buildRoute(rail: "near"): RouteArg;
46
+ export declare function buildRoute(rail: "intents", intents: IntentsRouteFields): RouteArg;
47
+ /** Fields of a cross-chain route, all produced by the quote signer. */
48
+ export interface IntentsRouteFields {
49
+ depositAddress: string;
50
+ quoteDeadlineNs: string;
51
+ /** base64 ed25519 over borsh(QuoteBinding). See quote.ts. */
52
+ signature: string;
53
+ }
54
+ export interface PayArgs {
55
+ rpcUrl: string;
56
+ vault: string;
57
+ agentId: string;
58
+ agentKey: string;
59
+ requestId: string;
60
+ token: string;
61
+ /** Base units. Already scaled by amounts.toBaseUnits. */
62
+ amount: bigint;
63
+ recipient: string;
64
+ memo: string;
65
+ gasTgas: number;
66
+ /** Omitted means the NEAR rail, matching the contract's `Option<Route>`. */
67
+ route?: RouteArg;
68
+ }
69
+ export declare function sendRequestPayment(a: PayArgs): Promise<unknown>;