@abstract-foundation/agw-mcp 0.1.0-beta.6 → 0.1.0-beta.7

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.
Files changed (3) hide show
  1. package/README.md +4 -0
  2. package/dist/index.mjs +170 -42
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -126,6 +126,9 @@ npx -y @abstract-foundation/agw-mcp serve --chain-id 2741
126
126
 
127
127
  # Custom RPC
128
128
  npx -y @abstract-foundation/agw-mcp serve --chain-id 2741 --rpc-url https://api.mainnet.abs.xyz
129
+
130
+ # 0x API key override (for swap_tokens quote requests)
131
+ npx -y @abstract-foundation/agw-mcp serve --chain-id 2741 --zeroex-api-key YOUR_0X_API_KEY
129
132
  ```
130
133
 
131
134
  Environment variables are also supported:
@@ -133,6 +136,7 @@ Environment variables are also supported:
133
136
  ```bash
134
137
  AGW_MCP_CHAIN_ID=2741 npx -y @abstract-foundation/agw-mcp serve
135
138
  AGW_MCP_RPC_URL=https://api.mainnet.abs.xyz npx -y @abstract-foundation/agw-mcp serve
139
+ AGW_MCP_ZEROEX_API_KEY=YOUR_0X_API_KEY npx -y @abstract-foundation/agw-mcp serve
136
140
  AGW_MCP_APP_URL=https://mcp.abs.xyz npx -y @abstract-foundation/agw-mcp init --chain-id 2741
