agentwallet-mcp 1.13.3 → 1.13.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/build/index.js CHANGED
@@ -12,8 +12,8 @@
12
12
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
13
13
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
14
14
  import { z } from 'zod';
15
- import { assertPublicUrl, safeFetch, FINAL_URL_HEADER } from './ssrf-guard.js';
16
- import { deriveX402Payment, isWithinCap, lookupTrustedDecimals, assertDeclaredDecimals, nativeDecimals, SOLANA_CHAIN_IDS, assetLabel, autopayAssetAllowed, autopayEnvCap, effectiveAutopayCap, maxAuthWindowSeconds, parseDecimalPins, approvalRefusal, } from './x402-payment.js';
15
+ import { assertPublicUrl, safeFetch, finalUrlOf } from './ssrf-guard.js';
16
+ import { deriveX402Payment, isWithinCap, lookupTrustedDecimals, assertDeclaredDecimals, nativeDecimals, SOLANA_CHAIN_IDS, assetLabel, autopayAssetAllowed, autopayEnvCap, effectiveAutopayCap, maxAuthWindowSeconds, parseDecimalPins, approvalRefusal, authorizationResource, } from './x402-payment.js';
17
17
  import { isLocalMode, getLocalAddress, localWalletRecord, localSend, localNativeBalance, localTokenBalance, localSignMessage, resolveRpcUrl, } from './local-wallet.js';
18
18
  import { localSignAuthorization, localEthCall, localGetCode, localSignPermit2Upto } from './local-wallet.js';
19
19
  import { WRAPPED_NATIVE } from './wrapped-native.js';
@@ -561,7 +561,7 @@ function resolveChainId(network) {
561
561
  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');
562
562
  const server = new McpServer({
563
563
  name: 'agentwallet',
564
- version: '1.13.3',
564
+ version: '1.13.4',
565
565
  }, {
566
566
  instructions: `AgentWallet gives AI agents their own blockchain wallets. ${anyLocalMode()
567
567
  ? 'This server runs in LOCAL (self-custody) mode: the operator supplied the key, signing happens in this process, and the only spend guards are the AGENTWALLET_MAX_TX_* environment caps. There is no server-side pause, daily limit or approval email; pause_wallet, unpause_wallet and delete_wallet are refused.'
@@ -1290,7 +1290,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1290
1290
  // A 402 that arrived through a cross-origin redirect cannot be paid here: the
1291
1291
  // payment header would be stripped on the same hop and the money would go
1292
1292
  // to whoever the redirecting server named. The agent should call the final URL.
1293
- const servedFrom = initialRes.headers.get(FINAL_URL_HEADER) || url;
1293
+ const servedFrom = finalUrlOf(initialRes, url); // recorded by safeFetch itself, never read from a header the server could set
1294
1294
  if (new URL(servedFrom).origin !== new URL(url).origin) {
1295
1295
  return jsonResponse({ status: 402, payment_required: true, payment_made: false,
1296
1296
  error: `The endpoint redirected to another origin (${new URL(servedFrom).origin}) before asking for payment. Call pay_x402 with that URL directly if you trust it.`,
@@ -1420,7 +1420,10 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1420
1420
  let permit2ApprovalTx = null;
1421
1421
  let payerDelegationInfo = null;
1422
1422
  let authorizationReused = false;
1423
- const cacheKey = [wallet_id, chainId, tokenAddress.toLowerCase(), option.payTo.toLowerCase(), rawAmount, option.scheme, new URL(url).origin + new URL(url).pathname].join('|');
1423
+ // The resource is the whole URL including its query: /buy?id=1 and /buy?id=2
1424
+ // are different purchases, and reusing the first signature for the second
1425
+ // would report a payment the second resource never received.
1426
+ const cacheKey = [wallet_id, chainId, tokenAddress.toLowerCase(), option.payTo.toLowerCase(), rawAmount, option.scheme, authorizationResource(url)].join('|');
1424
1427
  const cachedAuth = fresh_authorization ? undefined : signedPayments.get(cacheKey);
1425
1428
  for (const [k, v] of signedPayments)
1426
1429
  if (v.until <= Date.now())
@@ -1518,7 +1521,7 @@ server.tool('pay_x402', 'Handle an x402 payment flow. Fetches a URL, and if the
1518
1521
  // A second 402 means the facilitator declined (unfunded payer, expired window, bad domain...). v2 servers
1519
1522
  // put the reason in a fresh PAYMENT-REQUIRED header, v1 servers in the body; surface it instead of {}.
1520
1523
  let retryError = null;
1521
- const retryServedFrom = retryRes.headers.get(FINAL_URL_HEADER) || url;
1524
+ const retryServedFrom = finalUrlOf(retryRes, url);
1522
1525
  const retryCrossOrigin = new URL(retryServedFrom).origin !== new URL(url).origin;
1523
1526
  if (retryCrossOrigin) {
1524
1527
  retryError = `The paid request was redirected to another origin (${new URL(retryServedFrom).origin}); the payment header is never forwarded across origins, so this response did not see the payment.`;
@@ -294,7 +294,9 @@ const CAP_LAYOUTS = {
294
294
  * checked, and unpriced calldata aimed at a token contract or at Permit2 is
295
295
  * refused, because any state change there can move funds. Calls to other
296
296
  * contracts (routers, bridges) still pass: they can only pull what an approval
297
- * already allowed, and approvals are what this guard bounds.
297
+ * already allows. Approvals granted through this guard are bounded by it; an
298
+ * allowance that existed before the cap was set, or was granted elsewhere, is
299
+ * not, and a router call can spend up to that allowance (noted 2026-10-04).
298
300
  *
299
301
  * Reported privately 2026-09-27: that refusal also caught our own wrap_eth and
300
302
  * unwrap_eth, whose WETH deposit() / withdraw(uint256) calldata was unpriced,
@@ -335,7 +337,7 @@ async function assertWithinTokenCap(chainId, to, data, valueWei = 0n) {
335
337
  `or deposit/withdraw on the chain's wrapped-native contract, ` +
336
338
  `or set AGENTWALLET_ALLOW_UNKNOWN_TOKEN_CALLS=1 to allow it deliberately.`);
337
339
  }
338
- return; // an ordinary contract call; it can only pull what an approval already allowed
340
+ return; // an ordinary contract call; it can only pull what an existing approval allows (see the note above)
339
341
  }
