weather-agent-mcp 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/.env.example ADDED
@@ -0,0 +1,21 @@
1
+ # Optional: OpenAI-compatible API (OpenAI, OpenRouter, Groq, DeepSeek, Ollama, vLLM)
2
+ OPENAI_API_KEY=
3
+ OPENAI_BASE_URL=https://api.openai.com/v1
4
+ OPENAI_MODEL=gpt-4o-mini
5
+
6
+ # Optional: Anthropic Messages API
7
+ ANTHROPIC_API_KEY=
8
+ ANTHROPIC_MODEL=claude-3-5-haiku-latest
9
+
10
+ # Open-Meteo base URLs (override in tests with fake server)
11
+ OPEN_METEO_FORECAST_URL=https://api.open-meteo.com
12
+ OPEN_METEO_GEOCODING_URL=https://geocoding-api.open-meteo.com
13
+
14
+ # Agent defaults
15
+ AGENT_MAX_ITERATIONS=10
16
+ AGENT_MAX_TOOL_CALLS=20
17
+ AGENT_MAX_TOKENS=8000
18
+ AGENT_TIMEOUT_MS=30000
19
+
20
+ # Provider for CLI: mock | openai | anthropic
21
+ LLM_PROVIDER=mock
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 weather-agent-mcp contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,156 @@
1
+ # Weather Agent MCP
2
+
3
+ Model-agnostic AI-агент на TypeScript: собственный Agent Runtime, MCP-инструменты над Open-Meteo, Tool Contracts, Policy Engine, observability и eval-харнесс из 12 golden-сценариев.
4
+
5
+ Демонстрирует путь **AI Engineer → Agent Engineer → AI Architect**.
6
+
7
+ ## Быстрый старт
8
+
9
+ ```bash
10
+ npm install
11
+ cp .env.example .env
12
+
13
+ # Вопрос через детерминированный Mock LLM (без API key)
14
+ npm run ask -- "Какая погода будет в Москве завтра?"
15
+
16
+ # Юнит- и интеграционные тесты
17
+ npm test
18
+
19
+ # 12 golden eval-сценариев
20
+ npm run eval
21
+
22
+ # Проверка типов
23
+ npm run typecheck
24
+ ```
25
+
26
+ Опционально — реальные провайдеры (ключи в `.env`):
27
+
28
+ ```bash
29
+ npm run ask -- --provider=openai "Сравни погоду в Москве и Санкт-Петербурге на 5 дней"
30
+ npm run ask -- --provider=claude "Стоит ли брать дождевик в Казань на следующую неделю?"
31
+ ```
32
+
33
+ ## MCP в Cursor / Copilot (PoC)
34
+
35
+ Хост использует **tools** этого пакета; loop остаётся у Cursor/Copilot.
36
+
37
+ ### Через npm (`npx`) — предпочтительно
38
+
39
+ После публикации пакета `weather-agent-mcp@0.1.0`:
40
+
41
+ ```json
42
+ {
43
+ "mcpServers": {
44
+ "open-meteo-weather": {
45
+ "command": "npx",
46
+ "args": ["-y", "weather-agent-mcp"]
47
+ }
48
+ }
49
+ }
50
+ ```
51
+
52
+ То же для Copilot / VS Code (поле `servers` + `"type": "stdio"`):
53
+
54
+ ```json
55
+ {
56
+ "servers": {
57
+ "open-meteo-weather": {
58
+ "type": "stdio",
59
+ "command": "npx",
60
+ "args": ["-y", "weather-agent-mcp"]
61
+ }
62
+ }
63
+ }
64
+ ```
65
+
66
+ ### Из клона (dev)
67
+
68
+ ```bash
69
+ npm install && npm run build
70
+ ```
71
+
72
+ ```json
73
+ {
74
+ "mcpServers": {
75
+ "open-meteo-weather": {
76
+ "command": "node",
77
+ "args": ["dist/weather-mcp.js"],
78
+ "cwd": "/ABS/PATH/TO/REPO"
79
+ }
80
+ }
81
+ }
82
+ ```
83
+
84
+ Dev без сборки: `npm run mcp` или `"args": ["src/mcp-server/index.ts"]` (нужен Node ≥20 с type-stripping).
85
+
86
+ ### Publish (maintainer)
87
+
88
+ Нужен аккаунт npm с 2FA (или granular access token с bypass 2fa):
89
+
90
+ ```bash
91
+ npm login # при необходимости
92
+ npm run build
93
+ npm publish --access public
94
+ npm view weather-agent-mcp version # ожидаем 0.1.0
95
+ ```
96
+
97
+ Cursor / Copilot после publish: `"command": "npx", "args": ["-y", "weather-agent-mcp"]`.
98
+
99
+ Подробности: [`docs/13-poc-readiness.md`](docs/13-poc-readiness.md).
100
+
101
+ ## Архитектура
102
+
103
+ ```
104
+ CLI / Eval
105
+ → Agent Runtime (свой loop: budget, policy, repair)
106
+ → LLMProvider (mock | openai | anthropic)
107
+ → Tool Registry (ToolDefinition + metadata)
108
+ → McpToolSource (discovery + типизированные ошибки)
109
+ → MCP Client (stdio)
110
+ → процесс MCP Server
111
+ → домен weather/
112
+ → Open-Meteo HTTP
113
+ ```
114
+
115
+ Подробный разбор — в [docs/](docs/) (контракты, security, масштабирование, защита MCP, 10-минутная презентация).
116
+
117
+ ## AI stewardship
118
+
119
+ | Артефакт | Зачем |
120
+ |----------|-------|
121
+ | [`AGENTS.md`](AGENTS.md) | Конституция coding-агента |
122
+ | [`MEMORY.md`](MEMORY.md) | Долговременные решения |
123
+ | [`openspec/`](openspec/) | Spec-driven workflow ([openspec.dev](https://openspec.dev/)) |
124
+ | [`graphify-out/COMPASS.md`](graphify-out/COMPASS.md) | Карта архитектуры для агентов |
125
+ | [`docs/12-ai-stewardship.md`](docs/12-ai-stewardship.md) | Как демонстрировать навык управления ИИ |
126
+ | [`docs/13-poc-readiness.md`](docs/13-poc-readiness.md) | PoC MCP hosts + вердикт npm |
127
+
128
+ Команды OpenSpec в Cursor: `/opsx-explore`, `/opsx-propose`, `/opsx-apply`, `/opsx-archive`.
129
+
130
+ ## Скрипты
131
+
132
+ | Скрипт | Назначение |
133
+ |--------|------------|
134
+ | `npm run ask` | Интерактивный CLI-агент |
135
+ | `npm run mcp` | MCP-сервер на stdio |
136
+ | `npm test` | Юнит + интеграция |
137
+ | `npm run eval` | 12 golden-сценариев (Mock LLM) |
138
+ | `npm run compare-models` | Mock vs реальный провайдер (если есть ключ) |
139
+ | `npm run typecheck` | `tsc --noEmit` |
140
+ | `npm run build` | Бандл MCP → `dist/weather-mcp.js` |
141
+
142
+ ## Структура
143
+
144
+ ```
145
+ src/
146
+ weather/ Бизнес-логика (без MCP и LLM)
147
+ mcp-server/ MCP-адаптер (stdio, Zod-схемы)
148
+ tools/ Tool Contracts над MCP
149
+ llm/ Model-agnostic провайдеры
150
+ agent/ Собственный Agent Runtime
151
+ policy/ Policy Engine
152
+ observability/ Трейсы → traces/runs.jsonl
153
+ eval/ Golden-сценарии + FakeOpenMeteo
154
+ cli/ ask.ts
155
+ docs/ Архитектурные ответы (01–13)
156
+ ```
@@ -0,0 +1,567 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/mcp-server/index.ts
4
+ import { serveStdio } from "@modelcontextprotocol/server/stdio";
5
+
6
+ // src/mcp-server/server.ts
7
+ import { McpServer } from "@modelcontextprotocol/server";
8
+
9
+ // src/shared/errors.ts
10
+ var AppError = class extends Error {
11
+ code;
12
+ retryable;
13
+ details;
14
+ constructor(code, message, retryable, details) {
15
+ super(message);
16
+ this.name = new.target.name;
17
+ this.code = code;
18
+ this.retryable = retryable;
19
+ this.details = details;
20
+ }
21
+ toEnvelope() {
22
+ return {
23
+ code: this.code,
24
+ message: this.message,
25
+ retryable: this.retryable,
26
+ details: this.details
27
+ };
28
+ }
29
+ };
30
+ var ToolError = class _ToolError extends AppError {
31
+ constructor(code, message, retryable, details) {
32
+ super(code, message, retryable, details);
33
+ }
34
+ static fromEnvelope(env) {
35
+ return new _ToolError(env.code, env.message, env.retryable, env.details);
36
+ }
37
+ };
38
+ function isAppError(err) {
39
+ return err instanceof AppError;
40
+ }
41
+
42
+ // src/shared/http.ts
43
+ function mergeSignals(a, b) {
44
+ if (!a && !b) return void 0;
45
+ if (!a) return b;
46
+ if (!b) return a;
47
+ const controller = new AbortController();
48
+ const onAbort = () => controller.abort();
49
+ if (a.aborted || b.aborted) {
50
+ controller.abort();
51
+ return controller.signal;
52
+ }
53
+ a.addEventListener("abort", onAbort, { once: true });
54
+ b.addEventListener("abort", onAbort, { once: true });
55
+ return controller.signal;
56
+ }
57
+ async function sleep(ms, signal) {
58
+ if (ms <= 0) return;
59
+ await new Promise((resolve, reject) => {
60
+ const timer = setTimeout(resolve, ms);
61
+ const onAbort = () => {
62
+ clearTimeout(timer);
63
+ reject(new ToolError("UPSTREAM_TIMEOUT", "Request aborted during backoff", true));
64
+ };
65
+ if (signal?.aborted) {
66
+ onAbort();
67
+ return;
68
+ }
69
+ signal?.addEventListener("abort", onAbort, { once: true });
70
+ });
71
+ }
72
+ async function httpJson(url, options = {}) {
73
+ const {
74
+ method = "GET",
75
+ headers = {},
76
+ body,
77
+ signal,
78
+ timeoutMs = 1e4,
79
+ retries = 0,
80
+ retryBackoffMs = 200
81
+ } = options;
82
+ let lastError;
83
+ for (let attempt = 0; attempt <= retries; attempt++) {
84
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
85
+ const combined = mergeSignals(signal, timeoutSignal);
86
+ try {
87
+ const res = await fetch(url, {
88
+ method,
89
+ headers: { Accept: "application/json", ...headers },
90
+ body,
91
+ signal: combined
92
+ });
93
+ const rawText = await res.text();
94
+ if (res.status === 429) {
95
+ const retryAfter = Number(res.headers.get("retry-after") ?? "1");
96
+ const err = new ToolError("RATE_LIMITED", `Rate limited: ${rawText}`, true, {
97
+ status: 429,
98
+ retryAfter
99
+ });
100
+ if (attempt < retries) {
101
+ await sleep(Math.max(retryAfter * 1e3, retryBackoffMs * 2 ** attempt), signal);
102
+ lastError = err;
103
+ continue;
104
+ }
105
+ throw err;
106
+ }
107
+ if (res.status >= 500) {
108
+ const err = new ToolError(
109
+ "UPSTREAM_5XX",
110
+ `Upstream ${res.status}: ${rawText.slice(0, 200)}`,
111
+ true,
112
+ { status: res.status }
113
+ );
114
+ if (attempt < retries) {
115
+ await sleep(retryBackoffMs * 2 ** attempt, signal);
116
+ lastError = err;
117
+ continue;
118
+ }
119
+ throw err;
120
+ }
121
+ let data;
122
+ try {
123
+ data = rawText ? JSON.parse(rawText) : {};
124
+ } catch {
125
+ throw new ToolError("MALFORMED_RESPONSE", "Response is not valid JSON", true, {
126
+ status: res.status,
127
+ rawText: rawText.slice(0, 200)
128
+ });
129
+ }
130
+ return { status: res.status, headers: res.headers, data, rawText };
131
+ } catch (err) {
132
+ if (err instanceof ToolError) {
133
+ lastError = err;
134
+ if (err.retryable && attempt < retries) {
135
+ await sleep(retryBackoffMs * 2 ** attempt, signal);
136
+ continue;
137
+ }
138
+ throw err;
139
+ }
140
+ const isAbort = err instanceof Error && (err.name === "AbortError" || err.name === "TimeoutError" || err.message.includes("aborted"));
141
+ if (isAbort) {
142
+ const te = new ToolError("UPSTREAM_TIMEOUT", "Upstream request timed out or aborted", true);
143
+ lastError = te;
144
+ if (attempt < retries) {
145
+ await sleep(retryBackoffMs * 2 ** attempt, signal);
146
+ continue;
147
+ }
148
+ throw te;
149
+ }
150
+ throw new ToolError(
151
+ "INTERNAL",
152
+ err instanceof Error ? err.message : String(err),
153
+ false,
154
+ { cause: err }
155
+ );
156
+ }
157
+ }
158
+ throw lastError instanceof Error ? lastError : new ToolError("INTERNAL", "HTTP request failed", false);
159
+ }
160
+
161
+ // src/weather/open-meteo-client.ts
162
+ function defaultConfig(overrides = {}) {
163
+ return {
164
+ forecastBaseUrl: process.env.OPEN_METEO_FORECAST_URL ?? "https://api.open-meteo.com",
165
+ geocodingBaseUrl: process.env.OPEN_METEO_GEOCODING_URL ?? "https://geocoding-api.open-meteo.com",
166
+ timeoutMs: 1e4,
167
+ retries: 1,
168
+ ...overrides
169
+ };
170
+ }
171
+ async function meteoGet(baseUrl, path, params, config) {
172
+ const url = new URL(path, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`);
173
+ for (const [k, v] of Object.entries(params)) {
174
+ if (v !== void 0) url.searchParams.set(k, String(v));
175
+ }
176
+ const res = await httpJson(url.toString(), {
177
+ signal: config.signal,
178
+ timeoutMs: config.timeoutMs,
179
+ retries: config.retries
180
+ });
181
+ if (res.status >= 400 || res.data && typeof res.data === "object" && "error" in res.data && res.data.error) {
182
+ const reason = res.data.reason ?? res.rawText.slice(0, 200);
183
+ const lower = reason.toLowerCase();
184
+ if (lower.includes("latitude") || lower.includes("longitude")) {
185
+ throw new ToolError("INVALID_COORDINATES", reason, false, { status: res.status });
186
+ }
187
+ if (lower.includes("date") || lower.includes("bad request")) {
188
+ throw new ToolError("INVALID_DATE_RANGE", reason, false, { status: res.status });
189
+ }
190
+ throw new ToolError("INTERNAL", `Open-Meteo error: ${reason}`, false, { status: res.status });
191
+ }
192
+ return res.data;
193
+ }
194
+
195
+ // src/weather/geocode.ts
196
+ async function geocode(city, config = {}) {
197
+ const cfg = defaultConfig(config);
198
+ const data = await meteoGet(
199
+ cfg.geocodingBaseUrl,
200
+ "/v1/search",
201
+ { name: city, count: 5, language: "en", format: "json" },
202
+ cfg
203
+ );
204
+ if (!data.results || data.results.length === 0) {
205
+ throw new ToolError("CITY_NOT_FOUND", `No location found for city: ${city}`, false, { city });
206
+ }
207
+ const sorted = [...data.results].sort(
208
+ (a, b) => (b.population ?? 0) - (a.population ?? 0)
209
+ );
210
+ const best = sorted[0];
211
+ return {
212
+ name: best.name,
213
+ latitude: best.latitude,
214
+ longitude: best.longitude,
215
+ country: best.country ?? best.country_code ?? "Unknown",
216
+ admin1: best.admin1,
217
+ population: best.population,
218
+ timezone: best.timezone
219
+ };
220
+ }
221
+
222
+ // src/weather/wmo-codes.ts
223
+ var WMO = {
224
+ 0: "Clear sky",
225
+ 1: "Mainly clear",
226
+ 2: "Partly cloudy",
227
+ 3: "Overcast",
228
+ 45: "Fog",
229
+ 48: "Depositing rime fog",
230
+ 51: "Light drizzle",
231
+ 53: "Moderate drizzle",
232
+ 55: "Dense drizzle",
233
+ 61: "Slight rain",
234
+ 63: "Moderate rain",
235
+ 65: "Heavy rain",
236
+ 71: "Slight snow",
237
+ 73: "Moderate snow",
238
+ 75: "Heavy snow",
239
+ 80: "Slight rain showers",
240
+ 81: "Moderate rain showers",
241
+ 82: "Violent rain showers",
242
+ 95: "Thunderstorm",
243
+ 96: "Thunderstorm with slight hail",
244
+ 99: "Thunderstorm with heavy hail"
245
+ };
246
+ function describeWmo(code) {
247
+ return WMO[code] ?? `Unknown weather code ${code}`;
248
+ }
249
+
250
+ // src/weather/current.ts
251
+ async function getCurrentWeather(latitude, longitude, config = {}) {
252
+ const cfg = defaultConfig(config);
253
+ const data = await meteoGet(
254
+ cfg.forecastBaseUrl,
255
+ "/v1/forecast",
256
+ {
257
+ latitude,
258
+ longitude,
259
+ current: "temperature_2m,relative_humidity_2m,precipitation,weather_code,wind_speed_10m",
260
+ timezone: "auto"
261
+ },
262
+ cfg
263
+ );
264
+ const c = data.current;
265
+ return {
266
+ latitude: data.latitude,
267
+ longitude: data.longitude,
268
+ timezone: data.timezone,
269
+ temperatureC: c.temperature_2m,
270
+ humidityPercent: c.relative_humidity_2m,
271
+ precipitationMm: c.precipitation,
272
+ weatherCode: c.weather_code,
273
+ weatherDescription: describeWmo(c.weather_code),
274
+ windSpeedKmh: c.wind_speed_10m,
275
+ observedAt: c.time
276
+ };
277
+ }
278
+
279
+ // src/weather/forecast.ts
280
+ var DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
281
+ function assertDateRange(startDate, endDate) {
282
+ if (!DATE_RE.test(startDate) || !DATE_RE.test(endDate)) {
283
+ throw new ToolError(
284
+ "INVALID_DATE_RANGE",
285
+ `Dates must be YYYY-MM-DD. Got start=${startDate}, end=${endDate}`,
286
+ false
287
+ );
288
+ }
289
+ if (!isValidCalendarDate(startDate) || !isValidCalendarDate(endDate)) {
290
+ throw new ToolError(
291
+ "INVALID_DATE_RANGE",
292
+ `Dates must be valid calendar dates. Got start=${startDate}, end=${endDate}`,
293
+ false
294
+ );
295
+ }
296
+ if (startDate > endDate) {
297
+ throw new ToolError(
298
+ "INVALID_DATE_RANGE",
299
+ `startDate (${startDate}) must be <= endDate (${endDate})`,
300
+ false
301
+ );
302
+ }
303
+ }
304
+ function isValidCalendarDate(iso) {
305
+ const [y, m, d] = iso.split("-").map(Number);
306
+ if (m < 1 || m > 12 || d < 1 || d > 31) return false;
307
+ const dt = new Date(Date.UTC(y, m - 1, d));
308
+ return dt.getUTCFullYear() === y && dt.getUTCMonth() === m - 1 && dt.getUTCDate() === d;
309
+ }
310
+ async function getForecast(latitude, longitude, startDate, endDate, config = {}) {
311
+ assertDateRange(startDate, endDate);
312
+ const cfg = defaultConfig(config);
313
+ const data = await meteoGet(
314
+ cfg.forecastBaseUrl,
315
+ "/v1/forecast",
316
+ {
317
+ latitude,
318
+ longitude,
319
+ daily: "temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max,weather_code",
320
+ timezone: "auto",
321
+ start_date: startDate,
322
+ end_date: endDate
323
+ },
324
+ cfg
325
+ );
326
+ const days = data.daily.time.map((date, i) => ({
327
+ date,
328
+ tempMaxC: data.daily.temperature_2m_max[i],
329
+ tempMinC: data.daily.temperature_2m_min[i],
330
+ precipitationMm: data.daily.precipitation_sum[i],
331
+ precipitationProbabilityPercent: data.daily.precipitation_probability_max[i],
332
+ weatherCode: data.daily.weather_code[i],
333
+ weatherDescription: describeWmo(data.daily.weather_code[i])
334
+ }));
335
+ return {
336
+ latitude: data.latitude,
337
+ longitude: data.longitude,
338
+ timezone: data.timezone,
339
+ startDate,
340
+ endDate,
341
+ days
342
+ };
343
+ }
344
+
345
+ // src/weather/compare.ts
346
+ function summarize(forecast) {
347
+ const n = forecast.days.length || 1;
348
+ const avgTempMaxC = forecast.days.reduce((s, d) => s + d.tempMaxC, 0) / n;
349
+ const avgTempMinC = forecast.days.reduce((s, d) => s + d.tempMinC, 0) / n;
350
+ const totalPrecipitationMm = forecast.days.reduce(
351
+ (s, d) => s + d.precipitationMm,
352
+ 0
353
+ );
354
+ const rainyDays = forecast.days.filter((d) => d.precipitationMm > 0.1).length;
355
+ return {
356
+ avgTempMaxC: Math.round(avgTempMaxC * 10) / 10,
357
+ avgTempMinC: Math.round(avgTempMinC * 10) / 10,
358
+ totalPrecipitationMm: Math.round(totalPrecipitationMm * 10) / 10,
359
+ rainyDays
360
+ };
361
+ }
362
+ async function compareWeather(locations, startDate, endDate, config = {}) {
363
+ const results = await Promise.all(
364
+ locations.map(async (loc) => {
365
+ const forecast = await getForecast(
366
+ loc.latitude,
367
+ loc.longitude,
368
+ startDate,
369
+ endDate,
370
+ config
371
+ );
372
+ return {
373
+ name: loc.name,
374
+ forecast,
375
+ summary: summarize(forecast)
376
+ };
377
+ })
378
+ );
379
+ return { startDate, endDate, locations: results };
380
+ }
381
+
382
+ // src/mcp-server/error-envelope.ts
383
+ function toErrorText(err) {
384
+ const envelope = isAppError(err) ? err.toEnvelope() : {
385
+ code: "INTERNAL",
386
+ message: err instanceof Error ? err.message : String(err),
387
+ retryable: false
388
+ };
389
+ return JSON.stringify(envelope);
390
+ }
391
+ function okResult(output) {
392
+ return {
393
+ content: [{ type: "text", text: JSON.stringify(output) }],
394
+ structuredContent: output
395
+ };
396
+ }
397
+ function errResult(err) {
398
+ return {
399
+ content: [{ type: "text", text: toErrorText(err) }],
400
+ isError: true
401
+ };
402
+ }
403
+
404
+ // src/mcp-server/schemas.ts
405
+ import { z } from "zod";
406
+ var LocationSchema = z.object({
407
+ name: z.string(),
408
+ latitude: z.number(),
409
+ longitude: z.number()
410
+ });
411
+ var GeocodeInputSchema = z.object({
412
+ city: z.string().min(1).describe("City name to geocode, e.g. Moscow")
413
+ });
414
+ var GeocodeOutputSchema = z.object({
415
+ name: z.string(),
416
+ latitude: z.number(),
417
+ longitude: z.number(),
418
+ country: z.string(),
419
+ admin1: z.string().optional(),
420
+ population: z.number().optional(),
421
+ timezone: z.string().optional()
422
+ });
423
+ var CurrentWeatherInputSchema = z.object({
424
+ latitude: z.number().min(-90).max(90),
425
+ longitude: z.number().min(-180).max(180)
426
+ });
427
+ var CurrentWeatherOutputSchema = z.object({
428
+ latitude: z.number(),
429
+ longitude: z.number(),
430
+ timezone: z.string(),
431
+ temperatureC: z.number(),
432
+ humidityPercent: z.number(),
433
+ precipitationMm: z.number(),
434
+ weatherCode: z.number(),
435
+ weatherDescription: z.string(),
436
+ windSpeedKmh: z.number(),
437
+ observedAt: z.string()
438
+ });
439
+ var ForecastInputSchema = z.object({
440
+ latitude: z.number().min(-90).max(90),
441
+ longitude: z.number().min(-180).max(180),
442
+ startDate: z.string().describe("Start date YYYY-MM-DD"),
443
+ endDate: z.string().describe("End date YYYY-MM-DD")
444
+ });
445
+ var DailyForecastSchema = z.object({
446
+ date: z.string(),
447
+ tempMaxC: z.number(),
448
+ tempMinC: z.number(),
449
+ precipitationMm: z.number(),
450
+ precipitationProbabilityPercent: z.number(),
451
+ weatherCode: z.number(),
452
+ weatherDescription: z.string()
453
+ });
454
+ var ForecastOutputSchema = z.object({
455
+ latitude: z.number(),
456
+ longitude: z.number(),
457
+ timezone: z.string(),
458
+ startDate: z.string(),
459
+ endDate: z.string(),
460
+ days: z.array(DailyForecastSchema)
461
+ });
462
+ var CompareWeatherInputSchema = z.object({
463
+ locations: z.array(LocationSchema).min(2).describe("Locations to compare"),
464
+ startDate: z.string().describe("Start date YYYY-MM-DD"),
465
+ endDate: z.string().describe("End date YYYY-MM-DD")
466
+ });
467
+ var CompareWeatherOutputSchema = z.object({
468
+ startDate: z.string(),
469
+ endDate: z.string(),
470
+ locations: z.array(
471
+ z.object({
472
+ name: z.string(),
473
+ forecast: ForecastOutputSchema,
474
+ summary: z.object({
475
+ avgTempMaxC: z.number(),
476
+ avgTempMinC: z.number(),
477
+ totalPrecipitationMm: z.number(),
478
+ rainyDays: z.number()
479
+ })
480
+ })
481
+ )
482
+ });
483
+
484
+ // src/mcp-server/server.ts
485
+ function createWeatherMcpServer() {
486
+ const server = new McpServer(
487
+ { name: "open-meteo-weather", version: "1.0.0" },
488
+ { capabilities: { tools: {} } }
489
+ );
490
+ server.registerTool(
491
+ "geocode",
492
+ {
493
+ title: "Geocode city",
494
+ description: "Resolve a city name to geographic coordinates (latitude, longitude, country). Prefer the most populous match when multiple cities share a name.",
495
+ inputSchema: GeocodeInputSchema,
496
+ outputSchema: GeocodeOutputSchema
497
+ },
498
+ async ({ city }) => {
499
+ try {
500
+ const result = await geocode(city);
501
+ return okResult(result);
502
+ } catch (err) {
503
+ return errResult(err);
504
+ }
505
+ }
506
+ );
507
+ server.registerTool(
508
+ "getCurrentWeather",
509
+ {
510
+ title: "Current weather",
511
+ description: "Get current weather conditions for the given coordinates: temperature, humidity, precipitation, wind, and WMO weather description.",
512
+ inputSchema: CurrentWeatherInputSchema,
513
+ outputSchema: CurrentWeatherOutputSchema
514
+ },
515
+ async ({ latitude, longitude }) => {
516
+ try {
517
+ const result = await getCurrentWeather(latitude, longitude);
518
+ return okResult(result);
519
+ } catch (err) {
520
+ return errResult(err);
521
+ }
522
+ }
523
+ );
524
+ server.registerTool(
525
+ "getForecast",
526
+ {
527
+ title: "Weather forecast",
528
+ description: "Get daily weather forecast between startDate and endDate (YYYY-MM-DD) for the given coordinates. Returns max/min temperature, precipitation, probability, and weather description per day.",
529
+ inputSchema: ForecastInputSchema,
530
+ outputSchema: ForecastOutputSchema
531
+ },
532
+ async ({ latitude, longitude, startDate, endDate }) => {
533
+ try {
534
+ const result = await getForecast(latitude, longitude, startDate, endDate);
535
+ return okResult(result);
536
+ } catch (err) {
537
+ return errResult(err);
538
+ }
539
+ }
540
+ );
541
+ server.registerTool(
542
+ "compareWeather",
543
+ {
544
+ title: "Compare weather",
545
+ description: "Compare weather forecasts across multiple locations for the same date range. Each location needs name, latitude, and longitude. Returns per-location forecast plus summary stats (avg temps, total precipitation, rainy days).",
546
+ inputSchema: CompareWeatherInputSchema,
547
+ outputSchema: CompareWeatherOutputSchema
548
+ },
549
+ async ({ locations, startDate, endDate }) => {
550
+ try {
551
+ const result = await compareWeather(locations, startDate, endDate);
552
+ return okResult(result);
553
+ } catch (err) {
554
+ return errResult(err);
555
+ }
556
+ }
557
+ );
558
+ return server;
559
+ }
560
+
561
+ // src/mcp-server/index.ts
562
+ void serveStdio(() => createWeatherMcpServer(), {
563
+ onerror: (err) => {
564
+ console.error("[mcp-server]", err.message);
565
+ }
566
+ });
567
+ console.error("open-meteo-weather MCP server running on stdio");
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "weather-agent-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Model-agnostic AI agent with MCP tools over Open-Meteo",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=20"
8
+ },
9
+ "bin": {
10
+ "weather-mcp": "dist/weather-mcp.js",
11
+ "weather-agent-mcp": "dist/weather-mcp.js"
12
+ },
13
+ "files": [
14
+ "dist/",
15
+ "README.md",
16
+ "LICENSE",
17
+ ".env.example"
18
+ ],
19
+ "scripts": {
20
+ "ask": "node --env-file-if-exists=.env src/cli/ask.ts",
21
+ "mcp": "node --env-file-if-exists=.env src/mcp-server/index.ts",
22
+ "build": "esbuild src/mcp-server/index.ts --bundle --platform=node --format=esm --outfile=dist/weather-mcp.js --packages=external",
23
+ "prepublishOnly": "npm run build",
24
+ "test": "node --test --test-reporter=spec test/unit/*.test.ts test/integration/*.test.ts",
25
+ "test:unit": "node --test --test-reporter=spec test/unit/*.test.ts",
26
+ "test:integration": "node --test --test-reporter=spec test/integration/*.test.ts",
27
+ "eval": "node --env-file-if-exists=.env src/eval/harness.ts",
28
+ "typecheck": "tsc --noEmit",
29
+ "compare-models": "node --env-file-if-exists=.env src/eval/harness.ts --compare",
30
+ "openspec": "openspec",
31
+ "openspec:list": "openspec list"
32
+ },
33
+ "dependencies": {
34
+ "@modelcontextprotocol/client": "2.1.0",
35
+ "@modelcontextprotocol/server": "2.1.0",
36
+ "zod": "^4.2.0"
37
+ },
38
+ "devDependencies": {
39
+ "@fission-ai/openspec": "^1.13.2",
40
+ "@types/node": "^22.13.10",
41
+ "esbuild": "^0.25.0",
42
+ "typescript": "^5.8.2"
43
+ }
44
+ }