mcp-meteoblue 1.0.0 → 1.0.2
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/README.md +49 -2
- package/package.json +1 -1
- package/src/meteoblue.js +1 -1
- package/src/server.js +115 -4
package/README.md
CHANGED
|
@@ -10,6 +10,20 @@ An MCP server for the [meteoblue Weather API](https://docs.meteoblue.com/en/weat
|
|
|
10
10
|
|
|
11
11
|
Place names passed to forecast or image tools are always resolved through the official meteoblue Location Search API. Forecast package inputs are restricted to the packages listed in the Free Weather API documentation.
|
|
12
12
|
|
|
13
|
+
`get_forecast` can restrict hourly output to a local time range. For example, tomorrow from 14:00 through 16:00 (inclusive):
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"location": "Paris",
|
|
18
|
+
"forecastDays": 2,
|
|
19
|
+
"dayOffset": 1,
|
|
20
|
+
"startHour": "14:00",
|
|
21
|
+
"endHour": "16:00"
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
You can use an explicit `date` such as `2026-08-24` instead of `dayOffset`. An end time earlier than the start time represents an overnight range. When a range is specified, the server requests `basic-1h` automatically and returns only the hourly data inside that range.
|
|
26
|
+
|
|
13
27
|
> [!NOTE]
|
|
14
28
|
> meteoblue's current Free Weather API documentation says that images require a higher access level. The image tool is included for keys with an Image API entitlement and will return a clear authorization error otherwise.
|
|
15
29
|
|
|
@@ -46,6 +60,39 @@ METEOBLUE_API_KEY=your_api_key_here npx -y mcp-meteoblue
|
|
|
46
60
|
|
|
47
61
|
The server uses stdio, so it intentionally produces no normal terminal output while it waits for an MCP client.
|
|
48
62
|
|
|
63
|
+
## Docker
|
|
64
|
+
|
|
65
|
+
Build the image locally:
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
docker build -t mcp-meteoblue .
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Configure a Docker-based stdio server in your MCP client:
|
|
72
|
+
|
|
73
|
+
```json
|
|
74
|
+
{
|
|
75
|
+
"mcpServers": {
|
|
76
|
+
"meteoblue": {
|
|
77
|
+
"command": "docker",
|
|
78
|
+
"args": [
|
|
79
|
+
"run",
|
|
80
|
+
"--rm",
|
|
81
|
+
"-i",
|
|
82
|
+
"--env",
|
|
83
|
+
"METEOBLUE_API_KEY",
|
|
84
|
+
"mcp-meteoblue"
|
|
85
|
+
],
|
|
86
|
+
"env": {
|
|
87
|
+
"METEOBLUE_API_KEY": "your_api_key_here"
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The container runs as the unprivileged `node` user. Keep stdin open with `-i` because MCP communication uses stdio.
|
|
95
|
+
|
|
49
96
|
## Develop locally
|
|
50
97
|
|
|
51
98
|
```sh
|
|
@@ -58,9 +105,9 @@ Point an MCP client at `node /absolute/path/to/mcp-meteoblue/src/index.js` and s
|
|
|
58
105
|
|
|
59
106
|
## Releases
|
|
60
107
|
|
|
61
|
-
GitHub Actions runs the test suite and validates the npm tarball on Node.js 20, 22, 24, and 26 for every push and pull request.
|
|
108
|
+
GitHub Actions runs the test suite and validates the npm tarball on Node.js 20, 22, 24, and 26 for every push and pull request. Dependabot checks npm and GitHub Actions dependencies weekly.
|
|
62
109
|
|
|
63
|
-
|
|
110
|
+
Pushing a `v*` Git tag triggers `.github/workflows/publish.yml`. The workflow uses npm trusted publishing (OIDC) and automatically attaches provenance. Configure the npm trusted publisher with:
|
|
64
111
|
|
|
65
112
|
- GitHub owner: `unixfox`
|
|
66
113
|
- Repository: `mcp-meteoblue`
|
package/package.json
CHANGED
package/src/meteoblue.js
CHANGED
|
@@ -93,7 +93,7 @@ export class MeteoblueClient {
|
|
|
93
93
|
async #fetch(url) {
|
|
94
94
|
try {
|
|
95
95
|
return await this.fetch(url, {
|
|
96
|
-
headers: { "user-agent": "mcp-meteoblue/1.0.
|
|
96
|
+
headers: { "user-agent": "mcp-meteoblue/1.0.2" },
|
|
97
97
|
signal: AbortSignal.timeout(this.timeoutMs)
|
|
98
98
|
});
|
|
99
99
|
} catch (error) {
|
package/src/server.js
CHANGED
|
@@ -7,6 +7,7 @@ const latitude = z.number().min(-90).max(90).optional().describe("WGS84 latitude
|
|
|
7
7
|
const longitude = z.number().min(-180).max(180).optional().describe("WGS84 longitude; required with latitude when location is omitted");
|
|
8
8
|
const elevation = z.number().min(-500).max(9000).optional().describe("Elevation above sea level in metres");
|
|
9
9
|
const location = z.string().min(2).optional().describe("Place name, postal code, IATA, or ICAO code; resolved with meteoblue Location Search");
|
|
10
|
+
const hour = z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/).optional();
|
|
10
11
|
|
|
11
12
|
function textResult(value) {
|
|
12
13
|
return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }] };
|
|
@@ -35,9 +36,93 @@ async function coordinatesFor(client, input) {
|
|
|
35
36
|
return { latitude: input.latitude, longitude: input.longitude, elevation: input.elevation };
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
function minutesSinceMidnight(value) {
|
|
40
|
+
const [hours, minutes] = value.split(":").map(Number);
|
|
41
|
+
return hours * 60 + minutes;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function datePart(value) {
|
|
45
|
+
return value.slice(0, 10);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function timePart(value) {
|
|
49
|
+
const match = value.match(/(?:T|\s)(\d{2}):(\d{2})/);
|
|
50
|
+
return match ? `${match[1]}:${match[2]}` : undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function addDays(isoDate, days) {
|
|
54
|
+
const date = new Date(`${isoDate}T00:00:00Z`);
|
|
55
|
+
date.setUTCDate(date.getUTCDate() + days);
|
|
56
|
+
return date.toISOString().slice(0, 10);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function filterHourlyForecast(forecast, { startHour, endHour, date, dayOffset }) {
|
|
60
|
+
const hourly = forecast.data_1h;
|
|
61
|
+
if (!hourly?.time?.length) {
|
|
62
|
+
throw new Error("meteoblue returned no hourly data for the requested range");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const availableDates = [...new Set(hourly.time.map(datePart))];
|
|
66
|
+
const targetDate = date ?? (dayOffset !== undefined ? availableDates[dayOffset] : undefined);
|
|
67
|
+
if ((date || dayOffset !== undefined) && !targetDate) {
|
|
68
|
+
throw new Error("The requested date is outside the returned forecast period");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const startMinutes = minutesSinceMidnight(startHour);
|
|
72
|
+
const endMinutes = minutesSinceMidnight(endHour);
|
|
73
|
+
const crossesMidnight = endMinutes < startMinutes;
|
|
74
|
+
const nextDate = targetDate && crossesMidnight ? addDays(targetDate, 1) : undefined;
|
|
75
|
+
const selectedIndices = [];
|
|
76
|
+
|
|
77
|
+
for (const [index, timestamp] of hourly.time.entries()) {
|
|
78
|
+
const rowDate = datePart(timestamp);
|
|
79
|
+
const rowTime = timePart(timestamp);
|
|
80
|
+
if (!rowTime) continue;
|
|
81
|
+
const rowMinutes = minutesSinceMidnight(rowTime);
|
|
82
|
+
|
|
83
|
+
let selected;
|
|
84
|
+
if (targetDate) {
|
|
85
|
+
selected = crossesMidnight
|
|
86
|
+
? (rowDate === targetDate && rowMinutes >= startMinutes) || (rowDate === nextDate && rowMinutes <= endMinutes)
|
|
87
|
+
: rowDate === targetDate && rowMinutes >= startMinutes && rowMinutes <= endMinutes;
|
|
88
|
+
} else {
|
|
89
|
+
selected = crossesMidnight
|
|
90
|
+
? rowMinutes >= startMinutes || rowMinutes <= endMinutes
|
|
91
|
+
: rowMinutes >= startMinutes && rowMinutes <= endMinutes;
|
|
92
|
+
}
|
|
93
|
+
if (selected) selectedIndices.push(index);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!selectedIndices.length) {
|
|
97
|
+
throw new Error("No hourly forecast data falls within the requested range");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const filteredHourly = Object.fromEntries(
|
|
101
|
+
Object.entries(hourly).map(([key, value]) => [
|
|
102
|
+
key,
|
|
103
|
+
Array.isArray(value) && value.length === hourly.time.length
|
|
104
|
+
? selectedIndices.map((index) => value[index])
|
|
105
|
+
: value
|
|
106
|
+
])
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
metadata: forecast.metadata,
|
|
111
|
+
units: forecast.units,
|
|
112
|
+
requestedRange: {
|
|
113
|
+
startHour,
|
|
114
|
+
endHour,
|
|
115
|
+
...(targetDate ? { date: targetDate } : {}),
|
|
116
|
+
inclusive: true,
|
|
117
|
+
crossesMidnight
|
|
118
|
+
},
|
|
119
|
+
data_1h: filteredHourly
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
38
123
|
export function createServer({ apiKey = process.env.METEOBLUE_API_KEY, client } = {}) {
|
|
39
124
|
const meteoblue = client || new MeteoblueClient({ apiKey });
|
|
40
|
-
const server = new McpServer({ name: "mcp-meteoblue", version: "1.0.
|
|
125
|
+
const server = new McpServer({ name: "mcp-meteoblue", version: "1.0.2" });
|
|
41
126
|
|
|
42
127
|
server.registerTool(
|
|
43
128
|
"search_locations",
|
|
@@ -65,7 +150,7 @@ export function createServer({ apiKey = process.env.METEOBLUE_API_KEY, client }
|
|
|
65
150
|
"get_forecast",
|
|
66
151
|
{
|
|
67
152
|
title: "Get meteoblue weather forecast",
|
|
68
|
-
description: "Get meteoblue forecast JSON by place name or coordinates. Place names are resolved through the Location Search API.
|
|
153
|
+
description: "Get meteoblue forecast JSON by place name or coordinates. Place names are resolved through the Location Search API. For a specific time window, provide startHour and endHour plus either date or dayOffset; the tool then returns only inclusive basic-1h rows in that range.",
|
|
69
154
|
inputSchema: {
|
|
70
155
|
location,
|
|
71
156
|
latitude,
|
|
@@ -79,13 +164,39 @@ export function createServer({ apiKey = process.env.METEOBLUE_API_KEY, client }
|
|
|
79
164
|
timezone: z.string().optional().describe("IANA timezone such as Europe/Paris; auto-detected when omitted"),
|
|
80
165
|
temperatureUnit: z.enum(["C", "K", "F"]).default("C"),
|
|
81
166
|
windSpeedUnit: z.enum(["m/s", "km/h", "mph", "kn", "bft"]).default("km/h"),
|
|
82
|
-
precipitationUnit: z.enum(["metric", "imperial"]).default("metric")
|
|
167
|
+
precipitationUnit: z.enum(["metric", "imperial"]).default("metric"),
|
|
168
|
+
startHour: hour.describe("Inclusive local start time in HH:mm format, for example 14:00; must be used with endHour"),
|
|
169
|
+
endHour: hour.describe("Inclusive local end time in HH:mm format, for example 16:00; must be used with startHour"),
|
|
170
|
+
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Local forecast date in YYYY-MM-DD format for the hour range"),
|
|
171
|
+
dayOffset: z.number().int().min(0).max(6).optional().describe("Forecast day for the hour range: 0 is today/first returned day, 1 is tomorrow; use instead of date")
|
|
83
172
|
}
|
|
84
173
|
},
|
|
85
174
|
async (input) => {
|
|
86
175
|
try {
|
|
176
|
+
const hasHourRange = input.startHour !== undefined || input.endHour !== undefined;
|
|
177
|
+
if (hasHourRange && (!input.startHour || !input.endHour)) {
|
|
178
|
+
throw new Error("startHour and endHour must be provided together");
|
|
179
|
+
}
|
|
180
|
+
if (!hasHourRange && (input.date || input.dayOffset !== undefined)) {
|
|
181
|
+
throw new Error("date and dayOffset can only be used with startHour and endHour");
|
|
182
|
+
}
|
|
183
|
+
if (input.date && input.dayOffset !== undefined) {
|
|
184
|
+
throw new Error("Use either date or dayOffset, not both");
|
|
185
|
+
}
|
|
186
|
+
|
|
87
187
|
const coords = await coordinatesFor(meteoblue, input);
|
|
88
|
-
const
|
|
188
|
+
const request = hasHourRange
|
|
189
|
+
? {
|
|
190
|
+
...input,
|
|
191
|
+
packages: ["basic-1h"],
|
|
192
|
+
forecastDays: input.dayOffset !== undefined
|
|
193
|
+
? Math.max(input.forecastDays, input.dayOffset + 1)
|
|
194
|
+
: input.forecastDays,
|
|
195
|
+
...coords
|
|
196
|
+
}
|
|
197
|
+
: { ...input, ...coords };
|
|
198
|
+
const rawForecast = await meteoblue.getForecast(request);
|
|
199
|
+
const forecast = hasHourRange ? filterHourlyForecast(rawForecast, input) : rawForecast;
|
|
89
200
|
return textResult({ resolvedLocation: coords.resolvedLocation, forecast });
|
|
90
201
|
} catch (error) {
|
|
91
202
|
return errorResult(error);
|