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
@@ -0,0 +1,217 @@
1
+ /**
2
+ * fetch_webpage - Structured web scraping.
3
+ *
4
+ * Fetches a URL and returns structured JSON with:
5
+ * - Page title
6
+ * - Meta description
7
+ * - Headings hierarchy
8
+ * - Main text content (HTML stripped)
9
+ * - Selected content via CSS-selector-like path (basic support)
10
+ * - Links found on the page
11
+ *
12
+ * Uses rotating User-Agent strings for basic anti-scraping.
13
+ */
14
+
15
+ import { cache, TTL } from "../utils/cache.js";
16
+ import { rateLimiter } from "../utils/rate-limiter.js";
17
+ import { httpGetText, formatError } from "../utils/http.js";
18
+ import type { ScrapedPage, ToolResult } from "../types.js";
19
+
20
+ // ─── Basic HTML Parsing Helpers ────────────────────────────────────────────
21
+ // We do simple regex-based extraction to avoid heavy deps like cheerio.
22
+
23
+ function extractTag(html: string, tag: string): string {
24
+ const re = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, "i");
25
+ const m = html.match(re);
26
+ return m ? stripHtml(m[1]!).trim() : "";
27
+ }
28
+
29
+ function extractMetaContent(html: string, name: string): string {
30
+ // Match both name="..." and property="..."
31
+ const re = new RegExp(
32
+ `<meta\\s+(?:[^>]*?(?:name|property)\\s*=\\s*["']${name}["'][^>]*?content\\s*=\\s*["']([^"']*)["']|[^>]*?content\\s*=\\s*["']([^"']*)["'][^>]*?(?:name|property)\\s*=\\s*["']${name}["'])`,
33
+ "i"
34
+ );
35
+ const m = html.match(re);
36
+ return (m?.[1] ?? m?.[2] ?? "").trim();
37
+ }
38
+
39
+ function extractHeadings(html: string): { level: number; text: string }[] {
40
+ const headings: { level: number; text: string }[] = [];
41
+ const re = /<(h[1-6])[^>]*>([\s\S]*?)<\/\1>/gi;
42
+ let match;
43
+ while ((match = re.exec(html)) !== null) {
44
+ const level = parseInt(match[1]!.charAt(1), 10);
45
+ const text = stripHtml(match[2]!).trim();
46
+ if (text) headings.push({ level, text });
47
+ }
48
+ return headings;
49
+ }
50
+
51
+ function extractLinks(html: string, baseUrl: string): { text: string; href: string }[] {
52
+ const links: { text: string; href: string }[] = [];
53
+ const re = /<a\s+[^>]*href\s*=\s*["']([^"'#]+)["'][^>]*>([\s\S]*?)<\/a>/gi;
54
+ let match;
55
+ const seen = new Set<string>();
56
+
57
+ while ((match = re.exec(html)) !== null) {
58
+ let href = match[1]!.trim();
59
+ const text = stripHtml(match[2]!).trim();
60
+ if (!text || !href) continue;
61
+
62
+ // Resolve relative URLs
63
+ try {
64
+ href = new URL(href, baseUrl).href;
65
+ } catch {
66
+ continue;
67
+ }
68
+
69
+ if (!seen.has(href)) {
70
+ seen.add(href);
71
+ links.push({ text: text.slice(0, 200), href });
72
+ }
73
+
74
+ if (links.length >= 50) break;
75
+ }
76
+
77
+ return links;
78
+ }
79
+
80
+ function stripHtml(html: string): string {
81
+ return html
82
+ .replace(/<script[\s\S]*?<\/script>/gi, " ")
83
+ .replace(/<style[\s\S]*?<\/style>/gi, " ")
84
+ .replace(/<[^>]+>/g, " ")
85
+ .replace(/&nbsp;/gi, " ")
86
+ .replace(/&amp;/gi, "&")
87
+ .replace(/&lt;/gi, "<")
88
+ .replace(/&gt;/gi, ">")
89
+ .replace(/&quot;/gi, '"')
90
+ .replace(/&#39;/gi, "'")
91
+ .replace(/\s+/g, " ")
92
+ .trim();
93
+ }
94
+
95
+ function extractMainContent(html: string): string {
96
+ // Try to find <main>, <article>, or role="main" first
97
+ const mainPatterns = [
98
+ /<main[^>]*>([\s\S]*?)<\/main>/i,
99
+ /<article[^>]*>([\s\S]*?)<\/article>/i,
100
+ /<div[^>]*role\s*=\s*["']main["'][^>]*>([\s\S]*?)<\/div>/i,
101
+ ];
102
+
103
+ for (const pattern of mainPatterns) {
104
+ const match = html.match(pattern);
105
+ if (match) {
106
+ const text = stripHtml(match[1]!);
107
+ if (text.length > 100) return text.slice(0, 10_000);
108
+ }
109
+ }
110
+
111
+ // Fall back to stripping the body
112
+ const bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
113
+ const body = bodyMatch ? bodyMatch[1]! : html;
114
+ return stripHtml(body).slice(0, 10_000);
115
+ }
116
+
117
+ /**
118
+ * Very basic CSS-selector-like content extraction.
119
+ * Supports: tag, .class, #id (single selector only).
120
+ */
121
+ function extractBySelector(html: string, selector: string): string {
122
+ let pattern: RegExp;
123
+
124
+ if (selector.startsWith("#")) {
125
+ const id = selector.slice(1);
126
+ pattern = new RegExp(
127
+ `<([a-z][a-z0-9]*)\\s+[^>]*id\\s*=\\s*["']${id}["'][^>]*>([\\s\\S]*?)<\\/\\1>`,
128
+ "i"
129
+ );
130
+ } else if (selector.startsWith(".")) {
131
+ const cls = selector.slice(1);
132
+ pattern = new RegExp(
133
+ `<([a-z][a-z0-9]*)\\s+[^>]*class\\s*=\\s*["'][^"']*\\b${cls}\\b[^"']*["'][^>]*>([\\s\\S]*?)<\\/\\1>`,
134
+ "i"
135
+ );
136
+ } else {
137
+ // Plain tag name
138
+ pattern = new RegExp(`<${selector}[^>]*>([\\s\\S]*?)<\\/${selector}>`, "i");
139
+ }
140
+
141
+ const match = html.match(pattern);
142
+ if (!match) return "";
143
+
144
+ // The captured content is in group 2 for id/class patterns, group 1 for tag
145
+ const content = match[2] ?? match[1] ?? "";
146
+ return stripHtml(content).slice(0, 10_000);
147
+ }
148
+
149
+ // ─── Public handler ────────────────────────────────────────────────────────
150
+
151
+ export async function fetchWebpage(args: {
152
+ url: string;
153
+ selector?: string;
154
+ }): Promise<ToolResult> {
155
+ try {
156
+ const url = args.url.trim();
157
+
158
+ // Validate URL
159
+ try {
160
+ new URL(url);
161
+ } catch {
162
+ return {
163
+ content: [{ type: "text", text: `Invalid URL: "${url}"` }],
164
+ isError: true,
165
+ };
166
+ }
167
+
168
+ const cacheKey = `scrape:${url}:${args.selector ?? ""}`;
169
+ const cached = cache.get<ScrapedPage>(cacheKey);
170
+ if (cached) {
171
+ return {
172
+ content: [
173
+ { type: "text", text: JSON.stringify({ ...cached, cached: true }, null, 2) },
174
+ ],
175
+ };
176
+ }
177
+
178
+ await rateLimiter.acquire("generic");
179
+
180
+ const html = await httpGetText(url, {
181
+ rotateUserAgent: true,
182
+ headers: {
183
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
184
+ "Accept-Language": "en-US,en;q=0.5",
185
+ },
186
+ });
187
+
188
+ const result: ScrapedPage = {
189
+ url,
190
+ title: extractTag(html, "title"),
191
+ metaDescription:
192
+ extractMetaContent(html, "description") ||
193
+ extractMetaContent(html, "og:description"),
194
+ headings: extractHeadings(html),
195
+ mainContent: extractMainContent(html),
196
+ links: extractLinks(html, url),
197
+ timestamp: new Date().toISOString(),
198
+ };
199
+
200
+ if (args.selector) {
201
+ result.selectedContent = extractBySelector(html, args.selector);
202
+ }
203
+
204
+ cache.set(cacheKey, result, TTL.SCRAPE);
205
+
206
+ return {
207
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
208
+ };
209
+ } catch (err) {
210
+ return {
211
+ content: [
212
+ { type: "text", text: `Error scraping webpage: ${formatError(err)}` },
213
+ ],
214
+ isError: true,
215
+ };
216
+ }
217
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * query_stocks - Fetch stock and crypto market data.
3
+ *
4
+ * Free data sources:
5
+ * - CoinGecko API (crypto, no key required)
6
+ * - Yahoo Finance v8 unofficial API (stocks, no key required)
7
+ * - Alpha Vantage (stocks, optional key via ALPHA_VANTAGE_KEY env)
8
+ */
9
+
10
+ import { cache, TTL } from "../utils/cache.js";
11
+ import { rateLimiter } from "../utils/rate-limiter.js";
12
+ import { httpGetJson, formatError } from "../utils/http.js";
13
+ import type { StockQuote, CryptoQuote, ToolResult } from "../types.js";
14
+
15
+ // ─── CoinGecko ID mapping for common crypto symbols ────────────────────────
16
+
17
+ const CRYPTO_MAP: Record<string, string> = {
18
+ btc: "bitcoin",
19
+ eth: "ethereum",
20
+ sol: "solana",
21
+ ada: "cardano",
22
+ dot: "polkadot",
23
+ matic: "matic-network",
24
+ avax: "avalanche-2",
25
+ link: "chainlink",
26
+ doge: "dogecoin",
27
+ shib: "shiba-inu",
28
+ xrp: "ripple",
29
+ bnb: "binancecoin",
30
+ ltc: "litecoin",
31
+ uni: "uniswap",
32
+ atom: "cosmos",
33
+ near: "near",
34
+ apt: "aptos",
35
+ arb: "arbitrum",
36
+ op: "optimism",
37
+ sui: "sui",
38
+ };
39
+
40
+ function isCrypto(symbol: string): boolean {
41
+ return symbol.toLowerCase() in CRYPTO_MAP;
42
+ }
43
+
44
+ function coingeckoId(symbol: string): string {
45
+ return CRYPTO_MAP[symbol.toLowerCase()] ?? symbol.toLowerCase();
46
+ }
47
+
48
+ // ─── Crypto via CoinGecko ──────────────────────────────────────────────────
49
+
50
+ async function fetchCryptoQuote(symbol: string, vsCurrency = "usd"): Promise<CryptoQuote> {
51
+ const id = coingeckoId(symbol);
52
+ const url =
53
+ `https://api.coingecko.com/api/v3/coins/${id}` +
54
+ `?localization=false&tickers=false&community_data=false&developer_data=false`;
55
+
56
+ await rateLimiter.acquire("coingecko");
57
+ const data: any = await httpGetJson(url);
58
+
59
+ const md = data.market_data;
60
+ return {
61
+ symbol: (data.symbol as string).toUpperCase(),
62
+ name: data.name,
63
+ price: md.current_price[vsCurrency] ?? md.current_price.usd,
64
+ currency: vsCurrency.toUpperCase(),
65
+ change24h: md.price_change_24h ?? 0,
66
+ changePercent24h: md.price_change_percentage_24h ?? 0,
67
+ volume24h: md.total_volume[vsCurrency] ?? md.total_volume.usd ?? 0,
68
+ marketCap: md.market_cap[vsCurrency] ?? md.market_cap.usd ?? 0,
69
+ high24h: md.high_24h?.[vsCurrency],
70
+ low24h: md.low_24h?.[vsCurrency],
71
+ timestamp: new Date().toISOString(),
72
+ source: "CoinGecko",
73
+ };
74
+ }
75
+
76
+ // ─── Stocks via Yahoo Finance (unofficial v8 endpoint) ─────────────────────
77
+
78
+ async function fetchStockQuoteYahoo(symbol: string): Promise<StockQuote> {
79
+ const url = `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}?range=1d&interval=1d`;
80
+
81
+ await rateLimiter.acquire("generic");
82
+
83
+ const data: any = await httpGetJson(url, { rotateUserAgent: true });
84
+ const result = data?.chart?.result?.[0];
85
+ if (!result) throw new Error(`No data found for symbol "${symbol}"`);
86
+
87
+ const meta = result.meta;
88
+ const price = meta.regularMarketPrice ?? meta.previousClose;
89
+ const prevClose = meta.chartPreviousClose ?? meta.previousClose ?? price;
90
+
91
+ return {
92
+ symbol: meta.symbol ?? symbol.toUpperCase(),
93
+ price,
94
+ currency: (meta.currency ?? "USD").toUpperCase(),
95
+ change: +(price - prevClose).toFixed(4),
96
+ changePercent: prevClose ? +(((price - prevClose) / prevClose) * 100).toFixed(4) : 0,
97
+ volume: meta.regularMarketVolume,
98
+ high: meta.regularMarketDayHigh,
99
+ low: meta.regularMarketDayLow,
100
+ open: meta.regularMarketOpen ?? meta.openPrice,
101
+ previousClose: prevClose,
102
+ timestamp: new Date().toISOString(),
103
+ source: "Yahoo Finance",
104
+ };
105
+ }
106
+
107
+ // ─── Stocks via Alpha Vantage (if key is available) ────────────────────────
108
+
109
+ async function fetchStockQuoteAlphaVantage(
110
+ symbol: string,
111
+ apiKey: string
112
+ ): Promise<StockQuote> {
113
+ const url =
114
+ `https://www.alphavantage.co/query?function=GLOBAL_QUOTE&symbol=${encodeURIComponent(symbol)}&apikey=${apiKey}`;
115
+
116
+ await rateLimiter.acquire("generic");
117
+ const data: any = await httpGetJson(url);
118
+ const q = data["Global Quote"];
119
+
120
+ if (!q || !q["05. price"]) {
121
+ throw new Error(`Alpha Vantage returned no data for "${symbol}"`);
122
+ }
123
+
124
+ const price = parseFloat(q["05. price"]);
125
+ const prevClose = parseFloat(q["08. previous close"]);
126
+
127
+ return {
128
+ symbol: q["01. symbol"] ?? symbol.toUpperCase(),
129
+ price,
130
+ currency: "USD",
131
+ change: parseFloat(q["09. change"]) || 0,
132
+ changePercent: parseFloat(q["10. change percent"]?.replace("%", "")) || 0,
133
+ volume: parseInt(q["06. volume"], 10) || undefined,
134
+ high: parseFloat(q["03. high"]) || undefined,
135
+ low: parseFloat(q["04. low"]) || undefined,
136
+ open: parseFloat(q["02. open"]) || undefined,
137
+ previousClose: prevClose || undefined,
138
+ timestamp: new Date().toISOString(),
139
+ source: "Alpha Vantage",
140
+ };
141
+ }
142
+
143
+ // ─── Public handler ────────────────────────────────────────────────────────
144
+
145
+ export async function queryStocks(args: {
146
+ symbol: string;
147
+ type?: "stock" | "crypto" | "auto";
148
+ }): Promise<ToolResult> {
149
+ try {
150
+ const symbol = args.symbol.trim();
151
+ const assetType =
152
+ args.type === "crypto" || (args.type !== "stock" && isCrypto(symbol))
153
+ ? "crypto"
154
+ : "stock";
155
+
156
+ const cacheKey = `stocks:${assetType}:${symbol.toLowerCase()}`;
157
+
158
+ // Check cache
159
+ const cached = cache.get<StockQuote | CryptoQuote>(cacheKey);
160
+ if (cached) {
161
+ return {
162
+ content: [
163
+ {
164
+ type: "text",
165
+ text: JSON.stringify({ ...cached, cached: true }, null, 2),
166
+ },
167
+ ],
168
+ };
169
+ }
170
+
171
+ let result: StockQuote | CryptoQuote;
172
+
173
+ if (assetType === "crypto") {
174
+ result = await fetchCryptoQuote(symbol);
175
+ } else {
176
+ // Try Yahoo first; fall back to Alpha Vantage if a key is available
177
+ try {
178
+ result = await fetchStockQuoteYahoo(symbol);
179
+ } catch (yahooErr) {
180
+ const avKey = process.env.ALPHA_VANTAGE_KEY;
181
+ if (avKey) {
182
+ result = await fetchStockQuoteAlphaVantage(symbol, avKey);
183
+ } else {
184
+ throw yahooErr;
185
+ }
186
+ }
187
+ }
188
+
189
+ cache.set(cacheKey, result, TTL.MARKET);
190
+
191
+ return {
192
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
193
+ };
194
+ } catch (err) {
195
+ return {
196
+ content: [
197
+ {
198
+ type: "text",
199
+ text: `Error fetching market data: ${formatError(err)}`,
200
+ },
201
+ ],
202
+ isError: true,
203
+ };
204
+ }
205
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * query_weather - Weather data via Open-Meteo API.
3
+ *
4
+ * Open-Meteo is 100% free, no API key required.
5
+ * Provides current conditions + 7-day forecast.
6
+ * Accepts city name (geocoded) or direct lat/lon.
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 { WeatherData, CurrentWeather, DailyForecast, ToolResult } from "../types.js";
13
+
14
+ // ─── WMO Weather Code Descriptions ────────────────────────────────────────
15
+
16
+ const WMO_CODES: Record<number, string> = {
17
+ 0: "Clear sky",
18
+ 1: "Mainly clear",
19
+ 2: "Partly cloudy",
20
+ 3: "Overcast",
21
+ 45: "Fog",
22
+ 48: "Depositing rime fog",
23
+ 51: "Light drizzle",
24
+ 53: "Moderate drizzle",
25
+ 55: "Dense drizzle",
26
+ 56: "Light freezing drizzle",
27
+ 57: "Dense freezing drizzle",
28
+ 61: "Slight rain",
29
+ 63: "Moderate rain",
30
+ 65: "Heavy rain",
31
+ 66: "Light freezing rain",
32
+ 67: "Heavy freezing rain",
33
+ 71: "Slight snowfall",
34
+ 73: "Moderate snowfall",
35
+ 75: "Heavy snowfall",
36
+ 77: "Snow grains",
37
+ 80: "Slight rain showers",
38
+ 81: "Moderate rain showers",
39
+ 82: "Violent rain showers",
40
+ 85: "Slight snow showers",
41
+ 86: "Heavy snow showers",
42
+ 95: "Thunderstorm",
43
+ 96: "Thunderstorm with slight hail",
44
+ 99: "Thunderstorm with heavy hail",
45
+ };
46
+
47
+ function weatherDescription(code: number): string {
48
+ return WMO_CODES[code] ?? `Unknown (code ${code})`;
49
+ }
50
+
51
+ // ─── Geocoding via Open-Meteo ──────────────────────────────────────────────
52
+
53
+ interface GeoResult {
54
+ name: string;
55
+ latitude: number;
56
+ longitude: number;
57
+ timezone: string;
58
+ country?: string;
59
+ admin1?: string;
60
+ }
61
+
62
+ async function geocodeCity(city: string): Promise<GeoResult> {
63
+ const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1&language=en&format=json`;
64
+
65
+ await rateLimiter.acquire("open-meteo");
66
+ const data: any = await httpGetJson(url);
67
+
68
+ if (!data.results || data.results.length === 0) {
69
+ throw new Error(`Could not find location "${city}". Try a different spelling or use latitude/longitude.`);
70
+ }
71
+
72
+ const r = data.results[0];
73
+ return {
74
+ name: [r.name, r.admin1, r.country].filter(Boolean).join(", "),
75
+ latitude: r.latitude,
76
+ longitude: r.longitude,
77
+ timezone: r.timezone ?? "UTC",
78
+ };
79
+ }
80
+
81
+ // ─── Weather fetch ─────────────────────────────────────────────────────────
82
+
83
+ async function fetchWeather(
84
+ lat: number,
85
+ lon: number,
86
+ locationName: string,
87
+ timezone: string,
88
+ units: "celsius" | "fahrenheit"
89
+ ): Promise<WeatherData> {
90
+ const tempUnit = units === "fahrenheit" ? "fahrenheit" : "celsius";
91
+ const windUnit = units === "fahrenheit" ? "mph" : "kmh";
92
+
93
+ const params = new URLSearchParams({
94
+ latitude: String(lat),
95
+ longitude: String(lon),
96
+ timezone,
97
+ temperature_unit: tempUnit,
98
+ wind_speed_unit: windUnit,
99
+ current: "temperature_2m,relative_humidity_2m,apparent_temperature,precipitation,weather_code,wind_speed_10m,wind_direction_10m,is_day",
100
+ daily: "weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max,wind_speed_10m_max,sunrise,sunset",
101
+ forecast_days: "7",
102
+ });
103
+
104
+ const url = `https://api.open-meteo.com/v1/forecast?${params.toString()}`;
105
+
106
+ await rateLimiter.acquire("open-meteo");
107
+ const data: any = await httpGetJson(url);
108
+
109
+ const c = data.current;
110
+ const current: CurrentWeather = {
111
+ temperature: c.temperature_2m,
112
+ feelsLike: c.apparent_temperature,
113
+ humidity: c.relative_humidity_2m,
114
+ windSpeed: c.wind_speed_10m,
115
+ windDirection: c.wind_direction_10m,
116
+ precipitation: c.precipitation,
117
+ weatherCode: c.weather_code,
118
+ weatherDescription: weatherDescription(c.weather_code),
119
+ isDay: c.is_day === 1,
120
+ timestamp: new Date().toISOString(),
121
+ };
122
+
123
+ const d = data.daily;
124
+ const forecast: DailyForecast[] = [];
125
+ for (let i = 0; i < (d.time?.length ?? 0); i++) {
126
+ forecast.push({
127
+ date: d.time[i],
128
+ temperatureMax: d.temperature_2m_max[i],
129
+ temperatureMin: d.temperature_2m_min[i],
130
+ precipitationSum: d.precipitation_sum[i],
131
+ precipitationProbability: d.precipitation_probability_max[i],
132
+ windSpeedMax: d.wind_speed_10m_max[i],
133
+ weatherCode: d.weather_code[i],
134
+ weatherDescription: weatherDescription(d.weather_code[i]),
135
+ sunrise: d.sunrise[i],
136
+ sunset: d.sunset[i],
137
+ });
138
+ }
139
+
140
+ return {
141
+ location: locationName,
142
+ latitude: lat,
143
+ longitude: lon,
144
+ timezone,
145
+ current,
146
+ forecast,
147
+ source: "Open-Meteo",
148
+ };
149
+ }
150
+
151
+ // ─── Public handler ────────────────────────────────────────────────────────
152
+
153
+ export async function queryWeather(args: {
154
+ city?: string;
155
+ latitude?: number;
156
+ longitude?: number;
157
+ units?: "celsius" | "fahrenheit";
158
+ }): Promise<ToolResult> {
159
+ try {
160
+ const { city, units = "celsius" } = args;
161
+ let { latitude, longitude } = args;
162
+
163
+ if (!city && (latitude === undefined || longitude === undefined)) {
164
+ return {
165
+ content: [
166
+ {
167
+ type: "text",
168
+ text: 'Please provide either a city name or both latitude and longitude.',
169
+ },
170
+ ],
171
+ isError: true,
172
+ };
173
+ }
174
+
175
+ let locationName: string;
176
+ let timezone: string;
177
+
178
+ if (city) {
179
+ const geo = await geocodeCity(city);
180
+ latitude = geo.latitude;
181
+ longitude = geo.longitude;
182
+ locationName = geo.name;
183
+ timezone = geo.timezone;
184
+ } else {
185
+ locationName = `${latitude!.toFixed(4)}, ${longitude!.toFixed(4)}`;
186
+ timezone = "UTC";
187
+ }
188
+
189
+ const cacheKey = `weather:${latitude!.toFixed(2)}:${longitude!.toFixed(2)}:${units}`;
190
+ const cached = cache.get<WeatherData>(cacheKey);
191
+ if (cached) {
192
+ return {
193
+ content: [
194
+ {
195
+ type: "text",
196
+ text: JSON.stringify({ ...cached, cached: true }, null, 2),
197
+ },
198
+ ],
199
+ };
200
+ }
201
+
202
+ const weather = await fetchWeather(latitude!, longitude!, locationName, timezone, units);
203
+ cache.set(cacheKey, weather, TTL.WEATHER);
204
+
205
+ return {
206
+ content: [{ type: "text", text: JSON.stringify(weather, null, 2) }],
207
+ };
208
+ } catch (err) {
209
+ return {
210
+ content: [
211
+ { type: "text", text: `Error fetching weather: ${formatError(err)}` },
212
+ ],
213
+ isError: true,
214
+ };
215
+ }
216
+ }