ofw-mcp 2.9.0 → 2.9.2
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/dist/bundle.js +404 -24
- package/dist/index.js +1 -1
- package/dist/timestamps.js +282 -0
- package/dist/tools/_shared.js +17 -3
- package/package.json +3 -3
- package/server.json +2 -2
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
},
|
|
7
7
|
"metadata": {
|
|
8
8
|
"description": "OurFamilyWizard tools for Claude Code",
|
|
9
|
-
"version": "2.9.
|
|
9
|
+
"version": "2.9.2"
|
|
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.
|
|
17
|
+
"version": "2.9.2",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "Chris Chall"
|
|
20
20
|
},
|
package/dist/bundle.js
CHANGED
|
@@ -34929,7 +34929,8 @@ var KNOWN_CAPABILITIES = /* @__PURE__ */ new Set([
|
|
|
34929
34929
|
"capture_redirect",
|
|
34930
34930
|
"read_indexed_db",
|
|
34931
34931
|
"read_dom",
|
|
34932
|
-
"download"
|
|
34932
|
+
"download",
|
|
34933
|
+
"graphql"
|
|
34933
34934
|
]);
|
|
34934
34935
|
|
|
34935
34936
|
// node_modules/@fetchproxy/protocol/dist/mcp-id.js
|
|
@@ -35232,6 +35233,7 @@ function assertIndexedDbScopesArray(value, label) {
|
|
|
35232
35233
|
}
|
|
35233
35234
|
var DOM_SELECTOR_RE = /^[^-]{1,512}$/;
|
|
35234
35235
|
var DOM_ATTRIBUTE_RE = /^[A-Za-z_:][A-Za-z0-9_:.\-]{0,127}$/;
|
|
35236
|
+
var GRAPHQL_OP_NAME_RE = /^[_A-Za-z][_0-9A-Za-z]{0,127}$/;
|
|
35235
35237
|
function assertDomSelectorsArray(value, label) {
|
|
35236
35238
|
if (!Array.isArray(value)) {
|
|
35237
35239
|
throw new ProtocolError(`${label}: expected array, got ${typeof value}`);
|
|
@@ -35268,6 +35270,37 @@ function assertDomSelectorsArray(value, label) {
|
|
|
35268
35270
|
}
|
|
35269
35271
|
}
|
|
35270
35272
|
}
|
|
35273
|
+
function assertGraphqlOpsArray(value, label) {
|
|
35274
|
+
if (!Array.isArray(value)) {
|
|
35275
|
+
throw new ProtocolError(`${label}: expected array, got ${typeof value}`);
|
|
35276
|
+
}
|
|
35277
|
+
const seen = /* @__PURE__ */ new Set();
|
|
35278
|
+
for (let i = 0; i < value.length; i++) {
|
|
35279
|
+
const entry = value[i];
|
|
35280
|
+
assertObject(entry, `${label}[${i}]`);
|
|
35281
|
+
if (entry.name === void 0) {
|
|
35282
|
+
throw new ProtocolError(`${label}[${i}].name: missing`);
|
|
35283
|
+
}
|
|
35284
|
+
if (entry.operationName === void 0) {
|
|
35285
|
+
throw new ProtocolError(`${label}[${i}].operationName: missing`);
|
|
35286
|
+
}
|
|
35287
|
+
if (typeof entry.name !== "string" || !SCOPE_KEY_RE.test(entry.name)) {
|
|
35288
|
+
throw new ProtocolError(`${label}[${i}].name: invalid ${JSON.stringify(entry.name)}`);
|
|
35289
|
+
}
|
|
35290
|
+
if (typeof entry.operationName !== "string" || !GRAPHQL_OP_NAME_RE.test(entry.operationName)) {
|
|
35291
|
+
throw new ProtocolError(`${label}[${i}].operationName: invalid ${JSON.stringify(entry.operationName)}`);
|
|
35292
|
+
}
|
|
35293
|
+
if (seen.has(entry.name)) {
|
|
35294
|
+
throw new ProtocolError(`${label}: duplicate name ${JSON.stringify(entry.name)}`);
|
|
35295
|
+
}
|
|
35296
|
+
seen.add(entry.name);
|
|
35297
|
+
for (const k of Object.keys(entry)) {
|
|
35298
|
+
if (k !== "name" && k !== "operationName") {
|
|
35299
|
+
throw new ProtocolError(`${label}[${i}]: unexpected field ${JSON.stringify(k)}`);
|
|
35300
|
+
}
|
|
35301
|
+
}
|
|
35302
|
+
}
|
|
35303
|
+
}
|
|
35271
35304
|
function validateFrame(raw) {
|
|
35272
35305
|
assertObject(raw, "frame");
|
|
35273
35306
|
const t = raw.type;
|
|
@@ -35346,6 +35379,9 @@ function validateHello(raw) {
|
|
|
35346
35379
|
if (raw.domSelectors !== void 0) {
|
|
35347
35380
|
assertDomSelectorsArray(raw.domSelectors, "hello.domSelectors");
|
|
35348
35381
|
}
|
|
35382
|
+
if (raw.graphqlOps !== void 0) {
|
|
35383
|
+
assertGraphqlOpsArray(raw.graphqlOps, "hello.graphqlOps");
|
|
35384
|
+
}
|
|
35349
35385
|
assertBase64(raw.identityX25519Pub, "hello.identityX25519Pub");
|
|
35350
35386
|
assertBase64(raw.identityEd25519Pub, "hello.identityEd25519Pub");
|
|
35351
35387
|
assertBase64(raw.sessionNonce, "hello.sessionNonce");
|
|
@@ -35595,6 +35631,28 @@ function validateInnerRequest(raw) {
|
|
|
35595
35631
|
}
|
|
35596
35632
|
return raw;
|
|
35597
35633
|
}
|
|
35634
|
+
if (raw.op === "graphql_query") {
|
|
35635
|
+
assertObject(raw.init, "inner.init");
|
|
35636
|
+
if (raw.init.name === void 0)
|
|
35637
|
+
throw new ProtocolError("inner.init.name: missing");
|
|
35638
|
+
if (raw.init.variables === void 0) {
|
|
35639
|
+
throw new ProtocolError("inner.init.variables: missing");
|
|
35640
|
+
}
|
|
35641
|
+
assertString(raw.init.name, "inner.init.name");
|
|
35642
|
+
if (raw.init.name.length === 0) {
|
|
35643
|
+
throw new ProtocolError("inner.init.name: must be non-empty");
|
|
35644
|
+
}
|
|
35645
|
+
assertObject(raw.init.variables, "inner.init.variables");
|
|
35646
|
+
if (raw.init.tabUrl !== void 0) {
|
|
35647
|
+
assertString(raw.init.tabUrl, "inner.init.tabUrl");
|
|
35648
|
+
}
|
|
35649
|
+
for (const k of Object.keys(raw.init)) {
|
|
35650
|
+
if (k !== "name" && k !== "variables" && k !== "tabUrl") {
|
|
35651
|
+
throw new ProtocolError(`inner.init: unexpected field ${JSON.stringify(k)} on graphql_query`);
|
|
35652
|
+
}
|
|
35653
|
+
}
|
|
35654
|
+
return raw;
|
|
35655
|
+
}
|
|
35598
35656
|
if (raw.op === "download") {
|
|
35599
35657
|
assertObject(raw.init, "inner.init");
|
|
35600
35658
|
if (raw.init.url === void 0) {
|
|
@@ -35620,7 +35678,7 @@ function validateInnerRequest(raw) {
|
|
|
35620
35678
|
}
|
|
35621
35679
|
return raw;
|
|
35622
35680
|
}
|
|
35623
|
-
throw new ProtocolError(`inner.op: must be one of "fetch", "read_cookies", "read_local_storage", "read_session_storage", "capture_request_header", "capture_redirect", "read_indexed_db", "read_dom", "download"; got ${JSON.stringify(raw.op)}`);
|
|
35681
|
+
throw new ProtocolError(`inner.op: must be one of "fetch", "read_cookies", "read_local_storage", "read_session_storage", "capture_request_header", "capture_redirect", "read_indexed_db", "read_dom", "download", "graphql_query"; got ${JSON.stringify(raw.op)}`);
|
|
35624
35682
|
}
|
|
35625
35683
|
function assertNonEmptyKeyArray(value, label) {
|
|
35626
35684
|
if (!Array.isArray(value)) {
|
|
@@ -35651,6 +35709,10 @@ function assertStringMap(value, label) {
|
|
|
35651
35709
|
}
|
|
35652
35710
|
}
|
|
35653
35711
|
}
|
|
35712
|
+
var KNOWN_RESPONSE_OPS = /* @__PURE__ */ new Set([
|
|
35713
|
+
...KNOWN_CAPABILITIES,
|
|
35714
|
+
"graphql_query"
|
|
35715
|
+
]);
|
|
35654
35716
|
function validateInnerResponse(raw) {
|
|
35655
35717
|
assertPositiveInt(raw.id, "inner.id");
|
|
35656
35718
|
if (raw.ok === true) {
|
|
@@ -35712,6 +35774,13 @@ function validateInnerResponse(raw) {
|
|
|
35712
35774
|
assertStringMap(raw.values, "inner.values");
|
|
35713
35775
|
return raw;
|
|
35714
35776
|
}
|
|
35777
|
+
if (op === "graphql_query") {
|
|
35778
|
+
if (raw.data === void 0) {
|
|
35779
|
+
throw new ProtocolError("inner.data: missing on graphql_query response");
|
|
35780
|
+
}
|
|
35781
|
+
assertObject(raw.data, "inner.data");
|
|
35782
|
+
return raw;
|
|
35783
|
+
}
|
|
35715
35784
|
if (op === "download") {
|
|
35716
35785
|
assertObject(raw.value, "inner.value");
|
|
35717
35786
|
assertString(raw.value.path, "inner.value.path");
|
|
@@ -35736,7 +35805,7 @@ function validateInnerResponse(raw) {
|
|
|
35736
35805
|
if (raw.ok === false) {
|
|
35737
35806
|
assertString(raw.error, "inner.error");
|
|
35738
35807
|
if (raw.op !== void 0) {
|
|
35739
|
-
if (typeof raw.op !== "string" || !
|
|
35808
|
+
if (typeof raw.op !== "string" || !KNOWN_RESPONSE_OPS.has(raw.op)) {
|
|
35740
35809
|
throw new ProtocolError(`inner.op: unknown response op ${JSON.stringify(raw.op)}`);
|
|
35741
35810
|
}
|
|
35742
35811
|
}
|
|
@@ -35928,11 +35997,33 @@ async function sealInnerFrame(sessionKey, mcpId, seq, inner) {
|
|
|
35928
35997
|
};
|
|
35929
35998
|
}
|
|
35930
35999
|
async function openEncryptedFrame(sessionKey, frame) {
|
|
35931
|
-
const
|
|
35932
|
-
|
|
35933
|
-
|
|
35934
|
-
|
|
35935
|
-
|
|
36000
|
+
const result = await openEncryptedFrameDetailed(sessionKey, frame);
|
|
36001
|
+
if (result.stage === "ok")
|
|
36002
|
+
return result.inner;
|
|
36003
|
+
throw result.error instanceof Error ? result.error : new Error(String(result.error));
|
|
36004
|
+
}
|
|
36005
|
+
async function openEncryptedFrameDetailed(sessionKey, frame) {
|
|
36006
|
+
let pt;
|
|
36007
|
+
try {
|
|
36008
|
+
const iv = fromB64(frame.iv);
|
|
36009
|
+
const ct = fromB64(frame.ciphertext);
|
|
36010
|
+
pt = await aesGcmOpen(sessionKey, iv, ct);
|
|
36011
|
+
} catch (error51) {
|
|
36012
|
+
return { stage: "decrypt-failed", error: error51 };
|
|
36013
|
+
}
|
|
36014
|
+
let parsed;
|
|
36015
|
+
try {
|
|
36016
|
+
parsed = JSON.parse(dec.decode(pt));
|
|
36017
|
+
} catch (error51) {
|
|
36018
|
+
return { stage: "validation-failed", error: error51, recoveredId: void 0 };
|
|
36019
|
+
}
|
|
36020
|
+
try {
|
|
36021
|
+
const inner = validateInnerFrame(parsed);
|
|
36022
|
+
return { stage: "ok", inner };
|
|
36023
|
+
} catch (error51) {
|
|
36024
|
+
const recoveredId = parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) && typeof parsed.id === "number" && Number.isInteger(parsed.id) && parsed.id > 0 ? parsed.id : void 0;
|
|
36025
|
+
return { stage: "validation-failed", error: error51, recoveredId };
|
|
36026
|
+
}
|
|
35936
36027
|
}
|
|
35937
36028
|
|
|
35938
36029
|
// node_modules/@fetchproxy/server/dist/election.js
|
|
@@ -36058,6 +36149,12 @@ async function buildServerHello(opts) {
|
|
|
36058
36149
|
...d.attribute !== void 0 ? { attribute: d.attribute } : {}
|
|
36059
36150
|
}));
|
|
36060
36151
|
}
|
|
36152
|
+
if (opts.graphqlOps && opts.graphqlOps.length > 0) {
|
|
36153
|
+
hello.graphqlOps = opts.graphqlOps.map((d) => ({
|
|
36154
|
+
name: d.name,
|
|
36155
|
+
operationName: d.operationName
|
|
36156
|
+
}));
|
|
36157
|
+
}
|
|
36061
36158
|
return hello;
|
|
36062
36159
|
}
|
|
36063
36160
|
|
|
@@ -36148,7 +36245,8 @@ async function startHost(opts) {
|
|
|
36148
36245
|
indexedDbScopes: opts.ownIndexedDbScopes,
|
|
36149
36246
|
localStoragePointers: opts.ownLocalStoragePointers,
|
|
36150
36247
|
sessionStoragePointers: opts.ownSessionStoragePointers,
|
|
36151
|
-
domSelectors: opts.ownDomSelectors
|
|
36248
|
+
domSelectors: opts.ownDomSelectors,
|
|
36249
|
+
graphqlOps: opts.ownGraphqlOps
|
|
36152
36250
|
});
|
|
36153
36251
|
const ownSessionNonce = fromB64(ownHello.sessionNonce);
|
|
36154
36252
|
let extensionWs = null;
|
|
@@ -36385,7 +36483,8 @@ async function startPeer(opts) {
|
|
|
36385
36483
|
indexedDbScopes: opts.indexedDbScopes,
|
|
36386
36484
|
domSelectors: opts.domSelectors,
|
|
36387
36485
|
localStoragePointers: opts.localStoragePointers,
|
|
36388
|
-
sessionStoragePointers: opts.sessionStoragePointers
|
|
36486
|
+
sessionStoragePointers: opts.sessionStoragePointers,
|
|
36487
|
+
graphqlOps: opts.graphqlOps
|
|
36389
36488
|
});
|
|
36390
36489
|
const sessionNonce = fromB64(hello.sessionNonce);
|
|
36391
36490
|
ws.send(JSON.stringify(hello));
|
|
@@ -36429,10 +36528,20 @@ async function startPeer(opts) {
|
|
|
36429
36528
|
return;
|
|
36430
36529
|
if (!session.acceptInboundSeq(frame.seq))
|
|
36431
36530
|
return;
|
|
36432
|
-
|
|
36433
|
-
|
|
36434
|
-
innerListeners.forEach((cb) => cb(inner));
|
|
36435
|
-
}
|
|
36531
|
+
const result = await openEncryptedFrameDetailed(session.sessionKey, frame);
|
|
36532
|
+
if (result.stage === "ok") {
|
|
36533
|
+
innerListeners.forEach((cb) => cb(result.inner));
|
|
36534
|
+
} else if (result.stage === "decrypt-failed") {
|
|
36535
|
+
} else {
|
|
36536
|
+
console.error("[fetchproxy] peer: received a frame that decrypted OK but failed validation:", result.error);
|
|
36537
|
+
if (result.recoveredId !== void 0) {
|
|
36538
|
+
innerListeners.forEach((cb) => cb({
|
|
36539
|
+
type: "response",
|
|
36540
|
+
id: result.recoveredId,
|
|
36541
|
+
ok: false,
|
|
36542
|
+
error: `malformed response failed protocol validation: ${String(result.error)}`
|
|
36543
|
+
}));
|
|
36544
|
+
}
|
|
36436
36545
|
}
|
|
36437
36546
|
}
|
|
36438
36547
|
} catch (e) {
|
|
@@ -36709,6 +36818,10 @@ var FetchproxyServer = class {
|
|
|
36709
36818
|
pendingIdb = /* @__PURE__ */ new Map();
|
|
36710
36819
|
// download awaiters resolve the saved-file metadata (path + size + mime).
|
|
36711
36820
|
pendingDownload = /* @__PURE__ */ new Map();
|
|
36821
|
+
// 1.x+: graphql_query awaiters resolve the GraphQL `data` object. Its
|
|
36822
|
+
// shape is operation-specific, so the awaiter resolves `unknown` and the
|
|
36823
|
+
// caller narrows.
|
|
36824
|
+
pendingGraphql = /* @__PURE__ */ new Map();
|
|
36712
36825
|
mcpId = null;
|
|
36713
36826
|
identity = null;
|
|
36714
36827
|
// 0.5.3+: in-flight role-election / handle-start promise. Set the
|
|
@@ -36796,6 +36909,10 @@ var FetchproxyServer = class {
|
|
|
36796
36909
|
selector: d.selector,
|
|
36797
36910
|
...d.attribute !== void 0 ? { attribute: d.attribute } : {}
|
|
36798
36911
|
})),
|
|
36912
|
+
graphqlOps: (opts.graphqlOps ?? []).map((d) => ({
|
|
36913
|
+
name: d.name,
|
|
36914
|
+
operationName: d.operationName
|
|
36915
|
+
})),
|
|
36799
36916
|
// 0.8.0+: timer + lazy-revive default to ON. Every realty MCP
|
|
36800
36917
|
// adapter was about to set these to the same numbers anyway; the
|
|
36801
36918
|
// back-door is `0` (explicit opt-out) if a caller genuinely wants
|
|
@@ -36917,6 +37034,7 @@ var FetchproxyServer = class {
|
|
|
36917
37034
|
ownLocalStoragePointers: this.opts.localStoragePointers,
|
|
36918
37035
|
ownSessionStoragePointers: this.opts.sessionStoragePointers,
|
|
36919
37036
|
ownDomSelectors: this.opts.domSelectors,
|
|
37037
|
+
ownGraphqlOps: this.opts.graphqlOps,
|
|
36920
37038
|
onPairCode: this.opts.onPairCode
|
|
36921
37039
|
});
|
|
36922
37040
|
this.hostHandle.onOwnInner((inner) => this.onInner(inner));
|
|
@@ -36945,7 +37063,8 @@ var FetchproxyServer = class {
|
|
|
36945
37063
|
indexedDbScopes: this.opts.indexedDbScopes,
|
|
36946
37064
|
localStoragePointers: this.opts.localStoragePointers,
|
|
36947
37065
|
sessionStoragePointers: this.opts.sessionStoragePointers,
|
|
36948
|
-
domSelectors: this.opts.domSelectors
|
|
37066
|
+
domSelectors: this.opts.domSelectors,
|
|
37067
|
+
graphqlOps: this.opts.graphqlOps
|
|
36949
37068
|
});
|
|
36950
37069
|
this.peerHandle.onInner((inner) => this.onInner(inner));
|
|
36951
37070
|
this.peerHandle.onRenegotiate(() => {
|
|
@@ -37151,6 +37270,7 @@ var FetchproxyServer = class {
|
|
|
37151
37270
|
this.pendingRedirect.delete(id);
|
|
37152
37271
|
this.pendingDownload.delete(id);
|
|
37153
37272
|
this.pendingIdb.delete(id);
|
|
37273
|
+
this.pendingGraphql.delete(id);
|
|
37154
37274
|
}
|
|
37155
37275
|
throw err;
|
|
37156
37276
|
}
|
|
@@ -37960,6 +38080,52 @@ var FetchproxyServer = class {
|
|
|
37960
38080
|
await this.sendInnerFrame(inner);
|
|
37961
38081
|
return this._withVerbTimeout(pending, this.pendingStorage, id, origin);
|
|
37962
38082
|
}
|
|
38083
|
+
/**
|
|
38084
|
+
* 1.x+: run a declared GraphQL operation through the page's own Apollo
|
|
38085
|
+
* client (`window.__APOLLO_CLIENT__`) in the signed-in tab's MAIN world.
|
|
38086
|
+
* Requires `'graphql'` in capabilities AND `name` to match a declared
|
|
38087
|
+
* `graphqlOps` entry. The extension resolves `name` → `operationName` →
|
|
38088
|
+
* the live DocumentNode the page already observed, then invokes
|
|
38089
|
+
* `client.query({ query, variables })` — the site's own request path, so
|
|
38090
|
+
* per-request bot telemetry (Akamai etc.) runs automatically.
|
|
38091
|
+
*
|
|
38092
|
+
* Returns the GraphQL `data` object on success (shape is
|
|
38093
|
+
* operation-specific; the caller narrows). Throws a plain `Error` on
|
|
38094
|
+
* developer mistakes (undeclared capability, undeclared name) and a
|
|
38095
|
+
* descriptive `Error` on the `ok:false` bridge path — which includes the
|
|
38096
|
+
* typed "operation not yet observed on this tab" case (open the site's
|
|
38097
|
+
* page and retry).
|
|
38098
|
+
*/
|
|
38099
|
+
async graphqlQuery(opts) {
|
|
38100
|
+
if (!this.opts.capabilities.includes("graphql")) {
|
|
38101
|
+
throw new Error('FetchproxyServer.graphqlQuery(): MCP did not declare "graphql" in capabilities');
|
|
38102
|
+
}
|
|
38103
|
+
if (typeof opts.name !== "string" || opts.name.length === 0) {
|
|
38104
|
+
throw new Error("FetchproxyServer.graphqlQuery: opts.name must be a non-empty string");
|
|
38105
|
+
}
|
|
38106
|
+
const declaredNames = this.opts.graphqlOps.map((d) => d.name);
|
|
38107
|
+
if (!declaredNames.includes(opts.name)) {
|
|
38108
|
+
throw new Error(`FetchproxyServer.graphqlQuery: operation ${JSON.stringify(opts.name)} not in declared graphqlOps [${declaredNames.map((n) => JSON.stringify(n)).join(", ")}]`);
|
|
38109
|
+
}
|
|
38110
|
+
await this.ensureConnected();
|
|
38111
|
+
this.throwIfPendingPair();
|
|
38112
|
+
const id = this.nextRequestId++;
|
|
38113
|
+
const inner = {
|
|
38114
|
+
type: "request",
|
|
38115
|
+
id,
|
|
38116
|
+
op: "graphql_query",
|
|
38117
|
+
init: {
|
|
38118
|
+
name: opts.name,
|
|
38119
|
+
variables: opts.variables,
|
|
38120
|
+
...opts.tabUrl !== void 0 ? { tabUrl: opts.tabUrl } : {}
|
|
38121
|
+
}
|
|
38122
|
+
};
|
|
38123
|
+
const pending = new Promise((resolve2, reject) => {
|
|
38124
|
+
this.pendingGraphql.set(id, { resolve: resolve2, reject });
|
|
38125
|
+
});
|
|
38126
|
+
await this.sendInnerFrame(inner);
|
|
38127
|
+
return this._withVerbTimeout(pending, this.pendingGraphql, id, opts.name);
|
|
38128
|
+
}
|
|
37963
38129
|
assertScopeSubset(requested, declared, label) {
|
|
37964
38130
|
const undeclared = undeclaredKeys(requested, declared);
|
|
37965
38131
|
if (undeclared.length > 0) {
|
|
@@ -38097,6 +38263,20 @@ var FetchproxyServer = class {
|
|
|
38097
38263
|
}
|
|
38098
38264
|
return;
|
|
38099
38265
|
}
|
|
38266
|
+
const graphqlCb = this.pendingGraphql.get(inner.id);
|
|
38267
|
+
if (graphqlCb) {
|
|
38268
|
+
this.pendingGraphql.delete(inner.id);
|
|
38269
|
+
if (inner.ok) {
|
|
38270
|
+
if (inner.op === "graphql_query") {
|
|
38271
|
+
graphqlCb.resolve(inner.data);
|
|
38272
|
+
} else {
|
|
38273
|
+
graphqlCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on graphql_query awaiter`));
|
|
38274
|
+
}
|
|
38275
|
+
} else {
|
|
38276
|
+
graphqlCb.reject(new FetchproxyProtocolError(inner.error));
|
|
38277
|
+
}
|
|
38278
|
+
return;
|
|
38279
|
+
}
|
|
38100
38280
|
const cookiesCb = this.pendingReadCookies.get(inner.id);
|
|
38101
38281
|
if (cookiesCb) {
|
|
38102
38282
|
this.pendingReadCookies.delete(inner.id);
|
|
@@ -38148,6 +38328,9 @@ var FetchproxyServer = class {
|
|
|
38148
38328
|
for (const { reject } of this.pendingDownload.values())
|
|
38149
38329
|
reject(err);
|
|
38150
38330
|
this.pendingDownload.clear();
|
|
38331
|
+
for (const { reject } of this.pendingGraphql.values())
|
|
38332
|
+
reject(err);
|
|
38333
|
+
this.pendingGraphql.clear();
|
|
38151
38334
|
}
|
|
38152
38335
|
/**
|
|
38153
38336
|
* 0.5.2+: read the current pair-pending pair code from whichever handle
|
|
@@ -38407,7 +38590,7 @@ async function loginWithPassword(username, password) {
|
|
|
38407
38590
|
// package.json
|
|
38408
38591
|
var package_default = {
|
|
38409
38592
|
name: "ofw-mcp",
|
|
38410
|
-
version: "2.9.
|
|
38593
|
+
version: "2.9.2",
|
|
38411
38594
|
license: "MIT",
|
|
38412
38595
|
mcpName: "io.github.chrischall/ofw-mcp",
|
|
38413
38596
|
description: "OurFamilyWizard MCP server for Claude \u2014 developed and maintained by AI (Claude Code)",
|
|
@@ -38442,8 +38625,8 @@ var package_default = {
|
|
|
38442
38625
|
"worker:test": "vitest run --config vitest.workers.config.ts"
|
|
38443
38626
|
},
|
|
38444
38627
|
dependencies: {
|
|
38445
|
-
"@chrischall/mcp-utils": "^0.
|
|
38446
|
-
"@fetchproxy/bootstrap": "^1.
|
|
38628
|
+
"@chrischall/mcp-utils": "^0.14.0",
|
|
38629
|
+
"@fetchproxy/bootstrap": "^1.7.0",
|
|
38447
38630
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
38448
38631
|
dotenv: "^17.4.2",
|
|
38449
38632
|
zod: "^4.4.3"
|
|
@@ -38689,11 +38872,208 @@ var OFWClient = class {
|
|
|
38689
38872
|
};
|
|
38690
38873
|
var client = new OFWClient();
|
|
38691
38874
|
|
|
38875
|
+
// src/timestamps.ts
|
|
38876
|
+
var DEFAULT_DISPLAY_TZ = "America/New_York";
|
|
38877
|
+
function isValidTimeZone(tz) {
|
|
38878
|
+
try {
|
|
38879
|
+
new Intl.DateTimeFormat("en-US", { timeZone: tz });
|
|
38880
|
+
return true;
|
|
38881
|
+
} catch {
|
|
38882
|
+
return false;
|
|
38883
|
+
}
|
|
38884
|
+
}
|
|
38885
|
+
function displayTimeZone() {
|
|
38886
|
+
const configured = readEnvVar("DISPLAY_TZ");
|
|
38887
|
+
if (configured && isValidTimeZone(configured)) return configured;
|
|
38888
|
+
return DEFAULT_DISPLAY_TZ;
|
|
38889
|
+
}
|
|
38890
|
+
function offsetAt(instant, tz) {
|
|
38891
|
+
const w = wallPartsIn(instant, tz);
|
|
38892
|
+
const asUTC = Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute, w.second, instant.getUTCMilliseconds());
|
|
38893
|
+
const minutes = Math.round((asUTC - instant.getTime()) / 6e4);
|
|
38894
|
+
const sign = minutes < 0 ? "-" : "+";
|
|
38895
|
+
const abs = Math.abs(minutes);
|
|
38896
|
+
return `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
|
|
38897
|
+
}
|
|
38898
|
+
var wallPartsFormatters = /* @__PURE__ */ new Map();
|
|
38899
|
+
var displayFormatters = /* @__PURE__ */ new Map();
|
|
38900
|
+
function wallPartsFormatter(tz) {
|
|
38901
|
+
let fmt = wallPartsFormatters.get(tz);
|
|
38902
|
+
if (!fmt) {
|
|
38903
|
+
fmt = buildWallPartsFormatter(tz);
|
|
38904
|
+
wallPartsFormatters.set(tz, fmt);
|
|
38905
|
+
}
|
|
38906
|
+
return fmt;
|
|
38907
|
+
}
|
|
38908
|
+
function wallPartsIn(instant, tz) {
|
|
38909
|
+
const parts = wallPartsFormatter(tz).formatToParts(instant);
|
|
38910
|
+
const out = {};
|
|
38911
|
+
for (const p of parts) {
|
|
38912
|
+
if (p.type !== "literal") out[p.type] = Number(p.value);
|
|
38913
|
+
}
|
|
38914
|
+
return out;
|
|
38915
|
+
}
|
|
38916
|
+
function buildWallPartsFormatter(tz) {
|
|
38917
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
38918
|
+
timeZone: tz,
|
|
38919
|
+
year: "numeric",
|
|
38920
|
+
month: "2-digit",
|
|
38921
|
+
day: "2-digit",
|
|
38922
|
+
hour: "2-digit",
|
|
38923
|
+
minute: "2-digit",
|
|
38924
|
+
second: "2-digit",
|
|
38925
|
+
// h23 pins midnight to hour 00; without it some ICU builds render hour 24.
|
|
38926
|
+
hourCycle: "h23"
|
|
38927
|
+
});
|
|
38928
|
+
}
|
|
38929
|
+
function wallTimeToInstant(y, mo, d, h, mi, s, ms, tz) {
|
|
38930
|
+
let guess = Date.UTC(y, mo - 1, d, h, mi, s, ms);
|
|
38931
|
+
for (let i = 0; i < 2; i += 1) {
|
|
38932
|
+
const seen = wallPartsIn(new Date(guess), tz);
|
|
38933
|
+
const seenUTC = Date.UTC(seen.year, seen.month - 1, seen.day, seen.hour, seen.minute, seen.second, ms);
|
|
38934
|
+
const drift = Date.UTC(y, mo - 1, d, h, mi, s, ms) - seenUTC;
|
|
38935
|
+
if (drift === 0) break;
|
|
38936
|
+
guess += drift;
|
|
38937
|
+
}
|
|
38938
|
+
return new Date(guess);
|
|
38939
|
+
}
|
|
38940
|
+
function pad(n, width = 2) {
|
|
38941
|
+
return String(n).padStart(width, "0");
|
|
38942
|
+
}
|
|
38943
|
+
function isoWithOffset(instant, tz, offset) {
|
|
38944
|
+
const w = wallPartsIn(instant, tz);
|
|
38945
|
+
const msPart = instant.getUTCMilliseconds();
|
|
38946
|
+
const frac = msPart ? `.${pad(msPart, 3)}` : "";
|
|
38947
|
+
return `${pad(w.year, 4)}-${pad(w.month)}-${pad(w.day)}T${pad(w.hour)}:${pad(w.minute)}:${pad(w.second)}${frac}${offset}`;
|
|
38948
|
+
}
|
|
38949
|
+
function formatInstant(instant, tz = displayTimeZone()) {
|
|
38950
|
+
const offset = offsetAt(instant, tz);
|
|
38951
|
+
let fmt = displayFormatters.get(tz);
|
|
38952
|
+
if (!fmt) {
|
|
38953
|
+
fmt = new Intl.DateTimeFormat("en-US", {
|
|
38954
|
+
timeZone: tz,
|
|
38955
|
+
weekday: "short",
|
|
38956
|
+
month: "short",
|
|
38957
|
+
day: "numeric",
|
|
38958
|
+
year: "numeric",
|
|
38959
|
+
hour: "numeric",
|
|
38960
|
+
minute: "2-digit",
|
|
38961
|
+
timeZoneName: "short"
|
|
38962
|
+
});
|
|
38963
|
+
displayFormatters.set(tz, fmt);
|
|
38964
|
+
}
|
|
38965
|
+
return { iso: isoWithOffset(instant, tz, offset), display: fmt.format(instant) };
|
|
38966
|
+
}
|
|
38967
|
+
var TIMESTAMP_KEYS = /* @__PURE__ */ new Set([
|
|
38968
|
+
// OFW: naive local wall-clock from the API.
|
|
38969
|
+
"sentAt",
|
|
38970
|
+
"viewedAt",
|
|
38971
|
+
"modifiedAt",
|
|
38972
|
+
"createdAt",
|
|
38973
|
+
"dueAt",
|
|
38974
|
+
"occurredAt",
|
|
38975
|
+
// OFW: UTC instants we stamp ourselves.
|
|
38976
|
+
"fetchedBodyAt",
|
|
38977
|
+
"fetchedAt",
|
|
38978
|
+
"syncedAt",
|
|
38979
|
+
"downloadedAt",
|
|
38980
|
+
"recordedAt",
|
|
38981
|
+
"expiresAt",
|
|
38982
|
+
// Freshness/sync bookkeeping. These sit in the SAME object as `asOf`, so
|
|
38983
|
+
// omitting them left the freshness block emitting two zones at once — the
|
|
38984
|
+
// exact defect this module exists to remove. Enumerated from a sweep of
|
|
38985
|
+
// emitted field names rather than from the ones a bug report happened to
|
|
38986
|
+
// mention.
|
|
38987
|
+
"asOf",
|
|
38988
|
+
"checkedAt",
|
|
38989
|
+
"lastVerifiedAt",
|
|
38990
|
+
"oldestVerifiedAt",
|
|
38991
|
+
"lastServerSyncAt",
|
|
38992
|
+
"lastSyncAt",
|
|
38993
|
+
// OFW API inner shape: `date: { dateTime }`, `viewed: { dateTime }`.
|
|
38994
|
+
"dateTime",
|
|
38995
|
+
// Generic.
|
|
38996
|
+
"date",
|
|
38997
|
+
"updated",
|
|
38998
|
+
"lastModified",
|
|
38999
|
+
"expirationTime"
|
|
39000
|
+
]);
|
|
39001
|
+
var ZONE_NAME_KEYS = /* @__PURE__ */ new Set(["timeZone", "timezone"]);
|
|
39002
|
+
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})$/;
|
|
39003
|
+
var NAIVE_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\.(\d{1,9}))?$/;
|
|
39004
|
+
var DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
|
|
39005
|
+
function isRealCalendarDate(p) {
|
|
39006
|
+
const utc = new Date(Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second));
|
|
39007
|
+
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;
|
|
39008
|
+
}
|
|
39009
|
+
function parseTimestampValue(key, value, assumeNaiveIn) {
|
|
39010
|
+
if (typeof value !== "string") return null;
|
|
39011
|
+
const raw = value.trim();
|
|
39012
|
+
if (raw === "" || DATE_ONLY.test(raw)) return null;
|
|
39013
|
+
if (RFC3339_WITH_OFFSET.test(raw)) {
|
|
39014
|
+
const parsed = new Date(raw.replace(" ", "T"));
|
|
39015
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
39016
|
+
}
|
|
39017
|
+
const naive = NAIVE_DATE_TIME.exec(raw);
|
|
39018
|
+
if (naive) {
|
|
39019
|
+
const [, y, mo, d, h, mi, s, frac] = naive;
|
|
39020
|
+
const ms = frac ? Number(frac.padEnd(3, "0").slice(0, 3)) : 0;
|
|
39021
|
+
const parts = {
|
|
39022
|
+
year: Number(y),
|
|
39023
|
+
month: Number(mo),
|
|
39024
|
+
day: Number(d),
|
|
39025
|
+
hour: Number(h),
|
|
39026
|
+
minute: Number(mi),
|
|
39027
|
+
second: Number(s ?? "0")
|
|
39028
|
+
};
|
|
39029
|
+
if (!isRealCalendarDate(parts)) return null;
|
|
39030
|
+
return wallTimeToInstant(
|
|
39031
|
+
parts.year,
|
|
39032
|
+
parts.month,
|
|
39033
|
+
parts.day,
|
|
39034
|
+
parts.hour,
|
|
39035
|
+
parts.minute,
|
|
39036
|
+
parts.second,
|
|
39037
|
+
ms,
|
|
39038
|
+
assumeNaiveIn
|
|
39039
|
+
);
|
|
39040
|
+
}
|
|
39041
|
+
return null;
|
|
39042
|
+
}
|
|
39043
|
+
function walk(node, tz) {
|
|
39044
|
+
if (Array.isArray(node)) {
|
|
39045
|
+
for (const item of node) walk(item, tz);
|
|
39046
|
+
return node;
|
|
39047
|
+
}
|
|
39048
|
+
if (node === null || typeof node !== "object") return node;
|
|
39049
|
+
const obj = node;
|
|
39050
|
+
for (const key of Object.keys(obj)) {
|
|
39051
|
+
const value = obj[key];
|
|
39052
|
+
if (value !== null && typeof value === "object") {
|
|
39053
|
+
walk(value, tz);
|
|
39054
|
+
continue;
|
|
39055
|
+
}
|
|
39056
|
+
if (ZONE_NAME_KEYS.has(key) || !TIMESTAMP_KEYS.has(key)) continue;
|
|
39057
|
+
const instant = parseTimestampValue(key, value, tz);
|
|
39058
|
+
if (!instant) continue;
|
|
39059
|
+
const { iso, display } = formatInstant(instant, tz);
|
|
39060
|
+
obj[key] = iso;
|
|
39061
|
+
obj[`${key}Display`] = display;
|
|
39062
|
+
}
|
|
39063
|
+
return obj;
|
|
39064
|
+
}
|
|
39065
|
+
function normalizeTimestampsInValue(value, tz = displayTimeZone()) {
|
|
39066
|
+
if (value === null || typeof value !== "object") return value;
|
|
39067
|
+
return walk(structuredClone(value), tz);
|
|
39068
|
+
}
|
|
39069
|
+
|
|
38692
39070
|
// src/tools/_shared.ts
|
|
38693
|
-
|
|
39071
|
+
function jsonResponse(data) {
|
|
39072
|
+
return textResult(normalizeTimestampsInValue(data));
|
|
39073
|
+
}
|
|
38694
39074
|
var textResponse = rawTextResult;
|
|
38695
39075
|
function jsonErrorResponse(data) {
|
|
38696
|
-
return { ...
|
|
39076
|
+
return { ...jsonResponse(data), isError: true };
|
|
38697
39077
|
}
|
|
38698
39078
|
var ApiRecipientSchema = external_exports.looseObject({
|
|
38699
39079
|
// Live OFW payloads key the recipient's id as `userId` (verified against a
|
|
@@ -40191,7 +40571,7 @@ function orderedPages(objects) {
|
|
|
40191
40571
|
if (rootRef === void 0) return inFileOrder;
|
|
40192
40572
|
const ordered = [];
|
|
40193
40573
|
const seen = /* @__PURE__ */ new Set();
|
|
40194
|
-
const
|
|
40574
|
+
const walk2 = (num) => {
|
|
40195
40575
|
if (seen.has(num)) return;
|
|
40196
40576
|
seen.add(num);
|
|
40197
40577
|
const obj = objects.get(num);
|
|
@@ -40201,9 +40581,9 @@ function orderedPages(objects) {
|
|
|
40201
40581
|
return;
|
|
40202
40582
|
}
|
|
40203
40583
|
const kids = /\/Kids\s*\[([^\]]*)\]/.exec(obj.body)?.[1];
|
|
40204
|
-
if (kids) for (const kid of refsIn(kids))
|
|
40584
|
+
if (kids) for (const kid of refsIn(kids)) walk2(kid);
|
|
40205
40585
|
};
|
|
40206
|
-
|
|
40586
|
+
walk2(rootRef);
|
|
40207
40587
|
return ordered.length > 0 ? ordered : inFileOrder;
|
|
40208
40588
|
}
|
|
40209
40589
|
function streamBytes(bytes, obj) {
|
|
@@ -42714,7 +43094,7 @@ var nodeCacheProvider = () => nodeCache ??= OFWCache.open(getCacheDbPath());
|
|
|
42714
43094
|
var nodeAttachmentIO = new NodeAttachmentIO();
|
|
42715
43095
|
await runMcp({
|
|
42716
43096
|
name: "ofw",
|
|
42717
|
-
version: "2.9.
|
|
43097
|
+
version: "2.9.2",
|
|
42718
43098
|
// x-release-please-version
|
|
42719
43099
|
deps: client,
|
|
42720
43100
|
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.
|
|
38
|
+
version: '2.9.2', // 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
|
+
}
|
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*`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ofw-mcp",
|
|
3
|
-
"version": "2.9.
|
|
3
|
+
"version": "2.9.2",
|
|
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)",
|
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
"worker:test": "vitest run --config vitest.workers.config.ts"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@chrischall/mcp-utils": "^0.
|
|
39
|
-
"@fetchproxy/bootstrap": "^1.
|
|
38
|
+
"@chrischall/mcp-utils": "^0.14.0",
|
|
39
|
+
"@fetchproxy/bootstrap": "^1.7.0",
|
|
40
40
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
41
41
|
"dotenv": "^17.4.2",
|
|
42
42
|
"zod": "^4.4.3"
|
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.
|
|
9
|
+
"version": "2.9.2",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "ofw-mcp",
|
|
14
|
-
"version": "2.9.
|
|
14
|
+
"version": "2.9.2",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|