web3-tools-mcp 3.1.0 → 3.2.0

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 (48) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +23 -11
  3. package/dist/chains.d.ts +609 -65
  4. package/dist/chains.d.ts.map +1 -1
  5. package/dist/chains.js +89 -28
  6. package/dist/chains.js.map +1 -1
  7. package/dist/client.d.ts.map +1 -1
  8. package/dist/client.js +2 -4
  9. package/dist/client.js.map +1 -1
  10. package/dist/http-server.d.ts +1 -1
  11. package/dist/http-server.d.ts.map +1 -1
  12. package/dist/http-server.js +3 -2
  13. package/dist/http-server.js.map +1 -1
  14. package/dist/index.js +0 -5
  15. package/dist/index.js.map +1 -1
  16. package/dist/tools/index.d.ts.map +1 -1
  17. package/dist/tools/index.js +2 -0
  18. package/dist/tools/index.js.map +1 -1
  19. package/dist/tools/read/advanced.d.ts +16 -16
  20. package/dist/tools/read/balance.d.ts +6 -6
  21. package/dist/tools/read/contract-info.d.ts +12 -12
  22. package/dist/tools/read/contract.d.ts +10 -10
  23. package/dist/tools/read/defi.d.ts +86 -0
  24. package/dist/tools/read/defi.d.ts.map +1 -0
  25. package/dist/tools/read/defi.js +210 -0
  26. package/dist/tools/read/defi.js.map +1 -0
  27. package/dist/tools/read/gas.d.ts +16 -16
  28. package/dist/tools/read/logs.d.ts +4 -4
  29. package/dist/tools/sign/transactions.d.ts +12 -12
  30. package/dist/tools/sign/wallet.d.ts +4 -4
  31. package/dist/types.d.ts +0 -1
  32. package/dist/types.d.ts.map +1 -1
  33. package/dist/utils.d.ts.map +1 -1
  34. package/dist/utils.js +1 -2
  35. package/dist/utils.js.map +1 -1
  36. package/dist/wallet-client.d.ts.map +1 -1
  37. package/dist/wallet-client.js +2 -2
  38. package/dist/wallet-client.js.map +1 -1
  39. package/package.json +2 -2
  40. package/src/chains.ts +101 -31
  41. package/src/client.ts +2 -3
  42. package/src/http-server.ts +3 -3
  43. package/src/index.ts +0 -5
  44. package/src/tools/index.ts +2 -0
  45. package/src/tools/read/defi.ts +287 -0
  46. package/src/types.ts +0 -1
  47. package/src/utils.ts +1 -2
  48. package/src/wallet-client.ts +9 -2
package/src/chains.ts CHANGED
@@ -1,16 +1,23 @@
1
1
  import type { Chain } from 'viem'
2
2
  import {
3
3
  arbitrum,
4
+ arc,
4
5
  avalanche,
5
6
  base,
6
7
  bsc,
8
+ celo,
7
9
  gnosis,
10
+ hyperEvm,
11
+ ink,
8
12
  linea,
9
13
  localhost,
10
14
  mainnet,
15
+ mantle,
16
+ monad,
11
17
  optimism,
18
+ plasma,
12
19
  polygon,
13
- sonic,
20
+ robinhood,
14
21
  unichain,
15
22
  zksync
16
23
  } from 'viem/chains'
@@ -25,39 +32,41 @@ interface ChainInfo {
25
32
  chain: Chain
26
33
  /** Subdomain under g.alchemy.com; absent where Alchemy has no endpoint. */
27
34
  alchemy?: string
28
- /** Subdomain under infura.io; absent where Infura has no endpoint. */
29
- infura?: string
30
- /** Used when no provider key is configured. */
35
+ /** False where Alchemy serves the RPC but not its token API, which `get_portfolio` needs. */
36
+ alchemyTokenApi?: false
37
+ /** Used when no Alchemy key is configured, or Alchemy has no endpoint. */
31
38
  fallback: string
32
39
  /** Block explorer domain, for links. Absent where there is nothing to link to. */
33
40
  explorer?: string
34
41
  hypersync?: string
42
+ /** DefiLlama's two names for the chain: the prefix of a coin id, and the `chain` of a yield pool. */
43
+ llama?: { coins: string; yields: string }
35
44
  }
36
45
 
