Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 66 additions & 31 deletions src/time/src/mcp_server_time/server.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
63 changes: 55 additions & 8 deletions src/time/test/time_server_test.py
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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",
[
Expand Down Expand Up @@ -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"
Expand All @@ -484,18 +531,18 @@ 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
result = get_local_tz()
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'.
"""
Expand All @@ -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",
Expand All @@ -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
Expand Down
Loading