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.
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
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,2 @@
1
+ [console_scripts]
2
+ chronoguard = chronoguard.server:main
@@ -0,0 +1,4 @@
1
+ fastmcp>=4.0.3
2
+ holidays>=0.80
3
+ python-dateutil>=2.9
4
+ pydantic>=2
@@ -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"