#
tokens: 35342/50000 9/9 files
lines: on (toggle) GitHub
raw markdown copy reset
# Directory Structure

```
├── CLAUDE.md
├── LICENSE
├── mcp_client_config.json
├── README.md
├── requirements.txt
├── test_mcp_client.py
├── test_mcp_integration.py
├── test_weather_mcp.py
├── test_weather_response.json
└── weather_mcp_server.py
```

# Files

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
  1 | # 🌦️ Weekly Weather MCP Server
  2 | 
  3 | A weather forecast MCP (Model Context Protocol) server providing **8-day global weather forecasts** and current weather conditions using the [OpenWeatherMap](https://openweathermap.org) [One Call API 3.0](https://openweathermap.org/api/one-call-3).
  4 | 
  5 | > This project builds upon an earlier project by [Zippland](https://github.com/Zippland/weather-mcp), with modifications to support full week forecasts and additional time-of-day data points.
  6 | 
  7 | <div align="center">
  8 |   <img src="https://rossshannon.github.io/weekly-weather-mcp/images/weather-mcp-thinking.gif" alt="Claude calling MCP server" width="800">
  9 |   <p><em>Animation showing Claude Desktop processing the weather data from the MCP Server</em></p>
 10 | </div>
 11 | <br>
 12 | <div align="center">
 13 |   <img src="https://rossshannon.github.io/weekly-weather-mcp/images/weather-forecast-example.png" alt="Claude displaying weather forecast" width="700">
 14 |   <p><em>Claude Desktop showing a detailed weather forecast with lawn mowing recommendations</em></p>
 15 | </div>
 16 | 
 17 | ## Features
 18 | 
 19 | - 🌍 Support for querying weather conditions anywhere in the world
 20 | - 🌤️ Hourly forecasts for the next 48 hours
 21 | - 📅 Provides detailed 8-day forecasts (today + following 7 days), with morning, afternoon, and evening data points
 22 | - 🌧️ Weather summaries and precipitation probabilities
 23 | - 🌡️ Detailed weather information including temperature, humidity, wind speed, etc.
 24 | - 📍 Support for reporting results in different time zones
 25 | - 🗂️ No separate configuration file needed; API key can be passed directly through environment variables or parameters
 26 | 
 27 | ## Usage
 28 | 
 29 | ### 1. Get an OpenWeatherMap API Key with One Call API 3.0 Access (free)
 30 | 
 31 | 1. Visit [OpenWeatherMap](https://openweathermap.org/) and register an account
 32 | 2. Subscribe to the “One Call API 3.0” plan (offers 1,000 API calls per day for free)
 33 | 3. Wait for API key activation (this can take up to an hour)
 34 | 
 35 | #### About the One Call API 3.0
 36 | 
 37 | The One Call API 3.0 provides comprehensive weather data:
 38 | - Current weather conditions
 39 | - Minute forecast for 1 hour
 40 | - Hourly forecast for 48 hours
 41 | - Daily forecast for 8 days (including today)
 42 | - National weather alerts
 43 | - Historical weather data
 44 | 
 45 | #### API Usage and Limits
 46 | 
 47 | - **Free tier**: 1,000 API calls per day
 48 | - **Default limit**: 2,000 API calls per day (can be adjusted in your account)
 49 | - **Billing**: Any calls beyond the free 1,000/day will be charged according to OpenWeatherMap pricing
 50 | - **Usage cap**: You can set a call limit in your account to prevent exceeding your budget (including capping your usage at the free tier limit so no costs can be incurred)
 51 | - If you reach your limit, you’ll receive a HTTP 429 error response
 52 | 
 53 | > **Note**: API key activation can take several minutes up to an hour. If you receive authentication errors shortly after subscribing or generating a new key, wait a bit and try again later.
 54 | 
 55 | ### 2. Clone the Repository and Install Dependencies
 56 | 
 57 | ```bash
 58 | # Clone the repository
 59 | git clone https://github.com/rossshannon/weekly-weather-mcp.git
 60 | cd weekly-weather-mcp
 61 | 
 62 | # Create a virtual environment (recommended)
 63 | python3 -m venv venv
 64 | source venv/bin/activate  # Linux/Mac
 65 | # OR
 66 | venv\Scripts\activate  # Windows
 67 | 
 68 | # Install dependencies
 69 | pip3 install -r requirements.txt
 70 | ```
 71 | 
 72 | This will install all the necessary dependencies to run the server and development tools.
 73 | 
 74 | ### 3. Run the Server
 75 | 
 76 | There are two ways to provide the API key:
 77 | 
 78 | #### Method 1: Using Environment Variables
 79 | 
 80 | ```bash
 81 | # Set environment variables
 82 | export OPENWEATHER_API_KEY="your_api_key"  # Linux/Mac
 83 | set OPENWEATHER_API_KEY=your_api_key  # Windows
 84 | 
 85 | # Run the server
 86 | python weather_mcp_server.py
 87 | ```
 88 | 
 89 | #### Method 2: Provide When Calling the Tool
 90 | 
 91 | Run directly without setting environment variables:
 92 | 
 93 | ```bash
 94 | python weather_mcp_server.py
 95 | ```
 96 | 
 97 | When calling the tool, you’ll need to provide the `api_key` parameter.
 98 | 
 99 | ### 4. Use in MCP Client Configuration
100 | 
101 | Add the following configuration to your MCP-supported client (e.g., [Claude Desktop](https://www.anthropic.com/claude-desktop) ([instructions](https://modelcontextprotocol.io/quickstart/user)), [Cursor](https://www.cursor.com/)):
102 | 
103 | ```json
104 | {
105 |   "weather_forecast": {
106 |     "command": "python3",
107 |     "args": [
108 |       "/full_path/weather_mcp_server.py"
109 |     ],
110 |     "env": {
111 |       "OPENWEATHER_API_KEY": "your_openweathermap_key_here"
112 |     },
113 |     "disabled": false,
114 |     "autoApprove": ["get_weather", "get_current_weather"]
115 |   }
116 | }
117 | ```
118 | 
119 | If you’re using a virtual environment, your configuration should include the full path to the Python executable in the virtual environment:
120 | 
121 | ```json
122 | {
123 |   "weather_forecast": {
124 |     "command": "/full_path/venv/bin/python3",
125 |     "args": [
126 |       "/full_path/weather_mcp_server.py"
127 |     ],
128 |     "env": {
129 |       "OPENWEATHER_API_KEY": "your_openweathermap_key_here"
130 |     },
131 |     "disabled": false,
132 |     "autoApprove": ["get_weather", "get_current_weather"]
133 |   }
134 | }
135 | ```
136 | 
137 | ### 5. Available Tools
138 | 
139 | The server exposes two tools, `get_weather` and `get_current_weather`. Both tools accept the same parameters:
140 | 
141 | - `location`: Location name as a string, e.g., “Beijing”, “New York”, “Tokyo”. The tool will handle geocoding this to a latitude/longitude coordinate.
142 | - `api_key`: OpenWeatherMap API key (optional, will read from environment variable if not provided)
143 | - `timezone_offset`: Timezone offset in hours, e.g., 8 for Beijing, -4 for New York. Default is 0 (UTC time). Times in the returned data will be accurate for this timezone.
144 | 
145 | #### get_weather
146 | 
147 | Get comprehensive weather data for a location including current weather (next 48 hours) and 8-day forecast with detailed information.
148 | 
149 | Returns:
150 | - Current weather information
151 | - Hourly forecasts for the next 48 hours
152 | - Daily forecasts for 8 days (today + 7 days ahead)
153 | - Morning (9 AM), afternoon (3 PM), and evening (8 PM) data points for each day
154 | - Weather summaries and precipitation probabilities
155 | - Detailed weather information including temperature, humidity, wind speed, etc.
156 | 
157 | Perfect for use cases like:
158 | - “🏃‍♂️ Which days this week should I go for a run?”
159 | - “🪴 When’s the best evening to work in my garden this week?”
160 | - “🪁 What’s the windiest day coming up soon for flying a kite?”
161 | - “💧 Will I need to water my garden this week or will rain take care of it?”
162 | 
163 | #### get_current_weather
164 | 
165 | Get current weather for a specified location.
166 | 
167 | Returns:
168 | - A simplified subset of the data returned by `get_weather`
169 | - Only the current weather information (temperature, feels like, weather condition, humidity, wind, etc.); no forecast data for future time periods
170 | - Useful for quick queries about present conditions only
171 | 
172 | ##### Location Lookup Details
173 | 
174 | The `location` parameter uses OpenWeatherMap’s geocoding to convert location names to geographic coordinates:
175 | 
176 | - Simple location names work: “Paris”, “Tokyo”, “New York”
177 | - For better accuracy, include country codes: “Paris,FR”, “London,GB”, “Portland,US”
178 | - For US cities, you can include state: “Portland,OR,US” or “Portland,ME,US”
179 | - The API supports any location on Earth that OpenWeatherMap can geocode
180 | - Location names are converted to latitude/longitude coordinates internally
181 | 
182 | If a location can’t be found, the API will return an error. In case of ambiguous locations, try adding country or state codes for more precise results.
183 | 
184 | ## Usage Examples
185 | 
186 | ### Example 1: Current Weather
187 | 
188 | ```
189 | User: What’s the weather like in New York right now?
190 | 
191 | AI: Let me check the current weather in New York for you.
192 | [Calling get_current_weather("New York", timezone_offset=-4)]
193 | 
194 | Current weather in New York: 5°C, few clouds, humidity 42%, wind speed 4.1m/s.
195 | ```
196 | 
197 | ### Example 2: Weekly Planning
198 | 
199 | ```
200 | User: I need to mow my lawn this week in Boston. Which day would be best?
201 | 
202 | AI: Let me check the weather forecast for Boston to find the best day for lawn mowing.
203 | [Calling get_weather("Boston", timezone_offset=-4)]
204 | 
205 | Looking at the Boston forecast for the week:
206 | - Today (Monday): Light rain (28% chance), 5°C
207 | - Tuesday: Clear, sunny, 10°C
208 | - Wednesday: Light rain (100% chance), 9°C
209 | - Thursday: Moderate rain (100% chance), 10°C
210 | - Friday: Moderate rain (100% chance), 11°C
211 | - Saturday: Partly cloudy, 13°C
212 | - Sunday: Scattered clouds, 17°C
213 | 
214 | Tuesday would be your best option for mowing the lawn. It will be clear and sunny with no chance of rain, and the temperature will be comfortable at around 10°C.
215 | ```
216 | 
217 | You can combine this MCP server with others to achieve multi-step workflows. For example, once the weather has been checked, you can also tell Claude to add that as an event in your calendar to remind yourself of those plans.
218 | 
219 | <div align="center">
220 |   <img src="https://rossshannon.github.io/weekly-weather-mcp/images/calendar-integration-example.png" alt="Calendar event created by Claude" width="365">
221 |   <p><em>Calendar event created by Claude based on the weather forecast</em></p>
222 | </div>
223 | 
224 | ## Troubleshooting
225 | 
226 | ### API Key Issues
227 | 
228 | If you encounter an “Invalid API key” or authorization error:
229 | 1. Make sure you’ve subscribed to the “One Call API 3.0” plan. You’ll need a debit or credit card to enable your account, but you’ll only be charged if you exceed the free tier limit.
230 | 2. Remember that API key activation can take up to an hour
231 | 3. Verify you have set the `OPENWEATHER_API_KEY` correctly in environment variables, or check that you’re providing the correct `api_key` parameter when calling the tools
232 | 
233 | ### Other Common Issues
234 | 
235 | - **“Location not found” error**:
236 |   - Check for typos in location names
237 |   - Some very small or remote locations might not be in OpenWeatherMap’s database
238 | 
239 | - **Incorrect location returned**:
240 |   - Try using a more accurate city name or add a country code, e.g., “Beijing,CN” or “Porto,PT”
241 |   - For US cities with common names, specify the state: “Springfield,IL,US” or “Portland,OR,US”
242 |   - For cities with the same name in different countries, always include the country code and state if applicable: “Paris,FR” for Paris, France vs “Paris,TX,US” for Paris, Texas, USA.
243 | 
244 | - **Rate limiting (429 error)**: You’ve exceeded your API call limit. Check your OpenWeatherMap account settings.
245 | 
246 | ## Development and Testing
247 | 
248 | ### Testing
249 | 
250 | This project includes unit tests, integration tests, and mock client test files to validate the MCP server functionality. The server has been manually tested to ensure it works correctly with Claude Desktop, Cursor, and other MCP clients.
251 | 
252 | #### Manual Client Testing
253 | 
254 | Before configuring the server with Claude Desktop or other MCP clients, you can use the included test script to verify your API key and installation:
255 | 
256 | 1. Set your OpenWeatherMap API key:
257 |    ```bash
258 |    export OPENWEATHER_API_KEY="your_api_key"
259 |    ```
260 | 
261 | 2. Run the test client:
262 |    ```bash
263 |    python3 test_mcp_client.py
264 |    ```
265 | 
266 | The test script directly calls the weather functions to check the current weather in New York and displays the results. This helps verify that:
267 | 1. Your API key is working properly
268 | 2. The OpenWeatherMap API is accessible
269 | 3. The weather data functions are operational
270 | 
271 | If the test shows current weather data, you’re ready to configure the server with Claude Desktop, Cursor, or other MCP clients!
272 | 
273 | <div align="center">
274 |   <img src="https://rossshannon.github.io/weekly-weather-mcp/images/weather-mcp-test-client.png" alt="Running local test client" width="800">
275 |   <p><em>Running local test client to verify API key and installation</em></p>
276 | </div>
277 | 
278 | #### Automated Tests
279 | 
280 | The repository includes unit and integration test files that:
281 | - Test API key handling and validation
282 | - Validate data parsing and formatting
283 | - Verify error handling for API failures
284 | - Test both exposed MCP tools: `get_weather` and `get_current_weather`
285 | 
286 | These tests require proper setup of the development environment with all dependencies installed. They’re provided as reference for future development.
287 | 
288 | To run the automated tests:
289 | 
290 | ```bash
291 | # Run unit tests
292 | python test_weather_mcp.py
293 | 
294 | # Run integration tests
295 | python test_mcp_integration.py
296 | ```
297 | 
298 | The tests use a sample API response (`test_weather_response.json`) to simulate responses from the OpenWeatherMap API, so they can be run without an API key or internet connection.
299 | 
300 | These tests are provided as reference for future development and to ensure the MCP server continues to function correctly after any modifications.
301 | 
302 | ## Credits
303 | 
304 | This project is adapted from an original [Weather MCP](https://github.com/Zippland/weather-mcp) by Zippland. The modifications include:
305 | 
306 | - Integration with OpenWeatherMap One Call API 3.0
307 | - Extended forecast data from 2 days to 8 days (today + 7 days)
308 | - Addition of morning, afternoon and evening data points for each day
309 | - Hourly forecasts for the next 48 hours
310 | - Inclusion of weather summaries, wind speed, and precipitation probabilities
311 | - Unit tests, integration tests, and mock client test files
312 | 
```

--------------------------------------------------------------------------------
/CLAUDE.md:
--------------------------------------------------------------------------------

```markdown
 1 | # CLAUDE.md
 2 | 
 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
 4 | 
 5 | ## Commands
 6 | - Run server: `python3 weather_mcp_server.py`
 7 | - Install dependencies: `pip3 install mcp-server requests pydantic`
 8 | - Format code: `black --skip-string-normalization weather_mcp_server.py`
 9 | - Typecheck: `mypy weather_mcp_server.py --strict`
10 | - Run test client: `python3 test_mcp_client.py`
11 | 
12 | ## Style Guidelines
13 | - **Python Version**: 3.6+
14 | - **Code Style**: PEP 8 compliant
15 | - **Imports**: Group standard library, third-party, and local imports with newlines between groups
16 | - **Type Hints**: Use type annotations for all function parameters and return values
17 | - **Error Handling**: Use try/except blocks with specific exception types
18 | - **Docstrings**: Use triple-quoted docstrings for all functions and classes
19 | - **Naming**: Use snake_case for variables and functions, PascalCase for classes
20 | - **API Key Handling**: Never hardcode API keys; use environment variables or parameters
21 | 
22 | ## Architecture Notes
23 | - Single-file MCP server based on FastMCP for OpenWeatherMap API
24 | - Pydantic models for data validation and serialization
25 | - Functions handle API requests with proper error handling
26 | 
```

--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------

```
 1 | # Core dependencies - required for running the application
 2 | mcp-server>=0.1.3
 3 | fastmcp>=0.4.1
 4 | requests>=2.32.0
 5 | pydantic>=2.0.0
 6 | 
 7 | # Development dependencies - required for testing, formatting, and type checking
 8 | pytest>=7.0.0
 9 | black>=23.0.0
10 | mypy>=1.0.0
11 | 
```

--------------------------------------------------------------------------------
/mcp_client_config.json:
--------------------------------------------------------------------------------

```json
 1 | {
 2 |   "weather_forecast": {
 3 |     "command": "python",
 4 |     "args": [
 5 |       "weather_mcp_server.py"
 6 |     ],
 7 |     "env": {
 8 |       "OPENWEATHER_API_KEY": "your_openweathermap_api_key_here"
 9 |     },
10 |     "disabled": false,
11 |     "autoApprove": ["get_weather", "get_current_weather"]
12 |   }
13 | } 
```

--------------------------------------------------------------------------------
/test_mcp_client.py:
--------------------------------------------------------------------------------

```python
 1 | #!/usr/bin/env python3
 2 | """
 3 | Simple test script for the Weather MCP Server
 4 | 
 5 | This script directly imports the functions from the weather_mcp_server.py
 6 | file and tests them without going through the MCP server protocol.
 7 | """
 8 | import os
 9 | import sys
10 | from weather_mcp_server import get_current_weather, get_weather
11 | 
12 | def main():
13 |     """Test the weather server functions directly"""
14 |     # Check if API key is set
15 |     api_key = os.environ.get("OPENWEATHER_API_KEY")
16 |     if not api_key:
17 |         print("Error: OPENWEATHER_API_KEY environment variable not set")
18 |         print("Please set the environment variable and try again:")
19 |         print("  export OPENWEATHER_API_KEY=your_key_here")
20 |         return
21 |     print("Using API key from environment variables")
22 | 
23 |     # Location to test
24 |     location = "New York"
25 |     timezone_offset = -4
26 |     
27 |     print(f"\nTesting get_current_weather for {location}...")
28 |     try:
29 |         result = get_current_weather(location, api_key, timezone_offset)
30 |         
31 |         if 'error' in result:
32 |             print(f"Error: {result['error']}")
33 |         else:
34 |             print("\nCurrent Weather:")
35 |             print(f"Temperature: {result['temperature']}")
36 |             print(f"Conditions: {result['weather_condition']}")
37 |             print(f"Humidity: {result['humidity']}")
38 |             print(f"Wind: {result['wind']['speed']} at {result['wind']['direction']}")
39 |     except Exception as e:
40 |         print(f"Error during execution: {str(e)}")
41 |         import traceback
42 |         traceback.print_exc()
43 |     
44 |     print(f"\nTesting get_weather (8-day forecast) for {location}...")
45 |     try:
46 |         result = get_weather(location, api_key, timezone_offset)
47 |         
48 |         if 'error' in result:
49 |             print(f"Error: {result['error']}")
50 |         else:
51 |             print("\nWeather Forecast Summary:")
52 |             print(f"Current Temperature: {result['current']['temperature']}")
53 |             print(f"Current Conditions: {result['current']['weather_condition']}")
54 |             print(f"\nForecasted Days: {len(result['daily_forecasts'])}")
55 |             
56 |             # Print a summary of each day's forecast
57 |             print("\nDaily Forecasts:")
58 |             for i, day in enumerate(result['daily_forecasts']):
59 |                 print(f"Day {i+1} ({day['date']}): {day['summary']}")
60 |                 
61 |                 # Find the afternoon entry for a temperature estimate
62 |                 afternoon_entries = [entry for entry in day['entries'] 
63 |                                     if '15:00:00' in entry['time']]
64 |                 if afternoon_entries:
65 |                     temp = afternoon_entries[0]['temperature']
66 |                     condition = afternoon_entries[0]['weather_condition']
67 |                     pop = afternoon_entries[0].get('pop', 'N/A')
68 |                     print(f"  Afternoon: {temp}, {condition}, Precip: {pop}")
69 |     except Exception as e:
70 |         print(f"Error during execution: {str(e)}")
71 |         import traceback
72 |         traceback.print_exc()
73 |     
74 |     print("\nTest complete")
75 | 
76 | if __name__ == "__main__":
77 |     main()
```

--------------------------------------------------------------------------------
/test_mcp_integration.py:
--------------------------------------------------------------------------------

```python
  1 | #!/usr/bin/env python3
  2 | """
  3 | Integration tests for the Weather MCP Server
  4 | """
  5 | 
  6 | import unittest
  7 | from unittest.mock import patch, MagicMock
  8 | import json
  9 | import os
 10 | import sys
 11 | 
 12 | # Try to import weather_mcp_server, but if mcp package is missing, mock it
 13 | try:
 14 |     import weather_mcp_server
 15 | except ModuleNotFoundError:
 16 |     # Create a mock for the FastMCP class
 17 |     sys.modules['mcp'] = MagicMock()
 18 |     sys.modules['mcp.server'] = MagicMock()
 19 |     sys.modules['mcp.server.fastmcp'] = MagicMock()
 20 |     sys.modules['mcp.server.fastmcp'].FastMCP = MagicMock()
 21 | 
 22 |     # Now import should work
 23 |     import weather_mcp_server
 24 | 
 25 | 
 26 | class TestMCPIntegration(unittest.TestCase):
 27 |     """Test the MCP server integration functionality"""
 28 | 
 29 |     def setUp(self):
 30 |         """Set up test environment"""
 31 |         # Clear environment variable to ensure tests control it
 32 |         if "OPENWEATHER_API_KEY" in os.environ:
 33 |             self.original_api_key = os.environ["OPENWEATHER_API_KEY"]
 34 |             del os.environ["OPENWEATHER_API_KEY"]
 35 |         else:
 36 |             self.original_api_key = None
 37 | 
 38 |         # Sample test data
 39 |         self.test_location = "New York"
 40 |         self.test_api_key = "test_api_key_123"
 41 |         self.test_timezone_offset = -4
 42 | 
 43 |         # Load sample response data
 44 |         with open("test_weather_response.json") as f:
 45 |             self.sample_onecall_data = json.load(f)
 46 | 
 47 |     def tearDown(self):
 48 |         """Clean up after tests"""
 49 |         # Restore original environment if it existed
 50 |         if self.original_api_key is not None:
 51 |             os.environ["OPENWEATHER_API_KEY"] = self.original_api_key
 52 |         elif "OPENWEATHER_API_KEY" in os.environ:
 53 |             del os.environ["OPENWEATHER_API_KEY"]
 54 | 
 55 |     @patch('requests.get')
 56 |     def test_mcp_get_weather_tool(self, mock_get):
 57 |         """Test the MCP get_weather tool with a simulated API response"""
 58 |         # Sample locations response
 59 |         locations_response = MagicMock()
 60 |         locations_response.status_code = 200
 61 |         locations_response.json.return_value = {
 62 |             "coord": {"lon": -74.006, "lat": 40.7128},
 63 |             "weather": [{"id": 800, "main": "Clear", "description": "clear sky", "icon": "01d"}],
 64 |             "main": {
 65 |                 "temp": 5.46, "feels_like": 0.42, "temp_min": 4.0, "temp_max": 6.5,
 66 |                 "pressure": 1006, "humidity": 36
 67 |             },
 68 |             "wind": {"speed": 9.17, "deg": 298},
 69 |             "clouds": {"all": 20},
 70 |             "name": "New York"
 71 |         }
 72 | 
 73 |         # Sample forecast response
 74 |         forecast_response = MagicMock()
 75 |         forecast_response.status_code = 200
 76 |         forecast_response.json.return_value = {
 77 |             "list": [
 78 |                 # Today's forecast entries
 79 |                 {
 80 |                     "dt": 1744128000,  # Today
 81 |                     "main": {
 82 |                         "temp": 5.0, "feels_like": 2.0, "temp_min": 4.0, "temp_max": 6.0,
 83 |                         "humidity": 40, "pressure": 1006
 84 |                     },
 85 |                     "weather": [{"description": "clear sky"}],
 86 |                     "clouds": {"all": 10},
 87 |                     "wind": {"speed": 5.0, "deg": 270}
 88 |                 },
 89 |                 # Tomorrow's forecast entries
 90 |                 {
 91 |                     "dt": 1744214400,  # Tomorrow
 92 |                     "main": {
 93 |                         "temp": 6.0, "feels_like": 3.0, "temp_min": 5.0, "temp_max": 7.0,
 94 |                         "humidity": 45, "pressure": 1008
 95 |                     },
 96 |                     "weather": [{"description": "scattered clouds"}],
 97 |                     "clouds": {"all": 30},
 98 |                     "wind": {"speed": 4.5, "deg": 280}
 99 |                 }
100 |             ],
101 |             "city": {"name": "New York"}
102 |         }
103 | 
104 |         # Mock the geocoding API response
105 |         geocoding_response = MagicMock()
106 |         geocoding_response.status_code = 200
107 |         geocoding_response.json.return_value = [
108 |             {"name": "New York", "lat": 40.7128, "lon": -74.0060}
109 |         ]
110 | 
111 |         # Mock the One Call API response
112 |         onecall_response = MagicMock()
113 |         onecall_response.status_code = 200
114 |         onecall_response.json.return_value = {
115 |             "lat": 40.7128,
116 |             "lon": -74.0060,
117 |             "timezone": "America/New_York",
118 |             "timezone_offset": -14400,
119 |             "current": {
120 |                 "dt": 1617979000,
121 |                 "temp": 15.2,
122 |                 "feels_like": 14.3,
123 |                 "humidity": 76,
124 |                 "wind_speed": 2.06,
125 |                 "wind_deg": 210,
126 |                 "weather": [{"description": "clear sky"}],
127 |                 "clouds": 1
128 |             },
129 |             "daily": [
130 |                 {
131 |                     "dt": 1617979000,
132 |                     "temp": {"day": 15.0, "min": 9.0, "max": 17.0, "eve": 13.0, "morn": 10.0},
133 |                     "feels_like": {"day": 14.3, "night": 8.5, "eve": 12.5, "morn": 9.5},
134 |                     "humidity": 76,
135 |                     "wind_speed": 2.06,
136 |                     "wind_deg": 210,
137 |                     "weather": [{"description": "clear sky"}],
138 |                     "clouds": 1,
139 |                     "pop": 0.2,
140 |                     "summary": "Nice day"
141 |                 }
142 |             ],
143 |             "hourly": [
144 |                 {
145 |                     "dt": 1617979000,
146 |                     "temp": 15.2,
147 |                     "feels_like": 14.3,
148 |                     "humidity": 76,
149 |                     "wind_speed": 2.06,
150 |                     "wind_deg": 210,
151 |                     "weather": [{"description": "clear sky"}],
152 |                     "clouds": 1,
153 |                     "pop": 0.2
154 |                 }
155 |             ]
156 |         }
157 | 
158 |         # Configure the mock to return different responses for different URLs
159 |         def side_effect(url, *args, **kwargs):
160 |             if "onecall" in url:
161 |                 return onecall_response
162 |             elif "geo" in url:
163 |                 return geocoding_response
164 |             else:
165 |                 return geocoding_response
166 | 
167 |         mock_get.side_effect = side_effect
168 | 
169 |         # Set up the API key in environment
170 |         os.environ["OPENWEATHER_API_KEY"] = self.test_api_key
171 | 
172 |         # Call the MCP tool function
173 |         result = weather_mcp_server.get_weather(
174 |             location=self.test_location,
175 |             timezone_offset=self.test_timezone_offset
176 |         )
177 | 
178 |         # Verify the result structure
179 |         self.assertIn('daily_forecasts', result)
180 |         self.assertIn('current', result)
181 |         self.assertTrue(len(result['daily_forecasts']) > 0)
182 | 
183 |         # Check current weather info
184 |         current = result['current']
185 |         self.assertIn('temperature', current)
186 |         self.assertIn('feels_like', current)
187 |         self.assertIn('weather_condition', current)
188 |         self.assertIn('humidity', current)
189 |         self.assertIn('wind', current)
190 | 
191 |     @patch('requests.get')
192 |     def test_mcp_get_current_weather_tool(self, mock_get):
193 |         """Test the MCP get_current_weather tool with a simulated API response"""
194 |         # Mock the geocoding API response
195 |         geocoding_response = MagicMock()
196 |         geocoding_response.status_code = 200
197 |         geocoding_response.json.return_value = [
198 |             {"name": "New York", "lat": 40.7128, "lon": -74.0060}
199 |         ]
200 | 
201 |         # Mock the One Call API response
202 |         onecall_response = MagicMock()
203 |         onecall_response.status_code = 200
204 |         onecall_response.json.return_value = {
205 |             "lat": 40.7128,
206 |             "lon": -74.0060,
207 |             "timezone": "America/New_York",
208 |             "timezone_offset": -14400,
209 |             "current": {
210 |                 "dt": 1617979000,
211 |                 "temp": 15.2,
212 |                 "feels_like": 14.3,
213 |                 "humidity": 76,
214 |                 "wind_speed": 2.06,
215 |                 "wind_deg": 210,
216 |                 "weather": [{"description": "clear sky"}],
217 |                 "clouds": 1
218 |             },
219 |             "daily": [],
220 |             "hourly": []
221 |         }
222 | 
223 |         # Configure the mock to return different responses for different URLs
224 |         def side_effect(url, *args, **kwargs):
225 |             if "onecall" in url:
226 |                 return onecall_response
227 |             elif "geo" in url:
228 |                 return geocoding_response
229 |             else:
230 |                 return geocoding_response
231 | 
232 |         mock_get.side_effect = side_effect
233 | 
234 |         # Set up the API key in environment
235 |         os.environ["OPENWEATHER_API_KEY"] = self.test_api_key
236 | 
237 |         # Call the MCP tool function
238 |         result = weather_mcp_server.get_current_weather(
239 |             location=self.test_location,
240 |             timezone_offset=self.test_timezone_offset
241 |         )
242 | 
243 |         # Verify the result is just the current weather
244 |         self.assertIn('temperature', result)
245 |         self.assertIn('feels_like', result)
246 |         self.assertIn('weather_condition', result)
247 |         self.assertIn('humidity', result)
248 |         self.assertIn('wind', result)
249 | 
250 |     @patch('requests.get')
251 |     def test_api_key_parameter_overrides_env(self, mock_get):
252 |         """Test that API key provided as parameter overrides the environment variable"""
253 |         # Mock geocoding response
254 |         mock_geocoding = MagicMock()
255 |         mock_geocoding.status_code = 200
256 |         mock_geocoding.json.return_value = [
257 |             {"name": "New York", "lat": 40.7128, "lon": -74.0060}
258 |         ]
259 | 
260 |         # Mock One Call API response
261 |         mock_onecall = MagicMock()
262 |         mock_onecall.status_code = 200
263 |         mock_onecall.json.return_value = {
264 |             "lat": 40.7128,
265 |             "lon": -74.0060,
266 |             "current": {"temp": 15, "feels_like": 14, "humidity": 70,
267 |                        "weather": [{"description": "clear"}], "wind_speed": 5, "wind_deg": 270}
268 |         }
269 | 
270 |         # Track which API key was used
271 |         param_api_key_used = [False]
272 |         env_api_key_used = [False]
273 | 
274 |         # Configure the side effect to return the appropriate mock based on the URL
275 |         def side_effect(url, *args, **kwargs):
276 |             if "param_api_key" in url:
277 |                 param_api_key_used[0] = True
278 |             if "env_api_key" in url:
279 |                 env_api_key_used[0] = True
280 | 
281 |             if "geo/1.0/direct" in url or "geo" in url:
282 |                 return mock_geocoding
283 |             else:
284 |                 return mock_onecall
285 | 
286 |         mock_get.side_effect = side_effect
287 | 
288 |         # Set up environment API key
289 |         os.environ["OPENWEATHER_API_KEY"] = "env_api_key"
290 | 
291 |         # Call the function with a different API key parameter
292 |         weather_mcp_server.get_current_weather(
293 |             location=self.test_location,
294 |             api_key="param_api_key",
295 |             timezone_offset=self.test_timezone_offset
296 |         )
297 | 
298 |         # Check that the parameter API key was used in the request
299 |         self.assertTrue(param_api_key_used[0], "Parameter API key wasn't used")
300 |         self.assertFalse(env_api_key_used[0], "Environment API key was incorrectly used")
301 | 
302 | 
303 | if __name__ == '__main__':
304 |     unittest.main()
305 | 
```

--------------------------------------------------------------------------------
/weather_mcp_server.py:
--------------------------------------------------------------------------------

```python
  1 | # weather_mcp_server.py
  2 | from mcp.server.fastmcp import FastMCP
  3 | from pydantic import BaseModel, Field
  4 | from typing import List, Dict, Any, Optional, Union
  5 | import requests
  6 | from datetime import datetime, timedelta, timezone
  7 | import os
  8 | 
  9 | # Create MCP server instance
 10 | mcp = FastMCP(
 11 |     name="WeatherForecastServer",
 12 |     description="Provides global weather forecasts and current weather conditions",
 13 |     version="1.2.0"
 14 | )
 15 | 
 16 | # Define data models
 17 | class WindInfo(BaseModel):
 18 |     speed: str = Field(..., description="Wind speed in meters per second")
 19 |     direction: str = Field(..., description="Wind direction in degrees")
 20 | 
 21 | class WeatherEntry(BaseModel):
 22 |     time: str = Field(..., description="Time of the weather data")
 23 |     temperature: str = Field(..., description="Temperature in Celsius")
 24 |     feels_like: str = Field(..., description="Feels like temperature in Celsius")
 25 |     temp_min: str = Field(..., description="Minimum temperature in Celsius")
 26 |     temp_max: str = Field(..., description="Maximum temperature in Celsius")
 27 |     weather_condition: str = Field(..., description="Weather condition description")
 28 |     humidity: str = Field(..., description="Humidity percentage")
 29 |     wind: WindInfo = Field(..., description="Wind speed and direction information")
 30 |     rain: str = Field(..., description="Rainfall amount")
 31 |     clouds: str = Field(..., description="Cloud coverage percentage")
 32 |     pop: Optional[str] = Field(None, description="Probability of precipitation")
 33 | 
 34 | class DailyForecast(BaseModel):
 35 |     date: str = Field(..., description="Date of the forecast in YYYY-MM-DD format")
 36 |     entries: List[WeatherEntry] = Field(..., description="Weather entries for this day")
 37 |     summary: Optional[str] = Field(None, description="Summary of the day's weather")
 38 | 
 39 | class WeatherForecast(BaseModel):
 40 |     daily_forecasts: List[DailyForecast] = Field(..., description="Weather forecasts for up to 8 days, including today")
 41 |     current: WeatherEntry = Field(..., description="Current weather information")
 42 | 
 43 | # Helper function to get API key
 44 | def get_api_key(provided_key: Optional[str] = None) -> str:
 45 |     """
 46 |     Get API key, prioritizing parameter-provided key, then trying to read from environment variables
 47 | 
 48 |     Parameters:
 49 |         provided_key: User-provided API key (optional)
 50 | 
 51 |     Returns:
 52 |         API key string
 53 |     """
 54 |     if provided_key:
 55 |         return provided_key
 56 | 
 57 |     env_key = os.environ.get("OPENWEATHER_API_KEY")
 58 |     if env_key:
 59 |         return env_key
 60 | 
 61 |     raise ValueError("No API key provided and no OPENWEATHER_API_KEY found in environment variables")
 62 | 
 63 | # Function to get location coordinates using Geocoding API
 64 | def get_coordinates(location, api_key):
 65 |     """
 66 |     Get geographic coordinates for a location name using Geocoding API
 67 | 
 68 |     Parameters:
 69 |         location: Location name as string
 70 |         api_key: OpenWeatherMap API key
 71 | 
 72 |     Returns:
 73 |         Tuple of (latitude, longitude)
 74 |     """
 75 |     try:
 76 |         # First try the Geocoding API
 77 |         geocode_url = f"https://api.openweathermap.org/geo/1.0/direct?q={location}&limit=1&appid={api_key}"
 78 |         response = requests.get(geocode_url)
 79 |         response.raise_for_status()
 80 |         data = response.json()
 81 | 
 82 |         if data and len(data) > 0:
 83 |             return data[0]['lat'], data[0]['lon']
 84 | 
 85 |         # Fallback to current weather API if geocoding fails
 86 |         print("Geocoding API failed, falling back to current weather API for coordinates")
 87 |         fallback_url = f"https://api.openweathermap.org/data/2.5/weather?q={location}&appid={api_key}&units=metric"
 88 |         response = requests.get(fallback_url)
 89 |         response.raise_for_status()
 90 |         data = response.json()
 91 | 
 92 |         return data['coord']['lat'], data['coord']['lon']
 93 |     except Exception as e:
 94 |         print(f"Error getting coordinates: {str(e)}")
 95 |         raise
 96 | 
 97 | # Function to format timestamp to human-readable time
 98 | def format_timestamp(ts, tz_offset):
 99 |     """
100 |     Convert Unix timestamp to human-readable time
101 | 
102 |     Parameters:
103 |         ts: Unix timestamp
104 |         tz_offset: Timezone offset in hours
105 | 
106 |     Returns:
107 |         Formatted time string
108 |     """
109 |     tz = timezone(timedelta(hours=tz_offset))
110 |     dt = datetime.fromtimestamp(ts, tz)
111 |     return dt.strftime('%Y-%m-%d %H:%M:%S')
112 | 
113 | # Core weather forecast function using One Call API 3.0
114 | def get_weather_forecast(present_location, time_zone_offset, api_key=None):
115 |     # Get API key
116 |     try:
117 |         api_key = get_api_key(api_key)
118 |     except ValueError as e:
119 |         return {'error': str(e)}
120 | 
121 |     try:
122 |         # Get geographic coordinates
123 |         lat, lon = get_coordinates(present_location, api_key)
124 | 
125 |         # Call One Call API 3.0
126 |         onecall_url = f"https://api.openweathermap.org/data/3.0/onecall?lat={lat}&lon={lon}&appid={api_key}&units=metric"
127 | 
128 |         response = requests.get(onecall_url)
129 |         response.raise_for_status()
130 |         data = response.json()
131 | 
132 |         # Set timezone
133 |         tz = timezone(timedelta(hours=time_zone_offset))
134 | 
135 |         # Process current weather
136 |         current = data.get('current', {})
137 |         current_weather = {
138 |             'time': format_timestamp(current.get('dt', 0), time_zone_offset),
139 |             'temperature': f"{current.get('temp', 0)} °C",
140 |             'feels_like': f"{current.get('feels_like', 0)} °C",
141 |             'temp_min': "N/A",  # One Call doesn't provide min/max in current
142 |             'temp_max': "N/A",
143 |             'weather_condition': current.get('weather', [{}])[0].get('description', 'Unknown'),
144 |             'humidity': f"{current.get('humidity', 0)}%",
145 |             'wind': {
146 |                 'speed': f"{current.get('wind_speed', 0)} m/s",
147 |                 'direction': f"{current.get('wind_deg', 0)} degrees"
148 |             },
149 |             'rain': f"{current.get('rain', {}).get('1h', 0)} mm/h" if 'rain' in current else 'No rain',
150 |             'clouds': f"{current.get('clouds', 0)}%",
151 |             'pop': "N/A"  # One Call doesn't provide precipitation probability in current
152 |         }
153 | 
154 |         # Process daily forecasts
155 |         forecasts_by_date = {}
156 | 
157 |         for day in data.get('daily', []):
158 |             dt = datetime.fromtimestamp(day.get('dt', 0), tz)
159 |             date_str = dt.date().strftime('%Y-%m-%d')
160 | 
161 |             # Initialize this date in the dictionary if needed
162 |             if date_str not in forecasts_by_date:
163 |                 forecasts_by_date[date_str] = {
164 |                     'entries': [],
165 |                     'summary': day.get('summary', '')
166 |                 }
167 | 
168 |             # Create a forecast entry for this day
169 |             forecast_entry = {
170 |                 'time': format_timestamp(day.get('dt', 0), time_zone_offset),
171 |                 'temperature': f"{day.get('temp', {}).get('day', 0)} °C",
172 |                 'feels_like': f"{day.get('feels_like', {}).get('day', 0)} °C",
173 |                 'temp_min': f"{day.get('temp', {}).get('min', 0)} °C",
174 |                 'temp_max': f"{day.get('temp', {}).get('max', 0)} °C",
175 |                 'weather_condition': day.get('weather', [{}])[0].get('description', 'Unknown'),
176 |                 'humidity': f"{day.get('humidity', 0)}%",
177 |                 'wind': {
178 |                     'speed': f"{day.get('wind_speed', 0)} m/s",
179 |                     'direction': f"{day.get('wind_deg', 0)} degrees"
180 |                 },
181 |                 'rain': f"{day.get('rain', 0)} mm" if 'rain' in day else 'No rain',
182 |                 'clouds': f"{day.get('clouds', 0)}%",
183 |                 'pop': f"{day.get('pop', 0) * 100}%"  # Convert to percentage
184 |             }
185 | 
186 |             forecasts_by_date[date_str]['entries'].append(forecast_entry)
187 | 
188 |             # Add morning, afternoon, evening entries for richer data
189 |             # These entries help with use cases like "when should I mow my lawn"
190 |             day_temps = day.get('temp', {})
191 |             feels_like = day.get('feels_like', {})
192 | 
193 |             # Morning entry (9 AM)
194 |             morning_time = dt.replace(hour=9, minute=0, second=0)
195 |             forecasts_by_date[date_str]['entries'].append({
196 |                 'time': morning_time.strftime('%Y-%m-%d %H:%M:%S'),
197 |                 'temperature': f"{day_temps.get('morn', 0)} °C",
198 |                 'feels_like': f"{feels_like.get('morn', 0)} °C",
199 |                 'temp_min': f"{day_temps.get('min', 0)} °C",
200 |                 'temp_max': f"{day_temps.get('max', 0)} °C",
201 |                 'weather_condition': day.get('weather', [{}])[0].get('description', 'Unknown'),
202 |                 'humidity': f"{day.get('humidity', 0)}%",
203 |                 'wind': {
204 |                     'speed': f"{day.get('wind_speed', 0)} m/s",
205 |                     'direction': f"{day.get('wind_deg', 0)} degrees"
206 |                 },
207 |                 'rain': f"{day.get('rain', 0)} mm" if 'rain' in day else 'No rain',
208 |                 'clouds': f"{day.get('clouds', 0)}%",
209 |                 'pop': f"{day.get('pop', 0) * 100}%"
210 |             })
211 | 
212 |             # Afternoon entry (15 PM)
213 |             afternoon_time = dt.replace(hour=15, minute=0, second=0)
214 |             forecasts_by_date[date_str]['entries'].append({
215 |                 'time': afternoon_time.strftime('%Y-%m-%d %H:%M:%S'),
216 |                 'temperature': f"{day_temps.get('day', 0)} °C",
217 |                 'feels_like': f"{feels_like.get('day', 0)} °C",
218 |                 'temp_min': f"{day_temps.get('min', 0)} °C",
219 |                 'temp_max': f"{day_temps.get('max', 0)} °C",
220 |                 'weather_condition': day.get('weather', [{}])[0].get('description', 'Unknown'),
221 |                 'humidity': f"{day.get('humidity', 0)}%",
222 |                 'wind': {
223 |                     'speed': f"{day.get('wind_speed', 0)} m/s",
224 |                     'direction': f"{day.get('wind_deg', 0)} degrees"
225 |                 },
226 |                 'rain': f"{day.get('rain', 0)} mm" if 'rain' in day else 'No rain',
227 |                 'clouds': f"{day.get('clouds', 0)}%",
228 |                 'pop': f"{day.get('pop', 0) * 100}%"
229 |             })
230 | 
231 |             # Evening entry (20 PM)
232 |             evening_time = dt.replace(hour=20, minute=0, second=0)
233 |             forecasts_by_date[date_str]['entries'].append({
234 |                 'time': evening_time.strftime('%Y-%m-%d %H:%M:%S'),
235 |                 'temperature': f"{day_temps.get('eve', 0)} °C",
236 |                 'feels_like': f"{feels_like.get('eve', 0)} °C",
237 |                 'temp_min': f"{day_temps.get('min', 0)} °C",
238 |                 'temp_max': f"{day_temps.get('max', 0)} °C",
239 |                 'weather_condition': day.get('weather', [{}])[0].get('description', 'Unknown'),
240 |                 'humidity': f"{day.get('humidity', 0)}%",
241 |                 'wind': {
242 |                     'speed': f"{day.get('wind_speed', 0)} m/s",
243 |                     'direction': f"{day.get('wind_deg', 0)} degrees"
244 |                 },
245 |                 'rain': f"{day.get('rain', 0)} mm" if 'rain' in day else 'No rain',
246 |                 'clouds': f"{day.get('clouds', 0)}%",
247 |                 'pop': f"{day.get('pop', 0) * 100}%"
248 |             })
249 | 
250 |         # Process hourly forecasts and add to appropriate days
251 |         for hour in data.get('hourly', []):
252 |             dt = datetime.fromtimestamp(hour.get('dt', 0), tz)
253 |             date_str = dt.date().strftime('%Y-%m-%d')
254 | 
255 |             # Skip if we don't have this date (shouldn't happen but just in case)
256 |             if date_str not in forecasts_by_date:
257 |                 continue
258 | 
259 |             # Add the hourly forecast to the appropriate day
260 |             hourly_entry = {
261 |                 'time': format_timestamp(hour.get('dt', 0), time_zone_offset),
262 |                 'temperature': f"{hour.get('temp', 0)} °C",
263 |                 'feels_like': f"{hour.get('feels_like', 0)} °C",
264 |                 'temp_min': "N/A",  # Hourly doesn't have min/max
265 |                 'temp_max': "N/A",
266 |                 'weather_condition': hour.get('weather', [{}])[0].get('description', 'Unknown'),
267 |                 'humidity': f"{hour.get('humidity', 0)}%",
268 |                 'wind': {
269 |                     'speed': f"{hour.get('wind_speed', 0)} m/s",
270 |                     'direction': f"{hour.get('wind_deg', 0)} degrees"
271 |                 },
272 |                 'rain': f"{hour.get('rain', {}).get('1h', 0)} mm/h" if 'rain' in hour else 'No rain',
273 |                 'clouds': f"{hour.get('clouds', 0)}%",
274 |                 'pop': f"{hour.get('pop', 0) * 100}%"  # Convert to percentage
275 |             }
276 | 
277 |             # Only add hourly entries for the first 48 hours (to keep the response size reasonable)
278 |             current_time = datetime.now(tz)
279 |             if (dt - current_time).total_seconds() <= 48 * 3600:  # 48 hours in seconds
280 |                 forecasts_by_date[date_str]['entries'].append(hourly_entry)
281 | 
282 |         # Convert dictionary to list of daily forecasts
283 |         daily_forecasts = []
284 |         for date_str, forecast in sorted(forecasts_by_date.items()):
285 |             daily_forecasts.append({
286 |                 'date': date_str,
287 |                 'entries': forecast['entries'],
288 |                 'summary': forecast.get('summary', '')
289 |             })
290 | 
291 |         # Return structured forecast data
292 |         return {
293 |             'daily_forecasts': daily_forecasts,
294 |             'current': current_weather
295 |         }
296 |     except requests.RequestException as e:
297 |         return {'error': f"Request error: {str(e)}"}
298 |     except ValueError as e:
299 |         return {'error': f"JSON parsing error: {str(e)}"}
300 |     except KeyError as e:
301 |         return {'error': f"Data structure error: Missing key {str(e)}"}
302 |     except Exception as e:
303 |         return {'error': f"Unexpected error: {str(e)}"}
304 | 
305 | # Define MCP tools
306 | @mcp.tool()
307 | def get_weather(location: str, api_key: Optional[str] = None, timezone_offset: float = 0) -> Dict[str, Any]:
308 |     """
309 |     Get comprehensive weather data for a location including current weather and 8-day forecast with detailed information
310 | 
311 |     Parameters:
312 |         location: Location name, e.g., "Beijing", "New York", "Tokyo"
313 |         api_key: OpenWeatherMap API key (optional, will read from environment variable if not provided)
314 |         timezone_offset: Timezone offset in hours, e.g., 8 for Beijing, -4 for New York. Default is 0 (UTC time)
315 | 
316 |     Returns:
317 |         Dictionary containing current weather and detailed forecasts for 8 days (including today)
318 |         with morning, afternoon, and evening data points for each day
319 |     """
320 |     # Call weather forecast function
321 |     return get_weather_forecast(location, timezone_offset, api_key)
322 | 
323 | @mcp.tool()
324 | def get_current_weather(location: str, api_key: Optional[str] = None, timezone_offset: float = 0) -> Dict[str, Any]:
325 |     """
326 |     Get current weather for a specified location
327 | 
328 |     Parameters:
329 |         location: Location name, e.g., "Beijing", "New York", "Tokyo"
330 |         api_key: OpenWeatherMap API key (optional, will read from environment variable if not provided)
331 |         timezone_offset: Timezone offset in hours, e.g., 8 for Beijing, -4 for New York. Default is 0 (UTC time)
332 | 
333 |     Returns:
334 |         Current weather information
335 |     """
336 |     # Get full weather information
337 |     full_weather = get_weather(location, api_key, timezone_offset)
338 | 
339 |     # Check if an error occurred
340 |     if 'error' in full_weather:
341 |         return full_weather
342 | 
343 |     # Only return current weather
344 |     if 'current' in full_weather:
345 |         return full_weather['current']
346 |     else:
347 |         return {"error": "Unable to get current weather information"}
348 | 
349 | # Start server
350 | if __name__ == "__main__":
351 |     # Check if environment variable is set and print log information
352 |     if os.environ.get("OPENWEATHER_API_KEY"):
353 |         print("API key found in environment variables")
354 |         print("API tools are ready, can be called without api_key parameter")
355 |     else:
356 |         print("No API key found in environment variables")
357 |         print("API key parameter required when calling tools")
358 | 
359 |     print("Weather Forecast MCP Server running...")
360 |     mcp.run(transport='stdio')
361 | 
```

--------------------------------------------------------------------------------
/test_weather_mcp.py:
--------------------------------------------------------------------------------

```python
  1 | #!/usr/bin/env python3
  2 | """
  3 | Unit tests for the Weather MCP Server
  4 | """
  5 | import unittest
  6 | from unittest.mock import patch, MagicMock
  7 | import os
  8 | from datetime import datetime, timezone, timedelta
  9 | import json
 10 | import sys
 11 | import importlib.util
 12 | 
 13 | # Create mock classes and modules needed for testing
 14 | class MockField:
 15 |     def __init__(self, *args, **kwargs):
 16 |         self.description = kwargs.get('description', '')
 17 | 
 18 | class MockBaseModel:
 19 |     pass
 20 | 
 21 | # Mock modules needed for testing
 22 | sys.modules['mcp'] = MagicMock()
 23 | sys.modules['mcp.server'] = MagicMock()
 24 | sys.modules['mcp.server.fastmcp'] = MagicMock()
 25 | sys.modules['mcp.server.fastmcp'].FastMCP = MagicMock()
 26 | sys.modules['pydantic'] = MagicMock()
 27 | sys.modules['pydantic'].BaseModel = MockBaseModel
 28 | sys.modules['pydantic'].Field = MockField
 29 | 
 30 | # Make mcp.tool() return the function itself, not a mock
 31 | def mock_tool_decorator():
 32 |     def decorator(func):
 33 |         return func
 34 |     return decorator
 35 | 
 36 | sys.modules['mcp.server.fastmcp'].FastMCP().tool = mock_tool_decorator
 37 | 
 38 | # Now import should work
 39 | import weather_mcp_server
 40 | 
 41 | 
 42 | class TestWeatherMCP(unittest.TestCase):
 43 |     """Test the Weather MCP server functionality"""
 44 | 
 45 |     def setUp(self):
 46 |         """Set up test environment"""
 47 |         # Clear environment variable to ensure tests control it
 48 |         if "OPENWEATHER_API_KEY" in os.environ:
 49 |             self.original_api_key = os.environ["OPENWEATHER_API_KEY"]
 50 |             del os.environ["OPENWEATHER_API_KEY"]
 51 |         else:
 52 |             self.original_api_key = None
 53 | 
 54 |         # Sample test data
 55 |         self.test_location = "New York"
 56 |         self.test_api_key = "test_api_key_123"
 57 |         self.test_timezone_offset = -4
 58 | 
 59 |         # Sample response data
 60 |         with open("test_weather_response.json") as f:
 61 |             self.sample_onecall_data = json.load(f)
 62 | 
 63 |         # Sample current weather response
 64 |         self.sample_current_weather = {
 65 |             "coord": {"lon": -74.006, "lat": 40.7128},
 66 |             "weather": [{"id": 800, "main": "Clear", "description": "clear sky", "icon": "01d"}],
 67 |             "base": "stations",
 68 |             "main": {
 69 |                 "temp": 5.46,
 70 |                 "feels_like": 0.42,
 71 |                 "temp_min": 4.0,
 72 |                 "temp_max": 6.5,
 73 |                 "pressure": 1006,
 74 |                 "humidity": 36
 75 |             },
 76 |             "visibility": 10000,
 77 |             "wind": {"speed": 9.17, "deg": 298},
 78 |             "clouds": {"all": 20},
 79 |             "dt": 1744128000,
 80 |             "sys": {
 81 |                 "type": 2,
 82 |                 "id": 2039034,
 83 |                 "country": "US",
 84 |                 "sunrise": 1744108059,
 85 |                 "sunset": 1744154851
 86 |             },
 87 |             "timezone": -14400,
 88 |             "id": 5128581,
 89 |             "name": "New York",
 90 |             "cod": 200
 91 |         }
 92 | 
 93 |     def tearDown(self):
 94 |         """Clean up after tests"""
 95 |         # Restore original environment if it existed
 96 |         if self.original_api_key is not None:
 97 |             os.environ["OPENWEATHER_API_KEY"] = self.original_api_key
 98 |         elif "OPENWEATHER_API_KEY" in os.environ:
 99 |             del os.environ["OPENWEATHER_API_KEY"]
100 | 
101 |     def test_get_api_key_from_param(self):
102 |         """Test getting API key from parameter"""
103 |         api_key = weather_mcp_server.get_api_key(self.test_api_key)
104 |         self.assertEqual(api_key, self.test_api_key)
105 | 
106 |     def test_get_api_key_from_env(self):
107 |         """Test getting API key from environment variable"""
108 |         os.environ["OPENWEATHER_API_KEY"] = self.test_api_key
109 |         api_key = weather_mcp_server.get_api_key()
110 |         self.assertEqual(api_key, self.test_api_key)
111 | 
112 |     def test_get_api_key_missing(self):
113 |         """Test error when API key is missing"""
114 |         with self.assertRaises(ValueError):
115 |             weather_mcp_server.get_api_key()
116 | 
117 |     @patch('weather_mcp_server.requests.get')
118 |     def test_get_weather_success(self, mock_get):
119 |         """Test successful weather forecast retrieval"""
120 |         # Mock the geocoding API response
121 |         mock_geocoding_response = MagicMock()
122 |         mock_geocoding_response.status_code = 200
123 |         mock_geocoding_response.json.return_value = [
124 |             {"name": "New York", "lat": 40.7128, "lon": -74.0060}
125 |         ]
126 | 
127 |         # Mock the One Call API response
128 |         mock_onecall_response = MagicMock()
129 |         mock_onecall_response.status_code = 200
130 |         mock_onecall_response.json.return_value = {
131 |             "lat": 40.7128,
132 |             "lon": -74.0060,
133 |             "timezone": "America/New_York",
134 |             "timezone_offset": -14400,
135 |             "current": {
136 |                 "dt": 1617979000,
137 |                 "temp": 15.2,
138 |                 "feels_like": 14.3,
139 |                 "humidity": 76,
140 |                 "wind_speed": 2.06,
141 |                 "wind_deg": 210,
142 |                 "weather": [{"description": "clear sky"}],
143 |                 "clouds": 1
144 |             },
145 |             "daily": [
146 |                 {
147 |                     "dt": 1617979000,
148 |                     "temp": {"day": 15.0, "min": 9.0, "max": 17.0, "eve": 13.0, "morn": 10.0},
149 |                     "feels_like": {"day": 14.3, "night": 8.5, "eve": 12.5, "morn": 9.5},
150 |                     "humidity": 76,
151 |                     "wind_speed": 2.06,
152 |                     "wind_deg": 210,
153 |                     "weather": [{"description": "clear sky"}],
154 |                     "clouds": 1,
155 |                     "pop": 0.2,
156 |                     "summary": "Nice day"
157 |                 }
158 |             ],
159 |             "hourly": [
160 |                 {
161 |                     "dt": 1617979000,
162 |                     "temp": 15.2,
163 |                     "feels_like": 14.3,
164 |                     "humidity": 76,
165 |                     "wind_speed": 2.06,
166 |                     "wind_deg": 210,
167 |                     "weather": [{"description": "clear sky"}],
168 |                     "clouds": 1,
169 |                     "pop": 0.2
170 |                 }
171 |             ]
172 |         }
173 | 
174 |         # Configure the mock to return different responses for different URLs
175 |         def side_effect(url, *args, **kwargs):
176 |             if "onecall" in url:
177 |                 return mock_onecall_response
178 |             elif "geo" in url:
179 |                 return mock_geocoding_response
180 |             else:
181 |                 # Fallback to current weather API for coordinates
182 |                 return mock_geocoding_response
183 | 
184 |         mock_get.side_effect = side_effect
185 | 
186 |         # Call the function with test data
187 |         result = weather_mcp_server.get_weather(
188 |             self.test_location,
189 |             self.test_api_key,
190 |             self.test_timezone_offset
191 |         )
192 | 
193 |         # Check the result
194 |         self.assertIn('daily_forecasts', result)
195 |         self.assertIn('current', result)
196 |         self.assertTrue(len(result['daily_forecasts']) > 0)
197 | 
198 |         # Verify API was called with the right parameters
199 |         mock_get.assert_called()
200 | 
201 |         # Verify the structure of the returned data
202 |         self.assertIn('time', result['current'])
203 |         self.assertIn('temperature', result['current'])
204 |         self.assertIn('weather_condition', result['current'])
205 | 
206 |         # Verify daily forecast structure
207 |         first_day = result['daily_forecasts'][0]
208 |         self.assertIn('date', first_day)
209 |         self.assertIn('entries', first_day)
210 |         self.assertIn('summary', first_day)
211 | 
212 |     @patch('weather_mcp_server.requests.get')
213 |     def test_get_current_weather_success(self, mock_get):
214 |         """Test successful current weather retrieval"""
215 |         # Mock the geocoding API response
216 |         mock_geocoding_response = MagicMock()
217 |         mock_geocoding_response.status_code = 200
218 |         mock_geocoding_response.json.return_value = [
219 |             {"name": "New York", "lat": 40.7128, "lon": -74.0060}
220 |         ]
221 | 
222 |         # Mock the One Call API response
223 |         mock_onecall_response = MagicMock()
224 |         mock_onecall_response.status_code = 200
225 |         mock_onecall_response.json.return_value = {
226 |             "lat": 40.7128,
227 |             "lon": -74.0060,
228 |             "timezone": "America/New_York",
229 |             "timezone_offset": -14400,
230 |             "current": {
231 |                 "dt": 1617979000,
232 |                 "temp": 15.2,
233 |                 "feels_like": 14.3,
234 |                 "humidity": 76,
235 |                 "wind_speed": 2.06,
236 |                 "wind_deg": 210,
237 |                 "weather": [{"description": "clear sky"}],
238 |                 "clouds": 1
239 |             },
240 |             "daily": [],
241 |             "hourly": []
242 |         }
243 | 
244 |         # Configure the mock to return different responses for different URLs
245 |         def side_effect(url, *args, **kwargs):
246 |             if "onecall" in url:
247 |                 return mock_onecall_response
248 |             elif "geo" in url:
249 |                 return mock_geocoding_response
250 |             else:
251 |                 # Fallback to current weather API for coordinates
252 |                 return mock_geocoding_response
253 | 
254 |         mock_get.side_effect = side_effect
255 | 
256 |         # Call the function
257 |         result = weather_mcp_server.get_current_weather(
258 |             self.test_location,
259 |             self.test_api_key,
260 |             self.test_timezone_offset
261 |         )
262 | 
263 |         # Verify the structure of the returned data
264 |         self.assertIn('time', result)
265 |         self.assertIn('temperature', result)
266 |         self.assertIn('weather_condition', result)
267 |         self.assertEqual(result['weather_condition'], 'clear sky')
268 | 
269 |     @patch('weather_mcp_server.requests.get')
270 |     def test_api_error_handling(self, mock_get):
271 |         """Test error handling for API failures"""
272 |         # Mock an API error response
273 |         mock_response = MagicMock()
274 |         mock_response.status_code = 401
275 |         mock_response.raise_for_status.side_effect = Exception("API Error")
276 |         mock_get.return_value = mock_response
277 | 
278 |         # Call the function
279 |         result = weather_mcp_server.get_weather(
280 |             self.test_location,
281 |             self.test_api_key,
282 |             self.test_timezone_offset
283 |         )
284 | 
285 |         # Verify error is returned
286 |         self.assertIn('error', result)
287 | 
288 |     def test_env_variable_priority(self):
289 |         """Test that provided API key takes priority over environment variable"""
290 |         os.environ["OPENWEATHER_API_KEY"] = "env_api_key"
291 |         api_key = weather_mcp_server.get_api_key("provided_api_key")
292 |         self.assertEqual(api_key, "provided_api_key")
293 | 
294 |     @patch('weather_mcp_server.requests.get')
295 |     def test_location_not_found(self, mock_get):
296 |         """Test handling when location is not found"""
297 |         # Mock the geo API response for location not found
298 |         mock_response = MagicMock()
299 |         mock_response.status_code = 200
300 |         mock_response.json.return_value = []  # Empty response means location not found
301 |         mock_get.return_value = mock_response
302 | 
303 |         # Since we're using a new get_coordinates function, we need to mock how it raises the exception
304 |         mock_get.side_effect = KeyError("coord")
305 | 
306 |         # Call the function
307 |         result = weather_mcp_server.get_weather("NonExistentLocation", self.test_api_key)
308 | 
309 |         # Verify error is returned
310 |         self.assertIn('error', result)
311 | 
312 |     @patch('weather_mcp_server.get_weather')
313 |     def test_get_current_weather_error_propagation(self, mock_get_weather):
314 |         """Test that errors from get_weather are propagated to get_current_weather"""
315 |         # Mock an error from get_weather
316 |         mock_get_weather.return_value = {"error": "Test error"}
317 | 
318 |         # Call get_current_weather
319 |         result = weather_mcp_server.get_current_weather(
320 |             self.test_location,
321 |             self.test_api_key,
322 |             self.test_timezone_offset
323 |         )
324 | 
325 |         # Verify error is propagated
326 |         self.assertIn('error', result)
327 |         self.assertEqual(result['error'], "Test error")
328 | 
329 |     @patch('weather_mcp_server.get_weather')
330 |     def test_get_current_weather_missing_current(self, mock_get_weather):
331 |         """Test handling when get_weather returns data without 'current' field"""
332 |         # Mock results from get_weather without 'current' field
333 |         mock_get_weather.return_value = {
334 |             "daily_forecasts": [{"date": "2023-01-01", "entries": [], "summary": ""}]
335 |         }
336 | 
337 |         # Call get_current_weather
338 |         result = weather_mcp_server.get_current_weather(
339 |             self.test_location,
340 |             self.test_api_key,
341 |             self.test_timezone_offset
342 |         )
343 | 
344 |         # Verify error is returned
345 |         self.assertIn('error', result)
346 |         self.assertEqual(result['error'], "Unable to get current weather information")
347 | 
348 |     @patch('weather_mcp_server.requests.get')
349 |     def test_geocoding_fallback(self, mock_get):
350 |         """Test that geocoding falls back to current weather API when geocoding API returns no results"""
351 |         # First create empty geocoding response
352 |         empty_geocoding_response = MagicMock()
353 |         empty_geocoding_response.status_code = 200
354 |         empty_geocoding_response.json.return_value = []  # Empty response to trigger fallback
355 |         
356 |         # Create fallback current weather API response with coordinates
357 |         fallback_response = MagicMock()
358 |         fallback_response.status_code = 200
359 |         fallback_response.json.return_value = {
360 |             "coord": {"lat": 40.7128, "lon": -74.0060},
361 |             "weather": [{"description": "clear sky"}],
362 |             "main": {"temp": 15.0},
363 |             "name": "New York"
364 |         }
365 |         
366 |         # Create onecall API response for after coordinates are found
367 |         onecall_response = MagicMock()
368 |         onecall_response.status_code = 200
369 |         onecall_response.json.return_value = {
370 |             "lat": 40.7128,
371 |             "lon": -74.0060,
372 |             "timezone": "America/New_York",
373 |             "timezone_offset": -14400,
374 |             "current": {
375 |                 "dt": 1617979000,
376 |                 "temp": 15.2,
377 |                 "feels_like": 14.3,
378 |                 "humidity": 76,
379 |                 "wind_speed": 2.06,
380 |                 "wind_deg": 210,
381 |                 "weather": [{"description": "clear sky"}],
382 |                 "clouds": 1
383 |             },
384 |             "daily": [
385 |                 {
386 |                     "dt": 1617979000,
387 |                     "temp": {"day": 15.0, "min": 9.0, "max": 17.0, "eve": 13.0, "morn": 10.0},
388 |                     "feels_like": {"day": 14.3, "night": 8.5, "eve": 12.5, "morn": 9.5},
389 |                     "humidity": 76,
390 |                     "wind_speed": 2.06,
391 |                     "wind_deg": 210,
392 |                     "weather": [{"description": "clear sky"}],
393 |                     "clouds": 1,
394 |                     "pop": 0.2,
395 |                     "summary": "Nice day"
396 |                 }
397 |             ],
398 |             "hourly": []
399 |         }
400 |         
401 |         # Configure the mock to return different responses based on the URL
402 |         # First geocoding returns empty, then fallback to current weather API works, then onecall API works
403 |         call_count = [0]
404 |         
405 |         def side_effect(url, *args, **kwargs):
406 |             call_count[0] += 1
407 |             
408 |             if "onecall" in url:
409 |                 return onecall_response
410 |             elif "geo/1.0/direct" in url:
411 |                 return empty_geocoding_response
412 |             elif "data/2.5/weather" in url:
413 |                 # This is the fallback API
414 |                 return fallback_response
415 |             else:
416 |                 return empty_geocoding_response
417 |         
418 |         mock_get.side_effect = side_effect
419 |         
420 |         # Call the function
421 |         result = weather_mcp_server.get_weather(
422 |             self.test_location, 
423 |             self.test_api_key, 
424 |             self.test_timezone_offset
425 |         )
426 |         
427 |         # Verify we got valid results indicating the fallback worked
428 |         self.assertIn('daily_forecasts', result)
429 |         self.assertIn('current', result)
430 |         
431 |         # Check that we called the APIs in the right order
432 |         # Should be at least 3 calls: geocoding, fallback, onecall
433 |         self.assertGreaterEqual(call_count[0], 3)
434 | 
435 | 
436 | if __name__ == '__main__':
437 |     unittest.main()
438 | 
```

--------------------------------------------------------------------------------
/test_weather_response.json:
--------------------------------------------------------------------------------

```json
   1 | {
   2 |   "lat": 40.7127,
   3 |   "lon": -74.006,
   4 |   "timezone": "America/New_York",
   5 |   "timezone_offset": -14400,
   6 |   "current": {
   7 |     "dt": 1744127650,
   8 |     "sunrise": 1744108059,
   9 |     "sunset": 1744154851,
  10 |     "temp": 5.46,
  11 |     "feels_like": -0.36,
  12 |     "pressure": 1006,
  13 |     "humidity": 36,
  14 |     "dew_point": -7.48,
  15 |     "uvi": 5.17,
  16 |     "clouds": 20,
  17 |     "visibility": 10000,
  18 |     "wind_speed": 12.35,
  19 |     "wind_deg": 290,
  20 |     "wind_gust": 16.98,
  21 |     "weather": [
  22 |       {
  23 |         "id": 801,
  24 |         "main": "Clouds",
  25 |         "description": "few clouds",
  26 |         "icon": "02d"
  27 |       }
  28 |     ]
  29 |   },
  30 |   "minutely": [
  31 |     {
  32 |       "dt": 1744127700,
  33 |       "precipitation": 0
  34 |     },
  35 |     {
  36 |       "dt": 1744127760,
  37 |       "precipitation": 0
  38 |     },
  39 |     {
  40 |       "dt": 1744127820,
  41 |       "precipitation": 0
  42 |     },
  43 |     {
  44 |       "dt": 1744127880,
  45 |       "precipitation": 0
  46 |     },
  47 |     {
  48 |       "dt": 1744127940,
  49 |       "precipitation": 0
  50 |     },
  51 |     {
  52 |       "dt": 1744128000,
  53 |       "precipitation": 0
  54 |     },
  55 |     {
  56 |       "dt": 1744128060,
  57 |       "precipitation": 0
  58 |     },
  59 |     {
  60 |       "dt": 1744128120,
  61 |       "precipitation": 0
  62 |     },
  63 |     {
  64 |       "dt": 1744128180,
  65 |       "precipitation": 0
  66 |     },
  67 |     {
  68 |       "dt": 1744128240,
  69 |       "precipitation": 0
  70 |     },
  71 |     {
  72 |       "dt": 1744128300,
  73 |       "precipitation": 0
  74 |     },
  75 |     {
  76 |       "dt": 1744128360,
  77 |       "precipitation": 0
  78 |     },
  79 |     {
  80 |       "dt": 1744128420,
  81 |       "precipitation": 0
  82 |     },
  83 |     {
  84 |       "dt": 1744128480,
  85 |       "precipitation": 0
  86 |     },
  87 |     {
  88 |       "dt": 1744128540,
  89 |       "precipitation": 0
  90 |     },
  91 |     {
  92 |       "dt": 1744128600,
  93 |       "precipitation": 0
  94 |     },
  95 |     {
  96 |       "dt": 1744128660,
  97 |       "precipitation": 0
  98 |     },
  99 |     {
 100 |       "dt": 1744128720,
 101 |       "precipitation": 0
 102 |     },
 103 |     {
 104 |       "dt": 1744128780,
 105 |       "precipitation": 0
 106 |     },
 107 |     {
 108 |       "dt": 1744128840,
 109 |       "precipitation": 0
 110 |     },
 111 |     {
 112 |       "dt": 1744128900,
 113 |       "precipitation": 0
 114 |     },
 115 |     {
 116 |       "dt": 1744128960,
 117 |       "precipitation": 0
 118 |     },
 119 |     {
 120 |       "dt": 1744129020,
 121 |       "precipitation": 0
 122 |     },
 123 |     {
 124 |       "dt": 1744129080,
 125 |       "precipitation": 0
 126 |     },
 127 |     {
 128 |       "dt": 1744129140,
 129 |       "precipitation": 0
 130 |     },
 131 |     {
 132 |       "dt": 1744129200,
 133 |       "precipitation": 0
 134 |     },
 135 |     {
 136 |       "dt": 1744129260,
 137 |       "precipitation": 0
 138 |     },
 139 |     {
 140 |       "dt": 1744129320,
 141 |       "precipitation": 0
 142 |     },
 143 |     {
 144 |       "dt": 1744129380,
 145 |       "precipitation": 0
 146 |     },
 147 |     {
 148 |       "dt": 1744129440,
 149 |       "precipitation": 0
 150 |     },
 151 |     {
 152 |       "dt": 1744129500,
 153 |       "precipitation": 0
 154 |     },
 155 |     {
 156 |       "dt": 1744129560,
 157 |       "precipitation": 0
 158 |     },
 159 |     {
 160 |       "dt": 1744129620,
 161 |       "precipitation": 0
 162 |     },
 163 |     {
 164 |       "dt": 1744129680,
 165 |       "precipitation": 0
 166 |     },
 167 |     {
 168 |       "dt": 1744129740,
 169 |       "precipitation": 0
 170 |     },
 171 |     {
 172 |       "dt": 1744129800,
 173 |       "precipitation": 0
 174 |     },
 175 |     {
 176 |       "dt": 1744129860,
 177 |       "precipitation": 0
 178 |     },
 179 |     {
 180 |       "dt": 1744129920,
 181 |       "precipitation": 0
 182 |     },
 183 |     {
 184 |       "dt": 1744129980,
 185 |       "precipitation": 0
 186 |     },
 187 |     {
 188 |       "dt": 1744130040,
 189 |       "precipitation": 0
 190 |     },
 191 |     {
 192 |       "dt": 1744130100,
 193 |       "precipitation": 0
 194 |     },
 195 |     {
 196 |       "dt": 1744130160,
 197 |       "precipitation": 0
 198 |     },
 199 |     {
 200 |       "dt": 1744130220,
 201 |       "precipitation": 0
 202 |     },
 203 |     {
 204 |       "dt": 1744130280,
 205 |       "precipitation": 0
 206 |     },
 207 |     {
 208 |       "dt": 1744130340,
 209 |       "precipitation": 0
 210 |     },
 211 |     {
 212 |       "dt": 1744130400,
 213 |       "precipitation": 0
 214 |     },
 215 |     {
 216 |       "dt": 1744130460,
 217 |       "precipitation": 0
 218 |     },
 219 |     {
 220 |       "dt": 1744130520,
 221 |       "precipitation": 0
 222 |     },
 223 |     {
 224 |       "dt": 1744130580,
 225 |       "precipitation": 0
 226 |     },
 227 |     {
 228 |       "dt": 1744130640,
 229 |       "precipitation": 0
 230 |     },
 231 |     {
 232 |       "dt": 1744130700,
 233 |       "precipitation": 0
 234 |     },
 235 |     {
 236 |       "dt": 1744130760,
 237 |       "precipitation": 0
 238 |     },
 239 |     {
 240 |       "dt": 1744130820,
 241 |       "precipitation": 0
 242 |     },
 243 |     {
 244 |       "dt": 1744130880,
 245 |       "precipitation": 0
 246 |     },
 247 |     {
 248 |       "dt": 1744130940,
 249 |       "precipitation": 0
 250 |     },
 251 |     {
 252 |       "dt": 1744131000,
 253 |       "precipitation": 0
 254 |     },
 255 |     {
 256 |       "dt": 1744131060,
 257 |       "precipitation": 0
 258 |     },
 259 |     {
 260 |       "dt": 1744131120,
 261 |       "precipitation": 0
 262 |     },
 263 |     {
 264 |       "dt": 1744131180,
 265 |       "precipitation": 0
 266 |     },
 267 |     {
 268 |       "dt": 1744131240,
 269 |       "precipitation": 0
 270 |     }
 271 |   ],
 272 |   "hourly": [
 273 |     {
 274 |       "dt": 1744124400,
 275 |       "temp": 5.12,
 276 |       "feels_like": -0.06,
 277 |       "pressure": 1006,
 278 |       "humidity": 35,
 279 |       "dew_point": -8.07,
 280 |       "uvi": 4.15,
 281 |       "clouds": 16,
 282 |       "visibility": 10000,
 283 |       "wind_speed": 9.3,
 284 |       "wind_deg": 298,
 285 |       "wind_gust": 12.46,
 286 |       "weather": [
 287 |         {
 288 |           "id": 801,
 289 |           "main": "Clouds",
 290 |           "description": "few clouds",
 291 |           "icon": "02d"
 292 |         }
 293 |       ],
 294 |       "pop": 0
 295 |     },
 296 |     {
 297 |       "dt": 1744128000,
 298 |       "temp": 5.46,
 299 |       "feels_like": 0.42,
 300 |       "pressure": 1006,
 301 |       "humidity": 36,
 302 |       "dew_point": -7.48,
 303 |       "uvi": 5.17,
 304 |       "clouds": 20,
 305 |       "visibility": 10000,
 306 |       "wind_speed": 9.17,
 307 |       "wind_deg": 298,
 308 |       "wind_gust": 12.71,
 309 |       "weather": [
 310 |         {
 311 |           "id": 801,
 312 |           "main": "Clouds",
 313 |           "description": "few clouds",
 314 |           "icon": "02d"
 315 |         }
 316 |       ],
 317 |       "pop": 0
 318 |     },
 319 |     {
 320 |       "dt": 1744131600,
 321 |       "temp": 5.56,
 322 |       "feels_like": 0.48,
 323 |       "pressure": 1006,
 324 |       "humidity": 33,
 325 |       "dew_point": -8.4,
 326 |       "uvi": 5.38,
 327 |       "clouds": 16,
 328 |       "visibility": 10000,
 329 |       "wind_speed": 9.43,
 330 |       "wind_deg": 294,
 331 |       "wind_gust": 13.34,
 332 |       "weather": [
 333 |         {
 334 |           "id": 801,
 335 |           "main": "Clouds",
 336 |           "description": "few clouds",
 337 |           "icon": "02d"
 338 |         }
 339 |       ],
 340 |       "pop": 0
 341 |     },
 342 |     {
 343 |       "dt": 1744135200,
 344 |       "temp": 6.04,
 345 |       "feels_like": 1.02,
 346 |       "pressure": 1006,
 347 |       "humidity": 30,
 348 |       "dew_point": -9.1,
 349 |       "uvi": 4.85,
 350 |       "clouds": 13,
 351 |       "visibility": 10000,
 352 |       "wind_speed": 9.8,
 353 |       "wind_deg": 292,
 354 |       "wind_gust": 14.26,
 355 |       "weather": [
 356 |         {
 357 |           "id": 801,
 358 |           "main": "Clouds",
 359 |           "description": "few clouds",
 360 |           "icon": "02d"
 361 |         }
 362 |       ],
 363 |       "pop": 0
 364 |     },
 365 |     {
 366 |       "dt": 1744138800,
 367 |       "temp": 6.42,
 368 |       "feels_like": 1.23,
 369 |       "pressure": 1007,
 370 |       "humidity": 28,
 371 |       "dew_point": -9.59,
 372 |       "uvi": 3.77,
 373 |       "clouds": 68,
 374 |       "visibility": 10000,
 375 |       "wind_speed": 10.99,
 376 |       "wind_deg": 297,
 377 |       "wind_gust": 15.04,
 378 |       "weather": [
 379 |         {
 380 |           "id": 803,
 381 |           "main": "Clouds",
 382 |           "description": "broken clouds",
 383 |           "icon": "04d"
 384 |         }
 385 |       ],
 386 |       "pop": 0
 387 |     },
 388 |     {
 389 |       "dt": 1744142400,
 390 |       "temp": 6.29,
 391 |       "feels_like": 1,
 392 |       "pressure": 1008,
 393 |       "humidity": 28,
 394 |       "dew_point": -9.69,
 395 |       "uvi": 2.43,
 396 |       "clouds": 84,
 397 |       "visibility": 10000,
 398 |       "wind_speed": 11.25,
 399 |       "wind_deg": 302,
 400 |       "wind_gust": 15.13,
 401 |       "weather": [
 402 |         {
 403 |           "id": 803,
 404 |           "main": "Clouds",
 405 |           "description": "broken clouds",
 406 |           "icon": "04d"
 407 |         }
 408 |       ],
 409 |       "pop": 0
 410 |     },
 411 |     {
 412 |       "dt": 1744146000,
 413 |       "temp": 6,
 414 |       "feels_like": 0.7,
 415 |       "pressure": 1009,
 416 |       "humidity": 30,
 417 |       "dew_point": -10.33,
 418 |       "uvi": 1.22,
 419 |       "clouds": 100,
 420 |       "visibility": 10000,
 421 |       "wind_speed": 10.86,
 422 |       "wind_deg": 304,
 423 |       "wind_gust": 15.76,
 424 |       "weather": [
 425 |         {
 426 |           "id": 804,
 427 |           "main": "Clouds",
 428 |           "description": "overcast clouds",
 429 |           "icon": "04d"
 430 |         }
 431 |       ],
 432 |       "pop": 0
 433 |     },
 434 |     {
 435 |       "dt": 1744149600,
 436 |       "temp": 5.53,
 437 |       "feels_like": 0.31,
 438 |       "pressure": 1011,
 439 |       "humidity": 32,
 440 |       "dew_point": -9.75,
 441 |       "uvi": 0.43,
 442 |       "clouds": 100,
 443 |       "visibility": 10000,
 444 |       "wind_speed": 9.91,
 445 |       "wind_deg": 303,
 446 |       "wind_gust": 15.95,
 447 |       "weather": [
 448 |         {
 449 |           "id": 804,
 450 |           "main": "Clouds",
 451 |           "description": "overcast clouds",
 452 |           "icon": "04d"
 453 |         }
 454 |       ],
 455 |       "pop": 0
 456 |     },
 457 |     {
 458 |       "dt": 1744153200,
 459 |       "temp": 4.82,
 460 |       "feels_like": -0.28,
 461 |       "pressure": 1012,
 462 |       "humidity": 37,
 463 |       "dew_point": -8.57,
 464 |       "uvi": 0,
 465 |       "clouds": 81,
 466 |       "visibility": 10000,
 467 |       "wind_speed": 8.69,
 468 |       "wind_deg": 305,
 469 |       "wind_gust": 15.6,
 470 |       "weather": [
 471 |         {
 472 |           "id": 803,
 473 |           "main": "Clouds",
 474 |           "description": "broken clouds",
 475 |           "icon": "04d"
 476 |         }
 477 |       ],
 478 |       "pop": 0
 479 |     },
 480 |     {
 481 |       "dt": 1744156800,
 482 |       "temp": 3.9,
 483 |       "feels_like": -1.32,
 484 |       "pressure": 1014,
 485 |       "humidity": 39,
 486 |       "dew_point": -9.04,
 487 |       "uvi": 0,
 488 |       "clouds": 68,
 489 |       "visibility": 10000,
 490 |       "wind_speed": 8.2,
 491 |       "wind_deg": 311,
 492 |       "wind_gust": 14.82,
 493 |       "weather": [
 494 |         {
 495 |           "id": 803,
 496 |           "main": "Clouds",
 497 |           "description": "broken clouds",
 498 |           "icon": "04n"
 499 |         }
 500 |       ],
 501 |       "pop": 0
 502 |     },
 503 |     {
 504 |       "dt": 1744160400,
 505 |       "temp": 3.05,
 506 |       "feels_like": -2.19,
 507 |       "pressure": 1015,
 508 |       "humidity": 39,
 509 |       "dew_point": -9.78,
 510 |       "uvi": 0,
 511 |       "clouds": 0,
 512 |       "visibility": 10000,
 513 |       "wind_speed": 7.51,
 514 |       "wind_deg": 318,
 515 |       "wind_gust": 14.23,
 516 |       "weather": [
 517 |         {
 518 |           "id": 800,
 519 |           "main": "Clear",
 520 |           "description": "clear sky",
 521 |           "icon": "01n"
 522 |         }
 523 |       ],
 524 |       "pop": 0
 525 |     },
 526 |     {
 527 |       "dt": 1744164000,
 528 |       "temp": 2.29,
 529 |       "feels_like": -3.06,
 530 |       "pressure": 1016,
 531 |       "humidity": 39,
 532 |       "dew_point": -10.26,
 533 |       "uvi": 0,
 534 |       "clouds": 1,
 535 |       "visibility": 10000,
 536 |       "wind_speed": 7.23,
 537 |       "wind_deg": 315,
 538 |       "wind_gust": 14.55,
 539 |       "weather": [
 540 |         {
 541 |           "id": 800,
 542 |           "main": "Clear",
 543 |           "description": "clear sky",
 544 |           "icon": "01n"
 545 |         }
 546 |       ],
 547 |       "pop": 0
 548 |     },
 549 |     {
 550 |       "dt": 1744167600,
 551 |       "temp": 1.63,
 552 |       "feels_like": -3.9,
 553 |       "pressure": 1017,
 554 |       "humidity": 40,
 555 |       "dew_point": -10.5,
 556 |       "uvi": 0,
 557 |       "clouds": 1,
 558 |       "visibility": 10000,
 559 |       "wind_speed": 7.19,
 560 |       "wind_deg": 310,
 561 |       "wind_gust": 14.97,
 562 |       "weather": [
 563 |         {
 564 |           "id": 800,
 565 |           "main": "Clear",
 566 |           "description": "clear sky",
 567 |           "icon": "01n"
 568 |         }
 569 |       ],
 570 |       "pop": 0
 571 |     },
 572 |     {
 573 |       "dt": 1744171200,
 574 |       "temp": 1.13,
 575 |       "feels_like": -4.56,
 576 |       "pressure": 1018,
 577 |       "humidity": 41,
 578 |       "dew_point": -10.78,
 579 |       "uvi": 0,
 580 |       "clouds": 1,
 581 |       "visibility": 10000,
 582 |       "wind_speed": 7.25,
 583 |       "wind_deg": 303,
 584 |       "wind_gust": 14.83,
 585 |       "weather": [
 586 |         {
 587 |           "id": 800,
 588 |           "main": "Clear",
 589 |           "description": "clear sky",
 590 |           "icon": "01n"
 591 |         }
 592 |       ],
 593 |       "pop": 0
 594 |     },
 595 |     {
 596 |       "dt": 1744174800,
 597 |       "temp": 0.71,
 598 |       "feels_like": -4.98,
 599 |       "pressure": 1019,
 600 |       "humidity": 41,
 601 |       "dew_point": -11.07,
 602 |       "uvi": 0,
 603 |       "clouds": 1,
 604 |       "visibility": 10000,
 605 |       "wind_speed": 6.94,
 606 |       "wind_deg": 297,
 607 |       "wind_gust": 14.36,
 608 |       "weather": [
 609 |         {
 610 |           "id": 800,
 611 |           "main": "Clear",
 612 |           "description": "clear sky",
 613 |           "icon": "01n"
 614 |         }
 615 |       ],
 616 |       "pop": 0
 617 |     },
 618 |     {
 619 |       "dt": 1744178400,
 620 |       "temp": 2.43,
 621 |       "feels_like": -2.74,
 622 |       "pressure": 1020,
 623 |       "humidity": 41,
 624 |       "dew_point": -11.31,
 625 |       "uvi": 0,
 626 |       "clouds": 0,
 627 |       "visibility": 10000,
 628 |       "wind_speed": 6.87,
 629 |       "wind_deg": 294,
 630 |       "wind_gust": 13.91,
 631 |       "weather": [
 632 |         {
 633 |           "id": 800,
 634 |           "main": "Clear",
 635 |           "description": "clear sky",
 636 |           "icon": "01n"
 637 |         }
 638 |       ],
 639 |       "pop": 0
 640 |     },
 641 |     {
 642 |       "dt": 1744182000,
 643 |       "temp": 0.18,
 644 |       "feels_like": -5.66,
 645 |       "pressure": 1021,
 646 |       "humidity": 42,
 647 |       "dew_point": -11.37,
 648 |       "uvi": 0,
 649 |       "clouds": 0,
 650 |       "visibility": 10000,
 651 |       "wind_speed": 6.95,
 652 |       "wind_deg": 294,
 653 |       "wind_gust": 13.52,
 654 |       "weather": [
 655 |         {
 656 |           "id": 800,
 657 |           "main": "Clear",
 658 |           "description": "clear sky",
 659 |           "icon": "01n"
 660 |         }
 661 |       ],
 662 |       "pop": 0
 663 |     },
 664 |     {
 665 |       "dt": 1744185600,
 666 |       "temp": -0.05,
 667 |       "feels_like": -5.9,
 668 |       "pressure": 1021,
 669 |       "humidity": 42,
 670 |       "dew_point": -11.37,
 671 |       "uvi": 0,
 672 |       "clouds": 0,
 673 |       "visibility": 10000,
 674 |       "wind_speed": 6.83,
 675 |       "wind_deg": 297,
 676 |       "wind_gust": 12.79,
 677 |       "weather": [
 678 |         {
 679 |           "id": 800,
 680 |           "main": "Clear",
 681 |           "description": "clear sky",
 682 |           "icon": "01n"
 683 |         }
 684 |       ],
 685 |       "pop": 0
 686 |     },
 687 |     {
 688 |       "dt": 1744189200,
 689 |       "temp": -0.19,
 690 |       "feels_like": -6.02,
 691 |       "pressure": 1022,
 692 |       "humidity": 43,
 693 |       "dew_point": -11.41,
 694 |       "uvi": 0,
 695 |       "clouds": 0,
 696 |       "visibility": 10000,
 697 |       "wind_speed": 6.68,
 698 |       "wind_deg": 300,
 699 |       "wind_gust": 12.1,
 700 |       "weather": [
 701 |         {
 702 |           "id": 800,
 703 |           "main": "Clear",
 704 |           "description": "clear sky",
 705 |           "icon": "01n"
 706 |         }
 707 |       ],
 708 |       "pop": 0
 709 |     },
 710 |     {
 711 |       "dt": 1744192800,
 712 |       "temp": -0.29,
 713 |       "feels_like": -5.99,
 714 |       "pressure": 1023,
 715 |       "humidity": 43,
 716 |       "dew_point": -11.52,
 717 |       "uvi": 0,
 718 |       "clouds": 0,
 719 |       "visibility": 10000,
 720 |       "wind_speed": 6.36,
 721 |       "wind_deg": 303,
 722 |       "wind_gust": 11.11,
 723 |       "weather": [
 724 |         {
 725 |           "id": 800,
 726 |           "main": "Clear",
 727 |           "description": "clear sky",
 728 |           "icon": "01n"
 729 |         }
 730 |       ],
 731 |       "pop": 0
 732 |     },
 733 |     {
 734 |       "dt": 1744196400,
 735 |       "temp": -0.29,
 736 |       "feels_like": -5.86,
 737 |       "pressure": 1024,
 738 |       "humidity": 43,
 739 |       "dew_point": -11.54,
 740 |       "uvi": 0.1,
 741 |       "clouds": 0,
 742 |       "visibility": 10000,
 743 |       "wind_speed": 6.08,
 744 |       "wind_deg": 306,
 745 |       "wind_gust": 10.29,
 746 |       "weather": [
 747 |         {
 748 |           "id": 800,
 749 |           "main": "Clear",
 750 |           "description": "clear sky",
 751 |           "icon": "01d"
 752 |         }
 753 |       ],
 754 |       "pop": 0
 755 |     },
 756 |     {
 757 |       "dt": 1744200000,
 758 |       "temp": 0.1,
 759 |       "feels_like": -5.39,
 760 |       "pressure": 1025,
 761 |       "humidity": 40,
 762 |       "dew_point": -11.88,
 763 |       "uvi": 0.51,
 764 |       "clouds": 0,
 765 |       "visibility": 10000,
 766 |       "wind_speed": 6.13,
 767 |       "wind_deg": 311,
 768 |       "wind_gust": 9.08,
 769 |       "weather": [
 770 |         {
 771 |           "id": 800,
 772 |           "main": "Clear",
 773 |           "description": "clear sky",
 774 |           "icon": "01d"
 775 |         }
 776 |       ],
 777 |       "pop": 0
 778 |     },
 779 |     {
 780 |       "dt": 1744203600,
 781 |       "temp": 0.97,
 782 |       "feels_like": -4.04,
 783 |       "pressure": 1025,
 784 |       "humidity": 37,
 785 |       "dew_point": -12.31,
 786 |       "uvi": 1.41,
 787 |       "clouds": 0,
 788 |       "visibility": 10000,
 789 |       "wind_speed": 5.63,
 790 |       "wind_deg": 315,
 791 |       "wind_gust": 7.75,
 792 |       "weather": [
 793 |         {
 794 |           "id": 800,
 795 |           "main": "Clear",
 796 |           "description": "clear sky",
 797 |           "icon": "01d"
 798 |         }
 799 |       ],
 800 |       "pop": 0
 801 |     },
 802 |     {
 803 |       "dt": 1744207200,
 804 |       "temp": 2.26,
 805 |       "feels_like": -2.06,
 806 |       "pressure": 1025,
 807 |       "humidity": 31,
 808 |       "dew_point": -13.24,
 809 |       "uvi": 2.75,
 810 |       "clouds": 0,
 811 |       "visibility": 10000,
 812 |       "wind_speed": 4.94,
 813 |       "wind_deg": 313,
 814 |       "wind_gust": 6.88,
 815 |       "weather": [
 816 |         {
 817 |           "id": 800,
 818 |           "main": "Clear",
 819 |           "description": "clear sky",
 820 |           "icon": "01d"
 821 |         }
 822 |       ],
 823 |       "pop": 0
 824 |     },
 825 |     {
 826 |       "dt": 1744210800,
 827 |       "temp": 3.6,
 828 |       "feels_like": -0.08,
 829 |       "pressure": 1025,
 830 |       "humidity": 26,
 831 |       "dew_point": -14.24,
 832 |       "uvi": 4.24,
 833 |       "clouds": 0,
 834 |       "visibility": 10000,
 835 |       "wind_speed": 4.38,
 836 |       "wind_deg": 303,
 837 |       "wind_gust": 6.28,
 838 |       "weather": [
 839 |         {
 840 |           "id": 800,
 841 |           "main": "Clear",
 842 |           "description": "clear sky",
 843 |           "icon": "01d"
 844 |         }
 845 |       ],
 846 |       "pop": 0
 847 |     },
 848 |     {
 849 |       "dt": 1744214400,
 850 |       "temp": 5.06,
 851 |       "feels_like": 2,
 852 |       "pressure": 1025,
 853 |       "humidity": 22,
 854 |       "dew_point": -15.3,
 855 |       "uvi": 5.41,
 856 |       "clouds": 0,
 857 |       "visibility": 10000,
 858 |       "wind_speed": 3.88,
 859 |       "wind_deg": 293,
 860 |       "wind_gust": 5.93,
 861 |       "weather": [
 862 |         {
 863 |           "id": 800,
 864 |           "main": "Clear",
 865 |           "description": "clear sky",
 866 |           "icon": "01d"
 867 |         }
 868 |       ],
 869 |       "pop": 0
 870 |     },
 871 |     {
 872 |       "dt": 1744218000,
 873 |       "temp": 6.34,
 874 |       "feels_like": 3.67,
 875 |       "pressure": 1024,
 876 |       "humidity": 19,
 877 |       "dew_point": -16.12,
 878 |       "uvi": 5.75,
 879 |       "clouds": 0,
 880 |       "visibility": 10000,
 881 |       "wind_speed": 3.71,
 882 |       "wind_deg": 280,
 883 |       "wind_gust": 6.06,
 884 |       "weather": [
 885 |         {
 886 |           "id": 800,
 887 |           "main": "Clear",
 888 |           "description": "clear sky",
 889 |           "icon": "01d"
 890 |         }
 891 |       ],
 892 |       "pop": 0
 893 |     },
 894 |     {
 895 |       "dt": 1744221600,
 896 |       "temp": 7.71,
 897 |       "feels_like": 5.29,
 898 |       "pressure": 1024,
 899 |       "humidity": 17,
 900 |       "dew_point": -16.16,
 901 |       "uvi": 5.23,
 902 |       "clouds": 0,
 903 |       "visibility": 10000,
 904 |       "wind_speed": 3.81,
 905 |       "wind_deg": 269,
 906 |       "wind_gust": 6.26,
 907 |       "weather": [
 908 |         {
 909 |           "id": 800,
 910 |           "main": "Clear",
 911 |           "description": "clear sky",
 912 |           "icon": "01d"
 913 |         }
 914 |       ],
 915 |       "pop": 0
 916 |     },
 917 |     {
 918 |       "dt": 1744225200,
 919 |       "temp": 8.85,
 920 |       "feels_like": 6.62,
 921 |       "pressure": 1023,
 922 |       "humidity": 18,
 923 |       "dew_point": -14.73,
 924 |       "uvi": 4.07,
 925 |       "clouds": 0,
 926 |       "visibility": 10000,
 927 |       "wind_speed": 3.93,
 928 |       "wind_deg": 265,
 929 |       "wind_gust": 6.27,
 930 |       "weather": [
 931 |         {
 932 |           "id": 800,
 933 |           "main": "Clear",
 934 |           "description": "clear sky",
 935 |           "icon": "01d"
 936 |         }
 937 |       ],
 938 |       "pop": 0
 939 |     },
 940 |     {
 941 |       "dt": 1744228800,
 942 |       "temp": 9.63,
 943 |       "feels_like": 7.38,
 944 |       "pressure": 1022,
 945 |       "humidity": 19,
 946 |       "dew_point": -13.16,
 947 |       "uvi": 2.6,
 948 |       "clouds": 0,
 949 |       "visibility": 10000,
 950 |       "wind_speed": 4.38,
 951 |       "wind_deg": 261,
 952 |       "wind_gust": 6.42,
 953 |       "weather": [
 954 |         {
 955 |           "id": 800,
 956 |           "main": "Clear",
 957 |           "description": "clear sky",
 958 |           "icon": "01d"
 959 |         }
 960 |       ],
 961 |       "pop": 0
 962 |     },
 963 |     {
 964 |       "dt": 1744232400,
 965 |       "temp": 10.13,
 966 |       "feels_like": 7.75,
 967 |       "pressure": 1022,
 968 |       "humidity": 21,
 969 |       "dew_point": -11.51,
 970 |       "uvi": 1.31,
 971 |       "clouds": 0,
 972 |       "visibility": 10000,
 973 |       "wind_speed": 4.86,
 974 |       "wind_deg": 257,
 975 |       "wind_gust": 6.5,
 976 |       "weather": [
 977 |         {
 978 |           "id": 800,
 979 |           "main": "Clear",
 980 |           "description": "clear sky",
 981 |           "icon": "01d"
 982 |         }
 983 |       ],
 984 |       "pop": 0
 985 |     },
 986 |     {
 987 |       "dt": 1744236000,
 988 |       "temp": 10.17,
 989 |       "feels_like": 7.87,
 990 |       "pressure": 1023,
 991 |       "humidity": 24,
 992 |       "dew_point": -10.02,
 993 |       "uvi": 0.45,
 994 |       "clouds": 0,
 995 |       "visibility": 10000,
 996 |       "wind_speed": 4.43,
 997 |       "wind_deg": 251,
 998 |       "wind_gust": 5.99,
 999 |       "weather": [
1000 |         {
1001 |           "id": 800,
1002 |           "main": "Clear",
1003 |           "description": "clear sky",
1004 |           "icon": "01d"
1005 |         }
1006 |       ],
1007 |       "pop": 0
1008 |     },
1009 |     {
1010 |       "dt": 1744239600,
1011 |       "temp": 9.65,
1012 |       "feels_like": 7.78,
1013 |       "pressure": 1024,
1014 |       "humidity": 28,
1015 |       "dew_point": -8.42,
1016 |       "uvi": 0,
1017 |       "clouds": 3,
1018 |       "visibility": 10000,
1019 |       "wind_speed": 3.58,
1020 |       "wind_deg": 224,
1021 |       "wind_gust": 5.21,
1022 |       "weather": [
1023 |         {
1024 |           "id": 800,
1025 |           "main": "Clear",
1026 |           "description": "clear sky",
1027 |           "icon": "01d"
1028 |         }
1029 |       ],
1030 |       "pop": 0
1031 |     },
1032 |     {
1033 |       "dt": 1744243200,
1034 |       "temp": 8.97,
1035 |       "feels_like": 6.94,
1036 |       "pressure": 1025,
1037 |       "humidity": 35,
1038 |       "dew_point": -6.02,
1039 |       "uvi": 0,
1040 |       "clouds": 5,
1041 |       "visibility": 10000,
1042 |       "wind_speed": 3.59,
1043 |       "wind_deg": 183,
1044 |       "wind_gust": 4.91,
1045 |       "weather": [
1046 |         {
1047 |           "id": 800,
1048 |           "main": "Clear",
1049 |           "description": "clear sky",
1050 |           "icon": "01n"
1051 |         }
1052 |       ],
1053 |       "pop": 0
1054 |     },
1055 |     {
1056 |       "dt": 1744246800,
1057 |       "temp": 8.19,
1058 |       "feels_like": 5.9,
1059 |       "pressure": 1025,
1060 |       "humidity": 42,
1061 |       "dew_point": -4.31,
1062 |       "uvi": 0,
1063 |       "clouds": 11,
1064 |       "visibility": 10000,
1065 |       "wind_speed": 3.76,
1066 |       "wind_deg": 171,
1067 |       "wind_gust": 5.35,
1068 |       "weather": [
1069 |         {
1070 |           "id": 801,
1071 |           "main": "Clouds",
1072 |           "description": "few clouds",
1073 |           "icon": "02n"
1074 |         }
1075 |       ],
1076 |       "pop": 0
1077 |     },
1078 |     {
1079 |       "dt": 1744250400,
1080 |       "temp": 7.85,
1081 |       "feels_like": 5.36,
1082 |       "pressure": 1025,
1083 |       "humidity": 48,
1084 |       "dew_point": -2.75,
1085 |       "uvi": 0,
1086 |       "clouds": 11,
1087 |       "visibility": 10000,
1088 |       "wind_speed": 4.01,
1089 |       "wind_deg": 167,
1090 |       "wind_gust": 4.86,
1091 |       "weather": [
1092 |         {
1093 |           "id": 801,
1094 |           "main": "Clouds",
1095 |           "description": "few clouds",
1096 |           "icon": "02n"
1097 |         }
1098 |       ],
1099 |       "pop": 0
1100 |     },
1101 |     {
1102 |       "dt": 1744254000,
1103 |       "temp": 7.31,
1104 |       "feels_like": 3.9,
1105 |       "pressure": 1025,
1106 |       "humidity": 50,
1107 |       "dew_point": -2.36,
1108 |       "uvi": 0,
1109 |       "clouds": 10,
1110 |       "visibility": 10000,
1111 |       "wind_speed": 5.8,
1112 |       "wind_deg": 201,
1113 |       "wind_gust": 7.94,
1114 |       "weather": [
1115 |         {
1116 |           "id": 800,
1117 |           "main": "Clear",
1118 |           "description": "clear sky",
1119 |           "icon": "01n"
1120 |         }
1121 |       ],
1122 |       "pop": 0
1123 |     },
1124 |     {
1125 |       "dt": 1744257600,
1126 |       "temp": 6.35,
1127 |       "feels_like": 2.7,
1128 |       "pressure": 1026,
1129 |       "humidity": 53,
1130 |       "dew_point": -2.68,
1131 |       "uvi": 0,
1132 |       "clouds": 11,
1133 |       "visibility": 10000,
1134 |       "wind_speed": 5.77,
1135 |       "wind_deg": 214,
1136 |       "wind_gust": 9.56,
1137 |       "weather": [
1138 |         {
1139 |           "id": 801,
1140 |           "main": "Clouds",
1141 |           "description": "few clouds",
1142 |           "icon": "02n"
1143 |         }
1144 |       ],
1145 |       "pop": 0
1146 |     },
1147 |     {
1148 |       "dt": 1744261200,
1149 |       "temp": 6.03,
1150 |       "feels_like": 2.77,
1151 |       "pressure": 1026,
1152 |       "humidity": 57,
1153 |       "dew_point": -1.97,
1154 |       "uvi": 0,
1155 |       "clouds": 29,
1156 |       "visibility": 10000,
1157 |       "wind_speed": 4.68,
1158 |       "wind_deg": 211,
1159 |       "wind_gust": 9.05,
1160 |       "weather": [
1161 |         {
1162 |           "id": 802,
1163 |           "main": "Clouds",
1164 |           "description": "scattered clouds",
1165 |           "icon": "03n"
1166 |         }
1167 |       ],
1168 |       "pop": 0
1169 |     },
1170 |     {
1171 |       "dt": 1744264800,
1172 |       "temp": 6.05,
1173 |       "feels_like": 3.2,
1174 |       "pressure": 1026,
1175 |       "humidity": 57,
1176 |       "dew_point": -1.78,
1177 |       "uvi": 0,
1178 |       "clouds": 41,
1179 |       "visibility": 10000,
1180 |       "wind_speed": 3.91,
1181 |       "wind_deg": 214,
1182 |       "wind_gust": 7.73,
1183 |       "weather": [
1184 |         {
1185 |           "id": 802,
1186 |           "main": "Clouds",
1187 |           "description": "scattered clouds",
1188 |           "icon": "03n"
1189 |         }
1190 |       ],
1191 |       "pop": 0
1192 |     },
1193 |     {
1194 |       "dt": 1744268400,
1195 |       "temp": 6.03,
1196 |       "feels_like": 3.74,
1197 |       "pressure": 1026,
1198 |       "humidity": 58,
1199 |       "dew_point": -1.66,
1200 |       "uvi": 0,
1201 |       "clouds": 100,
1202 |       "visibility": 10000,
1203 |       "wind_speed": 3,
1204 |       "wind_deg": 214,
1205 |       "wind_gust": 6.34,
1206 |       "weather": [
1207 |         {
1208 |           "id": 804,
1209 |           "main": "Clouds",
1210 |           "description": "overcast clouds",
1211 |           "icon": "04n"
1212 |         }
1213 |       ],
1214 |       "pop": 0
1215 |     },
1216 |     {
1217 |       "dt": 1744272000,
1218 |       "temp": 6.13,
1219 |       "feels_like": 4.04,
1220 |       "pressure": 1026,
1221 |       "humidity": 59,
1222 |       "dew_point": -1.45,
1223 |       "uvi": 0,
1224 |       "clouds": 100,
1225 |       "visibility": 10000,
1226 |       "wind_speed": 2.75,
1227 |       "wind_deg": 201,
1228 |       "wind_gust": 6.13,
1229 |       "weather": [
1230 |         {
1231 |           "id": 804,
1232 |           "main": "Clouds",
1233 |           "description": "overcast clouds",
1234 |           "icon": "04n"
1235 |         }
1236 |       ],
1237 |       "pop": 0
1238 |     },
1239 |     {
1240 |       "dt": 1744275600,
1241 |       "temp": 6.22,
1242 |       "feels_like": 3.86,
1243 |       "pressure": 1026,
1244 |       "humidity": 61,
1245 |       "dew_point": -0.96,
1246 |       "uvi": 0,
1247 |       "clouds": 100,
1248 |       "visibility": 10000,
1249 |       "wind_speed": 3.17,
1250 |       "wind_deg": 194,
1251 |       "wind_gust": 6.7,
1252 |       "weather": [
1253 |         {
1254 |           "id": 804,
1255 |           "main": "Clouds",
1256 |           "description": "overcast clouds",
1257 |           "icon": "04n"
1258 |         }
1259 |       ],
1260 |       "pop": 0
1261 |     },
1262 |     {
1263 |       "dt": 1744279200,
1264 |       "temp": 6.28,
1265 |       "feels_like": 3.82,
1266 |       "pressure": 1026,
1267 |       "humidity": 64,
1268 |       "dew_point": -0.13,
1269 |       "uvi": 0,
1270 |       "clouds": 100,
1271 |       "visibility": 10000,
1272 |       "wind_speed": 3.33,
1273 |       "wind_deg": 182,
1274 |       "wind_gust": 6.94,
1275 |       "weather": [
1276 |         {
1277 |           "id": 804,
1278 |           "main": "Clouds",
1279 |           "description": "overcast clouds",
1280 |           "icon": "04n"
1281 |         }
1282 |       ],
1283 |       "pop": 0
1284 |     },
1285 |     {
1286 |       "dt": 1744282800,
1287 |       "temp": 6.37,
1288 |       "feels_like": 3.87,
1289 |       "pressure": 1026,
1290 |       "humidity": 67,
1291 |       "dew_point": 0.58,
1292 |       "uvi": 0.08,
1293 |       "clouds": 97,
1294 |       "visibility": 10000,
1295 |       "wind_speed": 3.43,
1296 |       "wind_deg": 189,
1297 |       "wind_gust": 6.93,
1298 |       "weather": [
1299 |         {
1300 |           "id": 804,
1301 |           "main": "Clouds",
1302 |           "description": "overcast clouds",
1303 |           "icon": "04d"
1304 |         }
1305 |       ],
1306 |       "pop": 0
1307 |     },
1308 |     {
1309 |       "dt": 1744286400,
1310 |       "temp": 7.22,
1311 |       "feels_like": 4.48,
1312 |       "pressure": 1026,
1313 |       "humidity": 64,
1314 |       "dew_point": 0.89,
1315 |       "uvi": 0.41,
1316 |       "clouds": 98,
1317 |       "visibility": 10000,
1318 |       "wind_speed": 4.21,
1319 |       "wind_deg": 183,
1320 |       "wind_gust": 7.87,
1321 |       "weather": [
1322 |         {
1323 |           "id": 804,
1324 |           "main": "Clouds",
1325 |           "description": "overcast clouds",
1326 |           "icon": "04d"
1327 |         }
1328 |       ],
1329 |       "pop": 0
1330 |     },
1331 |     {
1332 |       "dt": 1744290000,
1333 |       "temp": 8.01,
1334 |       "feels_like": 4.92,
1335 |       "pressure": 1026,
1336 |       "humidity": 60,
1337 |       "dew_point": 0.7,
1338 |       "uvi": 1.33,
1339 |       "clouds": 100,
1340 |       "visibility": 10000,
1341 |       "wind_speed": 5.45,
1342 |       "wind_deg": 177,
1343 |       "wind_gust": 8.13,
1344 |       "weather": [
1345 |         {
1346 |           "id": 804,
1347 |           "main": "Clouds",
1348 |           "description": "overcast clouds",
1349 |           "icon": "04d"
1350 |         }
1351 |       ],
1352 |       "pop": 0
1353 |     },
1354 |     {
1355 |       "dt": 1744293600,
1356 |       "temp": 8.91,
1357 |       "feels_like": 5.67,
1358 |       "pressure": 1026,
1359 |       "humidity": 55,
1360 |       "dew_point": 0.31,
1361 |       "uvi": 1.92,
1362 |       "clouds": 100,
1363 |       "visibility": 10000,
1364 |       "wind_speed": 6.56,
1365 |       "wind_deg": 171,
1366 |       "wind_gust": 8.7,
1367 |       "weather": [
1368 |         {
1369 |           "id": 804,
1370 |           "main": "Clouds",
1371 |           "description": "overcast clouds",
1372 |           "icon": "04d"
1373 |         }
1374 |       ],
1375 |       "pop": 0
1376 |     }
1377 |   ],
1378 |   "daily": [
1379 |     {
1380 |       "dt": 1744128000,
1381 |       "sunrise": 1744108059,
1382 |       "sunset": 1744154851,
1383 |       "moonrise": 1744140420,
1384 |       "moonset": 1744101780,
1385 |       "moon_phase": 0.37,
1386 |       "summary": "Expect a day of partly cloudy with rain",
1387 |       "temp": {
1388 |         "day": 5.46,
1389 |         "min": 1.63,
1390 |         "max": 7.67,
1391 |         "night": 1.63,
1392 |         "eve": 5.53,
1393 |         "morn": 5.07
1394 |       },
1395 |       "feels_like": {
1396 |         "day": 0.42,
1397 |         "night": -3.9,
1398 |         "eve": 0.31,
1399 |         "morn": 0.76
1400 |       },
1401 |       "pressure": 1006,
1402 |       "humidity": 36,
1403 |       "dew_point": -7.48,
1404 |       "wind_speed": 11.25,
1405 |       "wind_deg": 302,
1406 |       "wind_gust": 15.95,
1407 |       "weather": [
1408 |         {
1409 |           "id": 500,
1410 |           "main": "Rain",
1411 |           "description": "light rain",
1412 |           "icon": "10d"
1413 |         }
1414 |       ],
1415 |       "clouds": 20,
1416 |       "pop": 0.28,
1417 |       "rain": 0.19,
1418 |       "uvi": 5.38
1419 |     },
1420 |     {
1421 |       "dt": 1744214400,
1422 |       "sunrise": 1744194363,
1423 |       "sunset": 1744241313,
1424 |       "moonrise": 1744230540,
1425 |       "moonset": 1744189500,
1426 |       "moon_phase": 0.4,
1427 |       "summary": "Expect a day of partly cloudy with clear spells",
1428 |       "temp": {
1429 |         "day": 5.06,
1430 |         "min": -0.29,
1431 |         "max": 10.17,
1432 |         "night": 7.31,
1433 |         "eve": 10.17,
1434 |         "morn": -0.29
1435 |       },
1436 |       "feels_like": {
1437 |         "day": 2,
1438 |         "night": 3.9,
1439 |         "eve": 7.87,
1440 |         "morn": -5.99
1441 |       },
1442 |       "pressure": 1025,
1443 |       "humidity": 22,
1444 |       "dew_point": -15.3,
1445 |       "wind_speed": 7.25,
1446 |       "wind_deg": 303,
1447 |       "wind_gust": 14.83,
1448 |       "weather": [
1449 |         {
1450 |           "id": 800,
1451 |           "main": "Clear",
1452 |           "description": "clear sky",
1453 |           "icon": "01d"
1454 |         }
1455 |       ],
1456 |       "clouds": 0,
1457 |       "pop": 0,
1458 |       "uvi": 5.75
1459 |     },
1460 |     {
1461 |       "dt": 1744300800,
1462 |       "sunrise": 1744280668,
1463 |       "sunset": 1744327776,
1464 |       "moonrise": 1744320600,
1465 |       "moonset": 1744277040,
1466 |       "moon_phase": 0.43,
1467 |       "summary": "Expect a day of partly cloudy with rain",
1468 |       "temp": {
1469 |         "day": 9,
1470 |         "min": 6.03,
1471 |         "max": 9.5,
1472 |         "night": 7.53,
1473 |         "eve": 7.75,
1474 |         "morn": 6.28
1475 |       },
1476 |       "feels_like": {
1477 |         "day": 5.46,
1478 |         "night": 4.88,
1479 |         "eve": 3.88,
1480 |         "morn": 3.82
1481 |       },
1482 |       "pressure": 1025,
1483 |       "humidity": 55,
1484 |       "dew_point": 0.26,
1485 |       "wind_speed": 8.08,
1486 |       "wind_deg": 154,
1487 |       "wind_gust": 11.96,
1488 |       "weather": [
1489 |         {
1490 |           "id": 500,
1491 |           "main": "Rain",
1492 |           "description": "light rain",
1493 |           "icon": "10d"
1494 |         }
1495 |       ],
1496 |       "clouds": 100,
1497 |       "pop": 1,
1498 |       "rain": 1.6,
1499 |       "uvi": 3.75
1500 |     },
1501 |     {
1502 |       "dt": 1744387200,
1503 |       "sunrise": 1744366973,
1504 |       "sunset": 1744414239,
1505 |       "moonrise": 1744410600,
1506 |       "moonset": 1744364580,
1507 |       "moon_phase": 0.46,
1508 |       "summary": "There will be rain today",
1509 |       "temp": {
1510 |         "day": 8.77,
1511 |         "min": 7.14,
1512 |         "max": 10.63,
1513 |         "night": 10.25,
1514 |         "eve": 10.63,
1515 |         "morn": 7.73
1516 |       },
1517 |       "feels_like": {
1518 |         "day": 5.71,
1519 |         "night": 9.81,
1520 |         "eve": 9.86,
1521 |         "morn": 4.98
1522 |       },
1523 |       "pressure": 1019,
1524 |       "humidity": 89,
1525 |       "dew_point": 7,
1526 |       "wind_speed": 8.72,
1527 |       "wind_deg": 103,
1528 |       "wind_gust": 18.17,
1529 |       "weather": [
1530 |         {
1531 |           "id": 501,
1532 |           "main": "Rain",
1533 |           "description": "moderate rain",
1534 |           "icon": "10d"
1535 |         }
1536 |       ],
1537 |       "clouds": 100,
1538 |       "pop": 1,
1539 |       "rain": 35.75,
1540 |       "uvi": 0.55
1541 |     },
1542 |     {
1543 |       "dt": 1744473600,
1544 |       "sunrise": 1744453279,
1545 |       "sunset": 1744500702,
1546 |       "moonrise": 1744500660,
1547 |       "moonset": 1744452060,
1548 |       "moon_phase": 0.5,
1549 |       "summary": "You can expect rain in the morning, with partly cloudy in the afternoon",
1550 |       "temp": {
1551 |         "day": 9.98,
1552 |         "min": 8.32,
1553 |         "max": 11.11,
1554 |         "night": 8.32,
1555 |         "eve": 11.11,
1556 |         "morn": 9.66
1557 |       },
1558 |       "feels_like": {
1559 |         "day": 9.98,
1560 |         "night": 5.85,
1561 |         "eve": 10.65,
1562 |         "morn": 8.1
1563 |       },
1564 |       "pressure": 1009,
1565 |       "humidity": 97,
1566 |       "dew_point": 9.5,
1567 |       "wind_speed": 5.56,
1568 |       "wind_deg": 136,
1569 |       "wind_gust": 11.82,
1570 |       "weather": [
1571 |         {
1572 |           "id": 501,
1573 |           "main": "Rain",
1574 |           "description": "moderate rain",
1575 |           "icon": "10d"
1576 |         }
1577 |       ],
1578 |       "clouds": 100,
1579 |       "pop": 1,
1580 |       "rain": 14.82,
1581 |       "uvi": 0.3
1582 |     },
1583 |     {
1584 |       "dt": 1744560000,
1585 |       "sunrise": 1744539586,
1586 |       "sunset": 1744587164,
1587 |       "moonrise": 1744590720,
1588 |       "moonset": 1744539660,
1589 |       "moon_phase": 0.52,
1590 |       "summary": "You can expect partly cloudy in the morning, with clearing in the afternoon",
1591 |       "temp": {
1592 |         "day": 9.56,
1593 |         "min": 7.37,
1594 |         "max": 13.26,
1595 |         "night": 8.42,
1596 |         "eve": 13.26,
1597 |         "morn": 7.51
1598 |       },
1599 |       "feels_like": {
1600 |         "day": 8.44,
1601 |         "night": 5.15,
1602 |         "eve": 12.13,
1603 |         "morn": 5.46
1604 |       },
1605 |       "pressure": 1011,
1606 |       "humidity": 70,
1607 |       "dew_point": 4.25,
1608 |       "wind_speed": 7.08,
1609 |       "wind_deg": 316,
1610 |       "wind_gust": 10.32,
1611 |       "weather": [
1612 |         {
1613 |           "id": 804,
1614 |           "main": "Clouds",
1615 |           "description": "overcast clouds",
1616 |           "icon": "04d"
1617 |         }
1618 |       ],
1619 |       "clouds": 100,
1620 |       "pop": 0,
1621 |       "uvi": 1
1622 |     },
1623 |     {
1624 |       "dt": 1744646400,
1625 |       "sunrise": 1744625894,
1626 |       "sunset": 1744673627,
1627 |       "moonrise": 1744680960,
1628 |       "moonset": 1744627440,
1629 |       "moon_phase": 0.55,
1630 |       "summary": "Expect a day of partly cloudy with clear spells",
1631 |       "temp": {
1632 |         "day": 12.6,
1633 |         "min": 7.84,
1634 |         "max": 17.57,
1635 |         "night": 14.48,
1636 |         "eve": 17.57,
1637 |         "morn": 7.84
1638 |       },
1639 |       "feels_like": {
1640 |         "day": 11.06,
1641 |         "night": 13.39,
1642 |         "eve": 16.4,
1643 |         "morn": 5.03
1644 |       },
1645 |       "pressure": 1015,
1646 |       "humidity": 44,
1647 |       "dew_point": 0.46,
1648 |       "wind_speed": 5.46,
1649 |       "wind_deg": 319,
1650 |       "wind_gust": 11.02,
1651 |       "weather": [
1652 |         {
1653 |           "id": 802,
1654 |           "main": "Clouds",
1655 |           "description": "scattered clouds",
1656 |           "icon": "03d"
1657 |         }
1658 |       ],
1659 |       "clouds": 32,
1660 |       "pop": 0,
1661 |       "uvi": 1
1662 |     },
1663 |     {
1664 |       "dt": 1744732800,
1665 |       "sunrise": 1744712202,
1666 |       "sunset": 1744760090,
1667 |       "moonrise": 1744771140,
1668 |       "moonset": 1744715460,
1669 |       "moon_phase": 0.58,
1670 |       "summary": "There will be partly cloudy today",
1671 |       "temp": {
1672 |         "day": 18.38,
1673 |         "min": 11.81,
1674 |         "max": 23.53,
1675 |         "night": 16.57,
1676 |         "eve": 22.17,
1677 |         "morn": 11.81
1678 |       },
1679 |       "feels_like": {
1680 |         "day": 17.92,
1681 |         "night": 15.46,
1682 |         "eve": 21.3,
1683 |         "morn": 11.06
1684 |       },
1685 |       "pressure": 1005,
1686 |       "humidity": 63,
1687 |       "dew_point": 10.97,
1688 |       "wind_speed": 8.5,
1689 |       "wind_deg": 251,
1690 |       "wind_gust": 14.82,
1691 |       "weather": [
1692 |         {
1693 |           "id": 803,
1694 |           "main": "Clouds",
1695 |           "description": "broken clouds",
1696 |           "icon": "04d"
1697 |         }
1698 |       ],
1699 |       "clouds": 65,
1700 |       "pop": 0,
1701 |       "uvi": 1
1702 |     }
1703 |   ],
1704 |   "alerts": [
1705 |     {
1706 |       "sender_name": "NWS Upton NY",
1707 |       "event": "Freeze Warning",
1708 |       "start": 1744171200,
1709 |       "end": 1744203600,
1710 |       "description": "* WHAT...Sub-freezing temperatures as low as 28 expected.\n\n* WHERE...Portions of northeastern New Jersey and southeastern New\nYork.\n\n* WHEN...From midnight tonight to 9 AM EDT Wednesday.\n\n* IMPACTS...Freezing temperatures could kill crops and other\nsensitive vegetation.",
1711 |       "tags": [
1712 |         "Extreme low temperature"
1713 |       ]
1714 |     }
1715 |   ]
1716 | }
```