web3-tools-mcp 1.6.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/README.md +328 -234
  3. package/dist/anvil.d.ts +1 -1
  4. package/dist/anvil.d.ts.map +1 -1
  5. package/dist/anvil.js +11 -9
  6. package/dist/anvil.js.map +1 -1
  7. package/dist/chain-meta.d.ts.map +1 -1
  8. package/dist/chain-meta.js +7 -3
  9. package/dist/chain-meta.js.map +1 -1
  10. package/dist/chains.d.ts +960 -0
  11. package/dist/chains.d.ts.map +1 -0
  12. package/dist/chains.js +108 -0
  13. package/dist/chains.js.map +1 -0
  14. package/dist/client.d.ts +10 -1717
  15. package/dist/client.d.ts.map +1 -1
  16. package/dist/client.js +33 -218
  17. package/dist/client.js.map +1 -1
  18. package/dist/http-server.d.ts +6 -0
  19. package/dist/http-server.d.ts.map +1 -1
  20. package/dist/http-server.js +55 -40
  21. package/dist/http-server.js.map +1 -1
  22. package/dist/index.js +50 -29
  23. package/dist/index.js.map +1 -1
  24. package/dist/oauth.d.ts +31 -0
  25. package/dist/oauth.d.ts.map +1 -0
  26. package/dist/oauth.js +227 -0
  27. package/dist/oauth.js.map +1 -0
  28. package/dist/preview.d.ts.map +1 -1
  29. package/dist/preview.js +2 -2
  30. package/dist/preview.js.map +1 -1
  31. package/dist/tools/advanced.d.ts +4 -4
  32. package/dist/tools/advanced.d.ts.map +1 -1
  33. package/dist/tools/advanced.js +228 -247
  34. package/dist/tools/advanced.js.map +1 -1
  35. package/dist/tools/balance.d.ts +1 -1
  36. package/dist/tools/balance.d.ts.map +1 -1
  37. package/dist/tools/balance.js +97 -103
  38. package/dist/tools/balance.js.map +1 -1
  39. package/dist/tools/contract-info.d.ts +3 -3
  40. package/dist/tools/contract-info.d.ts.map +1 -1
  41. package/dist/tools/contract-info.js +150 -161
  42. package/dist/tools/contract-info.js.map +1 -1
  43. package/dist/tools/contract.d.ts +4 -4
  44. package/dist/tools/contract.d.ts.map +1 -1
  45. package/dist/tools/contract.js +91 -110
  46. package/dist/tools/contract.js.map +1 -1
  47. package/dist/tools/ens.d.ts +6 -6
  48. package/dist/tools/ens.d.ts.map +1 -1
  49. package/dist/tools/ens.js +239 -297
  50. package/dist/tools/ens.js.map +1 -1
  51. package/dist/tools/gas.d.ts +4 -4
  52. package/dist/tools/gas.d.ts.map +1 -1
  53. package/dist/tools/gas.js +90 -138
  54. package/dist/tools/gas.js.map +1 -1
  55. package/dist/tools/index.d.ts +1 -1
  56. package/dist/tools/index.d.ts.map +1 -1
  57. package/dist/tools/logs.d.ts +1 -1
  58. package/dist/tools/logs.d.ts.map +1 -1
  59. package/dist/tools/logs.js +36 -37
  60. package/dist/tools/logs.js.map +1 -1
  61. package/dist/tools/signatures.d.ts.map +1 -1
  62. package/dist/tools/signatures.js +67 -89
  63. package/dist/tools/signatures.js.map +1 -1
  64. package/dist/tools/transactions.d.ts +35 -5
  65. package/dist/tools/transactions.d.ts.map +1 -1
  66. package/dist/tools/transactions.js +148 -74
  67. package/dist/tools/transactions.js.map +1 -1
  68. package/dist/types.d.ts +1 -1
  69. package/dist/types.d.ts.map +1 -1
  70. package/dist/utils.d.ts +1 -1
  71. package/dist/utils.d.ts.map +1 -1
  72. package/dist/utils.js +35 -98
  73. package/dist/utils.js.map +1 -1
  74. package/dist/wallet-client.d.ts +11 -1
  75. package/dist/wallet-client.d.ts.map +1 -1
  76. package/dist/wallet-client.js +27 -9
  77. package/dist/wallet-client.js.map +1 -1
  78. package/dist/walletconnect.d.ts +22 -1
  79. package/dist/walletconnect.d.ts.map +1 -1
  80. package/dist/walletconnect.js +106 -8
  81. package/dist/walletconnect.js.map +1 -1
  82. package/package.json +17 -9
  83. package/src/anvil.ts +15 -18
  84. package/src/chain-meta.ts +8 -4
  85. package/src/chains.ts +145 -0
  86. package/src/client.ts +39 -254
  87. package/src/http-server.ts +67 -44
  88. package/src/index.ts +77 -53
  89. package/src/oauth.ts +270 -0
  90. package/src/preview.ts +3 -12
  91. package/src/tools/advanced.ts +195 -213
  92. package/src/tools/balance.ts +103 -108
  93. package/src/tools/contract-info.ts +163 -184
  94. package/src/tools/contract.ts +123 -144
  95. package/src/tools/ens.ts +290 -345
  96. package/src/tools/gas.ts +122 -170
  97. package/src/tools/index.ts +2 -2
  98. package/src/tools/logs.ts +45 -43
  99. package/src/tools/signatures.ts +68 -86
  100. package/src/tools/transactions.ts +194 -87
  101. package/src/types.ts +1 -15
  102. package/src/utils.ts +52 -110
  103. package/src/wallet-client.ts +31 -9
  104. package/src/walletconnect.ts +124 -7
