@floatonnear/mcp 0.3.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/roles.js ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Picking which agent in a fleet signs a payment.
3
+ *
4
+ * WHICH KEY SIGNS *IS* WHICH BUDGET IS CHARGED — the contract resolves the
5
+ * spending node from the signer (`pubkey_to_node`), never from the request
6
+ * body. So a wrong choice here does not fail; it succeeds against another
7
+ * agent's budget and shows up later as a balance nobody can explain.
8
+ *
9
+ * This reads the SAME file the adapter reads, and accepts the same entry
10
+ * shape, because two readers of one keyring that disagree about what counts
11
+ * as a credential is how an agent ends up "configured" and unreachable.
12
+ */
13
+ import { existsSync, readFileSync } from "node:fs";
14
+ /** Entries that `loadKeyring` would accept, nothing else. */
15
+ function loadable(path) {
16
+ if (!existsSync(path))
17
+ return [];
18
+ let parsed;
19
+ try {
20
+ parsed = JSON.parse(readFileSync(path, "utf8"));
21
+ }
22
+ catch {
23
+ return [];
24
+ }
25
+ if (!Array.isArray(parsed))
26
+ return [];
27
+ // THE SAME PREDICATE THE ADAPTER USES. An entry failing it is skipped there
28
+ // in total silence, so listing it here would advertise an agent that can
29
+ // never be selected.
30
+ return parsed.filter((e) => e && typeof e.agentId === "string" && typeof e.secret === "string");
31
+ }
32
+ export class RoleSelectionError extends Error {
33
+ }
34
+ export function selectRole(cfg, role) {
35
+ const path = cfg.agentKeysPath ?? ".float/agent-keys.json";
36
+ const entries = loadable(path);
37
+ const known = entries
38
+ .map((e) => (typeof e.role === "string" ? e.role : null))
39
+ .filter(Boolean);
40
+ const hit = entries.find((e) => e.role === role);
41
+ if (!hit) {
42
+ throw new RoleSelectionError(`no agent with role '${role}' in ${path}. ` +
43
+ `Known roles: ${known.length ? known.join(", ") : "none"}. ` +
44
+ (entries.length && !known.length
45
+ ? `${entries.length} credential(s) are loaded but none carries a role — ` +
46
+ `they predate roles, so select them by nodeId or add a "role" field.`
47
+ : `Add one with vault_add_role.`));
48
+ }
49
+ return { agentId: hit.agentId, agentKey: hit.secret };
50
+ }
51
+ /** Every role that could actually sign, for listing back to a caller. */
52
+ export function listRoles(cfg) {
53
+ return loadable(cfg.agentKeysPath ?? ".float/agent-keys.json")
54
+ .filter((e) => typeof e.role === "string")
55
+ .map((e) => ({
56
+ agentId: e.agentId,
57
+ agentKey: e.secret,
58
+ role: e.role,
59
+ nodeId: typeof e.nodeId === "number" ? e.nodeId : undefined,
60
+ }));
61
+ }
package/dist/rpc.d.ts CHANGED
@@ -32,6 +32,19 @@ interface RawViewResponse {
32
32
  */
33
33
  export declare function decodeViewResult<T = unknown>(res: RawViewResponse): T;
34
34
  export declare function viewFunction<T = unknown>(rpcUrl: string, contractId: string, methodName: string, args?: unknown): Promise<T>;
35
+ /**
36
+ * Does this account exist on chain?
37
+ *
38
+ * Used before offering to CREATE a vault at a derived address. Getting this
39
+ * wrong is not cosmetic: the create batch's first action is `CreateAccount`,
40
+ * so offering an address that is already taken sends the user to a wallet,
41
+ * takes their signature, and fails only after they have committed.
42
+ *
43
+ * Distinguishes "does not exist" from "could not tell". An RPC timeout that
44
+ * returned `false` would read as a free address, so ONLY the specific
45
+ * UNKNOWN_ACCOUNT answer counts as absent and anything else throws.
46
+ */
47
+ export declare function accountExists(rpcUrl: string, accountId: string): Promise<boolean>;
35
48
  /** One access key as NEAR reports it. */
36
49
  export interface AccessKeyInfo {
37
50
  public_key: string;
@@ -43,6 +56,14 @@ export interface AccessKeyInfo {
43
56
  receiver_id: string;
44
57
  method_names: string[];
45
58
  };
59
+ } | {
60
+ GasKeyFunctionCall: {
61
+ allowance: string | null;
62
+ balance: string;
63
+ receiver_id: string;
64
+ method_names: string[];
65
+ num_nonces?: number;
66
+ };
46
67
  };
47
68
  };
