agentwallet-mcp 1.10.2 → 1.10.4

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/README.md CHANGED
@@ -320,8 +320,15 @@ All guards are active by default, no configuration required.
320
320
  each redirect hop. IP literals are canonicalized (including IPv4-mapped IPv6
321
321
  such as `[::ffff:127.0.0.1]`) and hostnames are resolved, with loopback,
322
322
  private, link-local, carrier-grade NAT, multicast and cloud-metadata
323
- destinations refused. Report security issues privately to
324
- security@hifriendbot.com.
323
+ destinations refused.
324
+
325
+ Validation and connection use the same DNS answer. Each hop resolves once, and
326
+ the socket is pinned to an address from that answer, so a hostname cannot
327
+ resolve public for the check and private for the connection. The hostname is
328
+ still used for the `Host` header and for TLS SNI and certificate validation, so
329
+ pinning is invisible to legitimate endpoints.
330
+
331
+ Report security issues privately to security@hifriendbot.com.
325
332
 
326
333
  ## License
327
334
 
package/build/index.js CHANGED
@@ -410,9 +410,21 @@ function resolveChainId(network) {
410
410
  return null;
411
411
  }
412
412
  // ─── Server ──────────────────────────────────────────────────────
413
+ /**
414
+ * Destination address shape, EVM or Solana.
415
+ *
416
+ * The contract-address params were regex-guarded but the DESTINATION on every
417
+ * transfer tool was a bare z.string(), so a truncated or mistyped address went
418
+ * straight into a signed transaction. On-chain sends are irreversible, and the
419
+ * chain will happily accept any well-formed address, so the cheapest place to
420
+ * catch a malformed one is before it is signed. This does not validate the
421
+ * EIP-55 checksum, only the shape; a wrong-but-well-formed address is still the
422
+ * caller's responsibility.
423
+ */
424
+ const AddressSchema = z.string().regex(/^(0x[a-fA-F0-9]{40}|[1-9A-HJ-NP-Za-km-z]{32,44})$/, 'Must be a 0x-prefixed 40-hex EVM address or a base58 Solana address');
413
425
  const server = new McpServer({
414
426
  name: 'agentwallet',
415
- version: '1.10.1',
427
+ version: '1.10.4',
416
428
  }, {
417
429
  instructions: `AgentWallet gives AI agents their own blockchain wallets. Private keys are encrypted server-side and never exposed — agents sign and broadcast transactions without ever touching raw keys.
418
430
 
@@ -528,7 +540,7 @@ server.tool('sign_transaction', 'Sign a transaction with a wallet\'s private key
528
540
  'For EVM: returns signed raw transaction hex. For Solana: returns base64 signed transaction. ' +
529
541
  'Does NOT broadcast — use send_transaction for sign + broadcast.', {
530
542
  wallet_id: z.number().int().describe('Wallet ID'),
531
- to: z.string().describe('Destination address (0x-prefixed for EVM, Base58 for Solana)'),
543
+ to: AddressSchema.describe('Destination address (0x-prefixed for EVM, Base58 for Solana)'),
532
544
  chain_id: z.number().int().optional().describe('Chain ID (defaults to wallet\'s default)'),
533
545
  value: z.string().default('0').describe('Value in wei/lamports (decimal string)'),
534
546
  data: z.string().default('').describe('Hex-encoded calldata (0x-prefixed) for EVM contract calls'),
@@ -570,7 +582,7 @@ server.tool('send_transaction', 'Sign and broadcast a transaction. ' +
570
582
  'Returns the transaction hash (EVM) or signature (Solana) on success. ' +
571
583
  'The transaction is signed server-side and broadcast via RPC.', {
572
584
  wallet_id: z.number().int().describe('Wallet ID'),
573
- to: z.string().describe('Destination address (0x-prefixed for EVM, Base58 for Solana)'),
585
+ to: AddressSchema.describe('Destination address (0x-prefixed for EVM, Base58 for Solana)'),
574
586
  chain_id: z.number().int().optional().describe('Chain ID (defaults to wallet\'s default)'),
575
587
  value: z.string().default('0').describe('Value in wei/lamports (decimal string)'),
576
588
  data: z.string().default('').describe('Hex-encoded calldata (0x-prefixed) for EVM contract calls'),
@@ -612,7 +624,7 @@ server.tool('transfer', 'Send native tokens (ETH, AVAX, BNB, POL, PLS, SOL) to a
612
624
  'Specify the amount in human-readable format (e.g. "0.1" for 0.1 ETH). ' +
613
625
  'The amount is converted to wei/lamports automatically. Signs and broadcasts the transaction.', {
614
626
  wallet_id: z.number().int().describe('Wallet ID to send from'),
615
- to: z.string().describe('Destination address (0x-prefixed for EVM, Base58 for Solana)'),
627
+ to: AddressSchema.describe('Destination address (0x-prefixed for EVM, Base58 for Solana)'),
616
628
  amount: z.string().describe('Amount to send in human-readable format (e.g. "0.1" for 0.1 ETH)'),
617
629
  chain_id: z.number().int().describe('Chain ID (1=Ethereum, 8453=Base, 42161=Arbitrum, 10=Optimism, 137=Polygon, 43114=Avalanche, 56=BSC, 7777777=Zora, 369=PulseChain, 900=Solana, 901=Solana Devnet)'),
618
630
  }, async ({ wallet_id, to, amount, chain_id }) => {
@@ -675,7 +687,7 @@ server.tool('transfer_token', 'Send ERC-20 tokens (EVM) or SPL tokens (Solana) t
675
687
  'Signs and broadcasts the transaction. Use get_chains to find stablecoin addresses.', {
676
688
  wallet_id: z.number().int().describe('Wallet ID to send from'),
677
689
  token: z.string().describe('Token address (0x-prefixed ERC-20 contract for EVM, Base58 mint for Solana)'),
678
- to: z.string().describe('Recipient address (0x-prefixed for EVM, Base58 for Solana)'),
690
+ to: AddressSchema.describe('Recipient address (0x-prefixed for EVM, Base58 for Solana)'),
679
691
  amount: z.string().describe('Amount in human-readable format (e.g. "100" for 100 USDC)'),
680
692
  chain_id: z.number().int().describe('Chain ID'),
681
693
  decimals: z.number().int().default(18).describe('Token decimals (6 for USDC, 18 for most tokens)'),
@@ -953,8 +965,10 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
953
965
  }
954
966
  }
955
967
  // Validate URL: canonicalizes IP literals (including IPv4-mapped IPv6) and
956
- // resolves DNS names, rejecting any private/internal destination. Redirect
957
- // hops are re-validated by safeFetch below.
968
+ // resolves DNS names, rejecting any private/internal destination. An early
969
+ // check here gives a clear error before any request is built; safeFetch
970
+ // below re-validates every hop and pins each connection to the addresses it
971
+ // validated, so DNS cannot be rebound between the check and the connect.
958
972
  await assertPublicUrl(url);
959
973
  // Step 1: Make initial request
960
974
  const reqOptions = {
@@ -14,6 +14,7 @@
14
14
  import { readFileSync } from 'node:fs';
15
15
  import { Connection, Keypair, PublicKey, SystemProgram, Transaction, TransactionInstruction, sendAndConfirmTransaction, LAMPORTS_PER_SOL, } from '@solana/web3.js';
16
16
  import bs58 from 'bs58';
17
+ import { lookupTrustedDecimals } from './x402-payment.js';
17
18
  /* ── Program IDs ─────────────────────────────────────────────────── */
18
19
  const TOKEN_PROGRAM = new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');
19
20
  const TOKEN_2022_PROGRAM = new PublicKey('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb');
@@ -114,6 +115,34 @@ function assertWithinSolCap(lamports) {
114
115
  throw new Error(`Blocked by local guard: amount exceeds AGENTWALLET_MAX_TX_SOL (${cap} SOL).`);
115
116
  }
116
117
  }
118
+ /**
119
+ * Bound SPL movement. AGENTWALLET_MAX_TX_SOL only ever covered native SOL, so
120
+ * an SPL transfer, which is how USDC moves on Solana and the whole point of the
121
+ * x402 rail here, went out with no local ceiling at all. Mirrors
122
+ * AGENTWALLET_MAX_TX_TOKEN on the EVM side and uses the same variable so an
123
+ * operator sets one token cap, not one per chain family.
124
+ *
125
+ * Decimals for the cap come from the trusted table when the mint is known.
126
+ * The caller-supplied value is not used for the ceiling: an inflated one would
127
+ * inflate the cap exactly like the x402 bypass. When the mint is unknown we
128
+ * assume 6, the tightest common value, so it fails closed.
129
+ */
130
+ function assertWithinSplCap(mint, rawAmount) {
131
+ const cap = (process.env.AGENTWALLET_MAX_TX_TOKEN || '').trim();
132
+ if (!cap)
133
+ return;
134
+ if (!/^\d+(\.\d+)?$/.test(cap)) {
135
+ throw new Error(`AGENTWALLET_MAX_TX_TOKEN must be a decimal number, got "${cap}".`);
136
+ }
137
+ const decimals = lookupTrustedDecimals(900, mint) ?? 6;
138
+ const [whole, frac = ''] = cap.split('.');
139
+ const capRaw = BigInt(whole + frac.padEnd(decimals, '0').slice(0, decimals));
140
+ if (rawAmount > capRaw) {
141
+ throw new Error(`Blocked by local guard: SPL transfer of ${rawAmount} base units exceeds ` +
142
+ `AGENTWALLET_MAX_TX_TOKEN (${cap}, evaluated at ${decimals} decimals). ` +
143
+ `Raise the cap deliberately if this is intended.`);
144
+ }
145
+ }
117
146
  /* ── Reads ───────────────────────────────────────────────────────── */
118
147
  export async function localSolBalance(chainId = 900) {
119
148
  const conn = connection(chainId);
@@ -161,6 +190,11 @@ export async function localSplBalance(mintStr, chainId = 900) {
161
190
  export async function localSolTransfer(to, lamports, chainId = 900) {
162
191
  const amount = BigInt(lamports);
163
192
  assertWithinSolCap(amount);
193
+ // Number() silently loses precision past 2**53 lamports. Refuse rather than
194
+ // sign a transaction for an amount other than the one that was requested.
195
+ if (amount > BigInt(Number.MAX_SAFE_INTEGER)) {
196
+ throw new Error(`Amount ${lamports} lamports exceeds safe integer range and was refused.`);
197
+ }
164
198
  const conn = connection(chainId);
165
199
  const payer = getSolanaKeypair();
166
200
  const tx = new Transaction().add(SystemProgram.transfer({
@@ -216,6 +250,9 @@ function createAtaIdempotentIx(payer, ata, owner, mint, tokenProgram) {
216
250
  * not exist, using the idempotent instruction so a race cannot fail the send.
217
251
  */
218
252
  export async function localSplTransfer(mintStr, to, rawAmount, decimals, chainId = 900) {
253
+ // Checked before any RPC round trip: a blocked transfer should cost nothing
254
+ // and should not announce itself to the RPC provider first.
255
+ assertWithinSplCap(mintStr, BigInt(rawAmount));
219
256
  const conn = connection(chainId);
220
257
  const payer = getSolanaKeypair();
221
258
  const mint = new PublicKey(mintStr);
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * SSRF guard for outbound x402 requests.
3
3
  *
4
- * The previous guard compared `new URL(url).hostname` against literal strings
5
- * and dotted-decimal prefixes. That misses every alternate spelling of a
4
+ * The original guard compared `new URL(url).hostname` against literal strings
5
+ * and dotted-decimal prefixes. That missed every alternate spelling of a
6
6
  * private destination: IPv4-mapped IPv6 (`[::ffff:127.0.0.1]` parses to
7
7
  * `[::ffff:7f00:1]`), the rest of 127.0.0.0/8, IPv6 unique-local and
8
8
  * link-local, the whole 169.254.0.0/16 link-local range rather than only the
@@ -12,7 +12,17 @@
12
12
  * against numeric ranges, then resolves DNS names and applies the same test to
13
13
  * every answer, so a public name pointing at a private address is refused too.
14
14
  *
15
- * Reported privately by ARC Security Research, 2026-07-31.
15
+ * Validating a name is not enough on its own. If the request is then handed to
16
+ * fetch() as a URL, the name is resolved a second time by the HTTP client, and
17
+ * an attacker who controls the authoritative DNS can answer public for the
18
+ * check and private for the connection. So `safeFetch` pins the connection to
19
+ * the exact addresses that were validated: the resolver runs once, and the
20
+ * socket is only allowed to reach an address from that answer. The hostname is
21
+ * still used for the Host header and for TLS SNI and certificate validation,
22
+ * so pinning is invisible to legitimate endpoints.
23
+ *
24
+ * Reported privately by ARC Security Research, 2026-07-31 (spelling bypasses)
25
+ * and by an anonymous researcher, 2026-08-02 (the DNS rebinding race).
16
26
  */
17
27
  /**
18
28
  * Reduce a host to the IP address it actually denotes.
@@ -24,15 +34,45 @@ export declare function canonicalizeHost(hostRaw: string): {
24
34
  };
25
35
  /** True when the address is loopback, private, link-local, multicast or reserved. */
26
36
  export declare function isPrivateAddress(ipRaw: string): boolean;
37
+ /** An address that passed validation and is therefore allowed to be connected to. */
38
+ export type ValidatedAddress = {
39
+ address: string;
40
+ family: 4 | 6;
41
+ };
42
+ /**
43
+ * Validate `url` and return the exact set of addresses a connection to it is
44
+ * allowed to use. DNS names are resolved once here; that single answer is both
45
+ * what gets checked and what gets connected to, which is what removes the
46
+ * time-of-check/time-of-use gap.
47
+ *
48
+ * Throws unless the URL is https and every address it denotes is public.
49
+ */
50
+ export declare function resolvePublicUrl(url: string): Promise<{
51
+ target: URL;
52
+ addresses: ValidatedAddress[];
53
+ }>;
27
54
  /**
28
55
  * Throw unless `url` is an https URL pointing at a public destination.
29
- * DNS names are resolved and every answer is checked, so a public name that
30
- * maps to a private address is rejected.
56
+ * Kept for callers that only want the check and not the resolved addresses.
31
57
  */
32
58
  export declare function assertPublicUrl(url: string): Promise<void>;
59
+ type LookupCallback = (err: NodeJS.ErrnoException | null, addressOrList?: string | ValidatedAddress[], family?: number) => void;
60
+ /**
61
+ * A drop-in replacement for dns.lookup that answers only from an already
62
+ * validated set and never queries a resolver. Handing this to the socket layer
63
+ * is what pins the connection: whatever the authoritative server says on a
64
+ * second query cannot reach the socket.
65
+ */
66
+ export declare function createPinnedLookup(addresses: ValidatedAddress[]): (_hostname: string, options: unknown, callback?: LookupCallback) => void;
33
67
  /**
34
68
  * fetch() that validates the target, and every redirect hop, against
35
- * assertPublicUrl. Redirects are followed manually because the automatic
36
- * follower would happily land on a private address after a public first hop.
69
+ * resolvePublicUrl, and connects only to the addresses that validation
70
+ * returned. Redirects are followed manually because the automatic follower
71
+ * would happily land on a private address after a public first hop, and
72
+ * because each hop needs its own resolve-then-pin cycle.
73
+ *
74
+ * The body is buffered so the pinned connection can be torn down before the
75
+ * response is handed back.
37
76
  */
38
77
  export declare function safeFetch(url: string, options?: RequestInit, maxHops?: number): Promise<Response>;
78
+ export {};
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * SSRF guard for outbound x402 requests.
3
3
  *
4
- * The previous guard compared `new URL(url).hostname` against literal strings
5
- * and dotted-decimal prefixes. That misses every alternate spelling of a
4
+ * The original guard compared `new URL(url).hostname` against literal strings
5
+ * and dotted-decimal prefixes. That missed every alternate spelling of a
6
6
  * private destination: IPv4-mapped IPv6 (`[::ffff:127.0.0.1]` parses to
7
7
  * `[::ffff:7f00:1]`), the rest of 127.0.0.0/8, IPv6 unique-local and
8
8
  * link-local, the whole 169.254.0.0/16 link-local range rather than only the
@@ -12,10 +12,21 @@
12
12
  * against numeric ranges, then resolves DNS names and applies the same test to
13
13
  * every answer, so a public name pointing at a private address is refused too.
14
14
  *
15
- * Reported privately by ARC Security Research, 2026-07-31.
15
+ * Validating a name is not enough on its own. If the request is then handed to
16
+ * fetch() as a URL, the name is resolved a second time by the HTTP client, and
17
+ * an attacker who controls the authoritative DNS can answer public for the
18
+ * check and private for the connection. So `safeFetch` pins the connection to
19
+ * the exact addresses that were validated: the resolver runs once, and the
20
+ * socket is only allowed to reach an address from that answer. The hostname is
21
+ * still used for the Host header and for TLS SNI and certificate validation,
22
+ * so pinning is invisible to legitimate endpoints.
23
+ *
24
+ * Reported privately by ARC Security Research, 2026-07-31 (spelling bypasses)
25
+ * and by an anonymous researcher, 2026-08-02 (the DNS rebinding race).
16
26
  */
17
27
  import { isIP } from 'node:net';
18
- import { lookup } from 'node:dns/promises';
28
+ import { lookup as dnsLookup } from 'node:dns/promises';
29
+ import { Agent, fetch as undiciFetch } from 'undici';
19
30
  /** Hostnames that must never be reached regardless of what they resolve to. */
20
31
  const BLOCKED_NAMES = new Set([
21
32
  'localhost',
@@ -111,11 +122,14 @@ export function isPrivateAddress(ipRaw) {
111
122
  return false;
112
123
  }
113
124
  /**
114
- * Throw unless `url` is an https URL pointing at a public destination.
115
- * DNS names are resolved and every answer is checked, so a public name that
116
- * maps to a private address is rejected.
125
+ * Validate `url` and return the exact set of addresses a connection to it is
126
+ * allowed to use. DNS names are resolved once here; that single answer is both
127
+ * what gets checked and what gets connected to, which is what removes the
128
+ * time-of-check/time-of-use gap.
129
+ *
130
+ * Throws unless the URL is https and every address it denotes is public.
117
131
  */
118
- export async function assertPublicUrl(url) {
132
+ export async function resolvePublicUrl(url) {
119
133
  let u;
120
134
  try {
121
135
  u = new URL(url);
@@ -136,41 +150,121 @@ export async function assertPublicUrl(url) {
136
150
  if (isPrivateAddress(ip)) {
137
151
  throw new Error('URL points to a private/internal address. Only public URLs are allowed.');
138
152
  }
139
- return;
153
+ // An IP literal is never resolved, so there is nothing to rebind: the
154
+ // socket layer connects to the literal we just checked.
155
+ return { target: u, addresses: [{ address: ip, family }] };
140
156
  }
141
157
  // A name: every address it resolves to must be public.
142
158
  let answers;
143
159
  try {
144
- answers = await lookup(bare, { all: true });
160
+ answers = await dnsLookup(bare, { all: true });
145
161
  }
146
162
  catch {
147
163
  throw new Error(`Could not resolve host "${bare}".`);
148
164
  }
149
165
  if (!answers.length)
150
166
  throw new Error(`Could not resolve host "${bare}".`);
167
+ const addresses = [];
151
168
  for (const a of answers) {
152
169
  if (isPrivateAddress(a.address)) {
153
170
  throw new Error('URL resolves to a private/internal address. Only public URLs are allowed.');
154
171
  }
172
+ addresses.push({ address: a.address, family: a.family === 6 ? 6 : 4 });
155
173
  }
174
+ return { target: u, addresses };
175
+ }
176
+ /**
177
+ * Throw unless `url` is an https URL pointing at a public destination.
178
+ * Kept for callers that only want the check and not the resolved addresses.
179
+ */
180
+ export async function assertPublicUrl(url) {
181
+ await resolvePublicUrl(url);
182
+ }
183
+ /**
184
+ * A drop-in replacement for dns.lookup that answers only from an already
185
+ * validated set and never queries a resolver. Handing this to the socket layer
186
+ * is what pins the connection: whatever the authoritative server says on a
187
+ * second query cannot reach the socket.
188
+ */
189
+ export function createPinnedLookup(addresses) {
190
+ const pinned = addresses.slice();
191
+ return function pinnedLookup(_hostname, options, callback) {
192
+ const cb = (typeof options === 'function' ? options : callback);
193
+ const opts = (typeof options === 'object' && options !== null ? options : {});
194
+ let list = pinned;
195
+ if (opts.family === 4 || opts.family === 6) {
196
+ list = pinned.filter(a => a.family === opts.family);
197
+ }
198
+ queueMicrotask(() => {
199
+ if (!list.length) {
200
+ const err = new Error('No validated address is available for this host.');
201
+ err.code = 'ENOTFOUND';
202
+ cb(err);
203
+ return;
204
+ }
205
+ if (opts.all) {
206
+ cb(null, list.map(a => ({ address: a.address, family: a.family })));
207
+ return;
208
+ }
209
+ cb(null, list[0].address, list[0].family);
210
+ });
211
+ };
156
212
  }
213
+ /** Statuses whose Response must be constructed with a null body. */
214
+ const NULL_BODY_STATUS = new Set([101, 103, 204, 205, 304]);
157
215
  /**
158
216
  * fetch() that validates the target, and every redirect hop, against
159
- * assertPublicUrl. Redirects are followed manually because the automatic
160
- * follower would happily land on a private address after a public first hop.
217
+ * resolvePublicUrl, and connects only to the addresses that validation
218
+ * returned. Redirects are followed manually because the automatic follower
219
+ * would happily land on a private address after a public first hop, and
220
+ * because each hop needs its own resolve-then-pin cycle.
221
+ *
222
+ * The body is buffered so the pinned connection can be torn down before the
223
+ * response is handed back.
161
224
  */
162
225
  export async function safeFetch(url, options = {}, maxHops = 3) {
163
226
  let current = url;
227
+ let opts = options;
164
228
  for (let hop = 0; hop <= maxHops; hop++) {
165
- await assertPublicUrl(current);
166
- const res = await fetch(current, { ...options, redirect: 'manual' });
167
- const isRedirect = res.status >= 300 && res.status < 400 && res.headers.has('location');
229
+ const { addresses } = await resolvePublicUrl(current);
230
+ // One agent per hop, carrying that hop's pinned addresses. The hostname is
231
+ // untouched, so Host and TLS SNI/certificate validation still use the name.
232
+ const agent = new Agent({
233
+ connect: { lookup: createPinnedLookup(addresses) },
234
+ });
235
+ let status;
236
+ let statusText;
237
+ let headers;
238
+ let body;
239
+ try {
240
+ const res = await undiciFetch(current, {
241
+ ...opts,
242
+ redirect: 'manual',
243
+ dispatcher: agent,
244
+ });
245
+ status = res.status;
246
+ statusText = res.statusText;
247
+ headers = [];
248
+ for (const [k, v] of res.headers) {
249
+ // The body below is already decoded, so the transfer-level description
250
+ // of it would be wrong if carried over.
251
+ if (k === 'content-encoding' || k === 'content-length')
252
+ continue;
253
+ headers.push([k, v]);
254
+ }
255
+ body = NULL_BODY_STATUS.has(status) ? null : await res.arrayBuffer();
256
+ }
257
+ finally {
258
+ await agent.destroy();
259
+ }
260
+ const out = new Response(body, { status, statusText, headers });
261
+ const isRedirect = status >= 300 && status < 400 && out.headers.has('location');
168
262
  if (!isRedirect)
169
- return res;
170
- const next = new URL(res.headers.get('location'), current).toString();
263
+ return out;
264
+ const next = new URL(out.headers.get('location'), current).toString();
171
265
  // A redirected request must not replay the body or method blindly.
172
- if (res.status === 303 || ((res.status === 301 || res.status === 302) && options.method && options.method !== 'GET')) {
173
- options = { ...options, method: 'GET', body: undefined };
266
+ if (status === 303 || ((status === 301 || status === 302) && opts.method && opts.method !== 'GET')) {
267
+ opts = { ...opts, method: 'GET', body: undefined };
174
268
  }
175
269
  current = next;
176
270
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agentwallet-mcp",
3
3
  "mcpName": "io.github.hifriendbot/agentwallet",
4
- "version": "1.10.2",
4
+ "version": "1.10.4",
5
5
  "description": "MCP server for AgentWallet: self-custody local signing on EVM and Solana, or hosted wallets. Sign, broadcast, pay x402 invoices, and accept x402 payments.",
6
6
  "type": "module",
7
7
  "main": "build/index.js",
@@ -16,7 +16,7 @@
16
16
  "scripts": {
17
17
  "build": "tsc",
18
18
  "start": "node build/index.js",
19
- "test": "node test/x402-payment.test.mjs && node test/token-cap.test.mjs && node test/local-mode.test.mjs && node test/ssrf-guard.test.mjs",
19
+ "test": "node test/x402-payment.test.mjs && node test/token-cap.test.mjs && node test/spl-cap.test.mjs && node test/local-mode.test.mjs && node test/ssrf-guard.test.mjs",
20
20
  "prepublishOnly": "npm run build"
21
21
  },
22
22
  "keywords": [
@@ -58,6 +58,7 @@
58
58
  "@modelcontextprotocol/sdk": "^1.11.4",
59
59
  "@solana/web3.js": "^1.98.4",
60
60
  "bs58": "^6.0.0",
61
+ "undici": "^8.9.0",
61
62
  "viem": "^2.55.10",
62
63
  "zod": "^3.24.0"
63
64
  },