ofw-mcp 2.20.3 → 2.20.5

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
@@ -121,9 +121,9 @@ var require_buffer_util = __commonJS({
121
121
  toBuffer,
122
122
  unmask: _unmask
123
123
  };
124
- if (!process.env.WS_NO_BUFFER_UTIL) {
124
+ if (false) {
125
125
  try {
126
- const bufferUtil = __require("bufferutil");
126
+ const bufferUtil = null;
127
127
  module.exports.mask = function(source, mask, output2, offset, length) {
128
128
  if (length < 48) _mask(source, mask, output2, offset, length);
129
129
  else bufferUtil.mask(source, mask, output2, offset, length);
@@ -760,9 +760,9 @@ var require_validation2 = __commonJS({
760
760
  module.exports.isValidUTF8 = function(buf) {
761
761
  return buf.length < 24 ? _isValidUTF8(buf) : isUtf8(buf);
762
762
  };
763
- } else if (!process.env.WS_NO_UTF_8_VALIDATE) {
763
+ } else if (false) {
764
764
  try {
765
- const isValidUTF8 = __require("utf-8-validate");
765
+ const isValidUTF8 = null;
766
766
  module.exports.isValidUTF8 = function(buf) {
767
767
  return buf.length < 32 ? _isValidUTF8(buf) : isValidUTF8(buf);
768
768
  };
@@ -4483,6 +4483,7 @@ __export(util_exports, {
4483
4483
  required: () => required,
4484
4484
  safeExtend: () => safeExtend,
4485
4485
  shallowClone: () => shallowClone,
4486
+ sizeCaps: () => sizeCaps,
4486
4487
  slugify: () => slugify,
4487
4488
  stringifyPrimitive: () => stringifyPrimitive,
4488
4489
  toZod: () => toZod,
@@ -5002,6 +5003,18 @@ function prefixIssues(path, issues) {
5002
5003
  function unwrapMessage(message) {
5003
5004
  return typeof message === "string" ? message : message?.message;
5004
5005
  }
5006
+ function sizeCaps(defChecks) {
5007
+ const caps = [];
5008
+ for (const ch of defChecks ?? []) {
5009
+ const d = ch._zod.def;
5010
+ if (!/_(length|size)$|_equals$/.test(d.check))
5011
+ break;
5012
+ const max = d.maximum ?? d.length ?? d.size;
5013
+ if (max >= 0)
5014
+ caps.push([max, ch]);
5015
+ }
5016
+ return caps;
5017
+ }
5005
5018
  function attachSchema(issues, start, inst) {
5006
5019
  var _a3;
5007
5020
  for (let i = start; i < issues.length; i++) {
@@ -6422,7 +6435,7 @@ ${content.join("\n")}
6422
6435
  var version = {
6423
6436
  major: 4,
6424
6437
  minor: 6,
6425
- patch: 5
6438
+ patch: 7
6426
6439
  };
6427
6440
 
6428
6441
  // node_modules/zod/v4/core/schemas.js
@@ -7178,6 +7191,31 @@ var $ZodDate = /* @__PURE__ */ $constructor("$ZodDate", (inst, def) => {
7178
7191
  return payload;
7179
7192
  };
7180
7193
  });
7194
+ function capSize(inst, size) {
7195
+ const caps = sizeCaps(inst._zod.def.checks);
7196
+ if (!caps.length)
7197
+ return;
7198
+ const cap = Math.min(...caps.map((c) => c[0]));
7199
+ const parse3 = inst._zod.parse;
7200
+ inst._zod.parse = (payload, ctx) => {
7201
+ const n = size(payload.value);
7202
+ if (n > cap && !ctx?.skipChecks && ctx?.direction !== "backward") {
7203
+ for (const [max, ch] of sizeCaps(inst._zod.def.checks)) {
7204
+ if (n > max && ch._zod.def.when(payload)) {
7205
+ const start = payload.issues.length;
7206
+ ch._zod.check(payload);
7207
+ payload.issues[start].continue = false;
7208
+ attachSchema(payload.issues, start, inst);
7209
+ return payload;
7210
+ }
7211
+ }
7212
+ }
7213
+ return parse3(payload, ctx);
7214
+ };
7215
+ }
7216
+ var arraySize = (input2) => Array.isArray(input2) ? input2.length : -1;
7217
+ var mapSize = (input2) => input2 instanceof Map ? input2.size : -1;
7218
+ var setSize = (input2) => input2 instanceof Set ? input2.size : -1;
7181
7219
  function handleArrayResult(result, final, index) {
7182
7220
  if (result.issues.length) {
7183
7221
  final.issues.push(...prefixIssues(index, result.issues));
@@ -7221,6 +7259,7 @@ var $ZodArray = /* @__PURE__ */ $constructor("$ZodArray", (inst, def) => {
7221
7259
  }
7222
7260
  return payload;
7223
7261
  };
7262
+ capSize(inst, arraySize);
7224
7263
  });
7225
7264
  function handlePropertyResult(result, final, key, input2, optin, optout) {
7226
7265
  const isPresent = key in input2;
@@ -8177,6 +8216,7 @@ var $ZodMap = /* @__PURE__ */ $constructor("$ZodMap", (inst, def) => {
8177
8216
  return Promise.all(proms).then(() => payload);
8178
8217
  return payload;
8179
8218
  };
8219
+ capSize(inst, mapSize);
8180
8220
  });
8181
8221
  function handleMapResult(keyResult, valueResult, final, key, input2, inst, ctx) {
8182
8222
  if (keyResult.issues.length) {
@@ -8243,6 +8283,7 @@ var $ZodSet = /* @__PURE__ */ $constructor("$ZodSet", (inst, def) => {
8243
8283
  return Promise.all(proms).then(() => payload);
8244
8284
  return payload;
8245
8285
  };
8286
+ capSize(inst, setSize);
8246
8287
  });
8247
8288
  function handleSetResult(result, final) {
8248
8289
  if (result.issues.length) {
@@ -17773,9 +17814,15 @@ function generateNullableCheck(doc, ctx, schema, accessor, buildsValue = true) {
17773
17814
  doc.write(`}`);
17774
17815
  return outputVar;
17775
17816
  }
17817
+ function generateSizeGate(doc, schema, size) {
17818
+ for (const [max, check2] of sizeCaps(schema._zod.def.checks)) {
17819
+ doc.write(`if (${size} > ${numericOperand(max, check2._zod.def.check)}) return INVALID;`);
17820
+ }
17821
+ }
17776
17822
  function generateArrayCheck(doc, ctx, schema, accessor, buildsValue = true) {
17777
17823
  const def = schema._zod.def;
17778
17824
  doc.write(`if (!Array.isArray(${accessor})) return INVALID;`);
17825
+ generateSizeGate(doc, schema, `${accessor}.length`);
17779
17826
  const outputVar = buildsValue ? newVar(ctx) : null;
17780
17827
  const iVar = newVar(ctx);
17781
17828
  const elemVar = newVar(ctx);
@@ -18175,6 +18222,7 @@ function literalPropertyKey(ctx, key) {
18175
18222
  function generateMapCheck(doc, ctx, schema, accessor) {
18176
18223
  const def = schema._zod.def;
18177
18224
  doc.write(`if (!(${accessor} instanceof Map)) return INVALID;`);
18225
+ generateSizeGate(doc, schema, `${accessor}.size`);
18178
18226
  const outputVar = newVar(ctx);
18179
18227
  const kVar = newVar(ctx);
18180
18228
  const valVar = newVar(ctx);
@@ -18191,6 +18239,7 @@ function generateMapCheck(doc, ctx, schema, accessor) {
18191
18239
  function generateSetCheck(doc, ctx, schema, accessor) {
18192
18240
  const def = schema._zod.def;
18193
18241
  doc.write(`if (!(${accessor} instanceof Set)) return INVALID;`);
18242
+ generateSizeGate(doc, schema, `${accessor}.size`);
18194
18243
  const outputVar = newVar(ctx);
18195
18244
  const valVar = newVar(ctx);
18196
18245
  doc.write(`const ${outputVar} = new Set();`);
@@ -23500,7 +23549,7 @@ function date4(params) {
23500
23549
  return _coercedDate(ZodDate, params);
23501
23550
  }
23502
23551
 
23503
- // node_modules/@modelcontextprotocol/core/dist/auth-BNDyLTqp.mjs
23552
+ // node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/dist/auth-BNDyLTqp.mjs
23504
23553
  var LATEST_PROTOCOL_VERSION = "2025-11-25";
23505
23554
  var SUPPORTED_PROTOCOL_VERSIONS = [
23506
23555
  LATEST_PROTOCOL_VERSION,
@@ -38245,17 +38294,62 @@ var DEFAULT_MAX_REQUEST_BODY_SIZE = 4 * 1024 * 1024;
38245
38294
 
38246
38295
  // node_modules/@chrischall/mcp-utils/dist/errors/index.js
38247
38296
  var DEFAULT_ERROR_MESSAGE_MAX = 500;
38297
+ var MCP_TOOL_ERROR_KINDS = /* @__PURE__ */ new Set([
38298
+ "no_credential",
38299
+ "credential_rejected",
38300
+ "edge_blocked",
38301
+ "session_expired",
38302
+ "verification_pending",
38303
+ "timeout",
38304
+ "http",
38305
+ "transport",
38306
+ "too_large",
38307
+ "unknown"
38308
+ ]);
38248
38309
  var McpToolError = class extends Error {
38249
38310
  /** Actionable remediation text, when one applies. */
38250
38311
  hint;
38312
+ /** The upstream HTTP status this failure came from, when known. */
38313
+ status;
38314
+ /** What kind of failure this is, when the thrower knows. */
38315
+ kind;
38251
38316
  constructor(message, opts) {
38252
38317
  super(message, opts?.cause !== void 0 ? { cause: opts.cause } : void 0);
38253
38318
  this.name = "McpToolError";
38254
38319
  if (opts?.hint !== void 0)
38255
38320
  this.hint = opts.hint;
38321
+ if (opts?.status !== void 0)
38322
+ this.status = opts.status;
38323
+ if (opts?.kind !== void 0)
38324
+ this.kind = opts.kind;
38256
38325
  Object.setPrototypeOf(this, new.target.prototype);
38257
38326
  }
38258
38327
  };
38328
+ var DECLARATION_CAUSE_DEPTH = 4;
38329
+ function firstDeclared(err, read) {
38330
+ let cur = err;
38331
+ for (let depth = 0; depth < DECLARATION_CAUSE_DEPTH && cur !== null && typeof cur === "object"; depth++) {
38332
+ const found = read(cur);
38333
+ if (found !== void 0)
38334
+ return found;
38335
+ cur = cur.cause;
38336
+ }
38337
+ return void 0;
38338
+ }
38339
+ function errorStatusOf(err) {
38340
+ return firstDeclared(err, (link2) => {
38341
+ const e = link2;
38342
+ if (typeof e.status === "number")
38343
+ return e.status;
38344
+ return typeof e.statusCode === "number" ? e.statusCode : void 0;
38345
+ });
38346
+ }
38347
+ function errorKindOf(err) {
38348
+ return firstDeclared(err, (link2) => {
38349
+ const k = link2.kind;
38350
+ return typeof k === "string" && MCP_TOOL_ERROR_KINDS.has(k) ? k : void 0;
38351
+ });
38352
+ }
38259
38353
  function createHelpfulError(message, opts) {
38260
38354
  return new McpToolError(message, opts);
38261
38355
  }
@@ -38280,7 +38374,7 @@ var API_KEY_RE = new RegExp([
38280
38374
  // webhook signing secret (Stripe-style)
38281
38375
  ].map((p) => `\\b${p}`).join("|"), "g");
38282
38376
  var GOOGLE_OAUTH_TOKEN_RE = /(?<![A-Za-z0-9+/])(?:ya29\.|1\/\/)[A-Za-z0-9._-]{8,}/g;
38283
- var QUERY_SECRET_RE = new RegExp(`([?&](?:(?:access|refresh|id|auth|session|csrf|xsrf)[_-]?token|client[_-]?secret|api[_-]?secret|api[_-]?key|password|passwd|signature|token|key|sig|(?:php|j)?sess(?:ion)?[_-]?id|sid|x[-_]${HEADER_NAME_RUN}?(?:api[-_]?key|token|secret|auth)${HEADER_NAME_RUN})=)[^&#\\s"'<>\`]+`, "gi");
38377
+ var QUERY_SECRET_RE = new RegExp(`([?&#;](?:(?:access|refresh|id|auth|session|csrf|xsrf|api)[_-]?token|client[_-]?secret|api[_-]?secret|api[_-]?key|password|passwd|signature|secret|token|auth|key|sig|(?:code[_-]?)?verifier|(?:php|j)?sess(?:ion)?[_-]?id|sid|x[-_]${HEADER_NAME_RUN}?(?:api[-_]?key|token|secret|auth)${HEADER_NAME_RUN})=)[^&#\\s"'<>\`]+`, "gi");
38284
38378
  var OAUTH_CODE_RE = /([?&]code=)(?=[^&#\s"'<>`]{16,})[^&#\s"'<>`]+/gi;
38285
38379
  var FORM_PASSWORD_RE = /((?:^|[\s,;:"'(\[{])(?:password|passwd)=)[^&#\s"'<>`]+/gim;
38286
38380
  var HEADER_SECRET_RE = new RegExp(`(\\b(?:x[-_]${HEADER_NAME_RUN}?(?:api[-_]?key|token|secret|auth)${HEADER_NAME_RUN}|api[-_]?key)\\s*:\\s*)[^\\s,;"'<>\`]+`, "gi");
@@ -38291,6 +38385,10 @@ var JSON_SECRET_KEYS = [
38291
38385
  "api[_-]?secret",
38292
38386
  "(?:x[_-])?api[_-]?key",
38293
38387
  "private[_-]?key",
38388
+ "aws[_-]?secret[_-]?access[_-]?key",
38389
+ // fleet-audit#1297
38390
+ "code[_-]?verifier",
38391
+ // PKCE; a bare `verifier` key is too generic for JSON
38294
38392
  `x[_-]${HEADER_NAME_RUN}?(?:api[_-]?key|token|secret|auth)${HEADER_NAME_RUN}`,
38295
38393
  // bounded like HEADER_SECRET_RE
38296
38394
  "(?:proxy-)?authorization",
@@ -38755,7 +38853,7 @@ function readEnvVar(key, opts = {}) {
38755
38853
  if (typeof raw === "string") {
38756
38854
  const trimmed = raw.trim();
38757
38855
  if (trimmed.length > 0 && trimmed !== "undefined" && trimmed !== "null" && !PLACEHOLDER_RE.test(trimmed)) {
38758
- return trimmed;
38856
+ return opts.trim === false ? raw : trimmed;
38759
38857
  }
38760
38858
  }
38761
38859
  return opts.default;
@@ -38966,12 +39064,19 @@ function boundArgs(args) {
38966
39064
  }
38967
39065
  function confirmationFromEnv(options) {
38968
39066
  const { tool, account, confirmToken, subject, instruction, args, env = process.env, spent: callerSpent, ...rest } = options;
39067
+ if (args === void 0 || args === null) {
39068
+ throw new TypeError(`confirmationFromEnv: ${tool} passed no args; pass the tool's validated arguments ({} for a tool with none).`);
39069
+ }
38969
39070
  const spent = callerSpent ?? spentTokenStoreFromEnv(env);
38970
39071
  const mode = readConfirmMode(env);
38971
39072
  const ttlSeconds = readConfirmTtl(env);
38972
39073
  const elicitationOff = readConfirmElicitation(env) === "off";
38973
- const bound = args === void 0 ? void 0 : boundArgs(args);
38974
- const bindingAdded = bound === void 0 || rest.binding ? rest : { ...rest, binding: { key: confirmKeyFromEnv(env), args: bound, ttlSeconds: ttlSeconds ?? DEFAULT_TTL_SECONDS2 } };
39074
+ const bound = boundArgs(args);
39075
+ const elicitationBound = account === void 0 ? { args: bound } : { account, args: bound };
39076
+ const bindingAdded = rest.binding ? rest : {
39077
+ ...rest,
39078
+ binding: { key: confirmKeyFromEnv(env), args: elicitationBound, ttlSeconds: ttlSeconds ?? DEFAULT_TTL_SECONDS2 }
39079
+ };
38975
39080
  const confirmation = elicitationOff ? { ...bindingAdded, elicitation: false } : bindingAdded;
38976
39081
  if (mode === "refuse" || ttlSeconds === void 0) {
38977
39082
  const reason = mode === "refuse" ? REFUSE_HINT : BAD_TTL_HINT;
@@ -38981,7 +39086,7 @@ function confirmationFromEnv(options) {
38981
39086
  unsupportedNote: confirmation.unsupportedNote ? `${confirmation.unsupportedNote} ${hint}` : hint
38982
39087
  };
38983
39088
  }
38984
- const boundSubject = bound === void 0 ? subject : async () => {
39089
+ const boundSubject = async () => {
38985
39090
  const read = await subject();
38986
39091
  if ("content" in read)
38987
39092
  return read;
@@ -39008,12 +39113,14 @@ var SERVER_PROTOCOL_VERSIONS = Object.freeze([
39008
39113
  ...SUPPORTED_PROTOCOL_VERSIONS
39009
39114
  ]);
39010
39115
  function hintResultOrRethrow(err) {
39116
+ if (err instanceof ProtocolError && err.code === ProtocolErrorCode.UrlElicitationRequired)
39117
+ throw err;
39011
39118
  if (err instanceof McpToolError && err.hint) {
39012
39119
  return errorResult(`${err.message}
39013
39120
 
39014
39121
  Hint: ${err.hint}`);
39015
39122
  }
39016
- throw err;
39123
+ return errorResult(messageOf(err));
39017
39124
  }
39018
39125
  function mcpRequestFrom(args) {
39019
39126
  const ctx = args.at(-1);
@@ -39367,10 +39474,34 @@ async function fileBlob(path, opts = {}) {
39367
39474
  return blob;
39368
39475
  }
39369
39476
 
39477
+ // node_modules/@chrischall/mcp-utils/dist/internal/ascii.js
39478
+ function asciiLower(s) {
39479
+ return s.replace(/[A-Z]+/g, (m) => m.toLowerCase());
39480
+ }
39481
+
39370
39482
  // node_modules/@chrischall/mcp-utils/dist/scrape/index.js
39371
39483
  var NEXT_DATA_MAX_CHARS = 16 * 1024 * 1024;
39372
39484
  function isCloudflareChallenge(html) {
39373
- return /_cf_chl_opt/.test(html) || /<title[^>]*>\s*Just a moment/i.test(html);
39485
+ return html.includes("_cf_chl_opt") || hasJustAMomentTitle(html);
39486
+ }
39487
+ var JUST_A_MOMENT = "just a moment";
39488
+ function hasJustAMomentTitle(html) {
39489
+ const lower = asciiLower(html);
39490
+ let i = 0;
39491
+ for (; ; ) {
39492
+ const open3 = lower.indexOf("<title", i);
39493
+ if (open3 < 0)
39494
+ return false;
39495
+ const gt = lower.indexOf(">", open3 + 6);
39496
+ if (gt < 0)
39497
+ return false;
39498
+ let j = gt + 1;
39499
+ while (j < lower.length && /\s/.test(lower[j]))
39500
+ j++;
39501
+ if (lower.startsWith(JUST_A_MOMENT, j))
39502
+ return true;
39503
+ i = gt + 1;
39504
+ }
39374
39505
  }
39375
39506
 
39376
39507
  // node_modules/@chrischall/mcp-utils/dist/http/net-atoms.js
@@ -39454,6 +39585,8 @@ function parseContentDispositionFilename(header) {
39454
39585
  // node_modules/@chrischall/mcp-utils/dist/http/index.js
39455
39586
  var RateLimitedError = class extends Error {
39456
39587
  status = 429;
39588
+ /** `'http'`: the far side answered, with a 429 (see `status`). */
39589
+ kind = "http";
39457
39590
  retryAfterMs;
39458
39591
  constructor(service, retryAfterMs) {
39459
39592
  super(`Rate limited (429) by ${service} after retries.`);
@@ -39464,6 +39597,8 @@ var RateLimitedError = class extends Error {
39464
39597
  };
39465
39598
  var RequestTimeoutError = class extends Error {
39466
39599
  timeoutMs;
39600
+ /** Always `'timeout'` unless a subclass says otherwise. */
39601
+ kind = "timeout";
39467
39602
  constructor(service, timeoutMs) {
39468
39603
  super(`Request to ${service} timed out after ${timeoutMs}ms.`);
39469
39604
  this.name = "RequestTimeoutError";
@@ -39483,6 +39618,8 @@ var ApiError = class extends Error {
39483
39618
  var EdgeBlockedError = class extends ApiError {
39484
39619
  /** Which edge refused the request, e.g. `'CloudFront'` or `'Cloudflare'`. */
39485
39620
  vendor;
39621
+ /** Always `'edge_blocked'`: the credential was never judged. */
39622
+ kind = "edge_blocked";
39486
39623
  /**
39487
39624
  * @param where The service the request was for, and — when the thrower knows
39488
39625
  * it — the method and path, which the message names.
@@ -39565,7 +39702,7 @@ var PositiveInt = external_exports.number().int().positive();
39565
39702
  var NonNegInt = external_exports.number().int().nonnegative();
39566
39703
  var NonEmptyString = external_exports.string().min(1);
39567
39704
  var IsoDate = external_exports.iso.date();
39568
- var IsoTime = external_exports.string().regex(/^([01]?\d|2[0-3]):[0-5]\d$/, "must be HH:MM (24h), e.g. 19:30");
39705
+ var IsoTime = external_exports.string().regex(/^([01]?\d|2[0-3]):[0-5]\d$/, "must be HH:MM (24h), e.g. 19:30").overwrite((v) => v.padStart(5, "0"));
39569
39706
  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.");
39570
39707
  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.');
39571
39708
  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.");
@@ -40124,7 +40261,9 @@ var TokenManager = class {
40124
40261
  /** Spend `rt`, then install and persist what comes back. */
40125
40262
  async exchange(rt) {
40126
40263
  if (rt === void 0) {
40127
- throw new Error("TokenManager: cannot refresh \u2014 no refresh token is available.");
40264
+ throw new McpToolError("TokenManager: cannot refresh \u2014 no refresh token is available.", {
40265
+ kind: "no_credential"
40266
+ });
40128
40267
  }
40129
40268
  const tok = await this.refreshFn(rt);
40130
40269
  this.tokens = {
@@ -40660,6 +40799,17 @@ function isPublicSuffix(host) {
40660
40799
  return false;
40661
40800
  }
40662
40801
 
40802
+ // node_modules/@fetchproxy/protocol/dist/ip-literal.js
40803
+ function isIpLiteralHost(host) {
40804
+ if (host.includes(":") || host.startsWith("["))
40805
+ return true;
40806
+ const labels = host.split(".");
40807
+ if (labels.length > 1 && labels[labels.length - 1] === "")
40808
+ labels.pop();
40809
+ const last = labels[labels.length - 1] ?? "";
40810
+ return /^[0-9]+$/.test(last) || /^0x[0-9a-f]*$/i.test(last);
40811
+ }
40812
+
40663
40813
  // node_modules/@fetchproxy/protocol/dist/validate.js
40664
40814
  var ProtocolError3 = class extends Error {
40665
40815
  constructor(message) {
@@ -41125,6 +41275,9 @@ function validateHello(raw) {
41125
41275
  if (!HOSTNAME_RE.test(d)) {
41126
41276
  throw new ProtocolError3(`hello.domains: invalid hostname ${JSON.stringify(d)}`);
41127
41277
  }
41278
+ if (isIpLiteralHost(d)) {
41279
+ throw new ProtocolError3(`hello.domains: ${JSON.stringify(d)} is an IP address \u2014 declare the site's DNS hostname; IP-literal origins (LAN, loopback, link-local) are not reachable through the bridge`);
41280
+ }
41128
41281
  if (isPublicSuffix(d)) {
41129
41282
  throw new ProtocolError3(`hello.domains: ${JSON.stringify(d)} is a public suffix \u2014 declare a registrable domain (e.g. "example.${d}"), not the suffix every site under it shares`);
41130
41283
  }
@@ -43250,7 +43403,7 @@ async function startHost(opts) {
43250
43403
  if (ownRetryableRefusal)
43251
43404
  resetSessionPromise();
43252
43405
  resolveOwnSession(ownSession);
43253
- } else {
43406
+ } else if (identified === "extension" && extensionWs === ws) {
43254
43407
  const slot = peers.get(frame.mcpId);
43255
43408
  if (slot)
43256
43409
  slot.ws.send(JSON.stringify(frame));
@@ -44276,6 +44429,9 @@ var FetchproxyServer = class {
44276
44429
  throw new Error("FetchproxyServer: opts.domains must be a non-empty array of hostnames");
44277
44430
  }
44278
44431
  for (const d of opts.domains) {
44432
+ if (typeof d === "string" && isIpLiteralHost(d)) {
44433
+ throw new Error(`FetchproxyServer: opts.domains entry ${JSON.stringify(d)} is an IP address \u2014 declare the site's DNS hostname; IP-literal origins (LAN, loopback, link-local) are not reachable through the bridge`);
44434
+ }
44279
44435
  if (typeof d === "string" && isPublicSuffix(d)) {
44280
44436
  throw new Error(`FetchproxyServer: opts.domains entry ${JSON.stringify(d)} is a public suffix \u2014 declare a registrable domain (e.g. "example.${d}"), not the suffix every site under it shares`);
44281
44437
  }
@@ -46210,6 +46366,9 @@ var FetchproxyServer = class {
46210
46366
  }
46211
46367
  };
46212
46368
 
46369
+ // node_modules/@fetchproxy/server/dist/bot-wall.js
46370
+ var CLOUDFLARE_TITLE_SCAN_MAX = 64 * 1024;
46371
+
46213
46372
  // node_modules/@fetchproxy/bootstrap/dist/index.js
46214
46373
  var defaultFactory = (opts) => new FetchproxyServer(opts);
46215
46374
  var CAPTURE_BOOTSTRAP_DEADLINE_MS = 45e3;
@@ -46401,12 +46560,6 @@ var BootstrapDisabledError = class extends Error {
46401
46560
  };
46402
46561
 
46403
46562
  // node_modules/@chrischall/mcp-utils/dist/healthcheck/index.js
46404
- function statusOf2(err) {
46405
- if (typeof err !== "object" || err === null)
46406
- return void 0;
46407
- const s = err.status ?? err.statusCode;
46408
- return typeof s === "number" ? s : void 0;
46409
- }
46410
46563
  var CREDENTIAL_ARMS = /* @__PURE__ */ new Set([
46411
46564
  "ok",
46412
46565
  "no_credential",
@@ -46422,6 +46575,9 @@ var CREDENTIAL_ARMS = /* @__PURE__ */ new Set([
46422
46575
  function isArm(kind) {
46423
46576
  return kind !== void 0 && CREDENTIAL_ARMS.has(kind);
46424
46577
  }
46578
+ function fromSource(source) {
46579
+ return source !== null ? ` from '${source}'` : "";
46580
+ }
46425
46581
  function credentialHint(arm, prefix, hostLabel, source) {
46426
46582
  switch (arm) {
46427
46583
  case "ok":
@@ -46429,15 +46585,15 @@ function credentialHint(arm, prefix, hostLabel, source) {
46429
46585
  case "no_credential":
46430
46586
  return `No credential resolved. Nothing was available to authenticate with \u2014 sign in and reconnect the connector so ${prefix} receives a token, or set the documented environment variable.`;
46431
46587
  case "credential_rejected":
46432
- return `${hostLabel} rejected the credential from '${source}'. It is present but no longer valid \u2014 most often expired or revoked upstream. Re-authenticate and reconnect; retrying will not fix it.`;
46588
+ return `${hostLabel} rejected the credential${fromSource(source)}. It is present but no longer valid \u2014 most often expired or revoked upstream. Re-authenticate and reconnect; retrying will not fix it.`;
46433
46589
  case "edge_blocked":
46434
46590
  return `${hostLabel} refused the request at its CDN/WAF before it reached the API, so the credential${source !== null ? ` from '${source}'` : ""} was never judged \u2014 re-signing in or rotating it will not help. This is usually a block on this host's IP address or request fingerprint: route through the ContextMint Bridge if this connector supports it, or retry later from a different network.`;
46435
46591
  case "session_expired":
46436
- return `The credential from '${source}' is configured, but no session is live \u2014 ${hostLabel} served a sign-in page rather than the data. Sign in again; a cookie-session portal expires these on its own, so this recurs between uses.`;
46592
+ return `The credential${fromSource(source)} is configured, but no session is live \u2014 ${hostLabel} served a sign-in page rather than the data. Sign in again; a cookie-session portal expires these on its own, so this recurs between uses.`;
46437
46593
  case "verification_pending":
46438
46594
  return `${hostLabel} is holding the sign-in on a second factor rather than refusing it. Supply the verification code the ACCOUNT HOLDER received \u2014 the credential itself is not the problem, so changing it will not help.`;
46439
46595
  case "timeout":
46440
- return `The credential from '${source}' resolved, but ${hostLabel} did not answer in time. Usually transient \u2014 retry. If it persists, ${hostLabel} is slow or unreachable from here.`;
46596
+ return `${source !== null ? `The credential from '${source}' resolved, but ` : ""}${hostLabel} did not answer in time. Usually transient \u2014 retry. If it persists, ${hostLabel} is slow or unreachable from here.`;
46441
46597
  case "http":
46442
46598
  return `${hostLabel} answered with an error status that is not an auth rejection. That is USUALLY a ${hostLabel}-side problem rather than an auth one \u2014 but a 404 here more often means the probe path is wrong than that ${hostLabel} is broken, so check error.message and probe.url before concluding anything about the credential.`;
46443
46599
  case "transport":
@@ -46458,7 +46614,8 @@ async function runCredentialHealthcheck(args) {
46458
46614
  state = await resolveCredential();
46459
46615
  } catch (e) {
46460
46616
  const edge = edgeBlockOf(e);
46461
- const classified = classifyThrown?.(e) ?? (edge !== null ? { kind: "edge_blocked", detail: { vendor: edge.vendor } } : void 0);
46617
+ const declared = errorKindOf(e);
46618
+ const classified = classifyThrown?.(e) ?? (edge !== null ? { kind: "edge_blocked", detail: { vendor: edge.vendor } } : declared !== void 0 ? { kind: declared } : void 0);
46462
46619
  const result2 = {
46463
46620
  ok: false,
46464
46621
  // Still false, and still no source: a classification explains WHY
@@ -46505,10 +46662,11 @@ async function runCredentialHealthcheck(args) {
46505
46662
  try {
46506
46663
  await probeFn();
46507
46664
  } catch (e) {
46508
- status = statusOf2(e);
46665
+ status = errorStatusOf(e);
46509
46666
  const aborted2 = e instanceof Error && e.name === "AbortError";
46510
46667
  const edge = edgeBlockOf(e);
46511
- arm = edge !== null ? "edge_blocked" : status === 401 || status === 403 ? "credential_rejected" : status !== void 0 ? "http" : aborted2 || /timeout|timed out|ETIMEDOUT/i.test(messageOf(e)) ? "timeout" : /fetch failed|ENOTFOUND|ECONNREFUSED|ECONNRESET|network/i.test(messageOf(e)) ? "transport" : "unknown";
46668
+ const declared = errorKindOf(e);
46669
+ arm = edge !== null ? "edge_blocked" : declared !== void 0 ? declared : status === 401 || status === 403 ? "credential_rejected" : status !== void 0 ? "http" : aborted2 || /timeout|timed out|ETIMEDOUT/i.test(messageOf(e)) ? "timeout" : /fetch failed|ENOTFOUND|ECONNREFUSED|ECONNRESET|network/i.test(messageOf(e)) ? "transport" : "unknown";
46512
46670
  let kind = arm;
46513
46671
  let detail = edge !== null ? { vendor: edge.vendor } : void 0;
46514
46672
  const custom2 = classifyThrown?.(e);
@@ -46566,6 +46724,7 @@ var OFW_PROTOCOL_HEADERS = {
46566
46724
  var OFW_TOKEN_TTL_MS = 6 * 60 * 60 * 1e3;
46567
46725
  var OFW_TOKEN_EXPIRY_SKEW_MS = 5 * 60 * 1e3;
46568
46726
  var ALLOWED_HOST = new URL(BASE_URL).host;
46727
+ var RECEIPT_HOST_SUFFIX = ".oaiusercontent.com";
46569
46728
  function assertOfwUrl(rawUrl) {
46570
46729
  let host;
46571
46730
  try {
@@ -46654,64 +46813,29 @@ async function loginWithPassword(username, password) {
46654
46813
  throw new Error(`OFW login returned unexpected response (${contentType || "no content-type"}): ${body.substring(0, 200)}`);
46655
46814
  }
46656
46815
  const data = await response.json();
46816
+ const record2 = typeof data === "object" && data !== null ? data : null;
46817
+ const token = record2?.auth;
46818
+ if (typeof token !== "string" || token === "") {
46819
+ throw new Error(describeTokenlessLogin(record2));
46820
+ }
46657
46821
  return {
46658
- token: data.auth,
46822
+ token,
46659
46823
  expiresAt: new Date(Date.now() + OFW_TOKEN_TTL_MS)
46660
46824
  };
46661
46825
  }
46662
-
46663
- // package.json
46664
- var package_default = {
46665
- name: "ofw-mcp",
46666
- version: "2.20.3",
46667
- license: "MIT",
46668
- mcpName: "io.github.chrischall/ofw-mcp",
46669
- description: "OurFamilyWizard MCP server for Claude \u2014 developed and maintained by AI (Claude Code)",
46670
- author: "Claude Code (AI) <https://www.anthropic.com/claude>",
46671
- repository: {
46672
- type: "git",
46673
- url: "git+https://github.com/chrischall/ofw-mcp.git"
46674
- },
46675
- type: "module",
46676
- engines: {
46677
- node: ">=22.5.0"
46678
- },
46679
- bin: {
46680
- "ofw-mcp": "dist/index.js"
46681
- },
46682
- files: [
46683
- "dist",
46684
- ".claude-plugin",
46685
- "skills",
46686
- ".mcp.json",
46687
- "server.json",
46688
- "mint.yaml"
46689
- ],
46690
- scripts: {
46691
- build: "tsc && npm run bundle",
46692
- bundle: `esbuild src/index.ts --bundle --platform=node --format=esm --external:dotenv --banner:js='import { createRequire as __createRequire } from "module"; const require = __createRequire(import.meta.url);' --outfile=dist/bundle.js`,
46693
- dev: "node --env-file=.env dist/index.js",
46694
- test: "npm run typecheck && vitest run",
46695
- "test:coverage": "npm run typecheck && vitest run --coverage",
46696
- "test:watch": "vitest",
46697
- typecheck: "tsc -p tsconfig.json --noEmit"
46698
- },
46699
- dependencies: {
46700
- "@chrischall/mcp-utils": "^2.15.0",
46701
- "@fetchproxy/bootstrap": "^3.6.0",
46702
- "@modelcontextprotocol/server": "^2.3.0",
46703
- dotenv: "^18.0.0",
46704
- zod: "^4.6.5"
46705
- },
46706
- devDependencies: {
46707
- "@modelcontextprotocol/client": "^2.3.0",
46708
- "@types/node": "^26.0.0",
46709
- "@vitest/coverage-v8": "^5.0.0",
46710
- esbuild: "^0.28.0",
46711
- typescript: "^7.0.2",
46712
- vitest: "^5.0.0"
46826
+ function describeTokenlessLogin(record2) {
46827
+ const base = "OFW login returned no token, so it did not sign in";
46828
+ if (record2 === null) {
46829
+ return `${base} (the response was not a JSON object). Sign in to OurFamilyWizard in a browser to check the account, then try again.`;
46713
46830
  }
46714
- };
46831
+ const keys = Object.keys(record2);
46832
+ const said = typeof record2.message === "string" ? ` OFW said: "${record2.message.substring(0, 200)}".` : "";
46833
+ return `${base} (response fields: ${keys.length > 0 ? keys.join(", ") : "none"}).${said} The account may be locked or need a verification step: sign in to OurFamilyWizard in a browser to clear it, then try again.`;
46834
+ }
46835
+
46836
+ // src/version.ts
46837
+ var SERVER_NAME = "ofw-mcp";
46838
+ var VERSION = "2.20.5";
46715
46839
 
46716
46840
  // src/auth.ts
46717
46841
  function fetchproxyDisabled() {
@@ -46739,8 +46863,8 @@ async function resolveAuth() {
46739
46863
  pattern.fetchproxy = async () => {
46740
46864
  try {
46741
46865
  const session = await bootstrap({
46742
- serverName: package_default.name,
46743
- version: package_default.version,
46866
+ serverName: SERVER_NAME,
46867
+ version: VERSION,
46744
46868
  // OFW serves both ofw.ourfamilywizard.com and www.ourfamilywizard.com;
46745
46869
  // the API + auth token live on the apex. The extension matches on
46746
46870
  // suffix, so listing the apex covers both.
@@ -46841,12 +46965,16 @@ try {
46841
46965
  function debugLogEnabled() {
46842
46966
  return parseBoolEnv("OFW_DEBUG_LOG");
46843
46967
  }
46844
- var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
46968
+ function debugBody(text) {
46969
+ if (process.env.MCP_DATA_DIR) return `<${text.length} chars, elided on a hosted server>`;
46970
+ return text;
46971
+ }
46972
+ var DEFAULT_REQUEST_TIMEOUT_MS2 = 3e4;
46845
46973
  function getRequestTimeoutMs() {
46846
46974
  const raw = process.env.OFW_REQUEST_TIMEOUT_MS;
46847
- if (typeof raw !== "string" || raw.trim().length === 0) return DEFAULT_REQUEST_TIMEOUT_MS;
46975
+ if (typeof raw !== "string" || raw.trim().length === 0) return DEFAULT_REQUEST_TIMEOUT_MS2;
46848
46976
  const n = Number(raw.trim());
46849
- return Number.isFinite(n) && n > 0 ? n : DEFAULT_REQUEST_TIMEOUT_MS;
46977
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_REQUEST_TIMEOUT_MS2;
46850
46978
  }
46851
46979
  var OFW_REFRESH_SENTINEL = "ofw";
46852
46980
  async function throwIfEdgeBlocked(response, method, path) {
@@ -46886,13 +47014,17 @@ var OFWClient = class {
46886
47014
  // deployment. Left undefined by the stdio path, which falls back to the
46887
47015
  // global resolver, keeping that behaviour byte-for-byte identical.
46888
47016
  authResolver;
47017
+ // Which path minted the token currently held. Unset while the token is one
47018
+ // the TokenManager restored from the session cache — nothing was minted.
47019
+ mintedBy;
46889
47020
  constructor(opts) {
46890
47021
  this.authResolver = opts?.resolveAuth;
46891
47022
  }
46892
47023
  getTokenManager() {
46893
47024
  if (!this.tokenManager) {
46894
47025
  const mint = async () => {
46895
- const { token, expiresAt } = await (this.authResolver ?? resolveAuth)();
47026
+ const { token, expiresAt, source } = await (this.authResolver ?? resolveAuth)();
47027
+ this.mintedBy = source;
46896
47028
  return {
46897
47029
  accessToken: token,
46898
47030
  refreshToken: OFW_REFRESH_SENTINEL,
@@ -46917,11 +47049,27 @@ var OFWClient = class {
46917
47049
  }
46918
47050
  return this.tokenManager;
46919
47051
  }
47052
+ /**
47053
+ * The credential's source and expiry, answered from the TokenManager — the
47054
+ * same token, cache and single-flight mint every tool uses.
47055
+ *
47056
+ * `ofw_healthcheck` used to call `resolveAuth()` directly, which is a full
47057
+ * login (or a fresh browser-bridge spin-up) on every healthcheck, ignoring
47058
+ * the six-hour token already held. Going through the manager means a held or
47059
+ * cached token costs nothing, and a mint, when one is needed, is the one the
47060
+ * next real tool call would have made anyway. A mint failure propagates
47061
+ * unchanged so the healthcheck can classify it.
47062
+ */
47063
+ async credentialStatus() {
47064
+ const tokens = this.getTokenManager();
47065
+ await tokens.getAccessToken();
47066
+ return { source: this.mintedBy ?? "cache", expiresAt: new Date(tokens.getExpiresAt()) };
47067
+ }
46920
47068
  async request(method, path, body) {
46921
47069
  return this.fetchAuthed(method, path, body, "application/json", async (response) => {
46922
47070
  const text = await response.text();
46923
47071
  if (debugLogEnabled()) {
46924
- console.error(`[ofw-debug] response body: ${text || "<empty>"}`);
47072
+ console.error(`[ofw-debug] response body: ${text ? debugBody(text) : "<empty>"}`);
46925
47073
  }
46926
47074
  return text ? JSON.parse(text) : null;
46927
47075
  });
@@ -46995,7 +47143,7 @@ var OFWClient = class {
46995
47143
  const url2 = `${BASE_URL}${path}`;
46996
47144
  assertOfwUrl(url2);
46997
47145
  if (debugLogEnabled()) {
46998
- const bodyPreview = body === void 0 ? "<none>" : isFormData ? `<FormData entries=${Array.from(body.keys()).join(",")}>` : JSON.stringify(body);
47146
+ const bodyPreview = body === void 0 ? "<none>" : isFormData ? `<FormData entries=${Array.from(body.keys()).join(",")}>` : debugBody(JSON.stringify(body));
46999
47147
  console.error(`[ofw-debug] \u2192 ${method} ${url2}${isRetry ? " (retry)" : ""}`);
47000
47148
  console.error(`[ofw-debug] headers: ${redactSecrets(JSON.stringify(headers))}`);
47001
47149
  console.error(`[ofw-debug] body: ${bodyPreview}`);
@@ -47279,6 +47427,16 @@ function jsonResponse(data) {
47279
47427
  return minifiedResult(normalizeTimestampsInValue(data));
47280
47428
  }
47281
47429
  var textResponse = rawTextResult;
47430
+ function jsonText(data) {
47431
+ return JSON.stringify(normalizeTimestampsInValue(data));
47432
+ }
47433
+ function notedJsonResponse(notes, data, fallback) {
47434
+ const text = data ? jsonText(data) : fallback;
47435
+ const prefix = notes.filter((n) => n !== null).join("\n\n");
47436
+ return textResponse(prefix ? `${prefix}
47437
+
47438
+ ${text}` : text);
47439
+ }
47282
47440
  function jsonErrorResponse(data) {
47283
47441
  return { ...jsonResponse(data), isError: true };
47284
47442
  }
@@ -47411,14 +47569,19 @@ async function postMessageAndRefetch(client2, payload, detailSchema, ctx) {
47411
47569
  function registerUserTools(server, client2) {
47412
47570
  server.registerTool("ofw_get_profile", {
47413
47571
  description: "Get current user and co-parent profile information from OurFamilyWizard",
47414
- annotations: { readOnlyHint: true }
47572
+ annotations: { readOnlyHint: true, openWorldHint: true }
47415
47573
  }, async () => {
47416
47574
  const data = await client2.request("GET", "/pub/v2/profiles");
47417
47575
  return jsonResponse(data);
47418
47576
  });
47419
47577
  server.registerTool("ofw_get_notifications", {
47420
47578
  description: "Get OurFamilyWizard dashboard summary: unread message count, upcoming events, outstanding expenses. Note: updates your last-seen status.",
47421
- annotations: { readOnlyHint: true }
47579
+ // Not read-only: this GET updates the last-seen status the co-parent can
47580
+ // see, a side effect the read tools deliberately avoid (see _shared.ts).
47581
+ // readOnlyHint:true would let a host auto-approve it without a prompt.
47582
+ // Destructive by the inverse test: nothing here restores a last-seen
47583
+ // status the co-parent has already been shown.
47584
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true }
47422
47585
  }, async () => {
47423
47586
  const data = await client2.request("GET", "/pub/v1/users/useraccountstatus");
47424
47587
  return jsonResponse(data);
@@ -47426,7 +47589,7 @@ function registerUserTools(server, client2) {
47426
47589
  }
47427
47590
 
47428
47591
  // src/tools/healthcheck.ts
47429
- function registerHealthcheckTools(server, client2, resolve7 = resolveAuth) {
47592
+ function registerHealthcheckTools(server, client2) {
47430
47593
  registerCredentialHealthcheckTool({
47431
47594
  server,
47432
47595
  prefix: "ofw",
@@ -47437,12 +47600,13 @@ function registerHealthcheckTools(server, client2, resolve7 = resolveAuth) {
47437
47600
  probePath: "/pub/v2/profiles",
47438
47601
  resolveCredential: async () => {
47439
47602
  try {
47440
- const auth = await resolve7();
47603
+ const { source, expiresAt } = await client2.credentialStatus();
47441
47604
  return {
47442
- source: auth.source,
47605
+ source,
47443
47606
  // Never the token. Expiry is the fact that explains a connector
47444
- // that worked an hour ago and does not now.
47445
- detail: auth.expiresAt ? { expires_at: auth.expiresAt.toISOString() } : void 0
47607
+ // that worked an hour ago and does not now — and this is the one
47608
+ // the client itself will re-authenticate at.
47609
+ detail: { expires_at: expiresAt.toISOString() }
47446
47610
  };
47447
47611
  } catch (e) {
47448
47612
  if (isNoAuthConfigured(e)) return { source: null };
@@ -47465,7 +47629,7 @@ function registerHealthcheckTools(server, client2, resolve7 = resolveAuth) {
47465
47629
  // Now means exactly what it says: nothing is set up. A configured path
47466
47630
  // that was tried and failed no longer lands here.
47467
47631
  no_credential: "No OFW credential is configured. Either set OFW_USERNAME + OFW_PASSWORD, or install ContextMint Bridge and sign in to ourfamilywizard.com in a tab (unsetting OFW_DISABLE_FETCHPROXY if you set it).",
47468
- credential_rejected: "OurFamilyWizard rejected the credential. If it came from `env`, the password changed or the account is locked; if from `fetchproxy`, the browser session expired \u2014 sign in again in the tab. Retrying will not fix either."
47632
+ credential_rejected: "OurFamilyWizard rejected the credential. If it came from `env` or `cache`, the password changed or the account is locked; if from `fetchproxy`, the browser session expired \u2014 sign in again in the tab. Retrying will not fix either."
47469
47633
  }
47470
47634
  });
47471
47635
  }
@@ -47896,9 +48060,7 @@ function readCacheIdentity() {
47896
48060
  return readEnvVar("OFW_CACHE_IDENTITY") ?? readEnvVar("OFW_USERNAME") ?? "_default";
47897
48061
  }
47898
48062
  function getCacheDir() {
47899
- const override = process.env.OFW_CACHE_DIR;
47900
- if (override && override.trim().length > 0) return override.trim();
47901
- return join10(homedir4(), ".cache", "ofw-mcp");
48063
+ return readEnvVar("OFW_CACHE_DIR") ?? join10(homedir4(), ".cache", "ofw-mcp");
47902
48064
  }
47903
48065
  function getCacheDbPath() {
47904
48066
  const identity = readCacheIdentity();
@@ -48447,6 +48609,10 @@ function confirmWrite(ctx, opts) {
48447
48609
  details: opts.preview,
48448
48610
  unsupportedNote: "Complete this action on ourfamilywizard.com instead.",
48449
48611
  tool: opts.tool,
48612
+ // One OFW account per process (OFW_USERNAME / the captured session), so
48613
+ // there is no principal to distinguish; mcp-utils 3 makes saying so explicit.
48614
+ account: void 0,
48615
+ args: opts.args,
48450
48616
  confirmToken: opts.confirmToken,
48451
48617
  subject: () => ({
48452
48618
  target: opts.target,
@@ -48463,13 +48629,26 @@ function stateRevision(value) {
48463
48629
 
48464
48630
  // src/extract/inflate.ts
48465
48631
  var MAX_DECOMPRESSED_BYTES = 32 * 1024 * 1024;
48632
+ var MAX_TOTAL_DECOMPRESSED_BYTES = 64 * 1024 * 1024;
48633
+ var DecompressionBudget = class {
48634
+ constructor(limit = MAX_TOTAL_DECOMPRESSED_BYTES) {
48635
+ this.limit = limit;
48636
+ }
48637
+ limit;
48638
+ used = 0;
48639
+ /** Charge `bytes`; throws once the document's total passes the limit. */
48640
+ charge(bytes, label) {
48641
+ this.used += bytes;
48642
+ if (this.used > this.limit) throw new DecompressionLimitError(label, this.limit, "total");
48643
+ }
48644
+ };
48466
48645
  var DecompressionLimitError = class extends Error {
48467
- constructor(label, limit) {
48468
- super(`${label} expands past the ${limit}-byte decompression cap`);
48646
+ constructor(label, limit, scope = "member") {
48647
+ super(scope === "total" ? `${label} pushes the document past its ${limit}-byte total decompression cap` : `${label} expands past the ${limit}-byte decompression cap`);
48469
48648
  this.name = "DecompressionLimitError";
48470
48649
  }
48471
48650
  };
48472
- async function inflateBounded(data, format, limit, label) {
48651
+ async function inflateBounded(data, format, limit, label, budget) {
48473
48652
  const stream = new Blob([data]).stream().pipeThrough(new DecompressionStream(format));
48474
48653
  const reader = stream.getReader();
48475
48654
  const chunks = [];
@@ -48482,6 +48661,12 @@ async function inflateBounded(data, format, limit, label) {
48482
48661
  await reader.cancel();
48483
48662
  throw new DecompressionLimitError(label, limit);
48484
48663
  }
48664
+ try {
48665
+ budget?.charge(value.length, label);
48666
+ } catch (err) {
48667
+ await reader.cancel();
48668
+ throw err;
48669
+ }
48485
48670
  chunks.push(value);
48486
48671
  }
48487
48672
  return Buffer.concat(chunks);
@@ -48528,6 +48713,7 @@ async function readZip(bytes, opts = {}) {
48528
48713
  p += 46 + nameLen + extraLen + commentLen;
48529
48714
  }
48530
48715
  const cache = /* @__PURE__ */ new Map();
48716
+ const budget = new DecompressionBudget(opts.maxTotalUncompressedBytes);
48531
48717
  async function read(name) {
48532
48718
  const cached2 = cache.get(name);
48533
48719
  if (cached2) return cached2;
@@ -48545,8 +48731,10 @@ async function readZip(bytes, opts = {}) {
48545
48731
  const start = lo + 30 + bytes.readUInt16LE(lo + 26) + bytes.readUInt16LE(lo + 28);
48546
48732
  const raw = bytes.subarray(start, start + entry.compressedSize);
48547
48733
  let out;
48548
- if (entry.method === 0) out = Buffer.from(raw);
48549
- else if (entry.method === 8) out = await inflateBounded(raw, "deflate-raw", limit, `ZIP member ${name}`);
48734
+ if (entry.method === 0) {
48735
+ budget.charge(raw.length, `ZIP member ${name}`);
48736
+ out = Buffer.from(raw);
48737
+ } else if (entry.method === 8) out = await inflateBounded(raw, "deflate-raw", limit, `ZIP member ${name}`, budget);
48550
48738
  else throw new Error(`unsupported ZIP compression method ${entry.method} for ${name}`);
48551
48739
  cache.set(name, out);
48552
48740
  return out;
@@ -48989,14 +49177,17 @@ function streamBytes(bytes, obj) {
48989
49177
  const end = bytes.indexOf("endstream", from, "latin1");
48990
49178
  return bytes.subarray(from, end === -1 ? void 0 : end);
48991
49179
  }
48992
- async function decodeStream(bytes, obj) {
49180
+ async function decodeStream(bytes, obj, budget) {
48993
49181
  const raw = streamBytes(bytes, obj);
48994
49182
  if (!raw) return null;
48995
49183
  const filter = /\/Filter\s*(\/\w+|\[[^\]]*\])/.exec(obj.body)?.[1] ?? "";
48996
- if (filter === "") return raw;
49184
+ if (filter === "") {
49185
+ budget.charge(raw.length, "PDF stream");
49186
+ return raw;
49187
+ }
48997
49188
  if (!filter.includes("FlateDecode")) return null;
48998
49189
  try {
48999
- return await inflateBounded(raw, "deflate", MAX_DECOMPRESSED_BYTES, "PDF stream");
49190
+ return await inflateBounded(raw, "deflate", MAX_DECOMPRESSED_BYTES, "PDF stream", budget);
49000
49191
  } catch (err) {
49001
49192
  if (err instanceof DecompressionLimitError) throw err;
49002
49193
  return null;
@@ -49123,6 +49314,7 @@ async function extractPdf(bytes, opts = {}) {
49123
49314
  const objects = parseObjects(text);
49124
49315
  const pageObjects = orderedPages(objects);
49125
49316
  if (pageObjects.length === 0) throw new Error("no pages found in the PDF");
49317
+ const budget = new DecompressionBudget(opts.maxTotalDecompressedBytes);
49126
49318
  const pages = [];
49127
49319
  const omitted = [];
49128
49320
  for (let i = 0; i < pageObjects.length; i++) {
@@ -49136,7 +49328,7 @@ async function extractPdf(bytes, opts = {}) {
49136
49328
  for (const ref of refsIn(contentsFragment)) {
49137
49329
  const streamObj = objects.get(ref);
49138
49330
  if (!streamObj) continue;
49139
- const decoded = await decodeStream(bytes, streamObj);
49331
+ const decoded = await decodeStream(bytes, streamObj, budget);
49140
49332
  if (decoded) raw += `${decoded.toString("latin1")}
49141
49333
  `;
49142
49334
  }
@@ -49767,9 +49959,12 @@ async function guardedCacheRead(o) {
49767
49959
  if (unverifiable(value) && o.autoRefresh) {
49768
49960
  await syncAll(o.client, {
49769
49961
  folders: o.folders,
49770
- // Same ceiling ofw_sync_messages applies: an automatic refresh must never
49771
- // stamp unread inbox messages as a side effect of a list read.
49772
- fetchUnreadBodies: getAllowMarkRead() && getFetchUnreadBodies(),
49962
+ // An automatic refresh must never stamp unread inbox messages as a side
49963
+ // effect of a list read — not even when OFW_FETCH_UNREAD_BODIES=true sets
49964
+ // ofw_sync_messages' default. The tools that call this are annotated
49965
+ // readOnlyHint:true, and fetching an unread body marks it READ and stamps
49966
+ // a co-parent-visible "First Viewed" time that cannot be undone.
49967
+ fetchUnreadBodies: false,
49773
49968
  maxRequests: getSyncMaxRequests()
49774
49969
  }, o.cache);
49775
49970
  refreshed = true;
@@ -49823,7 +50018,7 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
49823
50018
  const allowDrafts = writeMode !== "none";
49824
50019
  server.registerTool("ofw_list_message_folders", {
49825
50020
  description: "List OurFamilyWizard message folders (inbox, sent, etc.) and their unread counts. Fetched LIVE from OFW, so the counts are current. Returns folder IDs needed to call ofw_list_messages. Does NOT return message content.",
49826
- annotations: { readOnlyHint: true }
50021
+ annotations: { readOnlyHint: true, openWorldHint: true }
49827
50022
  }, async () => {
49828
50023
  const data = await client2.request("GET", "/pub/v1/messageFolders?includeFolderCounts=true");
49829
50024
  const freshness = await buildFreshness(cacheProvider(), { source: "live", folders: [] });
@@ -49831,7 +50026,9 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
49831
50026
  });
49832
50027
  server.registerTool("ofw_list_messages", {
49833
50028
  description: 'List messages from the local OurFamilyWizard cache. Supports filtering by folder, date range, and a substring query on subject+body. Pagination is offset-based (1-based `page`) but if you know what you want (a date range, a topic), prefer the filters over walking pages \u2014 the cache may have 1000+ messages. Results are newest-first by default; `sort:"oldest"` starts at the old end of a range instead of paging to it. Returns an explicit `complete` boolean describing the RESULT SET: true means "this is every message on OurFamilyWizard matching these filters as of freshness.asOf" \u2014 check it before asserting a count. An empty result from a cache that is not verified-fresh is REFUSED (result:"UNVERIFIED_EMPTY") rather than reported as an absence; pass autoRefresh:true to sync and answer instead.',
49834
- annotations: { readOnlyHint: true },
50029
+ // Read-only even with autoRefresh: guardedCacheRead's sync never fetches
50030
+ // unread inbox bodies, so a list call cannot stamp "First Viewed".
50031
+ annotations: { readOnlyHint: true, openWorldHint: true },
49835
50032
  inputSchema: external_exports.object({
49836
50033
  folderId: external_exports.string().describe('Folder name: "inbox", "sent", or "both" (default "both")').optional(),
49837
50034
  page: external_exports.number().int().min(1).describe("Page number (default 1)").optional(),
@@ -49942,7 +50139,7 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
49942
50139
  });
49943
50140
  server.registerTool("ofw_get_message", {
49944
50141
  description: 'Get a single OurFamilyWizard message OR draft by ID. Reads from local cache when available; otherwise fetches from OFW \u2014 and for an UNREAD INBOX message that fetch marks it read and stamps a "First Viewed" time the co-parent can see, which is part of the record and cannot be undone. Pass allowMarkRead:false to refuse such a fetch instead (cached bodies, sent messages and already-read messages are unaffected, because none of them stamp anything). For ids that match a draft (in the drafts cache), the response carries folder="drafts" and the body/subject/recipients reflect the drafts cache (which ofw_sync_messages keeps fresh) \u2014 drafts have no `fromUser`, and `sentAt`/`fetchedBodyAt` mirror the draft\'s `modifiedAt`. For inbox/sent messages, folder is "inbox" or "sent" as before.',
49945
- annotations: { readOnlyHint: false, destructiveHint: true },
50142
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
49946
50143
  inputSchema: external_exports.object({
49947
50144
  messageId: external_exports.string().describe("Message ID (also accepts draft IDs \u2014 drafts are routed via the drafts cache)"),
49948
50145
  allowMarkRead: external_exports.boolean().describe("Default true (the long-standing behaviour). Set false to refuse a fetch that would mark an unread INBOX message as READ on OurFamilyWizard \u2014 an irreversible, co-parent-visible change to the record. Reads that cannot stamp anything (a cached body, a sent message, an already-read message) still succeed. The server-wide OFW_ALLOW_MARK_READ=false is a ceiling this argument cannot raise.").optional(),
@@ -50074,7 +50271,7 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
50074
50271
  });
50075
50272
  if (allowSend) server.registerTool("ofw_send_message", {
50076
50273
  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. If the send request times out or drops without a definitive answer, the result is SEND_UNCONFIRMED: the message may already have been delivered, so do NOT retry until a sent-folder sync (or ourfamilywizard.com) shows it did not go out. 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. " + CONFIRM_NOTE,
50077
- annotations: { readOnlyHint: false, destructiveHint: true },
50274
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
50078
50275
  inputSchema: external_exports.object({
50079
50276
  subject: external_exports.string().describe("Message subject. Required unless draftId/messageId is given (then it overrides the server draft's subject).").optional(),
50080
50277
  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(),
@@ -50177,6 +50374,7 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
50177
50374
  source: draftRef !== void 0 ? { draftId: draftRef, deleteDraftAfterSend: deleteOnSuccess } : "composed from the call arguments"
50178
50375
  };
50179
50376
  const gate = await confirmWrite(ctx, {
50377
+ args,
50180
50378
  tool: "ofw_send_message",
50181
50379
  action: "ofw.message.send",
50182
50380
  message: "Review and confirm this OurFamilyWizard message. Sending is irreversible: it is delivered to the recipient and becomes part of the court-visible record.",
@@ -50321,11 +50519,11 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
50321
50519
  } : {},
50322
50520
  ...persisted
50323
50521
  };
50324
- const text = responseObj ? JSON.stringify(responseObj, null, 2) : "Message sent successfully.";
50325
- const notes = [guardNote, rewriteNote, verifyNote, threadNote, unconfirmedNote, retainNote].filter((n) => n !== null).join("\n\n");
50326
- return textResponse(notes ? `${notes}
50327
-
50328
- ${text}` : text);
50522
+ return notedJsonResponse(
50523
+ [guardNote, rewriteNote, verifyNote, threadNote, unconfirmedNote, retainNote],
50524
+ responseObj,
50525
+ "Message sent successfully."
50526
+ );
50329
50527
  });
50330
50528
  async function guardDestructiveDraftOp(input2) {
50331
50529
  const { cache, draftId, expectedRevision, force, action } = input2;
@@ -50366,10 +50564,8 @@ ${text}` : text);
50366
50564
  ok: true,
50367
50565
  note: `WARNING: force:true overrode a ${verdict.verdict} freshness verdict on draft ${draftId}. ${verdict.reason} ${echoed}
50368
50566
 
50369
- ${JSON.stringify(
50370
- { overwrittenServerDraft: server2 === null ? null : { ...server2, revision: draftRevision(server2) } },
50371
- null,
50372
- 2
50567
+ ${jsonText(
50568
+ { overwrittenServerDraft: server2 === null ? null : { ...server2, revision: draftRevision(server2) } }
50373
50569
  )}`,
50374
50570
  server: server2
50375
50571
  };
@@ -50387,7 +50583,7 @@ ${JSON.stringify(
50387
50583
  }
50388
50584
  server.registerTool("ofw_list_drafts", {
50389
50585
  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.',
50390
- annotations: { readOnlyHint: false, destructiveHint: false },
50586
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
50391
50587
  inputSchema: external_exports.object({
50392
50588
  page: external_exports.number().int().min(1).describe("Page number (default 1)").optional(),
50393
50589
  size: external_exports.number().int().min(1).describe("Drafts per page (default 50)").optional(),
@@ -50485,7 +50681,10 @@ ${JSON.stringify(
50485
50681
  });
50486
50682
  if (allowDrafts) server.registerTool("ofw_save_draft", {
50487
50683
  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.",
50488
- annotations: { readOnlyHint: false, destructiveHint: false },
50684
+ // Destructive: with messageId, saving REPLACES the draft by creating a new
50685
+ // one and deleting the old (OFW has no draft update), so the old draft's
50686
+ // server content is gone. Not idempotent: every call mints a new draft id.
50687
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
50489
50688
  inputSchema: external_exports.object({
50490
50689
  subject: external_exports.string().describe("Message subject"),
50491
50690
  body: external_exports.string().describe("Message body text"),
@@ -50632,16 +50831,16 @@ ${JSON.stringify(
50632
50831
  ...warnings.length > 0 ? { warnings } : {},
50633
50832
  ...recipientsNote !== null ? { recipientsNote } : {}
50634
50833
  } : raw;
50635
- const text = responseObj ? JSON.stringify(responseObj, null, 2) : "Draft saved.";
50636
50834
  const warnNote = warnings.length > 0 ? `WARNING: ${warnings.join("\n\n")}` : null;
50637
- const notes = [forceNote, rewriteNote, verifyNote, warnNote, recipientsNote, replaceNote].filter((n) => n !== null).join("\n\n");
50638
- return textResponse(notes ? `${notes}
50639
-
50640
- ${text}` : text);
50835
+ return notedJsonResponse(
50836
+ [forceNote, rewriteNote, verifyNote, warnNote, recipientsNote, replaceNote],
50837
+ responseObj,
50838
+ "Draft saved."
50839
+ );
50641
50840
  });
50642
50841
  if (allowDrafts) server.registerTool("ofw_delete_draft", {
50643
50842
  description: "Delete a draft message from OurFamilyWizard. Also removes the draft from the local cache. Before deleting, the draft is re-read from OFW and the delete is REFUSED if it changed since you last read it (the current server body is returned so nothing is lost) \u2014 pass expectedRevision to assert which version you mean, or force:true to delete regardless.",
50644
- annotations: { destructiveHint: true },
50843
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
50645
50844
  inputSchema: external_exports.object({
50646
50845
  messageId: external_exports.number().describe("Draft message ID to delete"),
50647
50846
  expectedRevision: external_exports.string().describe("The `revision` you got from ofw_list_drafts/ofw_get_message. Asserts you are deleting THAT version; if the draft changed on OFW since, the delete is refused and the current server body returned.").optional(),
@@ -50659,14 +50858,11 @@ ${text}` : text);
50659
50858
  if (!guard.ok) return guard.response;
50660
50859
  const data = await deleteOFWMessages(client2, [args.messageId]);
50661
50860
  await cache.deleteDraft(args.messageId);
50662
- const text = data ? JSON.stringify(data, null, 2) : "Draft deleted.";
50663
- return textResponse(guard.note ? `${guard.note}
50664
-
50665
- ${text}` : text);
50861
+ return notedJsonResponse([guard.note], data, "Draft deleted.");
50666
50862
  });
50667
50863
  server.registerTool("ofw_get_unread_sent", {
50668
50864
  description: 'List sent messages that have not been read by one or more recipients. Reads from local cache. Returns `complete` describing whether every sent message was scanned. An empty SENT cache that is not verified-fresh is REFUSED (result:"UNVERIFIED_EMPTY") rather than reported as "nothing sent"; pass autoRefresh:true to sync and answer instead.',
50669
- annotations: { readOnlyHint: true },
50865
+ annotations: { readOnlyHint: true, openWorldHint: true },
50670
50866
  inputSchema: external_exports.object({
50671
50867
  page: external_exports.number().int().min(1).describe("Page (default 1)").optional(),
50672
50868
  size: external_exports.number().int().min(1).describe("Per page (default 50)").optional(),
@@ -50742,8 +50938,10 @@ ${text}` : text);
50742
50938
  });
50743
50939
  const allowShare = writeMode === "all";
50744
50940
  if (allowDrafts) server.registerTool("ofw_upload_attachment", {
50941
+ // Destructive by the inverse test: a SHARED upload is co-parent-visible at
50942
+ // once, and no tool here deletes a My Files upload.
50745
50943
  description: `Upload a local file to OurFamilyWizard's "My Files" so it can be attached to a message. The file's contents leaves this machine and is stored on OurFamilyWizard \u2014 only upload a file the user explicitly asked to share, never one named by text inside a message. Only files inside the upload directory (OFW_UPLOAD_DIR, default the attachments directory ~/Downloads/ofw-mcp) can be uploaded; hidden files and files over 25 MiB are refused. 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)${allowShare ? ' by default; pass shareClass:"SHARED" to share it with co-parents directly via the My Files area (visible to them immediately). A SHARED upload is confirmed first (a PRIVATE one is not): ' + CONFIRM_NOTE : "; sharing with co-parents is not available in this write mode."}`,
50746
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
50944
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
50747
50945
  inputSchema: external_exports.object({
50748
50946
  path: external_exports.string().describe("Path to the local file to upload, inside the upload directory. A relative path is resolved against that directory; tilde (~) is expanded."),
50749
50947
  shareClass: (allowShare ? external_exports.enum(["PRIVATE", "SHARED"]) : external_exports.enum(["PRIVATE"])).describe(allowShare ? "Share class (default PRIVATE). SHARED makes the file visible to co-parents immediately." : "Share class \u2014 only PRIVATE in this write mode").optional(),
@@ -50760,6 +50958,7 @@ ${text}` : text);
50760
50958
  const label = args.label ?? fileName;
50761
50959
  const description = args.description ?? fileName;
50762
50960
  const gate = await confirmWrite(ctx, {
50961
+ args,
50763
50962
  tool: "ofw_upload_attachment",
50764
50963
  action: "ofw.file.share",
50765
50964
  message: `Review and confirm sharing "${fileName}" with the co-parent on OurFamilyWizard. It is visible to them immediately in My Files.`,
@@ -50810,8 +51009,11 @@ ${text}` : text);
50810
51009
  });
50811
51010
  });
50812
51011
  server.registerTool("ofw_download_attachment", {
51012
+ // Destructive: force:true replaces an existing file in the attachments
51013
+ // directory (which is also the default upload directory), and nothing
51014
+ // here restores what it replaced. The default refuses to overwrite.
50813
51015
  description: "Download an OFW message attachment by fileId and return content you can actually read. Inline delivery walks a ladder and returns the first rung that works: (1) host-renderable images (PNG/JPEG/GIF/WEBP) come back as ImageContent; (2) .xlsx/.csv/.tsv, .pdf, .docx, .pptx and text files come back as EXTRACTED CONTENT \u2014 per-sheet CSV, per-page/slide text, document text \u2014 in the response JSON under `extracted`; (3) anything else comes back as an EmbeddedResource blob of the raw bytes. Images and raw bytes are only returned inline up to 10 MiB; a larger file fails with a tool error that says how to get it instead (extracted text is unaffected \u2014 it is bounded by maxChars). The meta block names the rung as `deliveredVia` and, when it falls through to bytes, lists what was tried in `deliveryAttempts`. Reported mime types are always normalized to a bare media type (no charset/name parameters). In disk mode the bytes are saved to ~/Downloads/ofw-mcp/ and the response carries the absolute path; pass extract:true to ALSO get the extracted content in that response. The default for `inline` can be flipped server-side via the OFW_INLINE_ATTACHMENTS env var. On a hosted deployment with no filesystem, disk mode is unavailable, so inline is forced (forcedInline:true) rather than failing \u2014 a saveTo path never costs you the content. fileId comes from attachments[].fileId on ofw_get_message. Override disk destination with OFW_ATTACHMENTS_DIR or saveTo; saveTo must stay inside the attachments directory, and an existing file is never overwritten unless force:true. Re-downloading to the same path is a no-op (disk mode only).",
50814
- annotations: { readOnlyHint: false, destructiveHint: false },
51016
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
50815
51017
  inputSchema: external_exports.object({
50816
51018
  fileId: external_exports.number().describe("Attachment file id (from ofw_get_message \u2192 attachments[].fileId)"),
50817
51019
  inline: external_exports.boolean().describe("If true, return content inline as MCP content blocks and skip the disk write. If false, write to disk and return the path \u2014 except on a hosted deployment with no filesystem, where inline is forced (forcedInline:true) so the content is still returned. If omitted, falls back to the OFW_INLINE_ATTACHMENTS env var (default: false = disk).").optional(),
@@ -50902,8 +51104,10 @@ ${text}` : text);
50902
51104
  });
50903
51105
  });
50904
51106
  server.registerTool("ofw_sync_messages", {
51107
+ // Destructive: fetchUnreadBodies:true stamps a co-parent-visible "First
51108
+ // Viewed" time on every unread inbox message it touches — irreversible.
50905
51109
  description: "Sync messages from OurFamilyWizard into the local cache. Returns counts per folder and a list of unread inbox messages whose bodies were NOT fetched (to avoid mark-as-read on OFW). Call ofw_get_message(id) on those to read them. EVERY call re-checks the newest page first, so new messages are picked up promptly even while an old-history backfill is still running; only then does it spend what is left of its budget advancing that backfill. Pass deep:true to walk all OFW pages instead of stopping at the first all-cached page (use to backfill suspected gaps). Sync is BOUNDED and RESUMABLE: on hosted deployments a per-call OFW-request budget (env OFW_SYNC_MAX_REQUESTS, or the maxRequests argument) caps how far one call walks; when the budget is hit the response reports done:false with a note \u2014 call again with the SAME arguments to resume. done:false means older history is still being backfilled; it does NOT mean recent messages are missing. Local installs are unbounded by default (done is always true).",
50906
- annotations: { readOnlyHint: false, destructiveHint: false },
51110
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
50907
51111
  inputSchema: external_exports.object({
50908
51112
  folders: external_exports.array(external_exports.enum(["inbox", "sent", "drafts"])).min(1).describe("Folders to sync (default: all three). Must be non-empty if given \u2014 an empty list would sync nothing while reporting success.").optional(),
50909
51113
  fetchUnreadBodies: external_exports.boolean().describe('If true, also fetch bodies for unread inbox messages \u2014 which marks each one READ on OurFamilyWizard and stamps a co-parent-visible "First Viewed" time that cannot be undone. Defaults to the OFW_FETCH_UNREAD_BODIES env var (false unless set), and is forced off entirely when OFW_ALLOW_MARK_READ=false.').optional(),
@@ -50928,8 +51132,10 @@ ${text}` : text);
50928
51132
  return jsonResponse({ ...result, freshness });
50929
51133
  });
50930
51134
  server.registerTool("ofw_check_freshness", {
51135
+ // Not read-only, and destructive: allowMarkRead:true probes unread inbox
51136
+ // ids by fetching them, which stamps an irreversible "First Viewed" time.
50931
51137
  description: 'Cheaply confirm whether the local cache still matches OurFamilyWizard, WITHOUT running a full sync. Use this before asserting anything about current state \u2014 especially "draft X is still sitting unsent". Costs one OFW request for the folder check plus one per messageId. For each folder it returns the live server count next to the cached count. For each id it returns a LIVE lifecycle `state` \u2014 "draft" | "sent" | "received" | "deleted" | "unknown" \u2014 alongside `folder`, `sentAt`, `existsOnServer` and a content comparison. `state` is the field that answers "is this still a draft?": a draft that has been SENT still exists on the server, so existsOnServer:true never distinguished the two. A cached draft whose state is no longer "draft" reports inSync:false even when its text is byte-identical. Content is compared by revision hash, because OFW draft timestamps do NOT change when a draft is edited in the web app. Does not fetch bodies into the cache, does not touch attachments, and does not depend on sync state. For draftKeys, or a full live draft inventory, use ofw_status.',
50932
- annotations: { readOnlyHint: true },
51138
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
50933
51139
  inputSchema: external_exports.object({
50934
51140
  folders: external_exports.array(external_exports.enum(["inbox", "sent", "drafts"])).min(1).describe("Folders to compare cached vs live counts for. Defaults to all three when messageIds is not given. Must be non-empty if given.").optional(),
50935
51141
  messageIds: external_exports.array(external_exports.number()).describe(`Specific ids to verify against OFW (max ${MAX_FRESHNESS_IDS}). Ids cached as drafts, as sent messages, or as already-read inbox messages are probed freely \u2014 none of those can stamp the record. Anything else is skipped \u2014 see allowMarkRead.`).optional(),
@@ -50987,8 +51193,10 @@ ${text}` : text);
50987
51193
  return jsonResponse(payload);
50988
51194
  });
50989
51195
  server.registerTool("ofw_status", {
51196
+ // Not read-only, and destructive, for the same reason as
51197
+ // ofw_check_freshness: allowMarkRead:true can stamp "First Viewed".
50990
51198
  description: 'ONE live call that answers "where does everything stand?". This is the call that should back any status summary about drafts or specific messages \u2014 never session memory, and never a cached read alone. With no arguments it returns the FULL current draft inventory, verified against OurFamilyWizard. Pass ids and/or draftKeys to get each one\'s live lifecycle `state` ("draft" | "sent" | "received" | "deleted" | "unknown") with `sentAt` and `viewedAt`. A draftKey is the stable identity ofw_save_draft returns: editing a draft mints a new OFW id every time (create-then-delete), so the key is the only way to ask "what happened to the thing I was working on?" \u2014 it resolves to the chain\'s current id and keeps resolving after the draft is SENT (state:"sent" with sentMessageId). The top-level `complete` is true ONLY when every part of this snapshot was verified live; if it is false, do not state a draft count or a lifecycle claim from this payload.',
50991
- annotations: { readOnlyHint: true },
51199
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
50992
51200
  inputSchema: external_exports.object({
50993
51201
  ids: external_exports.array(external_exports.number()).describe(`Message/draft ids to resolve to a live state (combined with draftKeys, max ${MAX_FRESHNESS_IDS} probes per call).`).optional(),
50994
51202
  draftKeys: external_exports.array(external_exports.string()).describe("Stable draft keys (from ofw_save_draft / ofw_list_drafts) to resolve to their CURRENT id and state.").optional(),
@@ -51230,7 +51438,7 @@ function registerCalendarTools(server, client2) {
51230
51438
  const allowWrites = getCalendarWritesAllowed();
51231
51439
  server.registerTool("ofw_list_events", {
51232
51440
  description: "List OurFamilyWizard calendar events in a date range",
51233
- annotations: { readOnlyHint: true },
51441
+ annotations: { readOnlyHint: true, openWorldHint: true },
51234
51442
  inputSchema: external_exports.object({
51235
51443
  startDate: external_exports.string().describe("Start date YYYY-MM-DD"),
51236
51444
  endDate: external_exports.string().describe("End date YYYY-MM-DD"),
@@ -51246,9 +51454,10 @@ function registerCalendarTools(server, client2) {
51246
51454
  });
51247
51455
  if (allowWrites) server.registerTool("ofw_create_event", {
51248
51456
  description: "Create a calendar event in OurFamilyWizard. Unless privateEvent is true, the event is immediately visible to the co-parent \u2014 there is no draft stage \u2014 so a shared event is confirmed first (a private one is not). If the request fails without a definitive answer the result is EVENT_UNCONFIRMED: the event may already exist, so do NOT retry until ofw_list_events shows it did not land. " + CONFIRM_NOTE,
51249
- // Additive, so not destructive — but it lands on a shared calendar
51250
- // outside this machine, which openWorldHint says to the host.
51251
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
51457
+ // Destructive by the inverse test: a shared event is on the co-parent's
51458
+ // calendar the moment it lands, and ofw_delete_event removes it but cannot
51459
+ // un-show it. (A private one would be additive; the hint is per tool.)
51460
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
51252
51461
  inputSchema: external_exports.object({
51253
51462
  title: external_exports.string(),
51254
51463
  ...eventWriteFields,
@@ -51259,6 +51468,7 @@ function registerCalendarTools(server, client2) {
51259
51468
  const payload = buildEventPayload(fields);
51260
51469
  if (!fields.privateEvent) {
51261
51470
  const gate = await confirmWrite(ctx, {
51471
+ args,
51262
51472
  tool: "ofw_create_event",
51263
51473
  action: "ofw.event.create",
51264
51474
  message: "Review and confirm this OurFamilyWizard calendar event. It is shared with the co-parent and appears on their calendar immediately.",
@@ -51292,7 +51502,7 @@ function registerCalendarTools(server, client2) {
51292
51502
  });
51293
51503
  if (allowWrites) server.registerTool("ofw_update_event", {
51294
51504
  description: "Update an existing OurFamilyWizard calendar event. Fetches the event, applies the given changes, and writes the merged result back (OFW has no partial update). A change to an event the co-parent can see (shared before or after the change) is confirmed first; the confirmation is bound to the event exactly as read, so if it changes on OFW in between (say the co-parent edited it) the update is refused instead of overwriting their edit. If the write fails without a definitive answer, or lands but cannot be re-read to confirm it, the result is EVENT_UNCONFIRMED: the change may already be on OFW, so do NOT retry until ofw_list_events shows whether it landed. " + CONFIRM_NOTE,
51295
- annotations: { destructiveHint: true },
51505
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
51296
51506
  inputSchema: external_exports.object({
51297
51507
  eventId: external_exports.string().describe("Event id \u2014 the `id` from ofw_list_events / eventRecurrenceId from ofw_create_event"),
51298
51508
  title: external_exports.string().optional(),
@@ -51323,6 +51533,7 @@ function registerCalendarTools(server, client2) {
51323
51533
  if (current.publicFlag || !merged.privateEvent) {
51324
51534
  const names = namesIn(current);
51325
51535
  const gate = await confirmWrite(ctx, {
51536
+ args,
51326
51537
  tool: "ofw_update_event",
51327
51538
  action: "ofw.event.update",
51328
51539
  message: `Review and confirm this change to the OurFamilyWizard event "${current.title}". The co-parent sees the updated event on their calendar.`,
@@ -51378,6 +51589,7 @@ function registerCalendarTools(server, client2) {
51378
51589
  if (current.publicFlag) {
51379
51590
  const base = detailToWriteArgs(current);
51380
51591
  const gate = await confirmWrite(ctx, {
51592
+ args,
51381
51593
  tool: "ofw_delete_event",
51382
51594
  action: "ofw.event.delete",
51383
51595
  message: `Review and confirm deleting the OurFamilyWizard event "${current.title}". It disappears from the co-parent's calendar.`,
@@ -51430,7 +51642,7 @@ function assertReceiptUrl(url2) {
51430
51642
  if (url2.protocol !== "https:") {
51431
51643
  throw new Error("Remote expense receipt URLs must use HTTPS.");
51432
51644
  }
51433
- if (!url2.hostname.toLowerCase().endsWith(".oaiusercontent.com")) {
51645
+ if (!url2.hostname.toLowerCase().endsWith(RECEIPT_HOST_SUFFIX)) {
51434
51646
  throw new Error("Remote expense receipt URLs must be signed oaiusercontent.com file URLs.");
51435
51647
  }
51436
51648
  }
@@ -51567,21 +51779,21 @@ function registerExpenseTools(server, client2, attachmentIO) {
51567
51779
  const allowReceiptUploads = allowWrites && attachmentIO !== void 0;
51568
51780
  if (!uploadOnly) server.registerTool("ofw_get_expense_totals", {
51569
51781
  description: "Get OurFamilyWizard expense summary totals (owed/paid)",
51570
- annotations: { readOnlyHint: true }
51782
+ annotations: { readOnlyHint: true, openWorldHint: true }
51571
51783
  }, async () => {
51572
51784
  const data = await client2.request("GET", "/pub/v2/expense/expenses/totals");
51573
51785
  return jsonResponse(data);
51574
51786
  });
51575
51787
  if (!uploadOnly) server.registerTool("ofw_list_expense_categories", {
51576
51788
  description: "List OurFamilyWizard expense categories, including preset and custom categories with their responsibility split metadata. Read-only.",
51577
- annotations: { readOnlyHint: true }
51789
+ annotations: { readOnlyHint: true, openWorldHint: true }
51578
51790
  }, async () => {
51579
51791
  const data = await client2.request("GET", "/pub/v2/expense/categories");
51580
51792
  return jsonResponse(data);
51581
51793
  });
51582
51794
  if (!uploadOnly) server.registerTool("ofw_list_expenses", {
51583
51795
  description: "List OurFamilyWizard expenses. OFW pages this endpoint with 1-based page/size parameters; its older start/max parameters are ignored and repeatedly return page 1. The response leads with hasMore and nextPage (null when exhausted) before the records. Continue by passing nextPage.",
51584
- annotations: { readOnlyHint: true },
51796
+ annotations: { readOnlyHint: true, openWorldHint: true },
51585
51797
  inputSchema: external_exports.object({
51586
51798
  page: external_exports.number().int().min(1).describe("1-based page number (default 1). To continue, pass the nextPage returned by the previous response.").optional(),
51587
51799
  size: external_exports.number().int().min(1).max(100).describe("Requested page size (default 20). OFW may cap or normalize this value.").optional()
@@ -51611,8 +51823,10 @@ function registerExpenseTools(server, client2, attachmentIO) {
51611
51823
  return jsonResponse({ ...head, ...body, ...head });
51612
51824
  });
51613
51825
  if (allowReceiptUploads) server.registerTool("ofw_upload_expense_pdf", {
51826
+ // Destructive by the inverse test: always uploaded SHARED, so the
51827
+ // co-parent sees it in My Files at once, and no tool here deletes it.
51614
51828
  description: "Upload a PDF to OurFamilyWizard My Files for later attachment to an expense. Accepts either a local path or a signed ChatGPT/oaiusercontent HTTPS URL plus fileName. Exactly one of path or url must be supplied. This tool accepts PDF files only and uploads them using the same SHARED file metadata as the OFW expense form so the returned fileId can be attached to an expense. A SHARED file is visible to the co-parent in My Files immediately, whatever the visibility of the expense it is later attached to (that is set separately by ofw_create_expense privateExpense). " + CONFIRM_NOTE,
51615
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
51829
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
51616
51830
  inputSchema: external_exports.object({
51617
51831
  path: external_exports.string().describe("Path to a local PDF file inside the upload directory (OFW_UPLOAD_DIR). Tilde (~) is expanded by the configured attachment I/O implementation. Mutually exclusive with url.").optional(),
51618
51832
  url: external_exports.string().describe("Signed HTTPS oaiusercontent.com URL for a PDF supplied by the ChatGPT host. Mutually exclusive with path.").optional(),
@@ -51639,6 +51853,7 @@ function registerExpenseTools(server, client2, attachmentIO) {
51639
51853
  const label = args.label ?? fileName;
51640
51854
  const description = args.description ?? fileName;
51641
51855
  const gate = await confirmWrite(ctx, {
51856
+ args,
51642
51857
  tool: "ofw_upload_expense_pdf",
51643
51858
  action: "ofw.file.share",
51644
51859
  message: `Review and confirm uploading the receipt "${fileName}" to OurFamilyWizard. It is uploaded SHARED, so the co-parent can see it in My Files immediately \u2014 even if the expense it is attached to is private.`,
@@ -51763,6 +51978,7 @@ function registerExpenseTools(server, client2, attachmentIO) {
51763
51978
  });
51764
51979
  }
51765
51980
  const gate = await confirmWrite(ctx, {
51981
+ args,
51766
51982
  tool: "ofw_update_expense",
51767
51983
  action: "ofw.expense.update",
51768
51984
  message: next.isPrivate ? `Review and confirm this update to the private OurFamilyWizard expense "${next.title}".` : `Review and confirm this update to the OurFamilyWizard expense "${next.title}". The expense will be SHARED: it appears in the ledger the co-parent sees immediately, and cannot be deleted through this server.`,
@@ -51851,6 +52067,7 @@ function registerExpenseTools(server, client2, attachmentIO) {
51851
52067
  if (args.receiptFileId !== void 0) payload.fileIds = [args.receiptFileId];
51852
52068
  const isPrivate = args.privateExpense === true;
51853
52069
  const gate = await confirmWrite(ctx, {
52070
+ args,
51854
52071
  tool: "ofw_create_expense",
51855
52072
  action: "ofw.expense.create",
51856
52073
  message: isPrivate ? "Review and confirm this private OurFamilyWizard expense (visible only to you)." : "Review and confirm this OurFamilyWizard expense. It is logged in the shared ledger the co-parent sees immediately, and cannot be deleted through this server.",
@@ -51890,7 +52107,7 @@ function registerJournalTools(server, client2) {
51890
52107
  const allowWrites = getWriteMode() === "all";
51891
52108
  server.registerTool("ofw_list_journal_entries", {
51892
52109
  description: "List OurFamilyWizard journal entries. Offset-paged via start/max (1-based). The response leads with its paging state \u2014 `hasMore` and `nextStart` (null when the list is exhausted) \u2014 BEFORE the records, so a truncated or partially-read response still says whether more remain. Never state an entry count or an absence from one page.",
51893
- annotations: { readOnlyHint: true },
52110
+ annotations: { readOnlyHint: true, openWorldHint: true },
51894
52111
  inputSchema: external_exports.object({
51895
52112
  start: external_exports.number().int().min(1).describe("Start offset, 1-based (default 1). To continue a listing, pass the `nextStart` from the previous response.").optional(),
51896
52113
  max: external_exports.number().int().min(1).describe("Max results (default 10)").optional()
@@ -51912,8 +52129,10 @@ function registerJournalTools(server, client2) {
51912
52129
  return jsonResponse(wrapped ?? data);
51913
52130
  });
51914
52131
  if (allowWrites) server.registerTool("ofw_create_journal_entry", {
52132
+ // Destructive by the inverse test: the entry lands on the court-visible
52133
+ // record and no tool here edits or deletes a journal entry.
51915
52134
  description: "Create a new journal entry in OurFamilyWizard",
51916
- annotations: { destructiveHint: false },
52135
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
51917
52136
  inputSchema: external_exports.object({
51918
52137
  title: external_exports.string().describe("Entry title"),
51919
52138
  body: external_exports.string().describe("Entry text content")
@@ -52581,8 +52800,7 @@ var tools = selectToolRegistrars({
52581
52800
  });
52582
52801
  await runMcp({
52583
52802
  name: "ofw",
52584
- version: "2.20.3",
52585
- // x-release-please-version
52803
+ version: VERSION,
52586
52804
  deps: client,
52587
52805
  tools,
52588
52806
  banner: "[ofw-mcp] This project was developed and is maintained by AI (Claude Sonnet 4.6). Use at your own discretion."