mcp-meteoblue 1.0.1 → 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 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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-meteoblue",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "MCP server for meteoblue forecast, forecast image, and location search APIs",
5
5
  "type": "module",
6
6
  "bin": {
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.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.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. Only packages documented for the Free Weather API are accepted.",
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 forecast = await meteoblue.getForecast({ ...input, ...coords });
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);