├── docs ├── screenshot-0.png ├── screenshot-5a.png ├── screenshot-5b.png └── screenshot-5c.png ├── .replit ├── Pipfile ├── .gitignore ├── README.md ├── tests └── test_passage_of_time_mcp.py ├── LICENSE ├── passage_of_time_mcp.py └── Pipfile.lock /docs/screenshot-0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlumbroso/passage-of-time-mcp/HEAD/docs/screenshot-0.png -------------------------------------------------------------------------------- /docs/screenshot-5a.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlumbroso/passage-of-time-mcp/HEAD/docs/screenshot-5a.png -------------------------------------------------------------------------------- /docs/screenshot-5b.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlumbroso/passage-of-time-mcp/HEAD/docs/screenshot-5b.png -------------------------------------------------------------------------------- /docs/screenshot-5c.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlumbroso/passage-of-time-mcp/HEAD/docs/screenshot-5c.png -------------------------------------------------------------------------------- /.replit: -------------------------------------------------------------------------------- 1 | run = "pip install pipenv && pipenv install && pipenv run server" 2 | entrypoint = "passage_of_time_mcp.py" 3 | 4 | [nix] 5 | channel = "stable-23_11" 6 | 7 | [deployment] 8 | run = ["sh", "-c", "pip install pipenv && pipenv install && pipenv run server"] 9 | 10 | [[ports]] 11 | localPort = 8000 12 | externalPort = 80 13 | 14 | [env] 15 | PYTHON_VERSION = "3.12" -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | fastmcp = "*" 8 | asyncio = "*" 9 | pytz = "*" 10 | python-dateutil = "*" 11 | 12 | [dev-packages] 13 | pytest = "*" 14 | 15 | [requires] 16 | python_version = "3.12" 17 | 18 | [scripts] 19 | test = "pytest" 20 | server = "python passage_of_time_mcp.py" 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | #uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | 110 | # pdm 111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 112 | #pdm.lock 113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 114 | # in version control. 115 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 116 | .pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 121 | __pypackages__/ 122 | 123 | # Celery stuff 124 | celerybeat-schedule 125 | celerybeat.pid 126 | 127 | # SageMath parsed files 128 | *.sage.py 129 | 130 | # Environments 131 | .env 132 | .venv 133 | env/ 134 | venv/ 135 | ENV/ 136 | env.bak/ 137 | venv.bak/ 138 | 139 | # Spyder project settings 140 | .spyderproject 141 | .spyproject 142 | 143 | # Rope project settings 144 | .ropeproject 145 | 146 | # mkdocs documentation 147 | /site 148 | 149 | # mypy 150 | .mypy_cache/ 151 | .dmypy.json 152 | dmypy.json 153 | 154 | # Pyre type checker 155 | .pyre/ 156 | 157 | # pytype static type analyzer 158 | .pytype/ 159 | 160 | # Cython debug symbols 161 | cython_debug/ 162 | 163 | # PyCharm 164 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 165 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 166 | # and can be added to the global gitignore or merged into this file. For a more nuclear 167 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 168 | #.idea/ 169 | 170 | # Abstra 171 | # Abstra is an AI-powered process automation framework. 172 | # Ignore directories containing user credentials, local state, and settings. 173 | # Learn more at https://abstra.io/docs 174 | .abstra/ 175 | 176 | # Visual Studio Code 177 | # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore 178 | # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore 179 | # and can be added to the global gitignore or merged into this file. However, if you prefer, 180 | # you could uncomment the following to ignore the enitre vscode folder 181 | # .vscode/ 182 | 183 | # Ruff stuff: 184 | .ruff_cache/ 185 | 186 | # PyPI configuration file 187 | .pypirc 188 | 189 | # Cursor 190 | # Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to 191 | # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data 192 | # refer to https://docs.cursor.com/context/ignore-files 193 | .cursorignore 194 | .cursorindexingignore -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # "Passage of Time" Model Context Protocol (MCP) Server 🕐 2 | 3 | An MCP server that gives language models temporal awareness and time calculation abilities. Teaching LLMs the significance of the passage of time through collaborative tool development. 4 | 5 | ![Claude's Passage of Time Tools](docs/screenshot-0.png) 6 | 7 | ## 📖 The Story 8 | 9 | This project emerged from a philosophical question: "Can AI perceive the passage of time?" What started as an exploration of machine consciousness became a practical solution to a real problem - LLMs can't reliably calculate time differences. 10 | 11 | Instead of publishing a paper about how "silly" these models are at mental math, we decided to do what we've done for ourselves: **equip them with a calculator for time**. 12 | 13 | Through human-LLM collaboration, we discovered that with proper temporal tools, models can uncover surprising insights about conversation patterns, work rhythms, and the human experience of time. 14 | 15 | [Read the full story on Medium →](https://medium.com/@jeremie.lumbroso/teaching-ai-the-significance-of-the-passage-of-time-yes-that-one-106ad7d20957) 16 | 17 | ## 🚀 Quick Start 18 | 19 | ### Prerequisites 20 | 21 | - Python 3.12+ 22 | - pipenv (or pip) 23 | - An MCP-compatible client (Claude.ai, Continue.dev, etc.) 24 | 25 | ### Installation 26 | 27 | 1. Clone the repository: 28 | ```bash 29 | git clone https://github.com/jlumbroso/passage-of-time-mcp.git 30 | cd passage-of-time-mcp 31 | ``` 32 | 33 | 2. Install dependencies: 34 | ```bash 35 | pipenv install 36 | # or with pip: 37 | pip install fastmcp pytz 38 | ``` 39 | 40 | 3. Run the server: 41 | ```bash 42 | pipenv run server 43 | # or directly: 44 | pipenv run python passage_of_time_mcp.py 45 | ``` 46 | 47 | The server will start on `http://0.0.0.0:8000/sse`. 48 | 49 | ### Connecting to Claude.ai 50 | 51 | 1. In Claude.ai, go to Settings → Integrations 52 | 2. Click "Add integration" and select "Custom" 53 | 3. Enter the server URL (e.g., `https://your-server.ngrok-free.app/sse` if using ngrok, make sure to add `/sse` at the end) 54 | 4. Save and enable all the time-related tools 55 | 56 | > **Note**: For local development, you'll need to expose your server using ngrok or deploy it to a public URL. 57 | 58 | ## 🛠️ Available Tools 59 | 60 | ### Core Functions 61 | 62 | #### `current_datetime(timezone="America/New_York")` 63 | Returns the current date and time. The foundation of temporal awareness. 64 | 65 | ``` 66 | Returns: "2024-01-15 14:30:45 EST" 67 | ``` 68 | 69 | #### `time_difference(timestamp1, timestamp2, unit="auto")` 70 | Calculates the duration between two timestamps with human-readable output. 71 | 72 | ```python 73 | # Example response: 74 | { 75 | "seconds": 11401, 76 | "formatted": "3 hours, 10 minutes, 1 second", 77 | "requested_unit": 3.17, # if unit="hours" 78 | "is_negative": false 79 | } 80 | ``` 81 | 82 | #### `timestamp_context(timestamp)` 83 | Provides human context about a timestamp - is it weekend? Business hours? Dinner time? 84 | 85 | ```python 86 | # Example response: 87 | { 88 | "time_of_day": "evening", 89 | "day_of_week": "Saturday", 90 | "is_weekend": true, 91 | "is_business_hours": false, 92 | "typical_activity": "leisure_time", 93 | "relative_day": "today" 94 | } 95 | ``` 96 | 97 | #### `time_since(timestamp)` 98 | Calculates how long ago something happened with contextual descriptions. 99 | 100 | ```python 101 | # Example response: 102 | { 103 | "seconds": 7200, 104 | "formatted": "2 hours ago", 105 | "context": "earlier today" 106 | } 107 | ``` 108 | 109 | #### `parse_timestamp(timestamp)` 110 | Converts timestamps between different formats for maximum compatibility. 111 | 112 | ```python 113 | # Example response: 114 | { 115 | "iso": "2024-01-15T14:30:45-05:00", 116 | "unix": "1705343445", 117 | "human": "January 15, 2024 at 2:30 PM EST", 118 | "day_of_week": "Monday" 119 | } 120 | ``` 121 | 122 | #### `add_time(timestamp, duration, unit)` 123 | Adds or subtracts time with natural language descriptions. 124 | 125 | ```python 126 | # Example response: 127 | { 128 | "result": "2024-01-16 14:30:45", 129 | "iso": "2024-01-16T14:30:45-05:00", 130 | "description": "tomorrow at 2:30 PM" 131 | } 132 | ``` 133 | 134 | #### `format_duration(seconds, style="full")` 135 | Formats durations in various styles for different contexts. 136 | 137 | ```python 138 | # style="full": "2 hours, 30 minutes, 15 seconds" 139 | # style="compact": "2h 30m 15s" 140 | # style="minimal": "2:30:15" 141 | ``` 142 | 143 | ## 💡 Example Conversations 144 | 145 | ### Basic Time Awareness 146 | ``` 147 | Human: What time is it? 148 | Claude: [calls current_datetime] It's currently 3:45 PM EST on Monday, January 15, 2024. 149 | ``` 150 | 151 | ### Conversation Rhythm Analysis 152 | ``` 153 | Human: Let's have a discussion about AI consciousness. 154 | Claude: [calls current_datetime at start of each response] 155 | [After several exchanges] 156 | Human: What patterns do you notice in our conversation? 157 | Claude: Looking at the timestamps, I notice you're taking 30 seconds to 2 minutes between responses, suggesting active engagement. The 3-minute pause before your question about implementation shows deeper consideration... 158 | ``` 159 | 160 | ### Understanding Work Context 161 | ``` 162 | Human: [at 11:45 PM] Can you help me debug this code? 163 | Claude: [calls current_datetime, then timestamp_context] 164 | I see it's late night on a weekday. Given that it's approaching midnight, let me help you efficiently so you can get some rest. Here's a focused debugging approach... 165 | ``` 166 | 167 | ## 🏗️ Design Philosophy 168 | 169 | This server embodies several key principles: 170 | 171 | 1. **Cognitive Partnership**: We treat LLMs as cognitive partners who need proper tools, not black boxes to be dressed up. 172 | 173 | 2. **Collaborative Design**: The tool suite emerged from asking Claude what they needed, not imposing our assumptions. 174 | 175 | 3. **Human Context Matters**: Time isn't just numbers - it's about understanding human rhythms, work patterns, and social contexts. 176 | 177 | 4. **Practical Over Perfect**: We provide the tools models actually need, tested through real conversations. 178 | 179 | ## 🔧 Configuration 180 | 181 | ### Timezone Support 182 | The server defaults to `America/New_York` but supports all standard timezone names: 183 | - `UTC` 184 | - `US/Pacific` 185 | - `Europe/London` 186 | - `Asia/Tokyo` 187 | - etc. 188 | 189 | ### Timestamp Formats 190 | All timestamps must use one of these formats: 191 | - Full: `YYYY-MM-DD HH:MM:SS` (e.g., "2024-01-15 14:30:45") 192 | - Date only: `YYYY-MM-DD` (e.g., "2024-01-15") 193 | 194 | This strict formatting prevents ambiguity and ensures reliable calculations. 195 | 196 | ## 🚧 Known Issues & Future Work 197 | 198 | ### Current Limitations 199 | - The SSE transport is deprecated but currently most reliable 200 | - Server requires public URL for web-based clients 201 | - No persistent memory of past time calculations 202 | 203 | ### Roadmap 204 | - [ ] Migrate to modern `http-stream` transport 205 | - [ ] Add Docker support for easier deployment 206 | - [ ] Create browser extension for local development 207 | - [ ] Add configurable activity patterns per user 208 | - [ ] Support for calendar integration 209 | - [ ] Natural language time parsing ("next Tuesday", "in 3 hours") 210 | 211 | ## 🤝 Contributing 212 | 213 | This project emerged from human-LLM collaboration and welcomes more of the same! Whether you're contributing solo or with AI assistance, we value: 214 | 215 | 1. **Practical additions** - Tools that solve real temporal understanding problems 216 | 2. **Human context** - Features that help models understand how humans experience time 217 | 3. **Clear documentation** - Examples that show real-world usage 218 | 219 | ### Development Setup 220 | 221 | First, clone the repository: 222 | 223 | ```bash 224 | git clone https://github.com/jlumbroso/passage-of-time-mcp.git 225 | cd passage-of-time-mcp 226 | ``` 227 | 228 | Then, install dependencies (I am using `pipenv` because it simultaneously creates a virtual environment and installs package, but any pip-compatible tool will work): 229 | 230 | ```bash 231 | # Install dev dependencies 232 | pipenv install --dev 233 | 234 | # Run tests 235 | pipenv run test 236 | 237 | # Run server 238 | pipenv run server 239 | ``` 240 | 241 | This will start the server on `http://0.0.0.0:8000/sse` on your local computer. However, for web-based clients to connect to it, you will need to expose it to the internet using a service like [ngrok](https://ngrok.com/). 242 | 243 | Assuming you have ngrok installed, you can run `ngrok http 8000` to expose the server to the internet, and then use the provided URL in your MCP client. By default, ngrok will provide the endpoint to use in the form of `https://.ngrok-free.app/` in the Terminal: 244 | 245 | ```bash 246 | ❤️ ngrok? We're hiring https://ngrok.com/careers 247 | 248 | Session Status online 249 | Account Jérémie Lumbroso (Plan: Free) 250 | Update update available (version 3.23.1, Ctrl-U to update) 251 | Version 3.22.1 252 | Region United States (us) 253 | Latency 31ms 254 | Latency 1575ms 255 | Web Interface http://127.0.0.1:4040 256 | Forwarding https://37f9-2607-f470-6-1001-243b-bc5c-df2e-762.ngrok-free.app -> 257 | 258 | Connections ttl opn rt1 rt5 p50 p90 259 | 1756 0 0.01 0.03 5.32 61.51 260 | 261 | HTTP Requests 262 | ------------- 263 | 264 | 00:06:44.030 EDT POST /messages/ 202 Accepted 265 | 00:06:43.936 EDT POST /messages/ 202 Accepted 266 | 00:06:43.514 EDT GET /sse 200 OK 267 | 00:06:43.682 EDT POST /messages/ 202 Accepted 268 | 00:06:43.342 EDT POST /sse 405 Method Not Allowed 269 | ``` 270 | 271 | In my case, I used `https://37f9-2607-f470-6-1001-243b-bc5c-df2e-762.ngrok-free.app`, but since we are using the "SSE" transport method, the endpoint will have `/sse` appended at the end, so the final URL will be `https://37f9-2607-f470-6-1001-243b-bc5c-df2e-762.ngrok-free.app/sse`. 272 | 273 | Once this endpoint exists, you can add the MCP server as an integration to LLMs, such as Claude, following these instructions: 274 | 275 | ![Setting up the passage-of-time MCP server in Claude's interface - each tool comes with clear descriptions and permissions](docs/screenshot-5b.png) 276 | 277 | Once you've connected the MCP server to Claude.ai, you should start receiving queries locally: 278 | 279 | ```bash 280 | $ pipenv run server 281 | /Users/jlumbroso/.asdf/installs/python/3.12.4/lib/python3.12/asyncio/events.py:88: DeprecationWarning: The run_sse_async method is deprecated (as of 2.3.2). Use run_http_async for a modern (non-SSE) alternative, or create an SSE app with `fastmcp.server.http.create_sse_app` and run it directly. 282 | self._context.run(self._callback, *self._args) 283 | [06/16/25 19:18:04] INFO Starting MCP server 'Passage of Time' with transport 'sse' on http://0.0.0.0:8000/sse server.py:1219 284 | INFO: Started server process [11373] 285 | INFO: Waiting for application startup. 286 | INFO: Application startup complete. 287 | INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit) 288 | INFO: 34.162.142.92:0 - "POST /sse HTTP/1.1" 405 Method Not Allowed 289 | INFO: 34.162.142.92:0 - "GET /sse HTTP/1.1" 200 OK 290 | INFO: 34.162.142.92:0 - "POST /messages/?session_id=e21108cecbf646ffb7effe14dd856b3d HTTP/1.1" 202 Accepted 291 | INFO: 34.162.142.92:0 - "POST /messages/?session_id=e21108cecbf646ffb7effe14dd856b3d HTTP/1.1" 202 Accepted 292 | INFO: 34.162.142.92:0 - "POST /messages/?session_id=e21108cecbf646ffb7effe14dd856b3d HTTP/1.1" 202 Accepted 293 | INFO: 34.162.142.92:0 - "POST /messages/?session_id=e21108cecbf646ffb7effe14dd856b3d HTTP/1.1" 202 Accepted 294 | INFO: 34.162.142.92:0 - "POST /messages/?session_id=e21108cecbf646ffb7effe14dd856b3d HTTP/1.1" 202 Accepted 295 | INFO: 34.162.142.92:0 - "POST /messages/?session_id=e21108cecbf646ffb7effe14dd856b3d HTTP/1.1" 202 Accepted 296 | ``` 297 | 298 | Eventually, you will want to deploy this MCP server to a cloud provider such as Render.com, so your LLM doesn't have to contend with the unreliable nature of your local machine. 299 | 300 | ## 📝 License 301 | 302 | Mozilla Public License 2.0 - because good ideas should spread while staying open. 303 | 304 | ## 🙏 Acknowledgments 305 | 306 | - Created through extended collaboration between [Jérémie Lumbroso](https://github.com/jlumbroso) and Claude Opus 4.0 (Anthropic) 307 | - Inspired by the question: "Can AI perceive the passage of time?" 308 | - Built on [FastMCP](https://github.com/fastmcp/fastmcp) framework 309 | - Special thanks to the [Natural and Artificial Minds initiative](https://nam.ai.princeton.edu/) at Princeton University 310 | 311 | ## 📚 Further Reading 312 | 313 | - [Teaching AI "The Significance of the Passage of Time" - Medium Article](https://medium.com/@jeremie.lumbroso/teaching-ai-the-significance-of-the-passage-of-time-yes-that-one-106ad7d20957) 314 | - [We Can't Understand AI Using our Existing Vocabulary - Been Kim et al.](https://arxiv.org/abs/2502.07586) 315 | - [Model Context Protocol Documentation](https://modelcontextprotocol.io) 316 | 317 | --- 318 | 319 | *"We're not just building better LLM tools. We're teaching curious cognitive systems about what it means to be human—one timestamp at a time."* 320 | -------------------------------------------------------------------------------- /tests/test_passage_of_time_mcp.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from datetime import datetime, timedelta 3 | import pytz 4 | from unittest.mock import patch, MagicMock 5 | import sys 6 | import os 7 | 8 | # Import the actual implementation without mocking FastMCP 9 | # We'll extract the raw functions from the decorated versions 10 | 11 | # First, let's create a minimal FastMCP mock that preserves function behavior 12 | class MockFastMCP: 13 | def __init__(self, **kwargs): 14 | pass 15 | 16 | def tool(self): 17 | def decorator(func): 18 | # Return the original function unchanged 19 | return func 20 | return decorator 21 | 22 | def run_sse_async(self, **kwargs): 23 | pass 24 | 25 | # Replace fastmcp with our mock that preserves function behavior 26 | sys.modules['fastmcp'] = MagicMock() 27 | sys.modules['fastmcp'].FastMCP = MockFastMCP 28 | 29 | # Now import after setting up the mock 30 | sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 31 | import passage_of_time_mcp 32 | 33 | # Extract the actual functions 34 | current_datetime = passage_of_time_mcp.current_datetime 35 | time_difference = passage_of_time_mcp.time_difference 36 | time_since = passage_of_time_mcp.time_since 37 | parse_timestamp = passage_of_time_mcp.parse_timestamp 38 | add_time = passage_of_time_mcp.add_time 39 | timestamp_context = passage_of_time_mcp.timestamp_context 40 | format_duration = passage_of_time_mcp.format_duration 41 | 42 | 43 | class TestCurrentDatetime: 44 | def test_default_timezone(self): 45 | result = current_datetime() 46 | assert isinstance(result, str) 47 | # Check that it has date, time, and timezone components 48 | parts = result.split() 49 | assert len(parts) >= 3 50 | # Should contain NYC timezone (EST or EDT) 51 | assert any(tz in result for tz in ["EST", "EDT", "America/New_York"]) 52 | 53 | def test_utc_timezone(self): 54 | result = current_datetime("UTC") 55 | assert "UTC" in result 56 | 57 | def test_invalid_timezone(self): 58 | result = current_datetime("Invalid/Timezone") 59 | assert "Error: Unknown timezone" in result 60 | 61 | 62 | class TestTimeDifference: 63 | def test_simple_difference(self): 64 | result = time_difference( 65 | "2024-01-01 10:00:00", 66 | "2024-01-01 13:30:00" 67 | ) 68 | assert isinstance(result, dict) 69 | assert result["seconds"] == 12600 # 3.5 hours 70 | assert "3 hours, 30 minutes" in result["formatted"] 71 | assert result["is_negative"] is False 72 | 73 | def test_negative_difference(self): 74 | result = time_difference( 75 | "2024-01-01 13:30:00", 76 | "2024-01-01 10:00:00" 77 | ) 78 | assert result["seconds"] == -12600 79 | assert result["is_negative"] is True 80 | assert "-3 hours, 30 minutes" in result["formatted"] 81 | 82 | def test_with_specific_unit(self): 83 | result = time_difference( 84 | "2024-01-01 10:00:00", 85 | "2024-01-01 13:30:00", 86 | unit="hours" 87 | ) 88 | assert abs(result["requested_unit"] - 3.5) < 0.001 89 | 90 | def test_different_date_formats(self): 91 | # Test with standard format only now 92 | result = time_difference( 93 | "2024-01-01 10:00:00", 94 | "2024-01-01 13:30:00" 95 | ) 96 | assert result["seconds"] == 12600 97 | 98 | def test_invalid_format(self): 99 | # Test that non-standard formats return clear errors 100 | result = time_difference( 101 | "Jan 1, 2024 10:00 AM", 102 | "2024-01-01 13:30:00" 103 | ) 104 | assert "error" in result 105 | assert "Invalid timestamp format" in result["error"] 106 | assert "YYYY-MM-DD HH:MM:SS" in result["error"] 107 | 108 | def test_days_difference(self): 109 | result = time_difference( 110 | "2024-01-01", 111 | "2024-01-05" 112 | ) 113 | # Allow for floating point precision differences 114 | assert result["seconds"] == 345600 # 4 days 115 | assert "4 days" in result["formatted"] 116 | 117 | def test_invalid_format(self): 118 | # Test that non-standard formats return clear errors 119 | result = time_difference( 120 | "Jan 1, 2024 10:00 AM", 121 | "2024-01-01 13:30:00" 122 | ) 123 | assert "error" in result 124 | assert "Invalid timestamp format" in result["error"] 125 | assert "YYYY-MM-DD HH:MM:SS" in result["error"] 126 | 127 | def test_error_handling(self): 128 | result = time_difference( 129 | "invalid date", 130 | "2024-01-01" 131 | ) 132 | assert "error" in result 133 | 134 | 135 | class TestTimeSince: 136 | @patch('passage_of_time_mcp.datetime') 137 | def test_time_since_past(self, mock_datetime): 138 | # Create a proper mock for datetime 139 | real_datetime = datetime 140 | mock_datetime.now = MagicMock() 141 | mock_datetime.strptime = real_datetime.strptime 142 | 143 | # Set up the mock to return a specific time 144 | tz = pytz.timezone("America/New_York") 145 | mock_now = real_datetime(2024, 1, 10, 15, 0, 0, tzinfo=tz) 146 | mock_datetime.now.return_value = mock_now 147 | 148 | result = time_since("2024-01-10 14:00:00", timezone="America/New_York") 149 | assert isinstance(result, dict) 150 | assert abs(result["seconds"] - 3600) < 60 # Allow for small differences 151 | assert "hour" in result["formatted"] 152 | assert result["context"] in ["earlier today", "earlier"] 153 | 154 | @patch('passage_of_time_mcp.datetime') 155 | def test_time_since_yesterday(self, mock_datetime): 156 | real_datetime = datetime 157 | mock_datetime.now = MagicMock() 158 | mock_datetime.strptime = real_datetime.strptime 159 | 160 | tz = pytz.timezone("America/New_York") 161 | mock_now = real_datetime(2024, 1, 10, 15, 0, 0, tzinfo=tz) 162 | mock_datetime.now.return_value = mock_now 163 | 164 | result = time_since("2024-01-09 15:00:00", timezone="America/New_York") 165 | assert result["context"] == "yesterday" 166 | 167 | @patch('passage_of_time_mcp.datetime') 168 | def test_time_since_future(self, mock_datetime): 169 | real_datetime = datetime 170 | mock_datetime.now = MagicMock() 171 | mock_datetime.strptime = real_datetime.strptime 172 | 173 | tz = pytz.timezone("America/New_York") 174 | mock_now = real_datetime(2024, 1, 10, 15, 0, 0, tzinfo=tz) 175 | mock_datetime.now.return_value = mock_now 176 | 177 | result = time_since("2024-01-10 16:00:00", timezone="America/New_York") 178 | assert result["seconds"] < 0 179 | assert "from now" in result["formatted"] 180 | assert result["context"] == "in the future" 181 | 182 | 183 | class TestParseTimestamp: 184 | def test_basic_parsing(self): 185 | result = parse_timestamp("2024-01-15 14:30:00") 186 | assert isinstance(result, dict) 187 | assert result["date"] == "2024-01-15" 188 | assert result["time"] == "14:30:00" 189 | assert result["day_of_week"] == "Monday" 190 | assert "January 15, 2024" in result["human"] 191 | 192 | def test_standard_format_only(self): 193 | # Test that only standard format works 194 | result = parse_timestamp("2024-01-15 14:30:00") 195 | assert result["date"] == "2024-01-15" 196 | assert "14:30" in result["time"] 197 | 198 | def test_invalid_format_error(self): 199 | # Test that non-standard format returns error 200 | result = parse_timestamp("Jan 15th, 2024 at 2:30 PM") 201 | assert "error" in result 202 | assert "Invalid timestamp format" in result["error"] 203 | 204 | def test_timezone_conversion(self): 205 | result = parse_timestamp( 206 | "2024-01-15 14:30:00", 207 | source_timezone="UTC", 208 | target_timezone="America/New_York" 209 | ) 210 | # UTC 14:30 should be EST 09:30 211 | assert "09:30" in result["time"] 212 | 213 | def test_unix_timestamp(self): 214 | result = parse_timestamp("2024-01-15 00:00:00", target_timezone="UTC") 215 | unix_ts = int(result["unix"]) 216 | # Verify it's a reasonable Unix timestamp for that date 217 | assert unix_ts > 1700000000 # After Nov 2023 218 | assert unix_ts < 1800000000 # Before 2027 219 | 220 | def test_error_handling(self): 221 | result = parse_timestamp("not a valid date") 222 | assert "error" in result 223 | assert "Invalid timestamp format" in result["error"] 224 | assert "YYYY-MM-DD" in result["error"] # Check format hint is included 225 | 226 | 227 | class TestAddTime: 228 | def test_add_hours(self): 229 | result = add_time("2024-01-15 10:00:00", 3, "hours") 230 | assert isinstance(result, dict) 231 | assert "13:00:00" in result["result"] 232 | 233 | def test_add_days(self): 234 | result = add_time("2024-01-15", 5, "days") 235 | assert "2024-01-20" in result["result"] 236 | 237 | def test_subtract_time(self): 238 | result = add_time("2024-01-15 10:00:00", -2, "hours") 239 | assert "08:00:00" in result["result"] 240 | 241 | @patch('passage_of_time_mcp.datetime') 242 | def test_natural_language_tomorrow(self, mock_datetime): 243 | real_datetime = datetime 244 | 245 | # Mock datetime.now to return a specific time 246 | def mock_now(tz=None): 247 | if tz: 248 | return real_datetime(2024, 1, 15, 10, 0, 0, tzinfo=tz) 249 | return real_datetime(2024, 1, 15, 10, 0, 0) 250 | 251 | mock_datetime.now = mock_now 252 | mock_datetime.strptime = real_datetime.strptime 253 | 254 | result = add_time("2024-01-15 14:00:00", 1, "days") 255 | assert isinstance(result["description"], str) 256 | assert "tomorrow" in result["description"].lower() 257 | 258 | def test_add_weeks(self): 259 | result = add_time("2024-01-01", 2, "weeks") 260 | assert "2024-01-15" in result["result"] 261 | 262 | def test_error_handling(self): 263 | result = add_time("invalid date", 1, "days") 264 | assert "error" in result 265 | 266 | 267 | class TestTimestampContext: 268 | def test_morning_context(self): 269 | # Use standard format without timezone abbreviation 270 | result = timestamp_context("2024-01-15 08:30:00", timezone="America/New_York") 271 | assert isinstance(result, dict) 272 | assert result["time_of_day"] == "early_morning" 273 | assert result["typical_activity"] == "commute_time" 274 | assert result["hour_24"] == 8 275 | 276 | def test_lunch_time(self): 277 | # Use standard format without timezone abbreviation 278 | result = timestamp_context("2024-01-15 12:30:00", timezone="America/New_York") 279 | assert result["time_of_day"] == "afternoon" 280 | assert result["typical_activity"] == "lunch_time" 281 | 282 | def test_weekend_detection(self): 283 | # January 13, 2024 is a Saturday 284 | result = timestamp_context("2024-01-13 10:00:00") 285 | assert result["is_weekend"] is True 286 | assert result["is_business_hours"] is False 287 | assert result["day_of_week"] == "Saturday" 288 | 289 | def test_business_hours(self): 290 | # January 15, 2024 is a Monday 291 | result = timestamp_context("2024-01-15 14:00:00") 292 | assert result["is_weekend"] is False 293 | assert result["is_business_hours"] is True 294 | 295 | @patch('passage_of_time_mcp.datetime') 296 | def test_relative_day_today(self, mock_datetime): 297 | real_datetime = datetime 298 | 299 | # Mock datetime.now 300 | def mock_now(tz=None): 301 | if tz: 302 | return real_datetime(2024, 1, 15, 15, 0, 0, tzinfo=tz) 303 | return real_datetime(2024, 1, 15, 15, 0, 0) 304 | 305 | mock_datetime.now = mock_now 306 | mock_datetime.strptime = real_datetime.strptime 307 | 308 | result = timestamp_context("2024-01-15 10:00:00") 309 | assert result["relative_day"] == "today" 310 | 311 | def test_late_night(self): 312 | result = timestamp_context("2024-01-15 23:30:00") 313 | assert result["time_of_day"] == "late_night" 314 | assert result["typical_activity"] == "sleeping_time" 315 | 316 | def test_error_handling(self): 317 | result = timestamp_context("invalid date") 318 | assert "error" in result 319 | 320 | 321 | class TestFormatDuration: 322 | def test_full_format(self): 323 | result = format_duration(93784) # 1 day, 2 hours, 3 minutes, 4 seconds 324 | assert isinstance(result, str) 325 | assert "1 day" in result 326 | assert "2 hours" in result 327 | assert "3 minutes" in result 328 | assert "4 seconds" in result 329 | 330 | def test_compact_format(self): 331 | result = format_duration(93784, style="compact") 332 | assert result == "1d 2h 3m 4s" 333 | 334 | def test_minimal_format(self): 335 | result = format_duration(3665, style="minimal") # 1 hour, 1 minute, 5 seconds 336 | assert result == "1:01:05" 337 | 338 | def test_minimal_format_no_hours(self): 339 | result = format_duration(125, style="minimal") # 2 minutes, 5 seconds 340 | assert result == "2:05" 341 | 342 | def test_negative_duration(self): 343 | result = format_duration(-3600, style="full") 344 | assert result == "-1 hour" 345 | 346 | def test_zero_duration(self): 347 | result = format_duration(0, style="full") 348 | assert result == "0 seconds" 349 | 350 | def test_edge_cases(self): 351 | # Test singular vs plural 352 | assert "1 second" in format_duration(1) 353 | assert "2 seconds" in format_duration(2) 354 | assert "1 minute" in format_duration(60) 355 | assert "2 minutes" in format_duration(120) 356 | 357 | def test_error_handling(self): 358 | # Test with invalid input type 359 | result = format_duration("not a number") 360 | assert "Error" in result 361 | assert "must be a number" in result 362 | 363 | 364 | # Integration tests 365 | class TestIntegration: 366 | def test_parse_and_add(self): 367 | """Test parsing a timestamp and then adding time to it""" 368 | # Use standard format 369 | parsed = parse_timestamp("2024-01-15 15:00:00") 370 | 371 | # Skip if parsing failed 372 | if "error" in parsed: 373 | pytest.skip("Parsing failed") 374 | 375 | original_time = parsed["iso"] 376 | 377 | # Add 2 hours - need to use standard format 378 | result = add_time("2024-01-15 15:00:00", 2, "hours") 379 | 380 | # Verify the time was added correctly 381 | assert "17:00" in result["result"] or "5:00" in result["description"] 382 | 383 | def test_difference_and_format(self): 384 | """Test calculating difference and formatting it""" 385 | diff = time_difference( 386 | "2024-01-01 09:00:00", 387 | "2024-01-02 11:30:45" 388 | ) 389 | 390 | # Skip if difference calculation failed 391 | if "error" in diff: 392 | pytest.skip("Difference calculation failed") 393 | 394 | # Format the duration in different styles 395 | seconds = diff["seconds"] 396 | full = format_duration(seconds, "full") 397 | compact = format_duration(seconds, "compact") 398 | 399 | assert "1 day" in full 400 | assert "2 hours" in full 401 | assert "30 minutes" in full 402 | assert "1d 2h 30m 45s" == compact 403 | 404 | def test_context_and_time_since(self): 405 | """Test getting context and calculating time since""" 406 | # Get context for a timestamp 407 | context = timestamp_context("2024-01-15 09:00:00", timezone="America/New_York") 408 | 409 | assert context["time_of_day"] == "morning" # 9 AM is in the morning range 410 | assert context["is_business_hours"] is True 411 | assert context["typical_activity"] == "work_time" 412 | 413 | 414 | if __name__ == "__main__": 415 | pytest.main([__file__, "-v"]) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at https://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /passage_of_time_mcp.py: -------------------------------------------------------------------------------- 1 | from fastmcp import FastMCP 2 | import datetime 3 | import pytz 4 | import os 5 | from typing import Dict, Union, Optional, Literal 6 | from datetime import datetime, timedelta 7 | 8 | mcp = FastMCP( 9 | name="Perception of Passage of Time", 10 | instructions="""You now have access to current_datetime() and can pass along an optional timezone parameter (defaults to NYC). You should call this at the start of every response, so you can get an appreciation for the user's passage of time. 11 | 12 | To help you make computations about time, you have multiple other tools: 13 | - time_difference(): Calculate difference between two timestamps 14 | - time_since(): Calculate time elapsed since a timestamp 15 | - parse_timestamp(): Convert timestamp to multiple formats 16 | - add_time(): Add/subtract duration from a timestamp 17 | - timestamp_context(): Get contextual info about a timestamp 18 | - format_duration(): Format seconds into readable text 19 | 20 | IMPORTANT: All timestamps must use format "YYYY-MM-DD HH:MM:SS" or "YYYY-MM-DD" for dates only. 21 | Examples: "2024-01-15 14:30:00" or "2024-01-15". This ensures no ambiguity in parsing.""" 22 | ) 23 | 24 | def parse_standard_timestamp(timestamp_str: str, timezone: str = "America/New_York") -> datetime: 25 | """ 26 | Parse a timestamp in our standard format. 27 | Accepts: "YYYY-MM-DD HH:MM:SS" or "YYYY-MM-DD" 28 | 29 | Raises ValueError with clear message if format is invalid. 30 | """ 31 | tz = pytz.timezone(timezone) 32 | timestamp_str = timestamp_str.strip() 33 | 34 | # Try full timestamp format first 35 | try: 36 | dt = datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S") 37 | return tz.localize(dt) 38 | except ValueError: 39 | pass 40 | 41 | # Try date-only format 42 | try: 43 | dt = datetime.strptime(timestamp_str, "%Y-%m-%d") 44 | # For date-only, set time to midnight 45 | return tz.localize(dt) 46 | except ValueError: 47 | pass 48 | 49 | # Try with timezone abbreviation 50 | try: 51 | # Split off timezone if present 52 | parts = timestamp_str.rsplit(' ', 2) 53 | if len(parts) == 3 and len(parts[2]) <= 4: # Likely a timezone 54 | dt_str = f"{parts[0]} {parts[1]}" 55 | dt = datetime.strptime(dt_str, "%Y-%m-%d %H:%M:%S") 56 | # For now, ignore the provided timezone and use the parameter 57 | return tz.localize(dt) 58 | except ValueError: 59 | pass 60 | 61 | # If all parsing attempts failed, raise clear error 62 | raise ValueError( 63 | f"Invalid timestamp format: '{timestamp_str}'. " 64 | f"Expected format: 'YYYY-MM-DD HH:MM:SS' or 'YYYY-MM-DD'. " 65 | f"Examples: '2024-01-15 14:30:00' or '2024-01-15'" 66 | ) 67 | 68 | @mcp.tool() 69 | def current_datetime(timezone: str = "America/New_York") -> str: 70 | """ 71 | Returns the current date and time as a string. 72 | If you are asked for the current date or time, call this function. 73 | 74 | Args: 75 | timezone: Timezone name (e.g., 'UTC', 'US/Pacific', 'Europe/London'). 76 | Defaults to 'America/New_York'. 77 | 78 | Returns: 79 | A formatted date and time string in format: YYYY-MM-DD HH:MM:SS TZ 80 | """ 81 | try: 82 | tz = pytz.timezone(timezone) 83 | now = datetime.now(tz) 84 | return now.strftime("%Y-%m-%d %H:%M:%S %Z") 85 | except pytz.exceptions.UnknownTimeZoneError: 86 | return f"Error: Unknown timezone '{timezone}'. Please use a valid timezone name like 'UTC', 'US/Pacific', or 'Europe/London'." 87 | 88 | @mcp.tool() 89 | def time_difference( 90 | timestamp1: str, 91 | timestamp2: str, 92 | unit: Literal["auto", "seconds", "minutes", "hours", "days"] = "auto", 93 | timezone: str = "America/New_York" 94 | ) -> Dict[str, Union[int, float, str, bool]]: 95 | """ 96 | Calculate the time difference between two timestamps. 97 | 98 | Timestamps must be in format: "YYYY-MM-DD HH:MM:SS" or "YYYY-MM-DD" 99 | 100 | Args: 101 | timestamp1: First timestamp (earlier time expected) 102 | timestamp2: Second timestamp (later time expected) 103 | unit: Desired unit for the result. "auto" provides multiple formats 104 | timezone: Timezone for parsing ambiguous timestamps 105 | 106 | Returns: 107 | Dictionary containing: 108 | - seconds: Total difference in seconds 109 | - formatted: Human-readable format (e.g., "3 hours, 10 minutes") 110 | - requested_unit: Difference in the requested unit (if not "auto") 111 | - is_negative: Boolean indicating if timestamp1 > timestamp2 112 | """ 113 | try: 114 | dt1 = parse_standard_timestamp(timestamp1, timezone) 115 | dt2 = parse_standard_timestamp(timestamp2, timezone) 116 | 117 | # Calculate difference 118 | delta = dt2 - dt1 119 | total_seconds = delta.total_seconds() 120 | is_negative = total_seconds < 0 121 | abs_seconds = abs(total_seconds) 122 | 123 | # Format human-readable string 124 | def format_timedelta(seconds): 125 | days = int(seconds // 86400) 126 | hours = int((seconds % 86400) // 3600) 127 | minutes = int((seconds % 3600) // 60) 128 | secs = int(seconds % 60) 129 | 130 | parts = [] 131 | if days > 0: 132 | parts.append(f"{days} day{'s' if days != 1 else ''}") 133 | if hours > 0: 134 | parts.append(f"{hours} hour{'s' if hours != 1 else ''}") 135 | if minutes > 0: 136 | parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}") 137 | if secs > 0 or not parts: 138 | parts.append(f"{secs} second{'s' if secs != 1 else ''}") 139 | 140 | return ", ".join(parts) 141 | 142 | formatted = format_timedelta(abs_seconds) 143 | if is_negative: 144 | formatted = f"-{formatted}" 145 | 146 | result = { 147 | "seconds": total_seconds, 148 | "formatted": formatted, 149 | "is_negative": is_negative 150 | } 151 | 152 | # Add requested unit if not "auto" 153 | if unit != "auto": 154 | unit_conversions = { 155 | "seconds": 1, 156 | "minutes": 60, 157 | "hours": 3600, 158 | "days": 86400 159 | } 160 | result["requested_unit"] = total_seconds / unit_conversions[unit] 161 | 162 | return result 163 | 164 | except ValueError as e: 165 | return { 166 | "error": str(e), 167 | "seconds": 0, 168 | "formatted": "Error parsing timestamps", 169 | "is_negative": False 170 | } 171 | except Exception as e: 172 | return { 173 | "error": f"Unexpected error: {str(e)}", 174 | "seconds": 0, 175 | "formatted": "Error parsing timestamps", 176 | "is_negative": False 177 | } 178 | 179 | @mcp.tool() 180 | def time_since( 181 | timestamp: str, 182 | timezone: str = "America/New_York" 183 | ) -> Dict[str, Union[int, float, str]]: 184 | """ 185 | Calculate time elapsed since a given timestamp until now. 186 | 187 | Timestamp must be in format: "YYYY-MM-DD HH:MM:SS" or "YYYY-MM-DD" 188 | 189 | Args: 190 | timestamp: Past timestamp to compare against current time 191 | timezone: Timezone for parsing and current time 192 | 193 | Returns: 194 | Dictionary containing: 195 | - seconds: Seconds elapsed (negative if timestamp is in future) 196 | - formatted: Human-readable format (e.g., "2 days, 3 hours ago") 197 | - context: Contextual description (e.g., "earlier today", "yesterday") 198 | """ 199 | try: 200 | tz = pytz.timezone(timezone) 201 | now = datetime.now(tz) 202 | now_str = now.strftime("%Y-%m-%d %H:%M:%S") 203 | 204 | # Use time_difference to calculate 205 | diff = time_difference(timestamp, now_str, unit="auto", timezone=timezone) 206 | 207 | # Check if time_difference returned an error 208 | if "error" in diff: 209 | return { 210 | "error": diff.get("error", "Unknown error in time_difference"), 211 | "seconds": 0, 212 | "formatted": "Error calculating time since", 213 | "context": "unknown" 214 | } 215 | 216 | seconds = diff["seconds"] 217 | abs_seconds = abs(seconds) 218 | 219 | # Generate contextual description 220 | dt = parse_standard_timestamp(timestamp, timezone) 221 | 222 | context = "" 223 | if seconds < 0: 224 | context = "in the future" 225 | elif abs_seconds < 60: 226 | context = "just now" 227 | elif abs_seconds < 3600: 228 | context = "earlier" 229 | elif abs_seconds < 86400: 230 | if dt.date() == now.date(): 231 | context = "earlier today" 232 | else: 233 | context = "yesterday" 234 | elif abs_seconds < 172800: # 2 days 235 | context = "yesterday" 236 | elif abs_seconds < 604800: # 1 week 237 | context = "this week" 238 | elif abs_seconds < 2592000: # 30 days 239 | context = "this month" 240 | else: 241 | context = "a while ago" 242 | 243 | formatted = diff["formatted"] + (" ago" if seconds >= 0 else " from now") 244 | 245 | return { 246 | "seconds": seconds, 247 | "formatted": formatted, 248 | "context": context 249 | } 250 | 251 | except ValueError as e: 252 | return { 253 | "error": str(e), 254 | "seconds": 0, 255 | "formatted": "Error calculating time since", 256 | "context": "unknown" 257 | } 258 | except Exception as e: 259 | return { 260 | "error": f"Unexpected error: {str(e)}", 261 | "seconds": 0, 262 | "formatted": "Error calculating time since", 263 | "context": "unknown" 264 | } 265 | 266 | @mcp.tool() 267 | def parse_timestamp( 268 | timestamp: str, 269 | source_timezone: Optional[str] = None, 270 | target_timezone: str = "America/New_York" 271 | ) -> Dict[str, str]: 272 | """ 273 | Parse and convert a timestamp to multiple formats. 274 | 275 | Timestamp must be in format: "YYYY-MM-DD HH:MM:SS" or "YYYY-MM-DD" 276 | 277 | Args: 278 | timestamp: Timestamp string in standard format 279 | source_timezone: Timezone of the input (if None, uses target_timezone) 280 | target_timezone: Desired output timezone 281 | 282 | Returns: 283 | Dictionary containing: 284 | - iso: ISO 8601 format 285 | - unix: Unix timestamp (seconds since epoch) 286 | - human: Human-friendly format 287 | - timezone: Timezone name 288 | - day_of_week: Full day name 289 | - date: Date only (YYYY-MM-DD) 290 | - time: Time only (HH:MM:SS) 291 | """ 292 | try: 293 | # Parse the timestamp 294 | parse_tz = source_timezone or target_timezone 295 | dt = parse_standard_timestamp(timestamp, parse_tz) 296 | 297 | # Convert to target timezone if different 298 | if source_timezone and source_timezone != target_timezone: 299 | tgt_tz = pytz.timezone(target_timezone) 300 | dt = dt.astimezone(tgt_tz) 301 | 302 | return { 303 | "iso": dt.isoformat(), 304 | "unix": str(int(dt.timestamp())), 305 | "human": dt.strftime("%B %d, %Y at %I:%M %p %Z"), 306 | "timezone": target_timezone, 307 | "day_of_week": dt.strftime("%A"), 308 | "date": dt.strftime("%Y-%m-%d"), 309 | "time": dt.strftime("%H:%M:%S") 310 | } 311 | 312 | except ValueError as e: 313 | return { 314 | "error": str(e), 315 | "iso": "", 316 | "unix": "", 317 | "human": "Error parsing timestamp", 318 | "timezone": target_timezone, 319 | "day_of_week": "", 320 | "date": "", 321 | "time": "" 322 | } 323 | except Exception as e: 324 | return { 325 | "error": f"Unexpected error: {str(e)}", 326 | "iso": "", 327 | "unix": "", 328 | "human": "Error parsing timestamp", 329 | "timezone": target_timezone, 330 | "day_of_week": "", 331 | "date": "", 332 | "time": "" 333 | } 334 | 335 | @mcp.tool() 336 | def add_time( 337 | timestamp: str, 338 | duration: Union[int, float], 339 | unit: Literal["seconds", "minutes", "hours", "days", "weeks"], 340 | timezone: str = "America/New_York" 341 | ) -> Dict[str, str]: 342 | """ 343 | Add a duration to a timestamp. 344 | 345 | Timestamp must be in format: "YYYY-MM-DD HH:MM:SS" or "YYYY-MM-DD" 346 | 347 | Args: 348 | timestamp: Starting timestamp in standard format 349 | duration: Amount to add (can be negative to subtract) 350 | unit: Unit of the duration 351 | timezone: Timezone for calculations 352 | 353 | Returns: 354 | Dictionary containing: 355 | - result: Resulting timestamp in same format as input 356 | - iso: ISO 8601 format of result 357 | - description: Natural language description (e.g., "tomorrow at 3:00 PM") 358 | """ 359 | try: 360 | tz = pytz.timezone(timezone) 361 | dt = parse_standard_timestamp(timestamp, timezone) 362 | 363 | # Remember if input was date-only 364 | is_date_only = ":" not in timestamp 365 | 366 | # Calculate timedelta based on unit 367 | if unit == "seconds": 368 | delta = timedelta(seconds=duration) 369 | elif unit == "minutes": 370 | delta = timedelta(minutes=duration) 371 | elif unit == "hours": 372 | delta = timedelta(hours=duration) 373 | elif unit == "days": 374 | delta = timedelta(days=duration) 375 | elif unit == "weeks": 376 | delta = timedelta(weeks=duration) 377 | else: 378 | raise ValueError(f"Invalid unit: {unit}") 379 | 380 | # Add duration 381 | result_dt = dt + delta 382 | 383 | # Generate natural language description 384 | now = datetime.now(tz) 385 | days_diff = (result_dt.date() - now.date()).days 386 | 387 | if days_diff == 0: 388 | day_desc = "today" 389 | elif days_diff == 1: 390 | day_desc = "tomorrow" 391 | elif days_diff == -1: 392 | day_desc = "yesterday" 393 | elif days_diff > 1 and days_diff <= 7: 394 | day_desc = f"next {result_dt.strftime('%A')}" 395 | elif days_diff < -1 and days_diff >= -7: 396 | day_desc = f"last {result_dt.strftime('%A')}" 397 | else: 398 | day_desc = result_dt.strftime("%B %d, %Y") 399 | 400 | time_desc = result_dt.strftime("%I:%M %p").lstrip("0") 401 | description = f"{day_desc} at {time_desc}" if not is_date_only else day_desc 402 | 403 | # Format result to match input format 404 | if is_date_only: 405 | result_str = result_dt.strftime("%Y-%m-%d") 406 | else: 407 | result_str = result_dt.strftime("%Y-%m-%d %H:%M:%S") 408 | 409 | return { 410 | "result": result_str, 411 | "iso": result_dt.isoformat(), 412 | "description": description 413 | } 414 | 415 | except ValueError as e: 416 | return { 417 | "error": str(e), 418 | "result": "Error adding time", 419 | "iso": "", 420 | "description": "Error calculating result" 421 | } 422 | except Exception as e: 423 | return { 424 | "error": f"Unexpected error: {str(e)}", 425 | "result": "Error adding time", 426 | "iso": "", 427 | "description": "Error calculating result" 428 | } 429 | 430 | @mcp.tool() 431 | def timestamp_context( 432 | timestamp: str, 433 | timezone: str = "America/New_York" 434 | ) -> Dict[str, Union[str, bool, int]]: 435 | """ 436 | Provide contextual information about a timestamp. 437 | 438 | Timestamp must be in format: "YYYY-MM-DD HH:MM:SS" or "YYYY-MM-DD" 439 | 440 | Args: 441 | timestamp: Timestamp to analyze in standard format 442 | timezone: Timezone for context 443 | 444 | Returns: 445 | Dictionary containing: 446 | - time_of_day: "early_morning", "morning", "afternoon", "evening", "late_night" 447 | - day_of_week: Full day name 448 | - is_weekend: Boolean 449 | - is_business_hours: Boolean (Mon-Fri 9-5) 450 | - hour_24: Hour in 24-hour format 451 | - typical_activity: Contextual description (e.g., "lunch_time", "commute_time") 452 | - relative_day: "today", "yesterday", "tomorrow", or None 453 | """ 454 | try: 455 | tz = pytz.timezone(timezone) 456 | dt = parse_standard_timestamp(timestamp, timezone) 457 | now = datetime.now(tz) 458 | 459 | hour = dt.hour 460 | 461 | # Determine time of day 462 | if 5 <= hour < 9: 463 | time_of_day = "early_morning" 464 | elif 9 <= hour < 12: 465 | time_of_day = "morning" 466 | elif 12 <= hour < 17: 467 | time_of_day = "afternoon" 468 | elif 17 <= hour < 21: 469 | time_of_day = "evening" 470 | else: 471 | time_of_day = "late_night" 472 | 473 | # Day of week and weekend check 474 | day_of_week = dt.strftime("%A") 475 | is_weekend = dt.weekday() >= 5 # Saturday = 5, Sunday = 6 476 | 477 | # Business hours check (Mon-Fri 9-5) 478 | is_business_hours = ( 479 | not is_weekend and 480 | 9 <= hour < 17 481 | ) 482 | 483 | # Typical activity based on time 484 | if 6 <= hour < 9: 485 | typical_activity = "commute_time" 486 | elif 12 <= hour < 13: 487 | typical_activity = "lunch_time" 488 | elif 17 <= hour < 19: 489 | typical_activity = "commute_time" 490 | elif 19 <= hour < 21: 491 | typical_activity = "dinner_time" 492 | elif 22 <= hour or hour < 6: 493 | typical_activity = "sleeping_time" 494 | else: 495 | typical_activity = "work_time" if is_business_hours else "leisure_time" 496 | 497 | # Relative day 498 | days_diff = (dt.date() - now.date()).days 499 | if days_diff == 0: 500 | relative_day = "today" 501 | elif days_diff == -1: 502 | relative_day = "yesterday" 503 | elif days_diff == 1: 504 | relative_day = "tomorrow" 505 | else: 506 | relative_day = None 507 | 508 | return { 509 | "time_of_day": time_of_day, 510 | "day_of_week": day_of_week, 511 | "is_weekend": is_weekend, 512 | "is_business_hours": is_business_hours, 513 | "hour_24": hour, 514 | "typical_activity": typical_activity, 515 | "relative_day": relative_day 516 | } 517 | 518 | except ValueError as e: 519 | return { 520 | "error": str(e), 521 | "time_of_day": "unknown", 522 | "day_of_week": "", 523 | "is_weekend": False, 524 | "is_business_hours": False, 525 | "hour_24": 0, 526 | "typical_activity": "unknown", 527 | "relative_day": None 528 | } 529 | except Exception as e: 530 | return { 531 | "error": f"Unexpected error: {str(e)}", 532 | "time_of_day": "unknown", 533 | "day_of_week": "", 534 | "is_weekend": False, 535 | "is_business_hours": False, 536 | "hour_24": 0, 537 | "typical_activity": "unknown", 538 | "relative_day": None 539 | } 540 | 541 | @mcp.tool() 542 | def format_duration( 543 | seconds: Union[int, float], 544 | style: Literal["full", "compact", "minimal"] = "full" 545 | ) -> str: 546 | """ 547 | Format a duration in seconds into human-readable text. 548 | 549 | Args: 550 | seconds: Duration in seconds (can be negative) 551 | style: Format style 552 | - "full": "2 hours, 30 minutes, 15 seconds" 553 | - "compact": "2h 30m 15s" 554 | - "minimal": "2:30:15" 555 | 556 | Returns: 557 | Formatted duration string 558 | """ 559 | try: 560 | # Validate input 561 | seconds = float(seconds) 562 | 563 | # Handle negative durations 564 | is_negative = seconds < 0 565 | abs_seconds = abs(seconds) 566 | 567 | # Break down into components 568 | days = int(abs_seconds // 86400) 569 | hours = int((abs_seconds % 86400) // 3600) 570 | minutes = int((abs_seconds % 3600) // 60) 571 | secs = int(abs_seconds % 60) 572 | 573 | if style == "full": 574 | parts = [] 575 | if days > 0: 576 | parts.append(f"{days} day{'s' if days != 1 else ''}") 577 | if hours > 0: 578 | parts.append(f"{hours} hour{'s' if hours != 1 else ''}") 579 | if minutes > 0: 580 | parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}") 581 | if secs > 0 or not parts: 582 | parts.append(f"{secs} second{'s' if secs != 1 else ''}") 583 | result = ", ".join(parts) 584 | 585 | elif style == "compact": 586 | parts = [] 587 | if days > 0: 588 | parts.append(f"{days}d") 589 | if hours > 0: 590 | parts.append(f"{hours}h") 591 | if minutes > 0: 592 | parts.append(f"{minutes}m") 593 | if secs > 0 or not parts: 594 | parts.append(f"{secs}s") 595 | result = " ".join(parts) 596 | 597 | elif style == "minimal": 598 | if days > 0: 599 | result = f"{days}:{hours:02d}:{minutes:02d}:{secs:02d}" 600 | elif hours > 0: 601 | result = f"{hours}:{minutes:02d}:{secs:02d}" 602 | else: 603 | result = f"{minutes}:{secs:02d}" 604 | else: 605 | raise ValueError(f"Invalid style: {style}. Must be 'full', 'compact', or 'minimal'") 606 | 607 | return f"-{result}" if is_negative else result 608 | 609 | except (ValueError, TypeError) as e: 610 | return f"Error: Invalid input. Seconds must be a number. {str(e)}" 611 | except Exception as e: 612 | return f"Error formatting duration: {str(e)}" 613 | 614 | if __name__ == "__main__": 615 | import asyncio 616 | port = int(os.environ.get("PORT", 8000)) 617 | asyncio.run( 618 | mcp.run_sse_async( 619 | host="0.0.0.0", # Changed from 127.0.0.1 to allow external connections 620 | port=port, 621 | log_level="debug" 622 | ) 623 | ) -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "34df99d9a2be7ca2247e6d790247103572de04c616e26eeec4271edb7bf15825" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.12" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "annotated-types": { 20 | "hashes": [ 21 | "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", 22 | "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89" 23 | ], 24 | "markers": "python_version >= '3.8'", 25 | "version": "==0.7.0" 26 | }, 27 | "anyio": { 28 | "hashes": [ 29 | "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", 30 | "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c" 31 | ], 32 | "markers": "python_version >= '3.9'", 33 | "version": "==4.9.0" 34 | }, 35 | "asyncio": { 36 | "hashes": [ 37 | "sha256:83360ff8bc97980e4ff25c964c7bd3923d333d177aa4f7fb736b019f26c7cb41", 38 | "sha256:b62c9157d36187eca799c378e572c969f0da87cd5fc42ca372d92cdb06e7e1de", 39 | "sha256:c46a87b48213d7464f22d9a497b9eef8c1928b68320a2fa94240f969f6fec08c", 40 | "sha256:c4d18b22701821de07bd6aea8b53d21449ec0ec5680645e5317062ea21817d2d" 41 | ], 42 | "index": "pypi", 43 | "version": "==3.4.3" 44 | }, 45 | "authlib": { 46 | "hashes": [ 47 | "sha256:4367d32031b7af175ad3a323d571dc7257b7099d55978087ceae4a0d88cd3210", 48 | "sha256:91685589498f79e8655e8a8947431ad6288831d643f11c55c2143ffcc738048d" 49 | ], 50 | "markers": "python_version >= '3.9'", 51 | "version": "==1.6.0" 52 | }, 53 | "certifi": { 54 | "hashes": [ 55 | "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057", 56 | "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b" 57 | ], 58 | "markers": "python_version >= '3.7'", 59 | "version": "==2025.6.15" 60 | }, 61 | "cffi": { 62 | "hashes": [ 63 | "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", 64 | "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", 65 | "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1", 66 | "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", 67 | "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", 68 | "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", 69 | "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8", 70 | "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36", 71 | "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", 72 | "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", 73 | "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc", 74 | "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", 75 | "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", 76 | "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", 77 | "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", 78 | "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", 79 | "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", 80 | "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", 81 | "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", 82 | "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b", 83 | "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", 84 | "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", 85 | "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c", 86 | "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", 87 | "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", 88 | "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", 89 | "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8", 90 | "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1", 91 | "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", 92 | "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", 93 | "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", 94 | "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595", 95 | "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0", 96 | "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", 97 | "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", 98 | "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", 99 | "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", 100 | "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", 101 | "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", 102 | "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16", 103 | "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", 104 | "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e", 105 | "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", 106 | "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964", 107 | "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", 108 | "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576", 109 | "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", 110 | "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3", 111 | "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662", 112 | "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", 113 | "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", 114 | "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", 115 | "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", 116 | "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", 117 | "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", 118 | "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", 119 | "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", 120 | "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9", 121 | "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7", 122 | "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", 123 | "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a", 124 | "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", 125 | "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", 126 | "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", 127 | "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", 128 | "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87", 129 | "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b" 130 | ], 131 | "markers": "platform_python_implementation != 'PyPy'", 132 | "version": "==1.17.1" 133 | }, 134 | "click": { 135 | "hashes": [ 136 | "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", 137 | "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b" 138 | ], 139 | "markers": "python_version >= '3.10'", 140 | "version": "==8.2.1" 141 | }, 142 | "cryptography": { 143 | "hashes": [ 144 | "sha256:0339a692de47084969500ee455e42c58e449461e0ec845a34a6a9b9bf7df7fb8", 145 | "sha256:03dbff8411206713185b8cebe31bc5c0eb544799a50c09035733716b386e61a4", 146 | "sha256:06509dc70dd71fa56eaa138336244e2fbaf2ac164fc9b5e66828fccfd2b680d6", 147 | "sha256:0cf13c77d710131d33e63626bd55ae7c0efb701ebdc2b3a7952b9b23a0412862", 148 | "sha256:23b9c3ea30c3ed4db59e7b9619272e94891f8a3a5591d0b656a7582631ccf750", 149 | "sha256:25eb4d4d3e54595dc8adebc6bbd5623588991d86591a78c2548ffb64797341e2", 150 | "sha256:2882338b2a6e0bd337052e8b9007ced85c637da19ef9ecaf437744495c8c2999", 151 | "sha256:3530382a43a0e524bc931f187fc69ef4c42828cf7d7f592f7f249f602b5a4ab0", 152 | "sha256:425a9a6ac2823ee6e46a76a21a4e8342d8fa5c01e08b823c1f19a8b74f096069", 153 | "sha256:46cf7088bf91bdc9b26f9c55636492c1cce3e7aaf8041bbf0243f5e5325cfb2d", 154 | "sha256:4828190fb6c4bcb6ebc6331f01fe66ae838bb3bd58e753b59d4b22eb444b996c", 155 | "sha256:49fe9155ab32721b9122975e168a6760d8ce4cffe423bcd7ca269ba41b5dfac1", 156 | "sha256:4ca0f52170e821bc8da6fc0cc565b7bb8ff8d90d36b5e9fdd68e8a86bdf72036", 157 | "sha256:51dfbd4d26172d31150d84c19bbe06c68ea4b7f11bbc7b3a5e146b367c311349", 158 | "sha256:5f31e6b0a5a253f6aa49be67279be4a7e5a4ef259a9f33c69f7d1b1191939872", 159 | "sha256:627ba1bc94f6adf0b0a2e35d87020285ead22d9f648c7e75bb64f367375f3b22", 160 | "sha256:680806cf63baa0039b920f4976f5f31b10e772de42f16310a6839d9f21a26b0d", 161 | "sha256:6a3511ae33f09094185d111160fd192c67aa0a2a8d19b54d36e4c78f651dc5ad", 162 | "sha256:6a5bf57554e80f75a7db3d4b1dacaa2764611ae166ab42ea9a72bcdb5d577637", 163 | "sha256:6b613164cb8425e2f8db5849ffb84892e523bf6d26deb8f9bb76ae86181fa12b", 164 | "sha256:7405ade85c83c37682c8fe65554759800a4a8c54b2d96e0f8ad114d31b808d57", 165 | "sha256:7aad98a25ed8ac917fdd8a9c1e706e5a0956e06c498be1f713b61734333a4507", 166 | "sha256:7bedbe4cc930fa4b100fc845ea1ea5788fcd7ae9562e669989c11618ae8d76ee", 167 | "sha256:7ef2dde4fa9408475038fc9aadfc1fb2676b174e68356359632e980c661ec8f6", 168 | "sha256:817ee05c6c9f7a69a16200f0c90ab26d23a87701e2a284bd15156783e46dbcc8", 169 | "sha256:944e9ccf67a9594137f942d5b52c8d238b1b4e46c7a0c2891b7ae6e01e7c80a4", 170 | "sha256:964bcc28d867e0f5491a564b7debb3ffdd8717928d315d12e0d7defa9e43b723", 171 | "sha256:96d4819e25bf3b685199b304a0029ce4a3caf98947ce8a066c9137cc78ad2c58", 172 | "sha256:a77c6fb8d76e9c9f99f2f3437c1a4ac287b34eaf40997cfab1e9bd2be175ac39", 173 | "sha256:b0a97c927497e3bc36b33987abb99bf17a9a175a19af38a892dc4bbb844d7ee2", 174 | "sha256:b97737a3ffbea79eebb062eb0d67d72307195035332501722a9ca86bab9e3ab2", 175 | "sha256:bbc505d1dc469ac12a0a064214879eac6294038d6b24ae9f71faae1448a9608d", 176 | "sha256:c22fe01e53dc65edd1945a2e6f0015e887f84ced233acecb64b4daadb32f5c97", 177 | "sha256:ce1678a2ccbe696cf3af15a75bb72ee008d7ff183c9228592ede9db467e64f1b", 178 | "sha256:e00a6c10a5c53979d6242f123c0a97cff9f3abed7f064fc412c36dc521b5f257", 179 | "sha256:eaa3e28ea2235b33220b949c5a0d6cf79baa80eab2eb5607ca8ab7525331b9ff", 180 | "sha256:f3fe7a5ae34d5a414957cc7f457e2b92076e72938423ac64d215722f6cf49a9e" 181 | ], 182 | "markers": "python_version >= '3.7' and python_full_version not in '3.9.0, 3.9.1'", 183 | "version": "==45.0.4" 184 | }, 185 | "exceptiongroup": { 186 | "hashes": [ 187 | "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", 188 | "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88" 189 | ], 190 | "markers": "python_version >= '3.7'", 191 | "version": "==1.3.0" 192 | }, 193 | "fastmcp": { 194 | "hashes": [ 195 | "sha256:3b56a7bbab6bbac64d2a251a98b3dec5bb822ab1e4e9f20bb259add028b10d44", 196 | "sha256:c89d8ce8bf53a166eda444cfdcb2c638170e62445487229fbaf340aed31beeaf" 197 | ], 198 | "index": "pypi", 199 | "markers": "python_version >= '3.10'", 200 | "version": "==2.8.1" 201 | }, 202 | "h11": { 203 | "hashes": [ 204 | "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", 205 | "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86" 206 | ], 207 | "markers": "python_version >= '3.8'", 208 | "version": "==0.16.0" 209 | }, 210 | "httpcore": { 211 | "hashes": [ 212 | "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", 213 | "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8" 214 | ], 215 | "markers": "python_version >= '3.8'", 216 | "version": "==1.0.9" 217 | }, 218 | "httpx": { 219 | "hashes": [ 220 | "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", 221 | "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad" 222 | ], 223 | "markers": "python_version >= '3.8'", 224 | "version": "==0.28.1" 225 | }, 226 | "httpx-sse": { 227 | "hashes": [ 228 | "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", 229 | "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f" 230 | ], 231 | "markers": "python_version >= '3.8'", 232 | "version": "==0.4.0" 233 | }, 234 | "idna": { 235 | "hashes": [ 236 | "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", 237 | "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3" 238 | ], 239 | "markers": "python_version >= '3.6'", 240 | "version": "==3.10" 241 | }, 242 | "markdown-it-py": { 243 | "hashes": [ 244 | "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", 245 | "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb" 246 | ], 247 | "markers": "python_version >= '3.8'", 248 | "version": "==3.0.0" 249 | }, 250 | "mcp": { 251 | "hashes": [ 252 | "sha256:7fcf36b62936adb8e63f89346bccca1268eeca9bf6dfb562ee10b1dfbda9dac0", 253 | "sha256:cfb0bcd1a9535b42edaef89947b9e18a8feb49362e1cc059d6e7fc636f2cb09f" 254 | ], 255 | "markers": "python_version >= '3.10'", 256 | "version": "==1.9.4" 257 | }, 258 | "mdurl": { 259 | "hashes": [ 260 | "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", 261 | "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" 262 | ], 263 | "markers": "python_version >= '3.7'", 264 | "version": "==0.1.2" 265 | }, 266 | "openapi-pydantic": { 267 | "hashes": [ 268 | "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", 269 | "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d" 270 | ], 271 | "markers": "python_version >= '3.8' and python_version < '4.0'", 272 | "version": "==0.5.1" 273 | }, 274 | "pycparser": { 275 | "hashes": [ 276 | "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", 277 | "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc" 278 | ], 279 | "markers": "python_version >= '3.8'", 280 | "version": "==2.22" 281 | }, 282 | "pydantic": { 283 | "hashes": [ 284 | "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", 285 | "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b" 286 | ], 287 | "markers": "python_version >= '3.9'", 288 | "version": "==2.11.7" 289 | }, 290 | "pydantic-core": { 291 | "hashes": [ 292 | "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d", 293 | "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac", 294 | "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02", 295 | "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", 296 | "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4", 297 | "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22", 298 | "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", 299 | "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec", 300 | "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d", 301 | "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b", 302 | "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", 303 | "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", 304 | "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052", 305 | "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", 306 | "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", 307 | "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c", 308 | "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", 309 | "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27", 310 | "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", 311 | "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8", 312 | "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", 313 | "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", 314 | "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", 315 | "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039", 316 | "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca", 317 | "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", 318 | "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", 319 | "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6", 320 | "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782", 321 | "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b", 322 | "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", 323 | "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", 324 | "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", 325 | "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7", 326 | "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", 327 | "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa", 328 | "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", 329 | "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", 330 | "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", 331 | "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", 332 | "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", 333 | "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", 334 | "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", 335 | "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2", 336 | "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954", 337 | "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b", 338 | "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", 339 | "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", 340 | "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64", 341 | "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", 342 | "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9", 343 | "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101", 344 | "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d", 345 | "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", 346 | "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3", 347 | "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", 348 | "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", 349 | "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", 350 | "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d", 351 | "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", 352 | "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e", 353 | "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", 354 | "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808", 355 | "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", 356 | "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d", 357 | "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", 358 | "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e", 359 | "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640", 360 | "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", 361 | "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", 362 | "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", 363 | "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a", 364 | "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", 365 | "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", 366 | "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb", 367 | "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", 368 | "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", 369 | "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d", 370 | "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572", 371 | "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", 372 | "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29", 373 | "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535", 374 | "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", 375 | "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", 376 | "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", 377 | "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", 378 | "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", 379 | "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", 380 | "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", 381 | "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9", 382 | "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a", 383 | "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3", 384 | "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", 385 | "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", 386 | "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a", 387 | "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", 388 | "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c", 389 | "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", 390 | "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d" 391 | ], 392 | "markers": "python_version >= '3.9'", 393 | "version": "==2.33.2" 394 | }, 395 | "pydantic-settings": { 396 | "hashes": [ 397 | "sha256:59b4f431b1defb26fe620c71a7d3968a710d719f5f4cdbbdb7926edeb770f6ef", 398 | "sha256:c509bf79d27563add44e8446233359004ed85066cd096d8b510f715e6ef5d268" 399 | ], 400 | "markers": "python_version >= '3.9'", 401 | "version": "==2.9.1" 402 | }, 403 | "pygments": { 404 | "hashes": [ 405 | "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", 406 | "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c" 407 | ], 408 | "markers": "python_version >= '3.8'", 409 | "version": "==2.19.1" 410 | }, 411 | "python-dateutil": { 412 | "hashes": [ 413 | "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", 414 | "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" 415 | ], 416 | "index": "pypi", 417 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 418 | "version": "==2.9.0.post0" 419 | }, 420 | "python-dotenv": { 421 | "hashes": [ 422 | "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", 423 | "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d" 424 | ], 425 | "markers": "python_version >= '3.9'", 426 | "version": "==1.1.0" 427 | }, 428 | "python-multipart": { 429 | "hashes": [ 430 | "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", 431 | "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13" 432 | ], 433 | "markers": "python_version >= '3.8'", 434 | "version": "==0.0.20" 435 | }, 436 | "pytz": { 437 | "hashes": [ 438 | "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", 439 | "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00" 440 | ], 441 | "index": "pypi", 442 | "version": "==2025.2" 443 | }, 444 | "rich": { 445 | "hashes": [ 446 | "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", 447 | "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725" 448 | ], 449 | "markers": "python_full_version >= '3.8.0'", 450 | "version": "==14.0.0" 451 | }, 452 | "shellingham": { 453 | "hashes": [ 454 | "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", 455 | "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de" 456 | ], 457 | "markers": "python_version >= '3.7'", 458 | "version": "==1.5.4" 459 | }, 460 | "six": { 461 | "hashes": [ 462 | "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", 463 | "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81" 464 | ], 465 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 466 | "version": "==1.17.0" 467 | }, 468 | "sniffio": { 469 | "hashes": [ 470 | "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", 471 | "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc" 472 | ], 473 | "markers": "python_version >= '3.7'", 474 | "version": "==1.3.1" 475 | }, 476 | "sse-starlette": { 477 | "hashes": [ 478 | "sha256:0382336f7d4ec30160cf9ca0518962905e1b69b72d6c1c995131e0a703b436e3", 479 | "sha256:d49a8285b182f6e2228e2609c350398b2ca2c36216c2675d875f81e93548f760" 480 | ], 481 | "markers": "python_version >= '3.9'", 482 | "version": "==2.3.6" 483 | }, 484 | "starlette": { 485 | "hashes": [ 486 | "sha256:1f64887e94a447fed5f23309fb6890ef23349b7e478faa7b24a851cd4eb844af", 487 | "sha256:9d052d4933683af40ffd47c7465433570b4949dc937e20ad1d73b34e72f10c37" 488 | ], 489 | "markers": "python_version >= '3.9'", 490 | "version": "==0.47.0" 491 | }, 492 | "typer": { 493 | "hashes": [ 494 | "sha256:1f79bed11d4d02d4310e3c1b7ba594183bcedb0ac73b27a9e5f28f6fb5b98855", 495 | "sha256:af377ffaee1dbe37ae9440cb4e8f11686ea5ce4e9bae01b84ae7c63b87f1dd3b" 496 | ], 497 | "markers": "python_version >= '3.7'", 498 | "version": "==0.16.0" 499 | }, 500 | "typing-extensions": { 501 | "hashes": [ 502 | "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", 503 | "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af" 504 | ], 505 | "markers": "python_version < '3.13'", 506 | "version": "==4.14.0" 507 | }, 508 | "typing-inspection": { 509 | "hashes": [ 510 | "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", 511 | "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28" 512 | ], 513 | "markers": "python_version >= '3.9'", 514 | "version": "==0.4.1" 515 | }, 516 | "uvicorn": { 517 | "hashes": [ 518 | "sha256:16246631db62bdfbf069b0645177d6e8a77ba950cfedbfd093acef9444e4d885", 519 | "sha256:35919a9a979d7a59334b6b10e05d77c1d0d574c50e0fc98b8b1a0f165708b55a" 520 | ], 521 | "markers": "sys_platform != 'emscripten'", 522 | "version": "==0.34.3" 523 | } 524 | }, 525 | "develop": { 526 | "iniconfig": { 527 | "hashes": [ 528 | "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", 529 | "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760" 530 | ], 531 | "markers": "python_version >= '3.8'", 532 | "version": "==2.1.0" 533 | }, 534 | "packaging": { 535 | "hashes": [ 536 | "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", 537 | "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f" 538 | ], 539 | "markers": "python_version >= '3.8'", 540 | "version": "==25.0" 541 | }, 542 | "pluggy": { 543 | "hashes": [ 544 | "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", 545 | "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" 546 | ], 547 | "markers": "python_version >= '3.9'", 548 | "version": "==1.6.0" 549 | }, 550 | "pygments": { 551 | "hashes": [ 552 | "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", 553 | "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c" 554 | ], 555 | "markers": "python_version >= '3.8'", 556 | "version": "==2.19.1" 557 | }, 558 | "pytest": { 559 | "hashes": [ 560 | "sha256:14d920b48472ea0dbf68e45b96cd1ffda4705f33307dcc86c676c1b5104838a6", 561 | "sha256:f40f825768ad76c0977cbacdf1fd37c6f7a468e460ea6a0636078f8972d4517e" 562 | ], 563 | "index": "pypi", 564 | "markers": "python_version >= '3.9'", 565 | "version": "==8.4.0" 566 | } 567 | } 568 | } 569 | --------------------------------------------------------------------------------