web3-tools-mcp 1.5.0 → 1.6.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.
@@ -0,0 +1,94 @@
1
+ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
2
+ import { timingSafeEqual } from 'node:crypto'
3
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
4
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
5
+
6
+ /**
7
+ * Serve the MCP over HTTP instead of stdio, so the server can live somewhere other than the
8
+ * machine running the agent. Only worth doing alongside WalletConnect: a hosted server has
9
+ * no browser to open, so a phone wallet is the only way it can have anything signed.
10
+ *
11
+ * Stateless — a server per request — because every tool here is a one-shot call and the
12
+ * signing state lives in the WalletConnect session, not in the transport.
13
+ */
14
+
15
+ const DEFAULT_PORT = 3457
16
+
17
+ function unauthorized(res: ServerResponse) {
18
+ res.writeHead(401, { 'content-type': 'application/json', 'www-authenticate': 'Bearer' })
19
+ res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32001, message: 'Unauthorized' } }))
20
+ }
21
+
22
+ /** Constant-time compare, so the token cannot be guessed a character at a time. */
23
+ function tokenMatches(presented: string, expected: string): boolean {
24
+ const a = Buffer.from(presented)
25
+ const b = Buffer.from(expected)
26
+ return a.length === b.length && timingSafeEqual(a, b)
27
+ }
28
+
29
+ export interface HttpServerOptions {
30
+ port?: number
31
+ host?: string
32
+ token?: string
33
+ createMcpServer: () => McpServer
34
+ }
35
+
36
+ export async function startHttpServer(options: HttpServerOptions): Promise<{ url: string; port: number }> {
37
+ const port = options.port ?? Number(process.env.MCP_HTTP_PORT) ?? DEFAULT_PORT
38
+ const host = options.host ?? process.env.HOST ?? (process.env.MCP_HTTP_HOST ? process.env.MCP_HTTP_HOST : '0.0.0.0')
39
+ const token = options.token ?? process.env.MCP_TOKEN
40
+
41
+ // A reachable MCP server with a paired phone can push signing prompts at that phone. It
42
+ // never runs without a token.
43
+ if (!token) {
44
+ throw new Error('MCP_TOKEN is required to serve MCP over HTTP — anyone reaching the URL could request signatures')
45
+ }
46
+
47
+ const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
48
+ if (req.url === '/health') {
49
+ res.writeHead(200, { 'content-type': 'application/json' })
50
+ res.end(JSON.stringify({ status: 'ok' }))
51
+ return
52
+ }
53
+
54
+ if (!req.url?.startsWith('/mcp')) {
55
+ res.writeHead(404).end()
56
+ return
57
+ }
58
+
59
+ const presented = req.headers.authorization?.replace(/^Bearer /i, '') ?? ''
60
+ if (!tokenMatches(presented, token)) {
61
+ unauthorized(res)
62
+ return
63
+ }
64
+
65
+ const mcp = options.createMcpServer()
66
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true })
67
+
68
+ res.on('close', () => {
69
+ transport.close().catch(() => {})
70
+ mcp.close().catch(() => {})
71
+ })
72
+
73
+ try {
74
+ await mcp.connect(transport)
75
+ await transport.handleRequest(req, res)
76
+ } catch (error) {
77
+ console.error('[MCP] Request failed:', error)
78
+ if (!res.headersSent) {
79
+ res.writeHead(500, { 'content-type': 'application/json' })
80
+ res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal error' } }))
81
+ }
82
+ }
83
+ })
84
+
85
+ await new Promise<void>((resolve, reject) => {
86
+ server.once('error', reject)
87
+ server.listen(port, host, () => {
88
+ server.removeListener('error', reject)
89
+ resolve()
90
+ })
91
+ })
92
+
93
+ return { url: `http://${host}:${port}/mcp`, port }
94
+ }
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ import { initializeClientManager } from "./client.js";
7
7
  import { registerAllTools } from "./tools/index.js";
8
8
  import { parseCommandLineArgs } from "./utils.js";
9
9
  import { getWalletClient } from "./wallet-client.js";
10
+ import { startHttpServer } from "./http-server.js";
10
11
 
11
12
  // Parse configuration
12
13
  const config = parseCommandLineArgs();
@@ -26,6 +27,8 @@ OPTIONS:
26
27
  --alchemy-api-key <key> Alchemy API key (for enhanced RPC)
