data-aggregator-mcp 1.0.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.

Potentially problematic release.


This version of data-aggregator-mcp might be problematic. Click here for more details.

Files changed (58) hide show
  1. package/README.md +336 -0
  2. package/dist/index.d.ts +14 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +333 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/tools/exchange.d.ts +15 -0
  7. package/dist/tools/exchange.d.ts.map +1 -0
  8. package/dist/tools/exchange.js +195 -0
  9. package/dist/tools/exchange.js.map +1 -0
  10. package/dist/tools/news.d.ts +20 -0
  11. package/dist/tools/news.d.ts.map +1 -0
  12. package/dist/tools/news.js +175 -0
  13. package/dist/tools/news.js.map +1 -0
  14. package/dist/tools/public-data.d.ts +24 -0
  15. package/dist/tools/public-data.d.ts.map +1 -0
  16. package/dist/tools/public-data.js +262 -0
  17. package/dist/tools/public-data.js.map +1 -0
  18. package/dist/tools/scraper.d.ts +19 -0
  19. package/dist/tools/scraper.d.ts.map +1 -0
  20. package/dist/tools/scraper.js +185 -0
  21. package/dist/tools/scraper.js.map +1 -0
  22. package/dist/tools/stocks.d.ts +14 -0
  23. package/dist/tools/stocks.d.ts.map +1 -0
  24. package/dist/tools/stocks.js +172 -0
  25. package/dist/tools/stocks.js.map +1 -0
  26. package/dist/tools/weather.d.ts +15 -0
  27. package/dist/tools/weather.d.ts.map +1 -0
  28. package/dist/tools/weather.js +172 -0
  29. package/dist/tools/weather.js.map +1 -0
  30. package/dist/types.d.ts +160 -0
  31. package/dist/types.d.ts.map +1 -0
  32. package/dist/types.js +3 -0
  33. package/dist/types.js.map +1 -0
  34. package/dist/utils/cache.d.ts +45 -0
  35. package/dist/utils/cache.d.ts.map +1 -0
  36. package/dist/utils/cache.js +88 -0
  37. package/dist/utils/cache.js.map +1 -0
  38. package/dist/utils/http.d.ts +39 -0
  39. package/dist/utils/http.d.ts.map +1 -0
  40. package/dist/utils/http.js +103 -0
  41. package/dist/utils/http.js.map +1 -0
  42. package/dist/utils/rate-limiter.d.ts +34 -0
  43. package/dist/utils/rate-limiter.d.ts.map +1 -0
  44. package/dist/utils/rate-limiter.js +95 -0
  45. package/dist/utils/rate-limiter.js.map +1 -0
  46. package/package.json +42 -0
  47. package/src/index.ts +461 -0
  48. package/src/tools/exchange.ts +241 -0
  49. package/src/tools/news.ts +238 -0
  50. package/src/tools/public-data.ts +325 -0
  51. package/src/tools/scraper.ts +217 -0
  52. package/src/tools/stocks.ts +205 -0
  53. package/src/tools/weather.ts +216 -0
  54. package/src/types.ts +184 -0
  55. package/src/utils/cache.ts +103 -0
  56. package/src/utils/http.ts +156 -0
  57. package/src/utils/rate-limiter.ts +114 -0
  58. package/tsconfig.json +19 -0
package/src/index.ts ADDED
@@ -0,0 +1,461 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Data Aggregator MCP Server
5
+ *
6
+ * A unified data server that replaces 5-10 separate MCP servers.
7
+ * Provides stocks, weather, news, web scraping, exchange rates,
8
+ * and public data queries through a single installation.
9
+ *
10
+ * Transports:
11
+ * - stdio (default): for Claude Desktop, CLI tools
12
+ * - HTTP/SSE (--sse or --http flag): for remote deployments
13
+ */
14
+
15
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
16
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
17
+ import { z } from "zod";
18
+
19
+ import { queryStocks } from "./tools/stocks.js";
20
+ import { fetchWebpage } from "./tools/scraper.js";
21
+ import { searchNews } from "./tools/news.js";
22
+ import { queryWeather } from "./tools/weather.js";
23
+ import { queryExchangeRates } from "./tools/exchange.js";
24
+ import { queryPublicData } from "./tools/public-data.js";
25
+ import { cache } from "./utils/cache.js";
26
+
27
+ // ─── Server Setup ──────────────────────────────────────────────────────────
28
+
29
+ const server = new McpServer(
30
+ {
31
+ name: "data-aggregator",
32
+ version: "1.0.0",
33
+ },
34
+ {
35
+ capabilities: {
36
+ logging: {},
37
+ },
38
+ }
39
+ );
40
+
41
+ // ─── Tool Registration ────────────────────────────────────────────────────
42
+
43
+ // 1. query_stocks
44
+ server.tool(
45
+ "query_stocks",
46
+ "Fetch real-time stock and cryptocurrency market data. Supports major stocks (AAPL, MSFT, GOOGL) and crypto (BTC, ETH, SOL). Returns price, daily change, volume, and market cap. Data from Yahoo Finance (stocks) and CoinGecko (crypto).",
47
+ {
48
+ symbol: z
49
+ .string()
50
+ .describe(
51
+ "Ticker symbol (e.g., AAPL, MSFT, GOOGL for stocks; BTC, ETH, SOL for crypto)"
52
+ ),
53
+ type: z
54
+ .enum(["stock", "crypto", "auto"])
55
+ .optional()
56
+ .default("auto")
57
+ .describe(
58
+ 'Asset type. "auto" detects based on symbol. Use "stock" or "crypto" to force.'
59
+ ),
60
+ },
61
+ async (args) => queryStocks(args)
62
+ );
63
+
64
+ // 2. fetch_webpage
65
+ server.tool(
66
+ "fetch_webpage",
67
+ "Scrape and extract structured data from any web page. Returns title, meta description, headings, main text content, and links. Optionally extract specific content using a CSS selector (#id, .class, or tag name).",
68
+ {
69
+ url: z.string().url().describe("The URL to scrape"),
70
+ selector: z
71
+ .string()
72
+ .optional()
73
+ .describe(
74
+ 'Optional CSS-like selector to extract specific content (e.g., "#main", ".article-body", "article")'
75
+ ),
76
+ },
77
+ async (args) => fetchWebpage(args)
78
+ );
79
+
80
+ // 3. search_news
81
+ server.tool(
82
+ "search_news",
83
+ "Search and aggregate news articles from multiple sources. Uses NewsAPI.org (if API key configured) or Google News RSS (free, no key). Filter by keyword, category, source, date range, and language.",
84
+ {
85
+ query: z
86
+ .string()
87
+ .optional()
88
+ .describe("Search keyword or phrase (e.g., 'artificial intelligence', 'climate change')"),
89
+ category: z
90
+ .enum([
91
+ "business",
92
+ "entertainment",
93
+ "general",
94
+ "health",
95
+ "science",
96
+ "sports",
97
+ "technology",
98
+ ])
99
+ .optional()
100
+ .describe("News category (only with NewsAPI key, ignored with Google News RSS)"),
101
+ source: z
102
+ .string()
103
+ .optional()
104
+ .describe('Specific news source ID (e.g., "bbc-news", "cnn", "the-verge")'),
105
+ language: z
106
+ .string()
107
+ .optional()
108
+ .default("en")
109
+ .describe("Language code (default: en)"),
110
+ from: z
111
+ .string()
112
+ .optional()
113
+ .describe("Start date for articles (YYYY-MM-DD format, NewsAPI only)"),
114
+ to: z
115
+ .string()
116
+ .optional()
117
+ .describe("End date for articles (YYYY-MM-DD format, NewsAPI only)"),
118
+ maxResults: z
119
+ .number()
120
+ .int()
121
+ .min(1)
122
+ .max(100)
123
+ .optional()
124
+ .default(10)
125
+ .describe("Maximum number of articles to return (1-100, default: 10)"),
126
+ },
127
+ async (args) => searchNews(args)
128
+ );
129
+
130
+ // 4. query_weather
131
+ server.tool(
132
+ "query_weather",
133
+ "Get current weather conditions and 7-day forecast. Uses Open-Meteo API (free, no API key required). Accepts city name or coordinates. Returns temperature, humidity, wind, precipitation, and daily forecasts.",
134
+ {
135
+ city: z
136
+ .string()
137
+ .optional()
138
+ .describe('City name (e.g., "London", "New York", "Tokyo")'),
139
+ latitude: z
140
+ .number()
141
+ .min(-90)
142
+ .max(90)
143
+ .optional()
144
+ .describe("Latitude (-90 to 90). Use with longitude as alternative to city name."),
145
+ longitude: z
146
+ .number()
147
+ .min(-180)
148
+ .max(180)
149
+ .optional()
150
+ .describe("Longitude (-180 to 180). Use with latitude as alternative to city name."),
151
+ units: z
152
+ .enum(["celsius", "fahrenheit"])
153
+ .optional()
154
+ .default("celsius")
155
+ .describe("Temperature units (default: celsius)"),
156
+ },
157
+ async (args) => queryWeather(args)
158
+ );
159
+
160
+ // 5. query_exchange_rates
161
+ server.tool(
162
+ "query_exchange_rates",
163
+ "Get currency exchange rates and perform conversions. Supports all major fiat currencies (USD, EUR, GBP, JPY, etc.) and popular cryptocurrencies (BTC, ETH, SOL, etc.). Can fetch historical rates by date.",
164
+ {
165
+ base: z
166
+ .string()
167
+ .describe(
168
+ 'Base currency code (e.g., "USD", "EUR", "BTC")'
169
+ ),
170
+ target: z
171
+ .string()
172
+ .describe(
173
+ 'Target currency code (e.g., "GBP", "JPY", "ETH")'
174
+ ),
175
+ amount: z
176
+ .number()
177
+ .positive()
178
+ .optional()
179
+ .default(1)
180
+ .describe("Amount to convert (default: 1)"),
181
+ date: z
182
+ .string()
183
+ .optional()
184
+ .describe(
185
+ "Historical date in YYYY-MM-DD format (fiat only). Omit for current rates."
186
+ ),
187
+ },
188
+ async (args) => queryExchangeRates(args)
189
+ );
190
+
191
+ // 6. query_public_data
192
+ server.tool(
193
+ "query_public_data",
194
+ "Access miscellaneous public data APIs: Wikipedia summaries, IP geolocation, DNS lookups, and URL expansion. Select the sub-command and provide the relevant parameters.",
195
+ {
196
+ command: z
197
+ .enum(["wikipedia", "ip_geolocation", "dns_lookup", "expand_url"])
198
+ .describe(
199
+ "Sub-command to run: wikipedia (article summary), ip_geolocation (IP location), dns_lookup (DNS records), expand_url (follow redirects)"
200
+ ),
201
+ query: z
202
+ .string()
203
+ .optional()
204
+ .describe('Search term for Wikipedia (e.g., "Theory of relativity")'),
205
+ ip: z
206
+ .string()
207
+ .optional()
208
+ .describe(
209
+ 'IP address for geolocation (e.g., "8.8.8.8"). Omit to look up your own IP.'
210
+ ),
211
+ domain: z
212
+ .string()
213
+ .optional()
214
+ .describe('Domain for DNS lookup (e.g., "example.com")'),
215
+ recordType: z
216
+ .enum(["A", "AAAA", "MX", "TXT", "NS", "CNAME", "SOA"])
217
+ .optional()
218
+ .default("A")
219
+ .describe("DNS record type (default: A)"),
220
+ url: z
221
+ .string()
222
+ .optional()
223
+ .describe('Shortened URL to expand (e.g., "https://bit.ly/xyz")'),
224
+ language: z
225
+ .string()
226
+ .optional()
227
+ .default("en")
228
+ .describe("Language code for Wikipedia (default: en)"),
229
+ },
230
+ async (args) => queryPublicData(args)
231
+ );
232
+
233
+ // ─── Transport Selection & Startup ─────────────────────────────────────────
234
+
235
+ async function main(): Promise<void> {
236
+ const args = process.argv.slice(2);
237
+ const useHttp = args.includes("--sse") || args.includes("--http");
238
+
239
+ if (useHttp) {
240
+ // HTTP / SSE transport for remote deployments
241
+ const portArg = args.find((a) => a.startsWith("--port="));
242
+ const port = portArg ? parseInt(portArg.split("=")[1]!, 10) : 3000;
243
+
244
+ try {
245
+ const { StreamableHTTPServerTransport } = await import(
246
+ "@modelcontextprotocol/sdk/server/streamableHttp.js"
247
+ );
248
+ const { createServer } = await import("node:http");
249
+ const { randomUUID } = await import("node:crypto");
250
+
251
+ const transports: Record<string, InstanceType<typeof StreamableHTTPServerTransport>> = {};
252
+
253
+ const httpServer = createServer(async (req, res) => {
254
+ // CORS headers
255
+ res.setHeader("Access-Control-Allow-Origin", "*");
256
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
257
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, mcp-session-id");
258
+
259
+ if (req.method === "OPTIONS") {
260
+ res.writeHead(204);
261
+ res.end();
262
+ return;
263
+ }
264
+
265
+ // Health check endpoint
266
+ if (req.url === "/health") {
267
+ res.writeHead(200, { "Content-Type": "application/json" });
268
+ res.end(JSON.stringify({ status: "ok", server: "data-aggregator", version: "1.0.0" }));
269
+ return;
270
+ }
271
+
272
+ if (req.url !== "/mcp") {
273
+ res.writeHead(404);
274
+ res.end("Not found");
275
+ return;
276
+ }
277
+
278
+ const sessionId = req.headers["mcp-session-id"] as string | undefined;
279
+
280
+ // Parse body for POST requests
281
+ let body: any = undefined;
282
+ if (req.method === "POST") {
283
+ const chunks: Buffer[] = [];
284
+ for await (const chunk of req) {
285
+ chunks.push(chunk as Buffer);
286
+ }
287
+ try {
288
+ body = JSON.parse(Buffer.concat(chunks).toString());
289
+ } catch {
290
+ res.writeHead(400, { "Content-Type": "application/json" });
291
+ res.end(JSON.stringify({ error: "Invalid JSON" }));
292
+ return;
293
+ }
294
+ }
295
+
296
+ if (req.method === "GET" && sessionId && transports[sessionId]) {
297
+ await transports[sessionId]!.handleRequest(req, res);
298
+ return;
299
+ }
300
+
301
+ if (req.method === "POST" && sessionId && transports[sessionId]) {
302
+ await transports[sessionId]!.handleRequest(req, res, body);
303
+ return;
304
+ }
305
+
306
+ if (req.method === "POST" && !sessionId) {
307
+ // New session
308
+ const transport = new StreamableHTTPServerTransport({
309
+ sessionIdGenerator: () => randomUUID(),
310
+ onsessioninitialized: (id: string) => {
311
+ transports[id] = transport;
312
+ },
313
+ });
314
+
315
+ transport.onclose = () => {
316
+ if (transport.sessionId) {
317
+ delete transports[transport.sessionId];
318
+ }
319
+ };
320
+
321
+ const sessionServer = new McpServer(
322
+ { name: "data-aggregator", version: "1.0.0" },
323
+ { capabilities: { logging: {} } }
324
+ );
325
+
326
+ // Re-register tools for this session's server
327
+ registerTools(sessionServer);
328
+
329
+ await sessionServer.connect(transport);
330
+ await transport.handleRequest(req, res, body);
331
+ return;
332
+ }
333
+
334
+ res.writeHead(400, { "Content-Type": "application/json" });
335
+ res.end(
336
+ JSON.stringify({
337
+ jsonrpc: "2.0",
338
+ error: { code: -32000, message: "Invalid request or session" },
339
+ id: null,
340
+ })
341
+ );
342
+ });
343
+
344
+ httpServer.listen(port, "0.0.0.0", () => {
345
+ console.error(`Data Aggregator MCP server (HTTP) listening on http://0.0.0.0:${port}/mcp`);
346
+ console.error(`Health check: http://0.0.0.0:${port}/health`);
347
+ });
348
+ } catch (err) {
349
+ console.error(
350
+ "Failed to start HTTP transport. Make sure @modelcontextprotocol/node is installed.",
351
+ err
352
+ );
353
+ process.exit(1);
354
+ }
355
+ } else {
356
+ // stdio transport (default) for Claude Desktop, CLI
357
+ const transport = new StdioServerTransport();
358
+ await server.connect(transport);
359
+ console.error("Data Aggregator MCP server started (stdio transport)");
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Register all tools on a given McpServer instance.
365
+ * Used for per-session servers in HTTP mode.
366
+ */
367
+ function registerTools(s: McpServer): void {
368
+ s.tool(
369
+ "query_stocks",
370
+ "Fetch real-time stock and cryptocurrency market data.",
371
+ {
372
+ symbol: z.string().describe("Ticker symbol (e.g., AAPL, BTC)"),
373
+ type: z.enum(["stock", "crypto", "auto"]).optional().default("auto")
374
+ .describe('Asset type. "auto" detects based on symbol.'),
375
+ },
376
+ async (args) => queryStocks(args)
377
+ );
378
+
379
+ s.tool(
380
+ "fetch_webpage",
381
+ "Scrape and extract structured data from any web page.",
382
+ {
383
+ url: z.string().url().describe("The URL to scrape"),
384
+ selector: z.string().optional()
385
+ .describe("Optional CSS-like selector to extract specific content"),
386
+ },
387
+ async (args) => fetchWebpage(args)
388
+ );
389
+
390
+ s.tool(
391
+ "search_news",
392
+ "Search and aggregate news articles from multiple sources.",
393
+ {
394
+ query: z.string().optional().describe("Search keyword or phrase"),
395
+ category: z.enum(["business", "entertainment", "general", "health", "science", "sports", "technology"]).optional(),
396
+ source: z.string().optional().describe("Specific news source ID"),
397
+ language: z.string().optional().default("en"),
398
+ from: z.string().optional().describe("Start date (YYYY-MM-DD)"),
399
+ to: z.string().optional().describe("End date (YYYY-MM-DD)"),
400
+ maxResults: z.number().int().min(1).max(100).optional().default(10),
401
+ },
402
+ async (args) => searchNews(args)
403
+ );
404
+
405
+ s.tool(
406
+ "query_weather",
407
+ "Get current weather conditions and 7-day forecast via Open-Meteo.",
408
+ {
409
+ city: z.string().optional().describe("City name"),
410
+ latitude: z.number().min(-90).max(90).optional(),
411
+ longitude: z.number().min(-180).max(180).optional(),
412
+ units: z.enum(["celsius", "fahrenheit"]).optional().default("celsius"),
413
+ },
414
+ async (args) => queryWeather(args)
415
+ );
416
+
417
+ s.tool(
418
+ "query_exchange_rates",
419
+ "Get currency exchange rates and perform conversions.",
420
+ {
421
+ base: z.string().describe("Base currency code"),
422
+ target: z.string().describe("Target currency code"),
423
+ amount: z.number().positive().optional().default(1),
424
+ date: z.string().optional().describe("Historical date (YYYY-MM-DD)"),
425
+ },
426
+ async (args) => queryExchangeRates(args)
427
+ );
428
+
429
+ s.tool(
430
+ "query_public_data",
431
+ "Access Wikipedia, IP geolocation, DNS lookup, and URL expansion.",
432
+ {
433
+ command: z.enum(["wikipedia", "ip_geolocation", "dns_lookup", "expand_url"]),
434
+ query: z.string().optional(),
435
+ ip: z.string().optional(),
436
+ domain: z.string().optional(),
437
+ recordType: z.enum(["A", "AAAA", "MX", "TXT", "NS", "CNAME", "SOA"]).optional().default("A"),
438
+ url: z.string().optional(),
439
+ language: z.string().optional().default("en"),
440
+ },
441
+ async (args) => queryPublicData(args)
442
+ );
443
+ }
444
+
445
+ // ─── Graceful shutdown ─────────────────────────────────────────────────────
446
+
447
+ function shutdown(): void {
448
+ console.error("Shutting down Data Aggregator MCP server...");
449
+ cache.destroy();
450
+ process.exit(0);
451
+ }
452
+
453
+ process.on("SIGINT", shutdown);
454
+ process.on("SIGTERM", shutdown);
455
+
456
+ // ─── Start ─────────────────────────────────────────────────────────────────
457
+
458
+ main().catch((err) => {
459
+ console.error("Fatal error starting server:", err);
460
+ process.exit(1);
461
+ });
@@ -0,0 +1,241 @@
1
+ /**
2
+ * query_exchange_rates - Currency exchange rates and conversion.
3
+ *
4
+ * Data sources:
5
+ * - Open Exchange Rates / frankfurter.app (free, no key required)
6
+ * - CoinGecko for crypto-to-fiat rates (free, no key required)
7
+ */
8
+
9
+ import { cache, TTL } from "../utils/cache.js";
10
+ import { rateLimiter } from "../utils/rate-limiter.js";
11
+ import { httpGetJson, formatError } from "../utils/http.js";
12
+ import type { ExchangeRate, CurrencyConversion, ToolResult } from "../types.js";
13
+
14
+ // ─── Common crypto symbols ─────────────────────────────────────────────────
15
+
16
+ const CRYPTO_IDS: Record<string, string> = {
17
+ BTC: "bitcoin",
18
+ ETH: "ethereum",
19
+ SOL: "solana",
20
+ XRP: "ripple",
21
+ ADA: "cardano",
22
+ DOT: "polkadot",
23
+ DOGE: "dogecoin",
24
+ LTC: "litecoin",
25
+ BNB: "binancecoin",
26
+ AVAX: "avalanche-2",
27
+ LINK: "chainlink",
28
+ MATIC: "matic-network",
29
+ UNI: "uniswap",
30
+ };
31
+
32
+ function isCryptoSymbol(sym: string): boolean {
33
+ return sym.toUpperCase() in CRYPTO_IDS;
34
+ }
35
+
36
+ // ─── Fiat rates via frankfurter.app ────────────────────────────────────────
37
+
38
+ async function fetchFiatRate(base: string, target: string): Promise<ExchangeRate> {
39
+ const b = base.toUpperCase();
40
+ const t = target.toUpperCase();
41
+
42
+ const url = `https://api.frankfurter.app/latest?from=${b}&to=${t}`;
43
+ await rateLimiter.acquire("exchangerate");
44
+
45
+ const data: any = await httpGetJson(url);
46
+ const rate = data.rates?.[t];
47
+
48
+ if (rate === undefined) {
49
+ throw new Error(`No exchange rate found for ${b} -> ${t}`);
50
+ }
51
+
52
+ return {
53
+ base: b,
54
+ target: t,
55
+ rate,
56
+ timestamp: data.date ?? new Date().toISOString(),
57
+ source: "Frankfurter (ECB)",
58
+ };
59
+ }
60
+
61
+ async function fetchFiatRatesMultiple(
62
+ base: string,
63
+ targets: string[]
64
+ ): Promise<ExchangeRate[]> {
65
+ const b = base.toUpperCase();
66
+ const t = targets.map((s) => s.toUpperCase()).join(",");
67
+
68
+ const url = `https://api.frankfurter.app/latest?from=${b}&to=${t}`;
69
+ await rateLimiter.acquire("exchangerate");
70
+
71
+ const data: any = await httpGetJson(url);
72
+ const rates: ExchangeRate[] = [];
73
+
74
+ for (const [sym, rate] of Object.entries(data.rates ?? {})) {
75
+ rates.push({
76
+ base: b,
77
+ target: sym,
78
+ rate: rate as number,
79
+ timestamp: data.date ?? new Date().toISOString(),
80
+ source: "Frankfurter (ECB)",
81
+ });
82
+ }
83
+
84
+ return rates;
85
+ }
86
+
87
+ // ─── Historical fiat rates ─────────────────────────────────────────────────
88
+
89
+ async function fetchHistoricalRate(
90
+ base: string,
91
+ target: string,
92
+ date: string
93
+ ): Promise<ExchangeRate> {
94
+ const b = base.toUpperCase();
95
+ const t = target.toUpperCase();
96
+
97
+ const url = `https://api.frankfurter.app/${date}?from=${b}&to=${t}`;
98
+ await rateLimiter.acquire("exchangerate");
99
+
100
+ const data: any = await httpGetJson(url);
101
+ const rate = data.rates?.[t];
102
+
103
+ if (rate === undefined) {
104
+ throw new Error(`No historical rate found for ${b} -> ${t} on ${date}`);
105
+ }
106
+
107
+ return {
108
+ base: b,
109
+ target: t,
110
+ rate,
111
+ timestamp: data.date ?? date,
112
+ source: "Frankfurter (ECB)",
113
+ };
114
+ }
115
+
116
+ // ─── Crypto-to-fiat via CoinGecko ─────────────────────────────────────────
117
+
118
+ async function fetchCryptoRate(crypto: string, fiat: string): Promise<ExchangeRate> {
119
+ const c = crypto.toUpperCase();
120
+ const coinId = CRYPTO_IDS[c];
121
+ if (!coinId) {
122
+ throw new Error(`Unknown crypto symbol: ${c}. Supported: ${Object.keys(CRYPTO_IDS).join(", ")}`);
123
+ }
124
+
125
+ const f = fiat.toLowerCase();
126
+ const url = `https://api.coingecko.com/api/v3/simple/price?ids=${coinId}&vs_currencies=${f}`;
127
+ await rateLimiter.acquire("coingecko");
128
+
129
+ const data: any = await httpGetJson(url);
130
+ const rate = data[coinId]?.[f];
131
+
132
+ if (rate === undefined) {
133
+ throw new Error(`No rate found for ${c} -> ${fiat.toUpperCase()}`);
134
+ }
135
+
136
+ return {
137
+ base: c,
138
+ target: fiat.toUpperCase(),
139
+ rate,
140
+ timestamp: new Date().toISOString(),
141
+ source: "CoinGecko",
142
+ };
143
+ }
144
+
145
+ // ─── Public handler ────────────────────────────────────────────────────────
146
+
147
+ export async function queryExchangeRates(args: {
148
+ base: string;
149
+ target: string;
150
+ amount?: number;
151
+ date?: string;
152
+ }): Promise<ToolResult> {
153
+ try {
154
+ const base = args.base.trim().toUpperCase();
155
+ const target = args.target.trim().toUpperCase();
156
+ const amount = args.amount ?? 1;
157
+ const date = args.date?.trim();
158
+
159
+ // Determine if this involves crypto
160
+ const baseCrypto = isCryptoSymbol(base);
161
+ const targetCrypto = isCryptoSymbol(target);
162
+
163
+ const cacheKey = `exchange:${base}:${target}:${date ?? "latest"}`;
164
+ const cached = cache.get<ExchangeRate>(cacheKey);
165
+
166
+ let exchangeRate: ExchangeRate;
167
+
168
+ if (cached) {
169
+ exchangeRate = cached;
170
+ } else if (baseCrypto && !targetCrypto) {
171
+ // Crypto -> Fiat
172
+ exchangeRate = await fetchCryptoRate(base, target);
173
+ } else if (!baseCrypto && targetCrypto) {
174
+ // Fiat -> Crypto (invert crypto -> fiat)
175
+ const inverse = await fetchCryptoRate(target, base);
176
+ exchangeRate = {
177
+ base,
178
+ target,
179
+ rate: inverse.rate > 0 ? 1 / inverse.rate : 0,
180
+ timestamp: inverse.timestamp,
181
+ source: inverse.source,
182
+ };
183
+ } else if (baseCrypto && targetCrypto) {
184
+ // Crypto -> Crypto (go through USD)
185
+ const baseToUsd = await fetchCryptoRate(base, "USD");
186
+ const targetToUsd = await fetchCryptoRate(target, "USD");
187
+ exchangeRate = {
188
+ base,
189
+ target,
190
+ rate: targetToUsd.rate > 0 ? baseToUsd.rate / targetToUsd.rate : 0,
191
+ timestamp: new Date().toISOString(),
192
+ source: "CoinGecko",
193
+ };
194
+ } else if (date) {
195
+ // Historical fiat rate
196
+ exchangeRate = await fetchHistoricalRate(base, target, date);
197
+ } else {
198
+ // Current fiat rate
199
+ exchangeRate = await fetchFiatRate(base, target);
200
+ }
201
+
202
+ cache.set(cacheKey, exchangeRate, TTL.EXCHANGE);
203
+
204
+ // Build conversion result
205
+ const conversion: CurrencyConversion = {
206
+ from: base,
207
+ to: target,
208
+ amount,
209
+ convertedAmount: +(amount * exchangeRate.rate).toFixed(6),
210
+ rate: exchangeRate.rate,
211
+ timestamp: exchangeRate.timestamp,
212
+ };
213
+
214
+ return {
215
+ content: [
216
+ {
217
+ type: "text",
218
+ text: JSON.stringify(
219
+ {
220
+ exchangeRate,
221
+ conversion,
222
+ cached: !!cached,
223
+ },
224
+ null,
225
+ 2
226
+ ),
227
+ },
228
+ ],
229
+ };
230
+ } catch (err) {
231
+ return {
232
+ content: [
233
+ {
234
+ type: "text",
235
+ text: `Error fetching exchange rates: ${formatError(err)}`,
236
+ },
237
+ ],
238
+ isError: true,
239
+ };
240
+ }
241
+ }