# Directory Structure ``` ├── .github │ └── workflows │ └── publish-to-pypi.yml ├── .gitignore ├── Dockerfile ├── glama.json ├── LICENSE ├── pyproject.toml ├── pytest.ini ├── README.md ├── requirements.txt ├── smithery.yaml ├── src │ └── mcp_weather_server │ ├── __init__.py │ ├── __main__.py │ ├── server.py │ ├── tools │ │ ├── __init__.py │ │ ├── toolhandler.py │ │ ├── tools_time.py │ │ ├── tools_weather.py │ │ └── weather_service.py │ └── utils.py ├── tests │ ├── __init__.py │ ├── conftest.py │ ├── test_integration.py │ ├── test_mcp_weather_server.py │ ├── test_performance.py │ ├── test_server.py │ ├── test_time_tools.py │ ├── test_utils.py │ ├── test_weather_service.py │ └── test_weather_tools.py └── uv.lock ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` __pycache__/ *.pyc venv/ .pytest_cache/ node_modules ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown [](https://smithery.ai/server/@isdaniel/mcp_weather_server) [](https://pypi.org/project/mcp-weather-server/) [](https://pypi.org/project/mcp-weather-server/) <a href="https://glama.ai/mcp/servers/@isdaniel/mcp_weather_server"> <img width="380" height="200" src="https://glama.ai/mcp/servers/@isdaniel/mcp_weather_server/badge" /> </a> # Weather MCP Server A Model Context Protocol (MCP) server that provides weather information using the Open-Meteo API. This server supports both standard MCP communication and HTTP Server-Sent Events (SSE) for web-based integration. ## Features * Get current weather information for a specified city * Get weather data for a date range * Get current date/time in any timezone * Convert time between timezones * Get timezone information * HTTP SSE (Server-Sent Events) support for web applications * RESTful API endpoints via Starlette/FastAPI integration ## Installation ### Standard Installation (for MCP clients like Claude Desktop) This package can be installed using pip: ```bash pip install mcp_weather_server ``` ### Manual Configuration for MCP Clients This server is designed to be installed manually by adding its configuration to the `cline_mcp_settings.json` file. 1. Add the following entry to the `mcpServers` object in your `cline_mcp_settings.json` file: ```json { "mcpServers": { "weather": { "command": "python", "args": [ "-m", "mcp_weather_server" ], "disabled": false, "autoApprove": [] } } } ``` 2. Save the `cline_mcp_settings.json` file. ### SSE Server Installation (for web applications) For HTTP SSE support, you'll need additional dependencies: ```bash pip install mcp_weather_server starlette uvicorn ``` ## Server Modes This MCP server supports both **stdio** and **SSE** modes in a single unified server: ### 1. Standard MCP Mode (Default) The standard mode communicates via stdio and is compatible with MCP clients like Claude Desktop. ```bash # Default mode (stdio) python -m mcp_weather_server # Explicitly specify stdio mode python -m mcp_weather_server.server --mode stdio ``` ### 2. HTTP SSE Mode (Web Applications) The SSE mode runs an HTTP server that provides MCP functionality via Server-Sent Events, making it accessible to web applications. ```bash # Start SSE server on default host/port (0.0.0.0:8080) python -m mcp_weather_server.server --mode sse # Specify custom host and port python -m mcp_weather_server.server --mode sse --host localhost --port 3000 # Enable debug mode python -m mcp_weather_server.server --mode sse --debug ``` **Command Line Options:** ``` --mode {stdio,sse} Server mode: stdio (default) or sse --host HOST Host to bind to (SSE mode only, default: 0.0.0.0) --port PORT Port to listen on (SSE mode only, default: 8080) --debug Enable debug mode ``` **SSE Endpoints:** - `GET /sse` - SSE endpoint for MCP communication - `POST /messages/` - Message endpoint for sending MCP requests **Example SSE Usage:** ```javascript // Connect to SSE endpoint const eventSource = new EventSource('http://localhost:8080/sse'); // Send MCP tool request fetch('http://localhost:8080/messages/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'tool_call', tool: 'get_weather', arguments: { city: 'Tokyo' } }) }); ``` ## Configuration This server does not require an API key. It uses the Open-Meteo API, which is free and open-source. ## Usage This server provides several tools for weather and time-related operations: ### Available Tools 1. **`get_current_weather`** - Get current weather for a city 2. **`get_weather_by_datetime_range`** - Get weather data for a date range 3. **`get_current_datetime`** - Get current time in any timezone 4. **`get_timezone_info`** - Get timezone information 5. **`convert_time`** - Convert time between timezones ### Tool Details #### `get_current_weather` Retrieves the current weather information for a given city. **Parameters:** - `city` (string, required): The name of the city (English names only) **Returns:** JSON formatted weather data with current temperature and conditions **Example:** ```json { "city": "Taipei", "weather": "Partly cloudy", "temperature_celsius": 25 } ``` #### `get_weather_by_datetime_range` Retrieves weather information for a specified city between start and end dates. **Parameters:** - `city` (string, required): The name of the city (English names only) - `start_date` (string, required): Start date in format YYYY-MM-DD (ISO 8601) - `end_date` (string, required): End date in format YYYY-MM-DD (ISO 8601) **Returns:** JSON array with daily weather summaries **Example:** ```json [ { "date": "2024-01-01", "day_of_week": "Monday", "city": "London", "weather": "Light rain", "temperature_celsius": 8 } ] ``` #### `get_current_datetime` Retrieves the current time in a specified timezone. **Parameters:** - `timezone_name` (string, required): IANA timezone name (e.g., 'America/New_York', 'Europe/London'). Use UTC if no timezone provided. **Returns:** Current date and time in the specified timezone **Example:** ```json { "timezone": "America/New_York", "current_time": "2024-01-15T14:30:00-05:00", "utc_time": "2024-01-15T19:30:00Z" } ``` #### `get_timezone_info` Get information about a specific timezone. **Parameters:** - `timezone_name` (string, required): IANA timezone name **Returns:** Timezone details including offset and DST information #### `convert_time` Convert time from one timezone to another. **Parameters:** - `time_str` (string, required): Time to convert (ISO format) - `from_timezone` (string, required): Source timezone - `to_timezone` (string, required): Target timezone **Returns:** Converted time in target timezone ## MCP Client Usage Examples ### Using with Claude Desktop or MCP Clients ```xml <use_mcp_tool> <server_name>weather</server_name> <tool_name>get_current_weather</tool_name> <arguments> { "city": "Tokyo" } </arguments> </use_mcp_tool> ``` ```xml <use_mcp_tool> <server_name>weather</server_name> <tool_name>get_weather_by_datetime_range</tool_name> <arguments> { "city": "Paris", "start_date": "2024-01-01", "end_date": "2024-01-07" } </arguments> </use_mcp_tool> ``` ```xml <use_mcp_tool> <server_name>weather</server_name> <tool_name>get_current_datetime</tool_name> <arguments> { "timezone_name": "Europe/Paris" } </arguments> </use_mcp_tool> ``` ## Web Integration (SSE Mode) When running in SSE mode, you can integrate the weather server with web applications: ### HTML/JavaScript Example ```html <!DOCTYPE html> <html> <head> <title>Weather MCP Client</title> </head> <body> <div id="weather-data"></div> <script> // Connect to SSE endpoint const eventSource = new EventSource('http://localhost:8080/sse'); eventSource.onmessage = function(event) { const data = JSON.parse(event.data); document.getElementById('weather-data').innerHTML = JSON.stringify(data, null, 2); }; // Function to get weather async function getWeather(city) { const response = await fetch('http://localhost:8080/messages/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', method: 'tools/call', params: { name: 'get_current_weather', arguments: { city: city } }, id: 1 }) }); } // Example: Get weather for Tokyo getWeather('Tokyo'); </script> </body> </html> ``` ## Development ### Project Structure ``` mcp_weather_server/ ├── src/ │ └── mcp_weather_server/ │ ├── __init__.py │ ├── __main__.py # Main MCP server entry point │ ├── server.py # Standard MCP server implementation │ ├── server-see.py # SSE server implementation (NEW) │ ├── utils.py # Utility functions │ └── tools/ # Tool implementations │ ├── __init__.py │ ├── toolhandler.py # Base tool handler │ ├── tools_weather.py # Weather-related tools │ ├── tools_time.py # Time-related tools │ └── weather_service.py # Weather API service ├── tests/ ├── pyproject.toml ├── requirements.txt └── README.md ``` ### Running for Development #### Standard MCP Mode ```bash # From project root python -m mcp_weather_server # Or with PYTHONPATH export PYTHONPATH="/path/to/mcp_weather_server/src" python -m mcp_weather_server ``` #### SSE Server Mode ```bash # From project root python src/mcp_weather_server/server-see.py --host 0.0.0.0 --port 8080 # With custom host/port python src/mcp_weather_server/server-see.py --host localhost --port 3000 ``` ### Adding New Tools To add new weather or time-related tools: 1. Create a new tool handler in the appropriate file under `tools/` 2. Inherit from the `ToolHandler` base class 3. Implement the required methods (`get_name`, `get_description`, `call`) 4. Register the tool in `server.py` ## Dependencies ### Core Dependencies - `mcp>=1.0.0` - Model Context Protocol implementation - `httpx>=0.28.1` - HTTP client for API requests - `python-dateutil>=2.8.2` - Date/time parsing utilities ### SSE Server Dependencies - `starlette` - ASGI web framework - `uvicorn` - ASGI server ### Development Dependencies - `pytest` - Testing framework ## API Data Source This server uses the [Open-Meteo API](https://open-meteo.com/), which is: - Free and open-source - No API key required - Provides accurate weather forecasts - Supports global locations - Historical and current weather data ## Troubleshooting ### Common Issues **1. City not found** - Ensure city names are in English - Try using the full city name or include country (e.g., "Paris, France") - Check spelling of city names **2. SSE Server not accessible** - Verify the server is running: `python src/mcp_weather_server/server-see.py` - Check firewall settings for the specified port - Ensure all dependencies are installed: `pip install starlette uvicorn` **3. MCP Client connection issues** - Verify Python path in MCP client configuration - Check that `mcp_weather_server` package is installed - Ensure Python environment has required dependencies **4. Date format errors** - Use ISO 8601 format for dates: YYYY-MM-DD - Ensure start_date is before end_date - Check that dates are not too far in the future ### Error Responses The server returns structured error messages: ```json { "error": "Could not retrieve coordinates for InvalidCity." } ``` ## Changelog ### v0.2.1 (Current) - Added HTTP SSE (Server-Sent Events) support - Added timezone conversion tools - Enhanced weather data formatting - Improved error handling - Added comprehensive documentation ### Previous Versions - v0.2.0: Added date range weather queries - v0.1.0: Initial release with basic weather functionality ``` -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- ```python ``` -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- ``` httpx mcp python-dateutil ``` -------------------------------------------------------------------------------- /src/mcp_weather_server/__main__.py: -------------------------------------------------------------------------------- ```python import asyncio from .server import main if __name__ == "__main__": asyncio.run(main()) ``` -------------------------------------------------------------------------------- /glama.json: -------------------------------------------------------------------------------- ```json { "$schema": "https://glama.ai/mcp/schemas/server.json", "maintainers": [ "isdaniel" ] } ``` -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- ```yaml runtime: python startCommand: type: stdio configSchema: type: object commandFunction: | config => ({ command: "uv", args: ["run", "mcp_weather_server"] }) ``` -------------------------------------------------------------------------------- /src/mcp_weather_server/tools/__init__.py: -------------------------------------------------------------------------------- ```python """Tools package for MCP Weather Server.""" from .toolhandler import * from .tools_time import * from .tools_weather import * from .weather_service import * __all__ = [] ``` -------------------------------------------------------------------------------- /src/mcp_weather_server/__init__.py: -------------------------------------------------------------------------------- ```python from .server import main as async_main import asyncio def main(): """Synchronous entry point for the package.""" asyncio.run(async_main()) __all__ = ['main', 'async_main'] ``` -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- ```dockerfile FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim # Set the working directory WORKDIR /app # Copy the current directory contents into the container at /app COPY . /app RUN (uv venv .venv) && (. .venv/bin/activate) && (uv pip install mcp_weather_server) CMD ["uv","run","mcp_weather_server"] ``` -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- ```toml [build-system] requires = ["setuptools>=61.0", "wheel"] build-backend = "setuptools.build_meta" [project] name = "mcp_weather_server" version = "0.3.1" authors = [ { name = "danielshih", email = "[email protected]" }, ] description = "An MCP server for weather information" readme = "README.md" requires-python = ">=3.13" dependencies = [ "mcp>=1.0.0", "argparse>=1.4.0", "httpx>=0.28.1", "mcp[cli]>=1.2.1", "python-dotenv>=1.0.1", "python-dateutil>=2.8.2" ] [tool.hatch.build.targets.wheel] packages = ["src/mcp_weather_server"] [project.scripts] mcp_weather_server = "mcp_weather_server:main" [project.urls] Source = "https://github.com/isdaniel/mcp_weather_server" ``` -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- ``` [pytest] minversion = 6.0 addopts = -ra --strict-markers --strict-config --tb=short -v testpaths = tests python_files = test_*.py python_classes = Test* python_functions = test_* markers = asyncio: marks tests as async slow: marks tests as slow (deselect with '-m "not slow"') integration: marks tests as integration tests unit: marks tests as unit tests performance: marks tests as performance tests # Async test configuration asyncio_mode = auto asyncio_default_fixture_loop_scope = function # Filter warnings filterwarnings = ignore::DeprecationWarning ignore::PendingDeprecationWarning # Test output console_output_style = progress log_cli = true log_cli_level = INFO log_cli_format = %(asctime)s [%(levelname)8s] %(message)s log_cli_date_format = %Y-%m-%d %H:%M:%S ``` -------------------------------------------------------------------------------- /.github/workflows/publish-to-pypi.yml: -------------------------------------------------------------------------------- ```yaml name: Publish to PyPI on: push: branches: - main # Trigger on pushes to the main branch (adjust as needed) tags: - "v*" # or trigger when create new tag with "v" prefix jobs: build-and-publish: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v4 with: python-version: '3.x' # Use a suitable Python version - name: Install dependencies run: | python -m pip install --upgrade pip pip install build - name: Build package run: python -m build - name: Extract version from pyproject.toml run: | VERSION=$(grep -oP '(?<=version = ").*(?=")' pyproject.toml) echo "VERSION=$VERSION" >> $GITHUB_ENV - name: Check if version exists on PyPI id: check_version run: | PACKAGE_NAME="mcp_weather_server" if curl -sSf "https://pypi.org/pypi/${PACKAGE_NAME}/${VERSION}/json" > /dev/null; then echo "Version ${VERSION} already exists on PyPI. Skipping publish." echo "skip_publish=true" >> $GITHUB_ENV else echo "skip_publish=false" >> $GITHUB_ENV fi - name: Publish to PyPI if: env.skip_publish != 'true' uses: pypa/gh-action-pypi-publish@release/v1 with: password: ${{ secrets.PYPI_API_TOKEN }} ``` -------------------------------------------------------------------------------- /src/mcp_weather_server/utils.py: -------------------------------------------------------------------------------- ```python from datetime import datetime, timezone import json from typing import List from zoneinfo import ZoneInfo from mcp.types import ErrorData from mcp import McpError from pydantic import BaseModel from dateutil import parser class TimeResult(BaseModel): timezone: str datetime: str def get_zoneinfo(timezone_name: str) -> ZoneInfo: try: return ZoneInfo(timezone_name) except Exception as e: error_data = ErrorData(code=-1, message=f"Invalid timezone: {str(e)}") raise McpError(error_data) def format_get_weather_bytime(data_result) -> str: return f""" Please analyze the above JSON weather forecast information and generate a report for me. Please note that the content is provided city: city name start_date: search weather start time end_date: search weather end time weather_data: weather data. {json.dumps(data_result)} """ def get_closest_utc_index(hourly_times: List[str]) -> int: """ Returns the index of the datetime in `hourly_times` closest to the current UTC time or a provided datetime. :param hourly_times: List of ISO 8601 time strings (UTC) :return: Index of the closest datetime in the list """ current_time = datetime.now(timezone.utc) parsed_times = [ parser.isoparse(t).replace(tzinfo=timezone.utc) if parser.isoparse(t).tzinfo is None else parser.isoparse(t).astimezone(timezone.utc) for t in hourly_times ] return min(range(len(parsed_times)), key=lambda i: abs(parsed_times[i] - current_time)) # Weather code descriptions (from Open-Meteo documentation) weather_descriptions = { 0: "Clear sky", 1: "Mainly clear", 2: "Partly cloudy", 3: "Overcast", 45: "Fog", 48: "Depositing rime fog", 51: "Light drizzle", 53: "Moderate drizzle", 55: "Dense drizzle", 56: "Light freezing drizzle", 57: "Dense freezing drizzle", 61: "Slight rain", 63: "Moderate rain", 65: "Heavy rain", 66: "Light freezing rain", 67: "Heavy freezing rain", 71: "Slight snow fall", 73: "Moderate snow fall", 75: "Heavy snow fall", 77: "Snow grains", 80: "Slight rain showers", 81: "Moderate rain showers", 82: "Violent rain showers", 85: "Slight snow showers", 86: "Heavy snow showers", 95: "Thunderstorm", 96: "Thunderstorm with slight hail", 99: "Thunderstorm with heavy hail", } ``` -------------------------------------------------------------------------------- /src/mcp_weather_server/tools/toolhandler.py: -------------------------------------------------------------------------------- ```python """ Base ToolHandler class for extensible MCP tool management. This follows the architecture pattern from mcp-gsuite for easy extension. """ from abc import ABC, abstractmethod from collections.abc import Sequence from mcp.types import ( Tool, TextContent, ImageContent, EmbeddedResource, ) class ToolHandler(ABC): """ Abstract base class for all MCP tool handlers. This provides a consistent interface for tool registration, description, and execution across the MCP weather server. """ def __init__(self, tool_name: str): """ Initialize a tool handler with a unique name. Args: tool_name: Unique identifier for this tool """ self.name = tool_name @abstractmethod def get_tool_description(self) -> Tool: """ Return the MCP Tool description for this handler. This method must be implemented by each tool handler to define the tool's schema, parameters, and documentation. Returns: Tool: MCP Tool object with schema and metadata """ raise NotImplementedError("Each tool handler must implement get_tool_description") @abstractmethod async def run_tool(self, args: dict) -> Sequence[TextContent | ImageContent | EmbeddedResource]: """ Execute the tool with provided arguments. This method contains the actual logic for the tool's functionality. Note: This method should be async to support API calls. Args: args: Dictionary of arguments passed to the tool Returns: Sequence of MCP content objects (text, image, or embedded resources) Raises: RuntimeError: For missing required arguments or execution errors """ raise NotImplementedError("Each tool handler must implement run_tool") def validate_required_args(self, args: dict, required_fields: list[str]) -> None: """ Validate that all required arguments are present. Args: args: Dictionary of provided arguments required_fields: List of required field names Raises: RuntimeError: If any required field is missing """ missing_fields = [field for field in required_fields if field not in args] if missing_fields: raise RuntimeError(f"Missing required arguments: {', '.join(missing_fields)}") ``` -------------------------------------------------------------------------------- /tests/test_mcp_weather_server.py: -------------------------------------------------------------------------------- ```python """ Comprehensive test suite for MCP Weather Server. This file imports and runs all test modules to ensure complete coverage. """ import pytest from tests.test_weather_service import * from tests.test_weather_tools import * from tests.test_time_tools import * from tests.test_utils import * from tests.test_server import * from tests.test_integration import * # Additional legacy tests for backward compatibility import httpx from unittest.mock import AsyncMock, Mock, patch @pytest.mark.asyncio async def test_legacy_get_weather_functionality(): """Legacy test to ensure backward compatibility.""" # This test ensures that any legacy get_weather function (if it exists) still works try: from src.mcp_weather_server.server import get_weather # Mock HTTP client for legacy function with patch('httpx.AsyncClient') as mock_client_class: mock_client = AsyncMock() mock_response = Mock() mock_response.status_code = 500 mock_client.get.return_value = mock_response mock_client_class.return_value.__aenter__.return_value = mock_client result = await get_weather("InvalidCity") assert "Error" in str(result) or "error" in str(result).lower() except ImportError: # get_weather function doesn't exist, which is fine # The new architecture uses tool handlers instead pytest.skip("Legacy get_weather function not found - using new tool handler architecture") # Smoke tests to verify all components work together class TestSmokeTests: """Smoke tests to verify basic functionality.""" @pytest.mark.asyncio async def test_can_import_all_modules(self): """Test that all modules can be imported without errors.""" from src.mcp_weather_server import server from src.mcp_weather_server import utils from src.mcp_weather_server.tools import toolhandler from src.mcp_weather_server.tools import weather_service from src.mcp_weather_server.tools import tools_weather from src.mcp_weather_server.tools import tools_time # Basic assertions to ensure imports worked assert hasattr(server, 'register_all_tools') assert hasattr(utils, 'get_zoneinfo') assert hasattr(toolhandler, 'ToolHandler') assert hasattr(weather_service, 'WeatherService') @pytest.mark.asyncio async def test_server_starts_without_errors(self): """Test that server initialization doesn't raise errors.""" from src.mcp_weather_server.server import register_all_tools, list_tools # Clear any existing handlers from src.mcp_weather_server.server import tool_handlers tool_handlers.clear() # Register tools and list them register_all_tools() tools = await list_tools() # Verify we have the expected number of tools assert len(tools) >= 6 tool_names = [tool.name for tool in tools] expected_tools = [ "get_current_weather", "get_weather_byDateTimeRange", "get_weather_details", "get_current_datetime", "get_timezone_info", "convert_time" ] for expected_tool in expected_tools: assert expected_tool in tool_names ``` -------------------------------------------------------------------------------- /src/mcp_weather_server/server.py: -------------------------------------------------------------------------------- ```python """ This server implements a modular, extensible design pattern similar to mcp-gsuite, making it easy to add new weather-related tools and functionality. Supports both stdio and SSE MCP server modes. """ import argparse import asyncio import logging import sys import traceback from typing import Any, Dict, Optional from collections.abc import Sequence from mcp.server import Server from mcp.types import ( Tool, TextContent, ImageContent, EmbeddedResource, ) # SSE-related imports (imported conditionally) try: from mcp.server.sse import SseServerTransport from starlette.applications import Starlette from starlette.requests import Request from starlette.routing import Mount, Route import uvicorn SSE_AVAILABLE = True except ImportError: SSE_AVAILABLE = False # Import tool handlers from .tools.toolhandler import ToolHandler from .tools.tools_weather import ( GetCurrentWeatherToolHandler, GetWeatherByDateRangeToolHandler, GetWeatherDetailsToolHandler, ) from .tools.tools_time import ( GetCurrentDateTimeToolHandler, GetTimeZoneInfoToolHandler, ConvertTimeToolHandler, ) # Configure logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger("mcp-weather") # Create the MCP server instance app = Server("mcp-weather-server") # Global tool handlers registry tool_handlers: Dict[str, ToolHandler] = {} def add_tool_handler(tool_handler: ToolHandler) -> None: """ Register a tool handler with the server. Args: tool_handler: The tool handler instance to register """ global tool_handlers tool_handlers[tool_handler.name] = tool_handler logger.info(f"Registered tool handler: {tool_handler.name}") def get_tool_handler(name: str) -> ToolHandler | None: """ Retrieve a tool handler by name. Args: name: The name of the tool handler Returns: The tool handler instance or None if not found """ return tool_handlers.get(name) def register_all_tools() -> None: """ Register all available tool handlers. This function serves as the central registry for all tools. New tool handlers should be added here for automatic registration. """ # Weather tools add_tool_handler(GetCurrentWeatherToolHandler()) add_tool_handler(GetWeatherByDateRangeToolHandler()) add_tool_handler(GetWeatherDetailsToolHandler()) # Time tools add_tool_handler(GetCurrentDateTimeToolHandler()) add_tool_handler(GetTimeZoneInfoToolHandler()) add_tool_handler(ConvertTimeToolHandler()) logger.info(f"Registered {len(tool_handlers)} tool handlers") def create_starlette_app(mcp_server: Server, *, debug: bool = False) -> Starlette: """ Create a Starlette application that can serve the provided mcp server with SSE. Args: mcp_server: The MCP server instance debug: Whether to enable debug mode Returns: Starlette application instance """ if not SSE_AVAILABLE: raise RuntimeError("SSE dependencies not available. Install with: pip install starlette uvicorn") sse = SseServerTransport("/messages/") async def handle_sse(request: Request) -> None: async with sse.connect_sse( request.scope, request.receive, request._send, ) as (read_stream, write_stream): await mcp_server.run( read_stream, write_stream, mcp_server.create_initialization_options(), ) return Starlette( debug=debug, routes=[ Route("/sse", endpoint=handle_sse), Mount("/messages/", app=sse.handle_post_message), ], ) @app.list_tools() async def list_tools() -> list[Tool]: """ List all available tools. Returns: List of Tool objects describing all registered tools """ try: tools = [handler.get_tool_description() for handler in tool_handlers.values()] logger.info(f"Listed {len(tools)} available tools") return tools except Exception as e: logger.exception(f"Error listing tools: {str(e)}") raise @app.call_tool() async def call_tool(name: str, arguments: Any) -> Sequence[TextContent | ImageContent | EmbeddedResource]: """ Execute a tool with the provided arguments. Args: name: The name of the tool to execute arguments: The arguments to pass to the tool Returns: Sequence of MCP content objects Raises: RuntimeError: If the tool execution fails """ try: # Validate arguments if not isinstance(arguments, dict): raise RuntimeError("Arguments must be a dictionary") # Get the tool handler tool_handler = get_tool_handler(name) if not tool_handler: raise ValueError(f"Unknown tool: {name}") logger.info(f"Executing tool: {name} with arguments: {list(arguments.keys())}") # Execute the tool result = await tool_handler.run_tool(arguments) logger.info(f"Tool {name} executed successfully") return result except Exception as e: logger.exception(f"Error executing tool {name}: {str(e)}") error_traceback = traceback.format_exc() logger.error(f"Full traceback: {error_traceback}") # Return error as text content return [ TextContent( type="text", text=f"Error executing tool '{name}': {str(e)}" ) ] async def main(): """ Main entry point for the MCP weather server. Supports both stdio and SSE modes based on command line arguments. """ # Parse command line arguments parser = argparse.ArgumentParser(description='MCP Weather Server - supports stdio and SSE modes') parser.add_argument('--mode', choices=['stdio', 'sse'], default='stdio', help='Server mode: stdio (default) or sse') parser.add_argument('--host', default='0.0.0.0', help='Host to bind to (SSE mode only, default: 0.0.0.0)') parser.add_argument('--port', type=int, default=8080, help='Port to listen on (SSE mode only, default: 8080)') parser.add_argument('--debug', action='store_true', help='Enable debug mode') args = parser.parse_args() try: # Register all tools register_all_tools() logger.info(f"Starting MCP Weather Server in {args.mode} mode...") logger.info(f"Python version: {sys.version}") logger.info(f"Registered tools: {list(tool_handlers.keys())}") # Run the server in the specified mode await run_server(args.mode, args.host, args.port, args.debug) except Exception as e: logger.exception(f"Failed to start server: {str(e)}") raise async def run_server(mode: str, host: str = "0.0.0.0", port: int = 8080, debug: bool = False): """ Unified server runner that supports both stdio and SSE modes. Args: mode: Server mode ("stdio" or "sse") host: Host to bind to (SSE mode only) port: Port to listen on (SSE mode only) debug: Whether to enable debug mode """ if mode == "stdio": logger.info("Starting stdio server...") from mcp.server.stdio import stdio_server async with stdio_server() as (read_stream, write_stream): await app.run( read_stream, write_stream, app.create_initialization_options() ) elif mode == "sse": if not SSE_AVAILABLE: raise RuntimeError( "SSE mode requires additional dependencies. " "Install with: pip install starlette uvicorn" ) logger.info(f"Starting SSE server on {host}:{port}...") # Create Starlette app with SSE transport starlette_app = create_starlette_app(app, debug=debug) # Configure uvicorn config = uvicorn.Config( app=starlette_app, host=host, port=port, log_level="debug" if debug else "info" ) # Run the server server = uvicorn.Server(config) await server.serve() else: raise ValueError(f"Unknown mode: {mode}") if __name__ == "__main__": import asyncio asyncio.run(main()) ``` -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- ```python """ Pytest configuration and fixtures for MCP weather server tests. """ import pytest import asyncio import subprocess import json import time import sys import os from pathlib import Path import httpx from typing import Any, Dict, List class MCPServerProcess: """Helper class to manage MCP server process for testing.""" def __init__(self): self.process = None self.port = 8000 self.base_url = f"http://localhost:{self.port}" async def start(self): """Start the MCP server process.""" # Get the project root directory project_root = Path(__file__).parent.parent server_script = project_root / "test_server.py" # Start the server process self.process = subprocess.Popen([ sys.executable, str(server_script) ], stdout=subprocess.PIPE, stderr=subprocess.PIPE) # Wait for server to start up max_retries = 30 for _ in range(max_retries): try: async with httpx.AsyncClient() as client: response = await client.get(f"{self.base_url}/health") if response.status_code == 200: break except (httpx.RequestError, httpx.HTTPStatusError): pass await asyncio.sleep(0.1) else: raise RuntimeError("MCP server failed to start") async def stop(self): """Stop the MCP server process.""" if self.process: self.process.terminate() self.process.wait() self.process = None async def call_tool(self, name: str, arguments: Dict[str, Any]) -> List[Dict[str, Any]]: """Call a tool on the MCP server.""" async with httpx.AsyncClient() as client: response = await client.post( f"{self.base_url}/call_tool", json={"name": name, "arguments": arguments} ) response.raise_for_status() return response.json()["result"] async def list_tools(self) -> List[Dict[str, Any]]: """List available tools on the MCP server.""" async with httpx.AsyncClient() as client: response = await client.get(f"{self.base_url}/list_tools") response.raise_for_status() return response.json()["tools"] @pytest.fixture(scope="session") async def mcp_server(): """Fixture that provides a running MCP server process for testing.""" server = MCPServerProcess() await server.start() yield server await server.stop() @pytest.fixture def mock_datetime(): """Fixture to provide controlled datetime mocking.""" from unittest.mock import patch from datetime import datetime from zoneinfo import ZoneInfo class MockDateTime: def __init__(self): self.fixed_time = None self.patches = [] def set_fixed_time(self, dt: datetime): """Set a fixed time to return from datetime.now().""" self.fixed_time = dt def start_mocking(self): """Start datetime mocking.""" if self.patches: self.stop_mocking() def mock_now(tz=None): if self.fixed_time and tz: return self.fixed_time.astimezone(tz) elif self.fixed_time: return self.fixed_time else: return datetime.now(tz) patch_obj = patch('src.mcp_weather_server.tools.tools_time.datetime') mock_datetime = patch_obj.start() mock_datetime.now = mock_now self.patches.append(patch_obj) def stop_mocking(self): """Stop datetime mocking.""" for patch_obj in self.patches: patch_obj.stop() self.patches.clear() mock_dt = MockDateTime() yield mock_dt mock_dt.stop_mocking() @pytest.fixture def mock_timezone(): """Fixture to provide controlled timezone mocking.""" from unittest.mock import patch from zoneinfo import ZoneInfo class MockTimezone: def __init__(self): self.patches = [] self.zone_mapping = {} def add_zone(self, name: str, zone: ZoneInfo): """Add a timezone mapping.""" self.zone_mapping[name] = zone def start_mocking(self): """Start timezone mocking.""" if self.patches: self.stop_mocking() def mock_get_zoneinfo(name: str): if name in self.zone_mapping: return self.zone_mapping[name] return ZoneInfo(name) patch_obj = patch('src.mcp_weather_server.utils.get_zoneinfo', side_effect=mock_get_zoneinfo) patch_obj.start() self.patches.append(patch_obj) def stop_mocking(self): """Stop timezone mocking.""" for patch_obj in self.patches: patch_obj.stop() self.patches.clear() mock_tz = MockTimezone() yield mock_tz mock_tz.stop_mocking() # Weather service fixtures @pytest.fixture def weather_service(): """Create a WeatherService instance for testing.""" from src.mcp_weather_server.tools.weather_service import WeatherService return WeatherService() @pytest.fixture def mock_geo_response(): """Mock geocoding API response.""" return { "results": [ { "latitude": 40.7128, "longitude": -74.0060 } ] } @pytest.fixture def mock_empty_geo_response(): """Mock empty geocoding API response.""" return {"results": []} @pytest.fixture def mock_weather_response(): """Mock weather API response.""" return { "hourly": { "time": [ "2024-01-01T12:00", "2024-01-01T13:00" ], "temperature_2m": [20.0, 21.0], "relative_humidity_2m": [65, 66], "dew_point_2m": [13.0, 14.0], "weather_code": [0, 1] } } @pytest.fixture def mock_weather_range_response(): """Mock weather range API response.""" return { "hourly": { "time": [ "2024-01-01T12:00", "2024-01-01T13:00", "2024-01-02T12:00", "2024-01-02T13:00" ], "temperature_2m": [20.0, 21.0, 22.0, 23.0], "relative_humidity_2m": [65, 66, 67, 68], "dew_point_2m": [13.0, 14.0, 15.0, 16.0], "weather_code": [0, 1, 0, 1] } } @pytest.fixture def sample_current_weather_data(): """Sample current weather data for testing.""" return { "city": "New York", "latitude": 40.7128, "longitude": -74.0060, "temperature_c": 25.0, "relative_humidity_percent": 70, "dew_point_c": 16.0, "weather_code": 1, "weather_description": "Mainly clear" } @pytest.fixture def sample_weather_range_data(): """Sample weather range data for testing.""" return { "city": "New York", "start_date": "2024-01-01", "end_date": "2024-01-02", "weather_data": [ { "datetime": "2024-01-01T12:00:00", "temperature_c": 20.0, "relative_humidity_percent": 65, "dew_point_c": 13.0, "weather_code": 0, "weather_description": "Clear sky" }, { "datetime": "2024-01-01T13:00:00", "temperature_c": 21.0, "relative_humidity_percent": 66, "dew_point_c": 14.0, "weather_code": 1, "weather_description": "Mainly clear" } ] } # HTTP client mock fixtures @pytest.fixture def mock_successful_geo_client(): """Mock successful geocoding client.""" from unittest.mock import AsyncMock, Mock client = AsyncMock() response = Mock() response.status_code = 200 response.json.return_value = { "results": [ { "latitude": 40.7128, "longitude": -74.0060 } ] } client.get.return_value = response return client @pytest.fixture def mock_failed_client(): """Mock failed HTTP client.""" from unittest.mock import AsyncMock, Mock client = AsyncMock() response = Mock() response.status_code = 500 client.get.return_value = response return client @pytest.fixture def mock_network_error_client(): """Mock network error HTTP client.""" from unittest.mock import AsyncMock import httpx client = AsyncMock() client.get.side_effect = httpx.RequestError("Network error") return client ``` -------------------------------------------------------------------------------- /src/mcp_weather_server/tools/tools_time.py: -------------------------------------------------------------------------------- ```python """ Time-related tool handlers for the MCP weather server. This module contains time and timezone-related tool implementations. """ import json import logging from collections.abc import Sequence from datetime import datetime from mcp.types import Tool, TextContent, ImageContent, EmbeddedResource from .toolhandler import ToolHandler from .. import utils logger = logging.getLogger("mcp-weather") class GetCurrentDateTimeToolHandler(ToolHandler): """ Tool handler for getting current date and time in a specified timezone. """ def __init__(self): super().__init__("get_current_datetime") def get_tool_description(self) -> Tool: """ Return the tool description for current datetime lookup. """ return Tool( name=self.name, description="""Get current time in specified timezone.""", inputSchema={ "type": "object", "properties": { "timezone_name": { "type": "string", "description": "IANA timezone name (e.g., 'America/New_York', 'Europe/London'). Use UTC timezone if no timezone provided by the user." } }, "required": ["timezone_name"] } ) async def run_tool(self, args: dict) -> Sequence[TextContent | ImageContent | EmbeddedResource]: """ Execute the current datetime tool. """ try: self.validate_required_args(args, ["timezone_name"]) timezone_name = args["timezone_name"] logger.info(f"Getting current time for timezone: {timezone_name}") # Get timezone info timezone = utils.get_zoneinfo(timezone_name) current_time = datetime.now(timezone) # Create time result time_result = utils.TimeResult( timezone=timezone_name, datetime=current_time.isoformat(timespec="seconds"), ) return [ TextContent( type="text", text=json.dumps(time_result.model_dump(), indent=2) ) ] except Exception as e: logger.exception(f"Error in get_current_datetime: {str(e)}") return [ TextContent( type="text", text=f"Error getting current time: {str(e)}" ) ] class GetTimeZoneInfoToolHandler(ToolHandler): """ Tool handler for getting information about timezones. """ def __init__(self): super().__init__("get_timezone_info") def get_tool_description(self) -> Tool: """ Return the tool description for timezone information lookup. """ return Tool( name=self.name, description="""Get information about a specific timezone including current time and UTC offset.""", inputSchema={ "type": "object", "properties": { "timezone_name": { "type": "string", "description": "IANA timezone name (e.g., 'America/New_York', 'Europe/London')" } }, "required": ["timezone_name"] } ) async def run_tool(self, args: dict) -> Sequence[TextContent | ImageContent | EmbeddedResource]: """ Execute the timezone info tool. """ try: self.validate_required_args(args, ["timezone_name"]) timezone_name = args["timezone_name"] logger.info(f"Getting timezone info for: {timezone_name}") # Get timezone info timezone = utils.get_zoneinfo(timezone_name) current_time = datetime.now(timezone) utc_time = datetime.utcnow() # Calculate UTC offset offset = current_time.utcoffset() offset_hours = offset.total_seconds() / 3600 if offset else 0 timezone_info = { "timezone_name": timezone_name, "current_local_time": current_time.isoformat(timespec="seconds"), "current_utc_time": utc_time.isoformat(timespec="seconds"), "utc_offset_hours": offset_hours, "is_dst": current_time.dst() is not None and current_time.dst().total_seconds() > 0, "timezone_abbreviation": current_time.strftime("%Z"), } return [ TextContent( type="text", text=json.dumps(timezone_info, indent=2) ) ] except Exception as e: logger.exception(f"Error in get_timezone_info: {str(e)}") return [ TextContent( type="text", text=f"Error getting timezone info: {str(e)}" ) ] class ConvertTimeToolHandler(ToolHandler): """ Tool handler for converting time between different timezones. """ def __init__(self): super().__init__("convert_time") def get_tool_description(self) -> Tool: """ Return the tool description for time conversion. """ return Tool( name=self.name, description="""Convert time from one timezone to another.""", inputSchema={ "type": "object", "properties": { "datetime_str": { "type": "string", "description": "DateTime string in ISO format (e.g., '2024-01-15T14:30:00') or 'now' for current time" }, "from_timezone": { "type": "string", "description": "Source timezone (IANA timezone name)" }, "to_timezone": { "type": "string", "description": "Target timezone (IANA timezone name)" } }, "required": ["datetime_str", "from_timezone", "to_timezone"] } ) async def run_tool(self, args: dict) -> Sequence[TextContent | ImageContent | EmbeddedResource]: """ Execute the time conversion tool. """ try: self.validate_required_args(args, ["datetime_str", "from_timezone", "to_timezone"]) datetime_str = args["datetime_str"] from_timezone_name = args["from_timezone"] to_timezone_name = args["to_timezone"] logger.info(f"Converting time '{datetime_str}' from {from_timezone_name} to {to_timezone_name}") # Get timezone objects from_timezone = utils.get_zoneinfo(from_timezone_name) to_timezone = utils.get_zoneinfo(to_timezone_name) # Parse the datetime if datetime_str.lower() == "now": source_time = datetime.now(from_timezone) else: # Parse the datetime string and localize it if datetime_str.endswith('Z'): # UTC time naive_time = datetime.fromisoformat(datetime_str[:-1]) source_time = naive_time.replace(tzinfo=from_timezone) else: # Assume local time in from_timezone naive_time = datetime.fromisoformat(datetime_str) source_time = naive_time.replace(tzinfo=from_timezone) # Convert to target timezone target_time = source_time.astimezone(to_timezone) conversion_result = { "original_datetime": source_time.isoformat(timespec="seconds"), "original_timezone": from_timezone_name, "converted_datetime": target_time.isoformat(timespec="seconds"), "converted_timezone": to_timezone_name, "time_difference_hours": (target_time.utcoffset().total_seconds() - source_time.utcoffset().total_seconds()) / 3600 } return [ TextContent( type="text", text=json.dumps(conversion_result, indent=2) ) ] except Exception as e: logger.exception(f"Error in convert_time: {str(e)}") return [ TextContent( type="text", text=f"Error converting time: {str(e)}" ) ] ``` -------------------------------------------------------------------------------- /src/mcp_weather_server/tools/weather_service.py: -------------------------------------------------------------------------------- ```python """ Weather service for handling all weather API interactions. This separates the business logic from the tool handlers. """ import httpx import logging from typing import Dict, List, Tuple, Any from datetime import datetime, timezone from . import utils logger = logging.getLogger("mcp-weather") class WeatherService: """ Service class for weather-related API interactions. This class encapsulates all weather API logic, making it reusable across different tool handlers and easier to test and maintain. """ BASE_GEO_URL = "https://geocoding-api.open-meteo.com/v1/search" BASE_WEATHER_URL = "https://api.open-meteo.com/v1/forecast" def __init__(self): """Initialize the weather service.""" pass async def get_coordinates(self, city: str) -> Tuple[float, float]: """ Fetch the latitude and longitude for a given city using the Open-Meteo Geocoding API. Args: city: The name of the city to fetch coordinates for Returns: Tuple of (latitude, longitude) Raises: ValueError: If the coordinates cannot be retrieved """ async with httpx.AsyncClient() as client: try: geo_response = await client.get(f"{self.BASE_GEO_URL}?name={city}") if geo_response.status_code != 200: raise ValueError(f"Geocoding API returned status {geo_response.status_code}") geo_data = geo_response.json() if "results" not in geo_data or not geo_data["results"]: raise ValueError(f"No coordinates found for city: {city}") result = geo_data["results"][0] return result["latitude"], result["longitude"] except httpx.RequestError as e: raise ValueError(f"Network error while fetching coordinates for {city}: {str(e)}") except (KeyError, IndexError) as e: raise ValueError(f"Invalid response format from geocoding API: {str(e)}") async def get_current_weather(self, city: str) -> Dict[str, Any]: """ Get current weather information for a specified city. Args: city: The name of the city Returns: Dictionary containing current weather data Raises: ValueError: If weather data cannot be retrieved """ try: latitude, longitude = await self.get_coordinates(city) # Build the weather API URL for current conditions url = ( f"{self.BASE_WEATHER_URL}" f"?latitude={latitude}&longitude={longitude}" f"&hourly=temperature_2m,relative_humidity_2m,dew_point_2m,weather_code" f"&timezone=GMT&forecast_days=1" ) logger.info(f"Fetching current weather from: {url}") async with httpx.AsyncClient() as client: weather_response = await client.get(url) if weather_response.status_code != 200: raise ValueError(f"Weather API returned status {weather_response.status_code}") weather_data = weather_response.json() # Find the current hour index current_index = utils.get_closest_utc_index(weather_data["hourly"]["time"]) # Extract current weather data current_weather = { "city": city, "latitude": latitude, "longitude": longitude, "time": weather_data["hourly"]["time"][current_index], "temperature_c": weather_data["hourly"]["temperature_2m"][current_index], "relative_humidity_percent": weather_data["hourly"]["relative_humidity_2m"][current_index], "dew_point_c": weather_data["hourly"]["dew_point_2m"][current_index], "weather_code": weather_data["hourly"]["weather_code"][current_index], "weather_description": utils.weather_descriptions.get( weather_data["hourly"]["weather_code"][current_index], "Unknown weather condition" ) } return current_weather except httpx.RequestError as e: raise ValueError(f"Network error while fetching weather for {city}: {str(e)}") except (KeyError, IndexError) as e: raise ValueError(f"Invalid response format from weather API: {str(e)}") async def get_weather_by_date_range( self, city: str, start_date: str, end_date: str ) -> Dict[str, Any]: """ Get weather information for a specified city between start and end dates. Args: city: The name of the city start_date: Start date in YYYY-MM-DD format end_date: End date in YYYY-MM-DD format Returns: Dictionary containing weather data for the date range Raises: ValueError: If weather data cannot be retrieved """ try: latitude, longitude = await self.get_coordinates(city) # Build the weather API URL for date range url = ( f"{self.BASE_WEATHER_URL}" f"?latitude={latitude}&longitude={longitude}" f"&hourly=temperature_2m,relative_humidity_2m,dew_point_2m,weather_code" f"&timezone=GMT&start_date={start_date}&end_date={end_date}" ) logger.info(f"Fetching weather history from: {url}") async with httpx.AsyncClient() as client: response = await client.get(url) if response.status_code != 200: raise ValueError(f"Weather API returned status {response.status_code}") data = response.json() # Process the hourly data times = data["hourly"]["time"] temperatures = data["hourly"]["temperature_2m"] humidities = data["hourly"]["relative_humidity_2m"] dew_points = data["hourly"]["dew_point_2m"] weather_codes = data["hourly"]["weather_code"] weather_data = [] for time, temp, humidity, dew_point, weather_code in zip( times, temperatures, humidities, dew_points, weather_codes ): weather_data.append({ "time": time, "temperature_c": temp, "humidity_percent": humidity, "dew_point_c": dew_point, "weather_code": weather_code, "weather_description": utils.weather_descriptions.get( weather_code, "Unknown weather condition" ) }) return { "city": city, "latitude": latitude, "longitude": longitude, "start_date": start_date, "end_date": end_date, "weather_data": weather_data } except httpx.RequestError as e: raise ValueError(f"Network error while fetching weather for {city}: {str(e)}") except (KeyError, IndexError) as e: raise ValueError(f"Invalid response format from weather API: {str(e)}") def format_current_weather_response(self, weather_data: Dict[str, Any]) -> str: """ Format current weather data into a human-readable string. Args: weather_data: Weather data dictionary from get_current_weather Returns: Formatted weather description string """ return ( f"The weather in {weather_data['city']} is {weather_data['weather_description']} " f"with a temperature of {weather_data['temperature_c']}°C, " f"relative humidity at {weather_data['relative_humidity_percent']}%, " f"and dew point at {weather_data['dew_point_c']}°C." ) def format_weather_range_response(self, weather_data: Dict[str, Any]) -> str: """ Format weather range data for analysis. Args: weather_data: Weather data dictionary from get_weather_by_date_range Returns: Formatted string ready for AI analysis """ return utils.format_get_weather_bytime(weather_data) ``` -------------------------------------------------------------------------------- /src/mcp_weather_server/tools/tools_weather.py: -------------------------------------------------------------------------------- ```python """ Weather-related tool handlers for the MCP weather server. This module contains all weather-specific tool implementations. """ import json import logging from collections.abc import Sequence from mcp.types import Tool, TextContent, ImageContent, EmbeddedResource from .toolhandler import ToolHandler from .weather_service import WeatherService logger = logging.getLogger("mcp-weather") class GetCurrentWeatherToolHandler(ToolHandler): """ Tool handler for getting current weather information for a city. """ def __init__(self): super().__init__("get_current_weather") self.weather_service = WeatherService() def get_tool_description(self) -> Tool: """ Return the tool description for current weather lookup. """ return Tool( name=self.name, description="""Get current weather information for a specified city. It extracts the current hour's temperature and weather code, maps the weather code to a human-readable description, and returns a formatted summary.""", inputSchema={ "type": "object", "properties": { "city": { "type": "string", "description": "The name of the city to fetch weather information for, PLEASE NOTE English name only, if the parameter city isn't English please translate to English before invoking this function." } }, "required": ["city"] } ) async def run_tool(self, args: dict) -> Sequence[TextContent | ImageContent | EmbeddedResource]: """ Execute the current weather tool. """ try: self.validate_required_args(args, ["city"]) city = args["city"] logger.info(f"Getting current weather for: {city}") # Get weather data from service weather_data = await self.weather_service.get_current_weather(city) # Format the response formatted_response = self.weather_service.format_current_weather_response(weather_data) return [ TextContent( type="text", text=formatted_response ) ] except ValueError as e: logger.error(f"Weather service error: {str(e)}") return [ TextContent( type="text", text=f"Error: {str(e)}" ) ] except Exception as e: logger.exception(f"Unexpected error in get_current_weather: {str(e)}") return [ TextContent( type="text", text=f"Unexpected error occurred: {str(e)}" ) ] class GetWeatherByDateRangeToolHandler(ToolHandler): """ Tool handler for getting weather information for a date range. """ def __init__(self): super().__init__("get_weather_byDateTimeRange") self.weather_service = WeatherService() def get_tool_description(self) -> Tool: """ Return the tool description for weather date range lookup. """ return Tool( name=self.name, description="""Get weather information for a specified city between start and end dates.""", inputSchema={ "type": "object", "properties": { "city": { "type": "string", "description": "The name of the city to fetch weather information for, PLEASE NOTE English name only, if the parameter city isn't English please translate to English before invoking this function." }, "start_date": { "type": "string", "description": "Start date in format YYYY-MM-DD, please follow ISO 8601 format" }, "end_date": { "type": "string", "description": "End date in format YYYY-MM-DD , please follow ISO 8601 format" } }, "required": ["city", "start_date", "end_date"] } ) async def run_tool(self, args: dict) -> Sequence[TextContent | ImageContent | EmbeddedResource]: """ Execute the weather date range tool. """ try: self.validate_required_args(args, ["city", "start_date", "end_date"]) city = args["city"] start_date = args["start_date"] end_date = args["end_date"] logger.info(f"Getting weather for {city} from {start_date} to {end_date}") # Get weather data from service weather_data = await self.weather_service.get_weather_by_date_range( city, start_date, end_date ) # Format the response for analysis formatted_response = self.weather_service.format_weather_range_response(weather_data) return [ TextContent( type="text", text=formatted_response ) ] except ValueError as e: logger.error(f"Weather service error: {str(e)}") return [ TextContent( type="text", text=f"Error: {str(e)}" ) ] except Exception as e: logger.exception(f"Unexpected error in get_weather_by_date_range: {str(e)}") return [ TextContent( type="text", text=f"Unexpected error occurred: {str(e)}" ) ] class GetWeatherDetailsToolHandler(ToolHandler): """ Tool handler for getting detailed weather information with raw data. This tool provides structured JSON output for programmatic use. """ def __init__(self): super().__init__("get_weather_details") self.weather_service = WeatherService() def get_tool_description(self) -> Tool: """ Return the tool description for detailed weather lookup. """ return Tool( name=self.name, description="""Get detailed weather information for a specified city as structured JSON data. This tool provides raw weather data for programmatic analysis and processing.""", inputSchema={ "type": "object", "properties": { "city": { "type": "string", "description": "The name of the city to fetch weather information for, PLEASE NOTE English name only, if the parameter city isn't English please translate to English before invoking this function." }, "include_forecast": { "type": "boolean", "description": "Whether to include forecast data (next 24 hours)", "default": False } }, "required": ["city"] } ) async def run_tool(self, args: dict) -> Sequence[TextContent | ImageContent | EmbeddedResource]: """ Execute the detailed weather tool. """ try: self.validate_required_args(args, ["city"]) city = args["city"] include_forecast = args.get("include_forecast", False) logger.info(f"Getting detailed weather for: {city} (forecast: {include_forecast})") # Get current weather data weather_data = await self.weather_service.get_current_weather(city) # If forecast is requested, get the next 24 hours if include_forecast: from datetime import datetime, timedelta today = datetime.now().strftime("%Y-%m-%d") tomorrow = (datetime.now() + timedelta(days=1)).strftime("%Y-%m-%d") forecast_data = await self.weather_service.get_weather_by_date_range( city, today, tomorrow ) weather_data["forecast"] = forecast_data["weather_data"] return [ TextContent( type="text", text=json.dumps(weather_data, indent=2) ) ] except ValueError as e: logger.error(f"Weather service error: {str(e)}") return [ TextContent( type="text", text=json.dumps({"error": str(e)}, indent=2) ) ] except Exception as e: logger.exception(f"Unexpected error in get_weather_details: {str(e)}") return [ TextContent( type="text", text=json.dumps({"error": f"Unexpected error occurred: {str(e)}"}, indent=2) ) ] ``` -------------------------------------------------------------------------------- /tests/test_weather_service.py: -------------------------------------------------------------------------------- ```python """ Unit tests for WeatherService class. """ import pytest import httpx from unittest.mock import AsyncMock, Mock, patch from src.mcp_weather_server.tools.weather_service import WeatherService class TestWeatherService: """Test cases for WeatherService class.""" @pytest.fixture def weather_service(self): """Create a WeatherService instance for testing.""" return WeatherService() @pytest.mark.asyncio async def test_get_coordinates_success( self, weather_service, mock_successful_geo_client, mock_geo_response ): """Test successful coordinate retrieval.""" with patch('httpx.AsyncClient') as mock_client_class: mock_client_class.return_value.__aenter__.return_value = mock_successful_geo_client lat, lon = await weather_service.get_coordinates("New York") assert lat == 40.7128 assert lon == -74.0060 mock_successful_geo_client.get.assert_called_once() @pytest.mark.asyncio async def test_get_coordinates_api_error( self, weather_service, mock_failed_client ): """Test coordinate retrieval with API error.""" with patch('httpx.AsyncClient') as mock_client_class: mock_client_class.return_value.__aenter__.return_value = mock_failed_client with pytest.raises(ValueError, match="Geocoding API returned status 500"): await weather_service.get_coordinates("Invalid City") @pytest.mark.asyncio async def test_get_coordinates_no_results( self, weather_service, mock_empty_geo_response ): """Test coordinate retrieval with no results.""" mock_client = AsyncMock() mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = mock_empty_geo_response mock_client.get.return_value = mock_response with patch('httpx.AsyncClient') as mock_client_class: mock_client_class.return_value.__aenter__.return_value = mock_client with pytest.raises(ValueError, match="No coordinates found for city: Unknown City"): await weather_service.get_coordinates("Unknown City") @pytest.mark.asyncio async def test_get_coordinates_network_error( self, weather_service, mock_network_error_client ): """Test coordinate retrieval with network error.""" with patch('httpx.AsyncClient') as mock_client_class: mock_client_class.return_value.__aenter__.return_value = mock_network_error_client with pytest.raises(ValueError, match="Network error while fetching coordinates"): await weather_service.get_coordinates("Test City") @pytest.mark.asyncio async def test_get_current_weather_success( self, weather_service, mock_geo_response, mock_weather_response ): """Test successful current weather retrieval.""" # Mock the coordinates call with patch.object(weather_service, 'get_coordinates', return_value=(40.7128, -74.0060)): # Mock the weather API call mock_client = AsyncMock() mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = mock_weather_response mock_client.get.return_value = mock_response with patch('httpx.AsyncClient') as mock_client_class: mock_client_class.return_value.__aenter__.return_value = mock_client # Mock the utility function to return a predictable index with patch('src.mcp_weather_server.utils.get_closest_utc_index', return_value=1): result = await weather_service.get_current_weather("New York") assert result["city"] == "New York" assert result["latitude"] == 40.7128 assert result["longitude"] == -74.0060 assert result["temperature_c"] == 21.0 assert result["relative_humidity_percent"] == 66 assert result["dew_point_c"] == 14.0 assert result["weather_code"] == 1 assert "Mainly clear" in result["weather_description"] @pytest.mark.asyncio async def test_get_current_weather_api_error(self, weather_service): """Test current weather retrieval with API error.""" with patch.object(weather_service, 'get_coordinates', return_value=(40.7128, -74.0060)): mock_client = AsyncMock() mock_response = Mock() mock_response.status_code = 500 mock_client.get.return_value = mock_response with patch('httpx.AsyncClient') as mock_client_class: mock_client_class.return_value.__aenter__.return_value = mock_client with pytest.raises(ValueError, match="Weather API returned status 500"): await weather_service.get_current_weather("New York") @pytest.mark.asyncio async def test_get_weather_by_date_range_success( self, weather_service, mock_weather_range_response ): """Test successful weather range retrieval.""" with patch.object(weather_service, 'get_coordinates', return_value=(40.7128, -74.0060)): mock_client = AsyncMock() mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = mock_weather_range_response mock_client.get.return_value = mock_response with patch('httpx.AsyncClient') as mock_client_class: mock_client_class.return_value.__aenter__.return_value = mock_client result = await weather_service.get_weather_by_date_range( "New York", "2024-01-01", "2024-01-02" ) assert result["city"] == "New York" assert result["start_date"] == "2024-01-01" assert result["end_date"] == "2024-01-02" assert len(result["weather_data"]) == 4 assert result["weather_data"][0]["temperature_c"] == 20.0 assert result["weather_data"][1]["weather_description"] == "Mainly clear" @pytest.mark.asyncio async def test_get_weather_by_date_range_invalid_response(self, weather_service): """Test weather range retrieval with invalid response.""" with patch.object(weather_service, 'get_coordinates', return_value=(40.7128, -74.0060)): mock_client = AsyncMock() mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = {"invalid": "response"} mock_client.get.return_value = mock_response with patch('httpx.AsyncClient') as mock_client_class: mock_client_class.return_value.__aenter__.return_value = mock_client with pytest.raises(ValueError, match="Invalid response format from weather API"): await weather_service.get_weather_by_date_range( "New York", "2024-01-01", "2024-01-02" ) def test_format_current_weather_response( self, weather_service, sample_current_weather_data ): """Test formatting of current weather response.""" result = weather_service.format_current_weather_response(sample_current_weather_data) expected = ( "The weather in New York is Mainly clear with a temperature of 25.0°C, " "relative humidity at 70%, and dew point at 16.0°C." ) assert result == expected def test_format_weather_range_response( self, weather_service, sample_weather_range_data ): """Test formatting of weather range response.""" with patch('src.mcp_weather_server.utils.format_get_weather_bytime') as mock_format: mock_format.return_value = "formatted response" result = weather_service.format_weather_range_response(sample_weather_range_data) assert result == "formatted response" mock_format.assert_called_once_with(sample_weather_range_data) @pytest.mark.asyncio async def test_get_coordinates_malformed_response(self, weather_service): """Test coordinate retrieval with malformed JSON response.""" mock_client = AsyncMock() mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = {"results": [{"invalid": "data"}]} mock_client.get.return_value = mock_response with patch('httpx.AsyncClient') as mock_client_class: mock_client_class.return_value.__aenter__.return_value = mock_client with pytest.raises(ValueError, match="Invalid response format from geocoding API"): await weather_service.get_coordinates("Test City") @pytest.mark.asyncio async def test_get_current_weather_network_error(self, weather_service): """Test current weather retrieval with network error.""" with patch.object(weather_service, 'get_coordinates', return_value=(40.7128, -74.0060)): mock_client = AsyncMock() mock_client.get.side_effect = httpx.RequestError("Network error") with patch('httpx.AsyncClient') as mock_client_class: mock_client_class.return_value.__aenter__.return_value = mock_client with pytest.raises(ValueError, match="Network error while fetching weather"): await weather_service.get_current_weather("New York") @pytest.mark.asyncio async def test_get_weather_by_date_range_network_error(self, weather_service): """Test weather range retrieval with network error.""" with patch.object(weather_service, 'get_coordinates', return_value=(40.7128, -74.0060)): mock_client = AsyncMock() mock_client.get.side_effect = httpx.RequestError("Network error") with patch('httpx.AsyncClient') as mock_client_class: mock_client_class.return_value.__aenter__.return_value = mock_client with pytest.raises(ValueError, match="Network error while fetching weather"): await weather_service.get_weather_by_date_range( "New York", "2024-01-01", "2024-01-02" ) ``` -------------------------------------------------------------------------------- /tests/test_server.py: -------------------------------------------------------------------------------- ```python """ Unit tests for server functionality. """ import pytest from unittest.mock import AsyncMock, Mock, patch from src.mcp_weather_server.server import ( add_tool_handler, get_tool_handler, register_all_tools, list_tools, call_tool, tool_handlers ) from src.mcp_weather_server.tools.toolhandler import ToolHandler from mcp.types import Tool, TextContent class MockToolHandler(ToolHandler): """Mock tool handler for testing.""" def __init__(self, name="mock_tool"): super().__init__(name) self.get_tool_description_called = False self.run_tool_called = False self.run_tool_args = None def get_tool_description(self) -> Tool: self.get_tool_description_called = True return Tool( name=self.name, description="Mock tool for testing", inputSchema={ "type": "object", "properties": { "test_param": {"type": "string"} }, "required": ["test_param"] } ) async def run_tool(self, args: dict): self.run_tool_called = True self.run_tool_args = args return [TextContent(type="text", text=f"Mock result for {args}")] class TestServerFunctions: """Test cases for server functions.""" def setup_method(self): """Clear tool handlers before each test.""" global tool_handlers tool_handlers.clear() def test_add_tool_handler(self): """Test adding a tool handler.""" handler = MockToolHandler("test_tool") add_tool_handler(handler) assert "test_tool" in tool_handlers assert tool_handlers["test_tool"] == handler def test_get_tool_handler_existing(self): """Test getting an existing tool handler.""" handler = MockToolHandler("existing_tool") add_tool_handler(handler) retrieved = get_tool_handler("existing_tool") assert retrieved == handler def test_get_tool_handler_nonexistent(self): """Test getting a non-existent tool handler.""" retrieved = get_tool_handler("nonexistent_tool") assert retrieved is None def test_register_all_tools(self): """Test registering all tools.""" register_all_tools() # Check that expected tools are registered expected_tools = [ "get_current_weather", "get_weather_byDateTimeRange", "get_weather_details", "get_current_datetime", "get_timezone_info", "convert_time" ] for tool_name in expected_tools: assert tool_name in tool_handlers assert tool_handlers[tool_name] is not None @pytest.mark.asyncio async def test_list_tools_empty(self): """Test listing tools when no tools are registered.""" result = await list_tools() assert result == [] @pytest.mark.asyncio async def test_list_tools_with_handlers(self): """Test listing tools with registered handlers.""" handler1 = MockToolHandler("tool1") handler2 = MockToolHandler("tool2") add_tool_handler(handler1) add_tool_handler(handler2) result = await list_tools() assert len(result) == 2 assert handler1.get_tool_description_called assert handler2.get_tool_description_called tool_names = [tool.name for tool in result] assert "tool1" in tool_names assert "tool2" in tool_names @pytest.mark.asyncio async def test_list_tools_exception_handling(self): """Test list_tools exception handling.""" # Create a mock handler that raises an exception with patch.dict(tool_handlers, {"bad_tool": Mock()}): tool_handlers["bad_tool"].get_tool_description.side_effect = Exception("Test error") with pytest.raises(Exception): await list_tools() @pytest.mark.asyncio async def test_call_tool_success(self): """Test successful tool execution.""" handler = MockToolHandler("test_tool") add_tool_handler(handler) args = {"test_param": "test_value"} result = await call_tool("test_tool", args) assert handler.run_tool_called assert handler.run_tool_args == args assert len(result) == 1 assert isinstance(result[0], TextContent) assert "test_value" in result[0].text @pytest.mark.asyncio async def test_call_tool_nonexistent(self): """Test calling a non-existent tool.""" args = {"test_param": "test_value"} result = await call_tool("nonexistent_tool", args) assert len(result) == 1 assert isinstance(result[0], TextContent) assert "Error executing tool 'nonexistent_tool'" in result[0].text @pytest.mark.asyncio async def test_call_tool_invalid_arguments(self): """Test calling a tool with invalid arguments.""" handler = MockToolHandler("test_tool") add_tool_handler(handler) # Pass non-dict arguments result = await call_tool("test_tool", "invalid_args") assert len(result) == 1 assert isinstance(result[0], TextContent) assert "Error executing tool 'test_tool'" in result[0].text assert "Arguments must be a dictionary" in result[0].text @pytest.mark.asyncio async def test_call_tool_handler_exception(self): """Test calling a tool when handler raises an exception.""" handler = MockToolHandler("test_tool") handler.run_tool = AsyncMock(side_effect=Exception("Handler error")) add_tool_handler(handler) args = {"test_param": "test_value"} result = await call_tool("test_tool", args) assert len(result) == 1 assert isinstance(result[0], TextContent) assert "Error executing tool 'test_tool'" in result[0].text assert "Handler error" in result[0].text @pytest.mark.asyncio async def test_call_tool_with_complex_args(self): """Test calling a tool with complex arguments.""" handler = MockToolHandler("complex_tool") add_tool_handler(handler) complex_args = { "string_param": "test_string", "number_param": 42, "list_param": [1, 2, 3], "dict_param": {"nested": "value"} } result = await call_tool("complex_tool", complex_args) assert handler.run_tool_called assert handler.run_tool_args == complex_args assert len(result) == 1 class TestToolHandlerIntegration: """Integration tests for tool handler registration and execution.""" def setup_method(self): """Clear tool handlers before each test.""" global tool_handlers tool_handlers.clear() @pytest.mark.asyncio async def test_full_workflow(self): """Test complete workflow from registration to execution.""" # Register a tool handler = MockToolHandler("workflow_tool") add_tool_handler(handler) # List tools tools = await list_tools() assert len(tools) == 1 assert tools[0].name == "workflow_tool" # Call the tool args = {"test_param": "workflow_test"} result = await call_tool("workflow_tool", args) assert len(result) == 1 assert "workflow_test" in result[0].text @pytest.mark.asyncio async def test_multiple_tools_workflow(self): """Test workflow with multiple tools.""" # Register multiple tools handler1 = MockToolHandler("tool_one") handler2 = MockToolHandler("tool_two") handler3 = MockToolHandler("tool_three") add_tool_handler(handler1) add_tool_handler(handler2) add_tool_handler(handler3) # List all tools tools = await list_tools() assert len(tools) == 3 tool_names = [tool.name for tool in tools] assert "tool_one" in tool_names assert "tool_two" in tool_names assert "tool_three" in tool_names # Call each tool for tool_name in ["tool_one", "tool_two", "tool_three"]: args = {"test_param": f"test_{tool_name}"} result = await call_tool(tool_name, args) assert len(result) == 1 assert f"test_{tool_name}" in result[0].text @pytest.mark.asyncio async def test_real_tool_registration(self): """Test registration of real tool handlers.""" register_all_tools() # Verify specific tools are registered weather_tool = get_tool_handler("get_current_weather") assert weather_tool is not None time_tool = get_tool_handler("get_current_datetime") assert time_tool is not None # Test tool descriptions tools = await list_tools() assert len(tools) >= 6 # Should have at least 6 tools # Verify tool names tool_names = [tool.name for tool in tools] expected_tools = [ "get_current_weather", "get_weather_byDateTimeRange", "get_weather_details", "get_current_datetime", "get_timezone_info", "convert_time" ] for expected_tool in expected_tools: assert expected_tool in tool_names def test_tool_handler_replacement(self): """Test replacing an existing tool handler.""" # Add initial handler handler1 = MockToolHandler("replaceable_tool") add_tool_handler(handler1) # Verify it's registered assert get_tool_handler("replaceable_tool") == handler1 # Replace with new handler handler2 = MockToolHandler("replaceable_tool") add_tool_handler(handler2) # Verify replacement assert get_tool_handler("replaceable_tool") == handler2 assert get_tool_handler("replaceable_tool") != handler1 def test_tool_handlers_persistence(self): """Test that tool handlers persist across multiple operations.""" handler = MockToolHandler("persistent_tool") add_tool_handler(handler) # Perform multiple operations retrieved1 = get_tool_handler("persistent_tool") retrieved2 = get_tool_handler("persistent_tool") retrieved3 = get_tool_handler("persistent_tool") # All should return the same handler instance assert retrieved1 == handler assert retrieved2 == handler assert retrieved3 == handler assert retrieved1 == retrieved2 == retrieved3 ``` -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- ```python """ Unit tests for utility functions. """ import pytest from datetime import datetime, timezone from unittest.mock import patch, Mock from zoneinfo import ZoneInfo from mcp import McpError from src.mcp_weather_server.utils import ( TimeResult, get_zoneinfo, format_get_weather_bytime, get_closest_utc_index, weather_descriptions ) class TestTimeResult: """Test cases for TimeResult model.""" def test_time_result_creation(self): """Test TimeResult model creation.""" time_result = TimeResult( timezone="America/New_York", datetime="2024-01-01T15:30:45" ) assert time_result.timezone == "America/New_York" assert time_result.datetime == "2024-01-01T15:30:45" def test_time_result_dict(self): """Test TimeResult conversion to dictionary.""" time_result = TimeResult( timezone="UTC", datetime="2024-01-01T12:00:00" ) result_dict = time_result.model_dump() assert result_dict["timezone"] == "UTC" assert result_dict["datetime"] == "2024-01-01T12:00:00" class TestGetZoneinfo: """Test cases for get_zoneinfo function.""" def test_get_zoneinfo_valid_timezone(self): """Test get_zoneinfo with valid timezone.""" tz = get_zoneinfo("America/New_York") assert isinstance(tz, ZoneInfo) assert str(tz) == "America/New_York" def test_get_zoneinfo_utc(self): """Test get_zoneinfo with UTC timezone.""" tz = get_zoneinfo("UTC") assert isinstance(tz, ZoneInfo) assert str(tz) == "UTC" def test_get_zoneinfo_invalid_timezone(self): """Test get_zoneinfo with invalid timezone.""" with pytest.raises(McpError, match="Invalid timezone"): get_zoneinfo("Invalid/Timezone") def test_get_zoneinfo_empty_string(self): """Test get_zoneinfo with empty string.""" with pytest.raises(McpError, match="Invalid timezone"): get_zoneinfo("") def test_get_zoneinfo_none(self): """Test get_zoneinfo with None.""" with pytest.raises(McpError, match="Invalid timezone"): get_zoneinfo(None) class TestFormatGetWeatherBytime: """Test cases for format_get_weather_bytime function.""" def test_format_get_weather_bytime_basic(self): """Test basic formatting of weather data.""" weather_data = { "city": "New York", "start_date": "2024-01-01", "end_date": "2024-01-02", "weather_data": [ { "time": "2024-01-01T12:00", "temperature_c": 25.0, "humidity_percent": 70, "weather_description": "Clear sky" } ] } result = format_get_weather_bytime(weather_data) assert "analyze" in result.lower() assert "New York" in result assert "2024-01-01" in result assert "2024-01-02" in result assert "weather_data" in result def test_format_get_weather_bytime_empty_data(self): """Test formatting with empty weather data.""" weather_data = { "city": "Test City", "start_date": "2024-01-01", "end_date": "2024-01-01", "weather_data": [] } result = format_get_weather_bytime(weather_data) assert "Test City" in result assert "weather_data" in result assert "[]" in result def test_format_get_weather_bytime_complex_data(self): """Test formatting with complex weather data.""" weather_data = { "city": "London", "latitude": 51.5074, "longitude": -0.1278, "start_date": "2024-01-01", "end_date": "2024-01-03", "weather_data": [ { "time": "2024-01-01T00:00", "temperature_c": 15.0, "humidity_percent": 80, "dew_point_c": 11.5, "weather_code": 61, "weather_description": "Slight rain" }, { "time": "2024-01-01T12:00", "temperature_c": 18.0, "humidity_percent": 75, "dew_point_c": 13.0, "weather_code": 1, "weather_description": "Mainly clear" } ] } result = format_get_weather_bytime(weather_data) assert "London" in result assert "51.5074" in result assert "-0.1278" in result assert "Slight rain" in result assert "Mainly clear" in result class TestGetClosestUtcIndex: """Test cases for get_closest_utc_index function.""" def test_get_closest_utc_index_exact_match(self): """Test finding index when current time matches exactly.""" current_time = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) hourly_times = [ "2024-01-01T10:00:00Z", "2024-01-01T11:00:00Z", "2024-01-01T12:00:00Z", # This should match "2024-01-01T13:00:00Z", "2024-01-01T14:00:00Z" ] with patch('src.mcp_weather_server.utils.datetime') as mock_datetime: mock_datetime.now.return_value = current_time mock_datetime.side_effect = lambda *args, **kwargs: datetime(*args, **kwargs) index = get_closest_utc_index(hourly_times) assert index == 2 def test_get_closest_utc_index_closest_before(self): """Test finding closest index when current time is between hours.""" current_time = datetime(2024, 1, 1, 12, 30, 0, tzinfo=timezone.utc) hourly_times = [ "2024-01-01T10:00:00Z", "2024-01-01T11:00:00Z", "2024-01-01T12:00:00Z", # This should be closest (30 min away) "2024-01-01T13:00:00Z", # This is also 30 min away, but 12:00 comes first "2024-01-01T14:00:00Z" ] with patch('src.mcp_weather_server.utils.datetime') as mock_datetime: mock_datetime.now.return_value = current_time mock_datetime.side_effect = lambda *args, **kwargs: datetime(*args, **kwargs) index = get_closest_utc_index(hourly_times) assert index == 2 def test_get_closest_utc_index_closest_after(self): """Test finding closest index when current time is closer to next hour.""" current_time = datetime(2024, 1, 1, 12, 35, 0, tzinfo=timezone.utc) hourly_times = [ "2024-01-01T10:00:00Z", "2024-01-01T11:00:00Z", "2024-01-01T12:00:00Z", # 35 min away "2024-01-01T13:00:00Z", # 25 min away - closer "2024-01-01T14:00:00Z" ] with patch('src.mcp_weather_server.utils.datetime') as mock_datetime: mock_datetime.now.return_value = current_time mock_datetime.side_effect = lambda *args, **kwargs: datetime(*args, **kwargs) index = get_closest_utc_index(hourly_times) assert index == 3 def test_get_closest_utc_index_single_time(self): """Test with single time entry.""" current_time = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) hourly_times = ["2024-01-01T15:00:00Z"] with patch('src.mcp_weather_server.utils.datetime') as mock_datetime: mock_datetime.now.return_value = current_time mock_datetime.side_effect = lambda *args, **kwargs: datetime(*args, **kwargs) index = get_closest_utc_index(hourly_times) assert index == 0 def test_get_closest_utc_index_empty_list(self): """Test with empty time list.""" current_time = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) hourly_times = [] with patch('src.mcp_weather_server.utils.datetime') as mock_datetime: mock_datetime.now.return_value = current_time mock_datetime.side_effect = lambda *args, **kwargs: datetime(*args, **kwargs) with pytest.raises(ValueError): get_closest_utc_index(hourly_times) def test_get_closest_utc_index_different_timezones(self): """Test with times in different timezone formats.""" current_time = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) hourly_times = [ "2024-01-01T10:00:00", # No timezone, should be treated as UTC "2024-01-01T11:00:00Z", # UTC "2024-01-01T12:00:00+00:00", # UTC with offset "2024-01-01T08:00:00-04:00", # EST (12:00 UTC) "2024-01-01T14:00:00Z" ] with patch('src.mcp_weather_server.utils.datetime') as mock_datetime: mock_datetime.now.return_value = current_time mock_datetime.side_effect = lambda *args, **kwargs: datetime(*args, **kwargs) # Should find one of the 12:00 UTC equivalent times index = get_closest_utc_index(hourly_times) assert index in [2, 3] # Either the +00:00 or -04:00 versions class TestWeatherDescriptions: """Test cases for weather descriptions dictionary.""" def test_weather_descriptions_coverage(self): """Test that weather_descriptions contains expected codes.""" # Test some common weather codes assert weather_descriptions[0] == "Clear sky" assert weather_descriptions[1] == "Mainly clear" assert weather_descriptions[2] == "Partly cloudy" assert weather_descriptions[3] == "Overcast" # Test precipitation codes assert weather_descriptions[61] == "Slight rain" assert weather_descriptions[63] == "Moderate rain" assert weather_descriptions[65] == "Heavy rain" # Test snow codes assert weather_descriptions[71] == "Slight snow fall" assert weather_descriptions[73] == "Moderate snow fall" assert weather_descriptions[75] == "Heavy snow fall" # Test severe weather assert weather_descriptions[95] == "Thunderstorm" assert weather_descriptions[99] == "Thunderstorm with heavy hail" def test_weather_descriptions_unknown_code(self): """Test behavior with unknown weather codes.""" # Unknown codes should not be in the dictionary assert 999 not in weather_descriptions assert -1 not in weather_descriptions def test_weather_descriptions_fog_codes(self): """Test fog-related weather codes.""" assert weather_descriptions[45] == "Fog" assert weather_descriptions[48] == "Depositing rime fog" def test_weather_descriptions_drizzle_codes(self): """Test drizzle-related weather codes.""" assert weather_descriptions[51] == "Light drizzle" assert weather_descriptions[53] == "Moderate drizzle" assert weather_descriptions[55] == "Dense drizzle" assert weather_descriptions[56] == "Light freezing drizzle" assert weather_descriptions[57] == "Dense freezing drizzle" def test_weather_descriptions_shower_codes(self): """Test shower-related weather codes.""" assert weather_descriptions[80] == "Slight rain showers" assert weather_descriptions[81] == "Moderate rain showers" assert weather_descriptions[82] == "Violent rain showers" assert weather_descriptions[85] == "Slight snow showers" assert weather_descriptions[86] == "Heavy snow showers" def test_weather_descriptions_hail_codes(self): """Test hail-related weather codes.""" assert weather_descriptions[96] == "Thunderstorm with slight hail" assert weather_descriptions[99] == "Thunderstorm with heavy hail" def test_weather_descriptions_completeness(self): """Test that all expected weather codes are present.""" expected_codes = [ 0, 1, 2, 3, 45, 48, 51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 71, 73, 75, 77, 80, 81, 82, 85, 86, 95, 96, 99 ] for code in expected_codes: assert code in weather_descriptions, f"Weather code {code} is missing" assert isinstance(weather_descriptions[code], str) assert len(weather_descriptions[code]) > 0 ``` -------------------------------------------------------------------------------- /tests/test_weather_tools.py: -------------------------------------------------------------------------------- ```python """ Unit tests for weather tool handlers. """ import pytest import json from unittest.mock import AsyncMock, Mock, patch from mcp.types import TextContent from src.mcp_weather_server.tools.tools_weather import ( GetCurrentWeatherToolHandler, GetWeatherByDateRangeToolHandler, GetWeatherDetailsToolHandler ) class TestGetCurrentWeatherToolHandler: """Test cases for GetCurrentWeatherToolHandler.""" @pytest.fixture def handler(self): """Create a GetCurrentWeatherToolHandler instance.""" return GetCurrentWeatherToolHandler() def test_tool_description(self, handler): """Test the tool description is properly formatted.""" description = handler.get_tool_description() assert description.name == "get_current_weather" assert "current weather" in description.description.lower() assert description.inputSchema["type"] == "object" assert "city" in description.inputSchema["properties"] assert description.inputSchema["required"] == ["city"] @pytest.mark.asyncio async def test_run_tool_success(self, handler, sample_current_weather_data): """Test successful tool execution.""" # Mock the weather service from unittest.mock import Mock mock_service = Mock() mock_service.get_current_weather = AsyncMock(return_value=sample_current_weather_data) mock_service.format_current_weather_response.return_value = "Formatted weather response" handler.weather_service = mock_service args = {"city": "New York"} result = await handler.run_tool(args) assert len(result) == 1 assert isinstance(result[0], TextContent) assert result[0].text == "Formatted weather response" mock_service.get_current_weather.assert_called_once_with("New York") mock_service.format_current_weather_response.assert_called_once_with(sample_current_weather_data) @pytest.mark.asyncio async def test_run_tool_missing_city(self, handler): """Test tool execution with missing city argument.""" args = {} result = await handler.run_tool(args) assert len(result) == 1 assert isinstance(result[0], TextContent) assert "Missing required arguments: city" in result[0].text @pytest.mark.asyncio async def test_run_tool_weather_service_error(self, handler): """Test tool execution when weather service raises ValueError.""" mock_service = AsyncMock() mock_service.get_current_weather.side_effect = ValueError("API error") handler.weather_service = mock_service args = {"city": "Invalid City"} result = await handler.run_tool(args) assert len(result) == 1 assert isinstance(result[0], TextContent) assert "Error: API error" in result[0].text @pytest.mark.asyncio async def test_run_tool_unexpected_error(self, handler): """Test tool execution with unexpected error.""" mock_service = AsyncMock() mock_service.get_current_weather.side_effect = Exception("Unexpected error") handler.weather_service = mock_service args = {"city": "Test City"} result = await handler.run_tool(args) assert len(result) == 1 assert isinstance(result[0], TextContent) assert "Unexpected error occurred: Unexpected error" in result[0].text class TestGetWeatherByDateRangeToolHandler: """Test cases for GetWeatherByDateRangeToolHandler.""" @pytest.fixture def handler(self): """Create a GetWeatherByDateRangeToolHandler instance.""" return GetWeatherByDateRangeToolHandler() def test_tool_description(self, handler): """Test the tool description is properly formatted.""" description = handler.get_tool_description() assert description.name == "get_weather_byDateTimeRange" assert "weather information" in description.description.lower() assert description.inputSchema["type"] == "object" required_fields = ["city", "start_date", "end_date"] for field in required_fields: assert field in description.inputSchema["properties"] assert set(description.inputSchema["required"]) == set(required_fields) @pytest.mark.asyncio async def test_run_tool_success(self, handler, sample_weather_range_data): """Test successful tool execution.""" from unittest.mock import Mock mock_service = Mock() mock_service.get_weather_by_date_range = AsyncMock(return_value=sample_weather_range_data) mock_service.format_weather_range_response.return_value = "Formatted range response" handler.weather_service = mock_service args = { "city": "New York", "start_date": "2024-01-01", "end_date": "2024-01-02" } result = await handler.run_tool(args) assert len(result) == 1 assert isinstance(result[0], TextContent) assert result[0].text == "Formatted range response" mock_service.get_weather_by_date_range.assert_called_once_with( "New York", "2024-01-01", "2024-01-02" ) @pytest.mark.asyncio async def test_run_tool_missing_required_args(self, handler): """Test tool execution with missing required arguments.""" args = {"city": "New York"} # Missing dates result = await handler.run_tool(args) assert len(result) == 1 assert isinstance(result[0], TextContent) assert "Missing required arguments" in result[0].text assert "start_date" in result[0].text assert "end_date" in result[0].text @pytest.mark.asyncio async def test_run_tool_service_error(self, handler): """Test tool execution when weather service raises error.""" mock_service = AsyncMock() mock_service.get_weather_by_date_range.side_effect = ValueError("Date range error") handler.weather_service = mock_service args = { "city": "Test City", "start_date": "invalid-date", "end_date": "2024-01-02" } result = await handler.run_tool(args) assert len(result) == 1 assert isinstance(result[0], TextContent) assert "Error: Date range error" in result[0].text class TestGetWeatherDetailsToolHandler: """Test cases for GetWeatherDetailsToolHandler.""" @pytest.fixture def handler(self): """Create a GetWeatherDetailsToolHandler instance.""" return GetWeatherDetailsToolHandler() def test_tool_description(self, handler): """Test the tool description is properly formatted.""" description = handler.get_tool_description() assert description.name == "get_weather_details" assert "detailed weather information" in description.description.lower() assert description.inputSchema["type"] == "object" assert "city" in description.inputSchema["properties"] assert description.inputSchema["required"] == ["city"] @pytest.mark.asyncio async def test_run_tool_success(self, handler, sample_current_weather_data): """Test successful tool execution.""" mock_service = AsyncMock() mock_service.get_current_weather.return_value = sample_current_weather_data handler.weather_service = mock_service args = {"city": "New York"} result = await handler.run_tool(args) assert len(result) == 1 assert isinstance(result[0], TextContent) # Parse the JSON response to verify structure response_data = json.loads(result[0].text) assert response_data["city"] == "New York" assert response_data["temperature_c"] == 25.0 assert response_data["weather_description"] == "Mainly clear" mock_service.get_current_weather.assert_called_once_with("New York") @pytest.mark.asyncio async def test_run_tool_missing_city(self, handler): """Test tool execution with missing city argument.""" args = {} result = await handler.run_tool(args) assert len(result) == 1 assert isinstance(result[0], TextContent) assert "Missing required arguments: city" in result[0].text @pytest.mark.asyncio async def test_run_tool_service_error(self, handler): """Test tool execution when weather service raises error.""" mock_service = AsyncMock() mock_service.get_current_weather.side_effect = ValueError("Service error") handler.weather_service = mock_service args = {"city": "Invalid City"} result = await handler.run_tool(args) assert len(result) == 1 assert isinstance(result[0], TextContent) # Check that the error is properly formatted as JSON response_data = json.loads(result[0].text) assert "error" in response_data assert "Service error" in response_data["error"] @pytest.mark.asyncio async def test_run_tool_json_serialization_error(self, handler): """Test tool execution when JSON serialization fails.""" mock_service = AsyncMock() # Create an object that can't be JSON serialized invalid_data = {"city": "Test", "invalid": object()} mock_service.get_current_weather.return_value = invalid_data handler.weather_service = mock_service args = {"city": "Test City"} result = await handler.run_tool(args) assert len(result) == 1 assert isinstance(result[0], TextContent) assert "Unexpected error occurred" in result[0].text # Additional integration-style tests for tool handlers class TestToolHandlerIntegration: """Integration tests for tool handlers with realistic scenarios.""" @pytest.mark.asyncio async def test_current_weather_end_to_end(self): """Test current weather tool with mocked HTTP responses.""" handler = GetCurrentWeatherToolHandler() # Mock the entire HTTP call chain mock_geo_data = { "results": [{"latitude": 51.5074, "longitude": -0.1278}] } mock_weather_data = { "hourly": { "time": ["2024-01-01T12:00"], "temperature_2m": [15.5], "relative_humidity_2m": [80], "dew_point_2m": [12.0], "weather_code": [61] } } with patch('httpx.AsyncClient') as mock_client_class: mock_client = AsyncMock() # Setup mock responses for geo and weather API calls geo_response = Mock() geo_response.status_code = 200 geo_response.json.return_value = mock_geo_data weather_response = Mock() weather_response.status_code = 200 weather_response.json.return_value = mock_weather_data # Return different responses for different URLs def mock_get(url): if "geocoding-api" in url: return geo_response elif "api.open-meteo.com" in url: return weather_response else: raise ValueError(f"Unexpected URL: {url}") mock_client.get.side_effect = mock_get mock_client_class.return_value.__aenter__.return_value = mock_client with patch('src.mcp_weather_server.utils.get_closest_utc_index', return_value=0): result = await handler.run_tool({"city": "London"}) assert len(result) == 1 assert isinstance(result[0], TextContent) assert "London" in result[0].text assert "15.5°C" in result[0].text assert "Slight rain" in result[0].text @pytest.mark.asyncio async def test_weather_range_end_to_end(self): """Test weather range tool with mocked HTTP responses.""" handler = GetWeatherByDateRangeToolHandler() mock_geo_data = { "results": [{"latitude": 48.8566, "longitude": 2.3522}] } mock_weather_data = { "hourly": { "time": ["2024-01-01T00:00", "2024-01-01T12:00"], "temperature_2m": [10.0, 15.0], "relative_humidity_2m": [90, 75], "dew_point_2m": [8.5, 11.0], "weather_code": [3, 1] } } with patch('httpx.AsyncClient') as mock_client_class: mock_client = AsyncMock() geo_response = Mock() geo_response.status_code = 200 geo_response.json.return_value = mock_geo_data weather_response = Mock() weather_response.status_code = 200 weather_response.json.return_value = mock_weather_data def mock_get(url): if "geocoding-api" in url: return geo_response else: return weather_response mock_client.get.side_effect = mock_get mock_client_class.return_value.__aenter__.return_value = mock_client args = { "city": "Paris", "start_date": "2024-01-01", "end_date": "2024-01-01" } result = await handler.run_tool(args) assert len(result) == 1 assert isinstance(result[0], TextContent) # The response should contain analysis prompt with JSON data assert "analyze" in result[0].text.lower() assert "Paris" in result[0].text ``` -------------------------------------------------------------------------------- /tests/test_time_tools.py: -------------------------------------------------------------------------------- ```python """ Unit tests for time-related tool handlers. """ import pytest import json from unittest.mock import Mock, patch from datetime import datetime, timezone from zoneinfo import ZoneInfo from mcp.types import TextContent, ErrorData from mcp import McpError from src.mcp_weather_server.tools.tools_time import ( GetCurrentDateTimeToolHandler, GetTimeZoneInfoToolHandler, ConvertTimeToolHandler ) class TestGetCurrentDateTimeToolHandler: """Test cases for GetCurrentDateTimeToolHandler.""" @pytest.fixture def handler(self): """Create a GetCurrentDateTimeToolHandler instance.""" return GetCurrentDateTimeToolHandler() def test_tool_description(self, handler): """Test the tool description is properly formatted.""" description = handler.get_tool_description() assert description.name == "get_current_datetime" assert "current time" in description.description.lower() assert description.inputSchema["type"] == "object" assert "timezone_name" in description.inputSchema["properties"] assert description.inputSchema["required"] == ["timezone_name"] @pytest.mark.asyncio async def test_run_tool_success(self, handler): """Test successful tool execution.""" fixed_time = datetime(2024, 1, 1, 15, 30, 45, tzinfo=ZoneInfo("America/New_York")) with patch('src.mcp_weather_server.utils.get_zoneinfo') as mock_get_tz: mock_get_tz.return_value = ZoneInfo("America/New_York") with patch('src.mcp_weather_server.tools.tools_time.datetime') as mock_datetime: mock_datetime.now.return_value = fixed_time args = {"timezone_name": "America/New_York"} result = await handler.run_tool(args) assert len(result) == 1 assert isinstance(result[0], TextContent) response_data = json.loads(result[0].text) assert response_data["timezone"] == "America/New_York" assert "2024-01-01T15:30:45" in response_data["datetime"] @pytest.mark.asyncio async def test_run_tool_missing_timezone(self, handler): """Test tool execution with missing timezone argument.""" args = {} result = await handler.run_tool(args) assert len(result) == 1 assert isinstance(result[0], TextContent) assert "Missing required arguments: timezone_name" in result[0].text @pytest.mark.asyncio async def test_run_tool_invalid_timezone(self, handler): """Test tool execution with invalid timezone.""" with patch('src.mcp_weather_server.utils.get_zoneinfo') as mock_get_tz: mock_get_tz.side_effect = McpError(ErrorData(code=-1, message="Invalid timezone: Invalid/Timezone")) args = {"timezone_name": "Invalid/Timezone"} result = await handler.run_tool(args) assert len(result) == 1 assert isinstance(result[0], TextContent) assert "Error getting current time: Invalid timezone" in result[0].text @pytest.mark.asyncio async def test_run_tool_utc_timezone(self, handler): """Test tool execution with UTC timezone.""" fixed_time = datetime(2024, 1, 1, 12, 0, 0, tzinfo=ZoneInfo("UTC")) with patch('src.mcp_weather_server.utils.get_zoneinfo') as mock_get_tz: mock_get_tz.return_value = ZoneInfo("UTC") with patch('src.mcp_weather_server.tools.tools_time.datetime') as mock_datetime: mock_datetime.now.return_value = fixed_time args = {"timezone_name": "UTC"} result = await handler.run_tool(args) assert len(result) == 1 response_data = json.loads(result[0].text) assert response_data["timezone"] == "UTC" assert "2024-01-01T12:00:00" in response_data["datetime"] class TestGetTimeZoneInfoToolHandler: """Test cases for GetTimeZoneInfoToolHandler.""" @pytest.fixture def handler(self): """Create a GetTimeZoneInfoToolHandler instance.""" return GetTimeZoneInfoToolHandler() def test_tool_description(self, handler): """Test the tool description is properly formatted.""" description = handler.get_tool_description() assert description.name == "get_timezone_info" assert "timezone" in description.description.lower() assert description.inputSchema["type"] == "object" assert "timezone_name" in description.inputSchema["properties"] assert description.inputSchema["required"] == ["timezone_name"] @pytest.mark.asyncio async def test_run_tool_success(self, handler): """Test successful tool execution.""" fixed_time = datetime(2024, 6, 15, 14, 30, 0, tzinfo=ZoneInfo("Europe/London")) fixed_utc_time = datetime(2024, 6, 15, 13, 0, 0) # UTC time without timezone with patch('src.mcp_weather_server.utils.get_zoneinfo') as mock_get_tz: mock_get_tz.return_value = ZoneInfo("Europe/London") with patch('src.mcp_weather_server.tools.tools_time.datetime') as mock_datetime: mock_datetime.now.return_value = fixed_time mock_datetime.utcnow.return_value = fixed_utc_time args = {"timezone_name": "Europe/London"} result = await handler.run_tool(args) assert len(result) == 1 assert isinstance(result[0], TextContent) response_data = json.loads(result[0].text) assert response_data["timezone_name"] == "Europe/London" assert "current_local_time" in response_data assert "utc_offset_hours" in response_data @pytest.mark.asyncio async def test_run_tool_missing_timezone(self, handler): """Test tool execution with missing timezone argument.""" args = {} result = await handler.run_tool(args) assert len(result) == 1 assert isinstance(result[0], TextContent) assert "Missing required arguments: timezone_name" in result[0].text @pytest.mark.asyncio async def test_run_tool_invalid_timezone(self, handler): """Test tool execution with invalid timezone.""" with patch('src.mcp_weather_server.utils.get_zoneinfo') as mock_get_tz: mock_get_tz.side_effect = McpError(ErrorData(code=-1, message="Invalid timezone")) args = {"timezone_name": "Invalid/Timezone"} result = await handler.run_tool(args) assert len(result) == 1 assert isinstance(result[0], TextContent) assert "Error getting timezone info: Invalid timezone" in result[0].text class TestConvertTimeToolHandler: """Test cases for ConvertTimeToolHandler.""" @pytest.fixture def handler(self): """Create a ConvertTimeToolHandler instance.""" return ConvertTimeToolHandler() def test_tool_description(self, handler): """Test the tool description is properly formatted.""" description = handler.get_tool_description() assert description.name == "convert_time" assert "convert time" in description.description.lower() assert description.inputSchema["type"] == "object" # Check that required parameters are supported assert "datetime_str" in description.inputSchema["properties"] assert "from_timezone" in description.inputSchema["properties"] assert "to_timezone" in description.inputSchema["properties"] assert "to_timezone" in description.inputSchema["properties"] # Check that required fields include the timezone fields assert "from_timezone" in description.inputSchema["required"] assert "to_timezone" in description.inputSchema["required"] @pytest.mark.asyncio async def test_run_tool_success(self, handler): """Test successful time conversion.""" with patch('src.mcp_weather_server.utils.get_zoneinfo') as mock_get_tz: # Mock timezone creation mock_get_tz.side_effect = lambda tz: ZoneInfo(tz) with patch('dateutil.parser.parse') as mock_parse: # Mock parsing the datetime string source_time = datetime(2024, 1, 1, 12, 0, 0, tzinfo=ZoneInfo("UTC")) mock_parse.return_value = source_time args = { "datetime_str": "2024-01-01T12:00:00", "from_timezone": "UTC", "to_timezone": "America/New_York" } result = await handler.run_tool(args) assert len(result) == 1 assert isinstance(result[0], TextContent) response_data = json.loads(result[0].text) assert response_data["original_timezone"] == "UTC" assert response_data["converted_timezone"] == "America/New_York" # Check that the datetime includes timezone info assert "2024-01-01T12:00:00" in response_data["original_datetime"] assert "converted_datetime" in response_data @pytest.mark.asyncio async def test_run_tool_missing_required_args(self, handler): """Test tool execution with missing required arguments.""" args = {"datetime": "2024-01-01T12:00:00"} # Missing timezones result = await handler.run_tool(args) assert len(result) == 1 assert isinstance(result[0], TextContent) assert "Missing required arguments" in result[0].text @pytest.mark.asyncio async def test_run_tool_invalid_datetime_format(self, handler): """Test tool execution with invalid datetime format.""" with patch('dateutil.parser.parse') as mock_parse: mock_parse.side_effect = ValueError("Invalid datetime format") args = { "datetime": "invalid-datetime", "from_timezone": "UTC", "to_timezone": "America/New_York" } result = await handler.run_tool(args) assert len(result) == 1 assert isinstance(result[0], TextContent) assert "Error converting time" in result[0].text @pytest.mark.asyncio async def test_run_tool_invalid_from_timezone(self, handler): """Test tool execution with invalid from_timezone.""" with patch('src.mcp_weather_server.utils.get_zoneinfo') as mock_get_tz: def side_effect(tz): if tz == "Invalid/Timezone": raise McpError(ErrorData(code=-1, message="Invalid timezone")) return ZoneInfo(tz) mock_get_tz.side_effect = side_effect args = { "datetime": "2024-01-01T12:00:00", "from_timezone": "Invalid/Timezone", "to_timezone": "UTC" } result = await handler.run_tool(args) assert len(result) == 1 assert isinstance(result[0], TextContent) assert "Error converting time" in result[0].text @pytest.mark.asyncio async def test_run_tool_invalid_to_timezone(self, handler): """Test tool execution with invalid to_timezone.""" with patch('src.mcp_weather_server.utils.get_zoneinfo') as mock_get_tz: def side_effect(tz): if tz == "Invalid/Timezone": raise McpError(ErrorData(code=-1, message="Invalid timezone")) return ZoneInfo(tz) mock_get_tz.side_effect = side_effect with patch('dateutil.parser.parse') as mock_parse: mock_parse.return_value = datetime(2024, 1, 1, 12, 0, 0, tzinfo=ZoneInfo("UTC")) args = { "datetime": "2024-01-01T12:00:00", "from_timezone": "UTC", "to_timezone": "Invalid/Timezone" } result = await handler.run_tool(args) assert len(result) == 1 assert isinstance(result[0], TextContent) assert "Error converting time" in result[0].text @pytest.mark.asyncio async def test_run_tool_same_timezone_conversion(self, handler): """Test time conversion between same timezones.""" with patch('src.mcp_weather_server.utils.get_zoneinfo') as mock_get_tz: mock_get_tz.return_value = ZoneInfo("UTC") with patch('dateutil.parser.parse') as mock_parse: source_time = datetime(2024, 1, 1, 12, 0, 0, tzinfo=ZoneInfo("UTC")) mock_parse.return_value = source_time args = { "datetime_str": "2024-01-01T12:00:00", "from_timezone": "UTC", "to_timezone": "UTC" } result = await handler.run_tool(args) assert len(result) == 1 response_data = json.loads(result[0].text) # When converting to same timezone, time should remain the same assert "2024-01-01T12:00:00" in response_data["original_datetime"] assert response_data["original_timezone"] == "UTC" assert response_data["converted_timezone"] == "UTC" @pytest.mark.asyncio async def test_run_tool_across_date_line(self, handler): """Test time conversion across international date line.""" with patch('src.mcp_weather_server.utils.get_zoneinfo') as mock_get_tz: mock_get_tz.side_effect = lambda tz: ZoneInfo(tz) with patch('dateutil.parser.parse') as mock_parse: # Late evening in one timezone source_time = datetime(2024, 1, 1, 23, 0, 0, tzinfo=ZoneInfo("Pacific/Kiritimati")) mock_parse.return_value = source_time args = { "datetime_str": "2024-01-01T23:00:00", "from_timezone": "Pacific/Kiritimati", "to_timezone": "Pacific/Honolulu" } result = await handler.run_tool(args) assert len(result) == 1 response_data = json.loads(result[0].text) assert response_data["original_timezone"] == "Pacific/Kiritimati" assert response_data["converted_timezone"] == "Pacific/Honolulu" # Should handle date line crossing correctly assert "converted_datetime" in response_data ``` -------------------------------------------------------------------------------- /tests/test_performance.py: -------------------------------------------------------------------------------- ```python """ Performance and load tests for MCP Weather Server. """ import pytest import asyncio import time from unittest.mock import AsyncMock, Mock, patch from src.mcp_weather_server.server import ( register_all_tools, call_tool, tool_handlers ) class TestPerformance: """Performance tests for the weather server.""" def setup_method(self): """Setup for each test.""" global tool_handlers tool_handlers.clear() register_all_tools() @pytest.mark.asyncio async def test_weather_request_performance(self): """Test that weather requests complete within reasonable time.""" mock_geo_response = { "results": [{"latitude": 40.7128, "longitude": -74.0060}] } mock_weather_response = { "hourly": { "time": ["2024-01-01T12:00"], "temperature_2m": [20.0], "relative_humidity_2m": [60], "dew_point_2m": [12.0], "weather_code": [0] } } with patch('httpx.AsyncClient') as mock_client_class: mock_client = AsyncMock() def mock_get(url): response = Mock() response.status_code = 200 if "geocoding-api" in url: response.json.return_value = mock_geo_response else: response.json.return_value = mock_weather_response return response mock_client.get.side_effect = mock_get mock_client_class.return_value.__aenter__.return_value = mock_client with patch('src.mcp_weather_server.utils.get_closest_utc_index', return_value=0): start_time = time.time() result = await call_tool("get_current_weather", {"city": "New York"}) end_time = time.time() # Request should complete in less than 1 second (mocked) assert end_time - start_time < 1.0 assert len(result) == 1 assert not result[0].text.startswith("Error:") @pytest.mark.asyncio async def test_concurrent_request_performance(self): """Test performance with multiple concurrent requests.""" mock_geo_response = { "results": [{"latitude": 40.7128, "longitude": -74.0060}] } mock_weather_response = { "hourly": { "time": ["2024-01-01T12:00"], "temperature_2m": [20.0], "relative_humidity_2m": [60], "dew_point_2m": [12.0], "weather_code": [0] } } with patch('httpx.AsyncClient') as mock_client_class: mock_client = AsyncMock() def mock_get(url): response = Mock() response.status_code = 200 if "geocoding-api" in url: response.json.return_value = mock_geo_response else: response.json.return_value = mock_weather_response return response mock_client.get.side_effect = mock_get mock_client_class.return_value.__aenter__.return_value = mock_client with patch('src.mcp_weather_server.utils.get_closest_utc_index', return_value=0): # Create 10 concurrent requests tasks = [ call_tool("get_current_weather", {"city": f"City{i}"}) for i in range(10) ] start_time = time.time() results = await asyncio.gather(*tasks) end_time = time.time() # All requests should complete in reasonable time assert end_time - start_time < 2.0 assert len(results) == 10 # All requests should succeed for result in results: assert len(result) == 1 assert not result[0].text.startswith("Error:") @pytest.mark.asyncio async def test_time_tool_performance(self): """Test performance of time-related tools.""" from datetime import datetime from zoneinfo import ZoneInfo fixed_time = datetime(2024, 1, 1, 12, 0, 0, tzinfo=ZoneInfo("UTC")) with patch('src.mcp_weather_server.utils.get_zoneinfo') as mock_get_tz: mock_get_tz.return_value = ZoneInfo("UTC") with patch('src.mcp_weather_server.tools.tools_time.datetime') as mock_datetime: mock_datetime.now.return_value = fixed_time start_time = time.time() result = await call_tool("get_current_datetime", {"timezone_name": "UTC"}) end_time = time.time() # Time tools should be very fast assert end_time - start_time < 0.1 assert len(result) == 1 assert not result[0].text.startswith("Error:") @pytest.mark.asyncio async def test_memory_usage_with_many_requests(self): """Test that memory usage doesn't grow excessively with many requests.""" import gc import sys # Get initial memory usage (approximation) gc.collect() initial_objects = len(gc.get_objects()) mock_geo_response = { "results": [{"latitude": 40.7128, "longitude": -74.0060}] } mock_weather_response = { "hourly": { "time": ["2024-01-01T12:00"], "temperature_2m": [20.0], "relative_humidity_2m": [60], "dew_point_2m": [12.0], "weather_code": [0] } } with patch('httpx.AsyncClient') as mock_client_class: mock_client = AsyncMock() def mock_get(url): response = Mock() response.status_code = 200 if "geocoding-api" in url: response.json.return_value = mock_geo_response else: response.json.return_value = mock_weather_response return response mock_client.get.side_effect = mock_get mock_client_class.return_value.__aenter__.return_value = mock_client with patch('src.mcp_weather_server.utils.get_closest_utc_index', return_value=0): # Make many sequential requests for i in range(50): result = await call_tool("get_current_weather", {"city": f"City{i}"}) assert len(result) == 1 assert not result[0].text.startswith("Error:") # Force garbage collection and check memory gc.collect() final_objects = len(gc.get_objects()) # Object count shouldn't grow dramatically # Allow some growth but not excessive (factor of 2) assert final_objects < initial_objects * 2 class TestLoadTesting: """Load testing scenarios.""" def setup_method(self): """Setup for each test.""" global tool_handlers tool_handlers.clear() register_all_tools() @pytest.mark.asyncio async def test_burst_load_handling(self): """Test handling of burst load (many requests at once).""" mock_geo_response = { "results": [{"latitude": 40.7128, "longitude": -74.0060}] } mock_weather_response = { "hourly": { "time": ["2024-01-01T12:00"], "temperature_2m": [20.0], "relative_humidity_2m": [60], "dew_point_2m": [12.0], "weather_code": [0] } } with patch('httpx.AsyncClient') as mock_client_class: mock_client = AsyncMock() def mock_get(url): response = Mock() response.status_code = 200 if "geocoding-api" in url: response.json.return_value = mock_geo_response else: response.json.return_value = mock_weather_response return response mock_client.get.side_effect = mock_get mock_client_class.return_value.__aenter__.return_value = mock_client with patch('src.mcp_weather_server.utils.get_closest_utc_index', return_value=0): # Create a large burst of requests (simulate high load) burst_size = 25 tasks = [ call_tool("get_current_weather", {"city": f"BurstCity{i}"}) for i in range(burst_size) ] start_time = time.time() results = await asyncio.gather(*tasks, return_exceptions=True) end_time = time.time() # Check that most requests succeeded successful_results = [r for r in results if not isinstance(r, Exception)] error_results = [r for r in results if isinstance(r, Exception)] # At least 80% should succeed under load success_rate = len(successful_results) / len(results) assert success_rate >= 0.8, f"Success rate {success_rate} too low" # Total time should be reasonable even under load assert end_time - start_time < 5.0 @pytest.mark.asyncio async def test_mixed_tool_load(self): """Test load with mixed tool types.""" from datetime import datetime from zoneinfo import ZoneInfo # Setup weather mocks mock_geo_response = { "results": [{"latitude": 40.7128, "longitude": -74.0060}] } mock_weather_response = { "hourly": { "time": ["2024-01-01T12:00"], "temperature_2m": [20.0], "relative_humidity_2m": [60], "dew_point_2m": [12.0], "weather_code": [0] } } # Setup time mocks fixed_time = datetime(2024, 1, 1, 12, 0, 0, tzinfo=ZoneInfo("UTC")) with patch('httpx.AsyncClient') as mock_client_class: mock_client = AsyncMock() def mock_get(url): response = Mock() response.status_code = 200 if "geocoding-api" in url: response.json.return_value = mock_geo_response else: response.json.return_value = mock_weather_response return response mock_client.get.side_effect = mock_get mock_client_class.return_value.__aenter__.return_value = mock_client with patch('src.mcp_weather_server.utils.get_zoneinfo') as mock_get_tz: mock_get_tz.return_value = ZoneInfo("UTC") with patch('src.mcp_weather_server.tools.tools_time.datetime') as mock_datetime: mock_datetime.now.return_value = fixed_time with patch('src.mcp_weather_server.utils.get_closest_utc_index', return_value=0): # Create mixed requests tasks = [] for i in range(20): if i % 3 == 0: tasks.append(call_tool("get_current_weather", {"city": f"City{i}"})) elif i % 3 == 1: tasks.append(call_tool("get_current_datetime", {"timezone_name": "UTC"})) else: tasks.append(call_tool("get_timezone_info", {"timezone_name": "UTC"})) start_time = time.time() results = await asyncio.gather(*tasks, return_exceptions=True) end_time = time.time() # Check results successful_results = [r for r in results if not isinstance(r, Exception)] success_rate = len(successful_results) / len(results) assert success_rate >= 0.9, f"Mixed load success rate {success_rate} too low" assert end_time - start_time < 3.0 assert len(results) == 20 class TestStressConditions: """Stress testing under adverse conditions.""" def setup_method(self): """Setup for each test.""" global tool_handlers tool_handlers.clear() register_all_tools() @pytest.mark.asyncio async def test_high_error_rate_resilience(self): """Test resilience when external APIs have high error rates.""" error_count = 0 success_count = 0 def mock_get_with_errors(url): nonlocal error_count, success_count response = Mock() # Simulate 30% error rate if (error_count + success_count) % 10 < 3: error_count += 1 response.status_code = 500 else: success_count += 1 response.status_code = 200 if "geocoding-api" in url: response.json.return_value = { "results": [{"latitude": 40.7128, "longitude": -74.0060}] } else: response.json.return_value = { "hourly": { "time": ["2024-01-01T12:00"], "temperature_2m": [20.0], "relative_humidity_2m": [60], "dew_point_2m": [12.0], "weather_code": [0] } } return response with patch('httpx.AsyncClient') as mock_client_class: mock_client = AsyncMock() mock_client.get.side_effect = mock_get_with_errors mock_client_class.return_value.__aenter__.return_value = mock_client with patch('src.mcp_weather_server.utils.get_closest_utc_index', return_value=0): # Make requests despite high error rate tasks = [ call_tool("get_current_weather", {"city": f"ErrorTestCity{i}"}) for i in range(20) ] results = await asyncio.gather(*tasks, return_exceptions=True) # Count successes and errors successful_results = [] error_results = [] for result in results: if isinstance(result, Exception): error_results.append(result) else: if result[0].text.startswith("Error:"): error_results.append(result) else: successful_results.append(result) # Should handle errors gracefully without crashing assert len(results) == 20 # Some should succeed despite errors assert len(successful_results) > 0 # Error handling should be proper for error_result in error_results: if not isinstance(error_result, Exception): assert "Error:" in error_result[0].text ``` -------------------------------------------------------------------------------- /tests/test_integration.py: -------------------------------------------------------------------------------- ```python """ Integration tests for the MCP Weather Server. These tests verify end-to-end functionality with real-world scenarios. """ import pytest import asyncio import json from unittest.mock import AsyncMock, Mock, patch from mcp.types import ErrorData from mcp import McpError from src.mcp_weather_server.server import ( register_all_tools, list_tools, call_tool, tool_handlers ) class TestWeatherIntegration: """Integration tests for weather functionality.""" def setup_method(self): """Setup for each test.""" global tool_handlers tool_handlers.clear() register_all_tools() @pytest.mark.asyncio async def test_current_weather_integration(self): """Test complete current weather workflow.""" # Mock HTTP responses mock_geo_response = { "results": [{"latitude": 40.7128, "longitude": -74.0060}] } mock_weather_response = { "hourly": { "time": ["2024-01-01T12:00"], "temperature_2m": [22.5], "relative_humidity_2m": [65], "dew_point_2m": [15.2], "weather_code": [1] } } with patch('httpx.AsyncClient') as mock_client_class: mock_client = AsyncMock() def mock_get(url): response = Mock() response.status_code = 200 if "geocoding-api" in url: response.json.return_value = mock_geo_response else: response.json.return_value = mock_weather_response return response mock_client.get.side_effect = mock_get mock_client_class.return_value.__aenter__.return_value = mock_client with patch('src.mcp_weather_server.utils.get_closest_utc_index', return_value=0): # Test the complete workflow tools = await list_tools() assert any(tool.name == "get_current_weather" for tool in tools) result = await call_tool("get_current_weather", {"city": "New York"}) assert len(result) == 1 response_text = result[0].text assert "New York" in response_text assert "22.5°C" in response_text assert "Mainly clear" in response_text @pytest.mark.asyncio async def test_weather_range_integration(self): """Test complete weather range workflow.""" mock_geo_response = { "results": [{"latitude": 51.5074, "longitude": -0.1278}] } mock_weather_response = { "hourly": { "time": ["2024-01-01T00:00", "2024-01-01T12:00", "2024-01-02T00:00"], "temperature_2m": [8.0, 12.0, 6.0], "relative_humidity_2m": [85, 70, 90], "dew_point_2m": [6.0, 7.0, 4.5], "weather_code": [3, 1, 61] } } with patch('httpx.AsyncClient') as mock_client_class: mock_client = AsyncMock() def mock_get(url): response = Mock() response.status_code = 200 if "geocoding-api" in url: response.json.return_value = mock_geo_response else: response.json.return_value = mock_weather_response return response mock_client.get.side_effect = mock_get mock_client_class.return_value.__aenter__.return_value = mock_client result = await call_tool("get_weather_byDateTimeRange", { "city": "London", "start_date": "2024-01-01", "end_date": "2024-01-02" }) assert len(result) == 1 response_text = result[0].text assert "London" in response_text assert "analyze" in response_text.lower() assert "2024-01-01" in response_text @pytest.mark.asyncio async def test_weather_details_integration(self): """Test weather details JSON output.""" mock_geo_response = { "results": [{"latitude": 48.8566, "longitude": 2.3522}] } mock_weather_response = { "hourly": { "time": ["2024-01-01T14:00"], "temperature_2m": [18.5], "relative_humidity_2m": [72], "dew_point_2m": [13.8], "weather_code": [2] } } with patch('httpx.AsyncClient') as mock_client_class: mock_client = AsyncMock() def mock_get(url): response = Mock() response.status_code = 200 if "geocoding-api" in url: response.json.return_value = mock_geo_response else: response.json.return_value = mock_weather_response return response mock_client.get.side_effect = mock_get mock_client_class.return_value.__aenter__.return_value = mock_client with patch('src.mcp_weather_server.utils.get_closest_utc_index', return_value=0): result = await call_tool("get_weather_details", {"city": "Paris"}) assert len(result) == 1 # Parse the JSON response weather_data = json.loads(result[0].text) assert weather_data["city"] == "Paris" assert weather_data["latitude"] == 48.8566 assert weather_data["longitude"] == 2.3522 assert weather_data["temperature_c"] == 18.5 assert weather_data["weather_description"] == "Partly cloudy" class TestTimeIntegration: """Integration tests for time functionality.""" def setup_method(self): """Setup for each test.""" global tool_handlers tool_handlers.clear() register_all_tools() @pytest.mark.asyncio async def test_current_datetime_integration(self): """Test complete current datetime workflow.""" from datetime import datetime from zoneinfo import ZoneInfo fixed_time = datetime(2024, 6, 15, 10, 30, 45, tzinfo=ZoneInfo("America/New_York")) with patch('src.mcp_weather_server.utils.get_zoneinfo') as mock_get_tz: mock_get_tz.return_value = ZoneInfo("America/New_York") with patch('src.mcp_weather_server.tools.tools_time.datetime') as mock_datetime: mock_datetime.now.return_value = fixed_time result = await call_tool("get_current_datetime", { "timezone_name": "America/New_York" }) assert len(result) == 1 time_data = json.loads(result[0].text) assert time_data["timezone"] == "America/New_York" assert "2024-06-15T10:30:45" in time_data["datetime"] @pytest.mark.asyncio async def test_timezone_info_integration(self): """Test complete timezone info workflow.""" from datetime import datetime from zoneinfo import ZoneInfo fixed_time = datetime(2024, 12, 25, 15, 0, 0, tzinfo=ZoneInfo("Europe/London")) fixed_utc_time = datetime(2024, 12, 25, 15, 0, 0) # UTC time without timezone with patch('src.mcp_weather_server.utils.get_zoneinfo') as mock_get_tz: mock_get_tz.return_value = ZoneInfo("Europe/London") with patch('src.mcp_weather_server.tools.tools_time.datetime') as mock_datetime: mock_datetime.now.return_value = fixed_time mock_datetime.utcnow.return_value = fixed_utc_time result = await call_tool("get_timezone_info", { "timezone_name": "Europe/London" }) assert len(result) == 1 tz_data = json.loads(result[0].text) assert tz_data["timezone_name"] == "Europe/London" assert "current_local_time" in tz_data assert "utc_offset_hours" in tz_data @pytest.mark.asyncio async def test_time_conversion_integration(self): """Test complete time conversion workflow.""" from datetime import datetime from zoneinfo import ZoneInfo with patch('src.mcp_weather_server.utils.get_zoneinfo') as mock_get_tz: mock_get_tz.side_effect = lambda tz: ZoneInfo(tz) with patch('dateutil.parser.parse') as mock_parse: source_time = datetime(2024, 7, 4, 12, 0, 0, tzinfo=ZoneInfo("UTC")) mock_parse.return_value = source_time result = await call_tool("convert_time", { "datetime_str": "2024-07-04T12:00:00", "from_timezone": "UTC", "to_timezone": "America/Los_Angeles" }) assert len(result) == 1 conversion_data = json.loads(result[0].text) assert "2024-07-04T12:00:00" in conversion_data["original_datetime"] assert conversion_data["original_timezone"] == "UTC" assert conversion_data["converted_timezone"] == "America/Los_Angeles" assert "converted_datetime" in conversion_data class TestErrorHandlingIntegration: """Integration tests for error handling scenarios.""" def setup_method(self): """Setup for each test.""" global tool_handlers tool_handlers.clear() register_all_tools() @pytest.mark.asyncio async def test_invalid_city_error_handling(self): """Test error handling for invalid city names.""" # Mock empty geocoding response mock_geo_response = {"results": []} with patch('httpx.AsyncClient') as mock_client_class: mock_client = AsyncMock() mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = mock_geo_response mock_client.get.return_value = mock_response mock_client_class.return_value.__aenter__.return_value = mock_client result = await call_tool("get_current_weather", {"city": "NonexistentCity"}) assert len(result) == 1 assert "Error:" in result[0].text assert "coordinates" in result[0].text.lower() @pytest.mark.asyncio async def test_api_error_handling(self): """Test handling of API errors.""" with patch('httpx.AsyncClient') as mock_client_class: mock_client = AsyncMock() mock_response = Mock() mock_response.status_code = 500 # Server error mock_client.get.return_value = mock_response mock_client_class.return_value.__aenter__.return_value = mock_client result = await call_tool("get_current_weather", {"city": "TestCity"}) assert len(result) == 1 assert "Error:" in result[0].text assert "500" in result[0].text @pytest.mark.asyncio async def test_network_error_handling(self): """Test handling of network errors.""" import httpx with patch('httpx.AsyncClient') as mock_client_class: mock_client = AsyncMock() mock_client.get.side_effect = httpx.RequestError("Network unavailable") mock_client_class.return_value.__aenter__.return_value = mock_client result = await call_tool("get_current_weather", {"city": "TestCity"}) assert len(result) == 1 assert "Error:" in result[0].text assert "Network" in result[0].text or "network" in result[0].text @pytest.mark.asyncio async def test_invalid_timezone_error_handling(self): """Test error handling for invalid timezones.""" with patch('src.mcp_weather_server.utils.get_zoneinfo') as mock_get_tz: mock_get_tz.side_effect = McpError(ErrorData(code=-1, message="Invalid timezone: BadTimezone")) result = await call_tool("get_current_datetime", { "timezone_name": "BadTimezone" }) assert len(result) == 1 assert "Error getting current time" in result[0].text @pytest.mark.asyncio async def test_missing_arguments_error_handling(self): """Test error handling for missing required arguments.""" # Test weather tool without city result = await call_tool("get_current_weather", {}) assert len(result) == 1 assert "Missing required arguments: city" in result[0].text # Test time conversion without required fields result = await call_tool("convert_time", {"datetime": "2024-01-01T12:00:00"}) assert len(result) == 1 assert "Missing required arguments" in result[0].text class TestConcurrentOperations: """Integration tests for concurrent operations.""" def setup_method(self): """Setup for each test.""" global tool_handlers tool_handlers.clear() register_all_tools() @pytest.mark.asyncio async def test_concurrent_weather_requests(self): """Test concurrent weather requests.""" mock_responses = { "New York": { "geo": {"results": [{"latitude": 40.7128, "longitude": -74.0060}]}, "weather": { "hourly": { "time": ["2024-01-01T12:00"], "temperature_2m": [20.0], "relative_humidity_2m": [60], "dew_point_2m": [12.0], "weather_code": [0] } } }, "London": { "geo": {"results": [{"latitude": 51.5074, "longitude": -0.1278}]}, "weather": { "hourly": { "time": ["2024-01-01T12:00"], "temperature_2m": [15.0], "relative_humidity_2m": [80], "dew_point_2m": [11.5], "weather_code": [3] } } } } with patch('httpx.AsyncClient') as mock_client_class: mock_client = AsyncMock() def mock_get(url): response = Mock() response.status_code = 200 # Default to New York for any unmapped requests default_geo = mock_responses["New York"]["geo"] default_weather = mock_responses["New York"]["weather"] # Determine which city based on the URL parameters if "name=New%20York" in url or "name=New+York" in url: if "geocoding-api" in url: response.json.return_value = mock_responses["New York"]["geo"] else: response.json.return_value = mock_responses["New York"]["weather"] elif "name=London" in url: if "geocoding-api" in url: response.json.return_value = mock_responses["London"]["geo"] else: response.json.return_value = mock_responses["London"]["weather"] elif "geocoding-api" in url: # Default geocoding response response.json.return_value = default_geo else: # Default weather response based on coordinates if "latitude=40.7128" in url: response.json.return_value = mock_responses["New York"]["weather"] elif "latitude=51.5074" in url: response.json.return_value = mock_responses["London"]["weather"] else: response.json.return_value = default_weather return response mock_client.get.side_effect = mock_get mock_client_class.return_value.__aenter__.return_value = mock_client with patch('src.mcp_weather_server.utils.get_closest_utc_index', return_value=0): # Run concurrent requests tasks = [ call_tool("get_current_weather", {"city": "New York"}), call_tool("get_current_weather", {"city": "London"}), ] results = await asyncio.gather(*tasks) assert len(results) == 2 # Check that both requests completed successfully for result in results: assert len(result) == 1 assert not result[0].text.startswith("Error:") # Verify different cities returned different results result_texts = [result[0].text for result in results] assert any("New York" in text for text in result_texts) assert any("London" in text for text in result_texts) @pytest.mark.asyncio async def test_mixed_tool_concurrent_requests(self): """Test concurrent requests to different types of tools.""" from datetime import datetime from zoneinfo import ZoneInfo # Setup mocks for weather mock_geo_response = { "results": [{"latitude": 35.6762, "longitude": 139.6503}] } mock_weather_response = { "hourly": { "time": ["2024-01-01T12:00"], "temperature_2m": [25.0], "relative_humidity_2m": [70], "dew_point_2m": [18.0], "weather_code": [1] } } # Setup mocks for time fixed_time = datetime(2024, 1, 1, 9, 0, 0, tzinfo=ZoneInfo("Asia/Tokyo")) with patch('httpx.AsyncClient') as mock_client_class: mock_client = AsyncMock() def mock_get(url): response = Mock() response.status_code = 200 if "geocoding-api" in url: response.json.return_value = mock_geo_response else: response.json.return_value = mock_weather_response return response mock_client.get.side_effect = mock_get mock_client_class.return_value.__aenter__.return_value = mock_client with patch('src.mcp_weather_server.utils.get_zoneinfo') as mock_get_tz: mock_get_tz.return_value = ZoneInfo("Asia/Tokyo") with patch('src.mcp_weather_server.tools.tools_time.datetime') as mock_datetime: mock_datetime.now.return_value = fixed_time with patch('src.mcp_weather_server.utils.get_closest_utc_index', return_value=0): # Run concurrent requests to different tools tasks = [ call_tool("get_current_weather", {"city": "Tokyo"}), call_tool("get_current_datetime", {"timezone_name": "Asia/Tokyo"}), ] results = await asyncio.gather(*tasks) assert len(results) == 2 # Verify weather result weather_result = results[0] assert len(weather_result) == 1 assert "Tokyo" in weather_result[0].text assert "25.0°C" in weather_result[0].text # Verify time result time_result = results[1] assert len(time_result) == 1 time_data = json.loads(time_result[0].text) assert time_data["timezone"] == "Asia/Tokyo" ```