27
28
  --infura-api-key <key> Infura API key (for alternative RPC)
28
29
  --hypersync-api-key <key> Hypersync API key (for fast event queries)
30
+ --walletconnect-project-id <id>
31
+ WalletConnect project id, to sign from a phone
29
32
  --custom-rpc <json> Custom RPC URLs as JSON object
30
33
  Example: '{"mainnet":"https://...", "base":"https://..."}'
31
34
 
@@ -34,6 +37,11 @@ ENVIRONMENT VARIABLES:
34
37
  ALCHEMY_API_KEY Alternative to --alchemy-api-key
35
38
  INFURA_API_KEY Alternative to --infura-api-key
36
39
  HYPERSYNC_API_KEY Alternative to --hypersync-api-key
40
+ WALLETCONNECT_PROJECT_ID Alternative to --walletconnect-project-id
41
+ MCP_HTTP_PORT Serve MCP over HTTP on this port instead of stdio
42
+ MCP_TOKEN Bearer token required by the HTTP transport
43
+ UPSTASH_REDIS_REST_URL Keep WalletConnect sessions in Redis, not on disk
44
+ UPSTASH_REDIS_REST_TOKEN
37
45
  WALLET_SERVER_URL URL of a hosted wallet relay (omit to run one locally)
38
46
  WALLET_TOKEN Shared pairing token for the wallet relay
39
47
 
