ofw-mcp 2.0.8 → 2.0.10
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 +352 -32
- package/dist/cache.js +72 -1
- package/dist/client.js +50 -0
- package/dist/config.js +9 -0
- package/dist/index.js +1 -1
- package/dist/sync.js +32 -1
- package/dist/tools/messages.js +178 -9
- 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.10"
|
|
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.10",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "Chris Chall"
|
|
20
20
|
},
|
package/dist/bundle.js
CHANGED
|
@@ -2980,7 +2980,7 @@ var require_compile = __commonJS({
|
|
|
2980
2980
|
const schOrFunc = root.refs[ref];
|
|
2981
2981
|
if (schOrFunc)
|
|
2982
2982
|
return schOrFunc;
|
|
2983
|
-
let _sch =
|
|
2983
|
+
let _sch = resolve2.call(this, root, ref);
|
|
2984
2984
|
if (_sch === void 0) {
|
|
2985
2985
|
const schema = (_a3 = root.localRefs) === null || _a3 === void 0 ? void 0 : _a3[ref];
|
|
2986
2986
|
const { schemaId } = this.opts;
|
|
@@ -3007,7 +3007,7 @@ var require_compile = __commonJS({
|
|
|
3007
3007
|
function sameSchemaEnv(s1, s2) {
|
|
3008
3008
|
return s1.schema === s2.schema && s1.root === s2.root && s1.baseId === s2.baseId;
|
|
3009
3009
|
}
|
|
3010
|
-
function
|
|
3010
|
+
function resolve2(root, ref) {
|
|
3011
3011
|
let sch;
|
|
3012
3012
|
while (typeof (sch = this.refs[ref]) == "string")
|
|
3013
3013
|
ref = sch;
|
|
@@ -3582,7 +3582,7 @@ var require_fast_uri = __commonJS({
|
|
|
3582
3582
|
}
|
|
3583
3583
|
return uri;
|
|
3584
3584
|
}
|
|
3585
|
-
function
|
|
3585
|
+
function resolve2(baseURI, relativeURI, options) {
|
|
3586
3586
|
const schemelessOptions = options ? Object.assign({ scheme: "null" }, options) : { scheme: "null" };
|
|
3587
3587
|
const resolved = resolveComponent(parse3(baseURI, schemelessOptions), parse3(relativeURI, schemelessOptions), schemelessOptions, true);
|
|
3588
3588
|
schemelessOptions.skipEscape = true;
|
|
@@ -3809,7 +3809,7 @@ var require_fast_uri = __commonJS({
|
|
|
3809
3809
|
var fastUri = {
|
|
3810
3810
|
SCHEMES,
|
|
3811
3811
|
normalize,
|
|
3812
|
-
resolve,
|
|
3812
|
+
resolve: resolve2,
|
|
3813
3813
|
resolveComponent,
|
|
3814
3814
|
equal,
|
|
3815
3815
|
serialize,
|
|
@@ -12700,7 +12700,7 @@ var Doc = class {
|
|
|
12700
12700
|
var version = {
|
|
12701
12701
|
major: 4,
|
|
12702
12702
|
minor: 4,
|
|
12703
|
-
patch:
|
|
12703
|
+
patch: 3
|
|
12704
12704
|
};
|
|
12705
12705
|
|
|
12706
12706
|
// node_modules/zod/v4/core/schemas.js
|
|
@@ -14299,6 +14299,7 @@ var $ZodFile = /* @__PURE__ */ $constructor("$ZodFile", (inst, def) => {
|
|
|
14299
14299
|
});
|
|
14300
14300
|
var $ZodTransform = /* @__PURE__ */ $constructor("$ZodTransform", (inst, def) => {
|
|
14301
14301
|
$ZodType.init(inst, def);
|
|
14302
|
+
inst._zod.optin = "optional";
|
|
14302
14303
|
inst._zod.parse = (payload, ctx) => {
|
|
14303
14304
|
if (ctx.direction === "backward") {
|
|
14304
14305
|
throw new $ZodEncodeError(inst.constructor.name);
|
|
@@ -14308,6 +14309,7 @@ var $ZodTransform = /* @__PURE__ */ $constructor("$ZodTransform", (inst, def) =>
|
|
|
14308
14309
|
const output = _out instanceof Promise ? _out : Promise.resolve(_out);
|
|
14309
14310
|
return output.then((output2) => {
|
|
14310
14311
|
payload.value = output2;
|
|
14312
|
+
payload.fallback = true;
|
|
14311
14313
|
return payload;
|
|
14312
14314
|
});
|
|
14313
14315
|
}
|
|
@@ -14315,11 +14317,12 @@ var $ZodTransform = /* @__PURE__ */ $constructor("$ZodTransform", (inst, def) =>
|
|
|
14315
14317
|
throw new $ZodAsyncError();
|
|
14316
14318
|
}
|
|
14317
14319
|
payload.value = _out;
|
|
14320
|
+
payload.fallback = true;
|
|
14318
14321
|
return payload;
|
|
14319
14322
|
};
|
|
14320
14323
|
});
|
|
14321
14324
|
function handleOptionalResult(result, input) {
|
|
14322
|
-
if (result.issues.length
|
|
14325
|
+
if (input === void 0 && (result.issues.length || result.fallback)) {
|
|
14323
14326
|
return { issues: [], value: void 0 };
|
|
14324
14327
|
}
|
|
14325
14328
|
return result;
|
|
@@ -14337,10 +14340,11 @@ var $ZodOptional = /* @__PURE__ */ $constructor("$ZodOptional", (inst, def) => {
|
|
|
14337
14340
|
});
|
|
14338
14341
|
inst._zod.parse = (payload, ctx) => {
|
|
14339
14342
|
if (def.innerType._zod.optin === "optional") {
|
|
14343
|
+
const input = payload.value;
|
|
14340
14344
|
const result = def.innerType._zod.run(payload, ctx);
|
|
14341
14345
|
if (result instanceof Promise)
|
|
14342
|
-
return result.then((r) => handleOptionalResult(r,
|
|
14343
|
-
return handleOptionalResult(result,
|
|
14346
|
+
return result.then((r) => handleOptionalResult(r, input));
|
|
14347
|
+
return handleOptionalResult(result, input);
|
|
14344
14348
|
}
|
|
14345
14349
|
if (payload.value === void 0) {
|
|
14346
14350
|
return payload;
|
|
@@ -14456,7 +14460,7 @@ var $ZodSuccess = /* @__PURE__ */ $constructor("$ZodSuccess", (inst, def) => {
|
|
|
14456
14460
|
});
|
|
14457
14461
|
var $ZodCatch = /* @__PURE__ */ $constructor("$ZodCatch", (inst, def) => {
|
|
14458
14462
|
$ZodType.init(inst, def);
|
|
14459
|
-
|
|
14463
|
+
inst._zod.optin = "optional";
|
|
14460
14464
|
defineLazy(inst._zod, "optout", () => def.innerType._zod.optout);
|
|
14461
14465
|
defineLazy(inst._zod, "values", () => def.innerType._zod.values);
|
|
14462
14466
|
inst._zod.parse = (payload, ctx) => {
|
|
@@ -14476,6 +14480,7 @@ var $ZodCatch = /* @__PURE__ */ $constructor("$ZodCatch", (inst, def) => {
|
|
|
14476
14480
|
input: payload.value
|
|
14477
14481
|
});
|
|
14478
14482
|
payload.issues = [];
|
|
14483
|
+
payload.fallback = true;
|
|
14479
14484
|
}
|
|
14480
14485
|
return payload;
|
|
14481
14486
|
});
|
|
@@ -14490,6 +14495,7 @@ var $ZodCatch = /* @__PURE__ */ $constructor("$ZodCatch", (inst, def) => {
|
|
|
14490
14495
|
input: payload.value
|
|
14491
14496
|
});
|
|
14492
14497
|
payload.issues = [];
|
|
14498
|
+
payload.fallback = true;
|
|
14493
14499
|
}
|
|
14494
14500
|
return payload;
|
|
14495
14501
|
};
|
|
@@ -14535,7 +14541,7 @@ function handlePipeResult(left, next, ctx) {
|
|
|
14535
14541
|
left.aborted = true;
|
|
14536
14542
|
return left;
|
|
14537
14543
|
}
|
|
14538
|
-
return next._zod.run({ value: left.value, issues: left.issues }, ctx);
|
|
14544
|
+
return next._zod.run({ value: left.value, issues: left.issues, fallback: left.fallback }, ctx);
|
|
14539
14545
|
}
|
|
14540
14546
|
var $ZodCodec = /* @__PURE__ */ $constructor("$ZodCodec", (inst, def) => {
|
|
14541
14547
|
$ZodType.init(inst, def);
|
|
@@ -14589,8 +14595,6 @@ function handleCodecTxResult(left, value, nextSchema, ctx) {
|
|
|
14589
14595
|
}
|
|
14590
14596
|
var $ZodPreprocess = /* @__PURE__ */ $constructor("$ZodPreprocess", (inst, def) => {
|
|
14591
14597
|
$ZodPipe.init(inst, def);
|
|
14592
|
-
defineLazy(inst._zod, "optin", () => def.out._zod.optin);
|
|
14593
|
-
defineLazy(inst._zod, "optout", () => def.out._zod.optout);
|
|
14594
14598
|
});
|
|
14595
14599
|
var $ZodReadonly = /* @__PURE__ */ $constructor("$ZodReadonly", (inst, def) => {
|
|
14596
14600
|
$ZodType.init(inst, def);
|
|
@@ -24541,10 +24545,12 @@ var ZodTransform = /* @__PURE__ */ $constructor("ZodTransform", (inst, def) => {
|
|
|
24541
24545
|
if (output instanceof Promise) {
|
|
24542
24546
|
return output.then((output2) => {
|
|
24543
24547
|
payload.value = output2;
|
|
24548
|
+
payload.fallback = true;
|
|
24544
24549
|
return payload;
|
|
24545
24550
|
});
|
|
24546
24551
|
}
|
|
24547
24552
|
payload.value = output;
|
|
24553
|
+
payload.fallback = true;
|
|
24548
24554
|
return payload;
|
|
24549
24555
|
};
|
|
24550
24556
|
});
|
|
@@ -28734,7 +28740,7 @@ var Protocol = class {
|
|
|
28734
28740
|
return;
|
|
28735
28741
|
}
|
|
28736
28742
|
const pollInterval = task2.pollInterval ?? this._options?.defaultTaskPollInterval ?? 1e3;
|
|
28737
|
-
await new Promise((
|
|
28743
|
+
await new Promise((resolve2) => setTimeout(resolve2, pollInterval));
|
|
28738
28744
|
options?.signal?.throwIfAborted();
|
|
28739
28745
|
}
|
|
28740
28746
|
} catch (error51) {
|
|
@@ -28751,7 +28757,7 @@ var Protocol = class {
|
|
|
28751
28757
|
*/
|
|
28752
28758
|
request(request, resultSchema, options) {
|
|
28753
28759
|
const { relatedRequestId, resumptionToken, onresumptiontoken, task, relatedTask } = options ?? {};
|
|
28754
|
-
return new Promise((
|
|
28760
|
+
return new Promise((resolve2, reject) => {
|
|
28755
28761
|
const earlyReject = (error51) => {
|
|
28756
28762
|
reject(error51);
|
|
28757
28763
|
};
|
|
@@ -28829,7 +28835,7 @@ var Protocol = class {
|
|
|
28829
28835
|
if (!parseResult.success) {
|
|
28830
28836
|
reject(parseResult.error);
|
|
28831
28837
|
} else {
|
|
28832
|
-
|
|
28838
|
+
resolve2(parseResult.data);
|
|
28833
28839
|
}
|
|
28834
28840
|
} catch (error51) {
|
|
28835
28841
|
reject(error51);
|
|
@@ -29090,12 +29096,12 @@ var Protocol = class {
|
|
|
29090
29096
|
}
|
|
29091
29097
|
} catch {
|
|
29092
29098
|
}
|
|
29093
|
-
return new Promise((
|
|
29099
|
+
return new Promise((resolve2, reject) => {
|
|
29094
29100
|
if (signal.aborted) {
|
|
29095
29101
|
reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
|
|
29096
29102
|
return;
|
|
29097
29103
|
}
|
|
29098
|
-
const timeoutId = setTimeout(
|
|
29104
|
+
const timeoutId = setTimeout(resolve2, interval);
|
|
29099
29105
|
signal.addEventListener("abort", () => {
|
|
29100
29106
|
clearTimeout(timeoutId);
|
|
29101
29107
|
reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
|
|
@@ -30195,7 +30201,7 @@ var McpServer = class {
|
|
|
30195
30201
|
let task = createTaskResult.task;
|
|
30196
30202
|
const pollInterval = task.pollInterval ?? 5e3;
|
|
30197
30203
|
while (task.status !== "completed" && task.status !== "failed" && task.status !== "cancelled") {
|
|
30198
|
-
await new Promise((
|
|
30204
|
+
await new Promise((resolve2) => setTimeout(resolve2, pollInterval));
|
|
30199
30205
|
const updatedTask = await extra.taskStore.getTask(taskId);
|
|
30200
30206
|
if (!updatedTask) {
|
|
30201
30207
|
throw new McpError(ErrorCode.InternalError, `Task ${taskId} not found during polling`);
|
|
@@ -30844,12 +30850,12 @@ var StdioServerTransport = class {
|
|
|
30844
30850
|
this.onclose?.();
|
|
30845
30851
|
}
|
|
30846
30852
|
send(message) {
|
|
30847
|
-
return new Promise((
|
|
30853
|
+
return new Promise((resolve2) => {
|
|
30848
30854
|
const json2 = serializeMessage(message);
|
|
30849
30855
|
if (this._stdout.write(json2)) {
|
|
30850
|
-
|
|
30856
|
+
resolve2();
|
|
30851
30857
|
} else {
|
|
30852
|
-
this._stdout.once("drain",
|
|
30858
|
+
this._stdout.once("drain", resolve2);
|
|
30853
30859
|
}
|
|
30854
30860
|
});
|
|
30855
30861
|
}
|
|
@@ -30887,6 +30893,52 @@ var OFWClient = class {
|
|
|
30887
30893
|
await this.ensureAuthenticated();
|
|
30888
30894
|
return this.doRequest(method, path, body, false);
|
|
30889
30895
|
}
|
|
30896
|
+
/** Like `request`, but returns the raw bytes plus Content-Type/-Disposition metadata. */
|
|
30897
|
+
async requestBinary(method, path) {
|
|
30898
|
+
await this.ensureAuthenticated();
|
|
30899
|
+
return this.doRequestBinary(method, path, false);
|
|
30900
|
+
}
|
|
30901
|
+
async doRequestBinary(method, path, isRetry) {
|
|
30902
|
+
const headers = {
|
|
30903
|
+
"ofw-client": "WebApplication",
|
|
30904
|
+
"ofw-version": "1.0.0",
|
|
30905
|
+
Accept: "application/octet-stream",
|
|
30906
|
+
Authorization: `Bearer ${this.token}`
|
|
30907
|
+
};
|
|
30908
|
+
const response = await fetch(`${BASE_URL}${path}`, { method, headers });
|
|
30909
|
+
if (response.status === 401 && !isRetry) {
|
|
30910
|
+
this.token = null;
|
|
30911
|
+
this.tokenExpiry = null;
|
|
30912
|
+
await this.ensureAuthenticated();
|
|
30913
|
+
return this.doRequestBinary(method, path, true);
|
|
30914
|
+
}
|
|
30915
|
+
if (response.status === 429 && !isRetry) {
|
|
30916
|
+
await new Promise((r) => setTimeout(r, 2e3));
|
|
30917
|
+
return this.doRequestBinary(method, path, true);
|
|
30918
|
+
}
|
|
30919
|
+
if (!response.ok) {
|
|
30920
|
+
throw new Error(`OFW API error: ${response.status} ${response.statusText} for ${method} ${path}`);
|
|
30921
|
+
}
|
|
30922
|
+
const buf = Buffer.from(await response.arrayBuffer());
|
|
30923
|
+
const cd = response.headers.get("content-disposition") ?? "";
|
|
30924
|
+
let suggestedFileName = null;
|
|
30925
|
+
const extMatch = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(cd);
|
|
30926
|
+
if (extMatch) {
|
|
30927
|
+
try {
|
|
30928
|
+
suggestedFileName = decodeURIComponent(extMatch[1].trim().replace(/^"|"$/g, ""));
|
|
30929
|
+
} catch {
|
|
30930
|
+
suggestedFileName = extMatch[1];
|
|
30931
|
+
}
|
|
30932
|
+
} else {
|
|
30933
|
+
const m = /filename="?([^";]+)"?/i.exec(cd);
|
|
30934
|
+
if (m) suggestedFileName = m[1];
|
|
30935
|
+
}
|
|
30936
|
+
return {
|
|
30937
|
+
body: buf,
|
|
30938
|
+
contentType: response.headers.get("content-type"),
|
|
30939
|
+
suggestedFileName
|
|
30940
|
+
};
|
|
30941
|
+
}
|
|
30890
30942
|
async doRequest(method, path, body, isRetry) {
|
|
30891
30943
|
const isFormData = body instanceof FormData;
|
|
30892
30944
|
const headers = {
|
|
@@ -31015,6 +31067,13 @@ function getCacheDbPath() {
|
|
|
31015
31067
|
const hash2 = createHash("sha256").update(username).digest("hex").slice(0, 16);
|
|
31016
31068
|
return join2(getCacheDir(), `${hash2}.db`);
|
|
31017
31069
|
}
|
|
31070
|
+
function getAttachmentsDir() {
|
|
31071
|
+
const override = process.env.OFW_ATTACHMENTS_DIR;
|
|
31072
|
+
if (override && override.trim().length > 0) return override.trim();
|
|
31073
|
+
const username = readUsername();
|
|
31074
|
+
const hash2 = createHash("sha256").update(username).digest("hex").slice(0, 16);
|
|
31075
|
+
return join2(getCacheDir(), "attachments", hash2);
|
|
31076
|
+
}
|
|
31018
31077
|
|
|
31019
31078
|
// src/cache.ts
|
|
31020
31079
|
var instance = null;
|
|
@@ -31057,9 +31116,26 @@ CREATE TABLE IF NOT EXISTS meta (
|
|
|
31057
31116
|
value TEXT NOT NULL
|
|
31058
31117
|
);
|
|
31059
31118
|
`;
|
|
31119
|
+
var SCHEMA_V2 = `
|
|
31120
|
+
CREATE TABLE IF NOT EXISTS attachments (
|
|
31121
|
+
file_id INTEGER PRIMARY KEY,
|
|
31122
|
+
file_name TEXT NOT NULL,
|
|
31123
|
+
label TEXT NOT NULL,
|
|
31124
|
+
mime_type TEXT NOT NULL,
|
|
31125
|
+
size_bytes INTEGER,
|
|
31126
|
+
metadata_json TEXT NOT NULL,
|
|
31127
|
+
message_ids_json TEXT NOT NULL, -- JSON array of message ids that reference this file
|
|
31128
|
+
downloaded_path TEXT, -- absolute path on disk if/when downloaded
|
|
31129
|
+
downloaded_at TEXT,
|
|
31130
|
+
fetched_metadata_at TEXT NOT NULL
|
|
31131
|
+
);
|
|
31132
|
+
`;
|
|
31060
31133
|
function migrate(db) {
|
|
31061
31134
|
db.exec(SCHEMA_V1);
|
|
31062
|
-
db.
|
|
31135
|
+
db.exec(SCHEMA_V2);
|
|
31136
|
+
db.prepare(
|
|
31137
|
+
"INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value"
|
|
31138
|
+
).run("schema_version", "2");
|
|
31063
31139
|
}
|
|
31064
31140
|
function openCache() {
|
|
31065
31141
|
if (instance) return instance;
|
|
@@ -31273,8 +31349,95 @@ function findLatestReplyTip(replyToId) {
|
|
|
31273
31349
|
).get(chainRoot);
|
|
31274
31350
|
return tip ? tip.id : replyToId;
|
|
31275
31351
|
}
|
|
31352
|
+
function attachmentFromDb(r) {
|
|
31353
|
+
return {
|
|
31354
|
+
fileId: r.file_id,
|
|
31355
|
+
fileName: r.file_name,
|
|
31356
|
+
label: r.label,
|
|
31357
|
+
mimeType: r.mime_type,
|
|
31358
|
+
sizeBytes: r.size_bytes,
|
|
31359
|
+
metadata: JSON.parse(r.metadata_json),
|
|
31360
|
+
messageIds: JSON.parse(r.message_ids_json),
|
|
31361
|
+
downloadedPath: r.downloaded_path,
|
|
31362
|
+
downloadedAt: r.downloaded_at
|
|
31363
|
+
};
|
|
31364
|
+
}
|
|
31365
|
+
function getAttachment(fileId) {
|
|
31366
|
+
const { db } = openCache();
|
|
31367
|
+
const r = db.prepare("SELECT * FROM attachments WHERE file_id = ?").get(fileId);
|
|
31368
|
+
return r ? attachmentFromDb(r) : null;
|
|
31369
|
+
}
|
|
31370
|
+
function listAttachmentsForMessage(messageId) {
|
|
31371
|
+
const { db } = openCache();
|
|
31372
|
+
const rows = db.prepare(
|
|
31373
|
+
`SELECT * FROM attachments
|
|
31374
|
+
WHERE EXISTS (SELECT 1 FROM json_each(message_ids_json) WHERE value = ?)
|
|
31375
|
+
ORDER BY file_id`
|
|
31376
|
+
).all(messageId);
|
|
31377
|
+
return rows.map(attachmentFromDb);
|
|
31378
|
+
}
|
|
31379
|
+
function upsertAttachmentForMessage(input) {
|
|
31380
|
+
const { db } = openCache();
|
|
31381
|
+
const existing = db.prepare("SELECT message_ids_json FROM attachments WHERE file_id = ?").get(input.fileId);
|
|
31382
|
+
let messageIds;
|
|
31383
|
+
if (existing) {
|
|
31384
|
+
const arr = JSON.parse(existing.message_ids_json);
|
|
31385
|
+
messageIds = arr.includes(input.messageId) ? arr : [...arr, input.messageId];
|
|
31386
|
+
} else {
|
|
31387
|
+
messageIds = [input.messageId];
|
|
31388
|
+
}
|
|
31389
|
+
db.prepare(
|
|
31390
|
+
`INSERT INTO attachments (
|
|
31391
|
+
file_id, file_name, label, mime_type, size_bytes,
|
|
31392
|
+
metadata_json, message_ids_json, fetched_metadata_at
|
|
31393
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
31394
|
+
ON CONFLICT(file_id) DO UPDATE SET
|
|
31395
|
+
file_name=excluded.file_name,
|
|
31396
|
+
label=excluded.label,
|
|
31397
|
+
mime_type=excluded.mime_type,
|
|
31398
|
+
size_bytes=excluded.size_bytes,
|
|
31399
|
+
metadata_json=excluded.metadata_json,
|
|
31400
|
+
message_ids_json=excluded.message_ids_json,
|
|
31401
|
+
fetched_metadata_at=excluded.fetched_metadata_at`
|
|
31402
|
+
).run(
|
|
31403
|
+
input.fileId,
|
|
31404
|
+
requireString("attachments.fileName", input.fileName),
|
|
31405
|
+
requireString("attachments.label", input.label),
|
|
31406
|
+
requireString("attachments.mimeType", input.mimeType),
|
|
31407
|
+
nullish3(input.sizeBytes),
|
|
31408
|
+
JSON.stringify(input.metadata ?? null),
|
|
31409
|
+
JSON.stringify(messageIds),
|
|
31410
|
+
(/* @__PURE__ */ new Date()).toISOString()
|
|
31411
|
+
);
|
|
31412
|
+
}
|
|
31413
|
+
function markAttachmentDownloaded(fileId, path) {
|
|
31414
|
+
const { db } = openCache();
|
|
31415
|
+
db.prepare(
|
|
31416
|
+
"UPDATE attachments SET downloaded_path = ?, downloaded_at = ? WHERE file_id = ?"
|
|
31417
|
+
).run(path, (/* @__PURE__ */ new Date()).toISOString(), fileId);
|
|
31418
|
+
}
|
|
31276
31419
|
|
|
31277
31420
|
// src/sync.ts
|
|
31421
|
+
async function fetchAndCacheAttachmentMeta(client2, fileId, messageId) {
|
|
31422
|
+
try {
|
|
31423
|
+
const meta3 = await client2.request("GET", `/pub/v1/myfiles/${fileId}`);
|
|
31424
|
+
upsertAttachmentForMessage({
|
|
31425
|
+
fileId: meta3.fileId ?? fileId,
|
|
31426
|
+
fileName: meta3.fileName ?? `file-${fileId}`,
|
|
31427
|
+
label: meta3.label ?? meta3.fileName ?? `file-${fileId}`,
|
|
31428
|
+
mimeType: meta3.fileType ?? "application/octet-stream",
|
|
31429
|
+
sizeBytes: typeof meta3.fileSize === "number" ? meta3.fileSize : null,
|
|
31430
|
+
metadata: meta3,
|
|
31431
|
+
messageId
|
|
31432
|
+
});
|
|
31433
|
+
} catch {
|
|
31434
|
+
}
|
|
31435
|
+
}
|
|
31436
|
+
async function fetchAttachmentMetaForMessage(client2, messageId, fileIds) {
|
|
31437
|
+
for (const fid of fileIds) {
|
|
31438
|
+
await fetchAndCacheAttachmentMeta(client2, fid, messageId);
|
|
31439
|
+
}
|
|
31440
|
+
}
|
|
31278
31441
|
async function resolveFolderIds(client2) {
|
|
31279
31442
|
const data = await client2.request(
|
|
31280
31443
|
"GET",
|
|
@@ -31321,10 +31484,14 @@ async function syncMessageFolder(client2, folder, folderId, opts) {
|
|
|
31321
31484
|
const shouldFetchBody = !isInboxUnread || opts.fetchUnreadBodies;
|
|
31322
31485
|
let body = null;
|
|
31323
31486
|
let fetchedBodyAt = null;
|
|
31487
|
+
let detailFileIds = [];
|
|
31324
31488
|
if (shouldFetchBody) {
|
|
31325
31489
|
const detail = await client2.request("GET", `/pub/v3/messages/${item.id}`);
|
|
31326
31490
|
body = detail.body ?? "";
|
|
31327
31491
|
fetchedBodyAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
31492
|
+
if (Array.isArray(detail.files) && detail.files.length > 0) {
|
|
31493
|
+
detailFileIds = detail.files;
|
|
31494
|
+
}
|
|
31328
31495
|
} else {
|
|
31329
31496
|
unread.push({
|
|
31330
31497
|
id: item.id,
|
|
@@ -31348,6 +31515,9 @@ async function syncMessageFolder(client2, folder, folderId, opts) {
|
|
|
31348
31515
|
};
|
|
31349
31516
|
upsertMessage(row);
|
|
31350
31517
|
synced++;
|
|
31518
|
+
if (detailFileIds.length > 0) {
|
|
31519
|
+
await fetchAttachmentMetaForMessage(client2, item.id, detailFileIds);
|
|
31520
|
+
}
|
|
31351
31521
|
}
|
|
31352
31522
|
if (!opts.deep && !pageHadNewItem) break;
|
|
31353
31523
|
page++;
|
|
@@ -31422,6 +31592,35 @@ async function syncAll(client2, opts) {
|
|
|
31422
31592
|
}
|
|
31423
31593
|
|
|
31424
31594
|
// src/tools/messages.ts
|
|
31595
|
+
import { mkdirSync as mkdirSync2, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
31596
|
+
import { basename, dirname as dirname3, extname, join as join3, isAbsolute, resolve } from "node:path";
|
|
31597
|
+
var MIME_BY_EXT = {
|
|
31598
|
+
".pdf": "application/pdf",
|
|
31599
|
+
".png": "image/png",
|
|
31600
|
+
".jpg": "image/jpeg",
|
|
31601
|
+
".jpeg": "image/jpeg",
|
|
31602
|
+
".gif": "image/gif",
|
|
31603
|
+
".webp": "image/webp",
|
|
31604
|
+
".heic": "image/heic",
|
|
31605
|
+
".txt": "text/plain",
|
|
31606
|
+
".md": "text/markdown",
|
|
31607
|
+
".csv": "text/csv",
|
|
31608
|
+
".html": "text/html",
|
|
31609
|
+
".htm": "text/html",
|
|
31610
|
+
".json": "application/json",
|
|
31611
|
+
".xml": "application/xml",
|
|
31612
|
+
".doc": "application/msword",
|
|
31613
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
31614
|
+
".xls": "application/vnd.ms-excel",
|
|
31615
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
31616
|
+
".ppt": "application/vnd.ms-powerpoint",
|
|
31617
|
+
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
31618
|
+
".zip": "application/zip",
|
|
31619
|
+
".ics": "text/calendar"
|
|
31620
|
+
};
|
|
31621
|
+
function mimeFromName(name) {
|
|
31622
|
+
return MIME_BY_EXT[extname(name).toLowerCase()] ?? "application/octet-stream";
|
|
31623
|
+
}
|
|
31425
31624
|
function registerMessageTools(server2, client2) {
|
|
31426
31625
|
server2.registerTool("ofw_list_message_folders", {
|
|
31427
31626
|
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.",
|
|
@@ -31481,7 +31680,8 @@ function registerMessageTools(server2, client2) {
|
|
|
31481
31680
|
const id = Number(args.messageId);
|
|
31482
31681
|
const cached2 = getMessage(id);
|
|
31483
31682
|
if (cached2 && cached2.body !== null) {
|
|
31484
|
-
|
|
31683
|
+
const attachments2 = listAttachmentsForMessage(id);
|
|
31684
|
+
return { content: [{ type: "text", text: JSON.stringify({ ...cached2, attachments: attachments2 }, null, 2) }] };
|
|
31485
31685
|
}
|
|
31486
31686
|
const detail = await client2.request("GET", `/pub/v3/messages/${encodeURIComponent(args.messageId)}`);
|
|
31487
31687
|
const recipients = (detail.recipients ?? []).map((r) => ({
|
|
@@ -31495,7 +31695,7 @@ function registerMessageTools(server2, client2) {
|
|
|
31495
31695
|
folder,
|
|
31496
31696
|
subject: detail.subject,
|
|
31497
31697
|
fromUser: detail.from?.name ?? "",
|
|
31498
|
-
sentAt: detail.date.
|
|
31698
|
+
sentAt: detail.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
31499
31699
|
recipients,
|
|
31500
31700
|
body: detail.body ?? "",
|
|
31501
31701
|
fetchedBodyAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
@@ -31504,17 +31704,22 @@ function registerMessageTools(server2, client2) {
|
|
|
31504
31704
|
listData: cached2?.listData ?? detail
|
|
31505
31705
|
};
|
|
31506
31706
|
upsertMessage(row);
|
|
31507
|
-
|
|
31707
|
+
if (Array.isArray(detail.files) && detail.files.length > 0) {
|
|
31708
|
+
await fetchAttachmentMetaForMessage(client2, detail.id, detail.files);
|
|
31709
|
+
}
|
|
31710
|
+
const attachments = listAttachmentsForMessage(detail.id);
|
|
31711
|
+
return { content: [{ type: "text", text: JSON.stringify({ ...row, attachments }, null, 2) }] };
|
|
31508
31712
|
});
|
|
31509
31713
|
server2.registerTool("ofw_send_message", {
|
|
31510
|
-
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).",
|
|
31714
|
+
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.",
|
|
31511
31715
|
annotations: { destructiveHint: true },
|
|
31512
31716
|
inputSchema: {
|
|
31513
31717
|
subject: external_exports.string().describe("Message subject"),
|
|
31514
31718
|
body: external_exports.string().describe("Message body text"),
|
|
31515
31719
|
recipientIds: external_exports.array(external_exports.number()).describe("Array of recipient user IDs (get from ofw_get_profile)"),
|
|
31516
31720
|
replyToId: external_exports.number().describe("ID of the message being replied to").optional(),
|
|
31517
|
-
draftId: external_exports.number().describe("ID of the draft to delete after sending (omit if not sending from a draft)").optional()
|
|
31721
|
+
draftId: external_exports.number().describe("ID of the draft to delete after sending (omit if not sending from a draft)").optional(),
|
|
31722
|
+
myFileIDs: external_exports.array(external_exports.number()).describe("Attachment file ids (from ofw_upload_attachment) to attach to the message").optional()
|
|
31518
31723
|
}
|
|
31519
31724
|
}, async (args) => {
|
|
31520
31725
|
const requestedReplyTo = args.replyToId ?? null;
|
|
@@ -31529,11 +31734,12 @@ function registerMessageTools(server2, client2) {
|
|
|
31529
31734
|
const parent = getMessage(resolvedReplyTo);
|
|
31530
31735
|
chainRootId = parent?.chainRootId ?? parent?.id ?? requestedReplyTo;
|
|
31531
31736
|
}
|
|
31737
|
+
const myFileIDs = args.myFileIDs ?? [];
|
|
31532
31738
|
const data = await client2.request("POST", "/pub/v3/messages", {
|
|
31533
31739
|
subject: args.subject,
|
|
31534
31740
|
body: args.body,
|
|
31535
31741
|
recipientIds: args.recipientIds,
|
|
31536
|
-
attachments: { myFileIDs
|
|
31742
|
+
attachments: { myFileIDs },
|
|
31537
31743
|
draft: false,
|
|
31538
31744
|
includeOriginal: resolvedReplyTo !== null,
|
|
31539
31745
|
replyToId: resolvedReplyTo
|
|
@@ -31558,6 +31764,18 @@ function registerMessageTools(server2, client2) {
|
|
|
31558
31764
|
listData: data
|
|
31559
31765
|
};
|
|
31560
31766
|
upsertMessage(row);
|
|
31767
|
+
for (const fileId of myFileIDs) {
|
|
31768
|
+
const existing = getAttachment(fileId);
|
|
31769
|
+
upsertAttachmentForMessage({
|
|
31770
|
+
fileId,
|
|
31771
|
+
fileName: existing?.fileName ?? `file-${fileId}`,
|
|
31772
|
+
label: existing?.label ?? existing?.fileName ?? `file-${fileId}`,
|
|
31773
|
+
mimeType: existing?.mimeType ?? "application/octet-stream",
|
|
31774
|
+
sizeBytes: existing?.sizeBytes ?? null,
|
|
31775
|
+
metadata: existing?.metadata ?? {},
|
|
31776
|
+
messageId: data.id
|
|
31777
|
+
});
|
|
31778
|
+
}
|
|
31561
31779
|
}
|
|
31562
31780
|
if (args.draftId !== void 0) {
|
|
31563
31781
|
const form = new FormData();
|
|
@@ -31586,14 +31804,15 @@ ${text}` : text;
|
|
|
31586
31804
|
return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
|
|
31587
31805
|
});
|
|
31588
31806
|
server2.registerTool("ofw_save_draft", {
|
|
31589
|
-
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).",
|
|
31807
|
+
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.",
|
|
31590
31808
|
annotations: { readOnlyHint: false },
|
|
31591
31809
|
inputSchema: {
|
|
31592
31810
|
subject: external_exports.string().describe("Message subject"),
|
|
31593
31811
|
body: external_exports.string().describe("Message body text"),
|
|
31594
31812
|
recipientIds: external_exports.array(external_exports.number()).describe("Array of recipient user IDs (optional for drafts)").optional(),
|
|
31595
31813
|
messageId: external_exports.number().describe("ID of an existing draft to update (omit to create a new draft)").optional(),
|
|
31596
|
-
replyToId: external_exports.number().describe("ID of the message this draft replies to").optional()
|
|
31814
|
+
replyToId: external_exports.number().describe("ID of the message this draft replies to").optional(),
|
|
31815
|
+
myFileIDs: external_exports.array(external_exports.number()).describe("Attachment file ids (from ofw_upload_attachment)").optional()
|
|
31597
31816
|
}
|
|
31598
31817
|
}, async (args) => {
|
|
31599
31818
|
const requestedReplyTo = args.replyToId ?? null;
|
|
@@ -31605,11 +31824,12 @@ ${text}` : text;
|
|
|
31605
31824
|
rewriteNote = `replyToId rewritten from ${requestedReplyTo} to ${resolvedReplyTo} (later reply in same thread found in sent cache).`;
|
|
31606
31825
|
}
|
|
31607
31826
|
}
|
|
31827
|
+
const myFileIDs = args.myFileIDs ?? [];
|
|
31608
31828
|
const payload = {
|
|
31609
31829
|
subject: args.subject,
|
|
31610
31830
|
body: args.body,
|
|
31611
31831
|
recipientIds: args.recipientIds ?? [],
|
|
31612
|
-
attachments: { myFileIDs
|
|
31832
|
+
attachments: { myFileIDs },
|
|
31613
31833
|
draft: true,
|
|
31614
31834
|
includeOriginal: resolvedReplyTo !== null,
|
|
31615
31835
|
replyToId: resolvedReplyTo
|
|
@@ -31681,6 +31901,106 @@ ${text}` : text;
|
|
|
31681
31901
|
}
|
|
31682
31902
|
return { content: [{ type: "text", text: JSON.stringify(unread, null, 2) }] };
|
|
31683
31903
|
});
|
|
31904
|
+
server2.registerTool("ofw_upload_attachment", {
|
|
31905
|
+
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.`,
|
|
31906
|
+
annotations: { destructiveHint: false },
|
|
31907
|
+
inputSchema: {
|
|
31908
|
+
path: external_exports.string().describe("Absolute path to the local file to upload. Tilde (~) is expanded."),
|
|
31909
|
+
shareClass: external_exports.enum(["PRIVATE", "SHARED"]).describe("Share class (default PRIVATE)").optional(),
|
|
31910
|
+
label: external_exports.string().describe("Display label for the file in OFW (default: filename)").optional(),
|
|
31911
|
+
description: external_exports.string().describe("Description shown in OFW My Files (default: filename)").optional()
|
|
31912
|
+
}
|
|
31913
|
+
}, async (args) => {
|
|
31914
|
+
const expanded = args.path.startsWith("~/") ? join3(process.env.HOME ?? "", args.path.slice(2)) : args.path;
|
|
31915
|
+
const abs = isAbsolute(expanded) ? expanded : resolve(expanded);
|
|
31916
|
+
const stat = statSync(abs);
|
|
31917
|
+
if (!stat.isFile()) throw new Error(`Not a file: ${abs}`);
|
|
31918
|
+
const buf = readFileSync(abs);
|
|
31919
|
+
const fileName = basename(abs);
|
|
31920
|
+
const mime = mimeFromName(fileName);
|
|
31921
|
+
const form = new FormData();
|
|
31922
|
+
form.append("file", new Blob([new Uint8Array(buf)], { type: mime }), fileName);
|
|
31923
|
+
form.append("source", "message");
|
|
31924
|
+
form.append("description", args.description ?? fileName);
|
|
31925
|
+
form.append("label", args.label ?? fileName);
|
|
31926
|
+
form.append("fileName", fileName);
|
|
31927
|
+
form.append("shareClass", args.shareClass ?? "PRIVATE");
|
|
31928
|
+
const meta3 = await client2.request("POST", "/pub/v3/myfiles/multipart", form);
|
|
31929
|
+
upsertAttachmentForMessage({
|
|
31930
|
+
fileId: meta3.fileId,
|
|
31931
|
+
fileName: meta3.fileName ?? fileName,
|
|
31932
|
+
label: meta3.label ?? args.label ?? fileName,
|
|
31933
|
+
mimeType: meta3.fileType ?? mime,
|
|
31934
|
+
sizeBytes: typeof meta3.sizeInBytes === "number" ? meta3.sizeInBytes : buf.length,
|
|
31935
|
+
metadata: meta3,
|
|
31936
|
+
messageId: 0
|
|
31937
|
+
});
|
|
31938
|
+
return { content: [{ type: "text", text: JSON.stringify({
|
|
31939
|
+
fileId: meta3.fileId,
|
|
31940
|
+
fileName: meta3.fileName ?? fileName,
|
|
31941
|
+
mimeType: meta3.fileType ?? mime,
|
|
31942
|
+
sizeBytes: meta3.sizeInBytes ?? buf.length,
|
|
31943
|
+
shareClass: meta3.shareClass ?? args.shareClass ?? "PRIVATE",
|
|
31944
|
+
note: "Pass this fileId to ofw_send_message or ofw_save_draft in myFileIDs to attach it."
|
|
31945
|
+
}, null, 2) }] };
|
|
31946
|
+
});
|
|
31947
|
+
server2.registerTool("ofw_download_attachment", {
|
|
31948
|
+
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.",
|
|
31949
|
+
annotations: { readOnlyHint: false },
|
|
31950
|
+
inputSchema: {
|
|
31951
|
+
fileId: external_exports.number().describe("Attachment file id (from ofw_get_message \u2192 attachments[].fileId)"),
|
|
31952
|
+
saveTo: external_exports.string().describe("Absolute path or directory to write to. If a directory, the OFW filename is used. Default: ~/.cache/ofw-mcp/attachments/<hash>/<fileId>-<filename>").optional(),
|
|
31953
|
+
force: external_exports.boolean().describe("Re-download even if already on disk. Default false.").optional()
|
|
31954
|
+
}
|
|
31955
|
+
}, async (args) => {
|
|
31956
|
+
const fileId = args.fileId;
|
|
31957
|
+
let cached2 = getAttachment(fileId);
|
|
31958
|
+
if (!cached2) {
|
|
31959
|
+
const meta3 = await client2.request("GET", `/pub/v1/myfiles/${fileId}`);
|
|
31960
|
+
upsertAttachmentForMessage({
|
|
31961
|
+
fileId: meta3.fileId ?? fileId,
|
|
31962
|
+
fileName: meta3.fileName ?? `file-${fileId}`,
|
|
31963
|
+
label: meta3.label ?? meta3.fileName ?? `file-${fileId}`,
|
|
31964
|
+
mimeType: meta3.fileType ?? "application/octet-stream",
|
|
31965
|
+
sizeBytes: typeof meta3.fileSize === "number" ? meta3.fileSize : null,
|
|
31966
|
+
metadata: meta3,
|
|
31967
|
+
messageId: 0
|
|
31968
|
+
// placeholder; will be cleaned up if a real message references it
|
|
31969
|
+
});
|
|
31970
|
+
cached2 = getAttachment(fileId);
|
|
31971
|
+
if (!cached2) throw new Error(`failed to fetch metadata for fileId ${fileId}`);
|
|
31972
|
+
}
|
|
31973
|
+
let dest;
|
|
31974
|
+
if (args.saveTo) {
|
|
31975
|
+
const expanded = args.saveTo.startsWith("~/") ? join3(process.env.HOME ?? "", args.saveTo.slice(2)) : args.saveTo;
|
|
31976
|
+
const abs = isAbsolute(expanded) ? expanded : resolve(expanded);
|
|
31977
|
+
const isDirArg = expanded.endsWith("/") || expanded.endsWith("\\");
|
|
31978
|
+
dest = isDirArg ? join3(abs, `${fileId}-${cached2.fileName}`) : abs;
|
|
31979
|
+
} else {
|
|
31980
|
+
dest = join3(getAttachmentsDir(), `${fileId}-${cached2.fileName}`);
|
|
31981
|
+
}
|
|
31982
|
+
if (!args.force && cached2.downloadedPath === dest) {
|
|
31983
|
+
return { content: [{ type: "text", text: JSON.stringify({
|
|
31984
|
+
fileId,
|
|
31985
|
+
path: dest,
|
|
31986
|
+
mimeType: cached2.mimeType,
|
|
31987
|
+
sizeBytes: cached2.sizeBytes,
|
|
31988
|
+
fileName: cached2.fileName,
|
|
31989
|
+
note: "already downloaded"
|
|
31990
|
+
}, null, 2) }] };
|
|
31991
|
+
}
|
|
31992
|
+
const response = await client2.requestBinary("GET", `/pub/v1/myfiles/${fileId}/data`);
|
|
31993
|
+
mkdirSync2(dirname3(dest), { recursive: true });
|
|
31994
|
+
writeFileSync(dest, response.body);
|
|
31995
|
+
markAttachmentDownloaded(fileId, dest);
|
|
31996
|
+
return { content: [{ type: "text", text: JSON.stringify({
|
|
31997
|
+
fileId,
|
|
31998
|
+
path: dest,
|
|
31999
|
+
mimeType: response.contentType ?? cached2.mimeType,
|
|
32000
|
+
sizeBytes: response.body.length,
|
|
32001
|
+
fileName: response.suggestedFileName ?? cached2.fileName
|
|
32002
|
+
}, null, 2) }] };
|
|
32003
|
+
});
|
|
31684
32004
|
server2.registerTool("ofw_sync_messages", {
|
|
31685
32005
|
description: "Sync messages from OurFamilyWizard into the local cache. Returns counts per folder and a list of unread inbox messages whose bodies were NOT fetched (to avoid mark-as-read on OFW). Call ofw_get_message(id) on those to read them. Pass deep:true to walk all OFW pages instead of stopping at the first all-cached page (use to backfill suspected gaps).",
|
|
31686
32006
|
annotations: { readOnlyHint: false },
|
|
@@ -31841,7 +32161,7 @@ process.emit = function(event, ...args) {
|
|
|
31841
32161
|
}
|
|
31842
32162
|
return originalEmit(event, ...args);
|
|
31843
32163
|
};
|
|
31844
|
-
var server = new McpServer({ name: "ofw", version: "2.0.
|
|
32164
|
+
var server = new McpServer({ name: "ofw", version: "2.0.10" });
|
|
31845
32165
|
registerUserTools(server, client);
|
|
31846
32166
|
registerMessageTools(server, client);
|
|
31847
32167
|
registerCalendarTools(server, client);
|
package/dist/cache.js
CHANGED
|
@@ -42,9 +42,25 @@ CREATE TABLE IF NOT EXISTS meta (
|
|
|
42
42
|
value TEXT NOT NULL
|
|
43
43
|
);
|
|
44
44
|
`;
|
|
45
|
+
// v2: add attachments table. Idempotent — IF NOT EXISTS.
|
|
46
|
+
const SCHEMA_V2 = `
|
|
47
|
+
CREATE TABLE IF NOT EXISTS attachments (
|
|
48
|
+
file_id INTEGER PRIMARY KEY,
|
|
49
|
+
file_name TEXT NOT NULL,
|
|
50
|
+
label TEXT NOT NULL,
|
|
51
|
+
mime_type TEXT NOT NULL,
|
|
52
|
+
size_bytes INTEGER,
|
|
53
|
+
metadata_json TEXT NOT NULL,
|
|
54
|
+
message_ids_json TEXT NOT NULL, -- JSON array of message ids that reference this file
|
|
55
|
+
downloaded_path TEXT, -- absolute path on disk if/when downloaded
|
|
56
|
+
downloaded_at TEXT,
|
|
57
|
+
fetched_metadata_at TEXT NOT NULL
|
|
58
|
+
);
|
|
59
|
+
`;
|
|
45
60
|
function migrate(db) {
|
|
46
61
|
db.exec(SCHEMA_V1);
|
|
47
|
-
db.
|
|
62
|
+
db.exec(SCHEMA_V2);
|
|
63
|
+
db.prepare('INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value').run('schema_version', '2');
|
|
48
64
|
}
|
|
49
65
|
export function openCache() {
|
|
50
66
|
if (instance)
|
|
@@ -249,3 +265,58 @@ export function findLatestReplyTip(replyToId) {
|
|
|
249
265
|
ORDER BY id DESC LIMIT 1`).get(chainRoot);
|
|
250
266
|
return tip ? tip.id : replyToId;
|
|
251
267
|
}
|
|
268
|
+
function attachmentFromDb(r) {
|
|
269
|
+
return {
|
|
270
|
+
fileId: r.file_id,
|
|
271
|
+
fileName: r.file_name,
|
|
272
|
+
label: r.label,
|
|
273
|
+
mimeType: r.mime_type,
|
|
274
|
+
sizeBytes: r.size_bytes,
|
|
275
|
+
metadata: JSON.parse(r.metadata_json),
|
|
276
|
+
messageIds: JSON.parse(r.message_ids_json),
|
|
277
|
+
downloadedPath: r.downloaded_path,
|
|
278
|
+
downloadedAt: r.downloaded_at,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
export function getAttachment(fileId) {
|
|
282
|
+
const { db } = openCache();
|
|
283
|
+
const r = db.prepare('SELECT * FROM attachments WHERE file_id = ?').get(fileId);
|
|
284
|
+
return r ? attachmentFromDb(r) : null;
|
|
285
|
+
}
|
|
286
|
+
export function listAttachmentsForMessage(messageId) {
|
|
287
|
+
const { db } = openCache();
|
|
288
|
+
// SQLite JSON1 contains check
|
|
289
|
+
const rows = db.prepare(`SELECT * FROM attachments
|
|
290
|
+
WHERE EXISTS (SELECT 1 FROM json_each(message_ids_json) WHERE value = ?)
|
|
291
|
+
ORDER BY file_id`).all(messageId);
|
|
292
|
+
return rows.map(attachmentFromDb);
|
|
293
|
+
}
|
|
294
|
+
export function upsertAttachmentForMessage(input) {
|
|
295
|
+
const { db } = openCache();
|
|
296
|
+
const existing = db.prepare('SELECT message_ids_json FROM attachments WHERE file_id = ?')
|
|
297
|
+
.get(input.fileId);
|
|
298
|
+
let messageIds;
|
|
299
|
+
if (existing) {
|
|
300
|
+
const arr = JSON.parse(existing.message_ids_json);
|
|
301
|
+
messageIds = arr.includes(input.messageId) ? arr : [...arr, input.messageId];
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
messageIds = [input.messageId];
|
|
305
|
+
}
|
|
306
|
+
db.prepare(`INSERT INTO attachments (
|
|
307
|
+
file_id, file_name, label, mime_type, size_bytes,
|
|
308
|
+
metadata_json, message_ids_json, fetched_metadata_at
|
|
309
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
310
|
+
ON CONFLICT(file_id) DO UPDATE SET
|
|
311
|
+
file_name=excluded.file_name,
|
|
312
|
+
label=excluded.label,
|
|
313
|
+
mime_type=excluded.mime_type,
|
|
314
|
+
size_bytes=excluded.size_bytes,
|
|
315
|
+
metadata_json=excluded.metadata_json,
|
|
316
|
+
message_ids_json=excluded.message_ids_json,
|
|
317
|
+
fetched_metadata_at=excluded.fetched_metadata_at`).run(input.fileId, requireString('attachments.fileName', input.fileName), requireString('attachments.label', input.label), requireString('attachments.mimeType', input.mimeType), nullish(input.sizeBytes), JSON.stringify(input.metadata ?? null), JSON.stringify(messageIds), new Date().toISOString());
|
|
318
|
+
}
|
|
319
|
+
export function markAttachmentDownloaded(fileId, path) {
|
|
320
|
+
const { db } = openCache();
|
|
321
|
+
db.prepare('UPDATE attachments SET downloaded_path = ?, downloaded_at = ? WHERE file_id = ?').run(path, new Date().toISOString(), fileId);
|
|
322
|
+
}
|
package/dist/client.js
CHANGED
|
@@ -41,6 +41,56 @@ export class OFWClient {
|
|
|
41
41
|
await this.ensureAuthenticated();
|
|
42
42
|
return this.doRequest(method, path, body, false);
|
|
43
43
|
}
|
|
44
|
+
/** Like `request`, but returns the raw bytes plus Content-Type/-Disposition metadata. */
|
|
45
|
+
async requestBinary(method, path) {
|
|
46
|
+
await this.ensureAuthenticated();
|
|
47
|
+
return this.doRequestBinary(method, path, false);
|
|
48
|
+
}
|
|
49
|
+
async doRequestBinary(method, path, isRetry) {
|
|
50
|
+
const headers = {
|
|
51
|
+
'ofw-client': 'WebApplication',
|
|
52
|
+
'ofw-version': '1.0.0',
|
|
53
|
+
Accept: 'application/octet-stream',
|
|
54
|
+
Authorization: `Bearer ${this.token}`,
|
|
55
|
+
};
|
|
56
|
+
const response = await fetch(`${BASE_URL}${path}`, { method, headers });
|
|
57
|
+
if (response.status === 401 && !isRetry) {
|
|
58
|
+
this.token = null;
|
|
59
|
+
this.tokenExpiry = null;
|
|
60
|
+
await this.ensureAuthenticated();
|
|
61
|
+
return this.doRequestBinary(method, path, true);
|
|
62
|
+
}
|
|
63
|
+
if (response.status === 429 && !isRetry) {
|
|
64
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
65
|
+
return this.doRequestBinary(method, path, true);
|
|
66
|
+
}
|
|
67
|
+
if (!response.ok) {
|
|
68
|
+
throw new Error(`OFW API error: ${response.status} ${response.statusText} for ${method} ${path}`);
|
|
69
|
+
}
|
|
70
|
+
const buf = Buffer.from(await response.arrayBuffer());
|
|
71
|
+
const cd = response.headers.get('content-disposition') ?? '';
|
|
72
|
+
// RFC 6266: filename*=UTF-8''… takes priority; fall back to filename="…"
|
|
73
|
+
let suggestedFileName = null;
|
|
74
|
+
const extMatch = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(cd);
|
|
75
|
+
if (extMatch) {
|
|
76
|
+
try {
|
|
77
|
+
suggestedFileName = decodeURIComponent(extMatch[1].trim().replace(/^"|"$/g, ''));
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
suggestedFileName = extMatch[1];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
const m = /filename="?([^";]+)"?/i.exec(cd);
|
|
85
|
+
if (m)
|
|
86
|
+
suggestedFileName = m[1];
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
body: buf,
|
|
90
|
+
contentType: response.headers.get('content-type'),
|
|
91
|
+
suggestedFileName,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
44
94
|
async doRequest(method, path, body, isRetry) {
|
|
45
95
|
const isFormData = body instanceof FormData;
|
|
46
96
|
const headers = {
|
package/dist/config.js
CHANGED
|
@@ -19,3 +19,12 @@ export function getCacheDbPath() {
|
|
|
19
19
|
const hash = createHash('sha256').update(username).digest('hex').slice(0, 16);
|
|
20
20
|
return join(getCacheDir(), `${hash}.db`);
|
|
21
21
|
}
|
|
22
|
+
export function getAttachmentsDir() {
|
|
23
|
+
const override = process.env.OFW_ATTACHMENTS_DIR;
|
|
24
|
+
if (override && override.trim().length > 0)
|
|
25
|
+
return override.trim();
|
|
26
|
+
// Sibling to the cache db: ~/.cache/ofw-mcp/attachments/<hash>/
|
|
27
|
+
const username = readUsername();
|
|
28
|
+
const hash = createHash('sha256').update(username).digest('hex').slice(0, 16);
|
|
29
|
+
return join(getCacheDir(), 'attachments', hash);
|
|
30
|
+
}
|
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.10' });
|
|
21
21
|
registerUserTools(server, client);
|
|
22
22
|
registerMessageTools(server, client);
|
|
23
23
|
registerCalendarTools(server, client);
|
package/dist/sync.js
CHANGED
|
@@ -1,4 +1,28 @@
|
|
|
1
|
-
import { setMeta, upsertMessage, getMessage, setSyncState, upsertDraft, getDraft, deleteDraft, listDraftIds, } from './cache.js';
|
|
1
|
+
import { setMeta, upsertMessage, getMessage, setSyncState, upsertDraft, getDraft, deleteDraft, listDraftIds, upsertAttachmentForMessage, } from './cache.js';
|
|
2
|
+
export async function fetchAndCacheAttachmentMeta(client, fileId, messageId) {
|
|
3
|
+
try {
|
|
4
|
+
const meta = await client.request('GET', `/pub/v1/myfiles/${fileId}`);
|
|
5
|
+
upsertAttachmentForMessage({
|
|
6
|
+
fileId: meta.fileId ?? fileId,
|
|
7
|
+
fileName: meta.fileName ?? `file-${fileId}`,
|
|
8
|
+
label: meta.label ?? meta.fileName ?? `file-${fileId}`,
|
|
9
|
+
mimeType: meta.fileType ?? 'application/octet-stream',
|
|
10
|
+
sizeBytes: typeof meta.fileSize === 'number' ? meta.fileSize : null,
|
|
11
|
+
metadata: meta,
|
|
12
|
+
messageId,
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// Attachment metadata failures shouldn't break the surrounding sync.
|
|
17
|
+
// The file ids stay in the message's listData; the model can retry later
|
|
18
|
+
// via ofw_download_attachment, which will surface the actual error.
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export async function fetchAttachmentMetaForMessage(client, messageId, fileIds) {
|
|
22
|
+
for (const fid of fileIds) {
|
|
23
|
+
await fetchAndCacheAttachmentMeta(client, fid, messageId);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
2
26
|
export async function resolveFolderIds(client) {
|
|
3
27
|
const data = await client.request('GET', '/pub/v1/messageFolders?includeFolderCounts=true');
|
|
4
28
|
const sys = data.systemFolders ?? [];
|
|
@@ -46,10 +70,14 @@ export async function syncMessageFolder(client, folder, folderId, opts) {
|
|
|
46
70
|
const shouldFetchBody = !isInboxUnread || opts.fetchUnreadBodies;
|
|
47
71
|
let body = null;
|
|
48
72
|
let fetchedBodyAt = null;
|
|
73
|
+
let detailFileIds = [];
|
|
49
74
|
if (shouldFetchBody) {
|
|
50
75
|
const detail = await client.request('GET', `/pub/v3/messages/${item.id}`);
|
|
51
76
|
body = detail.body ?? '';
|
|
52
77
|
fetchedBodyAt = new Date().toISOString();
|
|
78
|
+
if (Array.isArray(detail.files) && detail.files.length > 0) {
|
|
79
|
+
detailFileIds = detail.files;
|
|
80
|
+
}
|
|
53
81
|
}
|
|
54
82
|
else {
|
|
55
83
|
unread.push({
|
|
@@ -74,6 +102,9 @@ export async function syncMessageFolder(client, folder, folderId, opts) {
|
|
|
74
102
|
};
|
|
75
103
|
upsertMessage(row);
|
|
76
104
|
synced++;
|
|
105
|
+
if (detailFileIds.length > 0) {
|
|
106
|
+
await fetchAttachmentMetaForMessage(client, item.id, detailFileIds);
|
|
107
|
+
}
|
|
77
108
|
}
|
|
78
109
|
// Stop heuristic: a page with no new items means we've reached cached
|
|
79
110
|
// history (OFW returns date-desc). A page with even ONE new item could
|
package/dist/tools/messages.js
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { syncAll } from '../sync.js';
|
|
3
|
-
import { listMessages, countMessages, listDrafts, getMessage, upsertMessage, upsertDraft, deleteDraft, findLatestReplyTip, } from '../cache.js';
|
|
2
|
+
import { syncAll, fetchAttachmentMetaForMessage } from '../sync.js';
|
|
3
|
+
import { listMessages, countMessages, listDrafts, getMessage, upsertMessage, upsertDraft, deleteDraft, findLatestReplyTip, listAttachmentsForMessage, getAttachment, upsertAttachmentForMessage, markAttachmentDownloaded, } from '../cache.js';
|
|
4
|
+
import { getAttachmentsDir } from '../config.js';
|
|
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
|
+
}
|
|
4
34
|
export function registerMessageTools(server, client) {
|
|
5
35
|
server.registerTool('ofw_list_message_folders', {
|
|
6
36
|
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.',
|
|
@@ -64,7 +94,8 @@ export function registerMessageTools(server, client) {
|
|
|
64
94
|
const id = Number(args.messageId);
|
|
65
95
|
const cached = getMessage(id);
|
|
66
96
|
if (cached && cached.body !== null) {
|
|
67
|
-
|
|
97
|
+
const attachments = listAttachmentsForMessage(id);
|
|
98
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...cached, attachments }, null, 2) }] };
|
|
68
99
|
}
|
|
69
100
|
const detail = await client.request('GET', `/pub/v3/messages/${encodeURIComponent(args.messageId)}`);
|
|
70
101
|
const recipients = (detail.recipients ?? []).map((r) => ({
|
|
@@ -76,7 +107,7 @@ export function registerMessageTools(server, client) {
|
|
|
76
107
|
folder,
|
|
77
108
|
subject: detail.subject,
|
|
78
109
|
fromUser: detail.from?.name ?? '',
|
|
79
|
-
sentAt: detail.date.
|
|
110
|
+
sentAt: detail.date?.dateTime ?? new Date().toISOString(),
|
|
80
111
|
recipients,
|
|
81
112
|
body: detail.body ?? '',
|
|
82
113
|
fetchedBodyAt: new Date().toISOString(),
|
|
@@ -85,10 +116,14 @@ export function registerMessageTools(server, client) {
|
|
|
85
116
|
listData: cached?.listData ?? detail,
|
|
86
117
|
};
|
|
87
118
|
upsertMessage(row);
|
|
88
|
-
|
|
119
|
+
if (Array.isArray(detail.files) && detail.files.length > 0) {
|
|
120
|
+
await fetchAttachmentMetaForMessage(client, detail.id, detail.files);
|
|
121
|
+
}
|
|
122
|
+
const attachments = listAttachmentsForMessage(detail.id);
|
|
123
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...row, attachments }, null, 2) }] };
|
|
89
124
|
});
|
|
90
125
|
server.registerTool('ofw_send_message', {
|
|
91
|
-
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).',
|
|
126
|
+
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.',
|
|
92
127
|
annotations: { destructiveHint: true },
|
|
93
128
|
inputSchema: {
|
|
94
129
|
subject: z.string().describe('Message subject'),
|
|
@@ -96,6 +131,7 @@ export function registerMessageTools(server, client) {
|
|
|
96
131
|
recipientIds: z.array(z.number()).describe('Array of recipient user IDs (get from ofw_get_profile)'),
|
|
97
132
|
replyToId: z.number().describe('ID of the message being replied to').optional(),
|
|
98
133
|
draftId: z.number().describe('ID of the draft to delete after sending (omit if not sending from a draft)').optional(),
|
|
134
|
+
myFileIDs: z.array(z.number()).describe('Attachment file ids (from ofw_upload_attachment) to attach to the message').optional(),
|
|
99
135
|
},
|
|
100
136
|
}, async (args) => {
|
|
101
137
|
const requestedReplyTo = args.replyToId ?? null;
|
|
@@ -110,11 +146,12 @@ export function registerMessageTools(server, client) {
|
|
|
110
146
|
const parent = getMessage(resolvedReplyTo);
|
|
111
147
|
chainRootId = parent?.chainRootId ?? parent?.id ?? requestedReplyTo;
|
|
112
148
|
}
|
|
149
|
+
const myFileIDs = args.myFileIDs ?? [];
|
|
113
150
|
const data = await client.request('POST', '/pub/v3/messages', {
|
|
114
151
|
subject: args.subject,
|
|
115
152
|
body: args.body,
|
|
116
153
|
recipientIds: args.recipientIds,
|
|
117
|
-
attachments: { myFileIDs
|
|
154
|
+
attachments: { myFileIDs },
|
|
118
155
|
draft: false,
|
|
119
156
|
includeOriginal: resolvedReplyTo !== null,
|
|
120
157
|
replyToId: resolvedReplyTo,
|
|
@@ -137,6 +174,21 @@ export function registerMessageTools(server, client) {
|
|
|
137
174
|
listData: data,
|
|
138
175
|
};
|
|
139
176
|
upsertMessage(row);
|
|
177
|
+
// Link attached files to the new message in the attachments cache.
|
|
178
|
+
// We may not have full metadata if the upload happened in a prior
|
|
179
|
+
// session — fall back to what we know.
|
|
180
|
+
for (const fileId of myFileIDs) {
|
|
181
|
+
const existing = getAttachment(fileId);
|
|
182
|
+
upsertAttachmentForMessage({
|
|
183
|
+
fileId,
|
|
184
|
+
fileName: existing?.fileName ?? `file-${fileId}`,
|
|
185
|
+
label: existing?.label ?? existing?.fileName ?? `file-${fileId}`,
|
|
186
|
+
mimeType: existing?.mimeType ?? 'application/octet-stream',
|
|
187
|
+
sizeBytes: existing?.sizeBytes ?? null,
|
|
188
|
+
metadata: existing?.metadata ?? {},
|
|
189
|
+
messageId: data.id,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
140
192
|
}
|
|
141
193
|
if (args.draftId !== undefined) {
|
|
142
194
|
const form = new FormData();
|
|
@@ -165,7 +217,7 @@ export function registerMessageTools(server, client) {
|
|
|
165
217
|
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
166
218
|
});
|
|
167
219
|
server.registerTool('ofw_save_draft', {
|
|
168
|
-
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).',
|
|
220
|
+
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.',
|
|
169
221
|
annotations: { readOnlyHint: false },
|
|
170
222
|
inputSchema: {
|
|
171
223
|
subject: z.string().describe('Message subject'),
|
|
@@ -173,6 +225,7 @@ export function registerMessageTools(server, client) {
|
|
|
173
225
|
recipientIds: z.array(z.number()).describe('Array of recipient user IDs (optional for drafts)').optional(),
|
|
174
226
|
messageId: z.number().describe('ID of an existing draft to update (omit to create a new draft)').optional(),
|
|
175
227
|
replyToId: z.number().describe('ID of the message this draft replies to').optional(),
|
|
228
|
+
myFileIDs: z.array(z.number()).describe('Attachment file ids (from ofw_upload_attachment)').optional(),
|
|
176
229
|
},
|
|
177
230
|
}, async (args) => {
|
|
178
231
|
const requestedReplyTo = args.replyToId ?? null;
|
|
@@ -184,11 +237,12 @@ export function registerMessageTools(server, client) {
|
|
|
184
237
|
rewriteNote = `replyToId rewritten from ${requestedReplyTo} to ${resolvedReplyTo} (later reply in same thread found in sent cache).`;
|
|
185
238
|
}
|
|
186
239
|
}
|
|
240
|
+
const myFileIDs = args.myFileIDs ?? [];
|
|
187
241
|
const payload = {
|
|
188
242
|
subject: args.subject,
|
|
189
243
|
body: args.body,
|
|
190
244
|
recipientIds: args.recipientIds ?? [],
|
|
191
|
-
attachments: { myFileIDs
|
|
245
|
+
attachments: { myFileIDs },
|
|
192
246
|
draft: true,
|
|
193
247
|
includeOriginal: resolvedReplyTo !== null,
|
|
194
248
|
replyToId: resolvedReplyTo,
|
|
@@ -257,6 +311,121 @@ export function registerMessageTools(server, client) {
|
|
|
257
311
|
}
|
|
258
312
|
return { content: [{ type: 'text', text: JSON.stringify(unread, null, 2) }] };
|
|
259
313
|
});
|
|
314
|
+
server.registerTool('ofw_upload_attachment', {
|
|
315
|
+
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.',
|
|
316
|
+
annotations: { destructiveHint: false },
|
|
317
|
+
inputSchema: {
|
|
318
|
+
path: z.string().describe('Absolute path to the local file to upload. Tilde (~) is expanded.'),
|
|
319
|
+
shareClass: z.enum(['PRIVATE', 'SHARED']).describe('Share class (default PRIVATE)').optional(),
|
|
320
|
+
label: z.string().describe('Display label for the file in OFW (default: filename)').optional(),
|
|
321
|
+
description: z.string().describe('Description shown in OFW My Files (default: filename)').optional(),
|
|
322
|
+
},
|
|
323
|
+
}, async (args) => {
|
|
324
|
+
// Resolve and read the local file
|
|
325
|
+
const expanded = args.path.startsWith('~/')
|
|
326
|
+
? join(process.env.HOME ?? '', args.path.slice(2))
|
|
327
|
+
: args.path;
|
|
328
|
+
const abs = isAbsolute(expanded) ? expanded : resolve(expanded);
|
|
329
|
+
const stat = statSync(abs); // throws if missing
|
|
330
|
+
if (!stat.isFile())
|
|
331
|
+
throw new Error(`Not a file: ${abs}`);
|
|
332
|
+
const buf = readFileSync(abs);
|
|
333
|
+
const fileName = basename(abs);
|
|
334
|
+
const mime = mimeFromName(fileName);
|
|
335
|
+
// Build the multipart payload matching the OFW web UI's request shape
|
|
336
|
+
const form = new FormData();
|
|
337
|
+
form.append('file', new Blob([new Uint8Array(buf)], { type: mime }), fileName);
|
|
338
|
+
form.append('source', 'message');
|
|
339
|
+
form.append('description', args.description ?? fileName);
|
|
340
|
+
form.append('label', args.label ?? fileName);
|
|
341
|
+
form.append('fileName', fileName);
|
|
342
|
+
form.append('shareClass', args.shareClass ?? 'PRIVATE');
|
|
343
|
+
const meta = await client.request('POST', '/pub/v3/myfiles/multipart', form);
|
|
344
|
+
// Cache the metadata so subsequent ofw_get_message calls can surface it
|
|
345
|
+
// and ofw_download_attachment short-circuits if asked. messageId is 0
|
|
346
|
+
// because no message references this yet — it'll be linked once a
|
|
347
|
+
// message is sent with this fileId in its attachments.
|
|
348
|
+
upsertAttachmentForMessage({
|
|
349
|
+
fileId: meta.fileId,
|
|
350
|
+
fileName: meta.fileName ?? fileName,
|
|
351
|
+
label: meta.label ?? args.label ?? fileName,
|
|
352
|
+
mimeType: meta.fileType ?? mime,
|
|
353
|
+
sizeBytes: typeof meta.sizeInBytes === 'number' ? meta.sizeInBytes : buf.length,
|
|
354
|
+
metadata: meta,
|
|
355
|
+
messageId: 0,
|
|
356
|
+
});
|
|
357
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
358
|
+
fileId: meta.fileId,
|
|
359
|
+
fileName: meta.fileName ?? fileName,
|
|
360
|
+
mimeType: meta.fileType ?? mime,
|
|
361
|
+
sizeBytes: meta.sizeInBytes ?? buf.length,
|
|
362
|
+
shareClass: meta.shareClass ?? args.shareClass ?? 'PRIVATE',
|
|
363
|
+
note: 'Pass this fileId to ofw_send_message or ofw_save_draft in myFileIDs to attach it.',
|
|
364
|
+
}, null, 2) }] };
|
|
365
|
+
});
|
|
366
|
+
server.registerTool('ofw_download_attachment', {
|
|
367
|
+
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.',
|
|
368
|
+
annotations: { readOnlyHint: false },
|
|
369
|
+
inputSchema: {
|
|
370
|
+
fileId: z.number().describe('Attachment file id (from ofw_get_message → attachments[].fileId)'),
|
|
371
|
+
saveTo: z.string().describe('Absolute path or directory to write to. If a directory, the OFW filename is used. Default: ~/.cache/ofw-mcp/attachments/<hash>/<fileId>-<filename>').optional(),
|
|
372
|
+
force: z.boolean().describe('Re-download even if already on disk. Default false.').optional(),
|
|
373
|
+
},
|
|
374
|
+
}, async (args) => {
|
|
375
|
+
const fileId = args.fileId;
|
|
376
|
+
let cached = getAttachment(fileId);
|
|
377
|
+
if (!cached) {
|
|
378
|
+
// Metadata not in cache — fetch on the fly.
|
|
379
|
+
const meta = await client.request('GET', `/pub/v1/myfiles/${fileId}`);
|
|
380
|
+
// Store with a sentinel "metadata-only, no message link" — we don't know which message asked.
|
|
381
|
+
// We'll re-link if a message later references it during sync.
|
|
382
|
+
upsertAttachmentForMessage({
|
|
383
|
+
fileId: meta.fileId ?? fileId,
|
|
384
|
+
fileName: meta.fileName ?? `file-${fileId}`,
|
|
385
|
+
label: meta.label ?? meta.fileName ?? `file-${fileId}`,
|
|
386
|
+
mimeType: meta.fileType ?? 'application/octet-stream',
|
|
387
|
+
sizeBytes: typeof meta.fileSize === 'number' ? meta.fileSize : null,
|
|
388
|
+
metadata: meta,
|
|
389
|
+
messageId: 0, // placeholder; will be cleaned up if a real message references it
|
|
390
|
+
});
|
|
391
|
+
cached = getAttachment(fileId);
|
|
392
|
+
if (!cached)
|
|
393
|
+
throw new Error(`failed to fetch metadata for fileId ${fileId}`);
|
|
394
|
+
}
|
|
395
|
+
// Decide destination path
|
|
396
|
+
let dest;
|
|
397
|
+
if (args.saveTo) {
|
|
398
|
+
const expanded = args.saveTo.startsWith('~/')
|
|
399
|
+
? join(process.env.HOME ?? '', args.saveTo.slice(2))
|
|
400
|
+
: args.saveTo;
|
|
401
|
+
const abs = isAbsolute(expanded) ? expanded : resolve(expanded);
|
|
402
|
+
// If it looks like a directory (ends with /) OR is an existing directory, treat as dir.
|
|
403
|
+
const isDirArg = expanded.endsWith('/') || expanded.endsWith('\\');
|
|
404
|
+
dest = isDirArg ? join(abs, `${fileId}-${cached.fileName}`) : abs;
|
|
405
|
+
}
|
|
406
|
+
else {
|
|
407
|
+
dest = join(getAttachmentsDir(), `${fileId}-${cached.fileName}`);
|
|
408
|
+
}
|
|
409
|
+
// Short-circuit if already downloaded to this path
|
|
410
|
+
if (!args.force && cached.downloadedPath === dest) {
|
|
411
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
412
|
+
fileId, path: dest, mimeType: cached.mimeType, sizeBytes: cached.sizeBytes,
|
|
413
|
+
fileName: cached.fileName, note: 'already downloaded',
|
|
414
|
+
}, null, 2) }] };
|
|
415
|
+
}
|
|
416
|
+
// Fetch bytes
|
|
417
|
+
const response = await client.requestBinary('GET', `/pub/v1/myfiles/${fileId}/data`);
|
|
418
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
419
|
+
writeFileSync(dest, response.body);
|
|
420
|
+
markAttachmentDownloaded(fileId, dest);
|
|
421
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
422
|
+
fileId,
|
|
423
|
+
path: dest,
|
|
424
|
+
mimeType: response.contentType ?? cached.mimeType,
|
|
425
|
+
sizeBytes: response.body.length,
|
|
426
|
+
fileName: response.suggestedFileName ?? cached.fileName,
|
|
427
|
+
}, null, 2) }] };
|
|
428
|
+
});
|
|
260
429
|
server.registerTool('ofw_sync_messages', {
|
|
261
430
|
description: 'Sync messages from OurFamilyWizard into the local cache. Returns counts per folder and a list of unread inbox messages whose bodies were NOT fetched (to avoid mark-as-read on OFW). Call ofw_get_message(id) on those to read them. Pass deep:true to walk all OFW pages instead of stopping at the first all-cached page (use to backfill suspected gaps).',
|
|
262
431
|
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.10",
|
|
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.10",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "ofw-mcp",
|
|
14
|
-
"version": "2.0.
|
|
14
|
+
"version": "2.0.10",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|