48
69
  }
@@ -56,6 +77,20 @@ export interface AccessKeyInfo {
56
77
  * be true.
57
78
  */
58
79
  export declare function viewAccessKeys(rpcUrl: string, accountId: string): Promise<AccessKeyInfo[]>;
80
+ /** An account's balance and storage, as the chain reports them. */
81
+ export interface AccountView {
82
+ amount: string;
83
+ storage_usage: number;
84
+ }
85
+ /**
86
+ * Read an account's balance and storage.
87
+ *
88
+ * Both as the chain reports them: `amount` is the TOTAL, and the storage
89
+ * stake (`storage_usage * 1e19`) is locked inside it rather than held
90
+ * separately. Subtracting is the caller's job, and getting that backwards is
91
+ * how a vault with 0.02 spendable looks like it has 0.12.
92
+ */
93
+ export declare function viewAccount(rpcUrl: string, accountId: string): Promise<AccountView>;
59
94
  export interface AgentCreds {
60
95
  agentId: string;
61
96
  agentKey: string;
@@ -68,6 +103,22 @@ export interface AgentCreds {
68
103
  export declare function requireAgentKey(env: {
69
104
  agentId?: string;
70
105
  agentKey?: string;
106
+ /**
107
+ * Why `NEAR_VAULT_AGENT_KEY_FILE` produced no key, when one was named.
108
+ *
109
+ * SEPARATE FROM `agentKey` BEING ABSENT, and it must stay that way. "The
110
+ * variable is not set" and "the file you pointed it at is broken" are
111
+ * different problems with different fixes; reporting the second as the first
112
+ * sends the operator to set something they have already set — and the
113
+ * quickest way out of that dead end is pasting the key inline, which undoes
114
+ * the reason the file option exists.
115
+ */
116
+ agentKeyError?: string;
117
+ /**
118
+ * `account_id` from a `~/.near-credentials`-shaped key file, when the key
119
+ * came from one. Absent for an inline key or a bare key file.
120
+ */
121
+ agentKeyAccountId?: string;
71
122
  }): AgentCreds;
72
123
  /**
73
124
  * A request id the contract will accept.
package/dist/rpc.js CHANGED
@@ -71,6 +71,39 @@ export async function viewFunction(rpcUrl, contractId, methodName, args = {}) {
71
71
  return decodeViewResult({ error: json.error });
72
72
  return decodeViewResult(json.result ?? {});
73
73
  }
74
+ /**
75
+ * Does this account exist on chain?
76
+ *
77
+ * Used before offering to CREATE a vault at a derived address. Getting this
78
+ * wrong is not cosmetic: the create batch's first action is `CreateAccount`,
79
+ * so offering an address that is already taken sends the user to a wallet,
80
+ * takes their signature, and fails only after they have committed.
81
+ *
82
+ * Distinguishes "does not exist" from "could not tell". An RPC timeout that
83
+ * returned `false` would read as a free address, so ONLY the specific
84
+ * UNKNOWN_ACCOUNT answer counts as absent and anything else throws.
85
+ */
86
+ export async function accountExists(rpcUrl, accountId) {
87
+ const r = await fetch(rpcUrl, {
88
+ method: "POST",
89
+ headers: { "content-type": "application/json" },
90
+ body: JSON.stringify({
91
+ jsonrpc: "2.0",
92
+ id: "near-vault-mcp",
93
+ method: "query",
94
+ params: { request_type: "view_account", finality: "final", account_id: accountId },
95
+ }),
96
+ });
97
+ if (!r.ok)
98
+ throw new Error(`RPC ${r.status} checking whether ${accountId} exists`);
99
+ const j = (await r.json());
100
+ if (j.result)
101
+ return true;
102
+ const name = j.error?.cause?.name;
103
+ if (name === "UNKNOWN_ACCOUNT")
104
+ return false;
105
+ throw new Error(`could not determine whether ${accountId} exists: ${name ?? j.error?.message ?? "unknown error"}`);
106
+ }
74
107
  /**
75
108
  * Every access key on an account, read from the chain.
76
109
  *
@@ -98,28 +131,91 @@ export async function viewAccessKeys(rpcUrl, accountId) {
98
131
  throw new Error(`access key list failed: ${json.error.message ?? "unknown"}`);
99
132
  return json.result?.keys ?? [];
100
133
  }
134
+ /**
135
+ * Read an account's balance and storage.
136
+ *
137
+ * Both as the chain reports them: `amount` is the TOTAL, and the storage
138
+ * stake (`storage_usage * 1e19`) is locked inside it rather than held
139
+ * separately. Subtracting is the caller's job, and getting that backwards is
140
+ * how a vault with 0.02 spendable looks like it has 0.12.
141
+ */
142
+ export async function viewAccount(rpcUrl, accountId) {
143
+ const r = await fetch(rpcUrl, {
144
+ method: "POST",
145
+ headers: { "content-type": "application/json" },
146
+ body: JSON.stringify({
147
+ jsonrpc: "2.0",
148
+ id: "near-vault-mcp",
149
+ method: "query",
150
+ params: { request_type: "view_account", finality: "final", account_id: accountId },
151
+ }),
152
+ });
153
+ if (!r.ok)
154
+ throw new Error(`RPC ${rpcUrl} returned HTTP ${r.status}`);
155
+ const json = (await r.json());
156
+ if (json.error)
157
+ throw new Error(`view_account failed: ${json.error.message ?? "unknown"}`);
158
+ if (!json.result)
159
+ throw new Error(`view_account returned no result for ${accountId}`);
160
+ return json.result;
161
+ }
101
162
  /**
102
163
  * Both halves or neither. Failing here with the variable NAMES beats throwing a
103
164
  * TypeError three frames deeper, and beats silently signing with the wrong
104
165
  * account.
105
166
  */
106
167
  export function requireAgentKey(env) {
168
+ // THE FILE'S OWN FAILURE OUTRANKS EVERY "not set" MESSAGE BELOW. It is the
169
+ // only one of them that names a cause, and once a path is involved the
170
+ // missing-variable advice is actively misleading.
171
+ if (!env.agentKey && env.agentKeyError) {
172
+ throw new Error(`Paying needs the agent key, and the key file could not be read. ` +
173
+ `${env.agentKeyError} Fix the path or the file — or unset the _FILE variable ` +
174
+ `to fall back to an inline NEAR_VAULT_AGENT_KEY.`);
175
+ }
107
176
  // Name EVERY missing variable, not just the first. A message that reports
108
177
  // half the problem buys a second failed attempt.
109
178
  if (!env.agentId && !env.agentKey) {
110
- throw new Error("Paying requires NEAR_VAULT_AGENT_ID and NEAR_VAULT_AGENT_KEY, and neither is set. " +
179
+ throw new Error("Paying requires NEAR_VAULT_AGENT_ID and a key, and neither is set. " +
180
+ "The key can be NEAR_VAULT_AGENT_KEY (inline) or NEAR_VAULT_AGENT_KEY_FILE " +
181
+ "(a path to a ~/.near-credentials-shaped JSON file, or a bare key file) — " +
182
+ "prefer the file, since an inline key is printed by any read of the config. " +
111
183
  "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 " +
184
+ "The key must be a RESTRICTED one, from a VIRTUAL AGENT — never a " +
113
185
  "full-access key.");
114
186
  }
115
187
  if (!env.agentId) {
116
188
  throw new Error("NEAR_VAULT_AGENT_ID is not set, so there is no agent account to pay from.");
117
189
  }
118
190
  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 " +
191
+ throw new Error("No agent key is set: use NEAR_VAULT_AGENT_KEY_FILE (a path — preferred, since " +
192
+ "an inline key is printed by any read of the config) or NEAR_VAULT_AGENT_KEY. " +
193
+ "This must be a VIRTUAL AGENT's key, bound " +
194
+ "with bind_virtual_agent and installed as a NEP-611 gas key on the vault — it can call only request_payment on the vault, cannot " +
121
195
  "call other contracts, and cannot attach a deposit. Never put a full-access key here.");
122
196
  }
197
+ /**
198
+ * THE KEY FILE AND NEAR_VAULT_AGENT_ID MUST NAME THE SAME ACCOUNT.
199
+ *
200
+ * Which key signs *is* which budget is charged — the contract resolves the
201
+ * spending node from the signer, never from the request body. So a key used
202
+ * under the wrong account id does not get rejected locally; it goes to chain
203
+ * and fails with NOT_VIRTUAL_AGENT, which reads as a binding bug and sends
204
+ * the reader to the contract rather than to their config.
205
+ *
206
+ * No mismatch is legitimate. A virtual agent's key is a NEP-611 gas key ON
207
+ * the vault, so it signs AS the vault, and a credentials file for it names
208
+ * the vault. A file naming some other account holds some other account's key
209
+ * — most often the owner's full-access one, which must never be used here.
210
+ */
211
+ if (env.agentKeyAccountId && env.agentKeyAccountId !== env.agentId) {
212
+ throw new Error(`the key file belongs to ${env.agentKeyAccountId}, but NEAR_VAULT_AGENT_ID is ` +
213
+ `${env.agentId}. A key signs as the account it was issued for, and the vault ` +
214
+ `resolves the spending budget from the signer — so this pairing cannot pay. ` +
215
+ `Point NEAR_VAULT_AGENT_ID at ${env.agentKeyAccountId}, or use the key file ` +
216
+ `for ${env.agentId}. A virtual agent's key is a gas key ON the vault, so for ` +
217
+ `one it is the vault's own id.`);
218
+ }
123
219
  return { agentId: env.agentId, agentKey: env.agentKey };
124
220
  }
125
221
  /**
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
- * NEAR_VAULT_AGENT_KEY only needed to PAY. The restricted key.
22
+ * NEAR_VAULT_AGENT_ID only needed to PAY. Taken from the key file's
23
+ * `account_id` when one is given.
24
+ * NEAR_VAULT_AGENT_KEY only needed to PAY. The restricted key, inline.
25
+ * NEAR_VAULT_AGENT_KEY_FILE PREFERRED over the inline value above: a PATH to
26
+ * a ~/.near-credentials-shaped JSON file
27
+ * ({"account_id","public_key","private_key"}) or a
28
+ * bare key file. A path is not a secret, so the
29
+ * routine act of reading this config to see which
30
+ * vault is wired up stops printing a live private
31
+ * key into a terminal and an agent transcript.
32
+ * Same reason NEAR CLI signing in this repo uses
33
+ * `sign-with-access-key-file`. If both are set the
34
+ * FILE wins and the inline one is reported as
35
+ * ignored on stderr.
36
+ * NEAR_VAULT_NETWORK mainnet | testnet, default testnet
37
+ * NEAR_VAULT_FACTORY only needed to CREATE. No mainnet default
38
+ * exists on purpose — guessing it wrong would
39
+ * send a real deposit somewhere unverified.
40
+ * NEAR_VAULT_APP_URL only needed to CREATE, default
41
+ * https://app.usefloat.xyz
42
+ *
43
+ * A user with NO vault can therefore run this server with nothing configured,
44
+ * call `vault_create`, and be onboarded — which is the point. Every other tool
45
+ * reports that NEAR_VAULT_CONTRACT is unset rather than guessing one.
20
46
  */
21
47
  export {};