ofw-mcp 2.20.3 → 2.20.4

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
  };
@@ -38245,17 +38245,62 @@ var DEFAULT_MAX_REQUEST_BODY_SIZE = 4 * 1024 * 1024;
38245
38245
 
38246
38246
  // node_modules/@chrischall/mcp-utils/dist/errors/index.js
38247
38247
  var DEFAULT_ERROR_MESSAGE_MAX = 500;
38248
+ var MCP_TOOL_ERROR_KINDS = /* @__PURE__ */ new Set([
38249
+ "no_credential",
38250
+ "credential_rejected",
38251
+ "edge_blocked",
38252
+ "session_expired",
38253
+ "verification_pending",
38254
+ "timeout",
38255
+ "http",
38256
+ "transport",
38257
+ "too_large",
38258
+ "unknown"
38259
+ ]);
38248
38260
  var McpToolError = class extends Error {
38249
38261
  /** Actionable remediation text, when one applies. */
38250
38262
  hint;
38263
+ /** The upstream HTTP status this failure came from, when known. */
38264
+ status;
38265
+ /** What kind of failure this is, when the thrower knows. */
38266
+ kind;
38251
38267
  constructor(message, opts) {
38252
38268
  super(message, opts?.cause !== void 0 ? { cause: opts.cause } : void 0);
38253
38269
  this.name = "McpToolError";
38254
38270
  if (opts?.hint !== void 0)
38255
38271
  this.hint = opts.hint;
38272
+ if (opts?.status !== void 0)
38273
+ this.status = opts.status;
38274
+ if (opts?.kind !== void 0)
38275
+ this.kind = opts.kind;
38256
38276
  Object.setPrototypeOf(this, new.target.prototype);
38257
38277
  }
38258
38278
  };
38279
+ var DECLARATION_CAUSE_DEPTH = 4;
38280
+ function firstDeclared(err, read) {
38281
+ let cur = err;
38282
+ for (let depth = 0; depth < DECLARATION_CAUSE_DEPTH && cur !== null && typeof cur === "object"; depth++) {
38283
+ const found = read(cur);
38284
+ if (found !== void 0)
38285
+ return found;
38286
+ cur = cur.cause;
38287
+ }
38288
+ return void 0;
38289
+ }
38290
+ function errorStatusOf(err) {
38291
+ return firstDeclared(err, (link2) => {
38292
+ const e = link2;
38293
+ if (typeof e.status === "number")
38294
+ return e.status;
38295
+ return typeof e.statusCode === "number" ? e.statusCode : void 0;
38296
+ });
38297
+ }
38298
+ function errorKindOf(err) {
38299
+ return firstDeclared(err, (link2) => {
38300
+ const k = link2.kind;
38301
+ return typeof k === "string" && MCP_TOOL_ERROR_KINDS.has(k) ? k : void 0;
38302
+ });
38303
+ }
38259
38304
  function createHelpfulError(message, opts) {
38260
38305
  return new McpToolError(message, opts);
38261
38306
  }
@@ -38280,7 +38325,7 @@ var API_KEY_RE = new RegExp([
38280
38325
  // webhook signing secret (Stripe-style)
38281
38326
  ].map((p) => `\\b${p}`).join("|"), "g");
38282
38327
  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");
