Skip to content

Commit 740b744

Browse files
committed
Clean time server implementation
1 parent 122ca1a commit 740b744

File tree

3 files changed

+259
-86
lines changed

3 files changed

+259
-86
lines changed

src/time/pyproject.toml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "mcp-server-time"
3-
version = "0.5.1"
3+
version = "0.5.1.pre3"
44
description = "A Model Context Protocol server providing tools for time queries and timezone conversions for LLMs"
55
readme = "README.md"
66
requires-python = ">=3.10"
@@ -29,4 +29,9 @@ requires = ["hatchling"]
2929
build-backend = "hatchling.build"
3030

3131
[tool.uv]
32-
dev-dependencies = ["pyright>=1.1.389", "ruff>=0.7.3"]
32+
dev-dependencies = [
33+
"freezegun>=1.5.1",
34+
"pyright>=1.1.389",
35+
"pytest>=8.3.3",
36+
"ruff>=0.7.3",
37+
]

src/time/src/mcp_server_time/server.py

Lines changed: 131 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,76 +1,113 @@
1-
from datetime import datetime
1+
from dataclasses import dataclass
2+
from datetime import datetime, timedelta
3+
from enum import Enum
24
import json
3-
from typing import Dict, Any, Optional, Sequence
5+
from typing import Sequence
46

57
import pytz
68
from tzlocal import get_localzone
79
from mcp.server import Server
810
from mcp.server.stdio import stdio_server
911
from mcp.types import Tool, TextContent, ImageContent, EmbeddedResource
1012

13+
from pydantic import BaseModel
14+
15+
16+
class TimeTools(str, Enum):
17+
GET_CURRENT_TIME = "get_current_time"
18+
CONVERT_TIME = "convert_time"
19+
20+
21+
class TimeResult(BaseModel):
22+
timezone: str
23+
datetime: str
24+
is_dst: bool
25+
26+
27+
class TimeConversionResult(BaseModel):
28+
source: TimeResult
29+
target: TimeResult
30+
time_difference: str
31+
32+
33+
class TimeConversionInput(BaseModel):
34+
source_tz: str
35+
time: str
36+
target_tz_list: list[str]
37+
1138

1239
class TimeServer:
13-
def __init__(self, local_tz_override: Optional[str] = None):
14-
self.local_tz = pytz.timezone(local_tz_override) if local_tz_override else get_localzone()
40+
def __init__(self, local_tz_override: str | None = None):
41+
self.local_tz = (
42+
pytz.timezone(local_tz_override) if local_tz_override else get_localzone()
43+
)
44+
45+
def get_current_time(self, timezone_name: str) -> TimeResult:
46+
"""Get current time in specified timezone"""
47+
try:
48+
timezone = pytz.timezone(timezone_name)
49+
except pytz.exceptions.UnknownTimeZoneError as e:
50+
raise ValueError(f"Unknown timezone: {str(e)}")
1551

