@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.
- package/README.md +4 -0
- package/dist/index.mjs +170 -42
- 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
|
|
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
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
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
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
}
|
|
2216
|
-
})
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
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 = {
|
|
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
|