web3-tools-mcp 2.0.1 → 3.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 (167) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/README.md +5 -0
  3. package/dist/anvil.d.ts +17 -19
  4. package/dist/anvil.d.ts.map +1 -1
  5. package/dist/anvil.js +124 -228
  6. package/dist/anvil.js.map +1 -1
  7. package/dist/chains.d.ts +9 -1
  8. package/dist/chains.d.ts.map +1 -1
  9. package/dist/chains.js +11 -2
  10. package/dist/chains.js.map +1 -1
  11. package/dist/client.d.ts.map +1 -1
  12. package/dist/client.js.map +1 -1
  13. package/dist/context.d.ts +22 -0
  14. package/dist/context.d.ts.map +1 -0
  15. package/dist/context.js +24 -0
  16. package/dist/context.js.map +1 -0
  17. package/dist/hosted.d.ts +12 -0
  18. package/dist/hosted.d.ts.map +1 -0
  19. package/dist/hosted.js +12 -0
  20. package/dist/hosted.js.map +1 -0
  21. package/dist/http-server.d.ts +13 -0
  22. package/dist/http-server.d.ts.map +1 -1
  23. package/dist/http-server.js +13 -4
  24. package/dist/http-server.js.map +1 -1
  25. package/dist/index.js +19 -15
  26. package/dist/index.js.map +1 -1
  27. package/dist/lib.d.ts +24 -0
  28. package/dist/lib.d.ts.map +1 -0
  29. package/dist/lib.js +23 -0
  30. package/dist/lib.js.map +1 -0
  31. package/dist/log.d.ts +16 -0
  32. package/dist/log.d.ts.map +1 -0
  33. package/dist/log.js +20 -0
  34. package/dist/log.js.map +1 -0
  35. package/dist/mcp-server.d.ts +4 -0
  36. package/dist/mcp-server.d.ts.map +1 -0
  37. package/dist/mcp-server.js +43 -0
  38. package/dist/mcp-server.js.map +1 -0
  39. package/dist/oauth.d.ts +41 -6
  40. package/dist/oauth.d.ts.map +1 -1
  41. package/dist/oauth.js +75 -41
  42. package/dist/oauth.js.map +1 -1
  43. package/dist/preview.d.ts +7 -0
  44. package/dist/preview.d.ts.map +1 -1
  45. package/dist/preview.js +14 -2
  46. package/dist/preview.js.map +1 -1
  47. package/dist/signing-requests.d.ts +81 -0
  48. package/dist/signing-requests.d.ts.map +1 -0
  49. package/dist/signing-requests.js +138 -0
  50. package/dist/signing-requests.js.map +1 -0
  51. package/dist/tools/index.d.ts +27 -1
  52. package/dist/tools/index.d.ts.map +1 -1
  53. package/dist/tools/index.js +37 -15
  54. package/dist/tools/index.js.map +1 -1
  55. package/dist/tools/{advanced.d.ts → read/advanced.d.ts} +30 -26
  56. package/dist/tools/read/advanced.d.ts.map +1 -0
  57. package/dist/tools/{advanced.js → read/advanced.js} +10 -10
  58. package/dist/tools/read/advanced.js.map +1 -0
  59. package/dist/tools/{balance.d.ts → read/balance.d.ts} +8 -7
  60. package/dist/tools/read/balance.d.ts.map +1 -0
  61. package/dist/tools/{balance.js → read/balance.js} +2 -2
  62. package/dist/tools/read/balance.js.map +1 -0
  63. package/dist/tools/{contract-info.d.ts → read/contract-info.d.ts} +18 -15
  64. package/dist/tools/read/contract-info.d.ts.map +1 -0
  65. package/dist/tools/{contract-info.js → read/contract-info.js} +2 -2
  66. package/dist/tools/read/contract-info.js.map +1 -0
  67. package/dist/tools/{contract.d.ts → read/contract.d.ts} +14 -12
  68. package/dist/tools/read/contract.d.ts.map +1 -0
  69. package/dist/tools/{contract.js → read/contract.js} +2 -2
  70. package/dist/tools/read/contract.js.map +1 -0
  71. package/dist/tools/{ens.d.ts → read/ens.d.ts} +30 -25
  72. package/dist/tools/read/ens.d.ts.map +1 -0
  73. package/dist/tools/{ens.js → read/ens.js} +2 -2
  74. package/dist/tools/read/ens.js.map +1 -0
  75. package/dist/tools/read/gas.d.ts +171 -0
  76. package/dist/tools/read/gas.d.ts.map +1 -0
  77. package/dist/tools/read/gas.js +368 -0
  78. package/dist/tools/read/gas.js.map +1 -0
  79. package/dist/tools/{logs.d.ts → read/logs.d.ts} +6 -5
  80. package/dist/tools/read/logs.d.ts.map +1 -0
  81. package/dist/tools/{logs.js → read/logs.js} +2 -2
  82. package/dist/tools/read/logs.js.map +1 -0
  83. package/dist/tools/{signatures.d.ts → read/signatures.d.ts} +8 -4
  84. package/dist/tools/read/signatures.d.ts.map +1 -0
  85. package/dist/tools/{signatures.js → read/signatures.js} +1 -1
  86. package/dist/tools/read/signatures.js.map +1 -0
  87. package/dist/tools/read/signing.d.ts +27 -0
  88. package/dist/tools/read/signing.d.ts.map +1 -0
  89. package/dist/tools/read/signing.js +75 -0
  90. package/dist/tools/read/signing.js.map +1 -0
  91. package/dist/tools/sign/errors.d.ts +9 -0
  92. package/dist/tools/sign/errors.d.ts.map +1 -0
  93. package/dist/tools/sign/errors.js +29 -0
  94. package/dist/tools/sign/errors.js.map +1 -0
  95. package/dist/tools/{transactions.d.ts → sign/transactions.d.ts} +48 -62
  96. package/dist/tools/sign/transactions.d.ts.map +1 -0
  97. package/dist/tools/sign/transactions.js +316 -0
  98. package/dist/tools/sign/transactions.js.map +1 -0
  99. package/dist/tools/sign/wallet.d.ts +50 -0
  100. package/dist/tools/sign/wallet.d.ts.map +1 -0
  101. package/dist/tools/sign/wallet.js +180 -0
  102. package/dist/tools/sign/wallet.js.map +1 -0
  103. package/dist/utils.d.ts +5 -2
  104. package/dist/utils.d.ts.map +1 -1
  105. package/dist/utils.js +5 -2
  106. package/dist/utils.js.map +1 -1
  107. package/dist/wallet-client.d.ts +33 -4
  108. package/dist/wallet-client.d.ts.map +1 -1
  109. package/dist/wallet-client.js +90 -24
  110. package/dist/wallet-client.js.map +1 -1
  111. package/dist/walletconnect.d.ts +111 -13
  112. package/dist/walletconnect.d.ts.map +1 -1
  113. package/dist/walletconnect.js +248 -68
  114. package/dist/walletconnect.js.map +1 -1
  115. package/package.json +10 -4
  116. package/src/anvil.ts +150 -271
  117. package/src/chains.ts +14 -2
  118. package/src/client.ts +0 -1
  119. package/src/context.ts +27 -0
  120. package/src/hosted.ts +11 -0
  121. package/src/http-server.ts +30 -5
  122. package/src/index.ts +19 -16
  123. package/src/lib.ts +24 -0
  124. package/src/log.ts +25 -0
  125. package/src/mcp-server.ts +47 -0
  126. package/src/oauth.ts +98 -45
  127. package/src/preview.ts +21 -2
  128. package/src/signing-requests.ts +198 -0
  129. package/src/tools/index.ts +67 -16
  130. package/src/tools/{advanced.ts → read/advanced.ts} +13 -11
  131. package/src/tools/{balance.ts → read/balance.ts} +3 -3
  132. package/src/tools/{contract-info.ts → read/contract-info.ts} +3 -3
  133. package/src/tools/{contract.ts → read/contract.ts} +3 -3
  134. package/src/tools/{ens.ts → read/ens.ts} +3 -3
  135. package/src/tools/read/gas.ts +480 -0
  136. package/src/tools/{logs.ts → read/logs.ts} +3 -3
  137. package/src/tools/{signatures.ts → read/signatures.ts} +2 -2
  138. package/src/tools/read/signing.ts +86 -0
  139. package/src/tools/sign/errors.ts +28 -0
  140. package/src/tools/sign/transactions.ts +428 -0
  141. package/src/tools/sign/wallet.ts +216 -0
  142. package/src/utils.ts +5 -2
  143. package/src/wallet-client.ts +91 -22
  144. package/src/walletconnect.ts +286 -66
  145. package/dist/tools/advanced.d.ts.map +0 -1
  146. package/dist/tools/advanced.js.map +0 -1
  147. package/dist/tools/balance.d.ts.map +0 -1
  148. package/dist/tools/balance.js.map +0 -1
  149. package/dist/tools/contract-info.d.ts.map +0 -1
  150. package/dist/tools/contract-info.js.map +0 -1
  151. package/dist/tools/contract.d.ts.map +0 -1
  152. package/dist/tools/contract.js.map +0 -1
  153. package/dist/tools/ens.d.ts.map +0 -1
  154. package/dist/tools/ens.js.map +0 -1
  155. package/dist/tools/gas.d.ts +0 -99
  156. package/dist/tools/gas.d.ts.map +0 -1
  157. package/dist/tools/gas.js +0 -181
  158. package/dist/tools/gas.js.map +0 -1
  159. package/dist/tools/logs.d.ts.map +0 -1
  160. package/dist/tools/logs.js.map +0 -1
  161. package/dist/tools/signatures.d.ts.map +0 -1
  162. package/dist/tools/signatures.js.map +0 -1
  163. package/dist/tools/transactions.d.ts.map +0 -1
  164. package/dist/tools/transactions.js +0 -361
  165. package/dist/tools/transactions.js.map +0 -1
  166. package/src/tools/gas.ts +0 -219
  167. package/src/tools/transactions.ts +0 -452