38328
+ 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|(?:code[_-]?)?verifier|(?:php|j)?sess(?:ion)?[_-]?id|sid|x[-_]${HEADER_NAME_RUN}?(?:api[-_]?key|token|secret|auth)${HEADER_NAME_RUN})=)[^&#\\s"'<>\`]+`, "gi");
38284
38329
  var OAUTH_CODE_RE = /([?&]code=)(?=[^&#\s"'<>`]{16,})[^&#\s"'<>`]+/gi;
38285
38330
  var FORM_PASSWORD_RE = /((?:^|[\s,;:"'(\[{])(?:password|passwd)=)[^&#\s"'<>`]+/gim;
38286
38331
  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 +38336,8 @@ var JSON_SECRET_KEYS = [
38291
38336
  "api[_-]?secret",
38292
38337
  "(?:x[_-])?api[_-]?key",
38293
38338
  "private[_-]?key",
38339
+ "code[_-]?verifier",
38340
+ // PKCE; a bare `verifier` key is too generic for JSON
38294
38341
  `x[_-]${HEADER_NAME_RUN}?(?:api[_-]?key|token|secret|auth)${HEADER_NAME_RUN}`,
38295
38342
  // bounded like HEADER_SECRET_RE
38296
38343
  "(?:proxy-)?authorization",
@@ -38755,7 +38802,7 @@ function readEnvVar(key, opts = {}) {
38755
38802
  if (typeof raw === "string") {
38756
38803
  const trimmed = raw.trim();
38757
38804
  if (trimmed.length > 0 && trimmed !== "undefined" && trimmed !== "null" && !PLACEHOLDER_RE.test(trimmed)) {
38758
- return trimmed;
38805
+ return opts.trim === false ? raw : trimmed;
38759
38806
  }
38760
38807
  }
38761
38808
  return opts.default;
@@ -38966,12 +39013,19 @@ function boundArgs(args) {
38966
39013
  }
38967
39014
  function confirmationFromEnv(options) {
38968
39015
  const { tool, account, confirmToken, subject, instruction, args, env = process.env, spent: callerSpent, ...rest } = options;
39016
+ if (args === void 0 || args === null) {
39017
+ throw new TypeError(`confirmationFromEnv: ${tool} passed no args; pass the tool's validated arguments ({} for a tool with none).`);
39018
+ }
38969
39019
  const spent = callerSpent ?? spentTokenStoreFromEnv(env);
38970
39020
  const mode = readConfirmMode(env);
38971
39021
  const ttlSeconds = readConfirmTtl(env);
38972
39022
  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 } };
39023
+ const bound = boundArgs(args);
39024
+ const elicitationBound = account === void 0 ? { args: bound } : { account, args: bound };
39025
+ const bindingAdded = rest.binding ? rest : {
39026
+ ...rest,
39027
+ binding: { key: confirmKeyFromEnv(env), args: elicitationBound, ttlSeconds: ttlSeconds ?? DEFAULT_TTL_SECONDS2 }
39028
+ };
38975
39029
  const confirmation = elicitationOff ? { ...bindingAdded, elicitation: false } : bindingAdded;
38976
39030
  if (mode === "refuse" || ttlSeconds === void 0) {
38977
39031
  const reason = mode === "refuse" ? REFUSE_HINT : BAD_TTL_HINT;
@@ -38981,7 +39035,7 @@ function confirmationFromEnv(options) {
38981
39035
  unsupportedNote: confirmation.unsupportedNote ? `${confirmation.unsupportedNote} ${hint}` : hint
38982
39036
  };
38983
39037
  }
38984
- const boundSubject = bound === void 0 ? subject : async () => {
39038
+ const boundSubject = async () => {
38985
39039
  const read = await subject();
38986
39040
  if ("content" in read)
38987
39041
  return read;
@@ -39008,12 +39062,14 @@ var SERVER_PROTOCOL_VERSIONS = Object.freeze([
39008
39062
  ...SUPPORTED_PROTOCOL_VERSIONS
39009
39063
  ]);
39010
39064
  function hintResultOrRethrow(err) {
39065
+ if (err instanceof ProtocolError && err.code === ProtocolErrorCode.UrlElicitationRequired)
39066
+ throw err;
39011
39067
  if (err instanceof McpToolError && err.hint) {
39012
39068
  return errorResult(`${err.message}
39013
39069
 
39014
39070
  Hint: ${err.hint}`);
39015
39071
  }
39016
- throw err;
39072
+ return errorResult(messageOf(err));
39017
39073
  }
39018
39074
  function mcpRequestFrom(args) {
39019
39075
  const ctx = args.at(-1);
@@ -39454,6 +39510,8 @@ function parseContentDispositionFilename(header) {
39454
39510
  // node_modules/@chrischall/mcp-utils/dist/http/index.js
39455
39511
  var RateLimitedError = class extends Error {
39456
39512
  status = 429;
39513
+ /** `'http'`: the far side answered, with a 429 (see `status`). */
39514
+ kind = "http";
39457
39515
  retryAfterMs;
39458
39516
  constructor(service, retryAfterMs) {
39459
39517
  super(`Rate limited (429) by ${service} after retries.`);
@@ -39464,6 +39522,8 @@ var RateLimitedError = class extends Error {
39464
39522
  };
39465
39523
  var RequestTimeoutError = class extends Error {
39466
39524
  timeoutMs;
39525
+ /** Always `'timeout'` unless a subclass says otherwise. */
39526
+ kind = "timeout";
39467
39527
  constructor(service, timeoutMs) {
39468
39528
  super(`Request to ${service} timed out after ${timeoutMs}ms.`);
39469
39529
  this.name = "RequestTimeoutError";
@@ -39483,6 +39543,8 @@ var ApiError = class extends Error {
39483
39543
  var EdgeBlockedError = class extends ApiError {
39484
39544
  /** Which edge refused the request, e.g. `'CloudFront'` or `'Cloudflare'`. */
39485
39545
  vendor;
39546
+ /** Always `'edge_blocked'`: the credential was never judged. */
39547
+ kind = "edge_blocked";
39486
39548
  /**
39487
39549
  * @param where The service the request was for, and — when the thrower knows
39488
39550
  * it — the method and path, which the message names.
@@ -39565,7 +39627,7 @@ var PositiveInt = external_exports.number().int().positive();
39565
39627
  var NonNegInt = external_exports.number().int().nonnegative();
39566
39628
  var NonEmptyString = external_exports.string().min(1);
39567
39629
  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");
39630
+ 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
39631
  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
39632
  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
39633
  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 +40186,9 @@ var TokenManager = class {
40124
40186
  /** Spend `rt`, then install and persist what comes back. */
40125
40187
  async exchange(rt) {
40126
40188
  if (rt === void 0) {
40127
- throw new Error("TokenManager: cannot refresh \u2014 no refresh token is available.");
40189
+ throw new McpToolError("TokenManager: cannot refresh \u2014 no refresh token is available.", {
40190
+ kind: "no_credential"
40191
+ });
40128
40192
  }
40129
40193
  const tok = await this.refreshFn(rt);
40130
40194
  this.tokens = {
@@ -46401,12 +46465,6 @@ var BootstrapDisabledError = class extends Error {
46401
46465
  };
46402
46466
 
46403
46467
  // 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
46468
  var CREDENTIAL_ARMS = /* @__PURE__ */ new Set([
46411
46469
  "ok",
46412
46470
  "no_credential",
@@ -46422,6 +46480,9 @@ var CREDENTIAL_ARMS = /* @__PURE__ */ new Set([
46422
46480
  function isArm(kind) {
46423
46481
  return kind !== void 0 && CREDENTIAL_ARMS.has(kind);
46424
46482
  }
46483
+ function fromSource(source) {
46484
+ return source !== null ? ` from '${source}'` : "";
46485
+ }
46425
46486
  function credentialHint(arm, prefix, hostLabel, source) {
46426
46487
  switch (arm) {
46427
46488
  case "ok":
@@ -46429,15 +46490,15 @@ function credentialHint(arm, prefix, hostLabel, source) {
46429
46490
  case "no_credential":
46430
46491
  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
46492
  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.`;
46493
+ 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
46494
  case "edge_blocked":
46434
46495
  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
46496
  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.`;
46497
+ 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
46498
  case "verification_pending":
46438
46499
  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
46500
  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.`;
46501
+ 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
46502
  case "http":
46442
46503
  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
46504
  case "transport":
@@ -46458,7 +46519,8 @@ async function runCredentialHealthcheck(args) {
46458
46519
  state = await resolveCredential();
46459
46520
  } catch (e) {
46460
46521
  const edge = edgeBlockOf(e);
46461
- const classified = classifyThrown?.(e) ?? (edge !== null ? { kind: "edge_blocked", detail: { vendor: edge.vendor } } : void 0);
46522
+ const declared = errorKindOf(e);
46523
+ const classified = classifyThrown?.(e) ?? (edge !== null ? { kind: "edge_blocked", detail: { vendor: edge.vendor } } : declared !== void 0 ? { kind: declared } : void 0);
46462
46524
  const result2 = {
46463
46525
  ok: false,
46464
46526
  // Still false, and still no source: a classification explains WHY
@@ -46505,10 +46567,11 @@ async function runCredentialHealthcheck(args) {
46505
46567
  try {
46506
46568
  await probeFn();
46507
46569
  } catch (e) {
46508
- status = statusOf2(e);
46570
+ status = errorStatusOf(e);
46509
46571
  const aborted2 = e instanceof Error && e.name === "AbortError";
46510
46572
  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";
46573
+ const declared = errorKindOf(e);
46574
+ 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
46575
  let kind = arm;
46513
46576
  let detail = edge !== null ? { vendor: edge.vendor } : void 0;
46514
46577
  const custom2 = classifyThrown?.(e);
@@ -46654,64 +46717,29 @@ async function loginWithPassword(username, password) {
46654
46717
  throw new Error(`OFW login returned unexpected response (${contentType || "no content-type"}): ${body.substring(0, 200)}`);
46655
46718
  }
46656
46719
  const data = await response.json();
46720
+ const record2 = typeof data === "object" && data !== null ? data : null;
46721
+ const token = record2?.auth;
46722
+ if (typeof token !== "string" || token === "") {
46723
+ throw new Error(describeTokenlessLogin(record2));
46724
+ }
46657
46725
  return {
46658
- token: data.auth,
46726
+ token,
46659
46727
  expiresAt: new Date(Date.now() + OFW_TOKEN_TTL_MS)
46660
46728
  };
46661
46729
  }
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"
46730
+ function describeTokenlessLogin(record2) {
46731
+ const base = "OFW login returned no token, so it did not sign in";
46732
+ if (record2 === null) {
46733
+ return `${base} (the response was not a JSON object). Sign in to OurFamilyWizard in a browser to check the account, then try again.`;
46713
46734
  }
46714
- };
46735
+ const keys = Object.keys(record2);
46736
+ const said = typeof record2.message === "string" ? ` OFW said: "${record2.message.substring(0, 200)}".` : "";
46737
+ 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.`;
46738
+ }
46739
+
46740
+ // src/version.ts
46741
+ var SERVER_NAME = "ofw-mcp";
46742
+ var VERSION = "2.20.4";
46715
46743
 
46716
46744
  // src/auth.ts
46717
46745
  function fetchproxyDisabled() {
@@ -46739,8 +46767,8 @@ async function resolveAuth() {
46739
46767
  pattern.fetchproxy = async () => {
46740
46768
  try {
46741
46769
  const session = await bootstrap({
46742
- serverName: package_default.name,
46743
- version: package_default.version,
46770
+ serverName: SERVER_NAME,
46771
+ version: VERSION,
46744
46772
  // OFW serves both ofw.ourfamilywizard.com and www.ourfamilywizard.com;
46745
46773
  // the API + auth token live on the apex. The extension matches on
46746
46774
  // suffix, so listing the apex covers both.
@@ -46841,12 +46869,12 @@ try {
46841
46869
  function debugLogEnabled() {
46842
46870
  return parseBoolEnv("OFW_DEBUG_LOG");
46843
46871
  }
46844
- var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
46872
+ var DEFAULT_REQUEST_TIMEOUT_MS2 = 3e4;
46845
46873
  function getRequestTimeoutMs() {
46846
46874
  const raw = process.env.OFW_REQUEST_TIMEOUT_MS;
46847
- if (typeof raw !== "string" || raw.trim().length === 0) return DEFAULT_REQUEST_TIMEOUT_MS;
46875
+ if (typeof raw !== "string" || raw.trim().length === 0) return DEFAULT_REQUEST_TIMEOUT_MS2;
46848
46876
  const n = Number(raw.trim());
46849
- return Number.isFinite(n) && n > 0 ? n : DEFAULT_REQUEST_TIMEOUT_MS;
46877
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_REQUEST_TIMEOUT_MS2;
46850
46878
  }
46851
46879
  var OFW_REFRESH_SENTINEL = "ofw";
46852
46880
  async function throwIfEdgeBlocked(response, method, path) {
@@ -46886,13 +46914,17 @@ var OFWClient = class {
46886
46914
  // deployment. Left undefined by the stdio path, which falls back to the
46887
46915
  // global resolver, keeping that behaviour byte-for-byte identical.
46888
46916
  authResolver;
46917
+ // Which path minted the token currently held. Unset while the token is one
46918
+ // the TokenManager restored from the session cache — nothing was minted.
46919
+ mintedBy;
46889
46920
  constructor(opts) {
46890
46921
  this.authResolver = opts?.resolveAuth;
46891
46922
  }
46892
46923
  getTokenManager() {
46893
46924
  if (!this.tokenManager) {
46894
46925
  const mint = async () => {
46895
- const { token, expiresAt } = await (this.authResolver ?? resolveAuth)();
46926
+ const { token, expiresAt, source } = await (this.authResolver ?? resolveAuth)();
46927
+ this.mintedBy = source;
46896
46928
  return {
46897
46929
  accessToken: token,
46898
46930
  refreshToken: OFW_REFRESH_SENTINEL,
@@ -46917,6 +46949,22 @@ var OFWClient = class {
46917
46949
  }
46918
46950
  return this.tokenManager;
46919
46951
  }
46952
+ /**
46953
+ * The credential's source and expiry, answered from the TokenManager — the
46954
+ * same token, cache and single-flight mint every tool uses.
46955
+ *
46956
+ * `ofw_healthcheck` used to call `resolveAuth()` directly, which is a full
46957
+ * login (or a fresh browser-bridge spin-up) on every healthcheck, ignoring
46958
+ * the six-hour token already held. Going through the manager means a held or
46959
+ * cached token costs nothing, and a mint, when one is needed, is the one the
46960
+ * next real tool call would have made anyway. A mint failure propagates
46961
+ * unchanged so the healthcheck can classify it.
46962
+ */
46963
+ async credentialStatus() {
46964
+ const tokens = this.getTokenManager();
46965
+ await tokens.getAccessToken();
46966
+ return { source: this.mintedBy ?? "cache", expiresAt: new Date(tokens.getExpiresAt()) };
46967
+ }
46920
46968
  async request(method, path, body) {
46921
46969
  return this.fetchAuthed(method, path, body, "application/json", async (response) => {
46922
46970
  const text = await response.text();
@@ -47279,6 +47327,16 @@ function jsonResponse(data) {
47279
47327
  return minifiedResult(normalizeTimestampsInValue(data));
47280
47328
  }
47281
47329
  var textResponse = rawTextResult;
47330
+ function jsonText(data) {
47331
+ return JSON.stringify(normalizeTimestampsInValue(data));
47332
+ }
47333
+ function notedJsonResponse(notes, data, fallback) {
47334
+ const text = data ? jsonText(data) : fallback;
47335
+ const prefix = notes.filter((n) => n !== null).join("\n\n");
47336
+ return textResponse(prefix ? `${prefix}
47337
+
47338
+ ${text}` : text);
47339
+ }
47282
47340
  function jsonErrorResponse(data) {
47283
47341
  return { ...jsonResponse(data), isError: true };
47284
47342
  }
@@ -47411,14 +47469,19 @@ async function postMessageAndRefetch(client2, payload, detailSchema, ctx) {
47411
47469
  function registerUserTools(server, client2) {
47412
47470
  server.registerTool("ofw_get_profile", {
47413
47471
  description: "Get current user and co-parent profile information from OurFamilyWizard",
47414
- annotations: { readOnlyHint: true }
47472
+ annotations: { readOnlyHint: true, openWorldHint: true }
47415
47473
  }, async () => {
47416
47474
  const data = await client2.request("GET", "/pub/v2/profiles");
47417
47475
  return jsonResponse(data);
47418
47476
  });
47419
47477
  server.registerTool("ofw_get_notifications", {
47420
47478
  description: "Get OurFamilyWizard dashboard summary: unread message count, upcoming events, outstanding expenses. Note: updates your last-seen status.",
47421
- annotations: { readOnlyHint: true }
47479
+ // Not read-only: this GET updates the last-seen status the co-parent can
47480
+ // see, a side effect the read tools deliberately avoid (see _shared.ts).
47481
+ // readOnlyHint:true would let a host auto-approve it without a prompt.
47482
+ // Destructive by the inverse test: nothing here restores a last-seen
47483
+ // status the co-parent has already been shown.
47484
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true }
47422
47485
  }, async () => {
47423
47486
  const data = await client2.request("GET", "/pub/v1/users/useraccountstatus");
47424
47487
  return jsonResponse(data);
@@ -47426,7 +47489,7 @@ function registerUserTools(server, client2) {
47426
47489
  }
47427
47490
 
47428
47491
  // src/tools/healthcheck.ts
47429
- function registerHealthcheckTools(server, client2, resolve7 = resolveAuth) {
47492
+ function registerHealthcheckTools(server, client2) {
47430
47493
  registerCredentialHealthcheckTool({
47431
47494
  server,
47432
47495
  prefix: "ofw",
@@ -47437,12 +47500,13 @@ function registerHealthcheckTools(server, client2, resolve7 = resolveAuth) {
47437
47500
  probePath: "/pub/v2/profiles",
47438
47501
  resolveCredential: async () => {
47439
47502
  try {
47440
- const auth = await resolve7();
47503
+ const { source, expiresAt } = await client2.credentialStatus();
47441
47504
  return {
47442
- source: auth.source,
47505
+ source,
47443
47506
  // 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
47507
+ // that worked an hour ago and does not now — and this is the one
47508
+ // the client itself will re-authenticate at.
47509
+ detail: { expires_at: expiresAt.toISOString() }
47446
47510
  };
47447
47511
  } catch (e) {
47448
47512
  if (isNoAuthConfigured(e)) return { source: null };
@@ -47465,7 +47529,7 @@ function registerHealthcheckTools(server, client2, resolve7 = resolveAuth) {
47465
47529
  // Now means exactly what it says: nothing is set up. A configured path
47466
47530
  // that was tried and failed no longer lands here.
47467
47531
  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."
47532
+ 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
47533
  }
47470
47534
  });
47471
47535
  }
@@ -47896,9 +47960,7 @@ function readCacheIdentity() {
47896
47960
  return readEnvVar("OFW_CACHE_IDENTITY") ?? readEnvVar("OFW_USERNAME") ?? "_default";
47897
47961
  }
47898
47962
  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");
47963
+ return readEnvVar("OFW_CACHE_DIR") ?? join10(homedir4(), ".cache", "ofw-mcp");
47902
47964
  }
47903
47965
  function getCacheDbPath() {
47904
47966
  const identity = readCacheIdentity();
@@ -48447,6 +48509,10 @@ function confirmWrite(ctx, opts) {
48447
48509
  details: opts.preview,
48448
48510
  unsupportedNote: "Complete this action on ourfamilywizard.com instead.",
48449
48511
  tool: opts.tool,
48512
+ // One OFW account per process (OFW_USERNAME / the captured session), so
48513
+ // there is no principal to distinguish; mcp-utils 3 makes saying so explicit.
48514
+ account: void 0,
48515
+ args: opts.args,
48450
48516
  confirmToken: opts.confirmToken,
48451
48517
  subject: () => ({
48452
48518
  target: opts.target,
@@ -48463,13 +48529,26 @@ function stateRevision(value) {
48463
48529
 
48464
48530
  // src/extract/inflate.ts
48465
48531
  var MAX_DECOMPRESSED_BYTES = 32 * 1024 * 1024;
48532
+ var MAX_TOTAL_DECOMPRESSED_BYTES = 64 * 1024 * 1024;
48533
+ var DecompressionBudget = class {
48534
+ constructor(limit = MAX_TOTAL_DECOMPRESSED_BYTES) {
48535
+ this.limit = limit;
48536
+ }
48537
+ limit;
48538
+ used = 0;
48539
+ /** Charge `bytes`; throws once the document's total passes the limit. */
48540
+ charge(bytes, label) {
48541
+ this.used += bytes;
48542
+ if (this.used > this.limit) throw new DecompressionLimitError(label, this.limit, "total");
48543
+ }
48544
+ };
48466
48545
  var DecompressionLimitError = class extends Error {
48467
- constructor(label, limit) {
48468
- super(`${label} expands past the ${limit}-byte decompression cap`);
48546
+ constructor(label, limit, scope = "member") {
48547
+ super(scope === "total" ? `${label} pushes the document past its ${limit}-byte total decompression cap` : `${label} expands past the ${limit}-byte decompression cap`);
48469
48548
  this.name = "DecompressionLimitError";
48470
48549
  }
48471
48550
  };
48472
- async function inflateBounded(data, format, limit, label) {
48551
+ async function inflateBounded(data, format, limit, label, budget) {
48473
48552
  const stream = new Blob([data]).stream().pipeThrough(new DecompressionStream(format));
48474
48553
  const reader = stream.getReader();
48475
48554
  const chunks = [];
@@ -48482,6 +48561,12 @@ async function inflateBounded(data, format, limit, label) {
48482
48561
  await reader.cancel();
48483
48562
  throw new DecompressionLimitError(label, limit);
48484
48563
  }
48564
+ try {
48565
+ budget?.charge(value.length, label);
48566
+ } catch (err) {
48567
+ await reader.cancel();
48568
+ throw err;
48569
+ }
48485
48570
  chunks.push(value);
48486
48571
  }
48487
48572
  return Buffer.concat(chunks);
@@ -48528,6 +48613,7 @@ async function readZip(bytes, opts = {}) {
48528
48613
  p += 46 + nameLen + extraLen + commentLen;
48529
48614
  }
48530
48615
  const cache = /* @__PURE__ */ new Map();
48616
+ const budget = new DecompressionBudget(opts.maxTotalUncompressedBytes);
48531
48617
  async function read(name) {
48532
48618
  const cached2 = cache.get(name);
48533
48619
  if (cached2) return cached2;
@@ -48545,8 +48631,10 @@ async function readZip(bytes, opts = {}) {
48545
48631
  const start = lo + 30 + bytes.readUInt16LE(lo + 26) + bytes.readUInt16LE(lo + 28);
48546
48632
  const raw = bytes.subarray(start, start + entry.compressedSize);
48547
48633
  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}`);
48634
+ if (entry.method === 0) {
48635
+ budget.charge(raw.length, `ZIP member ${name}`);
48636
+ out = Buffer.from(raw);
48637
+ } else if (entry.method === 8) out = await inflateBounded(raw, "deflate-raw", limit, `ZIP member ${name}`, budget);
48550
48638
  else throw new Error(`unsupported ZIP compression method ${entry.method} for ${name}`);
48551
48639
  cache.set(name, out);
48552
48640
  return out;
@@ -48989,14 +49077,17 @@ function streamBytes(bytes, obj) {
48989
49077
  const end = bytes.indexOf("endstream", from, "latin1");
48990
49078
  return bytes.subarray(from, end === -1 ? void 0 : end);
48991
49079
  }
48992
- async function decodeStream(bytes, obj) {
49080
+ async function decodeStream(bytes, obj, budget) {
48993
49081
  const raw = streamBytes(bytes, obj);
48994
49082
  if (!raw) return null;
48995
49083
  const filter = /\/Filter\s*(\/\w+|\[[^\]]*\])/.exec(obj.body)?.[1] ?? "";
48996
- if (filter === "") return raw;
49084
+ if (filter === "") {
49085
+ budget.charge(raw.length, "PDF stream");
49086
+ return raw;
49087
+ }
48997
49088
  if (!filter.includes("FlateDecode")) return null;
48998
49089
  try {
48999
- return await inflateBounded(raw, "deflate", MAX_DECOMPRESSED_BYTES, "PDF stream");
49090
+ return await inflateBounded(raw, "deflate", MAX_DECOMPRESSED_BYTES, "PDF stream", budget);
49000
49091
  } catch (err) {
49001
49092
  if (err instanceof DecompressionLimitError) throw err;
49002
49093
  return null;
@@ -49123,6 +49214,7 @@ async function extractPdf(bytes, opts = {}) {
49123
49214
  const objects = parseObjects(text);
49124
49215
  const pageObjects = orderedPages(objects);
49125
49216
  if (pageObjects.length === 0) throw new Error("no pages found in the PDF");
49217
+ const budget = new DecompressionBudget(opts.maxTotalDecompressedBytes);
49126
49218
  const pages = [];
49127
49219
  const omitted = [];
49128
49220
  for (let i = 0; i < pageObjects.length; i++) {
@@ -49136,7 +49228,7 @@ async function extractPdf(bytes, opts = {}) {
49136
49228
  for (const ref of refsIn(contentsFragment)) {
49137
49229
  const streamObj = objects.get(ref);
49138
49230
  if (!streamObj) continue;
49139
- const decoded = await decodeStream(bytes, streamObj);
49231
+ const decoded = await decodeStream(bytes, streamObj, budget);
49140
49232
  if (decoded) raw += `${decoded.toString("latin1")}
49141
49233
  `;
49142
49234
  }
@@ -49767,9 +49859,12 @@ async function guardedCacheRead(o) {
49767
49859
  if (unverifiable(value) && o.autoRefresh) {
49768
49860
  await syncAll(o.client, {
49769
49861
  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(),
49862
+ // An automatic refresh must never stamp unread inbox messages as a side
49863
+ // effect of a list read — not even when OFW_FETCH_UNREAD_BODIES=true sets
49864
+ // ofw_sync_messages' default. The tools that call this are annotated
49865
+ // readOnlyHint:true, and fetching an unread body marks it READ and stamps
49866
+ // a co-parent-visible "First Viewed" time that cannot be undone.
49867
+ fetchUnreadBodies: false,
49773
49868
  maxRequests: getSyncMaxRequests()
49774
49869
  }, o.cache);
49775
49870
  refreshed = true;
@@ -49823,7 +49918,7 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
49823
49918
  const allowDrafts = writeMode !== "none";
49824
49919
  server.registerTool("ofw_list_message_folders", {
49825
49920
  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 }
49921
+ annotations: { readOnlyHint: true, openWorldHint: true }
49827
49922
  }, async () => {
49828
49923
  const data = await client2.request("GET", "/pub/v1/messageFolders?includeFolderCounts=true");
49829
49924
  const freshness = await buildFreshness(cacheProvider(), { source: "live", folders: [] });
@@ -49831,7 +49926,9 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
49831
49926
  });
49832
49927
  server.registerTool("ofw_list_messages", {
49833
49928
  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 },
49929
+ // Read-only even with autoRefresh: guardedCacheRead's sync never fetches
49930
+ // unread inbox bodies, so a list call cannot stamp "First Viewed".
49931
+ annotations: { readOnlyHint: true, openWorldHint: true },
49835
49932
  inputSchema: external_exports.object({
49836
49933
  folderId: external_exports.string().describe('Folder name: "inbox", "sent", or "both" (default "both")').optional(),
49837
49934
  page: external_exports.number().int().min(1).describe("Page number (default 1)").optional(),
@@ -49942,7 +50039,7 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
49942
50039
  });
49943
50040
  server.registerTool("ofw_get_message", {
49944
50041
  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 },
50042
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
49946
50043
  inputSchema: external_exports.object({
49947
50044
  messageId: external_exports.string().describe("Message ID (also accepts draft IDs \u2014 drafts are routed via the drafts cache)"),
49948
50045
  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 +50171,7 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
50074
50171
  });
50075
50172
  if (allowSend) server.registerTool("ofw_send_message", {
50076
50173
  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 },
50174
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
50078
50175
  inputSchema: external_exports.object({
50079
50176
  subject: external_exports.string().describe("Message subject. Required unless draftId/messageId is given (then it overrides the server draft's subject).").optional(),
50080
50177
  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 +50274,7 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
50177
50274
  source: draftRef !== void 0 ? { draftId: draftRef, deleteDraftAfterSend: deleteOnSuccess } : "composed from the call arguments"
50178
50275
  };
50179
50276
  const gate = await confirmWrite(ctx, {
50277
+ args,
50180
50278
  tool: "ofw_send_message",
50181
50279
  action: "ofw.message.send",
50182
50280
  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 +50419,11 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
50321
50419
  } : {},
50322
50420
  ...persisted
50323
50421
  };
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);
50422
+ return notedJsonResponse(
50423
+ [guardNote, rewriteNote, verifyNote, threadNote, unconfirmedNote, retainNote],
50424
+ responseObj,
50425
+ "Message sent successfully."
50426
+ );
50329
50427
  });
50330
50428
  async function guardDestructiveDraftOp(input2) {
50331
50429
  const { cache, draftId, expectedRevision, force, action } = input2;
@@ -50366,10 +50464,8 @@ ${text}` : text);
50366
50464
  ok: true,
50367
50465
  note: `WARNING: force:true overrode a ${verdict.verdict} freshness verdict on draft ${draftId}. ${verdict.reason} ${echoed}
50368
50466
 
50369
- ${JSON.stringify(
50370
- { overwrittenServerDraft: server2 === null ? null : { ...server2, revision: draftRevision(server2) } },
50371
- null,
50372
- 2
50467
+ ${jsonText(
50468
+ { overwrittenServerDraft: server2 === null ? null : { ...server2, revision: draftRevision(server2) } }
50373
50469
  )}`,
50374
50470
  server: server2
50375
50471
  };
@@ -50387,7 +50483,7 @@ ${JSON.stringify(
50387
50483
  }
50388
50484
  server.registerTool("ofw_list_drafts", {
50389
50485
  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 },
50486
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
50391
50487
  inputSchema: external_exports.object({
50392
50488
  page: external_exports.number().int().min(1).describe("Page number (default 1)").optional(),
50393
50489
  size: external_exports.number().int().min(1).describe("Drafts per page (default 50)").optional(),
@@ -50485,7 +50581,10 @@ ${JSON.stringify(
50485
50581
  });
50486
50582
  if (allowDrafts) server.registerTool("ofw_save_draft", {
50487
50583
  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 },
50584
+ // Destructive: with messageId, saving REPLACES the draft by creating a new
50585
+ // one and deleting the old (OFW has no draft update), so the old draft's
50586
+ // server content is gone. Not idempotent: every call mints a new draft id.
50587
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
50489
50588
  inputSchema: external_exports.object({
50490
50589
  subject: external_exports.string().describe("Message subject"),
50491
50590
  body: external_exports.string().describe("Message body text"),
@@ -50632,16 +50731,16 @@ ${JSON.stringify(
50632
50731
  ...warnings.length > 0 ? { warnings } : {},
50633
50732
  ...recipientsNote !== null ? { recipientsNote } : {}
50634
50733
  } : raw;
50635
- const text = responseObj ? JSON.stringify(responseObj, null, 2) : "Draft saved.";
50636
50734
  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);
50735
+ return notedJsonResponse(
50736
+ [forceNote, rewriteNote, verifyNote, warnNote, recipientsNote, replaceNote],
50737
+ responseObj,
50738
+ "Draft saved."
50739
+ );
50641
50740
  });
50642
50741
  if (allowDrafts) server.registerTool("ofw_delete_draft", {
50643
50742
  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 },
50743
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
50645
50744
  inputSchema: external_exports.object({
50646
50745
  messageId: external_exports.number().describe("Draft message ID to delete"),
50647
50746
  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 +50758,11 @@ ${text}` : text);
50659
50758
  if (!guard.ok) return guard.response;
50660
50759
  const data = await deleteOFWMessages(client2, [args.messageId]);
50661
50760
  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);
50761
+ return notedJsonResponse([guard.note], data, "Draft deleted.");
50666
50762
  });
50667
50763
  server.registerTool("ofw_get_unread_sent", {
50668
50764
  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 },
50765
+ annotations: { readOnlyHint: true, openWorldHint: true },
50670
50766
  inputSchema: external_exports.object({
50671
50767
  page: external_exports.number().int().min(1).describe("Page (default 1)").optional(),
50672
50768
  size: external_exports.number().int().min(1).describe("Per page (default 50)").optional(),
@@ -50742,8 +50838,10 @@ ${text}` : text);
50742
50838
  });
50743
50839
  const allowShare = writeMode === "all";
50744
50840
  if (allowDrafts) server.registerTool("ofw_upload_attachment", {
50841
+ // Destructive by the inverse test: a SHARED upload is co-parent-visible at
50842
+ // once, and no tool here deletes a My Files upload.
50745
50843
  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 },
50844
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
50747
50845
  inputSchema: external_exports.object({
50748
50846
  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
50847
  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 +50858,7 @@ ${text}` : text);
50760
50858
  const label = args.label ?? fileName;
50761
50859
  const description = args.description ?? fileName;
50762
50860
  const gate = await confirmWrite(ctx, {
50861
+ args,
50763
50862
  tool: "ofw_upload_attachment",
50764
50863
  action: "ofw.file.share",
50765
50864
  message: `Review and confirm sharing "${fileName}" with the co-parent on OurFamilyWizard. It is visible to them immediately in My Files.`,
@@ -50810,8 +50909,11 @@ ${text}` : text);
50810
50909
  });
50811
50910
  });
50812
50911
  server.registerTool("ofw_download_attachment", {
50912
+ // Destructive: force:true replaces an existing file in the attachments
50913
+ // directory (which is also the default upload directory), and nothing
50914
+ // here restores what it replaced. The default refuses to overwrite.
50813
50915
  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 },
50916
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
50815
50917
  inputSchema: external_exports.object({
50816
50918
  fileId: external_exports.number().describe("Attachment file id (from ofw_get_message \u2192 attachments[].fileId)"),
50817
50919
  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 +51004,10 @@ ${text}` : text);
50902
51004
  });
50903
51005
  });
50904
51006
  server.registerTool("ofw_sync_messages", {
51007
+ // Destructive: fetchUnreadBodies:true stamps a co-parent-visible "First
51008
+ // Viewed" time on every unread inbox message it touches — irreversible.
50905
51009
  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 },
51010
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
50907
51011
  inputSchema: external_exports.object({
50908
51012
  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
51013
  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 +51032,10 @@ ${text}` : text);
50928
51032
  return jsonResponse({ ...result, freshness });
50929
51033
  });
50930
51034
  server.registerTool("ofw_check_freshness", {
51035
+ // Not read-only, and destructive: allowMarkRead:true probes unread inbox
51036
+ // ids by fetching them, which stamps an irreversible "First Viewed" time.
50931
51037
  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 },
51038
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
50933
51039
  inputSchema: external_exports.object({
50934
51040
  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
51041
  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 +51093,10 @@ ${text}` : text);
50987
51093
  return jsonResponse(payload);
50988
51094
  });
50989
51095
  server.registerTool("ofw_status", {
51096
+ // Not read-only, and destructive, for the same reason as
51097
+ // ofw_check_freshness: allowMarkRead:true can stamp "First Viewed".
50990
51098
  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 },
51099
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
50992
51100
  inputSchema: external_exports.object({
50993
51101
  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
51102
  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 +51338,7 @@ function registerCalendarTools(server, client2) {
51230
51338
  const allowWrites = getCalendarWritesAllowed();
51231
51339
  server.registerTool("ofw_list_events", {
51232
51340
  description: "List OurFamilyWizard calendar events in a date range",
51233
- annotations: { readOnlyHint: true },
51341
+ annotations: { readOnlyHint: true, openWorldHint: true },
51234
51342
  inputSchema: external_exports.object({
51235
51343
  startDate: external_exports.string().describe("Start date YYYY-MM-DD"),
51236
51344
  endDate: external_exports.string().describe("End date YYYY-MM-DD"),
@@ -51246,9 +51354,10 @@ function registerCalendarTools(server, client2) {
51246
51354
  });
51247
51355
  if (allowWrites) server.registerTool("ofw_create_event", {
51248
51356
  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 },
51357
+ // Destructive by the inverse test: a shared event is on the co-parent's
51358
+ // calendar the moment it lands, and ofw_delete_event removes it but cannot
51359
+ // un-show it. (A private one would be additive; the hint is per tool.)
51360
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
51252
51361
  inputSchema: external_exports.object({
51253
51362
  title: external_exports.string(),
51254
51363
  ...eventWriteFields,
@@ -51259,6 +51368,7 @@ function registerCalendarTools(server, client2) {
51259
51368
  const payload = buildEventPayload(fields);
51260
51369
  if (!fields.privateEvent) {
51261
51370
  const gate = await confirmWrite(ctx, {
51371
+ args,
51262
51372
  tool: "ofw_create_event",
51263
51373
  action: "ofw.event.create",
51264
51374
  message: "Review and confirm this OurFamilyWizard calendar event. It is shared with the co-parent and appears on their calendar immediately.",
@@ -51292,7 +51402,7 @@ function registerCalendarTools(server, client2) {
51292
51402
  });
51293
51403
  if (allowWrites) server.registerTool("ofw_update_event", {
51294
51404
  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 },
51405
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
51296
51406
  inputSchema: external_exports.object({
51297
51407
  eventId: external_exports.string().describe("Event id \u2014 the `id` from ofw_list_events / eventRecurrenceId from ofw_create_event"),
51298
51408
  title: external_exports.string().optional(),
@@ -51323,6 +51433,7 @@ function registerCalendarTools(server, client2) {
51323
51433
  if (current.publicFlag || !merged.privateEvent) {
51324
51434
  const names = namesIn(current);
51325
51435
  const gate = await confirmWrite(ctx, {
51436
+ args,
51326
51437
  tool: "ofw_update_event",
51327
51438
  action: "ofw.event.update",
51328
51439
  message: `Review and confirm this change to the OurFamilyWizard event "${current.title}". The co-parent sees the updated event on their calendar.`,
@@ -51378,6 +51489,7 @@ function registerCalendarTools(server, client2) {
51378
51489
  if (current.publicFlag) {
51379
51490
  const base = detailToWriteArgs(current);
51380
51491
  const gate = await confirmWrite(ctx, {
51492
+ args,
51381
51493
  tool: "ofw_delete_event",
51382
51494
  action: "ofw.event.delete",
51383
51495
  message: `Review and confirm deleting the OurFamilyWizard event "${current.title}". It disappears from the co-parent's calendar.`,
@@ -51567,21 +51679,21 @@ function registerExpenseTools(server, client2, attachmentIO) {
51567
51679
  const allowReceiptUploads = allowWrites && attachmentIO !== void 0;
51568
51680
  if (!uploadOnly) server.registerTool("ofw_get_expense_totals", {
51569
51681
  description: "Get OurFamilyWizard expense summary totals (owed/paid)",
51570
- annotations: { readOnlyHint: true }
51682
+ annotations: { readOnlyHint: true, openWorldHint: true }
51571
51683
  }, async () => {
51572
51684
  const data = await client2.request("GET", "/pub/v2/expense/expenses/totals");
51573
51685
  return jsonResponse(data);
51574
51686
  });
51575
51687
  if (!uploadOnly) server.registerTool("ofw_list_expense_categories", {
51576
51688
  description: "List OurFamilyWizard expense categories, including preset and custom categories with their responsibility split metadata. Read-only.",
51577
- annotations: { readOnlyHint: true }
51689
+ annotations: { readOnlyHint: true, openWorldHint: true }
51578
51690
  }, async () => {
51579
51691
  const data = await client2.request("GET", "/pub/v2/expense/categories");
51580
51692
  return jsonResponse(data);
51581
51693
  });
51582
51694
  if (!uploadOnly) server.registerTool("ofw_list_expenses", {
51583
51695
  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 },
51696
+ annotations: { readOnlyHint: true, openWorldHint: true },
51585
51697
  inputSchema: external_exports.object({
51586
51698
  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
51699
  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 +51723,10 @@ function registerExpenseTools(server, client2, attachmentIO) {
51611
51723
  return jsonResponse({ ...head, ...body, ...head });
51612
51724
  });
51613
51725
  if (allowReceiptUploads) server.registerTool("ofw_upload_expense_pdf", {
51726
+ // Destructive by the inverse test: always uploaded SHARED, so the
51727
+ // co-parent sees it in My Files at once, and no tool here deletes it.
51614
51728
  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 },
51729
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
51616
51730
  inputSchema: external_exports.object({
51617
51731
  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
51732
  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 +51753,7 @@ function registerExpenseTools(server, client2, attachmentIO) {
51639
51753
  const label = args.label ?? fileName;
51640
51754
  const description = args.description ?? fileName;
51641
51755
  const gate = await confirmWrite(ctx, {
51756
+ args,
51642
51757
  tool: "ofw_upload_expense_pdf",
51643
51758
  action: "ofw.file.share",
51644
51759
  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 +51878,7 @@ function registerExpenseTools(server, client2, attachmentIO) {
51763
51878
  });
51764
51879
  }
51765
51880
  const gate = await confirmWrite(ctx, {
51881
+ args,
51766
51882
  tool: "ofw_update_expense",
51767
51883
  action: "ofw.expense.update",
51768
51884
  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 +51967,7 @@ function registerExpenseTools(server, client2, attachmentIO) {
51851
51967
  if (args.receiptFileId !== void 0) payload.fileIds = [args.receiptFileId];
51852
51968
  const isPrivate = args.privateExpense === true;
51853
51969
  const gate = await confirmWrite(ctx, {
51970
+ args,
51854
51971
  tool: "ofw_create_expense",
51855
51972
  action: "ofw.expense.create",
51856
51973
  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 +52007,7 @@ function registerJournalTools(server, client2) {
51890
52007
  const allowWrites = getWriteMode() === "all";
51891
52008
  server.registerTool("ofw_list_journal_entries", {
51892
52009
  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 },
52010
+ annotations: { readOnlyHint: true, openWorldHint: true },
51894
52011
  inputSchema: external_exports.object({
51895
52012
  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
52013
  max: external_exports.number().int().min(1).describe("Max results (default 10)").optional()
@@ -51912,8 +52029,10 @@ function registerJournalTools(server, client2) {
51912
52029
  return jsonResponse(wrapped ?? data);
51913
52030
  });
51914
52031
  if (allowWrites) server.registerTool("ofw_create_journal_entry", {
52032
+ // Destructive by the inverse test: the entry lands on the court-visible
52033
+ // record and no tool here edits or deletes a journal entry.
51915
52034
  description: "Create a new journal entry in OurFamilyWizard",
51916
- annotations: { destructiveHint: false },
52035
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
51917
52036
  inputSchema: external_exports.object({
51918
52037
  title: external_exports.string().describe("Entry title"),
51919
52038
  body: external_exports.string().describe("Entry text content")
@@ -52581,8 +52700,7 @@ var tools = selectToolRegistrars({
52581
52700
  });
52582
52701
  await runMcp({
52583
52702
  name: "ofw",
52584
- version: "2.20.3",
52585
- // x-release-please-version
52703
+ version: VERSION,
52586
52704
  deps: client,
52587
52705
  tools,
52588
52706
  banner: "[ofw-mcp] This project was developed and is maintained by AI (Claude Sonnet 4.6). Use at your own discretion."