@@ -2,12 +2,12 @@ import {
2
2
  type AbiEvent,
3
3
  type AbiFunction,
4
4
  type AbiParameter,
5
+ encodeFunctionData,
5
6
  keccak256,
6
7
  parseAbiItem,
7
8
  toBytes,
8
9
  toEventSignature,
9
- toFunctionSignature,
10
- encodeFunctionData
10
+ toFunctionSignature
11
11
  } from 'viem'
12
12
  import { z } from 'zod'
13
13
  import type { AbiError } from '../types.js'
@@ -24,9 +24,7 @@ const EventAbiSchema = z.object({
24
24
  })
25
25
 
26
26
  const ErrorAbiSchema = z.object({
27
- errorAbi: z
28
- .string()
29
- .describe('Error ABI definition (e.g., "error InsufficientBalance(uint256 available, uint256 required)")')
27
+ errorAbi: z.string().describe('Error ABI definition (e.g., "error InsufficientBalance(uint256 available, uint256 required)")')
30
28
  })
31
29
 
32
30
  export default {
@@ -34,33 +32,27 @@ export default {
34
32
  'Calculate Function Signatures',
35
33
  'Generate 4-byte function selectors from ABI definitions. BATCH OPTIMIZED - use for transaction decoding.',
36
34
  z.object({
37
- items: z
38
- .array(FunctionAbiSchema)
39
- .describe('Array of function ABI definitions. Batch multiple functions for efficiency.')
35
+ items: z.array(FunctionAbiSchema).describe('Array of function ABI definitions. Batch multiple functions for efficiency.')
40
36
  }),
41
37
  async (args) => {
42
- try {
43
- const results = args.items.map((item) => {
44
- const abiItem = parseAbiItem(item.functionAbi) as AbiFunction
45
- const signature = toFunctionSignature(abiItem)
46
- const hash = keccak256(toBytes(signature))
47
- const selector = hash.slice(0, 10)
38
+ const results = args.items.map((item) => {
39
+ const abiItem = parseAbiItem(item.functionAbi) as AbiFunction
40
+ const signature = toFunctionSignature(abiItem)
41
+ const hash = keccak256(toBytes(signature))
42
+ const selector = hash.slice(0, 10)
48
43
 
49
- return {
50
- signature: selector,
51
- fullSignature: signature,
52
- functionName: abiItem.name,
53
- inputs: abiItem.inputs,
54
- outputs: abiItem.outputs || [],
55
- stateMutability: abiItem.stateMutability,
56
- originalAbi: item.functionAbi
57
- }
58
- })
44
+ return {
45
+ signature: selector,
46
+ fullSignature: signature,
47
+ functionName: abiItem.name,
48
+ inputs: abiItem.inputs,
49
+ outputs: abiItem.outputs || [],
50
+ stateMutability: abiItem.stateMutability,
51
+ originalAbi: item.functionAbi
52
+ }
53
+ })
59
54
 
60
- return formatResponse(results)
61
- } catch (error) {
62
- throw new Error(`Failed to parse function ABI: ${error}`)
63
- }
55
+ return formatResponse(results)
64
56
  }
65
57
  ),
66
58
 
@@ -71,27 +63,23 @@ export default {
71
63
  items: z.array(EventAbiSchema).describe('Array of event ABI definitions. Batch multiple events for optimal performance.')
72
64
  }),
