@fundtracer/mcp 1.0.11 → 1.0.12
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/fundtracer-mcp.js +438 -437
- package/package.json +4 -3
package/fundtracer-mcp.js
CHANGED
|
@@ -1,226 +1,219 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
};
|
|
2
|
+
|
|
3
|
+
// src/mcp/stdio.ts
|
|
4
|
+
import * as dotenv from "dotenv";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
|
|
7
|
+
// src/mcp/server.ts
|
|
8
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
9
|
+
import { z } from "zod";
|
|
11
10
|
|
|
12
11
|
// src/mcp/tools.ts
|
|
13
|
-
var
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
properties: {
|
|
31
|
-
address: { type: "string", description: "Wallet address to analyze (0x... for EVM, base58 for Solana)" },
|
|
32
|
-
chainId: {
|
|
33
|
-
type: "string",
|
|
34
|
-
description: "Blockchain to analyze",
|
|
35
|
-
enum: ["ethereum", "base", "arbitrum", "optimism", "polygon", "linea", "bsc", "solana"]
|
|
36
|
-
},
|
|
37
|
-
transactionLimit: {
|
|
38
|
-
type: "number",
|
|
39
|
-
description: "Max transactions to fetch (default: 500)",
|
|
40
|
-
default: 500
|
|
41
|
-
}
|
|
42
|
-
},
|
|
43
|
-
required: ["address", "chainId"]
|
|
12
|
+
var ALL_MCP_TOOLS = [
|
|
13
|
+
{
|
|
14
|
+
name: "analyze_wallet",
|
|
15
|
+
description: "Perform a full blockchain wallet analysis including balance, transactions, risk score, suspicious indicators, and project interactions.",
|
|
16
|
+
inputSchema: {
|
|
17
|
+
type: "object",
|
|
18
|
+
properties: {
|
|
19
|
+
address: { type: "string", description: "Wallet address to analyze (0x... for EVM, base58 for Solana)" },
|
|
20
|
+
chainId: {
|
|
21
|
+
type: "string",
|
|
22
|
+
description: "Blockchain to analyze",
|
|
23
|
+
enum: ["ethereum", "base", "arbitrum", "optimism", "polygon", "linea", "bsc", "solana"]
|
|
24
|
+
},
|
|
25
|
+
transactionLimit: {
|
|
26
|
+
type: "number",
|
|
27
|
+
description: "Max transactions to fetch (default: 500)",
|
|
28
|
+
default: 500
|
|
44
29
|
}
|
|
45
30
|
},
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
31
|
+
required: ["address", "chainId"]
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: "trace_funds",
|
|
36
|
+
description: "Trace funding sources and destinations for a wallet address, building a recursive funding tree.",
|
|
37
|
+
inputSchema: {
|
|
38
|
+
type: "object",
|
|
39
|
+
properties: {
|
|
40
|
+
address: { type: "string", description: "Wallet address to trace" },
|
|
41
|
+
chainId: {
|
|
42
|
+
type: "string",
|
|
43
|
+
description: "Blockchain to trace on",
|
|
44
|
+
enum: ["ethereum", "base", "arbitrum", "optimism", "polygon", "linea", "bsc", "solana"]
|
|
45
|
+
},
|
|
46
|
+
maxDepth: {
|
|
47
|
+
type: "number",
|
|
48
|
+
description: "How many levels deep to trace (default: 3)",
|
|
49
|
+
default: 3
|
|
50
|
+
},
|
|
51
|
+
direction: {
|
|
52
|
+
type: "string",
|
|
53
|
+
description: "Which direction to trace",
|
|
54
|
+
enum: ["sources", "destinations", "both"],
|
|
55
|
+
default: "both"
|
|
71
56
|
}
|
|
72
57
|
},
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
58
|
+
required: ["address", "chainId"]
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: "compare_wallets",
|
|
63
|
+
description: "Compare multiple wallet addresses for common funding sources, shared project interactions, and sybil correlation scoring.",
|
|
64
|
+
inputSchema: {
|
|
65
|
+
type: "object",
|
|
66
|
+
properties: {
|
|
67
|
+
addresses: {
|
|
68
|
+
type: "string",
|
|
69
|
+
description: "Comma-separated list of wallet addresses to compare (2-20 wallets)"
|
|
70
|
+
},
|
|
71
|
+
chainId: {
|
|
72
|
+
type: "string",
|
|
73
|
+
description: "Blockchain to compare on",
|
|
74
|
+
enum: ["ethereum", "base", "arbitrum", "optimism", "polygon", "linea", "bsc", "solana"]
|
|
90
75
|
}
|
|
91
76
|
},
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
77
|
+
required: ["addresses", "chainId"]
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: "analyze_contract",
|
|
82
|
+
description: "Analyze all addresses that have interacted with a smart contract, detecting sybil clusters and shared funding sources.",
|
|
83
|
+
inputSchema: {
|
|
84
|
+
type: "object",
|
|
85
|
+
properties: {
|
|
86
|
+
contractAddress: { type: "string", description: "Smart contract address to analyze" },
|
|
87
|
+
chainId: {
|
|
88
|
+
type: "string",
|
|
89
|
+
description: "Blockchain the contract is on",
|
|
90
|
+
enum: ["ethereum", "base", "arbitrum", "optimism", "polygon", "linea", "bsc"]
|
|
91
|
+
},
|
|
92
|
+
maxInteractors: {
|
|
93
|
+
type: "number",
|
|
94
|
+
description: "Max interactors to analyze (default: 100)",
|
|
95
|
+
default: 100
|
|
111
96
|
}
|
|
112
97
|
},
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
98
|
+
required: ["contractAddress", "chainId"]
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: "detect_sybil_clusters",
|
|
103
|
+
description: "Detect sybil (fake) accounts by clustering wallets that share common funding sources.",
|
|
104
|
+
inputSchema: {
|
|
105
|
+
type: "object",
|
|
106
|
+
properties: {
|
|
107
|
+
addresses: {
|
|
108
|
+
type: "string",
|
|
109
|
+
description: "Comma-separated list of wallet addresses to check for sybil clustering"
|
|
110
|
+
},
|
|
111
|
+
chainId: {
|
|
112
|
+
type: "string",
|
|
113
|
+
description: "Blockchain to analyze on",
|
|
114
|
+
enum: ["ethereum", "base", "arbitrum", "optimism", "polygon", "bsc"]
|
|
130
115
|
}
|
|
131
116
|
},
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
117
|
+
required: ["addresses", "chainId"]
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: "get_portfolio",
|
|
122
|
+
description: "Get the token portfolio, DeFi positions, and NFT holdings for a wallet address.",
|
|
123
|
+
inputSchema: {
|
|
124
|
+
type: "object",
|
|
125
|
+
properties: {
|
|
126
|
+
address: { type: "string", description: "Wallet address" },
|
|
127
|
+
chainId: {
|
|
128
|
+
type: "string",
|
|
129
|
+
description: "Blockchain",
|
|
130
|
+
enum: ["ethereum", "solana", "base", "arbitrum", "optimism", "polygon", "linea"]
|
|
146
131
|
}
|
|
147
132
|
},
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
133
|
+
required: ["address", "chainId"]
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
name: "get_transactions",
|
|
138
|
+
description: "Get recent transaction history for a wallet address.",
|
|
139
|
+
inputSchema: {
|
|
140
|
+
type: "object",
|
|
141
|
+
properties: {
|
|
142
|
+
address: { type: "string", description: "Wallet address" },
|
|
143
|
+
chainId: {
|
|
144
|
+
type: "string",
|
|
145
|
+
description: "Blockchain",
|
|
146
|
+
enum: ["ethereum", "base", "arbitrum", "optimism", "polygon", "linea", "bsc", "solana"]
|
|
147
|
+
},
|
|
148
|
+
limit: {
|
|
149
|
+
type: "number",
|
|
150
|
+
description: "Number of transactions to return (default: 50)",
|
|
151
|
+
default: 50
|
|
167
152
|
}
|
|
168
153
|
},
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
154
|
+
required: ["address", "chainId"]
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
name: "lookup_entity",
|
|
159
|
+
description: "Look up a known blockchain entity, protocol, or address label.",
|
|
160
|
+
inputSchema: {
|
|
161
|
+
type: "object",
|
|
162
|
+
properties: {
|
|
163
|
+
query: { type: "string", description: "Entity name, address, or label to look up" },
|
|
164
|
+
chainId: {
|
|
165
|
+
type: "string",
|
|
166
|
+
description: "Blockchain to search (optional)",
|
|
167
|
+
enum: ["ethereum", "solana", "base", "arbitrum", "optimism", "polygon", "linea", "bsc", ""],
|
|
168
|
+
default: ""
|
|
184
169
|
}
|
|
185
170
|
},
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
171
|
+
required: ["query"]
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
name: "get_gas_prices",
|
|
176
|
+
description: "Get current gas prices across supported blockchain networks.",
|
|
177
|
+
inputSchema: {
|
|
178
|
+
type: "object",
|
|
179
|
+
properties: {
|
|
180
|
+
chainId: {
|
|
181
|
+
type: "string",
|
|
182
|
+
description: "Specific chain (optional, returns all if omitted)",
|
|
183
|
+
enum: ["ethereum", "base", "arbitrum", "optimism", "polygon", "linea", "bsc", ""],
|
|
184
|
+
default: ""
|
|
200
185
|
}
|
|
201
186
|
},
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
187
|
+
required: []
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
name: "get_token_info",
|
|
192
|
+
description: "Get market data and information for a token by address or symbol.",
|
|
193
|
+
inputSchema: {
|
|
194
|
+
type: "object",
|
|
195
|
+
properties: {
|
|
196
|
+
tokenAddress: { type: "string", description: "Token contract address" },
|
|
197
|
+
chainId: {
|
|
198
|
+
type: "string",
|
|
199
|
+
description: "Blockchain the token is on",
|
|
200
|
+
enum: ["ethereum", "base", "arbitrum", "optimism", "polygon", "linea", "bsc", "solana"]
|
|
216
201
|
}
|
|
217
|
-
}
|
|
218
|
-
|
|
202
|
+
},
|
|
203
|
+
required: ["tokenAddress", "chainId"]
|
|
204
|
+
}
|
|
219
205
|
}
|
|
220
|
-
|
|
206
|
+
];
|
|
207
|
+
|
|
208
|
+
// src/mcp/api-handlers.ts
|
|
209
|
+
import { default as axios } from "axios";
|
|
221
210
|
|
|
222
211
|
// src/mcp/mcpLogger.ts
|
|
212
|
+
var mcpLogWarnings = 0;
|
|
223
213
|
async function logMcpRequest(entry) {
|
|
214
|
+
if (process.env.FUNDTRACER_MCP_DISABLE_LOGGING === "1") {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
224
217
|
try {
|
|
225
218
|
const { getFirestore } = await import("../firebase.js");
|
|
226
219
|
const db = getFirestore();
|
|
@@ -237,25 +230,16 @@ async function logMcpRequest(entry) {
|
|
|
237
230
|
}
|
|
238
231
|
}
|
|
239
232
|
}
|
|
240
|
-
var mcpLogWarnings;
|
|
241
|
-
var init_mcpLogger = __esm({
|
|
242
|
-
"src/mcp/mcpLogger.ts"() {
|
|
243
|
-
mcpLogWarnings = 0;
|
|
244
|
-
}
|
|
245
|
-
});
|
|
246
233
|
|
|
247
234
|
// src/mcp/api-handlers.ts
|
|
248
|
-
var api_handlers_exports = {};
|
|
249
|
-
__export(api_handlers_exports, {
|
|
250
|
-
TOOL_HANDLERS: () => TOOL_HANDLERS
|
|
251
|
-
});
|
|
252
|
-
import { default as axios } from "axios";
|
|
253
235
|
function ok(text) {
|
|
254
236
|
return { content: [{ type: "text", text }] };
|
|
255
237
|
}
|
|
256
238
|
function err(message) {
|
|
257
239
|
return { content: [{ type: "text", text: message }], isError: true };
|
|
258
240
|
}
|
|
241
|
+
var API_BASE = process.env.FUNDTRACER_API_URL || "https://api.fundtracer.xyz";
|
|
242
|
+
var _mcpCtx = null;
|
|
259
243
|
function api() {
|
|
260
244
|
const key = _mcpCtx?.apiKey || process.env.FUNDTRACER_MCP_API_KEY || "";
|
|
261
245
|
const headers = {
|
|
@@ -272,6 +256,158 @@ function api() {
|
|
|
272
256
|
headers
|
|
273
257
|
});
|
|
274
258
|
}
|
|
259
|
+
var analyzeWallet = async (args, ctx) => {
|
|
260
|
+
const { address, chainId, transactionLimit } = args;
|
|
261
|
+
try {
|
|
262
|
+
const res = await api().post("/api/analyze/wallet", {
|
|
263
|
+
address,
|
|
264
|
+
chain: chainId,
|
|
265
|
+
options: { limit: transactionLimit || 500 }
|
|
266
|
+
});
|
|
267
|
+
return ok(JSON.stringify(res.data, null, 2));
|
|
268
|
+
} catch (error) {
|
|
269
|
+
const msg = error.response?.data?.error || error.message;
|
|
270
|
+
return err(`Wallet analysis failed: ${msg}`);
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
var traceFunds = async (args, ctx) => {
|
|
274
|
+
const { address, chainId, maxDepth = 3, direction = "both" } = args;
|
|
275
|
+
try {
|
|
276
|
+
const res = await api().post("/api/analyze/funding-tree", {
|
|
277
|
+
address,
|
|
278
|
+
chain: chainId,
|
|
279
|
+
maxDepth,
|
|
280
|
+
direction
|
|
281
|
+
});
|
|
282
|
+
return ok(JSON.stringify(res.data, null, 2));
|
|
283
|
+
} catch (error) {
|
|
284
|
+
const msg = error.response?.data?.error || error.message;
|
|
285
|
+
return err(`Fund tracing failed: ${msg}`);
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
var compareWallets = async (args, ctx) => {
|
|
289
|
+
const { addresses, chainId } = args;
|
|
290
|
+
const addrList = addresses.split(",").map((a) => a.trim()).filter(Boolean);
|
|
291
|
+
if (addrList.length < 2) return err("At least 2 addresses required");
|
|
292
|
+
try {
|
|
293
|
+
const res = await api().post("/api/analyze/compare", {
|
|
294
|
+
addresses: addrList,
|
|
295
|
+
chain: chainId
|
|
296
|
+
});
|
|
297
|
+
return ok(JSON.stringify(res.data, null, 2));
|
|
298
|
+
} catch (error) {
|
|
299
|
+
const msg = error.response?.data?.error || error.message;
|
|
300
|
+
return err(`Wallet comparison failed: ${msg}`);
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
var analyzeContract = async (args, ctx) => {
|
|
304
|
+
const { contractAddress, chainId, maxInteractors = 100 } = args;
|
|
305
|
+
try {
|
|
306
|
+
const res = await api().post("/api/analyze/contract", {
|
|
307
|
+
contractAddress,
|
|
308
|
+
chain: chainId,
|
|
309
|
+
maxInteractors
|
|
310
|
+
});
|
|
311
|
+
return ok(JSON.stringify(res.data, null, 2));
|
|
312
|
+
} catch (error) {
|
|
313
|
+
const msg = error.response?.data?.error || error.message;
|
|
314
|
+
return err(`Contract analysis failed: ${msg}`);
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
var detectSybilClusters = async (args, ctx) => {
|
|
318
|
+
const { addresses, chainId } = args;
|
|
319
|
+
const addrList = addresses.split(",").map((a) => a.trim()).filter(Boolean);
|
|
320
|
+
if (addrList.length < 3) return err("At least 3 addresses required for cluster detection");
|
|
321
|
+
try {
|
|
322
|
+
const res = await api().post("/api/analyze/sybil", {
|
|
323
|
+
addresses: addrList,
|
|
324
|
+
chain: chainId
|
|
325
|
+
});
|
|
326
|
+
return ok(JSON.stringify(res.data, null, 2));
|
|
327
|
+
} catch (error) {
|
|
328
|
+
const msg = error.response?.data?.error || error.message;
|
|
329
|
+
return err(`Sybil detection failed: ${msg}`);
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
var getPortfolio = async (args, ctx) => {
|
|
333
|
+
const { address, chainId } = args;
|
|
334
|
+
try {
|
|
335
|
+
const res = await api().get(`/api/portfolio/${address}`, {
|
|
336
|
+
params: { chain: chainId }
|
|
337
|
+
});
|
|
338
|
+
return ok(JSON.stringify(res.data, null, 2));
|
|
339
|
+
} catch (error) {
|
|
340
|
+
const msg = error.response?.data?.error || error.message;
|
|
341
|
+
return err(`Portfolio fetch failed: ${msg}`);
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
var getTransactions = async (args, ctx) => {
|
|
345
|
+
const { address, chainId, limit = 50 } = args;
|
|
346
|
+
try {
|
|
347
|
+
const res = await api().post("/api/history", {
|
|
348
|
+
wallet: address,
|
|
349
|
+
blockchain: chainId,
|
|
350
|
+
pageToken: null,
|
|
351
|
+
filters: {}
|
|
352
|
+
});
|
|
353
|
+
const txs = (res.data.transactions || []).slice(0, limit);
|
|
354
|
+
return ok(JSON.stringify({
|
|
355
|
+
address,
|
|
356
|
+
chainId,
|
|
357
|
+
transactions: txs,
|
|
358
|
+
totalCount: res.data.transactions?.length || 0
|
|
359
|
+
}, null, 2));
|
|
360
|
+
} catch (error) {
|
|
361
|
+
const msg = error.response?.data?.error || error.message;
|
|
362
|
+
return err(`Transaction fetch failed: ${msg}`);
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
var lookupEntity = async (args, ctx) => {
|
|
366
|
+
const { query, chainId } = args;
|
|
367
|
+
const chain = chainId || "ethereum";
|
|
368
|
+
try {
|
|
369
|
+
if (/^0x[a-fA-F0-9]{40}$/.test(query) || /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(query)) {
|
|
370
|
+
const res2 = await api().get(`/api/entities/${query}`, {
|
|
371
|
+
params: { chain }
|
|
372
|
+
});
|
|
373
|
+
return ok(JSON.stringify(res2.data, null, 2));
|
|
374
|
+
}
|
|
375
|
+
const res = await api().get("/api/entities/search", {
|
|
376
|
+
params: { q: query, chain }
|
|
377
|
+
});
|
|
378
|
+
return ok(JSON.stringify(res.data, null, 2));
|
|
379
|
+
} catch (error) {
|
|
380
|
+
if (error.response?.status === 404) {
|
|
381
|
+
return ok(JSON.stringify({ query, label: "Unknown address", chain }, null, 2));
|
|
382
|
+
}
|
|
383
|
+
const msg = error.response?.data?.error || error.message;
|
|
384
|
+
return err(`Entity lookup failed: ${msg}`);
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
var getGasPrices = async (args, ctx) => {
|
|
388
|
+
const { chainId } = args;
|
|
389
|
+
try {
|
|
390
|
+
const res = await api().get("/api/gas", {
|
|
391
|
+
params: chainId ? { chain: chainId } : {}
|
|
392
|
+
});
|
|
393
|
+
return ok(JSON.stringify(res.data, null, 2));
|
|
394
|
+
} catch (error) {
|
|
395
|
+
const msg = error.response?.data?.error || error.message;
|
|
396
|
+
return err(`Gas price fetch failed: ${msg}`);
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
var getTokenInfo = async (args, ctx) => {
|
|
400
|
+
const { tokenAddress, chainId } = args;
|
|
401
|
+
try {
|
|
402
|
+
const res = await api().get("/api/market/coins", {
|
|
403
|
+
params: { address: tokenAddress, chainId }
|
|
404
|
+
});
|
|
405
|
+
return ok(JSON.stringify(res.data, null, 2));
|
|
406
|
+
} catch (error) {
|
|
407
|
+
const msg = error.response?.data?.error || error.message;
|
|
408
|
+
return err(`Token info fetch failed: ${msg}`);
|
|
409
|
+
}
|
|
410
|
+
};
|
|
275
411
|
function withLogging(toolName, handler) {
|
|
276
412
|
return async (args, ctx) => {
|
|
277
413
|
_mcpCtx = ctx;
|
|
@@ -306,185 +442,20 @@ function withLogging(toolName, handler) {
|
|
|
306
442
|
}
|
|
307
443
|
};
|
|
308
444
|
}
|
|
309
|
-
var
|
|
310
|
-
|
|
311
|
-
"
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
options: { limit: transactionLimit || 500 }
|
|
322
|
-
});
|
|
323
|
-
return ok(JSON.stringify(res.data, null, 2));
|
|
324
|
-
} catch (error) {
|
|
325
|
-
const msg = error.response?.data?.error || error.message;
|
|
326
|
-
return err(`Wallet analysis failed: ${msg}`);
|
|
327
|
-
}
|
|
328
|
-
};
|
|
329
|
-
traceFunds = async (args, ctx) => {
|
|
330
|
-
const { address, chainId, maxDepth = 3, direction = "both" } = args;
|
|
331
|
-
try {
|
|
332
|
-
const res = await api().post("/api/analyze/funding-tree", {
|
|
333
|
-
address,
|
|
334
|
-
chain: chainId,
|
|
335
|
-
maxDepth,
|
|
336
|
-
direction
|
|
337
|
-
});
|
|
338
|
-
return ok(JSON.stringify(res.data, null, 2));
|
|
339
|
-
} catch (error) {
|
|
340
|
-
const msg = error.response?.data?.error || error.message;
|
|
341
|
-
return err(`Fund tracing failed: ${msg}`);
|
|
342
|
-
}
|
|
343
|
-
};
|
|
344
|
-
compareWallets = async (args, ctx) => {
|
|
345
|
-
const { addresses, chainId } = args;
|
|
346
|
-
const addrList = addresses.split(",").map((a) => a.trim()).filter(Boolean);
|
|
347
|
-
if (addrList.length < 2) return err("At least 2 addresses required");
|
|
348
|
-
try {
|
|
349
|
-
const res = await api().post("/api/analyze/compare", {
|
|
350
|
-
addresses: addrList,
|
|
351
|
-
chain: chainId
|
|
352
|
-
});
|
|
353
|
-
return ok(JSON.stringify(res.data, null, 2));
|
|
354
|
-
} catch (error) {
|
|
355
|
-
const msg = error.response?.data?.error || error.message;
|
|
356
|
-
return err(`Wallet comparison failed: ${msg}`);
|
|
357
|
-
}
|
|
358
|
-
};
|
|
359
|
-
analyzeContract = async (args, ctx) => {
|
|
360
|
-
const { contractAddress, chainId, maxInteractors = 100 } = args;
|
|
361
|
-
try {
|
|
362
|
-
const res = await api().post("/api/analyze/contract", {
|
|
363
|
-
contractAddress,
|
|
364
|
-
chain: chainId,
|
|
365
|
-
maxInteractors
|
|
366
|
-
});
|
|
367
|
-
return ok(JSON.stringify(res.data, null, 2));
|
|
368
|
-
} catch (error) {
|
|
369
|
-
const msg = error.response?.data?.error || error.message;
|
|
370
|
-
return err(`Contract analysis failed: ${msg}`);
|
|
371
|
-
}
|
|
372
|
-
};
|
|
373
|
-
detectSybilClusters = async (args, ctx) => {
|
|
374
|
-
const { addresses, chainId } = args;
|
|
375
|
-
const addrList = addresses.split(",").map((a) => a.trim()).filter(Boolean);
|
|
376
|
-
if (addrList.length < 3) return err("At least 3 addresses required for cluster detection");
|
|
377
|
-
try {
|
|
378
|
-
const res = await api().post("/api/analyze/sybil", {
|
|
379
|
-
addresses: addrList,
|
|
380
|
-
chain: chainId
|
|
381
|
-
});
|
|
382
|
-
return ok(JSON.stringify(res.data, null, 2));
|
|
383
|
-
} catch (error) {
|
|
384
|
-
const msg = error.response?.data?.error || error.message;
|
|
385
|
-
return err(`Sybil detection failed: ${msg}`);
|
|
386
|
-
}
|
|
387
|
-
};
|
|
388
|
-
getPortfolio = async (args, ctx) => {
|
|
389
|
-
const { address, chainId } = args;
|
|
390
|
-
try {
|
|
391
|
-
const res = await api().get(`/api/portfolio/${address}`, {
|
|
392
|
-
params: { chain: chainId }
|
|
393
|
-
});
|
|
394
|
-
return ok(JSON.stringify(res.data, null, 2));
|
|
395
|
-
} catch (error) {
|
|
396
|
-
const msg = error.response?.data?.error || error.message;
|
|
397
|
-
return err(`Portfolio fetch failed: ${msg}`);
|
|
398
|
-
}
|
|
399
|
-
};
|
|
400
|
-
getTransactions = async (args, ctx) => {
|
|
401
|
-
const { address, chainId, limit = 50 } = args;
|
|
402
|
-
try {
|
|
403
|
-
const res = await api().post("/api/history", {
|
|
404
|
-
wallet: address,
|
|
405
|
-
blockchain: chainId,
|
|
406
|
-
pageToken: null,
|
|
407
|
-
filters: {}
|
|
408
|
-
});
|
|
409
|
-
const txs = (res.data.transactions || []).slice(0, limit);
|
|
410
|
-
return ok(JSON.stringify({
|
|
411
|
-
address,
|
|
412
|
-
chainId,
|
|
413
|
-
transactions: txs,
|
|
414
|
-
totalCount: res.data.transactions?.length || 0
|
|
415
|
-
}, null, 2));
|
|
416
|
-
} catch (error) {
|
|
417
|
-
const msg = error.response?.data?.error || error.message;
|
|
418
|
-
return err(`Transaction fetch failed: ${msg}`);
|
|
419
|
-
}
|
|
420
|
-
};
|
|
421
|
-
lookupEntity = async (args, ctx) => {
|
|
422
|
-
const { query, chainId } = args;
|
|
423
|
-
const chain = chainId || "ethereum";
|
|
424
|
-
try {
|
|
425
|
-
if (/^0x[a-fA-F0-9]{40}$/.test(query) || /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(query)) {
|
|
426
|
-
const res2 = await api().get(`/api/entities/${query}`, {
|
|
427
|
-
params: { chain }
|
|
428
|
-
});
|
|
429
|
-
return ok(JSON.stringify(res2.data, null, 2));
|
|
430
|
-
}
|
|
431
|
-
const res = await api().get("/api/entities/search", {
|
|
432
|
-
params: { q: query, chain }
|
|
433
|
-
});
|
|
434
|
-
return ok(JSON.stringify(res.data, null, 2));
|
|
435
|
-
} catch (error) {
|
|
436
|
-
if (error.response?.status === 404) {
|
|
437
|
-
return ok(JSON.stringify({ query, label: "Unknown address", chain }, null, 2));
|
|
438
|
-
}
|
|
439
|
-
const msg = error.response?.data?.error || error.message;
|
|
440
|
-
return err(`Entity lookup failed: ${msg}`);
|
|
441
|
-
}
|
|
442
|
-
};
|
|
443
|
-
getGasPrices = async (args, ctx) => {
|
|
444
|
-
const { chainId } = args;
|
|
445
|
-
try {
|
|
446
|
-
const res = await api().get("/api/gas", {
|
|
447
|
-
params: chainId ? { chain: chainId } : {}
|
|
448
|
-
});
|
|
449
|
-
return ok(JSON.stringify(res.data, null, 2));
|
|
450
|
-
} catch (error) {
|
|
451
|
-
const msg = error.response?.data?.error || error.message;
|
|
452
|
-
return err(`Gas price fetch failed: ${msg}`);
|
|
453
|
-
}
|
|
454
|
-
};
|
|
455
|
-
getTokenInfo = async (args, ctx) => {
|
|
456
|
-
const { tokenAddress, chainId } = args;
|
|
457
|
-
try {
|
|
458
|
-
const res = await api().get("/api/market/coins", {
|
|
459
|
-
params: { address: tokenAddress, chainId }
|
|
460
|
-
});
|
|
461
|
-
return ok(JSON.stringify(res.data, null, 2));
|
|
462
|
-
} catch (error) {
|
|
463
|
-
const msg = error.response?.data?.error || error.message;
|
|
464
|
-
return err(`Token info fetch failed: ${msg}`);
|
|
465
|
-
}
|
|
466
|
-
};
|
|
467
|
-
TOOL_HANDLERS = {
|
|
468
|
-
analyze_wallet: withLogging("analyze_wallet", analyzeWallet),
|
|
469
|
-
trace_funds: withLogging("trace_funds", traceFunds),
|
|
470
|
-
compare_wallets: withLogging("compare_wallets", compareWallets),
|
|
471
|
-
analyze_contract: withLogging("analyze_contract", analyzeContract),
|
|
472
|
-
detect_sybil_clusters: withLogging("detect_sybil_clusters", detectSybilClusters),
|
|
473
|
-
get_portfolio: withLogging("get_portfolio", getPortfolio),
|
|
474
|
-
get_transactions: withLogging("get_transactions", getTransactions),
|
|
475
|
-
lookup_entity: withLogging("lookup_entity", lookupEntity),
|
|
476
|
-
get_gas_prices: withLogging("get_gas_prices", getGasPrices),
|
|
477
|
-
get_token_info: withLogging("get_token_info", getTokenInfo)
|
|
478
|
-
};
|
|
479
|
-
}
|
|
480
|
-
});
|
|
445
|
+
var TOOL_HANDLERS = {
|
|
446
|
+
analyze_wallet: withLogging("analyze_wallet", analyzeWallet),
|
|
447
|
+
trace_funds: withLogging("trace_funds", traceFunds),
|
|
448
|
+
compare_wallets: withLogging("compare_wallets", compareWallets),
|
|
449
|
+
analyze_contract: withLogging("analyze_contract", analyzeContract),
|
|
450
|
+
detect_sybil_clusters: withLogging("detect_sybil_clusters", detectSybilClusters),
|
|
451
|
+
get_portfolio: withLogging("get_portfolio", getPortfolio),
|
|
452
|
+
get_transactions: withLogging("get_transactions", getTransactions),
|
|
453
|
+
lookup_entity: withLogging("lookup_entity", lookupEntity),
|
|
454
|
+
get_gas_prices: withLogging("get_gas_prices", getGasPrices),
|
|
455
|
+
get_token_info: withLogging("get_token_info", getTokenInfo)
|
|
456
|
+
};
|
|
481
457
|
|
|
482
458
|
// src/mcp/mcpAuth.ts
|
|
483
|
-
var mcpAuth_exports = {};
|
|
484
|
-
__export(mcpAuth_exports, {
|
|
485
|
-
mcpApiKeyAuth: () => mcpApiKeyAuth,
|
|
486
|
-
validateMcpApiKey: () => validateMcpApiKey
|
|
487
|
-
});
|
|
488
459
|
async function validateMcpApiKey(rawKey) {
|
|
489
460
|
if (!rawKey.startsWith("ft_")) throw new Error("Invalid MCP API key format");
|
|
490
461
|
let firestoreResult = null;
|
|
@@ -569,69 +540,27 @@ async function trackUsage(userId, rawKey) {
|
|
|
569
540
|
} catch {
|
|
570
541
|
}
|
|
571
542
|
}
|
|
572
|
-
async function mcpApiKeyAuth(req, res, next) {
|
|
573
|
-
const authHeader = req.headers.authorization;
|
|
574
|
-
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
575
|
-
return res.status(401).json({ error: "MCP API key required (Authorization: Bearer ft_mcp_<key>)" });
|
|
576
|
-
}
|
|
577
|
-
const rawKey = authHeader.slice(7).trim();
|
|
578
|
-
if (!rawKey.startsWith("ft_")) {
|
|
579
|
-
return res.status(401).json({ error: "Invalid MCP API key format" });
|
|
580
|
-
}
|
|
581
|
-
try {
|
|
582
|
-
const ctx = await validateMcpApiKey(rawKey);
|
|
583
|
-
req.mcpContext = ctx;
|
|
584
|
-
next();
|
|
585
|
-
} catch (err2) {
|
|
586
|
-
return res.status(401).json({ error: err2.message });
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
|
-
var init_mcpAuth = __esm({
|
|
590
|
-
"src/mcp/mcpAuth.ts"() {
|
|
591
|
-
}
|
|
592
|
-
});
|
|
593
543
|
|
|
594
|
-
// src/mcp/
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
dotenv.config();
|
|
598
|
-
async function main() {
|
|
599
|
-
let firebaseAvailable = false;
|
|
600
|
-
try {
|
|
601
|
-
const { initializeFirebase } = await import("../firebase.js");
|
|
602
|
-
initializeFirebase();
|
|
603
|
-
firebaseAvailable = true;
|
|
604
|
-
console.error("[MCP] Firebase initialized");
|
|
605
|
-
} catch (err2) {
|
|
606
|
-
console.error("[MCP] Firebase not available \u2014 key validation will fail. Set Firebase credentials in env.");
|
|
607
|
-
}
|
|
608
|
-
const { ALL_MCP_TOOLS: ALL_MCP_TOOLS2 } = await Promise.resolve().then(() => (init_tools(), tools_exports));
|
|
609
|
-
const { TOOL_HANDLERS: TOOL_HANDLERS2 } = await Promise.resolve().then(() => (init_api_handlers(), api_handlers_exports));
|
|
610
|
-
const { validateMcpApiKey: validateMcpApiKey2 } = await Promise.resolve().then(() => (init_mcpAuth(), mcpAuth_exports));
|
|
544
|
+
// src/mcp/server.ts
|
|
545
|
+
function createFundTracerMcpServer(resolveContext, options = {}) {
|
|
546
|
+
const logRegistrations = options.logRegistrations ?? true;
|
|
611
547
|
const server = new McpServer({
|
|
612
548
|
name: "FundTracer MCP",
|
|
613
549
|
version: "1.0.0"
|
|
614
550
|
});
|
|
615
|
-
for (const toolDef of
|
|
616
|
-
const handler =
|
|
551
|
+
for (const toolDef of ALL_MCP_TOOLS) {
|
|
552
|
+
const handler = TOOL_HANDLERS[toolDef.name];
|
|
617
553
|
if (!handler) {
|
|
618
554
|
console.error(`[MCP] No handler for tool: ${toolDef.name}`);
|
|
619
555
|
continue;
|
|
620
556
|
}
|
|
621
557
|
server.registerTool(toolDef.name, {
|
|
622
558
|
description: toolDef.description,
|
|
623
|
-
inputSchema:
|
|
624
|
-
}, async (args) => {
|
|
625
|
-
const apiKey = process.env.FUNDTRACER_MCP_API_KEY;
|
|
626
|
-
if (!apiKey) {
|
|
627
|
-
return {
|
|
628
|
-
content: [{ type: "text", text: "FUNDTRACER_MCP_API_KEY environment variable not set" }],
|
|
629
|
-
isError: true
|
|
630
|
-
};
|
|
631
|
-
}
|
|
559
|
+
inputSchema: jsonSchemaObjectToZodShape(toolDef.inputSchema)
|
|
560
|
+
}, async (args, requestContext) => {
|
|
632
561
|
let ctx;
|
|
633
562
|
try {
|
|
634
|
-
ctx = await
|
|
563
|
+
ctx = await resolveContext(requestContext);
|
|
635
564
|
} catch (err2) {
|
|
636
565
|
return {
|
|
637
566
|
content: [{ type: "text", text: `Authentication failed: ${err2.message}` }],
|
|
@@ -640,10 +569,83 @@ async function main() {
|
|
|
640
569
|
}
|
|
641
570
|
return handler(args, ctx);
|
|
642
571
|
});
|
|
643
|
-
|
|
572
|
+
if (logRegistrations) {
|
|
573
|
+
console.error(`[MCP] Registered tool: ${toolDef.name}`);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
return server;
|
|
577
|
+
}
|
|
578
|
+
function jsonSchemaObjectToZodShape(schema) {
|
|
579
|
+
const required = new Set(Array.isArray(schema?.required) ? schema.required : []);
|
|
580
|
+
const properties = schema?.properties || {};
|
|
581
|
+
const shape = {};
|
|
582
|
+
for (const [name, propertySchema] of Object.entries(properties)) {
|
|
583
|
+
let field = jsonSchemaPropertyToZod(propertySchema);
|
|
584
|
+
if (!required.has(name)) field = field.optional();
|
|
585
|
+
shape[name] = field;
|
|
586
|
+
}
|
|
587
|
+
return shape;
|
|
588
|
+
}
|
|
589
|
+
function jsonSchemaPropertyToZod(schema) {
|
|
590
|
+
let field;
|
|
591
|
+
if (Array.isArray(schema?.enum) && schema.enum.length > 0) {
|
|
592
|
+
const values = schema.enum.filter((value) => typeof value === "string");
|
|
593
|
+
field = values.length > 0 ? z.enum(values) : z.string();
|
|
594
|
+
} else {
|
|
595
|
+
switch (schema?.type) {
|
|
596
|
+
case "number":
|
|
597
|
+
case "integer":
|
|
598
|
+
field = z.number();
|
|
599
|
+
break;
|
|
600
|
+
case "boolean":
|
|
601
|
+
field = z.boolean();
|
|
602
|
+
break;
|
|
603
|
+
case "array":
|
|
604
|
+
field = z.array(z.unknown());
|
|
605
|
+
break;
|
|
606
|
+
case "object":
|
|
607
|
+
field = z.record(z.unknown());
|
|
608
|
+
break;
|
|
609
|
+
case "string":
|
|
610
|
+
default:
|
|
611
|
+
field = z.string();
|
|
612
|
+
break;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
if (schema?.description && typeof field.describe === "function") {
|
|
616
|
+
field = field.describe(schema.description);
|
|
617
|
+
}
|
|
618
|
+
if (schema?.default !== void 0) {
|
|
619
|
+
field = field.default(schema.default);
|
|
620
|
+
}
|
|
621
|
+
return field;
|
|
622
|
+
}
|
|
623
|
+
async function resolveStdioMcpContext() {
|
|
624
|
+
const apiKey = process.env.FUNDTRACER_MCP_API_KEY;
|
|
625
|
+
if (!apiKey) {
|
|
626
|
+
throw new Error("FUNDTRACER_MCP_API_KEY environment variable not set");
|
|
627
|
+
}
|
|
628
|
+
return validateMcpApiKey(apiKey);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// src/mcp/stdio.ts
|
|
632
|
+
dotenv.config();
|
|
633
|
+
console.log = console.error.bind(console);
|
|
634
|
+
process.env.FUNDTRACER_MCP_DISABLE_LOGGING = process.env.FUNDTRACER_MCP_DISABLE_LOGGING || "1";
|
|
635
|
+
async function main() {
|
|
636
|
+
let firebaseAvailable = false;
|
|
637
|
+
try {
|
|
638
|
+
const { initializeFirebase } = await import("../firebase.js");
|
|
639
|
+
initializeFirebase();
|
|
640
|
+
firebaseAvailable = true;
|
|
641
|
+
console.error("[MCP] Firebase initialized");
|
|
642
|
+
} catch (err2) {
|
|
643
|
+
console.error("[MCP] Firebase not available \u2014 key validation will fail. Set Firebase credentials in env.");
|
|
644
644
|
}
|
|
645
|
+
const server = createFundTracerMcpServer(resolveStdioMcpContext);
|
|
645
646
|
const transport = new StdioServerTransport();
|
|
646
647
|
await server.connect(transport);
|
|
648
|
+
process.stdin.resume();
|
|
647
649
|
console.error("[MCP] FundTracer MCP server running on stdio");
|
|
648
650
|
}
|
|
649
651
|
main().catch((err2) => {
|
|
@@ -652,7 +654,6 @@ main().catch((err2) => {
|
|
|
652
654
|
});
|
|
653
655
|
process.on("SIGINT", async () => {
|
|
654
656
|
console.error("[MCP] Shutting down...");
|
|
655
|
-
const { McpServer: McpServer2 } = await import("@modelcontextprotocol/server");
|
|
656
657
|
process.exit(0);
|
|
657
658
|
});
|
|
658
659
|
process.on("SIGTERM", async () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fundtracer/mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.12",
|
|
4
4
|
"description": "FundTracer MCP Server — blockchain analysis for AI assistants (Claude Desktop, Claude Code, Cursor, etc.)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "fundtracer-mcp.js",
|
|
@@ -13,10 +13,11 @@
|
|
|
13
13
|
],
|
|
14
14
|
"dependencies": {
|
|
15
15
|
"@cfworker/json-schema": "^4.1.1",
|
|
16
|
-
"@modelcontextprotocol/
|
|
16
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
17
17
|
"axios": "^1.7.0",
|
|
18
18
|
"dotenv": "^16.3.1",
|
|
19
|
-
"node-fetch": "^2.7.0"
|
|
19
|
+
"node-fetch": "^2.7.0",
|
|
20
|
+
"zod": "^3.25.76"
|
|
20
21
|
},
|
|
21
22
|
"keywords": ["mcp", "blockchain", "wallet-analysis", "fundtracer", "ai"],
|
|
22
23
|
"license": "GPL-3.0",
|