16-
def get_current_time(self, timezone_name: str | None = None) -> Dict[str, Any]:
17-
"""Get current time in specified timezone or local timezone if none specified"""
18-
timezone = pytz.timezone(timezone_name) if timezone_name else self.local_tz
1952
current_time = datetime.now(timezone)
20-
21-
return {
22-
"timezone": timezone_name or str(self.local_tz),
23-
"time": current_time.strftime("%H:%M %Z"),
24-
"date": current_time.strftime("%Y-%m-%d"),
25-
"full_datetime": current_time.strftime("%Y-%m-%d %H:%M:%S %Z"),
26-
"is_dst": bool(current_time.dst())
27-
}
28-
29-
def convert_time(self, source_tz: str, time_str: str, target_tz: str) -> Dict[str, Any]:
53+
54+
return TimeResult(
55+
timezone=timezone_name,
56+
datetime=current_time.isoformat(timespec="seconds"),
57+
is_dst=bool(current_time.dst()),
58+
)
59+
60+
def convert_time(
61+
self, source_tz: str, time_str: str, target_tz: str
62+
) -> TimeConversionResult:
3063
"""Convert time between timezones"""
3164
try:
3265
source_timezone = pytz.timezone(source_tz)
66+
except pytz.exceptions.UnknownTimeZoneError as e:
67+
raise ValueError(f"Unknown source timezone: {str(e)}")
68+
69+
try:
3370
target_timezone = pytz.timezone(target_tz)
34-
35-
# Parse time
36-
hour, minute = map(int, time_str.split(":"))
37-
if not (0 <= hour <= 23 and 0 <= minute <= 59):
38-
raise ValueError
3971
except pytz.exceptions.UnknownTimeZoneError as e:
40-
raise ValueError(f"Unknown timezone: {str(e)}")
41-
except:
42-
raise ValueError("Invalid time format. Expected HH:MM (24-hour format)")
43-
44-
# Create time in source timezone
72+
raise ValueError(f"Unknown target timezone: {str(e)}")
73+
74+
try:
75+
parsed_time = datetime.strptime(time_str, "%H:%M").time()
76+
except ValueError:
77+
raise ValueError("Invalid time format. Expected HH:MM [24-hour format]")
78+
4579
now = datetime.now(source_timezone)
4680
source_time = source_timezone.localize(
47-
datetime(now.year, now.month, now.day, hour, minute)
81+
datetime(now.year, now.month, now.day, parsed_time.hour, parsed_time.minute)
4882
)
49-
50-
# Convert to target timezone
83+
5184
target_time = source_time.astimezone(target_timezone)
52-
date_changed = source_time.date() != target_time.date()
53-
54-
return {
55-
"source": {
56-
"timezone": str(source_timezone),
57-
"time": source_time.strftime("%H:%M %Z"),
58-
"date": source_time.strftime("%Y-%m-%d"),
59-
"full_datetime": source_time.strftime("%Y-%m-%d %H:%M:%S %Z")
60-
},
61-
"target": {
62-
"timezone": str(target_timezone),
63-
"time": target_time.strftime("%H:%M %Z"),
64-
"date": target_time.strftime("%Y-%m-%d"),
65-
"full_datetime": target_time.strftime("%Y-%m-%d %H:%M:%S %Z")
66-
},
67-
"time_difference": f"{(target_time.utcoffset() - source_time.utcoffset()).total_seconds() / 3600:+.1f}h",
68-
"date_changed": date_changed,
69-
"day_relation": "next day" if date_changed and target_time.date() > source_time.date() else "previous day" if date_changed else "same day"
70-
}
71-
72-
73-
async def serve(local_timezone: Optional[str] = None) -> None:
85+
hours_difference = (
86+
target_time.utcoffset() - source_time.utcoffset()
87+
).total_seconds() / 3600
88+
89+
if hours_difference.is_integer():
90+
time_diff_str = f"{hours_difference:+.1f}h"
91+
else:
92+
# For fractional hours like Nepal's UTC+5:45
93+
time_diff_str = f"{hours_difference:+.2f}".rstrip("0").rstrip(".") + "h"
94+
95+
return TimeConversionResult(
96+
source=TimeResult(
97+
timezone=source_tz,
98+
datetime=source_time.isoformat(timespec="seconds"),
99+
is_dst=bool(source_time.dst()),
100+
),
101+
target=TimeResult(
102+
timezone=target_tz,
103+
datetime=target_time.isoformat(timespec="seconds"),
104+
is_dst=bool(target_time.dst()),
105+
),
106+
time_difference=time_diff_str,
107+
)
108+
109+
110+
async def serve(local_timezone: str | None = None) -> None:
74111
server = Server("mcp-time")
75112
time_server = TimeServer(local_timezone)
76113
local_tz = str(time_server.local_tz)
@@ -80,65 +117,76 @@ async def list_tools() -> list[Tool]:
80117
"""List available time tools."""
81118
return [
82119
Tool(
83-
name="get_current_time",
84-
description=f"Get current time in a specific timezone (current system timezone is {local_tz})",
120+
name=TimeTools.GET_CURRENT_TIME.value,
121+
description=f"Get current time in a specific timezones",
85122
inputSchema={
86123
"type": "object",
87124
"properties": {
88125
"timezone": {
89126
"type": "string",
90-
"description": "IANA timezone name (e.g., 'America/New_York', 'Europe/London', etc). If not provided, uses system timezone"
127+
"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.",
91128
}
92-
}
93-
}
129+
},
130+
"required": ["timezone"],
131+
},
94132
),
95133
Tool(
96-
name="convert_time",
97-
description=f"Convert time between timezones using IANA timezone names (system timezone is {local_tz}, can be used as source or target)",
134+
name=TimeTools.CONVERT_TIME.value,
135+
description=f"Convert time between timezones",
98136
inputSchema={
99137
"type": "object",
100138
"properties": {
101139
"source_timezone": {
102140
"type": "string",
103-
"description": f"Source IANA timezone name (e.g., '{local_tz}', 'America/New_York')"
141+
"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.",
104142
},
105143
"time": {
106144
"type": "string",
107-
"description": "Time in 24-hour format (HH:MM)"
145+
"description": "Time to convert in 24-hour format (HH:MM)",
108146
},
109147
"target_timezone": {
110148
"type": "string",
111-
"description": f"Target IANA timezone name (e.g., '{local_tz}', 'Asia/Tokyo')"
112-
}
149+
"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.",
150+
},
113151
},
114-
"required": ["source_timezone", "time", "target_timezone"]
115-
}
116-
)
152+
"required": ["source_timezone", "time", "target_timezone"],
153+
},
154+
),
117155
]
118156

