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.
Files changed (109) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/README.md +346 -164
  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 -216
  17. package/dist/client.js.map +1 -1
  18. package/dist/http-server.d.ts +18 -0
  19. package/dist/http-server.d.ts.map +1 -0
  20. package/dist/http-server.js +89 -0
  21. package/dist/http-server.js.map +1 -0
  22. package/dist/index.js +77 -32
  23. package/dist/index.js.map +1 -1
  24. package/dist/kv-storage.d.ts +23 -0
  25. package/dist/kv-storage.d.ts.map +1 -0
  26. package/dist/kv-storage.js +69 -0
  27. package/dist/kv-storage.js.map +1 -0
  28. package/dist/oauth.d.ts +31 -0
  29. package/dist/oauth.d.ts.map +1 -0
  30. package/dist/oauth.js +227 -0
  31. package/dist/oauth.js.map +1 -0
  32. package/dist/preview.d.ts.map +1 -1
  33. package/dist/preview.js +14 -4
  34. package/dist/preview.js.map +1 -1
  35. package/dist/tools/advanced.d.ts +4 -4
  36. package/dist/tools/advanced.d.ts.map +1 -1
  37. package/dist/tools/advanced.js +228 -247
  38. package/dist/tools/advanced.js.map +1 -1
  39. package/dist/tools/balance.d.ts +1 -1
  40. package/dist/tools/balance.d.ts.map +1 -1
  41. package/dist/tools/balance.js +97 -103
  42. package/dist/tools/balance.js.map +1 -1
  43. package/dist/tools/contract-info.d.ts +3 -3
  44. package/dist/tools/contract-info.d.ts.map +1 -1
  45. package/dist/tools/contract-info.js +150 -161
  46. package/dist/tools/contract-info.js.map +1 -1
  47. package/dist/tools/contract.d.ts +4 -4
  48. package/dist/tools/contract.d.ts.map +1 -1
  49. package/dist/tools/contract.js +91 -110
  50. package/dist/tools/contract.js.map +1 -1
  51. package/dist/tools/ens.d.ts +6 -6
  52. package/dist/tools/ens.d.ts.map +1 -1
  53. package/dist/tools/ens.js +239 -297
  54. package/dist/tools/ens.js.map +1 -1
  55. package/dist/tools/gas.d.ts +4 -4
  56. package/dist/tools/gas.d.ts.map +1 -1
  57. package/dist/tools/gas.js +90 -138
  58. package/dist/tools/gas.js.map +1 -1
  59. package/dist/tools/index.d.ts +1 -1
  60. package/dist/tools/index.d.ts.map +1 -1
  61. package/dist/tools/logs.d.ts +1 -1
  62. package/dist/tools/logs.d.ts.map +1 -1
  63. package/dist/tools/logs.js +36 -37
  64. package/dist/tools/logs.js.map +1 -1
  65. package/dist/tools/signatures.d.ts.map +1 -1
  66. package/dist/tools/signatures.js +67 -89
  67. package/dist/tools/signatures.js.map +1 -1
  68. package/dist/tools/transactions.d.ts +48 -4
  69. package/dist/tools/transactions.d.ts.map +1 -1
  70. package/dist/tools/transactions.js +228 -44
  71. package/dist/tools/transactions.js.map +1 -1
  72. package/dist/types.d.ts +2 -1
  73. package/dist/types.d.ts.map +1 -1
  74. package/dist/utils.d.ts +3 -8
  75. package/dist/utils.d.ts.map +1 -1
  76. package/dist/utils.js +36 -98
  77. package/dist/utils.js.map +1 -1
  78. package/dist/wallet-client.d.ts +11 -1
  79. package/dist/wallet-client.d.ts.map +1 -1
  80. package/dist/wallet-client.js +27 -9
  81. package/dist/wallet-client.js.map +1 -1
  82. package/dist/walletconnect.d.ts +52 -0
  83. package/dist/walletconnect.d.ts.map +1 -0
  84. package/dist/walletconnect.js +221 -0
  85. package/dist/walletconnect.js.map +1 -0
  86. package/package.json +21 -9
  87. package/src/anvil.ts +15 -18
  88. package/src/chain-meta.ts +8 -4
  89. package/src/chains.ts +145 -0
  90. package/src/client.ts +39 -252
  91. package/src/http-server.ts +117 -0
  92. package/src/index.ts +98 -49
  93. package/src/kv-storage.ts +83 -0
  94. package/src/oauth.ts +270 -0
  95. package/src/preview.ts +15 -14
  96. package/src/tools/advanced.ts +195 -213
  97. package/src/tools/balance.ts +103 -108
  98. package/src/tools/contract-info.ts +163 -184
  99. package/src/tools/contract.ts +123 -144
  100. package/src/tools/ens.ts +290 -345
  101. package/src/tools/gas.ts +122 -170
  102. package/src/tools/index.ts +2 -2
  103. package/src/tools/logs.ts +45 -43
  104. package/src/tools/signatures.ts +68 -86
  105. package/src/tools/transactions.ts +283 -50
  106. package/src/types.ts +2 -15
  107. package/src/utils.ts +56 -127
  108. package/src/wallet-client.ts +31 -9
  109. package/src/walletconnect.ts +269 -0