@@ -0,0 +1,428 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { type AbiFunction, type Address, encodeFunctionData, isAddress, parseAbiItem, parseUnits, toHex } from 'viem'
3
+ import { z } from 'zod'
4
+ import { getClientManager, SUPPORTED_CHAINS } from '../../client.js'
5
+ import { log } from '../../log.js'
6
+ import { buildTxPreview, type RawTx, type TxPreview } from '../../preview.js'
7
+ import { type SigningProgress, type SigningRequest, signingCall } from '../../signing-requests.js'
8
+ import type { ChainName } from '../../types.js'
9
+ import { createTool, formatResponse } from '../../utils.js'
10
+ import { getWalletClient } from '../../wallet-client.js'
11
+ import { getPhoneSigner } from '../../walletconnect.js'
12
+ import { failure } from './errors.js'
13
+
14
+ function generateRequestId(): string {
15
+ return randomBytes(16).toString('hex')
16
+ }
17
+
18
+ function requireAddress(label: string, address: string): Address {
19
+ if (!isAddress(address)) throw new Error(`Invalid ${label}: ${address}`)
20
+ return address
21
+ }
22
+
23
+ function explorerTxUrl(chain: ChainName, txHash: unknown): string | undefined {
24
+ return getClientManager().explorerUrl(chain, `/tx/${txHash}`)
25
+ }
26
+
27
+ /**
28
+ * Which wallet to send the request to. Deliberately required on every signing tool: both
29
+ * signers can be live at once, and only the person holding the devices knows which one is
30
+ * actually to hand. Guessing strands the request in front of a screen nobody is looking at.
31
+ *
32
+ * Either way the transaction is decoded and simulated first — the wallet shows its own
33
+ * summary, so the preview is returned to the caller too, to be read before approving.
34
+ */
35
+ const SignWithSchema = z
36
+ .enum(['phone', 'browser'])
37
+ .describe(
38
+ 'Where to approve this: "phone" for a paired WalletConnect wallet, "browser" for the wallet signing page. Ask the user which they want — do not assume, even when only one is connected. Call wallet_status first: it says which signers are ready, and when no signing page is open it returns walletUrl. In that case give the user that link, ask them to open it and connect a wallet, and only call this once they confirm — a signing request cannot reach a page nobody has open.'
39
+ )
40
+
41
+ type SignWith = z.infer<typeof SignWithSchema>
42
+
43
+ /** Only meaningful with several wallets paired; wallet_status lists them. */
44
+ const AccountSchema = z
45
+ .string()
46
+ .optional()
47
+ .describe(
48
+ 'Which paired wallet to ask, by address. Only needed when wallet_status reports more than one — omit it and the most recently paired one is used.'
49
+ )
50
+
51
+ /** This identity's phone wallet and the address to sign from, or an error saying what to do. */
52
+ async function requirePhoneSession(identity: string, account?: string) {
53
+ const phone = getPhoneSigner()
54
+ if (!phone) {
55
+ throw new Error(
56
+ 'Phone signing is not configured on this server. Set WALLETCONNECT_PROJECT_ID, or sign in the browser instead.'
57
+ )
58
+ }
59
+
60
+ const session = await phone.session(identity, account)
61
+ if (!session) {
62
+ const paired = await phone.sessions(identity)
63
+ throw new Error(
64
+ account
65
+ ? `No paired wallet holds ${account}. Paired: ${paired.flatMap((s) => s.accounts).join(', ') || 'none'}.`
66
+ : 'No phone wallet is paired. Run pair_phone_wallet and scan the QR, or sign in the browser instead.'
67
+ )
68
+ }
69
+
70
+ // A session can expose several addresses; the one asked for, spelled as the wallet
71
+ // reported it, or its first when the caller did not choose.
72
+ const wanted = account?.toLowerCase()
73
+ const from = session.accounts.find((a) => a.toLowerCase() === wanted) ?? session.accounts[0]
74
+ return { phone, session, from }
75
+ }
76
+
77
+ /** Long enough for a congested chain, short enough that a stuck one is reported rather than hung on. */
78
+ const RECEIPT_TIMEOUT = 180_000
79
+
80
+ /**
81
+ * Wait for the transaction to actually be mined.
82
+ *
83
+ * A hash means broadcast, nothing more. Reporting that as success is how a reverted
84
+ * transaction — gas spent, nothing done — gets read back as if it had worked.
85
+ */
86
+ async function confirm(chain: ChainName, txHash: unknown) {
87
+ try {
88
+ const receipt = await getClientManager()
89
+ .getClient(chain)
90
+ .waitForTransactionReceipt({ hash: txHash as `0x${string}`, timeout: RECEIPT_TIMEOUT })
91
+
92
+ return {
93
+ status: receipt.status === 'success' ? ('mined' as const) : ('reverted' as const),
94
+ blockNumber: receipt.blockNumber.toString(),
95
+ gasUsed: receipt.gasUsed.toString()
96
+ }
97
+ } catch (error) {
98
+ // Still out there — it simply has not been mined yet. Not a failure, and saying so beats
99
+ // claiming either outcome.
100
+ log('warning', 'Transaction', `No receipt for ${txHash} yet: ${(error as Error).message}`)
101
+ return { status: 'broadcast' as const }
102
+ }
103
+ }
104
+
105
+ async function signOnPhone(
106
+ chain: ChainName,
107
+ tx: RawTx & { data?: string },
108
+ identity: string,
109
+ account: string | undefined,
110
+ progress: SigningProgress
111
+ ) {
112
+ const { phone, from } = await requirePhoneSession(identity, account)
113
+ const preview = await buildTxPreview(chain, tx, from)
114
+
115
+ const txHash = await phone.request(
116
+ chain,
117
+ 'eth_sendTransaction',
118
+ [{ from, to: tx.to, value: tx.value ?? '0x0', ...(tx.data && { data: tx.data }) }],
119
+ { identity, account, onWaiting: progress.waiting }
120
+ )
121
+
122
+ progress.mining(String(txHash))
123
+ return { txHash, preview, signedWith: 'phone' as const, receipt: await confirm(chain, txHash) }
124
+ }
125
+
126
+ /**
127
+ * `onWaiting` is what decides whether the tool call waits.
128
+ *
129
+ * The browser signer never calls it: the tab is opened and the title flashes, so somebody is
130
+ * looking within seconds and the grace period is worth spending. A phone is only as loud as
131
+ * the wallet, and the two people actually use push nothing at all — so that path hands back
132
+ * a request id as soon as the wallet has it, rather than spending the grace on silence.
133
+ */
134
+ async function requestSignature(
135
+ chain: ChainName,
136
+ tx: RawTx & { data?: string },
137
+ signWith: SignWith,
138
+ identity: string,
139
+ account: string | undefined,
140
+ progress: SigningProgress
141
+ ) {
142
+ log('info', 'Transaction', `${chain} → ${tx.to}, signing with ${signWith}`)
143
+ if (signWith === 'phone') return signOnPhone(chain, tx, identity, account, progress)
144
+
145
+ const wallet = getWalletClient(identity)
146
+ await wallet.waitForSigner()
147
+
148
+ const preview = await buildTxPreview(chain, tx, wallet.getAddress())
149
+
150
+ const txHash = await wallet.request({
151
+ id: generateRequestId(),
152
+ type: 'send_transaction',
153
+ chain,
154
+ data: { to: tx.to, value: tx.value ?? '0x0', ...(tx.data && { data: tx.data }) },
155
+ preview
156
+ })
157
+
158
+ progress.mining(String(txHash))
159
+ return { txHash, preview, signedWith: 'browser' as const, receipt: await confirm(chain, txHash) }
160
+ }
161
+
162
+ /**
163
+ * What to tell the agent when nobody has approved yet.
164
+ *
165
+ * Emphatically not a failure: the request is in front of a wallet right now. Calling the
166
+ * signing tool again would put a second, separately approvable request there — so the way
167
+ * back is the id, and this says so plainly enough that an agent does not retry instead.
168
+ */
169
+ function awaitingApproval(request: SigningRequest) {
170
+ return formatResponse({
171
+ success: true,
172
+ status: 'awaiting_approval',
173
+ requestId: request.id,
174
+ signedWith: request.signer,
175
+ what: request.summary,
176
+ nextStep: `Tell the user to approve it${request.signer === 'phone' ? ' in their phone wallet — some wallets do not notify, so they may need to open the app themselves' : ' in the signing page'}, then call check_signing_request with requestId "${request.id}". Do not call this tool again: that would ask for a second signature.`,
177
+ message: 'Sent to the wallet. Nothing has been signed or broadcast yet.'
178
+ })
179
+ }
180
+
181
+ /**
182
+ * Whether it worked, which is not the same question as whether it was sent.
183
+ *
184
+ * A reverted transaction is mined, costs gas and does nothing. Reporting it as a success
185
+ * because a hash came back is how an agent goes on to build on a state change that never
186
+ * happened.
187
+ */
188
+ function minedOutcome(receipt: { status: 'mined' | 'reverted' | 'broadcast'; blockNumber?: string; gasUsed?: string }) {
189
+ if (receipt.status === 'reverted') {
190
+ return {
191
+ success: false,
192
+ status: 'reverted',
193
+ blockNumber: receipt.blockNumber,
194
+ gasUsed: receipt.gasUsed,
195
+ message: 'Mined, but it reverted. The gas was spent and nothing else changed.'
196
+ }
197
+ }
198
+
199
+ if (receipt.status === 'broadcast') {
200
+ return {
201
+ success: true,
202
+ status: 'broadcast',
203
+ message: 'Signed and broadcast, but not mined yet. Check the explorer link before treating it as done.'
204
+ }
205
+ }
206
+
207
+ return { success: true, status: 'mined', blockNumber: receipt.blockNumber, gasUsed: receipt.gasUsed }
208
+ }
209
+
210
+ /** What the caller should see about a transaction before it is approved. */
211
+ function previewSummary(preview: TxPreview) {
212
+ return {
213
+ action: preview.decoded?.intent ?? preview.decoded?.functionName,
214
+ protocol: preview.decoded?.protocol ?? preview.toLabel,
215
+ details: preview.decoded?.fields.map((f) => `${f.name}: ${f.value}${f.warning ? ` (${f.warning})` : ''}`),
216
+ simulation: preview.simulation
217
+ }
218
+ }
219
+
220
+ export default {
221
+ send_native_token: createTool(
222
+ 'Send Native Token',
223
+ 'Send native tokens (ETH, MATIC, BNB, etc.) to an address. Simulates the transaction, then sends it to the signer you choose for approval.',
224
+ z.object({
225
+ chain: z.enum(SUPPORTED_CHAINS).describe('Blockchain network'),
226
+ to: z.string().describe('Recipient address'),
227
+ amount: z.string().describe('Amount in native token (e.g., "0.1" for 0.1 ETH)'),
228
+ data: z.string().optional().describe('Optional hex-encoded data to include with transaction'),
229
+ signWith: SignWithSchema,
230
+ account: AccountSchema
231
+ }),
232
+ async (args, identity) => {
233
+ try {
234
+ const to = requireAddress('recipient address', args.to)
235
+ const value = `0x${parseUnits(args.amount, 18).toString(16)}`
236
+
237
+ const outcome = await signingCall(
238
+ { identity, signer: args.signWith, summary: `Send ${args.amount} on ${args.chain} to ${to}` },
239
+ (progress) =>
240
+ requestSignature(
241
+ args.chain as ChainName,
242
+ { to, value, data: args.data },
243
+ args.signWith,
244
+ identity,
245
+ args.account,
246
+ progress
247
+ )
248
+ )
249
+ if (!outcome.done) return awaitingApproval(outcome.request)
250
+ const { txHash, preview, signedWith, receipt } = outcome.result
251
+
252
+ return formatResponse({
253
+ ...minedOutcome(receipt),
254
+ chain: args.chain,
255
+ transactionHash: txHash,
256
+ to,
257
+ amount: args.amount,
258
+ signedWith,
259
+ preview: previewSummary(preview),
260
+ explorerUrl: explorerTxUrl(args.chain as ChainName, txHash)
261
+ })
262
+ } catch (error) {
263
+ return failure(error, 'Transaction failed or was rejected')
264
+ }
265
+ }
266
+ ),
267
+
268
+ send_erc20_token: createTool(
269
+ 'Send ERC20 Token',
270
+ 'Send ERC20 tokens to an address. Simulates the transfer, then sends it to the signer you choose for approval.',
271
+ z.object({
272
+ chain: z.enum(SUPPORTED_CHAINS).describe('Blockchain network'),
273
+ tokenAddress: z.string().describe('ERC20 token contract address'),
274
+ to: z.string().describe('Recipient address'),
275
+ amount: z.string().describe('Amount in token units (e.g., "100" for 100 USDC)'),
276
+ decimals: z.number().optional().default(18).describe('Token decimals (default: 18)'),
277
+ signWith: SignWithSchema,
278
+ account: AccountSchema
279
+ }),
280
+ async (args, identity) => {
281
+ try {
282
+ const tokenAddress = requireAddress('token address', args.tokenAddress)
283
+ const to = requireAddress('recipient address', args.to)
284
+
285
+ const data = encodeFunctionData({
286
+ abi: [parseAbiItem('function transfer(address to, uint256 amount)')],
287
+ args: [to, parseUnits(args.amount, args.decimals ?? 18)]
288
+ })
289
+
290
+ const outcome = await signingCall(
291
+ { identity, signer: args.signWith, summary: `Send ${args.amount} of ${tokenAddress} on ${args.chain} to ${to}` },
292
+ (progress) =>
293
+ requestSignature(
294
+ args.chain as ChainName,
295
+ { to: tokenAddress, data, value: '0x0' },
296
+ args.signWith,
297
+ identity,
298
+ args.account,
299
+ progress
300
+ )
301
+ )
302
+ if (!outcome.done) return awaitingApproval(outcome.request)
303
+ const { txHash, preview, signedWith, receipt } = outcome.result
304
+
305
+ return formatResponse({
306
+ ...minedOutcome(receipt),
307
+ chain: args.chain,
308
+ transactionHash: txHash,
309
+ tokenAddress,
310
+ to,
311
+ amount: args.amount,
312
+ signedWith,
313
+ preview: previewSummary(preview),
314
+ explorerUrl: explorerTxUrl(args.chain as ChainName, txHash)
315
+ })
316
+ } catch (error) {
317
+ return failure(error, 'Token transfer failed or was rejected')
318
+ }
319
+ }
320
+ ),
321
+
322
+ write_contract: createTool(
323
+ 'Write Contract',
324
+ 'Send a transaction that calls a state-changing contract function. Simulates it first, then asks your wallet to approve. Costs gas.',
325
+ z.object({
326
+ chain: z.enum(SUPPORTED_CHAINS).describe('Blockchain network'),
327
+ contractAddress: z.string().describe('Contract address'),
328
+ functionAbi: z.string().describe('Function ABI definition (e.g., "function transfer(address to, uint256 amount)")'),
329
+ args: z
330
+ .array(z.union([z.string(), z.number(), z.boolean()]))
331
+ .optional()
332
+ .describe('Function arguments in order matching the ABI signature'),
333
+ value: z.string().optional().describe('Optional native value to send with transaction (in ETH units, e.g., "0.1")'),
334
+ signWith: SignWithSchema,
335
+ account: AccountSchema
336
+ }),
337
+ async (args, identity) => {
338
+ try {
339
+ const contractAddress = requireAddress('contract address', args.contractAddress)
340
+ const abiItem = parseAbiItem(args.functionAbi) as AbiFunction
341
+
342
+ const data = encodeFunctionData({
343
+ abi: [abiItem],
344
+ functionName: abiItem.name,
345
+ args: (args.args || []) as readonly unknown[]
346
+ })
347
+ const value = args.value ? `0x${parseUnits(args.value, 18).toString(16)}` : '0x0'
348
+
349
+ const outcome = await signingCall(
350
+ { identity, signer: args.signWith, summary: `${abiItem.name}() on ${contractAddress} (${args.chain})` },
351
+ (progress) =>
352
+ requestSignature(
353
+ args.chain as ChainName,
354
+ { to: contractAddress, data, value },
355
+ args.signWith,
356
+ identity,
357
+ args.account,
358
+ progress
359
+ )
360
+ )
361
+ if (!outcome.done) return awaitingApproval(outcome.request)
362
+ const { txHash, preview, signedWith, receipt } = outcome.result
363
+
364
+ return formatResponse({
365
+ ...minedOutcome(receipt),
366
+ chain: args.chain,
367
+ transactionHash: txHash,
368
+ contractAddress,
369
+ functionName: abiItem.name,
370
+ signedWith,
371
+ preview: previewSummary(preview),
372
+ explorerUrl: explorerTxUrl(args.chain as ChainName, txHash)
373
+ })
374
+ } catch (error) {
375
+ return failure(error, 'Contract call failed or was rejected')
376
+ }
377
+ }
378
+ ),
379
+
380
+ sign_message: createTool(
381
+ 'Sign Message',
382
+ 'Sign a message with your wallet, on whichever signer you choose.',
383
+ z.object({
384
+ message: z.string().describe('Message to sign'),
385
+ signWith: SignWithSchema,
386
+ account: AccountSchema
387
+ }),
388
+ async (args, identity) => {
389
+ try {
390
+ const sign = async (progress: SigningProgress) => {
391
+ if (args.signWith === 'phone') {
392
+ const { phone, from } = await requirePhoneSession(identity, args.account)
393
+ return phone.request('mainnet', 'personal_sign', [toHex(args.message), from], {
394
+ identity,
395
+ account: args.account,
396
+ onWaiting: progress.waiting
397
+ })
398
+ }
399
+
400
+ const wallet = getWalletClient(identity)
401
+ await wallet.waitForSigner()
402
+ return wallet.request({
403
+ id: generateRequestId(),
404
+ type: 'sign_message',
405
+ chain: 'any',
406
+ data: { message: args.message }
407
+ })
408
+ }
409
+
410
+ const outcome = await signingCall(
411
+ { identity, signer: args.signWith, summary: `Sign the message "${args.message.slice(0, 60)}"` },
412
+ sign
413
+ )
414
+ if (!outcome.done) return awaitingApproval(outcome.request)
415
+
416
+ return formatResponse({
417
+ success: true,
418
+ message: args.message,
419
+ signature: outcome.result,
420
+ signedWith: args.signWith,
421
+ signatureType: 'personal_sign'
422
+ })
423
+ } catch (error) {
424
+ return failure(error, 'Message signing failed or was rejected')
425
+ }
426
+ }
427
+ )
428
+ }
@@ -0,0 +1,216 @@
1
+ import qrcode from 'qrcode-terminal'
2
+ import { z } from 'zod'
3
+ import { getClientManager, SUPPORTED_CHAINS } from '../../client.js'
4
+ import { pendingSigningRequests } from '../../signing-requests.js'
5
+ import type { ChainName } from '../../types.js'
6
+ import { createTool, formatResponse } from '../../utils.js'
7
+ import { getWalletClient } from '../../wallet-client.js'
8
+ import { getPhoneSigner, pairingLinks } from '../../walletconnect.js'
9
+ import { describeError, failure } from './errors.js'
10
+
11
+ /**
12
+ * Connecting a wallet and reporting what is connected — separate from the tools that ask
13
+ * one to sign, because this is the part a user does once and that has nothing to do with
14
+ * any particular transaction.
15
+ */
16
+
17
+ /** Render a pairing URI as an ASCII QR, so it can be scanned straight from the chat. */
18
+ function qrFor(uri: string): Promise<string> {
19
+ return new Promise((resolve) => qrcode.generate(uri, { small: true }, resolve))
20
+ }
21
+
22
+ export default {
23
+ pair_phone_wallet: createTool(
24
+ 'Pair Phone Wallet',
25
+ 'Pair a phone wallet over WalletConnect, so transactions are signed on the phone instead of in a browser. ' +
26
+ 'Returns a QR to scan plus links that open a wallet directly — show those to the user as clickable links. ' +
27
+ 'Always pairs: call it when the user wants a wallet connected, and call wallet_status instead to find out ' +
28
+ 'whether one already is. Pairing again adds a wallet rather than replacing one, and signing tools then take ' +
29
+ '"account" to choose between them. Requires a WalletConnect project id.',
30
+ z.object({
31
+ chains: z
32
+ .array(z.enum(SUPPORTED_CHAINS))
33
+ .optional()
34
+ .describe('Chains to request access to (defaults to every supported network)'),
35
+ wallet: z.string().optional().describe('Name of the wallet to open, e.g. "rabby" — omit to list the common ones'),
36
+ agent: z
37
+ .string()
38
+ .optional()
39
+ .describe(
40
+ 'Who is asking, shown in the wallet approval dialog, e.g. "Claude Code". Say who you actually are — ' +
41
+ 'the user reads this when deciding whether to grant signing access.'
42
+ )
43
+ }),
44
+ async (args, identity) => {
45
+ const phone = getPhoneSigner()
46
+ if (!phone) {
47
+ return formatResponse({
48
+ success: false,
49
+ message:
50
+ 'No WalletConnect project id configured. Get a free one at https://dashboard.walletconnect.com and pass --walletconnect-project-id or set WALLETCONNECT_PROJECT_ID.'
51
+ })
52
+ }
53
+
54
+ try {
55
+ // Before anything starts the client: its name is fixed the moment it does.
56
+ phone.nameAgent(args.agent)
57
+ const paired = await phone.sessions(identity)
58
+
59
+ const chains = (args.chains ?? SUPPORTED_CHAINS.filter((c) => c !== 'localhost')) as ChainName[]
60
+ const uri = await phone.pair(chains, identity)
61
+ const projectId = getClientManager().getConfig().walletConnectProjectId as string
62
+
63
+ return formatResponse({
64
+ qr: await qrFor(uri),
65
+ uri,
66
+ // Pairing adds rather than replaces, so say what is already there — scanning this
67
+ // leaves the user with both, and signing tools take `account` to pick.
68
+ ...(paired.length > 0 && {
69
+ alreadyPaired: paired.map((session) => ({ address: session.accounts[0], wallet: session.peer })),
70
+ // Whether both survive is the wallet's call, not this server's, and some keep
71
+ // only one session per site.
72
+ keepingBoth:
73
+ `Approving this in a wallet app already listed above (${[...new Set(paired.map((s) => s.peer).filter(Boolean))].join(', ')}) ` +
74
+ 'may end its existing session — some wallets keep one per site. To hold both at once, approve this in a different wallet app.'
75
+ }),
76
+ // For an agent running on the phone itself there is nothing to scan, so hand over
77
+ // links that open the wallet directly. `wallet` narrows the list to one.
78
+ openOnThisDevice: await pairingLinks(projectId, uri, args.wallet),
79
+ // Approving grants an agent standing permission to request signatures, so the
80
+ // wallet dialog names the agent rather than implying a website is connecting.
81
+ shownInWallet: args.agent ? `${args.agent} via web3-tools-mcp` : 'web3-tools-mcp (AI agent)',
82
+ howToPresent:
83
+ 'Render each openOnThisDevice entry as a clickable link labelled with the wallet name, and show the ' +
84
+ 'uri as copyable text. On a phone that tap is the whole flow. Show the QR only when the user is on a ' +
85
+ 'different device from their wallet — it is unreadable inline on the phone that holds the wallet. ' +
86
+ 'If a link does nothing, some clients drop custom schemes like rabby://, so tell them to paste the uri.',
87
+ message:
88
+ 'On another device: scan the QR with your wallet. On this device: tap one of the openOnThisDevice ' +
89
+ 'links, or copy the uri and paste it into your wallet under WalletConnect — every wallet supports ' +
90
+ 'that, and it is the one that always works. Then run wallet_status to confirm.'
91
+ })
92
+ } catch (error) {
93
+ return failure(error, 'Could not start WalletConnect pairing')
94
+ }
95
+ }
96
+ ),
97
+
98
+ disconnect_phone_wallet: createTool(
99
+ 'Disconnect Phone Wallet',
100
+ 'End WalletConnect sessions and drop the pairings underneath them. Without "account" this disconnects every paired wallet, so name one when the user only wants that wallet gone — wallet_status lists them.',
101
+ z.object({
102
+ account: z
103
+ .string()
104
+ .optional()
105
+ .describe('Address of the one wallet to disconnect. Omit to disconnect every wallet paired here.')
106
+ }),
107
+ async (args, identity) => {
108
+ const phone = getPhoneSigner()
109
+ if (!phone) {
110
+ return formatResponse({ success: false, message: 'No WalletConnect project id configured — nothing to disconnect.' })
111
+ }
112
+
113
+ try {
114
+ const before = await phone.sessions(identity)
115
+ const removed = await phone.disconnect(identity, args.account)
116
+ const left = await phone.sessions(identity)
117
+
118
+ return formatResponse({
119
+ success: true,
120
+ disconnectedSessions: removed.sessions,
121
+ droppedPairings: removed.pairings,
122
+ wallets: before.map((session) => `${session.peer ?? 'unknown wallet'} (${session.accounts.join(', ')})`),
123
+ stillPaired: left.map((session) => ({ address: session.accounts[0], wallet: session.peer })),
124
+ message:
125
+ removed.sessions || removed.pairings
126
+ ? left.length > 0
127
+ ? `Disconnected. ${left.length} wallet(s) still paired — pass "account" to a signing tool to choose between them.`
128
+ : 'Disconnected. Transactions fall back to the browser wallet until a phone is paired again.'
129
+ : 'Nothing was connected.'
130
+ })
131
+ } catch (error) {
132
+ return failure(error, 'Could not disconnect the phone wallet')
133
+ }
134
+ }
135
+ ),
136
+
137
+ wallet_status: createTool(
138
+ 'Wallet Status',
139
+ 'Report every signer and whether it is ready, so a signing tool can be given the right signWith.',
140
+ z.object({}),
141
+ async (_args, identity) => {
142
+ // Both signers are reported, never just the first one found: signWith makes this a
143
+ // choice the user owns, so the agent has to see everything available.
144
+ const phone = getPhoneSigner()
145
+ const phonesPaired = (await phone?.sessions(identity).catch(() => undefined)) ?? []
146
+ const phoneSession = phonesPaired[phonesPaired.length - 1]
147
+
148
+ const wallet = getWalletClient(identity)
149
+ const relayError = await wallet
150
+ .connect()
151
+ .then(() => undefined)
152
+ .catch((error) => describeError(error))
153
+
154
+ const browserReady = !relayError && wallet.isConnected()
155
+ // Only raise a tab when there is no other way to sign — a status check should not
156
+ // open a window at someone who is about to use their phone.
157
+ if (!browserReady && !phoneSession && !relayError) wallet.openBrowser()
158
+
159
+ const signers = {
160
+ phone: phoneSession
161
+ ? {
162
+ ready: true,
163
+ address: phoneSession.accounts[0],
164
+ // One wallet can expose several addresses; `account` on a signing tool picks.
165
+ ...(phoneSession.accounts.length > 1 && { accounts: phoneSession.accounts }),
166
+ wallet: phoneSession.peer,
167
+ chains: phoneSession.chains,
168
+ // Only when there is a choice to make: pass one of these as `account`.
169
+ ...(phonesPaired.length > 1 && {
170
+ alsoPaired: phonesPaired.slice(0, -1).map((s) => ({ address: s.accounts[0], wallet: s.peer, chains: s.chains }))
171
+ })
172
+ }
173
+ : {
174
+ ready: false,
175
+ reason: phone
176
+ ? 'No phone paired. Run pair_phone_wallet and scan the QR.'
177
+ : 'Phone signing is not configured — set WALLETCONNECT_PROJECT_ID.'
178
+ },
179
+ browser: browserReady
180
+ ? { ready: true, address: wallet.getAddress(), openTab: wallet.getPageUrl() }
181
+ : {
182
+ ready: false,
183
+ reason: relayError ?? 'No signing page is open.',
184
+ walletUrl: relayError ? undefined : wallet.getUrl()
185
+ }
186
+ }
187
+
188
+ const ready = [phoneSession ? 'phone' : undefined, browserReady ? 'browser' : undefined].filter(Boolean)
189
+ const message =
190
+ ready.length === 2
191
+ ? 'Both signers are ready. Ask the user which one to use, then pass it as signWith.'
192
+ : ready.length === 1
193
+ ? `Only ${ready[0]} is ready. Confirm with the user before signing there, or set the other one up.`
194
+ : `No signer is ready. For a browser, give the user this link and ask them to open it and connect a wallet: ${signers.browser.walletUrl ?? '(relay unavailable)'}. For a phone, run pair_phone_wallet.`
195
+
196
+ // Handed back by a signing tool that outlasted its call, and collectable by id —
197
+ // otherwise a signature waiting on somebody's phone is invisible from here.
198
+ const waiting = pendingSigningRequests(identity)
199
+
200
+ return formatResponse({
201
+ ready,
202
+ signers,
203
+ ...(waiting.length > 0 && {
204
+ awaitingApproval: waiting.map((request) => ({
205
+ requestId: request.id,
206
+ what: request.summary,
207
+ signedWith: request.signer,
208
+ waitingFor: `${Math.round((Date.now() - request.startedAt) / 1000)}s`
209
+ })),
210
+ nextStep: 'Collect each of these with check_signing_request rather than asking for the signature again.'
211
+ }),
212
+ message
213
+ })
214
+ }
215
+ )
216
+ }
package/src/utils.ts CHANGED
@@ -7,13 +7,16 @@ export function createTool<T extends z.ZodType>(
7
7
  title: string,
8
8
  description: string,
9
9
  schema: T,
10
- handler: (args: z.infer<T>) => Promise<ToolResult>
10
+ handler: (args: z.infer<T>, identity: string) => Promise<ToolResult>,
11
+ /** Whether this server can serve the tool at all; one it cannot is never offered. */
12
+ available?: () => boolean
11
13
  ) {
12
14
  return {
13
15
  title,
14
16
  description,
15
17
  schema,
16
- handler
18
+ handler,
19
+ available
17
20
  }
18
21
  }
19
22