crossfin-mcp 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +116 -0
- package/dist/index.js +398 -0
- package/dist/ledgerStore.js +129 -0
- package/package.json +54 -0
package/README.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# CrossFin MCP Server
|
|
2
|
+
|
|
3
|
+
MCP server for CrossFin — 13 tools for service discovery, local ledger, and paid API execution.
|
|
4
|
+
|
|
5
|
+
## Install (npm)
|
|
6
|
+
|
|
7
|
+
Use directly with `npx`:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx -y crossfin-mcp
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or install globally:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm i -g crossfin-mcp
|
|
17
|
+
crossfin-mcp
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Tools
|
|
21
|
+
|
|
22
|
+
### Local ledger
|
|
23
|
+
|
|
24
|
+
- `create_wallet`
|
|
25
|
+
- `get_balance`
|
|
26
|
+
- `transfer`
|
|
27
|
+
- `list_transactions`
|
|
28
|
+
- `set_budget`
|
|
29
|
+
|
|
30
|
+
### CrossFin API (live)
|
|
31
|
+
|
|
32
|
+
- `search_services`
|
|
33
|
+
- `list_services`
|
|
34
|
+
- `get_service`
|
|
35
|
+
- `list_categories`
|
|
36
|
+
- `get_kimchi_premium`
|
|
37
|
+
- `get_analytics`
|
|
38
|
+
- `get_guide`
|
|
39
|
+
|
|
40
|
+
### Paid execution
|
|
41
|
+
|
|
42
|
+
- `call_paid_service` — call any CrossFin paid endpoint with automatic x402 USDC payment on Base (requires `EVM_PRIVATE_KEY`)
|
|
43
|
+
|
|
44
|
+
## Run (dev)
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
cd apps/mcp-server
|
|
48
|
+
npm install
|
|
49
|
+
npm run dev
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Ledger storage
|
|
53
|
+
|
|
54
|
+
By default the server stores data at:
|
|
55
|
+
|
|
56
|
+
- `~/.crossfin/ledger.json`
|
|
57
|
+
|
|
58
|
+
Override with:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
export CROSSFIN_LEDGER_PATH="/path/to/ledger.json"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## API base URL
|
|
65
|
+
|
|
66
|
+
By default the server calls the live CrossFin API at `https://crossfin.dev`.
|
|
67
|
+
|
|
68
|
+
Override with:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
export CROSSFIN_API_URL="https://crossfin.dev"
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Claude Desktop config (example)
|
|
75
|
+
|
|
76
|
+
Use the published npm package:
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
{
|
|
80
|
+
"mcpServers": {
|
|
81
|
+
"crossfin": {
|
|
82
|
+
"command": "npx",
|
|
83
|
+
"args": ["-y", "crossfin-mcp"],
|
|
84
|
+
"env": {
|
|
85
|
+
"CROSSFIN_API_URL": "https://crossfin.dev",
|
|
86
|
+
"EVM_PRIVATE_KEY": "0x..."
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Or point Claude Desktop to the local built output:
|
|
94
|
+
|
|
95
|
+
```json
|
|
96
|
+
{
|
|
97
|
+
"mcpServers": {
|
|
98
|
+
"crossfin": {
|
|
99
|
+
"command": "node",
|
|
100
|
+
"args": ["/ABS/PATH/TO/crossfin/apps/mcp-server/dist/index.js"],
|
|
101
|
+
"env": {
|
|
102
|
+
"CROSSFIN_LEDGER_PATH": "/ABS/PATH/TO/ledger.json",
|
|
103
|
+
"CROSSFIN_API_URL": "https://crossfin.dev",
|
|
104
|
+
"EVM_PRIVATE_KEY": "0x..."
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Build:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
cd apps/mcp-server
|
|
115
|
+
npm run build
|
|
116
|
+
```
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { z } from 'zod/v4';
|
|
5
|
+
import { x402Client, wrapFetchWithPayment, x402HTTPClient } from '@x402/fetch';
|
|
6
|
+
import { registerExactEvmScheme } from '@x402/evm/exact/client';
|
|
7
|
+
import { privateKeyToAccount } from 'viem/accounts';
|
|
8
|
+
import { createWallet, defaultLedgerPath, getBalance, listTransactions, setBudget, transfer, } from './ledgerStore.js';
|
|
9
|
+
const LEDGER_PATH = process.env.CROSSFIN_LEDGER_PATH?.trim() || defaultLedgerPath();
|
|
10
|
+
const API_BASE = (process.env.CROSSFIN_API_URL?.trim() || 'https://crossfin.dev').replace(/\/$/, '');
|
|
11
|
+
const EVM_PRIVATE_KEY = process.env.EVM_PRIVATE_KEY?.trim() ?? '';
|
|
12
|
+
const API_ORIGIN = new URL(API_BASE).origin;
|
|
13
|
+
function ensureCrossfinPaidUrl(raw) {
|
|
14
|
+
let url;
|
|
15
|
+
try {
|
|
16
|
+
url = new URL(raw.trim());
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
throw new Error('Invalid URL');
|
|
20
|
+
}
|
|
21
|
+
if (url.protocol !== 'https:') {
|
|
22
|
+
throw new Error('Only https:// URLs are allowed');
|
|
23
|
+
}
|
|
24
|
+
if (url.username || url.password) {
|
|
25
|
+
throw new Error('Credentials in URL are not allowed');
|
|
26
|
+
}
|
|
27
|
+
if (url.origin !== API_ORIGIN) {
|
|
28
|
+
throw new Error(`Only ${API_ORIGIN} URLs are allowed`);
|
|
29
|
+
}
|
|
30
|
+
if (!url.pathname.startsWith('/api/premium/')) {
|
|
31
|
+
throw new Error('Only /api/premium/* endpoints are allowed');
|
|
32
|
+
}
|
|
33
|
+
return url.toString();
|
|
34
|
+
}
|
|
35
|
+
/* ── x402 paid fetch setup ── */
|
|
36
|
+
let paidFetch = null;
|
|
37
|
+
let httpClient = null;
|
|
38
|
+
let payerAddress = '';
|
|
39
|
+
if (EVM_PRIVATE_KEY) {
|
|
40
|
+
const signer = privateKeyToAccount(EVM_PRIVATE_KEY);
|
|
41
|
+
payerAddress = signer.address;
|
|
42
|
+
const client = new x402Client();
|
|
43
|
+
registerExactEvmScheme(client, { signer });
|
|
44
|
+
paidFetch = wrapFetchWithPayment(fetch, client);
|
|
45
|
+
httpClient = new x402HTTPClient(client);
|
|
46
|
+
}
|
|
47
|
+
function basescanLink(networkId, txHash) {
|
|
48
|
+
if (!txHash)
|
|
49
|
+
return null;
|
|
50
|
+
if (networkId === 'eip155:84532')
|
|
51
|
+
return `https://sepolia.basescan.org/tx/${txHash}`;
|
|
52
|
+
if (networkId === 'eip155:8453')
|
|
53
|
+
return `https://basescan.org/tx/${txHash}`;
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
const server = new McpServer({ name: 'crossfin', version: '1.7.1' });
|
|
57
|
+
const railSchema = z.enum(['manual', 'kakaopay', 'toss', 'stripe', 'x402']);
|
|
58
|
+
async function apiFetch(path) {
|
|
59
|
+
const res = await fetch(`${API_BASE}${path}`);
|
|
60
|
+
if (!res.ok) {
|
|
61
|
+
throw new Error(`API ${res.status}: ${await res.text().catch(() => res.statusText)}`);
|
|
62
|
+
}
|
|
63
|
+
return res.json();
|
|
64
|
+
}
|
|
65
|
+
server.registerTool('create_wallet', {
|
|
66
|
+
title: 'Create wallet',
|
|
67
|
+
description: 'Create a wallet in the local CrossFin ledger',
|
|
68
|
+
inputSchema: z.object({
|
|
69
|
+
label: z.string().min(1).describe('Wallet label'),
|
|
70
|
+
initialDepositKrw: z.number().optional().describe('Optional initial deposit (KRW)'),
|
|
71
|
+
}),
|
|
72
|
+
outputSchema: z.object({
|
|
73
|
+
walletId: z.string(),
|
|
74
|
+
label: z.string(),
|
|
75
|
+
balanceKrw: z.number(),
|
|
76
|
+
}),
|
|
77
|
+
}, async ({ label, initialDepositKrw }) => {
|
|
78
|
+
const wallet = await createWallet(LEDGER_PATH, label, initialDepositKrw ?? 0);
|
|
79
|
+
const out = { walletId: wallet.id, label: wallet.label, balanceKrw: wallet.balanceKrw };
|
|
80
|
+
return {
|
|
81
|
+
content: [{ type: 'text', text: JSON.stringify(out) }],
|
|
82
|
+
structuredContent: out,
|
|
83
|
+
};
|
|
84
|
+
});
|
|
85
|
+
server.registerTool('get_balance', {
|
|
86
|
+
title: 'Get balance',
|
|
87
|
+
description: 'Get the balance (KRW) of a wallet',
|
|
88
|
+
inputSchema: z.object({ walletId: z.string().min(1) }),
|
|
89
|
+
outputSchema: z.object({ walletId: z.string(), balanceKrw: z.number() }),
|
|
90
|
+
}, async ({ walletId }) => {
|
|
91
|
+
const balance = await getBalance(LEDGER_PATH, walletId);
|
|
92
|
+
if (balance === null) {
|
|
93
|
+
return {
|
|
94
|
+
content: [{ type: 'text', text: `Wallet not found: ${walletId}` }],
|
|
95
|
+
structuredContent: { walletId, balanceKrw: -1 },
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const out = { walletId, balanceKrw: balance };
|
|
99
|
+
return {
|
|
100
|
+
content: [{ type: 'text', text: JSON.stringify(out) }],
|
|
101
|
+
structuredContent: out,
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
server.registerTool('transfer', {
|
|
105
|
+
title: 'Transfer',
|
|
106
|
+
description: 'Transfer funds between wallets (KRW)',
|
|
107
|
+
inputSchema: z.object({
|
|
108
|
+
fromWalletId: z.string().min(1),
|
|
109
|
+
toWalletId: z.string().min(1),
|
|
110
|
+
amountKrw: z.number().describe('Transfer amount in KRW'),
|
|
111
|
+
rail: railSchema.optional().describe('Payment rail'),
|
|
112
|
+
memo: z.string().optional().describe('Memo'),
|
|
113
|
+
}),
|
|
114
|
+
outputSchema: z.object({
|
|
115
|
+
transactionId: z.string(),
|
|
116
|
+
fromBalanceKrw: z.number(),
|
|
117
|
+
toBalanceKrw: z.number(),
|
|
118
|
+
}),
|
|
119
|
+
}, async ({ fromWalletId, toWalletId, amountKrw, rail, memo }) => {
|
|
120
|
+
const result = await transfer(LEDGER_PATH, {
|
|
121
|
+
fromWalletId,
|
|
122
|
+
toWalletId,
|
|
123
|
+
amountKrw,
|
|
124
|
+
rail: (rail ?? 'manual'),
|
|
125
|
+
memo: memo ?? '',
|
|
126
|
+
});
|
|
127
|
+
if (!result) {
|
|
128
|
+
return {
|
|
129
|
+
content: [{ type: 'text', text: 'Transfer failed (check wallet ids / balance / amount)' }],
|
|
130
|
+
structuredContent: {
|
|
131
|
+
transactionId: '',
|
|
132
|
+
fromBalanceKrw: -1,
|
|
133
|
+
toBalanceKrw: -1,
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
const out = {
|
|
138
|
+
transactionId: result.tx.id,
|
|
139
|
+
fromBalanceKrw: result.fromBalanceKrw,
|
|
140
|
+
toBalanceKrw: result.toBalanceKrw,
|
|
141
|
+
};
|
|
142
|
+
return {
|
|
143
|
+
content: [{ type: 'text', text: JSON.stringify(out) }],
|
|
144
|
+
structuredContent: out,
|
|
145
|
+
};
|
|
146
|
+
});
|
|
147
|
+
server.registerTool('list_transactions', {
|
|
148
|
+
title: 'List transactions',
|
|
149
|
+
description: 'List transactions (optionally filtered by wallet)',
|
|
150
|
+
inputSchema: z.object({
|
|
151
|
+
walletId: z.string().optional(),
|
|
152
|
+
limit: z.number().optional().describe('Max items (1..200). Default 50'),
|
|
153
|
+
}),
|
|
154
|
+
}, async ({ walletId, limit }) => {
|
|
155
|
+
const trimmedWalletId = walletId?.trim();
|
|
156
|
+
const txs = await listTransactions(LEDGER_PATH, trimmedWalletId ? { walletId: trimmedWalletId, limit: limit ?? 50 } : { limit: limit ?? 50 });
|
|
157
|
+
return {
|
|
158
|
+
content: [{ type: 'text', text: JSON.stringify({ transactions: txs }) }],
|
|
159
|
+
structuredContent: { transactions: txs },
|
|
160
|
+
};
|
|
161
|
+
});
|
|
162
|
+
server.registerTool('set_budget', {
|
|
163
|
+
title: 'Set budget',
|
|
164
|
+
description: 'Set a daily spend limit for the local ledger (KRW)',
|
|
165
|
+
inputSchema: z.object({
|
|
166
|
+
dailyLimitKrw: z.number().nullable().describe('Daily budget limit (KRW). Use null to clear.'),
|
|
167
|
+
}),
|
|
168
|
+
outputSchema: z.object({ dailyLimitKrw: z.number().nullable() }),
|
|
169
|
+
}, async ({ dailyLimitKrw }) => {
|
|
170
|
+
const out = await setBudget(LEDGER_PATH, dailyLimitKrw);
|
|
171
|
+
return {
|
|
172
|
+
content: [{ type: 'text', text: JSON.stringify(out) }],
|
|
173
|
+
structuredContent: out,
|
|
174
|
+
};
|
|
175
|
+
});
|
|
176
|
+
server.registerTool('search_services', {
|
|
177
|
+
title: 'Search services',
|
|
178
|
+
description: 'Search the CrossFin service registry for x402 services by keyword',
|
|
179
|
+
inputSchema: z.object({
|
|
180
|
+
query: z.string().describe('Search keyword (e.g. "crypto", "translate", "korea")'),
|
|
181
|
+
}),
|
|
182
|
+
}, async ({ query }) => {
|
|
183
|
+
try {
|
|
184
|
+
const qs = new URLSearchParams({ q: query });
|
|
185
|
+
const data = await apiFetch(`/api/registry/search?${qs.toString()}`);
|
|
186
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
187
|
+
}
|
|
188
|
+
catch (e) {
|
|
189
|
+
return {
|
|
190
|
+
content: [{ type: 'text', text: `Error: ${e instanceof Error ? e.message : 'Unknown error'}` }],
|
|
191
|
+
isError: true,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
server.registerTool('list_services', {
|
|
196
|
+
title: 'List services',
|
|
197
|
+
description: 'List services from the CrossFin registry with optional category filter',
|
|
198
|
+
inputSchema: z.object({
|
|
199
|
+
category: z.string().optional().describe('Category filter (e.g. "crypto-data", "ai", "tools")'),
|
|
200
|
+
limit: z.number().int().min(1).max(100).optional().describe('Max results (default 20, max 100)'),
|
|
201
|
+
}),
|
|
202
|
+
}, async ({ category, limit }) => {
|
|
203
|
+
try {
|
|
204
|
+
const qs = new URLSearchParams();
|
|
205
|
+
const trimmedCategory = category?.trim();
|
|
206
|
+
if (trimmedCategory)
|
|
207
|
+
qs.set('category', trimmedCategory);
|
|
208
|
+
if (typeof limit === 'number')
|
|
209
|
+
qs.set('limit', String(limit));
|
|
210
|
+
const path = qs.size ? `/api/registry?${qs.toString()}` : '/api/registry';
|
|
211
|
+
const data = await apiFetch(path);
|
|
212
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
213
|
+
}
|
|
214
|
+
catch (e) {
|
|
215
|
+
return {
|
|
216
|
+
content: [{ type: 'text', text: `Error: ${e instanceof Error ? e.message : 'Unknown error'}` }],
|
|
217
|
+
isError: true,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
server.registerTool('get_service', {
|
|
222
|
+
title: 'Get service',
|
|
223
|
+
description: 'Get detailed information about a specific service by ID',
|
|
224
|
+
inputSchema: z.object({
|
|
225
|
+
serviceId: z.string().describe('Service ID (e.g. "svc_kimchi_premium")'),
|
|
226
|
+
}),
|
|
227
|
+
}, async ({ serviceId }) => {
|
|
228
|
+
try {
|
|
229
|
+
const encodedId = encodeURIComponent(serviceId);
|
|
230
|
+
const data = await apiFetch(`/api/registry/${encodedId}`);
|
|
231
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
232
|
+
}
|
|
233
|
+
catch (e) {
|
|
234
|
+
return {
|
|
235
|
+
content: [{ type: 'text', text: `Error: ${e instanceof Error ? e.message : 'Unknown error'}` }],
|
|
236
|
+
isError: true,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
server.registerTool('list_categories', {
|
|
241
|
+
title: 'List categories',
|
|
242
|
+
description: 'List all service categories with counts',
|
|
243
|
+
inputSchema: z.object({}),
|
|
244
|
+
}, async (_params) => {
|
|
245
|
+
try {
|
|
246
|
+
const data = await apiFetch('/api/registry/categories');
|
|
247
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
248
|
+
}
|
|
249
|
+
catch (e) {
|
|
250
|
+
return {
|
|
251
|
+
content: [{ type: 'text', text: `Error: ${e instanceof Error ? e.message : 'Unknown error'}` }],
|
|
252
|
+
isError: true,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
server.registerTool('get_kimchi_premium', {
|
|
257
|
+
title: 'Get kimchi premium',
|
|
258
|
+
description: 'Get free preview of the Kimchi Premium index — real-time price spread between Korean and global crypto exchanges (top 3 pairs)',
|
|
259
|
+
inputSchema: z.object({}),
|
|
260
|
+
}, async (_params) => {
|
|
261
|
+
try {
|
|
262
|
+
const data = await apiFetch('/api/arbitrage/demo');
|
|
263
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
264
|
+
}
|
|
265
|
+
catch (e) {
|
|
266
|
+
return {
|
|
267
|
+
content: [{ type: 'text', text: `Error: ${e instanceof Error ? e.message : 'Unknown error'}` }],
|
|
268
|
+
isError: true,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
server.registerTool('get_analytics', {
|
|
273
|
+
title: 'Get analytics',
|
|
274
|
+
description: 'Get CrossFin gateway usage analytics — total calls, top services, recent activity',
|
|
275
|
+
inputSchema: z.object({}),
|
|
276
|
+
}, async (_params) => {
|
|
277
|
+
try {
|
|
278
|
+
const data = await apiFetch('/api/analytics/overview');
|
|
279
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
280
|
+
}
|
|
281
|
+
catch (e) {
|
|
282
|
+
return {
|
|
283
|
+
content: [{ type: 'text', text: `Error: ${e instanceof Error ? e.message : 'Unknown error'}` }],
|
|
284
|
+
isError: true,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
server.registerTool('get_guide', {
|
|
289
|
+
title: 'Get CrossFin guide',
|
|
290
|
+
description: 'Get the complete CrossFin API guide — what services are available, how to search, pricing, x402 payment flow, and code examples',
|
|
291
|
+
inputSchema: z.object({}),
|
|
292
|
+
}, async (_params) => {
|
|
293
|
+
try {
|
|
294
|
+
const data = await apiFetch('/api/docs/guide');
|
|
295
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
296
|
+
}
|
|
297
|
+
catch (e) {
|
|
298
|
+
return {
|
|
299
|
+
content: [{ type: 'text', text: `Error: ${e instanceof Error ? e.message : 'Unknown error'}` }],
|
|
300
|
+
isError: true,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
server.registerTool('call_paid_service', {
|
|
305
|
+
title: 'Call paid service',
|
|
306
|
+
description: 'Call a CrossFin paid API endpoint with automatic x402 USDC payment on Base. ' +
|
|
307
|
+
'Requires EVM_PRIVATE_KEY env var with funded wallet. ' +
|
|
308
|
+
'Returns the API response data plus payment proof (txHash, basescan link).',
|
|
309
|
+
inputSchema: z.object({
|
|
310
|
+
serviceId: z.string().optional().describe('Service ID from registry — looks up endpoint/price automatically'),
|
|
311
|
+
url: z.string().optional().describe('Direct URL to call (use this OR serviceId, not both)'),
|
|
312
|
+
params: z.record(z.string(), z.string()).optional().describe('Query parameters'),
|
|
313
|
+
}),
|
|
314
|
+
}, async ({ serviceId, url, params }) => {
|
|
315
|
+
if (!paidFetch || !httpClient) {
|
|
316
|
+
return {
|
|
317
|
+
content: [{ type: 'text', text: 'EVM_PRIVATE_KEY not configured — cannot make paid calls' }],
|
|
318
|
+
isError: true,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
if (serviceId && url) {
|
|
322
|
+
return { content: [{ type: 'text', text: 'Provide serviceId or url, not both' }], isError: true };
|
|
323
|
+
}
|
|
324
|
+
let targetUrl;
|
|
325
|
+
if (url) {
|
|
326
|
+
try {
|
|
327
|
+
targetUrl = ensureCrossfinPaidUrl(url);
|
|
328
|
+
}
|
|
329
|
+
catch (e) {
|
|
330
|
+
return {
|
|
331
|
+
content: [{ type: 'text', text: `Invalid url: ${e instanceof Error ? e.message : String(e)}` }],
|
|
332
|
+
isError: true,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
else if (serviceId) {
|
|
337
|
+
if (!serviceId.startsWith('crossfin_')) {
|
|
338
|
+
return {
|
|
339
|
+
content: [{ type: 'text', text: 'Only crossfin_* serviceId values are allowed for paid calls' }],
|
|
340
|
+
isError: true,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
try {
|
|
344
|
+
const svc = await apiFetch(`/api/registry/${encodeURIComponent(serviceId)}`);
|
|
345
|
+
const endpoint = typeof svc.data?.endpoint === 'string' ? svc.data.endpoint : '';
|
|
346
|
+
if (!endpoint) {
|
|
347
|
+
return { content: [{ type: 'text', text: `Service ${serviceId} has no endpoint` }], isError: true };
|
|
348
|
+
}
|
|
349
|
+
targetUrl = ensureCrossfinPaidUrl(endpoint);
|
|
350
|
+
}
|
|
351
|
+
catch (e) {
|
|
352
|
+
return {
|
|
353
|
+
content: [{ type: 'text', text: `Registry lookup failed: ${e instanceof Error ? e.message : String(e)}` }],
|
|
354
|
+
isError: true,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
return { content: [{ type: 'text', text: 'Provide serviceId or url' }], isError: true };
|
|
360
|
+
}
|
|
361
|
+
if (params && Object.keys(params).length > 0) {
|
|
362
|
+
const qs = new URLSearchParams(params);
|
|
363
|
+
const sep = targetUrl.includes('?') ? '&' : '?';
|
|
364
|
+
targetUrl = `${targetUrl}${sep}${qs.toString()}`;
|
|
365
|
+
}
|
|
366
|
+
try {
|
|
367
|
+
const res = await paidFetch(targetUrl, { method: 'GET' });
|
|
368
|
+
const body = await res.text();
|
|
369
|
+
let data;
|
|
370
|
+
try {
|
|
371
|
+
data = JSON.parse(body);
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
data = body;
|
|
375
|
+
}
|
|
376
|
+
const settle = httpClient.getPaymentSettleResponse((name) => res.headers.get(name));
|
|
377
|
+
const txHash = settle?.transaction;
|
|
378
|
+
const networkId = settle?.network;
|
|
379
|
+
const scanLink = basescanLink(networkId, txHash);
|
|
380
|
+
const result = {
|
|
381
|
+
status: res.status,
|
|
382
|
+
payer: payerAddress,
|
|
383
|
+
paid: !!settle,
|
|
384
|
+
txHash: txHash ?? null,
|
|
385
|
+
basescan: scanLink,
|
|
386
|
+
data,
|
|
387
|
+
};
|
|
388
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
389
|
+
}
|
|
390
|
+
catch (e) {
|
|
391
|
+
return {
|
|
392
|
+
content: [{ type: 'text', text: `Payment call failed: ${e instanceof Error ? e.message : String(e)}` }],
|
|
393
|
+
isError: true,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
const transport = new StdioServerTransport();
|
|
398
|
+
await server.connect(transport);
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
export function defaultLedgerPath() {
|
|
6
|
+
return path.join(os.homedir(), '.crossfin', 'ledger.json');
|
|
7
|
+
}
|
|
8
|
+
export async function readDb(filePath) {
|
|
9
|
+
try {
|
|
10
|
+
const raw = await readFile(filePath, 'utf8');
|
|
11
|
+
const parsed = JSON.parse(raw);
|
|
12
|
+
if (isLedgerDb(parsed))
|
|
13
|
+
return parsed;
|
|
14
|
+
return emptyDb();
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return emptyDb();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export async function writeDb(filePath, db) {
|
|
21
|
+
const dir = path.dirname(filePath);
|
|
22
|
+
await mkdir(dir, { recursive: true });
|
|
23
|
+
const tmp = `${filePath}.tmp-${randomUUID()}`;
|
|
24
|
+
const payload = JSON.stringify(db, null, 2);
|
|
25
|
+
await writeFile(tmp, payload, 'utf8');
|
|
26
|
+
await rename(tmp, filePath);
|
|
27
|
+
}
|
|
28
|
+
export async function createWallet(filePath, label, initialDepositKrw) {
|
|
29
|
+
const db = await readDb(filePath);
|
|
30
|
+
const now = new Date().toISOString();
|
|
31
|
+
const wallet = {
|
|
32
|
+
id: randomUUID(),
|
|
33
|
+
label: label.trim(),
|
|
34
|
+
balanceKrw: 0,
|
|
35
|
+
createdAt: now,
|
|
36
|
+
};
|
|
37
|
+
db.wallets.unshift(wallet);
|
|
38
|
+
if (initialDepositKrw > 0) {
|
|
39
|
+
const amount = Math.round(initialDepositKrw);
|
|
40
|
+
wallet.balanceKrw += amount;
|
|
41
|
+
db.transactions.unshift({
|
|
42
|
+
id: randomUUID(),
|
|
43
|
+
at: now,
|
|
44
|
+
rail: 'manual',
|
|
45
|
+
fromWalletId: null,
|
|
46
|
+
toWalletId: wallet.id,
|
|
47
|
+
amountKrw: amount,
|
|
48
|
+
memo: 'Initial deposit',
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
await writeDb(filePath, db);
|
|
52
|
+
return wallet;
|
|
53
|
+
}
|
|
54
|
+
export async function getBalance(filePath, walletId) {
|
|
55
|
+
const db = await readDb(filePath);
|
|
56
|
+
const w = db.wallets.find((x) => x.id === walletId);
|
|
57
|
+
return w ? w.balanceKrw : null;
|
|
58
|
+
}
|
|
59
|
+
export async function transfer(filePath, input) {
|
|
60
|
+
const db = await readDb(filePath);
|
|
61
|
+
const from = db.wallets.find((w) => w.id === input.fromWalletId);
|
|
62
|
+
const to = db.wallets.find((w) => w.id === input.toWalletId);
|
|
63
|
+
if (!from || !to)
|
|
64
|
+
return null;
|
|
65
|
+
const amount = Math.round(input.amountKrw);
|
|
66
|
+
if (amount <= 0)
|
|
67
|
+
return null;
|
|
68
|
+
if (from.balanceKrw < amount)
|
|
69
|
+
return null;
|
|
70
|
+
from.balanceKrw -= amount;
|
|
71
|
+
to.balanceKrw += amount;
|
|
72
|
+
const tx = {
|
|
73
|
+
id: randomUUID(),
|
|
74
|
+
at: new Date().toISOString(),
|
|
75
|
+
rail: input.rail,
|
|
76
|
+
fromWalletId: from.id,
|
|
77
|
+
toWalletId: to.id,
|
|
78
|
+
amountKrw: amount,
|
|
79
|
+
memo: input.memo.trim(),
|
|
80
|
+
};
|
|
81
|
+
db.transactions.unshift(tx);
|
|
82
|
+
await writeDb(filePath, db);
|
|
83
|
+
return { tx, fromBalanceKrw: from.balanceKrw, toBalanceKrw: to.balanceKrw };
|
|
84
|
+
}
|
|
85
|
+
export async function listTransactions(filePath, input) {
|
|
86
|
+
const db = await readDb(filePath);
|
|
87
|
+
const limit = Math.max(1, Math.min(200, Math.round(input.limit)));
|
|
88
|
+
const walletId = input.walletId?.trim();
|
|
89
|
+
if (!walletId)
|
|
90
|
+
return db.transactions.slice(0, limit);
|
|
91
|
+
return db.transactions
|
|
92
|
+
.filter((t) => t.fromWalletId === walletId || t.toWalletId === walletId)
|
|
93
|
+
.slice(0, limit);
|
|
94
|
+
}
|
|
95
|
+
export async function setBudget(filePath, dailyLimitKrw) {
|
|
96
|
+
const db = await readDb(filePath);
|
|
97
|
+
if (dailyLimitKrw === null) {
|
|
98
|
+
db.budget.dailyLimitKrw = null;
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
const limit = Math.round(dailyLimitKrw);
|
|
102
|
+
db.budget.dailyLimitKrw = limit > 0 ? limit : null;
|
|
103
|
+
}
|
|
104
|
+
await writeDb(filePath, db);
|
|
105
|
+
return { dailyLimitKrw: db.budget.dailyLimitKrw };
|
|
106
|
+
}
|
|
107
|
+
function emptyDb() {
|
|
108
|
+
return {
|
|
109
|
+
version: 1,
|
|
110
|
+
wallets: [],
|
|
111
|
+
transactions: [],
|
|
112
|
+
budget: { dailyLimitKrw: null },
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
function isLedgerDb(input) {
|
|
116
|
+
if (!input || typeof input !== 'object')
|
|
117
|
+
return false;
|
|
118
|
+
const s = input;
|
|
119
|
+
if (s.version !== 1)
|
|
120
|
+
return false;
|
|
121
|
+
if (!Array.isArray(s.wallets) || !Array.isArray(s.transactions))
|
|
122
|
+
return false;
|
|
123
|
+
if (!s.budget || typeof s.budget !== 'object')
|
|
124
|
+
return false;
|
|
125
|
+
const b = s.budget;
|
|
126
|
+
if (b.dailyLimitKrw !== null && typeof b.dailyLimitKrw !== 'number')
|
|
127
|
+
return false;
|
|
128
|
+
return true;
|
|
129
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "crossfin-mcp",
|
|
3
|
+
"private": false,
|
|
4
|
+
"version": "1.7.1",
|
|
5
|
+
"description": "CrossFin MCP server for service discovery and paid x402 API execution",
|
|
6
|
+
"bin": {
|
|
7
|
+
"crossfin-mcp": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/bubilife1202/crossfin.git",
|
|
16
|
+
"directory": "apps/mcp-server"
|
|
17
|
+
},
|
|
18
|
+
"homepage": "https://crossfin.dev",
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/bubilife1202/crossfin/issues"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"mcp",
|
|
24
|
+
"model-context-protocol",
|
|
25
|
+
"crossfin",
|
|
26
|
+
"x402",
|
|
27
|
+
"usdc",
|
|
28
|
+
"base"
|
|
29
|
+
],
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=18.17"
|
|
32
|
+
},
|
|
33
|
+
"license": "UNLICENSED",
|
|
34
|
+
"type": "module",
|
|
35
|
+
"scripts": {
|
|
36
|
+
"dev": "tsx src/index.ts",
|
|
37
|
+
"build": "tsc -p tsconfig.json",
|
|
38
|
+
"start": "node dist/index.js",
|
|
39
|
+
"prepublishOnly": "npm run build"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
43
|
+
"@x402/core": "^2.3.1",
|
|
44
|
+
"@x402/evm": "^2.3.1",
|
|
45
|
+
"@x402/fetch": "^2.3.0",
|
|
46
|
+
"viem": "^2.46.1",
|
|
47
|
+
"zod": "^4.1.11"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/node": "^24.10.1",
|
|
51
|
+
"tsx": "^4.20.5",
|
|
52
|
+
"typescript": "^5.9.3"
|
|
53
|
+
}
|
|
54
|
+
}
|