web3-tools-mcp 1.5.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.
- package/CHANGELOG.md +95 -0
- package/README.md +346 -164
- package/dist/anvil.d.ts +1 -1
- package/dist/anvil.d.ts.map +1 -1
- package/dist/anvil.js +11 -9
- package/dist/anvil.js.map +1 -1
- package/dist/chain-meta.d.ts.map +1 -1
- package/dist/chain-meta.js +7 -3
- package/dist/chain-meta.js.map +1 -1
- package/dist/chains.d.ts +960 -0
- package/dist/chains.d.ts.map +1 -0
- package/dist/chains.js +108 -0
- package/dist/chains.js.map +1 -0
- package/dist/client.d.ts +10 -1717
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +33 -216
- package/dist/client.js.map +1 -1
- package/dist/http-server.d.ts +18 -0
- package/dist/http-server.d.ts.map +1 -0
- package/dist/http-server.js +89 -0
- package/dist/http-server.js.map +1 -0
- package/dist/index.js +77 -32
- package/dist/index.js.map +1 -1
- package/dist/kv-storage.d.ts +23 -0
- package/dist/kv-storage.d.ts.map +1 -0
- package/dist/kv-storage.js +69 -0
- package/dist/kv-storage.js.map +1 -0
- package/dist/oauth.d.ts +31 -0
- package/dist/oauth.d.ts.map +1 -0
- package/dist/oauth.js +227 -0
- package/dist/oauth.js.map +1 -0
- package/dist/preview.d.ts.map +1 -1
- package/dist/preview.js +14 -4
- package/dist/preview.js.map +1 -1
- package/dist/tools/advanced.d.ts +4 -4
- package/dist/tools/advanced.d.ts.map +1 -1
- package/dist/tools/advanced.js +228 -247
- package/dist/tools/advanced.js.map +1 -1
- package/dist/tools/balance.d.ts +1 -1
- package/dist/tools/balance.d.ts.map +1 -1
- package/dist/tools/balance.js +97 -103
- package/dist/tools/balance.js.map +1 -1
- package/dist/tools/contract-info.d.ts +3 -3
- package/dist/tools/contract-info.d.ts.map +1 -1
- package/dist/tools/contract-info.js +150 -161
- package/dist/tools/contract-info.js.map +1 -1
- package/dist/tools/contract.d.ts +4 -4
- package/dist/tools/contract.d.ts.map +1 -1
- package/dist/tools/contract.js +91 -110
- package/dist/tools/contract.js.map +1 -1
- package/dist/tools/ens.d.ts +6 -6
- package/dist/tools/ens.d.ts.map +1 -1
- package/dist/tools/ens.js +239 -297
- package/dist/tools/ens.js.map +1 -1
- package/dist/tools/gas.d.ts +4 -4
- package/dist/tools/gas.d.ts.map +1 -1
- package/dist/tools/gas.js +90 -138
- package/dist/tools/gas.js.map +1 -1
- package/dist/tools/index.d.ts +1 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/logs.d.ts +1 -1
- package/dist/tools/logs.d.ts.map +1 -1
- package/dist/tools/logs.js +36 -37
- package/dist/tools/logs.js.map +1 -1
- package/dist/tools/signatures.d.ts.map +1 -1
- package/dist/tools/signatures.js +67 -89
- package/dist/tools/signatures.js.map +1 -1
- package/dist/tools/transactions.d.ts +48 -4
- package/dist/tools/transactions.d.ts.map +1 -1
- package/dist/tools/transactions.js +228 -44
- package/dist/tools/transactions.js.map +1 -1
- package/dist/types.d.ts +2 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts +3 -8
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +36 -98
- package/dist/utils.js.map +1 -1
- package/dist/wallet-client.d.ts +11 -1
- package/dist/wallet-client.d.ts.map +1 -1
- package/dist/wallet-client.js +27 -9
- package/dist/wallet-client.js.map +1 -1
- package/dist/walletconnect.d.ts +52 -0
- package/dist/walletconnect.d.ts.map +1 -0
- package/dist/walletconnect.js +221 -0
- package/dist/walletconnect.js.map +1 -0
- package/package.json +21 -9
- package/src/anvil.ts +15 -18
- package/src/chain-meta.ts +8 -4
- package/src/chains.ts +145 -0
- package/src/client.ts +39 -252
- package/src/http-server.ts +117 -0
- package/src/index.ts +98 -49
- package/src/kv-storage.ts +83 -0
- package/src/oauth.ts +270 -0
- package/src/preview.ts +15 -14
- package/src/tools/advanced.ts +195 -213
- package/src/tools/balance.ts +103 -108
- package/src/tools/contract-info.ts +163 -184
- package/src/tools/contract.ts +123 -144
- package/src/tools/ens.ts +290 -345
- package/src/tools/gas.ts +122 -170
- package/src/tools/index.ts +2 -2
- package/src/tools/logs.ts +45 -43
- package/src/tools/signatures.ts +68 -86
- package/src/tools/transactions.ts +283 -50
- package/src/types.ts +2 -15
- package/src/utils.ts +56 -127
- package/src/wallet-client.ts +31 -9
- package/src/walletconnect.ts +269 -0
package/src/client.ts
CHANGED
|
@@ -1,278 +1,65 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
: 'https://base-rpc.publicnode.com',
|
|
37
|
-
|
|
38
|
-
arbitrum: config.alchemyApiKey
|
|
39
|
-
? `https://arb-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`
|
|
40
|
-
: config.infuraApiKey
|
|
41
|
-
? `https://arbitrum-mainnet.infura.io/v3/${config.infuraApiKey}`
|
|
42
|
-
: 'https://arb1.arbitrum.io/rpc',
|
|
43
|
-
|
|
44
|
-
polygon: config.alchemyApiKey
|
|
45
|
-
? `https://polygon-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`
|
|
46
|
-
: config.infuraApiKey
|
|
47
|
-
? `https://polygon-mainnet.infura.io/v3/${config.infuraApiKey}`
|
|
48
|
-
: 'https://polygon-rpc.com',
|
|
49
|
-
|
|
50
|
-
optimism: config.alchemyApiKey
|
|
51
|
-
? `https://opt-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`
|
|
52
|
-
: config.infuraApiKey
|
|
53
|
-
? `https://optimism-mainnet.infura.io/v3/${config.infuraApiKey}`
|
|
54
|
-
: 'https://mainnet.optimism.io',
|
|
55
|
-
|
|
56
|
-
avalanche: config.alchemyApiKey
|
|
57
|
-
? `https://avax-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`
|
|
58
|
-
: config.infuraApiKey
|
|
59
|
-
? `https://avalanche-mainnet.infura.io/v3/${config.infuraApiKey}`
|
|
60
|
-
: 'https://api.avax.network/ext/bc/C/rpc',
|
|
5
|
+
export { SUPPORTED_CHAINS } from './chains.js'
|
|
61
6
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
: '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
|
|
67
11
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
sonic: config.alchemyApiKey
|
|
73
|
-
? `https://sonic-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`
|
|
74
|
-
: 'https://rpc.soniclabs.com',
|
|
75
|
-
|
|
76
|
-
zksync: config.alchemyApiKey
|
|
77
|
-
? `https://zksync-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`
|
|
78
|
-
: config.infuraApiKey
|
|
79
|
-
? `https://zksync-mainnet.infura.io/v3/${config.infuraApiKey}`
|
|
80
|
-
: 'https://mainnet.era.zksync.io',
|
|
81
|
-
|
|
82
|
-
linea: config.alchemyApiKey
|
|
83
|
-
? `https://linea-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`
|
|
84
|
-
: config.infuraApiKey
|
|
85
|
-
? `https://linea-mainnet.infura.io/v3/${config.infuraApiKey}`
|
|
86
|
-
: 'https://rpc.linea.build',
|
|
87
|
-
|
|
88
|
-
unichain: config.alchemyApiKey
|
|
89
|
-
? `https://unichain-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`
|
|
90
|
-
: config.infuraApiKey
|
|
91
|
-
? `https://unichain-mainnet.infura.io/v3/${config.infuraApiKey}`
|
|
92
|
-
: 'https://sepolia.unichain.org',
|
|
93
|
-
|
|
94
|
-
localhost: 'http://localhost:8545'
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
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
|
|
98
16
|
}
|
|
99
17
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
mainnet: {
|
|
103
|
-
chain: mainnet,
|
|
104
|
-
get rpc() {
|
|
105
|
-
return getRpcUrl('mainnet', config)
|
|
106
|
-
}
|
|
107
|
-
},
|
|
108
|
-
base: {
|
|
109
|
-
chain: base,
|
|
110
|
-
get rpc() {
|
|
111
|
-
return getRpcUrl('base', config)
|
|
112
|
-
}
|
|
113
|
-
},
|
|
114
|
-
arbitrum: {
|
|
115
|
-
chain: arbitrum,
|
|
116
|
-
get rpc() {
|
|
117
|
-
return getRpcUrl('arbitrum', config)
|
|
118
|
-
}
|
|
119
|
-
},
|
|
120
|
-
polygon: {
|
|
121
|
-
chain: polygon,
|
|
122
|
-
get rpc() {
|
|
123
|
-
return getRpcUrl('polygon', config)
|
|
124
|
-
}
|
|
125
|
-
},
|
|
126
|
-
optimism: {
|
|
127
|
-
chain: optimism,
|
|
128
|
-
get rpc() {
|
|
129
|
-
return getRpcUrl('optimism', config)
|
|
130
|
-
}
|
|
131
|
-
},
|
|
132
|
-
avalanche: {
|
|
133
|
-
chain: avalanche,
|
|
134
|
-
get rpc() {
|
|
135
|
-
return getRpcUrl('avalanche', config)
|
|
136
|
-
}
|
|
137
|
-
},
|
|
138
|
-
bnb: {
|
|
139
|
-
chain: bsc,
|
|
140
|
-
get rpc() {
|
|
141
|
-
return getRpcUrl('bnb', config)
|
|
142
|
-
}
|
|
143
|
-
},
|
|
144
|
-
gnosis: {
|
|
145
|
-
chain: gnosis,
|
|
146
|
-
get rpc() {
|
|
147
|
-
return getRpcUrl('gnosis', config)
|
|
148
|
-
}
|
|
149
|
-
},
|
|
150
|
-
sonic: {
|
|
151
|
-
chain: sonic,
|
|
152
|
-
get rpc() {
|
|
153
|
-
return getRpcUrl('sonic', config)
|
|
154
|
-
}
|
|
155
|
-
},
|
|
156
|
-
zksync: {
|
|
157
|
-
chain: zksync,
|
|
158
|
-
get rpc() {
|
|
159
|
-
return getRpcUrl('zksync', config)
|
|
160
|
-
}
|
|
161
|
-
},
|
|
162
|
-
linea: {
|
|
163
|
-
chain: linea,
|
|
164
|
-
get rpc() {
|
|
165
|
-
return getRpcUrl('linea', config)
|
|
166
|
-
}
|
|
167
|
-
},
|
|
168
|
-
unichain: {
|
|
169
|
-
chain: unichain,
|
|
170
|
-
get rpc() {
|
|
171
|
-
return getRpcUrl('unichain', config)
|
|
172
|
-
}
|
|
173
|
-
},
|
|
174
|
-
localhost: {
|
|
175
|
-
chain: localhost,
|
|
176
|
-
get rpc() {
|
|
177
|
-
return getRpcUrl('localhost', config)
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
})
|
|
18
|
+
/** Named so declaration emit can refer to it: node_modules is hoisted above this package. */
|
|
19
|
+
type PublicClient = ReturnType<typeof createPublicClient>
|
|
181
20
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
mainnet: 'https://eth.hypersync.xyz', // Chain ID 1
|
|
185
|
-
arbitrum: 'https://arbitrum.hypersync.xyz', // Chain ID 42161
|
|
186
|
-
avalanche: 'https://avalanche.hypersync.xyz', // Chain ID 43114
|
|
187
|
-
base: 'https://base.hypersync.xyz', // Chain ID 8453
|
|
188
|
-
bnb: 'https://bsc.hypersync.xyz', // Chain ID 56
|
|
189
|
-
gnosis: 'https://gnosis.hypersync.xyz', // Chain ID 100
|
|
190
|
-
sonic: 'https://sonic.hypersync.xyz', // Chain ID 146
|
|
191
|
-
optimism: 'https://optimism.hypersync.xyz', // Chain ID 10
|
|
192
|
-
polygon: 'https://polygon.hypersync.xyz', // Chain ID 137
|
|
193
|
-
zksync: 'https://zksync.hypersync.xyz', // Chain ID 324
|
|
194
|
-
linea: 'https://linea.hypersync.xyz', // Chain ID 59144
|
|
195
|
-
unichain: 'https://unichain.hypersync.xyz' // Chain ID 130
|
|
196
|
-
// localhost is not supported by hypersync
|
|
197
|
-
}
|
|
21
|
+
export class ClientManager {
|
|
22
|
+
private clients = new Map<ChainName, PublicClient>()
|
|
198
23
|
|
|
199
|
-
|
|
200
|
-
'mainnet',
|
|
201
|
-
'arbitrum',
|
|
202
|
-
'avalanche',
|
|
203
|
-
'base',
|
|
204
|
-
'bnb',
|
|
205
|
-
'gnosis',
|
|
206
|
-
'sonic',
|
|
207
|
-
'optimism',
|
|
208
|
-
'polygon',
|
|
209
|
-
'zksync',
|
|
210
|
-
'linea',
|
|
211
|
-
'unichain',
|
|
212
|
-
'localhost'
|
|
213
|
-
] as const
|
|
24
|
+
constructor(private config: Config) {}
|
|
214
25
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
private config: Config
|
|
26
|
+
getClient(chainName: ChainName): PublicClient {
|
|
27
|
+
const cached = this.clients.get(chainName)
|
|
28
|
+
if (cached) return cached
|
|
219
29
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
223
35
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const chains = CHAINS(this.config)
|
|
227
|
-
const { chain, rpc } = chains[chainName]
|
|
228
|
-
const client = createPublicClient({
|
|
229
|
-
chain,
|
|
230
|
-
transport: http(rpc)
|
|
231
|
-
})
|
|
232
|
-
this.clients.set(chainName, client)
|
|
233
|
-
}
|
|
234
|
-
const client = this.clients.get(chainName)
|
|
235
|
-
if (!client) {
|
|
236
|
-
throw new Error(`Failed to create client for chain: ${chainName}`)
|
|
237
|
-
}
|
|
238
|
-
return client as ReturnType<typeof createPublicClient>
|
|
36
|
+
this.clients.set(chainName, client)
|
|
37
|
+
return client
|
|
239
38
|
}
|
|
240
39
|
|
|
241
40
|
getConfig(): Config {
|
|
242
41
|
return this.config
|
|
243
42
|
}
|
|
244
43
|
|
|
245
|
-
getEtherscanDomain(chainName: ChainName): string {
|
|
246
|
-
const domains: Record<ChainName, string> = {
|
|
247
|
-
mainnet: 'etherscan.io',
|
|
248
|
-
base: 'basescan.org',
|
|
249
|
-
arbitrum: 'arbiscan.io',
|
|
250
|
-
polygon: 'polygonscan.com',
|
|
251
|
-
optimism: 'optimistic.etherscan.io',
|
|
252
|
-
avalanche: 'snowtrace.io',
|
|
253
|
-
bnb: 'bscscan.com',
|
|
254
|
-
gnosis: 'gnosisscan.io',
|
|
255
|
-
sonic: 'sonicscan.org',
|
|
256
|
-
zksync: 'era.zksync.network',
|
|
257
|
-
linea: 'lineascan.build',
|
|
258
|
-
unichain: 'uniscan.xyz',
|
|
259
|
-
localhost: 'etherscan.io'
|
|
260
|
-
}
|
|
261
|
-
return domains[chainName] || 'etherscan.io'
|
|
262
|
-
}
|
|
263
|
-
|
|
264
44
|
getRpcUrl(chainName: ChainName): string {
|
|
265
|
-
|
|
266
|
-
return chains[chainName].rpc
|
|
45
|
+
return getRpcUrl(chainName, this.config)
|
|
267
46
|
}
|
|
268
47
|
|
|
269
48
|
getChainId(chainName: ChainName): number {
|
|
270
|
-
|
|
271
|
-
|
|
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
|
|
272
60
|
}
|
|
273
61
|
}
|
|
274
62
|
|
|
275
|
-
// Export a global client manager instance
|
|
276
63
|
let clientManager: ClientManager | null = null
|
|
277
64
|
|
|
278
65
|
export function initializeClientManager(config: Config): ClientManager {
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js'
|
|
2
|
+
import { getOAuthProtectedResourceMetadataUrl, mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js'
|
|
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'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Serve the MCP over HTTP instead of stdio, so the server can live somewhere other than the
|
|
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.
|
|
18
|
+
*
|
|
19
|
+
* Stateless — a server per request — because every tool here is a one-shot call and the
|
|
20
|
+
* signing state lives in the WalletConnect session, not in the transport.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const DEFAULT_PORT = 3457
|
|
24
|
+
|
|
25
|
+
export interface HttpServerOptions {
|
|
26
|
+
port?: number
|
|
27
|
+
host?: string
|
|
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
|
+
createMcpServer: () => McpServer
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function startHttpServer(options: HttpServerOptions): Promise<{ url: string; port: number; walletUrl?: string }> {
|
|
37
|
+
const port = options.port ?? Number(process.env.MCP_HTTP_PORT) ?? DEFAULT_PORT
|
|
38
|
+
const host = options.host ?? process.env.MCP_HTTP_HOST ?? '0.0.0.0'
|
|
39
|
+
const token = options.token ?? process.env.MCP_TOKEN
|
|
40
|
+
|
|
41
|
+
// A reachable MCP server with a paired phone can push signing prompts at that phone. It
|
|
42
|
+
// never runs without a token.
|
|
43
|
+
if (!token) {
|
|
44
|
+
throw new Error('MCP_TOKEN is required to serve MCP over HTTP — anyone reaching the URL could request signatures')
|
|
45
|
+
}
|
|
46
|
+
|
|
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)
|
|
50
|
+
|
|
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 }))
|
|
55
|
+
|
|
56
|
+
app.get('/health', (_req, res) => {
|
|
57
|
+
res.json({ status: 'ok' })
|
|
58
|
+
})
|
|
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) => {
|
|
82
|
+
const mcp = options.createMcpServer()
|
|
83
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true })
|
|
84
|
+
|
|
85
|
+
res.on('close', () => {
|
|
86
|
+
transport.close().catch(() => {})
|
|
87
|
+
mcp.close().catch(() => {})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
await mcp.connect(transport)
|
|
92
|
+
await transport.handleRequest(req, res, req.body)
|
|
93
|
+
} catch (error) {
|
|
94
|
+
console.error('[MCP] Request failed:', error)
|
|
95
|
+
if (!res.headersSent) {
|
|
96
|
+
res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal error' } })
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
|
|
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)
|
|
104
|
+
})
|
|
105
|
+
|
|
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 }
|
|
117
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
3
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
4
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
5
|
+
import { WalletRelay } from 'web3-wallet-relay'
|
|
6
|
+
import packageJson from '../package.json' with { type: 'json' }
|
|
7
|
+
import { initializeClientManager, SUPPORTED_CHAINS } from './client.js'
|
|
8
|
+
import { startHttpServer } from './http-server.js'
|
|
9
|
+
import { registerAllTools } from './tools/index.js'
|
|
10
|
+
import { parseCommandLineArgs } from './utils.js'
|
|
11
|
+
import { getWalletClient } from './wallet-client.js'
|
|
10
12
|
|
|
11
13
|
// Parse configuration
|
|
12
|
-
const config = parseCommandLineArgs()
|
|
14
|
+
const config = parseCommandLineArgs()
|
|
13
15
|
|
|
14
16
|
// Show help if requested
|
|
15
17
|
if (config.showHelp) {
|
|
@@ -26,6 +28,8 @@ OPTIONS:
|
|
|
26
28
|
--alchemy-api-key <key> Alchemy API key (for enhanced RPC)
|
|
27
29
|
--infura-api-key <key> Infura API key (for alternative RPC)
|
|
28
30
|
--hypersync-api-key <key> Hypersync API key (for fast event queries)
|
|
31
|
+
--walletconnect-project-id <id>
|
|
32
|
+
WalletConnect project id, to sign from a phone
|
|
29
33
|
--custom-rpc <json> Custom RPC URLs as JSON object
|
|
30
34
|
Example: '{"mainnet":"https://...", "base":"https://..."}'
|
|
31
35
|
|
|
@@ -34,11 +38,18 @@ ENVIRONMENT VARIABLES:
|
|
|
34
38
|
ALCHEMY_API_KEY Alternative to --alchemy-api-key
|
|
35
39
|
INFURA_API_KEY Alternative to --infura-api-key
|
|
36
40
|
HYPERSYNC_API_KEY Alternative to --hypersync-api-key
|
|
41
|
+
WALLETCONNECT_PROJECT_ID Alternative to --walletconnect-project-id
|
|
42
|
+
CUSTOM_RPC Alternative to --custom-rpc
|
|
43
|
+
MCP_HTTP_PORT Serve MCP over HTTP on this port instead of stdio
|
|
44
|
+
MCP_TOKEN Bearer token required by the HTTP transport
|
|
45
|
+
MCP_PUBLIC_URL Public URL of this server, advertised in OAuth metadata
|
|
46
|
+
UPSTASH_REDIS_REST_URL Keep WalletConnect sessions in Redis, not on disk
|
|
47
|
+
UPSTASH_REDIS_REST_TOKEN
|
|
37
48
|
WALLET_SERVER_URL URL of a hosted wallet relay (omit to run one locally)
|
|
38
49
|
WALLET_TOKEN Shared pairing token for the wallet relay
|
|
39
50
|
|
|
40
51
|
SUPPORTED CHAINS:
|
|
41
|
-
|
|
52
|
+
${SUPPORTED_CHAINS.join(', ')}
|
|
42
53
|
|
|
43
54
|
EXAMPLES:
|
|
44
55
|
# Use with npx (recommended)
|
|
@@ -56,70 +67,108 @@ DOCUMENTATION:
|
|
|
56
67
|
Issues: ${packageJson.bugs.url}
|
|
57
68
|
|
|
58
69
|
For MCP client configuration, see the README.md file.
|
|
59
|
-
`)
|
|
60
|
-
process.exit(0)
|
|
70
|
+
`)
|
|
71
|
+
process.exit(0)
|
|
61
72
|
}
|
|
62
73
|
|
|
63
74
|
// Log configuration info
|
|
64
75
|
if (config.etherscanApiKey) {
|
|
65
|
-
console.error(
|
|
76
|
+
console.error('[MCP] Etherscan API key configured')
|
|
66
77
|
}
|
|
67
78
|
if (config.alchemyApiKey) {
|
|
68
|
-
console.error(
|
|
79
|
+
console.error('[MCP] Alchemy API key configured')
|
|
69
80
|
}
|
|
70
81
|
if (config.infuraApiKey) {
|
|
71
|
-
console.error(
|
|
82
|
+
console.error('[MCP] Infura API key configured')
|
|
83
|
+
}
|
|
84
|
+
if (config.walletConnectProjectId) {
|
|
85
|
+
console.error('[MCP] WalletConnect project id configured — phone signing available')
|
|
72
86
|
}
|
|
73
87
|
if (config.customRpcUrls) {
|
|
74
|
-
console.error(
|
|
88
|
+
console.error('[MCP] Custom RPC URLs:', Object.keys(config.customRpcUrls))
|
|
75
89
|
}
|
|
76
90
|
|
|
77
91
|
// Initialize client manager
|
|
78
|
-
initializeClientManager(config)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const server = new McpServer({
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
})
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
92
|
+
initializeClientManager(config)
|
|
93
|
+
|
|
94
|
+
function createMcpServer() {
|
|
95
|
+
const server = new McpServer({
|
|
96
|
+
name: 'web3-tools-mcp',
|
|
97
|
+
version: packageJson.version
|
|
98
|
+
})
|
|
99
|
+
registerAllTools(server)
|
|
100
|
+
return server
|
|
101
|
+
}
|
|
88
102
|
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
103
|
+
const httpMode = Boolean(process.env.MCP_HTTP_PORT)
|
|
104
|
+
const httpPort = Number(process.env.MCP_HTTP_PORT)
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Hosted, the signing page rides on the same port as /mcp, so a browser wallet works from
|
|
108
|
+
* a deployment too — the platform gives one port and both fit on it.
|
|
109
|
+
*
|
|
110
|
+
* Its token is minted per boot rather than persisted. A hosted relay is reachable from
|
|
111
|
+
* anywhere, and the pairing URL is fetched through wallet_status, which already needs
|
|
112
|
+
* MCP_TOKEN — so nobody types this token, and a short-lived one costs nothing. Locally the
|
|
113
|
+
* token stays on disk, because several editor sessions have to find the same relay.
|
|
114
|
+
*/
|
|
115
|
+
const hostedRelay = httpMode
|
|
116
|
+
? new WalletRelay({ token: process.env.WALLET_TOKEN, publicUrl: process.env.MCP_PUBLIC_URL })
|
|
117
|
+
: undefined
|
|
118
|
+
|
|
119
|
+
const wallet = getWalletClient(hostedRelay ? { port: httpPort, token: hostedRelay.token } : undefined)
|
|
120
|
+
|
|
121
|
+
// Hosted, the relay is attached to the HTTP server, so connect only once that is listening.
|
|
122
|
+
const walletReady = httpMode
|
|
123
|
+
? Promise.resolve()
|
|
124
|
+
: wallet.connect().catch((error) => {
|
|
125
|
+
console.error('[MCP] Wallet relay unavailable:', error.message)
|
|
126
|
+
console.error('[MCP] Transaction signing features will not be available')
|
|
127
|
+
})
|
|
95
128
|
|
|
96
129
|
// The wallet relay's listening socket keeps the event loop alive, so this process would
|
|
97
130
|
// outlive the client that spawned it and go on holding its port. Leave when the client does.
|
|
98
|
-
let shuttingDown = false
|
|
131
|
+
let shuttingDown = false
|
|
99
132
|
async function shutdown(reason: string) {
|
|
100
|
-
if (shuttingDown) return
|
|
101
|
-
shuttingDown = true
|
|
102
|
-
console.error(`[MCP] Shutting down (${reason})`)
|
|
103
|
-
await wallet.stop().catch(() => {})
|
|
104
|
-
process.exit(0)
|
|
133
|
+
if (shuttingDown) return
|
|
134
|
+
shuttingDown = true
|
|
135
|
+
console.error(`[MCP] Shutting down (${reason})`)
|
|
136
|
+
await wallet.stop().catch(() => {})
|
|
137
|
+
process.exit(0)
|
|
105
138
|
}
|
|
106
139
|
|
|
107
|
-
process.stdin.on(
|
|
108
|
-
process.stdin.on(
|
|
109
|
-
process.on(
|
|
110
|
-
process.on(
|
|
140
|
+
process.stdin.on('end', () => shutdown('client disconnected'))
|
|
141
|
+
process.stdin.on('close', () => shutdown('client disconnected'))
|
|
142
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
|
143
|
+
process.on('SIGINT', () => shutdown('SIGINT'))
|
|
111
144
|
|
|
112
145
|
// Start server
|
|
113
146
|
async function main() {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
147
|
+
if (httpMode) {
|
|
148
|
+
const { url, walletUrl } = await startHttpServer({ createMcpServer, walletRelay: hostedRelay })
|
|
149
|
+
console.error(`Web3 Tools MCP Server listening on ${url}`)
|
|
150
|
+
|
|
151
|
+
// Join the relay now that it is listening, so browser signing is available here too.
|
|
152
|
+
await wallet.connect().catch((error) => {
|
|
153
|
+
console.error('[MCP] Could not join the wallet relay:', error.message)
|
|
154
|
+
})
|
|
155
|
+
if (walletUrl) console.error(`Wallet interface available at ${walletUrl}`)
|
|
156
|
+
|
|
157
|
+
if (!config.walletConnectProjectId) {
|
|
158
|
+
console.error('[MCP] No WalletConnect project id — phone signing is unavailable on this server')
|
|
159
|
+
}
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const transport = new StdioServerTransport()
|
|
164
|
+
await createMcpServer().connect(transport)
|
|
165
|
+
console.error('Web3 Tools MCP Server running on stdio')
|
|
117
166
|
// The relay may still be picking a free port, and its URL carries the pairing token.
|
|
118
|
-
await walletReady
|
|
119
|
-
console.error(`Wallet interface available at ${wallet.getUrl()}`)
|
|
167
|
+
await walletReady
|
|
168
|
+
console.error(`Wallet interface available at ${wallet.getUrl()}`)
|
|
120
169
|
}
|
|
121
170
|
|
|
122
171
|
main().catch((error) => {
|
|
123
|
-
console.error(
|
|
124
|
-
process.exit(1)
|
|
125
|
-
})
|
|
172
|
+
console.error('Fatal error:', error)
|
|
173
|
+
process.exit(1)
|
|
174
|
+
})
|