ofw-mcp 2.0.9 → 2.0.11

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.0.9"
9
+ "version": "2.0.11"
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.0.9",
17
+ "version": "2.0.11",
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.0.9",
4
+ "version": "2.0.11",
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
@@ -31379,12 +31379,14 @@ function listAttachmentsForMessage(messageId) {
31379
31379
  function upsertAttachmentForMessage(input) {
31380
31380
  const { db } = openCache();
31381
31381
  const existing = db.prepare("SELECT message_ids_json FROM attachments WHERE file_id = ?").get(input.fileId);
31382
+ const prior = existing ? JSON.parse(existing.message_ids_json) : [];
31382
31383
  let messageIds;
31383
- if (existing) {
31384
- const arr = JSON.parse(existing.message_ids_json);
31385
- messageIds = arr.includes(input.messageId) ? arr : [...arr, input.messageId];
31384
+ if (input.messageId === 0) {
31385
+ messageIds = prior;
31386
+ } else if (prior.includes(input.messageId)) {
31387
+ messageIds = prior;
31386
31388
  } else {
31387
- messageIds = [input.messageId];
31389
+ messageIds = [...prior, input.messageId];
31388
31390
  }
31389
31391
  db.prepare(
31390
31392
  `INSERT INTO attachments (
@@ -31592,8 +31594,42 @@ async function syncAll(client2, opts) {
31592
31594
  }
31593
31595
 
31594
31596
  // src/tools/messages.ts
31595
- import { mkdirSync as mkdirSync2, writeFileSync } from "node:fs";
31596
- import { dirname as dirname3, join as join3, isAbsolute, resolve } from "node:path";
31597
+ import { mkdirSync as mkdirSync2, readFileSync, statSync, writeFileSync } from "node:fs";
31598
+ import { basename, dirname as dirname3, extname, join as join3, isAbsolute, resolve } from "node:path";
31599
+ var MIME_BY_EXT = {
31600
+ ".pdf": "application/pdf",
31601
+ ".png": "image/png",
31602
+ ".jpg": "image/jpeg",
31603
+ ".jpeg": "image/jpeg",
31604
+ ".gif": "image/gif",
31605
+ ".webp": "image/webp",
31606
+ ".heic": "image/heic",
31607
+ ".txt": "text/plain",
31608
+ ".md": "text/markdown",
31609
+ ".csv": "text/csv",
31610
+ ".html": "text/html",
31611
+ ".htm": "text/html",
31612
+ ".json": "application/json",
31613
+ ".xml": "application/xml",
31614
+ ".doc": "application/msword",
31615
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
31616
+ ".xls": "application/vnd.ms-excel",
31617
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
31618
+ ".ppt": "application/vnd.ms-powerpoint",
31619
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
31620
+ ".zip": "application/zip",
31621
+ ".ics": "text/calendar"
31622
+ };
31623
+ function mimeFromName(name) {
31624
+ return MIME_BY_EXT[extname(name).toLowerCase()] ?? "application/octet-stream";
31625
+ }
31626
+ function listDataHintsAtFiles(listData) {
31627
+ if (typeof listData !== "object" || listData === null) return false;
31628
+ const ld = listData;
31629
+ if (typeof ld.files === "number") return ld.files > 0;
31630
+ if (Array.isArray(ld.files)) return ld.files.length > 0;
31631
+ return false;
31632
+ }
31597
31633
  function registerMessageTools(server2, client2) {
31598
31634
  server2.registerTool("ofw_list_message_folders", {
31599
31635
  description: "List OurFamilyWizard message folders (inbox, sent, etc.) and their unread counts. Returns folder IDs needed to call ofw_list_messages. Does NOT return message content.",
@@ -31653,7 +31689,17 @@ function registerMessageTools(server2, client2) {
31653
31689
  const id = Number(args.messageId);
31654
31690
  const cached2 = getMessage(id);
31655
31691
  if (cached2 && cached2.body !== null) {
31656
- const attachments2 = listAttachmentsForMessage(id);
31692
+ let attachments2 = listAttachmentsForMessage(id);
31693
+ if (attachments2.length === 0 && listDataHintsAtFiles(cached2.listData)) {
31694
+ try {
31695
+ const detail2 = await client2.request("GET", `/pub/v3/messages/${id}`);
31696
+ if (Array.isArray(detail2.files) && detail2.files.length > 0) {
31697
+ await fetchAttachmentMetaForMessage(client2, id, detail2.files);
31698
+ attachments2 = listAttachmentsForMessage(id);
31699
+ }
31700
+ } catch {
31701
+ }
31702
+ }
31657
31703
  return { content: [{ type: "text", text: JSON.stringify({ ...cached2, attachments: attachments2 }, null, 2) }] };
31658
31704
  }
31659
31705
  const detail = await client2.request("GET", `/pub/v3/messages/${encodeURIComponent(args.messageId)}`);
@@ -31684,14 +31730,15 @@ function registerMessageTools(server2, client2) {
31684
31730
  return { content: [{ type: "text", text: JSON.stringify({ ...row, attachments }, null, 2) }] };
31685
31731
  });
31686
31732
  server2.registerTool("ofw_send_message", {
31687
- 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).",
31733
+ 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.",
31688
31734
  annotations: { destructiveHint: true },
31689
31735
  inputSchema: {
31690
31736
  subject: external_exports.string().describe("Message subject"),
31691
31737
  body: external_exports.string().describe("Message body text"),
31692
31738
  recipientIds: external_exports.array(external_exports.number()).describe("Array of recipient user IDs (get from ofw_get_profile)"),
31693
31739
  replyToId: external_exports.number().describe("ID of the message being replied to").optional(),
31694
- draftId: external_exports.number().describe("ID of the draft to delete after sending (omit if not sending from a draft)").optional()
31740
+ draftId: external_exports.number().describe("ID of the draft to delete after sending (omit if not sending from a draft)").optional(),
31741
+ myFileIDs: external_exports.array(external_exports.number()).describe("Attachment file ids (from ofw_upload_attachment) to attach to the message").optional()
31695
31742
  }
31696
31743
  }, async (args) => {
31697
31744
  const requestedReplyTo = args.replyToId ?? null;
@@ -31706,11 +31753,12 @@ function registerMessageTools(server2, client2) {
31706
31753
  const parent = getMessage(resolvedReplyTo);
31707
31754
  chainRootId = parent?.chainRootId ?? parent?.id ?? requestedReplyTo;
31708
31755
  }
31756
+ const myFileIDs = args.myFileIDs ?? [];
31709
31757
  const data = await client2.request("POST", "/pub/v3/messages", {
31710
31758
  subject: args.subject,
31711
31759
  body: args.body,
31712
31760
  recipientIds: args.recipientIds,
31713
- attachments: { myFileIDs: [] },
31761
+ attachments: { myFileIDs },
31714
31762
  draft: false,
31715
31763
  includeOriginal: resolvedReplyTo !== null,
31716
31764
  replyToId: resolvedReplyTo
@@ -31735,6 +31783,18 @@ function registerMessageTools(server2, client2) {
31735
31783
  listData: data
31736
31784
  };
31737
31785
  upsertMessage(row);
31786
+ for (const fileId of myFileIDs) {
31787
+ const existing = getAttachment(fileId);
31788
+ upsertAttachmentForMessage({
31789
+ fileId,
31790
+ fileName: existing?.fileName ?? `file-${fileId}`,
31791
+ label: existing?.label ?? existing?.fileName ?? `file-${fileId}`,
31792
+ mimeType: existing?.mimeType ?? "application/octet-stream",
31793
+ sizeBytes: existing?.sizeBytes ?? null,
31794
+ metadata: existing?.metadata ?? {},
31795
+ messageId: data.id
31796
+ });
31797
+ }
31738
31798
  }
31739
31799
  if (args.draftId !== void 0) {
31740
31800
  const form = new FormData();
@@ -31763,14 +31823,15 @@ ${text}` : text;
31763
31823
  return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
31764
31824
  });
31765
31825
  server2.registerTool("ofw_save_draft", {
31766
- description: "Save a message as a draft in OurFamilyWizard. Recipients are optional. To update an existing draft, provide its messageId. If replyToId is provided, the cache may rewrite it to the latest reply in the thread (note included in response).",
31826
+ description: "Save a message as a draft in OurFamilyWizard. Recipients are optional. To update an existing draft, provide its messageId. If replyToId is provided, the cache may rewrite it to the latest reply in the thread (note included in response). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs.",
31767
31827
  annotations: { readOnlyHint: false },
31768
31828
  inputSchema: {
31769
31829
  subject: external_exports.string().describe("Message subject"),
31770
31830
  body: external_exports.string().describe("Message body text"),
31771
31831
  recipientIds: external_exports.array(external_exports.number()).describe("Array of recipient user IDs (optional for drafts)").optional(),
31772
31832
  messageId: external_exports.number().describe("ID of an existing draft to update (omit to create a new draft)").optional(),
31773
- replyToId: external_exports.number().describe("ID of the message this draft replies to").optional()
31833
+ replyToId: external_exports.number().describe("ID of the message this draft replies to").optional(),
31834
+ myFileIDs: external_exports.array(external_exports.number()).describe("Attachment file ids (from ofw_upload_attachment)").optional()
31774
31835
  }
31775
31836
  }, async (args) => {
31776
31837
  const requestedReplyTo = args.replyToId ?? null;
@@ -31782,11 +31843,12 @@ ${text}` : text;
31782
31843
  rewriteNote = `replyToId rewritten from ${requestedReplyTo} to ${resolvedReplyTo} (later reply in same thread found in sent cache).`;
31783
31844
  }
31784
31845
  }
31846
+ const myFileIDs = args.myFileIDs ?? [];
31785
31847
  const payload = {
31786
31848
  subject: args.subject,
31787
31849
  body: args.body,
31788
31850
  recipientIds: args.recipientIds ?? [],
31789
- attachments: { myFileIDs: [] },
31851
+ attachments: { myFileIDs },
31790
31852
  draft: true,
31791
31853
  includeOriginal: resolvedReplyTo !== null,
31792
31854
  replyToId: resolvedReplyTo
@@ -31858,6 +31920,49 @@ ${text}` : text;
31858
31920
  }
31859
31921
  return { content: [{ type: "text", text: JSON.stringify(unread, null, 2) }] };
31860
31922
  });
31923
+ server2.registerTool("ofw_upload_attachment", {
31924
+ description: `Upload a local file to OurFamilyWizard's "My Files" so it can be attached to a message. Returns the fileId \u2014 pass that to ofw_send_message or ofw_save_draft in myFileIDs to attach it. The file is uploaded as PRIVATE (visible only to you) by default; pass shareClass:"SHARED" to share with co-parents directly via the My Files area.`,
31925
+ annotations: { destructiveHint: false },
31926
+ inputSchema: {
31927
+ path: external_exports.string().describe("Absolute path to the local file to upload. Tilde (~) is expanded."),
31928
+ shareClass: external_exports.enum(["PRIVATE", "SHARED"]).describe("Share class (default PRIVATE)").optional(),
31929
+ label: external_exports.string().describe("Display label for the file in OFW (default: filename)").optional(),
31930
+ description: external_exports.string().describe("Description shown in OFW My Files (default: filename)").optional()
31931
+ }
31932
+ }, async (args) => {
31933
+ const expanded = args.path.startsWith("~/") ? join3(process.env.HOME ?? "", args.path.slice(2)) : args.path;
31934
+ const abs = isAbsolute(expanded) ? expanded : resolve(expanded);
31935
+ const stat = statSync(abs);
31936
+ if (!stat.isFile()) throw new Error(`Not a file: ${abs}`);
31937
+ const buf = readFileSync(abs);
31938
+ const fileName = basename(abs);
31939
+ const mime = mimeFromName(fileName);
31940
+ const form = new FormData();
31941
+ form.append("file", new Blob([new Uint8Array(buf)], { type: mime }), fileName);
31942
+ form.append("source", "message");
31943
+ form.append("description", args.description ?? fileName);
31944
+ form.append("label", args.label ?? fileName);
31945
+ form.append("fileName", fileName);
31946
+ form.append("shareClass", args.shareClass ?? "PRIVATE");
31947
+ const meta3 = await client2.request("POST", "/pub/v3/myfiles/multipart", form);
31948
+ upsertAttachmentForMessage({
31949
+ fileId: meta3.fileId,
31950
+ fileName: meta3.fileName ?? fileName,
31951
+ label: meta3.label ?? args.label ?? fileName,
31952
+ mimeType: meta3.fileType ?? mime,
31953
+ sizeBytes: typeof meta3.sizeInBytes === "number" ? meta3.sizeInBytes : buf.length,
31954
+ metadata: meta3,
31955
+ messageId: 0
31956
+ });
31957
+ return { content: [{ type: "text", text: JSON.stringify({
31958
+ fileId: meta3.fileId,
31959
+ fileName: meta3.fileName ?? fileName,
31960
+ mimeType: meta3.fileType ?? mime,
31961
+ sizeBytes: meta3.sizeInBytes ?? buf.length,
31962
+ shareClass: meta3.shareClass ?? args.shareClass ?? "PRIVATE",
31963
+ note: "Pass this fileId to ofw_send_message or ofw_save_draft in myFileIDs to attach it."
31964
+ }, null, 2) }] };
31965
+ });
31861
31966
  server2.registerTool("ofw_download_attachment", {
31862
31967
  description: "Download an OFW message attachment by fileId. Bytes are saved to disk; the tool returns the absolute path, mime type, and size so the caller can then read/analyze the file. fileId comes from the attachments array on ofw_get_message. Saves under ~/.cache/ofw-mcp/attachments/<hash>/ by default (override via OFW_ATTACHMENTS_DIR or the saveTo argument). Re-downloading is a no-op if the file is already on disk.",
31863
31968
  annotations: { readOnlyHint: false },
@@ -32075,7 +32180,7 @@ process.emit = function(event, ...args) {
32075
32180
  }
32076
32181
  return originalEmit(event, ...args);
32077
32182
  };
32078
- var server = new McpServer({ name: "ofw", version: "2.0.9" });
32183
+ var server = new McpServer({ name: "ofw", version: "2.0.11" });
32079
32184
  registerUserTools(server, client);
32080
32185
  registerMessageTools(server, client);
32081
32186
  registerCalendarTools(server, client);
package/dist/cache.js CHANGED
@@ -295,13 +295,19 @@ export function upsertAttachmentForMessage(input) {
295
295
  const { db } = openCache();
296
296
  const existing = db.prepare('SELECT message_ids_json FROM attachments WHERE file_id = ?')
297
297
  .get(input.fileId);
298
+ // messageId === 0 is the "metadata-only, not yet linked to a message"
299
+ // sentinel used by upload-without-send and download-by-id. Don't
300
+ // pollute the array with it — leave the list empty / unchanged.
301
+ const prior = existing ? JSON.parse(existing.message_ids_json) : [];
298
302
  let messageIds;
299
- if (existing) {
300
- const arr = JSON.parse(existing.message_ids_json);
301
- messageIds = arr.includes(input.messageId) ? arr : [...arr, input.messageId];
303
+ if (input.messageId === 0) {
304
+ messageIds = prior;
305
+ }
306
+ else if (prior.includes(input.messageId)) {
307
+ messageIds = prior;
302
308
  }
303
309
  else {
304
- messageIds = [input.messageId];
310
+ messageIds = [...prior, input.messageId];
305
311
  }
306
312
  db.prepare(`INSERT INTO attachments (
307
313
  file_id, file_name, label, mime_type, size_bytes,
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.0.9' });
20
+ const server = new McpServer({ name: 'ofw', version: '2.0.11' });
21
21
  registerUserTools(server, client);
22
22
  registerMessageTools(server, client);
23
23
  registerCalendarTools(server, client);
@@ -2,8 +2,49 @@ import { z } from 'zod';
2
2
  import { syncAll, fetchAttachmentMetaForMessage } from '../sync.js';
3
3
  import { listMessages, countMessages, listDrafts, getMessage, upsertMessage, upsertDraft, deleteDraft, findLatestReplyTip, listAttachmentsForMessage, getAttachment, upsertAttachmentForMessage, markAttachmentDownloaded, } from '../cache.js';
4
4
  import { getAttachmentsDir } from '../config.js';
5
- import { mkdirSync, writeFileSync } from 'node:fs';
6
- import { dirname, join, isAbsolute, resolve } from 'node:path';
5
+ import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
6
+ import { basename, dirname, extname, join, isAbsolute, resolve } from 'node:path';
7
+ // Lightweight mime sniff from extension. OFW re-derives mime from the filename
8
+ // server-side anyway, so this is just a polite Content-Type for the Blob.
9
+ const MIME_BY_EXT = {
10
+ '.pdf': 'application/pdf',
11
+ '.png': 'image/png',
12
+ '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
13
+ '.gif': 'image/gif',
14
+ '.webp': 'image/webp',
15
+ '.heic': 'image/heic',
16
+ '.txt': 'text/plain',
17
+ '.md': 'text/markdown',
18
+ '.csv': 'text/csv',
19
+ '.html': 'text/html', '.htm': 'text/html',
20
+ '.json': 'application/json',
21
+ '.xml': 'application/xml',
22
+ '.doc': 'application/msword',
23
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
24
+ '.xls': 'application/vnd.ms-excel',
25
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
26
+ '.ppt': 'application/vnd.ms-powerpoint',
27
+ '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
28
+ '.zip': 'application/zip',
29
+ '.ics': 'text/calendar',
30
+ };
31
+ function mimeFromName(name) {
32
+ return MIME_BY_EXT[extname(name).toLowerCase()] ?? 'application/octet-stream';
33
+ }
34
+ // The list endpoint payload (cached as `listData`) reports attachments via
35
+ // `files: <count>` (a number) — the actual fileIds only appear on the detail
36
+ // endpoint as `files: [number, ...]`. Some intermediate shapes return an
37
+ // array on the list too. Treat any of those as "this message has files".
38
+ function listDataHintsAtFiles(listData) {
39
+ if (typeof listData !== 'object' || listData === null)
40
+ return false;
41
+ const ld = listData;
42
+ if (typeof ld.files === 'number')
43
+ return ld.files > 0;
44
+ if (Array.isArray(ld.files))
45
+ return ld.files.length > 0;
46
+ return false;
47
+ }
7
48
  export function registerMessageTools(server, client) {
8
49
  server.registerTool('ofw_list_message_folders', {
9
50
  description: 'List OurFamilyWizard message folders (inbox, sent, etc.) and their unread counts. Returns folder IDs needed to call ofw_list_messages. Does NOT return message content.',
@@ -67,7 +108,26 @@ export function registerMessageTools(server, client) {
67
108
  const id = Number(args.messageId);
68
109
  const cached = getMessage(id);
69
110
  if (cached && cached.body !== null) {
70
- const attachments = listAttachmentsForMessage(id);
111
+ let attachments = listAttachmentsForMessage(id);
112
+ // Lazy attachment backfill. The list-endpoint payload (stored in
113
+ // listData) hints at attachments via `files: <count>` but doesn't
114
+ // expose the fileIds — those live only on /pub/v3/messages/{id}.
115
+ // For messages bodied before attachment caching existed, the
116
+ // attachments table is empty even though OFW has files. Re-hit
117
+ // detail to harvest fileIds (idempotent: body is already cached so
118
+ // OFW state isn't changing).
119
+ if (attachments.length === 0 && listDataHintsAtFiles(cached.listData)) {
120
+ try {
121
+ const detail = await client.request('GET', `/pub/v3/messages/${id}`);
122
+ if (Array.isArray(detail.files) && detail.files.length > 0) {
123
+ await fetchAttachmentMetaForMessage(client, id, detail.files);
124
+ attachments = listAttachmentsForMessage(id);
125
+ }
126
+ }
127
+ catch {
128
+ // Backfill is best-effort. Fall through with whatever we have.
129
+ }
130
+ }
71
131
  return { content: [{ type: 'text', text: JSON.stringify({ ...cached, attachments }, null, 2) }] };
72
132
  }
73
133
  const detail = await client.request('GET', `/pub/v3/messages/${encodeURIComponent(args.messageId)}`);
@@ -96,7 +156,7 @@ export function registerMessageTools(server, client) {
96
156
  return { content: [{ type: 'text', text: JSON.stringify({ ...row, attachments }, null, 2) }] };
97
157
  });
98
158
  server.registerTool('ofw_send_message', {
99
- 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).',
159
+ 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.',
100
160
  annotations: { destructiveHint: true },
101
161
  inputSchema: {
102
162
  subject: z.string().describe('Message subject'),
@@ -104,6 +164,7 @@ export function registerMessageTools(server, client) {
104
164
  recipientIds: z.array(z.number()).describe('Array of recipient user IDs (get from ofw_get_profile)'),
105
165
  replyToId: z.number().describe('ID of the message being replied to').optional(),
106
166
  draftId: z.number().describe('ID of the draft to delete after sending (omit if not sending from a draft)').optional(),
167
+ myFileIDs: z.array(z.number()).describe('Attachment file ids (from ofw_upload_attachment) to attach to the message').optional(),
107
168
  },
108
169
  }, async (args) => {
109
170
  const requestedReplyTo = args.replyToId ?? null;
@@ -118,11 +179,12 @@ export function registerMessageTools(server, client) {
118
179
  const parent = getMessage(resolvedReplyTo);
119
180
  chainRootId = parent?.chainRootId ?? parent?.id ?? requestedReplyTo;
120
181
  }
182
+ const myFileIDs = args.myFileIDs ?? [];
121
183
  const data = await client.request('POST', '/pub/v3/messages', {
122
184
  subject: args.subject,
123
185
  body: args.body,
124
186
  recipientIds: args.recipientIds,
125
- attachments: { myFileIDs: [] },
187
+ attachments: { myFileIDs },
126
188
  draft: false,
127
189
  includeOriginal: resolvedReplyTo !== null,
128
190
  replyToId: resolvedReplyTo,
@@ -145,6 +207,21 @@ export function registerMessageTools(server, client) {
145
207
  listData: data,
146
208
  };
147
209
  upsertMessage(row);
210
+ // Link attached files to the new message in the attachments cache.
211
+ // We may not have full metadata if the upload happened in a prior
212
+ // session — fall back to what we know.
213
+ for (const fileId of myFileIDs) {
214
+ const existing = getAttachment(fileId);
215
+ upsertAttachmentForMessage({
216
+ fileId,
217
+ fileName: existing?.fileName ?? `file-${fileId}`,
218
+ label: existing?.label ?? existing?.fileName ?? `file-${fileId}`,
219
+ mimeType: existing?.mimeType ?? 'application/octet-stream',
220
+ sizeBytes: existing?.sizeBytes ?? null,
221
+ metadata: existing?.metadata ?? {},
222
+ messageId: data.id,
223
+ });
224
+ }
148
225
  }
149
226
  if (args.draftId !== undefined) {
150
227
  const form = new FormData();
@@ -173,7 +250,7 @@ export function registerMessageTools(server, client) {
173
250
  return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
174
251
  });
175
252
  server.registerTool('ofw_save_draft', {
176
- description: 'Save a message as a draft in OurFamilyWizard. Recipients are optional. To update an existing draft, provide its messageId. If replyToId is provided, the cache may rewrite it to the latest reply in the thread (note included in response).',
253
+ description: 'Save a message as a draft in OurFamilyWizard. Recipients are optional. To update an existing draft, provide its messageId. If replyToId is provided, the cache may rewrite it to the latest reply in the thread (note included in response). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs.',
177
254
  annotations: { readOnlyHint: false },
178
255
  inputSchema: {
179
256
  subject: z.string().describe('Message subject'),
@@ -181,6 +258,7 @@ export function registerMessageTools(server, client) {
181
258
  recipientIds: z.array(z.number()).describe('Array of recipient user IDs (optional for drafts)').optional(),
182
259
  messageId: z.number().describe('ID of an existing draft to update (omit to create a new draft)').optional(),
183
260
  replyToId: z.number().describe('ID of the message this draft replies to').optional(),
261
+ myFileIDs: z.array(z.number()).describe('Attachment file ids (from ofw_upload_attachment)').optional(),
184
262
  },
185
263
  }, async (args) => {
186
264
  const requestedReplyTo = args.replyToId ?? null;
@@ -192,11 +270,12 @@ export function registerMessageTools(server, client) {
192
270
  rewriteNote = `replyToId rewritten from ${requestedReplyTo} to ${resolvedReplyTo} (later reply in same thread found in sent cache).`;
193
271
  }
194
272
  }
273
+ const myFileIDs = args.myFileIDs ?? [];
195
274
  const payload = {
196
275
  subject: args.subject,
197
276
  body: args.body,
198
277
  recipientIds: args.recipientIds ?? [],
199
- attachments: { myFileIDs: [] },
278
+ attachments: { myFileIDs },
200
279
  draft: true,
201
280
  includeOriginal: resolvedReplyTo !== null,
202
281
  replyToId: resolvedReplyTo,
@@ -265,6 +344,58 @@ export function registerMessageTools(server, client) {
265
344
  }
266
345
  return { content: [{ type: 'text', text: JSON.stringify(unread, null, 2) }] };
267
346
  });
347
+ server.registerTool('ofw_upload_attachment', {
348
+ description: 'Upload a local file to OurFamilyWizard\'s "My Files" so it can be attached to a message. Returns the fileId — pass that to ofw_send_message or ofw_save_draft in myFileIDs to attach it. The file is uploaded as PRIVATE (visible only to you) by default; pass shareClass:"SHARED" to share with co-parents directly via the My Files area.',
349
+ annotations: { destructiveHint: false },
350
+ inputSchema: {
351
+ path: z.string().describe('Absolute path to the local file to upload. Tilde (~) is expanded.'),
352
+ shareClass: z.enum(['PRIVATE', 'SHARED']).describe('Share class (default PRIVATE)').optional(),
353
+ label: z.string().describe('Display label for the file in OFW (default: filename)').optional(),
354
+ description: z.string().describe('Description shown in OFW My Files (default: filename)').optional(),
355
+ },
356
+ }, async (args) => {
357
+ // Resolve and read the local file
358
+ const expanded = args.path.startsWith('~/')
359
+ ? join(process.env.HOME ?? '', args.path.slice(2))
360
+ : args.path;
361
+ const abs = isAbsolute(expanded) ? expanded : resolve(expanded);
362
+ const stat = statSync(abs); // throws if missing
363
+ if (!stat.isFile())
364
+ throw new Error(`Not a file: ${abs}`);
365
+ const buf = readFileSync(abs);
366
+ const fileName = basename(abs);
367
+ const mime = mimeFromName(fileName);
368
+ // Build the multipart payload matching the OFW web UI's request shape
369
+ const form = new FormData();
370
+ form.append('file', new Blob([new Uint8Array(buf)], { type: mime }), fileName);
371
+ form.append('source', 'message');
372
+ form.append('description', args.description ?? fileName);
373
+ form.append('label', args.label ?? fileName);
374
+ form.append('fileName', fileName);
375
+ form.append('shareClass', args.shareClass ?? 'PRIVATE');
376
+ const meta = await client.request('POST', '/pub/v3/myfiles/multipart', form);
377
+ // Cache the metadata so subsequent ofw_get_message calls can surface it
378
+ // and ofw_download_attachment short-circuits if asked. messageId is 0
379
+ // because no message references this yet — it'll be linked once a
380
+ // message is sent with this fileId in its attachments.
381
+ upsertAttachmentForMessage({
382
+ fileId: meta.fileId,
383
+ fileName: meta.fileName ?? fileName,
384
+ label: meta.label ?? args.label ?? fileName,
385
+ mimeType: meta.fileType ?? mime,
386
+ sizeBytes: typeof meta.sizeInBytes === 'number' ? meta.sizeInBytes : buf.length,
387
+ metadata: meta,
388
+ messageId: 0,
389
+ });
390
+ return { content: [{ type: 'text', text: JSON.stringify({
391
+ fileId: meta.fileId,
392
+ fileName: meta.fileName ?? fileName,
393
+ mimeType: meta.fileType ?? mime,
394
+ sizeBytes: meta.sizeInBytes ?? buf.length,
395
+ shareClass: meta.shareClass ?? args.shareClass ?? 'PRIVATE',
396
+ note: 'Pass this fileId to ofw_send_message or ofw_save_draft in myFileIDs to attach it.',
397
+ }, null, 2) }] };
398
+ });
268
399
  server.registerTool('ofw_download_attachment', {
269
400
  description: 'Download an OFW message attachment by fileId. Bytes are saved to disk; the tool returns the absolute path, mime type, and size so the caller can then read/analyze the file. fileId comes from the attachments array on ofw_get_message. Saves under ~/.cache/ofw-mcp/attachments/<hash>/ by default (override via OFW_ATTACHMENTS_DIR or the saveTo argument). Re-downloading is a no-op if the file is already on disk.',
270
401
  annotations: { readOnlyHint: false },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ofw-mcp",
3
- "version": "2.0.9",
3
+ "version": "2.0.11",
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.0.9",
9
+ "version": "2.0.11",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ofw-mcp",
14
- "version": "2.0.9",
14
+ "version": "2.0.11",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },