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.
- package/README.md +106 -7
- package/dist/chain-meta.d.ts +27 -0
- package/dist/chain-meta.d.ts.map +1 -0
- package/dist/chain-meta.js +92 -0
- package/dist/chain-meta.js.map +1 -0
- package/dist/clear-signing.d.ts +62 -0
- package/dist/clear-signing.d.ts.map +1 -0
- package/dist/clear-signing.js +139 -0
- package/dist/clear-signing.js.map +1 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +4 -2
- package/dist/client.js.map +1 -1
- package/dist/erc7730-index.json.gz +0 -0
- package/dist/http-server.d.ts +12 -0
- package/dist/http-server.d.ts.map +1 -0
- package/dist/http-server.js +74 -0
- package/dist/http-server.js.map +1 -0
- package/dist/index.js +37 -13
- package/dist/index.js.map +1 -1
- package/dist/kv-storage.d.ts +23 -0
- package/dist/kv-storage.d.ts.map +1 -0
- package/dist/kv-storage.js +69 -0
- package/dist/kv-storage.js.map +1 -0
- package/dist/preview.d.ts +3 -0
- package/dist/preview.d.ts.map +1 -1
- package/dist/preview.js +46 -87
- package/dist/preview.js.map +1 -1
- package/dist/tools/transactions.d.ts +16 -2
- package/dist/tools/transactions.d.ts.map +1 -1
- package/dist/tools/transactions.js +128 -18
- package/dist/tools/transactions.js.map +1 -1
- package/dist/types.d.ts +1 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts +2 -7
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +1 -0
- package/dist/utils.js.map +1 -1
- package/dist/walletconnect.d.ts +31 -0
- package/dist/walletconnect.d.ts.map +1 -0
- package/dist/walletconnect.js +128 -0
- package/dist/walletconnect.js.map +1 -0
- package/package.json +9 -4
- package/src/chain-meta.ts +102 -0
- package/src/clear-signing.ts +206 -0
- package/src/client.ts +4 -2
- package/src/erc7730-index.json.gz +0 -0
- package/src/http-server.ts +94 -0
- package/src/index.ts +38 -13
- package/src/kv-storage.ts +83 -0
- package/src/preview.ts +49 -94
- package/src/tools/transactions.ts +146 -20
- package/src/types.ts +1 -0
- package/src/utils.ts +4 -17
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
const server = new McpServer({
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
//
|
|
87
|
-
|
|
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 =
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
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
|
+
}
|