137
141
  ```
138
142
 
package/dist/index.mjs CHANGED
@@ -8,7 +8,7 @@ import os from "node:os";
8
8
  import path from "node:path";
9
9
  import { sessionKeyValidatorAddress } from "@abstract-foundation/agw-client/constants";
10
10
  import { SessionKeyValidatorAbi, createSessionClient, getSessionHash } from "@abstract-foundation/agw-client/sessions";
11
- import { createPublicClient, encodeFunctionData, erc20Abi, formatUnits, http, isAddress, zeroAddress } from "viem";
11
+ import { createPublicClient, encodeFunctionData, erc20Abi, formatUnits, getAddress, http, isAddress, parseAbiItem, zeroAddress } from "viem";
12
12
  import { abstract, abstractTestnet } from "viem/chains";
13
13
  import http$1 from "node:http";
14
14
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
@@ -240,7 +240,7 @@ const SUPPORTED_CHAINS = {
240
240
  [abstract.id]: abstract
241
241
  };
242
242
  const DEFAULT_CHAIN_ID = abstract.id;
243
- function normalizeOptionalString(value) {
243
+ function normalizeOptionalString$1(value) {
244
244
  if (typeof value !== "string") return;
245
245
  const normalized = value.trim();
246
246
  return normalized ? normalized : void 0;
@@ -251,9 +251,9 @@ function parseChainId(value, source) {
251
251
  if (!Number.isInteger(chainId) || chainId <= 0) throw new Error(`Invalid chain id from ${source}. Expected a positive integer.`);
252
252
  return chainId;
253
253
  }
254
- function resolveEnvValue(env, keys) {
254
+ function resolveEnvValue$1(env, keys) {
255
255
  for (const key of keys) {
256
- const value = normalizeOptionalString(env[key]);
256
+ const value = normalizeOptionalString$1(env[key]);
257
257
  if (value) return value;
258
258
  }
259
259
  }
@@ -270,17 +270,36 @@ function getDefaultRpcUrl(chain) {
270
270
  function resolveNetworkConfig(input = {}) {
271
271
  const env = input.env ?? process.env;
272
272
  const chainIdFromCli = input.chainId === void 0 ? void 0 : parseChainId(input.chainId, "--chain-id");
273
- const chainIdFromEnvRaw = resolveEnvValue(env, CHAIN_ID_ENV_KEYS);
273
+ const chainIdFromEnvRaw = resolveEnvValue$1(env, CHAIN_ID_ENV_KEYS);
274
274
  const chainIdFromEnv = chainIdFromEnvRaw === void 0 ? void 0 : parseChainId(chainIdFromEnvRaw, "environment");
275
275
  const chainId = chainIdFromCli ?? chainIdFromEnv ?? DEFAULT_CHAIN_ID;
276
276
  const chain = resolveChain(chainId);
277
277
  return {
278
278
  chainId,
279
279
  chain,
280
- rpcUrl: normalizeOptionalString(input.rpcUrl) ?? resolveEnvValue(env, RPC_URL_ENV_KEYS) ?? getDefaultRpcUrl(chain)
280
+ rpcUrl: normalizeOptionalString$1(input.rpcUrl) ?? resolveEnvValue$1(env, RPC_URL_ENV_KEYS) ?? getDefaultRpcUrl(chain)
281
281
  };
282
282
  }
283
283
 
284
+ //#endregion
285
+ //#region src/config/zeroex.ts
286
+ const ZEROEX_API_KEY_ENV_KEYS = ["AGW_MCP_ZEROEX_API_KEY", "ZEROEX_API_KEY"];
287
+ function normalizeOptionalString(value) {
288
+ if (typeof value !== "string") return;
289
+ const normalized = value.trim();
290
+ return normalized ? normalized : void 0;
291
+ }
292
+ function resolveEnvValue(env, keys) {
293
+ for (const key of keys) {
294
+ const value = normalizeOptionalString(env[key]);
295
+ if (value) return value;
296
+ }
297
+ }
298
+ function resolveZeroExConfig(input = {}) {
299
+ const env = input.env ?? process.env;
300
+ return { apiKey: normalizeOptionalString(input.apiKey) ?? resolveEnvValue(env, ZEROEX_API_KEY_ENV_KEYS) };
301
+ }
302
+
284
303
  //#endregion
285
304
  //#region src/integrations/zeroex/quote-adapter.ts
286
305
  const DEFAULT_ZEROEX_API_BASE_URL = "https://api.0x.org";
@@ -594,7 +613,8 @@ function createZeroExQuoteAdapter(config = {}) {
594
613
  }
595
614
  } };
596
615
  }
597
- const zeroExQuoteAdapter = createZeroExQuoteAdapter();
616
+ const defaultZeroExConfig = resolveZeroExConfig();
617
+ const zeroExQuoteAdapter = createZeroExQuoteAdapter({ apiKey: defaultZeroExConfig.apiKey });
598
618
 
599
619
  //#endregion
600
620
  //#region src/errors/contract.ts
@@ -2100,6 +2120,98 @@ const getSessionStatusTool = {
2100
2120
 
2101
2121
  //#endregion
2102
2122
  //#region src/tools/get-token-list.ts
2123
+ const ERC20_TRANSFER_EVENT = parseAbiItem("event Transfer(address indexed from, address indexed to, uint256 value)");
2124
+ const LOG_SCAN_MIN_CHUNK_SIZE = 50000n;
2125
+ const BALANCE_QUERY_CONCURRENCY = 8;
2126
+ const METADATA_QUERY_CONCURRENCY = 8;
2127
+ function getErrorMessage(error) {
2128
+ if (error instanceof Error) return error.message;
2129
+ try {
2130
+ return JSON.stringify(error);
2131
+ } catch {
2132
+ return String(error);
2133
+ }
2134
+ }
2135
+ function isRpcMethodUnavailableError(error) {
2136
+ const message = getErrorMessage(error).toLowerCase();
2137
+ return message.includes("rpc method is not whitelisted") || message.includes("method not found") || message.includes("code\":-32601") && message.includes("zks_getallaccountbalances");
2138
+ }
2139
+ function isLogRangeError(error) {
2140
+ const message = getErrorMessage(error).toLowerCase();
2141
+ return message.includes("too many results") || message.includes("response size exceeded") || message.includes("query timeout") || message.includes("block range") || message.includes("limit");
2142
+ }
2143
+ async function getTransferLogsWithAdaptiveChunking(publicClient, latestBlock, args) {
2144
+ if (latestBlock < 0n) return [];
2145
+ const logs = [];
2146
+ let chunkSize = latestBlock + 1n;
2147
+ let fromBlock = 0n;
2148
+ while (fromBlock <= latestBlock) {
2149
+ const toBlock = fromBlock + chunkSize - 1n > latestBlock ? latestBlock : fromBlock + chunkSize - 1n;
2150
+ try {
2151
+ const batch = await publicClient.getLogs({
2152
+ event: ERC20_TRANSFER_EVENT,
2153
+ args,
2154
+ fromBlock,
2155
+ toBlock
2156
+ });
2157
+ logs.push(...batch);
2158
+ fromBlock = toBlock + 1n;
2159
+ } catch (error) {
2160
+ if (!isLogRangeError(error) || chunkSize <= LOG_SCAN_MIN_CHUNK_SIZE) throw error;
2161
+ chunkSize = chunkSize / 2n;
2162
+ if (chunkSize < LOG_SCAN_MIN_CHUNK_SIZE) chunkSize = LOG_SCAN_MIN_CHUNK_SIZE;
2163
+ }
2164
+ }
2165
+ return logs;
2166
+ }
2167
+ async function mapWithConcurrency(values, concurrency, mapper) {
2168
+ if (values.length === 0) return [];
2169
+ const workerCount = Math.max(1, Math.min(concurrency, values.length));
2170
+ const results = new Array(values.length);
2171
+ let nextIndex = 0;
2172
+ const workers = Array.from({ length: workerCount }, async () => {
2173
+ while (nextIndex < values.length) {
2174
+ const index = nextIndex;
2175
+ nextIndex += 1;
2176
+ results[index] = await mapper(values[index]);
2177
+ }
2178
+ });
2179
+ await Promise.all(workers);
2180
+ return results;
2181
+ }
2182
+ async function getTokenBalancesFromTransferLogs(publicClient, accountAddress) {
2183
+ const normalizedAccount = getAddress(accountAddress);
2184
+ const latestBlock = await publicClient.getBlockNumber();
2185
+ const [incomingLogs, outgoingLogs] = await Promise.all([getTransferLogsWithAdaptiveChunking(publicClient, latestBlock, { to: normalizedAccount }), getTransferLogsWithAdaptiveChunking(publicClient, latestBlock, { from: normalizedAccount })]);
2186
+ const tokenAddresses = /* @__PURE__ */ new Map();
2187
+ for (const log of [...incomingLogs, ...outgoingLogs]) {
2188
+ if (!isAddress(log.address, { strict: false })) continue;
2189
+ tokenAddresses.set(log.address.toLowerCase(), log.address);
2190
+ }
2191
+ const balanceEntries = await mapWithConcurrency(Array.from(tokenAddresses.values()), BALANCE_QUERY_CONCURRENCY, async (tokenAddress) => {
2192
+ try {
2193
+ const rawBalance = await publicClient.readContract({
2194
+ address: tokenAddress,
2195
+ abi: erc20Abi,
2196
+ functionName: "balanceOf",
2197
+ args: [normalizedAccount]
2198
+ });
2199
+ if (typeof rawBalance !== "bigint" || rawBalance <= 0n) return null;
2200
+ return {
2201
+ tokenAddress,
2202
+ rawBalance
2203
+ };
2204
+ } catch {
2205
+ return null;
2206
+ }
2207
+ });
2208
+ const balances = {};
2209
+ for (const entry of balanceEntries) {
2210
+ if (!entry) continue;
2211
+ balances[entry.tokenAddress] = entry.rawBalance.toString();
2212
+ }
2213
+ return balances;
2214
+ }
2103
2215
  function normalizeTokenBalances(value) {
2104
2216
  if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("token balances payload must be an object");
2105
2217
  const entries = [];
@@ -2136,18 +2248,23 @@ function createDefaultTokenListReader(input) {
2136
2248
  });
2137
2249
  return {
2138
2250
  getTokenBalances: async (accountAddress) => {
2139
- const request = publicClient.request;
2140
- const response = await request({
2141
- method: "zks_getAllAccountBalances",
2142
- params: [accountAddress]
2143
- });
2144
- if (typeof response !== "object" || response === null || Array.isArray(response)) throw new Error("zks_getAllAccountBalances returned an invalid response payload");
2145
- const balances = {};
2146
- for (const [tokenAddress, rawValue] of Object.entries(response)) {
2147
- if (typeof rawValue !== "string") throw new Error(`token balance for ${tokenAddress} returned a non-string value`);
2148
- balances[tokenAddress] = rawValue;
2251
+ try {
2252
+ const request = publicClient.request;
2253
+ const response = await request({
2254
+ method: "zks_getAllAccountBalances",
2255
+ params: [accountAddress]
2256
+ });
2257
+ if (typeof response !== "object" || response === null || Array.isArray(response)) throw new Error("zks_getAllAccountBalances returned an invalid response payload");
2258
+ const balances = {};
2259
+ for (const [tokenAddress, rawValue] of Object.entries(response)) {
2260
+ if (typeof rawValue !== "string") throw new Error(`token balance for ${tokenAddress} returned a non-string value`);
2261
+ balances[tokenAddress] = rawValue;
2262
+ }
2263
+ return balances;
2264
+ } catch (error) {
2265
+ if (!isRpcMethodUnavailableError(error)) throw error;
2266
+ return getTokenBalancesFromTransferLogs(publicClient, accountAddress);
2149
2267
  }
2150
- return balances;
2151
2268
  },
2152
2269
  getTokenMetadata: async (tokenAddress) => {
2153
2270
  const [symbol, decimals] = await Promise.all([publicClient.readContract({
@@ -2197,27 +2314,28 @@ function createGetTokenListTool(dependencies = { createTokenListReader: createDe
2197
2314
  chainId: networkConfig.chainId,
2198
2315
  rpcUrl: networkConfig.rpcUrl
2199
2316
  });
2200
- const normalizedTokenBalances = normalizeTokenBalances(await reader.getTokenBalances(accountAddress));
2201
- const tokenHoldings = [];
2202
- for (const { tokenAddress, rawValue } of normalizedTokenBalances) try {
2203
- const metadata = await reader.getTokenMetadata(tokenAddress);
2204
- tokenHoldings.push({
2205
- tokenAddress,
2206
- symbol: metadata.symbol,
2207
- decimals: metadata.decimals,
2208
- value: {
2209
- raw: rawValue.toString(),
2210
- formatted: formatUnits(rawValue, metadata.decimals)
2211
- },
2212
- explorer: {
2213
- token: buildExplorerUrl(explorerBase, `/token/${tokenAddress}`),
2214
- holder: buildExplorerUrl(explorerBase, `/token/${tokenAddress}?a=${accountAddress}`)
2215
- }
2216
- });
2217
- } catch (error) {
2218
- const message = error instanceof Error ? error.message : String(error);
2219
- context.logger.warn(`Skipping token ${tokenAddress}: ${message}`);
2220
- }
2317
+ const tokenHoldings = (await mapWithConcurrency(normalizeTokenBalances(await reader.getTokenBalances(accountAddress)), METADATA_QUERY_CONCURRENCY, async ({ tokenAddress, rawValue }) => {
2318
+ try {
2319
+ const metadata = await reader.getTokenMetadata(tokenAddress);
2320
+ return {
2321
+ tokenAddress,
2322
+ symbol: metadata.symbol,
2323
+ decimals: metadata.decimals,
2324
+ value: {
2325
+ raw: rawValue.toString(),
2326
+ formatted: formatUnits(rawValue, metadata.decimals)
2327
+ },
2328
+ explorer: {
2329
+ token: buildExplorerUrl(explorerBase, `/token/${tokenAddress}`),
2330
+ holder: buildExplorerUrl(explorerBase, `/token/${tokenAddress}?a=${accountAddress}`)
2331
+ }
2332
+ };
2333
+ } catch (error) {
2334
+ const message = error instanceof Error ? error.message : String(error);
2335
+ context.logger.warn(`Skipping token ${tokenAddress}: ${message}`);
2336
+ return null;
2337
+ }
2338
+ })).filter((entry) => entry !== null);
2221
2339
  return {
2222
2340
  connected: true,
2223
2341
  sessionStatus: context.sessionManager.getSessionStatus(),
@@ -2807,7 +2925,10 @@ function parseOptionalString(value, field) {
2807
2925
  if (typeof value !== "string" || value.trim() === "") throw new Error(`${field} must be a non-empty string when provided`);
2808
2926
  return value.trim();
2809
2927
  }
2810
- function createSwapTokensTool(dependencies = { quoteAdapter: zeroExQuoteAdapter }) {
2928
+ function createSwapTokensTool(dependencies = {}) {
2929
+ const createQuoteAdapter = dependencies.createQuoteAdapter ?? (() => {
2930
+ return createZeroExQuoteAdapter({ apiKey: resolveZeroExConfig().apiKey });
2931
+ });
2811
2932
  return {
2812
2933
  name: "swap_tokens",
2813
2934
  description: "Fetches 0x swap quotes and executes swap transactions through AGW session keys when explicitly requested.",
@@ -2850,7 +2971,7 @@ function createSwapTokensTool(dependencies = { quoteAdapter: zeroExQuoteAdapter
2850
2971
  const session = context.sessionManager.getSession();
2851
2972
  if (!session) throw new Error("session is missing");
2852
2973
  const execute = parseExecute$1(params.execute);
2853
- const quote = await dependencies.quoteAdapter.getQuote({
2974
+ const quote = await (dependencies.quoteAdapter ?? createQuoteAdapter()).getQuote({
2854
2975
  chainId: session.chainId,
2855
2976
  taker: session.accountAddress,
2856
2977
  sellToken: params.sellToken.trim(),
@@ -3358,6 +3479,7 @@ var AgwMcpServer = class {
3358
3479
  //#endregion
3359
3480
  //#region src/index.ts
3360
3481
  const logger = new Logger("agw-mcp");
3482
+ const ZEROEX_API_KEY_ENV = "AGW_MCP_ZEROEX_API_KEY";
3361
3483
  function resolveCliVersion() {
3362
3484
  try {
3363
3485
  const packageJsonUrl = new URL("../package.json", import.meta.url);
@@ -3367,6 +3489,11 @@ function resolveCliVersion() {
3367
3489
  } catch {}
3368
3490
  return "0.1.0";
3369
3491
  }
3492
+ function applyZeroExApiKeyOverride(apiKeyValue) {
3493
+ if (apiKeyValue === void 0) return;
3494
+ if (typeof apiKeyValue !== "string" || apiKeyValue.trim() === "") throw new Error("--zeroex-api-key must be a non-empty string");
3495
+ process.env[ZEROEX_API_KEY_ENV] = apiKeyValue.trim();
3496
+ }
3370
3497
  const program = new Command();
3371
3498
  program.name("agw-mcp").description("Local MCP server for AGW session-key workflows").version(resolveCliVersion());
3372
3499
  program.command("init").description("Bootstrap local AGW MCP session storage").option("--chain-id <chainId>", "EVM chain id (env: AGW_MCP_CHAIN_ID)").option("--rpc-url <rpcUrl>", "RPC URL override (env: AGW_MCP_RPC_URL)").option("--app-url <url>", "Hosted session onboarding URL (defaults to https://mcp.abs.xyz; env: AGW_MCP_APP_URL)").option("--storage-dir <dir>", "Session storage directory").action(async (options) => {
@@ -3396,7 +3523,8 @@ program.command("config").description("Print a ready-to-paste local MCP config s
3396
3523
  });
3397
3524
  process.stdout.write(`${JSON.stringify(snippet, null, 2)}\n`);
3398
3525
  });
3399
- program.command("serve").description("Run the local stdio MCP server").option("--chain-id <chainId>", "EVM chain id (env: AGW_MCP_CHAIN_ID)").option("--rpc-url <rpcUrl>", "RPC URL override (env: AGW_MCP_RPC_URL)").option("--storage-dir <dir>", "Session storage directory").action(async (options) => {
3526
+ program.command("serve").description("Run the local stdio MCP server").option("--chain-id <chainId>", "EVM chain id (env: AGW_MCP_CHAIN_ID)").option("--rpc-url <rpcUrl>", "RPC URL override (env: AGW_MCP_RPC_URL)").option("--zeroex-api-key <apiKey>", "0x API key override (env: AGW_MCP_ZEROEX_API_KEY)").option("--storage-dir <dir>", "Session storage directory").action(async (options) => {
3527
+ applyZeroExApiKeyOverride(options.zeroexApiKey);
3400
3528
  const networkConfig = resolveNetworkConfig({
3401
3529
  chainId: options.chainId,
3402
3530
  rpcUrl: options.rpcUrl
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abstract-foundation/agw-mcp",
3
- "version": "0.1.0-beta.6",
3
+ "version": "0.1.0-beta.7",
4
4
  "description": "MCP server for Abstract Global Wallet session-key workflows",
5
5
  "license": "MIT",
6
6
  "author": "Abstract Foundation",