chronoguard 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chronoguard-0.1.1/PKG-INFO +245 -0
- chronoguard-0.1.1/README.md +231 -0
- chronoguard-0.1.1/pyproject.toml +31 -0
- chronoguard-0.1.1/setup.cfg +4 -0
- chronoguard-0.1.1/src/chronoguard/__init__.py +0 -0
- chronoguard-0.1.1/src/chronoguard/server.py +260 -0
- chronoguard-0.1.1/src/chronoguard.egg-info/PKG-INFO +245 -0
- chronoguard-0.1.1/src/chronoguard.egg-info/SOURCES.txt +11 -0
- chronoguard-0.1.1/src/chronoguard.egg-info/dependency_links.txt +1 -0
- chronoguard-0.1.1/src/chronoguard.egg-info/entry_points.txt +2 -0
- chronoguard-0.1.1/src/chronoguard.egg-info/requires.txt +4 -0
- chronoguard-0.1.1/src/chronoguard.egg-info/top_level.txt +1 -0
- chronoguard-0.1.1/tests/test_chronoguard.py +325 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: chronoguard
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Deterministic date, time, timezone, holiday, and business-day arithmetic for AI agents via MCP.
|
|
5
|
+
Project-URL: Homepage, https://github.com/kelli930/chronoguard
|
|
6
|
+
Project-URL: Repository, https://github.com/kelli930/chronoguard
|
|
7
|
+
Project-URL: Issues, https://github.com/kelli930/chronoguard/issues
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: fastmcp>=4.0.3
|
|
11
|
+
Requires-Dist: holidays>=0.80
|
|
12
|
+
Requires-Dist: python-dateutil>=2.9
|
|
13
|
+
Requires-Dist: pydantic>=2
|
|
14
|
+
|
|
15
|
+
<!-- mcp-name: io.github.kelli930/chronoguard -->
|
|
16
|
+
|
|
17
|
+
# ChronoGuard
|
|
18
|
+
|
|
19
|
+
Deterministic date, time, timezone, holiday, and business-day arithmetic for AI agents via MCP.
|
|
20
|
+
|
|
21
|
+
LLMs are good at language. They should not have to guess whether a deadline lands on a holiday, whether five business days crosses a weekend, or what a timestamp means across a daylight-saving transition. ChronoGuard moves that work into a small deterministic tool with a stable contract.
|
|
22
|
+
|
|
23
|
+
## Status
|
|
24
|
+
|
|
25
|
+
**v0.1.1 — public validation prototype**
|
|
26
|
+
|
|
27
|
+
Validated in Replit on September 11, 2026 with:
|
|
28
|
+
|
|
29
|
+
- 28 automated tests passing
|
|
30
|
+
- FastMCP 4.0.3
|
|
31
|
+
- MCP 2.2.0
|
|
32
|
+
- Real `holidays` package integration
|
|
33
|
+
- Successful real stdio MCP client discovery of `chronoguard_resolve_time`
|
|
34
|
+
- Successful end-to-end MCP tool invocation through the stdio server
|
|
35
|
+
- Successful FastMCP inspector discovery
|
|
36
|
+
|
|
37
|
+
ChronoGuard is ready for developer testing, but it is **not yet positioned as production-grade global business-calendar infrastructure**.
|
|
38
|
+
|
|
39
|
+
## What ChronoGuard solves
|
|
40
|
+
|
|
41
|
+
ChronoGuard gives an agent a deterministic answer for temporal questions that are easy for an LLM to get subtly wrong.
|
|
42
|
+
|
|
43
|
+
Example workflows:
|
|
44
|
+
|
|
45
|
+
1. **SLA deadlines** — “What is 4 US business days after this support ticket opened?”
|
|
46
|
+
2. **Billing and finance cutoffs** — “What is the previous business day before month-end?”
|
|
47
|
+
3. **Rolling data windows** — “Give me the exact timestamps for the last 30 days.”
|
|
48
|
+
4. **Timezone-safe scheduling** — “Convert this timestamp to America/Chicago and preserve the correct date.”
|
|
49
|
+
5. **Holiday-aware automation** — “What date is 5 business days after Friday, September 11, 2026?”
|
|
50
|
+
|
|
51
|
+
## Supported operations
|
|
52
|
+
|
|
53
|
+
The MCP tool is named:
|
|
54
|
+
|
|
55
|
+
```text
|
|
56
|
+
chronoguard_resolve_time
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Supported operations:
|
|
60
|
+
|
|
61
|
+
- `current_time`
|
|
62
|
+
- `add_duration`
|
|
63
|
+
- `subtract_duration`
|
|
64
|
+
- `business_day_offset`
|
|
65
|
+
- `calculate_span`
|
|
66
|
+
|
|
67
|
+
Supported units:
|
|
68
|
+
|
|
69
|
+
- `minutes`
|
|
70
|
+
- `hours`
|
|
71
|
+
- `days`
|
|
72
|
+
- `weeks`
|
|
73
|
+
- `business_days`
|
|
74
|
+
|
|
75
|
+
Other inputs:
|
|
76
|
+
|
|
77
|
+
- IANA timezone such as `America/Chicago` or `UTC`
|
|
78
|
+
- ISO-8601 reference timestamp
|
|
79
|
+
- Country code such as `US`
|
|
80
|
+
- Optional holiday-calendar subdivision such as a state or region when supported by the `holidays` package
|
|
81
|
+
|
|
82
|
+
## Temporal semantics
|
|
83
|
+
|
|
84
|
+
ChronoGuard deliberately distinguishes different meanings of “add time”:
|
|
85
|
+
|
|
86
|
+
- **Minutes / hours:** elapsed-time arithmetic. Calculation happens through UTC and converts back to the requested timezone.
|
|
87
|
+
- **Days / weeks:** local calendar arithmetic, preserving wall-clock time across DST changes.
|
|
88
|
+
- **Business days:** local calendar arithmetic that skips weekends and supported official holidays.
|
|
89
|
+
- **Naive local timestamps:** accepted only when they map to one unambiguous real instant. Nonexistent spring-forward times and ambiguous fall-back times are rejected unless an explicit UTC offset is supplied.
|
|
90
|
+
|
|
91
|
+
## Important v0.1 limitation
|
|
92
|
+
|
|
93
|
+
ChronoGuard currently assumes **Saturday and Sunday are weekends** for business-day calculations.
|
|
94
|
+
|
|
95
|
+
The `holidays` dependency supports many countries and subdivisions, but that does **not** mean v0.1 correctly models every country's weekend convention, banking calendar, exchange calendar, or company-specific business calendar.
|
|
96
|
+
|
|
97
|
+
Do not describe v0.1 as universally correct for global business calendars.
|
|
98
|
+
|
|
99
|
+
## Install
|
|
100
|
+
|
|
101
|
+
Requires Python 3.11+.
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
python -m pip install -r requirements.txt
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Run the tests
|
|
108
|
+
|
|
109
|
+
From the project root:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
python -m pytest -v
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Expected result for this release:
|
|
116
|
+
|
|
117
|
+
```text
|
|
118
|
+
28 passed
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Inspect the MCP server
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
fastmcp inspect server.py
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
A successful inspection should show one registered tool.
|
|
128
|
+
|
|
129
|
+
## Run locally over stdio
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
python server.py
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
ChronoGuard currently uses MCP stdio transport for local clients.
|
|
136
|
+
|
|
137
|
+
## Example MCP client call
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
import asyncio
|
|
141
|
+
from fastmcp import Client
|
|
142
|
+
from server import mcp
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def main():
|
|
146
|
+
async with Client(mcp) as client:
|
|
147
|
+
result = await client.call_tool(
|
|
148
|
+
"chronoguard_resolve_time",
|
|
149
|
+
{
|
|
150
|
+
"operation": "business_day_offset",
|
|
151
|
+
"timezone": "America/Chicago",
|
|
152
|
+
"reference_timestamp": "2026-09-11T10:00:00",
|
|
153
|
+
"value": 5,
|
|
154
|
+
"country_code": "US",
|
|
155
|
+
},
|
|
156
|
+
)
|
|
157
|
+
print(result.data)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
asyncio.run(main())
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Expected resolved date:
|
|
164
|
+
|
|
165
|
+
```text
|
|
166
|
+
2026-09-18
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Example MCP configuration
|
|
170
|
+
|
|
171
|
+
For an MCP client that launches local stdio servers, use a configuration shaped like this and replace the path with the absolute location of `server.py` on your machine:
|
|
172
|
+
|
|
173
|
+
```json
|
|
174
|
+
{
|
|
175
|
+
"mcpServers": {
|
|
176
|
+
"chronoguard": {
|
|
177
|
+
"command": "python",
|
|
178
|
+
"args": ["/absolute/path/to/server.py"]
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Depending on the client and Python environment, you may need to use the absolute path to the Python executable for the environment where ChronoGuard's dependencies are installed.
|
|
185
|
+
|
|
186
|
+
## Example response
|
|
187
|
+
|
|
188
|
+
A successful business-day call returns structured data such as:
|
|
189
|
+
|
|
190
|
+
```json
|
|
191
|
+
{
|
|
192
|
+
"resolved_datetime_iso": "2026-09-18T10:00:00-05:00",
|
|
193
|
+
"timezone": "America/Chicago",
|
|
194
|
+
"day_of_week": "Friday",
|
|
195
|
+
"is_business_day": true,
|
|
196
|
+
"is_holiday": false,
|
|
197
|
+
"holiday_name": null,
|
|
198
|
+
"date_range": null
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## Error behavior
|
|
203
|
+
|
|
204
|
+
ChronoGuard fails explicitly rather than silently guessing when it encounters inputs such as:
|
|
205
|
+
|
|
206
|
+
- Invalid IANA timezone names
|
|
207
|
+
- Invalid ISO timestamps
|
|
208
|
+
- Unsupported holiday calendars
|
|
209
|
+
- Nonexistent DST-local times
|
|
210
|
+
- Ambiguous DST-local times without an explicit offset
|
|
211
|
+
|
|
212
|
+
That behavior is intentional: a deterministic agent tool should prefer a clear error to a plausible but wrong date.
|
|
213
|
+
|
|
214
|
+
## What is not in v0.1
|
|
215
|
+
|
|
216
|
+
Not yet supported:
|
|
217
|
+
|
|
218
|
+
- Non-Saturday/Sunday weekend conventions
|
|
219
|
+
- NYSE or other exchange calendars
|
|
220
|
+
- Federal Reserve settlement calendars
|
|
221
|
+
- Custom company holiday calendars
|
|
222
|
+
- Remote HTTP transport
|
|
223
|
+
- Authentication or rate limiting
|
|
224
|
+
- Hosted commercial API
|
|
225
|
+
- Billing or usage metering
|
|
226
|
+
|
|
227
|
+
Those should be added only after developer demand justifies them.
|
|
228
|
+
|
|
229
|
+
## Why this exists
|
|
230
|
+
|
|
231
|
+
The experiment behind ChronoGuard is simple:
|
|
232
|
+
|
|
233
|
+
> When an AI workflow has a narrow deterministic failure mode, move that task out of LLM reasoning and into a small tool with a strict contract.
|
|
234
|
+
|
|
235
|
+
ChronoGuard is the first test of that idea.
|
|
236
|
+
|
|
237
|
+
## Feedback wanted
|
|
238
|
+
|
|
239
|
+
This release is intentionally small. Useful feedback includes:
|
|
240
|
+
|
|
241
|
+
- Where your agent currently gets date/time logic wrong
|
|
242
|
+
- Which calendar rules you actually need
|
|
243
|
+
- Whether local stdio is enough or remote HTTP matters
|
|
244
|
+
- Which operations you expected but did not find
|
|
245
|
+
- Whether you would adopt a shared temporal utility instead of maintaining date logic inside each agent
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
<!-- mcp-name: io.github.kelli930/chronoguard -->
|
|
2
|
+
|
|
3
|
+
# ChronoGuard
|
|
4
|
+
|
|
5
|
+
Deterministic date, time, timezone, holiday, and business-day arithmetic for AI agents via MCP.
|
|
6
|
+
|
|
7
|
+
LLMs are good at language. They should not have to guess whether a deadline lands on a holiday, whether five business days crosses a weekend, or what a timestamp means across a daylight-saving transition. ChronoGuard moves that work into a small deterministic tool with a stable contract.
|
|
8
|
+
|
|
9
|
+
## Status
|
|
10
|
+
|
|
11
|
+
**v0.1.1 — public validation prototype**
|
|
12
|
+
|
|
13
|
+
Validated in Replit on September 11, 2026 with:
|
|
14
|
+
|
|
15
|
+
- 28 automated tests passing
|
|
16
|
+
- FastMCP 4.0.3
|
|
17
|
+
- MCP 2.2.0
|
|
18
|
+
- Real `holidays` package integration
|
|
19
|
+
- Successful real stdio MCP client discovery of `chronoguard_resolve_time`
|
|
20
|
+
- Successful end-to-end MCP tool invocation through the stdio server
|
|
21
|
+
- Successful FastMCP inspector discovery
|
|
22
|
+
|
|
23
|
+
ChronoGuard is ready for developer testing, but it is **not yet positioned as production-grade global business-calendar infrastructure**.
|
|
24
|
+
|
|
25
|
+
## What ChronoGuard solves
|
|
26
|
+
|
|
27
|
+
ChronoGuard gives an agent a deterministic answer for temporal questions that are easy for an LLM to get subtly wrong.
|
|
28
|
+
|
|
29
|
+
Example workflows:
|
|
30
|
+
|
|
31
|
+
1. **SLA deadlines** — “What is 4 US business days after this support ticket opened?”
|
|
32
|
+
2. **Billing and finance cutoffs** — “What is the previous business day before month-end?”
|
|
33
|
+
3. **Rolling data windows** — “Give me the exact timestamps for the last 30 days.”
|
|
34
|
+
4. **Timezone-safe scheduling** — “Convert this timestamp to America/Chicago and preserve the correct date.”
|
|
35
|
+
5. **Holiday-aware automation** — “What date is 5 business days after Friday, September 11, 2026?”
|
|
36
|
+
|
|
37
|
+
## Supported operations
|
|
38
|
+
|
|
39
|
+
The MCP tool is named:
|
|
40
|
+
|
|
41
|
+
```text
|
|
42
|
+
chronoguard_resolve_time
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Supported operations:
|
|
46
|
+
|
|
47
|
+
- `current_time`
|
|
48
|
+
- `add_duration`
|
|
49
|
+
- `subtract_duration`
|
|
50
|
+
- `business_day_offset`
|
|
51
|
+
- `calculate_span`
|
|
52
|
+
|
|
53
|
+
Supported units:
|
|
54
|
+
|
|
55
|
+
- `minutes`
|
|
56
|
+
- `hours`
|
|
57
|
+
- `days`
|
|
58
|
+
- `weeks`
|
|
59
|
+
- `business_days`
|
|
60
|
+
|
|
61
|
+
Other inputs:
|
|
62
|
+
|
|
63
|
+
- IANA timezone such as `America/Chicago` or `UTC`
|
|
64
|
+
- ISO-8601 reference timestamp
|
|
65
|
+
- Country code such as `US`
|
|
66
|
+
- Optional holiday-calendar subdivision such as a state or region when supported by the `holidays` package
|
|
67
|
+
|
|
68
|
+
## Temporal semantics
|
|
69
|
+
|
|
70
|
+
ChronoGuard deliberately distinguishes different meanings of “add time”:
|
|
71
|
+
|
|
72
|
+
- **Minutes / hours:** elapsed-time arithmetic. Calculation happens through UTC and converts back to the requested timezone.
|
|
73
|
+
- **Days / weeks:** local calendar arithmetic, preserving wall-clock time across DST changes.
|
|
74
|
+
- **Business days:** local calendar arithmetic that skips weekends and supported official holidays.
|
|
75
|
+
- **Naive local timestamps:** accepted only when they map to one unambiguous real instant. Nonexistent spring-forward times and ambiguous fall-back times are rejected unless an explicit UTC offset is supplied.
|
|
76
|
+
|
|
77
|
+
## Important v0.1 limitation
|
|
78
|
+
|
|
79
|
+
ChronoGuard currently assumes **Saturday and Sunday are weekends** for business-day calculations.
|
|
80
|
+
|
|
81
|
+
The `holidays` dependency supports many countries and subdivisions, but that does **not** mean v0.1 correctly models every country's weekend convention, banking calendar, exchange calendar, or company-specific business calendar.
|
|
82
|
+
|
|
83
|
+
Do not describe v0.1 as universally correct for global business calendars.
|
|
84
|
+
|
|
85
|
+
## Install
|
|
86
|
+
|
|
87
|
+
Requires Python 3.11+.
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
python -m pip install -r requirements.txt
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Run the tests
|
|
94
|
+
|
|
95
|
+
From the project root:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
python -m pytest -v
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Expected result for this release:
|
|
102
|
+
|
|
103
|
+
```text
|
|
104
|
+
28 passed
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Inspect the MCP server
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
fastmcp inspect server.py
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
A successful inspection should show one registered tool.
|
|
114
|
+
|
|
115
|
+
## Run locally over stdio
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
python server.py
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
ChronoGuard currently uses MCP stdio transport for local clients.
|
|
122
|
+
|
|
123
|
+
## Example MCP client call
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
import asyncio
|
|
127
|
+
from fastmcp import Client
|
|
128
|
+
from server import mcp
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
async def main():
|
|
132
|
+
async with Client(mcp) as client:
|
|
133
|
+
result = await client.call_tool(
|
|
134
|
+
"chronoguard_resolve_time",
|
|
135
|
+
{
|
|
136
|
+
"operation": "business_day_offset",
|
|
137
|
+
"timezone": "America/Chicago",
|
|
138
|
+
"reference_timestamp": "2026-09-11T10:00:00",
|
|
139
|
+
"value": 5,
|
|
140
|
+
"country_code": "US",
|
|
141
|
+
},
|
|
142
|
+
)
|
|
143
|
+
print(result.data)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
asyncio.run(main())
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Expected resolved date:
|
|
150
|
+
|
|
151
|
+
```text
|
|
152
|
+
2026-09-18
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Example MCP configuration
|
|
156
|
+
|
|
157
|
+
For an MCP client that launches local stdio servers, use a configuration shaped like this and replace the path with the absolute location of `server.py` on your machine:
|
|
158
|
+
|
|
159
|
+
```json
|
|
160
|
+
{
|
|
161
|
+
"mcpServers": {
|
|
162
|
+
"chronoguard": {
|
|
163
|
+
"command": "python",
|
|
164
|
+
"args": ["/absolute/path/to/server.py"]
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Depending on the client and Python environment, you may need to use the absolute path to the Python executable for the environment where ChronoGuard's dependencies are installed.
|
|
171
|
+
|
|
172
|
+
## Example response
|
|
173
|
+
|
|
174
|
+
A successful business-day call returns structured data such as:
|
|
175
|
+
|
|
176
|
+
```json
|
|
177
|
+
{
|
|
178
|
+
"resolved_datetime_iso": "2026-09-18T10:00:00-05:00",
|
|
179
|
+
"timezone": "America/Chicago",
|
|
180
|
+
"day_of_week": "Friday",
|
|
181
|
+
"is_business_day": true,
|
|
182
|
+
"is_holiday": false,
|
|
183
|
+
"holiday_name": null,
|
|
184
|
+
"date_range": null
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Error behavior
|
|
189
|
+
|
|
190
|
+
ChronoGuard fails explicitly rather than silently guessing when it encounters inputs such as:
|
|
191
|
+
|
|
192
|
+
- Invalid IANA timezone names
|
|
193
|
+
- Invalid ISO timestamps
|
|
194
|
+
- Unsupported holiday calendars
|
|
195
|
+
- Nonexistent DST-local times
|
|
196
|
+
- Ambiguous DST-local times without an explicit offset
|
|
197
|
+
|
|
198
|
+
That behavior is intentional: a deterministic agent tool should prefer a clear error to a plausible but wrong date.
|
|
199
|
+
|
|
200
|
+
## What is not in v0.1
|
|
201
|
+
|
|
202
|
+
Not yet supported:
|
|
203
|
+
|
|
204
|
+
- Non-Saturday/Sunday weekend conventions
|
|
205
|
+
- NYSE or other exchange calendars
|
|
206
|
+
- Federal Reserve settlement calendars
|
|
207
|
+
- Custom company holiday calendars
|
|
208
|
+
- Remote HTTP transport
|
|
209
|
+
- Authentication or rate limiting
|
|
210
|
+
- Hosted commercial API
|
|
211
|
+
- Billing or usage metering
|
|
212
|
+
|
|
213
|
+
Those should be added only after developer demand justifies them.
|
|
214
|
+
|
|
215
|
+
## Why this exists
|
|
216
|
+
|
|
217
|
+
The experiment behind ChronoGuard is simple:
|
|
218
|
+
|
|
219
|
+
> When an AI workflow has a narrow deterministic failure mode, move that task out of LLM reasoning and into a small tool with a strict contract.
|
|
220
|
+
|
|
221
|
+
ChronoGuard is the first test of that idea.
|
|
222
|
+
|
|
223
|
+
## Feedback wanted
|
|
224
|
+
|
|
225
|
+
This release is intentionally small. Useful feedback includes:
|
|
226
|
+
|
|
227
|
+
- Where your agent currently gets date/time logic wrong
|
|
228
|
+
- Which calendar rules you actually need
|
|
229
|
+
- Whether local stdio is enough or remote HTTP matters
|
|
230
|
+
- Which operations you expected but did not find
|
|
231
|
+
- Whether you would adopt a shared temporal utility instead of maintaining date logic inside each agent
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "chronoguard"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "Deterministic date, time, timezone, holiday, and business-day arithmetic for AI agents via MCP."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"fastmcp>=4.0.3",
|
|
13
|
+
"holidays>=0.80",
|
|
14
|
+
"python-dateutil>=2.9",
|
|
15
|
+
"pydantic>=2"
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.urls]
|
|
19
|
+
Homepage = "https://github.com/kelli930/chronoguard"
|
|
20
|
+
Repository = "https://github.com/kelli930/chronoguard"
|
|
21
|
+
Issues = "https://github.com/kelli930/chronoguard/issues"
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
chronoguard = "chronoguard.server:main"
|
|
25
|
+
|
|
26
|
+
[tool.setuptools.packages.find]
|
|
27
|
+
where = ["src"]
|
|
28
|
+
|
|
29
|
+
[tool.pytest.ini_options]
|
|
30
|
+
pythonpath = ["src"]
|
|
31
|
+
testpaths = ["tests"]
|
|
File without changes
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
from datetime import datetime, date, timedelta, timezone as dt_timezone
|
|
2
|
+
import zoneinfo
|
|
3
|
+
from typing import Optional, Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
import holidays
|
|
7
|
+
from dateutil import parser
|
|
8
|
+
from fastmcp import FastMCP
|
|
9
|
+
|
|
10
|
+
mcp = FastMCP(name="ChronoGuard")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DateRange(BaseModel):
|
|
14
|
+
start_iso: str
|
|
15
|
+
end_iso: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ChronoGuardResponse(BaseModel):
|
|
19
|
+
resolved_datetime_iso: str
|
|
20
|
+
timezone: str
|
|
21
|
+
day_of_week: str
|
|
22
|
+
is_business_day: bool
|
|
23
|
+
is_holiday: bool
|
|
24
|
+
holiday_name: Optional[str] = None
|
|
25
|
+
date_range: Optional[DateRange] = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _get_holiday_calendar(country_code: str, subdiv: Optional[str] = None):
|
|
29
|
+
"""Return a supported holiday calendar or fail explicitly."""
|
|
30
|
+
try:
|
|
31
|
+
return holidays.country_holidays(country_code.upper(), subdiv=subdiv)
|
|
32
|
+
except (NotImplementedError, KeyError, ValueError) as exc:
|
|
33
|
+
detail = f" country '{country_code}'"
|
|
34
|
+
if subdiv:
|
|
35
|
+
detail += f" subdivision '{subdiv}'"
|
|
36
|
+
raise ValueError(f"Unsupported holiday calendar for{detail}.") from exc
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_workday(
|
|
40
|
+
target_date: date, country_code: str, subdiv: Optional[str] = None
|
|
41
|
+
) -> tuple[bool, bool, Optional[str]]:
|
|
42
|
+
"""Check business-day status using a Sat/Sun weekend plus official holidays.
|
|
43
|
+
|
|
44
|
+
v0.1 intentionally supports only calendars whose weekend is Saturday/Sunday.
|
|
45
|
+
Holiday status is evaluated even when the holiday falls on a weekend.
|
|
46
|
+
"""
|
|
47
|
+
country_holidays = _get_holiday_calendar(country_code, subdiv)
|
|
48
|
+
is_holiday = target_date in country_holidays
|
|
49
|
+
holiday_name = country_holidays.get(target_date) if is_holiday else None
|
|
50
|
+
is_weekend = target_date.weekday() >= 5
|
|
51
|
+
return (not is_weekend and not is_holiday), is_holiday, holiday_name
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def shift_business_days(
|
|
55
|
+
start_dt: datetime,
|
|
56
|
+
offset: int,
|
|
57
|
+
country_code: str,
|
|
58
|
+
subdiv: Optional[str] = None,
|
|
59
|
+
) -> datetime:
|
|
60
|
+
"""Offset by N business days while preserving local wall-clock time."""
|
|
61
|
+
if offset == 0:
|
|
62
|
+
return start_dt
|
|
63
|
+
|
|
64
|
+
step = 1 if offset > 0 else -1
|
|
65
|
+
remaining = abs(offset)
|
|
66
|
+
current_dt = start_dt
|
|
67
|
+
|
|
68
|
+
while remaining:
|
|
69
|
+
current_dt += timedelta(days=step)
|
|
70
|
+
workday, _, _ = is_workday(current_dt.date(), country_code, subdiv)
|
|
71
|
+
if workday:
|
|
72
|
+
remaining -= 1
|
|
73
|
+
|
|
74
|
+
return current_dt
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _validate_naive_local_datetime(parsed_dt: datetime, tz: zoneinfo.ZoneInfo) -> datetime:
|
|
78
|
+
"""Attach tzinfo only when a naive local time maps to exactly one real instant.
|
|
79
|
+
|
|
80
|
+
Rejects nonexistent spring-forward times and ambiguous fall-back times rather
|
|
81
|
+
than silently choosing an interpretation.
|
|
82
|
+
"""
|
|
83
|
+
candidates: list[datetime] = []
|
|
84
|
+
for fold in (0, 1):
|
|
85
|
+
candidate = parsed_dt.replace(tzinfo=tz, fold=fold)
|
|
86
|
+
roundtrip = candidate.astimezone(dt_timezone.utc).astimezone(tz)
|
|
87
|
+
if roundtrip.replace(tzinfo=None) == parsed_dt:
|
|
88
|
+
candidates.append(candidate)
|
|
89
|
+
|
|
90
|
+
unique_offsets = {candidate.utcoffset() for candidate in candidates}
|
|
91
|
+
if not candidates:
|
|
92
|
+
raise ValueError(
|
|
93
|
+
f"Local time '{parsed_dt.isoformat()}' does not exist in timezone '{tz.key}' due to a DST transition."
|
|
94
|
+
)
|
|
95
|
+
if len(unique_offsets) > 1:
|
|
96
|
+
raise ValueError(
|
|
97
|
+
f"Local time '{parsed_dt.isoformat()}' is ambiguous in timezone '{tz.key}' due to a DST transition; provide an explicit UTC offset."
|
|
98
|
+
)
|
|
99
|
+
return candidates[0]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _parse_reference(reference_timestamp: Optional[str], tz: zoneinfo.ZoneInfo) -> datetime:
|
|
103
|
+
if reference_timestamp is None:
|
|
104
|
+
return datetime.now(tz)
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
parsed_dt = parser.isoparse(reference_timestamp)
|
|
108
|
+
except Exception as exc:
|
|
109
|
+
raise ValueError(f"Could not parse reference_timestamp ISO string: {exc}") from exc
|
|
110
|
+
|
|
111
|
+
if parsed_dt.tzinfo is None:
|
|
112
|
+
return _validate_naive_local_datetime(parsed_dt, tz)
|
|
113
|
+
return parsed_dt.astimezone(tz)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _apply_amount(
|
|
117
|
+
base_dt: datetime,
|
|
118
|
+
value: int,
|
|
119
|
+
unit: Literal["minutes", "hours", "days", "weeks", "business_days"],
|
|
120
|
+
country_code: str,
|
|
121
|
+
subdivision: Optional[str],
|
|
122
|
+
) -> datetime:
|
|
123
|
+
"""Apply a signed amount with explicit temporal semantics.
|
|
124
|
+
|
|
125
|
+
minutes/hours = elapsed time (UTC arithmetic)
|
|
126
|
+
days/weeks = local calendar arithmetic, preserving local wall-clock time
|
|
127
|
+
business_days = local business-calendar arithmetic
|
|
128
|
+
"""
|
|
129
|
+
if unit == "minutes":
|
|
130
|
+
utc_result = base_dt.astimezone(dt_timezone.utc) + timedelta(minutes=value)
|
|
131
|
+
return utc_result.astimezone(base_dt.tzinfo)
|
|
132
|
+
if unit == "hours":
|
|
133
|
+
utc_result = base_dt.astimezone(dt_timezone.utc) + timedelta(hours=value)
|
|
134
|
+
return utc_result.astimezone(base_dt.tzinfo)
|
|
135
|
+
if unit == "days":
|
|
136
|
+
return base_dt + timedelta(days=value)
|
|
137
|
+
if unit == "weeks":
|
|
138
|
+
return base_dt + timedelta(weeks=value)
|
|
139
|
+
if unit == "business_days":
|
|
140
|
+
return shift_business_days(base_dt, value, country_code, subdivision)
|
|
141
|
+
raise ValueError(f"Unsupported unit: {unit}")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def execute_chronoguard(
|
|
145
|
+
operation: Literal[
|
|
146
|
+
"current_time",
|
|
147
|
+
"add_duration",
|
|
148
|
+
"subtract_duration",
|
|
149
|
+
"business_day_offset",
|
|
150
|
+
"calculate_span",
|
|
151
|
+
],
|
|
152
|
+
timezone: str = "UTC",
|
|
153
|
+
reference_timestamp: Optional[str] = None,
|
|
154
|
+
unit: Optional[Literal["minutes", "hours", "days", "weeks", "business_days"]] = None,
|
|
155
|
+
value: int = 0,
|
|
156
|
+
country_code: str = "US",
|
|
157
|
+
subdivision: Optional[str] = None,
|
|
158
|
+
) -> ChronoGuardResponse:
|
|
159
|
+
try:
|
|
160
|
+
tz = zoneinfo.ZoneInfo(timezone)
|
|
161
|
+
except (zoneinfo.ZoneInfoNotFoundError, ValueError) as exc:
|
|
162
|
+
raise ValueError(
|
|
163
|
+
f"Invalid IANA timezone string: '{timezone}'. Example: 'America/Chicago' or 'UTC'"
|
|
164
|
+
) from exc
|
|
165
|
+
|
|
166
|
+
base_dt = _parse_reference(reference_timestamp, tz)
|
|
167
|
+
resolved_dt = base_dt
|
|
168
|
+
date_range = None
|
|
169
|
+
|
|
170
|
+
if operation == "current_time":
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
elif operation in ("add_duration", "subtract_duration"):
|
|
174
|
+
if unit is None:
|
|
175
|
+
raise ValueError(f"'{operation}' requires 'unit'.")
|
|
176
|
+
if value <= 0:
|
|
177
|
+
raise ValueError(f"'{operation}' requires a positive non-zero 'value'.")
|
|
178
|
+
signed_value = value if operation == "add_duration" else -value
|
|
179
|
+
resolved_dt = _apply_amount(
|
|
180
|
+
base_dt, signed_value, unit, country_code, subdivision
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
elif operation == "business_day_offset":
|
|
184
|
+
resolved_dt = shift_business_days(
|
|
185
|
+
base_dt, value, country_code, subdivision
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
elif operation == "calculate_span":
|
|
189
|
+
if unit is None:
|
|
190
|
+
raise ValueError("'calculate_span' requires 'unit'.")
|
|
191
|
+
other_end = _apply_amount(
|
|
192
|
+
base_dt, value, unit, country_code, subdivision
|
|
193
|
+
)
|
|
194
|
+
if value < 0:
|
|
195
|
+
start, end = other_end, base_dt
|
|
196
|
+
else:
|
|
197
|
+
start, end = base_dt, other_end
|
|
198
|
+
date_range = DateRange(start_iso=start.isoformat(), end_iso=end.isoformat())
|
|
199
|
+
resolved_dt = other_end
|
|
200
|
+
|
|
201
|
+
else:
|
|
202
|
+
raise ValueError(f"Unsupported operation: {operation}")
|
|
203
|
+
|
|
204
|
+
workday, is_holiday, holiday_name = is_workday(
|
|
205
|
+
resolved_dt.date(), country_code, subdivision
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
return ChronoGuardResponse(
|
|
209
|
+
resolved_datetime_iso=resolved_dt.isoformat(),
|
|
210
|
+
timezone=timezone,
|
|
211
|
+
day_of_week=resolved_dt.strftime("%A"),
|
|
212
|
+
is_business_day=workday,
|
|
213
|
+
is_holiday=is_holiday,
|
|
214
|
+
holiday_name=holiday_name,
|
|
215
|
+
date_range=date_range,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@mcp.tool(
|
|
220
|
+
name="chronoguard_resolve_time",
|
|
221
|
+
description=(
|
|
222
|
+
"Deterministic temporal resolution and business-date arithmetic. "
|
|
223
|
+
"Use for current time, elapsed or calendar offsets, rolling spans, "
|
|
224
|
+
"business-day offsets, SLAs, and financial cutoffs. v0.1 business-day "
|
|
225
|
+
"calendars assume a Saturday/Sunday weekend."
|
|
226
|
+
),
|
|
227
|
+
)
|
|
228
|
+
def chronoguard_tool(
|
|
229
|
+
operation: Literal[
|
|
230
|
+
"current_time",
|
|
231
|
+
"add_duration",
|
|
232
|
+
"subtract_duration",
|
|
233
|
+
"business_day_offset",
|
|
234
|
+
"calculate_span",
|
|
235
|
+
],
|
|
236
|
+
timezone: str = "UTC",
|
|
237
|
+
reference_timestamp: Optional[str] = None,
|
|
238
|
+
unit: Optional[Literal["minutes", "hours", "days", "weeks", "business_days"]] = None,
|
|
239
|
+
value: int = 0,
|
|
240
|
+
country_code: str = "US",
|
|
241
|
+
subdivision: Optional[str] = None,
|
|
242
|
+
) -> dict:
|
|
243
|
+
"""Resolve and calculate dates deterministically."""
|
|
244
|
+
return execute_chronoguard(
|
|
245
|
+
operation=operation,
|
|
246
|
+
timezone=timezone,
|
|
247
|
+
reference_timestamp=reference_timestamp,
|
|
248
|
+
unit=unit,
|
|
249
|
+
value=value,
|
|
250
|
+
country_code=country_code,
|
|
251
|
+
subdivision=subdivision,
|
|
252
|
+
).model_dump()
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def main():
|
|
256
|
+
mcp.run(transport="stdio")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
if __name__ == "__main__":
|
|
260
|
+
main()
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: chronoguard
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Deterministic date, time, timezone, holiday, and business-day arithmetic for AI agents via MCP.
|
|
5
|
+
Project-URL: Homepage, https://github.com/kelli930/chronoguard
|
|
6
|
+
Project-URL: Repository, https://github.com/kelli930/chronoguard
|
|
7
|
+
Project-URL: Issues, https://github.com/kelli930/chronoguard/issues
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: fastmcp>=4.0.3
|
|
11
|
+
Requires-Dist: holidays>=0.80
|
|
12
|
+
Requires-Dist: python-dateutil>=2.9
|
|
13
|
+
Requires-Dist: pydantic>=2
|
|
14
|
+
|
|
15
|
+
<!-- mcp-name: io.github.kelli930/chronoguard -->
|
|
16
|
+
|
|
17
|
+
# ChronoGuard
|
|
18
|
+
|
|
19
|
+
Deterministic date, time, timezone, holiday, and business-day arithmetic for AI agents via MCP.
|
|
20
|
+
|
|
21
|
+
LLMs are good at language. They should not have to guess whether a deadline lands on a holiday, whether five business days crosses a weekend, or what a timestamp means across a daylight-saving transition. ChronoGuard moves that work into a small deterministic tool with a stable contract.
|
|
22
|
+
|
|
23
|
+
## Status
|
|
24
|
+
|
|
25
|
+
**v0.1.1 — public validation prototype**
|
|
26
|
+
|
|
27
|
+
Validated in Replit on September 11, 2026 with:
|
|
28
|
+
|
|
29
|
+
- 28 automated tests passing
|
|
30
|
+
- FastMCP 4.0.3
|
|
31
|
+
- MCP 2.2.0
|
|
32
|
+
- Real `holidays` package integration
|
|
33
|
+
- Successful real stdio MCP client discovery of `chronoguard_resolve_time`
|
|
34
|
+
- Successful end-to-end MCP tool invocation through the stdio server
|
|
35
|
+
- Successful FastMCP inspector discovery
|
|
36
|
+
|
|
37
|
+
ChronoGuard is ready for developer testing, but it is **not yet positioned as production-grade global business-calendar infrastructure**.
|
|
38
|
+
|
|
39
|
+
## What ChronoGuard solves
|
|
40
|
+
|
|
41
|
+
ChronoGuard gives an agent a deterministic answer for temporal questions that are easy for an LLM to get subtly wrong.
|
|
42
|
+
|
|
43
|
+
Example workflows:
|
|
44
|
+
|
|
45
|
+
1. **SLA deadlines** — “What is 4 US business days after this support ticket opened?”
|
|
46
|
+
2. **Billing and finance cutoffs** — “What is the previous business day before month-end?”
|
|
47
|
+
3. **Rolling data windows** — “Give me the exact timestamps for the last 30 days.”
|
|
48
|
+
4. **Timezone-safe scheduling** — “Convert this timestamp to America/Chicago and preserve the correct date.”
|
|
49
|
+
5. **Holiday-aware automation** — “What date is 5 business days after Friday, September 11, 2026?”
|
|
50
|
+
|
|
51
|
+
## Supported operations
|
|
52
|
+
|
|
53
|
+
The MCP tool is named:
|
|
54
|
+
|
|
55
|
+
```text
|
|
56
|
+
chronoguard_resolve_time
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Supported operations:
|
|
60
|
+
|
|
61
|
+
- `current_time`
|
|
62
|
+
- `add_duration`
|
|
63
|
+
- `subtract_duration`
|
|
64
|
+
- `business_day_offset`
|
|
65
|
+
- `calculate_span`
|
|
66
|
+
|
|
67
|
+
Supported units:
|
|
68
|
+
|
|
69
|
+
- `minutes`
|
|
70
|
+
- `hours`
|
|
71
|
+
- `days`
|
|
72
|
+
- `weeks`
|
|
73
|
+
- `business_days`
|
|
74
|
+
|
|
75
|
+
Other inputs:
|
|
76
|
+
|
|
77
|
+
- IANA timezone such as `America/Chicago` or `UTC`
|
|
78
|
+
- ISO-8601 reference timestamp
|
|
79
|
+
- Country code such as `US`
|
|
80
|
+
- Optional holiday-calendar subdivision such as a state or region when supported by the `holidays` package
|
|
81
|
+
|
|
82
|
+
## Temporal semantics
|
|
83
|
+
|
|
84
|
+
ChronoGuard deliberately distinguishes different meanings of “add time”:
|
|
85
|
+
|
|
86
|
+
- **Minutes / hours:** elapsed-time arithmetic. Calculation happens through UTC and converts back to the requested timezone.
|
|
87
|
+
- **Days / weeks:** local calendar arithmetic, preserving wall-clock time across DST changes.
|
|
88
|
+
- **Business days:** local calendar arithmetic that skips weekends and supported official holidays.
|
|
89
|
+
- **Naive local timestamps:** accepted only when they map to one unambiguous real instant. Nonexistent spring-forward times and ambiguous fall-back times are rejected unless an explicit UTC offset is supplied.
|
|
90
|
+
|
|
91
|
+
## Important v0.1 limitation
|
|
92
|
+
|
|
93
|
+
ChronoGuard currently assumes **Saturday and Sunday are weekends** for business-day calculations.
|
|
94
|
+
|
|
95
|
+
The `holidays` dependency supports many countries and subdivisions, but that does **not** mean v0.1 correctly models every country's weekend convention, banking calendar, exchange calendar, or company-specific business calendar.
|
|
96
|
+
|
|
97
|
+
Do not describe v0.1 as universally correct for global business calendars.
|
|
98
|
+
|
|
99
|
+
## Install
|
|
100
|
+
|
|
101
|
+
Requires Python 3.11+.
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
python -m pip install -r requirements.txt
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Run the tests
|
|
108
|
+
|
|
109
|
+
From the project root:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
python -m pytest -v
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Expected result for this release:
|
|
116
|
+
|
|
117
|
+
```text
|
|
118
|
+
28 passed
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Inspect the MCP server
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
fastmcp inspect server.py
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
A successful inspection should show one registered tool.
|
|
128
|
+
|
|
129
|
+
## Run locally over stdio
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
python server.py
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
ChronoGuard currently uses MCP stdio transport for local clients.
|
|
136
|
+
|
|
137
|
+
## Example MCP client call
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
import asyncio
|
|
141
|
+
from fastmcp import Client
|
|
142
|
+
from server import mcp
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def main():
|
|
146
|
+
async with Client(mcp) as client:
|
|
147
|
+
result = await client.call_tool(
|
|
148
|
+
"chronoguard_resolve_time",
|
|
149
|
+
{
|
|
150
|
+
"operation": "business_day_offset",
|
|
151
|
+
"timezone": "America/Chicago",
|
|
152
|
+
"reference_timestamp": "2026-09-11T10:00:00",
|
|
153
|
+
"value": 5,
|
|
154
|
+
"country_code": "US",
|
|
155
|
+
},
|
|
156
|
+
)
|
|
157
|
+
print(result.data)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
asyncio.run(main())
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Expected resolved date:
|
|
164
|
+
|
|
165
|
+
```text
|
|
166
|
+
2026-09-18
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Example MCP configuration
|
|
170
|
+
|
|
171
|
+
For an MCP client that launches local stdio servers, use a configuration shaped like this and replace the path with the absolute location of `server.py` on your machine:
|
|
172
|
+
|
|
173
|
+
```json
|
|
174
|
+
{
|
|
175
|
+
"mcpServers": {
|
|
176
|
+
"chronoguard": {
|
|
177
|
+
"command": "python",
|
|
178
|
+
"args": ["/absolute/path/to/server.py"]
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Depending on the client and Python environment, you may need to use the absolute path to the Python executable for the environment where ChronoGuard's dependencies are installed.
|
|
185
|
+
|
|
186
|
+
## Example response
|
|
187
|
+
|
|
188
|
+
A successful business-day call returns structured data such as:
|
|
189
|
+
|
|
190
|
+
```json
|
|
191
|
+
{
|
|
192
|
+
"resolved_datetime_iso": "2026-09-18T10:00:00-05:00",
|
|
193
|
+
"timezone": "America/Chicago",
|
|
194
|
+
"day_of_week": "Friday",
|
|
195
|
+
"is_business_day": true,
|
|
196
|
+
"is_holiday": false,
|
|
197
|
+
"holiday_name": null,
|
|
198
|
+
"date_range": null
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## Error behavior
|
|
203
|
+
|
|
204
|
+
ChronoGuard fails explicitly rather than silently guessing when it encounters inputs such as:
|
|
205
|
+
|
|
206
|
+
- Invalid IANA timezone names
|
|
207
|
+
- Invalid ISO timestamps
|
|
208
|
+
- Unsupported holiday calendars
|
|
209
|
+
- Nonexistent DST-local times
|
|
210
|
+
- Ambiguous DST-local times without an explicit offset
|
|
211
|
+
|
|
212
|
+
That behavior is intentional: a deterministic agent tool should prefer a clear error to a plausible but wrong date.
|
|
213
|
+
|
|
214
|
+
## What is not in v0.1
|
|
215
|
+
|
|
216
|
+
Not yet supported:
|
|
217
|
+
|
|
218
|
+
- Non-Saturday/Sunday weekend conventions
|
|
219
|
+
- NYSE or other exchange calendars
|
|
220
|
+
- Federal Reserve settlement calendars
|
|
221
|
+
- Custom company holiday calendars
|
|
222
|
+
- Remote HTTP transport
|
|
223
|
+
- Authentication or rate limiting
|
|
224
|
+
- Hosted commercial API
|
|
225
|
+
- Billing or usage metering
|
|
226
|
+
|
|
227
|
+
Those should be added only after developer demand justifies them.
|
|
228
|
+
|
|
229
|
+
## Why this exists
|
|
230
|
+
|
|
231
|
+
The experiment behind ChronoGuard is simple:
|
|
232
|
+
|
|
233
|
+
> When an AI workflow has a narrow deterministic failure mode, move that task out of LLM reasoning and into a small tool with a strict contract.
|
|
234
|
+
|
|
235
|
+
ChronoGuard is the first test of that idea.
|
|
236
|
+
|
|
237
|
+
## Feedback wanted
|
|
238
|
+
|
|
239
|
+
This release is intentionally small. Useful feedback includes:
|
|
240
|
+
|
|
241
|
+
- Where your agent currently gets date/time logic wrong
|
|
242
|
+
- Which calendar rules you actually need
|
|
243
|
+
- Whether local stdio is enough or remote HTTP matters
|
|
244
|
+
- Which operations you expected but did not find
|
|
245
|
+
- Whether you would adopt a shared temporal utility instead of maintaining date logic inside each agent
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/chronoguard/__init__.py
|
|
4
|
+
src/chronoguard/server.py
|
|
5
|
+
src/chronoguard.egg-info/PKG-INFO
|
|
6
|
+
src/chronoguard.egg-info/SOURCES.txt
|
|
7
|
+
src/chronoguard.egg-info/dependency_links.txt
|
|
8
|
+
src/chronoguard.egg-info/entry_points.txt
|
|
9
|
+
src/chronoguard.egg-info/requires.txt
|
|
10
|
+
src/chronoguard.egg-info/top_level.txt
|
|
11
|
+
tests/test_chronoguard.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
chronoguard
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
from chronoguard.server import execute_chronoguard
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import sys
|
|
9
|
+
import fastmcp
|
|
10
|
+
from fastmcp import Client
|
|
11
|
+
from fastmcp.client.transports import StdioTransport
|
|
12
|
+
from chronoguard.server import execute_chronoguard, mcp
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def iso(res):
|
|
16
|
+
return datetime.fromisoformat(res.resolved_datetime_iso)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_weekend_skipping_forward():
|
|
20
|
+
res = execute_chronoguard(
|
|
21
|
+
operation="business_day_offset",
|
|
22
|
+
reference_timestamp="2026-09-11T09:00:00Z",
|
|
23
|
+
value=1,
|
|
24
|
+
timezone="UTC",
|
|
25
|
+
)
|
|
26
|
+
assert res.day_of_week == "Monday"
|
|
27
|
+
assert res.is_business_day is True
|
|
28
|
+
assert iso(res).date().isoformat() == "2026-09-14"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_weekend_skipping_backward():
|
|
32
|
+
res = execute_chronoguard(
|
|
33
|
+
operation="business_day_offset",
|
|
34
|
+
reference_timestamp="2026-09-14T09:00:00Z",
|
|
35
|
+
value=-1,
|
|
36
|
+
timezone="UTC",
|
|
37
|
+
)
|
|
38
|
+
assert iso(res).date().isoformat() == "2026-09-11"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_us_labor_day_skipped():
|
|
42
|
+
res = execute_chronoguard(
|
|
43
|
+
operation="business_day_offset",
|
|
44
|
+
reference_timestamp="2026-09-04T12:00:00",
|
|
45
|
+
value=1,
|
|
46
|
+
country_code="US",
|
|
47
|
+
timezone="America/New_York",
|
|
48
|
+
)
|
|
49
|
+
assert res.day_of_week == "Tuesday"
|
|
50
|
+
assert iso(res).date().isoformat() == "2026-09-08"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_holiday_metadata_on_weekday():
|
|
54
|
+
res = execute_chronoguard(
|
|
55
|
+
operation="current_time",
|
|
56
|
+
reference_timestamp="2026-07-03T12:00:00",
|
|
57
|
+
country_code="US",
|
|
58
|
+
timezone="America/New_York",
|
|
59
|
+
)
|
|
60
|
+
assert res.is_holiday is True
|
|
61
|
+
assert res.is_business_day is False
|
|
62
|
+
assert res.holiday_name
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_holiday_metadata_even_when_on_weekend():
|
|
66
|
+
res = execute_chronoguard(
|
|
67
|
+
operation="current_time",
|
|
68
|
+
reference_timestamp="2027-12-25T12:00:00",
|
|
69
|
+
country_code="US",
|
|
70
|
+
timezone="America/New_York",
|
|
71
|
+
)
|
|
72
|
+
assert res.is_holiday is True
|
|
73
|
+
assert res.is_business_day is False
|
|
74
|
+
assert "Christmas" in res.holiday_name
|
|
75
|
+
|
|
76
|
+
@pytest.mark.parametrize(
|
|
77
|
+
("country_code", "reference_timestamp", "holiday_fragment"),
|
|
78
|
+
[
|
|
79
|
+
("US", "2026-07-04T12:00:00", "Independence"),
|
|
80
|
+
("CA", "2026-07-01T12:00:00", "Canada Day"),
|
|
81
|
+
("GB", "2026-12-25T12:00:00", "Christmas"),
|
|
82
|
+
],
|
|
83
|
+
)
|
|
84
|
+
def test_supported_country_holiday_calendars(country_code, reference_timestamp, holiday_fragment):
|
|
85
|
+
res = execute_chronoguard(
|
|
86
|
+
operation="current_time",
|
|
87
|
+
reference_timestamp=reference_timestamp,
|
|
88
|
+
timezone="UTC",
|
|
89
|
+
country_code=country_code,
|
|
90
|
+
)
|
|
91
|
+
assert res.is_holiday is True
|
|
92
|
+
assert res.is_business_day is False
|
|
93
|
+
assert res.holiday_name and holiday_fragment in res.holiday_name
|
|
94
|
+
def test_rolling_span_days_past():
|
|
95
|
+
res = execute_chronoguard(
|
|
96
|
+
operation="calculate_span",
|
|
97
|
+
reference_timestamp="2026-09-11T12:00:00Z",
|
|
98
|
+
value=-30,
|
|
99
|
+
unit="days",
|
|
100
|
+
timezone="UTC",
|
|
101
|
+
)
|
|
102
|
+
assert res.date_range is not None
|
|
103
|
+
assert res.date_range.start_iso.startswith("2026-08-12")
|
|
104
|
+
assert res.date_range.end_iso.startswith("2026-09-11")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_rolling_span_minutes_is_minutes_not_days():
|
|
108
|
+
res = execute_chronoguard(
|
|
109
|
+
operation="calculate_span",
|
|
110
|
+
reference_timestamp="2026-09-11T12:00:00Z",
|
|
111
|
+
value=-30,
|
|
112
|
+
unit="minutes",
|
|
113
|
+
timezone="UTC",
|
|
114
|
+
)
|
|
115
|
+
assert res.date_range.start_iso.startswith("2026-09-11T11:30:00")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_rolling_span_business_days():
|
|
119
|
+
res = execute_chronoguard(
|
|
120
|
+
operation="calculate_span",
|
|
121
|
+
reference_timestamp="2026-09-11T12:00:00-04:00",
|
|
122
|
+
value=-4,
|
|
123
|
+
unit="business_days",
|
|
124
|
+
timezone="America/New_York",
|
|
125
|
+
country_code="US",
|
|
126
|
+
)
|
|
127
|
+
# Fri Sep 11 back four business days crosses Labor Day Sep 7.
|
|
128
|
+
assert res.date_range.start_iso.startswith("2026-09-04T12:00:00")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_add_elapsed_hours_across_spring_dst():
|
|
132
|
+
res = execute_chronoguard(
|
|
133
|
+
operation="add_duration",
|
|
134
|
+
reference_timestamp="2026-03-08T01:30:00-06:00",
|
|
135
|
+
value=2,
|
|
136
|
+
unit="hours",
|
|
137
|
+
timezone="America/Chicago",
|
|
138
|
+
)
|
|
139
|
+
# Two elapsed hours after 01:30 CST is 04:30 CDT.
|
|
140
|
+
assert res.resolved_datetime_iso.startswith("2026-03-08T04:30:00-05:00")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_add_calendar_day_preserves_wall_clock_across_spring_dst():
|
|
144
|
+
res = execute_chronoguard(
|
|
145
|
+
operation="add_duration",
|
|
146
|
+
reference_timestamp="2026-03-07T12:00:00-06:00",
|
|
147
|
+
value=1,
|
|
148
|
+
unit="days",
|
|
149
|
+
timezone="America/Chicago",
|
|
150
|
+
)
|
|
151
|
+
assert res.resolved_datetime_iso.startswith("2026-03-08T12:00:00-05:00")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_reject_nonexistent_naive_dst_time():
|
|
155
|
+
with pytest.raises(ValueError, match="does not exist"):
|
|
156
|
+
execute_chronoguard(
|
|
157
|
+
operation="current_time",
|
|
158
|
+
reference_timestamp="2026-03-08T02:30:00",
|
|
159
|
+
timezone="America/Chicago",
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_reject_ambiguous_naive_dst_time():
|
|
164
|
+
with pytest.raises(ValueError, match="ambiguous"):
|
|
165
|
+
execute_chronoguard(
|
|
166
|
+
operation="current_time",
|
|
167
|
+
reference_timestamp="2026-11-01T01:30:00",
|
|
168
|
+
timezone="America/Chicago",
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_explicit_offset_disambiguates_fall_dst_time():
|
|
173
|
+
res = execute_chronoguard(
|
|
174
|
+
operation="current_time",
|
|
175
|
+
reference_timestamp="2026-11-01T01:30:00-05:00",
|
|
176
|
+
timezone="America/Chicago",
|
|
177
|
+
)
|
|
178
|
+
assert res.resolved_datetime_iso.startswith("2026-11-01T01:30:00-05:00")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_timezone_conversion_can_change_date():
|
|
182
|
+
res = execute_chronoguard(
|
|
183
|
+
operation="current_time",
|
|
184
|
+
reference_timestamp="2026-09-11T01:00:00Z",
|
|
185
|
+
timezone="America/Los_Angeles",
|
|
186
|
+
)
|
|
187
|
+
assert res.resolved_datetime_iso.startswith("2026-09-10T18:00:00-07:00")
|
|
188
|
+
assert res.day_of_week == "Thursday"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_invalid_timezone_rejected():
|
|
192
|
+
with pytest.raises(ValueError, match="Invalid IANA timezone"):
|
|
193
|
+
execute_chronoguard(operation="current_time", timezone="Mars/Olympus_Mons")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_invalid_timestamp_rejected():
|
|
197
|
+
with pytest.raises(ValueError, match="Could not parse"):
|
|
198
|
+
execute_chronoguard(
|
|
199
|
+
operation="current_time",
|
|
200
|
+
reference_timestamp="definitely-not-a-date",
|
|
201
|
+
timezone="UTC",
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_unsupported_country_rejected():
|
|
206
|
+
with pytest.raises(ValueError, match="Unsupported holiday calendar"):
|
|
207
|
+
execute_chronoguard(
|
|
208
|
+
operation="current_time",
|
|
209
|
+
reference_timestamp="2026-09-11T12:00:00Z",
|
|
210
|
+
country_code="ZZ",
|
|
211
|
+
timezone="UTC",
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_subdivision_holiday_new_york():
|
|
216
|
+
# Election Day is recognized by the US-NY holidays calendar in applicable years.
|
|
217
|
+
res = execute_chronoguard(
|
|
218
|
+
operation="current_time",
|
|
219
|
+
reference_timestamp="2026-11-03T12:00:00",
|
|
220
|
+
timezone="America/New_York",
|
|
221
|
+
country_code="US",
|
|
222
|
+
subdivision="NY",
|
|
223
|
+
)
|
|
224
|
+
# This assertion intentionally only validates that a subdivision calendar executes.
|
|
225
|
+
assert res.timezone == "America/New_York"
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_zero_business_day_offset_returns_reference():
|
|
229
|
+
res = execute_chronoguard(
|
|
230
|
+
operation="business_day_offset",
|
|
231
|
+
reference_timestamp="2026-09-12T12:00:00Z",
|
|
232
|
+
value=0,
|
|
233
|
+
timezone="UTC",
|
|
234
|
+
)
|
|
235
|
+
assert res.resolved_datetime_iso.startswith("2026-09-12T12:00:00+00:00")
|
|
236
|
+
assert res.is_business_day is False
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@pytest.mark.parametrize(
|
|
240
|
+
"operation",
|
|
241
|
+
["add_duration", "subtract_duration"],
|
|
242
|
+
)
|
|
243
|
+
def test_duration_requires_positive_value(operation):
|
|
244
|
+
with pytest.raises(ValueError, match="positive non-zero"):
|
|
245
|
+
execute_chronoguard(
|
|
246
|
+
operation=operation,
|
|
247
|
+
reference_timestamp="2026-09-11T12:00:00Z",
|
|
248
|
+
unit="hours",
|
|
249
|
+
value=0,
|
|
250
|
+
timezone="UTC",
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_subtract_duration():
|
|
255
|
+
res = execute_chronoguard(
|
|
256
|
+
operation="subtract_duration",
|
|
257
|
+
reference_timestamp="2026-09-11T12:00:00Z",
|
|
258
|
+
unit="hours",
|
|
259
|
+
value=3,
|
|
260
|
+
timezone="UTC",
|
|
261
|
+
)
|
|
262
|
+
assert res.resolved_datetime_iso.startswith("2026-09-11T09:00:00+00:00")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def test_business_day_offset_preserves_time():
|
|
266
|
+
res = execute_chronoguard(
|
|
267
|
+
operation="business_day_offset",
|
|
268
|
+
reference_timestamp="2026-09-11T15:45:12-04:00",
|
|
269
|
+
value=1,
|
|
270
|
+
timezone="America/New_York",
|
|
271
|
+
country_code="US",
|
|
272
|
+
)
|
|
273
|
+
dt = iso(res)
|
|
274
|
+
assert (dt.hour, dt.minute, dt.second) == (15, 45, 12)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def test_round_trip_business_days_from_business_date():
|
|
278
|
+
start = "2026-09-09T10:00:00-04:00"
|
|
279
|
+
forward = execute_chronoguard(
|
|
280
|
+
operation="business_day_offset",
|
|
281
|
+
reference_timestamp=start,
|
|
282
|
+
value=8,
|
|
283
|
+
timezone="America/New_York",
|
|
284
|
+
country_code="US",
|
|
285
|
+
)
|
|
286
|
+
backward = execute_chronoguard(
|
|
287
|
+
operation="business_day_offset",
|
|
288
|
+
reference_timestamp=forward.resolved_datetime_iso,
|
|
289
|
+
value=-8,
|
|
290
|
+
timezone="America/New_York",
|
|
291
|
+
country_code="US",
|
|
292
|
+
)
|
|
293
|
+
assert backward.resolved_datetime_iso.startswith("2026-09-09T10:00:00")
|
|
294
|
+
|
|
295
|
+
def test_fastmcp_client_discovers_and_invokes_tool():
|
|
296
|
+
async def exercise_mcp():
|
|
297
|
+
site_packages = str(Path(fastmcp.__file__).resolve().parent.parent)
|
|
298
|
+
python_path = os.pathsep.join(
|
|
299
|
+
path for path in (site_packages, os.environ.get("PYTHONPATH")) if path
|
|
300
|
+
)
|
|
301
|
+
transport = StdioTransport(
|
|
302
|
+
command=sys.executable,
|
|
303
|
+
args=[str(Path(__file__).resolve().parents[1] / "src" / "chronoguard" / "server.py")],
|
|
304
|
+
env={**os.environ, "PYTHONPATH": python_path},
|
|
305
|
+
)
|
|
306
|
+
async with Client(transport) as client:
|
|
307
|
+
tools = await client.list_tools()
|
|
308
|
+
result = await client.call_tool(
|
|
309
|
+
"chronoguard_resolve_time",
|
|
310
|
+
{
|
|
311
|
+
"operation": "current_time",
|
|
312
|
+
"reference_timestamp": "2026-07-01T12:00:00",
|
|
313
|
+
"timezone": "UTC",
|
|
314
|
+
"country_code": "CA",
|
|
315
|
+
},
|
|
316
|
+
)
|
|
317
|
+
return tools, result
|
|
318
|
+
|
|
319
|
+
tools, result = asyncio.run(exercise_mcp())
|
|
320
|
+
assert any(tool.name == "chronoguard_resolve_time" for tool in tools)
|
|
321
|
+
assert result.is_error is False
|
|
322
|
+
payload = result.structured_content
|
|
323
|
+
assert payload is not None
|
|
324
|
+
assert payload["is_holiday"] is True
|
|
325
|
+
assert payload["holiday_name"] == "Canada Day"
|