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 +9 -6
- package/build/local-wallet.js +4 -2
- package/build/ssrf-guard.d.ts +8 -0
- package/build/ssrf-guard.js +32 -8
- package/build/x402-payment.d.ts +6 -0
- package/build/x402-payment.js +9 -0
- package/package.json +2 -2
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,
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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.`;
|
package/build/local-wallet.js
CHANGED
|
@@ -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
|
|
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
|
|
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;
|
package/build/ssrf-guard.d.ts
CHANGED
|
@@ -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
|
package/build/ssrf-guard.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/build/x402-payment.d.ts
CHANGED
|
@@ -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;
|
package/build/x402-payment.js
CHANGED
|
@@ -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.
|
|
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": [
|