ofw-mcp 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "OurFamilyWizard tools for Claude Code",
9
- "version": "2.1.0"
9
+ "version": "2.2.0"
10
10
  },
11
11
  "plugins": [
12
12
  {
@@ -14,7 +14,7 @@
14
14
  "displayName": "OurFamilyWizard",
15
15
  "source": "./",
16
16
  "description": "OurFamilyWizard co-parenting tools for Claude — messages, calendar, expenses, and journal via MCP",
17
- "version": "2.1.0",
17
+ "version": "2.2.0",
18
18
  "author": {
19
19
  "name": "Chris Chall"
20
20
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ofw",
3
3
  "displayName": "OurFamilyWizard",
4
- "version": "2.1.0",
4
+ "version": "2.2.0",
5
5
  "description": "OurFamilyWizard co-parenting tools for Claude — messages, calendar, expenses, and journal via MCP",
6
6
  "author": {
7
7
  "name": "Chris Chall"
package/dist/bundle.js CHANGED
@@ -3112,6 +3112,9 @@ var require_utils = __commonJS({
3112
3112
  "use strict";
3113
3113
  var isUUID = RegExp.prototype.test.bind(/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/iu);
3114
3114
  var isIPv4 = RegExp.prototype.test.bind(/^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)$/u);
3115
+ var isHexPair = RegExp.prototype.test.bind(/^[\da-f]{2}$/iu);
3116
+ var isUnreserved = RegExp.prototype.test.bind(/^[\da-z\-._~]$/iu);
3117
+ var isPathCharacter = RegExp.prototype.test.bind(/^[\da-z\-._~!$&'()*+,;=:@/]$/iu);
3115
3118
  function stringArrayToHexStripped(input) {
3116
3119
  let acc = "";
3117
3120
  let code = 0;
@@ -3304,27 +3307,77 @@ var require_utils = __commonJS({
3304
3307
  }
3305
3308
  return output.join("");
3306
3309
  }
3307
- function normalizeComponentEncoding(component, esc2) {
3308
- const func = esc2 !== true ? escape : unescape;
3309
- if (component.scheme !== void 0) {
3310
- component.scheme = func(component.scheme);
3311
- }
3312
- if (component.userinfo !== void 0) {
3313
- component.userinfo = func(component.userinfo);
3314
- }
3315
- if (component.host !== void 0) {
3316
- component.host = func(component.host);
3310
+ var HOST_DELIMS = { "@": "%40", "/": "%2F", "?": "%3F", "#": "%23", ":": "%3A" };
3311
+ var HOST_DELIM_RE = /[@/?#:]/g;
3312
+ var HOST_DELIM_NO_COLON_RE = /[@/?#]/g;
3313
+ function reescapeHostDelimiters(host, isIP) {
3314
+ const re = isIP ? HOST_DELIM_NO_COLON_RE : HOST_DELIM_RE;
3315
+ re.lastIndex = 0;
3316
+ return host.replace(re, (ch) => HOST_DELIMS[ch]);
3317
+ }
3318
+ function normalizePercentEncoding(input, decodeUnreserved = false) {
3319
+ if (input.indexOf("%") === -1) {
3320
+ return input;
3317
3321
  }
3318
- if (component.path !== void 0) {
3319
- component.path = func(component.path);
3322
+ let output = "";
3323
+ for (let i = 0; i < input.length; i++) {
3324
+ if (input[i] === "%" && i + 2 < input.length) {
3325
+ const hex3 = input.slice(i + 1, i + 3);
3326
+ if (isHexPair(hex3)) {
3327
+ const normalizedHex = hex3.toUpperCase();
3328
+ const decoded = String.fromCharCode(parseInt(normalizedHex, 16));
3329
+ if (decodeUnreserved && isUnreserved(decoded)) {
3330
+ output += decoded;
3331
+ } else {
3332
+ output += "%" + normalizedHex;
3333
+ }
3334
+ i += 2;
3335
+ continue;
3336
+ }
3337
+ }
3338
+ output += input[i];
3320
3339
  }
3321
- if (component.query !== void 0) {
3322
- component.query = func(component.query);
3340
+ return output;
3341
+ }
3342
+ function normalizePathEncoding(input) {
3343
+ let output = "";
3344
+ for (let i = 0; i < input.length; i++) {
3345
+ if (input[i] === "%" && i + 2 < input.length) {
3346
+ const hex3 = input.slice(i + 1, i + 3);
3347
+ if (isHexPair(hex3)) {
3348
+ const normalizedHex = hex3.toUpperCase();
3349
+ const decoded = String.fromCharCode(parseInt(normalizedHex, 16));
3350
+ if (decoded !== "." && isUnreserved(decoded)) {
3351
+ output += decoded;
3352
+ } else {
3353
+ output += "%" + normalizedHex;
3354
+ }
3355
+ i += 2;
3356
+ continue;
3357
+ }
3358
+ }
3359
+ if (isPathCharacter(input[i])) {
3360
+ output += input[i];
3361
+ } else {
3362
+ output += escape(input[i]);
3363
+ }
3323
3364
  }
3324
- if (component.fragment !== void 0) {
3325
- component.fragment = func(component.fragment);
3365
+ return output;
3366
+ }
3367
+ function escapePreservingEscapes(input) {
3368
+ let output = "";
3369
+ for (let i = 0; i < input.length; i++) {
3370
+ if (input[i] === "%" && i + 2 < input.length) {
3371
+ const hex3 = input.slice(i + 1, i + 3);
3372
+ if (isHexPair(hex3)) {
3373
+ output += "%" + hex3.toUpperCase();
3374
+ i += 2;
3375
+ continue;
3376
+ }
3377
+ }
3378
+ output += escape(input[i]);
3326
3379
  }
3327
- return component;
3380
+ return output;
3328
3381
  }
3329
3382
  function recomposeAuthority(component) {
3330
3383
  const uriTokens = [];
@@ -3339,7 +3392,7 @@ var require_utils = __commonJS({
3339
3392
  if (ipV6res.isIPV6 === true) {
3340
3393
  host = `[${ipV6res.escapedHost}]`;
3341
3394
  } else {
3342
- host = component.host;
3395
+ host = reescapeHostDelimiters(host, false);
3343
3396
  }
3344
3397
  }
3345
3398
  uriTokens.push(host);
@@ -3353,7 +3406,10 @@ var require_utils = __commonJS({
3353
3406
  module.exports = {
3354
3407
  nonSimpleDomain,
3355
3408
  recomposeAuthority,
3356
- normalizeComponentEncoding,
3409
+ reescapeHostDelimiters,
3410
+ normalizePercentEncoding,
3411
+ normalizePathEncoding,
3412
+ escapePreservingEscapes,
3357
3413
  removeDotSegments,
3358
3414
  isIPv4,
3359
3415
  isUUID,
@@ -3577,12 +3633,12 @@ var require_schemes = __commonJS({
3577
3633
  var require_fast_uri = __commonJS({
3578
3634
  "node_modules/fast-uri/index.js"(exports, module) {
3579
3635
  "use strict";
3580
- var { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizeComponentEncoding, isIPv4, nonSimpleDomain } = require_utils();
3636
+ var { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizePercentEncoding, normalizePathEncoding, escapePreservingEscapes, reescapeHostDelimiters, isIPv4, nonSimpleDomain } = require_utils();
3581
3637
  var { SCHEMES, getSchemeHandler } = require_schemes();
3582
3638
  function normalize(uri, options) {
3583
3639
  if (typeof uri === "string") {
3584
3640
  uri = /** @type {T} */
3585
- serialize(parse3(uri, options), options);
3641
+ normalizeString(uri, options);
3586
3642
  } else if (typeof uri === "object") {
3587
3643
  uri = /** @type {T} */
3588
3644
  parse3(serialize(uri, options), options);
@@ -3649,19 +3705,9 @@ var require_fast_uri = __commonJS({
3649
3705
  return target;
3650
3706
  }
3651
3707
  function equal(uriA, uriB, options) {
3652
- if (typeof uriA === "string") {
3653
- uriA = unescape(uriA);
3654
- uriA = serialize(normalizeComponentEncoding(parse3(uriA, options), true), { ...options, skipEscape: true });
3655
- } else if (typeof uriA === "object") {
3656
- uriA = serialize(normalizeComponentEncoding(uriA, true), { ...options, skipEscape: true });
3657
- }
3658
- if (typeof uriB === "string") {
3659
- uriB = unescape(uriB);
3660
- uriB = serialize(normalizeComponentEncoding(parse3(uriB, options), true), { ...options, skipEscape: true });
3661
- } else if (typeof uriB === "object") {
3662
- uriB = serialize(normalizeComponentEncoding(uriB, true), { ...options, skipEscape: true });
3663
- }
3664
- return uriA.toLowerCase() === uriB.toLowerCase();
3708
+ const normalizedA = normalizeComparableURI(uriA, options);
3709
+ const normalizedB = normalizeComparableURI(uriB, options);
3710
+ return normalizedA !== void 0 && normalizedB !== void 0 && normalizedA.toLowerCase() === normalizedB.toLowerCase();
3665
3711
  }
3666
3712
  function serialize(cmpts, opts) {
3667
3713
  const component = {
@@ -3686,12 +3732,12 @@ var require_fast_uri = __commonJS({
3686
3732
  if (schemeHandler && schemeHandler.serialize) schemeHandler.serialize(component, options);
3687
3733
  if (component.path !== void 0) {
3688
3734
  if (!options.skipEscape) {
3689
- component.path = escape(component.path);
3735
+ component.path = escapePreservingEscapes(component.path);
3690
3736
  if (component.scheme !== void 0) {
3691
3737
  component.path = component.path.split("%3A").join(":");
3692
3738
  }
3693
3739
  } else {
3694
- component.path = unescape(component.path);
3740
+ component.path = normalizePercentEncoding(component.path);
3695
3741
  }
3696
3742
  }
3697
3743
  if (options.reference !== "suffix" && component.scheme) {
@@ -3726,7 +3772,16 @@ var require_fast_uri = __commonJS({
3726
3772
  return uriTokens.join("");
3727
3773
  }
3728
3774
  var URI_PARSE = /^(?:([^#/:?]+):)?(?:\/\/((?:([^#/?@]*)@)?(\[[^#/?\]]+\]|[^#/:?]*)(?::(\d*))?))?([^#?]*)(?:\?([^#]*))?(?:#((?:.|[\n\r])*))?/u;
3729
- function parse3(uri, opts) {
3775
+ function getParseError(parsed, matches) {
3776
+ if (matches[2] !== void 0 && parsed.path && parsed.path[0] !== "/") {
3777
+ return 'URI path must start with "/" when authority is present.';
3778
+ }
3779
+ if (typeof parsed.port === "number" && (parsed.port < 0 || parsed.port > 65535)) {
3780
+ return "URI port is malformed.";
3781
+ }
3782
+ return void 0;
3783
+ }
3784
+ function parseWithStatus(uri, opts) {
3730
3785
  const options = Object.assign({}, opts);
3731
3786
  const parsed = {
3732
3787
  scheme: void 0,
@@ -3737,6 +3792,7 @@ var require_fast_uri = __commonJS({
3737
3792
  query: void 0,
3738
3793
  fragment: void 0
3739
3794
  };
3795
+ let malformedAuthorityOrPort = false;
3740
3796
  let isIP = false;
3741
3797
  if (options.reference === "suffix") {
3742
3798
  if (options.scheme) {
@@ -3757,6 +3813,11 @@ var require_fast_uri = __commonJS({
3757
3813
  if (isNaN(parsed.port)) {
3758
3814
  parsed.port = matches[5];
3759
3815
  }
3816
+ const parseError = getParseError(parsed, matches);
3817
+ if (parseError !== void 0) {
3818
+ parsed.error = parsed.error || parseError;
3819
+ malformedAuthorityOrPort = true;
3820
+ }
3760
3821
  if (parsed.host) {
3761
3822
  const ipv4result = isIPv4(parsed.host);
3762
3823
  if (ipv4result === false) {
@@ -3795,14 +3856,18 @@ var require_fast_uri = __commonJS({
3795
3856
  parsed.scheme = unescape(parsed.scheme);
3796
3857
  }
3797
3858
  if (parsed.host !== void 0) {
3798
- parsed.host = unescape(parsed.host);
3859
+ parsed.host = reescapeHostDelimiters(unescape(parsed.host), isIP);
3799
3860
  }
3800
3861
  }
3801
3862
  if (parsed.path) {
3802
- parsed.path = escape(unescape(parsed.path));
3863
+ parsed.path = normalizePathEncoding(parsed.path);
3803
3864
  }
3804
3865
  if (parsed.fragment) {
3805
- parsed.fragment = encodeURI(decodeURIComponent(parsed.fragment));
3866
+ try {
3867
+ parsed.fragment = encodeURI(decodeURIComponent(parsed.fragment));
3868
+ } catch {
3869
+ parsed.error = parsed.error || "URI malformed";
3870
+ }
3806
3871
  }
3807
3872
  }
3808
3873
  if (schemeHandler && schemeHandler.parse) {
@@ -3811,7 +3876,29 @@ var require_fast_uri = __commonJS({
3811
3876
  } else {
3812
3877
  parsed.error = parsed.error || "URI can not be parsed.";
3813
3878
  }
3814
- return parsed;
3879
+ return { parsed, malformedAuthorityOrPort };
3880
+ }
3881
+ function parse3(uri, opts) {
3882
+ return parseWithStatus(uri, opts).parsed;
3883
+ }
3884
+ function normalizeString(uri, opts) {
3885
+ return normalizeStringWithStatus(uri, opts).normalized;
3886
+ }
3887
+ function normalizeStringWithStatus(uri, opts) {
3888
+ const { parsed, malformedAuthorityOrPort } = parseWithStatus(uri, opts);
3889
+ return {
3890
+ normalized: malformedAuthorityOrPort ? uri : serialize(parsed, opts),
3891
+ malformedAuthorityOrPort
3892
+ };
3893
+ }
3894
+ function normalizeComparableURI(uri, opts) {
3895
+ if (typeof uri === "string") {
3896
+ const { normalized, malformedAuthorityOrPort } = normalizeStringWithStatus(uri, opts);
3897
+ return malformedAuthorityOrPort ? void 0 : normalized;
3898
+ }
3899
+ if (typeof uri === "object") {
3900
+ return serialize(uri, opts);
3901
+ }
3815
3902
  }
3816
3903
  var fastUri = {
3817
3904
  SCHEMES,
@@ -37235,7 +37322,7 @@ function getDefaultInlineAttachments() {
37235
37322
  // package.json
37236
37323
  var package_default = {
37237
37324
  name: "ofw-mcp",
37238
- version: "2.1.0",
37325
+ version: "2.2.0",
37239
37326
  mcpName: "io.github.chrischall/ofw-mcp",
37240
37327
  description: "OurFamilyWizard MCP server for Claude \u2014 developed and maintained by AI (Claude Code)",
37241
37328
  author: "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -37375,6 +37462,13 @@ function redactHeaders(h) {
37375
37462
  if (out.Authorization) out.Authorization = `Bearer ${out.Authorization.slice(7, 17)}\u2026`;
37376
37463
  return out;
37377
37464
  }
37465
+ var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
37466
+ function getRequestTimeoutMs() {
37467
+ const raw = process.env.OFW_REQUEST_TIMEOUT_MS;
37468
+ if (typeof raw !== "string" || raw.trim().length === 0) return DEFAULT_REQUEST_TIMEOUT_MS;
37469
+ const n = Number(raw.trim());
37470
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_REQUEST_TIMEOUT_MS;
37471
+ }
37378
37472
  var OFWClient = class {
37379
37473
  token = null;
37380
37474
  tokenExpiry = null;
@@ -37415,13 +37509,37 @@ var OFWClient = class {
37415
37509
  console.error(`[ofw-debug] headers: ${JSON.stringify(redactHeaders(headers))}`);
37416
37510
  console.error(`[ofw-debug] body: ${bodyPreview}`);
37417
37511
  }
37418
- const response = await fetch(url2, {
37419
- method,
37420
- headers,
37421
- ...body !== void 0 ? { body: isFormData ? body : JSON.stringify(body) } : {}
37422
- });
37512
+ const timeoutMs = getRequestTimeoutMs();
37513
+ const ac = new AbortController();
37514
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
37515
+ const startedAt = Date.now();
37516
+ let response;
37517
+ try {
37518
+ response = await fetch(url2, {
37519
+ method,
37520
+ headers,
37521
+ signal: ac.signal,
37522
+ ...body !== void 0 ? { body: isFormData ? body : JSON.stringify(body) } : {}
37523
+ });
37524
+ } catch (err) {
37525
+ const elapsed = Date.now() - startedAt;
37526
+ if (ac.signal.aborted) {
37527
+ if (debugLogEnabled()) {
37528
+ console.error(`[ofw-debug] \u23F1 TIMEOUT after ${elapsed}ms: ${method} ${url2}`);
37529
+ }
37530
+ throw new Error(
37531
+ `OFW API request timed out after ${timeoutMs}ms: ${method} ${path}`
37532
+ );
37533
+ }
37534
+ if (debugLogEnabled()) {
37535
+ console.error(`[ofw-debug] \u2717 ${err.message} after ${elapsed}ms: ${method} ${url2}`);
37536
+ }
37537
+ throw err;
37538
+ } finally {
37539
+ clearTimeout(timer);
37540
+ }
37423
37541
  if (debugLogEnabled()) {
37424
- console.error(`[ofw-debug] \u2190 ${response.status} ${response.statusText}`);
37542
+ console.error(`[ofw-debug] \u2190 ${response.status} ${response.statusText} (${Date.now() - startedAt}ms)`);
37425
37543
  }
37426
37544
  if (response.status === 401 && !isRetry) {
37427
37545
  this.token = null;
@@ -38156,18 +38274,55 @@ function registerMessageTools(server2, client2) {
38156
38274
  return jsonResponse({ ...row, attachments });
38157
38275
  });
38158
38276
  server2.registerTool("ofw_send_message", {
38159
- description: "Send a message via OurFamilyWizard. If sending from a draft, pass draftId to delete the draft after sending. If replyToId is provided, the cache may rewrite it to the latest reply in the same thread (a note is included in the response when this happens). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs. After sending, the tool re-fetches the message from OFW to populate the local cache and link attachments to the new message id.",
38277
+ description: "Send a message via OurFamilyWizard. To send an existing draft, pass messageId \u2014 subject/body/recipientIds become optional overrides (missing fields default to the draft's cached values) and the draft is deleted after sending. To send a fresh message, supply subject/body/recipientIds directly. draftId is the legacy spelling of messageId and works the same way. If replyToId is provided, the cache may rewrite it to the latest reply in the same thread (a note is included in the response when this happens). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs. After sending, the tool re-fetches the message from OFW to populate the local cache and link attachments to the new message id.",
38160
38278
  annotations: { destructiveHint: true },
38161
38279
  inputSchema: {
38162
- subject: external_exports.string().describe("Message subject"),
38163
- body: external_exports.string().describe("Message body text"),
38164
- recipientIds: external_exports.array(external_exports.number()).describe("Array of recipient user IDs (get from ofw_get_profile)"),
38280
+ subject: external_exports.string().describe("Message subject. Required unless messageId/draftId references a cached draft.").optional(),
38281
+ body: external_exports.string().describe("Message body text. Required unless messageId/draftId references a cached draft.").optional(),
38282
+ recipientIds: external_exports.array(external_exports.number()).describe("Array of recipient user IDs (get from ofw_get_profile). Required unless messageId/draftId references a cached draft.").optional(),
38165
38283
  replyToId: external_exports.number().describe("ID of the message being replied to").optional(),
38166
- draftId: external_exports.number().describe("ID of the draft to delete after sending (omit if not sending from a draft)").optional(),
38284
+ messageId: external_exports.number().describe("ID of an existing draft to send. When set, missing subject/body/recipientIds default to the draft's cached values, and the draft is deleted after sending.").optional(),
38285
+ draftId: external_exports.number().describe("Legacy synonym for messageId. If both are passed they must be equal.").optional(),
38167
38286
  myFileIDs: external_exports.array(external_exports.number()).describe("Attachment file ids (from ofw_upload_attachment) to attach to the message").optional()
38168
38287
  }
38169
38288
  }, async (args) => {
38170
- const requestedReplyTo = args.replyToId ?? null;
38289
+ if (args.messageId !== void 0 && args.draftId !== void 0 && args.messageId !== args.draftId) {
38290
+ throw new Error(`messageId (${args.messageId}) and draftId (${args.draftId}) refer to different drafts; pass only one.`);
38291
+ }
38292
+ const draftRef = args.messageId ?? args.draftId;
38293
+ let subject = args.subject;
38294
+ let body = args.body;
38295
+ let recipientIds = args.recipientIds;
38296
+ let draftReplyToId = null;
38297
+ let draftLookupAttempted = false;
38298
+ let draftFound = false;
38299
+ if (draftRef !== void 0) {
38300
+ draftLookupAttempted = true;
38301
+ const draft = getDraft(draftRef);
38302
+ if (draft !== null) {
38303
+ draftFound = true;
38304
+ subject = subject ?? draft.subject;
38305
+ body = body ?? draft.body;
38306
+ recipientIds = recipientIds ?? draft.recipients.map((r) => r.userId);
38307
+ draftReplyToId = draft.replyToId;
38308
+ }
38309
+ }
38310
+ if (subject === void 0 || body === void 0 || recipientIds === void 0) {
38311
+ if (draftLookupAttempted && !draftFound) {
38312
+ throw new Error(
38313
+ `draft ${draftRef} not found in local cache. Call ofw_sync_messages first, or supply subject/body/recipientIds explicitly.`
38314
+ );
38315
+ }
38316
+ const missing = [
38317
+ subject === void 0 ? "subject" : null,
38318
+ body === void 0 ? "body" : null,
38319
+ recipientIds === void 0 ? "recipientIds" : null
38320
+ ].filter((n) => n !== null).join(", ");
38321
+ throw new Error(
38322
+ `ofw_send_message requires ${missing}. Pass it directly, or pass messageId to default missing fields from a cached draft.`
38323
+ );
38324
+ }
38325
+ const requestedReplyTo = args.replyToId ?? draftReplyToId ?? null;
38171
38326
  let resolvedReplyTo = requestedReplyTo;
38172
38327
  let chainRootId = null;
38173
38328
  let rewriteNote = null;
@@ -38181,9 +38336,9 @@ function registerMessageTools(server2, client2) {
38181
38336
  }
38182
38337
  const myFileIDs = args.myFileIDs ?? [];
38183
38338
  const { id: newId, detail, raw } = await postMessageAndRefetch(client2, {
38184
- subject: args.subject,
38185
- body: args.body,
38186
- recipientIds: args.recipientIds,
38339
+ subject,
38340
+ body,
38341
+ recipientIds,
38187
38342
  attachments: { myFileIDs },
38188
38343
  draft: false,
38189
38344
  includeOriginal: resolvedReplyTo !== null,
@@ -38194,11 +38349,11 @@ function registerMessageTools(server2, client2) {
38194
38349
  persisted = {
38195
38350
  id: newId,
38196
38351
  folder: "sent",
38197
- subject: detail.subject ?? args.subject,
38352
+ subject: detail.subject ?? subject,
38198
38353
  fromUser: detail.from?.name ?? "",
38199
38354
  sentAt: detail.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString(),
38200
38355
  recipients: mapRecipients(detail.recipients),
38201
- body: detail.body ?? args.body,
38356
+ body: detail.body ?? body,
38202
38357
  fetchedBodyAt: (/* @__PURE__ */ new Date()).toISOString(),
38203
38358
  replyToId: resolvedReplyTo,
38204
38359
  chainRootId,
@@ -38218,9 +38373,9 @@ function registerMessageTools(server2, client2) {
38218
38373
  });
38219
38374
  }
38220
38375
  }
38221
- if (args.draftId !== void 0) {
38222
- await deleteOFWMessages(client2, [args.draftId]);
38223
- deleteDraft(args.draftId);
38376
+ if (draftRef !== void 0) {
38377
+ await deleteOFWMessages(client2, [draftRef]);
38378
+ deleteDraft(draftRef);
38224
38379
  }
38225
38380
  const responseObj = persisted ?? raw;
38226
38381
  const text = responseObj ? JSON.stringify(responseObj, null, 2) : "Message sent successfully.";
@@ -38629,7 +38784,7 @@ process.emit = function(event, ...args) {
38629
38784
  }
38630
38785
  return originalEmit(event, ...args);
38631
38786
  };
38632
- var server = new McpServer({ name: "ofw", version: "2.1.0" });
38787
+ var server = new McpServer({ name: "ofw", version: "2.2.0" });
38633
38788
  registerUserTools(server, client);
38634
38789
  registerMessageTools(server, client);
38635
38790
  registerCalendarTools(server, client);
package/dist/client.js CHANGED
@@ -40,6 +40,19 @@ function redactHeaders(h) {
40
40
  out.Authorization = `Bearer ${out.Authorization.slice(7, 17)}…`;
41
41
  return out;
42
42
  }
43
+ // Per-request timeout. Overridable via OFW_REQUEST_TIMEOUT_MS. The default
44
+ // (30s) is comfortably above OFW's typical p99 but low enough that a stuck
45
+ // upstream fails fast instead of burning the MCP client-side budget — which
46
+ // is what produced the multi-minute hangs we've seen on ofw_list_messages
47
+ // and ofw_save_draft. Each retry (401/429 replay) gets its own fresh window.
48
+ const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
49
+ function getRequestTimeoutMs() {
50
+ const raw = process.env.OFW_REQUEST_TIMEOUT_MS;
51
+ if (typeof raw !== 'string' || raw.trim().length === 0)
52
+ return DEFAULT_REQUEST_TIMEOUT_MS;
53
+ const n = Number(raw.trim());
54
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_REQUEST_TIMEOUT_MS;
55
+ }
43
56
  export class OFWClient {
44
57
  token = null;
45
58
  tokenExpiry = null;
@@ -85,13 +98,40 @@ export class OFWClient {
85
98
  console.error(`[ofw-debug] headers: ${JSON.stringify(redactHeaders(headers))}`);
86
99
  console.error(`[ofw-debug] body: ${bodyPreview}`);
87
100
  }
88
- const response = await fetch(url, {
89
- method,
90
- headers,
91
- ...(body !== undefined ? { body: isFormData ? body : JSON.stringify(body) } : {}),
92
- });
101
+ // AbortController + setTimeout (not AbortSignal.timeout) so vitest fake
102
+ // timers can drive the timeout in tests, and so we can attach a clear
103
+ // error message instead of a bare DOMException on the abort path.
104
+ const timeoutMs = getRequestTimeoutMs();
105
+ const ac = new AbortController();
106
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
107
+ const startedAt = Date.now();
108
+ let response;
109
+ try {
110
+ response = await fetch(url, {
111
+ method,
112
+ headers,
113
+ signal: ac.signal,
114
+ ...(body !== undefined ? { body: isFormData ? body : JSON.stringify(body) } : {}),
115
+ });
116
+ }
117
+ catch (err) {
118
+ const elapsed = Date.now() - startedAt;
119
+ if (ac.signal.aborted) {
120
+ if (debugLogEnabled()) {
121
+ console.error(`[ofw-debug] ⏱ TIMEOUT after ${elapsed}ms: ${method} ${url}`);
122
+ }
123
+ throw new Error(`OFW API request timed out after ${timeoutMs}ms: ${method} ${path}`);
124
+ }
125
+ if (debugLogEnabled()) {
126
+ console.error(`[ofw-debug] ✗ ${err.message} after ${elapsed}ms: ${method} ${url}`);
127
+ }
128
+ throw err;
129
+ }
130
+ finally {
131
+ clearTimeout(timer);
132
+ }
93
133
  if (debugLogEnabled()) {
94
- console.error(`[ofw-debug] ← ${response.status} ${response.statusText}`);
134
+ console.error(`[ofw-debug] ← ${response.status} ${response.statusText} (${Date.now() - startedAt}ms)`);
95
135
  }
96
136
  if (response.status === 401 && !isRetry) {
97
137
  this.token = null;
package/dist/index.js CHANGED
@@ -17,7 +17,7 @@ import { registerMessageTools } from './tools/messages.js';
17
17
  import { registerCalendarTools } from './tools/calendar.js';
18
18
  import { registerExpenseTools } from './tools/expenses.js';
19
19
  import { registerJournalTools } from './tools/journal.js';
20
- const server = new McpServer({ name: 'ofw', version: '2.1.0' }); // x-release-please-version
20
+ const server = new McpServer({ name: 'ofw', version: '2.2.0' }); // x-release-please-version
21
21
  registerUserTools(server, client);
22
22
  registerMessageTools(server, client);
23
23
  registerCalendarTools(server, client);
@@ -175,18 +175,59 @@ export function registerMessageTools(server, client) {
175
175
  return jsonResponse({ ...row, attachments });
176
176
  });
177
177
  server.registerTool('ofw_send_message', {
178
- description: 'Send a message via OurFamilyWizard. If sending from a draft, pass draftId to delete the draft after sending. If replyToId is provided, the cache may rewrite it to the latest reply in the same thread (a note is included in the response when this happens). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs. After sending, the tool re-fetches the message from OFW to populate the local cache and link attachments to the new message id.',
178
+ description: 'Send a message via OurFamilyWizard. To send an existing draft, pass messageId — subject/body/recipientIds become optional overrides (missing fields default to the draft\'s cached values) and the draft is deleted after sending. To send a fresh message, supply subject/body/recipientIds directly. draftId is the legacy spelling of messageId and works the same way. If replyToId is provided, the cache may rewrite it to the latest reply in the same thread (a note is included in the response when this happens). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs. After sending, the tool re-fetches the message from OFW to populate the local cache and link attachments to the new message id.',
179
179
  annotations: { destructiveHint: true },
180
180
  inputSchema: {
181
- subject: z.string().describe('Message subject'),
182
- body: z.string().describe('Message body text'),
183
- recipientIds: z.array(z.number()).describe('Array of recipient user IDs (get from ofw_get_profile)'),
181
+ subject: z.string().describe('Message subject. Required unless messageId/draftId references a cached draft.').optional(),
182
+ body: z.string().describe('Message body text. Required unless messageId/draftId references a cached draft.').optional(),
183
+ recipientIds: z.array(z.number()).describe('Array of recipient user IDs (get from ofw_get_profile). Required unless messageId/draftId references a cached draft.').optional(),
184
184
  replyToId: z.number().describe('ID of the message being replied to').optional(),
185
- draftId: z.number().describe('ID of the draft to delete after sending (omit if not sending from a draft)').optional(),
185
+ messageId: z.number().describe('ID of an existing draft to send. When set, missing subject/body/recipientIds default to the draft\'s cached values, and the draft is deleted after sending.').optional(),
186
+ draftId: z.number().describe('Legacy synonym for messageId. If both are passed they must be equal.').optional(),
186
187
  myFileIDs: z.array(z.number()).describe('Attachment file ids (from ofw_upload_attachment) to attach to the message').optional(),
187
188
  },
188
189
  }, async (args) => {
189
- const requestedReplyTo = args.replyToId ?? null;
190
+ if (args.messageId !== undefined && args.draftId !== undefined && args.messageId !== args.draftId) {
191
+ throw new Error(`messageId (${args.messageId}) and draftId (${args.draftId}) refer to different drafts; pass only one.`);
192
+ }
193
+ const draftRef = args.messageId ?? args.draftId;
194
+ // Best-effort draft lookup: when draftRef points at a cached draft, use
195
+ // its stored fields (including replyToId) as defaults for anything the
196
+ // caller didn't supply. The "missing draft" case only matters when we
197
+ // actually NEED the defaults — a caller passing all fields explicitly
198
+ // can use draftId as a pure delete-target even on an empty cache.
199
+ let subject = args.subject;
200
+ let body = args.body;
201
+ let recipientIds = args.recipientIds;
202
+ let draftReplyToId = null;
203
+ let draftLookupAttempted = false;
204
+ let draftFound = false;
205
+ if (draftRef !== undefined) {
206
+ draftLookupAttempted = true;
207
+ const draft = getDraft(draftRef);
208
+ if (draft !== null) {
209
+ draftFound = true;
210
+ subject = subject ?? draft.subject;
211
+ body = body ?? draft.body;
212
+ recipientIds = recipientIds ?? draft.recipients.map((r) => r.userId);
213
+ draftReplyToId = draft.replyToId;
214
+ }
215
+ }
216
+ if (subject === undefined || body === undefined || recipientIds === undefined) {
217
+ if (draftLookupAttempted && !draftFound) {
218
+ throw new Error(`draft ${draftRef} not found in local cache. Call ofw_sync_messages first, or supply subject/body/recipientIds explicitly.`);
219
+ }
220
+ const missing = [
221
+ subject === undefined ? 'subject' : null,
222
+ body === undefined ? 'body' : null,
223
+ recipientIds === undefined ? 'recipientIds' : null,
224
+ ].filter((n) => n !== null).join(', ');
225
+ throw new Error(`ofw_send_message requires ${missing}. Pass it directly, or pass messageId to default missing fields from a cached draft.`);
226
+ }
227
+ // Inherit the draft's replyToId when the caller didn't supply one. A
228
+ // reply-draft saved with replyToId would otherwise be sent as a
229
+ // top-level message — silently losing the thread.
230
+ const requestedReplyTo = args.replyToId ?? draftReplyToId ?? null;
190
231
  let resolvedReplyTo = requestedReplyTo;
191
232
  let chainRootId = null;
192
233
  let rewriteNote = null;
@@ -200,9 +241,9 @@ export function registerMessageTools(server, client) {
200
241
  }
201
242
  const myFileIDs = args.myFileIDs ?? [];
202
243
  const { id: newId, detail, raw } = await postMessageAndRefetch(client, {
203
- subject: args.subject,
204
- body: args.body,
205
- recipientIds: args.recipientIds,
244
+ subject,
245
+ body,
246
+ recipientIds,
206
247
  attachments: { myFileIDs },
207
248
  draft: false,
208
249
  includeOriginal: resolvedReplyTo !== null,
@@ -213,11 +254,11 @@ export function registerMessageTools(server, client) {
213
254
  persisted = {
214
255
  id: newId,
215
256
  folder: 'sent',
216
- subject: detail.subject ?? args.subject,
257
+ subject: detail.subject ?? subject,
217
258
  fromUser: detail.from?.name ?? '',
218
259
  sentAt: detail.date?.dateTime ?? new Date().toISOString(),
219
260
  recipients: mapRecipients(detail.recipients),
220
- body: detail.body ?? args.body,
261
+ body: detail.body ?? body,
221
262
  fetchedBodyAt: new Date().toISOString(),
222
263
  replyToId: resolvedReplyTo,
223
264
  chainRootId,
@@ -240,9 +281,9 @@ export function registerMessageTools(server, client) {
240
281
  });
241
282
  }
242
283
  }
243
- if (args.draftId !== undefined) {
244
- await deleteOFWMessages(client, [args.draftId]);
245
- deleteDraft(args.draftId);
284
+ if (draftRef !== undefined) {
285
+ await deleteOFWMessages(client, [draftRef]);
286
+ deleteDraft(draftRef);
246
287
  }
247
288
  const responseObj = persisted ?? raw;
248
289
  const text = responseObj ? JSON.stringify(responseObj, null, 2) : 'Message sent successfully.';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ofw-mcp",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "mcpName": "io.github.chrischall/ofw-mcp",
5
5
  "description": "OurFamilyWizard MCP server for Claude — developed and maintained by AI (Claude Code)",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/chrischall/ofw-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "2.1.0",
9
+ "version": "2.2.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ofw-mcp",
14
- "version": "2.1.0",
14
+ "version": "2.2.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },