web3-tools-mcp 1.6.0 → 2.0.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/CHANGELOG.md +95 -0
- package/README.md +328 -234
- package/dist/anvil.d.ts +1 -1
- package/dist/anvil.d.ts.map +1 -1
- package/dist/anvil.js +11 -9
- package/dist/anvil.js.map +1 -1
- package/dist/chain-meta.d.ts.map +1 -1
- package/dist/chain-meta.js +7 -3
- package/dist/chain-meta.js.map +1 -1
- package/dist/chains.d.ts +960 -0
- package/dist/chains.d.ts.map +1 -0
- package/dist/chains.js +108 -0
- package/dist/chains.js.map +1 -0
- package/dist/client.d.ts +10 -1717
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +33 -218
- package/dist/client.js.map +1 -1
- package/dist/http-server.d.ts +6 -0
- package/dist/http-server.d.ts.map +1 -1
- package/dist/http-server.js +55 -40
- package/dist/http-server.js.map +1 -1
- package/dist/index.js +50 -29
- package/dist/index.js.map +1 -1
- package/dist/oauth.d.ts +31 -0
- package/dist/oauth.d.ts.map +1 -0
- package/dist/oauth.js +227 -0
- package/dist/oauth.js.map +1 -0
- package/dist/preview.d.ts.map +1 -1
- package/dist/preview.js +2 -2
- package/dist/preview.js.map +1 -1
- package/dist/tools/advanced.d.ts +4 -4
- package/dist/tools/advanced.d.ts.map +1 -1
- package/dist/tools/advanced.js +228 -247
- package/dist/tools/advanced.js.map +1 -1
- package/dist/tools/balance.d.ts +1 -1
- package/dist/tools/balance.d.ts.map +1 -1
- package/dist/tools/balance.js +97 -103
- package/dist/tools/balance.js.map +1 -1
- package/dist/tools/contract-info.d.ts +3 -3
- package/dist/tools/contract-info.d.ts.map +1 -1
- package/dist/tools/contract-info.js +150 -161
- package/dist/tools/contract-info.js.map +1 -1
- package/dist/tools/contract.d.ts +4 -4
- package/dist/tools/contract.d.ts.map +1 -1
- package/dist/tools/contract.js +91 -110
- package/dist/tools/contract.js.map +1 -1
- package/dist/tools/ens.d.ts +6 -6
- package/dist/tools/ens.d.ts.map +1 -1
- package/dist/tools/ens.js +239 -297
- package/dist/tools/ens.js.map +1 -1
- package/dist/tools/gas.d.ts +4 -4
- package/dist/tools/gas.d.ts.map +1 -1
- package/dist/tools/gas.js +90 -138
- package/dist/tools/gas.js.map +1 -1
- package/dist/tools/index.d.ts +1 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/logs.d.ts +1 -1
- package/dist/tools/logs.d.ts.map +1 -1
- package/dist/tools/logs.js +36 -37
- package/dist/tools/logs.js.map +1 -1
- package/dist/tools/signatures.d.ts.map +1 -1
- package/dist/tools/signatures.js +67 -89
- package/dist/tools/signatures.js.map +1 -1
- package/dist/tools/transactions.d.ts +35 -5
- package/dist/tools/transactions.d.ts.map +1 -1
- package/dist/tools/transactions.js +148 -74
- package/dist/tools/transactions.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts +1 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +35 -98
- package/dist/utils.js.map +1 -1
- package/dist/wallet-client.d.ts +11 -1
- package/dist/wallet-client.d.ts.map +1 -1
- package/dist/wallet-client.js +27 -9
- package/dist/wallet-client.js.map +1 -1
- package/dist/walletconnect.d.ts +22 -1
- package/dist/walletconnect.d.ts.map +1 -1
- package/dist/walletconnect.js +101 -8
- package/dist/walletconnect.js.map +1 -1
- package/package.json +17 -9
- package/src/anvil.ts +15 -18
- package/src/chain-meta.ts +8 -4
- package/src/chains.ts +145 -0
- package/src/client.ts +39 -254
- package/src/http-server.ts +67 -44
- package/src/index.ts +77 -53
- package/src/oauth.ts +270 -0
- package/src/preview.ts +3 -12
- package/src/tools/advanced.ts +195 -213
- package/src/tools/balance.ts +103 -108
- package/src/tools/contract-info.ts +163 -184
- package/src/tools/contract.ts +123 -144
- package/src/tools/ens.ts +290 -345
- package/src/tools/gas.ts +122 -170
- package/src/tools/index.ts +2 -2
- package/src/tools/logs.ts +45 -43
- package/src/tools/signatures.ts +68 -86
- package/src/tools/transactions.ts +194 -87
- package/src/types.ts +1 -15
- package/src/utils.ts +52 -110
- package/src/wallet-client.ts +31 -9
- package/src/walletconnect.ts +119 -7
package/src/chain-meta.ts
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
* human labels for addresses. Kept apart from the preview and the clear-signing registry
|
|
4
4
|
* so both can use it without importing each other.
|
|
5
5
|
*/
|
|
6
|
-
|
|
6
|
+
|
|
7
7
|
import { whatsabi } from '@shazow/whatsabi'
|
|
8
|
+
import { type Abi, type Address, parseAbiItem } from 'viem'
|
|
8
9
|
import { getClientManager } from './client.js'
|
|
9
10
|
import type { ChainName } from './types.js'
|
|
10
11
|
|
|
@@ -26,7 +27,9 @@ export async function loadAbi(chain: ChainName, address: string) {
|
|
|
26
27
|
const client = clientManager.getClient(chain)
|
|
27
28
|
const etherscanApiKey = clientManager.getConfig().etherscanApiKey
|
|
28
29
|
|
|
29
|
-
const loaders: whatsabi.loaders.ABILoader[] = [
|
|
30
|
+
const loaders: whatsabi.loaders.ABILoader[] = [
|
|
31
|
+
new whatsabi.loaders.SourcifyABILoader({ chainId: clientManager.getChainId(chain) })
|
|
32
|
+
]
|
|
30
33
|
if (etherscanApiKey) {
|
|
31
34
|
loaders.push(new whatsabi.loaders.EtherscanV2ABILoader({ apiKey: etherscanApiKey, chainId: clientManager.getChainId(chain) }))
|
|
32
35
|
}
|
|
@@ -83,7 +86,9 @@ export async function addressLabel(chain: ChainName, address: string): Promise<s
|
|
|
83
86
|
|
|
84
87
|
let label: string | undefined
|
|
85
88
|
try {
|
|
86
|
-
const code = await getClientManager()
|
|
89
|
+
const code = await getClientManager()
|
|
90
|
+
.getClient(chain)
|
|
91
|
+
.getBytecode({ address: address as Address })
|
|
87
92
|
if (code && code !== '0x') {
|
|
88
93
|
label = (await tokenMeta(chain, address).catch(() => ({ symbol: undefined }))).symbol
|
|
89
94
|
if (!label) label = (await loadAbi(chain, address)).name
|
|
@@ -96,7 +101,6 @@ export async function addressLabel(chain: ChainName, address: string): Promise<s
|
|
|
96
101
|
return label
|
|
97
102
|
}
|
|
98
103
|
|
|
99
|
-
|
|
100
104
|
/** uint256 max and anything near it means "infinite" in an approval. */
|
|
101
105
|
export const MAX_UINT256 = (1n << 256n) - 1n
|
|
102
106
|
export const UNLIMITED_THRESHOLD = MAX_UINT256 / 2n
|
package/src/chains.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { Chain } from 'viem'
|
|
2
|
+
import {
|
|
3
|
+
arbitrum,
|
|
4
|
+
avalanche,
|
|
5
|
+
base,
|
|
6
|
+
bsc,
|
|
7
|
+
gnosis,
|
|
8
|
+
linea,
|
|
9
|
+
localhost,
|
|
10
|
+
mainnet,
|
|
11
|
+
optimism,
|
|
12
|
+
polygon,
|
|
13
|
+
sonic,
|
|
14
|
+
unichain,
|
|
15
|
+
zksync
|
|
16
|
+
} from 'viem/chains'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Every chain this server knows, in one table. Adding one here is the whole change: the
|
|
20
|
+
* `ChainName` union, the tool schemas, RPC selection, explorer links, Hypersync routing and
|
|
21
|
+
* the `--help` listing are all derived from it.
|
|
22
|
+
*/
|
|
23
|
+
interface ChainInfo {
|
|
24
|
+
chain: Chain
|
|
25
|
+
/** Subdomain under g.alchemy.com; absent where Alchemy has no endpoint. */
|
|
26
|
+
alchemy?: string
|
|
27
|
+
/** Subdomain under infura.io; absent where Infura has no endpoint. */
|
|
28
|
+
infura?: string
|
|
29
|
+
/** Used when no provider key is configured. */
|
|
30
|
+
fallback: string
|
|
31
|
+
/** Block explorer domain, for links. Absent where there is nothing to link to. */
|
|
32
|
+
explorer?: string
|
|
33
|
+
hypersync?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const TABLE = {
|
|
37
|
+
mainnet: {
|
|
38
|
+
chain: mainnet,
|
|
39
|
+
alchemy: 'eth-mainnet',
|
|
40
|
+
infura: 'mainnet',
|
|
41
|
+
fallback: 'https://ethereum-rpc.publicnode.com',
|
|
42
|
+
explorer: 'etherscan.io',
|
|
43
|
+
hypersync: 'https://eth.hypersync.xyz'
|
|
44
|
+
},
|
|
45
|
+
arbitrum: {
|
|
46
|
+
chain: arbitrum,
|
|
47
|
+
alchemy: 'arb-mainnet',
|
|
48
|
+
infura: 'arbitrum-mainnet',
|
|
49
|
+
fallback: 'https://arb1.arbitrum.io/rpc',
|
|
50
|
+
explorer: 'arbiscan.io',
|
|
51
|
+
hypersync: 'https://arbitrum.hypersync.xyz'
|
|
52
|
+
},
|
|
53
|
+
avalanche: {
|
|
54
|
+
chain: avalanche,
|
|
55
|
+
alchemy: 'avax-mainnet',
|
|
56
|
+
infura: 'avalanche-mainnet',
|
|
57
|
+
fallback: 'https://api.avax.network/ext/bc/C/rpc',
|
|
58
|
+
explorer: 'snowtrace.io',
|
|
59
|
+
hypersync: 'https://avalanche.hypersync.xyz'
|
|
60
|
+
},
|
|
61
|
+
base: {
|
|
62
|
+
chain: base,
|
|
63
|
+
alchemy: 'base-mainnet',
|
|
64
|
+
// publicnode refuses receipts and anything else it calls an archive request
|
|
65
|
+
fallback: 'https://mainnet.base.org',
|
|
66
|
+
explorer: 'basescan.org',
|
|
67
|
+
hypersync: 'https://base.hypersync.xyz'
|
|
68
|
+
},
|
|
69
|
+
bnb: {
|
|
70
|
+
chain: bsc,
|
|
71
|
+
alchemy: 'bnb-mainnet',
|
|
72
|
+
infura: 'bsc-mainnet',
|
|
73
|
+
fallback: 'https://bsc-dataseed.bnbchain.org',
|
|
74
|
+
explorer: 'bscscan.com',
|
|
75
|
+
hypersync: 'https://bsc.hypersync.xyz'
|
|
76
|
+
},
|
|
77
|
+
gnosis: {
|
|
78
|
+
chain: gnosis,
|
|
79
|
+
alchemy: 'gnosis-mainnet',
|
|
80
|
+
fallback: 'https://rpc.gnosischain.com',
|
|
81
|
+
explorer: 'gnosisscan.io',
|
|
82
|
+
hypersync: 'https://gnosis.hypersync.xyz'
|
|
83
|
+
},
|
|
84
|
+
sonic: {
|
|
85
|
+
chain: sonic,
|
|
86
|
+
alchemy: 'sonic-mainnet',
|
|
87
|
+
fallback: 'https://rpc.soniclabs.com',
|
|
88
|
+
explorer: 'sonicscan.org',
|
|
89
|
+
hypersync: 'https://sonic.hypersync.xyz'
|
|
90
|
+
},
|
|
91
|
+
optimism: {
|
|
92
|
+
chain: optimism,
|
|
93
|
+
alchemy: 'opt-mainnet',
|
|
94
|
+
infura: 'optimism-mainnet',
|
|
95
|
+
fallback: 'https://mainnet.optimism.io',
|
|
96
|
+
explorer: 'optimistic.etherscan.io',
|
|
97
|
+
hypersync: 'https://optimism.hypersync.xyz'
|
|
98
|
+
},
|
|
99
|
+
polygon: {
|
|
100
|
+
chain: polygon,
|
|
101
|
+
alchemy: 'polygon-mainnet',
|
|
102
|
+
infura: 'polygon-mainnet',
|
|
103
|
+
// polygon-rpc.com now answers 401 "API key disabled" to anonymous callers
|
|
104
|
+
fallback: 'https://polygon-bor-rpc.publicnode.com',
|
|
105
|
+
explorer: 'polygonscan.com',
|
|
106
|
+
hypersync: 'https://polygon.hypersync.xyz'
|
|
107
|
+
},
|
|
108
|
+
zksync: {
|
|
109
|
+
chain: zksync,
|
|
110
|
+
alchemy: 'zksync-mainnet',
|
|
111
|
+
infura: 'zksync-mainnet',
|
|
112
|
+
fallback: 'https://mainnet.era.zksync.io',
|
|
113
|
+
explorer: 'era.zksync.network',
|
|
114
|
+
hypersync: 'https://zksync.hypersync.xyz'
|
|
115
|
+
},
|
|
116
|
+
linea: {
|
|
117
|
+
chain: linea,
|
|
118
|
+
alchemy: 'linea-mainnet',
|
|
119
|
+
infura: 'linea-mainnet',
|
|
120
|
+
fallback: 'https://rpc.linea.build',
|
|
121
|
+
explorer: 'lineascan.build',
|
|
122
|
+
hypersync: 'https://linea.hypersync.xyz'
|
|
123
|
+
},
|
|
124
|
+
unichain: {
|
|
125
|
+
chain: unichain,
|
|
126
|
+
alchemy: 'unichain-mainnet',
|
|
127
|
+
infura: 'unichain-mainnet',
|
|
128
|
+
fallback: 'https://mainnet.unichain.org',
|
|
129
|
+
explorer: 'uniscan.xyz',
|
|
130
|
+
hypersync: 'https://unichain.hypersync.xyz'
|
|
131
|
+
},
|
|
132
|
+
localhost: {
|
|
133
|
+
chain: localhost,
|
|
134
|
+
fallback: 'http://localhost:8545'
|
|
135
|
+
}
|
|
136
|
+
} as const satisfies Record<string, ChainInfo>
|
|
137
|
+
|
|
138
|
+
export type ChainName = keyof typeof TABLE
|
|
139
|
+
|
|
140
|
+
// Widened: `as const` above is only there to infer ChainName from the keys, and reading a
|
|
141
|
+
// literal entry would otherwise lose the fields that not every chain sets.
|
|
142
|
+
export const CHAINS: Record<ChainName, ChainInfo> = TABLE
|
|
143
|
+
|
|
144
|
+
/** Tuple rather than array, because `z.enum` needs a non-empty literal list. */
|
|
145
|
+
export const SUPPORTED_CHAINS = Object.keys(CHAINS) as [ChainName, ...ChainName[]]
|
package/src/client.ts
CHANGED
|
@@ -1,280 +1,65 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
avalanche,
|
|
5
|
-
base,
|
|
6
|
-
bsc,
|
|
7
|
-
gnosis,
|
|
8
|
-
linea,
|
|
9
|
-
localhost,
|
|
10
|
-
mainnet,
|
|
11
|
-
optimism,
|
|
12
|
-
polygon,
|
|
13
|
-
sonic,
|
|
14
|
-
unichain,
|
|
15
|
-
zksync
|
|
16
|
-
} from 'viem/chains'
|
|
17
|
-
import type { ChainName, Config } from './types.js'
|
|
1
|
+
import { createPublicClient, http } from 'viem'
|
|
2
|
+
import { CHAINS, type ChainName } from './chains.js'
|
|
3
|
+
import type { Config } from './types.js'
|
|
18
4
|
|
|
19
|
-
|
|
20
|
-
function getRpcUrl(chainName: string, config: Config): string {
|
|
21
|
-
// Check for custom RPC URLs first
|
|
22
|
-
if (config.customRpcUrls?.[chainName]) {
|
|
23
|
-
return config.customRpcUrls[chainName]
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// Enhanced RPC URLs with API keys
|
|
27
|
-
const enhancedRpcs: Record<string, string> = {
|
|
28
|
-
mainnet: config.alchemyApiKey
|
|
29
|
-
? `https://eth-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`
|
|
30
|
-
: config.infuraApiKey
|
|
31
|
-
? `https://mainnet.infura.io/v3/${config.infuraApiKey}`
|
|
32
|
-
: 'https://ethereum-rpc.publicnode.com',
|
|
33
|
-
|
|
34
|
-
base: config.alchemyApiKey
|
|
35
|
-
? `https://base-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`
|
|
36
|
-
// publicnode refuses receipts and anything else it calls an archive request
|
|
37
|
-
: 'https://mainnet.base.org',
|
|
38
|
-
|
|
39
|
-
arbitrum: config.alchemyApiKey
|
|
40
|
-
? `https://arb-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`
|
|
41
|
-
: config.infuraApiKey
|
|
42
|
-
? `https://arbitrum-mainnet.infura.io/v3/${config.infuraApiKey}`
|
|
43
|
-
: 'https://arb1.arbitrum.io/rpc',
|
|
44
|
-
|
|
45
|
-
polygon: config.alchemyApiKey
|
|
46
|
-
? `https://polygon-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`
|
|
47
|
-
: config.infuraApiKey
|
|
48
|
-
? `https://polygon-mainnet.infura.io/v3/${config.infuraApiKey}`
|
|
49
|
-
// polygon-rpc.com now answers 401 "API key disabled" to anonymous callers
|
|
50
|
-
: 'https://polygon-bor-rpc.publicnode.com',
|
|
51
|
-
|
|
52
|
-
optimism: config.alchemyApiKey
|
|
53
|
-
? `https://opt-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`
|
|
54
|
-
: config.infuraApiKey
|
|
55
|
-
? `https://optimism-mainnet.infura.io/v3/${config.infuraApiKey}`
|
|
56
|
-
: 'https://mainnet.optimism.io',
|
|
57
|
-
|
|
58
|
-
avalanche: config.alchemyApiKey
|
|
59
|
-
? `https://avax-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`
|
|
60
|
-
: config.infuraApiKey
|
|
61
|
-
? `https://avalanche-mainnet.infura.io/v3/${config.infuraApiKey}`
|
|
62
|
-
: 'https://api.avax.network/ext/bc/C/rpc',
|
|
5
|
+
export { SUPPORTED_CHAINS } from './chains.js'
|
|
63
6
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
: 'https://bsc-dataseed.bnbchain.org',
|
|
7
|
+
/** Custom URL first, then whichever provider key is configured, then the public endpoint. */
|
|
8
|
+
function getRpcUrl(chainName: ChainName, config: Config): string {
|
|
9
|
+
const custom = config.customRpcUrls?.[chainName]
|
|
10
|
+
if (custom) return custom
|
|
69
11
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
sonic: config.alchemyApiKey
|
|
75
|
-
? `https://sonic-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`
|
|
76
|
-
: 'https://rpc.soniclabs.com',
|
|
77
|
-
|
|
78
|
-
zksync: config.alchemyApiKey
|
|
79
|
-
? `https://zksync-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`
|
|
80
|
-
: config.infuraApiKey
|
|
81
|
-
? `https://zksync-mainnet.infura.io/v3/${config.infuraApiKey}`
|
|
82
|
-
: 'https://mainnet.era.zksync.io',
|
|
83
|
-
|
|
84
|
-
linea: config.alchemyApiKey
|
|
85
|
-
? `https://linea-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`
|
|
86
|
-
: config.infuraApiKey
|
|
87
|
-
? `https://linea-mainnet.infura.io/v3/${config.infuraApiKey}`
|
|
88
|
-
: 'https://rpc.linea.build',
|
|
89
|
-
|
|
90
|
-
unichain: config.alchemyApiKey
|
|
91
|
-
? `https://unichain-mainnet.g.alchemy.com/v2/${config.alchemyApiKey}`
|
|
92
|
-
: config.infuraApiKey
|
|
93
|
-
? `https://unichain-mainnet.infura.io/v3/${config.infuraApiKey}`
|
|
94
|
-
: 'https://sepolia.unichain.org',
|
|
95
|
-
|
|
96
|
-
localhost: 'http://localhost:8545'
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
return enhancedRpcs[chainName] ?? 'https://eth.llamarpc.com'
|
|
12
|
+
const { alchemy, infura, fallback } = CHAINS[chainName]
|
|
13
|
+
if (alchemy && config.alchemyApiKey) return `https://${alchemy}.g.alchemy.com/v2/${config.alchemyApiKey}`
|
|
14
|
+
if (infura && config.infuraApiKey) return `https://${infura}.infura.io/v3/${config.infuraApiKey}`
|
|
15
|
+
return fallback
|
|
100
16
|
}
|
|
101
17
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
mainnet: {
|
|
105
|
-
chain: mainnet,
|
|
106
|
-
get rpc() {
|
|
107
|
-
return getRpcUrl('mainnet', config)
|
|
108
|
-
}
|
|
109
|
-
},
|
|
110
|
-
base: {
|
|
111
|
-
chain: base,
|
|
112
|
-
get rpc() {
|
|
113
|
-
return getRpcUrl('base', config)
|
|
114
|
-
}
|
|
115
|
-
},
|
|
116
|
-
arbitrum: {
|
|
117
|
-
chain: arbitrum,
|
|
118
|
-
get rpc() {
|
|
119
|
-
return getRpcUrl('arbitrum', config)
|
|
120
|
-
}
|
|
121
|
-
},
|
|
122
|
-
polygon: {
|
|
123
|
-
chain: polygon,
|
|
124
|
-
get rpc() {
|
|
125
|
-
return getRpcUrl('polygon', config)
|
|
126
|
-
}
|
|
127
|
-
},
|
|
128
|
-
optimism: {
|
|
129
|
-
chain: optimism,
|
|
130
|
-
get rpc() {
|
|
131
|
-
return getRpcUrl('optimism', config)
|
|
132
|
-
}
|
|
133
|
-
},
|
|
134
|
-
avalanche: {
|
|
135
|
-
chain: avalanche,
|
|
136
|
-
get rpc() {
|
|
137
|
-
return getRpcUrl('avalanche', config)
|
|
138
|
-
}
|
|
139
|
-
},
|
|
140
|
-
bnb: {
|
|
141
|
-
chain: bsc,
|
|
142
|
-
get rpc() {
|
|
143
|
-
return getRpcUrl('bnb', config)
|
|
144
|
-
}
|
|
145
|
-
},
|
|
146
|
-
gnosis: {
|
|
147
|
-
chain: gnosis,
|
|
148
|
-
get rpc() {
|
|
149
|
-
return getRpcUrl('gnosis', config)
|
|
150
|
-
}
|
|
151
|
-
},
|
|
152
|
-
sonic: {
|
|
153
|
-
chain: sonic,
|
|
154
|
-
get rpc() {
|
|
155
|
-
return getRpcUrl('sonic', config)
|
|
156
|
-
}
|
|
157
|
-
},
|
|
158
|
-
zksync: {
|
|
159
|
-
chain: zksync,
|
|
160
|
-
get rpc() {
|
|
161
|
-
return getRpcUrl('zksync', config)
|
|
162
|
-
}
|
|
163
|
-
},
|
|
164
|
-
linea: {
|
|
165
|
-
chain: linea,
|
|
166
|
-
get rpc() {
|
|
167
|
-
return getRpcUrl('linea', config)
|
|
168
|
-
}
|
|
169
|
-
},
|
|
170
|
-
unichain: {
|
|
171
|
-
chain: unichain,
|
|
172
|
-
get rpc() {
|
|
173
|
-
return getRpcUrl('unichain', config)
|
|
174
|
-
}
|
|
175
|
-
},
|
|
176
|
-
localhost: {
|
|
177
|
-
chain: localhost,
|
|
178
|
-
get rpc() {
|
|
179
|
-
return getRpcUrl('localhost', config)
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
})
|
|
18
|
+
/** Named so declaration emit can refer to it: node_modules is hoisted above this package. */
|
|
19
|
+
type PublicClient = ReturnType<typeof createPublicClient>
|
|
183
20
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
mainnet: 'https://eth.hypersync.xyz', // Chain ID 1
|
|
187
|
-
arbitrum: 'https://arbitrum.hypersync.xyz', // Chain ID 42161
|
|
188
|
-
avalanche: 'https://avalanche.hypersync.xyz', // Chain ID 43114
|
|
189
|
-
base: 'https://base.hypersync.xyz', // Chain ID 8453
|
|
190
|
-
bnb: 'https://bsc.hypersync.xyz', // Chain ID 56
|
|
191
|
-
gnosis: 'https://gnosis.hypersync.xyz', // Chain ID 100
|
|
192
|
-
sonic: 'https://sonic.hypersync.xyz', // Chain ID 146
|
|
193
|
-
optimism: 'https://optimism.hypersync.xyz', // Chain ID 10
|
|
194
|
-
polygon: 'https://polygon.hypersync.xyz', // Chain ID 137
|
|
195
|
-
zksync: 'https://zksync.hypersync.xyz', // Chain ID 324
|
|
196
|
-
linea: 'https://linea.hypersync.xyz', // Chain ID 59144
|
|
197
|
-
unichain: 'https://unichain.hypersync.xyz' // Chain ID 130
|
|
198
|
-
// localhost is not supported by hypersync
|
|
199
|
-
}
|
|
21
|
+
export class ClientManager {
|
|
22
|
+
private clients = new Map<ChainName, PublicClient>()
|
|
200
23
|
|
|
201
|
-
|
|
202
|
-
'mainnet',
|
|
203
|
-
'arbitrum',
|
|
204
|
-
'avalanche',
|
|
205
|
-
'base',
|
|
206
|
-
'bnb',
|
|
207
|
-
'gnosis',
|
|
208
|
-
'sonic',
|
|
209
|
-
'optimism',
|
|
210
|
-
'polygon',
|
|
211
|
-
'zksync',
|
|
212
|
-
'linea',
|
|
213
|
-
'unichain',
|
|
214
|
-
'localhost'
|
|
215
|
-
] as const
|
|
24
|
+
constructor(private config: Config) {}
|
|
216
25
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
private config: Config
|
|
26
|
+
getClient(chainName: ChainName): PublicClient {
|
|
27
|
+
const cached = this.clients.get(chainName)
|
|
28
|
+
if (cached) return cached
|
|
221
29
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
30
|
+
// Cast because a client built for a concrete chain is not assignable to the generic one.
|
|
31
|
+
const client = createPublicClient({
|
|
32
|
+
chain: CHAINS[chainName].chain,
|
|
33
|
+
transport: http(this.getRpcUrl(chainName))
|
|
34
|
+
}) as PublicClient
|
|
225
35
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
const chains = CHAINS(this.config)
|
|
229
|
-
const { chain, rpc } = chains[chainName]
|
|
230
|
-
const client = createPublicClient({
|
|
231
|
-
chain,
|
|
232
|
-
transport: http(rpc)
|
|
233
|
-
})
|
|
234
|
-
this.clients.set(chainName, client)
|
|
235
|
-
}
|
|
236
|
-
const client = this.clients.get(chainName)
|
|
237
|
-
if (!client) {
|
|
238
|
-
throw new Error(`Failed to create client for chain: ${chainName}`)
|
|
239
|
-
}
|
|
240
|
-
return client as ReturnType<typeof createPublicClient>
|
|
36
|
+
this.clients.set(chainName, client)
|
|
37
|
+
return client
|
|
241
38
|
}
|
|
242
39
|
|
|
243
40
|
getConfig(): Config {
|
|
244
41
|
return this.config
|
|
245
42
|
}
|
|
246
43
|
|
|
247
|
-
getEtherscanDomain(chainName: ChainName): string {
|
|
248
|
-
const domains: Record<ChainName, string> = {
|
|
249
|
-
mainnet: 'etherscan.io',
|
|
250
|
-
base: 'basescan.org',
|
|
251
|
-
arbitrum: 'arbiscan.io',
|
|
252
|
-
polygon: 'polygonscan.com',
|
|
253
|
-
optimism: 'optimistic.etherscan.io',
|
|
254
|
-
avalanche: 'snowtrace.io',
|
|
255
|
-
bnb: 'bscscan.com',
|
|
256
|
-
gnosis: 'gnosisscan.io',
|
|
257
|
-
sonic: 'sonicscan.org',
|
|
258
|
-
zksync: 'era.zksync.network',
|
|
259
|
-
linea: 'lineascan.build',
|
|
260
|
-
unichain: 'uniscan.xyz',
|
|
261
|
-
localhost: 'etherscan.io'
|
|
262
|
-
}
|
|
263
|
-
return domains[chainName] || 'etherscan.io'
|
|
264
|
-
}
|
|
265
|
-
|
|
266
44
|
getRpcUrl(chainName: ChainName): string {
|
|
267
|
-
|
|
268
|
-
return chains[chainName].rpc
|
|
45
|
+
return getRpcUrl(chainName, this.config)
|
|
269
46
|
}
|
|
270
47
|
|
|
271
48
|
getChainId(chainName: ChainName): number {
|
|
272
|
-
|
|
273
|
-
|
|
49
|
+
return CHAINS[chainName].chain.id
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
getHypersyncUrl(chainName: ChainName): string | undefined {
|
|
53
|
+
return CHAINS[chainName].hypersync
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Undefined where the chain has no explorer — a local node, for one. */
|
|
57
|
+
explorerUrl(chainName: ChainName, path = ''): string | undefined {
|
|
58
|
+
const domain = CHAINS[chainName].explorer
|
|
59
|
+
return domain ? `https://${domain}${path}` : undefined
|
|
274
60
|
}
|
|
275
61
|
}
|
|
276
62
|
|
|
277
|
-
// Export a global client manager instance
|
|
278
63
|
let clientManager: ClientManager | null = null
|
|
279
64
|
|
|
280
65
|
export function initializeClientManager(config: Config): ClientManager {
|
package/src/http-server.ts
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
|
|
1
|
+
import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js'
|
|
2
|
+
import { getOAuthProtectedResourceMetadataUrl, mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js'
|
|
4
3
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
4
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
|
|
5
|
+
import express from 'express'
|
|
6
|
+
import type { WalletRelay } from 'web3-wallet-relay'
|
|
7
|
+
import { SingleUserOAuthProvider } from './oauth.js'
|
|
5
8
|
|
|
6
9
|
/**
|
|
7
10
|
* Serve the MCP over HTTP instead of stdio, so the server can live somewhere other than the
|
|
8
|
-
* machine running the agent.
|
|
9
|
-
*
|
|
11
|
+
* machine running the agent. Signing still reaches you: a paired phone over WalletConnect,
|
|
12
|
+
* or the signing page, which is mounted on this same server so one platform port carries
|
|
13
|
+
* both it and /mcp.
|
|
14
|
+
*
|
|
15
|
+
* Two ways in, because clients differ: a static `Authorization: Bearer <MCP_TOKEN>` header
|
|
16
|
+
* (Claude Code), and the OAuth flow the MCP spec defines (claude.ai connectors, which offer
|
|
17
|
+
* no way to send a header). Both end up at the same single credential.
|
|
10
18
|
*
|
|
11
19
|
* Stateless — a server per request — because every tool here is a one-shot call and the
|
|
12
20
|
* signing state lives in the WalletConnect session, not in the transport.
|
|
@@ -14,28 +22,20 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
|
14
22
|
|
|
15
23
|
const DEFAULT_PORT = 3457
|
|
16
24
|
|
|
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
25
|
export interface HttpServerOptions {
|
|
30
26
|
port?: number
|
|
31
27
|
host?: string
|
|
32
28
|
token?: string
|
|
29
|
+
/** Public URL clients reach this server on; OAuth metadata must advertise it. */
|
|
30
|
+
publicUrl?: string
|
|
31
|
+
/** Serve the signing page on this same server, so browser signing works when hosted. */
|
|
32
|
+
walletRelay?: WalletRelay
|
|
33
33
|
createMcpServer: () => McpServer
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
export async function startHttpServer(options: HttpServerOptions): Promise<{ url: string; port: number }> {
|
|
36
|
+
export async function startHttpServer(options: HttpServerOptions): Promise<{ url: string; port: number; walletUrl?: string }> {
|
|
37
37
|
const port = options.port ?? Number(process.env.MCP_HTTP_PORT) ?? DEFAULT_PORT
|
|
38
|
-
const host = options.host ?? process.env.
|
|
38
|
+
const host = options.host ?? process.env.MCP_HTTP_HOST ?? '0.0.0.0'
|
|
39
39
|
const token = options.token ?? process.env.MCP_TOKEN
|
|
40
40
|
|
|
41
41
|
// A reachable MCP server with a paired phone can push signing prompts at that phone. It
|
|
@@ -44,24 +44,41 @@ export async function startHttpServer(options: HttpServerOptions): Promise<{ url
|
|
|
44
44
|
throw new Error('MCP_TOKEN is required to serve MCP over HTTP — anyone reaching the URL could request signatures')
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
res.end(JSON.stringify({ status: 'ok' }))
|
|
51
|
-
return
|
|
52
|
-
}
|
|
47
|
+
const publicUrl = options.publicUrl ?? process.env.MCP_PUBLIC_URL ?? `http://localhost:${port}`
|
|
48
|
+
const issuer = new URL(publicUrl)
|
|
49
|
+
const provider = new SingleUserOAuthProvider(token)
|
|
53
50
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
51
|
+
const app = express()
|
|
52
|
+
app.use(express.json())
|
|
53
|
+
// The login form posts a token, so the authorize route needs form bodies too.
|
|
54
|
+
app.use(express.urlencoded({ extended: false }))
|
|
58
55
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
return
|
|
63
|
-
}
|
|
56
|
+
app.get('/health', (_req, res) => {
|
|
57
|
+
res.json({ status: 'ok' })
|
|
58
|
+
})
|
|
64
59
|
|
|
60
|
+
// /authorize, /token, /register, /revoke and the metadata documents a client discovers
|
|
61
|
+
// after a 401.
|
|
62
|
+
app.use(
|
|
63
|
+
mcpAuthRouter({
|
|
64
|
+
provider,
|
|
65
|
+
issuerUrl: issuer,
|
|
66
|
+
baseUrl: issuer,
|
|
67
|
+
resourceServerUrl: new URL('/mcp', issuer),
|
|
68
|
+
resourceName: 'web3-tools-mcp',
|
|
69
|
+
scopesSupported: []
|
|
70
|
+
})
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
// The metadata document is mounted under the resource path, so the 401 has to advertise
|
|
74
|
+
// that exact URL — a bare /.well-known/oauth-protected-resource 404s, and a client that
|
|
75
|
+
// follows it gives up right where discovery should have started.
|
|
76
|
+
const authenticate = requireBearerAuth({
|
|
77
|
+
verifier: provider,
|
|
78
|
+
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(new URL('/mcp', issuer))
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
app.all('/mcp', authenticate, async (req, res) => {
|
|
65
82
|
const mcp = options.createMcpServer()
|
|
66
83
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true })
|
|
67
84
|
|
|
@@ -72,23 +89,29 @@ export async function startHttpServer(options: HttpServerOptions): Promise<{ url
|
|
|
72
89
|
|
|
73
90
|
try {
|
|
74
91
|
await mcp.connect(transport)
|
|
75
|
-
await transport.handleRequest(req, res)
|
|
92
|
+
await transport.handleRequest(req, res, req.body)
|
|
76
93
|
} catch (error) {
|
|
77
94
|
console.error('[MCP] Request failed:', error)
|
|
78
95
|
if (!res.headersSent) {
|
|
79
|
-
res.
|
|
80
|
-
res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal error' } }))
|
|
96
|
+
res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal error' } })
|
|
81
97
|
}
|
|
82
98
|
}
|
|
83
99
|
})
|
|
84
100
|
|
|
85
|
-
await new Promise<
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
server.removeListener('error', reject)
|
|
89
|
-
resolve()
|
|
90
|
-
})
|
|
101
|
+
const server = await new Promise<ReturnType<typeof app.listen>>((resolve, reject) => {
|
|
102
|
+
const listening = app.listen(port, host, () => resolve(listening))
|
|
103
|
+
listening.once('error', reject)
|
|
91
104
|
})
|
|
92
105
|
|
|
93
|
-
|
|
106
|
+
// The signing page rides on this same server, so a hosted deployment can offer browser
|
|
107
|
+
// signing without a second service: one platform port serves both /mcp and the page.
|
|
108
|
+
// Mounted last, so the routes above keep their paths — the relay only claims what is
|
|
109
|
+
// left, which is the static page at the root.
|
|
110
|
+
let walletUrl: string | undefined
|
|
111
|
+
if (options.walletRelay) {
|
|
112
|
+
options.walletRelay.attach(server, (relayApp) => app.use(relayApp))
|
|
113
|
+
walletUrl = `${publicUrl.replace(/\/$/, '')}/#t=${options.walletRelay.token}`
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { url: `${publicUrl.replace(/\/$/, '')}/mcp`, port: (server.address() as { port: number }).port, walletUrl }
|
|
94
117
|
}
|