ofw-mcp 2.3.1 → 2.4.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
@@ -34681,6 +34681,22 @@ async function runMcp(opts) {
34681
34681
  return server;
34682
34682
  }
34683
34683
 
34684
+ // node_modules/@chrischall/mcp-utils/dist/errors/index.js
34685
+ var API_KEY_RE = new RegExp([
34686
+ "sk-[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_-])",
34687
+ // OpenAI / Anthropic (incl. sk-ant-…)
34688
+ "gh[pousr]_[A-Za-z0-9]{36,}\\b",
34689
+ // GitHub ghp_/gho_/ghu_/ghs_/ghr_
34690
+ "xox[baprs]-[A-Za-z0-9-]{10,}(?![A-Za-z0-9-])",
34691
+ // Slack
34692
+ "AIza[0-9A-Za-z_-]{35}(?![0-9A-Za-z_-])",
34693
+ // Google API key (39 chars total)
34694
+ "AKIA[0-9A-Z]{16}\\b",
34695
+ // AWS access key id (20 chars total)
34696
+ "whsec_[A-Za-z0-9]{16,}\\b"
34697
+ // webhook signing secret (Stripe-style)
34698
+ ].map((p) => `\\b${p}`).join("|"), "g");
34699
+
34684
34700
  // node_modules/@chrischall/mcp-utils/dist/response/index.js