119157
@server.call_tool()
120-
async def call_tool(name: str, arguments: dict) -> Sequence[TextContent | ImageContent | EmbeddedResource]:
158+
async def call_tool(
159+
name: str, arguments: dict
160+
) -> Sequence[TextContent | ImageContent | EmbeddedResource]:
121161
"""Handle tool calls for time queries."""
122162
try:
123-
if name == "get_current_time":
124-
timezone = arguments.get("timezone")
125-
result = time_server.get_current_time(timezone)
126-
elif name == "convert_time":
127-
if not all(k in arguments for k in ["source_timezone", "time", "target_timezone"]):
128-
raise ValueError("Missing required arguments")
129-
130-
result = time_server.convert_time(
131-
arguments["source_timezone"],
132-
arguments["time"],
133-
arguments["target_timezone"]
134-
)
135-
else:
136-
raise ValueError(f"Unknown tool: {name}")
163+
match name:
164+
case TimeTools.GET_CURRENT_TIME.value:
165+
timezone = arguments.get("timezone")
166+
if not timezone:
167+
raise ValueError("Missing required argument: timezone")
168+
169+
result = time_server.get_current_time(timezone)
170+
171+
case TimeTools.CONVERT_TIME.value:
172+
if not all(
173+
k in arguments
174+
for k in ["source_timezone", "time", "target_timezone"]
175+
):
176+
raise ValueError("Missing required arguments")
177+
178+
result = time_server.convert_time(
179+
arguments["source_timezone"],
180+
arguments["time"],
181+
arguments["target_timezone"],
182+
)
183+
case _:
184+
raise ValueError(f"Unknown tool: {name}")
137185

138186
return [TextContent(type="text", text=json.dumps(result, indent=2))]
139-
187+
140188
except Exception as e:
141-
raise ValueError(f"Error processing time query: {str(e)}")
189+
raise ValueError(f"Error processing mcp-server-time query: {str(e)}")
142190

143191
options = server.create_initialization_options()
144192
async with stdio_server() as (read_stream, write_stream):

0 commit comments

Comments
 (0)