fix time server

This commit is contained in:
David Soria Parra
2024-11-29 11:28:45 +00:00
parent 18f6ab6ce8
commit bccd33f7a1
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"
readme = "README.md"
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"]
license = { text = "MIT" }
classifiers = [
@@ -17,8 +19,7 @@ classifiers = [
dependencies = [
"mcp>=1.0.0",
"pydantic>=2.0.0",
"pytz>=2024.2",
"tzlocal>=5.2",
"tzdata>=2024.2",
]
[project.scripts]
@@ -33,4 +34,5 @@ dev-dependencies = [
"freezegun>=1.5.1",
"pyright>=1.1.389",
"pytest>=8.3.3",
"ruff>=0.8.1",
]

View File

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

View File

@@ -1,13 +1,13 @@
from datetime import datetime
from datetime import datetime, timedelta
from enum import Enum
import json
from typing import Sequence
import pytz
from tzlocal import get_localzone
from zoneinfo import ZoneInfo
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent, ImageContent, EmbeddedResource
from mcp.shared.exceptions import McpError
from pydantic import BaseModel
@@ -34,17 +34,29 @@ class TimeConversionInput(BaseModel):
time: 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:
def get_current_time(self, timezone_name: str) -> TimeResult:
"""Get current time in specified timezone"""
try:
timezone = pytz.timezone(timezone_name)
except pytz.exceptions.UnknownTimeZoneError as e:
raise ValueError(f"Unknown timezone: {str(e)}")
timezone = get_zoneinfo(timezone_name)
current_time = datetime.now(timezone)
return TimeResult(
@@ -57,15 +69,8 @@ class TimeServer:
self, source_tz: str, time_str: str, target_tz: str
) -> TimeConversionResult:
"""Convert time between timezones"""
try:
source_timezone = pytz.timezone(source_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)}")
source_timezone = get_zoneinfo(source_tz)
target_timezone = get_zoneinfo(target_tz)
try:
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]")
now = datetime.now(source_timezone)
source_time = source_timezone.localize(
datetime(now.year, now.month, now.day, parsed_time.hour, parsed_time.minute)
source_time = datetime(
now.year,
now.month,
now.day,
parsed_time.hour,
parsed_time.minute,
tzinfo=source_timezone,
)
target_time = source_time.astimezone(target_timezone)
hours_difference = (
target_time.utcoffset() - source_time.utcoffset()
).total_seconds() / 3600
source_offset = source_time.utcoffset() or timedelta()
target_offset = target_time.utcoffset() or timedelta()
hours_difference = (target_offset - source_offset).total_seconds() / 3600
if hours_difference.is_integer():
time_diff_str = f"{hours_difference:+.1f}h"
@@ -114,7 +124,7 @@ async def serve(local_timezone: str | None = None) -> None:
return [
Tool(
name=TimeTools.GET_CURRENT_TIME.value,
description=f"Get current time in a specific timezones",
description="Get current time in a specific timezones",
inputSchema={
"type": "object",
"properties": {
@@ -128,7 +138,7 @@ async def serve(local_timezone: str | None = None) -> None:
),
Tool(
name=TimeTools.CONVERT_TIME.value,
description=f"Convert time between timezones",
description="Convert time between timezones",
inputSchema={
"type": "object",
"properties": {
@@ -179,7 +189,9 @@ async def serve(local_timezone: str | None = None) -> None:
case _:
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:
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 mcp.shared.exceptions import McpError
import pytest
from mcp_server_time.server import TimeServer, serve
from mcp_server_time.server import TimeServer
@pytest.mark.parametrize(
@@ -82,7 +82,10 @@ def test_get_current_time(test_time, timezone, expected):
def test_get_current_time_with_invalid_timezone():
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")
@@ -93,13 +96,13 @@ def test_get_current_time_with_invalid_timezone():
"invalid_tz",
"12:00",
"Europe/London",
"Unknown source timezone: 'invalid_tz'",
"Invalid timezone: 'No time zone found with key invalid_tz'",
),
(
"Europe/Warsaw",
"12:00",
"invalid_tz",
"Unknown target timezone: 'invalid_tz'",
"Invalid timezone: 'No time zone found with key invalid_tz'",
),
(
"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):
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)

44
src/time/uv.lock generated
View File

@@ -165,8 +165,7 @@ source = { editable = "." }
dependencies = [
{ name = "mcp" },
{ name = "pydantic" },
{ name = "pytz" },
{ name = "tzlocal" },
{ name = "tzdata" },
]
[package.dev-dependencies]
@@ -174,14 +173,14 @@ dev = [
{ name = "freezegun" },
{ name = "pyright" },
{ name = "pytest" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "mcp", specifier = ">=1.0.0" },
{ name = "pydantic", specifier = ">=2.0.0" },
{ name = "pytz", specifier = ">=2024.2" },
{ name = "tzlocal", specifier = ">=5.2" },
{ name = "tzdata", specifier = ">=2024.2" },
]
[package.metadata.requires-dev]
@@ -189,6 +188,7 @@ dev = [
{ name = "freezegun", specifier = ">=1.5.1" },
{ name = "pyright", specifier = ">=1.1.389" },
{ name = "pytest", specifier = ">=8.3.3" },
{ name = "ruff", specifier = ">=0.8.1" },
]
[[package]]
@@ -350,12 +350,28 @@ wheels = [
]
[[package]]
name = "pytz"
version = "2024.2"
name = "ruff"
version = "0.8.1"
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 = [
{ 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]]
@@ -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 },
]
[[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]]
name = "uvicorn"
version = "0.32.1"