fix time server

This commit is contained in:
David Soria Parra 2024-11-29 11:28:45 +00:00
commit bccd33f7a1
No known key found for this signature in database
5 changed files with 78 additions and 56 deletions

View file

@ -4,7 +4,9 @@ version = "0.5.1"
description = "A Model Context Protocol server providing tools for time queries and timezone conversions for LLMs" description = "A Model Context Protocol server providing tools for time queries and timezone conversions for LLMs"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
authors = [{ name = "Mariusz 'maledorak' Korzekwa", email = "mariusz@korzekwa.dev" }] authors = [
{ name = "Mariusz 'maledorak' Korzekwa", email = "mariusz@korzekwa.dev" },
]
keywords = ["time", "timezone", "mcp", "llm"] keywords = ["time", "timezone", "mcp", "llm"]
license = { text = "MIT" } license = { text = "MIT" }
classifiers = [ classifiers = [
@ -17,8 +19,7 @@ classifiers = [
dependencies = [ dependencies = [
"mcp>=1.0.0", "mcp>=1.0.0",
"pydantic>=2.0.0", "pydantic>=2.0.0",
"pytz>=2024.2", "tzdata>=2024.2",
"tzlocal>=5.2",
] ]
[project.scripts] [project.scripts]
@ -33,4 +34,5 @@ dev-dependencies = [
"freezegun>=1.5.1", "freezegun>=1.5.1",
"pyright>=1.1.389", "pyright>=1.1.389",
"pytest>=8.3.3", "pytest>=8.3.3",
"ruff>=0.8.1",
] ]

View file

@ -1,5 +1,6 @@
from .server import serve from .server import serve
def main(): def main():
"""MCP Time Server - Time and timezone conversion functionality for MCP""" """MCP Time Server - Time and timezone conversion functionality for MCP"""
import argparse import argparse

View file

@ -1,13 +1,13 @@
from datetime import datetime from datetime import datetime, timedelta
from enum import Enum from enum import Enum
import json import json
from typing import Sequence from typing import Sequence
import pytz from zoneinfo import ZoneInfo
from tzlocal import get_localzone
from mcp.server import Server from mcp.server import Server
from mcp.server.stdio import stdio_server from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent, ImageContent, EmbeddedResource from mcp.types import Tool, TextContent, ImageContent, EmbeddedResource
from mcp.shared.exceptions import McpError
from pydantic import BaseModel from pydantic import BaseModel
@ -34,17 +34,29 @@ class TimeConversionInput(BaseModel):
time: str time: str
target_tz_list: list[str] target_tz_list: list[str]
def get_local_tz(local_tz_override: str | None = None) -> pytz.timezone:
return pytz.timezone(local_tz_override) if local_tz_override else get_localzone() def get_local_tz(local_tz_override: str | None = None) -> ZoneInfo:
if local_tz_override:
return ZoneInfo(local_tz_override)
# Get local timezone from datetime.now()
tzinfo = datetime.now().astimezone(tz=None).tzinfo
if tzinfo is not None:
return ZoneInfo(str(tzinfo))
raise McpError("Could not determine local timezone - tzinfo is None")
def get_zoneinfo(timezone_name: str) -> ZoneInfo:
try:
return ZoneInfo(timezone_name)
except Exception as e:
raise McpError(f"Invalid timezone: {str(e)}")
class TimeServer: class TimeServer:
def get_current_time(self, timezone_name: str) -> TimeResult: def get_current_time(self, timezone_name: str) -> TimeResult:
"""Get current time in specified timezone""" """Get current time in specified timezone"""
try: timezone = get_zoneinfo(timezone_name)
timezone = pytz.timezone(timezone_name)
except pytz.exceptions.UnknownTimeZoneError as e:
raise ValueError(f"Unknown timezone: {str(e)}")
current_time = datetime.now(timezone) current_time = datetime.now(timezone)
return TimeResult( return TimeResult(
@ -57,15 +69,8 @@ class TimeServer:
self, source_tz: str, time_str: str, target_tz: str self, source_tz: str, time_str: str, target_tz: str
) -> TimeConversionResult: ) -> TimeConversionResult:
"""Convert time between timezones""" """Convert time between timezones"""
try: source_timezone = get_zoneinfo(source_tz)
source_timezone = pytz.timezone(source_tz) target_timezone = get_zoneinfo(target_tz)
except pytz.exceptions.UnknownTimeZoneError as e:
raise ValueError(f"Unknown source timezone: {str(e)}")
try:
target_timezone = pytz.timezone(target_tz)
except pytz.exceptions.UnknownTimeZoneError as e:
raise ValueError(f"Unknown target timezone: {str(e)}")
try: try:
parsed_time = datetime.strptime(time_str, "%H:%M").time() parsed_time = datetime.strptime(time_str, "%H:%M").time()
@ -73,14 +78,19 @@ class TimeServer:
raise ValueError("Invalid time format. Expected HH:MM [24-hour format]") raise ValueError("Invalid time format. Expected HH:MM [24-hour format]")
now = datetime.now(source_timezone) now = datetime.now(source_timezone)
source_time = source_timezone.localize( source_time = datetime(
datetime(now.year, now.month, now.day, parsed_time.hour, parsed_time.minute) now.year,
now.month,
now.day,
parsed_time.hour,
parsed_time.minute,
tzinfo=source_timezone,
) )
target_time = source_time.astimezone(target_timezone) target_time = source_time.astimezone(target_timezone)
hours_difference = ( source_offset = source_time.utcoffset() or timedelta()
target_time.utcoffset() - source_time.utcoffset() target_offset = target_time.utcoffset() or timedelta()
).total_seconds() / 3600 hours_difference = (target_offset - source_offset).total_seconds() / 3600
if hours_difference.is_integer(): if hours_difference.is_integer():
time_diff_str = f"{hours_difference:+.1f}h" time_diff_str = f"{hours_difference:+.1f}h"
@ -114,7 +124,7 @@ async def serve(local_timezone: str | None = None) -> None:
return [ return [
Tool( Tool(
name=TimeTools.GET_CURRENT_TIME.value, name=TimeTools.GET_CURRENT_TIME.value,
description=f"Get current time in a specific timezones", description="Get current time in a specific timezones",
inputSchema={ inputSchema={
"type": "object", "type": "object",
"properties": { "properties": {
@ -128,7 +138,7 @@ async def serve(local_timezone: str | None = None) -> None:
), ),
Tool( Tool(
name=TimeTools.CONVERT_TIME.value, name=TimeTools.CONVERT_TIME.value,
description=f"Convert time between timezones", description="Convert time between timezones",
inputSchema={ inputSchema={
"type": "object", "type": "object",
"properties": { "properties": {
@ -179,7 +189,9 @@ async def serve(local_timezone: str | None = None) -> None:
case _: case _:
raise ValueError(f"Unknown tool: {name}") raise ValueError(f"Unknown tool: {name}")
return [TextContent(type="text", text=json.dumps(result.model_dump(), indent=2))] return [
TextContent(type="text", text=json.dumps(result.model_dump(), indent=2))
]
except Exception as e: except Exception as e:
raise ValueError(f"Error processing mcp-server-time query: {str(e)}") raise ValueError(f"Error processing mcp-server-time query: {str(e)}")

View file

@ -1,9 +1,9 @@
import os
from freezegun import freeze_time from freezegun import freeze_time
from mcp.shared.exceptions import McpError
import pytest import pytest
from mcp_server_time.server import TimeServer, serve from mcp_server_time.server import TimeServer
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -82,7 +82,10 @@ def test_get_current_time(test_time, timezone, expected):
def test_get_current_time_with_invalid_timezone(): def test_get_current_time_with_invalid_timezone():
time_server = TimeServer() time_server = TimeServer()
with pytest.raises(ValueError, match=r"Unknown timezone: 'Invalid/Timezone'"): with pytest.raises(
McpError,
match=r"Invalid timezone: 'No time zone found with key Invalid/Timezone'",
):
time_server.get_current_time("Invalid/Timezone") time_server.get_current_time("Invalid/Timezone")
@ -93,13 +96,13 @@ def test_get_current_time_with_invalid_timezone():
"invalid_tz", "invalid_tz",
"12:00", "12:00",
"Europe/London", "Europe/London",
"Unknown source timezone: 'invalid_tz'", "Invalid timezone: 'No time zone found with key invalid_tz'",
), ),
( (
"Europe/Warsaw", "Europe/Warsaw",
"12:00", "12:00",
"invalid_tz", "invalid_tz",
"Unknown target timezone: 'invalid_tz'", "Invalid timezone: 'No time zone found with key invalid_tz'",
), ),
( (
"Europe/Warsaw", "Europe/Warsaw",
@ -111,7 +114,7 @@ def test_get_current_time_with_invalid_timezone():
) )
def test_convert_time_errors(source_tz, time_str, target_tz, expected_error): def test_convert_time_errors(source_tz, time_str, target_tz, expected_error):
time_server = TimeServer() time_server = TimeServer()
with pytest.raises(ValueError, match=expected_error): with pytest.raises((McpError, ValueError), match=expected_error):
time_server.convert_time(source_tz, time_str, target_tz) time_server.convert_time(source_tz, time_str, target_tz)

44
src/time/uv.lock generated
View file

@ -165,8 +165,7 @@ source = { editable = "." }
dependencies = [ dependencies = [
{ name = "mcp" }, { name = "mcp" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pytz" }, { name = "tzdata" },
{ name = "tzlocal" },
] ]
[package.dev-dependencies] [package.dev-dependencies]
@ -174,14 +173,14 @@ dev = [
{ name = "freezegun" }, { name = "freezegun" },
{ name = "pyright" }, { name = "pyright" },
{ name = "pytest" }, { name = "pytest" },
{ name = "ruff" },
] ]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "mcp", specifier = ">=1.0.0" }, { name = "mcp", specifier = ">=1.0.0" },
{ name = "pydantic", specifier = ">=2.0.0" }, { name = "pydantic", specifier = ">=2.0.0" },
{ name = "pytz", specifier = ">=2024.2" }, { name = "tzdata", specifier = ">=2024.2" },
{ name = "tzlocal", specifier = ">=5.2" },
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
@ -189,6 +188,7 @@ dev = [
{ name = "freezegun", specifier = ">=1.5.1" }, { name = "freezegun", specifier = ">=1.5.1" },
{ name = "pyright", specifier = ">=1.1.389" }, { name = "pyright", specifier = ">=1.1.389" },
{ name = "pytest", specifier = ">=8.3.3" }, { name = "pytest", specifier = ">=8.3.3" },
{ name = "ruff", specifier = ">=0.8.1" },
] ]
[[package]] [[package]]
@ -350,12 +350,28 @@ wheels = [
] ]
[[package]] [[package]]
name = "pytz" name = "ruff"
version = "2024.2" version = "0.8.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3a/31/3c70bf7603cc2dca0f19bdc53b4537a797747a58875b552c8c413d963a3f/pytz-2024.2.tar.gz", hash = "sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a", size = 319692 } sdist = { url = "https://files.pythonhosted.org/packages/95/d0/8ff5b189d125f4260f2255d143bf2fa413b69c2610c405ace7a0a8ec81ec/ruff-0.8.1.tar.gz", hash = "sha256:3583db9a6450364ed5ca3f3b4225958b24f78178908d5c4bc0f46251ccca898f", size = 3313222 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/11/c3/005fcca25ce078d2cc29fd559379817424e94885510568bc1bc53d7d5846/pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725", size = 508002 }, { url = "https://files.pythonhosted.org/packages/a2/d6/1a6314e568db88acdbb5121ed53e2c52cebf3720d3437a76f82f923bf171/ruff-0.8.1-py3-none-linux_armv6l.whl", hash = "sha256:fae0805bd514066f20309f6742f6ee7904a773eb9e6c17c45d6b1600ca65c9b5", size = 10532605 },
{ url = "https://files.pythonhosted.org/packages/89/a8/a957a8812e31facffb6a26a30be0b5b4af000a6e30c7d43a22a5232a3398/ruff-0.8.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b8a4f7385c2285c30f34b200ca5511fcc865f17578383db154e098150ce0a087", size = 10278243 },
{ url = "https://files.pythonhosted.org/packages/a8/23/9db40fa19c453fabf94f7a35c61c58f20e8200b4734a20839515a19da790/ruff-0.8.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cd054486da0c53e41e0086e1730eb77d1f698154f910e0cd9e0d64274979a209", size = 9917739 },
{ url = "https://files.pythonhosted.org/packages/e2/a0/6ee2d949835d5701d832fc5acd05c0bfdad5e89cfdd074a171411f5ccad5/ruff-0.8.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2029b8c22da147c50ae577e621a5bfbc5d1fed75d86af53643d7a7aee1d23871", size = 10779153 },
{ url = "https://files.pythonhosted.org/packages/7a/25/9c11dca9404ef1eb24833f780146236131a3c7941de394bc356912ef1041/ruff-0.8.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2666520828dee7dfc7e47ee4ea0d928f40de72056d929a7c5292d95071d881d1", size = 10304387 },
{ url = "https://files.pythonhosted.org/packages/c8/b9/84c323780db1b06feae603a707d82dbbd85955c8c917738571c65d7d5aff/ruff-0.8.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:333c57013ef8c97a53892aa56042831c372e0bb1785ab7026187b7abd0135ad5", size = 11360351 },
{ url = "https://files.pythonhosted.org/packages/6b/e1/9d4bbb2ace7aad14ded20e4674a48cda5b902aed7a1b14e6b028067060c4/ruff-0.8.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:288326162804f34088ac007139488dcb43de590a5ccfec3166396530b58fb89d", size = 12022879 },
{ url = "https://files.pythonhosted.org/packages/75/28/752ff6120c0e7f9981bc4bc275d540c7f36db1379ba9db9142f69c88db21/ruff-0.8.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b12c39b9448632284561cbf4191aa1b005882acbc81900ffa9f9f471c8ff7e26", size = 11610354 },
{ url = "https://files.pythonhosted.org/packages/ba/8c/967b61c2cc8ebd1df877607fbe462bc1e1220b4a30ae3352648aec8c24bd/ruff-0.8.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:364e6674450cbac8e998f7b30639040c99d81dfb5bbc6dfad69bc7a8f916b3d1", size = 12813976 },
{ url = "https://files.pythonhosted.org/packages/7f/29/e059f945d6bd2d90213387b8c360187f2fefc989ddcee6bbf3c241329b92/ruff-0.8.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b22346f845fec132aa39cd29acb94451d030c10874408dbf776af3aaeb53284c", size = 11154564 },
{ url = "https://files.pythonhosted.org/packages/55/47/cbd05e5a62f3fb4c072bc65c1e8fd709924cad1c7ec60a1000d1e4ee8307/ruff-0.8.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b2f2f7a7e7648a2bfe6ead4e0a16745db956da0e3a231ad443d2a66a105c04fa", size = 10760604 },
{ url = "https://files.pythonhosted.org/packages/bb/ee/4c3981c47147c72647a198a94202633130cfda0fc95cd863a553b6f65c6a/ruff-0.8.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:adf314fc458374c25c5c4a4a9270c3e8a6a807b1bec018cfa2813d6546215540", size = 10391071 },
{ url = "https://files.pythonhosted.org/packages/6b/e6/083eb61300214590b188616a8ac6ae1ef5730a0974240fb4bec9c17de78b/ruff-0.8.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a885d68342a231b5ba4d30b8c6e1b1ee3a65cf37e3d29b3c74069cdf1ee1e3c9", size = 10896657 },
{ url = "https://files.pythonhosted.org/packages/77/bd/aacdb8285d10f1b943dbeb818968efca35459afc29f66ae3bd4596fbf954/ruff-0.8.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d2c16e3508c8cc73e96aa5127d0df8913d2290098f776416a4b157657bee44c5", size = 11228362 },
{ url = "https://files.pythonhosted.org/packages/39/72/fcb7ad41947f38b4eaa702aca0a361af0e9c2bf671d7fd964480670c297e/ruff-0.8.1-py3-none-win32.whl", hash = "sha256:93335cd7c0eaedb44882d75a7acb7df4b77cd7cd0d2255c93b28791716e81790", size = 8803476 },
{ url = "https://files.pythonhosted.org/packages/e4/ea/cae9aeb0f4822c44651c8407baacdb2e5b4dcd7b31a84e1c5df33aa2cc20/ruff-0.8.1-py3-none-win_amd64.whl", hash = "sha256:2954cdbe8dfd8ab359d4a30cd971b589d335a44d444b6ca2cb3d1da21b75e4b6", size = 9614463 },
{ url = "https://files.pythonhosted.org/packages/eb/76/fbb4bd23dfb48fa7758d35b744413b650a9fd2ddd93bca77e30376864414/ruff-0.8.1-py3-none-win_arm64.whl", hash = "sha256:55873cc1a473e5ac129d15eccb3c008c096b94809d693fc7053f588b67822737", size = 8959621 },
] ]
[[package]] [[package]]
@ -459,18 +475,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a6/ab/7e5f53c3b9d14972843a647d8d7a853969a58aecc7559cb3267302c94774/tzdata-2024.2-py2.py3-none-any.whl", hash = "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd", size = 346586 }, { url = "https://files.pythonhosted.org/packages/a6/ab/7e5f53c3b9d14972843a647d8d7a853969a58aecc7559cb3267302c94774/tzdata-2024.2-py2.py3-none-any.whl", hash = "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd", size = 346586 },
] ]
[[package]]
name = "tzlocal"
version = "5.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzdata", marker = "platform_system == 'Windows'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/04/d3/c19d65ae67636fe63953b20c2e4a8ced4497ea232c43ff8d01db16de8dc0/tzlocal-5.2.tar.gz", hash = "sha256:8d399205578f1a9342816409cc1e46a93ebd5755e39ea2d85334bea911bf0e6e", size = 30201 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/97/3f/c4c51c55ff8487f2e6d0e618dba917e3c3ee2caae6cf0fbb59c9b1876f2e/tzlocal-5.2-py3-none-any.whl", hash = "sha256:49816ef2fe65ea8ac19d19aa7a1ae0551c834303d5014c6d5a62e4cbda8047b8", size = 17859 },
]
[[package]] [[package]]
name = "uvicorn" name = "uvicorn"
version = "0.32.1" version = "0.32.1"