34685
34701
  function textResult(data) {
34686
34702
  return {
@@ -34767,6 +34783,8 @@ var NonNegInt = external_exports.number().int().nonnegative();
34767
34783
  var NonEmptyString = external_exports.string().min(1);
34768
34784
  var IsoDate = external_exports.iso.date();
34769
34785
  var IsoTime = external_exports.string().regex(/^([01]?\d|2[0-3]):[0-5]\d$/, "must be HH:MM (24h), e.g. 19:30");
34786
+ var NumericIdString = external_exports.string().regex(/^\d+$/, "must be a numeric id (digits only)").describe("A numeric id string (digits only), safe to interpolate into a URL path.");
34787
+ var SafePathSegment = external_exports.string().min(1).regex(/^[^/?#\s]+$/, 'must not contain "/", "?", "#", or whitespace').refine((v) => !v.includes(".."), 'must not contain ".."').describe('A single URL path segment, safe to interpolate: no "/", "..", "?", "#", or whitespace.');
34770
34788
  var schemaOrigin = external_exports.string().optional().describe("Portal origin (e.g. https://<vendor>.example.co) selecting which active session to use. Optional when only one session is active.");
34771
34789
  var schemaConfirm = external_exports.boolean().optional().describe("Must be true to proceed. Without this, the tool returns a preview.");
34772
34790
  var paginationSchema = {
@@ -34778,6 +34796,83 @@ var pageSchema = {
34778
34796
  page_size: external_exports.number().int().min(1).max(200).default(50).describe("Number of items per page (1-200).")
34779
34797
  };
34780
34798
 
34799
+ // node_modules/@chrischall/mcp-utils/dist/session/index.js
34800
+ var TOKEN_REFRESH_SKEW_MS = 5 * 60 * 1e3;
34801
+ var TokenManager = class {
34802
+ accessToken;
34803
+ refreshToken;
34804
+ expiresAt;
34805
+ refreshFn;
34806
+ skewMs;
34807
+ inFlight;
34808
+ constructor(opts) {
34809
+ this.accessToken = opts.initial.accessToken;
34810
+ this.refreshToken = opts.initial.refreshToken;
34811
+ this.expiresAt = opts.initial.expiresAt;
34812
+ this.refreshFn = opts.refresh;
34813
+ this.skewMs = opts.skewMs ?? TOKEN_REFRESH_SKEW_MS;
34814
+ }
34815
+ /** Whether the token is within the skew window of (or past) expiry. */
34816
+ needsRefresh() {
34817
+ return Date.now() >= this.expiresAt - this.skewMs;
34818
+ }
34819
+ /**
34820
+ * Single-flight refresh. Concurrent callers share one in-flight promise; it is
34821
+ * cleared on settle (success or failure) so a subsequent refresh can proceed.
34822
+ */
34823
+ refreshNow() {
34824
+ if (!this.inFlight) {
34825
+ const rt = this.refreshToken;
34826
+ if (rt === void 0) {
34827
+ return Promise.reject(new Error("TokenManager: cannot refresh \u2014 no refresh token is available."));
34828
+ }
34829
+ this.inFlight = (async () => {
34830
+ const tok = await this.refreshFn(rt);
34831
+ this.accessToken = tok.accessToken;
34832
+ if (tok.refreshToken !== void 0 && tok.refreshToken !== "") {
34833
+ this.refreshToken = tok.refreshToken;
34834
+ }
34835
+ this.expiresAt = tok.expiresAt;
34836
+ })().finally(() => {
34837
+ this.inFlight = void 0;
34838
+ });
34839
+ }
34840
+ return this.inFlight;
34841
+ }
34842
+ /** Get a valid access token, refreshing proactively inside the skew window. */
34843
+ async getAccessToken() {
34844
+ if (this.needsRefresh())
34845
+ await this.refreshNow();
34846
+ return this.accessToken;
34847
+ }
34848
+ /** Current absolute expiry (epoch ms). */
34849
+ getExpiresAt() {
34850
+ return this.expiresAt;
34851
+ }
34852
+ /**
34853
+ * Run an authenticated request with reactive 401-replay. `call` receives a
34854
+ * valid access token and returns a `Response`. On `401`, the token is
34855
+ * refreshed once and `call` is invoked again exactly once.
34856
+ *
34857
+ * Guarded against double-refresh: if a concurrent caller already rotated the
34858
+ * token while this request was in flight (single-flight settled and cleared),
34859
+ * a late `401` from a request sent under the OLD token does NOT trigger a
34860
+ * second refresh — under refresh-token rotation that would consume and
34861
+ * invalidate the freshly-issued refresh token. It just replays with the
34862
+ * current token.
34863
+ */
34864
+ async withAuth(call) {
34865
+ const usedToken = await this.getAccessToken();
34866
+ let res = await call(usedToken);
34867
+ if (res.status === 401) {
34868
+ if (this.accessToken === usedToken)
34869
+ await this.refreshNow();
34870
+ res = await call(this.accessToken);
34871
+ }
34872
+ return res;
34873
+ }
34874
+ };
34875
+
34781
34876
  // src/client.ts
34782
34877
  import { dirname, join as join4 } from "path";
34783
34878
  import { fileURLToPath } from "url";
@@ -34791,7 +34886,9 @@ var KNOWN_CAPABILITIES = /* @__PURE__ */ new Set([
34791
34886
  "read_local_storage",
34792
34887
  "read_session_storage",
34793
34888
  "capture_request_header",
34794
- "read_indexed_db"
34889
+ "capture_redirect",
34890
+ "read_indexed_db",
34891
+ "download"
34795
34892
  ]);
34796
34893
 
34797
34894
  // node_modules/@fetchproxy/protocol/dist/mcp-id.js
@@ -34938,6 +35035,16 @@ function assertScopeKeyArray(value, label) {
34938
35035
  seen.add(k);
34939
35036
  }
34940
35037
  }
35038
+ var CAPTURE_PATH_RE = /^\/[A-Za-z0-9._~%\-/]*\*?$/;
35039
+ function hostMatchesAnyDomain(host, domains) {
35040
+ const h = host.toLowerCase();
35041
+ for (const d of domains) {
35042
+ const dom = d.toLowerCase();
35043
+ if (h === dom || h.endsWith("." + dom))
35044
+ return true;
35045
+ }
35046
+ return false;
35047
+ }
34941
35048
  function assertCaptureHeadersArray(value, label) {
34942
35049
  if (!Array.isArray(value)) {
34943
35050
  throw new ProtocolError(`${label}: expected array, got ${typeof value}`);
@@ -34946,14 +35053,25 @@ function assertCaptureHeadersArray(value, label) {
34946
35053
  for (let i = 0; i < value.length; i++) {
34947
35054
  const entry = value[i];
34948
35055
  assertObject(entry, `${label}[${i}]`);
34949
- if (entry.urlPattern === void 0) {
34950
- throw new ProtocolError(`${label}[${i}].urlPattern: missing`);
35056
+ if (entry.host === void 0) {
35057
+ throw new ProtocolError(`${label}[${i}].host: missing`);
34951
35058
  }
34952
35059
  if (entry.headerName === void 0) {
34953
35060
  throw new ProtocolError(`${label}[${i}].headerName: missing`);
34954
35061
  }
34955
- if (typeof entry.urlPattern !== "string") {
34956
- throw new ProtocolError(`${label}[${i}].urlPattern: expected string, got ${typeof entry.urlPattern}`);
35062
+ if (typeof entry.host !== "string") {
35063
+ throw new ProtocolError(`${label}[${i}].host: expected string, got ${typeof entry.host}`);
35064
+ }
35065
+ if (!HOSTNAME_RE.test(entry.host)) {
35066
+ throw new ProtocolError(`${label}[${i}].host: invalid hostname ${JSON.stringify(entry.host)}`);
35067
+ }
35068
+ if (entry.path !== void 0) {
35069
+ if (typeof entry.path !== "string") {
35070
+ throw new ProtocolError(`${label}[${i}].path: expected string, got ${typeof entry.path}`);
35071
+ }
35072
+ if (!CAPTURE_PATH_RE.test(entry.path)) {
35073
+ throw new ProtocolError(`${label}[${i}].path: must start with '/' ${JSON.stringify(entry.path)}`);
35074
+ }
34957
35075
  }
34958
35076
  if (typeof entry.headerName !== "string") {
34959
35077
  throw new ProtocolError(`${label}[${i}].headerName: expected string, got ${typeof entry.headerName}`);
@@ -34961,19 +35079,29 @@ function assertCaptureHeadersArray(value, label) {
34961
35079
  if (!HEADER_NAME_RE.test(entry.headerName)) {
34962
35080
  throw new ProtocolError(`${label}[${i}].headerName: invalid name ${JSON.stringify(entry.headerName)}`);
34963
35081
  }
34964
- assertCaptureUrlPattern(entry.urlPattern, `${label}[${i}].urlPattern`);
34965
- const key = `${entry.urlPattern}\0${entry.headerName}`;
35082
+ const normalizedPath = entry.path ?? "/*";
35083
+ const key = `${entry.host}\0${normalizedPath}\0${entry.headerName}`;
34966
35084
  if (seen.has(key)) {
34967
- throw new ProtocolError(`${label}: duplicate ${JSON.stringify({ urlPattern: entry.urlPattern, headerName: entry.headerName })}`);
35085
+ throw new ProtocolError(`${label}: duplicate ${JSON.stringify({ host: entry.host, path: normalizedPath, headerName: entry.headerName })}`);
34968
35086
  }
34969
35087
  seen.add(key);
34970
35088
  for (const k of Object.keys(entry)) {
34971
- if (k !== "urlPattern" && k !== "headerName") {
35089
+ if (k !== "host" && k !== "path" && k !== "headerName") {
34972
35090
  throw new ProtocolError(`${label}[${i}]: unexpected field ${JSON.stringify(k)}`);
34973
35091
  }
34974
35092
  }
34975
35093
  }
34976
35094
  }
35095
+ function validateCaptureHeaderDecls(value, domains, label = "captureHeaders") {
35096
+ assertCaptureHeadersArray(value, label);
35097
+ const arr = value;
35098
+ for (let i = 0; i < arr.length; i++) {
35099
+ const host = arr[i].host;
35100
+ if (!hostMatchesAnyDomain(host, domains)) {
35101
+ throw new ProtocolError(`${label}[${i}].host: ${JSON.stringify(host)} is not a declared domain or subdomain of [${domains.join(", ")}]`);
35102
+ }
35103
+ }
35104
+ }
34977
35105
  function assertStoragePointersArray(value, label, declaredKeys) {
34978
35106
  if (!Array.isArray(value)) {
34979
35107
  throw new ProtocolError(`${label}: expected array, got ${typeof value}`);
@@ -35061,23 +35189,6 @@ function assertIndexedDbScopesArray(value, label) {
35061
35189
  }
35062
35190
  }
35063
35191
  }
35064
- function assertCaptureUrlPattern(pattern, label) {
35065
- if (!pattern.startsWith("https://")) {
35066
- throw new ProtocolError(`${label}: must start with https:// (got ${JSON.stringify(pattern)})`);
35067
- }
35068
- const afterScheme = pattern.slice("https://".length);
35069
- const slash = afterScheme.indexOf("/");
35070
- const host = slash === -1 ? afterScheme : afterScheme.slice(0, slash);
35071
- if (host.length === 0) {
35072
- throw new ProtocolError(`${label}: missing host (got ${JSON.stringify(pattern)})`);
35073
- }
35074
- if (host.includes("*")) {
35075
- throw new ProtocolError(`${label}: wildcards not permitted in host (got ${JSON.stringify(pattern)})`);
35076
- }
35077
- if (!HOSTNAME_RE.test(host)) {
35078
- throw new ProtocolError(`${label}: invalid host ${JSON.stringify(host)} in ${JSON.stringify(pattern)}`);
35079
- }
35080
- }
35081
35192
  function validateFrame(raw) {
35082
35193
  assertObject(raw, "frame");
35083
35194
  const t = raw.type;
@@ -35142,7 +35253,7 @@ function validateHello(raw) {
35142
35253
  assertScopeKeyArray(raw.sessionStorageKeys, "hello.sessionStorageKeys");
35143
35254
  }
35144
35255
  if (raw.captureHeaders !== void 0) {
35145
- assertCaptureHeadersArray(raw.captureHeaders, "hello.captureHeaders");
35256
+ validateCaptureHeaderDecls(raw.captureHeaders, raw.domains, "hello.captureHeaders");
35146
35257
  }
35147
35258
  if (raw.indexedDbScopes !== void 0) {
35148
35259
  assertIndexedDbScopesArray(raw.indexedDbScopes, "hello.indexedDbScopes");
@@ -35310,24 +35421,58 @@ function validateInnerRequest(raw) {
35310
35421
  }
35311
35422
  if (raw.op === "capture_request_header") {
35312
35423
  assertObject(raw.init, "inner.init");
35313
- if (raw.init.urlPattern === void 0) {
35314
- throw new ProtocolError("inner.init.urlPattern: missing");
35424
+ if (raw.init.host === void 0) {
35425
+ throw new ProtocolError("inner.init.host: missing");
35315
35426
  }
35316
35427
  if (raw.init.headerName === void 0) {
35317
35428
  throw new ProtocolError("inner.init.headerName: missing");
35318
35429
  }
35319
- assertString(raw.init.urlPattern, "inner.init.urlPattern");
35430
+ assertString(raw.init.host, "inner.init.host");
35431
+ if (!HOSTNAME_RE.test(raw.init.host)) {
35432
+ throw new ProtocolError(`inner.init.host: invalid hostname ${JSON.stringify(raw.init.host)}`);
35433
+ }
35434
+ if (raw.init.path !== void 0) {
35435
+ assertString(raw.init.path, "inner.init.path");
35436
+ if (!CAPTURE_PATH_RE.test(raw.init.path)) {
35437
+ throw new ProtocolError(`inner.init.path: must start with '/' ${JSON.stringify(raw.init.path)}`);
35438
+ }
35439
+ }
35320
35440
  assertString(raw.init.headerName, "inner.init.headerName");
35321
35441
  if (raw.init.timeoutMs !== void 0) {
35322
35442
  assertPositiveInt(raw.init.timeoutMs, "inner.init.timeoutMs");
35323
35443
  }
35324
35444
  for (const k of Object.keys(raw.init)) {
35325
- if (k !== "urlPattern" && k !== "headerName" && k !== "timeoutMs") {
35445
+ if (k !== "host" && k !== "path" && k !== "headerName" && k !== "timeoutMs") {
35326
35446
  throw new ProtocolError(`inner.init: unexpected field ${JSON.stringify(k)} on capture_request_header`);
35327
35447
  }
35328
35448
  }
35329
35449
  return raw;
35330
35450
  }
35451
+ if (raw.op === "capture_redirect") {
35452
+ assertObject(raw.init, "inner.init");
35453
+ if (raw.init.host === void 0) {
35454
+ throw new ProtocolError("inner.init.host: missing");
35455
+ }
35456
+ assertString(raw.init.host, "inner.init.host");
35457
+ if (!HOSTNAME_RE.test(raw.init.host)) {
35458
+ throw new ProtocolError(`inner.init.host: invalid hostname ${JSON.stringify(raw.init.host)}`);
35459
+ }
35460
+ if (raw.init.path !== void 0) {
35461
+ assertString(raw.init.path, "inner.init.path");
35462
+ if (!CAPTURE_PATH_RE.test(raw.init.path)) {
35463
+ throw new ProtocolError(`inner.init.path: must start with '/' ${JSON.stringify(raw.init.path)}`);
35464
+ }
35465
+ }
35466
+ if (raw.init.timeoutMs !== void 0) {
35467
+ assertPositiveInt(raw.init.timeoutMs, "inner.init.timeoutMs");
35468
+ }
35469
+ for (const k of Object.keys(raw.init)) {
35470
+ if (k !== "host" && k !== "path" && k !== "timeoutMs") {
35471
+ throw new ProtocolError(`inner.init: unexpected field ${JSON.stringify(k)} on capture_redirect`);
35472
+ }
35473
+ }
35474
+ return raw;
35475
+ }
35331
35476
  if (raw.op === "read_indexed_db") {
35332
35477
  assertObject(raw.init, "inner.init");
35333
35478
  if (raw.init.origin === void 0)
@@ -35353,7 +35498,32 @@ function validateInnerRequest(raw) {
35353
35498
  }
35354
35499
  return raw;
35355
35500
  }
35356
- throw new ProtocolError(`inner.op: must be one of "fetch", "read_cookies", "read_local_storage", "read_session_storage", "capture_request_header", "read_indexed_db"; got ${JSON.stringify(raw.op)}`);
35501
+ if (raw.op === "download") {
35502
+ assertObject(raw.init, "inner.init");
35503
+ if (raw.init.url === void 0) {
35504
+ throw new ProtocolError("inner.init.url: missing");
35505
+ }
35506
+ assertHttpUrl(raw.init.url, "inner.init.url");
35507
+ if (raw.init.filename !== void 0) {
35508
+ assertString(raw.init.filename, "inner.init.filename");
35509
+ if (/^([/\\]|[A-Za-z]:)/.test(raw.init.filename)) {
35510
+ throw new ProtocolError(`inner.init.filename: must be relative ${JSON.stringify(raw.init.filename)}`);
35511
+ }
35512
+ if (raw.init.filename.split(/[/\\]/).includes("..")) {
35513
+ throw new ProtocolError(`inner.init.filename: must not contain '..' ${JSON.stringify(raw.init.filename)}`);
35514
+ }
35515
+ }
35516
+ if (raw.init.timeoutMs !== void 0) {
35517
+ assertPositiveInt(raw.init.timeoutMs, "inner.init.timeoutMs");
35518
+ }
35519
+ for (const k of Object.keys(raw.init)) {
35520
+ if (k !== "url" && k !== "filename" && k !== "timeoutMs") {
35521
+ throw new ProtocolError(`inner.init: unexpected field ${JSON.stringify(k)} on download`);
35522
+ }
35523
+ }
35524
+ return raw;
35525
+ }
35526
+ 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", "download"; got ${JSON.stringify(raw.op)}`);
35357
35527
  }
35358
35528
  function assertNonEmptyKeyArray(value, label) {
35359
35529
  if (!Array.isArray(value)) {
@@ -35424,6 +35594,13 @@ function validateInnerResponse(raw) {
35424
35594
  assertString(raw.value, "inner.value");
35425
35595
  return raw;
35426
35596
  }
35597
+ if (op === "capture_redirect") {
35598
+ if (raw.value === void 0) {
35599
+ throw new ProtocolError("inner.value: missing on capture_redirect response");
35600
+ }
35601
+ assertString(raw.value, "inner.value");
35602
+ return raw;
35603
+ }
35427
35604
  if (op === "read_indexed_db") {
35428
35605
  if (raw.values === void 0) {
35429
35606
  throw new ProtocolError("inner.values: missing on read_indexed_db response");
@@ -35431,6 +35608,25 @@ function validateInnerResponse(raw) {
35431
35608
  assertObject(raw.values, "inner.values");
35432
35609
  return raw;
35433
35610
  }
35611
+ if (op === "download") {
35612
+ assertObject(raw.value, "inner.value");
35613
+ assertString(raw.value.path, "inner.value.path");
35614
+ if (typeof raw.value.bytes !== "number" || !Number.isInteger(raw.value.bytes) || raw.value.bytes < 0) {
35615
+ throw new ProtocolError("inner.value.bytes: expected non-negative integer");
35616
+ }
35617
+ if (raw.value.mime !== void 0) {
35618
+ assertString(raw.value.mime, "inner.value.mime");
35619
+ }
35620
+ if (raw.value.finalUrl !== void 0) {
35621
+ assertString(raw.value.finalUrl, "inner.value.finalUrl");
35622
+ }
35623
+ for (const k of Object.keys(raw.value)) {
35624
+ if (k !== "path" && k !== "bytes" && k !== "mime" && k !== "finalUrl") {
35625
+ throw new ProtocolError(`inner.value: unexpected field ${JSON.stringify(k)} on download response`);
35626
+ }
35627
+ }
35628
+ return raw;
35629
+ }
35434
35630
  throw new ProtocolError(`inner.op: unknown success-response op ${JSON.stringify(raw.op)}`);
35435
35631
  }
35436
35632
  if (raw.ok === false) {
@@ -35702,7 +35898,8 @@ async function buildServerHello(opts) {
35702
35898
  }
35703
35899
  if (opts.captureHeaders && opts.captureHeaders.length > 0) {
35704
35900
  hello.captureHeaders = opts.captureHeaders.map((d) => ({
35705
- urlPattern: d.urlPattern,
35901
+ host: d.host,
35902
+ ...d.path !== void 0 ? { path: d.path } : {},
35706
35903
  headerName: d.headerName
35707
35904
  }));
35708
35905
  }
@@ -35940,7 +36137,9 @@ async function startHost(opts) {
35940
36137
  } catch {
35941
36138
  }
35942
36139
  }
35943
- wss.close(() => resolve2());
36140
+ wss.close(() => {
36141
+ opts.httpServer.close(() => resolve2());
36142
+ });
35944
36143
  }),
35945
36144
  sendOwnInner: async (inner) => {
35946
36145
  const session = await ownSessionReady;
@@ -35990,6 +36189,7 @@ async function startPeer(opts) {
35990
36189
  const innerListeners = [];
35991
36190
  const renegotiateListeners = [];
35992
36191
  const pendingPairListeners = [];
36192
+ const closeListeners = [];
35993
36193
  let session = null;
35994
36194
  let pendingPairCode = null;
35995
36195
  let resolveFirstReady;
@@ -36039,6 +36239,7 @@ async function startPeer(opts) {
36039
36239
  ws.on("message", onMessage);
36040
36240
  ws.once("close", () => {
36041
36241
  rejectFirstReady(new Error("peer WS closed before ready"));
36242
+ closeListeners.forEach((cb) => cb());
36042
36243
  });
36043
36244
  sessionPromise.catch(() => {
36044
36245
  });
@@ -36061,6 +36262,9 @@ async function startPeer(opts) {
36061
36262
  pendingPairListeners.push(cb);
36062
36263
  },
36063
36264
  pendingPairCode: () => pendingPairCode,
36265
+ onClose: (cb) => {
36266
+ closeListeners.push(cb);
36267
+ },
36064
36268
  close: () => ws.close()
36065
36269
  };
36066
36270
  return handle;
@@ -36262,6 +36466,12 @@ var FetchproxyServer = class {
36262
36466
  opts;
36263
36467
  hostHandle = null;
36264
36468
  peerHandle = null;
36469
+ // 0.13.0+: true from the start of `close()` until the next `doConnect()`.
36470
+ // Distinguishes an intentional shutdown (whose WS close we must ignore)
36471
+ // from the host process dying (which strands a peer and must trigger
36472
+ // re-election). The WS `close` event fires asynchronously, so this stays
36473
+ // latched across `close()` rather than being reset in its tail.
36474
+ closing = false;
36265
36475
  nextRequestId = 1;
36266
36476
  // 0.8.0+: process-wide freshness counters surfaced via bridgeHealth().
36267
36477
  // Replaces the local copies every downstream MCP was rolling on top
@@ -36287,8 +36497,12 @@ var FetchproxyServer = class {
36287
36497
  pendingStorage = /* @__PURE__ */ new Map();
36288
36498
  // 0.3.0+: capture-header awaiters resolve a single string.
36289
36499
  pendingCapture = /* @__PURE__ */ new Map();
36500
+ // capture_redirect awaiters resolve the captured redirect URL string.
36501
+ pendingRedirect = /* @__PURE__ */ new Map();
36290
36502
  // 0.4.0+: read_indexed_db awaiters resolve a JSON-typed values map.
36291
36503
  pendingIdb = /* @__PURE__ */ new Map();
36504
+ // download awaiters resolve the saved-file metadata (path + size + mime).
36505
+ pendingDownload = /* @__PURE__ */ new Map();
36292
36506
  mcpId = null;
36293
36507
  identity = null;
36294
36508
  // 0.5.3+: in-flight role-election / handle-start promise. Set the
@@ -36334,6 +36548,14 @@ var FetchproxyServer = class {
36334
36548
  }
36335
36549
  capabilities = [...opts.capabilities];
36336
36550
  }
36551
+ if (opts.captureHeaders !== void 0) {
36552
+ try {
36553
+ validateCaptureHeaderDecls(opts.captureHeaders, opts.domains);
36554
+ } catch (err) {
36555
+ const message = err instanceof Error ? err.message : String(err);
36556
+ throw new Error("FetchproxyServer: invalid captureHeaders \u2014 " + message);
36557
+ }
36558
+ }
36337
36559
  this.opts = {
36338
36560
  port: opts.port ?? 37149,
36339
36561
  host: opts.host ?? "127.0.0.1",
@@ -36345,7 +36567,8 @@ var FetchproxyServer = class {
36345
36567
  localStorageKeys: [...opts.localStorageKeys ?? []],
36346
36568
  sessionStorageKeys: [...opts.sessionStorageKeys ?? []],
36347
36569
  captureHeaders: (opts.captureHeaders ?? []).map((d) => ({
36348
- urlPattern: d.urlPattern,
36570
+ host: d.host,
36571
+ ...d.path !== void 0 ? { path: d.path } : {},
36349
36572
  headerName: d.headerName
36350
36573
  })),
36351
36574
  indexedDbScopes: (opts.indexedDbScopes ?? []).map((d) => ({
@@ -36463,6 +36686,7 @@ var FetchproxyServer = class {
36463
36686
  async doConnect() {
36464
36687
  const identity = this.identity;
36465
36688
  const mcpId = this.mcpId;
36689
+ this.closing = false;
36466
36690
  const el = await electRole({ host: this.opts.host, port: this.opts.port });
36467
36691
  if (el.role === "host") {
36468
36692
  this.role = "host";
@@ -36522,6 +36746,14 @@ var FetchproxyServer = class {
36522
36746
  const cb = this.opts.onPairCode;
36523
36747
  this.peerHandle.onPendingPair((code) => cb(code));
36524
36748
  }
36749
+ this.peerHandle.onClose(() => {
36750
+ if (this.closing || this.peerHandle === null)
36751
+ return;
36752
+ this.stopKeepalive();
36753
+ this.rejectAllPending();
36754
+ this.peerHandle = null;
36755
+ this.role = null;
36756
+ });
36525
36757
  }
36526
36758
  }
36527
36759
  pairingErrorMessage(code) {
@@ -37096,12 +37328,12 @@ var FetchproxyServer = class {
37096
37328
  /**
37097
37329
  * 0.3.0+: snapshot the next outgoing request's named header. Single-
37098
37330
  * shot: the extension registers a one-time `webRequest` listener
37099
- * filtered on `urlPattern`, captures the named header on the first
37100
- * match, removes itself, and resolves with the value. Times out
37101
- * after `timeoutMs` (default 30s on the extension).
37331
+ * filtered on `https://${host}${path ?? '/*'}`, captures the named
37332
+ * header on the first match, removes itself, and resolves with the
37333
+ * value. Times out after `timeoutMs` (default 30s on the extension).
37102
37334
  *
37103
- * `(urlPattern, headerName)` must exactly match a declared entry in
37104
- * `FetchproxyServerOpts.captureHeaders`.
37335
+ * `(host, path?, headerName)` must match a declared entry in
37336
+ * `FetchproxyServerOpts.captureHeaders` (omitted path ≡ `/*`).
37105
37337
  */
37106
37338
  async captureRequestHeader(opts) {
37107
37339
  if (!this.opts.capabilities.includes("capture_request_header")) {
@@ -37110,24 +37342,25 @@ var FetchproxyServer = class {
37110
37342
  await this.ensureConnected();
37111
37343
  this.throwIfPendingPair();
37112
37344
  const decls = this.opts.captureHeaders;
37345
+ const normPath = (p) => p ?? "/*";
37113
37346
  let resolved;
37114
- if (opts?.urlPattern !== void 0 && opts?.headerName !== void 0) {
37115
- const found = decls.find((d) => d.urlPattern === opts.urlPattern && d.headerName === opts.headerName);
37347
+ if (opts?.host !== void 0 && opts?.headerName !== void 0) {
37348
+ const found = decls.find((d) => d.host === opts.host && normPath(d.path) === normPath(opts.path) && d.headerName === opts.headerName);
37116
37349
  if (!found) {
37117
- throw new Error(`FetchproxyServer.captureRequestHeader: (urlPattern=${JSON.stringify(opts.urlPattern)}, headerName=${JSON.stringify(opts.headerName)}) not declared in captureHeaders`);
37350
+ throw new Error(`FetchproxyServer.captureRequestHeader: (host=${JSON.stringify(opts.host)}, path=${JSON.stringify(normPath(opts.path))}, headerName=${JSON.stringify(opts.headerName)}) not declared in captureHeaders`);
37118
37351
  }
37119
37352
  resolved = found;
37120
- } else if (opts?.urlPattern === void 0 && opts?.headerName === void 0) {
37353
+ } else if (opts?.host === void 0 && opts?.headerName === void 0) {
37121
37354
  if (decls.length === 0) {
37122
- throw new Error("FetchproxyServer.captureRequestHeader: no captureHeaders declared on this server \u2014 declare at least one entry in FetchproxyServerOpts.captureHeaders, or pass {urlPattern, headerName} explicitly");
37355
+ throw new Error("FetchproxyServer.captureRequestHeader: no captureHeaders declared on this server \u2014 declare at least one entry in FetchproxyServerOpts.captureHeaders, or pass {host, headerName} explicitly");
37123
37356
  }
37124
37357
  if (decls.length > 1) {
37125
- const list = decls.map((d) => `${JSON.stringify(d.urlPattern)}/${JSON.stringify(d.headerName)}`).join(", ");
37126
- throw new Error(`FetchproxyServer.captureRequestHeader: multiple captureHeaders declared (${decls.length}: ${list}); pass {urlPattern, headerName} to disambiguate`);
37358
+ const list = decls.map((d) => `${JSON.stringify(d.host)}${JSON.stringify(normPath(d.path))}/${JSON.stringify(d.headerName)}`).join(", ");
37359
+ throw new Error(`FetchproxyServer.captureRequestHeader: multiple captureHeaders declared (${decls.length}: ${list}); pass {host, headerName} to disambiguate`);
37127
37360
  }
37128
37361
  resolved = decls[0];
37129
37362
  } else {
37130
- throw new Error("FetchproxyServer.captureRequestHeader: pass both urlPattern AND headerName, or neither (which defaults to the single declared entry)");
37363
+ throw new Error("FetchproxyServer.captureRequestHeader: pass both host AND headerName, or neither (which defaults to the single declared entry)");
37131
37364
  }
37132
37365
  const callOpts = { ...resolved, ...opts?.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {} };
37133
37366
  try {
@@ -37161,7 +37394,7 @@ var FetchproxyServer = class {
37161
37394
  originalError: retryErr.message,
37162
37395
  retryAttempted: true,
37163
37396
  op: "capture_request_header",
37164
- url: resolved.urlPattern,
37397
+ url: `https://${resolved.host}${resolved.path ?? "/*"}`,
37165
37398
  role: this.role,
37166
37399
  port: this.opts.port
37167
37400
  });
@@ -37172,7 +37405,7 @@ var FetchproxyServer = class {
37172
37405
  originalError: err.message,
37173
37406
  retryAttempted: false,
37174
37407
  op: "capture_request_header",
37175
- url: resolved.urlPattern,
37408
+ url: `https://${resolved.host}${resolved.path ?? "/*"}`,
37176
37409
  role: this.role,
37177
37410
  port: this.opts.port
37178
37411
  });
@@ -37185,7 +37418,8 @@ var FetchproxyServer = class {
37185
37418
  id,
37186
37419
  op: "capture_request_header",
37187
37420
  init: {
37188
- urlPattern: opts.urlPattern,
37421
+ host: opts.host,
37422
+ ...opts.path !== void 0 ? { path: opts.path } : {},
37189
37423
  headerName: opts.headerName,
37190
37424
  ...opts.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {}
37191
37425
  }
@@ -37200,6 +37434,181 @@ var FetchproxyServer = class {
37200
37434
  }
37201
37435
  return pending;
37202
37436
  }
37437
+ /**
37438
+ * Snapshot the redirect target URL of the next request the browser
37439
+ * makes to `(host, path?)`. Single-shot: the extension registers a
37440
+ * one-time `chrome.webRequest.onBeforeRedirect` listener filtered on
37441
+ * `https://${host}${path ?? '/*'}`, captures `details.redirectUrl` on
37442
+ * the first match, removes itself, and resolves with the URL. Times out
37443
+ * after `timeoutMs` (default 30s on the extension).
37444
+ *
37445
+ * Use case: a Cloudflare-walled endpoint that 302-redirects cross-origin
37446
+ * to a presigned URL — a page-level fetch sees only an opaque redirect,
37447
+ * but `onBeforeRedirect` exposes the target. Capture is limited to the
37448
+ * MCP's own declared `domains`; no per-entry declared scope is required.
37449
+ */
37450
+ async captureRedirect(opts) {
37451
+ if (!this.opts.capabilities.includes("capture_redirect")) {
37452
+ throw new Error('FetchproxyServer.captureRedirect(): MCP did not declare "capture_redirect" in capabilities');
37453
+ }
37454
+ await this.ensureConnected();
37455
+ this.throwIfPendingPair();
37456
+ try {
37457
+ const result = await this._captureRedirectOnce(opts);
37458
+ this.recordSuccess();
37459
+ return result;
37460
+ } catch (err) {
37461
+ const swDown = err instanceof FetchproxyProtocolError && classifyFetchError(err.message) === "content_script_unreachable";
37462
+ if (!swDown) {
37463
+ this.recordFailure(`capture_redirect: ${err.message ?? String(err)}`);
37464
+ throw err;
37465
+ }
37466
+ this.lastEvictionDetectedAt = Date.now();
37467
+ const reviveMs = this.opts.bridgeReviveDelayMs ?? 0;
37468
+ if (reviveMs > 0) {
37469
+ this.lazyReviveAttempts += 1;
37470
+ await new Promise((r) => setTimeout(r, reviveMs));
37471
+ try {
37472
+ const result = await this._captureRedirectOnce(opts);
37473
+ this.lazyReviveSuccesses += 1;
37474
+ this.recordSuccess();
37475
+ return result;
37476
+ } catch (retryErr) {
37477
+ const stillDown = retryErr instanceof FetchproxyProtocolError && classifyFetchError(retryErr.message) === "content_script_unreachable";
37478
+ if (!stillDown) {
37479
+ this.recordFailure(`capture_redirect: ${retryErr.message ?? String(retryErr)}`);
37480
+ throw retryErr;
37481
+ }
37482
+ this.recordFailure(`capture_redirect bridge-down: ${retryErr.message}`);
37483
+ throw new FetchproxyBridgeDownError({
37484
+ originalError: retryErr.message,
37485
+ retryAttempted: true,
37486
+ op: "capture_redirect",
37487
+ url: `https://${opts.host}${opts.path ?? "/*"}`,
37488
+ role: this.role,
37489
+ port: this.opts.port
37490
+ });
37491
+ }
37492
+ }
37493
+ this.recordFailure(`capture_redirect bridge-down: ${err.message}`);
37494
+ throw new FetchproxyBridgeDownError({
37495
+ originalError: err.message,
37496
+ retryAttempted: false,
37497
+ op: "capture_redirect",
37498
+ url: `https://${opts.host}${opts.path ?? "/*"}`,
37499
+ role: this.role,
37500
+ port: this.opts.port
37501
+ });
37502
+ }
37503
+ }
37504
+ async _captureRedirectOnce(opts) {
37505
+ const id = this.nextRequestId++;
37506
+ const inner = {
37507
+ type: "request",
37508
+ id,
37509
+ op: "capture_redirect",
37510
+ init: {
37511
+ host: opts.host,
37512
+ ...opts.path !== void 0 ? { path: opts.path } : {},
37513
+ ...opts.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {}
37514
+ }
37515
+ };
37516
+ const pending = new Promise((resolve2, reject) => {
37517
+ this.pendingRedirect.set(id, { resolve: resolve2, reject });
37518
+ });
37519
+ if (this.hostHandle) {
37520
+ await this.hostHandle.sendOwnInner(inner);
37521
+ } else if (this.peerHandle) {
37522
+ await this.peerHandle.sendInner(inner);
37523
+ }
37524
+ return pending;
37525
+ }
37526
+ /**
37527
+ * Download `url` through the BROWSER's own network stack via
37528
+ * `chrome.downloads` (real cookies + TLS/JA3 fingerprint). Unlike a
37529
+ * page-level `fetch()` (cors mode), this clears a Cloudflare bot-challenge
37530
+ * on the endpoint and follows the cross-origin redirect to the final file.
37531
+ * Resolves the saved local file path + size; the bridge is loopback-only /
37532
+ * single-host, so the MCP reads the bytes from the same disk. Requires
37533
+ * `'download'` in capabilities and the `url` host to be a declared `domain`.
37534
+ */
37535
+ async download(opts) {
37536
+ if (!this.opts.capabilities.includes("download")) {
37537
+ throw new Error('FetchproxyServer.download(): MCP did not declare "download" in capabilities');
37538
+ }
37539
+ assertUrlInDomains("download url", opts.url, this.opts.domains);
37540
+ await this.ensureConnected();
37541
+ this.throwIfPendingPair();
37542
+ try {
37543
+ const result = await this._downloadOnce(opts);
37544
+ this.recordSuccess();
37545
+ return result;
37546
+ } catch (err) {
37547
+ const swDown = err instanceof FetchproxyProtocolError && classifyFetchError(err.message) === "content_script_unreachable";
37548
+ if (!swDown) {
37549
+ this.recordFailure(`download: ${err.message ?? String(err)}`);
37550
+ throw err;
37551
+ }
37552
+ this.lastEvictionDetectedAt = Date.now();
37553
+ const reviveMs = this.opts.bridgeReviveDelayMs ?? 0;
37554
+ if (reviveMs > 0) {
37555
+ this.lazyReviveAttempts += 1;
37556
+ await new Promise((r) => setTimeout(r, reviveMs));
37557
+ try {
37558
+ const result = await this._downloadOnce(opts);
37559
+ this.lazyReviveSuccesses += 1;
37560
+ this.recordSuccess();
37561
+ return result;
37562
+ } catch (retryErr) {
37563
+ const stillDown = retryErr instanceof FetchproxyProtocolError && classifyFetchError(retryErr.message) === "content_script_unreachable";
37564
+ if (!stillDown) {
37565
+ this.recordFailure(`download: ${retryErr.message ?? String(retryErr)}`);
37566
+ throw retryErr;
37567
+ }
37568
+ this.recordFailure(`download bridge-down: ${retryErr.message}`);
37569
+ throw new FetchproxyBridgeDownError({
37570
+ originalError: retryErr.message,
37571
+ retryAttempted: true,
37572
+ op: "download",
37573
+ url: opts.url,
37574
+ role: this.role,
37575
+ port: this.opts.port
37576
+ });
37577
+ }
37578
+ }
37579
+ this.recordFailure(`download bridge-down: ${err.message}`);
37580
+ throw new FetchproxyBridgeDownError({
37581
+ originalError: err.message,
37582
+ retryAttempted: false,
37583
+ op: "download",
37584
+ url: opts.url,
37585
+ role: this.role,
37586
+ port: this.opts.port
37587
+ });
37588
+ }
37589
+ }
37590
+ async _downloadOnce(opts) {
37591
+ const id = this.nextRequestId++;
37592
+ const inner = {
37593
+ type: "request",
37594
+ id,
37595
+ op: "download",
37596
+ init: {
37597
+ url: opts.url,
37598
+ ...opts.filename !== void 0 ? { filename: opts.filename } : {},
37599
+ ...opts.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {}
37600
+ }
37601
+ };
37602
+ const pending = new Promise((resolve2, reject) => {
37603
+ this.pendingDownload.set(id, { resolve: resolve2, reject });
37604
+ });
37605
+ if (this.hostHandle) {
37606
+ await this.hostHandle.sendOwnInner(inner);
37607
+ } else if (this.peerHandle) {
37608
+ await this.peerHandle.sendInner(inner);
37609
+ }
37610
+ return pending;
37611
+ }
37203
37612
  /**
37204
37613
  * 0.4.0+: read declared IndexedDB keys from the user's signed-in
37205
37614
  * tab. Requires `'read_indexed_db'` in capabilities AND the
@@ -37347,6 +37756,20 @@ var FetchproxyServer = class {
37347
37756
  }
37348
37757
  return;
37349
37758
  }
37759
+ const redirectCb = this.pendingRedirect.get(inner.id);
37760
+ if (redirectCb) {
37761
+ this.pendingRedirect.delete(inner.id);
37762
+ if (inner.ok) {
37763
+ if (inner.op === "capture_redirect" && typeof inner.value === "string") {
37764
+ redirectCb.resolve(inner.value);
37765
+ } else {
37766
+ redirectCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on capture_redirect awaiter`));
37767
+ }
37768
+ } else {
37769
+ redirectCb.reject(new FetchproxyProtocolError(inner.error));
37770
+ }
37771
+ return;
37772
+ }
37350
37773
  const idbCb = this.pendingIdb.get(inner.id);
37351
37774
  if (idbCb) {
37352
37775
  this.pendingIdb.delete(inner.id);
@@ -37361,6 +37784,20 @@ var FetchproxyServer = class {
37361
37784
  }
37362
37785
  return;
37363
37786
  }
37787
+ const downloadCb = this.pendingDownload.get(inner.id);
37788
+ if (downloadCb) {
37789
+ this.pendingDownload.delete(inner.id);
37790
+ if (inner.ok) {
37791
+ if (inner.op === "download" && inner.value && typeof inner.value === "object") {
37792
+ downloadCb.resolve({ ...inner.value });
37793
+ } else {
37794
+ downloadCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on download awaiter`));
37795
+ }
37796
+ } else {
37797
+ downloadCb.reject(new FetchproxyProtocolError(inner.error));
37798
+ }
37799
+ return;
37800
+ }
37364
37801
  const cookiesCb = this.pendingReadCookies.get(inner.id);
37365
37802
  if (cookiesCb) {
37366
37803
  this.pendingReadCookies.delete(inner.id);
@@ -37403,9 +37840,15 @@ var FetchproxyServer = class {
37403
37840
  for (const { reject } of this.pendingCapture.values())
37404
37841
  reject(err);
37405
37842
  this.pendingCapture.clear();
37843
+ for (const { reject } of this.pendingRedirect.values())
37844
+ reject(err);
37845
+ this.pendingRedirect.clear();
37406
37846
  for (const { reject } of this.pendingIdb.values())
37407
37847
  reject(err);
37408
37848
  this.pendingIdb.clear();
37849
+ for (const { reject } of this.pendingDownload.values())
37850
+ reject(err);
37851
+ this.pendingDownload.clear();
37409
37852
  }
37410
37853
  /**
37411
37854
  * 0.5.2+: read the current pair-pending pair code from whichever handle
@@ -37440,6 +37883,7 @@ var FetchproxyServer = class {
37440
37883
  * twice in a row.
37441
37884
  */
37442
37885
  async close() {
37886
+ this.closing = true;
37443
37887
  this.stopKeepalive();
37444
37888
  this.rejectAllPending();
37445
37889
  if (this.connectingPromise) {
@@ -37567,15 +38011,11 @@ async function bootstrap(opts) {
37567
38011
  const capturedHeaders = {};
37568
38012
  for (const h of opts.declare.captureHeaders) {
37569
38013
  if (opts.onWaiting) {
37570
- try {
37571
- const url2 = new URL(h.urlPattern.replace(/\*+/g, "placeholder"));
37572
- opts.onWaiting(`waiting for next request to ${url2.host} to capture ${h.headerName} \u2014 interact with the page in your browser`);
37573
- } catch {
37574
- opts.onWaiting(`waiting to capture ${h.headerName} \u2014 interact with the page in your browser`);
37575
- }
38014
+ opts.onWaiting(`waiting for next request to ${h.host} to capture ${h.headerName} \u2014 interact with the page in your browser`);
37576
38015
  }
37577
38016
  capturedHeaders[h.headerName] = await server.captureRequestHeader({
37578
- urlPattern: h.urlPattern,
38017
+ host: h.host,
38018
+ ...h.path !== void 0 ? { path: h.path } : {},
37579
38019
  headerName: h.headerName
37580
38020
  });
37581
38021
  }
@@ -37629,8 +38069,7 @@ async function loginWithPassword(username, password) {
37629
38069
  headers: { ...OFW_PROTOCOL_HEADERS },
37630
38070
  redirect: "manual"
37631
38071
  });
37632
- const setCookie = initResponse.headers.get("set-cookie") ?? "";
37633
- const sessionCookie = setCookie.split(";")[0];
38072
+ const sessionCookie = initResponse.headers.getSetCookie().map((c) => c.split(";")[0]).join("; ");
37634
38073
  const response = await fetch(`${BASE_URL}/ofw/login`, {
37635
38074
  method: "POST",
37636
38075
  headers: {
@@ -37690,6 +38129,16 @@ function getAttachmentsDir() {
37690
38129
  function parseBoolEnv2(name) {
37691
38130
  return parseBoolEnv(name);
37692
38131
  }
38132
+ function getWriteMode() {
38133
+ const raw = process.env.OFW_WRITE_MODE;
38134
+ if (typeof raw !== "string" || raw.trim().length === 0) return "all";
38135
+ const mode = raw.trim().toLowerCase();
38136
+ if (mode === "none" || mode === "drafts" || mode === "all") return mode;
38137
+ console.error(
38138
+ `[ofw-mcp] Unrecognized OFW_WRITE_MODE "${raw.trim()}" \u2014 failing closed to "none" (no write tools registered). Valid values: none, drafts, all.`
38139
+ );
38140
+ return "none";
38141
+ }
37693
38142
  function getDefaultInlineAttachments() {
37694
38143
  return parseBoolEnv2("OFW_INLINE_ATTACHMENTS");
37695
38144
  }
@@ -37697,7 +38146,8 @@ function getDefaultInlineAttachments() {
37697
38146
  // package.json
37698
38147
  var package_default = {
37699
38148
  name: "ofw-mcp",
37700
- version: "2.3.1",
38149
+ version: "2.4.0",
38150
+ license: "MIT",
37701
38151
  mcpName: "io.github.chrischall/ofw-mcp",
37702
38152
  description: "OurFamilyWizard MCP server for Claude \u2014 developed and maintained by AI (Claude Code)",
37703
38153
  author: "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -37706,6 +38156,9 @@ var package_default = {
37706
38156
  url: "git+https://github.com/chrischall/ofw-mcp.git"
37707
38157
  },
37708
38158
  type: "module",
38159
+ engines: {
38160
+ node: ">=22.5.0"
38161
+ },
37709
38162
  bin: {
37710
38163
  "ofw-mcp": "dist/index.js"
37711
38164
  },
@@ -37725,8 +38178,8 @@ var package_default = {
37725
38178
  "test:watch": "vitest"
37726
38179
  },
37727
38180
  dependencies: {
37728
- "@chrischall/mcp-utils": "^0.4.0",
37729
- "@fetchproxy/bootstrap": "^0.11.0",
38181
+ "@chrischall/mcp-utils": "^0.9.0",
38182
+ "@fetchproxy/bootstrap": "^1.3.0",
37730
38183
  "@modelcontextprotocol/sdk": "^1.29.0",
37731
38184
  dotenv: "^17.4.2",
37732
38185
  zod: "^4.4.3"
@@ -37835,12 +38288,39 @@ function getRequestTimeoutMs() {
37835
38288
  const n = Number(raw.trim());
37836
38289
  return Number.isFinite(n) && n > 0 ? n : DEFAULT_REQUEST_TIMEOUT_MS;
37837
38290
  }
38291
+ var OFW_REFRESH_SENTINEL = "ofw";
37838
38292
  var OFWClient = class {
37839
- token = null;
37840
- tokenExpiry = null;
38293
+ // Bearer-token lifecycle is delegated to the shared, race-safe TokenManager
38294
+ // (proactive refresh inside the skew window, single-flight refresh so a burst
38295
+ // of concurrent callers coalesces onto ONE `resolveAuth()`, and a 401-replay
38296
+ // guarded against double-refresh). It is created lazily, seeded with an
38297
+ // already-expired placeholder token so the first request drives the refresh
38298
+ // callback — i.e. the original "log in on first request" behavior.
38299
+ tokenManager;
38300
+ getTokenManager() {
38301
+ if (!this.tokenManager) {
38302
+ this.tokenManager = new TokenManager({
38303
+ initial: { accessToken: "", refreshToken: OFW_REFRESH_SENTINEL, expiresAt: 0 },
38304
+ skewMs: OFW_TOKEN_EXPIRY_SKEW_MS,
38305
+ // Map OFW's mint/refresh onto the refresh callback. `resolveAuth()`
38306
+ // returns a token and a best-effort expiry; when the fetchproxy path
38307
+ // can't supply one we fall back to the same 6h estimate the password
38308
+ // path uses (the 401-replay covers a wrong guess). We re-arm the
38309
+ // sentinel so the manager can refresh again later.
38310
+ refresh: async () => {
38311
+ const { token, expiresAt } = await resolveAuth();
38312
+ return {
38313
+ accessToken: token,
38314
+ refreshToken: OFW_REFRESH_SENTINEL,
38315
+ expiresAt: (expiresAt ?? new Date(Date.now() + OFW_TOKEN_TTL_MS)).getTime()
38316
+ };
38317
+ }
38318
+ });
38319
+ }
38320
+ return this.tokenManager;
38321
+ }
37841
38322
  async request(method, path, body) {
37842
- await this.ensureAuthenticated();
37843
- const response = await this.fetchWithRetry(method, path, body, "application/json", false);
38323
+ const response = await this.fetchAuthed(method, path, body, "application/json");
37844
38324
  const text = await response.text();
37845
38325
  if (debugLogEnabled()) {
37846
38326
  console.error(`[ofw-debug] response body: ${text || "<empty>"}`);
@@ -37849,23 +38329,45 @@ var OFWClient = class {
37849
38329
  }
37850
38330
  /** Like `request`, but returns the raw bytes plus Content-Type/-Disposition metadata. */
37851
38331
  async requestBinary(method, path) {
37852
- await this.ensureAuthenticated();
37853
- const response = await this.fetchWithRetry(method, path, void 0, "application/octet-stream", false);
38332
+ const response = await this.fetchAuthed(method, path, void 0, "application/octet-stream");
37854
38333
  return {
37855
38334
  body: Buffer.from(await response.arrayBuffer()),
37856
38335
  contentType: response.headers.get("content-type"),
37857
38336
  suggestedFileName: parseContentDispositionFilename(response.headers.get("content-disposition") ?? "")
37858
38337
  };
37859
38338
  }
37860
- // Single fetch+retry scaffold for both JSON and binary callers. Handles
37861
- // 401 (re-auth and replay once), 429 (wait 2s and replay once), and
37862
- // turns any other non-2xx into a thrown Error.
37863
- async fetchWithRetry(method, path, body, accept, isRetry) {
38339
+ // Authenticated fetch for both JSON and binary callers. Auth (proactive
38340
+ // refresh inside the skew window + one 401-replay, guarded against a
38341
+ // double-refresh under concurrency) is delegated to the shared TokenManager's
38342
+ // `withAuth`. The 429 wait-and-replay and the non-2xx → throw remain here.
38343
+ async fetchAuthed(method, path, body, accept) {
38344
+ let attempt = 0;
38345
+ let response = await this.getTokenManager().withAuth(
38346
+ (token) => this.fetchOnce(method, path, body, accept, token, attempt++ > 0)
38347
+ );
38348
+ if (response.status === 429) {
38349
+ await new Promise((r) => setTimeout(r, 2e3));
38350
+ response = await this.getTokenManager().withAuth(
38351
+ (token) => this.fetchOnce(method, path, body, accept, token, true)
38352
+ );
38353
+ if (response.status === 429) throw new Error("Rate limited by OFW API");
38354
+ }
38355
+ if (!response.ok) {
38356
+ throw new Error(`OFW API error: ${response.status} ${response.statusText} for ${method} ${path}`);
38357
+ }
38358
+ return response;
38359
+ }
38360
+ // A single OFW API fetch with the bearer token supplied by `withAuth`.
38361
+ // Carries the per-request timeout (AbortController + setTimeout so vitest
38362
+ // fake timers can drive it and we attach a clear error message) and the
38363
+ // OFW_DEBUG_LOG instrumentation. Returns the raw Response — 401/429/non-2xx
38364
+ // handling lives in the callers (`withAuth` and `fetchAuthed`).
38365
+ async fetchOnce(method, path, body, accept, token, isRetry = false) {
37864
38366
  const isFormData = body instanceof FormData;
37865
38367
  const headers = {
37866
38368
  ...OFW_PROTOCOL_HEADERS,
37867
38369
  Accept: accept,
37868
- Authorization: `Bearer ${this.token}`
38370
+ Authorization: `Bearer ${token}`
37869
38371
  };
37870
38372
  if (body !== void 0 && !isFormData) headers["Content-Type"] = "application/json";
37871
38373
  const url2 = `${BASE_URL}${path}`;
@@ -37907,51 +38409,29 @@ var OFWClient = class {
37907
38409
  if (debugLogEnabled()) {
37908
38410
  console.error(`[ofw-debug] \u2190 ${response.status} ${response.statusText} (${Date.now() - startedAt}ms)`);
37909
38411
  }
37910
- if (response.status === 401 && !isRetry) {
37911
- this.token = null;
37912
- this.tokenExpiry = null;
37913
- await this.ensureAuthenticated();
37914
- return this.fetchWithRetry(method, path, body, accept, true);
37915
- }
37916
- if (response.status === 429) {
37917
- if (!isRetry) {
37918
- await new Promise((r) => setTimeout(r, 2e3));
37919
- return this.fetchWithRetry(method, path, body, accept, true);
37920
- }
37921
- throw new Error("Rate limited by OFW API");
37922
- }
37923
- if (!response.ok) {
37924
- throw new Error(`OFW API error: ${response.status} ${response.statusText} for ${method} ${path}`);
37925
- }
37926
38412
  return response;
37927
38413
  }
37928
- async ensureAuthenticated() {
37929
- if (!this.isTokenExpiredSoon()) return;
37930
- await this.login();
37931
- }
37932
- // Auth resolution is delegated to `./auth.ts`. This client doesn't care
37933
- // whether the token came from a password POST or from a one-shot
37934
- // fetchproxy session-snapshot — it just consumes the result.
37935
- //
37936
- // If `expiresAt` is missing (the fetchproxy path on a tab whose
37937
- // browser didn't persist tokenExpiry), we fall back to the same 6h
37938
- // estimate the password path uses. The 401-replay path covers us if
37939
- // the estimate is wrong.
37940
- async login() {
37941
- const { token, expiresAt } = await resolveAuth();
37942
- this.token = token;
37943
- this.tokenExpiry = expiresAt ?? new Date(Date.now() + OFW_TOKEN_TTL_MS);
37944
- }
37945
- isTokenExpiredSoon() {
37946
- if (!this.token || !this.tokenExpiry) return true;
37947
- return this.tokenExpiry.getTime() - Date.now() < OFW_TOKEN_EXPIRY_SKEW_MS;
37948
- }
37949
38414
  };
37950
38415
  var client = new OFWClient();
37951
38416
 
38417
+ // src/validate.ts
38418
+ function parseOFW(schema, raw, ctx, mode = "lenient") {
38419
+ const result = schema.safeParse(raw);
38420
+ if (result.success) return result.data;
38421
+ const issues = result.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
38422
+ const message = `OFW response for ${ctx} failed validation: ${issues}`;
38423
+ if (mode === "strict") throw new Error(message);
38424
+ console.error(`[ofw-mcp] WARNING: ${message} \u2014 continuing with the raw response; fields derived from it may be missing or wrong.`);
38425
+ return raw;
38426
+ }
38427
+
37952
38428
  // src/tools/_shared.ts
37953
38429
  var jsonResponse = textResult;
37954
38430
  var textResponse = rawTextResult;
38431
+ var ApiRecipientSchema = external_exports.looseObject({
38432
+ user: external_exports.looseObject({ id: external_exports.number().optional(), name: external_exports.string().optional() }).optional(),
38433
+ viewed: external_exports.looseObject({ dateTime: external_exports.string() }).nullable().optional()
38434
+ });
37955
38435
  function mapRecipients(items) {
37956
38436
  return (items ?? []).map((r) => ({
37957
38437
  userId: r.user?.id ?? 0,
@@ -37960,15 +38440,36 @@ function mapRecipients(items) {
37960
38440
  }));
37961
38441
  }
37962
38442
  var expandPath2 = expandPath;
37963
- async function postMessageAndRefetch(client2, payload) {
37964
- const raw = await client2.request(
37965
- "POST",
37966
- "/pub/v3/messages",
37967
- payload
38443
+ function verifyWriteLanded(kind, sent, persisted) {
38444
+ const mismatches = [];
38445
+ if (typeof persisted.subject !== "string" || !persisted.subject.includes(sent.subject)) {
38446
+ mismatches.push("subject");
38447
+ }
38448
+ if (typeof persisted.body !== "string" || !persisted.body.includes(sent.body)) {
38449
+ mismatches.push("body");
38450
+ }
38451
+ if (mismatches.length === 0) return null;
38452
+ return `WARNING: the ${kind} re-fetched from OFW does not contain the ${mismatches.join(" and ")} that was posted \u2014 OFW may have silently dropped or altered the write. Verify the ${kind} on ourfamilywizard.com before relying on it.`;
38453
+ }
38454
+ var PostMessagesResponseSchema = external_exports.looseObject({
38455
+ id: external_exports.number().optional(),
38456
+ entityId: external_exports.number().optional()
38457
+ }).nullable();
38458
+ async function postMessageAndRefetch(client2, payload, detailSchema, ctx) {
38459
+ const raw = parseOFW(
38460
+ PostMessagesResponseSchema,
38461
+ await client2.request("POST", "/pub/v3/messages", payload),
38462
+ `POST /pub/v3/messages (${ctx})`,
38463
+ "strict"
37968
38464
  );
37969
38465
  const id = typeof raw?.id === "number" ? raw.id : typeof raw?.entityId === "number" ? raw.entityId : null;
37970
38466
  if (id === null) return { id: null, detail: null, raw };
37971
- const detail = await client2.request("GET", `/pub/v3/messages/${id}`);
38467
+ const detail = parseOFW(
38468
+ detailSchema,
38469
+ await client2.request("GET", `/pub/v3/messages/${id}`),
38470
+ `GET /pub/v3/messages/{id} (${ctx})`,
38471
+ "strict"
38472
+ );
37972
38473
  return { id, detail, raw };
37973
38474
  }
37974
38475
 
@@ -37992,7 +38493,7 @@ function registerUserTools(server, client2) {
37992
38493
 
37993
38494
  // src/cache.ts
37994
38495
  import { DatabaseSync } from "node:sqlite";
37995
- import { mkdirSync } from "node:fs";
38496
+ import { mkdirSync, chmodSync, existsSync } from "node:fs";
37996
38497
  import { dirname as dirname2 } from "node:path";
37997
38498
  var instance = null;
37998
38499
  var SCHEMA_V1 = `
@@ -38055,14 +38556,23 @@ function migrate(db) {
38055
38556
  "INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value"
38056
38557
  ).run("schema_version", "2");
38057
38558
  }
38559
+ function enforceCachePermissions(dbPath) {
38560
+ chmodSync(dirname2(dbPath), 448);
38561
+ chmodSync(dbPath, 384);
38562
+ for (const sibling of [`${dbPath}-wal`, `${dbPath}-shm`]) {
38563
+ if (existsSync(sibling)) chmodSync(sibling, 384);
38564
+ }
38565
+ }
38058
38566
  function openCache() {
38059
38567
  if (instance) return instance;
38060
38568
  const path = getCacheDbPath();
38061
38569
  mkdirSync(dirname2(path), { recursive: true });
38062
38570
  const db = new DatabaseSync(path);
38571
+ enforceCachePermissions(path);
38063
38572
  db.exec("PRAGMA journal_mode = WAL");
38064
38573
  db.exec("PRAGMA foreign_keys = ON");
38065
38574
  migrate(db);
38575
+ enforceCachePermissions(path);
38066
38576
  instance = { db };
38067
38577
  return instance;
38068
38578
  }
@@ -38328,8 +38838,20 @@ function markAttachmentDownloaded(fileId, path) {
38328
38838
  }
38329
38839
 
38330
38840
  // src/sync.ts
38841
+ var FileMetaSchema = external_exports.looseObject({
38842
+ fileId: external_exports.number(),
38843
+ label: external_exports.string().optional(),
38844
+ fileName: external_exports.string().optional(),
38845
+ fileType: external_exports.string().optional(),
38846
+ // MIME
38847
+ fileSize: external_exports.number().optional()
38848
+ });
38331
38849
  async function fetchAttachmentMeta(client2, fileId, messageId) {
38332
- const meta3 = await client2.request("GET", `/pub/v1/myfiles/${fileId}`);
38850
+ const meta3 = parseOFW(
38851
+ FileMetaSchema,
38852
+ await client2.request("GET", `/pub/v1/myfiles/${fileId}`),
38853
+ "GET /pub/v1/myfiles/{fileId}"
38854
+ );
38333
38855
  upsertAttachmentForMessage({
38334
38856
  fileId: meta3.fileId ?? fileId,
38335
38857
  fileName: meta3.fileName ?? `file-${fileId}`,
@@ -38343,10 +38865,14 @@ async function fetchAttachmentMeta(client2, fileId, messageId) {
38343
38865
  async function fetchAttachmentMetaForMessage(client2, messageId, fileIds) {
38344
38866
  await Promise.allSettled(fileIds.map((fid) => fetchAttachmentMeta(client2, fid, messageId)));
38345
38867
  }
38868
+ var FoldersSchema = external_exports.looseObject({
38869
+ systemFolders: external_exports.array(external_exports.looseObject({ id: external_exports.string(), folderType: external_exports.string() })).optional()
38870
+ });
38346
38871
  async function resolveFolderIds(client2) {
38347
- const data = await client2.request(
38348
- "GET",
38349
- "/pub/v1/messageFolders?includeFolderCounts=true"
38872
+ const data = parseOFW(
38873
+ FoldersSchema,
38874
+ await client2.request("GET", "/pub/v1/messageFolders?includeFolderCounts=true"),
38875
+ "GET /pub/v1/messageFolders"
38350
38876
  );
38351
38877
  const sys = data.systemFolders ?? [];
38352
38878
  const find = (type) => {
@@ -38362,6 +38888,19 @@ async function resolveFolderIds(client2) {
38362
38888
  setMeta("drafts_folder_id", ids.drafts);
38363
38889
  return ids;
38364
38890
  }
38891
+ var ListItemSchema = external_exports.looseObject({
38892
+ id: external_exports.number(),
38893
+ subject: external_exports.string(),
38894
+ date: external_exports.looseObject({ dateTime: external_exports.string() }),
38895
+ from: external_exports.looseObject({ name: external_exports.string().optional() }).optional(),
38896
+ showNeverViewed: external_exports.boolean(),
38897
+ recipients: external_exports.array(ApiRecipientSchema).optional()
38898
+ });
38899
+ var ListResponseSchema = external_exports.looseObject({ data: external_exports.array(ListItemSchema).optional() });
38900
+ var DetailResponseSchema = external_exports.looseObject({
38901
+ body: external_exports.string().optional(),
38902
+ files: external_exports.array(external_exports.number()).optional()
38903
+ });
38365
38904
  async function syncMessageFolder(client2, folder, folderId, opts) {
38366
38905
  let page = 1;
38367
38906
  let synced = 0;
@@ -38369,7 +38908,11 @@ async function syncMessageFolder(client2, folder, folderId, opts) {
38369
38908
  const unread = [];
38370
38909
  while (true) {
38371
38910
  const path = `/pub/v3/messages?folders=${encodeURIComponent(folderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
38372
- const list = await client2.request("GET", path);
38911
+ const list = parseOFW(
38912
+ ListResponseSchema,
38913
+ await client2.request("GET", path),
38914
+ `GET /pub/v3/messages?folders={${folder}}`
38915
+ );
38373
38916
  const items = list.data ?? [];
38374
38917
  if (items.length === 0) break;
38375
38918
  let pageHadNewItem = false;
@@ -38384,7 +38927,11 @@ async function syncMessageFolder(client2, folder, folderId, opts) {
38384
38927
  let fetchedBodyAt = null;
38385
38928
  let detailFileIds = [];
38386
38929
  if (shouldFetchBody) {
38387
- const detail = await client2.request("GET", `/pub/v3/messages/${item.id}`);
38930
+ const detail = parseOFW(
38931
+ DetailResponseSchema,
38932
+ await client2.request("GET", `/pub/v3/messages/${item.id}`),
38933
+ "GET /pub/v3/messages/{id} (sync)"
38934
+ );
38388
38935
  body = detail.body ?? "";
38389
38936
  fetchedBodyAt = (/* @__PURE__ */ new Date()).toISOString();
38390
38937
  if (Array.isArray(detail.files) && detail.files.length > 0) {
@@ -38426,17 +38973,44 @@ async function syncMessageFolder(client2, folder, folderId, opts) {
38426
38973
  });
38427
38974
  return { synced, unread };
38428
38975
  }
38976
+ var DraftListItemSchema = external_exports.looseObject({
38977
+ id: external_exports.number(),
38978
+ subject: external_exports.string(),
38979
+ date: external_exports.looseObject({ dateTime: external_exports.string() }),
38980
+ replyToId: external_exports.number().nullable().optional(),
38981
+ recipients: external_exports.array(ApiRecipientSchema).optional()
38982
+ });
38983
+ var DraftListResponseSchema = external_exports.looseObject({ data: external_exports.array(DraftListItemSchema).optional() });
38984
+ var DraftDetailSchema = external_exports.looseObject({
38985
+ body: external_exports.string().optional(),
38986
+ subject: external_exports.string().optional()
38987
+ });
38429
38988
  async function syncDrafts(client2, draftsFolderId) {
38430
- const path = `/pub/v3/messages?folders=${encodeURIComponent(draftsFolderId)}&page=1&size=50&sort=date&sortDirection=desc`;
38431
- const list = await client2.request("GET", path);
38432
- const items = list.data ?? [];
38989
+ const items = [];
38990
+ let page = 1;
38991
+ while (true) {
38992
+ const path = `/pub/v3/messages?folders=${encodeURIComponent(draftsFolderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
38993
+ const list = parseOFW(
38994
+ DraftListResponseSchema,
38995
+ await client2.request("GET", path),
38996
+ "GET /pub/v3/messages?folders={drafts}"
38997
+ );
38998
+ const pageItems = list.data ?? [];
38999
+ items.push(...pageItems);
39000
+ if (pageItems.length < 50) break;
39001
+ page++;
39002
+ }
38433
39003
  const seenIds = /* @__PURE__ */ new Set();
38434
39004
  let synced = 0;
38435
39005
  for (const item of items) {
38436
39006
  seenIds.add(item.id);
38437
39007
  const modifiedAt = item.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString();
38438
39008
  const existing = getDraft(item.id);
38439
- const detail = await client2.request("GET", `/pub/v3/messages/${item.id}`);
39009
+ const detail = parseOFW(
39010
+ DraftDetailSchema,
39011
+ await client2.request("GET", `/pub/v3/messages/${item.id}`),
39012
+ "GET /pub/v3/messages/{id} (drafts sync)"
39013
+ );
38440
39014
  const row = {
38441
39015
  id: item.id,
38442
39016
  subject: detail.subject ?? item.subject ?? "(no subject)",
@@ -38488,6 +39062,39 @@ async function syncAll(client2, opts) {
38488
39062
  // src/tools/messages.ts
38489
39063
  import { mkdirSync as mkdirSync2, readFileSync, statSync, writeFileSync } from "node:fs";
38490
39064
  import { basename, dirname as dirname3, extname, join as join5 } from "node:path";
39065
+ var DateSchema = external_exports.looseObject({ dateTime: external_exports.string() });
39066
+ var SentDetailSchema = external_exports.looseObject({
39067
+ subject: external_exports.string().optional(),
39068
+ body: external_exports.string().optional(),
39069
+ date: DateSchema.optional(),
39070
+ from: external_exports.looseObject({ name: external_exports.string().optional() }).optional(),
39071
+ recipients: external_exports.array(ApiRecipientSchema).optional()
39072
+ });
39073
+ var SavedDraftDetailSchema = external_exports.looseObject({
39074
+ subject: external_exports.string().optional(),
39075
+ body: external_exports.string().optional(),
39076
+ date: DateSchema.optional(),
39077
+ replyToId: external_exports.number().nullable().optional(),
39078
+ recipients: external_exports.array(ApiRecipientSchema).optional()
39079
+ });
39080
+ var MessageDetailSchema = external_exports.looseObject({
39081
+ id: external_exports.number(),
39082
+ subject: external_exports.string(),
39083
+ body: external_exports.string().optional(),
39084
+ date: DateSchema,
39085
+ from: external_exports.looseObject({ name: external_exports.string().optional() }).optional(),
39086
+ files: external_exports.array(external_exports.number()).optional(),
39087
+ recipients: external_exports.array(ApiRecipientSchema).optional()
39088
+ });
39089
+ var DetailFilesSchema = external_exports.looseObject({ files: external_exports.array(external_exports.number()).optional() });
39090
+ var UploadedFileSchema = external_exports.looseObject({
39091
+ fileId: external_exports.number(),
39092
+ fileName: external_exports.string().optional(),
39093
+ label: external_exports.string().optional(),
39094
+ fileType: external_exports.string().optional(),
39095
+ sizeInBytes: external_exports.number().optional(),
39096
+ shareClass: external_exports.string().optional()
39097
+ });
38491
39098
  var MIME_BY_EXT = {
38492
39099
  ".pdf": "application/pdf",
38493
39100
  ".png": "image/png",
@@ -38523,6 +39130,9 @@ function listDataHintsAtFiles(listData) {
38523
39130
  return false;
38524
39131
  }
38525
39132
  function registerMessageTools(server, client2) {
39133
+ const writeMode = getWriteMode();
39134
+ const allowSend = writeMode === "all";
39135
+ const allowDrafts = writeMode !== "none";
38526
39136
  server.registerTool("ofw_list_message_folders", {
38527
39137
  description: "List OurFamilyWizard message folders (inbox, sent, etc.) and their unread counts. Returns folder IDs needed to call ofw_list_messages. Does NOT return message content.",
38528
39138
  annotations: { readOnlyHint: true }
@@ -38535,8 +39145,8 @@ function registerMessageTools(server, client2) {
38535
39145
  annotations: { readOnlyHint: true },
38536
39146
  inputSchema: {
38537
39147
  folderId: external_exports.string().describe('Folder name: "inbox", "sent", or "both" (default "both")').optional(),
38538
- page: external_exports.number().describe("Page number (default 1)").optional(),
38539
- size: external_exports.number().describe("Messages per page (default 50)").optional(),
39148
+ page: external_exports.number().int().min(1).describe("Page number (default 1)").optional(),
39149
+ size: external_exports.number().int().min(1).describe("Messages per page (default 50)").optional(),
38540
39150
  since: external_exports.string().describe("ISO date or datetime \u2014 only messages with sent_at >= since (inclusive)").optional(),
38541
39151
  until: external_exports.string().describe("ISO date or datetime \u2014 only messages with sent_at < until (exclusive)").optional(),
38542
39152
  q: external_exports.string().describe("Substring match on subject AND body (case-insensitive). Use to find messages on a specific topic.").optional()
@@ -38599,7 +39209,11 @@ function registerMessageTools(server, client2) {
38599
39209
  let attachments2 = listAttachmentsForMessage(id);
38600
39210
  if (attachments2.length === 0 && listDataHintsAtFiles(cached2.listData)) {
38601
39211
  try {
38602
- const detail2 = await client2.request("GET", `/pub/v3/messages/${id}`);
39212
+ const detail2 = parseOFW(
39213
+ DetailFilesSchema,
39214
+ await client2.request("GET", `/pub/v3/messages/${id}`),
39215
+ "GET /pub/v3/messages/{id} (attachment backfill)"
39216
+ );
38603
39217
  if (Array.isArray(detail2.files) && detail2.files.length > 0) {
38604
39218
  await fetchAttachmentMetaForMessage(client2, id, detail2.files);
38605
39219
  attachments2 = listAttachmentsForMessage(id);
@@ -38609,7 +39223,11 @@ function registerMessageTools(server, client2) {
38609
39223
  }
38610
39224
  return jsonResponse({ ...cached2, attachments: attachments2 });
38611
39225
  }
38612
- const detail = await client2.request("GET", `/pub/v3/messages/${encodeURIComponent(args.messageId)}`);
39226
+ const detail = parseOFW(
39227
+ MessageDetailSchema,
39228
+ await client2.request("GET", `/pub/v3/messages/${encodeURIComponent(args.messageId)}`),
39229
+ "GET /pub/v3/messages/{id} (ofw_get_message)"
39230
+ );
38613
39231
  const folder = cached2?.folder ?? "inbox";
38614
39232
  const row = {
38615
39233
  id: detail.id,
@@ -38631,7 +39249,7 @@ function registerMessageTools(server, client2) {
38631
39249
  const attachments = listAttachmentsForMessage(detail.id);
38632
39250
  return jsonResponse({ ...row, attachments });
38633
39251
  });
38634
- server.registerTool("ofw_send_message", {
39252
+ if (allowSend) server.registerTool("ofw_send_message", {
38635
39253
  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.",
38636
39254
  annotations: { destructiveHint: true },
38637
39255
  inputSchema: {
@@ -38701,9 +39319,11 @@ function registerMessageTools(server, client2) {
38701
39319
  draft: false,
38702
39320
  includeOriginal: resolvedReplyTo !== null,
38703
39321
  replyToId: resolvedReplyTo
38704
- });
39322
+ }, SentDetailSchema, "ofw_send_message");
38705
39323
  let persisted = null;
39324
+ let verifyNote = null;
38706
39325
  if (newId !== null) {
39326
+ verifyNote = verifyWriteLanded("message", { subject, body }, detail);
38707
39327
  persisted = {
38708
39328
  id: newId,
38709
39329
  folder: "sent",
@@ -38731,13 +39351,18 @@ function registerMessageTools(server, client2) {
38731
39351
  });
38732
39352
  }
38733
39353
  }
38734
- if (draftRef !== void 0) {
39354
+ let unconfirmedNote = null;
39355
+ if (newId === null) {
39356
+ const draftClause = draftRef !== void 0 ? `Draft ${draftRef} was NOT deleted \u2014 check` : "Check";
39357
+ 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.`;
39358
+ } else if (draftRef !== void 0) {
38735
39359
  await deleteOFWMessages(client2, [draftRef]);
38736
39360
  deleteDraft(draftRef);
38737
39361
  }
38738
39362
  const responseObj = persisted ?? raw;
38739
39363
  const text = responseObj ? JSON.stringify(responseObj, null, 2) : "Message sent successfully.";
38740
- return textResponse(rewriteNote ? `${rewriteNote}
39364
+ const notes = [rewriteNote, verifyNote, unconfirmedNote].filter((n) => n !== null).join("\n\n");
39365
+ return textResponse(notes ? `${notes}
38741
39366
 
38742
39367
  ${text}` : text);
38743
39368
  });
@@ -38745,8 +39370,8 @@ ${text}` : text);
38745
39370
  description: "List draft messages from the local OurFamilyWizard cache. Call ofw_sync_messages first if the cache is empty.",
38746
39371
  annotations: { readOnlyHint: true },
38747
39372
  inputSchema: {
38748
- page: external_exports.number().describe("Page number (default 1)").optional(),
38749
- size: external_exports.number().describe("Drafts per page (default 50)").optional()
39373
+ page: external_exports.number().int().min(1).describe("Page number (default 1)").optional(),
39374
+ size: external_exports.number().int().min(1).describe("Drafts per page (default 50)").optional()
38750
39375
  }
38751
39376
  }, async (args) => {
38752
39377
  const page = args.page ?? 1;
@@ -38755,7 +39380,7 @@ ${text}` : text);
38755
39380
  const payload = drafts.length === 0 ? { drafts: [], note: "Cache empty. Call ofw_sync_messages to populate." } : { drafts };
38756
39381
  return jsonResponse(payload);
38757
39382
  });
38758
- server.registerTool("ofw_save_draft", {
39383
+ if (allowDrafts) server.registerTool("ofw_save_draft", {
38759
39384
  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. 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.",
38760
39385
  annotations: { readOnlyHint: false },
38761
39386
  inputSchema: {
@@ -38786,10 +39411,17 @@ ${text}` : text);
38786
39411
  includeOriginal: resolvedReplyTo !== null,
38787
39412
  replyToId: resolvedReplyTo
38788
39413
  };
38789
- const { id: newId, detail, raw } = await postMessageAndRefetch(client2, payload);
39414
+ const { id: newId, detail, raw } = await postMessageAndRefetch(
39415
+ client2,
39416
+ payload,
39417
+ SavedDraftDetailSchema,
39418
+ "ofw_save_draft"
39419
+ );
38790
39420
  let persisted = null;
38791
39421
  let replaceNote = null;
39422
+ let verifyNote = null;
38792
39423
  if (newId !== null) {
39424
+ verifyNote = verifyWriteLanded("draft", { subject: args.subject, body: args.body }, detail);
38793
39425
  persisted = {
38794
39426
  id: newId,
38795
39427
  subject: detail.subject ?? args.subject,
@@ -38812,12 +39444,12 @@ ${text}` : text);
38812
39444
  }
38813
39445
  const responseObj = persisted ?? raw;
38814
39446
  const text = responseObj ? JSON.stringify(responseObj, null, 2) : "Draft saved.";
38815
- const notes = [rewriteNote, replaceNote].filter((n) => n !== null).join("\n\n");
39447
+ const notes = [rewriteNote, verifyNote, replaceNote].filter((n) => n !== null).join("\n\n");
38816
39448
  return textResponse(notes ? `${notes}
38817
39449
 
38818
39450
  ${text}` : text);
38819
39451
  });
38820
- server.registerTool("ofw_delete_draft", {
39452
+ if (allowDrafts) server.registerTool("ofw_delete_draft", {
38821
39453
  description: "Delete a draft message from OurFamilyWizard. Also removes the draft from the local cache.",
38822
39454
  annotations: { destructiveHint: true },
38823
39455
  inputSchema: {
@@ -38832,8 +39464,8 @@ ${text}` : text);
38832
39464
  description: "List sent messages that have not been read by one or more recipients. Reads from local cache; call ofw_sync_messages first if cache is stale.",
38833
39465
  annotations: { readOnlyHint: true },
38834
39466
  inputSchema: {
38835
- page: external_exports.number().describe("Page (default 1)").optional(),
38836
- size: external_exports.number().describe("Per page (default 50)").optional()
39467
+ page: external_exports.number().int().min(1).describe("Page (default 1)").optional(),
39468
+ size: external_exports.number().int().min(1).describe("Per page (default 50)").optional()
38837
39469
  }
38838
39470
  }, async (args) => {
38839
39471
  const page = args.page ?? 1;
@@ -38854,7 +39486,7 @@ ${text}` : text);
38854
39486
  }
38855
39487
  return jsonResponse(unread);
38856
39488
  });
38857
- server.registerTool("ofw_upload_attachment", {
39489
+ if (allowDrafts) server.registerTool("ofw_upload_attachment", {
38858
39490
  description: `Upload a local file to OurFamilyWizard's "My Files" so it can be attached to a message. Returns the fileId \u2014 pass that to ofw_send_message or ofw_save_draft in myFileIDs to attach it. The file is uploaded as PRIVATE (visible only to you) by default; pass shareClass:"SHARED" to share with co-parents directly via the My Files area.`,
38859
39491
  annotations: { destructiveHint: false },
38860
39492
  inputSchema: {
@@ -38876,7 +39508,12 @@ ${text}` : text);
38876
39508
  form.append("label", args.label ?? fileName);
38877
39509
  form.append("fileName", fileName);
38878
39510
  form.append("shareClass", args.shareClass ?? "PRIVATE");
38879
- const meta3 = await client2.request("POST", "/pub/v3/myfiles/multipart", form);
39511
+ const meta3 = parseOFW(
39512
+ UploadedFileSchema,
39513
+ await client2.request("POST", "/pub/v3/myfiles/multipart", form),
39514
+ "POST /pub/v3/myfiles/multipart (ofw_upload_attachment)",
39515
+ "strict"
39516
+ );
38880
39517
  upsertAttachmentForMessage({
38881
39518
  fileId: meta3.fileId,
38882
39519
  fileName: meta3.fileName ?? fileName,
@@ -39001,6 +39638,7 @@ async function deleteOFWMessages(client2, ids) {
39001
39638
 
39002
39639
  // src/tools/calendar.ts
39003
39640
  function registerCalendarTools(server, client2) {
39641
+ const allowWrites = getWriteMode() === "all";
39004
39642
  server.registerTool("ofw_list_events", {
39005
39643
  description: "List OurFamilyWizard calendar events in a date range",
39006
39644
  annotations: { readOnlyHint: true },
@@ -39017,7 +39655,7 @@ function registerCalendarTools(server, client2) {
39017
39655
  );
39018
39656
  return jsonResponse(data);
39019
39657
  });
39020
- server.registerTool("ofw_create_event", {
39658
+ if (allowWrites) server.registerTool("ofw_create_event", {
39021
39659
  description: "Create a calendar event in OurFamilyWizard",
39022
39660
  annotations: { destructiveHint: false },
39023
39661
  inputSchema: {
@@ -39037,7 +39675,7 @@ function registerCalendarTools(server, client2) {
39037
39675
  const data = await client2.request("POST", "/pub/v1/calendar/events", args);
39038
39676
  return jsonResponse(data);
39039
39677
  });
39040
- server.registerTool("ofw_update_event", {
39678
+ if (allowWrites) server.registerTool("ofw_update_event", {
39041
39679
  description: "Update an existing OurFamilyWizard calendar event",
39042
39680
  annotations: { destructiveHint: true },
39043
39681
  inputSchema: {
@@ -39055,7 +39693,7 @@ function registerCalendarTools(server, client2) {
39055
39693
  const data = await client2.request("PUT", `/pub/v1/calendar/events/${encodeURIComponent(eventId)}`, updateData);
39056
39694
  return jsonResponse(data);
39057
39695
  });
39058
- server.registerTool("ofw_delete_event", {
39696
+ if (allowWrites) server.registerTool("ofw_delete_event", {
39059
39697
  description: "Delete an OurFamilyWizard calendar event",
39060
39698
  annotations: { destructiveHint: true },
39061
39699
  inputSchema: {
@@ -39069,6 +39707,7 @@ function registerCalendarTools(server, client2) {
39069
39707
 
39070
39708
  // src/tools/expenses.ts
39071
39709
  function registerExpenseTools(server, client2) {
39710
+ const allowWrites = getWriteMode() === "all";
39072
39711
  server.registerTool("ofw_get_expense_totals", {
39073
39712
  description: "Get OurFamilyWizard expense summary totals (owed/paid)",
39074
39713
  annotations: { readOnlyHint: true }
@@ -39080,8 +39719,8 @@ function registerExpenseTools(server, client2) {
39080
39719
  description: "List OurFamilyWizard expenses with pagination",
39081
39720
  annotations: { readOnlyHint: true },
39082
39721
  inputSchema: {
39083
- start: external_exports.number().describe("Start offset (default 0)").optional(),
39084
- max: external_exports.number().describe("Max results (default 20)").optional()
39722
+ start: external_exports.number().int().min(0).describe("Start offset (default 0)").optional(),
39723
+ max: external_exports.number().int().min(1).describe("Max results (default 20)").optional()
39085
39724
  }
39086
39725
  }, async (args) => {
39087
39726
  const start = args.start ?? 0;
@@ -39089,7 +39728,7 @@ function registerExpenseTools(server, client2) {
39089
39728
  const data = await client2.request("GET", `/pub/v2/expense/expenses?start=${start}&max=${max}`);
39090
39729
  return jsonResponse(data);
39091
39730
  });
39092
- server.registerTool("ofw_create_expense", {
39731
+ if (allowWrites) server.registerTool("ofw_create_expense", {
39093
39732
  description: "Log a new expense in OurFamilyWizard",
39094
39733
  annotations: { destructiveHint: false },
39095
39734
  inputSchema: {
@@ -39104,12 +39743,13 @@ function registerExpenseTools(server, client2) {
39104
39743
 
39105
39744
  // src/tools/journal.ts
39106
39745
  function registerJournalTools(server, client2) {
39746
+ const allowWrites = getWriteMode() === "all";
39107
39747
  server.registerTool("ofw_list_journal_entries", {
39108
39748
  description: "List OurFamilyWizard journal entries",
39109
39749
  annotations: { readOnlyHint: true },
39110
39750
  inputSchema: {
39111
- start: external_exports.number().describe("Start offset (default 1)").optional(),
39112
- max: external_exports.number().describe("Max results (default 10)").optional()
39751
+ start: external_exports.number().int().min(1).describe("Start offset (default 1)").optional(),
39752
+ max: external_exports.number().int().min(1).describe("Max results (default 10)").optional()
39113
39753
  }
39114
39754
  }, async (args) => {
39115
39755
  const start = args.start ?? 1;
@@ -39117,7 +39757,7 @@ function registerJournalTools(server, client2) {
39117
39757
  const data = await client2.request("GET", `/pub/v1/journals?start=${start}&max=${max}`);
39118
39758
  return jsonResponse(data);
39119
39759
  });
39120
- server.registerTool("ofw_create_journal_entry", {
39760
+ if (allowWrites) server.registerTool("ofw_create_journal_entry", {
39121
39761
  description: "Create a new journal entry in OurFamilyWizard",
39122
39762
  annotations: { destructiveHint: false },
39123
39763
  inputSchema: {
@@ -39143,7 +39783,7 @@ process.emit = function(event, ...args) {
39143
39783
  };
39144
39784
  await runMcp({
39145
39785
  name: "ofw",
39146
- version: "2.3.1",
39786
+ version: "2.4.0",
39147
39787
  // x-release-please-version
39148
39788
  deps: client,
39149
39789
  tools: [