ofw-mcp 2.0.8 → 2.0.9
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 +260 -26
- 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 +76 -5
- 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.9"
|
|
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.9",
|
|
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,8 @@ async function syncAll(client2, opts) {
|
|
|
31422
31592
|
}
|
|
31423
31593
|
|
|
31424
31594
|
// 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";
|
|
31425
31597
|
function registerMessageTools(server2, client2) {
|
|
31426
31598
|
server2.registerTool("ofw_list_message_folders", {
|
|
31427
31599
|
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 +31653,8 @@ function registerMessageTools(server2, client2) {
|
|
|
31481
31653
|
const id = Number(args.messageId);
|
|
31482
31654
|
const cached2 = getMessage(id);
|
|
31483
31655
|
if (cached2 && cached2.body !== null) {
|
|
31484
|
-
|
|
31656
|
+
const attachments2 = listAttachmentsForMessage(id);
|
|
31657
|
+
return { content: [{ type: "text", text: JSON.stringify({ ...cached2, attachments: attachments2 }, null, 2) }] };
|
|
31485
31658
|
}
|
|
31486
31659
|
const detail = await client2.request("GET", `/pub/v3/messages/${encodeURIComponent(args.messageId)}`);
|
|
31487
31660
|
const recipients = (detail.recipients ?? []).map((r) => ({
|
|
@@ -31495,7 +31668,7 @@ function registerMessageTools(server2, client2) {
|
|
|
31495
31668
|
folder,
|
|
31496
31669
|
subject: detail.subject,
|
|
31497
31670
|
fromUser: detail.from?.name ?? "",
|
|
31498
|
-
sentAt: detail.date.
|
|
31671
|
+
sentAt: detail.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
31499
31672
|
recipients,
|
|
31500
31673
|
body: detail.body ?? "",
|
|
31501
31674
|
fetchedBodyAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
@@ -31504,7 +31677,11 @@ function registerMessageTools(server2, client2) {
|
|
|
31504
31677
|
listData: cached2?.listData ?? detail
|
|
31505
31678
|
};
|
|
31506
31679
|
upsertMessage(row);
|
|
31507
|
-
|
|
31680
|
+
if (Array.isArray(detail.files) && detail.files.length > 0) {
|
|
31681
|
+
await fetchAttachmentMetaForMessage(client2, detail.id, detail.files);
|
|
31682
|
+
}
|
|
31683
|
+
const attachments = listAttachmentsForMessage(detail.id);
|
|
31684
|
+
return { content: [{ type: "text", text: JSON.stringify({ ...row, attachments }, null, 2) }] };
|
|
31508
31685
|
});
|
|
31509
31686
|
server2.registerTool("ofw_send_message", {
|
|
31510
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).",
|
|
@@ -31681,6 +31858,63 @@ ${text}` : text;
|
|
|
31681
31858
|
}
|
|
31682
31859
|
return { content: [{ type: "text", text: JSON.stringify(unread, null, 2) }] };
|
|
31683
31860
|
});
|
|
31861
|
+
server2.registerTool("ofw_download_attachment", {
|
|
31862
|
+
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
|
+
annotations: { readOnlyHint: false },
|
|
31864
|
+
inputSchema: {
|
|
31865
|
+
fileId: external_exports.number().describe("Attachment file id (from ofw_get_message \u2192 attachments[].fileId)"),
|
|
31866
|
+
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(),
|
|
31867
|
+
force: external_exports.boolean().describe("Re-download even if already on disk. Default false.").optional()
|
|
31868
|
+
}
|
|
31869
|
+
}, async (args) => {
|
|
31870
|
+
const fileId = args.fileId;
|
|
31871
|
+
let cached2 = getAttachment(fileId);
|
|
31872
|
+
if (!cached2) {
|
|
31873
|
+
const meta3 = await client2.request("GET", `/pub/v1/myfiles/${fileId}`);
|
|
31874
|
+
upsertAttachmentForMessage({
|
|
31875
|
+
fileId: meta3.fileId ?? fileId,
|
|
31876
|
+
fileName: meta3.fileName ?? `file-${fileId}`,
|
|
31877
|
+
label: meta3.label ?? meta3.fileName ?? `file-${fileId}`,
|
|
31878
|
+
mimeType: meta3.fileType ?? "application/octet-stream",
|
|
31879
|
+
sizeBytes: typeof meta3.fileSize === "number" ? meta3.fileSize : null,
|
|
31880
|
+
metadata: meta3,
|
|
31881
|
+
messageId: 0
|
|
31882
|
+
// placeholder; will be cleaned up if a real message references it
|
|
31883
|
+
});
|
|
31884
|
+
cached2 = getAttachment(fileId);
|
|
31885
|
+
if (!cached2) throw new Error(`failed to fetch metadata for fileId ${fileId}`);
|
|
31886
|
+
}
|
|
31887
|
+
let dest;
|
|
31888
|
+
if (args.saveTo) {
|
|
31889
|
+
const expanded = args.saveTo.startsWith("~/") ? join3(process.env.HOME ?? "", args.saveTo.slice(2)) : args.saveTo;
|
|
31890
|
+
const abs = isAbsolute(expanded) ? expanded : resolve(expanded);
|
|
31891
|
+
const isDirArg = expanded.endsWith("/") || expanded.endsWith("\\");
|
|
31892
|
+
dest = isDirArg ? join3(abs, `${fileId}-${cached2.fileName}`) : abs;
|
|
31893
|
+
} else {
|
|
31894
|
+
dest = join3(getAttachmentsDir(), `${fileId}-${cached2.fileName}`);
|
|
31895
|
+
}
|
|
31896
|
+
if (!args.force && cached2.downloadedPath === dest) {
|
|
31897
|
+
return { content: [{ type: "text", text: JSON.stringify({
|
|
31898
|
+
fileId,
|
|
31899
|
+
path: dest,
|
|
31900
|
+
mimeType: cached2.mimeType,
|
|
31901
|
+
sizeBytes: cached2.sizeBytes,
|
|
31902
|
+
fileName: cached2.fileName,
|
|
31903
|
+
note: "already downloaded"
|
|
31904
|
+
}, null, 2) }] };
|
|
31905
|
+
}
|
|
31906
|
+
const response = await client2.requestBinary("GET", `/pub/v1/myfiles/${fileId}/data`);
|
|
31907
|
+
mkdirSync2(dirname3(dest), { recursive: true });
|
|
31908
|
+
writeFileSync(dest, response.body);
|
|
31909
|
+
markAttachmentDownloaded(fileId, dest);
|
|
31910
|
+
return { content: [{ type: "text", text: JSON.stringify({
|
|
31911
|
+
fileId,
|
|
31912
|
+
path: dest,
|
|
31913
|
+
mimeType: response.contentType ?? cached2.mimeType,
|
|
31914
|
+
sizeBytes: response.body.length,
|
|
31915
|
+
fileName: response.suggestedFileName ?? cached2.fileName
|
|
31916
|
+
}, null, 2) }] };
|
|
31917
|
+
});
|
|
31684
31918
|
server2.registerTool("ofw_sync_messages", {
|
|
31685
31919
|
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
31920
|
annotations: { readOnlyHint: false },
|
|
@@ -31841,7 +32075,7 @@ process.emit = function(event, ...args) {
|
|
|
31841
32075
|
}
|
|
31842
32076
|
return originalEmit(event, ...args);
|
|
31843
32077
|
};
|
|
31844
|
-
var server = new McpServer({ name: "ofw", version: "2.0.
|
|
32078
|
+
var server = new McpServer({ name: "ofw", version: "2.0.9" });
|
|
31845
32079
|
registerUserTools(server, client);
|
|
31846
32080
|
registerMessageTools(server, client);
|
|
31847
32081
|
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.9' });
|
|
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,9 @@
|
|
|
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, writeFileSync } from 'node:fs';
|
|
6
|
+
import { dirname, join, isAbsolute, resolve } from 'node:path';
|
|
4
7
|
export function registerMessageTools(server, client) {
|
|
5
8
|
server.registerTool('ofw_list_message_folders', {
|
|
6
9
|
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 +67,8 @@ export function registerMessageTools(server, client) {
|
|
|
64
67
|
const id = Number(args.messageId);
|
|
65
68
|
const cached = getMessage(id);
|
|
66
69
|
if (cached && cached.body !== null) {
|
|
67
|
-
|
|
70
|
+
const attachments = listAttachmentsForMessage(id);
|
|
71
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...cached, attachments }, null, 2) }] };
|
|
68
72
|
}
|
|
69
73
|
const detail = await client.request('GET', `/pub/v3/messages/${encodeURIComponent(args.messageId)}`);
|
|
70
74
|
const recipients = (detail.recipients ?? []).map((r) => ({
|
|
@@ -76,7 +80,7 @@ export function registerMessageTools(server, client) {
|
|
|
76
80
|
folder,
|
|
77
81
|
subject: detail.subject,
|
|
78
82
|
fromUser: detail.from?.name ?? '',
|
|
79
|
-
sentAt: detail.date.
|
|
83
|
+
sentAt: detail.date?.dateTime ?? new Date().toISOString(),
|
|
80
84
|
recipients,
|
|
81
85
|
body: detail.body ?? '',
|
|
82
86
|
fetchedBodyAt: new Date().toISOString(),
|
|
@@ -85,7 +89,11 @@ export function registerMessageTools(server, client) {
|
|
|
85
89
|
listData: cached?.listData ?? detail,
|
|
86
90
|
};
|
|
87
91
|
upsertMessage(row);
|
|
88
|
-
|
|
92
|
+
if (Array.isArray(detail.files) && detail.files.length > 0) {
|
|
93
|
+
await fetchAttachmentMetaForMessage(client, detail.id, detail.files);
|
|
94
|
+
}
|
|
95
|
+
const attachments = listAttachmentsForMessage(detail.id);
|
|
96
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...row, attachments }, null, 2) }] };
|
|
89
97
|
});
|
|
90
98
|
server.registerTool('ofw_send_message', {
|
|
91
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).',
|
|
@@ -257,6 +265,69 @@ export function registerMessageTools(server, client) {
|
|
|
257
265
|
}
|
|
258
266
|
return { content: [{ type: 'text', text: JSON.stringify(unread, null, 2) }] };
|
|
259
267
|
});
|
|
268
|
+
server.registerTool('ofw_download_attachment', {
|
|
269
|
+
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
|
+
annotations: { readOnlyHint: false },
|
|
271
|
+
inputSchema: {
|
|
272
|
+
fileId: z.number().describe('Attachment file id (from ofw_get_message → attachments[].fileId)'),
|
|
273
|
+
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(),
|
|
274
|
+
force: z.boolean().describe('Re-download even if already on disk. Default false.').optional(),
|
|
275
|
+
},
|
|
276
|
+
}, async (args) => {
|
|
277
|
+
const fileId = args.fileId;
|
|
278
|
+
let cached = getAttachment(fileId);
|
|
279
|
+
if (!cached) {
|
|
280
|
+
// Metadata not in cache — fetch on the fly.
|
|
281
|
+
const meta = await client.request('GET', `/pub/v1/myfiles/${fileId}`);
|
|
282
|
+
// Store with a sentinel "metadata-only, no message link" — we don't know which message asked.
|
|
283
|
+
// We'll re-link if a message later references it during sync.
|
|
284
|
+
upsertAttachmentForMessage({
|
|
285
|
+
fileId: meta.fileId ?? fileId,
|
|
286
|
+
fileName: meta.fileName ?? `file-${fileId}`,
|
|
287
|
+
label: meta.label ?? meta.fileName ?? `file-${fileId}`,
|
|
288
|
+
mimeType: meta.fileType ?? 'application/octet-stream',
|
|
289
|
+
sizeBytes: typeof meta.fileSize === 'number' ? meta.fileSize : null,
|
|
290
|
+
metadata: meta,
|
|
291
|
+
messageId: 0, // placeholder; will be cleaned up if a real message references it
|
|
292
|
+
});
|
|
293
|
+
cached = getAttachment(fileId);
|
|
294
|
+
if (!cached)
|
|
295
|
+
throw new Error(`failed to fetch metadata for fileId ${fileId}`);
|
|
296
|
+
}
|
|
297
|
+
// Decide destination path
|
|
298
|
+
let dest;
|
|
299
|
+
if (args.saveTo) {
|
|
300
|
+
const expanded = args.saveTo.startsWith('~/')
|
|
301
|
+
? join(process.env.HOME ?? '', args.saveTo.slice(2))
|
|
302
|
+
: args.saveTo;
|
|
303
|
+
const abs = isAbsolute(expanded) ? expanded : resolve(expanded);
|
|
304
|
+
// If it looks like a directory (ends with /) OR is an existing directory, treat as dir.
|
|
305
|
+
const isDirArg = expanded.endsWith('/') || expanded.endsWith('\\');
|
|
306
|
+
dest = isDirArg ? join(abs, `${fileId}-${cached.fileName}`) : abs;
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
dest = join(getAttachmentsDir(), `${fileId}-${cached.fileName}`);
|
|
310
|
+
}
|
|
311
|
+
// Short-circuit if already downloaded to this path
|
|
312
|
+
if (!args.force && cached.downloadedPath === dest) {
|
|
313
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
314
|
+
fileId, path: dest, mimeType: cached.mimeType, sizeBytes: cached.sizeBytes,
|
|
315
|
+
fileName: cached.fileName, note: 'already downloaded',
|
|
316
|
+
}, null, 2) }] };
|
|
317
|
+
}
|
|
318
|
+
// Fetch bytes
|
|
319
|
+
const response = await client.requestBinary('GET', `/pub/v1/myfiles/${fileId}/data`);
|
|
320
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
321
|
+
writeFileSync(dest, response.body);
|
|
322
|
+
markAttachmentDownloaded(fileId, dest);
|
|
323
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
324
|
+
fileId,
|
|
325
|
+
path: dest,
|
|
326
|
+
mimeType: response.contentType ?? cached.mimeType,
|
|
327
|
+
sizeBytes: response.body.length,
|
|
328
|
+
fileName: response.suggestedFileName ?? cached.fileName,
|
|
329
|
+
}, null, 2) }] };
|
|
330
|
+
});
|
|
260
331
|
server.registerTool('ofw_sync_messages', {
|
|
261
332
|
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
333
|
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.9",
|
|
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.9",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "ofw-mcp",
|
|
14
|
-
"version": "2.0.
|
|
14
|
+
"version": "2.0.9",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|