ofw-mcp 2.9.1 → 2.10.0

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/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" || !KNOWN_CAPABILITIES.has(raw.op)) {
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 iv = fromB64(frame.iv);
35932
- const ct = fromB64(frame.ciphertext);
35933
- const pt = await aesGcmOpen(sessionKey, iv, ct);
35934
- const parsed = JSON.parse(dec.decode(pt));
35935
- return validateInnerFrame(parsed);
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
- try {
36433
- const inner = await openEncryptedFrame(session.sessionKey, frame);
36434
- innerListeners.forEach((cb) => cb(inner));
36435
- } catch {
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.1",
38593
+ version: "2.10.0",
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.13.0",
38446
- "@fetchproxy/bootstrap": "^1.3.0",
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"
@@ -38916,6 +39099,16 @@ function mapRecipients(items) {
38916
39099
  function hasRealView(recipients) {
38917
39100
  return recipients.some((r) => r.viewedAt !== null && !r.viewedAt.startsWith("1970-01-01"));
38918
39101
  }
39102
+ function threadedReplyTo(detail) {
39103
+ return detail.replyToId ?? detail.inReplyTo ?? null;
39104
+ }
39105
+ function reportsThreaded(detail) {
39106
+ return threadedReplyTo(detail) !== null || detail.showContext === true;
39107
+ }
39108
+ function reportsUnthreaded(detail) {
39109
+ if (reportsThreaded(detail)) return false;
39110
+ return detail.inReplyTo !== void 0 || detail.showContext !== void 0;
39111
+ }
38919
39112
  function scrapeSaysRead(listData) {
38920
39113
  if (typeof listData !== "object" || listData === null) return false;
38921
39114
  const ld = listData;
@@ -39230,7 +39423,12 @@ var DraftListItemSchema = external_exports.looseObject({
39230
39423
  id: external_exports.number(),
39231
39424
  subject: external_exports.string(),
39232
39425
  date: external_exports.looseObject({ dateTime: external_exports.string() }),
39426
+ // Both spellings of the threading echo — OFW reports the reply target as
39427
+ // `inReplyTo` (with showContext) on list payloads where `replyToId` is null.
39428
+ // The cached row must derive the SAME value ofw_save_draft derived from the
39429
+ // detail, or the content revision drifts between a save and the next sync.
39233
39430
  replyToId: external_exports.number().nullable().optional(),
39431
+ inReplyTo: external_exports.number().nullable().optional(),
39234
39432
  recipients: external_exports.array(ApiRecipientSchema).optional()
39235
39433
  });
39236
39434
  var DraftListResponseSchema = external_exports.looseObject({ data: external_exports.array(DraftListItemSchema).optional() });
@@ -39293,7 +39491,7 @@ async function syncDrafts(client2, draftsFolderId, store, budget) {
39293
39491
  subject: detail.subject ?? item.subject ?? "(no subject)",
39294
39492
  body: detail.body ?? "",
39295
39493
  recipients: mapRecipients(item.recipients),
39296
- replyToId: item.replyToId ?? null,
39494
+ replyToId: threadedReplyTo(item),
39297
39495
  modifiedAt: item.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString(),
39298
39496
  listData: item
39299
39497
  });
@@ -39594,8 +39792,18 @@ function draftRevision(d) {
39594
39792
  var ServerDraftSchema = external_exports.looseObject({
39595
39793
  subject: external_exports.string().optional(),
39596
39794
  body: external_exports.string().optional(),
39795
+ // BOTH spellings of the threading echo (see ThreadingEcho in _shared.ts):
39796
+ // OFW reports the reply target as `replyToId` on some payloads and as
39797
+ // `inReplyTo` on others. The snapshot derives one value from whichever is
39798
+ // present, so the revision hashed here matches the one ofw_save_draft
39799
+ // computed from the same server state — a one-sided read produced revisions
39800
+ // that disagreed about the same draft.
39597
39801
  replyToId: external_exports.number().nullable().optional(),
39802
+ inReplyTo: external_exports.number().nullable().optional(),
39598
39803
  recipients: external_exports.array(ApiRecipientSchema).optional(),
39804
+ // Attachment fileIds — read so send-by-draft carries the draft's
39805
+ // attachments onto the sent message (see DraftContent.files).
39806
+ files: external_exports.array(external_exports.number()).optional(),
39599
39807
  // Read for the LIFECYCLE answer (see tools/lifecycle.ts): which folder OFW
39600
39808
  // itself says this id lives in right now. `existsOnServer` alone cannot
39601
39809
  // distinguish "still a draft" from "was sent" — a sent draft still exists.
@@ -39638,8 +39846,9 @@ async function fetchMessageSnapshot(client2, id) {
39638
39846
  content: {
39639
39847
  subject: detail.subject ?? "",
39640
39848
  body: detail.body ?? "",
39641
- replyToId: detail.replyToId ?? null,
39642
- recipients: mapRecipients(detail.recipients)
39849
+ replyToId: threadedReplyTo(detail),
39850
+ recipients: mapRecipients(detail.recipients),
39851
+ ...detail.files !== void 0 ? { files: detail.files } : {}
39643
39852
  },
39644
39853
  folderId: detail.folder?.id === void 0 ? null : String(detail.folder.id),
39645
39854
  folderName: detail.folder?.name ?? null,
@@ -39769,13 +39978,24 @@ async function ensureFolderIdMap(client2, store) {
39769
39978
  return { map: cached2, requests: 1 };
39770
39979
  }
39771
39980
  }
39981
+ var STATE_BY_FOLDER_NAME = /* @__PURE__ */ new Map([
39982
+ ["drafts", "draft"],
39983
+ ["sent", "sent"],
39984
+ ["sent messages", "sent"],
39985
+ ["inbox", "received"]
39986
+ ]);
39772
39987
  function classifyState(snapshot, map2) {
39773
39988
  if (snapshot === null) return "deleted";
39774
- const { folderId } = snapshot;
39775
- if (folderId === null) return "unknown";
39776
- if (map2.drafts !== null && folderId === map2.drafts) return "draft";
39777
- if (map2.sent !== null && folderId === map2.sent) return "sent";
39778
- if (map2.inbox !== null && folderId === map2.inbox) return "received";
39989
+ const { folderId, folderName } = snapshot;
39990
+ if (folderId !== null) {
39991
+ if (map2.drafts !== null && folderId === map2.drafts) return "draft";
39992
+ if (map2.sent !== null && folderId === map2.sent) return "sent";
39993
+ if (map2.inbox !== null && folderId === map2.inbox) return "received";
39994
+ }
39995
+ if (folderName !== null) {
39996
+ const byName = STATE_BY_FOLDER_NAME.get(folderName.trim().toLowerCase());
39997
+ if (byName !== void 0) return byName;
39998
+ }
39779
39999
  return "unknown";
39780
40000
  }
39781
40001
  function probeWouldStamp(cachedDraft, cachedMessage) {
@@ -40917,13 +41137,25 @@ var SentDetailSchema = external_exports.looseObject({
40917
41137
  body: external_exports.string().optional(),
40918
41138
  date: DateSchema.optional(),
40919
41139
  from: external_exports.looseObject({ name: external_exports.string().optional() }).optional(),
40920
- recipients: external_exports.array(ApiRecipientSchema).optional()
41140
+ recipients: external_exports.array(ApiRecipientSchema).optional(),
41141
+ // The threading echo, in BOTH spellings plus showContext — OFW reports the
41142
+ // reply target inconsistently across payloads (see ThreadingEcho in
41143
+ // _shared.ts). Backs the `threaded` verdict on ofw_send_message.
41144
+ replyToId: external_exports.number().nullable().optional(),
41145
+ inReplyTo: external_exports.number().nullable().optional(),
41146
+ showContext: external_exports.boolean().optional()
40921
41147
  });
40922
41148
  var SavedDraftDetailSchema = external_exports.looseObject({
40923
41149
  subject: external_exports.string().optional(),
40924
41150
  body: external_exports.string().optional(),
40925
41151
  date: DateSchema.optional(),
41152
+ // All three threading-echo fields. Reading ONLY `replyToId` here fired a
41153
+ // false "OurFamilyWizard did not thread this draft" warning on nearly every
41154
+ // threaded save, while the same payload's `inReplyTo`/`showContext` showed
41155
+ // the draft WAS threaded — see threadedReplyTo in _shared.ts.
40926
41156
  replyToId: external_exports.number().nullable().optional(),
41157
+ inReplyTo: external_exports.number().nullable().optional(),
41158
+ showContext: external_exports.boolean().optional(),
40927
41159
  recipients: external_exports.array(ApiRecipientSchema).optional(),
40928
41160
  // Read to audit whether requested myFileIDs actually attached (Defect 3).
40929
41161
  files: external_exports.array(external_exports.number()).optional()
@@ -41122,6 +41354,11 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
41122
41354
  if (draftRow !== null) {
41123
41355
  const { freshness: freshness2, serverConfirmed, cacheStatus } = await draftsFreshness(cache);
41124
41356
  return jsonResponse({
41357
+ // Stable identity FIRST — the id below changes on every edit
41358
+ // (create-then-delete), so callers should key off draftKey. Null when
41359
+ // this draft was never written through this tool (e.g. authored in
41360
+ // the web app).
41361
+ draftKey: (await cache.getDraftLineageById(draftRow.id))?.draftKey ?? null,
41125
41362
  id: draftRow.id,
41126
41363
  folder: "drafts",
41127
41364
  subject: draftRow.subject,
@@ -41138,13 +41375,9 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
41138
41375
  listData: draftRow.listData,
41139
41376
  attachments: [],
41140
41377
  // Concurrency token — pass as expectedRevision to ofw_save_draft /
41141
- // ofw_delete_draft to assert you are editing THIS version.
41378
+ // ofw_delete_draft / ofw_send_message to assert you are acting on
41379
+ // THIS version.
41142
41380
  revision: draftRevision(draftRow),
41143
- // Stable logical identity. Survives the create-then-delete id churn of
41144
- // editing AND the transition to sent — pass it to ofw_status to ask
41145
- // "what happened to the thing I was working on?". Null when this draft
41146
- // was never written through this tool (e.g. authored in the web app).
41147
- draftKey: (await cache.getDraftLineageById(draftRow.id))?.draftKey ?? null,
41148
41381
  cacheStatus,
41149
41382
  // False = this draft's existence and unsent status are remembered from
41150
41383
  // a cache, not confirmed on OFW. Call ofw_check_freshness before
@@ -41227,16 +41460,19 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
41227
41460
  return jsonResponse({ ...withReadState(row), attachments, freshness });
41228
41461
  });
41229
41462
  if (allowSend) server.registerTool("ofw_send_message", {
41230
- description: "Send a message via OurFamilyWizard. To send an existing draft, pass messageId \u2014 subject/body/recipientIds become optional overrides (missing fields default to the draft's cached values) and the draft is deleted after sending. To send a fresh message, supply subject/body/recipientIds directly. draftId is the legacy spelling of messageId and works the same way. If replyToId is provided, the cache may rewrite it to the latest reply in the same thread (a note is included in the response when this happens). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs. After sending, the tool re-fetches the message from OFW to populate the local cache and link attachments to the new message id.",
41463
+ description: "Send a message via OurFamilyWizard \u2014 the ONE irreversible operation here, so it carries the strongest guard. TO SEND AN EXISTING DRAFT (the safe default): pass draftId (or messageId \u2014 same thing). The tool re-reads the draft from OFW and sends the SERVER'S version, so what goes out is what is on OurFamilyWizard, not what this session remembers \u2014 subject/body act only as explicit overrides. It is guarded exactly like ofw_save_draft: pass expectedRevision to assert which version you are sending; if the draft changed on OFW since you read it \u2014 or no longer exists (it may already have been SENT) \u2014 the send is REFUSED with the current server content echoed back, and nothing goes out. RECIPIENTS: OurFamilyWizard does not persist recipients on drafts, so recipientIds is usually still required at send time (ids from ofw_get_profile). After the send is CONFIRMED (OFW returned the new message id and the re-fetched sent record matches what was posted), the source draft is deleted automatically; pass deleteDraftOnSuccess:false to keep it. On ANY failure or ambiguity the draft is never deleted \u2014 the response carries draftRetained:true with the reason. TO COMPOSE FROM SCRATCH: supply subject/body/recipientIds with no draftId. If replyToId is provided (or inherited from the draft), the cache may rewrite it to the latest reply in the same thread (a note is included when this happens). ATTACHMENTS: when sending by draftId, the server draft's own attachments carry over automatically; myFileIDs (from ofw_upload_attachment) overrides or attaches files on a fresh compose. The response leads with sentMessageId and the stable draftKey, and reports threaded (whether OFW actually linked the reply) and draftDeleted.",
41231
41464
  annotations: { destructiveHint: true },
41232
41465
  inputSchema: {
41233
- subject: external_exports.string().describe("Message subject. Required unless messageId/draftId references a cached draft.").optional(),
41234
- body: external_exports.string().describe("Message body text. Required unless messageId/draftId references a cached draft.").optional(),
41235
- recipientIds: external_exports.array(external_exports.number()).describe("Array of recipient user IDs (get from ofw_get_profile). Required unless messageId/draftId references a cached draft.").optional(),
41236
- replyToId: external_exports.number().describe("ID of the message being replied to").optional(),
41237
- messageId: external_exports.number().describe("ID of an existing draft to send. When set, missing subject/body/recipientIds default to the draft's cached values, and the draft is deleted after sending.").optional(),
41238
- draftId: external_exports.number().describe("Legacy synonym for messageId. If both are passed they must be equal.").optional(),
41239
- myFileIDs: external_exports.array(external_exports.number()).describe("Attachment file ids (from ofw_upload_attachment) to attach to the message").optional()
41466
+ subject: external_exports.string().describe("Message subject. Required unless draftId/messageId is given (then it overrides the server draft's subject).").optional(),
41467
+ body: external_exports.string().describe("Message body text. Required unless draftId/messageId is given (then it overrides the server draft's body \u2014 omit it to send exactly what is on OurFamilyWizard).").optional(),
41468
+ recipientIds: external_exports.array(external_exports.number()).describe("Array of recipient user IDs (get from ofw_get_profile). Usually required even when sending a draft: OurFamilyWizard does not persist recipients on drafts.").optional(),
41469
+ replyToId: external_exports.number().describe("ID of the message being replied to. Defaults to the draft's stored reply target when sending by draftId.").optional(),
41470
+ draftId: external_exports.number().describe("ID of an existing draft to send. The draft is re-read from OurFamilyWizard and its SERVER content is sent; missing subject/body default from it. Guarded: a draft that changed since you read it, or that was already sent/deleted, refuses rather than sending blind.").optional(),
41471
+ messageId: external_exports.number().describe("Synonym for draftId (if both are passed they must be equal).").optional(),
41472
+ expectedRevision: external_exports.string().describe('With draftId: the `revision` from ofw_list_drafts / ofw_get_message / ofw_check_freshness for that draft. Asserts you are sending THAT version; if the draft changed on OFW since, the send is refused and the current server content returned. Omit and the tool compares the server against the local cache instead \u2014 omitting never means "send whatever is there now".').optional(),
41473
+ deleteDraftOnSuccess: external_exports.boolean().describe("Default true. Delete the source draft after \u2014 and ONLY after \u2014 the send is confirmed (new message id returned and the re-fetched sent record checks out). Set false to keep the draft. On a failed or unverifiable send the draft is ALWAYS kept, regardless of this flag.").optional(),
41474
+ force: external_exports.boolean().describe("Default false. Send even when the draft changed on OurFamilyWizard since you read it, or its current state could not be read. Only use after showing the user the conflict.").optional(),
41475
+ myFileIDs: external_exports.array(external_exports.number()).describe("Attachment file ids (from ofw_upload_attachment) to attach to the message. When sending by draftId, omit it to carry the server draft's own attachments over; passing it overrides them.").optional()
41240
41476
  }
41241
41477
  }, async (args) => {
41242
41478
  if (args.messageId !== void 0 && args.draftId !== void 0 && args.messageId !== args.draftId) {
@@ -41244,37 +41480,51 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
41244
41480
  }
41245
41481
  const draftRef = args.messageId ?? args.draftId;
41246
41482
  const cache = cacheProvider();
41483
+ const deleteOnSuccess = args.deleteDraftOnSuccess ?? true;
41247
41484
  let subject = args.subject;
41248
41485
  let body = args.body;
41249
41486
  let recipientIds = args.recipientIds;
41250
41487
  let draftReplyToId = null;
41251
- let draftLookupAttempted = false;
41252
- let draftFound = false;
41488
+ let guardNote = null;
41489
+ let serverDraft;
41253
41490
  if (draftRef !== void 0) {
41254
- draftLookupAttempted = true;
41255
- const draft = await cache.getDraft(draftRef);
41256
- if (draft !== null) {
41257
- draftFound = true;
41258
- subject = subject ?? draft.subject;
41259
- body = body ?? draft.body;
41260
- recipientIds = recipientIds ?? draft.recipients.map((r) => r.userId);
41261
- draftReplyToId = draft.replyToId;
41491
+ const cachedDraft = await cache.getDraft(draftRef);
41492
+ const needsContent = subject === void 0 || body === void 0 || recipientIds === void 0;
41493
+ if (needsContent || deleteOnSuccess) {
41494
+ const guard = await guardDestructiveDraftOp({
41495
+ cache,
41496
+ draftId: draftRef,
41497
+ expectedRevision: args.expectedRevision,
41498
+ force: args.force ?? false,
41499
+ action: "send"
41500
+ });
41501
+ if (!guard.ok) return guard.response;
41502
+ guardNote = guard.note;
41503
+ serverDraft = guard.server;
41504
+ }
41505
+ const base = serverDraft ?? cachedDraft;
41506
+ if (base != null) {
41507
+ subject = subject ?? base.subject;
41508
+ body = body ?? base.body;
41509
+ draftReplyToId = base.replyToId;
41510
+ }
41511
+ if (recipientIds === void 0) {
41512
+ const source = [serverDraft ?? null, cachedDraft].find(
41513
+ (s) => s !== null && s !== void 0 && s.recipients.some((r) => r.userId !== 0)
41514
+ );
41515
+ if (source != null) {
41516
+ recipientIds = [...new Set(source.recipients.map((r) => r.userId).filter((id) => id !== 0))];
41517
+ }
41262
41518
  }
41263
41519
  }
41264
41520
  if (subject === void 0 || body === void 0 || recipientIds === void 0) {
41265
- if (draftLookupAttempted && !draftFound) {
41266
- throw new Error(
41267
- `draft ${draftRef} not found in local cache. Call ofw_sync_messages first, or supply subject/body/recipientIds explicitly.`
41268
- );
41269
- }
41270
41521
  const missing = [
41271
41522
  subject === void 0 ? "subject" : null,
41272
41523
  body === void 0 ? "body" : null,
41273
41524
  recipientIds === void 0 ? "recipientIds" : null
41274
41525
  ].filter((n) => n !== null).join(", ");
41275
- throw new Error(
41276
- `ofw_send_message requires ${missing}. Pass it directly, or pass messageId to default missing fields from a cached draft.`
41277
- );
41526
+ const hint = draftRef === void 0 ? "Pass them directly, or pass draftId to send an existing draft." : missing === "recipientIds" ? `Draft ${draftRef} carries no stored recipients \u2014 OurFamilyWizard does not persist recipients on drafts, so they must be supplied at send time. Get the co-parent's user id from ofw_get_profile and pass recipientIds.` : `Draft ${draftRef}'s content was not readable from OurFamilyWizard or the local cache, so it cannot supply the missing fields. Pass them explicitly.`;
41527
+ throw new Error(`ofw_send_message requires ${missing}. ${hint}`);
41278
41528
  }
41279
41529
  const requestedReplyTo = args.replyToId ?? draftReplyToId ?? null;
41280
41530
  let resolvedReplyTo = requestedReplyTo;
@@ -41288,7 +41538,7 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
41288
41538
  const parent = await cache.getMessage(resolvedReplyTo);
41289
41539
  chainRootId = parent?.chainRootId ?? parent?.id ?? requestedReplyTo;
41290
41540
  }
41291
- const myFileIDs = args.myFileIDs ?? [];
41541
+ const myFileIDs = args.myFileIDs ?? serverDraft?.files ?? [];
41292
41542
  const { id: newId, detail, raw } = await postMessageAndRefetch(client2, {
41293
41543
  subject,
41294
41544
  body,
@@ -41301,19 +41551,51 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
41301
41551
  let persisted = null;
41302
41552
  let verifyNote = null;
41303
41553
  let sentDraftKey = null;
41554
+ let threaded = false;
41555
+ let threadNote = null;
41304
41556
  if (newId !== null) {
41305
41557
  verifyNote = verifyWriteLanded("message", { subject, body }, detail);
41558
+ const echoed = threadedReplyTo(detail);
41559
+ if (resolvedReplyTo === null) {
41560
+ threaded = reportsThreaded(detail);
41561
+ } else if (reportsThreaded(detail)) {
41562
+ threaded = true;
41563
+ if (echoed !== null && echoed !== resolvedReplyTo) {
41564
+ threadNote = `NOTE: the sent message threads to ${echoed}, not the requested ${resolvedReplyTo} \u2014 OurFamilyWizard re-targeted the reply within the thread.`;
41565
+ }
41566
+ } else if (reportsUnthreaded(detail)) {
41567
+ threaded = false;
41568
+ threadNote = `WARNING: the sent message came back UNTHREADED \u2014 replyToId ${resolvedReplyTo} was posted but OurFamilyWizard reports no reply linkage on the sent record, so it went out as a new top-level conversation. Verify on ourfamilywizard.com.`;
41569
+ } else {
41570
+ threaded = true;
41571
+ }
41572
+ const storedRecipients = mapRecipients(detail.recipients);
41573
+ if (Array.isArray(detail.recipients) && detail.recipients.length > 0) {
41574
+ const landed = new Set(storedRecipients.map((r) => r.userId));
41575
+ const missingRecipients = recipientIds.filter((rid) => !landed.has(rid));
41576
+ if (missingRecipients.length > 0) {
41577
+ verifyNote = [
41578
+ verifyNote,
41579
+ `WARNING: the sent record does not list requested recipient id(s) ${missingRecipients.join(", ")}, so the send could not be fully confirmed. Verify on ourfamilywizard.com.`
41580
+ ].filter((n) => n !== null).join("\n\n");
41581
+ }
41582
+ }
41306
41583
  persisted = {
41307
41584
  id: newId,
41308
41585
  folder: "sent",
41309
41586
  subject: detail.subject ?? subject,
41310
41587
  fromUser: detail.from?.name ?? "",
41311
41588
  sentAt: detail.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString(),
41312
- recipients: mapRecipients(detail.recipients),
41589
+ recipients: storedRecipients,
41313
41590
  body: detail.body ?? body,
41314
41591
  fetchedBodyAt: (/* @__PURE__ */ new Date()).toISOString(),
41315
- replyToId: resolvedReplyTo,
41316
- chainRootId,
41592
+ // Prefer OFW's own echo of where the reply landed; keep what was
41593
+ // posted when OFW echoed nothing (sent rows feed findLatestReplyTip,
41594
+ // and a null would break the chain for a message that IS threaded).
41595
+ // A positively UNTHREADED send stores null — the chain link OFW says
41596
+ // does not exist must not be invented.
41597
+ replyToId: threaded ? echoed ?? resolvedReplyTo : null,
41598
+ chainRootId: threaded ? chainRootId : null,
41317
41599
  listData: detail
41318
41600
  };
41319
41601
  await cache.upsertMessage(persisted);
@@ -41341,16 +41623,43 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
41341
41623
  }
41342
41624
  }
41343
41625
  let unconfirmedNote = null;
41626
+ let draftDeleted = false;
41627
+ let draftRetainedReason = null;
41344
41628
  if (newId === null) {
41345
41629
  const draftClause = draftRef !== void 0 ? `Draft ${draftRef} was NOT deleted \u2014 check` : "Check";
41346
41630
  unconfirmedNote = `WARNING: OFW's send response did not include a message id, so the send could not be confirmed. ${draftClause} ourfamilywizard.com to see whether the message went out before retrying.`;
41631
+ if (draftRef !== void 0) {
41632
+ draftRetainedReason = "the send could not be confirmed (OFW returned no message id), so the draft is your only reliable copy of the message";
41633
+ }
41347
41634
  } else if (draftRef !== void 0) {
41348
- await deleteOFWMessages(client2, [draftRef]);
41349
- await cache.deleteDraft(draftRef);
41635
+ if (verifyNote !== null) {
41636
+ draftRetainedReason = "the sent record could not be fully verified against what was posted (see WARNING above) \u2014 the draft is kept until you confirm the send on ourfamilywizard.com";
41637
+ } else if (!deleteOnSuccess) {
41638
+ draftRetainedReason = "deleteDraftOnSuccess:false \u2014 kept by request";
41639
+ } else {
41640
+ try {
41641
+ await deleteOFWMessages(client2, [draftRef]);
41642
+ await cache.deleteDraft(draftRef);
41643
+ draftDeleted = true;
41644
+ } catch (e) {
41645
+ draftRetainedReason = `the send succeeded but the draft delete failed (${e.message}) \u2014 remove it with ofw_delete_draft once you have verified the sent message`;
41646
+ }
41647
+ }
41350
41648
  }
41351
- const responseObj = persisted === null ? raw : { ...persisted, ...sentDraftKey !== null ? { draftKey: sentDraftKey, previousId: draftRef } : {} };
41649
+ const retainNote = draftRef !== void 0 && newId !== null && !draftDeleted ? `NOTE: draft ${draftRef} was retained: ${draftRetainedReason}.` : null;
41650
+ const responseObj = persisted === null ? draftRef !== void 0 ? { sendConfirmed: false, draftDeleted: false, draftRetained: true, draftRetainedReason, raw } : raw : {
41651
+ sentMessageId: newId,
41652
+ draftKey: sentDraftKey,
41653
+ threaded,
41654
+ ...draftRef !== void 0 ? {
41655
+ draftDeleted,
41656
+ ...draftDeleted ? {} : { draftRetained: true, draftRetainedReason },
41657
+ previousId: draftRef
41658
+ } : {},
41659
+ ...persisted
41660
+ };
41352
41661
  const text = responseObj ? JSON.stringify(responseObj, null, 2) : "Message sent successfully.";
41353
- const notes = [rewriteNote, verifyNote, unconfirmedNote].filter((n) => n !== null).join("\n\n");
41662
+ const notes = [guardNote, rewriteNote, verifyNote, threadNote, unconfirmedNote, retainNote].filter((n) => n !== null).join("\n\n");
41354
41663
  return textResponse(notes ? `${notes}
41355
41664
 
41356
41665
  ${text}` : text);
@@ -41370,7 +41679,7 @@ ${text}` : text);
41370
41679
  } catch (e) {
41371
41680
  const reason = e.message;
41372
41681
  if (force) {
41373
- return { ok: true, note: `WARNING: force:true \u2014 proceeded with ${action} on draft ${draftId} even though its current state could not be read from OurFamilyWizard (${reason}). Any newer server-side version was destroyed and is NOT recoverable from this response.` };
41682
+ return { ok: true, note: `WARNING: force:true \u2014 proceeded with ${action} on draft ${draftId} even though its current state could not be read from OurFamilyWizard (${reason}). Any newer server-side version was destroyed and is NOT recoverable from this response.`, server: void 0 };
41374
41683
  }
41375
41684
  return {
41376
41685
  ok: false,
@@ -41385,7 +41694,7 @@ ${text}` : text);
41385
41694
  const verdict = checkDraftFreshness({ server: server2, cached: cached2, expectedRevision });
41386
41695
  if (verdict.verdict === "FRESH") {
41387
41696
  const note = verdict.metadataOnly ? `NOTE: draft ${draftId} was treated as current for this ${action}. Since you read it, OurFamilyWizard normalized connector-authored metadata (${verdict.changedFields.join(", ")}); the subject, body and recipients are unchanged, so this is not a conflict.` : null;
41388
- return { ok: true, note };
41697
+ return { ok: true, note, server: server2 };
41389
41698
  }
41390
41699
  if (force) {
41391
41700
  console.error(`[ofw-mcp] WARNING: force:true overrode a ${verdict.verdict} verdict on draft ${draftId} (${action}). ${verdict.reason}`);
@@ -41398,7 +41707,8 @@ ${JSON.stringify(
41398
41707
  { overwrittenServerDraft: server2 === null ? null : { ...server2, revision: draftRevision(server2) } },
41399
41708
  null,
41400
41709
  2
41401
- )}`
41710
+ )}`,
41711
+ server: server2
41402
41712
  };
41403
41713
  }
41404
41714
  return {
@@ -41413,17 +41723,31 @@ ${JSON.stringify(
41413
41723
  };
41414
41724
  }
41415
41725
  server.registerTool("ofw_list_drafts", {
41416
- description: 'List draft messages from the local OurFamilyWizard cache. Returns an explicit `complete` boolean describing the RESULT SET: true means "these are ALL the drafts on OurFamilyWizard as of freshness.asOf" \u2014 check it before saying "you have N drafts". Each draft carries its `draftKey` (stable across the create-then-delete churn of editing) when one is known. An empty result from a cache that is not verified-fresh is REFUSED (result:"UNVERIFIED_EMPTY"); pass autoRefresh:true to sync and answer instead. For a live, one-call answer prefer ofw_status(includeDraftInventory:true).',
41726
+ description: 'List draft messages, verified against OurFamilyWizard in ONE call: when the local drafts cache is not verified-fresh, a cheap drafts sync runs first by default (verify:true), so the answer is server-confirmed without a second call. Pass verify:false to answer purely from the cache (no OFW requests). Returns an explicit `complete` boolean describing the RESULT SET: true means "these are ALL the drafts on OurFamilyWizard as of freshness.asOf" \u2014 check it before saying "you have N drafts". Each draft carries its `draftKey` (stable across the create-then-delete churn of editing) when one is known. An empty result from a cache that is not verified-fresh is REFUSED (result:"UNVERIFIED_EMPTY"); pass autoRefresh:true to sync and answer instead.',
41417
41727
  annotations: { readOnlyHint: false },
41418
41728
  inputSchema: {
41419
41729
  page: external_exports.number().int().min(1).describe("Page number (default 1)").optional(),
41420
41730
  size: external_exports.number().int().min(1).describe("Drafts per page (default 50)").optional(),
41731
+ verify: external_exports.boolean().describe("Default true: when the drafts cache is not verified-fresh, run a drafts sync first (cheap \u2014 one list page plus one detail per draft) so the response is server-confirmed in one call. Set false to serve straight from the local cache with no OFW requests.").optional(),
41421
41732
  autoRefresh: external_exports.boolean().describe(AUTO_REFRESH_DESC).optional()
41422
41733
  }
41423
41734
  }, async (args) => {
41424
41735
  const page = args.page ?? 1;
41425
41736
  const size = args.size ?? 50;
41426
41737
  const cache = cacheProvider();
41738
+ let autoVerified = false;
41739
+ let verifyNote = null;
41740
+ if (args.verify ?? true) {
41741
+ const { cacheStatus } = await draftsFreshness(cache);
41742
+ if (cacheStatus !== "fresh") {
41743
+ try {
41744
+ await syncAll(client2, { folders: ["drafts"], maxRequests: getSyncMaxRequests() }, cache);
41745
+ autoVerified = await getDraftsCacheStatus(cache) === "fresh";
41746
+ } catch (e) {
41747
+ verifyNote = `The automatic drafts verification could not reach OurFamilyWizard (${e.message}). Answering from the local cache \u2014 the freshness block below labels its age, and an empty result will still be refused rather than reported as an absence.`;
41748
+ }
41749
+ }
41750
+ }
41427
41751
  const { value, refreshed, unverifiedEmpty } = await guardedCacheRead({
41428
41752
  client: client2,
41429
41753
  cache,
@@ -41454,7 +41778,7 @@ ${JSON.stringify(
41454
41778
  freshness: value.freshness,
41455
41779
  refreshed,
41456
41780
  remedy: 'Call ofw_sync_messages(folders:["drafts"]) and retry, re-call with autoRefresh:true, or use ofw_status(includeDraftInventory:true) for a single live answer.',
41457
- extra: { page, size }
41781
+ extra: { page, size, ...verifyNote !== null ? { verifyNote } : {} }
41458
41782
  });
41459
41783
  }
41460
41784
  const { drafts, total, freshness, serverConfirmed } = value;
@@ -41473,10 +41797,16 @@ ${JSON.stringify(
41473
41797
  if (refreshed) {
41474
41798
  payload.autoRefreshed = true;
41475
41799
  }
41800
+ if (autoVerified) {
41801
+ payload.autoVerified = true;
41802
+ }
41803
+ if (verifyNote !== null) {
41804
+ payload.verifyNote = verifyNote;
41805
+ }
41476
41806
  return jsonResponse(payload);
41477
41807
  });
41478
41808
  if (allowDrafts) server.registerTool("ofw_save_draft", {
41479
- description: "Save a message as a draft in OurFamilyWizard. Recipients are optional. Pass messageId to replace an existing draft \u2014 note that under the hood this creates a NEW draft and deletes the old one (OFW's update-in-place endpoint silently no-ops while echoing the posted body, so we don't use it); the response.id will be the NEW id, not the messageId you passed, and the change is documented in a transparency NOTE in the response that also lists which fields (subject/body/recipients/replyToId/attachments) were carried over. If replyToId is provided, the cache may rewrite it to the latest reply in the thread (note included in response). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs. After saving, the tool re-fetches the draft from OFW to populate the local cache from authoritative server state, and the returned `revision` reflects that authoritative state (so it will match on your next edit). FIELD PRESERVATION: the response echoes the effective threading (replyToId/inReplyTo) and, whenever OFW did not carry over a requested replyToId, recipient or attachment, a `warnings[]` entry naming what was dropped \u2014 never a silent null. SAFETY: because replacing DESTROYS the old draft rather than merging, passing messageId first re-reads that draft from OFW and REFUSES the write if its subject/body/recipients changed since you read it (drafts edited in the OFW web app do not bump any timestamp, so the local cache can be silently behind). A pure replyToId normalization by OFW is NOT treated as a conflict. The refusal returns the current server body under serverBody \u2014 merge your edit into it and retry with expectedRevision.",
41809
+ description: "Save a message as a draft in OurFamilyWizard. RECIPIENTS: OurFamilyWizard does NOT persist recipients on drafts \u2014 recipientIds are accepted but the saved draft comes back with none (documented OFW behavior, noted once in the response, not warned about; supply recipientIds at send time instead). IDENTITY: the response leads with `draftKey`, the stable identity that survives editing \u2014 key off it, because the `id` changes on EVERY edit (replacing a draft creates a NEW draft and deletes the old one; OFW's update-in-place endpoint silently no-ops, so we never use it). Pass messageId to replace an existing draft; the response.id will be the NEW id, and a transparency NOTE documents the swap and which fields were carried over. THREADING: if replyToId is provided, the cache may rewrite it to the latest reply in the thread (note included). The threading verdict is read from OFW's full echo (replyToId/inReplyTo/showContext) \u2014 a warning appears ONLY when the reply linkage was genuinely dropped or re-targeted, and the response's top-level replyToId/inReplyTo always agree with its listData. Attach files via myFileIDs (from ofw_upload_attachment). After saving, the tool re-fetches the draft from OFW, and the returned `revision` reflects that authoritative state (so it will match on your next edit). SAFETY: because replacing DESTROYS the old draft rather than merging, passing messageId first re-reads that draft from OFW and REFUSES the write if its subject/body/recipients changed since you read it (drafts edited in the OFW web app do not bump any timestamp, so the local cache can be silently behind). A pure replyToId normalization by OFW is NOT treated as a conflict. The refusal returns the current server body under serverBody \u2014 merge your edit into it and retry with expectedRevision.",
41480
41810
  annotations: { readOnlyHint: false },
41481
41811
  inputSchema: {
41482
41812
  subject: external_exports.string().describe("Message subject"),
@@ -41530,12 +41860,13 @@ ${JSON.stringify(
41530
41860
  let persisted = null;
41531
41861
  let replaceNote = null;
41532
41862
  let verifyNote = null;
41863
+ let recipientsNote = null;
41533
41864
  let newRevision = null;
41534
41865
  let draftKey = null;
41535
41866
  const warnings = [];
41536
41867
  if (newId !== null) {
41537
41868
  verifyNote = verifyWriteLanded("draft", { subject: args.subject, body: args.body }, detail);
41538
- const effectiveReplyTo = detail.replyToId ?? null;
41869
+ const effectiveReplyTo = threadedReplyTo(detail);
41539
41870
  const storedRecipients = mapRecipients(detail.recipients);
41540
41871
  persisted = {
41541
41872
  id: newId,
@@ -41571,17 +41902,23 @@ ${JSON.stringify(
41571
41902
  previousId: args.messageId ?? null,
41572
41903
  recordedAt: now
41573
41904
  });
41574
- if (resolvedReplyTo !== null && effectiveReplyTo !== resolvedReplyTo) {
41905
+ if (resolvedReplyTo !== null && effectiveReplyTo !== resolvedReplyTo && reportsUnthreaded(detail)) {
41906
+ const rewrittenFrom = requestedReplyTo !== resolvedReplyTo ? ` (rewritten from ${requestedReplyTo})` : "";
41907
+ warnings.push(
41908
+ `replyToId was requested as ${resolvedReplyTo}${rewrittenFrom} but the saved draft came back with replyToId null \u2014 OurFamilyWizard did not thread this draft (its inReplyTo/showContext are empty). The subject and body were saved; only the reply linkage was dropped. If threading matters, verify on ourfamilywizard.com.`
41909
+ );
41910
+ } else if (resolvedReplyTo !== null && effectiveReplyTo !== null && effectiveReplyTo !== resolvedReplyTo) {
41575
41911
  const rewrittenFrom = requestedReplyTo !== resolvedReplyTo ? ` (rewritten from ${requestedReplyTo})` : "";
41576
- const outcome = effectiveReplyTo === null ? "OurFamilyWizard did not thread this draft (its inReplyTo/showContext will be empty). The subject and body were saved; only the reply linkage was dropped." : `OurFamilyWizard re-targeted the reply to message ${effectiveReplyTo} instead. The draft IS threaded \u2014 to that message, not the one requested \u2014 and the inReplyTo in this response reflects where it actually landed.`;
41577
41912
  warnings.push(
41578
- `replyToId was requested as ${resolvedReplyTo}${rewrittenFrom} but the saved draft came back with replyToId ${effectiveReplyTo === null ? "null" : effectiveReplyTo} \u2014 ${outcome} If threading matters, verify on ourfamilywizard.com.`
41913
+ `replyToId was requested as ${resolvedReplyTo}${rewrittenFrom} but the saved draft came back with replyToId ${effectiveReplyTo} \u2014 OurFamilyWizard re-targeted the reply to message ${effectiveReplyTo} instead. The draft IS threaded \u2014 to that message, not the one requested. If threading matters, verify on ourfamilywizard.com.`
41579
41914
  );
41580
41915
  }
41581
- if (args.recipientIds !== void 0 && Array.isArray(detail.recipients)) {
41916
+ if (args.recipientIds !== void 0 && args.recipientIds.length > 0 && Array.isArray(detail.recipients)) {
41582
41917
  const requested = [...new Set(args.recipientIds)].sort((a, b) => a - b);
41583
41918
  const stored = [...new Set(storedRecipients.map((r) => r.userId))].sort((a, b) => a - b);
41584
- if (requested.join(",") !== stored.join(",")) {
41919
+ if (stored.length === 0) {
41920
+ recipientsNote = "NOTE: OurFamilyWizard does not persist recipients on drafts \u2014 the recipientIds you passed were accepted but are not stored on the draft (documented OFW behavior, not an error; it also means a draft cannot be sent by accident). Supply recipientIds when you send: ofw_send_message requires them when the draft carries none.";
41921
+ } else if (requested.join(",") !== stored.join(",")) {
41585
41922
  warnings.push(
41586
41923
  `recipientIds were requested as [${requested.join(", ")}] but the saved draft has [${stored.join(", ")}]. Verify the recipients on ourfamilywizard.com.`
41587
41924
  );
@@ -41600,28 +41937,26 @@ ${JSON.stringify(
41600
41937
  try {
41601
41938
  await deleteOFWMessages(client2, [args.messageId]);
41602
41939
  await cache.deleteDraft(args.messageId);
41603
- replaceNote = `NOTE: ofw_save_draft replaced draft ${args.messageId} via create-then-delete. The new draft id is ${newId}; the old draft has been deleted. (OFW's update-in-place endpoint silently no-ops on subsequent updates, so we never use it. If you cached the old id anywhere, replace it with the new one.) Fields carried over to the new draft: subject, body, recipients (${persisted.recipients.length}), replyToId (${persisted.replyToId === null ? "none" : persisted.replyToId}), attachments (${myFileIDs.length}).${warnings.length > 0 ? " See warnings above for any field OurFamilyWizard did not carry over." : ""}`;
41940
+ replaceNote = `NOTE: ofw_save_draft replaced draft ${args.messageId} via create-then-delete. The new draft id is ${newId}; the old draft has been deleted. The draftKey is UNCHANGED \u2014 key off it rather than the volatile id, which changes on every edit. (OFW's update-in-place endpoint silently no-ops on subsequent updates, so we never use it.) Fields carried over to the new draft: subject, body, recipients (${persisted.recipients.length}), replyToId (${persisted.replyToId === null ? "none" : persisted.replyToId}), attachments (${myFileIDs.length}).${warnings.length > 0 ? " See warnings above for any field OurFamilyWizard did not carry over." : ""}`;
41604
41941
  } catch (e) {
41605
41942
  replaceNote = `WARNING: New draft ${newId} was created successfully, but the old draft ${args.messageId} could NOT be deleted: ${e.message}. BOTH drafts now exist on OurFamilyWizard and nothing was lost. Verify ${newId} reads correctly, then remove ${args.messageId} with ofw_delete_draft.`;
41606
41943
  }
41607
41944
  }
41608
41945
  }
41609
41946
  const responseObj = persisted !== null ? {
41947
+ draftKey,
41948
+ revision: newRevision,
41610
41949
  ...persisted,
41611
41950
  inReplyTo: persisted.replyToId,
41612
- revision: newRevision,
41613
- // The id above is volatile — it changes on every edit. `draftKey` is
41614
- // not: pass it to ofw_status to resolve the chain's CURRENT id, or to
41615
- // find out that the draft was sent and when.
41616
- draftKey,
41617
41951
  previousId: args.messageId ?? null,
41618
41952
  cacheStatus: "fresh",
41619
41953
  serverConfirmed: true,
41620
- ...warnings.length > 0 ? { warnings } : {}
41954
+ ...warnings.length > 0 ? { warnings } : {},
41955
+ ...recipientsNote !== null ? { recipientsNote } : {}
41621
41956
  } : raw;
41622
41957
  const text = responseObj ? JSON.stringify(responseObj, null, 2) : "Draft saved.";
41623
41958
  const warnNote = warnings.length > 0 ? `WARNING: ${warnings.join("\n\n")}` : null;
41624
- const notes = [forceNote, rewriteNote, verifyNote, warnNote, replaceNote].filter((n) => n !== null).join("\n\n");
41959
+ const notes = [forceNote, rewriteNote, verifyNote, warnNote, recipientsNote, replaceNote].filter((n) => n !== null).join("\n\n");
41625
41960
  return textResponse(notes ? `${notes}
41626
41961
 
41627
41962
  ${text}` : text);
@@ -42911,7 +43246,7 @@ var nodeCacheProvider = () => nodeCache ??= OFWCache.open(getCacheDbPath());
42911
43246
  var nodeAttachmentIO = new NodeAttachmentIO();
42912
43247
  await runMcp({
42913
43248
  name: "ofw",
42914
- version: "2.9.1",
43249
+ version: "2.10.0",
42915
43250
  // x-release-please-version
42916
43251
  deps: client,
42917
43252
  tools: [