ofw-mcp 2.9.0 → 2.9.1

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.
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "OurFamilyWizard tools for Claude Code",
9
- "version": "2.9.0"
9
+ "version": "2.9.1"
10
10
  },
11
11
  "plugins": [
12
12
  {
@@ -14,7 +14,7 @@
14
14
  "displayName": "OurFamilyWizard",
15
15
  "source": "./",
16
16
  "description": "OurFamilyWizard co-parenting tools for Claude — messages, calendar, expenses, and journal via MCP",
17
- "version": "2.9.0",
17
+ "version": "2.9.1",
18
18
  "author": {
19
19
  "name": "Chris Chall"
20
20
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ofw",
3
3
  "displayName": "OurFamilyWizard",
4
- "version": "2.9.0",
4
+ "version": "2.9.1",
5
5
  "description": "OurFamilyWizard co-parenting tools for Claude — messages, calendar, expenses, and journal via MCP",
6
6
  "author": {
7
7
  "name": "Chris Chall"
package/dist/bundle.js CHANGED
@@ -38407,7 +38407,7 @@ async function loginWithPassword(username, password) {
38407
38407
  // package.json
38408
38408
  var package_default = {
38409
38409
  name: "ofw-mcp",
38410
- version: "2.9.0",
38410
+ version: "2.9.1",
38411
38411
  license: "MIT",
38412
38412
  mcpName: "io.github.chrischall/ofw-mcp",
38413
38413
  description: "OurFamilyWizard MCP server for Claude \u2014 developed and maintained by AI (Claude Code)",
@@ -38689,11 +38689,208 @@ var OFWClient = class {
38689
38689
  };
38690
38690
  var client = new OFWClient();
38691
38691
 
38692
+ // src/timestamps.ts
38693
+ var DEFAULT_DISPLAY_TZ = "America/New_York";
38694
+ function isValidTimeZone(tz) {
38695
+ try {
38696
+ new Intl.DateTimeFormat("en-US", { timeZone: tz });
38697
+ return true;
38698
+ } catch {
38699
+ return false;
38700
+ }
38701
+ }
38702
+ function displayTimeZone() {
38703
+ const configured = readEnvVar("DISPLAY_TZ");
38704
+ if (configured && isValidTimeZone(configured)) return configured;
38705
+ return DEFAULT_DISPLAY_TZ;
38706
+ }
38707
+ function offsetAt(instant, tz) {
38708
+ const w = wallPartsIn(instant, tz);
38709
+ const asUTC = Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute, w.second, instant.getUTCMilliseconds());
38710
+ const minutes = Math.round((asUTC - instant.getTime()) / 6e4);
38711
+ const sign = minutes < 0 ? "-" : "+";
38712
+ const abs = Math.abs(minutes);
38713
+ return `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
38714
+ }
38715
+ var wallPartsFormatters = /* @__PURE__ */ new Map();
38716
+ var displayFormatters = /* @__PURE__ */ new Map();
38717
+ function wallPartsFormatter(tz) {
38718
+ let fmt = wallPartsFormatters.get(tz);
38719
+ if (!fmt) {
38720
+ fmt = buildWallPartsFormatter(tz);
38721
+ wallPartsFormatters.set(tz, fmt);
38722
+ }
38723
+ return fmt;
38724
+ }
38725
+ function wallPartsIn(instant, tz) {
38726
+ const parts = wallPartsFormatter(tz).formatToParts(instant);
38727
+ const out = {};
38728
+ for (const p of parts) {
38729
+ if (p.type !== "literal") out[p.type] = Number(p.value);
38730
+ }
38731
+ return out;
38732
+ }
38733
+ function buildWallPartsFormatter(tz) {
38734
+ return new Intl.DateTimeFormat("en-US", {
38735
+ timeZone: tz,
38736
+ year: "numeric",
38737
+ month: "2-digit",
38738
+ day: "2-digit",
38739
+ hour: "2-digit",
38740
+ minute: "2-digit",
38741
+ second: "2-digit",
38742
+ // h23 pins midnight to hour 00; without it some ICU builds render hour 24.
38743
+ hourCycle: "h23"
38744
+ });
38745
+ }
38746
+ function wallTimeToInstant(y, mo, d, h, mi, s, ms, tz) {
38747
+ let guess = Date.UTC(y, mo - 1, d, h, mi, s, ms);
38748
+ for (let i = 0; i < 2; i += 1) {
38749
+ const seen = wallPartsIn(new Date(guess), tz);
38750
+ const seenUTC = Date.UTC(seen.year, seen.month - 1, seen.day, seen.hour, seen.minute, seen.second, ms);
38751
+ const drift = Date.UTC(y, mo - 1, d, h, mi, s, ms) - seenUTC;
38752
+ if (drift === 0) break;
38753
+ guess += drift;
38754
+ }
38755
+ return new Date(guess);
38756
+ }
38757
+ function pad(n, width = 2) {
38758
+ return String(n).padStart(width, "0");
38759
+ }
38760
+ function isoWithOffset(instant, tz, offset) {
38761
+ const w = wallPartsIn(instant, tz);
38762
+ const msPart = instant.getUTCMilliseconds();
38763
+ const frac = msPart ? `.${pad(msPart, 3)}` : "";
38764
+ return `${pad(w.year, 4)}-${pad(w.month)}-${pad(w.day)}T${pad(w.hour)}:${pad(w.minute)}:${pad(w.second)}${frac}${offset}`;
38765
+ }
38766
+ function formatInstant(instant, tz = displayTimeZone()) {
38767
+ const offset = offsetAt(instant, tz);
38768
+ let fmt = displayFormatters.get(tz);
38769
+ if (!fmt) {
38770
+ fmt = new Intl.DateTimeFormat("en-US", {
38771
+ timeZone: tz,
38772
+ weekday: "short",
38773
+ month: "short",
38774
+ day: "numeric",
38775
+ year: "numeric",
38776
+ hour: "numeric",
38777
+ minute: "2-digit",
38778
+ timeZoneName: "short"
38779
+ });
38780
+ displayFormatters.set(tz, fmt);
38781
+ }
38782
+ return { iso: isoWithOffset(instant, tz, offset), display: fmt.format(instant) };
38783
+ }
38784
+ var TIMESTAMP_KEYS = /* @__PURE__ */ new Set([
38785
+ // OFW: naive local wall-clock from the API.
38786
+ "sentAt",
38787
+ "viewedAt",
38788
+ "modifiedAt",
38789
+ "createdAt",
38790
+ "dueAt",
38791
+ "occurredAt",
38792
+ // OFW: UTC instants we stamp ourselves.
38793
+ "fetchedBodyAt",
38794
+ "fetchedAt",
38795
+ "syncedAt",
38796
+ "downloadedAt",
38797
+ "recordedAt",
38798
+ "expiresAt",
38799
+ // Freshness/sync bookkeeping. These sit in the SAME object as `asOf`, so
38800
+ // omitting them left the freshness block emitting two zones at once — the
38801
+ // exact defect this module exists to remove. Enumerated from a sweep of
38802
+ // emitted field names rather than from the ones a bug report happened to
38803
+ // mention.
38804
+ "asOf",
38805
+ "checkedAt",
38806
+ "lastVerifiedAt",
38807
+ "oldestVerifiedAt",
38808
+ "lastServerSyncAt",
38809
+ "lastSyncAt",
38810
+ // OFW API inner shape: `date: { dateTime }`, `viewed: { dateTime }`.
38811
+ "dateTime",
38812
+ // Generic.
38813
+ "date",
38814
+ "updated",
38815
+ "lastModified",
38816
+ "expirationTime"
38817
+ ]);
38818
+ var ZONE_NAME_KEYS = /* @__PURE__ */ new Set(["timeZone", "timezone"]);
38819
+ var RFC3339_WITH_OFFSET = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\.(\d{1,9}))?(Z|[+-]\d{2}:?\d{2})$/;
38820
+ var NAIVE_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\.(\d{1,9}))?$/;
38821
+ var DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
38822
+ function isRealCalendarDate(p) {
38823
+ const utc = new Date(Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second));
38824
+ return utc.getUTCFullYear() === p.year && utc.getUTCMonth() === p.month - 1 && utc.getUTCDate() === p.day && utc.getUTCHours() === p.hour && utc.getUTCMinutes() === p.minute && utc.getUTCSeconds() === p.second;
38825
+ }
38826
+ function parseTimestampValue(key, value, assumeNaiveIn) {
38827
+ if (typeof value !== "string") return null;
38828
+ const raw = value.trim();
38829
+ if (raw === "" || DATE_ONLY.test(raw)) return null;
38830
+ if (RFC3339_WITH_OFFSET.test(raw)) {
38831
+ const parsed = new Date(raw.replace(" ", "T"));
38832
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
38833
+ }
38834
+ const naive = NAIVE_DATE_TIME.exec(raw);
38835
+ if (naive) {
38836
+ const [, y, mo, d, h, mi, s, frac] = naive;
38837
+ const ms = frac ? Number(frac.padEnd(3, "0").slice(0, 3)) : 0;
38838
+ const parts = {
38839
+ year: Number(y),
38840
+ month: Number(mo),
38841
+ day: Number(d),
38842
+ hour: Number(h),
38843
+ minute: Number(mi),
38844
+ second: Number(s ?? "0")
38845
+ };
38846
+ if (!isRealCalendarDate(parts)) return null;
38847
+ return wallTimeToInstant(
38848
+ parts.year,
38849
+ parts.month,
38850
+ parts.day,
38851
+ parts.hour,
38852
+ parts.minute,
38853
+ parts.second,
38854
+ ms,
38855
+ assumeNaiveIn
38856
+ );
38857
+ }
38858
+ return null;
38859
+ }
38860
+ function walk(node, tz) {
38861
+ if (Array.isArray(node)) {
38862
+ for (const item of node) walk(item, tz);
38863
+ return node;
38864
+ }
38865
+ if (node === null || typeof node !== "object") return node;
38866
+ const obj = node;
38867
+ for (const key of Object.keys(obj)) {
38868
+ const value = obj[key];
38869
+ if (value !== null && typeof value === "object") {
38870
+ walk(value, tz);
38871
+ continue;
38872
+ }
38873
+ if (ZONE_NAME_KEYS.has(key) || !TIMESTAMP_KEYS.has(key)) continue;
38874
+ const instant = parseTimestampValue(key, value, tz);
38875
+ if (!instant) continue;
38876
+ const { iso, display } = formatInstant(instant, tz);
38877
+ obj[key] = iso;
38878
+ obj[`${key}Display`] = display;
38879
+ }
38880
+ return obj;
38881
+ }
38882
+ function normalizeTimestampsInValue(value, tz = displayTimeZone()) {
38883
+ if (value === null || typeof value !== "object") return value;
38884
+ return walk(structuredClone(value), tz);
38885
+ }
38886
+
38692
38887
  // src/tools/_shared.ts
38693
- var jsonResponse = textResult;
38888
+ function jsonResponse(data) {
38889
+ return textResult(normalizeTimestampsInValue(data));
38890
+ }
38694
38891
  var textResponse = rawTextResult;
38695
38892
  function jsonErrorResponse(data) {
38696
- return { ...textResult(data), isError: true };
38893
+ return { ...jsonResponse(data), isError: true };
38697
38894
  }
38698
38895
  var ApiRecipientSchema = external_exports.looseObject({
38699
38896
  // Live OFW payloads key the recipient's id as `userId` (verified against a
@@ -40191,7 +40388,7 @@ function orderedPages(objects) {
40191
40388
  if (rootRef === void 0) return inFileOrder;
40192
40389
  const ordered = [];
40193
40390
  const seen = /* @__PURE__ */ new Set();
40194
- const walk = (num) => {
40391
+ const walk2 = (num) => {
40195
40392
  if (seen.has(num)) return;
40196
40393
  seen.add(num);
40197
40394
  const obj = objects.get(num);
@@ -40201,9 +40398,9 @@ function orderedPages(objects) {
40201
40398
  return;
40202
40399
  }
40203
40400
  const kids = /\/Kids\s*\[([^\]]*)\]/.exec(obj.body)?.[1];
40204
- if (kids) for (const kid of refsIn(kids)) walk(kid);
40401
+ if (kids) for (const kid of refsIn(kids)) walk2(kid);
40205
40402
  };
40206
- walk(rootRef);
40403
+ walk2(rootRef);
40207
40404
  return ordered.length > 0 ? ordered : inFileOrder;
40208
40405
  }
40209
40406
  function streamBytes(bytes, obj) {
@@ -42714,7 +42911,7 @@ var nodeCacheProvider = () => nodeCache ??= OFWCache.open(getCacheDbPath());
42714
42911
  var nodeAttachmentIO = new NodeAttachmentIO();
42715
42912
  await runMcp({
42716
42913
  name: "ofw",
42717
- version: "2.9.0",
42914
+ version: "2.9.1",
42718
42915
  // x-release-please-version
42719
42916
  deps: client,
42720
42917
  tools: [
package/dist/index.js CHANGED
@@ -35,7 +35,7 @@ const nodeAttachmentIO = new NodeAttachmentIO();
35
35
  // always succeeds before any credential check runs.
36
36
  await runMcp({
37
37
  name: 'ofw',
38
- version: '2.9.0', // x-release-please-version
38
+ version: '2.9.1', // x-release-please-version
39
39
  deps: client,
40
40
  tools: [
41
41
  registerUserTools,
@@ -0,0 +1,282 @@
1
+ // Canonical timestamp handling for every structured OFW response.
2
+ //
3
+ // A single response object used to mix zones: `sentAt`/`viewedAt`/`modifiedAt`
4
+ // came from OFW's API as NAIVE local wall-clock ("2026-07-27T23:31:09", no
5
+ // offset), while `fetchedBodyAt` and `freshness.asOf` were stamped by us as UTC
6
+ // with a `Z`. Nothing in the payload said which was which, so a reader assumed
7
+ // one zone for both and was wrong by the UTC offset on half the fields.
8
+ //
9
+ // The failure that matters is the calendar DAY. A message sent 10:38 PM Eastern
10
+ // reported as 02:38 lands on the following day, and in a co-parenting record
11
+ // that decides which custody day an event belongs to and whether it beat a
12
+ // 48-hour response window.
13
+ //
14
+ // Every value that survives detection is rewritten to ISO-8601 WITH an
15
+ // explicit offset and paired with a `<key>Display` sibling rendered in the
16
+ // operator's zone, weekday included, because a wrong weekday is what makes a
17
+ // date-boundary error visible at a glance.
18
+ //
19
+ // NOTE: this mirrors the helper in gogcli-mcp. Both connectors had the same
20
+ // defect, and the two copies should collapse into @chrischall/mcp-utils once
21
+ // that package next ships.
22
+ import { readEnvVar } from '@chrischall/mcp-utils';
23
+ // Fallback display zone for this deployment. IANA name, never a fixed offset —
24
+ // a hardcoded -04:00 would be an hour wrong from November through March.
25
+ export const DEFAULT_DISPLAY_TZ = 'America/New_York';
26
+ function isValidTimeZone(tz) {
27
+ try {
28
+ new Intl.DateTimeFormat('en-US', { timeZone: tz });
29
+ return true;
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
35
+ // The zone all *Display fields render in, and the zone a NAIVE source value is
36
+ // assumed to be wall-clock in. OFW's API reports naive local times in the
37
+ // account's own zone, so this must match it. An invalid DISPLAY_TZ falls back
38
+ // rather than throwing, so a typo degrades the label instead of breaking
39
+ // every tool.
40
+ export function displayTimeZone() {
41
+ const configured = readEnvVar('DISPLAY_TZ');
42
+ if (configured && isValidTimeZone(configured))
43
+ return configured;
44
+ return DEFAULT_DISPLAY_TZ;
45
+ }
46
+ // Offset of `tz` at a given instant, as "+HH:MM"/"-HH:MM". Uses the IANA
47
+ // database via Intl, so DST is handled per-instant rather than per-zone.
48
+ function offsetAt(instant, tz) {
49
+ // Derived arithmetically rather than parsed out of Intl's "GMT-04:00" label:
50
+ // the gap between the zone's wall clock and the instant IS the offset, and
51
+ // zone offsets are always whole minutes.
52
+ const w = wallPartsIn(instant, tz);
53
+ const asUTC = Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute, w.second, instant.getUTCMilliseconds());
54
+ const minutes = Math.round((asUTC - instant.getTime()) / 60_000);
55
+ const sign = minutes < 0 ? '-' : '+';
56
+ const abs = Math.abs(minutes);
57
+ return `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
58
+ }
59
+ // Wall-clock fields of `instant` as seen in `tz`, via Intl so the IANA rules
60
+ // (including DST) apply.
61
+ // Intl.DateTimeFormat construction dominates the cost here, and a 50-message
62
+ // listing formats hundreds of timestamps. Cache one formatter per zone.
63
+ const wallPartsFormatters = new Map();
64
+ const displayFormatters = new Map();
65
+ function wallPartsFormatter(tz) {
66
+ let fmt = wallPartsFormatters.get(tz);
67
+ if (!fmt) {
68
+ fmt = buildWallPartsFormatter(tz);
69
+ wallPartsFormatters.set(tz, fmt);
70
+ }
71
+ return fmt;
72
+ }
73
+ function wallPartsIn(instant, tz) {
74
+ const parts = wallPartsFormatter(tz).formatToParts(instant);
75
+ const out = {};
76
+ for (const p of parts) {
77
+ if (p.type !== 'literal')
78
+ out[p.type] = Number(p.value);
79
+ }
80
+ return out;
81
+ }
82
+ function buildWallPartsFormatter(tz) {
83
+ return new Intl.DateTimeFormat('en-US', {
84
+ timeZone: tz,
85
+ year: 'numeric',
86
+ month: '2-digit',
87
+ day: '2-digit',
88
+ hour: '2-digit',
89
+ minute: '2-digit',
90
+ second: '2-digit',
91
+ // h23 pins midnight to hour 00; without it some ICU builds render hour 24.
92
+ hourCycle: 'h23',
93
+ });
94
+ }
95
+ // Interpret naive wall-clock fields as an instant in `tz`. There is no direct
96
+ // inverse of the zone rules, so guess UTC, measure how far the guess lands from
97
+ // the requested wall time in that zone, and correct. Two passes settle the case
98
+ // where the correction itself crosses a DST boundary.
99
+ function wallTimeToInstant(y, mo, d, h, mi, s, ms, tz) {
100
+ let guess = Date.UTC(y, mo - 1, d, h, mi, s, ms);
101
+ for (let i = 0; i < 2; i += 1) {
102
+ const seen = wallPartsIn(new Date(guess), tz);
103
+ const seenUTC = Date.UTC(seen.year, seen.month - 1, seen.day, seen.hour, seen.minute, seen.second, ms);
104
+ const drift = Date.UTC(y, mo - 1, d, h, mi, s, ms) - seenUTC;
105
+ if (drift === 0)
106
+ break;
107
+ guess += drift;
108
+ }
109
+ return new Date(guess);
110
+ }
111
+ function pad(n, width = 2) {
112
+ return String(n).padStart(width, '0');
113
+ }
114
+ // Render `instant` as ISO-8601 carrying `offset`'s wall time and label.
115
+ function isoWithOffset(instant, tz, offset) {
116
+ const w = wallPartsIn(instant, tz);
117
+ const msPart = instant.getUTCMilliseconds();
118
+ const frac = msPart ? `.${pad(msPart, 3)}` : '';
119
+ return `${pad(w.year, 4)}-${pad(w.month)}-${pad(w.day)}T${pad(w.hour)}:${pad(w.minute)}:${pad(w.second)}${frac}${offset}`;
120
+ }
121
+ // The one place an instant becomes user-visible text. Every emitted timestamp
122
+ // goes through here, so no call site can reintroduce a naive value.
123
+ export function formatInstant(instant, tz = displayTimeZone()) {
124
+ const offset = offsetAt(instant, tz);
125
+ let fmt = displayFormatters.get(tz);
126
+ if (!fmt) {
127
+ fmt = new Intl.DateTimeFormat('en-US', {
128
+ timeZone: tz,
129
+ weekday: 'short',
130
+ month: 'short',
131
+ day: 'numeric',
132
+ year: 'numeric',
133
+ hour: 'numeric',
134
+ minute: '2-digit',
135
+ timeZoneName: 'short',
136
+ });
137
+ displayFormatters.set(tz, fmt);
138
+ }
139
+ return { iso: isoWithOffset(instant, tz, offset), display: fmt.format(instant) };
140
+ }
141
+ // Keys whose STRING values are timestamps. Deliberately an allowlist rather
142
+ // than a name pattern: a value must ALSO match a timestamp shape below, so both
143
+ // the key and the value have to agree before anything is touched. That keeps
144
+ // user-authored content (a message body quoting a date, an expense description)
145
+ // from ever being rewritten.
146
+ const TIMESTAMP_KEYS = new Set([
147
+ // OFW: naive local wall-clock from the API.
148
+ 'sentAt',
149
+ 'viewedAt',
150
+ 'modifiedAt',
151
+ 'createdAt',
152
+ 'dueAt',
153
+ 'occurredAt',
154
+ // OFW: UTC instants we stamp ourselves.
155
+ 'fetchedBodyAt',
156
+ 'fetchedAt',
157
+ 'syncedAt',
158
+ 'downloadedAt',
159
+ 'recordedAt',
160
+ 'expiresAt',
161
+ // Freshness/sync bookkeeping. These sit in the SAME object as `asOf`, so
162
+ // omitting them left the freshness block emitting two zones at once — the
163
+ // exact defect this module exists to remove. Enumerated from a sweep of
164
+ // emitted field names rather than from the ones a bug report happened to
165
+ // mention.
166
+ 'asOf',
167
+ 'checkedAt',
168
+ 'lastVerifiedAt',
169
+ 'oldestVerifiedAt',
170
+ 'lastServerSyncAt',
171
+ 'lastSyncAt',
172
+ // OFW API inner shape: `date: { dateTime }`, `viewed: { dateTime }`.
173
+ 'dateTime',
174
+ // Generic.
175
+ 'date',
176
+ 'updated',
177
+ 'lastModified',
178
+ 'expirationTime',
179
+ ]);
180
+ // Deliberately NOT timestamps: `startDate`/`endDate` are YYYY-MM-DD and
181
+ // `startTime`/`endTime` are HH:mm — a calendar date and a wall time, neither of
182
+ // which denotes an instant. Attaching an offset would invent information. The
183
+ // shape guards below would reject them anyway; this records the intent.
184
+ // Keys that hold a zone NAME rather than an instant. They cannot match a
185
+ // timestamp shape anyway, but naming them documents the hazard.
186
+ const ZONE_NAME_KEYS = new Set(['timeZone', 'timezone']);
187
+ const RFC3339_WITH_OFFSET = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\.(\d{1,9}))?(Z|[+-]\d{2}:?\d{2})$/;
188
+ const NAIVE_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\.(\d{1,9}))?$/;
189
+ // A bare YYYY-MM-DD is a DATE, not an instant — Calendar uses it for all-day
190
+ // events. Converting one would invent a time that the source never asserted.
191
+ const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
192
+ // True when the components describe a real calendar instant. Guards against
193
+ // Date.UTC's silent rollover of out-of-range values.
194
+ function isRealCalendarDate(p) {
195
+ const utc = new Date(Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second));
196
+ return utc.getUTCFullYear() === p.year
197
+ && utc.getUTCMonth() === p.month - 1
198
+ && utc.getUTCDate() === p.day
199
+ && utc.getUTCHours() === p.hour
200
+ && utc.getUTCMinutes() === p.minute
201
+ && utc.getUTCSeconds() === p.second;
202
+ }
203
+ // Resolve a raw field value to an instant, or null when it is not a timestamp.
204
+ // `assumeNaiveIn` is the zone a naive (offset-less) value is wall-clock in.
205
+ export function parseTimestampValue(key, value, assumeNaiveIn) {
206
+ if (typeof value !== 'string')
207
+ return null;
208
+ const raw = value.trim();
209
+ if (raw === '' || DATE_ONLY.test(raw))
210
+ return null;
211
+ if (RFC3339_WITH_OFFSET.test(raw)) {
212
+ // The source already knows its offset; trust it verbatim.
213
+ const parsed = new Date(raw.replace(' ', 'T'));
214
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
215
+ }
216
+ const naive = NAIVE_DATE_TIME.exec(raw);
217
+ if (naive) {
218
+ const [, y, mo, d, h, mi, s, frac] = naive;
219
+ const ms = frac ? Number(frac.padEnd(3, '0').slice(0, 3)) : 0;
220
+ const parts = {
221
+ year: Number(y), month: Number(mo), day: Number(d),
222
+ hour: Number(h), minute: Number(mi), second: Number(s ?? '0'),
223
+ };
224
+ // Date.UTC silently rolls impossible components over — month 99 becomes
225
+ // 2034, Feb 30 becomes Mar 2 — so a typo would surface as a confident wrong
226
+ // date rather than a rejection. The offset branch above already returns
227
+ // null for the same input; match it.
228
+ //
229
+ // Checked in UTC space, deliberately: validating against the ZONE's wall
230
+ // clock would also reject a non-existent spring-forward time like
231
+ // 2026-03-08 02:30 ET, and shifting such a value forward (as zone libraries
232
+ // do) is better than dropping a timestamp we can place to within an hour.
233
+ if (!isRealCalendarDate(parts))
234
+ return null;
235
+ return wallTimeToInstant(parts.year, parts.month, parts.day, parts.hour, parts.minute, parts.second, ms, assumeNaiveIn);
236
+ }
237
+ return null;
238
+ }
239
+ // True when a string carries no zone information — the shape this whole module
240
+ // exists to eliminate. Used by the contract test.
241
+ export function isNaiveTimestamp(value) {
242
+ return typeof value === 'string' && NAIVE_DATE_TIME.test(value.trim());
243
+ }
244
+ // Walk a parsed payload, rewriting every allowlisted timestamp to canonical
245
+ // form and attaching its display sibling. Mutates and returns `node`.
246
+ function walk(node, tz) {
247
+ if (Array.isArray(node)) {
248
+ for (const item of node)
249
+ walk(item, tz);
250
+ return node;
251
+ }
252
+ if (node === null || typeof node !== 'object')
253
+ return node;
254
+ const obj = node;
255
+ for (const key of Object.keys(obj)) {
256
+ const value = obj[key];
257
+ if (value !== null && typeof value === 'object') {
258
+ walk(value, tz);
259
+ continue;
260
+ }
261
+ if (ZONE_NAME_KEYS.has(key) || !TIMESTAMP_KEYS.has(key))
262
+ continue;
263
+ const instant = parseTimestampValue(key, value, tz);
264
+ if (!instant)
265
+ continue;
266
+ const { iso, display } = formatInstant(instant, tz);
267
+ obj[key] = iso;
268
+ obj[`${key}Display`] = display;
269
+ }
270
+ return obj;
271
+ }
272
+ // Normalize every timestamp in a structured response payload.
273
+ //
274
+ // The input is CLONED before walking: response payloads routinely include live
275
+ // cache rows, and rewriting those in place would corrupt the cache and make the
276
+ // normalization observable on a later read. Primitives pass through untouched
277
+ // so the seam is safe for any tool result.
278
+ export function normalizeTimestampsInValue(value, tz = displayTimeZone()) {
279
+ if (value === null || typeof value !== 'object')
280
+ return value;
281
+ return walk(structuredClone(value), tz);
282
+ }
@@ -1,9 +1,19 @@
1
1
  import { expandPath as expandPathUtil, rawTextResult, textResult } from '@chrischall/mcp-utils';
2
2
  import { z } from 'zod';
3
3
  import { parseLenient } from '@chrischall/mcp-utils';
4
+ import { normalizeTimestampsInValue } from '../timestamps.js';
4
5
  // Pretty-printed JSON tool result. Thin wrapper over @chrischall/mcp-utils'
5
- // `textResult` so the rest of the codebase keeps the local name.
6
- export const jsonResponse = textResult;
6
+ // `textResult`, with one addition: every timestamp in the payload is rewritten
7
+ // to ISO-8601 with an explicit offset and paired with a `<field>Display`
8
+ // sibling in the operator's zone.
9
+ //
10
+ // This is the single seam every structured tool response passes through, which
11
+ // is the point — normalizing here rather than at each call site is what makes
12
+ // it impossible for a tool to reintroduce the naive-local values that had
13
+ // `sentAt` and `fetchedBodyAt` silently disagreeing by the UTC offset.
14
+ export function jsonResponse(data) {
15
+ return textResult(normalizeTimestampsInValue(data));
16
+ }
7
17
  // Raw-string tool result. Wrapper over @chrischall/mcp-utils' `rawTextResult`.
8
18
  export const textResponse = rawTextResult;
9
19
  // A STRUCTURED failure: the machine-readable payload of `jsonResponse` plus
@@ -11,7 +21,11 @@ export const textResponse = rawTextResult;
11
21
  // we declined to overwrite) without being mistaken for a successful write.
12
22
  // mcp-utils' `errorResult` only carries a string.
13
23
  export function jsonErrorResponse(data) {
14
- return { ...textResult(data), isError: true };
24
+ // Routed through jsonResponse, not textResult: a refusal payload carries the
25
+ // same freshness block as the success path, and emitting it unnormalized made
26
+ // an UNVERIFIED_EMPTY response report `asOf` in UTC while every successful
27
+ // response reported it with an offset.
28
+ return { ...jsonResponse(data), isError: true };
15
29
  }
16
30
  // OFW API shape for `recipients[]` on message/draft list and detail
17
31
  // responses. Used wherever we validate the response of a `/pub/v3/messages*`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ofw-mcp",
3
- "version": "2.9.0",
3
+ "version": "2.9.1",
4
4
  "license": "MIT",
5
5
  "mcpName": "io.github.chrischall/ofw-mcp",
6
6
  "description": "OurFamilyWizard MCP server for Claude — developed and maintained by AI (Claude Code)",
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/chrischall/ofw-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "2.9.0",
9
+ "version": "2.9.1",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ofw-mcp",
14
- "version": "2.9.0",
14
+ "version": "2.9.1",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },