web3-tools-mcp 3.0.0 → 3.2.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 +115 -2
- package/README.md +96 -60
- package/dist/chain-meta.d.ts.map +1 -1
- package/dist/chain-meta.js +19 -8
- package/dist/chain-meta.js.map +1 -1
- package/dist/chains.d.ts +609 -65
- package/dist/chains.d.ts.map +1 -1
- package/dist/chains.js +89 -28
- package/dist/chains.js.map +1 -1
- package/dist/clear-signing.d.ts.map +1 -1
- package/dist/clear-signing.js +17 -6
- package/dist/clear-signing.js.map +1 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +2 -4
- package/dist/client.js.map +1 -1
- package/dist/http-server.d.ts +1 -1
- package/dist/http-server.d.ts.map +1 -1
- package/dist/http-server.js +12 -5
- package/dist/http-server.js.map +1 -1
- package/dist/index.js +5 -6
- package/dist/index.js.map +1 -1
- package/dist/kv-storage.d.ts +2 -1
- package/dist/kv-storage.d.ts.map +1 -1
- package/dist/kv-storage.js +5 -2
- package/dist/kv-storage.js.map +1 -1
- package/dist/oauth.d.ts +18 -4
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +62 -26
- package/dist/oauth.js.map +1 -1
- package/dist/preview.d.ts.map +1 -1
- package/dist/preview.js +7 -5
- package/dist/preview.js.map +1 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +16 -1
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/read/advanced.d.ts +25 -20
- package/dist/tools/read/advanced.d.ts.map +1 -1
- package/dist/tools/read/advanced.js +45 -33
- package/dist/tools/read/advanced.js.map +1 -1
- package/dist/tools/read/balance.d.ts +6 -6
- package/dist/tools/read/balance.d.ts.map +1 -1
- package/dist/tools/read/balance.js +17 -32
- package/dist/tools/read/balance.js.map +1 -1
- package/dist/tools/read/contract-info.d.ts +14 -12
- package/dist/tools/read/contract-info.d.ts.map +1 -1
- package/dist/tools/read/contract-info.js +53 -122
- package/dist/tools/read/contract-info.js.map +1 -1
- package/dist/tools/read/contract.d.ts +10 -10
- package/dist/tools/read/contract.d.ts.map +1 -1
- package/dist/tools/read/contract.js +2 -1
- package/dist/tools/read/contract.js.map +1 -1
- package/dist/tools/read/defi.d.ts +86 -0
- package/dist/tools/read/defi.d.ts.map +1 -0
- package/dist/tools/read/defi.js +210 -0
- package/dist/tools/read/defi.js.map +1 -0
- package/dist/tools/read/ens.d.ts +20 -40
- package/dist/tools/read/ens.d.ts.map +1 -1
- package/dist/tools/read/ens.js +25 -33
- package/dist/tools/read/ens.js.map +1 -1
- package/dist/tools/read/gas.d.ts +16 -16
- package/dist/tools/read/gas.d.ts.map +1 -1
- package/dist/tools/read/gas.js +13 -48
- package/dist/tools/read/gas.js.map +1 -1
- package/dist/tools/read/logs.d.ts +65 -4
- package/dist/tools/read/logs.d.ts.map +1 -1
- package/dist/tools/read/logs.js +96 -115
- package/dist/tools/read/logs.js.map +1 -1
- package/dist/tools/read/signatures.d.ts.map +1 -1
- package/dist/tools/read/signatures.js +4 -3
- package/dist/tools/read/signatures.js.map +1 -1
- package/dist/tools/read/signing.d.ts.map +1 -1
- package/dist/tools/read/signing.js +8 -2
- package/dist/tools/read/signing.js.map +1 -1
- package/dist/tools/sign/transactions.d.ts +16 -16
- package/dist/tools/sign/transactions.d.ts.map +1 -1
- package/dist/tools/sign/transactions.js +54 -9
- package/dist/tools/sign/transactions.js.map +1 -1
- package/dist/tools/sign/wallet.d.ts +4 -4
- package/dist/types.d.ts +0 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts +18 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +43 -2
- package/dist/utils.js.map +1 -1
- package/dist/wallet-client.d.ts +1 -0
- package/dist/wallet-client.d.ts.map +1 -1
- package/dist/wallet-client.js +18 -5
- package/dist/wallet-client.js.map +1 -1
- package/package.json +4 -7
- package/src/chain-meta.ts +16 -7
- package/src/chains.ts +101 -31
- package/src/clear-signing.ts +15 -9
- package/src/client.ts +2 -3
- package/src/http-server.ts +11 -6
- package/src/index.ts +5 -6
- package/src/kv-storage.ts +6 -3
- package/src/oauth.ts +93 -34
- package/src/preview.ts +7 -5
- package/src/tools/index.ts +14 -1
- package/src/tools/read/advanced.ts +63 -34
- package/src/tools/read/balance.ts +18 -33
- package/src/tools/read/contract-info.ts +64 -144
- package/src/tools/read/contract.ts +2 -1
- package/src/tools/read/defi.ts +287 -0
- package/src/tools/read/ens.ts +26 -33
- package/src/tools/read/gas.ts +13 -50
- package/src/tools/read/logs.ts +131 -143
- package/src/tools/read/signatures.ts +4 -4
- package/src/tools/read/signing.ts +8 -2
- package/src/tools/sign/transactions.ts +60 -9
- package/src/types.ts +0 -1
- package/src/utils.ts +47 -2
- package/src/wallet-client.ts +26 -5
package/src/oauth.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
|
3
3
|
import { homedir } from 'node:os'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import type { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients.js'
|
|
6
|
-
import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js'
|
|
6
|
+
import { InvalidGrantError, InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js'
|
|
7
7
|
import type { AuthorizationParams, OAuthServerProvider } from '@modelcontextprotocol/sdk/server/auth/provider.js'
|
|
8
8
|
import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'
|
|
9
9
|
import type { OAuthClientInformationFull, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js'
|
|
@@ -30,6 +30,7 @@ interface StoredCode {
|
|
|
30
30
|
resource?: string
|
|
31
31
|
scopes: string[]
|
|
32
32
|
address?: string
|
|
33
|
+
credential?: string
|
|
33
34
|
expiresAt: number
|
|
34
35
|
}
|
|
35
36
|
|
|
@@ -37,9 +38,17 @@ interface StoredToken {
|
|
|
37
38
|
clientId: string
|
|
38
39
|
scopes: string[]
|
|
39
40
|
address?: string
|
|
41
|
+
credential?: string
|
|
40
42
|
expiresAt: number
|
|
41
43
|
}
|
|
42
44
|
|
|
45
|
+
/** Codes expire in milliseconds, tokens in seconds — the unit OAuth reports them in. */
|
|
46
|
+
function expired(key: string, value: unknown): boolean {
|
|
47
|
+
const expiresAt = (value as { expiresAt?: number } | undefined)?.expiresAt
|
|
48
|
+
if (expiresAt === undefined) return false
|
|
49
|
+
return (key.startsWith('code:') ? expiresAt : expiresAt * 1000) < Date.now()
|
|
50
|
+
}
|
|
51
|
+
|
|
43
52
|
/** Redis where configured, a JSON file otherwise — issued tokens must outlive a restart. */
|
|
44
53
|
class Store {
|
|
45
54
|
private redis: KeyValueStorage | undefined
|
|
@@ -69,10 +78,12 @@ class Store {
|
|
|
69
78
|
return this.read()[key] as T | undefined
|
|
70
79
|
}
|
|
71
80
|
|
|
72
|
-
|
|
73
|
-
|
|
81
|
+
/** `ttl` in seconds lets Redis drop the entry itself; the file is pruned on every write. */
|
|
82
|
+
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
|
|
83
|
+
if (this.redis) return this.redis.setItem(`oauth:${key}`, value, ttl)
|
|
74
84
|
const data = this.read()
|
|
75
85
|
data[key] = value
|
|
86
|
+
for (const [name, stored] of Object.entries(data)) if (expired(name, stored)) delete data[name]
|
|
76
87
|
writeFileSync(this.file, JSON.stringify(data), { mode: 0o600 })
|
|
77
88
|
}
|
|
78
89
|
|
|
@@ -104,8 +115,12 @@ function constantTimeEquals(a: string, b: string): boolean {
|
|
|
104
115
|
* the deployment rather than anybody in particular.
|
|
105
116
|
*/
|
|
106
117
|
export interface Login {
|
|
107
|
-
/**
|
|
108
|
-
|
|
118
|
+
/**
|
|
119
|
+
* The page served at GET /authorize. `hidden` must be placed inside its form. `request`
|
|
120
|
+
* names who is asking and where the code will be sent, so a person can tell a phishing
|
|
121
|
+
* client from their own; like `error`, its values arrive HTML-escaped.
|
|
122
|
+
*/
|
|
123
|
+
page(hidden: string, error?: string, request?: { client: string; redirectHost: string }): string | Promise<string>
|
|
109
124
|
/** Reads the posted form. An `error` re-renders the page; an `address` names the person. */
|
|
110
125
|
identify(form: Record<string, string>): Promise<{ address?: string; error?: string }>
|
|
111
126
|
/**
|
|
@@ -113,6 +128,11 @@ export interface Login {
|
|
|
113
128
|
* page, so a credential can never be echoed back into the HTML.
|
|
114
129
|
*/
|
|
115
130
|
owns?: string[]
|
|
131
|
+
/**
|
|
132
|
+
* A shared secret this login checks against, if it has one. Everything issued is bound to
|
|
133
|
+
* it, so rotating the secret revokes every code and token handed out under the old one.
|
|
134
|
+
*/
|
|
135
|
+
credential?: string
|
|
116
136
|
}
|
|
117
137
|
|
|
118
138
|
const PAGE_STYLE = `
|
|
@@ -131,13 +151,15 @@ const PAGE_STYLE = `
|
|
|
131
151
|
export function tokenLogin(accessToken: string): Login {
|
|
132
152
|
return {
|
|
133
153
|
owns: ['token'],
|
|
134
|
-
|
|
154
|
+
credential: accessToken,
|
|
155
|
+
page: (hidden, error, request) => `<!doctype html>
|
|
135
156
|
<meta charset="utf-8">
|
|
136
157
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
137
158
|
<title>Connect to web3-tools-mcp</title>
|
|
138
159
|
<style>${PAGE_STYLE}
|
|
139
160
|
</style>
|
|
140
161
|
<h1>Connect to web3-tools-mcp</h1>
|
|
162
|
+
${request ? `<p><strong>${request.client}</strong> is asking for access, and will be sent back to <strong>${request.redirectHost}</strong>.</p>` : ''}
|
|
141
163
|
<p>Paste this server's access token to authorise the client.</p>
|
|
142
164
|
${error ? `<p class="error">${error}</p>` : ''}
|
|
143
165
|
<form method="post">
|
|
@@ -155,6 +177,8 @@ ${error ? `<p class="error">${error}</p>` : ''}
|
|
|
155
177
|
|
|
156
178
|
export class OAuthProvider implements OAuthServerProvider {
|
|
157
179
|
private store = new Store()
|
|
180
|
+
// A hash, so the secret itself never lands in the store.
|
|
181
|
+
private credential: string | undefined
|
|
158
182
|
|
|
159
183
|
/**
|
|
160
184
|
* `staticToken` is accepted verbatim in an Authorization header, for clients that can send
|
|
@@ -163,7 +187,9 @@ export class OAuthProvider implements OAuthServerProvider {
|
|
|
163
187
|
constructor(
|
|
164
188
|
private login: Login,
|
|
165
189
|
private staticToken?: string
|
|
166
|
-
) {
|
|
190
|
+
) {
|
|
191
|
+
this.credential = login.credential && createHash('sha256').update(login.credential).digest('hex').slice(0, 16)
|
|
192
|
+
}
|
|
167
193
|
|
|
168
194
|
get clientsStore(): OAuthRegisteredClientsStore {
|
|
169
195
|
const store = this.store
|
|
@@ -203,11 +229,21 @@ export class OAuthProvider implements OAuthServerProvider {
|
|
|
203
229
|
.map(([name, value]) => `<input type="hidden" name="${escapeHtml(name)}" value="${escapeHtml(value)}">`)
|
|
204
230
|
.join('\n ')
|
|
205
231
|
|
|
232
|
+
const redirectTo = new URL(params.redirectUri)
|
|
233
|
+
const shown = {
|
|
234
|
+
client: escapeHtml(client.client_name || 'An unnamed client'),
|
|
235
|
+
redirectHost: escapeHtml(redirectTo.host || redirectTo.href)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// A login page that can be framed can be clickjacked into authorising somebody else.
|
|
239
|
+
res.setHeader('X-Frame-Options', 'DENY')
|
|
240
|
+
res.setHeader('Content-Security-Policy', "frame-ancestors 'none'")
|
|
241
|
+
|
|
206
242
|
const render = async (status: number, error?: string) => {
|
|
207
243
|
res
|
|
208
244
|
.status(status)
|
|
209
245
|
.setHeader('content-type', 'text/html; charset=utf-8')
|
|
210
|
-
.send(await this.login.page(hidden, error ? escapeHtml(error) : undefined))
|
|
246
|
+
.send(await this.login.page(hidden, error ? escapeHtml(error) : undefined, shown))
|
|
211
247
|
}
|
|
212
248
|
|
|
213
249
|
if (request.method !== 'POST') return render(200)
|
|
@@ -216,32 +252,53 @@ export class OAuthProvider implements OAuthServerProvider {
|
|
|
216
252
|
if (identified.error) return render(401, identified.error)
|
|
217
253
|
|
|
218
254
|
const code = randomBytes(24).toString('hex')
|
|
219
|
-
await this.store.set<StoredCode>(
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
255
|
+
await this.store.set<StoredCode>(
|
|
256
|
+
`code:${code}`,
|
|
257
|
+
{
|
|
258
|
+
clientId: client.client_id,
|
|
259
|
+
codeChallenge: params.codeChallenge,
|
|
260
|
+
redirectUri: params.redirectUri,
|
|
261
|
+
resource: params.resource?.href,
|
|
262
|
+
scopes: params.scopes ?? [],
|
|
263
|
+
address: identified.address,
|
|
264
|
+
credential: this.credential,
|
|
265
|
+
expiresAt: Date.now() + CODE_TTL
|
|
266
|
+
},
|
|
267
|
+
CODE_TTL / 1000
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
redirectTo.searchParams.set('code', code)
|
|
271
|
+
if (params.state) redirectTo.searchParams.set('state', params.state)
|
|
272
|
+
res.redirect(redirectTo.href)
|
|
233
273
|
}
|
|
234
274
|
|
|
235
|
-
|
|
275
|
+
// Refusals here must be InvalidGrantError: the SDK answers anything else with a 500,
|
|
276
|
+
// where a client needs the 400 invalid_grant to know it should start the flow again.
|
|
277
|
+
private async validCode(client: OAuthClientInformationFull, authorizationCode: string): Promise<StoredCode> {
|
|
236
278
|
const stored = await this.store.get<StoredCode>(`code:${authorizationCode}`)
|
|
237
|
-
if (!stored || stored.expiresAt < Date.now()
|
|
238
|
-
|
|
279
|
+
if (!stored || stored.expiresAt < Date.now() || stored.credential !== this.credential) {
|
|
280
|
+
throw new InvalidGrantError('Authorization code is invalid or expired')
|
|
281
|
+
}
|
|
282
|
+
if (stored.clientId !== client.client_id) throw new InvalidGrantError('Authorization code was issued to a different client')
|
|
283
|
+
return stored
|
|
239
284
|
}
|
|
240
285
|
|
|
241
|
-
async
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
286
|
+
async challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise<string> {
|
|
287
|
+
return (await this.validCode(client, authorizationCode)).codeChallenge
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async exchangeAuthorizationCode(
|
|
291
|
+
client: OAuthClientInformationFull,
|
|
292
|
+
authorizationCode: string,
|
|
293
|
+
_codeVerifier?: string,
|
|
294
|
+
redirectUri?: string
|
|
295
|
+
): Promise<OAuthTokens> {
|
|
296
|
+
const stored = await this.validCode(client, authorizationCode)
|
|
297
|
+
// Optional at the token endpoint because the SDK lets a single-URI client omit it at
|
|
298
|
+
// /authorize too; when sent, it must be the one the code was issued for.
|
|
299
|
+
if (redirectUri !== undefined && redirectUri !== stored.redirectUri) {
|
|
300
|
+
throw new InvalidGrantError('redirect_uri does not match the authorization request')
|
|
301
|
+
}
|
|
245
302
|
|
|
246
303
|
// One use only: a replayed code must not mint a second token.
|
|
247
304
|
await this.store.remove(`code:${authorizationCode}`)
|
|
@@ -250,10 +307,11 @@ export class OAuthProvider implements OAuthServerProvider {
|
|
|
250
307
|
|
|
251
308
|
async exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[]): Promise<OAuthTokens> {
|
|
252
309
|
const stored = await this.store.get<StoredToken>(`refresh:${refreshToken}`)
|
|
253
|
-
if (!stored) throw new
|
|
254
|
-
if (stored.clientId !== client.client_id) throw new
|
|
310
|
+
if (!stored || stored.credential !== this.credential) throw new InvalidGrantError('Refresh token is invalid')
|
|
311
|
+
if (stored.clientId !== client.client_id) throw new InvalidGrantError('Refresh token was issued to a different client')
|
|
255
312
|
|
|
256
313
|
await this.store.remove(`refresh:${refreshToken}`)
|
|
314
|
+
if (stored.expiresAt * 1000 < Date.now()) throw new InvalidGrantError('Refresh token has expired')
|
|
257
315
|
// The refreshed token stays the same person's: a refresh proves possession, not identity.
|
|
258
316
|
return this.issue(client.client_id, scopes ?? stored.scopes, stored.address)
|
|
259
317
|
}
|
|
@@ -262,9 +320,10 @@ export class OAuthProvider implements OAuthServerProvider {
|
|
|
262
320
|
const accessToken = randomBytes(32).toString('hex')
|
|
263
321
|
const refreshToken = randomBytes(32).toString('hex')
|
|
264
322
|
const expiresAt = Math.floor(Date.now() / 1000) + TOKEN_TTL
|
|
323
|
+
const stored: StoredToken = { clientId, scopes, address, credential: this.credential, expiresAt }
|
|
265
324
|
|
|
266
|
-
await this.store.set
|
|
267
|
-
await this.store.set
|
|
325
|
+
await this.store.set(`token:${accessToken}`, stored, TOKEN_TTL)
|
|
326
|
+
await this.store.set(`refresh:${refreshToken}`, stored, TOKEN_TTL)
|
|
268
327
|
|
|
269
328
|
return {
|
|
270
329
|
access_token: accessToken,
|
|
@@ -285,7 +344,7 @@ export class OAuthProvider implements OAuthServerProvider {
|
|
|
285
344
|
// Must be InvalidTokenError specifically: the SDK turns anything else into a 500, and
|
|
286
345
|
// a client needs the 401 to know it should authenticate again.
|
|
287
346
|
const stored = await this.store.get<StoredToken>(`token:${token}`)
|
|
288
|
-
if (!stored) throw new InvalidTokenError('Invalid access token')
|
|
347
|
+
if (!stored || stored.credential !== this.credential) throw new InvalidTokenError('Invalid access token')
|
|
289
348
|
if (stored.expiresAt * 1000 < Date.now()) {
|
|
290
349
|
await this.store.remove(`token:${token}`)
|
|
291
350
|
throw new InvalidTokenError('Access token has expired')
|
package/src/preview.ts
CHANGED
|
@@ -85,15 +85,17 @@ function stringify(value: unknown): string {
|
|
|
85
85
|
* Decode calldata into labelled fields (clear signing). Token amounts on the standard
|
|
86
86
|
* ERC20 selectors are formatted with on-chain decimals and unlimited approvals flagged.
|
|
87
87
|
*/
|
|
88
|
-
async function decodeCalldata(chain: ChainName, tx: RawTx): Promise<TxPreview['decoded']> {
|
|
88
|
+
async function decodeCalldata(chain: ChainName, tx: RawTx, from?: string): Promise<TxPreview['decoded']> {
|
|
89
89
|
if (!tx.data || tx.data === '0x') return undefined
|
|
90
90
|
|
|
91
91
|
const { abi, source, proxy } = await loadAbi(chain, tx.to)
|
|
92
92
|
const { functionName, args } = decodeFunctionData({ abi, data: tx.data as `0x${string}` })
|
|
93
93
|
|
|
94
|
-
|
|
94
|
+
// By selector, not name: an overloaded name would otherwise pick the wrong inputs.
|
|
95
|
+
const selector = tx.data.slice(0, 10).toLowerCase()
|
|
96
|
+
const abiItem = abi.find((item): item is AbiFunction => item.type === 'function' && toFunctionSelector(item) === selector)
|
|
95
97
|
const inputs = abiItem?.inputs ?? []
|
|
96
|
-
const erc20 = ERC20_AMOUNT_ARGS[
|
|
98
|
+
const erc20 = ERC20_AMOUNT_ARGS[selector]
|
|
97
99
|
const meta = erc20 ? await tokenMeta(chain, tx.to).catch(() => ({ symbol: undefined, decimals: undefined })) : undefined
|
|
98
100
|
|
|
99
101
|
const fields: PreviewField[] = (args ?? []).map((value, i) => {
|
|
@@ -137,7 +139,7 @@ async function decodeCalldata(chain: ChainName, tx: RawTx): Promise<TxPreview['d
|
|
|
137
139
|
|
|
138
140
|
// A registry descriptor says what the call means and how the protocol wants each field
|
|
139
141
|
// labelled, which beats raw ABI argument names. Fall back to those when it has none.
|
|
140
|
-
const clearSigning = await resolveClearSigning(chain, tx, named).catch(() => null)
|
|
142
|
+
const clearSigning = await resolveClearSigning(chain, { ...tx, from }, named).catch(() => null)
|
|
141
143
|
if (clearSigning) {
|
|
142
144
|
return {
|
|
143
145
|
functionName,
|
|
@@ -256,7 +258,7 @@ export async function buildTxPreview(chain: ChainName, tx: RawTx, from?: string)
|
|
|
256
258
|
const [decoded, simulation, toLabel] = await Promise.all([
|
|
257
259
|
// A preview that cannot be built must not block the transaction, but swallowing the
|
|
258
260
|
// reason makes "no details shown" impossible to diagnose.
|
|
259
|
-
decodeCalldata(chain, tx).catch((error) => {
|
|
261
|
+
decodeCalldata(chain, tx, from).catch((error) => {
|
|
260
262
|
console.error('[Preview] Could not decode calldata:', error instanceof Error ? error.message : error)
|
|
261
263
|
return undefined
|
|
262
264
|
}),
|
package/src/tools/index.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
2
2
|
import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'
|
|
3
3
|
import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types.js'
|
|
4
|
+
import { getClientManager } from '../client.js'
|
|
4
5
|
import { identityFrom } from '../context.js'
|
|
5
6
|
import type { ToolResult } from '../types.js'
|
|
7
|
+
import { redactSecrets } from '../utils.js'
|
|
6
8
|
import advancedTools from './read/advanced.js'
|
|
7
9
|
import balanceTools from './read/balance.js'
|
|
8
10
|
import contractTools from './read/contract.js'
|
|
9
11
|
import contractInfoTools from './read/contract-info.js'
|
|
12
|
+
import defiTools from './read/defi.js'
|
|
10
13
|
import ensTools from './read/ens.js'
|
|
11
14
|
import gasTools from './read/gas.js'
|
|
12
15
|
import logTools from './read/logs.js'
|
|
@@ -34,6 +37,7 @@ const readTools = {
|
|
|
34
37
|
...advancedTools,
|
|
35
38
|
...ensTools,
|
|
36
39
|
...gasTools,
|
|
40
|
+
...defiTools,
|
|
37
41
|
// A signature already asked for and paid for; collecting it is not a second request.
|
|
38
42
|
...signingRequestTools
|
|
39
43
|
} as const
|
|
@@ -85,7 +89,16 @@ export function registerAllTools(server: McpServer, middleware?: ToolMiddleware)
|
|
|
85
89
|
const identity = identityFrom(extra.authInfo)
|
|
86
90
|
const run = () => tool.handler(args, identity)
|
|
87
91
|
const group = toolGroup(name) as ToolGroup
|
|
88
|
-
|
|
92
|
+
// Anything a tool returns or throws may quote an RPC URL, and those carry provider keys.
|
|
93
|
+
const config = getClientManager().getConfig()
|
|
94
|
+
try {
|
|
95
|
+
const result = await (middleware ? middleware({ tool: name, group, identity }, run) : run())
|
|
96
|
+
return { ...result, content: result.content.map((item) => ({ ...item, text: redactSecrets(item.text, config) })) }
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (!(error instanceof Error)) throw new Error(redactSecrets(String(error), config))
|
|
99
|
+
error.message = redactSecrets(error.message, config)
|
|
100
|
+
throw error
|
|
101
|
+
}
|
|
89
102
|
}
|
|
90
103
|
)
|
|
91
104
|
})
|
|
@@ -1,9 +1,37 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type AbiFunction,
|
|
3
|
+
type Address,
|
|
4
|
+
decodeAbiParameters,
|
|
5
|
+
encodeFunctionData,
|
|
6
|
+
isAddress,
|
|
7
|
+
parseAbiItem,
|
|
8
|
+
parseAbiParameters
|
|
9
|
+
} from 'viem'
|
|
2
10
|
import { z } from 'zod'
|
|
3
11
|
import { isDebugNodeAvailable, simulateCallWithTrace, traceTransactionWithAnvil } from '../../anvil.js'
|
|
4
12
|
import { getClientManager, SUPPORTED_CHAINS } from '../../client.js'
|
|
5
13
|
import type { ChainName } from '../../types.js'
|
|
6
|
-
import { createTool, formatResponse, summarizeTrace } from '../../utils.js'
|
|
14
|
+
import { convertArgumentsToTypes, createTool, formatResponse, rpcReason, summarizeTrace } from '../../utils.js'
|
|
15
|
+
|
|
16
|
+
/** Past these, a trace would crowd everything else out of the agent's context. */
|
|
17
|
+
const MAX_TRACE_CHARS = 50_000
|
|
18
|
+
const MAX_INPUT_CHARS = 10_000
|
|
19
|
+
const MAX_RECEIPT_LOGS = 100
|
|
20
|
+
|
|
21
|
+
const SUMMARIZE_DESCRIPTION =
|
|
22
|
+
'Return a compact summary instead of the full trace: the path to the revert, or the top-level calls, with selectors only and no calldata.'
|
|
23
|
+
|
|
24
|
+
/** The trace itself if it fits, otherwise its JSON cut short. */
|
|
25
|
+
export function boundTrace(trace: unknown): { trace: unknown; truncated?: true } {
|
|
26
|
+
const json = JSON.stringify(trace) ?? ''
|
|
27
|
+
return json.length > MAX_TRACE_CHARS ? { trace: json.slice(0, MAX_TRACE_CHARS), truncated: true } : { trace }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const TRACERS = {
|
|
31
|
+
trace: { tracer: 'callTracer' },
|
|
32
|
+
prestate: { tracer: 'prestateTracer' },
|
|
33
|
+
stateDiff: { tracer: 'prestateTracer', tracerConfig: { diffMode: true } }
|
|
34
|
+
} as const
|
|
7
35
|
|
|
8
36
|
export default {
|
|
9
37
|
get_storage_at: createTool(
|
|
@@ -77,8 +105,8 @@ export default {
|
|
|
77
105
|
const parsedTypes = parseAbiParameters([args.abiType])
|
|
78
106
|
if (parsedTypes.length > 0) {
|
|
79
107
|
const decoded = decodeAbiParameters(parsedTypes, storageValue) as readonly unknown[]
|
|
80
|
-
decodedValue = decoded
|
|
81
|
-
formattedValue = decodedValue
|
|
108
|
+
decodedValue = decoded[0]
|
|
109
|
+
formattedValue = decodedValue === undefined ? 'No data' : String(decodedValue)
|
|
82
110
|
} else {
|
|
83
111
|
decodedValue = null
|
|
84
112
|
formattedValue = 'Invalid ABI type'
|
|
@@ -148,9 +176,11 @@ export default {
|
|
|
148
176
|
chain: z.enum(SUPPORTED_CHAINS).describe('The blockchain network to use'),
|
|
149
177
|
transactionHash: z.string().describe('Transaction hash to trace (0x prefixed)'),
|
|
150
178
|
traceType: z
|
|
151
|
-
.enum(['trace', '
|
|
179
|
+
.enum(['trace', 'prestate', 'stateDiff'])
|
|
152
180
|
.optional()
|
|
153
|
-
.describe(
|
|
181
|
+
.describe(
|
|
182
|
+
'Trace type: "trace" (call tree, recommended), "prestate" (state of every touched account before the tx), "stateDiff" (state before and after, changed fields only)'
|
|
183
|
+
)
|
|
154
184
|
.default('trace'),
|
|
155
185
|
useAnvil: z
|
|
156
186
|
.boolean()
|
|
@@ -159,11 +189,7 @@ export default {
|
|
|
159
189
|
'Force tracing through the debug node (ANVIL_RPC_URL). Used automatically as a fallback when the RPC cannot trace.'
|
|
160
190
|
)
|
|
161
191
|
.default(false),
|
|
162
|
-
summarize: z
|
|
163
|
-
.boolean()
|
|
164
|
-
.optional()
|
|
165
|
-
.describe('Return a compact summary instead of full trace. Truncates hex data and flattens nested calls.')
|
|
166
|
-
.default(false)
|
|
192
|
+
summarize: z.boolean().optional().describe(SUMMARIZE_DESCRIPTION).default(false)
|
|
167
193
|
}),
|
|
168
194
|
async (args) => {
|
|
169
195
|
const clientManager = getClientManager()
|
|
@@ -176,24 +202,18 @@ export default {
|
|
|
176
202
|
let traceResult: unknown = null
|
|
177
203
|
let usedAnvil = false
|
|
178
204
|
|
|
179
|
-
|
|
180
|
-
const tracerMap: Record<string, 'callTracer' | 'prestateTracer' | 'stateDiffTracer'> = {
|
|
181
|
-
trace: 'callTracer',
|
|
182
|
-
vmTrace: 'prestateTracer',
|
|
183
|
-
stateDiff: 'stateDiffTracer'
|
|
184
|
-
}
|
|
185
|
-
const tracer = tracerMap[args.traceType ?? 'trace'] ?? 'callTracer'
|
|
205
|
+
const tracerOptions = TRACERS[args.traceType ?? 'trace']
|
|
186
206
|
|
|
187
207
|
// Try RPC tracing first (unless forceAnvil is true)
|
|
188
208
|
if (!args.useAnvil) {
|
|
189
209
|
try {
|
|
190
210
|
traceResult = await client.request({
|
|
191
211
|
method: 'debug_traceTransaction',
|
|
192
|
-
params: [args.transactionHash,
|
|
212
|
+
params: [args.transactionHash, tracerOptions]
|
|
193
213
|
})
|
|
194
214
|
} catch (rpcError) {
|
|
195
215
|
// RPC tracing failed, will try Anvil fallback
|
|
196
|
-
const errorMessage = (rpcError
|
|
216
|
+
const errorMessage = rpcReason(rpcError)
|
|
197
217
|
if (
|
|
198
218
|
errorMessage.includes('not supported') ||
|
|
199
219
|
errorMessage.includes('not available') ||
|
|
@@ -222,11 +242,11 @@ export default {
|
|
|
222
242
|
const forkUrl = clientManager.getRpcUrl(args.chain as ChainName)
|
|
223
243
|
const blockNumber = transaction.blockNumber ?? 0n
|
|
224
244
|
|
|
225
|
-
traceResult = await traceTransactionWithAnvil(forkUrl, args.transactionHash, blockNumber, tracer)
|
|
245
|
+
traceResult = await traceTransactionWithAnvil(forkUrl, args.transactionHash, blockNumber, tracerOptions.tracer)
|
|
226
246
|
} catch (anvilError) {
|
|
227
247
|
// Anvil tracing also failed
|
|
228
248
|
traceResult = {
|
|
229
|
-
error: `Anvil tracing failed: ${(anvilError
|
|
249
|
+
error: `Anvil tracing failed: ${rpcReason(anvilError)}`,
|
|
230
250
|
rpcError:
|
|
231
251
|
traceResult && typeof traceResult === 'object' && 'rpcError' in traceResult
|
|
232
252
|
? (traceResult as { rpcError: string }).rpcError
|
|
@@ -257,11 +277,20 @@ export default {
|
|
|
257
277
|
const traceSummary =
|
|
258
278
|
args.summarize && traceResult && typeof traceResult === 'object' && !isPlainError ? summarizeTrace(traceResult) : null
|
|
259
279
|
|
|
280
|
+
const replayNote =
|
|
281
|
+
usedAnvil && !isPlainError
|
|
282
|
+
? 'Replayed on the debug node against the state at the end of the previous block: transactions before this one in the same block were not applied, so the trace can differ from what happened on chain.' +
|
|
283
|
+
(args.traceType === 'stateDiff' ? ' This replay shows the prestate only, not a diff.' : '')
|
|
284
|
+
: undefined
|
|
285
|
+
const bounded = boundTrace(traceResult)
|
|
286
|
+
const input = transaction.input ?? '0x'
|
|
287
|
+
|
|
260
288
|
const result =
|
|
261
289
|
args.summarize && traceSummary
|
|
262
290
|
? {
|
|
263
291
|
chain: args.chain,
|
|
264
292
|
transactionHash: args.transactionHash,
|
|
293
|
+
...(replayNote && { note: replayNote }),
|
|
265
294
|
status: receipt.status,
|
|
266
295
|
gasUsed: receipt.gasUsed?.toString(),
|
|
267
296
|
...traceSummary // { hasError, errorPath, summary }
|
|
@@ -272,6 +301,10 @@ export default {
|
|
|
272
301
|
transactionHash: args.transactionHash,
|
|
273
302
|
traceType: args.traceType,
|
|
274
303
|
usedAnvil,
|
|
304
|
+
...(replayNote && { note: replayNote }),
|
|
305
|
+
...((bounded.truncated || input.length > MAX_INPUT_CHARS || receipt.logs.length > MAX_RECEIPT_LOGS) && {
|
|
306
|
+
truncated: true
|
|
307
|
+
}),
|
|
275
308
|
transaction: {
|
|
276
309
|
blockNumber: transaction.blockNumber?.toString(),
|
|
277
310
|
from: transaction.from,
|
|
@@ -280,19 +313,20 @@ export default {
|
|
|
280
313
|
gas: transaction.gas?.toString() || '0',
|
|
281
314
|
gasPrice: transaction.gasPrice?.toString() || '0',
|
|
282
315
|
nonce: transaction.nonce?.toString() || '0',
|
|
283
|
-
input:
|
|
316
|
+
input: input.length > MAX_INPUT_CHARS ? `${input.slice(0, MAX_INPUT_CHARS)}…` : input
|
|
284
317
|
},
|
|
285
318
|
receipt: {
|
|
286
319
|
status: receipt.status,
|
|
287
320
|
gasUsed: receipt.gasUsed?.toString() || '0',
|
|
288
321
|
effectiveGasPrice: receipt.effectiveGasPrice?.toString() || '0',
|
|
289
|
-
|
|
322
|
+
logCount: receipt.logs.length,
|
|
323
|
+
logs: receipt.logs.slice(0, MAX_RECEIPT_LOGS).map((log) => ({
|
|
290
324
|
address: log.address,
|
|
291
325
|
topics: log.topics,
|
|
292
326
|
data: log.data
|
|
293
327
|
}))
|
|
294
328
|
},
|
|
295
|
-
trace:
|
|
329
|
+
trace: bounded.trace
|
|
296
330
|
}
|
|
297
331
|
|
|
298
332
|
return formatResponse(result)
|
|
@@ -322,11 +356,7 @@ export default {
|
|
|
322
356
|
.optional()
|
|
323
357
|
.default('callTracer')
|
|
324
358
|
.describe('Trace type: callTracer (call tree) or prestateTracer (state before execution)'),
|
|
325
|
-
summarize: z
|
|
326
|
-
.boolean()
|
|
327
|
-
.optional()
|
|
328
|
-
.describe('Return a compact summary instead of full trace. Truncates hex data and flattens nested calls.')
|
|
329
|
-
.default(false)
|
|
359
|
+
summarize: z.boolean().optional().describe(SUMMARIZE_DESCRIPTION).default(false)
|
|
330
360
|
}),
|
|
331
361
|
async (args) => {
|
|
332
362
|
// Check if Anvil is installed
|
|
@@ -342,15 +372,14 @@ export default {
|
|
|
342
372
|
let calldata = args.data
|
|
343
373
|
if (args.functionAbi && !calldata) {
|
|
344
374
|
try {
|
|
345
|
-
const
|
|
346
|
-
const abiItem = parseAbiItem(args.functionAbi)
|
|
375
|
+
const abiItem = parseAbiItem(args.functionAbi) as AbiFunction
|
|
347
376
|
if (abiItem.type !== 'function') {
|
|
348
377
|
throw new Error('ABI must be a function signature')
|
|
349
378
|
}
|
|
350
379
|
calldata = encodeFunctionData({
|
|
351
380
|
abi: [abiItem],
|
|
352
381
|
functionName: abiItem.name,
|
|
353
|
-
args: (args.args ?? [])
|
|
382
|
+
args: convertArgumentsToTypes(args.args ?? [], abiItem.inputs)
|
|
354
383
|
})
|
|
355
384
|
} catch (encodeError) {
|
|
356
385
|
throw new Error(`Failed to encode function call: ${(encodeError as Error).message}`)
|
|
@@ -406,7 +435,7 @@ export default {
|
|
|
406
435
|
result: traceResult.result,
|
|
407
436
|
gasUsed: traceResult.gasUsed.toString(),
|
|
408
437
|
revertReason: traceResult.revertReason,
|
|
409
|
-
|
|
438
|
+
...boundTrace(traceResult.trace)
|
|
410
439
|
}
|
|
411
440
|
|
|
412
441
|
return formatResponse(result)
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { type Address, formatEther, formatUnits, isAddress } from 'viem'
|
|
2
2
|
import { z } from 'zod'
|
|
3
|
+
import { tokenMeta } from '../../chain-meta.js'
|
|
3
4
|
import { getClientManager, SUPPORTED_CHAINS } from '../../client.js'
|
|
4
5
|
import type { ChainName } from '../../types.js'
|
|
5
|
-
import { createTool, formatResponse } from '../../utils.js'
|
|
6
|
+
import { createTool, formatResponse, MAX_BATCH, rpcReason } from '../../utils.js'
|
|
6
7
|
|
|
7
8
|
const BalanceQuerySchema = z.object({
|
|
8
9
|
chain: z.enum(SUPPORTED_CHAINS).describe('The blockchain network to use'),
|
|
@@ -19,6 +20,7 @@ export default {
|
|
|
19
20
|
z.object({
|
|
20
21
|
queries: z
|
|
21
22
|
.array(BalanceQuerySchema)
|
|
23
|
+
.max(MAX_BATCH)
|
|
22
24
|
.describe('Array of balance queries. Omit tokenAddress for native balance, include for ERC20.')
|
|
23
25
|
}),
|
|
24
26
|
async (args) => {
|
|
@@ -42,7 +44,7 @@ export default {
|
|
|
42
44
|
success: boolean
|
|
43
45
|
balance?: unknown
|
|
44
46
|
balanceFormatted?: string
|
|
45
|
-
decimals?: number
|
|
47
|
+
decimals?: number | null
|
|
46
48
|
error?: string
|
|
47
49
|
}> = []
|
|
48
50
|
|
|
@@ -86,42 +88,24 @@ export default {
|
|
|
86
88
|
throw new Error(`Invalid token address format: ${query.tokenAddress}`)
|
|
87
89
|
}
|
|
88
90
|
|
|
89
|
-
const balance = await
|
|
90
|
-
|
|
91
|
-
abi: [
|
|
92
|
-
{
|
|
93
|
-
name: 'balanceOf',
|
|
94
|
-
type: 'function',
|
|
95
|
-
stateMutability: 'view',
|
|
96
|
-
inputs: [{ name: 'account', type: 'address' }],
|
|
97
|
-
outputs: [{ name: '', type: 'uint256' }]
|
|
98
|
-
}
|
|
99
|
-
],
|
|
100
|
-
functionName: 'balanceOf',
|
|
101
|
-
args: [query.address as Address],
|
|
102
|
-
blockNumber: blockTag === 'latest' ? undefined : blockTag
|
|
103
|
-
})
|
|
104
|
-
|
|
105
|
-
// Try to get decimals
|
|
106
|
-
let decimals = 18
|
|
107
|
-
try {
|
|
108
|
-
decimals = (await client.readContract({
|
|
91
|
+
const [balance, { decimals }] = await Promise.all([
|
|
92
|
+
client.readContract({
|
|
109
93
|
address: query.tokenAddress as Address,
|
|
110
94
|
abi: [
|
|
111
95
|
{
|
|
112
|
-
name: '
|
|
96
|
+
name: 'balanceOf',
|
|
113
97
|
type: 'function',
|
|
114
98
|
stateMutability: 'view',
|
|
115
|
-
inputs: [],
|
|
116
|
-
outputs: [{ name: '', type: '
|
|
99
|
+
inputs: [{ name: 'account', type: 'address' }],
|
|
100
|
+
outputs: [{ name: '', type: 'uint256' }]
|
|
117
101
|
}
|
|
118
102
|
],
|
|
119
|
-
functionName: '
|
|
103
|
+
functionName: 'balanceOf',
|
|
104
|
+
args: [query.address as Address],
|
|
120
105
|
blockNumber: blockTag === 'latest' ? undefined : blockTag
|
|
121
|
-
})
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
106
|
+
}),
|
|
107
|
+
tokenMeta(chain as ChainName, query.tokenAddress).catch(() => ({ decimals: undefined }))
|
|
108
|
+
])
|
|
125
109
|
|
|
126
110
|
return {
|
|
127
111
|
index: originalIndex,
|
|
@@ -132,8 +116,9 @@ export default {
|
|
|
132
116
|
blockNumber: query.blockNumber || 'latest',
|
|
133
117
|
success: true,
|
|
134
118
|
balance,
|
|
135
|
-
|
|
136
|
-
decimals
|
|
119
|
+
// Unknown decimals leave only the raw balance: guessing 18 could be off by 10^12.
|
|
120
|
+
balanceFormatted: decimals === undefined ? undefined : formatUnits(balance, decimals),
|
|
121
|
+
decimals: decimals ?? null
|
|
137
122
|
}
|
|
138
123
|
})
|
|
139
124
|
)
|
|
@@ -153,7 +138,7 @@ export default {
|
|
|
153
138
|
chain: query.chain,
|
|
154
139
|
blockNumber: query.blockNumber || 'latest',
|
|
155
140
|
success: false,
|
|
156
|
-
error:
|
|
141
|
+
error: rpcReason(result.reason)
|
|
157
142
|
}
|
|
158
143
|
}
|
|
159
144
|
})
|