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 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
- Publishing a GitHub release triggers `.github/workflows/publish.yml`. The workflow uses npm trusted publishing (OIDC) and automatically attaches provenance. Configure the npm trusted publisher with:
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-meteoblue",
3
- "version": "1.0.0",
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);