package/src/utils.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { AbiParameter } from 'viem'
2
- import type { ToolResult } from './types.js'
3
- import { z } from 'zod'
2
+ import type { z } from 'zod'
3
+ import type { Config, ToolResult } from './types.js'
4
4
 
5
5
  // Create tool helper
6
6
  export function createTool<T extends z.ZodType>(
@@ -23,7 +23,7 @@ export function convertBigIntToString(obj: unknown): unknown {
23
23
  return obj.toString()
24
24
  }
25
25
  if (Array.isArray(obj)) {
26
- return obj.map(item => convertBigIntToString(item))
26
+ return obj.map((item) => convertBigIntToString(item))
27
27
  }
28
28
  if (obj && typeof obj === 'object') {
29
29
  const converted: Record<string, unknown> = {}
@@ -35,7 +35,6 @@ export function convertBigIntToString(obj: unknown): unknown {
35
35
  return obj
36
36
  }
37
37
 
38
-
39
38
  // Summarize a call trace for compact output
40
39
  export interface SummarizedCall {
41
40
  type: string
@@ -73,17 +72,20 @@ export function summarizeTrace(trace: unknown): { hasError: boolean; errorPath:
73
72
  summary = `SUCCESS - ${topCalls.length} top-level calls: ${topCalls.slice(0, 5).join(', ')}${topCalls.length > 5 ? '...' : ''}`
74
73
  }
75
74
 
76
- return {
77
- hasError: result !== null,
78
- errorPath: result?.errorPath ?? null,
79
- summary
75
+ return {
76
+ hasError: result !== null,
77
+ errorPath: result?.errorPath ?? null,
78
+ summary
80
79
  }
81
80
  }
82
81
 
83
82
  function findErrorInTrace(trace: unknown): { errorPath: SummarizedCall[]; errorMessage: string } | null {
84
83
  if (!trace || typeof trace !== 'object') return null
85
84
 
86
- function findError(call: Record<string, unknown>, path: SummarizedCall[]): { errorPath: SummarizedCall[]; errorMessage: string } | null {
85
+ function findError(
86
+ call: Record<string, unknown>,
87
+ path: SummarizedCall[]
88
+ ): { errorPath: SummarizedCall[]; errorMessage: string } | null {
87
89
  const type = (call.type as string) || 'CALL'
88
90
  const error = (call.error as string) || (call.revertReason as string)
89
91
  const subcalls = call.calls as Record<string, unknown>[] | undefined
@@ -130,124 +132,64 @@ function findErrorInTrace(trace: unknown): { errorPath: SummarizedCall[]; errorM
130
132
  return findError(trace as Record<string, unknown>, [])
131
133
  }
132
134
 
133
- // Convert arguments to appropriate types based on ABI
135
+ /**
136
+ * Coerce a JSON value into what viem expects for an ABI type. Tuples, arrays and anything
137
+ * else pass through: viem validates them, and guessing here would only mangle them.
138
+ */
139
+ function coerce(value: unknown, type: string): unknown {
140
+ if (value === null || value === undefined) return null
141
+
142
+ if (type === 'address' || type === 'string' || type.startsWith('bytes')) {
143
+ return String(value)
144
+ }
145
+
146
+ if (type === 'bool') {
147
+ if (typeof value === 'boolean') return value
148
+ if (typeof value === 'string') return value.toLowerCase() === 'true'
149
+ return Boolean(value)
150
+ }
151
+
152
+ if (type.startsWith('uint') || type.startsWith('int')) {
153
+ if (typeof value === 'number' || typeof value === 'string') return BigInt(value)
154
+ throw new Error(`Invalid type for ${type}: ${typeof value}`)
155
+ }
156
+
157
+ return value
158
+ }
159
+
134
160
  export function convertArgumentsToTypes(
135
161
  args: (string | number | boolean | null)[],
136
162
  abiInputs: readonly AbiParameter[]
137
163
  ): unknown[] {
138
- return args.map((arg, index) => {
139
- if (index >= abiInputs.length) {
140
- throw new Error(`Too many arguments provided. Expected ${abiInputs.length}, got ${args.length}`)
141
- }
164
+ if (args.length > abiInputs.length) {
165
+ throw new Error(`Too many arguments provided. Expected ${abiInputs.length}, got ${args.length}`)
166
+ }
142
167
 
168
+ return args.map((arg, index) => {
143
169
  const param = abiInputs[index]
144
170
  if (!param?.type) {
145
171
  throw new Error(`Missing type information for parameter at index ${index}`)
146
172
  }
147
-
148
- const paramType = param.type
149
-
150
- // Handle null values
151
- if (arg === null) {
152
- return null
153
- }
154
-
155
- // Handle different parameter types
156
- if (paramType === 'address') {
157
- return String(arg)
158
- }
159
-
160
- if (paramType === 'bool') {
161
- if (typeof arg === 'boolean') return arg
162
- if (typeof arg === 'string') return arg.toLowerCase() === 'true'
163
- return Boolean(arg)
164
- }
165
-
166
- if (paramType.startsWith('uint') || paramType.startsWith('int')) {
167
- if (typeof arg === 'number') return BigInt(arg)
168
- if (typeof arg === 'string') return BigInt(arg)
169
- throw new Error(`Invalid type for ${paramType}: ${typeof arg}`)
170
- }
171
-
172
- if (paramType === 'string') {
173
- return String(arg)
174
- }
175
-
176
- if (paramType === 'bytes' || paramType.startsWith('bytes')) {
177
- return String(arg)
178
- }
179
-
180
- // For other types, try to convert appropriately
181
- if (typeof arg === 'string' && arg.startsWith('0x')) {
182
- return arg // Assume it's already properly formatted
183
- }
184
- return arg
173
+ return coerce(arg, param.type)
185
174
  })
186
175
  }
187
176
 
188
- // Convert event arguments to appropriate types
189
177
  export function convertEventArgsToTypes(
190
178
  eventArgs: Record<string, unknown>,
191
179
  abiInputs: readonly AbiParameter[]
192
180
  ): Record<string, unknown> {
193
- const converted: Record<string, unknown> = {}
194
-
195
- for (const [argName, argValue] of Object.entries(eventArgs)) {
196
- // Find the parameter definition for this argument name
197
- const param = abiInputs.find(input => input.name === argName)
198
-
199
- if (!param) {
200
- throw new Error(`Parameter '${argName}' not found in event ABI`)
201
- }
202
-
203
- if (!param.type) {
204
- throw new Error(`Missing type information for parameter '${argName}'`)
205
- }
206
-
207
- const paramType = param.type
208
-
209
- // Handle null/undefined values
210
- if (argValue === null || argValue === undefined) {
211
- converted[argName] = null
212
- continue
213
- }
214
-
215
- // Handle different parameter types
216
- if (paramType === 'address') {
217
- converted[argName] = String(argValue)
218
- } else if (paramType === 'bool') {
219
- if (typeof argValue === 'boolean') {
220
- converted[argName] = argValue
221
- } else if (typeof argValue === 'string') {
222
- converted[argName] = argValue.toLowerCase() === 'true'
223
- } else {
224
- converted[argName] = Boolean(argValue)
225
- }
226
- } else if (paramType.startsWith('uint') || paramType.startsWith('int')) {
227
- if (typeof argValue === 'number') {
228
- converted[argName] = BigInt(argValue)
229
- } else if (typeof argValue === 'string') {
230
- converted[argName] = BigInt(argValue)
231
- } else {
232
- throw new Error(`Invalid type for ${paramType}: ${typeof argValue}`)
181
+ return Object.fromEntries(
182
+ Object.entries(eventArgs).map(([argName, argValue]) => {
183
+ const param = abiInputs.find((input) => input.name === argName)
184
+ if (!param) {
185
+ throw new Error(`Parameter '${argName}' not found in event ABI`)
233
186
  }
234
- } else if (paramType === 'string') {
235
- converted[argName] = String(argValue)
236
- } else if (paramType === 'bytes' || paramType.startsWith('bytes')) {
237
- converted[argName] = String(argValue)
238
- } else {
239
- // For other types, handle arrays and try to convert appropriately
240
- if (Array.isArray(argValue)) {
241
- converted[argName] = argValue
242
- } else if (typeof argValue === 'string' && argValue.startsWith('0x')) {
243
- converted[argName] = argValue // Assume it's already properly formatted
244
- } else {
245
- converted[argName] = argValue
187
+ if (!param.type) {
188
+ throw new Error(`Missing type information for parameter '${argName}'`)
246
189
  }
247
- }
248
- }
249
-
250
- return converted
190
+ return [argName, coerce(argValue, param.type)]
191
+ })
192
+ )
251
193
  }
252
194
 
253
195
  // Format response as tool result
@@ -264,26 +206,12 @@ export function formatResponse(data: unknown): ToolResult {
264
206
  }
265
207
 
266
208
  // Parse command line arguments
267
- export function parseCommandLineArgs(): {
268
- etherscanApiKey?: string
269
- alchemyApiKey?: string
270
- infuraApiKey?: string
271
- customRpcUrls?: Record<string, string>
272
- hypersyncApiKey?: string
273
- showHelp?: boolean
274
- } {
275
- const config: {
276
- etherscanApiKey?: string
277
- alchemyApiKey?: string
278
- infuraApiKey?: string
279
- customRpcUrls?: Record<string, string>
280
- hypersyncApiKey?: string
281
- showHelp?: boolean
282
- } = {}
209
+ export function parseCommandLineArgs(): Config & { showHelp?: boolean } {
210
+ const config: Config & { showHelp?: boolean } = {}
283
211
 
284
212
  function getArgValue(argName: string): string | undefined {
285
213
  const args = process.argv
286
- const index = args.findIndex(arg => arg === argName)
214
+ const index = args.indexOf(argName)
287
215
  return index !== -1 && index + 1 < args.length ? args[index + 1] : undefined
288
216
  }
289
217
 
@@ -302,9 +230,10 @@ export function parseCommandLineArgs(): {
302
230
  config.alchemyApiKey = process.env.ALCHEMY_API_KEY || getArgValue('--alchemy-api-key')
303
231
  config.infuraApiKey = process.env.INFURA_API_KEY || getArgValue('--infura-api-key')
304
232
  config.hypersyncApiKey = process.env.HYPERSYNC_API_KEY || getArgValue('--hypersync-api-key')
233
+ config.walletConnectProjectId = process.env.WALLETCONNECT_PROJECT_ID || getArgValue('--walletconnect-project-id')
305
234
 
306
235
  // Parse custom RPC URLs
307
- const customRpcs = getArgValue('--custom-rpc')
236
+ const customRpcs = process.env.CUSTOM_RPC || getArgValue('--custom-rpc')
308
237
  if (customRpcs) {
309
238
  try {
310
239
  config.customRpcUrls = JSON.parse(customRpcs)
@@ -1,12 +1,21 @@
1
- import { WebSocket } from 'ws'
2
1
  import { execFile } from 'node:child_process'
3
2
  import { randomBytes } from 'node:crypto'
4
3
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
5
4
  import { homedir } from 'node:os'
6
5
  import { join } from 'node:path'
7
- import { WalletRelay, type TransactionRequest, type TransactionResponse } from 'web3-wallet-relay'
6
+ import { LOCAL_PORT_ATTEMPTS, type TransactionRequest, type TransactionResponse, WalletRelay } from 'web3-wallet-relay'
7
+ import { WebSocket } from 'ws'
8
8
 
9
9
  const REQUEST_TIMEOUT = 300_000
10
+
11
+ /** Serving over HTTP means no desktop here: never try to open a browser, and never
12
+ * hand out a loopback URL as the place to open the signing page. */
13
+ const HOSTED = Boolean(process.env.MCP_HTTP_PORT)
14
+
15
+ function pairingUrlFor(port: number, token: string): string {
16
+ const base = HOSTED ? (process.env.MCP_PUBLIC_URL ?? `http://localhost:${port}`) : `http://127.0.0.1:${port}`
17
+ return `${base.replace(/\/$/, '')}/#t=${token}`
18
+ }
10
19
  const SIGNER_WAIT_TIMEOUT = 30_000
11
20
  const CONNECT_ATTEMPTS = 8
12
21
  const MAX_CONNECT_BACKOFF = 15_000
@@ -31,13 +40,14 @@ function browserApp(userAgent?: string): string | undefined {
31
40
 
32
41
  // Every MCP process on this machine looks for a relay here before starting one, so all
33
42
  // sessions end up sharing a single wallet page instead of each opening its own tab.
34
- const LOCAL_PORTS = [3456, 3457, 3458, 3459, 3460]
43
+ // Kept in step with the relay: it walks the same span when 3456 is taken.
44
+ const LOCAL_PORTS = Array.from({ length: LOCAL_PORT_ATTEMPTS }, (_, i) => 3456 + i)
35
45
 
36
46
  /**
37
47
  * Token shared by every local session, so a process can join a relay another one owns.
38
48
  * Stored 0600 because holding it is enough to push transactions at the signer.
39
49
  */
40
- function localToken(): string {
50
+ export function localToken(): string {
41
51
  const dir = join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'web3-tools-mcp')
42
52
  const file = join(dir, 'relay-token')
43
53
 
@@ -143,7 +153,7 @@ export class WalletClient {
143
153
  for (const port of ports) {
144
154
  try {
145
155
  await this.openSocket(`ws://127.0.0.1:${port}`, token)
146
- this.pairingUrl = `http://127.0.0.1:${port}/#t=${token}`
156
+ this.pairingUrl = pairingUrlFor(port, token)
147
157
  return
148
158
  } catch {
149
159
  // Nothing on this port, or something that isn't our relay — keep looking.
@@ -253,7 +263,14 @@ export class WalletClient {
253
263
  // about the request over its own socket and raises itself (tab title + desktop
254
264
  // notification); asking the OS to open a URL cannot reliably focus an existing tab —
255
265
  // Chrome opens another one — and every attempt to do so left a stray tab behind.
256
- if (!this.isRemote && this.pages === 0) this.openBrowser()
266
+ if (this.pages === 0) {
267
+ // Nothing to open a page with and nobody already watching one: waiting out the
268
+ // timeout would just stall the caller in front of a screen that does not exist.
269
+ if (!this.canOpenBrowser) {
270
+ throw new Error(`No wallet page is open. Open ${this.getUrl()}, connect your wallet, then try again.`)
271
+ }
272
+ this.openBrowser()
273
+ }
257
274
 
258
275
  const deadline = Date.now() + this.signerWaitTimeout
259
276
  while (this.signers === 0 && Date.now() < deadline) {
@@ -305,8 +322,13 @@ export class WalletClient {
305
322
  return `http://127.0.0.1:${LOCAL_PORTS[0]}/#t=${this.relayOptions.token ?? localToken()}`
306
323
  }
307
324
 
325
+ /** A relay we do not own, or a server with no desktop, has no browser for us to open. */
326
+ private get canOpenBrowser(): boolean {
327
+ return !this.isRemote && !HOSTED
328
+ }
329
+
308
330
  openBrowser() {
309
- if (this.isRemote) return
331
+ if (!this.canOpenBrowser) return
310
332
  this.lastOpenedAt = Date.now()
311
333
  this.open(this.getUrl(), (url) => console.error(`[Wallet] Could not open a browser — visit ${url}`))
312
334
  }
@@ -374,7 +396,7 @@ export class WalletClient {
374
396
 
375
397
  let client: WalletClient | null = null
376
398
 
377
- export function getWalletClient(): WalletClient {
378
- if (!client) client = new WalletClient()
399
+ export function getWalletClient(relayOptions?: { port?: number; token?: string }): WalletClient {
400
+ if (!client) client = new WalletClient(relayOptions)
379
401
  return client
380
402
  }
@@ -0,0 +1,269 @@
1
+ import { mkdirSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { getClientManager } from './client.js'
5
+ import { getKeyValueStorage } from './kv-storage.js'
6
+ import type { ChainName } from './types.js'
7
+
8
+ /**
9
+ * Signing from a phone, with no page to load and nothing to host: the MCP server acts as
10
+ * the dApp, the wallet app is the signer, and WalletConnect's relay carries the (end-to-end
11
+ * encrypted) messages between them. Pair once by scanning a QR; the session is stored on
12
+ * disk, so it survives restarts.
13
+ */
14
+
15
+ type SignClientType = Awaited<ReturnType<typeof importSignClient>>['prototype']
16
+
17
+ async function importSignClient() {
18
+ // Imported lazily: several MB of SDK that is pointless to load without a project ID.
19
+ const { SignClient } = await import('@walletconnect/sign-client')
20
+ return SignClient
21
+ }
22
+
23
+ interface Session {
24
+ topic: string
25
+ accounts: string[]
26
+ chains: number[]
27
+ peer?: string
28
+ }
29
+
30
+ const METHODS = ['eth_sendTransaction', 'personal_sign', 'eth_signTypedData_v4']
31
+ const EVENTS = ['chainChanged', 'accountsChanged']
32
+ const APPROVAL_TIMEOUT = 300_000
33
+
34
+ /**
35
+ * Where this server lives, as shown to the wallet. A hosted server knows its public URL;
36
+ * a local one is only reachable from the machine it runs on, and says so.
37
+ */
38
+ function serverUrl(): string {
39
+ if (process.env.MCP_PUBLIC_URL) return process.env.MCP_PUBLIC_URL.replace(/\/$/, '')
40
+ if (process.env.MCP_HTTP_PORT) return `http://localhost:${process.env.MCP_HTTP_PORT}`
41
+ return 'http://localhost'
42
+ }
43
+
44
+ function storePath(): string {
45
+ const dir = join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'web3-tools-mcp')
46
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
47
+ return join(dir, 'walletconnect.db')
48
+ }
49
+
50
+ export class PhoneSigner {
51
+ private client: SignClientType | undefined
52
+ private starting: Promise<void> | undefined
53
+ private pairing: { uri: string; expiresAt: number } | undefined
54
+ /** Label shown in the wallet; whoever is driving this server says who they are. */
55
+ private agent: string | undefined
56
+
57
+ constructor(private projectId: string) {}
58
+
59
+ private async start(): Promise<void> {
60
+ if (this.client) return
61
+ if (this.starting) return this.starting
62
+
63
+ this.starting = (async () => {
64
+ const SignClient = await importSignClient()
65
+ // Redis when hosted (a free-tier filesystem is ephemeral, and losing the store means
66
+ // re-pairing by QR after every sleep); a file next to the other config locally.
67
+ const storage = getKeyValueStorage()
68
+ this.client = (await SignClient.init({
69
+ projectId: this.projectId,
70
+ ...(storage ? { storage } : { storageOptions: { database: storePath() } }),
71
+ // What the wallet shows when asking you to approve. Approving does not connect a
72
+ // website — it lets an agent request signatures for as long as the session lives,
73
+ // so the dialog says that. The name is self-reported and proves nothing; the URL
74
+ // is what distinguishes this server from any other.
75
+ metadata: {
76
+ name: this.agent ? `${this.agent} via web3-tools-mcp` : 'web3-tools-mcp (AI agent)',
77
+ description: 'An AI agent uses this server to request transactions. You approve each one here.',
78
+ url: serverUrl(),
79
+ icons: []
80
+ }
81
+ })) as SignClientType
82
+ })().finally(() => {
83
+ this.starting = undefined
84
+ })
85
+
86
+ return this.starting
87
+ }
88
+
89
+ /** The most recent still-valid session, if the phone is already paired. */
90
+ async session(): Promise<Session | undefined> {
91
+ await this.start()
92
+ const sessions = this.client?.session.getAll() ?? []
93
+ const live = sessions.filter((s) => s.expiry * 1000 > Date.now())
94
+ const session = live[live.length - 1]
95
+ if (!session) return undefined
96
+
97
+ const accounts = session.namespaces.eip155?.accounts ?? []
98
+ return {
99
+ topic: session.topic,
100
+ // "eip155:8453:0xabc…" → address / chain id
101
+ accounts: [...new Set(accounts.map((a) => a.split(':')[2] as string))],
102
+ chains: [...new Set(accounts.map((a) => Number(a.split(':')[1])))],
103
+ peer: session.peer?.metadata?.name
104
+ }
105
+ }
106
+
107
+ async isPaired(): Promise<boolean> {
108
+ return Boolean(await this.session())
109
+ }
110
+
111
+ /**
112
+ * Start a pairing and return its URI immediately, resolving the wallet's approval in the
113
+ * background — a tool result only reaches the user once it returns, so blocking here
114
+ * would hide the QR they are supposed to scan.
115
+ */
116
+ async pair(chains: ChainName[], agent?: string): Promise<string> {
117
+ // Metadata is fixed when the client starts, so a new label needs a new client. Only
118
+ // safe while nothing is connected — an existing session owns the old client.
119
+ if (agent && agent !== this.agent && this.client && this.client.session.getAll().length === 0) {
120
+ this.client = undefined
121
+ }
122
+ if (agent) this.agent = agent
123
+
124
+ await this.start()
125
+ if (!this.client) throw new Error('WalletConnect failed to start')
126
+
127
+ const clientManager = getClientManager()
128
+ const eip155 = chains.map((chain) => `eip155:${clientManager.getChainId(chain)}`)
129
+
130
+ const { uri, approval } = await this.client.connect({
131
+ // Optional rather than required: wallets reject a session outright when they cannot
132
+ // serve every required chain, and few support all thirteen.
133
+ optionalNamespaces: { eip155: { chains: eip155, methods: METHODS, events: EVENTS } }
134
+ })
135
+
136
+ if (!uri) throw new Error('WalletConnect did not return a pairing URI')
137
+ this.pairing = { uri, expiresAt: Date.now() + APPROVAL_TIMEOUT }
138
+
139
+ approval()
140
+ .then((session) => console.error(`[WalletConnect] Paired with ${session.peer?.metadata?.name ?? 'a wallet'}`))
141
+ .catch((error) => console.error('[WalletConnect] Pairing was not completed:', error.message))
142
+
143
+ return uri
144
+ }
145
+
146
+ /** Send a request to the paired wallet. Throws if the chain is outside the session. */
147
+ async request(chain: ChainName, method: string, params: unknown[]): Promise<unknown> {
148
+ const session = await this.session()
149
+ if (!session || !this.client) throw new Error('No phone wallet paired')
150
+
151
+ const chainId = getClientManager().getChainId(chain)
152
+ if (!session.chains.includes(chainId)) {
153
+ throw new Error(
154
+ `The paired wallet did not approve ${chain} (chain ${chainId}). It approved: ${session.chains.join(', ')}. Re-pair to add it.`
155
+ )
156
+ }
157
+
158
+ return this.client.request({
159
+ topic: session.topic,
160
+ chainId: `eip155:${chainId}`,
161
+ request: { method, params }
162
+ })
163
+ }
164
+
165
+ /** Every live session, not just the one that would be used for signing. */
166
+ async sessions(): Promise<Session[]> {
167
+ await this.start()
168
+ return (this.client?.session.getAll() ?? []).map((session) => ({
169
+ topic: session.topic,
170
+ accounts: [...new Set((session.namespaces.eip155?.accounts ?? []).map((a) => a.split(':')[2] as string))],
171
+ chains: [...new Set((session.namespaces.eip155?.accounts ?? []).map((a) => Number(a.split(':')[1])))],
172
+ peer: session.peer?.metadata?.name
173
+ }))
174
+ }
175
+
176
+ /**
177
+ * Disconnect everything: sessions, and the pairings underneath them. Each pairing
178
+ * attempt leaves one behind whether or not a wallet ever approved it, so they
179
+ * accumulate quietly in the store.
180
+ */
181
+ async disconnectAll(): Promise<{ sessions: number; pairings: number }> {
182
+ await this.start()
183
+ if (!this.client) return { sessions: 0, pairings: 0 }
184
+
185
+ const sessions = this.client.session.getAll()
186
+ for (const session of sessions) {
187
+ await this.client
188
+ .disconnect({ topic: session.topic, reason: { code: 6000, message: 'User disconnected' } })
189
+ .catch((error) => console.error('[WalletConnect] Could not disconnect a session:', error.message))
190
+ }
191
+
192
+ const pairings = this.client.core.pairing.getPairings()
193
+ for (const pairing of pairings) {
194
+ await this.client.core.pairing
195
+ .disconnect({ topic: pairing.topic })
196
+ .catch((error) => console.error('[WalletConnect] Could not drop a pairing:', error.message))
197
+ }
198
+
199
+ return { sessions: sessions.length, pairings: pairings.length }
200
+ }
201
+
202
+ async unpair(): Promise<void> {
203
+ await this.disconnectAll()
204
+ }
205
+ }
206
+
207
+ interface WalletEntry {
208
+ name: string
209
+ native?: string
210
+ universal?: string
211
+ }
212
+
213
+ let wallets: WalletEntry[] | undefined
214
+
215
+ /**
216
+ * Wallets from WalletConnect's registry, so the links we hand out are the schemes each
217
+ * wallet actually registered rather than ones we invented. Fetched once per process; a
218
+ * failure is not fatal, it just means no ready-made links.
219
+ */
220
+ async function walletRegistry(projectId: string): Promise<WalletEntry[]> {
221
+ if (wallets) return wallets
222
+
223
+ try {
224
+ const response = await fetch(`https://explorer-api.walletconnect.com/v3/wallets?projectId=${projectId}&entries=100&page=1`)
225
+ const listings =
226
+ ((await response.json()) as { listings?: Record<string, { name: string; mobile?: WalletEntry }> }).listings ?? {}
227
+
228
+ wallets = Object.values(listings)
229
+ .map(({ name, mobile }) => ({ name, native: mobile?.native || undefined, universal: mobile?.universal || undefined }))
230
+ .filter((wallet) => wallet.native || wallet.universal)
231
+ } catch {
232
+ wallets = []
233
+ }
234
+
235
+ return wallets
236
+ }
237
+
238
+ /**
239
+ * Links that open a wallet straight onto this pairing, for when the agent is running on
240
+ * the phone itself and there is no second screen to scan from.
241
+ *
242
+ * Native schemes are preferred over universal links: chat apps are documented to mishandle
243
+ * the latter, opening the wallet with no prompt or diverting to an app store.
244
+ */
245
+ export async function pairingLinks(projectId: string, uri: string, search?: string): Promise<Record<string, string>> {
246
+ const registry = await walletRegistry(projectId)
247
+ const encoded = encodeURIComponent(uri)
248
+
249
+ const matches = search
250
+ ? registry.filter((wallet) => wallet.name.toLowerCase().includes(search.toLowerCase()))
251
+ : registry.slice(0, 12)
252
+
253
+ return Object.fromEntries(
254
+ matches.map((wallet) => {
255
+ const base = wallet.native ?? `${wallet.universal?.replace(/\/$/, '')}/`
256
+ return [wallet.name, `${base}wc?uri=${encoded}`]
257
+ })
258
+ )
259
+ }
260
+
261
+ let signer: PhoneSigner | null = null
262
+
263
+ /** Configured only when a WalletConnect project id is present. */
264
+ export function getPhoneSigner(): PhoneSigner | null {
265
+ const projectId = getClientManager().getConfig().walletConnectProjectId
266
+ if (!projectId) return null
267
+ if (!signer) signer = new PhoneSigner(projectId)
268
+ return signer
269
+ }