ofw-mcp 2.0.7 → 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 +292 -50
- package/dist/cache.js +86 -3
- package/dist/client.js +50 -0
- package/dist/config.js +9 -0
- package/dist/index.js +1 -1
- package/dist/sync.js +42 -8
- 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;
|
|
@@ -31087,6 +31163,13 @@ function rowFromDb(r) {
|
|
|
31087
31163
|
listData: JSON.parse(r.list_data_json)
|
|
31088
31164
|
};
|
|
31089
31165
|
}
|
|
31166
|
+
function nullish3(v) {
|
|
31167
|
+
return v === void 0 ? null : v;
|
|
31168
|
+
}
|
|
31169
|
+
function requireString(field, v) {
|
|
31170
|
+
if (typeof v === "string") return v;
|
|
31171
|
+
throw new Error(`cache: ${field} is required (got ${v === void 0 ? "undefined" : "null"})`);
|
|
31172
|
+
}
|
|
31090
31173
|
function upsertMessage(row) {
|
|
31091
31174
|
const { db } = openCache();
|
|
31092
31175
|
db.prepare(
|
|
@@ -31108,16 +31191,16 @@ function upsertMessage(row) {
|
|
|
31108
31191
|
last_seen_at=excluded.last_seen_at`
|
|
31109
31192
|
).run(
|
|
31110
31193
|
row.id,
|
|
31111
|
-
row.folder,
|
|
31112
|
-
row.subject,
|
|
31113
|
-
row.fromUser,
|
|
31114
|
-
row.sentAt,
|
|
31115
|
-
JSON.stringify(row.recipients),
|
|
31116
|
-
row.body,
|
|
31117
|
-
row.fetchedBodyAt,
|
|
31118
|
-
row.replyToId,
|
|
31119
|
-
row.chainRootId,
|
|
31120
|
-
JSON.stringify(row.listData),
|
|
31194
|
+
requireString("messages.folder", row.folder),
|
|
31195
|
+
requireString("messages.subject", row.subject),
|
|
31196
|
+
requireString("messages.fromUser", row.fromUser),
|
|
31197
|
+
requireString("messages.sentAt", row.sentAt),
|
|
31198
|
+
JSON.stringify(row.recipients ?? []),
|
|
31199
|
+
nullish3(row.body),
|
|
31200
|
+
nullish3(row.fetchedBodyAt),
|
|
31201
|
+
nullish3(row.replyToId),
|
|
31202
|
+
nullish3(row.chainRootId),
|
|
31203
|
+
JSON.stringify(row.listData ?? null),
|
|
31121
31204
|
(/* @__PURE__ */ new Date()).toISOString()
|
|
31122
31205
|
);
|
|
31123
31206
|
}
|
|
@@ -31207,12 +31290,12 @@ function upsertDraft(row) {
|
|
|
31207
31290
|
list_data_json=excluded.list_data_json`
|
|
31208
31291
|
).run(
|
|
31209
31292
|
row.id,
|
|
31210
|
-
row.subject,
|
|
31211
|
-
row.body,
|
|
31212
|
-
JSON.stringify(row.recipients),
|
|
31213
|
-
row.replyToId,
|
|
31214
|
-
row.modifiedAt,
|
|
31215
|
-
JSON.stringify(row.listData)
|
|
31293
|
+
requireString("drafts.subject", row.subject),
|
|
31294
|
+
requireString("drafts.body", row.body),
|
|
31295
|
+
JSON.stringify(row.recipients ?? []),
|
|
31296
|
+
nullish3(row.replyToId),
|
|
31297
|
+
requireString("drafts.modifiedAt", row.modifiedAt),
|
|
31298
|
+
JSON.stringify(row.listData ?? null)
|
|
31216
31299
|
);
|
|
31217
31300
|
}
|
|
31218
31301
|
function getDraft(id) {
|
|
@@ -31266,8 +31349,95 @@ function findLatestReplyTip(replyToId) {
|
|
|
31266
31349
|
).get(chainRoot);
|
|
31267
31350
|
return tip ? tip.id : replyToId;
|
|
31268
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
|
+
}
|
|
31269
31419
|
|
|
31270
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
|
+
}
|
|
31271
31441
|
async function resolveFolderIds(client2) {
|
|
31272
31442
|
const data = await client2.request(
|
|
31273
31443
|
"GET",
|
|
@@ -31314,10 +31484,14 @@ async function syncMessageFolder(client2, folder, folderId, opts) {
|
|
|
31314
31484
|
const shouldFetchBody = !isInboxUnread || opts.fetchUnreadBodies;
|
|
31315
31485
|
let body = null;
|
|
31316
31486
|
let fetchedBodyAt = null;
|
|
31487
|
+
let detailFileIds = [];
|
|
31317
31488
|
if (shouldFetchBody) {
|
|
31318
31489
|
const detail = await client2.request("GET", `/pub/v3/messages/${item.id}`);
|
|
31319
31490
|
body = detail.body ?? "";
|
|
31320
31491
|
fetchedBodyAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
31492
|
+
if (Array.isArray(detail.files) && detail.files.length > 0) {
|
|
31493
|
+
detailFileIds = detail.files;
|
|
31494
|
+
}
|
|
31321
31495
|
} else {
|
|
31322
31496
|
unread.push({
|
|
31323
31497
|
id: item.id,
|
|
@@ -31329,9 +31503,9 @@ async function syncMessageFolder(client2, folder, folderId, opts) {
|
|
|
31329
31503
|
const row = {
|
|
31330
31504
|
id: item.id,
|
|
31331
31505
|
folder,
|
|
31332
|
-
subject: item.subject,
|
|
31506
|
+
subject: item.subject ?? "(no subject)",
|
|
31333
31507
|
fromUser: item.from?.name ?? "",
|
|
31334
|
-
sentAt: item.date.
|
|
31508
|
+
sentAt: item.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
31335
31509
|
recipients: recipientsFromList(item),
|
|
31336
31510
|
body,
|
|
31337
31511
|
fetchedBodyAt,
|
|
@@ -31341,6 +31515,9 @@ async function syncMessageFolder(client2, folder, folderId, opts) {
|
|
|
31341
31515
|
};
|
|
31342
31516
|
upsertMessage(row);
|
|
31343
31517
|
synced++;
|
|
31518
|
+
if (detailFileIds.length > 0) {
|
|
31519
|
+
await fetchAttachmentMetaForMessage(client2, item.id, detailFileIds);
|
|
31520
|
+
}
|
|
31344
31521
|
}
|
|
31345
31522
|
if (!opts.deep && !pageHadNewItem) break;
|
|
31346
31523
|
page++;
|
|
@@ -31359,22 +31536,23 @@ async function syncDrafts(client2, draftsFolderId) {
|
|
|
31359
31536
|
let synced = 0;
|
|
31360
31537
|
for (const item of items) {
|
|
31361
31538
|
seenIds.add(item.id);
|
|
31539
|
+
const modifiedAt = item.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
31362
31540
|
const existing = getDraft(item.id);
|
|
31363
|
-
if (existing && existing.modifiedAt ===
|
|
31541
|
+
if (existing && existing.modifiedAt === modifiedAt) {
|
|
31364
31542
|
continue;
|
|
31365
31543
|
}
|
|
31366
31544
|
const detail = await client2.request("GET", `/pub/v3/messages/${item.id}`);
|
|
31367
31545
|
const row = {
|
|
31368
31546
|
id: item.id,
|
|
31369
|
-
subject: detail.subject ?? item.subject,
|
|
31547
|
+
subject: detail.subject ?? item.subject ?? "(no subject)",
|
|
31370
31548
|
body: detail.body ?? "",
|
|
31371
31549
|
recipients: (item.recipients ?? []).map((r) => ({
|
|
31372
|
-
userId: r.user
|
|
31373
|
-
name: r.user
|
|
31550
|
+
userId: r.user?.id ?? 0,
|
|
31551
|
+
name: r.user?.name ?? "",
|
|
31374
31552
|
viewedAt: r.viewed?.dateTime ?? null
|
|
31375
31553
|
})),
|
|
31376
|
-
replyToId: item.replyToId,
|
|
31377
|
-
modifiedAt
|
|
31554
|
+
replyToId: item.replyToId ?? null,
|
|
31555
|
+
modifiedAt,
|
|
31378
31556
|
listData: item
|
|
31379
31557
|
};
|
|
31380
31558
|
upsertDraft(row);
|
|
@@ -31414,6 +31592,8 @@ async function syncAll(client2, opts) {
|
|
|
31414
31592
|
}
|
|
31415
31593
|
|
|
31416
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";
|
|
31417
31597
|
function registerMessageTools(server2, client2) {
|
|
31418
31598
|
server2.registerTool("ofw_list_message_folders", {
|
|
31419
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.",
|
|
@@ -31473,7 +31653,8 @@ function registerMessageTools(server2, client2) {
|
|
|
31473
31653
|
const id = Number(args.messageId);
|
|
31474
31654
|
const cached2 = getMessage(id);
|
|
31475
31655
|
if (cached2 && cached2.body !== null) {
|
|
31476
|
-
|
|
31656
|
+
const attachments2 = listAttachmentsForMessage(id);
|
|
31657
|
+
return { content: [{ type: "text", text: JSON.stringify({ ...cached2, attachments: attachments2 }, null, 2) }] };
|
|
31477
31658
|
}
|
|
31478
31659
|
const detail = await client2.request("GET", `/pub/v3/messages/${encodeURIComponent(args.messageId)}`);
|
|
31479
31660
|
const recipients = (detail.recipients ?? []).map((r) => ({
|
|
@@ -31487,7 +31668,7 @@ function registerMessageTools(server2, client2) {
|
|
|
31487
31668
|
folder,
|
|
31488
31669
|
subject: detail.subject,
|
|
31489
31670
|
fromUser: detail.from?.name ?? "",
|
|
31490
|
-
sentAt: detail.date.
|
|
31671
|
+
sentAt: detail.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
31491
31672
|
recipients,
|
|
31492
31673
|
body: detail.body ?? "",
|
|
31493
31674
|
fetchedBodyAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
@@ -31496,7 +31677,11 @@ function registerMessageTools(server2, client2) {
|
|
|
31496
31677
|
listData: cached2?.listData ?? detail
|
|
31497
31678
|
};
|
|
31498
31679
|
upsertMessage(row);
|
|
31499
|
-
|
|
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) }] };
|
|
31500
31685
|
});
|
|
31501
31686
|
server2.registerTool("ofw_send_message", {
|
|
31502
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).",
|
|
@@ -31673,6 +31858,63 @@ ${text}` : text;
|
|
|
31673
31858
|
}
|
|
31674
31859
|
return { content: [{ type: "text", text: JSON.stringify(unread, null, 2) }] };
|
|
31675
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
|
+
});
|
|
31676
31918
|
server2.registerTool("ofw_sync_messages", {
|
|
31677
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).",
|
|
31678
31920
|
annotations: { readOnlyHint: false },
|
|
@@ -31833,7 +32075,7 @@ process.emit = function(event, ...args) {
|
|
|
31833
32075
|
}
|
|
31834
32076
|
return originalEmit(event, ...args);
|
|
31835
32077
|
};
|
|
31836
|
-
var server = new McpServer({ name: "ofw", version: "2.0.
|
|
32078
|
+
var server = new McpServer({ name: "ofw", version: "2.0.9" });
|
|
31837
32079
|
registerUserTools(server, client);
|
|
31838
32080
|
registerMessageTools(server, client);
|
|
31839
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)
|
|
@@ -79,6 +95,18 @@ function rowFromDb(r) {
|
|
|
79
95
|
listData: JSON.parse(r.list_data_json),
|
|
80
96
|
};
|
|
81
97
|
}
|
|
98
|
+
// node:sqlite rejects `undefined` as a bound parameter ("Provided value cannot
|
|
99
|
+
// be bound"). Normalize undefined to null for nullable columns so callers
|
|
100
|
+
// don't have to remember; throw with a useful error for NOT NULL fields that
|
|
101
|
+
// somehow arrived as undefined.
|
|
102
|
+
function nullish(v) {
|
|
103
|
+
return v === undefined ? null : v;
|
|
104
|
+
}
|
|
105
|
+
function requireString(field, v) {
|
|
106
|
+
if (typeof v === 'string')
|
|
107
|
+
return v;
|
|
108
|
+
throw new Error(`cache: ${field} is required (got ${v === undefined ? 'undefined' : 'null'})`);
|
|
109
|
+
}
|
|
82
110
|
export function upsertMessage(row) {
|
|
83
111
|
const { db } = openCache();
|
|
84
112
|
db.prepare(`INSERT INTO messages (
|
|
@@ -96,7 +124,7 @@ export function upsertMessage(row) {
|
|
|
96
124
|
reply_to_id=excluded.reply_to_id,
|
|
97
125
|
chain_root_id=excluded.chain_root_id,
|
|
98
126
|
list_data_json=excluded.list_data_json,
|
|
99
|
-
last_seen_at=excluded.last_seen_at`).run(row.id, row.folder, row.subject, row.fromUser, row.sentAt, JSON.stringify(row.recipients), row.body, row.fetchedBodyAt, row.replyToId, row.chainRootId, JSON.stringify(row.listData), new Date().toISOString());
|
|
127
|
+
last_seen_at=excluded.last_seen_at`).run(row.id, requireString('messages.folder', row.folder), requireString('messages.subject', row.subject), requireString('messages.fromUser', row.fromUser), requireString('messages.sentAt', row.sentAt), JSON.stringify(row.recipients ?? []), nullish(row.body), nullish(row.fetchedBodyAt), nullish(row.replyToId), nullish(row.chainRootId), JSON.stringify(row.listData ?? null), new Date().toISOString());
|
|
100
128
|
}
|
|
101
129
|
export function getMessage(id) {
|
|
102
130
|
const { db } = openCache();
|
|
@@ -179,7 +207,7 @@ export function upsertDraft(row) {
|
|
|
179
207
|
recipients_json=excluded.recipients_json,
|
|
180
208
|
reply_to_id=excluded.reply_to_id,
|
|
181
209
|
modified_at=excluded.modified_at,
|
|
182
|
-
list_data_json=excluded.list_data_json`).run(row.id, row.subject, row.body, JSON.stringify(row.recipients), row.replyToId, row.modifiedAt, JSON.stringify(row.listData));
|
|
210
|
+
list_data_json=excluded.list_data_json`).run(row.id, requireString('drafts.subject', row.subject), requireString('drafts.body', row.body), JSON.stringify(row.recipients ?? []), nullish(row.replyToId), requireString('drafts.modifiedAt', row.modifiedAt), JSON.stringify(row.listData ?? null));
|
|
183
211
|
}
|
|
184
212
|
export function getDraft(id) {
|
|
185
213
|
const { db } = openCache();
|
|
@@ -237,3 +265,58 @@ export function findLatestReplyTip(replyToId) {
|
|
|
237
265
|
ORDER BY id DESC LIMIT 1`).get(chainRoot);
|
|
238
266
|
return tip ? tip.id : replyToId;
|
|
239
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({
|
|
@@ -62,9 +90,9 @@ export async function syncMessageFolder(client, folder, folderId, opts) {
|
|
|
62
90
|
const row = {
|
|
63
91
|
id: item.id,
|
|
64
92
|
folder,
|
|
65
|
-
subject: item.subject,
|
|
93
|
+
subject: item.subject ?? '(no subject)',
|
|
66
94
|
fromUser: item.from?.name ?? '',
|
|
67
|
-
sentAt: item.date.
|
|
95
|
+
sentAt: item.date?.dateTime ?? new Date().toISOString(),
|
|
68
96
|
recipients: recipientsFromList(item),
|
|
69
97
|
body,
|
|
70
98
|
fetchedBodyAt,
|
|
@@ -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
|
|
@@ -98,20 +129,23 @@ export async function syncDrafts(client, draftsFolderId) {
|
|
|
98
129
|
let synced = 0;
|
|
99
130
|
for (const item of items) {
|
|
100
131
|
seenIds.add(item.id);
|
|
132
|
+
const modifiedAt = item.date?.dateTime ?? new Date().toISOString();
|
|
101
133
|
const existing = getDraft(item.id);
|
|
102
|
-
if (existing && existing.modifiedAt ===
|
|
134
|
+
if (existing && existing.modifiedAt === modifiedAt) {
|
|
103
135
|
continue;
|
|
104
136
|
}
|
|
105
137
|
const detail = await client.request('GET', `/pub/v3/messages/${item.id}`);
|
|
106
138
|
const row = {
|
|
107
139
|
id: item.id,
|
|
108
|
-
subject: detail.subject ?? item.subject,
|
|
140
|
+
subject: detail.subject ?? item.subject ?? '(no subject)',
|
|
109
141
|
body: detail.body ?? '',
|
|
110
142
|
recipients: (item.recipients ?? []).map((r) => ({
|
|
111
|
-
userId: r.user
|
|
143
|
+
userId: r.user?.id ?? 0,
|
|
144
|
+
name: r.user?.name ?? '',
|
|
145
|
+
viewedAt: r.viewed?.dateTime ?? null,
|
|
112
146
|
})),
|
|
113
|
-
replyToId: item.replyToId,
|
|
114
|
-
modifiedAt
|
|
147
|
+
replyToId: item.replyToId ?? null,
|
|
148
|
+
modifiedAt,
|
|
115
149
|
listData: item,
|
|
116
150
|
};
|
|
117
151
|
upsertDraft(row);
|
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
|
},
|