340
342
  const wordAt = (i) => hex.slice(8 + i * 64, 8 + (i + 1) * 64);
341
343
  let amount;
@@ -98,6 +98,14 @@ export declare function headersForRedirect(headers: HeadersInit | undefined, fro
98
98
  export declare const MAX_RESPONSE_BYTES: number;
99
99
  /** Header safeFetch adds to every response: the URL the body actually came from, after redirects. */
100
100
  export declare const FINAL_URL_HEADER = "x-agw-final-url";
101
+ /** The URL a safeFetch response actually came from, after every redirect. Only safeFetch can set it. */
102
+ export declare function finalUrlOf(res: Response, fallback: string): string;
103
+ /**
104
+ * The synthetic Response safeFetch hands back: the server's headers minus the
105
+ * transfer-level ones (the body is already decoded) and minus any header only
106
+ * this client may set, plus the final URL, which is also recorded out of band.
107
+ */
108
+ export declare function buildFetchedResponse(status: number, statusText: string, serverHeaders: Iterable<[string, string]>, body: ArrayBuffer | null, finalUrl: string): Response;
101
109
  /**
102
110
  * fetch() that validates the target, and every redirect hop, against
103
111
  * resolvePublicUrl, and connects only to the addresses that validation
@@ -330,6 +330,36 @@ export function headersForRedirect(headers, from, to, bodyDropped) {
330
330
  export const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
331
331
  /** Header safeFetch adds to every response: the URL the body actually came from, after redirects. */
332
332
  export const FINAL_URL_HEADER = 'x-agw-final-url';
333
+ /* The final URL is also recorded here, keyed by the Response object, and
334
+ finalUrlOf() reads this rather than the header. A server could send a
335
+ header of the same name (it was copied straight through and read back
336
+ as the real one, 2026-10-04 report); nothing a server sends can reach
337
+ this map. */
338
+ const finalUrls = new WeakMap();
339
+ /** The URL a safeFetch response actually came from, after every redirect. Only safeFetch can set it. */
340
+ export function finalUrlOf(res, fallback) {
341
+ return finalUrls.get(res) ?? fallback;
342
+ }
343
+ /** Response headers that only this client may set on what it hands back. */
344
+ const CLIENT_ONLY_RESPONSE_HEADERS = new Set([FINAL_URL_HEADER]);
345
+ /**
346
+ * The synthetic Response safeFetch hands back: the server's headers minus the
347
+ * transfer-level ones (the body is already decoded) and minus any header only
348
+ * this client may set, plus the final URL, which is also recorded out of band.
349
+ */
350
+ export function buildFetchedResponse(status, statusText, serverHeaders, body, finalUrl) {
351
+ const headers = [];
352
+ for (const [k, v] of serverHeaders) {
353
+ const name = k.toLowerCase();
354
+ if (name === 'content-encoding' || name === 'content-length' || CLIENT_ONLY_RESPONSE_HEADERS.has(name))
355
+ continue;
356
+ headers.push([k, v]);
357
+ }
358
+ headers.push([FINAL_URL_HEADER, finalUrl]);
359
+ const out = new Response(body, { status, statusText, headers });
360
+ finalUrls.set(out, finalUrl);
361
+ return out;
362
+ }
333
363
  /** Read a body stream into memory, refusing past `limit` decoded bytes. */
334
364
  async function readBounded(stream, limit) {
335
365
  if (!stream)
@@ -389,20 +419,14 @@ export async function safeFetch(url, options = {}, maxHops = 3) {
389
419
  status = res.status;
390
420
  statusText = res.statusText;
391
421
  headers = [];
392
- for (const [k, v] of res.headers) {
393
- // The body below is already decoded, so the transfer-level description
394
- // of it would be wrong if carried over.
395
- if (k === 'content-encoding' || k === 'content-length')
396
- continue;
422
+ for (const [k, v] of res.headers)
397
423
  headers.push([k, v]);
398
- }
399
424
  body = NULL_BODY_STATUS.has(status) ? null : await readBounded(res.body, MAX_RESPONSE_BYTES);
400
425
  }
401
426
  finally {
402
427
  await agent.destroy();
403
428
  }
404
- headers.push([FINAL_URL_HEADER, current]);
405
- const out = new Response(body, { status, statusText, headers });
429
+ const out = buildFetchedResponse(status, statusText, headers, body, current);
406
430
  const isRedirect = status >= 300 && status < 400 && out.headers.has('location');
407
431
  if (!isRedirect)
408
432
  return out;
@@ -191,6 +191,12 @@ export interface ApprovalWant {
191
191
  * outright, before any lookup. For a hosted wallet every bound field is checked
192
192
  * here, the wallet included, and the server checks them again when it signs.
193
193
  */
194
+ /**
195
+ * The part of a URL that names the resource an authorization was signed for:
196
+ * origin, path and query. /buy?id=1 and /buy?id=2 are different purchases
197
+ * (2026-10-04 report); the fragment is never sent, so it is left out.
198
+ */
199
+ export declare function authorizationResource(url: string): string;
194
200
  export declare function approvalRefusal(approvalId: string, row: ApprovalRow | null | undefined, want: ApprovalWant, localMode: boolean): {
195
201
  error: string;
196
202
  approval_status?: string | null;
@@ -334,6 +334,15 @@ export function isWithinCap(rawAmountRequired, trustedDecimals, capHuman) {
334
334
  * outright, before any lookup. For a hosted wallet every bound field is checked
335
335
  * here, the wallet included, and the server checks them again when it signs.
336
336
  */
337
+ /**
338
+ * The part of a URL that names the resource an authorization was signed for:
339
+ * origin, path and query. /buy?id=1 and /buy?id=2 are different purchases
340
+ * (2026-10-04 report); the fragment is never sent, so it is left out.
341
+ */
342
+ export function authorizationResource(url) {
343
+ const u = new URL(url);
344
+ return u.origin + u.pathname + u.search;
345
+ }
337
346
  export function approvalRefusal(approvalId, row, want, localMode) {
338
347
  if (localMode) {
339
348
  return {
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.13.3",
4
+ "version": "1.13.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/eip3009.test.mjs && node test/permit2-risk.test.mjs && node test/token-cap.test.mjs && node test/spl-cap.test.mjs && node test/unknown-decimals-cap.test.mjs && node test/decimals-range-cap.test.mjs && node test/local-mode.test.mjs && node test/ssrf-guard.test.mjs && node test/x402-policy.test.mjs && node test/local-guard-shape.test.mjs && node test/allowance-total-cap.test.mjs && node test/autopay-scheme.test.mjs && node test/spl-recipient-guard.test.mjs && node test/approval-binding.test.mjs && node test/ata-rent-size.test.mjs",
19
+ "test": "node test/x402-payment.test.mjs && node test/eip3009.test.mjs && node test/permit2-risk.test.mjs && node test/token-cap.test.mjs && node test/spl-cap.test.mjs && node test/unknown-decimals-cap.test.mjs && node test/decimals-range-cap.test.mjs && node test/local-mode.test.mjs && node test/ssrf-guard.test.mjs && node test/x402-policy.test.mjs && node test/local-guard-shape.test.mjs && node test/allowance-total-cap.test.mjs && node test/autopay-scheme.test.mjs && node test/spl-recipient-guard.test.mjs && node test/approval-binding.test.mjs && node test/ata-rent-size.test.mjs && node test/final-url-guard.test.mjs",
20
20
  "prepublishOnly": "npm run build"
21
21
  },
22
22
  "keywords": [