web3-tools-mcp 1.4.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.
Files changed (54) hide show
  1. package/README.md +106 -7
  2. package/dist/chain-meta.d.ts +27 -0
  3. package/dist/chain-meta.d.ts.map +1 -0
  4. package/dist/chain-meta.js +92 -0
  5. package/dist/chain-meta.js.map +1 -0
  6. package/dist/clear-signing.d.ts +62 -0
  7. package/dist/clear-signing.d.ts.map +1 -0
  8. package/dist/clear-signing.js +139 -0
  9. package/dist/clear-signing.js.map +1 -0
  10. package/dist/client.d.ts.map +1 -1
  11. package/dist/client.js +4 -2
  12. package/dist/client.js.map +1 -1
  13. package/dist/erc7730-index.json.gz +0 -0
  14. package/dist/http-server.d.ts +12 -0
  15. package/dist/http-server.d.ts.map +1 -0
  16. package/dist/http-server.js +74 -0
  17. package/dist/http-server.js.map +1 -0
  18. package/dist/index.js +37 -13
  19. package/dist/index.js.map +1 -1
  20. package/dist/kv-storage.d.ts +23 -0
  21. package/dist/kv-storage.d.ts.map +1 -0
  22. package/dist/kv-storage.js +69 -0
  23. package/dist/kv-storage.js.map +1 -0
  24. package/dist/preview.d.ts +3 -0
  25. package/dist/preview.d.ts.map +1 -1
  26. package/dist/preview.js +46 -87
  27. package/dist/preview.js.map +1 -1
  28. package/dist/tools/transactions.d.ts +16 -2
  29. package/dist/tools/transactions.d.ts.map +1 -1
  30. package/dist/tools/transactions.js +128 -18
  31. package/dist/tools/transactions.js.map +1 -1
  32. package/dist/types.d.ts +1 -0
  33. package/dist/types.d.ts.map +1 -1
  34. package/dist/utils.d.ts +2 -7
  35. package/dist/utils.d.ts.map +1 -1
  36. package/dist/utils.js +1 -0
  37. package/dist/utils.js.map +1 -1
  38. package/dist/walletconnect.d.ts +31 -0
  39. package/dist/walletconnect.d.ts.map +1 -0
  40. package/dist/walletconnect.js +128 -0
  41. package/dist/walletconnect.js.map +1 -0
  42. package/package.json +9 -4
  43. package/src/chain-meta.ts +102 -0
  44. package/src/clear-signing.ts +206 -0
  45. package/src/client.ts +4 -2
  46. package/src/erc7730-index.json.gz +0 -0
  47. package/src/http-server.ts +94 -0
  48. package/src/index.ts +38 -13
  49. package/src/kv-storage.ts +83 -0
  50. package/src/preview.ts +49 -94
  51. package/src/tools/transactions.ts +146 -20
  52. package/src/types.ts +1 -0
  53. package/src/utils.ts +4 -17
  54. package/src/walletconnect.ts +157 -0
@@ -0,0 +1,102 @@
1
+ /**
2
+ * On-chain metadata every layer of the preview needs: contract ABIs, token symbols and
3
+ * human labels for addresses. Kept apart from the preview and the clear-signing registry
4
+ * so both can use it without importing each other.
5
+ */
6
+ import { type Abi, type Address, parseAbiItem } from 'viem'
7
+ import { whatsabi } from '@shazow/whatsabi'
8
+ import { getClientManager } from './client.js'
9
+ import type { ChainName } from './types.js'
10
+
11
+ const ERC20_META_ABI = [
12
+ parseAbiItem('function decimals() view returns (uint8)'),
13
+ parseAbiItem('function symbol() view returns (string)')
14
+ ]
15
+
16
+ const abiCache = new Map<string, { abi: Abi; source: 'verified' | 'guessed'; proxy?: string; name?: string }>()
17
+ const labelCache = new Map<string, string | undefined>()
18
+ const metaCache = new Map<string, { symbol?: string; decimals?: number }>()
19
+
20
+ export async function loadAbi(chain: ChainName, address: string) {
21
+ const key = `${chain}:${address.toLowerCase()}`
22
+ const cached = abiCache.get(key)
23
+ if (cached) return cached
24
+
25
+ const clientManager = getClientManager()
26
+ const client = clientManager.getClient(chain)
27
+ const etherscanApiKey = clientManager.getConfig().etherscanApiKey
28
+
29
+ const loaders: whatsabi.loaders.ABILoader[] = [new whatsabi.loaders.SourcifyABILoader({ chainId: clientManager.getChainId(chain) })]
30
+ if (etherscanApiKey) {
31
+ loaders.push(new whatsabi.loaders.EtherscanV2ABILoader({ apiKey: etherscanApiKey, chainId: clientManager.getChainId(chain) }))
32
+ }
33
+
34
+ const result = await whatsabi.autoload(address as Address, {
35
+ provider: client,
36
+ abiLoader: new whatsabi.loaders.MultiABILoader(loaders),
37
+ signatureLookup: new whatsabi.loaders.OpenChainSignatureLookup(),
38
+ followProxies: true
39
+ })
40
+
41
+ // Bytecode-guessed ABIs carry no argument names; verified ones do.
42
+ const source = result.abi.some((item) => item.type === 'function' && item.inputs?.some((i) => i.name)) ? 'verified' : 'guessed'
43
+ const loaded = {
44
+ abi: result.abi as Abi,
45
+ source: source as 'verified' | 'guessed',
46
+ proxy: result.address !== address ? result.address : undefined,
47
+ name: result.contractResult?.name ?? undefined
48
+ }
49
+ abiCache.set(key, loaded)
50
+ return loaded
51
+ }
52
+
53
+ export async function tokenMeta(chain: ChainName, token: string) {
54
+ const key = `${chain}:${token.toLowerCase()}`
55
+ const cached = metaCache.get(key)
56
+ if (cached) return cached
57
+
58
+ const client = getClientManager().getClient(chain)
59
+ const [decimals, symbol] = await client.multicall({
60
+ contracts: [
61
+ { address: token as Address, abi: ERC20_META_ABI, functionName: 'decimals' },
62
+ { address: token as Address, abi: ERC20_META_ABI, functionName: 'symbol' }
63
+ ],
64
+ ...(chain === 'localhost' && { deployless: true })
65
+ })
66
+
67
+ const meta = {
68
+ decimals: decimals.status === 'success' ? Number(decimals.result) : undefined,
69
+ symbol: symbol.status === 'success' ? (symbol.result as string) : undefined
70
+ }
71
+ metaCache.set(key, meta)
72
+ return meta
73
+ }
74
+
75
+ /**
76
+ * Human label for an address: token symbol first (more recognisable than the contract
77
+ * name — "USDC" beats "FiatTokenProxy"), then the verified contract name. EOAs get none,
78
+ * and the bytecode check keeps us from asking explorers about plain wallets.
79
+ */
80
+ export async function addressLabel(chain: ChainName, address: string): Promise<string | undefined> {
81
+ const key = `${chain}:${address.toLowerCase()}`
82
+ if (labelCache.has(key)) return labelCache.get(key)
83
+
84
+ let label: string | undefined
85
+ try {
86
+ const code = await getClientManager().getClient(chain).getBytecode({ address: address as Address })
87
+ if (code && code !== '0x') {
88
+ label = (await tokenMeta(chain, address).catch(() => ({ symbol: undefined }))).symbol
89
+ if (!label) label = (await loadAbi(chain, address)).name
90
+ }
91
+ } catch {
92
+ // Unknown address — show it bare rather than failing the preview.
93
+ }
94
+
95
+ labelCache.set(key, label)
96
+ return label
97
+ }
98
+
99
+
100
+ /** uint256 max and anything near it means "infinite" in an approval. */
101
+ export const MAX_UINT256 = (1n << 256n) - 1n
102
+ export const UNLIMITED_THRESHOLD = MAX_UINT256 / 2n
@@ -0,0 +1,206 @@
1
+ import { createRequire } from 'node:module'
2
+ import { gunzipSync } from 'node:zlib'
3
+ import { formatUnits, getAddress, isAddress } from 'viem'
4
+ import { addressLabel, tokenMeta, UNLIMITED_THRESHOLD } from './chain-meta.js'
5
+ import { getClientManager } from './client.js'
6
+ import type { ChainName } from './types.js'
7
+
8
+ /**
9
+ * Clear signing from the Ledger ERC-7730 registry: for a known contract and selector it
10
+ * says what the transaction *means* ("Swap", "Approve spending") and labels each field the
11
+ * way the protocol intends, instead of showing raw ABI argument names.
12
+ *
13
+ * The index is built from the registry by scripts/build-erc7730-index.mjs.
14
+ */
15
+
16
+ interface IndexField {
17
+ // A handful of registry descriptors omit these, so nothing here is guaranteed.
18
+ label?: string
19
+ path?: string
20
+ format?: string
21
+ /** tokenAmount: `tokenPath` points at the field holding the token address. */
22
+ params?: { tokenPath?: string; tokenAddress?: string; [k: string]: unknown }
23
+ }
24
+
25
+ interface IndexSelector {
26
+ name: string
27
+ intent: string
28
+ /** Full signature, needed to ABI-decode the calldata these paths address. */
29
+ signature?: string
30
+ fields: IndexField[]
31
+ }
32
+
33
+ interface IndexEntry {
34
+ protocol: string
35
+ chainIds: number[]
36
+ selectors: Record<string, IndexSelector>
37
+ }
38
+
39
+ export interface ClearSigningField {
40
+ label: string
41
+ format: string
42
+ value: string
43
+ address?: string
44
+ name?: string
45
+ }
46
+
47
+ export interface ClearSigningInfo {
48
+ protocol: string
49
+ intent: string
50
+ functionName: string
51
+ fields: ClearSigningField[]
52
+ }
53
+
54
+ // Prettier names for registry directory slugs; unknown ones read fine as-is.
55
+ const PROTOCOL_LABELS: Record<string, string> = {
56
+ lifi: 'LI.FI',
57
+ '1inch': '1inch',
58
+ uniswap: 'Uniswap',
59
+ paraswap: 'ParaSwap',
60
+ cowswap: 'CoW Swap',
61
+ '0x': '0x Protocol',
62
+ aave: 'Aave',
63
+ morpho: 'Morpho',
64
+ safe: 'Safe',
65
+ lido: 'Lido',
66
+ eigenlayer: 'EigenLayer',
67
+ ens: 'ENS'
68
+ }
69
+
70
+ const require = createRequire(import.meta.url)
71
+ let index: Record<string, IndexEntry> | undefined
72
+
73
+ /** Loaded on first use: ~1.5MB of JSON, shipped gzipped at ~60KB. */
74
+ function registry(): Record<string, IndexEntry> {
75
+ if (!index) {
76
+ try {
77
+ const path = require.resolve('./erc7730-index.json.gz')
78
+ index = JSON.parse(gunzipSync(require('node:fs').readFileSync(path)).toString())
79
+ } catch {
80
+ index = {}
81
+ }
82
+ }
83
+ return index as Record<string, IndexEntry>
84
+ }
85
+
86
+ export function protocolLabel(slug: string): string {
87
+ return PROTOCOL_LABELS[slug] ?? slug
88
+ }
89
+
90
+ /** Resolve a registry path ("params.amountIn", "#.to", "[0].value") against decoded args. */
91
+ function getByPath(root: unknown, path: string): unknown {
92
+ let current: unknown = root
93
+ for (const part of path.replace(/^[#@]\./, '').split('.')) {
94
+ if (current == null) return undefined
95
+ const arrayIndex = part.match(/^\[(\d*)\]$/)
96
+ if (arrayIndex) {
97
+ current = Array.isArray(current) ? current[arrayIndex[1] === '' ? 0 : Number(arrayIndex[1])] : undefined
98
+ } else {
99
+ current = (current as Record<string, unknown>)[part]
100
+ }
101
+ }
102
+ return current
103
+ }
104
+
105
+ interface TxContext {
106
+ to: string
107
+ value?: string
108
+ from?: string
109
+ }
110
+
111
+ function resolvePath(root: unknown, tx: TxContext, path: string): unknown {
112
+ if (path.startsWith('@.')) {
113
+ if (path.slice(2) === 'to') return tx.to
114
+ if (path.slice(2) === 'value') return tx.value
115
+ if (path.slice(2) === 'from') return tx.from
116
+ return undefined
117
+ }
118
+ return getByPath(root, path)
119
+ }
120
+
121
+ async function formatField(chain: ChainName, field: IndexField, root: unknown, tx: TxContext): Promise<ClearSigningField> {
122
+ // A missing format with a token reference still means an amount.
123
+ const format = field.format ?? (field.params?.tokenPath || field.params?.tokenAddress ? 'tokenAmount' : 'raw')
124
+ const label = field.label ?? field.path ?? format
125
+ const raw = field.path ? resolvePath(root, tx, field.path) : undefined
126
+ const out: ClearSigningField = { label, format, value: '' }
127
+
128
+ // Unresolved: show the descriptor's path rather than an empty row.
129
+ if (raw == null) {
130
+ out.value = field.path ?? ''
131
+ return out
132
+ }
133
+
134
+ switch (format) {
135
+ case 'tokenAmount':
136
+ case 'amount': {
137
+ const amount = typeof raw === 'bigint' ? raw : BigInt(raw as string | number)
138
+ const token =
139
+ field.params?.tokenAddress ?? (field.params?.tokenPath ? resolvePath(root, tx, field.params.tokenPath) : undefined)
140
+
141
+ const meta =
142
+ typeof token === 'string' && isAddress(token)
143
+ ? await tokenMeta(chain, token).catch(() => ({ symbol: undefined, decimals: undefined }))
144
+ : { symbol: undefined, decimals: undefined }
145
+
146
+ const symbol = meta.symbol ? ` ${meta.symbol}` : ''
147
+ out.value = amount > UNLIMITED_THRESHOLD ? `Unlimited${symbol}` : `${formatUnits(amount, meta.decimals ?? 18)}${symbol}`
148
+ return out
149
+ }
150
+
151
+ case 'addressName':
152
+ case 'address':
153
+ case 'tokenName': {
154
+ if (!isAddress(raw as string)) {
155
+ out.value = String(raw)
156
+ return out
157
+ }
158
+ const address = getAddress(raw as string)
159
+ const name = registry()[address.toLowerCase()]?.protocol
160
+ out.address = address
161
+ out.name = name ? protocolLabel(name) : await addressLabel(chain, address).catch(() => undefined)
162
+ out.value = out.name ?? address
163
+ return out
164
+ }
165
+
166
+ default:
167
+ out.value = typeof raw === 'bigint' ? raw.toString() : String(raw)
168
+ return out
169
+ }
170
+ }
171
+
172
+ /** Registry entry for a contract, if it is listed for this chain. */
173
+ export function lookupContract(chain: ChainName, address: string): IndexEntry | undefined {
174
+ const entry = registry()[address.toLowerCase()]
175
+ if (!entry) return undefined
176
+ const chainId = getClientManager().getChainId(chain)
177
+ return entry.chainIds.includes(chainId) ? entry : undefined
178
+ }
179
+
180
+ /**
181
+ * Clear-signing info for a transaction, or null when the contract/selector is unlisted.
182
+ * `decodedArgs` is supplied by the caller, which has already decoded the calldata against
183
+ * the contract's real ABI — the registry's own signature is only a fallback for naming.
184
+ */
185
+ export async function resolveClearSigning(
186
+ chain: ChainName,
187
+ tx: TxContext & { data?: string },
188
+ decodedArgs: Record<string, unknown>
189
+ ): Promise<ClearSigningInfo | null> {
190
+ if (!tx.data || tx.data.length < 10) return null
191
+
192
+ const entry = lookupContract(chain, tx.to)
193
+ const format = entry?.selectors[tx.data.slice(0, 10).toLowerCase()]
194
+ if (!entry || !format) return null
195
+
196
+ // Fields with neither a path nor a label carry nothing a signer could read.
197
+ const usable = format.fields.filter((field) => field.path || field.label)
198
+ const fields = await Promise.all(usable.map((field) => formatField(chain, field, decodedArgs, tx)))
199
+
200
+ return {
201
+ protocol: protocolLabel(entry.protocol),
202
+ intent: format.intent,
203
+ functionName: format.name,
204
+ fields
205
+ }
206
+ }
package/src/client.ts CHANGED
@@ -33,7 +33,8 @@ function getRpcUrl(chainName: string, config: Config): string {
33
33
 
34
34
  base: config.alchemyApiKey
35
35
  ? `https://base-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`
36
- : 'https://base-rpc.publicnode.com',
36
+ // publicnode refuses receipts and anything else it calls an archive request
37
+ : 'https://mainnet.base.org',
37
38
 
38
39
  arbitrum: config.alchemyApiKey
39
40
  ? `https://arb-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`
@@ -45,7 +46,8 @@ function getRpcUrl(chainName: string, config: Config): string {
45
46
  ? `https://polygon-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`
46
47
  : config.infuraApiKey
47
48
  ? `https://polygon-mainnet.infura.io/v3/${config.infuraApiKey}`
48
- : 'https://polygon-rpc.com',
49
+ // polygon-rpc.com now answers 401 "API key disabled" to anonymous callers
50
+ : 'https://polygon-bor-rpc.publicnode.com',
49
51
 
50
52
  optimism: config.alchemyApiKey
51
53
  ? `https://opt-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`
Binary file
@@ -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
+ }