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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/bundle.js +119 -14
- package/dist/cache.js +10 -4
- package/dist/index.js +1 -1
- package/dist/tools/messages.js +138 -7
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
},
|
|
7
7
|
"metadata": {
|
|
8
8
|
"description": "OurFamilyWizard tools for Claude Code",
|
|
9
|
-
"version": "2.0.
|
|
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.
|
|
17
|
+
"version": "2.0.11",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "Chris Chall"
|
|
20
20
|
},
|
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 (
|
|
31384
|
-
|
|
31385
|
-
|
|
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
|
-
|
|
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.
|
|
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 (
|
|
300
|
-
|
|
301
|
-
|
|
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.
|
|
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);
|
package/dist/tools/messages.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
+
"version": "2.0.11",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "ofw-mcp",
|
|
14
|
-
"version": "2.0.
|
|
14
|
+
"version": "2.0.11",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|