web3-tools-mcp 2.0.2 → 3.1.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 (180) hide show
  1. package/CHANGELOG.md +182 -1
  2. package/README.md +76 -47
  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/chain-meta.d.ts.map +1 -1
  8. package/dist/chain-meta.js +19 -8
  9. package/dist/chain-meta.js.map +1 -1
  10. package/dist/chains.d.ts +9 -1
  11. package/dist/chains.d.ts.map +1 -1
  12. package/dist/chains.js +11 -2
  13. package/dist/chains.js.map +1 -1
  14. package/dist/clear-signing.d.ts.map +1 -1
  15. package/dist/clear-signing.js +17 -6
  16. package/dist/clear-signing.js.map +1 -1
  17. package/dist/client.d.ts.map +1 -1
  18. package/dist/client.js.map +1 -1
  19. package/dist/context.d.ts +22 -0
  20. package/dist/context.d.ts.map +1 -0
  21. package/dist/context.js +24 -0
  22. package/dist/context.js.map +1 -0
  23. package/dist/hosted.d.ts +12 -0
  24. package/dist/hosted.d.ts.map +1 -0
  25. package/dist/hosted.js +12 -0
  26. package/dist/hosted.js.map +1 -0
  27. package/dist/http-server.d.ts +13 -0
  28. package/dist/http-server.d.ts.map +1 -1
  29. package/dist/http-server.js +22 -7
  30. package/dist/http-server.js.map +1 -1
  31. package/dist/index.js +20 -12
  32. package/dist/index.js.map +1 -1
  33. package/dist/kv-storage.d.ts +2 -1
  34. package/dist/kv-storage.d.ts.map +1 -1
  35. package/dist/kv-storage.js +5 -2
  36. package/dist/kv-storage.js.map +1 -1
  37. package/dist/lib.d.ts +24 -0
  38. package/dist/lib.d.ts.map +1 -0
  39. package/dist/lib.js +23 -0
  40. package/dist/lib.js.map +1 -0
  41. package/dist/mcp-server.d.ts +4 -0
  42. package/dist/mcp-server.d.ts.map +1 -0
  43. package/dist/mcp-server.js +43 -0
  44. package/dist/mcp-server.js.map +1 -0
  45. package/dist/oauth.d.ts +57 -8
  46. package/dist/oauth.d.ts.map +1 -1
  47. package/dist/oauth.js +133 -63
  48. package/dist/oauth.js.map +1 -1
  49. package/dist/preview.d.ts +7 -0
  50. package/dist/preview.d.ts.map +1 -1
  51. package/dist/preview.js +21 -7
  52. package/dist/preview.js.map +1 -1
  53. package/dist/signing-requests.d.ts +81 -0
  54. package/dist/signing-requests.d.ts.map +1 -0
  55. package/dist/signing-requests.js +138 -0
  56. package/dist/signing-requests.js.map +1 -0
  57. package/dist/tools/index.d.ts +27 -1
  58. package/dist/tools/index.d.ts.map +1 -1
  59. package/dist/tools/index.js +50 -15
  60. package/dist/tools/index.js.map +1 -1
  61. package/dist/tools/{advanced.d.ts → read/advanced.d.ts} +39 -30
  62. package/dist/tools/read/advanced.d.ts.map +1 -0
  63. package/dist/tools/{advanced.js → read/advanced.js} +53 -41
  64. package/dist/tools/read/advanced.js.map +1 -0
  65. package/dist/tools/{balance.d.ts → read/balance.d.ts} +8 -7
  66. package/dist/tools/read/balance.d.ts.map +1 -0
  67. package/dist/tools/{balance.js → read/balance.js} +18 -33
  68. package/dist/tools/read/balance.js.map +1 -0
  69. package/dist/tools/{contract-info.d.ts → read/contract-info.d.ts} +20 -15
  70. package/dist/tools/read/contract-info.d.ts.map +1 -0
  71. package/dist/tools/{contract-info.js → read/contract-info.js} +54 -123
  72. package/dist/tools/read/contract-info.js.map +1 -0
  73. package/dist/tools/{contract.d.ts → read/contract.d.ts} +14 -12
  74. package/dist/tools/read/contract.d.ts.map +1 -0
  75. package/dist/tools/{contract.js → read/contract.js} +3 -2
  76. package/dist/tools/read/contract.js.map +1 -0
  77. package/dist/tools/read/ens.d.ts +104 -0
  78. package/dist/tools/read/ens.d.ts.map +1 -0
  79. package/dist/tools/{ens.js → read/ens.js} +25 -33
  80. package/dist/tools/read/ens.js.map +1 -0
  81. package/dist/tools/read/gas.d.ts +171 -0
  82. package/dist/tools/read/gas.d.ts.map +1 -0
  83. package/dist/tools/read/gas.js +333 -0
  84. package/dist/tools/read/gas.js.map +1 -0
  85. package/dist/tools/read/logs.d.ts +101 -0
  86. package/dist/tools/read/logs.d.ts.map +1 -0
  87. package/dist/tools/read/logs.js +211 -0
  88. package/dist/tools/read/logs.js.map +1 -0
  89. package/dist/tools/{signatures.d.ts → read/signatures.d.ts} +8 -4
  90. package/dist/tools/read/signatures.d.ts.map +1 -0
  91. package/dist/tools/{signatures.js → read/signatures.js} +4 -3
  92. package/dist/tools/read/signatures.js.map +1 -0
  93. package/dist/tools/read/signing.d.ts +27 -0
  94. package/dist/tools/read/signing.d.ts.map +1 -0
  95. package/dist/tools/read/signing.js +81 -0
  96. package/dist/tools/read/signing.js.map +1 -0
  97. package/dist/tools/sign/errors.d.ts +9 -0
  98. package/dist/tools/sign/errors.d.ts.map +1 -0
  99. package/dist/tools/sign/errors.js +29 -0
  100. package/dist/tools/sign/errors.js.map +1 -0
  101. package/dist/tools/sign/transactions.d.ts +141 -0
  102. package/dist/tools/sign/transactions.d.ts.map +1 -0
  103. package/dist/tools/sign/transactions.js +361 -0
  104. package/dist/tools/sign/transactions.js.map +1 -0
  105. package/dist/tools/sign/wallet.d.ts +50 -0
  106. package/dist/tools/sign/wallet.d.ts.map +1 -0
  107. package/dist/tools/sign/wallet.js +180 -0
  108. package/dist/tools/sign/wallet.js.map +1 -0
  109. package/dist/utils.d.ts +23 -2
  110. package/dist/utils.d.ts.map +1 -1
  111. package/dist/utils.js +48 -3
  112. package/dist/utils.js.map +1 -1
  113. package/dist/wallet-client.d.ts +34 -4
  114. package/dist/wallet-client.d.ts.map +1 -1
  115. package/dist/wallet-client.js +106 -27
  116. package/dist/wallet-client.js.map +1 -1
  117. package/dist/walletconnect.d.ts +100 -13
  118. package/dist/walletconnect.d.ts.map +1 -1
  119. package/dist/walletconnect.js +215 -50
  120. package/dist/walletconnect.js.map +1 -1
  121. package/package.json +12 -9
  122. package/src/anvil.ts +150 -271
  123. package/src/chain-meta.ts +16 -7
  124. package/src/chains.ts +14 -2
  125. package/src/clear-signing.ts +15 -9
  126. package/src/client.ts +0 -1
  127. package/src/context.ts +27 -0
  128. package/src/hosted.ts +11 -0
  129. package/src/http-server.ts +38 -8
  130. package/src/index.ts +20 -13
  131. package/src/kv-storage.ts +6 -3
  132. package/src/lib.ts +24 -0
  133. package/src/mcp-server.ts +47 -0
  134. package/src/oauth.ts +181 -69
  135. package/src/preview.ts +28 -7
  136. package/src/signing-requests.ts +198 -0
  137. package/src/tools/index.ts +78 -16
  138. package/src/tools/{advanced.ts → read/advanced.ts} +74 -43
  139. package/src/tools/{balance.ts → read/balance.ts} +20 -35
  140. package/src/tools/{contract-info.ts → read/contract-info.ts} +66 -146
  141. package/src/tools/{contract.ts → read/contract.ts} +4 -3
  142. package/src/tools/{ens.ts → read/ens.ts} +27 -34
  143. package/src/tools/read/gas.ts +443 -0
  144. package/src/tools/read/logs.ts +281 -0
  145. package/src/tools/{signatures.ts → read/signatures.ts} +5 -5
  146. package/src/tools/read/signing.ts +92 -0
  147. package/src/tools/sign/errors.ts +28 -0
  148. package/src/tools/sign/transactions.ts +479 -0
  149. package/src/tools/sign/wallet.ts +216 -0
  150. package/src/utils.ts +52 -3
  151. package/src/wallet-client.ts +107 -24
  152. package/src/walletconnect.ts +250 -48
  153. package/dist/tools/advanced.d.ts.map +0 -1
  154. package/dist/tools/advanced.js.map +0 -1
  155. package/dist/tools/balance.d.ts.map +0 -1
  156. package/dist/tools/balance.js.map +0 -1
  157. package/dist/tools/contract-info.d.ts.map +0 -1
  158. package/dist/tools/contract-info.js.map +0 -1
  159. package/dist/tools/contract.d.ts.map +0 -1
  160. package/dist/tools/contract.js.map +0 -1
  161. package/dist/tools/ens.d.ts +0 -119
  162. package/dist/tools/ens.d.ts.map +0 -1
  163. package/dist/tools/ens.js.map +0 -1
  164. package/dist/tools/gas.d.ts +0 -99
  165. package/dist/tools/gas.d.ts.map +0 -1
  166. package/dist/tools/gas.js +0 -181
  167. package/dist/tools/gas.js.map +0 -1
  168. package/dist/tools/logs.d.ts +0 -39
  169. package/dist/tools/logs.d.ts.map +0 -1
  170. package/dist/tools/logs.js +0 -230
  171. package/dist/tools/logs.js.map +0 -1
  172. package/dist/tools/signatures.d.ts.map +0 -1
  173. package/dist/tools/signatures.js.map +0 -1
  174. package/dist/tools/transactions.d.ts +0 -155
  175. package/dist/tools/transactions.d.ts.map +0 -1
  176. package/dist/tools/transactions.js +0 -365
  177. package/dist/tools/transactions.js.map +0 -1
  178. package/src/tools/gas.ts +0 -219
  179. package/src/tools/logs.ts +0 -293
  180. package/src/tools/transactions.ts +0 -456
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
 
