aiomcp-server-time 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: aiomcp-server-time
3
+ Version: 0.0.1
4
+ Summary: Time and timezone conversion MCP server powered by aiomcp
5
+ License: MIT
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: aiomcp
9
+ Requires-Dist: pydantic
10
+ Requires-Dist: tzlocal
11
+ Provides-Extra: test
12
+ Requires-Dist: pytest; extra == "test"
13
+ Requires-Dist: pytest-asyncio; extra == "test"
@@ -0,0 +1,29 @@
1
+ def main() -> None:
2
+ import argparse
3
+ import asyncio
4
+
5
+ from aiomcp_server_time.server import host_http, host_stdio
6
+
7
+ parser = argparse.ArgumentParser(
8
+ description="Time and timezone conversion MCP server."
9
+ )
10
+ parser.add_argument(
11
+ "--http",
12
+ metavar="URL",
13
+ help="Host an HTTP MCP endpoint at URL instead of using stdio.",
14
+ )
15
+ parser.add_argument("--local-timezone", type=str, help="Override local timezone")
16
+
17
+ args = parser.parse_args()
18
+
19
+ try:
20
+ if args.http:
21
+ asyncio.run(host_http(args.http, local_timezone=args.local_timezone))
22
+ else:
23
+ asyncio.run(host_stdio(local_timezone=args.local_timezone))
24
+ except KeyboardInterrupt:
25
+ pass
26
+
27
+
28
+ if __name__ == "__main__":
29
+ main()
@@ -0,0 +1,165 @@
1
+ from datetime import datetime, timedelta
2
+ from typing import Annotated, Any
3
+
4
+ from aiomcp import McpServer
5
+ from aiomcp.transports.stdio import McpStdioServerTransport
6
+ from pydantic import BaseModel, Field
7
+ from tzlocal import get_localzone_name
8
+ from zoneinfo import ZoneInfo
9
+
10
+
11
+ class TimeResult(BaseModel):
12
+ timezone: str
13
+ datetime: str
14
+ day_of_week: str
15
+ is_dst: bool
16
+
17
+
18
+ class TimeConversionResult(BaseModel):
19
+ source: TimeResult
20
+ target: TimeResult
21
+ time_difference: str
22
+
23
+
24
+ TIME_TOOL_ANNOTATIONS = {
25
+ "readOnlyHint": True,
26
+ "destructiveHint": False,
27
+ "idempotentHint": True,
28
+ "openWorldHint": False,
29
+ }
30
+
31
+
32
+ SERVER_NAME = "aiomcp-time-server"
33
+
34
+
35
+ def get_timezone(timezone_name: str) -> ZoneInfo:
36
+ try:
37
+ return ZoneInfo(timezone_name)
38
+ except Exception as exc:
39
+ raise ValueError(f"Invalid timezone: {exc}") from exc
40
+
41
+
42
+ def get_local_timezone(local_timezone_override: str | None = None) -> ZoneInfo:
43
+ if local_timezone_override:
44
+ return get_timezone(local_timezone_override)
45
+
46
+ local_timezone_name = get_localzone_name()
47
+ if local_timezone_name is not None:
48
+ return get_timezone(local_timezone_name)
49
+ return ZoneInfo("UTC")
50
+
51
+
52
+ def get_current_time(
53
+ timezone: Annotated[
54
+ str,
55
+ Field(
56
+ description="IANA timezone name (e.g., 'America/New_York', 'Europe/London'). Use '{CURRENT_TIMEZONE}' as local timezone if no timezone provided by the user."
57
+ ),
58
+ ],
59
+ ) -> Any:
60
+ resolved_timezone = get_timezone(timezone)
61
+ current_time = datetime.now(resolved_timezone)
62
+
63
+ return TimeResult(
64
+ timezone=timezone,
65
+ datetime=current_time.isoformat(timespec="seconds"),
66
+ day_of_week=current_time.strftime("%A"),
67
+ is_dst=bool(current_time.dst()),
68
+ )
69
+
70
+
71
+ def convert_time(
72
+ source_timezone: Annotated[
73
+ str,
74
+ Field(
75
+ description="Source IANA timezone name (e.g., 'America/New_York', 'Europe/London'). Use '{CURRENT_TIMEZONE}' as local timezone if no source timezone provided by the user."
76
+ ),
77
+ ],
78
+ time: Annotated[
79
+ str,
80
+ Field(description="Time to convert in 24-hour format (HH:MM)"),
81
+ ],
82
+ target_timezone: Annotated[
83
+ str,
84
+ Field(
85
+ description="Target IANA timezone name (e.g., 'Asia/Tokyo', 'America/San_Francisco'). Use '{CURRENT_TIMEZONE}' as local timezone if no target timezone provided by the user."
86
+ ),
87
+ ],
88
+ ) -> Any:
89
+ resolved_source_timezone = get_timezone(source_timezone)
90
+ resolved_target_timezone = get_timezone(target_timezone)
91
+
92
+ try:
93
+ parsed_time = datetime.strptime(time, "%H:%M").time()
94
+ except ValueError as exc:
95
+ raise ValueError(
96
+ "Invalid time format. Expected HH:MM [24-hour format]"
97
+ ) from exc
98
+
99
+ now = datetime.now(resolved_source_timezone)
100
+ source_time = datetime(
101
+ now.year,
102
+ now.month,
103
+ now.day,
104
+ parsed_time.hour,
105
+ parsed_time.minute,
106
+ tzinfo=resolved_source_timezone,
107
+ )
108
+ target_time = source_time.astimezone(resolved_target_timezone)
109
+
110
+ source_offset = source_time.utcoffset() or timedelta()
111
+ target_offset = target_time.utcoffset() or timedelta()
112
+ hours_difference = (target_offset - source_offset).total_seconds() / 3600
113
+ if hours_difference.is_integer():
114
+ time_difference = f"{hours_difference:+.1f}h"
115
+ else:
116
+ time_difference = f"{hours_difference:+.2f}".rstrip("0").rstrip(".") + "h"
117
+
118
+ return TimeConversionResult(
119
+ source=TimeResult(
120
+ timezone=source_timezone,
121
+ datetime=source_time.isoformat(timespec="seconds"),
122
+ day_of_week=source_time.strftime("%A"),
123
+ is_dst=bool(source_time.dst()),
124
+ ),
125
+ target=TimeResult(
126
+ timezone=target_timezone,
127
+ datetime=target_time.isoformat(timespec="seconds"),
128
+ day_of_week=target_time.strftime("%A"),
129
+ is_dst=bool(target_time.dst()),
130
+ ),
131
+ time_difference=time_difference,
132
+ )
133
+
134
+
135
+ async def register_tools(server: McpServer, local_timezone: str | None = None) -> None:
136
+ resolved_local_timezone = str(get_local_timezone(local_timezone))
137
+ format_map = {"CURRENT_TIMEZONE": resolved_local_timezone}
138
+
139
+ await server.register_tool(
140
+ func=get_current_time,
141
+ description="Get current time in a specific timezones",
142
+ annotations=TIME_TOOL_ANNOTATIONS,
143
+ format_map=format_map,
144
+ )
145
+ await server.register_tool(
146
+ func=convert_time,
147
+ description="Convert time between timezones",
148
+ annotations=TIME_TOOL_ANNOTATIONS,
149
+ format_map=format_map,
150
+ )
151
+
152
+
153
+ async def host_stdio(local_timezone: str | None = None) -> None:
154
+ server = McpServer(SERVER_NAME)
155
+ await register_tools(server, local_timezone=local_timezone)
156
+
157
+ transport = McpStdioServerTransport()
158
+ await server.host(transport)
159
+
160
+
161
+ async def host_http(url: str, local_timezone: str | None = None) -> None:
162
+ server = McpServer(SERVER_NAME)
163
+ await register_tools(server, local_timezone=local_timezone)
164
+
165
+ await server.host(url)
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: aiomcp-server-time
3
+ Version: 0.0.1
4
+ Summary: Time and timezone conversion MCP server powered by aiomcp
5
+ License: MIT
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: aiomcp
9
+ Requires-Dist: pydantic
10
+ Requires-Dist: tzlocal
11
+ Provides-Extra: test
12
+ Requires-Dist: pytest; extra == "test"
13
+ Requires-Dist: pytest-asyncio; extra == "test"
@@ -0,0 +1,10 @@
1
+ pyproject.toml
2
+ aiomcp_server_time/__main__.py
3
+ aiomcp_server_time/server.py
4
+ aiomcp_server_time.egg-info/PKG-INFO
5
+ aiomcp_server_time.egg-info/SOURCES.txt
6
+ aiomcp_server_time.egg-info/dependency_links.txt
7
+ aiomcp_server_time.egg-info/entry_points.txt
8
+ aiomcp_server_time.egg-info/requires.txt
9
+ aiomcp_server_time.egg-info/top_level.txt
10
+ tests/test_time_server.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ aiomcp-server-time = aiomcp_server_time.__main__:main
@@ -0,0 +1,7 @@
1
+ aiomcp
2
+ pydantic
3
+ tzlocal
4
+
5
+ [test]
6
+ pytest
7
+ pytest-asyncio
@@ -0,0 +1 @@
1
+ aiomcp_server_time
@@ -0,0 +1,28 @@
1
+ [project]
2
+ name = "aiomcp-server-time"
3
+ description = "Time and timezone conversion MCP server powered by aiomcp"
4
+ version = "0.0.1"
5
+ requires-python = ">=3.11"
6
+ readme = "README.md"
7
+ license = { text = "MIT" }
8
+ dependencies = [
9
+ "aiomcp",
10
+ "pydantic",
11
+ "tzlocal",
12
+ ]
13
+
14
+ [project.optional-dependencies]
15
+ test = [
16
+ "pytest",
17
+ "pytest-asyncio",
18
+ ]
19
+
20
+ [project.scripts]
21
+ aiomcp-server-time = "aiomcp_server_time.__main__:main"
22
+
23
+ [build-system]
24
+ requires = ["setuptools"]
25
+ build-backend = "setuptools.build_meta"
26
+
27
+ [tool.setuptools.packages.find]
28
+ include = ["aiomcp_server_time*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,223 @@
1
+ import json
2
+ import re
3
+ from datetime import datetime
4
+ from unittest.mock import patch
5
+
6
+ import pytest
7
+
8
+ from aiomcp import McpClient, McpServer
9
+ from aiomcp_server_time.server import (
10
+ SERVER_NAME,
11
+ convert_time,
12
+ get_current_time,
13
+ get_local_timezone,
14
+ register_tools,
15
+ )
16
+
17
+
18
+ def freeze_time(test_time: str):
19
+ frozen_time = datetime.fromisoformat(test_time)
20
+
21
+ class FrozenDateTime(datetime):
22
+ @classmethod
23
+ def now(cls, timezone=None):
24
+ if timezone is None:
25
+ return frozen_time.replace(tzinfo=None)
26
+ return frozen_time.astimezone(timezone)
27
+
28
+ return patch("aiomcp_server_time.server.datetime", FrozenDateTime)
29
+
30
+
31
+ async def create_test_server(local_timezone: str | None = None) -> McpServer:
32
+ server = McpServer(SERVER_NAME)
33
+ await register_tools(server, local_timezone=local_timezone)
34
+ return server
35
+
36
+
37
+ @pytest.mark.parametrize(
38
+ ("test_time", "timezone", "expected"),
39
+ [
40
+ (
41
+ "2024-01-01 12:00:00+00:00",
42
+ "Europe/Warsaw",
43
+ {
44
+ "timezone": "Europe/Warsaw",
45
+ "datetime": "2024-01-01T13:00:00+01:00",
46
+ "day_of_week": "Monday",
47
+ "is_dst": False,
48
+ },
49
+ ),
50
+ (
51
+ "2024-03-31 12:00:00+00:00",
52
+ "America/New_York",
53
+ {
54
+ "timezone": "America/New_York",
55
+ "datetime": "2024-03-31T08:00:00-04:00",
56
+ "day_of_week": "Sunday",
57
+ "is_dst": True,
58
+ },
59
+ ),
60
+ ],
61
+ )
62
+ def test_get_current_time(test_time, timezone, expected):
63
+ with freeze_time(test_time):
64
+ result = get_current_time(timezone)
65
+
66
+ assert result.model_dump() == expected
67
+
68
+
69
+ @pytest.mark.parametrize(
70
+ ("test_time", "source_timezone", "time_str", "target_timezone", "expected"),
71
+ [
72
+ (
73
+ "2024-01-01 00:00:00+00:00",
74
+ "Europe/London",
75
+ "12:00",
76
+ "Europe/Warsaw",
77
+ {
78
+ "source": {
79
+ "timezone": "Europe/London",
80
+ "datetime": "2024-01-01T12:00:00+00:00",
81
+ "day_of_week": "Monday",
82
+ "is_dst": False,
83
+ },
84
+ "target": {
85
+ "timezone": "Europe/Warsaw",
86
+ "datetime": "2024-01-01T13:00:00+01:00",
87
+ "day_of_week": "Monday",
88
+ "is_dst": False,
89
+ },
90
+ "time_difference": "+1.0h",
91
+ },
92
+ ),
93
+ (
94
+ "2024-01-01 00:00:00+00:00",
95
+ "Europe/Warsaw",
96
+ "12:00",
97
+ "Asia/Kathmandu",
98
+ {
99
+ "source": {
100
+ "timezone": "Europe/Warsaw",
101
+ "datetime": "2024-01-01T12:00:00+01:00",
102
+ "day_of_week": "Monday",
103
+ "is_dst": False,
104
+ },
105
+ "target": {
106
+ "timezone": "Asia/Kathmandu",
107
+ "datetime": "2024-01-01T16:45:00+05:45",
108
+ "day_of_week": "Monday",
109
+ "is_dst": False,
110
+ },
111
+ "time_difference": "+4.75h",
112
+ },
113
+ ),
114
+ ],
115
+ )
116
+ def test_convert_time(test_time, source_timezone, time_str, target_timezone, expected):
117
+ with freeze_time(test_time):
118
+ result = convert_time(source_timezone, time_str, target_timezone)
119
+
120
+ assert result.model_dump() == expected
121
+
122
+
123
+ @pytest.mark.parametrize(
124
+ ("source_timezone", "time_str", "target_timezone", "expected_error"),
125
+ [
126
+ (
127
+ "invalid_timezone",
128
+ "12:00",
129
+ "Europe/London",
130
+ "Invalid timezone: 'No time zone found with key invalid_timezone'",
131
+ ),
132
+ (
133
+ "Europe/Warsaw",
134
+ "25:00",
135
+ "Europe/London",
136
+ "Invalid time format. Expected HH:MM [24-hour format]",
137
+ ),
138
+ ],
139
+ )
140
+ def test_convert_time_errors(
141
+ source_timezone, time_str, target_timezone, expected_error
142
+ ):
143
+ with pytest.raises(ValueError, match=re.escape(expected_error)):
144
+ convert_time(source_timezone, time_str, target_timezone)
145
+
146
+
147
+ def test_get_local_timezone_with_override():
148
+ assert str(get_local_timezone("America/New_York")) == "America/New_York"
149
+
150
+
151
+ @patch("aiomcp_server_time.server.get_localzone_name")
152
+ def test_get_local_timezone_defaults_to_utc(mock_get_localzone):
153
+ mock_get_localzone.return_value = None
154
+
155
+ assert str(get_local_timezone()) == "UTC"
156
+
157
+
158
+ @pytest.mark.asyncio
159
+ async def test_aiomcp_server_exposes_reference_tools():
160
+ server = await create_test_server("Europe/Warsaw")
161
+ tools = await server.list_tools()
162
+ tool_names = {tool.name for tool in tools}
163
+
164
+ assert tool_names == {"get_current_time", "convert_time"}
165
+
166
+ current_time_tool = next(tool for tool in tools if tool.name == "get_current_time")
167
+ assert current_time_tool.description == "Get current time in a specific timezones"
168
+ assert current_time_tool.outputSchema is None
169
+ current_time_input_schema = current_time_tool.inputSchema.model_dump(
170
+ exclude_none=True
171
+ )
172
+ assert current_time_input_schema["properties"]["timezone"]["description"] == (
173
+ "IANA timezone name (e.g., 'America/New_York', 'Europe/London'). "
174
+ "Use 'Europe/Warsaw' as local timezone if no timezone provided by the user."
175
+ )
176
+
177
+ convert_tool = next(tool for tool in tools if tool.name == "convert_time")
178
+ assert convert_tool.outputSchema is None
179
+ input_schema = convert_tool.inputSchema.model_dump(exclude_none=True)
180
+ annotations = convert_tool.annotations.model_dump(exclude_none=True)
181
+ assert input_schema["required"] == ["source_timezone", "time", "target_timezone"]
182
+ assert input_schema["properties"]["source_timezone"]["description"] == (
183
+ "Source IANA timezone name (e.g., 'America/New_York', 'Europe/London'). "
184
+ "Use 'Europe/Warsaw' as local timezone if no source timezone provided by the user."
185
+ )
186
+ assert input_schema["properties"]["time"]["description"] == (
187
+ "Time to convert in 24-hour format (HH:MM)"
188
+ )
189
+ assert input_schema["properties"]["target_timezone"]["description"] == (
190
+ "Target IANA timezone name (e.g., 'Asia/Tokyo', 'America/San_Francisco'). "
191
+ "Use 'Europe/Warsaw' as local timezone if no target timezone provided by the user."
192
+ )
193
+ assert annotations == {
194
+ "readOnlyHint": True,
195
+ "destructiveHint": False,
196
+ "idempotentHint": True,
197
+ "openWorldHint": False,
198
+ }
199
+
200
+
201
+ @pytest.mark.asyncio
202
+ async def test_aiomcp_client_can_call_convert_time():
203
+ server = await create_test_server()
204
+ client = McpClient()
205
+ await client.initialize(server)
206
+
207
+ try:
208
+ with freeze_time("2024-01-01 00:00:00+00:00"):
209
+ result = await client.invoke(
210
+ "convert_time",
211
+ {
212
+ "source_timezone": "Europe/Warsaw",
213
+ "time": "12:00",
214
+ "target_timezone": "Asia/Kathmandu",
215
+ },
216
+ )
217
+ finally:
218
+ await client.close()
219
+
220
+ assert result[0]["type"] == "text"
221
+ payload = json.loads(result[0]["text"])
222
+ assert payload["target"]["datetime"] == "2024-01-01T16:45:00+05:45"
223
+ assert payload["time_difference"] == "+4.75h"