73
65
  async (args) => {
74
- try {
75
- const results = args.items.map((item) => {
76
- const abiItem = parseAbiItem(item.eventAbi) as AbiEvent
77
- const signature = toEventSignature(abiItem)
78
- const hash = keccak256(toBytes(signature))
66
+ const results = args.items.map((item) => {
67
+ const abiItem = parseAbiItem(item.eventAbi) as AbiEvent
68
+ const signature = toEventSignature(abiItem)
69
+ const hash = keccak256(toBytes(signature))
79
70
 
80
- return {
81
- signature: hash,
82
- topic0: hash,
83
- fullSignature: signature,
84
- eventName: abiItem.name,
85
- inputs: abiItem.inputs,
86
- anonymous: abiItem.anonymous || false,
87
- originalAbi: item.eventAbi
88
- }
89
- })
71
+ return {
72
+ signature: hash,
73
+ topic0: hash,
74
+ fullSignature: signature,
75
+ eventName: abiItem.name,
76
+ inputs: abiItem.inputs,
77
+ anonymous: abiItem.anonymous || false,
78
+ originalAbi: item.eventAbi
79
+ }
80
+ })
90
81
 
91
- return formatResponse(results)
92
- } catch (error) {
93
- throw new Error(`Failed to parse event ABI: ${error}`)
94
- }
82
+ return formatResponse(results)
95
83
  }
96
84
  ),
97
85
 
@@ -102,58 +90,52 @@ export default {
102
90
  items: z.array(ErrorAbiSchema).describe('Array of error ABI definitions. Batch related errors for efficiency.')
103
91
  }),
104
92
  async (args) => {
105
- try {
106
- const results = args.items.map((item) => {
107
- const abiItem = parseAbiItem(item.errorAbi) as AbiError
108
- const signature = `${abiItem.name}(${abiItem.inputs.map((input: AbiParameter) => input.type).join(',')})`
109
- const hash = keccak256(toBytes(signature))
110
- const selector = hash.slice(0, 10)
93
+ const results = args.items.map((item) => {
94
+ const abiItem = parseAbiItem(item.errorAbi) as AbiError
95
+ const signature = `${abiItem.name}(${abiItem.inputs.map((input: AbiParameter) => input.type).join(',')})`
96
+ const hash = keccak256(toBytes(signature))
97
+ const selector = hash.slice(0, 10)
111
98
 
112
- return {
113
- signature: selector,
114
- fullSignature: signature,
115
- errorName: abiItem.name,
116
- inputs: abiItem.inputs,
117
- originalAbi: item.errorAbi
118
- }
119
- })
99
+ return {
100
+ signature: selector,
101
+ fullSignature: signature,
102
+ errorName: abiItem.name,
103
+ inputs: abiItem.inputs,
104
+ originalAbi: item.errorAbi
105
+ }
106
+ })
120
107
 
121
- return formatResponse(results)
122
- } catch (error) {
123
- throw new Error(`Failed to parse error ABI: ${error}`)
124
- }
108
+ return formatResponse(results)
125
109
  }
126
110
  ),
127
111
 
128
112
  encode_function_data: createTool(
129
113
  'Encode Function Call Data',
130
- 'Encode a function call with parameters into transaction data. Use this before calling call_contract_write.',
114
+ 'Encode a function call with parameters into transaction data. Use this before calling write_contract.',
131
115
  z.object({
132
116
  functionAbi: z.string().describe('Function ABI definition (e.g., "function transfer(address to, uint256 amount)")'),
133
- args: z.array(z.union([z.string(), z.number(), z.boolean()])).describe('Function arguments in order matching the ABI signature. Automatically type-converted.')
117
+ args: z
118
+ .array(z.union([z.string(), z.number(), z.boolean()]))
119
+ .describe('Function arguments in order matching the ABI signature. Automatically type-converted.')
134
120
  }),
135
121
  async (args) => {
136
- try {
137
- const abiItem = parseAbiItem(args.functionAbi) as AbiFunction
122
+ const abiItem = parseAbiItem(args.functionAbi) as AbiFunction
138
123
 
139
- // Encode the function data
140
- const data = encodeFunctionData({
141
- abi: [abiItem],
142
- functionName: abiItem.name,
143
- args: args.args as readonly unknown[]
144
- })
124
+ // Encode the function data
125
+ const data = encodeFunctionData({
126
+ abi: [abiItem],
127
+ functionName: abiItem.name,
128
+ args: args.args as readonly unknown[]
129
+ })
145
130
 
146
- return formatResponse({
147
- data,
148
- functionName: abiItem.name,
149
- functionSignature: toFunctionSignature(abiItem),
150
- selector: data.slice(0, 10),
151
- encodedArgs: data.slice(10),
152
- message: 'Function data encoded successfully. Use this data with call_contract_write tool.'
153
- })
154
- } catch (error) {
155
- throw new Error(`Failed to encode function data: ${error}`)
156
- }
131
+ return formatResponse({
132
+ data,
133
+ functionName: abiItem.name,
134
+ functionSignature: toFunctionSignature(abiItem),
135
+ selector: data.slice(0, 10),
136
+ encodedArgs: data.slice(10),
137
+ message: 'Function data encoded successfully. Use this data with write_contract tool.'
138
+ })
157
139
  }
158
140
  )
