diff --git a/src/time/src/mcp_server_time/server.py b/src/time/src/mcp_server_time/server.py index 2cb0926134..88d1cb5f68 100644 --- a/src/time/src/mcp_server_time/server.py +++ b/src/time/src/mcp_server_time/server.py @@ -1,14 +1,22 @@ from datetime import datetime, timedelta from enum import Enum import json -from typing import Sequence +from typing import Any, Sequence from zoneinfo import ZoneInfo from tzlocal import get_localzone_name # ← returns "Europe/Paris", etc. from mcp.server import Server from mcp.server.stdio import stdio_server -from mcp.types import Tool, ToolAnnotations, TextContent, ImageContent, EmbeddedResource, ErrorData, INVALID_PARAMS +from mcp.types import ( + Tool, + ToolAnnotations, + TextContent, + ImageContent, + EmbeddedResource, + ErrorData, + INVALID_PARAMS, +) from mcp.shared.exceptions import McpError from pydantic import BaseModel @@ -38,6 +46,57 @@ class TimeConversionInput(BaseModel): target_tz_list: list[str] +TIMEZONE_NAME_PATTERN = r"^[A-Za-z0-9_+\-./]+$" +TIMEZONE_NAME_MAX_LENGTH = 128 +TIME_24H_PATTERN = r"^([01]?[0-9]|2[0-3]):[0-5][0-9]$" + + +def timezone_schema(description: str) -> dict[str, Any]: + return { + "type": "string", + "description": description, + "maxLength": TIMEZONE_NAME_MAX_LENGTH, + "pattern": TIMEZONE_NAME_PATTERN, + } + + +def time_24h_schema() -> dict[str, Any]: + return { + "type": "string", + "description": "Time to convert in 24-hour format (HH:MM)", + "maxLength": 5, + "pattern": TIME_24H_PATTERN, + } + + +def get_current_time_input_schema(local_tz: str) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "timezone": timezone_schema( + f"IANA timezone name (e.g., 'America/New_York', 'Europe/London'). Use '{local_tz}' as local timezone if no timezone provided by the user." + ) + }, + "required": ["timezone"], + } + + +def convert_time_input_schema(local_tz: str) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "source_timezone": timezone_schema( + f"Source IANA timezone name (e.g., 'America/New_York', 'Europe/London'). Use '{local_tz}' as local timezone if no source timezone provided by the user." + ), + "time": time_24h_schema(), + "target_timezone": timezone_schema( + f"Target IANA timezone name (e.g., 'Asia/Tokyo', 'America/San_Francisco'). Use '{local_tz}' as local timezone if no target timezone provided by the user." + ), + }, + "required": ["source_timezone", "time", "target_timezone"], + } + + def get_local_tz(local_tz_override: str | None = None) -> ZoneInfo: if local_tz_override: return ZoneInfo(local_tz_override) @@ -54,7 +113,9 @@ def get_zoneinfo(timezone_name: str) -> ZoneInfo: try: return ZoneInfo(timezone_name) except Exception as e: - raise McpError(ErrorData(code=INVALID_PARAMS, message=f"Invalid timezone: {str(e)}")) + raise McpError( + ErrorData(code=INVALID_PARAMS, message=f"Invalid timezone: {str(e)}") + ) class TimeServer: @@ -132,16 +193,7 @@ async def list_tools() -> list[Tool]: Tool( name=TimeTools.GET_CURRENT_TIME.value, description="Get current time in a specific timezone", - inputSchema={ - "type": "object", - "properties": { - "timezone": { - "type": "string", - "description": f"IANA timezone name (e.g., 'America/New_York', 'Europe/London'). Use '{local_tz}' as local timezone if no timezone provided by the user.", - } - }, - "required": ["timezone"], - }, + inputSchema=get_current_time_input_schema(local_tz), annotations=ToolAnnotations( readOnlyHint=True, destructiveHint=False, @@ -152,24 +204,7 @@ async def list_tools() -> list[Tool]: Tool( name=TimeTools.CONVERT_TIME.value, description="Convert time between timezones", - inputSchema={ - "type": "object", - "properties": { - "source_timezone": { - "type": "string", - "description": f"Source IANA timezone name (e.g., 'America/New_York', 'Europe/London'). Use '{local_tz}' as local timezone if no source timezone provided by the user.", - }, - "time": { - "type": "string", - "description": "Time to convert in 24-hour format (HH:MM)", - }, - "target_timezone": { - "type": "string", - "description": f"Target IANA timezone name (e.g., 'Asia/Tokyo', 'America/San_Francisco'). Use '{local_tz}' as local timezone if no target timezone provided by the user.", - }, - }, - "required": ["source_timezone", "time", "target_timezone"], - }, + inputSchema=convert_time_input_schema(local_tz), annotations=ToolAnnotations( readOnlyHint=True, destructiveHint=False, diff --git a/src/time/test/time_server_test.py b/src/time/test/time_server_test.py index 8d963508d7..1c6139c24d 100644 --- a/src/time/test/time_server_test.py +++ b/src/time/test/time_server_test.py @@ -1,11 +1,20 @@ - +import re from freezegun import freeze_time from mcp.shared.exceptions import McpError import pytest from unittest.mock import patch from zoneinfo import ZoneInfo -from mcp_server_time.server import TimeServer, get_local_tz +from mcp_server_time.server import ( + TIMEZONE_NAME_MAX_LENGTH, + TIMEZONE_NAME_PATTERN, + TIME_24H_PATTERN, + TimeServer, + convert_time_input_schema, + get_current_time_input_schema, + get_local_tz, + time_24h_schema, +) @pytest.mark.parametrize( @@ -91,6 +100,44 @@ def test_get_current_time_with_invalid_timezone(): time_server.get_current_time("Invalid/Timezone") +def test_tool_input_schemas_include_string_constraints(): + current_time_schema = get_current_time_input_schema("UTC") + convert_time_schema = convert_time_input_schema("UTC") + timezone = current_time_schema["properties"]["timezone"] + source_timezone = convert_time_schema["properties"]["source_timezone"] + target_timezone = convert_time_schema["properties"]["target_timezone"] + time = convert_time_schema["properties"]["time"] + + assert timezone["maxLength"] == TIMEZONE_NAME_MAX_LENGTH + assert timezone["pattern"] == TIMEZONE_NAME_PATTERN + assert source_timezone["maxLength"] == TIMEZONE_NAME_MAX_LENGTH + assert source_timezone["pattern"] == TIMEZONE_NAME_PATTERN + assert target_timezone["maxLength"] == TIMEZONE_NAME_MAX_LENGTH + assert target_timezone["pattern"] == TIMEZONE_NAME_PATTERN + assert time["maxLength"] == 5 + assert time["pattern"] == TIME_24H_PATTERN + + +@pytest.mark.parametrize("timezone", ["America/New_York", "Etc/GMT+5", "UTC"]) +def test_timezone_schema_pattern_accepts_common_iana_names(timezone): + assert re.fullmatch(TIMEZONE_NAME_PATTERN, timezone) + + +@pytest.mark.parametrize("timezone", ["America/New York", "Bad\tZone"]) +def test_timezone_schema_pattern_rejects_whitespace(timezone): + assert not re.fullmatch(TIMEZONE_NAME_PATTERN, timezone) + + +@pytest.mark.parametrize("time", ["00:00", "7:30", "23:59"]) +def test_time_schema_pattern_accepts_runtime_compatible_times(time): + assert re.fullmatch(time_24h_schema()["pattern"], time) + + +@pytest.mark.parametrize("time", ["24:00", "12:60", "12:345"]) +def test_time_schema_pattern_rejects_invalid_times(time): + assert not re.fullmatch(time_24h_schema()["pattern"], time) + + @pytest.mark.parametrize( "source_tz,time_str,target_tz,expected_error", [ @@ -475,7 +522,7 @@ def test_get_local_tz_with_invalid_override(): get_local_tz("Invalid/Timezone") -@patch('mcp_server_time.server.get_localzone_name') +@patch("mcp_server_time.server.get_localzone_name") def test_get_local_tz_with_valid_iana_name(mock_get_localzone): """Test that valid IANA timezone names from tzlocal work correctly.""" mock_get_localzone.return_value = "Europe/London" @@ -484,7 +531,7 @@ def test_get_local_tz_with_valid_iana_name(mock_get_localzone): assert isinstance(result, ZoneInfo) -@patch('mcp_server_time.server.get_localzone_name') +@patch("mcp_server_time.server.get_localzone_name") def test_get_local_tz_when_none_returned(mock_get_localzone): """Test default to UTC when tzlocal returns None.""" mock_get_localzone.return_value = None @@ -492,10 +539,10 @@ def test_get_local_tz_when_none_returned(mock_get_localzone): assert str(result) == "UTC" -@patch('mcp_server_time.server.get_localzone_name') +@patch("mcp_server_time.server.get_localzone_name") def test_get_local_tz_handles_windows_timezones(mock_get_localzone): """Test that tzlocal properly handles Windows timezone names. - + Note: tzlocal should convert Windows names like 'Pacific Standard Time' to proper IANA names like 'America/Los_Angeles'. """ @@ -510,7 +557,7 @@ def test_get_local_tz_handles_windows_timezones(mock_get_localzone): "timezone_name", [ "America/New_York", - "Europe/Paris", + "Europe/Paris", "Asia/Tokyo", "Australia/Sydney", "Africa/Cairo", @@ -519,7 +566,7 @@ def test_get_local_tz_handles_windows_timezones(mock_get_localzone): "UTC", ], ) -@patch('mcp_server_time.server.get_localzone_name') +@patch("mcp_server_time.server.get_localzone_name") def test_get_local_tz_various_timezones(mock_get_localzone, timezone_name): """Test various timezone names that tzlocal might return.""" mock_get_localzone.return_value = timezone_name