@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.
Files changed (63) hide show
  1. package/README.md +99 -0
  2. package/dist/cli.js +214 -0
  3. package/dist/index.js +77 -0
  4. package/dist/lib/budget.js +54 -0
  5. package/dist/lib/cache.js +41 -0
  6. package/dist/lib/config.js +16 -0
  7. package/dist/lib/format.js +37 -0
  8. package/dist/lib/logger.js +2 -0
  9. package/dist/lib/wallet.js +106 -0
  10. package/dist/tools/budget.js +56 -0
  11. package/dist/tools/card.js +51 -0
  12. package/dist/tools/crypto.js +23 -0
  13. package/dist/tools/dex-candles.js +47 -0
  14. package/dist/tools/dex-orderbook.js +38 -0
  15. package/dist/tools/dex-trades.js +46 -0
  16. package/dist/tools/domain.js +22 -0
  17. package/dist/tools/image.js +42 -0
  18. package/dist/tools/oracle-price.js +36 -0
  19. package/dist/tools/reddit.js +40 -0
  20. package/dist/tools/research.js +39 -0
  21. package/dist/tools/scrape.js +32 -0
  22. package/dist/tools/screenshot.js +37 -0
  23. package/dist/tools/search.js +39 -0
  24. package/dist/tools/stellar-account.js +30 -0
  25. package/dist/tools/stellar-asset.js +31 -0
  26. package/dist/tools/stellar-pools.js +41 -0
  27. package/dist/tools/stocks.js +34 -0
  28. package/dist/tools/swap-quote.js +37 -0
  29. package/dist/tools/tools-list.js +16 -0
  30. package/dist/tools/wallet-tool.js +35 -0
  31. package/dist/tools/weather.js +22 -0
  32. package/dist/tools/youtube.js +42 -0
  33. package/package.json +28 -0
  34. package/src/cli.ts +240 -0
  35. package/src/index.ts +90 -0
  36. package/src/lib/budget.ts +78 -0
  37. package/src/lib/cache.ts +66 -0
  38. package/src/lib/config.ts +18 -0
  39. package/src/lib/format.ts +51 -0
  40. package/src/lib/logger.ts +6 -0
  41. package/src/lib/wallet.ts +143 -0
  42. package/src/tools/budget.ts +67 -0
  43. package/src/tools/crypto.ts +25 -0
  44. package/src/tools/dex-candles.ts +52 -0
  45. package/src/tools/dex-orderbook.ts +45 -0
  46. package/src/tools/dex-trades.ts +51 -0
  47. package/src/tools/domain.ts +24 -0
  48. package/src/tools/image.ts +49 -0
  49. package/src/tools/oracle-price.ts +42 -0
  50. package/src/tools/research.ts +47 -0
  51. package/src/tools/scrape.ts +41 -0
  52. package/src/tools/screenshot.ts +46 -0
  53. package/src/tools/search.ts +47 -0
  54. package/src/tools/stellar-account.ts +38 -0
  55. package/src/tools/stellar-asset.ts +39 -0
  56. package/src/tools/stellar-pools.ts +45 -0
  57. package/src/tools/stocks.ts +43 -0
  58. package/src/tools/swap-quote.ts +43 -0
  59. package/src/tools/tools-list.ts +22 -0
  60. package/src/tools/wallet-tool.ts +51 -0
  61. package/src/tools/weather.ts +24 -0
  62. package/src/tools/youtube.ts +49 -0
  63. 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,2 @@
1
+ import pino from "pino";
2
+ export const logger = pino({ name: "xlmtools", level: process.env.LOG_LEVEL ?? "info" }, pino.destination(2));
@@ -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
+ }