@@ -133,11 +136,13 @@ function findErrorInTrace(trace: unknown): { errorPath: SummarizedCall[]; errorM
133
136
  }
134
137
 
135
138
  /**
136
- * Coerce a JSON value into what viem expects for an ABI type. Tuples, arrays and anything
139
+ * Coerce a JSON value into what viem expects for an ABI type. A list is coerced element by
140
+ * element — an array argument, or an OR filter on an event argument. Tuples and anything
137
141
  * else pass through: viem validates them, and guessing here would only mangle them.
138
142
  */
139
143
  function coerce(value: unknown, type: string): unknown {
140
144
  if (value === null || value === undefined) return null
145
+ if (Array.isArray(value)) return value.map((item) => coerce(item, type))
141
146
 
142
147
  if (type === 'address' || type === 'string' || type.startsWith('bytes')) {
143
148
  return String(value)
@@ -192,6 +197,50 @@ export function convertEventArgsToTypes(
192
197
  )
193
198
  }
194
199
 
200
+ /** One tool call is one quota unit, so an unbounded batch would be a way around the quota. */
201
+ export const MAX_BATCH = 25
202
+
203
+ /**
204
+ * Why an RPC call failed, without the request URL — viem puts it in `message`, and with a
205
+ * provider key configured that URL carries the key.
206
+ */
207
+ export function rpcReason(error: unknown): string {
208
+ const { details, shortMessage } = error as { details?: string; shortMessage?: string }
209
+ return details || shortMessage || (error instanceof Error ? error.message : String(error))
210
+ }
211
+
212
+ /** Every configured key blanked out of `text`: RPC URLs carry them, and errors quote the URL. */
213
+ export function redactSecrets(text: string, config: Config): string {
214
+ return [config.alchemyApiKey, config.infuraApiKey, config.etherscanApiKey, config.hypersyncApiKey].reduce<string>(
215
+ (out, secret) => (secret ? out.replaceAll(secret, '[redacted]') : out),
216
+ text
217
+ )
218
+ }
219
+
220
+ /** A Map whose entries expire after `ttlMs`, dropping the oldest once it holds `maxSize`. */
221
+ export class TtlCache<V> {
222
+ private entries = new Map<string, { value: V; expires: number }>()
223
+
224
+ constructor(
225
+ private maxSize: number,
226
+ private ttlMs: number
227
+ ) {}
228
+
229
+ get(key: string): V | undefined {
230
+ const entry = this.entries.get(key)
231
+ if (!entry) return undefined
232
+ if (entry.expires > Date.now()) return entry.value
233
+ this.entries.delete(key)
234
+ return undefined
235
+ }
236
+
237
+ set(key: string, value: V): void {
238
+ this.entries.delete(key)
239
+ this.entries.set(key, { value, expires: Date.now() + this.ttlMs })
240
+ if (this.entries.size > this.maxSize) this.entries.delete(this.entries.keys().next().value as string)
241
+ }
242
+ }
243
+
195
244
  // Format response as tool result
196
245
  export function formatResponse(data: unknown): ToolResult {
197
246
  const serializable = convertBigIntToString(data)
@@ -3,18 +3,30 @@ import { randomBytes } from 'node:crypto'
3
3
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
4
4
  import { homedir } from 'node:os'
5
5
  import { join } from 'node:path'
6
- import { LOCAL_PORT_ATTEMPTS, type TransactionRequest, type TransactionResponse, WalletRelay } from 'web3-wallet-relay'
6
+ import { LOCAL_PORT_ATTEMPTS, roomToken, type TransactionRequest, type TransactionResponse, WalletRelay } from 'web3-wallet-relay'
7
7
  import { WebSocket } from 'ws'
8
+ import { HOSTED } from './hosted.js'
8
9
 
9
10
  const REQUEST_TIMEOUT = 300_000
10
11
 
11
- /** Serving over HTTP means no desktop here: never try to open a browser, and never
12
- * hand out a loopback URL as the place to open the signing page. */
13
- const HOSTED = Boolean(process.env.MCP_HTTP_PORT)
12
+ /**
13
+ * Past this point the page may have signed and broadcast without the answer reaching us, so
14
+ * a failure is not a rejection: retrying blindly could send the same thing twice.
15
+ */
16
+ const OUTCOME_UNKNOWN =
17
+ 'The outcome is unknown — it may still have been signed or broadcast. Check the wallet or a block explorer before retrying.'
18
+
19
+ /**
20
+ * How long a client with nothing in flight keeps its socket. A hosted server makes one per
21
+ * person who ever signed, and without this each would hold a relay connection for the life
22
+ * of the process. The next call reconnects.
23
+ */
24
+ const IDLE_TIMEOUT = 10 * 60_000
14
25
 
15
- function pairingUrlFor(port: number, token: string): string {
26
+ /** Always the signer token: this URL goes to a browser, which may not act as a requester. */
27
+ function pairingUrlFor(port: number, secret: string, identity: string): string {
16
28
  const base = HOSTED ? (process.env.MCP_PUBLIC_URL ?? `http://localhost:${port}`) : `http://127.0.0.1:${port}`
17
- return `${base.replace(/\/$/, '')}/#t=${token}`
29
+ return `${base.replace(/\/$/, '')}/#t=${roomToken(secret, identity, 'signer')}`
18
30
  }
19
31
  const SIGNER_WAIT_TIMEOUT = 30_000
20
32
  const CONNECT_ATTEMPTS = 8
@@ -85,13 +97,24 @@ export class WalletClient {
85
97
  private pageUrl: string | undefined
86
98
  private pageAgent: string | undefined
87
99
  private lastOpenedAt = 0
88
- /** Instance field so tests can shorten the wait instead of sitting out the real one. */
100
+ private idleTimer: NodeJS.Timeout | null = null
101
+ /** Instance fields so tests can shorten the waits instead of sitting out the real ones. */
89
102
  private signerWaitTimeout = SIGNER_WAIT_TIMEOUT
103
+ private idleTimeout = IDLE_TIMEOUT
104
+ private requestTimeout = REQUEST_TIMEOUT
90
105
 
91
106
  private readonly remoteUrl = process.env.WALLET_SERVER_URL
92
107
  private readonly remoteToken = process.env.WALLET_TOKEN
93
108
 
94
- constructor(private relayOptions: { port?: number; token?: string } = {}) {}
109
+ /**
110
+ * `identity` is the room this client works in. Everything below it — the socket's token,
111
+ * the pairing link it hands out — is signed for that room, so two identities on one relay
112
+ * cannot see each other's pages or transactions.
113
+ */
114
+ constructor(
115
+ private identity: string,
116
+ private relayOptions: { port?: number; token?: string } = {}
117
+ ) {}
95
118
 
96
119
  get isRemote(): boolean {
97
120
  return Boolean(this.remoteUrl)
@@ -124,8 +147,8 @@ export class WalletClient {
124
147
  // minute to come back.
125
148
  for (let attempt = 0; attempt < CONNECT_ATTEMPTS; attempt++) {
126
149
  try {
127
- await this.openSocket(url, this.remoteToken)
128
- this.pairingUrl = `${this.remoteUrl}#t=${this.remoteToken}`
150
+ await this.openSocket(url, roomToken(this.remoteToken, this.identity, 'requester'))
151
+ this.pairingUrl = `${this.remoteUrl}#t=${roomToken(this.remoteToken, this.identity, 'signer')}`
129
152
  return
130
153
  } catch (error) {
131
154
  lastError = error as Error
@@ -147,23 +170,24 @@ export class WalletClient {
147
170
  * the fix would be for the higher port to hand over when a lower one answers.
148
171
  */
149
172
  private async connectLocal(): Promise<void> {
150
- const token = this.relayOptions.token ?? localToken()
173
+ const secret = this.relayOptions.token ?? localToken()
174
+ const requester = roomToken(secret, this.identity, 'requester')
151
175
  const ports = this.relayOptions.port ? [this.relayOptions.port] : LOCAL_PORTS
152
176
 
153
177
  for (const port of ports) {
154
178
  try {
155
- await this.openSocket(`ws://127.0.0.1:${port}`, token)
156
- this.pairingUrl = pairingUrlFor(port, token)
179
+ await this.openSocket(`ws://127.0.0.1:${port}`, requester)
180
+ this.pairingUrl = pairingUrlFor(port, secret, this.identity)
157
181
  return
158
182
  } catch {
159
183
  // Nothing on this port, or something that isn't our relay — keep looking.
160
184
  }
161
185
  }
162
186
 
163
- this.relay = new WalletRelay({ port: ports[0], token })
187
+ this.relay = new WalletRelay({ port: ports[0], token: secret })
164
188
  await this.relay.start()
165
- await this.openSocket(`ws://127.0.0.1:${this.relay.getPort()}`, token)
166
- this.pairingUrl = this.relay.getUrl()
189
+ await this.openSocket(`ws://127.0.0.1:${this.relay.getPort()}`, requester)
190
+ this.pairingUrl = pairingUrlFor(this.relay.getPort(), secret, this.identity)
167
191
  }
168
192
 
169
193
  /**
@@ -198,6 +222,7 @@ export class WalletClient {
198
222
  clearTimeout(timer)
199
223
  ws.off('message', onHandshake)
200
224
  this.ws = ws
225
+ this.rearmIdle()
201
226
  resolve()
202
227
  }
203
228
 
@@ -217,6 +242,10 @@ export class WalletClient {
217
242
  this.signerAddress = undefined
218
243
  this.pageUrl = undefined
219
244
  this.pageAgent = undefined
245
+ for (const [id, pending] of this.pending) {
246
+ this.pending.delete(id)
247
+ pending.reject(new Error(`Lost the connection to the wallet relay before the wallet answered. ${OUTCOME_UNKNOWN}`))
248
+ }
220
249
  })
221
250
  })
222
251
  }
@@ -249,11 +278,27 @@ export class WalletClient {
249
278
  const pending = this.pending.get(message.id)
250
279
  if (!pending) return
251
280
  this.pending.delete(message.id)
281
+ this.rearmIdle()
252
282
 
253
283
  if (message.success) pending.resolve(message.result)
254
284
  else pending.reject(new Error(message.error || 'Transaction failed'))
255
285
  }
256
286
 
287
+ /**
288
+ * Drop the socket, and this client, once nothing has needed it for a while. The owner of
289
+ * a local relay is exempt: it is the one that has to stop that relay at shutdown.
290
+ */
291
+ private rearmIdle() {
292
+ if (this.idleTimer) clearTimeout(this.idleTimer)
293
+ if (this.relay) return
294
+ this.idleTimer = setTimeout(() => {
295
+ if (this.pending.size > 0) return this.rearmIdle()
296
+ this.ws?.close()
297
+ if (clients.get(this.identity) === this) clients.delete(this.identity)
298
+ }, this.idleTimeout)
299
+ this.idleTimer.unref()
300
+ }
301
+
257
302
  /** Wait for a wallet page to connect, opening the browser first when running locally. */
258
303
  async waitForSigner(): Promise<void> {
259
304
  await this.connect()
@@ -291,11 +336,17 @@ export class WalletClient {
291
336
 
292
337
  return new Promise((resolve, reject) => {
293
338
  this.pending.set(request.id, { resolve, reject })
294
- ws.send(JSON.stringify(request))
339
+ this.rearmIdle()
340
+ ws.send(JSON.stringify({ ...request, expiresAt: Date.now() + this.requestTimeout }))
295
341
 
296
342
  setTimeout(() => {
297
- if (this.pending.delete(request.id)) reject(new Error('Transaction request timed out'))
298
- }, REQUEST_TIMEOUT)
343
+ if (this.pending.delete(request.id)) {
344
+ this.rearmIdle()
345
+ // So the page stops offering it; its own expiresAt covers a cancel that never lands.
346
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'cancel', id: request.id }))
347
+ reject(new Error(`Wallet request timed out. ${OUTCOME_UNKNOWN}`))
348
+ }
349
+ }, this.requestTimeout)
299
350
  })
300
351
  }
301
352
 
@@ -318,8 +369,11 @@ export class WalletClient {
318
369
  */
319
370
  getUrl(): string {
320
371
  if (this.pairingUrl) return this.pairingUrl
321
- if (this.remoteUrl) return `${this.remoteUrl}#t=${this.remoteToken ?? ''}`
322
- return `http://127.0.0.1:${LOCAL_PORTS[0]}/#t=${this.relayOptions.token ?? localToken()}`
372
+ if (this.remoteUrl) {
373
+ const token = this.remoteToken ? roomToken(this.remoteToken, this.identity, 'signer') : ''
374
+ return `${this.remoteUrl}#t=${token}`
375
+ }
376
+ return pairingUrlFor(LOCAL_PORTS[0], this.relayOptions.token ?? localToken(), this.identity)
323
377
  }
324
378
 
325
379
  /** A relay we do not own, or a server with no desktop, has no browser for us to open. */
@@ -388,15 +442,44 @@ export class WalletClient {
388
442
  }
389
443
 
390
444
  async stop(): Promise<void> {
445
+ if (this.idleTimer) clearTimeout(this.idleTimer)
391
446
  this.ws?.close()
392
447
  await this.relay?.stop()
393
448
  this.relay = null
394
449
  }
395
450
  }
396
451
 
397
- let client: WalletClient | null = null
452
+ const clients = new Map<string, WalletClient>()
453
+
454
+ let relayOptions: { port?: number; token?: string } = {}
455
+
456
+ /**
457
+ * Where the relay is, for every client this process makes.
458
+ *
459
+ * A property of the deployment rather than of any one person: a hosted server runs the
460
+ * relay on its own port with its own secret, and each identity's client has to reach that
461
+ * one. Set it before anybody signs — a client that cannot find a relay starts its own,
462
+ * which then has nobody in it.
463
+ */
464
+ export function configureWalletRelay(options: { port?: number; token?: string }): void {
465
+ relayOptions = options
466
+ }
398
467
 
399
- export function getWalletClient(relayOptions?: { port?: number; token?: string }): WalletClient {
400
- if (!client) client = new WalletClient(relayOptions)
468
+ /**
469
+ * One client per identity, because everything it holds is that person's: the socket, the
470
+ * in-flight requests, and which page is open with which account. A single-user server has
471
+ * exactly one, which is every self-hosted run.
472
+ */
473
+ export function getWalletClient(identity: string): WalletClient {
474
+ let client = clients.get(identity)
475
+ if (!client) {
476
+ client = new WalletClient(identity, relayOptions)
477
+ clients.set(identity, client)
478
+ }
401
479
  return client
402
480
  }
481
+
482
+ /** Every live client, for shutdown — callers outside a tool call have no identity of their own. */
483
+ export function allWalletClients(): WalletClient[] {
484
+ return [...clients.values()]
485
+ }