From b7ca6915737ef2b30e8fbdc79707101fae29c5f5 Mon Sep 17 00:00:00 2001 From: Mariusz Korzekwa Date: Tue, 26 Nov 2024 01:02:33 +0100 Subject: [PATCH 1/5] Add initial time server --- src/time/.python-version | 1 + src/time/README.md | 226 ++++++++++ src/time/pyproject.toml | 32 ++ src/time/src/mcp_server_time/__init__.py | 18 + src/time/src/mcp_server_time/__main__.py | 3 + .../src/mcp_server_time/server-resources.py | 167 ++++++++ src/time/src/mcp_server_time/server.py | 145 +++++++ src/time/uv.lock | 393 ++++++++++++++++++ 8 files changed, 985 insertions(+) create mode 100644 src/time/.python-version create mode 100644 src/time/README.md create mode 100644 src/time/pyproject.toml create mode 100644 src/time/src/mcp_server_time/__init__.py create mode 100644 src/time/src/mcp_server_time/__main__.py create mode 100644 src/time/src/mcp_server_time/server-resources.py create mode 100644 src/time/src/mcp_server_time/server.py create mode 100644 src/time/uv.lock diff --git a/src/time/.python-version b/src/time/.python-version new file mode 100644 index 00000000..c8cfe395 --- /dev/null +++ b/src/time/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/src/time/README.md b/src/time/README.md new file mode 100644 index 00000000..109ecddc --- /dev/null +++ b/src/time/README.md @@ -0,0 +1,226 @@ +# Time MCP Server + +A Model Context Protocol server that provides time and timezone conversion capabilities. This server enables LLMs to get current time information and perform timezone conversions using IANA timezone names, with automatic system timezone detection. + +### Available Tools + +- `get_current_time` - Get current time in a specific timezone or system timezone. + - Optional argument: `timezone` (string): IANA timezone name (e.g., 'America/New_York', 'Europe/London') + - If timezone is not provided, returns time in system timezone + +- `convert_time` - Convert time between timezones. + - Required arguments: + - `source_timezone` (string): Source IANA timezone name + - `time` (string): Time in 24-hour format (HH:MM) + - `target_timezone` (string): Target IANA timezone name + +## Installation + +### Using uv (recommended) + +When using [`uv`](https://docs.astral.sh/uv/) no specific installation is needed. We will +use [`uvx`](https://docs.astral.sh/uv/guides/tools/) to directly run *mcp-server-time*. + +### Using PIP + +Alternatively you can install `mcp-server-time` via pip: + +```bash +pip install mcp-server-time +``` + +After installation, you can run it as a script using: + +```bash +python -m mcp_server_time +``` + +## Configuration + +### Configure for Claude.app + +Add to your Claude settings: + +
+Using uvx + +```json +"mcpServers": { + "time": { + "command": "uvx", + "args": ["mcp-server-time"] + } +} +``` +
+ +
+Using pip installation + +```json +"mcpServers": { + "time": { + "command": "python", + "args": ["-m", "mcp_server_time"] + } +} +``` +
+ +### Configure for Zed + +Add to your Zed settings.json: + +
+Using uvx + +```json +"context_servers": [ + "mcp-server-time": { + "command": "uvx", + "args": ["mcp-server-time"] + } +], +``` +
+ +
+Using pip installation + +```json +"context_servers": { + "mcp-server-time": { + "command": "python", + "args": ["-m", "mcp_server_time"] + } +}, +``` +
+ +### Customization - System Timezone + +By default, the server automatically detects your system's timezone. You can override this by adding the argument `--local-timezone` to the `args` list in the configuration. + +Example: +```json +{ + "command": "python", + "args": ["-m", "mcp_server_time", "--local-timezone=America/New_York"] +} +``` + +## Example Interactions + +1. Get current time (using system timezone): +```json +{ + "name": "get_current_time", + "arguments": {} +} +``` +Response: +```json +{ + "timezone": "Europe/London", + "time": "14:30 BST", + "date": "2024-11-25", + "full_datetime": "2024-11-25 14:30:00 BST", + "is_dst": true +} +``` + +2. Get current time in specific timezone: +```json +{ + "name": "get_current_time", + "arguments": { + "timezone": "America/New_York" + } +} +``` +Response: +```json +{ + "timezone": "America/New_York", + "time": "09:30 EDT", + "date": "2024-11-25", + "full_datetime": "2024-11-25 09:30:00 EDT", + "is_dst": true +} +``` + +3. Convert time between timezones: +```json +{ + "name": "convert_time", + "arguments": { + "source_timezone": "America/New_York", + "time": "16:30", + "target_timezone": "Asia/Tokyo" + } +} +``` +Response: +```json +{ + "source": { + "timezone": "America/New_York", + "time": "16:30 EDT", + "date": "2024-11-25" + }, + "target": { + "timezone": "Asia/Tokyo", + "time": "05:30 JST", + "date": "2024-11-26" + }, + "time_difference": "+13.0h", + "date_changed": true, + "day_relation": "next day" +} +``` + +## Tips for Using IANA Timezone Names + +Common timezone formats: +- North America: `America/New_York`, `America/Los_Angeles`, `America/Chicago` +- Europe: `Europe/London`, `Europe/Paris`, `Europe/Berlin` +- Asia: `Asia/Tokyo`, `Asia/Shanghai`, `Asia/Dubai` +- Pacific: `Pacific/Auckland`, `Pacific/Honolulu` +- Australia: `Australia/Sydney`, `Australia/Melbourne` + +The server will automatically detect and use your system timezone if no specific timezone is provided. + +## Debugging + +You can use the MCP inspector to debug the server. For uvx installations: + +```bash +npx @modelcontextprotocol/inspector uvx mcp-server-time +``` + +Or if you've installed the package in a specific directory or are developing on it: + +```bash +cd path/to/servers/src/time +npx @modelcontextprotocol/inspector uv run mcp-server-time +``` + +## Examples of Questions for Claude + +1. "What time is it now?" (will use system timezone) +2. "What time is it in Tokyo?" +3. "When it's 4 PM in New York, what time is it in London?" +4. "Convert 9:30 AM Tokyo time to New York time" + +## Contributing + +We encourage contributions to help expand and improve mcp-server-time. Whether you want to add new time-related tools, enhance existing functionality, or improve documentation, your input is valuable. + +For examples of other MCP servers and implementation patterns, see: +https://github.com/modelcontextprotocol/servers + +Pull requests are welcome! Feel free to contribute new ideas, bug fixes, or enhancements to make mcp-server-time even more powerful and useful. + +## License + +mcp-server-time is licensed under the MIT License. This means you are free to use, modify, and distribute the software, subject to the terms and conditions of the MIT License. For more details, please see the LICENSE file in the project repository. diff --git a/src/time/pyproject.toml b/src/time/pyproject.toml new file mode 100644 index 00000000..f2afd6a3 --- /dev/null +++ b/src/time/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = "mcp-server-time" +version = "0.1.0" +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 = "Your Name" }] +keywords = ["time", "timezone", "mcp", "llm"] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", +] +dependencies = [ + "mcp>=1.0.0", + "pydantic>=2.0.0", + "pytz>=2024.2", + "tzlocal>=5.2", +] + +[project.scripts] +mcp-server-time = "mcp_server_time:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.uv] +dev-dependencies = ["pyright>=1.1.389", "ruff>=0.7.3"] diff --git a/src/time/src/mcp_server_time/__init__.py b/src/time/src/mcp_server_time/__init__.py new file mode 100644 index 00000000..4817573b --- /dev/null +++ b/src/time/src/mcp_server_time/__init__.py @@ -0,0 +1,18 @@ +from .server import serve + +def main(): + """MCP Time Server - Time and timezone conversion functionality for MCP""" + import argparse + import asyncio + + parser = argparse.ArgumentParser( + description="give a model the ability to handle time queries and timezone conversions" + ) + parser.add_argument("--local-timezone", type=str, help="Override local timezone") + + args = parser.parse_args() + asyncio.run(serve(args.local_timezone)) + + +if __name__ == "__main__": + main() diff --git a/src/time/src/mcp_server_time/__main__.py b/src/time/src/mcp_server_time/__main__.py new file mode 100644 index 00000000..27adff28 --- /dev/null +++ b/src/time/src/mcp_server_time/__main__.py @@ -0,0 +1,3 @@ +from mcp_server_time import main + +main() diff --git a/src/time/src/mcp_server_time/server-resources.py b/src/time/src/mcp_server_time/server-resources.py new file mode 100644 index 00000000..23ae45e7 --- /dev/null +++ b/src/time/src/mcp_server_time/server-resources.py @@ -0,0 +1,167 @@ +# server.py + +from datetime import datetime +from typing import Dict, Any, Tuple, Optional +import pytz +from tzlocal import get_localzone +from mcp.server import Server +from mcp.server.stdio import stdio_server +from mcp.types import Resource, ResourceTemplate, ListResourcesResult, ListResourceTemplatesResult +from pydantic import AnyUrl +import json + + +class TimeServer: + def __init__(self, local_tz_override: Optional[str] = None): + self.local_tz = pytz.timezone(local_tz_override) if local_tz_override else get_localzone() + + def get_current_time(self, timezone_name: str | None = None) -> Dict[str, Any]: + """Get current time in specified timezone or local timezone if none specified""" + timezone = pytz.timezone(timezone_name) if timezone_name else self.local_tz + current_time = datetime.now(timezone) + + return { + "timezone": timezone_name or str(self.local_tz), + "time": current_time.strftime("%H:%M %Z"), + "date": current_time.strftime("%Y-%m-%d"), + "full_datetime": current_time.strftime("%Y-%m-%d %H:%M:%S %Z"), + "is_dst": bool(current_time.dst()) + } + + def parse_time_str(self, time_str: str) -> Tuple[int, int]: + """Parse time string in format HH:MM""" + try: + hour, minute = map(int, time_str.split(":")) + if not (0 <= hour <= 23 and 0 <= minute <= 59): + raise ValueError + return hour, minute + except: + raise ValueError("Invalid time format. Expected HH:MM (24-hour format)") + + def get_timezone(self, tz_name: str) -> pytz.timezone: + """Get timezone object, handling 'local' keyword""" + if tz_name.lower() == 'local': + return self.local_tz + try: + return pytz.timezone(tz_name) + except pytz.exceptions.UnknownTimeZoneError: + raise ValueError(f"Unknown timezone: {tz_name}") + + def convert_time(self, source_tz: str, time_str: str, target_tz: str) -> Dict[str, Any]: + """Convert time between timezones""" + source_timezone = self.get_timezone(source_tz) + target_timezone = self.get_timezone(target_tz) + + hour, minute = self.parse_time_str(time_str) + + now = datetime.now(source_timezone) + source_time = source_timezone.localize( + datetime(now.year, now.month, now.day, hour, minute) + ) + + target_time = source_time.astimezone(target_timezone) + date_changed = source_time.date() != target_time.date() + + return { + "source": { + "timezone": str(source_timezone), + "time": source_time.strftime("%H:%M %Z"), + "date": source_time.strftime("%Y-%m-%d"), + "full_datetime": source_time.strftime("%Y-%m-%d %H:%M:%S %Z") + }, + "target": { + "timezone": str(target_timezone), + "time": target_time.strftime("%H:%M %Z"), + "date": target_time.strftime("%Y-%m-%d"), + "full_datetime": target_time.strftime("%Y-%m-%d %H:%M:%S %Z") + }, + "time_difference": f"{(target_time.utcoffset() - source_time.utcoffset()).total_seconds() / 3600:+.1f}h", + "date_changed": date_changed, + "day_relation": "next day" if date_changed and target_time.date() > source_time.date() else "previous day" if date_changed else "same day" + } + + async def handle_uri(self, uri: str) -> str: + """Main URI handler that routes to appropriate method""" + try: + if uri == "time://query": + result = self.get_current_time() + elif uri.startswith("time://query/"): + timezone_name = uri.replace("time://query/", "") + result = self.get_current_time(timezone_name) + elif uri.startswith("time://convert/"): + path = uri.replace("time://convert/", "") + source_parts = path.split("/to/") + if len(source_parts) != 2: + raise ValueError("Invalid conversion URI format") + + source_info, target_tz = source_parts + source_parts = source_info.split("/") + if len(source_parts) != 2: + raise ValueError("Invalid source time format") + + source_tz, time_str = source_parts + result = self.convert_time(source_tz, time_str, target_tz) + else: + raise ValueError(f"Unsupported URI format: {uri}") + + return json.dumps(result) + + except Exception as e: + raise ValueError(f"Error processing time query: {str(e)}") + + +async def serve(local_timezone: Optional[str] = None) -> None: + server = Server("mcp-time") + time_server = TimeServer(local_timezone) + + @server.list_resources() + async def list_resources() -> list[Resource]: + current_tz = str(time_server.local_tz) + + return [ + Resource( + uri=AnyUrl("time://query"), + name="Local Time Query", + description=f"Get current time in your local timezone ({current_tz})", + mimeType="application/json" + ) + ] + # resourceTemplates=[ + # ResourceTemplate( + # uriTemplate="time://query/{timezone}", + # name="Timezone Time Query", + # description="Get current time in a specific timezone", + # mimeType="application/json" + # ), + # ResourceTemplate( + # uriTemplate="time://convert/{source_timezone}/{hour}:{minute}/to/{target_timezone}", + # name="Timezone Conversion", + # description="Convert specific time between timezones (use 'local' for local timezone)", + # mimeType="application/json" + # ) + # ] + + # @server.list_resources() + # async def list_resource_templates() -> list[ResourceTemplate]: + # return [ + # ResourceTemplate( + # uriTemplate="time://query/{timezone}", + # name="Timezone Time Query", + # description="Get current time in a specific timezone", + # mimeType="application/json" + # ), + # ResourceTemplate( + # uriTemplate="time://convert/{source_timezone}/{hour}:{minute}/to/{target_timezone}", + # name="Timezone Conversion", + # description="Convert specific time between timezones (use 'local' for local timezone)", + # mimeType="application/json" + # ) + # ] + + @server.read_resource() + async def read_resource(uri: AnyUrl) -> str: + return await time_server.handle_uri(str(uri)) + + options = server.create_initialization_options() + async with stdio_server() as (read_stream, write_stream): + await server.run(read_stream, write_stream, options) diff --git a/src/time/src/mcp_server_time/server.py b/src/time/src/mcp_server_time/server.py new file mode 100644 index 00000000..e3328d32 --- /dev/null +++ b/src/time/src/mcp_server_time/server.py @@ -0,0 +1,145 @@ +from datetime import datetime +import json +from typing import Dict, Any, Optional, Sequence + +import pytz +from tzlocal import get_localzone +from mcp.server import Server +from mcp.server.stdio import stdio_server +from mcp.types import Tool, TextContent, ImageContent, EmbeddedResource + + +class TimeServer: + def __init__(self, local_tz_override: Optional[str] = None): + self.local_tz = pytz.timezone(local_tz_override) if local_tz_override else get_localzone() + + def get_current_time(self, timezone_name: str | None = None) -> Dict[str, Any]: + """Get current time in specified timezone or local timezone if none specified""" + timezone = pytz.timezone(timezone_name) if timezone_name else self.local_tz + current_time = datetime.now(timezone) + + return { + "timezone": timezone_name or str(self.local_tz), + "time": current_time.strftime("%H:%M %Z"), + "date": current_time.strftime("%Y-%m-%d"), + "full_datetime": current_time.strftime("%Y-%m-%d %H:%M:%S %Z"), + "is_dst": bool(current_time.dst()) + } + + def convert_time(self, source_tz: str, time_str: str, target_tz: str) -> Dict[str, Any]: + """Convert time between timezones""" + try: + source_timezone = pytz.timezone(source_tz) + target_timezone = pytz.timezone(target_tz) + + # Parse time + hour, minute = map(int, time_str.split(":")) + if not (0 <= hour <= 23 and 0 <= minute <= 59): + raise ValueError + except pytz.exceptions.UnknownTimeZoneError as e: + raise ValueError(f"Unknown timezone: {str(e)}") + except: + raise ValueError("Invalid time format. Expected HH:MM (24-hour format)") + + # Create time in source timezone + now = datetime.now(source_timezone) + source_time = source_timezone.localize( + datetime(now.year, now.month, now.day, hour, minute) + ) + + # Convert to target timezone + target_time = source_time.astimezone(target_timezone) + date_changed = source_time.date() != target_time.date() + + return { + "source": { + "timezone": str(source_timezone), + "time": source_time.strftime("%H:%M %Z"), + "date": source_time.strftime("%Y-%m-%d"), + "full_datetime": source_time.strftime("%Y-%m-%d %H:%M:%S %Z") + }, + "target": { + "timezone": str(target_timezone), + "time": target_time.strftime("%H:%M %Z"), + "date": target_time.strftime("%Y-%m-%d"), + "full_datetime": target_time.strftime("%Y-%m-%d %H:%M:%S %Z") + }, + "time_difference": f"{(target_time.utcoffset() - source_time.utcoffset()).total_seconds() / 3600:+.1f}h", + "date_changed": date_changed, + "day_relation": "next day" if date_changed and target_time.date() > source_time.date() else "previous day" if date_changed else "same day" + } + + +async def serve(local_timezone: Optional[str] = None) -> None: + server = Server("mcp-time") + time_server = TimeServer(local_timezone) + local_tz = str(time_server.local_tz) + + @server.list_tools() + async def list_tools() -> list[Tool]: + """List available time tools.""" + return [ + Tool( + name="get_current_time", + description=f"Get current time in a specific timezone (current system timezone is {local_tz})", + inputSchema={ + "type": "object", + "properties": { + "timezone": { + "type": "string", + "description": "IANA timezone name (e.g., 'America/New_York', 'Europe/London', etc). If not provided, uses system timezone" + } + } + } + ), + Tool( + name="convert_time", + description=f"Convert time between timezones using IANA timezone names (system timezone is {local_tz}, can be used as source or target)", + inputSchema={ + "type": "object", + "properties": { + "source_timezone": { + "type": "string", + "description": f"Source IANA timezone name (e.g., '{local_tz}', 'America/New_York')" + }, + "time": { + "type": "string", + "description": "Time in 24-hour format (HH:MM)" + }, + "target_timezone": { + "type": "string", + "description": f"Target IANA timezone name (e.g., '{local_tz}', 'Asia/Tokyo')" + } + }, + "required": ["source_timezone", "time", "target_timezone"] + } + ) + ] + + @server.call_tool() + async def call_tool(name: str, arguments: dict) -> Sequence[TextContent | ImageContent | EmbeddedResource]: + """Handle tool calls for time queries.""" + try: + if name == "get_current_time": + timezone = arguments.get("timezone") + result = time_server.get_current_time(timezone) + elif name == "convert_time": + if not all(k in arguments for k in ["source_timezone", "time", "target_timezone"]): + raise ValueError("Missing required arguments") + + result = time_server.convert_time( + arguments["source_timezone"], + arguments["time"], + arguments["target_timezone"] + ) + else: + raise ValueError(f"Unknown tool: {name}") + + return [TextContent(type="text", text=json.dumps(result, indent=2))] + + except Exception as e: + raise ValueError(f"Error processing time query: {str(e)}") + + options = server.create_initialization_options() + async with stdio_server() as (read_stream, write_stream): + await server.run(read_stream, write_stream, options) diff --git a/src/time/uv.lock b/src/time/uv.lock new file mode 100644 index 00000000..ca42233f --- /dev/null +++ b/src/time/uv.lock @@ -0,0 +1,393 @@ +version = 1 +requires-python = ">=3.10" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + +[[package]] +name = "anyio" +version = "4.6.2.post1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/09/45b9b7a6d4e45c6bcb5bf61d19e3ab87df68e0601fa8c5293de3542546cc/anyio-4.6.2.post1.tar.gz", hash = "sha256:4c8bc31ccdb51c7f7bd251f51c609e038d63e34219b44aa86e47576389880b4c", size = 173422 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/f5/f2b75d2fc6f1a260f340f0e7c6a060f4dd2961cc16884ed851b0d18da06a/anyio-4.6.2.post1-py3-none-any.whl", hash = "sha256:6d170c36fba3bdd840c73d3868c1e777e33676a69c3a72cf0a0d5d6d8009b61d", size = 90377 }, +] + +[[package]] +name = "certifi" +version = "2024.8.30" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", size = 168507 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321 }, +] + +[[package]] +name = "click" +version = "8.1.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "platform_system == 'Windows'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", size = 97941 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, +] + +[[package]] +name = "h11" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, +] + +[[package]] +name = "httpcore" +version = "1.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 }, +] + +[[package]] +name = "httpx" +version = "0.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/82/08f8c936781f67d9e6b9eeb8a0c8b4e406136ea4c3d1f89a5db71d42e0e6/httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2", size = 144189 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/95/9377bcb415797e44274b51d46e3249eba641711cf3348050f76ee7b15ffc/httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0", size = 76395 }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "mcp" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "pydantic" }, + { name = "sse-starlette" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/de/a9ec0a1b6439f90ea59f89004bb2e7ec6890dfaeef809751d9e6577dca7e/mcp-1.0.0.tar.gz", hash = "sha256:dba51ce0b5c6a80e25576f606760c49a91ee90210fed805b530ca165d3bbc9b7", size = 82891 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/89/900c0c8445ec001d3725e475fc553b0feb2e8a51be018f3bb7de51e683db/mcp-1.0.0-py3-none-any.whl", hash = "sha256:bbe70ffa3341cd4da78b5eb504958355c68381fb29971471cea1e642a2af5b8a", size = 36361 }, +] + +[[package]] +name = "mcp-server-time" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "mcp" }, + { name = "pydantic" }, + { name = "pytz" }, + { name = "tzlocal" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { 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" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.389" }, + { name = "ruff", specifier = ">=0.7.3" }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, +] + +[[package]] +name = "pydantic" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/bd/7fc610993f616d2398958d0028d15eaf53bde5f80cb2edb7aa4f1feaf3a7/pydantic-2.10.1.tar.gz", hash = "sha256:a4daca2dc0aa429555e0656d6bf94873a7dc5f54ee42b1f5873d666fb3f35560", size = 783717 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/fc/fda48d347bd50a788dd2a0f318a52160f911b86fc2d8b4c86f4d7c9bceea/pydantic-2.10.1-py3-none-any.whl", hash = "sha256:a8d20db84de64cf4a7d59e899c2caf0fe9d660c7cfc482528e7020d7dd189a7e", size = 455329 }, +] + +[[package]] +name = "pydantic-core" +version = "2.27.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/9f/7de1f19b6aea45aeb441838782d68352e71bfa98ee6fa048d5041991b33e/pydantic_core-2.27.1.tar.gz", hash = "sha256:62a763352879b84aa31058fc931884055fd75089cccbd9d58bb6afd01141b235", size = 412785 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/ce/60fd96895c09738648c83f3f00f595c807cb6735c70d3306b548cc96dd49/pydantic_core-2.27.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:71a5e35c75c021aaf400ac048dacc855f000bdfed91614b4a726f7432f1f3d6a", size = 1897984 }, + { url = "https://files.pythonhosted.org/packages/fd/b9/84623d6b6be98cc209b06687d9bca5a7b966ffed008d15225dd0d20cce2e/pydantic_core-2.27.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f82d068a2d6ecfc6e054726080af69a6764a10015467d7d7b9f66d6ed5afa23b", size = 1807491 }, + { url = "https://files.pythonhosted.org/packages/01/72/59a70165eabbc93b1111d42df9ca016a4aa109409db04304829377947028/pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:121ceb0e822f79163dd4699e4c54f5ad38b157084d97b34de8b232bcaad70278", size = 1831953 }, + { url = "https://files.pythonhosted.org/packages/7c/0c/24841136476adafd26f94b45bb718a78cb0500bd7b4f8d667b67c29d7b0d/pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4603137322c18eaf2e06a4495f426aa8d8388940f3c457e7548145011bb68e05", size = 1856071 }, + { url = "https://files.pythonhosted.org/packages/53/5e/c32957a09cceb2af10d7642df45d1e3dbd8596061f700eac93b801de53c0/pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a33cd6ad9017bbeaa9ed78a2e0752c5e250eafb9534f308e7a5f7849b0b1bfb4", size = 2038439 }, + { url = "https://files.pythonhosted.org/packages/e4/8f/979ab3eccd118b638cd6d8f980fea8794f45018255a36044dea40fe579d4/pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15cc53a3179ba0fcefe1e3ae50beb2784dede4003ad2dfd24f81bba4b23a454f", size = 2787416 }, + { url = "https://files.pythonhosted.org/packages/02/1d/00f2e4626565b3b6d3690dab4d4fe1a26edd6a20e53749eb21ca892ef2df/pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45d9c5eb9273aa50999ad6adc6be5e0ecea7e09dbd0d31bd0c65a55a2592ca08", size = 2134548 }, + { url = "https://files.pythonhosted.org/packages/9d/46/3112621204128b90898adc2e721a3cd6cf5626504178d6f32c33b5a43b79/pydantic_core-2.27.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8bf7b66ce12a2ac52d16f776b31d16d91033150266eb796967a7e4621707e4f6", size = 1989882 }, + { url = "https://files.pythonhosted.org/packages/49/ec/557dd4ff5287ffffdf16a31d08d723de6762bb1b691879dc4423392309bc/pydantic_core-2.27.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:655d7dd86f26cb15ce8a431036f66ce0318648f8853d709b4167786ec2fa4807", size = 1995829 }, + { url = "https://files.pythonhosted.org/packages/6e/b2/610dbeb74d8d43921a7234555e4c091cb050a2bdb8cfea86d07791ce01c5/pydantic_core-2.27.1-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:5556470f1a2157031e676f776c2bc20acd34c1990ca5f7e56f1ebf938b9ab57c", size = 2091257 }, + { url = "https://files.pythonhosted.org/packages/8c/7f/4bf8e9d26a9118521c80b229291fa9558a07cdd9a968ec2d5c1026f14fbc/pydantic_core-2.27.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f69ed81ab24d5a3bd93861c8c4436f54afdf8e8cc421562b0c7504cf3be58206", size = 2143894 }, + { url = "https://files.pythonhosted.org/packages/1f/1c/875ac7139c958f4390f23656fe696d1acc8edf45fb81e4831960f12cd6e4/pydantic_core-2.27.1-cp310-none-win32.whl", hash = "sha256:f5a823165e6d04ccea61a9f0576f345f8ce40ed533013580e087bd4d7442b52c", size = 1816081 }, + { url = "https://files.pythonhosted.org/packages/d7/41/55a117acaeda25ceae51030b518032934f251b1dac3704a53781383e3491/pydantic_core-2.27.1-cp310-none-win_amd64.whl", hash = "sha256:57866a76e0b3823e0b56692d1a0bf722bffb324839bb5b7226a7dbd6c9a40b17", size = 1981109 }, + { url = "https://files.pythonhosted.org/packages/27/39/46fe47f2ad4746b478ba89c561cafe4428e02b3573df882334bd2964f9cb/pydantic_core-2.27.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ac3b20653bdbe160febbea8aa6c079d3df19310d50ac314911ed8cc4eb7f8cb8", size = 1895553 }, + { url = "https://files.pythonhosted.org/packages/1c/00/0804e84a78b7fdb394fff4c4f429815a10e5e0993e6ae0e0b27dd20379ee/pydantic_core-2.27.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a5a8e19d7c707c4cadb8c18f5f60c843052ae83c20fa7d44f41594c644a1d330", size = 1807220 }, + { url = "https://files.pythonhosted.org/packages/01/de/df51b3bac9820d38371f5a261020f505025df732ce566c2a2e7970b84c8c/pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f7059ca8d64fea7f238994c97d91f75965216bcbe5f695bb44f354893f11d52", size = 1829727 }, + { url = "https://files.pythonhosted.org/packages/5f/d9/c01d19da8f9e9fbdb2bf99f8358d145a312590374d0dc9dd8dbe484a9cde/pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bed0f8a0eeea9fb72937ba118f9db0cb7e90773462af7962d382445f3005e5a4", size = 1854282 }, + { url = "https://files.pythonhosted.org/packages/5f/84/7db66eb12a0dc88c006abd6f3cbbf4232d26adfd827a28638c540d8f871d/pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3cb37038123447cf0f3ea4c74751f6a9d7afef0eb71aa07bf5f652b5e6a132c", size = 2037437 }, + { url = "https://files.pythonhosted.org/packages/34/ac/a2537958db8299fbabed81167d58cc1506049dba4163433524e06a7d9f4c/pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:84286494f6c5d05243456e04223d5a9417d7f443c3b76065e75001beb26f88de", size = 2780899 }, + { url = "https://files.pythonhosted.org/packages/4a/c1/3e38cd777ef832c4fdce11d204592e135ddeedb6c6f525478a53d1c7d3e5/pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acc07b2cfc5b835444b44a9956846b578d27beeacd4b52e45489e93276241025", size = 2135022 }, + { url = "https://files.pythonhosted.org/packages/7a/69/b9952829f80fd555fe04340539d90e000a146f2a003d3fcd1e7077c06c71/pydantic_core-2.27.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4fefee876e07a6e9aad7a8c8c9f85b0cdbe7df52b8a9552307b09050f7512c7e", size = 1987969 }, + { url = "https://files.pythonhosted.org/packages/05/72/257b5824d7988af43460c4e22b63932ed651fe98804cc2793068de7ec554/pydantic_core-2.27.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:258c57abf1188926c774a4c94dd29237e77eda19462e5bb901d88adcab6af919", size = 1994625 }, + { url = "https://files.pythonhosted.org/packages/73/c3/78ed6b7f3278a36589bcdd01243189ade7fc9b26852844938b4d7693895b/pydantic_core-2.27.1-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:35c14ac45fcfdf7167ca76cc80b2001205a8d5d16d80524e13508371fb8cdd9c", size = 2090089 }, + { url = "https://files.pythonhosted.org/packages/8d/c8/b4139b2f78579960353c4cd987e035108c93a78371bb19ba0dc1ac3b3220/pydantic_core-2.27.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d1b26e1dff225c31897696cab7d4f0a315d4c0d9e8666dbffdb28216f3b17fdc", size = 2142496 }, + { url = "https://files.pythonhosted.org/packages/3e/f8/171a03e97eb36c0b51981efe0f78460554a1d8311773d3d30e20c005164e/pydantic_core-2.27.1-cp311-none-win32.whl", hash = "sha256:2cdf7d86886bc6982354862204ae3b2f7f96f21a3eb0ba5ca0ac42c7b38598b9", size = 1811758 }, + { url = "https://files.pythonhosted.org/packages/6a/fe/4e0e63c418c1c76e33974a05266e5633e879d4061f9533b1706a86f77d5b/pydantic_core-2.27.1-cp311-none-win_amd64.whl", hash = "sha256:3af385b0cee8df3746c3f406f38bcbfdc9041b5c2d5ce3e5fc6637256e60bbc5", size = 1980864 }, + { url = "https://files.pythonhosted.org/packages/50/fc/93f7238a514c155a8ec02fc7ac6376177d449848115e4519b853820436c5/pydantic_core-2.27.1-cp311-none-win_arm64.whl", hash = "sha256:81f2ec23ddc1b476ff96563f2e8d723830b06dceae348ce02914a37cb4e74b89", size = 1864327 }, + { url = "https://files.pythonhosted.org/packages/be/51/2e9b3788feb2aebff2aa9dfbf060ec739b38c05c46847601134cc1fed2ea/pydantic_core-2.27.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9cbd94fc661d2bab2bc702cddd2d3370bbdcc4cd0f8f57488a81bcce90c7a54f", size = 1895239 }, + { url = "https://files.pythonhosted.org/packages/7b/9e/f8063952e4a7d0127f5d1181addef9377505dcce3be224263b25c4f0bfd9/pydantic_core-2.27.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5f8c4718cd44ec1580e180cb739713ecda2bdee1341084c1467802a417fe0f02", size = 1805070 }, + { url = "https://files.pythonhosted.org/packages/2c/9d/e1d6c4561d262b52e41b17a7ef8301e2ba80b61e32e94520271029feb5d8/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15aae984e46de8d376df515f00450d1522077254ef6b7ce189b38ecee7c9677c", size = 1828096 }, + { url = "https://files.pythonhosted.org/packages/be/65/80ff46de4266560baa4332ae3181fffc4488ea7d37282da1a62d10ab89a4/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ba5e3963344ff25fc8c40da90f44b0afca8cfd89d12964feb79ac1411a260ac", size = 1857708 }, + { url = "https://files.pythonhosted.org/packages/d5/ca/3370074ad758b04d9562b12ecdb088597f4d9d13893a48a583fb47682cdf/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:992cea5f4f3b29d6b4f7f1726ed8ee46c8331c6b4eed6db5b40134c6fe1768bb", size = 2037751 }, + { url = "https://files.pythonhosted.org/packages/b1/e2/4ab72d93367194317b99d051947c071aef6e3eb95f7553eaa4208ecf9ba4/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0325336f348dbee6550d129b1627cb8f5351a9dc91aad141ffb96d4937bd9529", size = 2733863 }, + { url = "https://files.pythonhosted.org/packages/8a/c6/8ae0831bf77f356bb73127ce5a95fe115b10f820ea480abbd72d3cc7ccf3/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7597c07fbd11515f654d6ece3d0e4e5093edc30a436c63142d9a4b8e22f19c35", size = 2161161 }, + { url = "https://files.pythonhosted.org/packages/f1/f4/b2fe73241da2429400fc27ddeaa43e35562f96cf5b67499b2de52b528cad/pydantic_core-2.27.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3bbd5d8cc692616d5ef6fbbbd50dbec142c7e6ad9beb66b78a96e9c16729b089", size = 1993294 }, + { url = "https://files.pythonhosted.org/packages/77/29/4bb008823a7f4cc05828198153f9753b3bd4c104d93b8e0b1bfe4e187540/pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:dc61505e73298a84a2f317255fcc72b710b72980f3a1f670447a21efc88f8381", size = 2001468 }, + { url = "https://files.pythonhosted.org/packages/f2/a9/0eaceeba41b9fad851a4107e0cf999a34ae8f0d0d1f829e2574f3d8897b0/pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:e1f735dc43da318cad19b4173dd1ffce1d84aafd6c9b782b3abc04a0d5a6f5bb", size = 2091413 }, + { url = "https://files.pythonhosted.org/packages/d8/36/eb8697729725bc610fd73940f0d860d791dc2ad557faaefcbb3edbd2b349/pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:f4e5658dbffe8843a0f12366a4c2d1c316dbe09bb4dfbdc9d2d9cd6031de8aae", size = 2154735 }, + { url = "https://files.pythonhosted.org/packages/52/e5/4f0fbd5c5995cc70d3afed1b5c754055bb67908f55b5cb8000f7112749bf/pydantic_core-2.27.1-cp312-none-win32.whl", hash = "sha256:672ebbe820bb37988c4d136eca2652ee114992d5d41c7e4858cdd90ea94ffe5c", size = 1833633 }, + { url = "https://files.pythonhosted.org/packages/ee/f2/c61486eee27cae5ac781305658779b4a6b45f9cc9d02c90cb21b940e82cc/pydantic_core-2.27.1-cp312-none-win_amd64.whl", hash = "sha256:66ff044fd0bb1768688aecbe28b6190f6e799349221fb0de0e6f4048eca14c16", size = 1986973 }, + { url = "https://files.pythonhosted.org/packages/df/a6/e3f12ff25f250b02f7c51be89a294689d175ac76e1096c32bf278f29ca1e/pydantic_core-2.27.1-cp312-none-win_arm64.whl", hash = "sha256:9a3b0793b1bbfd4146304e23d90045f2a9b5fd5823aa682665fbdaf2a6c28f3e", size = 1883215 }, + { url = "https://files.pythonhosted.org/packages/0f/d6/91cb99a3c59d7b072bded9959fbeab0a9613d5a4935773c0801f1764c156/pydantic_core-2.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f216dbce0e60e4d03e0c4353c7023b202d95cbaeff12e5fd2e82ea0a66905073", size = 1895033 }, + { url = "https://files.pythonhosted.org/packages/07/42/d35033f81a28b27dedcade9e967e8a40981a765795c9ebae2045bcef05d3/pydantic_core-2.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a2e02889071850bbfd36b56fd6bc98945e23670773bc7a76657e90e6b6603c08", size = 1807542 }, + { url = "https://files.pythonhosted.org/packages/41/c2/491b59e222ec7e72236e512108ecad532c7f4391a14e971c963f624f7569/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42b0e23f119b2b456d07ca91b307ae167cc3f6c846a7b169fca5326e32fdc6cf", size = 1827854 }, + { url = "https://files.pythonhosted.org/packages/e3/f3/363652651779113189cefdbbb619b7b07b7a67ebb6840325117cc8cc3460/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:764be71193f87d460a03f1f7385a82e226639732214b402f9aa61f0d025f0737", size = 1857389 }, + { url = "https://files.pythonhosted.org/packages/5f/97/be804aed6b479af5a945daec7538d8bf358d668bdadde4c7888a2506bdfb/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c00666a3bd2f84920a4e94434f5974d7bbc57e461318d6bb34ce9cdbbc1f6b2", size = 2037934 }, + { url = "https://files.pythonhosted.org/packages/42/01/295f0bd4abf58902917e342ddfe5f76cf66ffabfc57c2e23c7681a1a1197/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ccaa88b24eebc0f849ce0a4d09e8a408ec5a94afff395eb69baf868f5183107", size = 2735176 }, + { url = "https://files.pythonhosted.org/packages/9d/a0/cd8e9c940ead89cc37812a1a9f310fef59ba2f0b22b4e417d84ab09fa970/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c65af9088ac534313e1963443d0ec360bb2b9cba6c2909478d22c2e363d98a51", size = 2160720 }, + { url = "https://files.pythonhosted.org/packages/73/ae/9d0980e286627e0aeca4c352a60bd760331622c12d576e5ea4441ac7e15e/pydantic_core-2.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:206b5cf6f0c513baffaeae7bd817717140770c74528f3e4c3e1cec7871ddd61a", size = 1992972 }, + { url = "https://files.pythonhosted.org/packages/bf/ba/ae4480bc0292d54b85cfb954e9d6bd226982949f8316338677d56541b85f/pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:062f60e512fc7fff8b8a9d680ff0ddaaef0193dba9fa83e679c0c5f5fbd018bc", size = 2001477 }, + { url = "https://files.pythonhosted.org/packages/55/b7/e26adf48c2f943092ce54ae14c3c08d0d221ad34ce80b18a50de8ed2cba8/pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:a0697803ed7d4af5e4c1adf1670af078f8fcab7a86350e969f454daf598c4960", size = 2091186 }, + { url = "https://files.pythonhosted.org/packages/ba/cc/8491fff5b608b3862eb36e7d29d36a1af1c945463ca4c5040bf46cc73f40/pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:58ca98a950171f3151c603aeea9303ef6c235f692fe555e883591103da709b23", size = 2154429 }, + { url = "https://files.pythonhosted.org/packages/78/d8/c080592d80edd3441ab7f88f865f51dae94a157fc64283c680e9f32cf6da/pydantic_core-2.27.1-cp313-none-win32.whl", hash = "sha256:8065914ff79f7eab1599bd80406681f0ad08f8e47c880f17b416c9f8f7a26d05", size = 1833713 }, + { url = "https://files.pythonhosted.org/packages/83/84/5ab82a9ee2538ac95a66e51f6838d6aba6e0a03a42aa185ad2fe404a4e8f/pydantic_core-2.27.1-cp313-none-win_amd64.whl", hash = "sha256:ba630d5e3db74c79300d9a5bdaaf6200172b107f263c98a0539eeecb857b2337", size = 1987897 }, + { url = "https://files.pythonhosted.org/packages/df/c3/b15fb833926d91d982fde29c0624c9f225da743c7af801dace0d4e187e71/pydantic_core-2.27.1-cp313-none-win_arm64.whl", hash = "sha256:45cf8588c066860b623cd11c4ba687f8d7175d5f7ef65f7129df8a394c502de5", size = 1882983 }, + { url = "https://files.pythonhosted.org/packages/7c/60/e5eb2d462595ba1f622edbe7b1d19531e510c05c405f0b87c80c1e89d5b1/pydantic_core-2.27.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3fa80ac2bd5856580e242dbc202db873c60a01b20309c8319b5c5986fbe53ce6", size = 1894016 }, + { url = "https://files.pythonhosted.org/packages/61/20/da7059855225038c1c4326a840908cc7ca72c7198cb6addb8b92ec81c1d6/pydantic_core-2.27.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d950caa237bb1954f1b8c9227b5065ba6875ac9771bb8ec790d956a699b78676", size = 1771648 }, + { url = "https://files.pythonhosted.org/packages/8f/fc/5485cf0b0bb38da31d1d292160a4d123b5977841ddc1122c671a30b76cfd/pydantic_core-2.27.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e4216e64d203e39c62df627aa882f02a2438d18a5f21d7f721621f7a5d3611d", size = 1826929 }, + { url = "https://files.pythonhosted.org/packages/a1/ff/fb1284a210e13a5f34c639efc54d51da136074ffbe25ec0c279cf9fbb1c4/pydantic_core-2.27.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02a3d637bd387c41d46b002f0e49c52642281edacd2740e5a42f7017feea3f2c", size = 1980591 }, + { url = "https://files.pythonhosted.org/packages/f1/14/77c1887a182d05af74f6aeac7b740da3a74155d3093ccc7ee10b900cc6b5/pydantic_core-2.27.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:161c27ccce13b6b0c8689418da3885d3220ed2eae2ea5e9b2f7f3d48f1d52c27", size = 1981326 }, + { url = "https://files.pythonhosted.org/packages/06/aa/6f1b2747f811a9c66b5ef39d7f02fbb200479784c75e98290d70004b1253/pydantic_core-2.27.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:19910754e4cc9c63bc1c7f6d73aa1cfee82f42007e407c0f413695c2f7ed777f", size = 1989205 }, + { url = "https://files.pythonhosted.org/packages/7a/d2/8ce2b074d6835f3c88d85f6d8a399790043e9fdb3d0e43455e72d19df8cc/pydantic_core-2.27.1-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:e173486019cc283dc9778315fa29a363579372fe67045e971e89b6365cc035ed", size = 2079616 }, + { url = "https://files.pythonhosted.org/packages/65/71/af01033d4e58484c3db1e5d13e751ba5e3d6b87cc3368533df4c50932c8b/pydantic_core-2.27.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:af52d26579b308921b73b956153066481f064875140ccd1dfd4e77db89dbb12f", size = 2133265 }, + { url = "https://files.pythonhosted.org/packages/33/72/f881b5e18fbb67cf2fb4ab253660de3c6899dbb2dba409d0b757e3559e3d/pydantic_core-2.27.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:981fb88516bd1ae8b0cbbd2034678a39dedc98752f264ac9bc5839d3923fa04c", size = 2001864 }, +] + +[[package]] +name = "pyright" +version = "1.1.389" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/4e/9a5ab8745e7606b88c2c7ca223449ac9d82a71fd5e31df47b453f2cb39a1/pyright-1.1.389.tar.gz", hash = "sha256:716bf8cc174ab8b4dcf6828c3298cac05c5ed775dda9910106a5dcfe4c7fe220", size = 21940 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/26/c288cabf8cfc5a27e1aa9e5029b7682c0f920b8074f45d22bf844314d66a/pyright-1.1.389-py3-none-any.whl", hash = "sha256:41e9620bba9254406dc1f621a88ceab5a88af4c826feb4f614d95691ed243a60", size = 18581 }, +] + +[[package]] +name = "pytz" +version = "2024.2" +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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/c3/005fcca25ce078d2cc29fd559379817424e94885510568bc1bc53d7d5846/pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725", size = 508002 }, +] + +[[package]] +name = "ruff" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/d6/a2373f3ba7180ddb44420d2a9d1f1510e1a4d162b3d27282bedcb09c8da9/ruff-0.8.0.tar.gz", hash = "sha256:a7ccfe6331bf8c8dad715753e157457faf7351c2b69f62f32c165c2dbcbacd44", size = 3276537 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/77/e889ee3ce7fd8baa3ed1b77a03b9fb8ec1be68be1418261522fd6a5405e0/ruff-0.8.0-py3-none-linux_armv6l.whl", hash = "sha256:fcb1bf2cc6706adae9d79c8d86478677e3bbd4ced796ccad106fd4776d395fea", size = 10518283 }, + { url = "https://files.pythonhosted.org/packages/da/c8/0a47de01edf19fb22f5f9b7964f46a68d0bdff20144d134556ffd1ba9154/ruff-0.8.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:295bb4c02d58ff2ef4378a1870c20af30723013f441c9d1637a008baaf928c8b", size = 10317691 }, + { url = "https://files.pythonhosted.org/packages/41/17/9885e4a0eeae07abd2a4ebabc3246f556719f24efa477ba2739146c4635a/ruff-0.8.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7b1f1c76b47c18fa92ee78b60d2d20d7e866c55ee603e7d19c1e991fad933a9a", size = 9940999 }, + { url = "https://files.pythonhosted.org/packages/3e/cd/46b6f7043597eb318b5f5482c8ae8f5491cccce771e85f59d23106f2d179/ruff-0.8.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eb0d4f250a7711b67ad513fde67e8870109e5ce590a801c3722580fe98c33a99", size = 10772437 }, + { url = "https://files.pythonhosted.org/packages/5d/87/afc95aeb8bc78b1d8a3461717a4419c05aa8aa943d4c9cbd441630f85584/ruff-0.8.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e55cce9aa93c5d0d4e3937e47b169035c7e91c8655b0974e61bb79cf398d49c", size = 10299156 }, + { url = "https://files.pythonhosted.org/packages/65/fa/04c647bb809c4d65e8eae1ed1c654d9481b21dd942e743cd33511687b9f9/ruff-0.8.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f4cd64916d8e732ce6b87f3f5296a8942d285bbbc161acee7fe561134af64f9", size = 11325819 }, + { url = "https://files.pythonhosted.org/packages/90/26/7dad6e7d833d391a8a1afe4ee70ca6f36c4a297d3cca83ef10e83e9aacf3/ruff-0.8.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c5c1466be2a2ebdf7c5450dd5d980cc87c8ba6976fb82582fea18823da6fa362", size = 12023927 }, + { url = "https://files.pythonhosted.org/packages/24/a0/be5296dda6428ba8a13bda8d09fbc0e14c810b485478733886e61597ae2b/ruff-0.8.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2dabfd05b96b7b8f2da00d53c514eea842bff83e41e1cceb08ae1966254a51df", size = 11589702 }, + { url = "https://files.pythonhosted.org/packages/26/3f/7602eb11d2886db545834182a9dbe500b8211fcbc9b4064bf9d358bbbbb4/ruff-0.8.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:facebdfe5a5af6b1588a1d26d170635ead6892d0e314477e80256ef4a8470cf3", size = 12782936 }, + { url = "https://files.pythonhosted.org/packages/4c/5d/083181bdec4ec92a431c1291d3fff65eef3ded630a4b55eb735000ef5f3b/ruff-0.8.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87a8e86bae0dbd749c815211ca11e3a7bd559b9710746c559ed63106d382bd9c", size = 11138488 }, + { url = "https://files.pythonhosted.org/packages/b7/23/c12cdef58413cee2436d6a177aa06f7a366ebbca916cf10820706f632459/ruff-0.8.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:85e654f0ded7befe2d61eeaf3d3b1e4ef3894469cd664ffa85006c7720f1e4a2", size = 10744474 }, + { url = "https://files.pythonhosted.org/packages/29/61/a12f3b81520083cd7c5caa24ba61bb99fd1060256482eff0ef04cc5ccd1b/ruff-0.8.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:83a55679c4cb449fa527b8497cadf54f076603cc36779b2170b24f704171ce70", size = 10369029 }, + { url = "https://files.pythonhosted.org/packages/08/2a/c013f4f3e4a54596c369cee74c24870ed1d534f31a35504908b1fc97017a/ruff-0.8.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:812e2052121634cf13cd6fddf0c1871d0ead1aad40a1a258753c04c18bb71bbd", size = 10867481 }, + { url = "https://files.pythonhosted.org/packages/d5/f7/685b1e1d42a3e94ceb25eab23c70bdd8c0ab66a43121ef83fe6db5a58756/ruff-0.8.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:780d5d8523c04202184405e60c98d7595bdb498c3c6abba3b6d4cdf2ca2af426", size = 11237117 }, + { url = "https://files.pythonhosted.org/packages/03/20/401132c0908e8837625e3b7e32df9962e7cd681a4df1e16a10e2a5b4ecda/ruff-0.8.0-py3-none-win32.whl", hash = "sha256:5fdb6efecc3eb60bba5819679466471fd7d13c53487df7248d6e27146e985468", size = 8783511 }, + { url = "https://files.pythonhosted.org/packages/1d/5c/4d800fca7854f62ad77f2c0d99b4b585f03e2d87a6ec1ecea85543a14a3c/ruff-0.8.0-py3-none-win_amd64.whl", hash = "sha256:582891c57b96228d146725975fbb942e1f30a0c4ba19722e692ca3eb25cc9b4f", size = 9559876 }, + { url = "https://files.pythonhosted.org/packages/5b/bc/cc8a6a5ca4960b226dc15dd8fb511dd11f2014ff89d325c0b9b9faa9871f/ruff-0.8.0-py3-none-win_arm64.whl", hash = "sha256:ba93e6294e9a737cd726b74b09a6972e36bb511f9a102f1d9a7e1ce94dd206a6", size = 8939733 }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, +] + +[[package]] +name = "sse-starlette" +version = "2.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, + { name = "uvicorn" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/fc/56ab9f116b2133521f532fce8d03194cf04dcac25f583cf3d839be4c0496/sse_starlette-2.1.3.tar.gz", hash = "sha256:9cd27eb35319e1414e3d2558ee7414487f9529ce3b3cf9b21434fd110e017169", size = 19678 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/aa/36b271bc4fa1d2796311ee7c7283a3a1c348bad426d37293609ca4300eef/sse_starlette-2.1.3-py3-none-any.whl", hash = "sha256:8ec846438b4665b9e8c560fcdea6bc8081a3abf7942faa95e5a744999d219772", size = 9383 }, +] + +[[package]] +name = "starlette" +version = "0.41.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/4c/9b5764bd22eec91c4039ef4c55334e9187085da2d8a2df7bd570869aae18/starlette-0.41.3.tar.gz", hash = "sha256:0e4ab3d16522a255be6b28260b938eae2482f98ce5cc934cb08dce8dc3ba5835", size = 2574159 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/00/2b325970b3060c7cecebab6d295afe763365822b1306a12eeab198f74323/starlette-0.41.3-py3-none-any.whl", hash = "sha256:44cedb2b7c77a9de33a8b74b2b90e9f50d11fcf25d8270ea525ad71a25374ff7", size = 73225 }, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, +] + +[[package]] +name = "tzdata" +version = "2024.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/34/943888654477a574a86a98e9896bae89c7aa15078ec29f490fef2f1e5384/tzdata-2024.2.tar.gz", hash = "sha256:7d85cc416e9382e69095b7bdf4afd9e3880418a2413feec7069d533d6b4e31cc", size = 193282 } +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" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/3c/21dba3e7d76138725ef307e3d7ddd29b763119b3aa459d02cc05fefcff75/uvicorn-0.32.1.tar.gz", hash = "sha256:ee9519c246a72b1c084cea8d3b44ed6026e78a4a309cbedae9c37e4cb9fbb175", size = 77630 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/c1/2d27b0a15826c2b71dcf6e2f5402181ef85acf439617bb2f1453125ce1f3/uvicorn-0.32.1-py3-none-any.whl", hash = "sha256:82ad92fd58da0d12af7482ecdb5f2470a04c9c9a53ced65b9bbb4a205377602e", size = 63828 }, +] From 122ca1aaa50a850908abcecfc2b1b4faff1978af Mon Sep 17 00:00:00 2001 From: Mariusz Korzekwa Date: Tue, 26 Nov 2024 09:58:15 +0100 Subject: [PATCH 2/5] Update pyproject of time server, remove time server temporary files --- src/time/pyproject.toml | 4 +- .../src/mcp_server_time/server-resources.py | 167 ------------------ src/time/uv.lock | 2 +- 3 files changed, 3 insertions(+), 170 deletions(-) delete mode 100644 src/time/src/mcp_server_time/server-resources.py diff --git a/src/time/pyproject.toml b/src/time/pyproject.toml index f2afd6a3..d1ec316a 100644 --- a/src/time/pyproject.toml +++ b/src/time/pyproject.toml @@ -1,10 +1,10 @@ [project] name = "mcp-server-time" -version = "0.1.0" +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 = "Your Name" }] +authors = [{ name = "Mariusz 'maledorak' Korzekwa", email = "mariusz@korzekwa.dev" }] keywords = ["time", "timezone", "mcp", "llm"] license = { text = "MIT" } classifiers = [ diff --git a/src/time/src/mcp_server_time/server-resources.py b/src/time/src/mcp_server_time/server-resources.py deleted file mode 100644 index 23ae45e7..00000000 --- a/src/time/src/mcp_server_time/server-resources.py +++ /dev/null @@ -1,167 +0,0 @@ -# server.py - -from datetime import datetime -from typing import Dict, Any, Tuple, Optional -import pytz -from tzlocal import get_localzone -from mcp.server import Server -from mcp.server.stdio import stdio_server -from mcp.types import Resource, ResourceTemplate, ListResourcesResult, ListResourceTemplatesResult -from pydantic import AnyUrl -import json - - -class TimeServer: - def __init__(self, local_tz_override: Optional[str] = None): - self.local_tz = pytz.timezone(local_tz_override) if local_tz_override else get_localzone() - - def get_current_time(self, timezone_name: str | None = None) -> Dict[str, Any]: - """Get current time in specified timezone or local timezone if none specified""" - timezone = pytz.timezone(timezone_name) if timezone_name else self.local_tz - current_time = datetime.now(timezone) - - return { - "timezone": timezone_name or str(self.local_tz), - "time": current_time.strftime("%H:%M %Z"), - "date": current_time.strftime("%Y-%m-%d"), - "full_datetime": current_time.strftime("%Y-%m-%d %H:%M:%S %Z"), - "is_dst": bool(current_time.dst()) - } - - def parse_time_str(self, time_str: str) -> Tuple[int, int]: - """Parse time string in format HH:MM""" - try: - hour, minute = map(int, time_str.split(":")) - if not (0 <= hour <= 23 and 0 <= minute <= 59): - raise ValueError - return hour, minute - except: - raise ValueError("Invalid time format. Expected HH:MM (24-hour format)") - - def get_timezone(self, tz_name: str) -> pytz.timezone: - """Get timezone object, handling 'local' keyword""" - if tz_name.lower() == 'local': - return self.local_tz - try: - return pytz.timezone(tz_name) - except pytz.exceptions.UnknownTimeZoneError: - raise ValueError(f"Unknown timezone: {tz_name}") - - def convert_time(self, source_tz: str, time_str: str, target_tz: str) -> Dict[str, Any]: - """Convert time between timezones""" - source_timezone = self.get_timezone(source_tz) - target_timezone = self.get_timezone(target_tz) - - hour, minute = self.parse_time_str(time_str) - - now = datetime.now(source_timezone) - source_time = source_timezone.localize( - datetime(now.year, now.month, now.day, hour, minute) - ) - - target_time = source_time.astimezone(target_timezone) - date_changed = source_time.date() != target_time.date() - - return { - "source": { - "timezone": str(source_timezone), - "time": source_time.strftime("%H:%M %Z"), - "date": source_time.strftime("%Y-%m-%d"), - "full_datetime": source_time.strftime("%Y-%m-%d %H:%M:%S %Z") - }, - "target": { - "timezone": str(target_timezone), - "time": target_time.strftime("%H:%M %Z"), - "date": target_time.strftime("%Y-%m-%d"), - "full_datetime": target_time.strftime("%Y-%m-%d %H:%M:%S %Z") - }, - "time_difference": f"{(target_time.utcoffset() - source_time.utcoffset()).total_seconds() / 3600:+.1f}h", - "date_changed": date_changed, - "day_relation": "next day" if date_changed and target_time.date() > source_time.date() else "previous day" if date_changed else "same day" - } - - async def handle_uri(self, uri: str) -> str: - """Main URI handler that routes to appropriate method""" - try: - if uri == "time://query": - result = self.get_current_time() - elif uri.startswith("time://query/"): - timezone_name = uri.replace("time://query/", "") - result = self.get_current_time(timezone_name) - elif uri.startswith("time://convert/"): - path = uri.replace("time://convert/", "") - source_parts = path.split("/to/") - if len(source_parts) != 2: - raise ValueError("Invalid conversion URI format") - - source_info, target_tz = source_parts - source_parts = source_info.split("/") - if len(source_parts) != 2: - raise ValueError("Invalid source time format") - - source_tz, time_str = source_parts - result = self.convert_time(source_tz, time_str, target_tz) - else: - raise ValueError(f"Unsupported URI format: {uri}") - - return json.dumps(result) - - except Exception as e: - raise ValueError(f"Error processing time query: {str(e)}") - - -async def serve(local_timezone: Optional[str] = None) -> None: - server = Server("mcp-time") - time_server = TimeServer(local_timezone) - - @server.list_resources() - async def list_resources() -> list[Resource]: - current_tz = str(time_server.local_tz) - - return [ - Resource( - uri=AnyUrl("time://query"), - name="Local Time Query", - description=f"Get current time in your local timezone ({current_tz})", - mimeType="application/json" - ) - ] - # resourceTemplates=[ - # ResourceTemplate( - # uriTemplate="time://query/{timezone}", - # name="Timezone Time Query", - # description="Get current time in a specific timezone", - # mimeType="application/json" - # ), - # ResourceTemplate( - # uriTemplate="time://convert/{source_timezone}/{hour}:{minute}/to/{target_timezone}", - # name="Timezone Conversion", - # description="Convert specific time between timezones (use 'local' for local timezone)", - # mimeType="application/json" - # ) - # ] - - # @server.list_resources() - # async def list_resource_templates() -> list[ResourceTemplate]: - # return [ - # ResourceTemplate( - # uriTemplate="time://query/{timezone}", - # name="Timezone Time Query", - # description="Get current time in a specific timezone", - # mimeType="application/json" - # ), - # ResourceTemplate( - # uriTemplate="time://convert/{source_timezone}/{hour}:{minute}/to/{target_timezone}", - # name="Timezone Conversion", - # description="Convert specific time between timezones (use 'local' for local timezone)", - # mimeType="application/json" - # ) - # ] - - @server.read_resource() - async def read_resource(uri: AnyUrl) -> str: - return await time_server.handle_uri(str(uri)) - - options = server.create_initialization_options() - async with stdio_server() as (read_stream, write_stream): - await server.run(read_stream, write_stream, options) diff --git a/src/time/uv.lock b/src/time/uv.lock index ca42233f..d74ecc2d 100644 --- a/src/time/uv.lock +++ b/src/time/uv.lock @@ -139,7 +139,7 @@ wheels = [ [[package]] name = "mcp-server-time" -version = "0.1.0" +version = "0.5.1" source = { editable = "." } dependencies = [ { name = "mcp" }, From 740b744ea86bf293b3b4ad4beb7d1870f3a7714f Mon Sep 17 00:00:00 2001 From: Mariusz Korzekwa Date: Tue, 26 Nov 2024 11:03:11 +0100 Subject: [PATCH 3/5] Clean time server implementation --- src/time/pyproject.toml | 9 +- src/time/src/mcp_server_time/server.py | 214 +++++++++++++++---------- src/time/uv.lock | 122 +++++++++++++- 3 files changed, 259 insertions(+), 86 deletions(-) diff --git a/src/time/pyproject.toml b/src/time/pyproject.toml index d1ec316a..d0700778 100644 --- a/src/time/pyproject.toml +++ b/src/time/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcp-server-time" -version = "0.5.1" +version = "0.5.1.pre3" description = "A Model Context Protocol server providing tools for time queries and timezone conversions for LLMs" readme = "README.md" requires-python = ">=3.10" @@ -29,4 +29,9 @@ requires = ["hatchling"] build-backend = "hatchling.build" [tool.uv] -dev-dependencies = ["pyright>=1.1.389", "ruff>=0.7.3"] +dev-dependencies = [ + "freezegun>=1.5.1", + "pyright>=1.1.389", + "pytest>=8.3.3", + "ruff>=0.7.3", +] diff --git a/src/time/src/mcp_server_time/server.py b/src/time/src/mcp_server_time/server.py index e3328d32..b3e5e795 100644 --- a/src/time/src/mcp_server_time/server.py +++ b/src/time/src/mcp_server_time/server.py @@ -1,6 +1,8 @@ -from datetime import datetime +from dataclasses import dataclass +from datetime import datetime, timedelta +from enum import Enum import json -from typing import Dict, Any, Optional, Sequence +from typing import Sequence import pytz from tzlocal import get_localzone @@ -8,69 +10,104 @@ from mcp.server.stdio import stdio_server from mcp.types import Tool, TextContent, ImageContent, EmbeddedResource +from pydantic import BaseModel + + +class TimeTools(str, Enum): + GET_CURRENT_TIME = "get_current_time" + CONVERT_TIME = "convert_time" + + +class TimeResult(BaseModel): + timezone: str + datetime: str + is_dst: bool + + +class TimeConversionResult(BaseModel): + source: TimeResult + target: TimeResult + time_difference: str + + +class TimeConversionInput(BaseModel): + source_tz: str + time: str + target_tz_list: list[str] + class TimeServer: - def __init__(self, local_tz_override: Optional[str] = None): - self.local_tz = pytz.timezone(local_tz_override) if local_tz_override else get_localzone() + def __init__(self, local_tz_override: str | None = None): + self.local_tz = ( + pytz.timezone(local_tz_override) if local_tz_override else get_localzone() + ) + + 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)}") - def get_current_time(self, timezone_name: str | None = None) -> Dict[str, Any]: - """Get current time in specified timezone or local timezone if none specified""" - timezone = pytz.timezone(timezone_name) if timezone_name else self.local_tz current_time = datetime.now(timezone) - - return { - "timezone": timezone_name or str(self.local_tz), - "time": current_time.strftime("%H:%M %Z"), - "date": current_time.strftime("%Y-%m-%d"), - "full_datetime": current_time.strftime("%Y-%m-%d %H:%M:%S %Z"), - "is_dst": bool(current_time.dst()) - } - - def convert_time(self, source_tz: str, time_str: str, target_tz: str) -> Dict[str, Any]: + + return TimeResult( + timezone=timezone_name, + datetime=current_time.isoformat(timespec="seconds"), + is_dst=bool(current_time.dst()), + ) + + def convert_time( + 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) - - # Parse time - hour, minute = map(int, time_str.split(":")) - if not (0 <= hour <= 23 and 0 <= minute <= 59): - raise ValueError except pytz.exceptions.UnknownTimeZoneError as e: - raise ValueError(f"Unknown timezone: {str(e)}") - except: - raise ValueError("Invalid time format. Expected HH:MM (24-hour format)") - - # Create time in source timezone + raise ValueError(f"Unknown target timezone: {str(e)}") + + try: + parsed_time = datetime.strptime(time_str, "%H:%M").time() + except ValueError: + 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, hour, minute) + datetime(now.year, now.month, now.day, parsed_time.hour, parsed_time.minute) ) - - # Convert to target timezone + target_time = source_time.astimezone(target_timezone) - date_changed = source_time.date() != target_time.date() - - return { - "source": { - "timezone": str(source_timezone), - "time": source_time.strftime("%H:%M %Z"), - "date": source_time.strftime("%Y-%m-%d"), - "full_datetime": source_time.strftime("%Y-%m-%d %H:%M:%S %Z") - }, - "target": { - "timezone": str(target_timezone), - "time": target_time.strftime("%H:%M %Z"), - "date": target_time.strftime("%Y-%m-%d"), - "full_datetime": target_time.strftime("%Y-%m-%d %H:%M:%S %Z") - }, - "time_difference": f"{(target_time.utcoffset() - source_time.utcoffset()).total_seconds() / 3600:+.1f}h", - "date_changed": date_changed, - "day_relation": "next day" if date_changed and target_time.date() > source_time.date() else "previous day" if date_changed else "same day" - } - - -async def serve(local_timezone: Optional[str] = None) -> None: + hours_difference = ( + target_time.utcoffset() - source_time.utcoffset() + ).total_seconds() / 3600 + + if hours_difference.is_integer(): + time_diff_str = f"{hours_difference:+.1f}h" + else: + # For fractional hours like Nepal's UTC+5:45 + time_diff_str = f"{hours_difference:+.2f}".rstrip("0").rstrip(".") + "h" + + return TimeConversionResult( + source=TimeResult( + timezone=source_tz, + datetime=source_time.isoformat(timespec="seconds"), + is_dst=bool(source_time.dst()), + ), + target=TimeResult( + timezone=target_tz, + datetime=target_time.isoformat(timespec="seconds"), + is_dst=bool(target_time.dst()), + ), + time_difference=time_diff_str, + ) + + +async def serve(local_timezone: str | None = None) -> None: server = Server("mcp-time") time_server = TimeServer(local_timezone) local_tz = str(time_server.local_tz) @@ -80,65 +117,76 @@ async def list_tools() -> list[Tool]: """List available time tools.""" return [ Tool( - name="get_current_time", - description=f"Get current time in a specific timezone (current system timezone is {local_tz})", + name=TimeTools.GET_CURRENT_TIME.value, + description=f"Get current time in a specific timezones", inputSchema={ "type": "object", "properties": { "timezone": { "type": "string", - "description": "IANA timezone name (e.g., 'America/New_York', 'Europe/London', etc). If not provided, uses system timezone" + "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"], + }, ), Tool( - name="convert_time", - description=f"Convert time between timezones using IANA timezone names (system timezone is {local_tz}, can be used as source or target)", + name=TimeTools.CONVERT_TIME.value, + description=f"Convert time between timezones", inputSchema={ "type": "object", "properties": { "source_timezone": { "type": "string", - "description": f"Source IANA timezone name (e.g., '{local_tz}', 'America/New_York')" + "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 in 24-hour format (HH:MM)" + "description": "Time to convert in 24-hour format (HH:MM)", }, "target_timezone": { "type": "string", - "description": f"Target IANA timezone name (e.g., '{local_tz}', 'Asia/Tokyo')" - } + "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"] - } - ) + "required": ["source_timezone", "time", "target_timezone"], + }, + ), ] @server.call_tool() - async def call_tool(name: str, arguments: dict) -> Sequence[TextContent | ImageContent | EmbeddedResource]: + async def call_tool( + name: str, arguments: dict + ) -> Sequence[TextContent | ImageContent | EmbeddedResource]: """Handle tool calls for time queries.""" try: - if name == "get_current_time": - timezone = arguments.get("timezone") - result = time_server.get_current_time(timezone) - elif name == "convert_time": - if not all(k in arguments for k in ["source_timezone", "time", "target_timezone"]): - raise ValueError("Missing required arguments") - - result = time_server.convert_time( - arguments["source_timezone"], - arguments["time"], - arguments["target_timezone"] - ) - else: - raise ValueError(f"Unknown tool: {name}") + match name: + case TimeTools.GET_CURRENT_TIME.value: + timezone = arguments.get("timezone") + if not timezone: + raise ValueError("Missing required argument: timezone") + + result = time_server.get_current_time(timezone) + + case TimeTools.CONVERT_TIME.value: + if not all( + k in arguments + for k in ["source_timezone", "time", "target_timezone"] + ): + raise ValueError("Missing required arguments") + + result = time_server.convert_time( + arguments["source_timezone"], + arguments["time"], + arguments["target_timezone"], + ) + case _: + raise ValueError(f"Unknown tool: {name}") return [TextContent(type="text", text=json.dumps(result, indent=2))] - + except Exception as e: - raise ValueError(f"Error processing time query: {str(e)}") + raise ValueError(f"Error processing mcp-server-time query: {str(e)}") options = server.create_initialization_options() async with stdio_server() as (read_stream, write_stream): diff --git a/src/time/uv.lock b/src/time/uv.lock index d74ecc2d..54eb3ccc 100644 --- a/src/time/uv.lock +++ b/src/time/uv.lock @@ -64,6 +64,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, ] +[[package]] +name = "freezegun" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/ef/722b8d71ddf4d48f25f6d78aa2533d505bf3eec000a7cacb8ccc8de61f2f/freezegun-1.5.1.tar.gz", hash = "sha256:b29dedfcda6d5e8e083ce71b2b542753ad48cfec44037b3fc79702e2980a89e9", size = 33697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/0b/0d7fee5919bccc1fdc1c2a7528b98f65c6f69b223a3fd8f809918c142c36/freezegun-1.5.1-py3-none-any.whl", hash = "sha256:bf111d7138a8abe55ab48a71755673dbaa4ab87f4cff5634a4442dfec34c15f1", size = 17569 }, +] + [[package]] name = "h11" version = "0.14.0" @@ -120,6 +132,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, ] +[[package]] +name = "iniconfig" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, +] + [[package]] name = "mcp" version = "1.0.0" @@ -139,7 +160,7 @@ wheels = [ [[package]] name = "mcp-server-time" -version = "0.5.1" +version = "0.5.1rc3" source = { editable = "." } dependencies = [ { name = "mcp" }, @@ -150,7 +171,9 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "freezegun" }, { name = "pyright" }, + { name = "pytest" }, { name = "ruff" }, ] @@ -164,7 +187,9 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "freezegun", specifier = ">=1.5.1" }, { name = "pyright", specifier = ">=1.1.389" }, + { name = "pytest", specifier = ">=8.3.3" }, { name = "ruff", specifier = ">=0.7.3" }, ] @@ -177,6 +202,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, ] +[[package]] +name = "packaging" +version = "24.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, +] + [[package]] name = "pydantic" version = "2.10.1" @@ -279,6 +322,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1b/26/c288cabf8cfc5a27e1aa9e5029b7682c0f920b8074f45d22bf844314d66a/pyright-1.1.389-py3-none-any.whl", hash = "sha256:41e9620bba9254406dc1f621a88ceab5a88af4c826feb4f614d95691ed243a60", size = 18581 }, ] +[[package]] +name = "pytest" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/6c/62bbd536103af674e227c41a8f3dcd022d591f6eed5facb5a0f31ee33bbc/pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181", size = 1442487 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/77/7440a06a8ead44c7757a64362dd22df5760f9b12dc5f11b6188cd2fc27a0/pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2", size = 342341 }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, +] + [[package]] name = "pytz" version = "2024.2" @@ -313,6 +385,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/bc/cc8a6a5ca4960b226dc15dd8fb511dd11f2014ff89d325c0b9b9faa9871f/ruff-0.8.0-py3-none-win_arm64.whl", hash = "sha256:ba93e6294e9a737cd726b74b09a6972e36bb511f9a102f1d9a7e1ce94dd206a6", size = 8939733 }, ] +[[package]] +name = "six" +version = "1.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/71/39/171f1c67cd00715f190ba0b100d606d440a28c93c7714febeca8b79af85e/six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", size = 34041 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254", size = 11053 }, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -348,6 +429,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/96/00/2b325970b3060c7cecebab6d295afe763365822b1306a12eeab198f74323/starlette-0.41.3-py3-none-any.whl", hash = "sha256:44cedb2b7c77a9de33a8b74b2b90e9f50d11fcf25d8270ea525ad71a25374ff7", size = 73225 }, ] +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, +] + [[package]] name = "typing-extensions" version = "4.12.2" From 774cd0bd8eba1d66040b40cad73c1f267a5c7b86 Mon Sep 17 00:00:00 2001 From: Mariusz Korzekwa Date: Thu, 28 Nov 2024 21:39:50 +0100 Subject: [PATCH 4/5] Add time server tests --- src/time/test/time_server_test.py | 495 ++++++++++++++++++++++++++++++ 1 file changed, 495 insertions(+) create mode 100644 src/time/test/time_server_test.py diff --git a/src/time/test/time_server_test.py b/src/time/test/time_server_test.py new file mode 100644 index 00000000..74313c78 --- /dev/null +++ b/src/time/test/time_server_test.py @@ -0,0 +1,495 @@ +import os + +from freezegun import freeze_time +import pytest + +from mcp_server_time.server import TimeServer + + +@pytest.mark.parametrize( + "test_time,timezone,expected", + [ + # UTC+1 non-DST + ( + "2024-01-01 12:00:00+00:00", + "Europe/Warsaw", + { + "timezone": "Europe/Warsaw", + "datetime": "2024-01-01T13:00:00+01:00", + "is_dst": False, + }, + ), + # UTC non-DST + ( + "2024-01-01 12:00:00+00:00", + "Europe/London", + { + "timezone": "Europe/London", + "datetime": "2024-01-01T12:00:00+00:00", + "is_dst": False, + }, + ), + # UTC-5 non-DST + ( + "2024-01-01 12:00:00-00:00", + "America/New_York", + { + "timezone": "America/New_York", + "datetime": "2024-01-01T07:00:00-05:00", + "is_dst": False, + }, + ), + # UTC+1 DST + ( + "2024-03-31 12:00:00+00:00", + "Europe/Warsaw", + { + "timezone": "Europe/Warsaw", + "datetime": "2024-03-31T14:00:00+02:00", + "is_dst": True, + }, + ), + # UTC DST + ( + "2024-03-31 12:00:00+00:00", + "Europe/London", + { + "timezone": "Europe/London", + "datetime": "2024-03-31T13:00:00+01:00", + "is_dst": True, + }, + ), + # UTC-5 DST + ( + "2024-03-31 12:00:00-00:00", + "America/New_York", + { + "timezone": "America/New_York", + "datetime": "2024-03-31T08:00:00-04:00", + "is_dst": True, + }, + ), + ], +) +def test_get_current_time(test_time, timezone, expected): + with freeze_time(test_time): + time_server = TimeServer() + result = time_server.get_current_time(timezone) + assert result.timezone == expected["timezone"] + assert result.datetime == expected["datetime"] + assert result.is_dst == expected["is_dst"] + + +def test_get_current_time_with_invalid_timezone(): + time_server = TimeServer() + with pytest.raises(ValueError, match=r"Unknown timezone: 'Invalid/Timezone'"): + time_server.get_current_time("Invalid/Timezone") + + +@pytest.mark.parametrize( + "source_tz,time_str,target_tz,expected_error", + [ + ( + "invalid_tz", + "12:00", + "Europe/London", + "Unknown source timezone: 'invalid_tz'", + ), + ( + "Europe/Warsaw", + "12:00", + "invalid_tz", + "Unknown target timezone: 'invalid_tz'", + ), + ( + "Europe/Warsaw", + "25:00", + "Europe/London", + "Invalid time format. Expected HH:MM [24-hour format]", + ), + ], +) +def test_convert_time_errors(source_tz, time_str, target_tz, expected_error): + time_server = TimeServer() + with pytest.raises(ValueError, match=expected_error): + time_server.convert_time(source_tz, time_str, target_tz) + + +@pytest.mark.parametrize( + "test_time,source_tz,time_str,target_tz,expected", + [ + # Basic case: Standard time conversion between Warsaw and London (1 hour difference) + # Warsaw is UTC+1, London is UTC+0 + ( + "2024-01-01 00:00:00+00:00", + "Europe/Warsaw", + "12:00", + "Europe/London", + { + "source": { + "timezone": "Europe/Warsaw", + "datetime": "2024-01-01T12:00:00+01:00", + "is_dst": False, + }, + "target": { + "timezone": "Europe/London", + "datetime": "2024-01-01T11:00:00+00:00", + "is_dst": False, + }, + "time_difference": "-1.0h", + }, + ), + # Reverse case of above: London to Warsaw conversion + # Shows how time difference is positive when going east + ( + "2024-01-01 00:00:00+00:00", + "Europe/London", + "12:00", + "Europe/Warsaw", + { + "source": { + "timezone": "Europe/London", + "datetime": "2024-01-01T12:00:00+00:00", + "is_dst": False, + }, + "target": { + "timezone": "Europe/Warsaw", + "datetime": "2024-01-01T13:00:00+01:00", + "is_dst": False, + }, + "time_difference": "+1.0h", + }, + ), + # Edge case: Different DST periods between Europe and USA + # Europe ends DST on Oct 27, while USA waits until Nov 3 + # This creates a one-week period where Europe is in standard time but USA still observes DST + ( + "2024-10-28 00:00:00+00:00", + "Europe/Warsaw", + "12:00", + "America/New_York", + { + "source": { + "timezone": "Europe/Warsaw", + "datetime": "2024-10-28T12:00:00+01:00", + "is_dst": False, + }, + "target": { + "timezone": "America/New_York", + "datetime": "2024-10-28T07:00:00-04:00", + "is_dst": True, + }, + "time_difference": "-5.0h", + }, + ), + # Follow-up to previous case: After both regions end DST + # Shows how time difference increases by 1 hour when USA also ends DST + ( + "2024-11-04 00:00:00+00:00", + "Europe/Warsaw", + "12:00", + "America/New_York", + { + "source": { + "timezone": "Europe/Warsaw", + "datetime": "2024-11-04T12:00:00+01:00", + "is_dst": False, + }, + "target": { + "timezone": "America/New_York", + "datetime": "2024-11-04T06:00:00-05:00", + "is_dst": False, + }, + "time_difference": "-6.0h", + }, + ), + # Edge case: Nepal's unusual UTC+5:45 offset + # One of the few time zones using 45-minute offset + ( + "2024-01-01 00:00:00+00:00", + "Europe/Warsaw", + "12:00", + "Asia/Kathmandu", + { + "source": { + "timezone": "Europe/Warsaw", + "datetime": "2024-01-01T12:00:00+01:00", + "is_dst": False, + }, + "target": { + "timezone": "Asia/Kathmandu", + "datetime": "2024-01-01T16:45:00+05:45", + "is_dst": False, + }, + "time_difference": "+4.75h", + }, + ), + # Reverse case for Nepal + # Demonstrates how 45-minute offset works in opposite direction + ( + "2024-01-01 00:00:00+00:00", + "Asia/Kathmandu", + "12:00", + "Europe/Warsaw", + { + "source": { + "timezone": "Asia/Kathmandu", + "datetime": "2024-01-01T12:00:00+05:45", + "is_dst": False, + }, + "target": { + "timezone": "Europe/Warsaw", + "datetime": "2024-01-01T07:15:00+01:00", + "is_dst": False, + }, + "time_difference": "-4.75h", + }, + ), + # Edge case: Lord Howe Island's unique DST rules + # One of the few places using 30-minute DST shift + # During summer (DST), they use UTC+11 + ( + "2024-01-01 00:00:00+00:00", + "Europe/Warsaw", + "12:00", + "Australia/Lord_Howe", + { + "source": { + "timezone": "Europe/Warsaw", + "datetime": "2024-01-01T12:00:00+01:00", + "is_dst": False, + }, + "target": { + "timezone": "Australia/Lord_Howe", + "datetime": "2024-01-01T22:00:00+11:00", + "is_dst": True, + }, + "time_difference": "+10.0h", + }, + ), + # Second Lord Howe Island case: During their standard time + # Shows transition to UTC+10:30 after DST ends + ( + "2024-04-07 00:00:00+00:00", + "Europe/Warsaw", + "12:00", + "Australia/Lord_Howe", + { + "source": { + "timezone": "Europe/Warsaw", + "datetime": "2024-04-07T12:00:00+02:00", + "is_dst": True, + }, + "target": { + "timezone": "Australia/Lord_Howe", + "datetime": "2024-04-07T20:30:00+10:30", + "is_dst": False, + }, + "time_difference": "+8.5h", + }, + ), + # Edge case: Date line crossing with Samoa + # Demonstrates how a single time conversion can result in a date change + # Samoa is UTC+13, creating almost a full day difference with Warsaw + ( + "2024-01-01 00:00:00+00:00", + "Europe/Warsaw", + "23:00", + "Pacific/Apia", + { + "source": { + "timezone": "Europe/Warsaw", + "datetime": "2024-01-01T23:00:00+01:00", + "is_dst": False, + }, + "target": { + "timezone": "Pacific/Apia", + "datetime": "2024-01-02T11:00:00+13:00", + "is_dst": False, + }, + "time_difference": "+12.0h", + }, + ), + # Edge case: Iran's unusual half-hour offset + # Demonstrates conversion with Iran's UTC+3:30 timezone + ( + "2024-03-21 00:00:00+00:00", + "Europe/Warsaw", + "12:00", + "Asia/Tehran", + { + "source": { + "timezone": "Europe/Warsaw", + "datetime": "2024-03-21T12:00:00+01:00", + "is_dst": False, + }, + "target": { + "timezone": "Asia/Tehran", + "datetime": "2024-03-21T14:30:00+03:30", + "is_dst": False, + }, + "time_difference": "+2.5h", + }, + ), + # Edge case: Venezuela's unusual -4:30 offset (historical) + # In 2016, Venezuela moved from -4:30 to -4:00 + # Useful for testing historical dates + ( + "2016-04-30 00:00:00+00:00", # Just before the change + "Europe/Warsaw", + "12:00", + "America/Caracas", + { + "source": { + "timezone": "Europe/Warsaw", + "datetime": "2016-04-30T12:00:00+02:00", + "is_dst": True, + }, + "target": { + "timezone": "America/Caracas", + "datetime": "2016-04-30T05:30:00-04:30", + "is_dst": False, + }, + "time_difference": "-6.5h", + }, + ), + # Edge case: Israel's variable DST + # Israel's DST changes don't follow a fixed pattern + # They often change dates year-to-year based on Hebrew calendar + ( + "2024-10-27 00:00:00+00:00", + "Europe/Warsaw", + "12:00", + "Asia/Jerusalem", + { + "source": { + "timezone": "Europe/Warsaw", + "datetime": "2024-10-27T12:00:00+01:00", + "is_dst": False, + }, + "target": { + "timezone": "Asia/Jerusalem", + "datetime": "2024-10-27T13:00:00+02:00", + "is_dst": False, + }, + "time_difference": "+1.0h", + }, + ), + # Edge case: Antarctica/Troll station + # Only timezone that uses UTC+0 in winter and UTC+2 in summer + # One of the few zones with exactly 2 hours DST difference + ( + "2024-03-31 00:00:00+00:00", + "Europe/Warsaw", + "12:00", + "Antarctica/Troll", + { + "source": { + "timezone": "Europe/Warsaw", + "datetime": "2024-03-31T12:00:00+02:00", + "is_dst": True, + }, + "target": { + "timezone": "Antarctica/Troll", + "datetime": "2024-03-31T12:00:00+02:00", + "is_dst": True, + }, + "time_difference": "+0.0h", + }, + ), + # Edge case: Kiribati date line anomaly + # After skipping Dec 31, 1994, eastern Kiribati is UTC+14 + # The furthest forward timezone in the world + ( + "2024-01-01 00:00:00+00:00", + "Europe/Warsaw", + "23:00", + "Pacific/Kiritimati", + { + "source": { + "timezone": "Europe/Warsaw", + "datetime": "2024-01-01T23:00:00+01:00", + "is_dst": False, + }, + "target": { + "timezone": "Pacific/Kiritimati", + "datetime": "2024-01-02T12:00:00+14:00", + "is_dst": False, + }, + "time_difference": "+13.0h", + }, + ), + # Edge case: Chatham Islands, New Zealand + # Uses unusual 45-minute offset AND observes DST + # UTC+12:45 in standard time, UTC+13:45 in DST + ( + "2024-01-01 00:00:00+00:00", + "Europe/Warsaw", + "12:00", + "Pacific/Chatham", + { + "source": { + "timezone": "Europe/Warsaw", + "datetime": "2024-01-01T12:00:00+01:00", + "is_dst": False, + }, + "target": { + "timezone": "Pacific/Chatham", + "datetime": "2024-01-02T00:45:00+13:45", + "is_dst": True, + }, + "time_difference": "+12.75h", + }, + ), + ], +) +def test_convert_time(test_time, source_tz, time_str, target_tz, expected): + with freeze_time(test_time): + time_server = TimeServer() + result = time_server.convert_time(source_tz, time_str, target_tz) + + assert result.source.timezone == expected["source"]["timezone"] + assert result.target.timezone == expected["target"]["timezone"] + assert result.source.datetime == expected["source"]["datetime"] + assert result.target.datetime == expected["target"]["datetime"] + assert result.source.is_dst == expected["source"]["is_dst"] + assert result.target.is_dst == expected["target"]["is_dst"] + assert result.time_difference == expected["time_difference"] + + +# @pytest.mark.anyio +# async def test_call_tool(mock_forecast_response): +# class Response(): +# def raise_for_status(self): +# pass + +# def json(self): +# return mock_forecast_response + +# class AsyncClient(): +# def __aenter__(self): +# return self + +# async def __aexit__(self, *exc_info): +# pass + +# async def get(self, *args, **kwargs): +# return Response() + +# with patch('httpx.AsyncClient', new=AsyncClient) as mock_client: +# result = await call_tool("get_forecast", {"city": "London", "days": 2}) + +# assert len(result) == 1 +# assert result[0].type == "text" +# forecast_data = json.loads(result[0].text) +# assert len(forecast_data) == 1 +# assert forecast_data[0]["temperature"] == 18.5 +# assert forecast_data[0]["conditions"] == "sunny" + + +# @pytest.mark.anyio +# async def test_list_tools(): +# tools = await list_tools() +# assert len(tools) == 1 +# assert tools[0].name == "get_forecast" +# assert "city" in tools[0].inputSchema["properties"] From d37ce3cc5145faa9ef85dae30d5b10799d722bf6 Mon Sep 17 00:00:00 2001 From: Mariusz Korzekwa Date: Thu, 28 Nov 2024 21:46:36 +0100 Subject: [PATCH 5/5] Fix uv lock for time-server, update Readme --- src/time/README.md | 57 ++++++-------------------- src/time/pyproject.toml | 3 +- src/time/src/mcp_server_time/server.py | 16 +++----- src/time/test/time_server_test.py | 40 +----------------- src/time/uv.lock | 29 +------------ 5 files changed, 21 insertions(+), 124 deletions(-) diff --git a/src/time/README.md b/src/time/README.md index 109ecddc..8f80e415 100644 --- a/src/time/README.md +++ b/src/time/README.md @@ -5,8 +5,8 @@ A Model Context Protocol server that provides time and timezone conversion capab ### Available Tools - `get_current_time` - Get current time in a specific timezone or system timezone. - - Optional argument: `timezone` (string): IANA timezone name (e.g., 'America/New_York', 'Europe/London') - - If timezone is not provided, returns time in system timezone + - Required arguments: + - `timezone` (string): IANA timezone name (e.g., 'America/New_York', 'Europe/London') - `convert_time` - Convert time between timezones. - Required arguments: @@ -111,45 +111,25 @@ Example: ## Example Interactions -1. Get current time (using system timezone): -```json -{ - "name": "get_current_time", - "arguments": {} -} -``` -Response: -```json -{ - "timezone": "Europe/London", - "time": "14:30 BST", - "date": "2024-11-25", - "full_datetime": "2024-11-25 14:30:00 BST", - "is_dst": true -} -``` - -2. Get current time in specific timezone: +1. Get current time: ```json { "name": "get_current_time", "arguments": { - "timezone": "America/New_York" + "timezone": "Europe/Warsaw" } } ``` Response: ```json { - "timezone": "America/New_York", - "time": "09:30 EDT", - "date": "2024-11-25", - "full_datetime": "2024-11-25 09:30:00 EDT", - "is_dst": true + "timezone": "Europe/Warsaw", + "datetime": "2024-01-01T13:00:00+01:00", + "is_dst": false } ``` -3. Convert time between timezones: +2. Convert time between timezones: ```json { "name": "convert_time", @@ -165,31 +145,18 @@ Response: { "source": { "timezone": "America/New_York", - "time": "16:30 EDT", - "date": "2024-11-25" + "datetime": "2024-01-01T12:30:00-05:00", + "is_dst": false }, "target": { "timezone": "Asia/Tokyo", - "time": "05:30 JST", - "date": "2024-11-26" + "datetime": "2024-01-01T12:30:00+09:00", + "is_dst": false }, "time_difference": "+13.0h", - "date_changed": true, - "day_relation": "next day" } ``` -## Tips for Using IANA Timezone Names - -Common timezone formats: -- North America: `America/New_York`, `America/Los_Angeles`, `America/Chicago` -- Europe: `Europe/London`, `Europe/Paris`, `Europe/Berlin` -- Asia: `Asia/Tokyo`, `Asia/Shanghai`, `Asia/Dubai` -- Pacific: `Pacific/Auckland`, `Pacific/Honolulu` -- Australia: `Australia/Sydney`, `Australia/Melbourne` - -The server will automatically detect and use your system timezone if no specific timezone is provided. - ## Debugging You can use the MCP inspector to debug the server. For uvx installations: diff --git a/src/time/pyproject.toml b/src/time/pyproject.toml index d0700778..c9c7ff67 100644 --- a/src/time/pyproject.toml +++ b/src/time/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcp-server-time" -version = "0.5.1.pre3" +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" @@ -33,5 +33,4 @@ dev-dependencies = [ "freezegun>=1.5.1", "pyright>=1.1.389", "pytest>=8.3.3", - "ruff>=0.7.3", ] diff --git a/src/time/src/mcp_server_time/server.py b/src/time/src/mcp_server_time/server.py index b3e5e795..d4a302ca 100644 --- a/src/time/src/mcp_server_time/server.py +++ b/src/time/src/mcp_server_time/server.py @@ -1,5 +1,4 @@ -from dataclasses import dataclass -from datetime import datetime, timedelta +from datetime import datetime from enum import Enum import json from typing import Sequence @@ -35,13 +34,10 @@ 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() class TimeServer: - def __init__(self, local_tz_override: str | None = None): - self.local_tz = ( - pytz.timezone(local_tz_override) if local_tz_override else get_localzone() - ) - def get_current_time(self, timezone_name: str) -> TimeResult: """Get current time in specified timezone""" try: @@ -109,8 +105,8 @@ def convert_time( async def serve(local_timezone: str | None = None) -> None: server = Server("mcp-time") - time_server = TimeServer(local_timezone) - local_tz = str(time_server.local_tz) + time_server = TimeServer() + local_tz = str(get_local_tz(local_timezone)) @server.list_tools() async def list_tools() -> list[Tool]: @@ -183,7 +179,7 @@ async def call_tool( case _: raise ValueError(f"Unknown tool: {name}") - return [TextContent(type="text", text=json.dumps(result, 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)}") diff --git a/src/time/test/time_server_test.py b/src/time/test/time_server_test.py index 74313c78..09c49eb7 100644 --- a/src/time/test/time_server_test.py +++ b/src/time/test/time_server_test.py @@ -3,7 +3,7 @@ from freezegun import freeze_time import pytest -from mcp_server_time.server import TimeServer +from mcp_server_time.server import TimeServer, serve @pytest.mark.parametrize( @@ -455,41 +455,3 @@ def test_convert_time(test_time, source_tz, time_str, target_tz, expected): assert result.source.is_dst == expected["source"]["is_dst"] assert result.target.is_dst == expected["target"]["is_dst"] assert result.time_difference == expected["time_difference"] - - -# @pytest.mark.anyio -# async def test_call_tool(mock_forecast_response): -# class Response(): -# def raise_for_status(self): -# pass - -# def json(self): -# return mock_forecast_response - -# class AsyncClient(): -# def __aenter__(self): -# return self - -# async def __aexit__(self, *exc_info): -# pass - -# async def get(self, *args, **kwargs): -# return Response() - -# with patch('httpx.AsyncClient', new=AsyncClient) as mock_client: -# result = await call_tool("get_forecast", {"city": "London", "days": 2}) - -# assert len(result) == 1 -# assert result[0].type == "text" -# forecast_data = json.loads(result[0].text) -# assert len(forecast_data) == 1 -# assert forecast_data[0]["temperature"] == 18.5 -# assert forecast_data[0]["conditions"] == "sunny" - - -# @pytest.mark.anyio -# async def test_list_tools(): -# tools = await list_tools() -# assert len(tools) == 1 -# assert tools[0].name == "get_forecast" -# assert "city" in tools[0].inputSchema["properties"] diff --git a/src/time/uv.lock b/src/time/uv.lock index 54eb3ccc..a7b0aa11 100644 --- a/src/time/uv.lock +++ b/src/time/uv.lock @@ -160,7 +160,7 @@ wheels = [ [[package]] name = "mcp-server-time" -version = "0.5.1rc3" +version = "0.5.1" source = { editable = "." } dependencies = [ { name = "mcp" }, @@ -174,7 +174,6 @@ dev = [ { name = "freezegun" }, { name = "pyright" }, { name = "pytest" }, - { name = "ruff" }, ] [package.metadata] @@ -190,7 +189,6 @@ dev = [ { name = "freezegun", specifier = ">=1.5.1" }, { name = "pyright", specifier = ">=1.1.389" }, { name = "pytest", specifier = ">=8.3.3" }, - { name = "ruff", specifier = ">=0.7.3" }, ] [[package]] @@ -360,31 +358,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/11/c3/005fcca25ce078d2cc29fd559379817424e94885510568bc1bc53d7d5846/pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725", size = 508002 }, ] -[[package]] -name = "ruff" -version = "0.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/d6/a2373f3ba7180ddb44420d2a9d1f1510e1a4d162b3d27282bedcb09c8da9/ruff-0.8.0.tar.gz", hash = "sha256:a7ccfe6331bf8c8dad715753e157457faf7351c2b69f62f32c165c2dbcbacd44", size = 3276537 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/77/e889ee3ce7fd8baa3ed1b77a03b9fb8ec1be68be1418261522fd6a5405e0/ruff-0.8.0-py3-none-linux_armv6l.whl", hash = "sha256:fcb1bf2cc6706adae9d79c8d86478677e3bbd4ced796ccad106fd4776d395fea", size = 10518283 }, - { url = "https://files.pythonhosted.org/packages/da/c8/0a47de01edf19fb22f5f9b7964f46a68d0bdff20144d134556ffd1ba9154/ruff-0.8.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:295bb4c02d58ff2ef4378a1870c20af30723013f441c9d1637a008baaf928c8b", size = 10317691 }, - { url = "https://files.pythonhosted.org/packages/41/17/9885e4a0eeae07abd2a4ebabc3246f556719f24efa477ba2739146c4635a/ruff-0.8.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7b1f1c76b47c18fa92ee78b60d2d20d7e866c55ee603e7d19c1e991fad933a9a", size = 9940999 }, - { url = "https://files.pythonhosted.org/packages/3e/cd/46b6f7043597eb318b5f5482c8ae8f5491cccce771e85f59d23106f2d179/ruff-0.8.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eb0d4f250a7711b67ad513fde67e8870109e5ce590a801c3722580fe98c33a99", size = 10772437 }, - { url = "https://files.pythonhosted.org/packages/5d/87/afc95aeb8bc78b1d8a3461717a4419c05aa8aa943d4c9cbd441630f85584/ruff-0.8.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e55cce9aa93c5d0d4e3937e47b169035c7e91c8655b0974e61bb79cf398d49c", size = 10299156 }, - { url = "https://files.pythonhosted.org/packages/65/fa/04c647bb809c4d65e8eae1ed1c654d9481b21dd942e743cd33511687b9f9/ruff-0.8.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f4cd64916d8e732ce6b87f3f5296a8942d285bbbc161acee7fe561134af64f9", size = 11325819 }, - { url = "https://files.pythonhosted.org/packages/90/26/7dad6e7d833d391a8a1afe4ee70ca6f36c4a297d3cca83ef10e83e9aacf3/ruff-0.8.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c5c1466be2a2ebdf7c5450dd5d980cc87c8ba6976fb82582fea18823da6fa362", size = 12023927 }, - { url = "https://files.pythonhosted.org/packages/24/a0/be5296dda6428ba8a13bda8d09fbc0e14c810b485478733886e61597ae2b/ruff-0.8.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2dabfd05b96b7b8f2da00d53c514eea842bff83e41e1cceb08ae1966254a51df", size = 11589702 }, - { url = "https://files.pythonhosted.org/packages/26/3f/7602eb11d2886db545834182a9dbe500b8211fcbc9b4064bf9d358bbbbb4/ruff-0.8.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:facebdfe5a5af6b1588a1d26d170635ead6892d0e314477e80256ef4a8470cf3", size = 12782936 }, - { url = "https://files.pythonhosted.org/packages/4c/5d/083181bdec4ec92a431c1291d3fff65eef3ded630a4b55eb735000ef5f3b/ruff-0.8.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87a8e86bae0dbd749c815211ca11e3a7bd559b9710746c559ed63106d382bd9c", size = 11138488 }, - { url = "https://files.pythonhosted.org/packages/b7/23/c12cdef58413cee2436d6a177aa06f7a366ebbca916cf10820706f632459/ruff-0.8.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:85e654f0ded7befe2d61eeaf3d3b1e4ef3894469cd664ffa85006c7720f1e4a2", size = 10744474 }, - { url = "https://files.pythonhosted.org/packages/29/61/a12f3b81520083cd7c5caa24ba61bb99fd1060256482eff0ef04cc5ccd1b/ruff-0.8.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:83a55679c4cb449fa527b8497cadf54f076603cc36779b2170b24f704171ce70", size = 10369029 }, - { url = "https://files.pythonhosted.org/packages/08/2a/c013f4f3e4a54596c369cee74c24870ed1d534f31a35504908b1fc97017a/ruff-0.8.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:812e2052121634cf13cd6fddf0c1871d0ead1aad40a1a258753c04c18bb71bbd", size = 10867481 }, - { url = "https://files.pythonhosted.org/packages/d5/f7/685b1e1d42a3e94ceb25eab23c70bdd8c0ab66a43121ef83fe6db5a58756/ruff-0.8.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:780d5d8523c04202184405e60c98d7595bdb498c3c6abba3b6d4cdf2ca2af426", size = 11237117 }, - { url = "https://files.pythonhosted.org/packages/03/20/401132c0908e8837625e3b7e32df9962e7cd681a4df1e16a10e2a5b4ecda/ruff-0.8.0-py3-none-win32.whl", hash = "sha256:5fdb6efecc3eb60bba5819679466471fd7d13c53487df7248d6e27146e985468", size = 8783511 }, - { url = "https://files.pythonhosted.org/packages/1d/5c/4d800fca7854f62ad77f2c0d99b4b585f03e2d87a6ec1ecea85543a14a3c/ruff-0.8.0-py3-none-win_amd64.whl", hash = "sha256:582891c57b96228d146725975fbb942e1f30a0c4ba19722e692ca3eb25cc9b4f", size = 9559876 }, - { url = "https://files.pythonhosted.org/packages/5b/bc/cc8a6a5ca4960b226dc15dd8fb511dd11f2014ff89d325c0b9b9faa9871f/ruff-0.8.0-py3-none-win_arm64.whl", hash = "sha256:ba93e6294e9a737cd726b74b09a6972e36bb511f9a102f1d9a7e1ce94dd206a6", size = 8939733 }, -] - [[package]] name = "six" version = "1.16.0"