ofw-mcp 2.8.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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +44 -3
- package/dist/bundle.js +846 -123
- package/dist/cache/store.js +79 -1
- package/dist/config.js +18 -0
- package/dist/index.js +1 -1
- package/dist/sync.js +4 -0
- package/dist/timestamps.js +282 -0
- package/dist/tools/_shared.js +17 -3
- package/dist/tools/draft-freshness.js +46 -13
- package/dist/tools/lifecycle.js +277 -0
- package/dist/tools/messages.js +447 -135
- package/package.json +1 -1
- package/server.json +2 -2
- package/skills/ofw/SKILL.md +32 -13
package/dist/cache/store.js
CHANGED
|
@@ -32,6 +32,14 @@ function draftFromDb(r) {
|
|
|
32
32
|
listData: JSON.parse(r.list_data_json),
|
|
33
33
|
};
|
|
34
34
|
}
|
|
35
|
+
function lineageFromDb(r) {
|
|
36
|
+
return {
|
|
37
|
+
id: r.id,
|
|
38
|
+
draftKey: r.draft_key,
|
|
39
|
+
previousId: r.previous_id,
|
|
40
|
+
recordedAt: r.recorded_at,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
35
43
|
function attachmentFromDb(r) {
|
|
36
44
|
return {
|
|
37
45
|
fileId: r.file_id,
|
|
@@ -93,6 +101,17 @@ export const SCHEMA_STATEMENTS = [
|
|
|
93
101
|
key TEXT PRIMARY KEY,
|
|
94
102
|
value TEXT NOT NULL
|
|
95
103
|
)`,
|
|
104
|
+
// v3: draft identity chain. One row per OFW id, all the ids of one logical
|
|
105
|
+
// document sharing a `draft_key`. Survives ofw_save_draft's create-then-delete
|
|
106
|
+
// replacement AND the transition to a sent message, so "what happened to the
|
|
107
|
+
// draft I was editing?" is answerable without guessing which id is current.
|
|
108
|
+
`CREATE TABLE IF NOT EXISTS draft_lineage (
|
|
109
|
+
id INTEGER PRIMARY KEY,
|
|
110
|
+
draft_key TEXT NOT NULL,
|
|
111
|
+
previous_id INTEGER,
|
|
112
|
+
recorded_at TEXT NOT NULL
|
|
113
|
+
)`,
|
|
114
|
+
`CREATE INDEX IF NOT EXISTS idx_draft_lineage_key ON draft_lineage(draft_key, recorded_at, id)`,
|
|
96
115
|
// v2: attachments table. Idempotent — IF NOT EXISTS.
|
|
97
116
|
`CREATE TABLE IF NOT EXISTS attachments (
|
|
98
117
|
file_id INTEGER PRIMARY KEY,
|
|
@@ -119,7 +138,7 @@ export const MIGRATIONS = [
|
|
|
119
138
|
'ALTER TABLE sync_state ADD COLUMN resume_page INTEGER',
|
|
120
139
|
];
|
|
121
140
|
/** The schema version stamped into the `meta` table on open. */
|
|
122
|
-
export const SCHEMA_VERSION = '
|
|
141
|
+
export const SCHEMA_VERSION = '3';
|
|
123
142
|
// Build the WHERE clause + bound params for message queries. listMessages and
|
|
124
143
|
// countMessages share this so the filter semantics can't drift.
|
|
125
144
|
function buildMessageFilter(opts) {
|
|
@@ -302,6 +321,11 @@ export class OFWCacheCore {
|
|
|
302
321
|
const rows = this.db.all('SELECT * FROM drafts ORDER BY modified_at DESC, id DESC LIMIT ? OFFSET ?', [opts.size, offset]);
|
|
303
322
|
return rows.map(draftFromDb);
|
|
304
323
|
}
|
|
324
|
+
countDrafts() {
|
|
325
|
+
const r = this.db.get('SELECT COUNT(*) as n FROM drafts', []);
|
|
326
|
+
/* v8 ignore next -- SELECT COUNT(*) always returns exactly one row; the ?./?? are defensive */
|
|
327
|
+
return r?.n ?? 0;
|
|
328
|
+
}
|
|
305
329
|
deleteDraft(id) {
|
|
306
330
|
this.db.run('DELETE FROM drafts WHERE id = ?', [id]);
|
|
307
331
|
}
|
|
@@ -309,6 +333,45 @@ export class OFWCacheCore {
|
|
|
309
333
|
const rows = this.db.all('SELECT id FROM drafts', []);
|
|
310
334
|
return rows.map((r) => r.id);
|
|
311
335
|
}
|
|
336
|
+
/**
|
|
337
|
+
* Link an id into a draft's identity chain. Upserts on id: re-recording the
|
|
338
|
+
* same id (e.g. a retried save) rewrites its link rather than duplicating it,
|
|
339
|
+
* so `getDraftLineage` can never report one id twice.
|
|
340
|
+
*/
|
|
341
|
+
recordDraftLineage(row) {
|
|
342
|
+
this.db.run(`INSERT INTO draft_lineage (id, draft_key, previous_id, recorded_at) VALUES (?, ?, ?, ?)
|
|
343
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
344
|
+
draft_key=excluded.draft_key,
|
|
345
|
+
previous_id=excluded.previous_id,
|
|
346
|
+
recorded_at=excluded.recorded_at`, [row.id, requireString('draft_lineage.draftKey', row.draftKey), nullish(row.previousId),
|
|
347
|
+
requireString('draft_lineage.recordedAt', row.recordedAt)]);
|
|
348
|
+
}
|
|
349
|
+
getDraftLineageById(id) {
|
|
350
|
+
const r = this.db.get('SELECT * FROM draft_lineage WHERE id = ?', [id]);
|
|
351
|
+
return r ? lineageFromDb(r) : null;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Batch read — one query for a whole page of drafts. On the Durable Object
|
|
355
|
+
* backend each cache call is a subrequest, so a per-draft lookup would spend
|
|
356
|
+
* the caller's sync budget on bookkeeping.
|
|
357
|
+
*/
|
|
358
|
+
getDraftLineageByIds(ids) {
|
|
359
|
+
if (ids.length === 0)
|
|
360
|
+
return [];
|
|
361
|
+
const placeholders = ids.map(() => '?').join(', ');
|
|
362
|
+
const rows = this.db.all(`SELECT * FROM draft_lineage WHERE id IN (${placeholders})`, ids);
|
|
363
|
+
return rows.map(lineageFromDb);
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Every link in one chain, OLDEST FIRST — so the last element is the chain's
|
|
367
|
+
* current id. Ordered by recorded_at then id: two links written inside the
|
|
368
|
+
* same millisecond tie-break on id, and OFW mints ids monotonically, so the
|
|
369
|
+
* newer replacement always sorts last.
|
|
370
|
+
*/
|
|
371
|
+
getDraftLineage(draftKey) {
|
|
372
|
+
const rows = this.db.all('SELECT * FROM draft_lineage WHERE draft_key = ? ORDER BY recorded_at ASC, id ASC', [draftKey]);
|
|
373
|
+
return rows.map(lineageFromDb);
|
|
374
|
+
}
|
|
312
375
|
getSyncState(folder) {
|
|
313
376
|
const r = this.db.get('SELECT last_sync_at, newest_id, resume_page FROM sync_state WHERE folder = ?', [folder]);
|
|
314
377
|
if (!r)
|
|
@@ -444,12 +507,27 @@ export class LocalCacheStore {
|
|
|
444
507
|
async listDrafts(opts) {
|
|
445
508
|
return this.core.listDrafts(opts);
|
|
446
509
|
}
|
|
510
|
+
async countDrafts() {
|
|
511
|
+
return this.core.countDrafts();
|
|
512
|
+
}
|
|
447
513
|
async deleteDraft(id) {
|
|
448
514
|
this.core.deleteDraft(id);
|
|
449
515
|
}
|
|
450
516
|
async listDraftIds() {
|
|
451
517
|
return this.core.listDraftIds();
|
|
452
518
|
}
|
|
519
|
+
async recordDraftLineage(row) {
|
|
520
|
+
this.core.recordDraftLineage(row);
|
|
521
|
+
}
|
|
522
|
+
async getDraftLineageById(id) {
|
|
523
|
+
return this.core.getDraftLineageById(id);
|
|
524
|
+
}
|
|
525
|
+
async getDraftLineageByIds(ids) {
|
|
526
|
+
return this.core.getDraftLineageByIds(ids);
|
|
527
|
+
}
|
|
528
|
+
async getDraftLineage(draftKey) {
|
|
529
|
+
return this.core.getDraftLineage(draftKey);
|
|
530
|
+
}
|
|
453
531
|
async getSyncState(folder) {
|
|
454
532
|
return this.core.getSyncState(folder);
|
|
455
533
|
}
|
package/dist/config.js
CHANGED
|
@@ -124,6 +124,24 @@ export function getAllowMarkRead() {
|
|
|
124
124
|
export function getFetchUnreadBodies() {
|
|
125
125
|
return parseBoolEnv('OFW_FETCH_UNREAD_BODIES');
|
|
126
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* Default for the read tools' `autoRefresh` arg.
|
|
129
|
+
*
|
|
130
|
+
* When a cached read comes back EMPTY and the cache is not `fresh`, the tools
|
|
131
|
+
* refuse to report that emptiness (see UNVERIFIED_EMPTY in tools/messages.ts):
|
|
132
|
+
* an empty result from a 207-minute-old cache is shaped identically to a
|
|
133
|
+
* verified "nothing there", and answering "no, it wasn't sent" from one is how
|
|
134
|
+
* a false negative becomes a confident statement about a legal record.
|
|
135
|
+
*
|
|
136
|
+
* The refusal names its remedy, so the default (false) costs one extra call.
|
|
137
|
+
* Set OFW_AUTO_REFRESH=true and the tools instead sync the backing folders
|
|
138
|
+
* themselves and answer from the refreshed cache — same guarantee, no round
|
|
139
|
+
* trip. Never silently degrades: if the refresh does not make the read
|
|
140
|
+
* verifiable, the refusal still fires.
|
|
141
|
+
*/
|
|
142
|
+
export function getAutoRefreshStaleReads() {
|
|
143
|
+
return parseBoolEnv('OFW_AUTO_REFRESH');
|
|
144
|
+
}
|
|
127
145
|
// Default for ofw_download_attachment's `inline` arg when the caller doesn't
|
|
128
146
|
// pass one. Set OFW_INLINE_ATTACHMENTS=true to have attachments returned as
|
|
129
147
|
// MCP content blocks by default (skipping disk) — useful on sandboxed MCP
|
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.
|
|
38
|
+
version: '2.9.1', // x-release-please-version
|
|
39
39
|
deps: client,
|
|
40
40
|
tools: [
|
|
41
41
|
registerUserTools,
|
package/dist/sync.js
CHANGED
|
@@ -94,6 +94,10 @@ export async function resolveFolderIds(client, store) {
|
|
|
94
94
|
// label an uncached message sent-vs-inbox from the detail payload's own folder
|
|
95
95
|
// id, instead of hard-defaulting to inbox.
|
|
96
96
|
await store.setMeta('sent_folder_id', ids.sent);
|
|
97
|
+
// And inbox, which completes the map a lifecycle probe needs to turn a detail
|
|
98
|
+
// payload's own folder id into "draft" / "sent" / "received" (see
|
|
99
|
+
// tools/lifecycle.ts). Without all three, a probe can only say "unknown".
|
|
100
|
+
await store.setMeta('inbox_folder_id', ids.inbox);
|
|
97
101
|
return ids;
|
|
98
102
|
}
|
|
99
103
|
// Required fields are the ones the sync loop reads unguarded (id keys the
|
|
@@ -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
|
+
}
|
package/dist/tools/_shared.js
CHANGED
|
@@ -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
|
|
6
|
-
|
|
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
|
-
|
|
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*`
|
|
@@ -39,23 +39,40 @@ const ServerDraftSchema = z.looseObject({
|
|
|
39
39
|
body: z.string().optional(),
|
|
40
40
|
replyToId: z.number().nullable().optional(),
|
|
41
41
|
recipients: z.array(ApiRecipientSchema).optional(),
|
|
42
|
+
// Read for the LIFECYCLE answer (see tools/lifecycle.ts): which folder OFW
|
|
43
|
+
// itself says this id lives in right now. `existsOnServer` alone cannot
|
|
44
|
+
// distinguish "still a draft" from "was sent" — a sent draft still exists.
|
|
45
|
+
// `id` accepts BOTH spellings deliberately. This schema is parsed in
|
|
46
|
+
// `mode: 'strict'` because it backs the destructive-draft guard, so a
|
|
47
|
+
// present-but-mistyped field THROWS — and OFW is already inconsistent about
|
|
48
|
+
// this exact field: the folders listing (`FoldersSchema` in sync.ts) types it
|
|
49
|
+
// `z.string()`, while message detail has been observed returning a number.
|
|
50
|
+
// Pinning one spelling here would turn a harmless representation change into
|
|
51
|
+
// a hard failure of ofw_save_draft / ofw_delete_draft, which is the opposite
|
|
52
|
+
// of what a strict boundary is for: it exists to stop us acting on a response
|
|
53
|
+
// we cannot interpret, not to reject one we can. `folderId` is normalized to
|
|
54
|
+
// a string below, so both spellings compare correctly downstream.
|
|
55
|
+
folder: z.looseObject({
|
|
56
|
+
id: z.union([z.string(), z.number()]).optional(),
|
|
57
|
+
name: z.string().optional(),
|
|
58
|
+
}).nullable().optional(),
|
|
59
|
+
date: z.looseObject({ dateTime: z.string().optional() }).nullable().optional(),
|
|
42
60
|
});
|
|
43
61
|
function isNotFound(e) {
|
|
44
62
|
return e instanceof Error && /OFW API error: 404\b/.test(e.message);
|
|
45
63
|
}
|
|
46
64
|
/**
|
|
47
|
-
* Read a
|
|
65
|
+
* Read a message's AUTHORITATIVE state straight from OFW, bypassing the cache.
|
|
48
66
|
*
|
|
49
|
-
* Returns `null` when
|
|
50
|
-
*
|
|
51
|
-
* check that could not run must never wave the write through.
|
|
67
|
+
* Returns `null` when it no longer exists (404, or an empty body — OFW's other
|
|
68
|
+
* way of saying "no such message"). Any other failure throws.
|
|
52
69
|
*
|
|
53
|
-
* Most failures throw `DraftFreshnessError
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
70
|
+
* Most failures throw `DraftFreshnessError`, but not all — a strict
|
|
71
|
+
* `parseLenient` mismatch throws `McpToolError` instead. Callers must not assume
|
|
72
|
+
* the narrower type (the catch blocks read only `.message`, which every Error
|
|
73
|
+
* carries).
|
|
57
74
|
*/
|
|
58
|
-
export async function
|
|
75
|
+
export async function fetchMessageSnapshot(client, id) {
|
|
59
76
|
let raw;
|
|
60
77
|
try {
|
|
61
78
|
raw = await client.request('GET', `/pub/v3/messages/${id}`);
|
|
@@ -76,12 +93,28 @@ export async function fetchServerDraft(client, id) {
|
|
|
76
93
|
mode: 'strict',
|
|
77
94
|
});
|
|
78
95
|
return {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
96
|
+
content: {
|
|
97
|
+
subject: detail.subject ?? '',
|
|
98
|
+
body: detail.body ?? '',
|
|
99
|
+
replyToId: detail.replyToId ?? null,
|
|
100
|
+
recipients: mapRecipients(detail.recipients),
|
|
101
|
+
},
|
|
102
|
+
folderId: detail.folder?.id === undefined ? null : String(detail.folder.id),
|
|
103
|
+
folderName: detail.folder?.name ?? null,
|
|
104
|
+
dateTime: detail.date?.dateTime ?? null,
|
|
83
105
|
};
|
|
84
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* The content-only view of {@link fetchMessageSnapshot}, used by the
|
|
109
|
+
* destructive-draft guard, which cares what the draft SAYS, not where it lives.
|
|
110
|
+
*
|
|
111
|
+
* Returns `null` when the draft no longer exists. Any other failure throws, and
|
|
112
|
+
* the callers in messages.ts abort on ALL of them: a freshness check that could
|
|
113
|
+
* not run must never wave the write through.
|
|
114
|
+
*/
|
|
115
|
+
export async function fetchServerDraft(client, id) {
|
|
116
|
+
return (await fetchMessageSnapshot(client, id))?.content ?? null;
|
|
117
|
+
}
|
|
85
118
|
/**
|
|
86
119
|
* The fields whose divergence constitutes a REAL conflict — the actual message
|
|
87
120
|
* content a caller would lose if we overwrote a copy edited elsewhere. Anything
|