web3-tools-mcp 1.6.0 → 2.0.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 (104) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/README.md +328 -234
  3. package/dist/anvil.d.ts +1 -1
  4. package/dist/anvil.d.ts.map +1 -1
  5. package/dist/anvil.js +11 -9
  6. package/dist/anvil.js.map +1 -1
  7. package/dist/chain-meta.d.ts.map +1 -1
  8. package/dist/chain-meta.js +7 -3
  9. package/dist/chain-meta.js.map +1 -1
  10. package/dist/chains.d.ts +960 -0
  11. package/dist/chains.d.ts.map +1 -0
  12. package/dist/chains.js +108 -0
  13. package/dist/chains.js.map +1 -0
  14. package/dist/client.d.ts +10 -1717
  15. package/dist/client.d.ts.map +1 -1
  16. package/dist/client.js +33 -218
  17. package/dist/client.js.map +1 -1
  18. package/dist/http-server.d.ts +6 -0
  19. package/dist/http-server.d.ts.map +1 -1
  20. package/dist/http-server.js +55 -40
  21. package/dist/http-server.js.map +1 -1
  22. package/dist/index.js +50 -29
  23. package/dist/index.js.map +1 -1
  24. package/dist/oauth.d.ts +31 -0
  25. package/dist/oauth.d.ts.map +1 -0
  26. package/dist/oauth.js +227 -0
  27. package/dist/oauth.js.map +1 -0
  28. package/dist/preview.d.ts.map +1 -1
  29. package/dist/preview.js +2 -2
  30. package/dist/preview.js.map +1 -1
  31. package/dist/tools/advanced.d.ts +4 -4
  32. package/dist/tools/advanced.d.ts.map +1 -1
  33. package/dist/tools/advanced.js +228 -247
  34. package/dist/tools/advanced.js.map +1 -1
  35. package/dist/tools/balance.d.ts +1 -1
  36. package/dist/tools/balance.d.ts.map +1 -1
  37. package/dist/tools/balance.js +97 -103
  38. package/dist/tools/balance.js.map +1 -1
  39. package/dist/tools/contract-info.d.ts +3 -3
  40. package/dist/tools/contract-info.d.ts.map +1 -1
  41. package/dist/tools/contract-info.js +150 -161
  42. package/dist/tools/contract-info.js.map +1 -1
  43. package/dist/tools/contract.d.ts +4 -4
  44. package/dist/tools/contract.d.ts.map +1 -1
  45. package/dist/tools/contract.js +91 -110
  46. package/dist/tools/contract.js.map +1 -1
  47. package/dist/tools/ens.d.ts +6 -6
  48. package/dist/tools/ens.d.ts.map +1 -1
  49. package/dist/tools/ens.js +239 -297
  50. package/dist/tools/ens.js.map +1 -1
  51. package/dist/tools/gas.d.ts +4 -4
  52. package/dist/tools/gas.d.ts.map +1 -1
  53. package/dist/tools/gas.js +90 -138
  54. package/dist/tools/gas.js.map +1 -1
  55. package/dist/tools/index.d.ts +1 -1
  56. package/dist/tools/index.d.ts.map +1 -1
  57. package/dist/tools/logs.d.ts +1 -1
  58. package/dist/tools/logs.d.ts.map +1 -1
  59. package/dist/tools/logs.js +36 -37
  60. package/dist/tools/logs.js.map +1 -1
  61. package/dist/tools/signatures.d.ts.map +1 -1
  62. package/dist/tools/signatures.js +67 -89
  63. package/dist/tools/signatures.js.map +1 -1
  64. package/dist/tools/transactions.d.ts +35 -5
  65. package/dist/tools/transactions.d.ts.map +1 -1
  66. package/dist/tools/transactions.js +148 -74
  67. package/dist/tools/transactions.js.map +1 -1
  68. package/dist/types.d.ts +1 -1
  69. package/dist/types.d.ts.map +1 -1
  70. package/dist/utils.d.ts +1 -1
  71. package/dist/utils.d.ts.map +1 -1
  72. package/dist/utils.js +35 -98
  73. package/dist/utils.js.map +1 -1
  74. package/dist/wallet-client.d.ts +11 -1
  75. package/dist/wallet-client.d.ts.map +1 -1
  76. package/dist/wallet-client.js +27 -9
  77. package/dist/wallet-client.js.map +1 -1
  78. package/dist/walletconnect.d.ts +22 -1
  79. package/dist/walletconnect.d.ts.map +1 -1
  80. package/dist/walletconnect.js +101 -8
  81. package/dist/walletconnect.js.map +1 -1
  82. package/package.json +17 -9
  83. package/src/anvil.ts +15 -18
  84. package/src/chain-meta.ts +8 -4
  85. package/src/chains.ts +145 -0
  86. package/src/client.ts +39 -254
  87. package/src/http-server.ts +67 -44
  88. package/src/index.ts +77 -53
  89. package/src/oauth.ts +270 -0
  90. package/src/preview.ts +3 -12
  91. package/src/tools/advanced.ts +195 -213
  92. package/src/tools/balance.ts +103 -108
  93. package/src/tools/contract-info.ts +163 -184
  94. package/src/tools/contract.ts +123 -144
  95. package/src/tools/ens.ts +290 -345
  96. package/src/tools/gas.ts +122 -170
  97. package/src/tools/index.ts +2 -2
  98. package/src/tools/logs.ts +45 -43
  99. package/src/tools/signatures.ts +68 -86
  100. package/src/tools/transactions.ts +194 -87
  101. package/src/types.ts +1 -15
  102. package/src/utils.ts +52 -110
  103. package/src/wallet-client.ts +31 -9
  104. package/src/walletconnect.ts +119 -7
package/src/chain-meta.ts CHANGED
@@ -3,8 +3,9 @@
3
3
  * human labels for addresses. Kept apart from the preview and the clear-signing registry
4
4
  * so both can use it without importing each other.
5
5
  */
6
- import { type Abi, type Address, parseAbiItem } from 'viem'
6
+
7
7
  import { whatsabi } from '@shazow/whatsabi'
8
+ import { type Abi, type Address, parseAbiItem } from 'viem'
8
9
  import { getClientManager } from './client.js'
9
10
  import type { ChainName } from './types.js'
10
11
 
@@ -26,7 +27,9 @@ export async function loadAbi(chain: ChainName, address: string) {
26
27
  const client = clientManager.getClient(chain)
27
28
  const etherscanApiKey = clientManager.getConfig().etherscanApiKey
28
29
 
29
- const loaders: whatsabi.loaders.ABILoader[] = [new whatsabi.loaders.SourcifyABILoader({ chainId: clientManager.getChainId(chain) })]
30
+ const loaders: whatsabi.loaders.ABILoader[] = [
31
+ new whatsabi.loaders.SourcifyABILoader({ chainId: clientManager.getChainId(chain) })
32
+ ]
30
33
  if (etherscanApiKey) {
31
34
  loaders.push(new whatsabi.loaders.EtherscanV2ABILoader({ apiKey: etherscanApiKey, chainId: clientManager.getChainId(chain) }))
32
35
  }
@@ -83,7 +86,9 @@ export async function addressLabel(chain: ChainName, address: string): Promise<s
83
86
 
84
87
  let label: string | undefined
85
88
  try {
86
- const code = await getClientManager().getClient(chain).getBytecode({ address: address as Address })
89
+ const code = await getClientManager()
90
+ .getClient(chain)
91
+ .getBytecode({ address: address as Address })
87
92
  if (code && code !== '0x') {
88
93
  label = (await tokenMeta(chain, address).catch(() => ({ symbol: undefined }))).symbol
89
94
  if (!label) label = (await loadAbi(chain, address)).name
@@ -96,7 +101,6 @@ export async function addressLabel(chain: ChainName, address: string): Promise<s
96
101
  return label
97
102
  }
98
103
 
99
-
100
104
  /** uint256 max and anything near it means "infinite" in an approval. */
101
105
  export const MAX_UINT256 = (1n << 256n) - 1n
102
106
  export const UNLIMITED_THRESHOLD = MAX_UINT256 / 2n
package/src/chains.ts ADDED
@@ -0,0 +1,145 @@
1
+ import type { Chain } from 'viem'
2
+ import {
3
+ arbitrum,
4
+ avalanche,
5
+ base,
6
+ bsc,
7
+ gnosis,
8
+ linea,
9
+ localhost,
10
+ mainnet,
11
+ optimism,
12
+ polygon,
13
+ sonic,
14
+ unichain,
15
+ zksync
16
+ } from 'viem/chains'
17
+
18
+ /**
19
+ * Every chain this server knows, in one table. Adding one here is the whole change: the
20
+ * `ChainName` union, the tool schemas, RPC selection, explorer links, Hypersync routing and
21
+ * the `--help` listing are all derived from it.
22
+ */
23
+ interface ChainInfo {
24
+ chain: Chain
25
+ /** Subdomain under g.alchemy.com; absent where Alchemy has no endpoint. */
26
+ alchemy?: string
27
+ /** Subdomain under infura.io; absent where Infura has no endpoint. */
28
+ infura?: string
29
+ /** Used when no provider key is configured. */
30
+ fallback: string
31
+ /** Block explorer domain, for links. Absent where there is nothing to link to. */
32
+ explorer?: string
33
+ hypersync?: string
34
+ }
35
+
36
+ const TABLE = {
37
+ mainnet: {
38
+ chain: mainnet,
39
+ alchemy: 'eth-mainnet',
40
+ infura: 'mainnet',
41
+ fallback: 'https://ethereum-rpc.publicnode.com',
42
+ explorer: 'etherscan.io',
43
+ hypersync: 'https://eth.hypersync.xyz'
44
+ },
45
+ arbitrum: {
46
+ chain: arbitrum,
47
+ alchemy: 'arb-mainnet',
48
+ infura: 'arbitrum-mainnet',
49
+ fallback: 'https://arb1.arbitrum.io/rpc',
50
+ explorer: 'arbiscan.io',
51
+ hypersync: 'https://arbitrum.hypersync.xyz'
52
+ },
53
+ avalanche: {
54
+ chain: avalanche,
55
+ alchemy: 'avax-mainnet',
56
+ infura: 'avalanche-mainnet',
57
+ fallback: 'https://api.avax.network/ext/bc/C/rpc',
58
+ explorer: 'snowtrace.io',
59
+ hypersync: 'https://avalanche.hypersync.xyz'
60
+ },
61
+ base: {
62
+ chain: base,
63
+ alchemy: 'base-mainnet',
64
+ // publicnode refuses receipts and anything else it calls an archive request
65
+ fallback: 'https://mainnet.base.org',
66
+ explorer: 'basescan.org',
67
+ hypersync: 'https://base.hypersync.xyz'
68
+ },
69
+ bnb: {
70
+ chain: bsc,
71
+ alchemy: 'bnb-mainnet',
72
+ infura: 'bsc-mainnet',
73
+ fallback: 'https://bsc-dataseed.bnbchain.org',
74
+ explorer: 'bscscan.com',
75
+ hypersync: 'https://bsc.hypersync.xyz'
76
+ },
77
+ gnosis: {
78
+ chain: gnosis,
79
+ alchemy: 'gnosis-mainnet',
80
+ fallback: 'https://rpc.gnosischain.com',
81
+ explorer: 'gnosisscan.io',
82
+ hypersync: 'https://gnosis.hypersync.xyz'
83
+ },
84
+ sonic: {
85
+ chain: sonic,
86
+ alchemy: 'sonic-mainnet',
87
+ fallback: 'https://rpc.soniclabs.com',
88
+ explorer: 'sonicscan.org',
89
+ hypersync: 'https://sonic.hypersync.xyz'
90
+ },
91
+ optimism: {
92
+ chain: optimism,
93
+ alchemy: 'opt-mainnet',
94
+ infura: 'optimism-mainnet',
95
+ fallback: 'https://mainnet.optimism.io',
96
+ explorer: 'optimistic.etherscan.io',
97
+ hypersync: 'https://optimism.hypersync.xyz'
98
+ },
99
+ polygon: {
100
+ chain: polygon,
101
+ alchemy: 'polygon-mainnet',
102
+ infura: 'polygon-mainnet',
103
+ // polygon-rpc.com now answers 401 "API key disabled" to anonymous callers
104
+ fallback: 'https://polygon-bor-rpc.publicnode.com',
105
+ explorer: 'polygonscan.com',
106
+ hypersync: 'https://polygon.hypersync.xyz'
107
+ },
108
+ zksync: {
109
+ chain: zksync,
110
+ alchemy: 'zksync-mainnet',
111
+ infura: 'zksync-mainnet',
112
+ fallback: 'https://mainnet.era.zksync.io',
113
+ explorer: 'era.zksync.network',
114
+ hypersync: 'https://zksync.hypersync.xyz'
115
+ },
116
+ linea: {
117
+ chain: linea,
118
+ alchemy: 'linea-mainnet',
119
+ infura: 'linea-mainnet',
120
+ fallback: 'https://rpc.linea.build',
121
+ explorer: 'lineascan.build',
122
+ hypersync: 'https://linea.hypersync.xyz'
123
+ },
124
+ unichain: {
125
+ chain: unichain,
126
+ alchemy: 'unichain-mainnet',
127
+ infura: 'unichain-mainnet',
128
+ fallback: 'https://mainnet.unichain.org',
129
+ explorer: 'uniscan.xyz',
130
+ hypersync: 'https://unichain.hypersync.xyz'
131
+ },
132
+ localhost: {
133
+ chain: localhost,
134
+ fallback: 'http://localhost:8545'
135
+ }
136
+ } as const satisfies Record<string, ChainInfo>
137
+
138
+ export type ChainName = keyof typeof TABLE
139
+
140
+ // Widened: `as const` above is only there to infer ChainName from the keys, and reading a
141
+ // literal entry would otherwise lose the fields that not every chain sets.
142
+ export const CHAINS: Record<ChainName, ChainInfo> = TABLE
143
+
144
+ /** Tuple rather than array, because `z.enum` needs a non-empty literal list. */
145
+ export const SUPPORTED_CHAINS = Object.keys(CHAINS) as [ChainName, ...ChainName[]]
package/src/client.ts CHANGED
@@ -1,280 +1,65 @@
1
- import { http, createPublicClient } from 'viem'
2
- import {
3
- arbitrum,
4
- avalanche,
5
- base,
6
- bsc,
7
- gnosis,
8
- linea,
9
- localhost,
10
- mainnet,
11
- optimism,
12
- polygon,
13
- sonic,
14
- unichain,
15
- zksync
16
- } from 'viem/chains'
17
- import type { ChainName, Config } from './types.js'
1
+ import { createPublicClient, http } from 'viem'
2
+ import { CHAINS, type ChainName } from './chains.js'
3
+ import type { Config } from './types.js'
18
4
 
19
- // Enhanced RPC configuration with API keys
20
- function getRpcUrl(chainName: string, config: Config): string {
21
- // Check for custom RPC URLs first
22
- if (config.customRpcUrls?.[chainName]) {
23
- return config.customRpcUrls[chainName]
24
- }
25
-
26
- // Enhanced RPC URLs with API keys
27
- const enhancedRpcs: Record<string, string> = {
28
- mainnet: config.alchemyApiKey
29
- ? `https://eth-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`
30
- : config.infuraApiKey
31
- ? `https://mainnet.infura.io/v3/${config.infuraApiKey}`
32
- : 'https://ethereum-rpc.publicnode.com',
33
-
34
- base: config.alchemyApiKey
35
- ? `https://base-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`
36
- // publicnode refuses receipts and anything else it calls an archive request
37
- : 'https://mainnet.base.org',
38
-
39
- arbitrum: config.alchemyApiKey
40
- ? `https://arb-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`
41
- : config.infuraApiKey
42
- ? `https://arbitrum-mainnet.infura.io/v3/${config.infuraApiKey}`
43
- : 'https://arb1.arbitrum.io/rpc',
44
-
45
- polygon: config.alchemyApiKey
46
- ? `https://polygon-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`
47
- : config.infuraApiKey
48
- ? `https://polygon-mainnet.infura.io/v3/${config.infuraApiKey}`
49
- // polygon-rpc.com now answers 401 "API key disabled" to anonymous callers
50
- : 'https://polygon-bor-rpc.publicnode.com',
51
-
52
- optimism: config.alchemyApiKey
53
- ? `https://opt-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`
54
- : config.infuraApiKey
55
- ? `https://optimism-mainnet.infura.io/v3/${config.infuraApiKey}`
56
- : 'https://mainnet.optimism.io',
57
-
58
- avalanche: config.alchemyApiKey
59
- ? `https://avax-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`
60
- : config.infuraApiKey
61
- ? `https://avalanche-mainnet.infura.io/v3/${config.infuraApiKey}`
62
- : 'https://api.avax.network/ext/bc/C/rpc',
5
+ export { SUPPORTED_CHAINS } from './chains.js'
63
6
 
64
- bnb: config.alchemyApiKey
65
- ? `https://bnb-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`
66
- : config.infuraApiKey
67
- ? `https://bsc-mainnet.infura.io/v3/${config.infuraApiKey}`
68
- : 'https://bsc-dataseed.bnbchain.org',
7
+ /** Custom URL first, then whichever provider key is configured, then the public endpoint. */
8
+ function getRpcUrl(chainName: ChainName, config: Config): string {
9
+ const custom = config.customRpcUrls?.[chainName]
10
+ if (custom) return custom
69
11
 
70
- gnosis: config.alchemyApiKey
71
- ? `https://gnosis-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`
72
- : 'https://rpc.gnosischain.com',
73
-
74
- sonic: config.alchemyApiKey
75
- ? `https://sonic-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`
76
- : 'https://rpc.soniclabs.com',
77
-
78
- zksync: config.alchemyApiKey
79
- ? `https://zksync-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`
80
- : config.infuraApiKey
81
- ? `https://zksync-mainnet.infura.io/v3/${config.infuraApiKey}`
82
- : 'https://mainnet.era.zksync.io',
83
-
84
- linea: config.alchemyApiKey
85
- ? `https://linea-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`
86
- : config.infuraApiKey
87
- ? `https://linea-mainnet.infura.io/v3/${config.infuraApiKey}`
88
- : 'https://rpc.linea.build',
89
-
90
- unichain: config.alchemyApiKey
91
- ? `https://unichain-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`
92
- : config.infuraApiKey
93
- ? `https://unichain-mainnet.infura.io/v3/${config.infuraApiKey}`
94
- : 'https://sepolia.unichain.org',
95
-
96
- localhost: 'http://localhost:8545'
97
- }
98
-
99
- return enhancedRpcs[chainName] ?? 'https://eth.llamarpc.com'
12
+ const { alchemy, infura, fallback } = CHAINS[chainName]
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
+ return fallback
100
16
  }
101
17
 
102
- // Supported chains with their RPC endpoints (now dynamic)
103
- export const CHAINS = (config: Config) => ({
104
- mainnet: {
105
- chain: mainnet,
106
- get rpc() {
107
- return getRpcUrl('mainnet', config)
108
- }
109
- },
110
- base: {
111
- chain: base,
112
- get rpc() {
113
- return getRpcUrl('base', config)
114
- }
115
- },
116
- arbitrum: {
117
- chain: arbitrum,
118
- get rpc() {
119
- return getRpcUrl('arbitrum', config)
120
- }
121
- },
122
- polygon: {
123
- chain: polygon,
124
- get rpc() {
125
- return getRpcUrl('polygon', config)
126
- }
127
- },
128
- optimism: {
129
- chain: optimism,
130
- get rpc() {
131
- return getRpcUrl('optimism', config)
132
- }
133
- },
134
- avalanche: {
135
- chain: avalanche,
136
- get rpc() {
137
- return getRpcUrl('avalanche', config)
138
- }
139
- },
140
- bnb: {
141
- chain: bsc,
142
- get rpc() {
143
- return getRpcUrl('bnb', config)
144
- }
145
- },
146
- gnosis: {
147
- chain: gnosis,
148
- get rpc() {
149
- return getRpcUrl('gnosis', config)
150
- }
151
- },
152
- sonic: {
153
- chain: sonic,
154
- get rpc() {
155
- return getRpcUrl('sonic', config)
156
- }
157
- },
158
- zksync: {
159
- chain: zksync,
160
- get rpc() {
161
- return getRpcUrl('zksync', config)
162
- }
163
- },
164
- linea: {
165
- chain: linea,
166
- get rpc() {
167
- return getRpcUrl('linea', config)
168
- }
169
- },
170
- unichain: {
171
- chain: unichain,
172
- get rpc() {
173
- return getRpcUrl('unichain', config)
174
- }
175
- },
176
- localhost: {
177
- chain: localhost,
178
- get rpc() {
179
- return getRpcUrl('localhost', config)
180
- }
181
- }
182
- })
18
+ /** Named so declaration emit can refer to it: node_modules is hoisted above this package. */
19
+ type PublicClient = ReturnType<typeof createPublicClient>
183
20
 
184
- // Hypersync URLs for supported chains (localhost is not supported)
185
- export const HYPERSYNC_URLS: Partial<Record<ChainName, string>> = {
186
- mainnet: 'https://eth.hypersync.xyz', // Chain ID 1
187
- arbitrum: 'https://arbitrum.hypersync.xyz', // Chain ID 42161
188
- avalanche: 'https://avalanche.hypersync.xyz', // Chain ID 43114
189
- base: 'https://base.hypersync.xyz', // Chain ID 8453
190
- bnb: 'https://bsc.hypersync.xyz', // Chain ID 56
191
- gnosis: 'https://gnosis.hypersync.xyz', // Chain ID 100
192
- sonic: 'https://sonic.hypersync.xyz', // Chain ID 146
193
- optimism: 'https://optimism.hypersync.xyz', // Chain ID 10
194
- polygon: 'https://polygon.hypersync.xyz', // Chain ID 137
195
- zksync: 'https://zksync.hypersync.xyz', // Chain ID 324
196
- linea: 'https://linea.hypersync.xyz', // Chain ID 59144
197
- unichain: 'https://unichain.hypersync.xyz' // Chain ID 130
198
- // localhost is not supported by hypersync
199
- }
21
+ export class ClientManager {
22
+ private clients = new Map<ChainName, PublicClient>()
200
23
 
201
- export const SUPPORTED_CHAINS = [
202
- 'mainnet',
203
- 'arbitrum',
204
- 'avalanche',
205
- 'base',
206
- 'bnb',
207
- 'gnosis',
208
- 'sonic',
209
- 'optimism',
210
- 'polygon',
211
- 'zksync',
212
- 'linea',
213
- 'unichain',
214
- 'localhost'
215
- ] as const
24
+ constructor(private config: Config) {}
216
25
 
217
- // Client manager
218
- export class ClientManager {
219
- private clients: Map<ChainName, unknown> = new Map()
220
- private config: Config
26
+ getClient(chainName: ChainName): PublicClient {
27
+ const cached = this.clients.get(chainName)
28
+ if (cached) return cached
221
29
 
222
- constructor(config: Config) {
223
- this.config = config
224
- }
30
+ // Cast because a client built for a concrete chain is not assignable to the generic one.
31
+ const client = createPublicClient({
32
+ chain: CHAINS[chainName].chain,
33
+ transport: http(this.getRpcUrl(chainName))
34
+ }) as PublicClient
225
35
 
226
- getClient(chainName: ChainName) {
227
- if (!this.clients.has(chainName)) {
228
- const chains = CHAINS(this.config)
229
- const { chain, rpc } = chains[chainName]
230
- const client = createPublicClient({
231
- chain,
232
- transport: http(rpc)
233
- })
234
- this.clients.set(chainName, client)
235
- }
236
- const client = this.clients.get(chainName)
237
- if (!client) {
238
- throw new Error(`Failed to create client for chain: ${chainName}`)
239
- }
240
- return client as ReturnType<typeof createPublicClient>
36
+ this.clients.set(chainName, client)
37
+ return client
241
38
  }
242
39
 
243
40
  getConfig(): Config {
244
41
  return this.config
245
42
  }
246
43
 
247
- getEtherscanDomain(chainName: ChainName): string {
248
- const domains: Record<ChainName, string> = {
249
- mainnet: 'etherscan.io',
250
- base: 'basescan.org',
251
- arbitrum: 'arbiscan.io',
252
- polygon: 'polygonscan.com',
253
- optimism: 'optimistic.etherscan.io',
254
- avalanche: 'snowtrace.io',
255
- bnb: 'bscscan.com',
256
- gnosis: 'gnosisscan.io',
257
- sonic: 'sonicscan.org',
258
- zksync: 'era.zksync.network',
259
- linea: 'lineascan.build',
260
- unichain: 'uniscan.xyz',
261
- localhost: 'etherscan.io'
262
- }
263
- return domains[chainName] || 'etherscan.io'
264
- }
265
-
266
44
  getRpcUrl(chainName: ChainName): string {
267
- const chains = CHAINS(this.config)
268
- return chains[chainName].rpc
45
+ return getRpcUrl(chainName, this.config)
269
46
  }
270
47
 
271
48
  getChainId(chainName: ChainName): number {
272
- const chains = CHAINS(this.config)
273
- return chains[chainName].chain.id
49
+ return CHAINS[chainName].chain.id
50
+ }
51
+
52
+ getHypersyncUrl(chainName: ChainName): string | undefined {
53
+ return CHAINS[chainName].hypersync
54
+ }
55
+
56
+ /** Undefined where the chain has no explorer — a local node, for one. */
57
+ explorerUrl(chainName: ChainName, path = ''): string | undefined {
58
+ const domain = CHAINS[chainName].explorer
59
+ return domain ? `https://${domain}${path}` : undefined
274
60
  }
275
61
  }
276
62
 
277
- // Export a global client manager instance
278
63
  let clientManager: ClientManager | null = null
279
64
 
280
65
  export function initializeClientManager(config: Config): ClientManager {
@@ -1,12 +1,20 @@
1
- import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
2
- import { timingSafeEqual } from 'node:crypto'
3
- import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
1
+ import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js'
2
+ import { getOAuthProtectedResourceMetadataUrl, mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js'
4
3
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
4
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
5
+ import express from 'express'
6
+ import type { WalletRelay } from 'web3-wallet-relay'
7
+ import { SingleUserOAuthProvider } from './oauth.js'
5
8
 
6
9
  /**
7
10
  * Serve the MCP over HTTP instead of stdio, so the server can live somewhere other than the
8
- * machine running the agent. Only worth doing alongside WalletConnect: a hosted server has
9
- * no browser to open, so a phone wallet is the only way it can have anything signed.
11
+ * machine running the agent. Signing still reaches you: a paired phone over WalletConnect,
12
+ * or the signing page, which is mounted on this same server so one platform port carries
13
+ * both it and /mcp.
14
+ *
15
+ * Two ways in, because clients differ: a static `Authorization: Bearer <MCP_TOKEN>` header
16
+ * (Claude Code), and the OAuth flow the MCP spec defines (claude.ai connectors, which offer
17
+ * no way to send a header). Both end up at the same single credential.
10
18
  *
11
19
  * Stateless — a server per request — because every tool here is a one-shot call and the
12
20
  * signing state lives in the WalletConnect session, not in the transport.
@@ -14,28 +22,20 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
14
22
 
15
23
  const DEFAULT_PORT = 3457
16
24
 
17
- function unauthorized(res: ServerResponse) {
18
- res.writeHead(401, { 'content-type': 'application/json', 'www-authenticate': 'Bearer' })
19
- res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32001, message: 'Unauthorized' } }))
20
- }
21
-
22
- /** Constant-time compare, so the token cannot be guessed a character at a time. */
23
- function tokenMatches(presented: string, expected: string): boolean {
24
- const a = Buffer.from(presented)
25
- const b = Buffer.from(expected)
26
- return a.length === b.length && timingSafeEqual(a, b)
27
- }
28
-
29
25
  export interface HttpServerOptions {
30
26
  port?: number
31
27
  host?: string
32
28
  token?: string
29
+ /** Public URL clients reach this server on; OAuth metadata must advertise it. */
30
+ publicUrl?: string
31
+ /** Serve the signing page on this same server, so browser signing works when hosted. */
32
+ walletRelay?: WalletRelay
33
33
  createMcpServer: () => McpServer
34
34
  }
35
35
 
36
- export async function startHttpServer(options: HttpServerOptions): Promise<{ url: string; port: number }> {
36
+ export async function startHttpServer(options: HttpServerOptions): Promise<{ url: string; port: number; walletUrl?: string }> {
37
37
  const port = options.port ?? Number(process.env.MCP_HTTP_PORT) ?? DEFAULT_PORT
38
- const host = options.host ?? process.env.HOST ?? (process.env.MCP_HTTP_HOST ? process.env.MCP_HTTP_HOST : '0.0.0.0')
38
+ const host = options.host ?? process.env.MCP_HTTP_HOST ?? '0.0.0.0'
39
39
  const token = options.token ?? process.env.MCP_TOKEN
40
40
 
41
41
  // A reachable MCP server with a paired phone can push signing prompts at that phone. It
@@ -44,24 +44,41 @@ export async function startHttpServer(options: HttpServerOptions): Promise<{ url
44
44
  throw new Error('MCP_TOKEN is required to serve MCP over HTTP — anyone reaching the URL could request signatures')
45
45
  }
46
46
 
47
- const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
48
- if (req.url === '/health') {
49
- res.writeHead(200, { 'content-type': 'application/json' })
50
- res.end(JSON.stringify({ status: 'ok' }))
51
- return
52
- }
47
+ const publicUrl = options.publicUrl ?? process.env.MCP_PUBLIC_URL ?? `http://localhost:${port}`
48
+ const issuer = new URL(publicUrl)
49
+ const provider = new SingleUserOAuthProvider(token)
53
50
 
54
- if (!req.url?.startsWith('/mcp')) {
55
- res.writeHead(404).end()
56
- return
57
- }
51
+ const app = express()
52
+ app.use(express.json())
53
+ // The login form posts a token, so the authorize route needs form bodies too.
54
+ app.use(express.urlencoded({ extended: false }))
58
55
 
59
- const presented = req.headers.authorization?.replace(/^Bearer /i, '') ?? ''
60
- if (!tokenMatches(presented, token)) {
61
- unauthorized(res)
62
- return
63
- }
56
+ app.get('/health', (_req, res) => {
57
+ res.json({ status: 'ok' })
58
+ })
64
59
 
60
+ // /authorize, /token, /register, /revoke and the metadata documents a client discovers
61
+ // after a 401.
62
+ app.use(
63
+ mcpAuthRouter({
64
+ provider,
65
+ issuerUrl: issuer,
66
+ baseUrl: issuer,
67
+ resourceServerUrl: new URL('/mcp', issuer),
68
+ resourceName: 'web3-tools-mcp',
69
+ scopesSupported: []
70
+ })
71
+ )
72
+
73
+ // The metadata document is mounted under the resource path, so the 401 has to advertise
74
+ // that exact URL — a bare /.well-known/oauth-protected-resource 404s, and a client that
75
+ // follows it gives up right where discovery should have started.
76
+ const authenticate = requireBearerAuth({
77
+ verifier: provider,
78
+ resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(new URL('/mcp', issuer))
79
+ })
80
+
81
+ app.all('/mcp', authenticate, async (req, res) => {
65
82
  const mcp = options.createMcpServer()
66
83
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true })
67
84
 
@@ -72,23 +89,29 @@ export async function startHttpServer(options: HttpServerOptions): Promise<{ url
72
89
 
73
90
  try {
74
91
  await mcp.connect(transport)
75
- await transport.handleRequest(req, res)
92
+ await transport.handleRequest(req, res, req.body)
76
93
  } catch (error) {
77
94
  console.error('[MCP] Request failed:', error)
78
95
  if (!res.headersSent) {
79
- res.writeHead(500, { 'content-type': 'application/json' })
80
- res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal error' } }))
96
+ res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal error' } })
81
97
  }
82
98
  }
83
99
  })
84
100
 
85
- await new Promise<void>((resolve, reject) => {
86
- server.once('error', reject)
87
- server.listen(port, host, () => {
88
- server.removeListener('error', reject)
89
- resolve()
90
- })
101
+ const server = await new Promise<ReturnType<typeof app.listen>>((resolve, reject) => {
102
+ const listening = app.listen(port, host, () => resolve(listening))
103
+ listening.once('error', reject)
91
104
  })
92
105
 
93
- return { url: `http://${host}:${port}/mcp`, port }
106
+ // The signing page rides on this same server, so a hosted deployment can offer browser
107
+ // signing without a second service: one platform port serves both /mcp and the page.
108
+ // Mounted last, so the routes above keep their paths — the relay only claims what is
109
+ // left, which is the static page at the root.
110
+ let walletUrl: string | undefined
111
+ if (options.walletRelay) {
112
+ options.walletRelay.attach(server, (relayApp) => app.use(relayApp))
113
+ walletUrl = `${publicUrl.replace(/\/$/, '')}/#t=${options.walletRelay.token}`
114
+ }
115
+
116
+ return { url: `${publicUrl.replace(/\/$/, '')}/mcp`, port: (server.address() as { port: number }).port, walletUrl }
94
117
  }