159
141
  }
@@ -1,13 +1,13 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import qrcode from 'qrcode-terminal'
3
+ import { type AbiFunction, type Address, encodeFunctionData, isAddress, parseAbiItem, parseUnits, toHex } from 'viem'
1
4
  import { z } from 'zod'
2
- import { getWalletClient } from '../wallet-client.js'
3
5
  import { getClientManager, SUPPORTED_CHAINS } from '../client.js'
4
- import { encodeFunctionData, isAddress, parseAbiItem, parseUnits, toHex, type AbiFunction, type Address } from 'viem'
5
- import { randomBytes } from 'node:crypto'
6
- import type { ChainName } from '../types.js'
7
6
  import { buildTxPreview, type RawTx, type TxPreview } from '../preview.js'
8
- import { getPhoneSigner } from '../walletconnect.js'
7
+ import type { ChainName } from '../types.js'
9
8
  import { createTool, formatResponse } from '../utils.js'
10
- import qrcode from 'qrcode-terminal'
9
+ import { getWalletClient } from '../wallet-client.js'
10
+ import { getPhoneSigner, pairingLinks } from '../walletconnect.js'
11
11
 
12
12
  /** Render a pairing URI as an ASCII QR, so it can be scanned straight from the chat. */
13
13
  function qrFor(uri: string): Promise<string> {
@@ -23,32 +23,50 @@ function requireAddress(label: string, address: string): Address {
23
23
  return address
24
24
  }
25
25
 
26
- function explorerTxUrl(chain: ChainName, txHash: unknown): string {
27
- return `https://${getClientManager().getEtherscanDomain(chain)}/tx/${txHash}`
26
+ function explorerTxUrl(chain: ChainName, txHash: unknown): string | undefined {
27
+ return getClientManager().explorerUrl(chain, `/tx/${txHash}`)
28
28
  }
29
29
 
30
30
  /**
31
- * Send a transaction for signing.
31
+ * Which wallet to send the request to. Deliberately required on every signing tool: both
32
+ * signers can be live at once, and only the person holding the devices knows which one is
33
+ * actually to hand. Guessing strands the request in front of a screen nobody is looking at.
32
34
  *
33
- * A paired phone wins over the browser page: it was set up deliberately and is the only
34
- * option when there is no browser to open. Either way the transaction is decoded and
35
- * simulated first — the phone wallet shows its own summary, so the preview is returned to
36
- * the caller as well, where it can be read before the prompt is approved.
35
+ * Either way the transaction is decoded and simulated first — the wallet shows its own
36
+ * summary, so the preview is returned to the caller too, to be read before approving.
37
37
  */
38
- async function requestSignature(chain: ChainName, tx: RawTx & { data?: string }) {
38
+ const SignWithSchema = z
39
+ .enum(['phone', 'browser'])
40
+ .describe(
41
+ '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.'
42
+ )
43
+
44
+ type SignWith = z.infer<typeof SignWithSchema>
45
+
46
+ async function signOnPhone(chain: ChainName, tx: RawTx & { data?: string }) {
39
47
  const phone = getPhoneSigner()
48
+ if (!phone) {
49
+ throw new Error(
50
+ 'Phone signing is not configured on this server. Set WALLETCONNECT_PROJECT_ID, or sign in the browser instead.'
51
+ )
52
+ }
53
+ if (!(await phone.isPaired())) {
54
+ throw new Error('No phone wallet is paired. Run pair_phone_wallet and scan the QR, or sign in the browser instead.')
55
+ }
40
56
 
41
- if (await phone?.isPaired()) {
42
- const session = await phone!.session()
43
- const from = session?.accounts[0]
44
- const preview = await buildTxPreview(chain, tx, from)
57
+ const session = await phone.session()
58
+ const from = session?.accounts[0]
59
+ const preview = await buildTxPreview(chain, tx, from)
45
60
 
46
- const txHash = await phone!.request(chain, 'eth_sendTransaction', [
47
- { from, to: tx.to, value: tx.value ?? '0x0', ...(tx.data && { data: tx.data }) }
48
- ])
61
+ const txHash = await phone.request(chain, 'eth_sendTransaction', [
62
+ { from, to: tx.to, value: tx.value ?? '0x0', ...(tx.data && { data: tx.data }) }
63
+ ])
49
64
 
50
- return { txHash, preview, signedWith: 'phone' as const }
51
- }
65
+ return { txHash, preview, signedWith: 'phone' as const }
66
+ }
67
+
68
+ async function requestSignature(chain: ChainName, tx: RawTx & { data?: string }, signWith: SignWith) {
69
+ if (signWith === 'phone') return signOnPhone(chain, tx)
52
70
 
53
71
  const wallet = getWalletClient()
54
72
  await wallet.waitForSigner()
@@ -106,19 +124,24 @@ function failure(error: unknown, message: string) {
106
124
  export default {
107
125
  send_native_token: createTool(
108
126
  'Send Native Token',
109
- 'Send native tokens (ETH, MATIC, BNB, etc.) to an address. Simulates the transaction, then opens the browser wallet for approval.',
127
+ 'Send native tokens (ETH, MATIC, BNB, etc.) to an address. Simulates the transaction, then sends it to the signer you choose for approval.',
110
128
  z.object({
111
129
  chain: z.enum(SUPPORTED_CHAINS).describe('Blockchain network'),
112
130
  to: z.string().describe('Recipient address'),
113
131
  amount: z.string().describe('Amount in native token (e.g., "0.1" for 0.1 ETH)'),
114
- data: z.string().optional().describe('Optional hex-encoded data to include with transaction')
132
+ data: z.string().optional().describe('Optional hex-encoded data to include with transaction'),
133
+ signWith: SignWithSchema
115
134
  }),
116
135
  async (args) => {
117
136
  try {
118
137
  const to = requireAddress('recipient address', args.to)
119
138
  const value = `0x${parseUnits(args.amount, 18).toString(16)}`
120
139
 
121
- const { txHash, preview, signedWith } = await requestSignature(args.chain as ChainName, { to, value, data: args.data })
140
+ const { txHash, preview, signedWith } = await requestSignature(
141
+ args.chain as ChainName,
142
+ { to, value, data: args.data },
143
+ args.signWith
144
+ )
122
145
 
123
146
  return formatResponse({
124
147
  success: true,
@@ -138,13 +161,14 @@ export default {
138
161
 
139
162
  send_erc20_token: createTool(
140
163
  'Send ERC20 Token',
141
- 'Send ERC20 tokens to an address. Simulates the transfer, then opens the browser wallet for approval.',
164
+ 'Send ERC20 tokens to an address. Simulates the transfer, then sends it to the signer you choose for approval.',
142
165
  z.object({
143
166
  chain: z.enum(SUPPORTED_CHAINS).describe('Blockchain network'),
144
167
  tokenAddress: z.string().describe('ERC20 token contract address'),
145
168
  to: z.string().describe('Recipient address'),
146
169
  amount: z.string().describe('Amount in token units (e.g., "100" for 100 USDC)'),
147
- decimals: z.number().optional().default(18).describe('Token decimals (default: 18)')
170
+ decimals: z.number().optional().default(18).describe('Token decimals (default: 18)'),
171
+ signWith: SignWithSchema
148
172
  }),
149
173
  async (args) => {
150
174
  try {
@@ -156,7 +180,15 @@ export default {
156
180
  args: [to, parseUnits(args.amount, args.decimals ?? 18)]
157
181
  })
158
182
 
159
- const { txHash, preview, signedWith } = await requestSignature(args.chain as ChainName, { to: tokenAddress, data, value: '0x0' })
183
+ const { txHash, preview, signedWith } = await requestSignature(
184
+ args.chain as ChainName,
185
+ {
186
+ to: tokenAddress,
187
+ data,
188
+ value: '0x0'
189
+ },
190
+ args.signWith
191
+ )
160
192
 
161
193
  return formatResponse({
162
194
  success: true,
@@ -175,9 +207,9 @@ export default {
175
207
  }
176
208
  ),
177
209
 
178
- call_contract_write: createTool(
179
- 'Call Contract (Write)',
180
- 'Call a state-changing contract function. Simulates the call, then opens the browser wallet for approval.',
210
+ write_contract: createTool(
211
+ 'Write Contract',
212
+ 'Send a transaction that calls a state-changing contract function. Simulates it first, then asks your wallet to approve. Costs gas.',
181
213
  z.object({
182
214
  chain: z.enum(SUPPORTED_CHAINS).describe('Blockchain network'),
183
215
  contractAddress: z.string().describe('Contract address'),
@@ -186,7 +218,8 @@ export default {
186
218
  .array(z.union([z.string(), z.number(), z.boolean()]))
187
219
  .optional()
188
220
  .describe('Function arguments in order matching the ABI signature'),
189
- value: z.string().optional().describe('Optional native value to send with transaction (in ETH units, e.g., "0.1")')
221
+ value: z.string().optional().describe('Optional native value to send with transaction (in ETH units, e.g., "0.1")'),
222
+ signWith: SignWithSchema
190
223
  }),
191
224
  async (args) => {
192
225
  try {
@@ -200,7 +233,15 @@ export default {
200
233
  })
201
234
  const value = args.value ? `0x${parseUnits(args.value, 18).toString(16)}` : '0x0'
202
235
 
203
- const { txHash, preview, signedWith } = await requestSignature(args.chain as ChainName, { to: contractAddress, data, value })
236
+ const { txHash, preview, signedWith } = await requestSignature(
237
+ args.chain as ChainName,
238
+ {
239
+ to: contractAddress,
240
+ data,
241
+ value
242
+ },
243
+ args.signWith
244
+ )
204
245
 
205
246
  return formatResponse({
206
247
  success: true,
@@ -220,30 +261,37 @@ export default {
220
261
 
221
262
  sign_message: createTool(
222
263
  'Sign Message',
223
- 'Sign a message with the connected wallet — a paired phone if there is one, otherwise the browser wallet.',
264
+ 'Sign a message with your wallet, on whichever signer you choose.',
224
265
  z.object({
225
- message: z.string().describe('Message to sign')
266
+ message: z.string().describe('Message to sign'),
267
+ signWith: SignWithSchema
226
268
  }),
227
269
  async (args) => {
228
270
  try {
229
- // Same precedence as transactions: a hosted server has no browser to fall back on.
230
- const phone = getPhoneSigner()
231
- const session = await phone?.session()
232
-
233
- const signature = session
234
- ? await phone!.request('mainnet', 'personal_sign', [toHex(args.message), session.accounts[0]])
235
- : await getWalletClient().request({
236
- id: generateRequestId(),
237
- type: 'sign_message',
238
- chain: 'any',
239
- data: { message: args.message }
240
- })
271
+ let signature: unknown
272
+
273
+ if (args.signWith === 'phone') {
274
+ const phone = getPhoneSigner()
275
+ const session = await phone?.session()
276
+ if (!session) {
277
+ throw new Error('No phone wallet is paired. Run pair_phone_wallet and scan the QR, or sign in the browser instead.')
278
+ }
279
+ signature = await phone!.request('mainnet', 'personal_sign', [toHex(args.message), session.accounts[0]])
280
+ } else {
281
+ await getWalletClient().waitForSigner()
282
+ signature = await getWalletClient().request({
283
+ id: generateRequestId(),
284
+ type: 'sign_message',
285
+ chain: 'any',
286
+ data: { message: args.message }
287
+ })
288
+ }
241
289
 
242
290
  return formatResponse({
243
291
  success: true,
244
292
  message: args.message,
245
293
  signature,
246
- signedWith: session ? 'phone' : 'browser',
294
+ signedWith: args.signWith,
247
295
  signatureType: 'personal_sign'
248
296
  })
249
297
  } catch (error) {
@@ -254,12 +302,22 @@ export default {
254
302
 
255
303
  pair_phone_wallet: createTool(
256
304
  'Pair Phone Wallet',
257
- 'Pair a phone wallet over WalletConnect, so transactions are signed on the phone instead of in a browser. Returns a QR code to scan. Requires a WalletConnect project id.',
305
+ 'Pair a phone wallet over WalletConnect, so transactions are signed on the phone instead of in a browser. ' +
306
+ 'Returns a QR to scan plus links that open a wallet directly — show those to the user as clickable links. ' +
307
+ 'Requires a WalletConnect project id.',
258
308
  z.object({
259
309
  chains: z
260
310
  .array(z.enum(SUPPORTED_CHAINS))
261
311
  .optional()
262
- .describe('Chains to request access to (defaults to every supported network)')
312
+ .describe('Chains to request access to (defaults to every supported network)'),
313
+ wallet: z.string().optional().describe('Name of the wallet to open, e.g. "rabby" — omit to list the common ones'),
314
+ agent: z
315
+ .string()
316
+ .optional()
317
+ .describe(
318
+ 'Who is asking, shown in the wallet approval dialog, e.g. "Claude Code". Say who you actually are — ' +
319
+ 'the user reads this when deciding whether to grant signing access.'
320
+ )
263
321
  }),
264
322
  async (args) => {
265
323
  const phone = getPhoneSigner()
@@ -267,7 +325,7 @@ export default {
267
325
  return formatResponse({
268
326
  success: false,
269
327
  message:
270
- 'No WalletConnect project id configured. Get a free one at https://dashboard.reown.com and pass --walletconnect-project-id or set WALLETCONNECT_PROJECT_ID.'
328
+ 'No WalletConnect project id configured. Get a free one at https://dashboard.walletconnect.com and pass --walletconnect-project-id or set WALLETCONNECT_PROJECT_ID.'
271
329
  })
272
330
  }
273
331
 
@@ -283,14 +341,27 @@ export default {
283
341
  }
284
342
 
285
343
  const chains = (args.chains ?? SUPPORTED_CHAINS.filter((c) => c !== 'localhost')) as ChainName[]
286
- const uri = await phone.pair(chains)
344
+ const uri = await phone.pair(chains, args.agent)
345
+ const projectId = getClientManager().getConfig().walletConnectProjectId as string
287
346
 
288
347
  return formatResponse({
289
348
  qr: await qrFor(uri),
290
349
  uri,
350
+ // For an agent running on the phone itself there is nothing to scan, so hand over
351
+ // links that open the wallet directly. `wallet` narrows the list to one.
352
+ openOnThisDevice: await pairingLinks(projectId, uri, args.wallet),
353
+ // Approving grants an agent standing permission to request signatures, so the
354
+ // wallet dialog names the agent rather than implying a website is connecting.
355
+ shownInWallet: args.agent ? `${args.agent} via web3-tools-mcp` : 'web3-tools-mcp (AI agent)',
356
+ howToPresent:
357
+ 'Render each openOnThisDevice entry as a clickable link labelled with the wallet name, and show the ' +
358
+ 'uri as copyable text. On a phone that tap is the whole flow. Show the QR only when the user is on a ' +
359
+ 'different device from their wallet — it is unreadable inline on the phone that holds the wallet. ' +
360
+ 'If a link does nothing, some clients drop custom schemes like rabby://, so tell them to paste the uri.',
291
361
  message:
292
- 'Scan this QR with your phone wallet (MetaMask, Rabby, Trust…), or open the uri on the phone. ' +
293
- 'Approve the session, then run wallet_status to confirm.'
362
+ 'On another device: scan the QR with your wallet. On this device: tap one of the openOnThisDevice ' +
363
+ 'links, or copy the uri and paste it into your wallet under WalletConnect — every wallet supports ' +
364
+ 'that, and it is the one that always works. Then run wallet_status to confirm.'
294
365
  })
295
366
  } catch (error) {
296
367
  return failure(error, 'Could not start WalletConnect pairing')
@@ -298,48 +369,84 @@ export default {
298
369
  }
299
370
  ),
300
371
 
301
- wallet_status: createTool(
302
- 'Wallet Status',
303
- 'Check if a wallet is connected to the browser interface',
372
+ disconnect_phone_wallet: createTool(
373
+ 'Disconnect Phone Wallet',
374
+ 'End every WalletConnect session and drop the pairings underneath them. Use before pairing a different wallet, or to clear out attempts that were never approved.',
304
375
  z.object({}),
305
376
  async () => {
306
- // A paired phone is the signer of record, so look there first. Connecting to the
307
- // browser relay before checking would start one on a hosted server, which has no
308
- // browser to open it in.
309
377
  const phone = getPhoneSigner()
310
- const phoneSession = await phone?.session().catch(() => undefined)
311
- if (phoneSession) {
378
+ if (!phone) {
379
+ return formatResponse({ success: false, message: 'No WalletConnect project id configured — nothing to disconnect.' })
380
+ }
381
+
382
+ try {
383
+ const before = await phone.sessions()
384
+ const removed = await phone.disconnectAll()
385
+
312
386
  return formatResponse({
313
- connected: true,
314
- signer: 'phone',
315
- address: phoneSession.accounts[0],
316
- wallet: phoneSession.peer,
317
- chains: phoneSession.chains,
318
- message: 'A phone wallet is paired over WalletConnect. Transactions are sent there for signing.'
387
+ success: true,
388
+ disconnectedSessions: removed.sessions,
389
+ droppedPairings: removed.pairings,
390
+ wallets: before.map((session) => `${session.peer ?? 'unknown wallet'} (${session.accounts[0]})`),
391
+ message:
392
+ removed.sessions || removed.pairings
393
+ ? 'Disconnected. Transactions fall back to the browser wallet until a phone is paired again.'
394
+ : 'Nothing was connected.'
319
395
  })
396
+ } catch (error) {
397
+ return failure(error, 'Could not disconnect the phone wallet')
320
398
  }
399
+ }
400
+ ),
401
+
402
+ wallet_status: createTool(
403
+ 'Wallet Status',
404
+ 'Report every signer and whether it is ready, so a signing tool can be given the right signWith.',
405
+ z.object({}),
406
+ async () => {
407
+ // Both signers are reported, never just the first one found: signWith makes this a
408
+ // choice the user owns, so the agent has to see everything available.
409
+ const phone = getPhoneSigner()
410
+ const phoneSession = await phone?.session().catch(() => undefined)
321
411
 
322
412
  const wallet = getWalletClient()
323
- try {
324
- await wallet.connect()
325
- } catch (error) {
326
- return failure(error, 'Wallet relay unavailable')
413
+ const relayError = await wallet
414
+ .connect()
415
+ .then(() => undefined)
416
+ .catch((error) => describeError(error))
417
+
418
+ const browserReady = !relayError && wallet.isConnected()
419
+ // Only raise a tab when there is no other way to sign — a status check should not
420
+ // open a window at someone who is about to use their phone.
421
+ if (!browserReady && !phoneSession && !relayError) wallet.openBrowser()
422
+
423
+ const signers = {
424
+ phone: phoneSession
425
+ ? { ready: true, address: phoneSession.accounts[0], wallet: phoneSession.peer, chains: phoneSession.chains }
426
+ : {
427
+ ready: false,
428
+ reason: phone
429
+ ? 'No phone paired. Run pair_phone_wallet and scan the QR.'
430
+ : 'Phone signing is not configured — set WALLETCONNECT_PROJECT_ID.'
431
+ },
432
+ browser: browserReady
433
+ ? { ready: true, address: wallet.getAddress(), openTab: wallet.getPageUrl() }
434
+ : {
435
+ ready: false,
436
+ reason: relayError ?? 'No signing page is open.',
437
+ walletUrl: relayError ? undefined : wallet.getUrl()
438
+ }
327
439
  }
328
440
 
329
- if (!wallet.isConnected()) wallet.openBrowser()
330
-
331
- return formatResponse({
332
- connected: wallet.isConnected(),
333
- signer: 'browser',
334
- phonePairing: phone ? 'Not paired — run pair_phone_wallet to sign from a phone.' : undefined,
335
- address: wallet.getAddress(),
336
- walletUrl: wallet.getUrl(),
337
- openTab: wallet.getPageUrl(),
338
- hosted: wallet.isRemote,
339
- message: wallet.isConnected()
340
- ? 'Wallet is connected and ready to sign transactions'
341
- : `No wallet connected. Open ${wallet.getUrl()} and connect your wallet.`
342
- })
441
+ const ready = [phoneSession ? 'phone' : undefined, browserReady ? 'browser' : undefined].filter(Boolean)
442
+ const message =
443
+ ready.length === 2
444
+ ? 'Both signers are ready. Ask the user which one to use, then pass it as signWith.'
445
+ : ready.length === 1
446
+ ? `Only ${ready[0]} is ready. Confirm with the user before signing there, or set the other one up.`
447
+ : `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.`
448
+
449
+ return formatResponse({ ready, signers, message })
343
450
  }
344
451
  )
345
452
  }
package/src/types.ts CHANGED
@@ -17,21 +17,7 @@ export interface Config {
17
17
  walletConnectProjectId?: string
18
18
  }
19
19
 
20
- // Chain names
21
- export type ChainName =
22
- | 'mainnet'
23
- | 'arbitrum'
24
- | 'avalanche'
25
- | 'base'
26
- | 'bnb'
27
- | 'gnosis'
28
- | 'sonic'
29
- | 'optimism'
30
- | 'polygon'
31
- | 'zksync'
32
- | 'linea'
33
- | 'unichain'
34
- | 'localhost'
20
+ export type { ChainName } from './chains.js'
35
21
 
36
22
  // Tool handler result type
37
23
  export interface ToolResult {