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,157 @@
1
+ import { mkdirSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { getClientManager } from './client.js'
5
+ import { getKeyValueStorage } from './kv-storage.js'
6
+ import type { ChainName } from './types.js'
7
+
8
+ /**
9
+ * Signing from a phone, with no page to load and nothing to host: the MCP server acts as
10
+ * the dApp, the wallet app is the signer, and WalletConnect's relay carries the (end-to-end
11
+ * encrypted) messages between them. Pair once by scanning a QR; the session is stored on
12
+ * disk, so it survives restarts.
13
+ */
14
+
15
+ type SignClientType = Awaited<ReturnType<typeof importSignClient>>['prototype']
16
+
17
+ async function importSignClient() {
18
+ // Imported lazily: several MB of SDK that is pointless to load without a project ID.
19
+ const { SignClient } = await import('@walletconnect/sign-client')
20
+ return SignClient
21
+ }
22
+
23
+ interface Session {
24
+ topic: string
25
+ accounts: string[]
26
+ chains: number[]
27
+ peer?: string
28
+ }
29
+
30
+ const METHODS = ['eth_sendTransaction', 'personal_sign', 'eth_signTypedData_v4']
31
+ const EVENTS = ['chainChanged', 'accountsChanged']
32
+ const APPROVAL_TIMEOUT = 300_000
33
+
34
+ function storePath(): string {
35
+ const dir = join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'web3-tools-mcp')
36
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
37
+ return join(dir, 'walletconnect.db')
38
+ }
39
+
40
+ export class PhoneSigner {
41
+ private client: SignClientType | undefined
42
+ private starting: Promise<void> | undefined
43
+ private pairing: { uri: string; expiresAt: number } | undefined
44
+
45
+ constructor(private projectId: string) {}
46
+
47
+ private async start(): Promise<void> {
48
+ if (this.client) return
49
+ if (this.starting) return this.starting
50
+
51
+ this.starting = (async () => {
52
+ const SignClient = await importSignClient()
53
+ // Redis when hosted (a free-tier filesystem is ephemeral, and losing the store means
54
+ // re-pairing by QR after every sleep); a file next to the other config locally.
55
+ const storage = getKeyValueStorage()
56
+ this.client = (await SignClient.init({
57
+ projectId: this.projectId,
58
+ ...(storage ? { storage } : { storageOptions: { database: storePath() } }),
59
+ metadata: {
60
+ name: 'web3-tools-mcp',
61
+ description: 'Blockchain tools for AI agents',
62
+ url: 'https://github.com/hajnalben/web3-tools-mcp',
63
+ icons: []
64
+ }
65
+ })) as SignClientType
66
+ })().finally(() => {
67
+ this.starting = undefined
68
+ })
69
+
70
+ return this.starting
71
+ }
72
+
73
+ /** The most recent still-valid session, if the phone is already paired. */
74
+ async session(): Promise<Session | undefined> {
75
+ await this.start()
76
+ const sessions = this.client?.session.getAll() ?? []
77
+ const live = sessions.filter((s) => s.expiry * 1000 > Date.now())
78
+ const session = live[live.length - 1]
79
+ if (!session) return undefined
80
+
81
+ const accounts = session.namespaces.eip155?.accounts ?? []
82
+ return {
83
+ topic: session.topic,
84
+ // "eip155:8453:0xabc…" → address / chain id
85
+ accounts: [...new Set(accounts.map((a) => a.split(':')[2] as string))],
86
+ chains: [...new Set(accounts.map((a) => Number(a.split(':')[1])))],
87
+ peer: session.peer?.metadata?.name
88
+ }
89
+ }
90
+
91
+ async isPaired(): Promise<boolean> {
92
+ return Boolean(await this.session())
93
+ }
94
+
95
+ /**
96
+ * Start a pairing and return its URI immediately, resolving the wallet's approval in the
97
+ * background — a tool result only reaches the user once it returns, so blocking here
98
+ * would hide the QR they are supposed to scan.
99
+ */
100
+ async pair(chains: ChainName[]): Promise<string> {
101
+ await this.start()
102
+ if (!this.client) throw new Error('WalletConnect failed to start')
103
+
104
+ const clientManager = getClientManager()
105
+ const eip155 = chains.map((chain) => `eip155:${clientManager.getChainId(chain)}`)
106
+
107
+ const { uri, approval } = await this.client.connect({
108
+ // Optional rather than required: wallets reject a session outright when they cannot
109
+ // serve every required chain, and few support all thirteen.
110
+ optionalNamespaces: { eip155: { chains: eip155, methods: METHODS, events: EVENTS } }
111
+ })
112
+
113
+ if (!uri) throw new Error('WalletConnect did not return a pairing URI')
114
+ this.pairing = { uri, expiresAt: Date.now() + APPROVAL_TIMEOUT }
115
+
116
+ approval()
117
+ .then((session) => console.error(`[WalletConnect] Paired with ${session.peer?.metadata?.name ?? 'a wallet'}`))
118
+ .catch((error) => console.error('[WalletConnect] Pairing was not completed:', error.message))
119
+
120
+ return uri
121
+ }
122
+
123
+ /** Send a request to the paired wallet. Throws if the chain is outside the session. */
124
+ async request(chain: ChainName, method: string, params: unknown[]): Promise<unknown> {
125
+ const session = await this.session()
126
+ if (!session || !this.client) throw new Error('No phone wallet paired')
127
+
128
+ const chainId = getClientManager().getChainId(chain)
129
+ if (!session.chains.includes(chainId)) {
130
+ throw new Error(
131
+ `The paired wallet did not approve ${chain} (chain ${chainId}). It approved: ${session.chains.join(', ')}. Re-pair to add it.`
132
+ )
133
+ }
134
+
135
+ return this.client.request({
136
+ topic: session.topic,
137
+ chainId: `eip155:${chainId}`,
138
+ request: { method, params }
139
+ })
140
+ }
141
+
142
+ async unpair(): Promise<void> {
143
+ const session = await this.session()
144
+ if (!session || !this.client) return
145
+ await this.client.disconnect({ topic: session.topic, reason: { code: 6000, message: 'User disconnected' } })
146
+ }
147
+ }
148
+
149
+ let signer: PhoneSigner | null = null
150
+
151
+ /** Configured only when a WalletConnect project id is present. */
152
+ export function getPhoneSigner(): PhoneSigner | null {
153
+ const projectId = getClientManager().getConfig().walletConnectProjectId
154
+ if (!projectId) return null
155
+ if (!signer) signer = new PhoneSigner(projectId)
156
+ return signer
157
+ }