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 +21 -0
- package/LICENSE +21 -0
- package/README.md +156 -0
- package/dist/weather-mcp.js +567 -0
- package/package.json +44 -0
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
|
+
}
|