@@ -70,6 +78,9 @@ if (config.alchemyApiKey) {
70
78
  if (config.infuraApiKey) {
71
79
  console.error("[MCP] Infura API key configured");
72
80
  }
81
+ if (config.walletConnectProjectId) {
82
+ console.error("[MCP] WalletConnect project id configured — phone signing available");
83
+ }
73
84
  if (config.customRpcUrls) {
74
85
  console.error("[MCP] Custom RPC URLs:", Object.keys(config.customRpcUrls));
75
86
  }
@@ -77,21 +88,26 @@ if (config.customRpcUrls) {
77
88
  // Initialize client manager
78
89
  initializeClientManager(config);
79
90
 
80
- // Create MCP server instance
81
- const server = new McpServer({
82
- name: "web3-tools-mcp",
83
- version: packageJson.version,
84
- });
91
+ function createMcpServer() {
92
+ const server = new McpServer({
93
+ name: "web3-tools-mcp",
94
+ version: packageJson.version,
95
+ });
96
+ registerAllTools(server);
97
+ return server;
98
+ }
85
99
 
86
- // Register all tools
87
- registerAllTools(server);
100
+ // Serving over HTTP means there is no browser on this machine to open, so the wallet relay
101
+ // is skipped and signing goes to a paired phone over WalletConnect.
102
+ const httpMode = Boolean(process.env.MCP_HTTP_PORT);
88
103
 
89
- // Connect to the wallet relay (hosted when WALLET_SERVER_URL is set, embedded otherwise)
90
104
  const wallet = getWalletClient();
91
- const walletReady = wallet.connect().catch((error) => {
92
- console.error("[MCP] Wallet relay unavailable:", error.message);
93
- console.error("[MCP] Transaction signing features will not be available");
94
- });
105
+ const walletReady = httpMode
106
+ ? Promise.resolve()
107
+ : wallet.connect().catch((error) => {
108
+ console.error("[MCP] Wallet relay unavailable:", error.message);
109
+ console.error("[MCP] Transaction signing features will not be available");
110
+ });
95
111
 
96
112
  // The wallet relay's listening socket keeps the event loop alive, so this process would
97
113
  // outlive the client that spawned it and go on holding its port. Leave when the client does.
@@ -111,8 +127,17 @@ process.on("SIGINT", () => shutdown("SIGINT"));
111
127
 
112
128
  // Start server
113
129
  async function main() {
130
+ if (httpMode) {
131
+ const { url } = await startHttpServer({ createMcpServer });
132
+ console.error(`Web3 Tools MCP Server listening on ${url}`);
133
+ if (!config.walletConnectProjectId) {
134
+ console.error("[MCP] No WalletConnect project id — a hosted server has no way to sign transactions");
135
+ }
136
+ return;
137
+ }
138
+
114
139
  const transport = new StdioServerTransport();
115
- await server.connect(transport);
140
+ await createMcpServer().connect(transport);
116
141
  console.error("Web3 Tools MCP Server running on stdio");
117
142
  // The relay may still be picking a free port, and its URL carries the pairing token.
118
143
  await walletReady;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * WalletConnect session storage backed by Upstash Redis.
3
+ *
4
+ * The default store is a file, which is fine on a laptop and useless on a free-tier host:
5
+ * the filesystem is ephemeral, so every sleep/wake would lose the pairing and ask for the
6
+ * QR again. Everything lives in one Redis hash, which is exactly the shape WalletConnect's
7
+ * key-value interface expects.
8
+ *
9
+ * Uses the REST API over plain fetch — no Redis client, no TCP, works anywhere.
10
+ */
11
+
12
+ const HASH_KEY = 'web3-tools-mcp:walletconnect'
13
+
14
+ export interface KeyValueStorage {
15
+ getKeys(): Promise<string[]>
16
+ getEntries<T = unknown>(): Promise<[string, T][]>
17
+ getItem<T = unknown>(key: string): Promise<T | undefined>
18
+ setItem<T = unknown>(key: string, value: T): Promise<void>
19
+ removeItem(key: string): Promise<void>
20
+ }
21
+
22
+ class RedisKeyValueStorage implements KeyValueStorage {
23
+ constructor(
24
+ private url: string,
25
+ private token: string
26
+ ) {}
27
+
28
+ /** Upstash takes a command as a JSON array and answers `{ result }`. */
29
+ private async command<T>(...parts: (string | number)[]): Promise<T> {
30
+ const response = await fetch(this.url, {
31
+ method: 'POST',
32
+ headers: { authorization: `Bearer ${this.token}`, 'content-type': 'application/json' },
33
+ body: JSON.stringify(parts)
34
+ })
35
+
36
+ if (!response.ok) {
37
+ throw new Error(`Upstash request failed (${response.status}): ${await response.text()}`)
38
+ }
39
+
40
+ return ((await response.json()) as { result: T }).result
41
+ }
42
+
43
+ async getKeys(): Promise<string[]> {
44
+ return (await this.command<string[]>('HKEYS', HASH_KEY)) ?? []
45
+ }
46
+
47
+ async getEntries<T = unknown>(): Promise<[string, T][]> {
48
+ // HGETALL comes back as a flat [field, value, field, value, …] array.
49
+ const flat = (await this.command<string[]>('HGETALL', HASH_KEY)) ?? []
50
+ const entries: [string, T][] = []
51
+ for (let i = 0; i < flat.length; i += 2) {
52
+ const key = flat[i]
53
+ const value = flat[i + 1]
54
+ if (key === undefined || value === undefined) continue
55
+ entries.push([key, JSON.parse(value) as T])
56
+ }
57
+ return entries
58
+ }
59
+
60
+ async getItem<T = unknown>(key: string): Promise<T | undefined> {
61
+ const value = await this.command<string | null>('HGET', HASH_KEY, key)
62
+ return value == null ? undefined : (JSON.parse(value) as T)
63
+ }
64
+
65
+ async setItem<T = unknown>(key: string, value: T): Promise<void> {
66
+ await this.command('HSET', HASH_KEY, key, JSON.stringify(value))
67
+ }
68
+
69
+ async removeItem(key: string): Promise<void> {
70
+ await this.command('HDEL', HASH_KEY, key)
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Redis-backed storage when Upstash is configured, otherwise nothing — the caller falls
76
+ * back to WalletConnect's own file store.
77
+ */
78
+ export function getKeyValueStorage(): KeyValueStorage | undefined {
79
+ const url = process.env.UPSTASH_REDIS_REST_URL
80
+ const token = process.env.UPSTASH_REDIS_REST_TOKEN
81
+ if (!url || !token) return undefined
82
+ return new RedisKeyValueStorage(url, token)
83
+ }
package/src/preview.ts CHANGED
@@ -244,8 +244,18 @@ export async function buildTxPreview(chain: ChainName, tx: RawTx, from?: string)
244
244
  const value = tx.value ? BigInt(tx.value).toString() : undefined
245
245
 
246
246
  const [decoded, simulation, toLabel] = await Promise.all([
247
- decodeCalldata(chain, tx).catch(() => undefined),
248
- from ? simulate(chain, tx, from as Address).catch(() => undefined) : Promise.resolve(undefined),
247
+ // A preview that cannot be built must not block the transaction, but swallowing the
248
+ // reason makes "no details shown" impossible to diagnose.
249
+ decodeCalldata(chain, tx).catch((error) => {
250
+ console.error('[Preview] Could not decode calldata:', error instanceof Error ? error.message : error)
251
+ return undefined
252
+ }),
253
+ from
254
+ ? simulate(chain, tx, from as Address).catch((error) => {
255
+ console.error('[Preview] Could not simulate:', error instanceof Error ? error.message : error)
256
+ return undefined
257
+ })
258
+ : Promise.resolve(undefined),
249
259
  // The registry's protocol name beats anything on-chain: "Aave", not a proxy's class name.
250
260
  Promise.resolve(lookupContract(chain, tx.to))
251
261
  .then((entry) => (entry ? protocolLabel(entry.protocol) : addressLabel(chain, tx.to)))
@@ -1,11 +1,18 @@
1
1
  import { z } from 'zod'
2
2
  import { getWalletClient } from '../wallet-client.js'
3
3
  import { getClientManager, SUPPORTED_CHAINS } from '../client.js'
4
- import { encodeFunctionData, isAddress, parseAbiItem, parseUnits, type AbiFunction, type Address } from 'viem'
4
+ import { encodeFunctionData, isAddress, parseAbiItem, parseUnits, toHex, type AbiFunction, type Address } from 'viem'
5
5
  import { randomBytes } from 'node:crypto'
6
6
  import type { ChainName } from '../types.js'
7
- import { buildTxPreview, type RawTx } from '../preview.js'
7
+ import { buildTxPreview, type RawTx, type TxPreview } from '../preview.js'
8
+ import { getPhoneSigner } from '../walletconnect.js'
8
9
  import { createTool, formatResponse } from '../utils.js'
10
+ import qrcode from 'qrcode-terminal'
11
+
12
+ /** Render a pairing URI as an ASCII QR, so it can be scanned straight from the chat. */
13
+ function qrFor(uri: string): Promise<string> {
14
+ return new Promise((resolve) => qrcode.generate(uri, { small: true }, resolve))
15
+ }
9
16
 
10
17
  function generateRequestId(): string {
11
18
  return randomBytes(16).toString('hex')
@@ -21,10 +28,28 @@ function explorerTxUrl(chain: ChainName, txHash: unknown): string {
21
28
  }
22
29
 
23
30
  /**
24
- * Send a transaction for signing, with a decoded + simulated preview attached so the
25
- * wallet page can show what it actually does instead of raw calldata.
31
+ * Send a transaction for signing.
32
+ *
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.
26
37
  */
27
38
  async function requestSignature(chain: ChainName, tx: RawTx & { data?: string }) {
39
+ const phone = getPhoneSigner()
40
+
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)
45
+
46
+ const txHash = await phone!.request(chain, 'eth_sendTransaction', [
47
+ { from, to: tx.to, value: tx.value ?? '0x0', ...(tx.data && { data: tx.data }) }
48
+ ])
49
+
50
+ return { txHash, preview, signedWith: 'phone' as const }
51
+ }
52
+
28
53
  const wallet = getWalletClient()
29
54
  await wallet.waitForSigner()
30
55
 
@@ -38,14 +63,42 @@ async function requestSignature(chain: ChainName, tx: RawTx & { data?: string })
38
63
  data: { to: tx.to, value: tx.value ?? '0x0', ...(tx.data && { data: tx.data }) },
39
64
  preview
40
65
  }),
41
- preview
66
+ preview,
67
+ signedWith: 'browser' as const
68
+ }
69
+ }
70
+
71
+ /** What the caller should see about a transaction before it is approved. */
72
+ function previewSummary(preview: TxPreview) {
73
+ return {
74
+ action: preview.decoded?.intent ?? preview.decoded?.functionName,
75
+ protocol: preview.decoded?.protocol ?? preview.toLabel,
76
+ details: preview.decoded?.fields.map((f) => `${f.name}: ${f.value}${f.warning ? ` (${f.warning})` : ''}`),
77
+ simulation: preview.simulation
42
78
  }
43
79
  }
44
80
 
81
+ /**
82
+ * WalletConnect rejects with a plain `{ code, message }` rather than an Error, which
83
+ * String() renders as "[object Object]" — the reason a wallet gave for refusing is the
84
+ * whole point of the message, so dig it out.
85
+ */
86
+ function describeError(error: unknown): string {
87
+ if (error instanceof Error) return error.message
88
+ if (error && typeof error === 'object') {
89
+ const { message, code, reason } = error as { message?: string; code?: number; reason?: string }
90
+ const text = message ?? reason
91
+ if (text) return code === undefined ? text : `${text} (code ${code})`
92
+ return JSON.stringify(error)
93
+ }
94
+ return String(error)
95
+ }
96
+
45
97
  function failure(error: unknown, message: string) {
98
+ console.error('[Transaction]', message, '—', describeError(error))
46
99
  return formatResponse({
47
100
  success: false,
48
- error: error instanceof Error ? error.message : String(error),
101
+ error: describeError(error),
49
102
  message
50
103
  })
51
104
  }
@@ -65,7 +118,7 @@ export default {
65
118
  const to = requireAddress('recipient address', args.to)
66
119
  const value = `0x${parseUnits(args.amount, 18).toString(16)}`
67
120
 
68
- const { txHash, preview } = await requestSignature(args.chain as ChainName, { to, value, data: args.data })
121
+ const { txHash, preview, signedWith } = await requestSignature(args.chain as ChainName, { to, value, data: args.data })
69
122
 
70
123
  return formatResponse({
71
124
  success: true,
@@ -73,7 +126,8 @@ export default {
73
126
  transactionHash: txHash,
74
127
  to,
75
128
  amount: args.amount,
76
- simulation: preview.simulation,
129
+ signedWith,
130
+ preview: previewSummary(preview),
77
131
  explorerUrl: explorerTxUrl(args.chain as ChainName, txHash)
78
132
  })
79
133
  } catch (error) {
@@ -102,7 +156,7 @@ export default {
102
156
  args: [to, parseUnits(args.amount, args.decimals ?? 18)]
103
157
  })
104
158
 
105
- const { txHash, preview } = await requestSignature(args.chain as ChainName, { to: tokenAddress, data, value: '0x0' })
159
+ const { txHash, preview, signedWith } = await requestSignature(args.chain as ChainName, { to: tokenAddress, data, value: '0x0' })
106
160
 
107
161
  return formatResponse({
108
162
  success: true,
@@ -111,7 +165,8 @@ export default {
111
165
  tokenAddress,
112
166
  to,
113
167
  amount: args.amount,
114
- simulation: preview.simulation,
168
+ signedWith,
169
+ preview: previewSummary(preview),
115
170
  explorerUrl: explorerTxUrl(args.chain as ChainName, txHash)
116
171
  })
117
172
  } catch (error) {
@@ -145,7 +200,7 @@ export default {
145
200
  })
146
201
  const value = args.value ? `0x${parseUnits(args.value, 18).toString(16)}` : '0x0'
147
202
 
148
- const { txHash, preview } = await requestSignature(args.chain as ChainName, { to: contractAddress, data, value })
203
+ const { txHash, preview, signedWith } = await requestSignature(args.chain as ChainName, { to: contractAddress, data, value })
149
204
 
150
205
  return formatResponse({
151
206
  success: true,
@@ -153,7 +208,8 @@ export default {
153
208
  transactionHash: txHash,
154
209
  contractAddress,
155
210
  functionName: abiItem.name,
156
- simulation: preview.simulation,
211
+ signedWith,
212
+ preview: previewSummary(preview),
157
213
  explorerUrl: explorerTxUrl(args.chain as ChainName, txHash)
158
214
  })
159
215
  } catch (error) {
@@ -164,23 +220,30 @@ export default {
164
220
 
165
221
  sign_message: createTool(
166
222
  'Sign Message',
167
- 'Sign a message with the connected wallet. Opens browser wallet for approval.',
223
+ 'Sign a message with the connected wallet — a paired phone if there is one, otherwise the browser wallet.',
168
224
  z.object({
169
225
  message: z.string().describe('Message to sign')
170
226
  }),
171
227
  async (args) => {
172
228
  try {
173
- const signature = await getWalletClient().request({
174
- id: generateRequestId(),
175
- type: 'sign_message',
176
- chain: 'any',
177
- data: { message: args.message }
178
- })
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
+ })
179
241
 
180
242
  return formatResponse({
181
243
  success: true,
182
244
  message: args.message,
183
245
  signature,
246
+ signedWith: session ? 'phone' : 'browser',
184
247
  signatureType: 'personal_sign'
185
248
  })
186
249
  } catch (error) {
@@ -189,13 +252,74 @@ export default {
189
252
  }
190
253
  ),
191
254
 
255
+ pair_phone_wallet: createTool(
256
+ '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.',
258
+ z.object({
259
+ chains: z
260
+ .array(z.enum(SUPPORTED_CHAINS))
261
+ .optional()
262
+ .describe('Chains to request access to (defaults to every supported network)')
263
+ }),
264
+ async (args) => {
265
+ const phone = getPhoneSigner()
266
+ if (!phone) {
267
+ return formatResponse({
268
+ success: false,
269
+ 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.'
271
+ })
272
+ }
273
+
274
+ try {
275
+ const existing = await phone.session()
276
+ if (existing) {
277
+ return formatResponse({
278
+ alreadyPaired: true,
279
+ address: existing.accounts[0],
280
+ wallet: existing.peer,
281
+ message: 'A phone wallet is already paired. Transactions will be sent to it.'
282
+ })
283
+ }
284
+
285
+ const chains = (args.chains ?? SUPPORTED_CHAINS.filter((c) => c !== 'localhost')) as ChainName[]
286
+ const uri = await phone.pair(chains)
287
+
288
+ return formatResponse({
289
+ qr: await qrFor(uri),
290
+ uri,
291
+ 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.'
294
+ })
295
+ } catch (error) {
296
+ return failure(error, 'Could not start WalletConnect pairing')
297
+ }
298
+ }
299
+ ),
300
+
192
301
  wallet_status: createTool(
193
302
  'Wallet Status',
194
303
  'Check if a wallet is connected to the browser interface',
195
304
  z.object({}),
196
305
  async () => {
197
- const wallet = getWalletClient()
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
+ const phone = getPhoneSigner()
310
+ const phoneSession = await phone?.session().catch(() => undefined)
311
+ if (phoneSession) {
312
+ 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.'
319
+ })
320
+ }
198
321
 
322
+ const wallet = getWalletClient()
199
323
  try {
200
324
  await wallet.connect()
201
325
  } catch (error) {
@@ -206,6 +330,8 @@ export default {
206
330
 
207
331
  return formatResponse({
208
332
  connected: wallet.isConnected(),
333
+ signer: 'browser',
334
+ phonePairing: phone ? 'Not paired — run pair_phone_wallet to sign from a phone.' : undefined,
209
335
  address: wallet.getAddress(),
210
336
  walletUrl: wallet.getUrl(),
211
337
  openTab: wallet.getPageUrl(),
package/src/types.ts CHANGED
@@ -14,6 +14,7 @@ export interface Config {
14
14
  infuraApiKey?: string
15
15
  customRpcUrls?: Record<string, string>
16
16
  hypersyncApiKey?: string
17
+ walletConnectProjectId?: string
17
18
  }
18
19
 
19
20
  // Chain names
package/src/utils.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { AbiParameter } from 'viem'
2
- import type { ToolResult } from './types.js'
2
+ import type { Config, ToolResult } from './types.js'
3
3
  import { z } from 'zod'
4
4
 
5
5
  // Create tool helper
@@ -264,22 +264,8 @@ export function formatResponse(data: unknown): ToolResult {
264
264
  }
265
265
 
266
266
  // 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
- } = {}
267
+ export function parseCommandLineArgs(): Config & { showHelp?: boolean } {
268
+ const config: Config & { showHelp?: boolean } = {}
283
269
 
284
270
  function getArgValue(argName: string): string | undefined {
285
271
  const args = process.argv
@@ -302,6 +288,7 @@ export function parseCommandLineArgs(): {
302
288
  config.alchemyApiKey = process.env.ALCHEMY_API_KEY || getArgValue('--alchemy-api-key')
303
289
  config.infuraApiKey = process.env.INFURA_API_KEY || getArgValue('--infura-api-key')
304
290
  config.hypersyncApiKey = process.env.HYPERSYNC_API_KEY || getArgValue('--hypersync-api-key')
291
+ config.walletConnectProjectId = process.env.WALLETCONNECT_PROJECT_ID || getArgValue('--walletconnect-project-id')
305
292
 
306
293
  // Parse custom RPC URLs
307
294
  const customRpcs = getArgValue('--custom-rpc')