@xlmtools/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +99 -0
- package/dist/cli.js +214 -0
- package/dist/index.js +77 -0
- package/dist/lib/budget.js +54 -0
- package/dist/lib/cache.js +41 -0
- package/dist/lib/config.js +16 -0
- package/dist/lib/format.js +37 -0
- package/dist/lib/logger.js +2 -0
- package/dist/lib/wallet.js +106 -0
- package/dist/tools/budget.js +56 -0
- package/dist/tools/card.js +51 -0
- package/dist/tools/crypto.js +23 -0
- package/dist/tools/dex-candles.js +47 -0
- package/dist/tools/dex-orderbook.js +38 -0
- package/dist/tools/dex-trades.js +46 -0
- package/dist/tools/domain.js +22 -0
- package/dist/tools/image.js +42 -0
- package/dist/tools/oracle-price.js +36 -0
- package/dist/tools/reddit.js +40 -0
- package/dist/tools/research.js +39 -0
- package/dist/tools/scrape.js +32 -0
- package/dist/tools/screenshot.js +37 -0
- package/dist/tools/search.js +39 -0
- package/dist/tools/stellar-account.js +30 -0
- package/dist/tools/stellar-asset.js +31 -0
- package/dist/tools/stellar-pools.js +41 -0
- package/dist/tools/stocks.js +34 -0
- package/dist/tools/swap-quote.js +37 -0
- package/dist/tools/tools-list.js +16 -0
- package/dist/tools/wallet-tool.js +35 -0
- package/dist/tools/weather.js +22 -0
- package/dist/tools/youtube.js +42 -0
- package/package.json +28 -0
- package/src/cli.ts +240 -0
- package/src/index.ts +90 -0
- package/src/lib/budget.ts +78 -0
- package/src/lib/cache.ts +66 -0
- package/src/lib/config.ts +18 -0
- package/src/lib/format.ts +51 -0
- package/src/lib/logger.ts +6 -0
- package/src/lib/wallet.ts +143 -0
- package/src/tools/budget.ts +67 -0
- package/src/tools/crypto.ts +25 -0
- package/src/tools/dex-candles.ts +52 -0
- package/src/tools/dex-orderbook.ts +45 -0
- package/src/tools/dex-trades.ts +51 -0
- package/src/tools/domain.ts +24 -0
- package/src/tools/image.ts +49 -0
- package/src/tools/oracle-price.ts +42 -0
- package/src/tools/research.ts +47 -0
- package/src/tools/scrape.ts +41 -0
- package/src/tools/screenshot.ts +46 -0
- package/src/tools/search.ts +47 -0
- package/src/tools/stellar-account.ts +38 -0
- package/src/tools/stellar-asset.ts +39 -0
- package/src/tools/stellar-pools.ts +45 -0
- package/src/tools/stocks.ts +43 -0
- package/src/tools/swap-quote.ts +43 -0
- package/src/tools/tools-list.ts +22 -0
- package/src/tools/wallet-tool.ts +51 -0
- package/src/tools/weather.ts +24 -0
- package/src/tools/youtube.ts +49 -0
- package/tsconfig.json +13 -0
package/README.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# @xlmtools/cli
|
|
2
|
+
|
|
3
|
+
XLMTools CLI — the MCP server that runs locally on the user's machine. Handles tool registration, payment signing, budget tracking, and response caching.
|
|
4
|
+
|
|
5
|
+
## How it works
|
|
6
|
+
|
|
7
|
+
This package is an MCP stdio server. It's started automatically by Claude, Cursor, or any MCP-compatible host when a user calls a XLMTools tool.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
MCP Host (Claude, Cursor, Windsurf)
|
|
11
|
+
| stdio
|
|
12
|
+
v
|
|
13
|
+
@xlmtools/cli (this package)
|
|
14
|
+
| - 21 tools registered via @modelcontextprotocol/sdk
|
|
15
|
+
| - mppx polyfills fetch to auto-handle 402 payments
|
|
16
|
+
| - budget enforcement (withBudget)
|
|
17
|
+
| - response caching (withCache, 5-min TTL)
|
|
18
|
+
|
|
|
19
|
+
| HTTPS
|
|
20
|
+
v
|
|
21
|
+
@xlmtools/api (hosted API server)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Install (for users)
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
claude mcp add xlmtools npx @xlmtools/cli
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
On first run, the CLI:
|
|
31
|
+
1. Generates a Stellar keypair at `~/.xlmtools/config.json`
|
|
32
|
+
2. Funds the wallet with testnet XLM via friendbot (testnet only)
|
|
33
|
+
3. Adds a USDC trustline so the wallet can receive payments
|
|
34
|
+
|
|
35
|
+
## Development
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
# From the monorepo root
|
|
39
|
+
pnpm dev:cli
|
|
40
|
+
|
|
41
|
+
# Or directly
|
|
42
|
+
cd packages/cli
|
|
43
|
+
pnpm dev
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The dev script uses `tsx watch` for hot-reload during development.
|
|
47
|
+
|
|
48
|
+
## Build
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pnpm build
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Compiles TypeScript to `dist/`. The compiled entry point is `dist/index.js`.
|
|
55
|
+
|
|
56
|
+
## Architecture
|
|
57
|
+
|
|
58
|
+
### Tools (21 total)
|
|
59
|
+
|
|
60
|
+
**Paid** (7) — wrapped with `withCache` + `withBudget`:
|
|
61
|
+
search, research, youtube, screenshot, scrape, image, stocks
|
|
62
|
+
|
|
63
|
+
**Free** (14):
|
|
64
|
+
crypto, weather, domain, wallet, tools, budget, dex-orderbook, dex-candles, dex-trades, swap-quote, stellar-asset, stellar-account, stellar-pools, oracle-price
|
|
65
|
+
|
|
66
|
+
### Key modules
|
|
67
|
+
|
|
68
|
+
| File | Purpose |
|
|
69
|
+
| --- | --- |
|
|
70
|
+
| `src/index.ts` | Entry point, registers all 21 tools |
|
|
71
|
+
| `src/lib/wallet.ts` | Wallet creation, auto-funding on testnet |
|
|
72
|
+
| `src/lib/budget.ts` | Session budget state, `withBudget()` wrapper |
|
|
73
|
+
| `src/lib/cache.ts` | Response cache, `withCache()` wrapper |
|
|
74
|
+
| `src/lib/format.ts` | `ok()`, `okPaid()`, `err()` response formatters |
|
|
75
|
+
| `src/lib/config.ts` | Tool prices and free tool list |
|
|
76
|
+
| `src/lib/logger.ts` | pino logger (stderr) |
|
|
77
|
+
| `src/tools/*.ts` | One file per tool |
|
|
78
|
+
|
|
79
|
+
### Payment flow
|
|
80
|
+
|
|
81
|
+
1. Tool handler calls `fetch(apiUrl/toolname)` with params
|
|
82
|
+
2. API returns `402 Payment Required`
|
|
83
|
+
3. mppx (global fetch polyfill) intercepts the 402
|
|
84
|
+
4. Builds a Soroban SAC USDC transfer using the local Stellar keypair
|
|
85
|
+
5. Signs and retries the request with payment proof
|
|
86
|
+
6. API verifies, executes the tool, returns result with receipt
|
|
87
|
+
7. `okPaid()` strips the receipt and appends a human-readable payment footer
|
|
88
|
+
|
|
89
|
+
### Budget flow
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
withCache(tool, params, () =>
|
|
93
|
+
withBudget(tool, async () => {
|
|
94
|
+
// API call
|
|
95
|
+
})
|
|
96
|
+
)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Cache is checked first (hit = free). Budget is checked second (over limit = blocked). API call happens last. Only successful calls are charged and cached.
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Mppx } from "mppx/client";
|
|
3
|
+
import { stellar } from "@stellar/mpp/charge/client";
|
|
4
|
+
import { Horizon } from "@stellar/stellar-sdk";
|
|
5
|
+
import { loadOrCreateWallet, getKeypair } from "./lib/wallet.js";
|
|
6
|
+
import { TOOL_PRICES } from "./lib/config.js";
|
|
7
|
+
import { logger } from "./lib/logger.js";
|
|
8
|
+
// ── Arg parsing ──────────────────────────────────────────
|
|
9
|
+
function parseArgs(argv) {
|
|
10
|
+
const [tool, ...rest] = argv;
|
|
11
|
+
const positional = [];
|
|
12
|
+
const flags = {};
|
|
13
|
+
for (let i = 0; i < rest.length; i++) {
|
|
14
|
+
if (rest[i].startsWith("--") && i + 1 < rest.length) {
|
|
15
|
+
const key = rest[i].slice(2).replace(/-/g, "_");
|
|
16
|
+
flags[key] = rest[i + 1];
|
|
17
|
+
i++;
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
positional.push(rest[i]);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return { tool: tool ?? "", positional, flags };
|
|
24
|
+
}
|
|
25
|
+
// ── URL builder ──────────────────────────────────────────
|
|
26
|
+
function buildRequest(base, tool, pos, flags) {
|
|
27
|
+
const p = new URLSearchParams();
|
|
28
|
+
// Map each tool to its API endpoint + params
|
|
29
|
+
const toolMap = {
|
|
30
|
+
search: () => { p.set("q", pos[0] ?? ""); if (flags.count)
|
|
31
|
+
p.set("count", flags.count); return { path: "/search" }; },
|
|
32
|
+
research: () => { p.set("q", pos[0] ?? ""); if (flags.num_results)
|
|
33
|
+
p.set("num_results", flags.num_results); return { path: "/research" }; },
|
|
34
|
+
youtube: () => { if (flags.id)
|
|
35
|
+
p.set("id", flags.id);
|
|
36
|
+
else
|
|
37
|
+
p.set("q", pos[0] ?? flags.query ?? ""); return { path: "/youtube" }; },
|
|
38
|
+
screenshot: () => { p.set("url", pos[0] ?? ""); if (flags.format)
|
|
39
|
+
p.set("format", flags.format); return { path: "/screenshot" }; },
|
|
40
|
+
scrape: () => { p.set("url", pos[0] ?? ""); return { path: "/scrape" }; },
|
|
41
|
+
image: () => ({ path: "/image", method: "POST", body: JSON.stringify({ prompt: pos[0] ?? "", size: flags.size ?? "1024x1024" }) }),
|
|
42
|
+
stocks: () => { p.set("symbol", pos[0] ?? ""); return { path: "/stocks" }; },
|
|
43
|
+
crypto: () => { p.set("ids", pos[0] ?? ""); if (flags.vs_currency)
|
|
44
|
+
p.set("vs_currency", flags.vs_currency); return { path: "/crypto" }; },
|
|
45
|
+
weather: () => { p.set("location", pos[0] ?? ""); return { path: "/weather" }; },
|
|
46
|
+
domain: () => { p.set("name", pos[0] ?? ""); return { path: "/domain" }; },
|
|
47
|
+
"dex-orderbook": () => { p.set("pair", pos[0] ?? ""); if (flags.limit)
|
|
48
|
+
p.set("limit", flags.limit); return { path: "/dex-orderbook" }; },
|
|
49
|
+
"dex-candles": () => { p.set("pair", pos[0] ?? ""); if (flags.resolution)
|
|
50
|
+
p.set("resolution", flags.resolution); if (flags.limit)
|
|
51
|
+
p.set("limit", flags.limit); return { path: "/dex-candles" }; },
|
|
52
|
+
"dex-trades": () => { p.set("pair", pos[0] ?? ""); if (flags.limit)
|
|
53
|
+
p.set("limit", flags.limit); if (flags.trade_type)
|
|
54
|
+
p.set("trade_type", flags.trade_type); return { path: "/dex-trades" }; },
|
|
55
|
+
"swap-quote": () => { p.set("from", pos[0] ?? flags.from ?? ""); p.set("to", pos[1] ?? flags.to ?? ""); p.set("amount", pos[2] ?? flags.amount ?? ""); if (flags.mode)
|
|
56
|
+
p.set("mode", flags.mode); return { path: "/swap-quote" }; },
|
|
57
|
+
"stellar-asset": () => { p.set("asset", pos[0] ?? ""); return { path: "/stellar-asset" }; },
|
|
58
|
+
"stellar-account": () => { p.set("address", pos[0] ?? ""); return { path: "/stellar-account" }; },
|
|
59
|
+
"stellar-pools": () => { if (pos[0] || flags.asset)
|
|
60
|
+
p.set("asset", pos[0] ?? flags.asset ?? ""); if (flags.limit)
|
|
61
|
+
p.set("limit", flags.limit); return { path: "/stellar-pools" }; },
|
|
62
|
+
"oracle-price": () => { p.set("asset", pos[0] ?? ""); if (flags.feed)
|
|
63
|
+
p.set("feed", flags.feed); return { path: "/oracle-price" }; },
|
|
64
|
+
};
|
|
65
|
+
const builder = toolMap[tool];
|
|
66
|
+
if (!builder) {
|
|
67
|
+
process.stderr.write(`Unknown tool: ${tool}\nRun xlm --help for available tools.\n`);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
const { path, method, body } = builder();
|
|
71
|
+
const qs = p.toString();
|
|
72
|
+
const url = qs ? `${base}${path}?${qs}` : `${base}${path}`;
|
|
73
|
+
const init = { method: method ?? "GET" };
|
|
74
|
+
if (body) {
|
|
75
|
+
init.headers = { "Content-Type": "application/json" };
|
|
76
|
+
init.body = body;
|
|
77
|
+
}
|
|
78
|
+
return { url, init };
|
|
79
|
+
}
|
|
80
|
+
// ── Local tool handlers ──────────────────────────────────
|
|
81
|
+
async function handleWallet(publicKey) {
|
|
82
|
+
const server = new Horizon.Server("https://horizon-testnet.stellar.org");
|
|
83
|
+
try {
|
|
84
|
+
const account = await server.loadAccount(publicKey);
|
|
85
|
+
const xlm = account.balances.find((b) => b.asset_type === "native");
|
|
86
|
+
const usdc = account.balances.find((b) => "asset_code" in b && b.asset_code === "USDC");
|
|
87
|
+
return {
|
|
88
|
+
address: publicKey,
|
|
89
|
+
network: "stellar:testnet",
|
|
90
|
+
xlm_balance: xlm?.balance ?? "0",
|
|
91
|
+
usdc_balance: usdc && "balance" in usdc ? usdc.balance : "0",
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return {
|
|
96
|
+
address: publicKey,
|
|
97
|
+
network: "stellar:testnet",
|
|
98
|
+
xlm_balance: "0",
|
|
99
|
+
usdc_balance: "0",
|
|
100
|
+
note: "Account not funded yet",
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function handleTools() {
|
|
105
|
+
const paid = Object.entries(TOOL_PRICES).map(([name, price]) => ({
|
|
106
|
+
name,
|
|
107
|
+
price: `$${price}`,
|
|
108
|
+
}));
|
|
109
|
+
const free = [
|
|
110
|
+
"crypto", "weather", "domain", "wallet", "tools", "budget",
|
|
111
|
+
"dex-orderbook", "dex-candles", "dex-trades", "swap-quote",
|
|
112
|
+
"stellar-asset", "stellar-account", "stellar-pools", "oracle-price",
|
|
113
|
+
];
|
|
114
|
+
return { paid, free, network: "stellar:testnet", payment: "MPP / USDC" };
|
|
115
|
+
}
|
|
116
|
+
// ── Help text ────────────────────────────────────────────
|
|
117
|
+
const HELP = `XLMTools CLI — Stellar-native tools with pay-per-call
|
|
118
|
+
|
|
119
|
+
Usage: xlm <tool> [args] [--flag value]
|
|
120
|
+
|
|
121
|
+
Paid tools ($0.001-$0.04 USDC via Stellar MPP):
|
|
122
|
+
search <query> [--count N] Web search
|
|
123
|
+
research <query> [--num-results N] Deep research
|
|
124
|
+
youtube <query> | --id <id> YouTube search/lookup
|
|
125
|
+
screenshot <url> [--format png] Capture URL screenshot
|
|
126
|
+
scrape <url> Extract text from URL
|
|
127
|
+
image <prompt> [--size 1024x1024] AI image generation
|
|
128
|
+
stocks <symbol> Stock quotes
|
|
129
|
+
|
|
130
|
+
Free tools:
|
|
131
|
+
crypto <ids> [--vs-currency usd] Crypto prices
|
|
132
|
+
weather <location> Current weather
|
|
133
|
+
domain <name> Domain availability
|
|
134
|
+
dex-orderbook <pair> [--limit N] Stellar DEX orderbook
|
|
135
|
+
dex-candles <pair> OHLCV candlesticks
|
|
136
|
+
dex-trades <pair> Recent DEX trades
|
|
137
|
+
swap-quote <from> <to> <amount> DEX swap pathfinding
|
|
138
|
+
stellar-asset <asset> Asset info
|
|
139
|
+
stellar-account <address> Account lookup
|
|
140
|
+
stellar-pools [--asset X] Liquidity pools
|
|
141
|
+
oracle-price <asset> [--feed crypto] Oracle prices
|
|
142
|
+
wallet Your Stellar wallet
|
|
143
|
+
tools List all tools
|
|
144
|
+
|
|
145
|
+
Examples:
|
|
146
|
+
xlm search "Stellar MPP micropayments"
|
|
147
|
+
xlm crypto bitcoin,ethereum,stellar
|
|
148
|
+
xlm weather Lagos
|
|
149
|
+
xlm stocks AAPL
|
|
150
|
+
xlm dex-orderbook XLM/USDC --limit 5
|
|
151
|
+
xlm wallet
|
|
152
|
+
`;
|
|
153
|
+
// ── Main ─────────────────────────────────────────────────
|
|
154
|
+
async function main() {
|
|
155
|
+
const args = process.argv.slice(2);
|
|
156
|
+
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
|
|
157
|
+
process.stdout.write(HELP);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const { tool, positional, flags } = parseArgs(args);
|
|
161
|
+
// Init wallet + mppx (payment handling)
|
|
162
|
+
const config = loadOrCreateWallet();
|
|
163
|
+
const keypair = getKeypair(config);
|
|
164
|
+
Mppx.create({
|
|
165
|
+
methods: [
|
|
166
|
+
stellar.charge({
|
|
167
|
+
keypair,
|
|
168
|
+
mode: "pull",
|
|
169
|
+
onProgress(event) {
|
|
170
|
+
logger.debug({ eventType: event.type }, "MPP payment event");
|
|
171
|
+
},
|
|
172
|
+
}),
|
|
173
|
+
],
|
|
174
|
+
});
|
|
175
|
+
// ── Local tools (no API call needed) ──
|
|
176
|
+
if (tool === "wallet") {
|
|
177
|
+
const data = await handleWallet(config.stellarPublicKey);
|
|
178
|
+
console.log(JSON.stringify(data, null, 2));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (tool === "tools") {
|
|
182
|
+
console.log(JSON.stringify(handleTools(), null, 2));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
// ── API tools ──
|
|
186
|
+
const isPaid = tool in TOOL_PRICES;
|
|
187
|
+
const { url, init } = buildRequest(config.apiUrl, tool, positional, flags);
|
|
188
|
+
if (isPaid) {
|
|
189
|
+
process.stderr.write(` Tool: ${tool} · Cost: $${TOOL_PRICES[tool]} USDC\n`);
|
|
190
|
+
}
|
|
191
|
+
const res = await fetch(url, init);
|
|
192
|
+
if (!res.ok) {
|
|
193
|
+
const text = await res.text();
|
|
194
|
+
process.stderr.write(`Error ${res.status}: ${text}\n`);
|
|
195
|
+
process.exit(1);
|
|
196
|
+
}
|
|
197
|
+
const data = (await res.json());
|
|
198
|
+
// Print receipt footer for paid tools
|
|
199
|
+
if (isPaid && data.receipt) {
|
|
200
|
+
const { receipt, ...rest } = data;
|
|
201
|
+
const r = receipt;
|
|
202
|
+
console.log(JSON.stringify(rest, null, 2));
|
|
203
|
+
const hash = r.tx_hash.length > 16 ? r.tx_hash.slice(0, 16) + "..." : r.tx_hash;
|
|
204
|
+
const network = r.network === "stellar:testnet" ? "stellar testnet" : r.network;
|
|
205
|
+
process.stderr.write(`\n Payment: $${r.amount} ${r.currency} · tx/${hash} · ${network}\n`);
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
console.log(JSON.stringify(data, null, 2));
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
main().catch((e) => {
|
|
212
|
+
logger.error({ err: e }, "CLI fatal error");
|
|
213
|
+
process.exit(1);
|
|
214
|
+
});
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
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 { Mppx } from "mppx/client";
|
|
5
|
+
import { stellar } from "@stellar/mpp/charge/client";
|
|
6
|
+
import { loadOrCreateWallet, getKeypair } from "./lib/wallet.js";
|
|
7
|
+
import { logger } from "./lib/logger.js";
|
|
8
|
+
import { registerCryptoTool } from "./tools/crypto.js";
|
|
9
|
+
import { registerWeatherTool } from "./tools/weather.js";
|
|
10
|
+
import { registerDomainTool } from "./tools/domain.js";
|
|
11
|
+
import { registerToolsListTool } from "./tools/tools-list.js";
|
|
12
|
+
import { registerWalletTool } from "./tools/wallet-tool.js";
|
|
13
|
+
import { registerSearchTool } from "./tools/search.js";
|
|
14
|
+
import { registerResearchTool } from "./tools/research.js";
|
|
15
|
+
import { registerYoutubeTool } from "./tools/youtube.js";
|
|
16
|
+
import { registerScreenshotTool } from "./tools/screenshot.js";
|
|
17
|
+
import { registerScrapeTool } from "./tools/scrape.js";
|
|
18
|
+
import { registerImageTool } from "./tools/image.js";
|
|
19
|
+
import { registerStocksTool } from "./tools/stocks.js";
|
|
20
|
+
import { registerDexOrderbookTool } from "./tools/dex-orderbook.js";
|
|
21
|
+
import { registerDexCandlesTool } from "./tools/dex-candles.js";
|
|
22
|
+
import { registerDexTradesTool } from "./tools/dex-trades.js";
|
|
23
|
+
import { registerSwapQuoteTool } from "./tools/swap-quote.js";
|
|
24
|
+
import { registerStellarAssetTool } from "./tools/stellar-asset.js";
|
|
25
|
+
import { registerStellarAccountTool } from "./tools/stellar-account.js";
|
|
26
|
+
import { registerStellarPoolsTool } from "./tools/stellar-pools.js";
|
|
27
|
+
import { registerOraclePriceTool } from "./tools/oracle-price.js";
|
|
28
|
+
import { registerBudgetTool } from "./tools/budget.js";
|
|
29
|
+
const config = loadOrCreateWallet();
|
|
30
|
+
const keypair = getKeypair(config);
|
|
31
|
+
// Mppx polyfills global fetch to auto-handle 402 payments
|
|
32
|
+
Mppx.create({
|
|
33
|
+
methods: [
|
|
34
|
+
stellar.charge({
|
|
35
|
+
keypair,
|
|
36
|
+
mode: "pull",
|
|
37
|
+
onProgress(event) {
|
|
38
|
+
logger.debug({ eventType: event.type }, "MPP payment event");
|
|
39
|
+
},
|
|
40
|
+
}),
|
|
41
|
+
],
|
|
42
|
+
});
|
|
43
|
+
// { logging: {} } enables ctx.mcpReq.log() in tool handlers
|
|
44
|
+
const server = new McpServer({ name: "xlmtools", version: "0.1.0" }, { capabilities: { tools: {}, logging: {} } });
|
|
45
|
+
// Free tools
|
|
46
|
+
registerCryptoTool(server);
|
|
47
|
+
registerWeatherTool(server);
|
|
48
|
+
registerDomainTool(server);
|
|
49
|
+
registerToolsListTool(server);
|
|
50
|
+
registerWalletTool(server);
|
|
51
|
+
registerBudgetTool(server);
|
|
52
|
+
// Paid tools
|
|
53
|
+
registerSearchTool(server);
|
|
54
|
+
registerResearchTool(server);
|
|
55
|
+
registerYoutubeTool(server);
|
|
56
|
+
registerScreenshotTool(server);
|
|
57
|
+
registerScrapeTool(server);
|
|
58
|
+
registerImageTool(server);
|
|
59
|
+
registerStocksTool(server);
|
|
60
|
+
// Stellar-native tools (free)
|
|
61
|
+
registerDexOrderbookTool(server);
|
|
62
|
+
registerDexCandlesTool(server);
|
|
63
|
+
registerDexTradesTool(server);
|
|
64
|
+
registerSwapQuoteTool(server);
|
|
65
|
+
registerStellarAssetTool(server);
|
|
66
|
+
registerStellarAccountTool(server);
|
|
67
|
+
registerStellarPoolsTool(server);
|
|
68
|
+
registerOraclePriceTool(server);
|
|
69
|
+
async function main() {
|
|
70
|
+
const transport = new StdioServerTransport();
|
|
71
|
+
await server.connect(transport);
|
|
72
|
+
logger.info("XLMTools MCP server running");
|
|
73
|
+
}
|
|
74
|
+
main().catch((error) => {
|
|
75
|
+
logger.error({ err: error }, "Fatal error");
|
|
76
|
+
process.exit(1);
|
|
77
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { err } from "./format.js";
|
|
2
|
+
import { TOOL_PRICES } from "./config.js";
|
|
3
|
+
import { logger } from "./logger.js";
|
|
4
|
+
// Session-scoped budget state (resets on MCP server restart)
|
|
5
|
+
let maxBudget = null;
|
|
6
|
+
let totalSpent = 0;
|
|
7
|
+
export function setBudget(max) {
|
|
8
|
+
maxBudget = max;
|
|
9
|
+
logger.info({ max }, "session budget set");
|
|
10
|
+
}
|
|
11
|
+
export function clearBudget() {
|
|
12
|
+
maxBudget = null;
|
|
13
|
+
totalSpent = 0;
|
|
14
|
+
logger.info("session budget cleared");
|
|
15
|
+
}
|
|
16
|
+
export function getStatus() {
|
|
17
|
+
return {
|
|
18
|
+
max: maxBudget,
|
|
19
|
+
spent: Math.round(totalSpent * 1000) / 1000,
|
|
20
|
+
remaining: maxBudget !== null
|
|
21
|
+
? Math.round((maxBudget - totalSpent) * 1000) / 1000
|
|
22
|
+
: null,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function canSpend(amount) {
|
|
26
|
+
if (maxBudget === null)
|
|
27
|
+
return true;
|
|
28
|
+
// Round to 3 decimal places to avoid floating-point drift
|
|
29
|
+
const spent = Math.round(totalSpent * 1000) / 1000;
|
|
30
|
+
return spent + amount <= maxBudget;
|
|
31
|
+
}
|
|
32
|
+
function recordSpend(amount) {
|
|
33
|
+
totalSpent += amount;
|
|
34
|
+
logger.debug({ amount, totalSpent }, "spend recorded");
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Wrap a paid tool call with budget checking.
|
|
38
|
+
* If budget would be exceeded, returns an error without calling the API.
|
|
39
|
+
* On success, records the spend.
|
|
40
|
+
*/
|
|
41
|
+
export async function withBudget(toolName, fn) {
|
|
42
|
+
const price = parseFloat(TOOL_PRICES[toolName] ?? "0");
|
|
43
|
+
if (!canSpend(price)) {
|
|
44
|
+
const status = getStatus();
|
|
45
|
+
return err(`Budget limit reached. This call costs $${TOOL_PRICES[toolName]} ` +
|
|
46
|
+
`but only $${status.remaining?.toFixed(3)} remains. ` +
|
|
47
|
+
`Use the budget tool to check or adjust your limit.`);
|
|
48
|
+
}
|
|
49
|
+
const result = await fn();
|
|
50
|
+
if (!result.isError) {
|
|
51
|
+
recordSpend(price);
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { logger } from "./logger.js";
|
|
2
|
+
const TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
3
|
+
const store = new Map();
|
|
4
|
+
/**
|
|
5
|
+
* Wrap a tool call with response caching.
|
|
6
|
+
* Identical tool+params within the TTL return the cached result
|
|
7
|
+
* with no API call and no payment.
|
|
8
|
+
*/
|
|
9
|
+
export async function withCache(toolName, params, fn) {
|
|
10
|
+
const key = `${toolName}:${JSON.stringify(params)}`;
|
|
11
|
+
const cached = store.get(key);
|
|
12
|
+
if (cached && Date.now() < cached.expires) {
|
|
13
|
+
logger.debug({ toolName }, "cache hit");
|
|
14
|
+
const original = cached.result;
|
|
15
|
+
if (original.content[0]?.type === "text") {
|
|
16
|
+
return {
|
|
17
|
+
...original,
|
|
18
|
+
content: [
|
|
19
|
+
{
|
|
20
|
+
type: "text",
|
|
21
|
+
text: `[cached — no charge]\n\n${original.content[0].text}`,
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
return original;
|
|
27
|
+
}
|
|
28
|
+
const result = await fn();
|
|
29
|
+
if (!result.isError) {
|
|
30
|
+
store.set(key, { result, expires: Date.now() + TTL_MS });
|
|
31
|
+
}
|
|
32
|
+
// Evict expired entries periodically
|
|
33
|
+
if (store.size > 50) {
|
|
34
|
+
const now = Date.now();
|
|
35
|
+
for (const [k, v] of store) {
|
|
36
|
+
if (now >= v.expires)
|
|
37
|
+
store.delete(k);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const TOOL_PRICES = {
|
|
2
|
+
search: "0.003",
|
|
3
|
+
research: "0.010",
|
|
4
|
+
youtube: "0.002",
|
|
5
|
+
screenshot: "0.010",
|
|
6
|
+
scrape: "0.002",
|
|
7
|
+
image: "0.040",
|
|
8
|
+
stocks: "0.001",
|
|
9
|
+
};
|
|
10
|
+
export const FREE_TOOLS = new Set([
|
|
11
|
+
"crypto", "weather", "domain", "wallet", "tools",
|
|
12
|
+
// Stellar-native tools (free — Horizon + StellarExpert + Reflector are public APIs)
|
|
13
|
+
"dex-orderbook", "dex-candles", "dex-trades",
|
|
14
|
+
"swap-quote", "stellar-asset", "stellar-account",
|
|
15
|
+
"stellar-pools", "oracle-price",
|
|
16
|
+
]);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
function formatReceipt(receipt) {
|
|
2
|
+
const hashShort = receipt.tx_hash.length > 12
|
|
3
|
+
? receipt.tx_hash.slice(0, 12) + "..."
|
|
4
|
+
: receipt.tx_hash;
|
|
5
|
+
const network = receipt.network === "stellar:testnet" ? "stellar testnet" : receipt.network;
|
|
6
|
+
return `\n---\nPayment: $${receipt.amount} ${receipt.currency} \u00b7 tx/${hashShort} \u00b7 ${network}`;
|
|
7
|
+
}
|
|
8
|
+
export function ok(data) {
|
|
9
|
+
return {
|
|
10
|
+
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
11
|
+
isError: false,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Format a paid tool response, appending a receipt line if present.
|
|
16
|
+
* Strips the receipt from the data to avoid cluttering the JSON output,
|
|
17
|
+
* then appends a human-readable payment line.
|
|
18
|
+
*/
|
|
19
|
+
export function okPaid(data) {
|
|
20
|
+
const receipt = data.receipt;
|
|
21
|
+
if (!receipt) {
|
|
22
|
+
return ok(data);
|
|
23
|
+
}
|
|
24
|
+
// Strip receipt from the JSON data — show it as a footer instead
|
|
25
|
+
const { receipt: _, ...rest } = data;
|
|
26
|
+
const text = JSON.stringify(rest, null, 2) + formatReceipt(receipt);
|
|
27
|
+
return {
|
|
28
|
+
content: [{ type: "text", text }],
|
|
29
|
+
isError: false,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export function err(message) {
|
|
33
|
+
return {
|
|
34
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
35
|
+
isError: true,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { Keypair, Horizon, TransactionBuilder, Networks, Operation, Asset, } from "@stellar/stellar-sdk";
|
|
2
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { logger } from "./logger.js";
|
|
6
|
+
const CONFIG_DIR = join(homedir(), ".xlmtools");
|
|
7
|
+
const CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
8
|
+
// Circle USDC issuer on Stellar testnet
|
|
9
|
+
const USDC_ISSUER = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
|
|
10
|
+
const HORIZON_TESTNET = "https://horizon-testnet.stellar.org";
|
|
11
|
+
/**
|
|
12
|
+
* Auto-fund a new wallet on Stellar TESTNET only.
|
|
13
|
+
* This uses the public friendbot (free testnet XLM) and adds a USDC
|
|
14
|
+
* trustline so the account can receive payments. This function must
|
|
15
|
+
* never be called on mainnet — friendbot does not exist there.
|
|
16
|
+
*/
|
|
17
|
+
async function fundTestnetWallet(keypair) {
|
|
18
|
+
// Safety: only run on testnet
|
|
19
|
+
const network = process.env.STELLAR_NETWORK ?? "testnet";
|
|
20
|
+
if (network !== "testnet") {
|
|
21
|
+
logger.info({ network }, "Skipping auto-fund — not on testnet");
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
const publicKey = keypair.publicKey();
|
|
25
|
+
// Step 1: Fund with friendbot
|
|
26
|
+
process.stderr.write(" Funding wallet with testnet XLM...");
|
|
27
|
+
try {
|
|
28
|
+
const res = await fetch(`https://friendbot.stellar.org?addr=${publicKey}`);
|
|
29
|
+
if (!res.ok) {
|
|
30
|
+
process.stderr.write(" failed\n");
|
|
31
|
+
logger.warn({ status: res.status }, "Friendbot funding failed");
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
process.stderr.write(" done\n");
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
process.stderr.write(" failed (network error)\n");
|
|
38
|
+
logger.warn({ err: e }, "Friendbot request failed");
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
// Step 2: Add USDC trustline
|
|
42
|
+
process.stderr.write(" Adding USDC trustline...");
|
|
43
|
+
try {
|
|
44
|
+
const server = new Horizon.Server(HORIZON_TESTNET);
|
|
45
|
+
const account = await server.loadAccount(publicKey);
|
|
46
|
+
const usdcAsset = new Asset("USDC", USDC_ISSUER);
|
|
47
|
+
const tx = new TransactionBuilder(account, {
|
|
48
|
+
fee: "100",
|
|
49
|
+
networkPassphrase: Networks.TESTNET,
|
|
50
|
+
})
|
|
51
|
+
.addOperation(Operation.changeTrust({ asset: usdcAsset }))
|
|
52
|
+
.setTimeout(30)
|
|
53
|
+
.build();
|
|
54
|
+
tx.sign(keypair);
|
|
55
|
+
await server.submitTransaction(tx);
|
|
56
|
+
process.stderr.write(" done\n");
|
|
57
|
+
}
|
|
58
|
+
catch (e) {
|
|
59
|
+
process.stderr.write(" failed\n");
|
|
60
|
+
logger.warn({ err: e }, "USDC trustline failed");
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
export function loadOrCreateWallet() {
|
|
66
|
+
if (existsSync(CONFIG_PATH)) {
|
|
67
|
+
return JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
|
|
68
|
+
}
|
|
69
|
+
const keypair = Keypair.random();
|
|
70
|
+
const config = {
|
|
71
|
+
stellarPrivateKey: keypair.secret(),
|
|
72
|
+
stellarPublicKey: keypair.publicKey(),
|
|
73
|
+
apiUrl: process.env.XLMTools_API_URL ?? "http://localhost:3000",
|
|
74
|
+
};
|
|
75
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
76
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
77
|
+
process.stderr.write("\n" +
|
|
78
|
+
"XLMTools — First Run Setup (Stellar Testnet)\n" +
|
|
79
|
+
"─".repeat(44) + "\n\n" +
|
|
80
|
+
` Wallet: ${config.stellarPublicKey}\n` +
|
|
81
|
+
` Network: Stellar Testnet\n\n`);
|
|
82
|
+
// Auto-fund on testnet (non-blocking — don't break startup if it fails)
|
|
83
|
+
fundTestnetWallet(keypair)
|
|
84
|
+
.then((funded) => {
|
|
85
|
+
if (funded) {
|
|
86
|
+
process.stderr.write("\n Wallet funded and ready.\n" +
|
|
87
|
+
" Get testnet USDC: https://faucet.circle.com\n" +
|
|
88
|
+
" (paste your wallet address above)\n\n" +
|
|
89
|
+
"─".repeat(40) + "\n\n");
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
process.stderr.write("\n Auto-funding failed. Fund manually:\n" +
|
|
93
|
+
" 1. https://lab.stellar.org/account/fund (testnet XLM)\n" +
|
|
94
|
+
" 2. https://faucet.circle.com (testnet USDC)\n\n" +
|
|
95
|
+
"─".repeat(40) + "\n\n");
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
.catch(() => {
|
|
99
|
+
// Silently handle — wallet is still created, just unfunded
|
|
100
|
+
});
|
|
101
|
+
logger.info({ publicKey: config.stellarPublicKey }, "New Stellar wallet generated");
|
|
102
|
+
return config;
|
|
103
|
+
}
|
|
104
|
+
export function getKeypair(config) {
|
|
105
|
+
return Keypair.fromSecret(config.stellarPrivateKey);
|
|
106
|
+
}
|