37
46
  const TABLE = {
38
47
  mainnet: {
39
48
  chain: mainnet,
40
49
  alchemy: 'eth-mainnet',
41
- infura: 'mainnet',
42
50
  fallback: 'https://ethereum-rpc.publicnode.com',
43
51
  explorer: 'etherscan.io',
44
- hypersync: 'https://eth.hypersync.xyz'
52
+ hypersync: 'https://eth.hypersync.xyz',
53
+ llama: { coins: 'ethereum', yields: 'Ethereum' }
45
54
  },
46
55
  arbitrum: {
47
56
  chain: arbitrum,
48
57
  alchemy: 'arb-mainnet',
49
- infura: 'arbitrum-mainnet',
50
58
  fallback: 'https://arb1.arbitrum.io/rpc',
51
59
  explorer: 'arbiscan.io',
52
- hypersync: 'https://arbitrum.hypersync.xyz'
60
+ hypersync: 'https://arbitrum.hypersync.xyz',
61
+ llama: { coins: 'arbitrum', yields: 'Arbitrum' }
53
62
  },
54
63
  avalanche: {
55
64
  chain: avalanche,
56
65
  alchemy: 'avax-mainnet',
57
- infura: 'avalanche-mainnet',
58
66
  fallback: 'https://api.avax.network/ext/bc/C/rpc',
59
67
  explorer: 'snowtrace.io',
60
- hypersync: 'https://avalanche.hypersync.xyz'
68
+ hypersync: 'https://avalanche.hypersync.xyz',
69
+ llama: { coins: 'avax', yields: 'Avalanche' }
61
70
  },
62
71
  base: {
63
72
  chain: base,
@@ -65,70 +74,131 @@ const TABLE = {
65
74
  // publicnode refuses receipts and anything else it calls an archive request
66
75
  fallback: 'https://mainnet.base.org',
67
76
  explorer: 'basescan.org',
68
- hypersync: 'https://base.hypersync.xyz'
77
+ hypersync: 'https://base.hypersync.xyz',
78
+ llama: { coins: 'base', yields: 'Base' }
69
79
  },
70
80
  bnb: {
71
81
  chain: bsc,
72
82
  alchemy: 'bnb-mainnet',
73
- infura: 'bsc-mainnet',
74
83
  fallback: 'https://bsc-dataseed.bnbchain.org',
75
84
  explorer: 'bscscan.com',
76
- hypersync: 'https://bsc.hypersync.xyz'
85
+ hypersync: 'https://bsc.hypersync.xyz',
86
+ llama: { coins: 'bsc', yields: 'BSC' }
77
87
  },
78
88
  gnosis: {
79
89
  chain: gnosis,
80
90
  alchemy: 'gnosis-mainnet',
81
91
  fallback: 'https://rpc.gnosischain.com',
82
92
  explorer: 'gnosisscan.io',
83
- hypersync: 'https://gnosis.hypersync.xyz'
84
- },
85
- sonic: {
86
- chain: sonic,
87
- alchemy: 'sonic-mainnet',
88
- fallback: 'https://rpc.soniclabs.com',
89
- explorer: 'sonicscan.org',
90
- hypersync: 'https://sonic.hypersync.xyz'
93
+ hypersync: 'https://gnosis.hypersync.xyz',
94
+ llama: { coins: 'xdai', yields: 'Gnosis' }
91
95
  },
92
96
  optimism: {
93
97
  chain: optimism,
94
98
  alchemy: 'opt-mainnet',
95
- infura: 'optimism-mainnet',
96
99
  fallback: 'https://mainnet.optimism.io',
97
100
  explorer: 'optimistic.etherscan.io',
98
- hypersync: 'https://optimism.hypersync.xyz'
101
+ hypersync: 'https://optimism.hypersync.xyz',
102
+ llama: { coins: 'optimism', yields: 'OP Mainnet' }
99
103
  },
100
104
  polygon: {
101
105
  chain: polygon,
102
106
  alchemy: 'polygon-mainnet',
103
- infura: 'polygon-mainnet',
104
107
  // polygon-rpc.com now answers 401 "API key disabled" to anonymous callers
105
108
  fallback: 'https://polygon-bor-rpc.publicnode.com',
106
109
  explorer: 'polygonscan.com',
107
- hypersync: 'https://polygon.hypersync.xyz'
110
+ hypersync: 'https://polygon.hypersync.xyz',
111
+ llama: { coins: 'polygon', yields: 'Polygon' }
108
112
  },
109
113
  zksync: {
110
114
  chain: zksync,
111
115
  alchemy: 'zksync-mainnet',
112
- infura: 'zksync-mainnet',
113
116
  fallback: 'https://mainnet.era.zksync.io',
114
117
  explorer: 'era.zksync.network',
115
- hypersync: 'https://zksync.hypersync.xyz'
118
+ hypersync: 'https://zksync.hypersync.xyz',
119
+ llama: { coins: 'era', yields: 'ZKsync Era' }
116
120
  },
117
121
  linea: {
118
122
  chain: linea,
119
123
  alchemy: 'linea-mainnet',
120
- infura: 'linea-mainnet',
121
124
  fallback: 'https://rpc.linea.build',
122
125
  explorer: 'lineascan.build',
123
- hypersync: 'https://linea.hypersync.xyz'
126
+ hypersync: 'https://linea.hypersync.xyz',
127
+ llama: { coins: 'linea', yields: 'Linea' }
124
128
  },
125
129
  unichain: {
126
130
  chain: unichain,
127
131
  alchemy: 'unichain-mainnet',
128
- infura: 'unichain-mainnet',
129
132
  fallback: 'https://mainnet.unichain.org',
130
133
  explorer: 'uniscan.xyz',
131
- hypersync: 'https://unichain.hypersync.xyz'
134
+ hypersync: 'https://unichain.hypersync.xyz',
135
+ llama: { coins: 'unichain', yields: 'Unichain' }
136
+ },
137
+ monad: {
138
+ chain: monad,
139
+ alchemy: 'monad-mainnet',
140
+ fallback: 'https://rpc.monad.xyz',
141
+ explorer: 'monadscan.com',
142
+ hypersync: 'https://monad.hypersync.xyz',
143
+ llama: { coins: 'monad', yields: 'Monad' }
144
+ },
145
+ robinhood: {
146
+ chain: robinhood,
147
+ alchemy: 'robinhood-mainnet',
148
+ fallback: 'https://rpc.mainnet.chain.robinhood.com',
149
+ explorer: 'robinhoodchain.blockscout.com',
150
+ hypersync: 'https://robinhood.hypersync.xyz',
151
+ llama: { coins: 'robinhood', yields: 'Robinhood Chain' }
152
+ },
153
+ arc: {
154
+ chain: arc,
155
+ alchemy: 'arc-mainnet',
156
+ fallback: 'https://rpc.mainnet.arc.io',
157
+ explorer: 'explorer.arc.io',
158
+ hypersync: 'https://arc.hypersync.xyz',
159
+ llama: { coins: 'arc', yields: 'Arc' }
160
+ },
161
+ plasma: {
162
+ chain: plasma,
163
+ alchemy: 'plasma-mainnet',
164
+ alchemyTokenApi: false,
165
+ fallback: 'https://rpc.plasma.to',
166
+ explorer: 'plasmascan.to',
167
+ hypersync: 'https://plasma.hypersync.xyz',
168
+ llama: { coins: 'plasma', yields: 'Plasma' }
169
+ },
170
+ ink: {
171
+ chain: ink,
172
+ alchemy: 'ink-mainnet',
173
+ fallback: 'https://rpc-gel.inkonchain.com',
174
+ explorer: 'explorer.inkonchain.com',
175
+ hypersync: 'https://ink.hypersync.xyz',
176
+ llama: { coins: 'ink', yields: 'Ink' }
177
+ },
178
+ mantle: {
179
+ chain: mantle,
180
+ alchemy: 'mantle-mainnet',
181
+ alchemyTokenApi: false,
182
+ fallback: 'https://rpc.mantle.xyz',
183
+ explorer: 'mantlescan.xyz',
184
+ hypersync: 'https://mantle.hypersync.xyz',
185
+ llama: { coins: 'mantle', yields: 'Mantle' }
186
+ },
187
+ celo: {
188
+ chain: celo,
189
+ alchemy: 'celo-mainnet',
190
+ fallback: 'https://forno.celo.org',
191
+ explorer: 'celoscan.io',
192
+ hypersync: 'https://celo.hypersync.xyz',
193
+ llama: { coins: 'celo', yields: 'Celo' }
194
+ },
195
+ hyperevm: {
196
+ chain: hyperEvm,
197
+ alchemy: 'hyperliquid-mainnet',
198
+ fallback: 'https://rpc.hyperliquid.xyz/evm',
199
+ explorer: 'hyperevmscan.io',
200
+ hypersync: 'https://hyperliquid.hypersync.xyz',
201
+ llama: { coins: 'hyperliquid', yields: 'Hyperliquid L1' }
132
202
  },
133
203
  localhost: {
134
204
  chain: localhost,
package/src/client.ts CHANGED
@@ -4,14 +4,13 @@ import type { Config } from './types.js'
4
4
 
5
5
  export { SUPPORTED_CHAINS } from './chains.js'
6
6
 
7
- /** Custom URL first, then whichever provider key is configured, then the public endpoint. */
7
+ /** Custom URL first, then Alchemy when a key is configured, then the public endpoint. */
8
8
  function getRpcUrl(chainName: ChainName, config: Config): string {
9
9
  const custom = config.customRpcUrls?.[chainName]
10
10
  if (custom) return custom
11
11
 
12
- const { alchemy, infura, fallback } = CHAINS[chainName]
12
+ const { alchemy, fallback } = CHAINS[chainName]
13
13
  if (alchemy && config.alchemyApiKey) return `https://${alchemy}.g.alchemy.com/v2/${config.alchemyApiKey}`
14
- if (infura && config.infuraApiKey) return `https://${infura}.infura.io/v3/${config.infuraApiKey}`
15
14
  return fallback
16
15
  }
17
16
 
@@ -4,7 +4,7 @@ import { getOAuthProtectedResourceMetadataUrl, mcpAuthRouter } from '@modelconte
4
4
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
5
5
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
6
6
  import express, { type Express } from 'express'
7
- import type { WalletRelay } from 'web3-wallet-relay'
7
+ import { SIGN_PATH, type WalletRelay } from 'web3-wallet-relay'
8
8
  import { HOSTED } from './hosted.js'
9
9
  import { SingleUserOAuthProvider } from './oauth.js'
10
10
 
@@ -136,11 +136,11 @@ export async function startHttpServer(options: HttpServerOptions): Promise<{ url
136
136
  // The signing page rides on this same server, so a hosted deployment can offer browser
137
137
  // signing without a second service: one platform port serves both /mcp and the page.
138
138
  // Mounted last, so the routes above keep their paths — the relay only claims what is
139
- // left, which is the static page at the root.
139
+ // left, which is the static page at /sign/.
140
140
  let walletUrl: string | undefined
141
141
  if (options.walletRelay) {
142
142
  options.walletRelay.attach(server, (relayApp) => app.use(relayApp))
143
- walletUrl = `${publicUrl.replace(/\/$/, '')}/#t=${options.walletRelay.signerToken}`
143
+ walletUrl = `${publicUrl.replace(/\/$/, '')}${SIGN_PATH}#t=${options.walletRelay.signerToken}`
144
144
  }
145
145
 
146
146
  return { url: `${publicUrl.replace(/\/$/, '')}/mcp`, port: (server.address() as { port: number }).port, walletUrl }
package/src/index.ts CHANGED
@@ -27,7 +27,6 @@ OPTIONS:
27
27
  --help, -h Show this help message
28
28
  --etherscan-api-key <key> Etherscan API key (for contract ABI retrieval)
29
29
  --alchemy-api-key <key> Alchemy API key (for enhanced RPC)
30
- --infura-api-key <key> Infura API key (for alternative RPC)
31
30
  --hypersync-api-key <key> Hypersync API key (for fast event queries)
32
31
  --walletconnect-project-id <id>
33
32
  WalletConnect project id, to sign from a phone
@@ -37,7 +36,6 @@ OPTIONS:
37
36
  ENVIRONMENT VARIABLES:
38
37
  ETHERSCAN_API_KEY Alternative to --etherscan-api-key
39
38
  ALCHEMY_API_KEY Alternative to --alchemy-api-key
40
- INFURA_API_KEY Alternative to --infura-api-key
41
39
  HYPERSYNC_API_KEY Alternative to --hypersync-api-key
42
40
  WALLETCONNECT_PROJECT_ID Alternative to --walletconnect-project-id
43
41
  CUSTOM_RPC Alternative to --custom-rpc
@@ -84,9 +82,6 @@ if (config.etherscanApiKey) {
84
82
  if (config.alchemyApiKey) {
85
83
  console.error('[MCP] Alchemy API key configured')
86
84
  }
87
- if (config.infuraApiKey) {
88
- console.error('[MCP] Infura API key configured')
89
- }
90
85
  if (config.walletConnectProjectId) {
91
86
  console.error('[MCP] WalletConnect project id configured — phone signing available')
92
87
  }
@@ -9,6 +9,7 @@ import advancedTools from './read/advanced.js'
9
9
  import balanceTools from './read/balance.js'
10
10
  import contractTools from './read/contract.js'
11
11
  import contractInfoTools from './read/contract-info.js'
12
+ import defiTools from './read/defi.js'
12
13
  import ensTools from './read/ens.js'
13
14
  import gasTools from './read/gas.js'
14
15
  import logTools from './read/logs.js'
@@ -36,6 +37,7 @@ const readTools = {
36
37
  ...advancedTools,
37
38
  ...ensTools,
38
39
  ...gasTools,
40
+ ...defiTools,
39
41
  // A signature already asked for and paid for; collecting it is not a second request.
40
42
  ...signingRequestTools
41
43
  } as const
@@ -0,0 +1,287 @@
1
+ import { type Address, formatUnits, type Hex, isAddress, zeroAddress } from 'viem'
2
+ import { z } from 'zod'
3
+ import { CHAINS, type ChainName, SUPPORTED_CHAINS } from '../../chains.js'
4
+ import { getClientManager } from '../../client.js'
5
+ import { createTool, formatResponse, MAX_BATCH, rpcReason } from '../../utils.js'
6
+
7
+ /** Token ids per DefiLlama price request, to keep the URL short. */
8
+ const PRICE_BATCH = 50
9
+ const MAX_TOKEN_PAGES = 10
10
+
11
+ interface LlamaPrice {
12
+ symbol: string
13
+ price: number
14
+ decimals?: number
15
+ confidence?: number
16
+ timestamp: number
17
+ }
18
+
19
+ interface LlamaPool {
20
+ pool: string
21
+ chain: string
22
+ project: string
23
+ symbol: string
24
+ poolMeta: string | null
25
+ tvlUsd: number
26
+ apy: number | null
27
+ apyBase: number | null
28
+ apyReward: number | null
29
+ apyMean30d: number | null
30
+ stablecoin: boolean
31
+ ilRisk: string
32
+ exposure: string
33
+ outlier: boolean
34
+ underlyingTokens: string[] | null
35
+ }
36
+
37
+ /** DefiLlama's public API: free, keyless, and the same for every caller. */
38
+ async function llama<T>(url: string): Promise<T> {
39
+ const response = await fetch(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(30_000) })
40
+ if (!response.ok) throw new Error(`DefiLlama answered ${response.status} for ${url}`)
41
+ return (await response.json()) as T
42
+ }
43
+
44
+ function llamaNames(chain: ChainName) {
45
+ const names = CHAINS[chain].llama
46
+ if (!names) throw new Error(`DefiLlama has no data for ${chain}`)
47
+ return names
48
+ }
49
+
50
+ /** Prices by lowercased token address; a token DefiLlama cannot price is absent. */
51
+ async function llamaPrices(chain: ChainName, tokens: string[], timestamp?: number): Promise<Map<string, LlamaPrice>> {
52
+ const prefix = llamaNames(chain).coins
53
+ const when = timestamp ? `historical/${timestamp}` : 'current'
54
+ const batches = Array.from({ length: Math.ceil(tokens.length / PRICE_BATCH) }, (_, index) =>
55
+ tokens.slice(index * PRICE_BATCH, (index + 1) * PRICE_BATCH)
56
+ )
57
+
58
+ const answers = await Promise.all(
59
+ batches.map((batch) =>
60
+ llama<{ coins: Record<string, LlamaPrice> }>(
61
+ `https://coins.llama.fi/prices/${when}/${batch.map((token) => `${prefix}:${token}`).join(',')}`
62
+ )
63
+ )
64
+ )
65
+ // DefiLlama keys its answer by the id as sent, but not reliably in the same case.
66
+ return new Map(
67
+ answers.flatMap(({ coins }) => Object.entries(coins).map(([id, price]) => [id.slice(prefix.length + 1).toLowerCase(), price]))
68
+ )
69
+ }
70
+
71
+ /**
72
+ * Every ERC20 the address holds a non-zero balance of, as Alchemy indexes them.
73
+ *
74
+ * ponytail: stops after MAX_TOKEN_PAGES pages; past that a wallet is nearly all spam, but a
75
+ * real token beyond the cut is missed. `truncated` says when that happened.
76
+ */
77
+ async function erc20Balances(chain: ChainName, address: Address) {
78
+ const client = getClientManager().getClient(chain)
79
+ const balances: [string, bigint][] = []
80
+ let pageKey: string | undefined
81
+
82
+ for (let page = 0; page < MAX_TOKEN_PAGES; page++) {
83
+ const result = (await client.request({
84
+ method: 'alchemy_getTokenBalances',
85
+ params: [address, 'erc20', { maxCount: 100, ...(pageKey && { pageKey }) }]
86
+ } as never)) as { tokenBalances: { contractAddress: string; tokenBalance: Hex | null }[]; pageKey?: string }
87
+
88
+ for (const { contractAddress, tokenBalance } of result.tokenBalances) {
89
+ if (tokenBalance && BigInt(tokenBalance) > 0n) balances.push([contractAddress, BigInt(tokenBalance)])
90
+ }
91
+ pageKey = result.pageKey
92
+ if (!pageKey) break
93
+ }
94
+
95
+ return { balances, truncated: Boolean(pageKey) }
96
+ }
97
+
98
+ async function holdingsOn(chain: ChainName, address: Address, minValueUsd: number) {
99
+ const [native, erc20] = await Promise.all([
100
+ getClientManager().getClient(chain).getBalance({ address }),
101
+ erc20Balances(chain, address)
102
+ ])
103
+ const held = native > 0n ? [[zeroAddress, native] as [string, bigint], ...erc20.balances] : erc20.balances
104
+ const prices = await llamaPrices(
105
+ chain,
106
+ held.map(([token]) => token)
107
+ )
108
+
109
+ const holdings = []
110
+ let unpriced = 0
111
+ let belowMinValue = 0
112
+ for (const [token, raw] of held) {
113
+ const price = prices.get(token.toLowerCase())
114
+ if (!price || price.decimals === undefined) {
115
+ unpriced++
116
+ continue
117
+ }
118
+ const amount = formatUnits(raw, price.decimals)
119
+ const valueUsd = Number(amount) * price.price
120
+ if (valueUsd < minValueUsd) {
121
+ belowMinValue++
122
+ continue
123
+ }
124
+ holdings.push({
125
+ chain,
126
+ token: token === zeroAddress ? 'native' : token,
127
+ symbol: price.symbol,
128
+ amount,
129
+ priceUsd: price.price,
130
+ valueUsd: Math.round(valueUsd * 100) / 100
131
+ })
132
+ }
133
+
134
+ return { chain, holdings, unpriced, belowMinValue, truncated: erc20.truncated }
135
+ }
136
+
137
+ export default {
138
+ get_token_prices: createTool(
139
+ 'Get Token Prices',
140
+ "USD prices from DefiLlama for tokens on one chain, now or at a past timestamp. Use the zero address for the chain's native token. " +
141
+ 'Tokens DefiLlama cannot price are listed as unpriced — usually spam or illiquid.',
142
+ z.object({
143
+ chain: z.enum(SUPPORTED_CHAINS).describe('Blockchain network'),
144
+ tokens: z
145
+ .array(z.string())
146
+ .min(1)
147
+ .max(MAX_BATCH)
148
+ .describe(`Token contract addresses (up to ${MAX_BATCH}); ${zeroAddress} for the native token`),
149
+ timestamp: z.number().int().positive().optional().describe('Unix time in seconds for a past price (defaults to now)')
150
+ }),
151
+ async (args) => {
152
+ const invalid = args.tokens.find((token) => !isAddress(token))
153
+ if (invalid) throw new Error(`Invalid token address: ${invalid}`)
154
+
155
+ const byToken = await llamaPrices(args.chain as ChainName, args.tokens, args.timestamp)
156
+
157
+ const prices = args.tokens.map((token) => {
158
+ const price = byToken.get(token.toLowerCase())
159
+ if (!price) return { token, priced: false }
160
+ return {
161
+ token,
162
+ priced: true,
163
+ symbol: price.symbol,
164
+ priceUsd: price.price,
165
+ decimals: price.decimals,
166
+ confidence: price.confidence,
167
+ updatedAt: new Date(price.timestamp * 1000).toISOString()
168
+ }
169
+ })
170
+
171
+ return formatResponse({ chain: args.chain, source: 'DefiLlama', prices })
172
+ }
173
+ ),
174
+
175
+ get_portfolio: createTool(
176
+ 'Get Portfolio',
177
+ 'What a wallet holds across chains, valued in USD: native and ERC20 balances found through Alchemy, priced by ' +
178
+ 'DefiLlama, largest first. Tokens DefiLlama cannot price are left out — that drops the spam airdrops most ' +
179
+ 'wallets collect. Positions inside DeFi protocols appear only as the receipt tokens DefiLlama happens to price.',
180
+ z.object({
181
+ address: z.string().describe('Wallet address'),
182
+ chains: z
183
+ .array(z.enum(SUPPORTED_CHAINS))
184
+ .optional()
185
+ .describe('Chains to look at (defaults to every chain where both the Alchemy token API and DefiLlama are available)'),
186
+ minValueUsd: z.number().optional().default(1).describe('Leave out holdings worth less than this, in USD (default 1)')
187
+ }),
188
+ async (args) => {
189
+ if (!isAddress(args.address)) throw new Error(`Invalid address: ${args.address}`)
190
+ const address = args.address
191
+ const chains = ((args.chains ?? SUPPORTED_CHAINS) as ChainName[]).filter(
192
+ (chain) => CHAINS[chain].alchemy && CHAINS[chain].alchemyTokenApi !== false && CHAINS[chain].llama
193
+ )
194
+
195
+ const scanned: Awaited<ReturnType<typeof holdingsOn>>[] = []
196
+ const failedChains: { chain: ChainName; error: string }[] = []
197
+ await Promise.all(
198
+ chains.map(async (chain) => {
199
+ try {
200
+ scanned.push(await holdingsOn(chain, address, args.minValueUsd))
201
+ } catch (error) {
202
+ failedChains.push({ chain, error: rpcReason(error) })
203
+ }
204
+ })
205
+ )
206
+ const holdings = scanned.flatMap((result) => result.holdings).sort((a, b) => b.valueUsd - a.valueUsd)
207
+ const truncatedChains = scanned.filter((result) => result.truncated).map((result) => result.chain)
208
+
209
+ return formatResponse({
210
+ address,
211
+ totalUsd: Math.round(holdings.reduce((sum, holding) => sum + holding.valueUsd, 0) * 100) / 100,
212
+ holdings,
213
+ hidden: {
214
+ unpriced: scanned.reduce((sum, result) => sum + result.unpriced, 0),
215
+ belowMinValue: scanned.reduce((sum, result) => sum + result.belowMinValue, 0)
216
+ },
217
+ ...(truncatedChains.length > 0 && { truncatedChains }),
218
+ ...(failedChains.length > 0 && { failedChains })
219
+ })
220
+ },
221
+ // alchemy_getTokenBalances is what finds the tokens; no other RPC here indexes them.
222
+ () => Boolean(getClientManager().getConfig().alchemyApiKey)
223
+ ),
224
+
225
+ get_yield_pools: createTool(
226
+ 'Get Yield Pools',
227
+ 'DeFi lending, staking and liquidity pools from DefiLlama, highest APY first — e.g. where USDC earns most on ' +
228
+ 'Arbitrum, or what Aave pays on Base. Pools DefiLlama flags as APY outliers are left out. APY is a percentage.',
229
+ z.object({
230
+ chain: z
231
+ .enum(SUPPORTED_CHAINS)
232
+ .optional()
233
+ .describe('Only pools on this chain (defaults to every chain this server supports)'),
234
+ symbol: z.string().optional().describe('Only pools holding this token, e.g. "USDC" (matches "USDC" and "USDC-WETH")'),
235
+ project: z.string().optional().describe('Only this protocol, as DefiLlama names it — "aave" matches aave-v3 and aave-v2'),
236
+ stablecoinOnly: z.boolean().optional().default(false).describe('Only pools of stablecoins'),
237
+ minTvlUsd: z.number().optional().default(1_000_000).describe('Leave out pools smaller than this, in USD (default 1M)'),
238
+ limit: z.number().int().min(1).max(100).optional().default(20).describe('How many pools to return (default 20)')
239
+ }),
240
+ async (args) => {
241
+ const chains = new Map<string, ChainName>(
242
+ (args.chain ? [args.chain as ChainName] : SUPPORTED_CHAINS)
243
+ .filter((chain) => CHAINS[chain].llama)
244
+ .map((chain) => [llamaNames(chain).yields, chain])
245
+ )
246
+ const symbol = args.symbol?.toUpperCase()
247
+ const project = args.project?.toLowerCase()
248
+
249
+ // ponytail: fetches DefiLlama's whole pool list (~12 MB) per call; cache it for a few minutes if calls pile up.
250
+ const { data } = await llama<{ data: LlamaPool[] }>('https://yields.llama.fi/pools')
251
+
252
+ const matching = data
253
+ .filter(
254
+ (pool) =>
255
+ chains.has(pool.chain) &&
256
+ !pool.outlier &&
257
+ pool.apy !== null &&
258
+ pool.tvlUsd >= args.minTvlUsd &&
259
+ (!args.stablecoinOnly || pool.stablecoin) &&
260
+ (!project || pool.project.includes(project)) &&
261
+ (!symbol || pool.symbol.toUpperCase().split('-').includes(symbol))
262
+ )
263
+ .sort((a, b) => (b.apy ?? 0) - (a.apy ?? 0))
264
+
265
+ return formatResponse({
266
+ source: 'DefiLlama',
267
+ matched: matching.length,
268
+ pools: matching.slice(0, args.limit).map((pool) => ({
269
+ chain: chains.get(pool.chain),
270
+ project: pool.project,
271
+ symbol: pool.symbol,
272
+ ...(pool.poolMeta && { meta: pool.poolMeta }),
273
+ apy: pool.apy,
274
+ apyBase: pool.apyBase,
275
+ apyReward: pool.apyReward,
276
+ apyMean30d: pool.apyMean30d,
277
+ tvlUsd: Math.round(pool.tvlUsd),
278
+ stablecoin: pool.stablecoin,
279
+ ilRisk: pool.ilRisk,
280
+ exposure: pool.exposure,
281
+ underlyingTokens: pool.underlyingTokens,
282
+ url: `https://defillama.com/yields/pool/${pool.pool}`
283
+ }))
284
+ })
285
+ }
286
+ )
287
+ }
package/src/types.ts CHANGED
@@ -11,7 +11,6 @@ export interface AbiError {
11
11
  export interface Config {
12
12
  etherscanApiKey?: string
13
13
  alchemyApiKey?: string
14
- infuraApiKey?: string
15
14
  customRpcUrls?: Record<string, string>
16
15
  hypersyncApiKey?: string
17
16
  walletConnectProjectId?: string
package/src/utils.ts CHANGED
@@ -211,7 +211,7 @@ export function rpcReason(error: unknown): string {
211
211
 
212
212
  /** Every configured key blanked out of `text`: RPC URLs carry them, and errors quote the URL. */
213
213
  export function redactSecrets(text: string, config: Config): string {
214
- return [config.alchemyApiKey, config.infuraApiKey, config.etherscanApiKey, config.hypersyncApiKey].reduce<string>(
214
+ return [config.alchemyApiKey, config.etherscanApiKey, config.hypersyncApiKey].reduce<string>(
215
215
  (out, secret) => (secret ? out.replaceAll(secret, '[redacted]') : out),
216
216
  text
217
217
  )
@@ -277,7 +277,6 @@ export function parseCommandLineArgs(): Config & { showHelp?: boolean } {
277
277
  // Parse environment variables and command line arguments
278
278
  config.etherscanApiKey = process.env.ETHERSCAN_API_KEY || getArgValue('--etherscan-api-key')
279
279
  config.alchemyApiKey = process.env.ALCHEMY_API_KEY || getArgValue('--alchemy-api-key')
280
- config.infuraApiKey = process.env.INFURA_API_KEY || getArgValue('--infura-api-key')
281
280
  config.hypersyncApiKey = process.env.HYPERSYNC_API_KEY || getArgValue('--hypersync-api-key')
282
281
  config.walletConnectProjectId = process.env.WALLETCONNECT_PROJECT_ID || getArgValue('--walletconnect-project-id')
283
282
 
@@ -3,7 +3,14 @@ import { randomBytes } from 'node:crypto'
3
3
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
4
4
  import { homedir } from 'node:os'
5
5
  import { join } from 'node:path'
6
- import { LOCAL_PORT_ATTEMPTS, roomToken, type TransactionRequest, type TransactionResponse, WalletRelay } from 'web3-wallet-relay'
6
+ import {
7
+ LOCAL_PORT_ATTEMPTS,
8
+ roomToken,
9
+ SIGN_PATH,
10
+ type TransactionRequest,
11
+ type TransactionResponse,
12
+ WalletRelay
13
+ } from 'web3-wallet-relay'
7
14
  import { WebSocket } from 'ws'
8
15
  import { HOSTED } from './hosted.js'
9
16
 
@@ -26,7 +33,7 @@ const IDLE_TIMEOUT = 10 * 60_000
26
33
  /** Always the signer token: this URL goes to a browser, which may not act as a requester. */
27
34
  function pairingUrlFor(port: number, secret: string, identity: string): string {
28
35
  const base = HOSTED ? (process.env.MCP_PUBLIC_URL ?? `http://localhost:${port}`) : `http://127.0.0.1:${port}`
29
- return `${base.replace(/\/$/, '')}/#t=${roomToken(secret, identity, 'signer')}`
36
+ return `${base.replace(/\/$/, '')}${SIGN_PATH}#t=${roomToken(secret, identity, 'signer')}`
30
37
  }
31
38
  const SIGNER_WAIT_TIMEOUT = 30_000
32
39
  const CONNECT_ATTEMPTS = 8