ofw-mcp 2.0.16 → 2.0.17
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/README.md +20 -13
- package/dist/auth-password.js +2 -9
- package/dist/auth.js +2 -4
- package/dist/bundle.js +109 -83
- package/dist/cache.js +10 -0
- package/dist/client.js +5 -9
- package/dist/config.js +13 -5
- package/dist/index.js +1 -1
- package/dist/protocol.js +17 -0
- package/dist/sync.js +12 -10
- package/dist/tools/_shared.js +27 -0
- package/dist/tools/calendar.js +1 -1
- package/dist/tools/messages.js +55 -37
- package/package.json +1 -1
- package/server.json +6 -6
- package/skills/ofw/SKILL.md +11 -7
|
@@ -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.17"
|
|
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.17",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "Chris Chall"
|
|
20
20
|
},
|
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ Ask Claude things like:
|
|
|
18
18
|
## Requirements
|
|
19
19
|
|
|
20
20
|
- [Claude Desktop](https://claude.ai/download)
|
|
21
|
-
- [Node.js](https://nodejs.org)
|
|
21
|
+
- [Node.js](https://nodejs.org) 22.5 or later (`node:sqlite` is the cache backend)
|
|
22
22
|
- An active OurFamilyWizard account
|
|
23
23
|
|
|
24
24
|
## Installation
|
|
@@ -147,25 +147,32 @@ Read-only tools run automatically. Write tools ask for your confirmation first.
|
|
|
147
147
|
## Development
|
|
148
148
|
|
|
149
149
|
```bash
|
|
150
|
-
npm test
|
|
151
|
-
npm run build
|
|
150
|
+
npm test # run the vitest suite
|
|
151
|
+
npm run build # tsc → dist/, then esbuild bundle → dist/bundle.js
|
|
152
|
+
npm run dev # node --env-file=.env dist/index.js (requires built dist)
|
|
152
153
|
```
|
|
153
154
|
|
|
155
|
+
Main is protected. All changes land via PR — open with `gh pr create --label <release-notes-label>` and add `ready-to-merge` once you're satisfied with the auto-review feedback. See `CLAUDE.md` for the full PR + release flow.
|
|
156
|
+
|
|
154
157
|
### Project structure
|
|
155
158
|
|
|
156
159
|
```
|
|
157
160
|
src/
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
journal.ts list, create entries
|
|
166
|
-
tests/
|
|
167
|
-
client.test.ts
|
|
161
|
+
index.ts MCP server entry (McpServer + StdioServerTransport)
|
|
162
|
+
client.ts OFW HTTP client with Bearer token + 401/429 retry
|
|
163
|
+
auth.ts resolveAuth(): env-var creds → fetchproxy → error
|
|
164
|
+
auth-password.ts Spring Security form login (legacy env-var path)
|
|
165
|
+
cache.ts SQLite cache (messages, drafts, attachments, sync state)
|
|
166
|
+
sync.ts Folder ID resolution + per-folder sync logic
|
|
167
|
+
config.ts Cache dir, attachment dir, env parsing
|
|
168
168
|
tools/
|
|
169
|
+
_shared.ts Recipient mapping, response helpers, path expansion
|
|
170
|
+
user.ts ofw_get_profile, ofw_get_notifications
|
|
171
|
+
messages.ts Folders, list, get, send, drafts, sync, attachments
|
|
172
|
+
calendar.ts List, create, update, delete events
|
|
173
|
+
expenses.ts Totals, list, create
|
|
174
|
+
journal.ts List, create entries
|
|
175
|
+
tests/ Mirrors src/; mocks OFWClient.request via vi.spyOn
|
|
169
176
|
```
|
|
170
177
|
|
|
171
178
|
### Auth flow
|
package/dist/auth-password.js
CHANGED
|
@@ -9,11 +9,7 @@
|
|
|
9
9
|
// This file exists as a standalone helper (not a method on `OFWClient`) so
|
|
10
10
|
// `resolveAuth()` in `./auth.ts` can call it without a Client instance, and
|
|
11
11
|
// so tests can mock it at the module boundary.
|
|
12
|
-
|
|
13
|
-
const OFW_PROTOCOL_HEADERS = {
|
|
14
|
-
'ofw-client': 'WebApplication',
|
|
15
|
-
'ofw-version': '1.0.0',
|
|
16
|
-
};
|
|
12
|
+
import { BASE_URL, OFW_PROTOCOL_HEADERS, OFW_TOKEN_TTL_MS } from './protocol.js';
|
|
17
13
|
export async function loginWithPassword(username, password) {
|
|
18
14
|
// Step 1: get a SESSION cookie (Spring Security refuses the POST without it).
|
|
19
15
|
const initResponse = await fetch(`${BASE_URL}/ofw/login.form`, {
|
|
@@ -49,9 +45,6 @@ export async function loginWithPassword(username, password) {
|
|
|
49
45
|
const data = (await response.json());
|
|
50
46
|
return {
|
|
51
47
|
token: data.auth,
|
|
52
|
-
|
|
53
|
-
// the historical behavior of this client (a single 401 → re-auth + replay
|
|
54
|
-
// covers the edge case where this estimate is wrong).
|
|
55
|
-
expiresAt: new Date(Date.now() + 6 * 60 * 60 * 1000),
|
|
48
|
+
expiresAt: new Date(Date.now() + OFW_TOKEN_TTL_MS),
|
|
56
49
|
};
|
|
57
50
|
}
|
package/dist/auth.js
CHANGED
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
// selection logic independent of either implementation.
|
|
48
48
|
import { bootstrap } from '@fetchproxy/bootstrap';
|
|
49
49
|
import { loginWithPassword } from './auth-password.js';
|
|
50
|
+
import { parseBoolEnv } from './config.js';
|
|
50
51
|
import pkg from '../package.json' with { type: 'json' };
|
|
51
52
|
/**
|
|
52
53
|
* Read an env var, trim, and treat blank / `${UNEXPANDED}` placeholders as
|
|
@@ -68,10 +69,7 @@ function readEnv(key) {
|
|
|
68
69
|
}
|
|
69
70
|
/** True if the user has explicitly disabled the fetchproxy fallback. */
|
|
70
71
|
function fetchproxyDisabled() {
|
|
71
|
-
|
|
72
|
-
if (raw === undefined)
|
|
73
|
-
return false;
|
|
74
|
-
return ['1', 'true', 'yes', 'on'].includes(raw.toLowerCase());
|
|
72
|
+
return parseBoolEnv('OFW_DISABLE_FETCHPROXY');
|
|
75
73
|
}
|
|
76
74
|
/**
|
|
77
75
|
* Resolve OFW auth using the three-path priority described at the top of
|
package/dist/bundle.js
CHANGED
|
@@ -34490,7 +34490,7 @@ var StdioServerTransport = class {
|
|
|
34490
34490
|
};
|
|
34491
34491
|
|
|
34492
34492
|
// src/client.ts
|
|
34493
|
-
import { dirname, join as
|
|
34493
|
+
import { dirname, join as join3 } from "path";
|
|
34494
34494
|
import { fileURLToPath } from "url";
|
|
34495
34495
|
|
|
34496
34496
|
// node_modules/@fetchproxy/protocol/dist/frames.js
|
|
@@ -36567,12 +36567,16 @@ var BootstrapDisabledError = class extends Error {
|
|
|
36567
36567
|
}
|
|
36568
36568
|
};
|
|
36569
36569
|
|
|
36570
|
-
// src/
|
|
36570
|
+
// src/protocol.ts
|
|
36571
36571
|
var BASE_URL = "https://ofw.ourfamilywizard.com";
|
|
36572
36572
|
var OFW_PROTOCOL_HEADERS = {
|
|
36573
36573
|
"ofw-client": "WebApplication",
|
|
36574
36574
|
"ofw-version": "1.0.0"
|
|
36575
36575
|
};
|
|
36576
|
+
var OFW_TOKEN_TTL_MS = 6 * 60 * 60 * 1e3;
|
|
36577
|
+
var OFW_TOKEN_EXPIRY_SKEW_MS = 5 * 60 * 1e3;
|
|
36578
|
+
|
|
36579
|
+
// src/auth-password.ts
|
|
36576
36580
|
async function loginWithPassword(username, password) {
|
|
36577
36581
|
const initResponse = await fetch(`${BASE_URL}/ofw/login.form`, {
|
|
36578
36582
|
headers: { ...OFW_PROTOCOL_HEADERS },
|
|
@@ -36606,17 +36610,49 @@ async function loginWithPassword(username, password) {
|
|
|
36606
36610
|
const data = await response.json();
|
|
36607
36611
|
return {
|
|
36608
36612
|
token: data.auth,
|
|
36609
|
-
|
|
36610
|
-
// the historical behavior of this client (a single 401 → re-auth + replay
|
|
36611
|
-
// covers the edge case where this estimate is wrong).
|
|
36612
|
-
expiresAt: new Date(Date.now() + 6 * 60 * 60 * 1e3)
|
|
36613
|
+
expiresAt: new Date(Date.now() + OFW_TOKEN_TTL_MS)
|
|
36613
36614
|
};
|
|
36614
36615
|
}
|
|
36615
36616
|
|
|
36617
|
+
// src/config.ts
|
|
36618
|
+
import { createHash } from "node:crypto";
|
|
36619
|
+
import { homedir as homedir2 } from "node:os";
|
|
36620
|
+
import { join as join2 } from "node:path";
|
|
36621
|
+
function readCacheIdentity() {
|
|
36622
|
+
const explicit = process.env.OFW_CACHE_IDENTITY;
|
|
36623
|
+
if (typeof explicit === "string" && explicit.trim().length > 0) return explicit.trim();
|
|
36624
|
+
const username = process.env.OFW_USERNAME;
|
|
36625
|
+
if (typeof username === "string" && username.trim().length > 0) return username.trim();
|
|
36626
|
+
return "_default";
|
|
36627
|
+
}
|
|
36628
|
+
function getCacheDir() {
|
|
36629
|
+
const override = process.env.OFW_CACHE_DIR;
|
|
36630
|
+
if (override && override.trim().length > 0) return override.trim();
|
|
36631
|
+
return join2(homedir2(), ".cache", "ofw-mcp");
|
|
36632
|
+
}
|
|
36633
|
+
function getCacheDbPath() {
|
|
36634
|
+
const identity = readCacheIdentity();
|
|
36635
|
+
const hash2 = createHash("sha256").update(identity).digest("hex").slice(0, 16);
|
|
36636
|
+
return join2(getCacheDir(), `${hash2}.db`);
|
|
36637
|
+
}
|
|
36638
|
+
function getAttachmentsDir() {
|
|
36639
|
+
const override = process.env.OFW_ATTACHMENTS_DIR;
|
|
36640
|
+
if (override && override.trim().length > 0) return override.trim();
|
|
36641
|
+
return join2(homedir2(), "Downloads", "ofw-mcp");
|
|
36642
|
+
}
|
|
36643
|
+
function parseBoolEnv(name) {
|
|
36644
|
+
const raw = process.env[name];
|
|
36645
|
+
if (typeof raw !== "string") return false;
|
|
36646
|
+
return ["1", "true", "yes", "on"].includes(raw.trim().toLowerCase());
|
|
36647
|
+
}
|
|
36648
|
+
function getDefaultInlineAttachments() {
|
|
36649
|
+
return parseBoolEnv("OFW_INLINE_ATTACHMENTS");
|
|
36650
|
+
}
|
|
36651
|
+
|
|
36616
36652
|
// package.json
|
|
36617
36653
|
var package_default = {
|
|
36618
36654
|
name: "ofw-mcp",
|
|
36619
|
-
version: "2.0.
|
|
36655
|
+
version: "2.0.17",
|
|
36620
36656
|
mcpName: "io.github.chrischall/ofw-mcp",
|
|
36621
36657
|
description: "OurFamilyWizard MCP server for Claude \u2014 developed and maintained by AI (Claude Code)",
|
|
36622
36658
|
author: "Claude Code (AI) <https://www.anthropic.com/claude>",
|
|
@@ -36668,9 +36704,7 @@ function readEnv(key) {
|
|
|
36668
36704
|
return trimmed;
|
|
36669
36705
|
}
|
|
36670
36706
|
function fetchproxyDisabled() {
|
|
36671
|
-
|
|
36672
|
-
if (raw === void 0) return false;
|
|
36673
|
-
return ["1", "true", "yes", "on"].includes(raw.toLowerCase());
|
|
36707
|
+
return parseBoolEnv("OFW_DISABLE_FETCHPROXY");
|
|
36674
36708
|
}
|
|
36675
36709
|
async function resolveAuth() {
|
|
36676
36710
|
const username = readEnv("OFW_USERNAME");
|
|
@@ -36727,14 +36761,9 @@ async function resolveAuth() {
|
|
|
36727
36761
|
try {
|
|
36728
36762
|
const { config: config2 } = await import("dotenv");
|
|
36729
36763
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
36730
|
-
config2({ path:
|
|
36764
|
+
config2({ path: join3(__dirname, "..", ".env"), override: false, quiet: true });
|
|
36731
36765
|
} catch {
|
|
36732
36766
|
}
|
|
36733
|
-
var BASE_URL2 = "https://ofw.ourfamilywizard.com";
|
|
36734
|
-
var OFW_PROTOCOL_HEADERS2 = {
|
|
36735
|
-
"ofw-client": "WebApplication",
|
|
36736
|
-
"ofw-version": "1.0.0"
|
|
36737
|
-
};
|
|
36738
36767
|
function parseContentDispositionFilename(cd) {
|
|
36739
36768
|
const extMatch = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(cd);
|
|
36740
36769
|
if (extMatch) {
|
|
@@ -36749,8 +36778,7 @@ function parseContentDispositionFilename(cd) {
|
|
|
36749
36778
|
return m ? m[1] : null;
|
|
36750
36779
|
}
|
|
36751
36780
|
function debugLogEnabled() {
|
|
36752
|
-
|
|
36753
|
-
return v === "1" || v === "true" || v === "yes" || v === "on";
|
|
36781
|
+
return parseBoolEnv("OFW_DEBUG_LOG");
|
|
36754
36782
|
}
|
|
36755
36783
|
function redactHeaders(h) {
|
|
36756
36784
|
const out = { ...h };
|
|
@@ -36785,12 +36813,12 @@ var OFWClient = class {
|
|
|
36785
36813
|
async fetchWithRetry(method, path, body, accept, isRetry) {
|
|
36786
36814
|
const isFormData = body instanceof FormData;
|
|
36787
36815
|
const headers = {
|
|
36788
|
-
...
|
|
36816
|
+
...OFW_PROTOCOL_HEADERS,
|
|
36789
36817
|
Accept: accept,
|
|
36790
36818
|
Authorization: `Bearer ${this.token}`
|
|
36791
36819
|
};
|
|
36792
36820
|
if (body !== void 0 && !isFormData) headers["Content-Type"] = "application/json";
|
|
36793
|
-
const url2 = `${
|
|
36821
|
+
const url2 = `${BASE_URL}${path}`;
|
|
36794
36822
|
if (debugLogEnabled()) {
|
|
36795
36823
|
const bodyPreview = body === void 0 ? "<none>" : isFormData ? `<FormData entries=${Array.from(body.keys()).join(",")}>` : JSON.stringify(body);
|
|
36796
36824
|
console.error(`[ofw-debug] \u2192 ${method} ${url2}${isRetry ? " (retry)" : ""}`);
|
|
@@ -36838,17 +36866,17 @@ var OFWClient = class {
|
|
|
36838
36866
|
async login() {
|
|
36839
36867
|
const { token, expiresAt } = await resolveAuth();
|
|
36840
36868
|
this.token = token;
|
|
36841
|
-
this.tokenExpiry = expiresAt ?? new Date(Date.now() +
|
|
36869
|
+
this.tokenExpiry = expiresAt ?? new Date(Date.now() + OFW_TOKEN_TTL_MS);
|
|
36842
36870
|
}
|
|
36843
36871
|
isTokenExpiredSoon() {
|
|
36844
36872
|
if (!this.token || !this.tokenExpiry) return true;
|
|
36845
|
-
return this.tokenExpiry.getTime() - Date.now() <
|
|
36873
|
+
return this.tokenExpiry.getTime() - Date.now() < OFW_TOKEN_EXPIRY_SKEW_MS;
|
|
36846
36874
|
}
|
|
36847
36875
|
};
|
|
36848
36876
|
var client = new OFWClient();
|
|
36849
36877
|
|
|
36850
36878
|
// src/tools/_shared.ts
|
|
36851
|
-
import { isAbsolute, join as
|
|
36879
|
+
import { isAbsolute, join as join4, resolve } from "node:path";
|
|
36852
36880
|
function jsonResponse(payload) {
|
|
36853
36881
|
return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
|
|
36854
36882
|
}
|
|
@@ -36863,9 +36891,20 @@ function mapRecipients(items) {
|
|
|
36863
36891
|
}));
|
|
36864
36892
|
}
|
|
36865
36893
|
function expandPath(p) {
|
|
36866
|
-
const expanded = p.startsWith("~/") ?
|
|
36894
|
+
const expanded = p.startsWith("~/") ? join4(process.env.HOME ?? "", p.slice(2)) : p;
|
|
36867
36895
|
return isAbsolute(expanded) ? expanded : resolve(expanded);
|
|
36868
36896
|
}
|
|
36897
|
+
async function postMessageAndRefetch(client2, payload) {
|
|
36898
|
+
const raw = await client2.request(
|
|
36899
|
+
"POST",
|
|
36900
|
+
"/pub/v3/messages",
|
|
36901
|
+
payload
|
|
36902
|
+
);
|
|
36903
|
+
const id = typeof raw?.id === "number" ? raw.id : typeof raw?.entityId === "number" ? raw.entityId : null;
|
|
36904
|
+
if (id === null) return { id: null, detail: null, raw };
|
|
36905
|
+
const detail = await client2.request("GET", `/pub/v3/messages/${id}`);
|
|
36906
|
+
return { id, detail, raw };
|
|
36907
|
+
}
|
|
36869
36908
|
|
|
36870
36909
|
// src/tools/user.ts
|
|
36871
36910
|
function registerUserTools(server2, client2) {
|
|
@@ -36889,40 +36928,6 @@ function registerUserTools(server2, client2) {
|
|
|
36889
36928
|
import { DatabaseSync } from "node:sqlite";
|
|
36890
36929
|
import { mkdirSync } from "node:fs";
|
|
36891
36930
|
import { dirname as dirname2 } from "node:path";
|
|
36892
|
-
|
|
36893
|
-
// src/config.ts
|
|
36894
|
-
import { createHash } from "node:crypto";
|
|
36895
|
-
import { homedir as homedir2 } from "node:os";
|
|
36896
|
-
import { join as join4 } from "node:path";
|
|
36897
|
-
function readCacheIdentity() {
|
|
36898
|
-
const explicit = process.env.OFW_CACHE_IDENTITY;
|
|
36899
|
-
if (typeof explicit === "string" && explicit.trim().length > 0) return explicit.trim();
|
|
36900
|
-
const username = process.env.OFW_USERNAME;
|
|
36901
|
-
if (typeof username === "string" && username.trim().length > 0) return username.trim();
|
|
36902
|
-
return "_default";
|
|
36903
|
-
}
|
|
36904
|
-
function getCacheDir() {
|
|
36905
|
-
const override = process.env.OFW_CACHE_DIR;
|
|
36906
|
-
if (override && override.trim().length > 0) return override.trim();
|
|
36907
|
-
return join4(homedir2(), ".cache", "ofw-mcp");
|
|
36908
|
-
}
|
|
36909
|
-
function getCacheDbPath() {
|
|
36910
|
-
const identity = readCacheIdentity();
|
|
36911
|
-
const hash2 = createHash("sha256").update(identity).digest("hex").slice(0, 16);
|
|
36912
|
-
return join4(getCacheDir(), `${hash2}.db`);
|
|
36913
|
-
}
|
|
36914
|
-
function getAttachmentsDir() {
|
|
36915
|
-
const override = process.env.OFW_ATTACHMENTS_DIR;
|
|
36916
|
-
if (override && override.trim().length > 0) return override.trim();
|
|
36917
|
-
return join4(homedir2(), "Downloads", "ofw-mcp");
|
|
36918
|
-
}
|
|
36919
|
-
function getDefaultInlineAttachments() {
|
|
36920
|
-
const raw = process.env.OFW_INLINE_ATTACHMENTS;
|
|
36921
|
-
if (typeof raw !== "string") return false;
|
|
36922
|
-
return ["1", "true", "yes", "on"].includes(raw.trim().toLowerCase());
|
|
36923
|
-
}
|
|
36924
|
-
|
|
36925
|
-
// src/cache.ts
|
|
36926
36931
|
var instance = null;
|
|
36927
36932
|
var SCHEMA_V1 = `
|
|
36928
36933
|
CREATE TABLE IF NOT EXISTS messages (
|
|
@@ -37056,6 +37061,10 @@ function getMessage(id) {
|
|
|
37056
37061
|
const r = db.prepare("SELECT * FROM messages WHERE id = ?").get(id);
|
|
37057
37062
|
return r ? rowFromDb(r) : null;
|
|
37058
37063
|
}
|
|
37064
|
+
function deleteMessage(id) {
|
|
37065
|
+
const { db } = openCache();
|
|
37066
|
+
db.prepare("DELETE FROM messages WHERE id = ?").run(id);
|
|
37067
|
+
}
|
|
37059
37068
|
function buildMessageFilter(opts) {
|
|
37060
37069
|
const wheres = [];
|
|
37061
37070
|
const params = [];
|
|
@@ -37266,12 +37275,7 @@ async function fetchAttachmentMeta(client2, fileId, messageId) {
|
|
|
37266
37275
|
});
|
|
37267
37276
|
}
|
|
37268
37277
|
async function fetchAttachmentMetaForMessage(client2, messageId, fileIds) {
|
|
37269
|
-
|
|
37270
|
-
try {
|
|
37271
|
-
await fetchAttachmentMeta(client2, fid, messageId);
|
|
37272
|
-
} catch {
|
|
37273
|
-
}
|
|
37274
|
-
}
|
|
37278
|
+
await Promise.allSettled(fileIds.map((fid) => fetchAttachmentMeta(client2, fid, messageId)));
|
|
37275
37279
|
}
|
|
37276
37280
|
async function resolveFolderIds(client2) {
|
|
37277
37281
|
const data = await client2.request(
|
|
@@ -37377,6 +37381,7 @@ async function syncDrafts(client2, draftsFolderId) {
|
|
|
37377
37381
|
listData: item
|
|
37378
37382
|
};
|
|
37379
37383
|
upsertDraft(row);
|
|
37384
|
+
if (getMessage(item.id)) deleteMessage(item.id);
|
|
37380
37385
|
if (!existing || existing.body !== row.body || existing.subject !== row.subject || existing.replyToId !== row.replyToId) {
|
|
37381
37386
|
synced++;
|
|
37382
37387
|
}
|
|
@@ -37496,13 +37501,33 @@ function registerMessageTools(server2, client2) {
|
|
|
37496
37501
|
return jsonResponse(payload);
|
|
37497
37502
|
});
|
|
37498
37503
|
server2.registerTool("ofw_get_message", {
|
|
37499
|
-
description:
|
|
37504
|
+
description: 'Get a single OurFamilyWizard message OR draft by ID. Reads from local cache when available; otherwise fetches from OFW (which will mark unread inbox messages as read on OFW). For ids that match a draft (in the drafts cache), the response carries folder="drafts" and the body/subject/recipients reflect the drafts cache (which ofw_sync_messages keeps fresh) \u2014 drafts have no `fromUser`, and `sentAt`/`fetchedBodyAt` mirror the draft\'s `modifiedAt`. For inbox/sent messages, folder is "inbox" or "sent" as before.',
|
|
37500
37505
|
annotations: { readOnlyHint: false },
|
|
37501
37506
|
inputSchema: {
|
|
37502
|
-
messageId: external_exports.string().describe("Message ID")
|
|
37507
|
+
messageId: external_exports.string().describe("Message ID (also accepts draft IDs \u2014 drafts are routed via the drafts cache)")
|
|
37503
37508
|
}
|
|
37504
37509
|
}, async (args) => {
|
|
37505
37510
|
const id = Number(args.messageId);
|
|
37511
|
+
const draftRow = getDraft(id);
|
|
37512
|
+
if (draftRow !== null) {
|
|
37513
|
+
return jsonResponse({
|
|
37514
|
+
id: draftRow.id,
|
|
37515
|
+
folder: "drafts",
|
|
37516
|
+
subject: draftRow.subject,
|
|
37517
|
+
fromUser: "",
|
|
37518
|
+
sentAt: draftRow.modifiedAt,
|
|
37519
|
+
recipients: draftRow.recipients,
|
|
37520
|
+
body: draftRow.body,
|
|
37521
|
+
// Best approximation: drafts don't separately track when the body
|
|
37522
|
+
// was last *fetched* — we last wrote it on the last sync, which
|
|
37523
|
+
// also updates modifiedAt.
|
|
37524
|
+
fetchedBodyAt: draftRow.modifiedAt,
|
|
37525
|
+
replyToId: draftRow.replyToId,
|
|
37526
|
+
chainRootId: null,
|
|
37527
|
+
listData: draftRow.listData,
|
|
37528
|
+
attachments: []
|
|
37529
|
+
});
|
|
37530
|
+
}
|
|
37506
37531
|
const cached2 = getMessage(id);
|
|
37507
37532
|
if (cached2 && cached2.body !== null) {
|
|
37508
37533
|
let attachments2 = listAttachmentsForMessage(id);
|
|
@@ -37565,7 +37590,7 @@ function registerMessageTools(server2, client2) {
|
|
|
37565
37590
|
chainRootId = parent?.chainRootId ?? parent?.id ?? requestedReplyTo;
|
|
37566
37591
|
}
|
|
37567
37592
|
const myFileIDs = args.myFileIDs ?? [];
|
|
37568
|
-
const
|
|
37593
|
+
const { id: newId, detail, raw } = await postMessageAndRefetch(client2, {
|
|
37569
37594
|
subject: args.subject,
|
|
37570
37595
|
body: args.body,
|
|
37571
37596
|
recipientIds: args.recipientIds,
|
|
@@ -37574,10 +37599,8 @@ function registerMessageTools(server2, client2) {
|
|
|
37574
37599
|
includeOriginal: resolvedReplyTo !== null,
|
|
37575
37600
|
replyToId: resolvedReplyTo
|
|
37576
37601
|
});
|
|
37577
|
-
const newId = typeof data?.id === "number" ? data.id : typeof data?.entityId === "number" ? data.entityId : null;
|
|
37578
37602
|
let persisted = null;
|
|
37579
37603
|
if (newId !== null) {
|
|
37580
|
-
const detail = await client2.request("GET", `/pub/v3/messages/${newId}`);
|
|
37581
37604
|
persisted = {
|
|
37582
37605
|
id: newId,
|
|
37583
37606
|
folder: "sent",
|
|
@@ -37609,7 +37632,7 @@ function registerMessageTools(server2, client2) {
|
|
|
37609
37632
|
await deleteOFWMessages(client2, [args.draftId]);
|
|
37610
37633
|
deleteDraft(args.draftId);
|
|
37611
37634
|
}
|
|
37612
|
-
const responseObj = persisted ??
|
|
37635
|
+
const responseObj = persisted ?? raw;
|
|
37613
37636
|
const text = responseObj ? JSON.stringify(responseObj, null, 2) : "Message sent successfully.";
|
|
37614
37637
|
return textResponse(rewriteNote ? `${rewriteNote}
|
|
37615
37638
|
|
|
@@ -37630,13 +37653,13 @@ ${text}` : text);
|
|
|
37630
37653
|
return jsonResponse(payload);
|
|
37631
37654
|
});
|
|
37632
37655
|
server2.registerTool("ofw_save_draft", {
|
|
37633
|
-
description: "Save a message as a draft in OurFamilyWizard. Recipients are optional.
|
|
37656
|
+
description: "Save a message as a draft in OurFamilyWizard. Recipients are optional. Pass messageId to replace an existing draft \u2014 note that under the hood this creates a NEW draft and deletes the old one (OFW's update-in-place endpoint silently no-ops while echoing the posted body, so we don't use it); the response.id will be the NEW id, not the messageId you passed, and the change is documented in a transparency NOTE in the response. If replyToId is provided, the cache may rewrite it to the latest reply in the thread (note included in response). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs. After saving, the tool re-fetches the draft from OFW to populate the local cache from authoritative server state.",
|
|
37634
37657
|
annotations: { readOnlyHint: false },
|
|
37635
37658
|
inputSchema: {
|
|
37636
37659
|
subject: external_exports.string().describe("Message subject"),
|
|
37637
37660
|
body: external_exports.string().describe("Message body text"),
|
|
37638
37661
|
recipientIds: external_exports.array(external_exports.number()).describe("Array of recipient user IDs (optional for drafts)").optional(),
|
|
37639
|
-
messageId: external_exports.number().describe("ID of an existing draft to
|
|
37662
|
+
messageId: external_exports.number().describe("ID of an existing draft to replace (the new draft will have a new id; the old is deleted)").optional(),
|
|
37640
37663
|
replyToId: external_exports.number().describe("ID of the message this draft replies to").optional(),
|
|
37641
37664
|
myFileIDs: external_exports.array(external_exports.number()).describe("Attachment file ids (from ofw_upload_attachment)").optional()
|
|
37642
37665
|
}
|
|
@@ -37660,13 +37683,10 @@ ${text}` : text);
|
|
|
37660
37683
|
includeOriginal: resolvedReplyTo !== null,
|
|
37661
37684
|
replyToId: resolvedReplyTo
|
|
37662
37685
|
};
|
|
37663
|
-
|
|
37664
|
-
const data = await client2.request("POST", "/pub/v3/messages", payload);
|
|
37665
|
-
const newId = typeof data?.id === "number" ? data.id : typeof data?.entityId === "number" ? data.entityId : null;
|
|
37686
|
+
const { id: newId, detail, raw } = await postMessageAndRefetch(client2, payload);
|
|
37666
37687
|
let persisted = null;
|
|
37667
|
-
let
|
|
37688
|
+
let replaceNote = null;
|
|
37668
37689
|
if (newId !== null) {
|
|
37669
|
-
const detail = await client2.request("GET", `/pub/v3/messages/${newId}`);
|
|
37670
37690
|
persisted = {
|
|
37671
37691
|
id: newId,
|
|
37672
37692
|
subject: detail.subject ?? args.subject,
|
|
@@ -37677,13 +37697,19 @@ ${text}` : text);
|
|
|
37677
37697
|
listData: detail
|
|
37678
37698
|
};
|
|
37679
37699
|
upsertDraft(persisted);
|
|
37680
|
-
if (args.messageId !== void 0 &&
|
|
37681
|
-
|
|
37700
|
+
if (args.messageId !== void 0 && args.messageId !== newId) {
|
|
37701
|
+
try {
|
|
37702
|
+
await deleteOFWMessages(client2, [args.messageId]);
|
|
37703
|
+
deleteDraft(args.messageId);
|
|
37704
|
+
replaceNote = `NOTE: ofw_save_draft replaced draft ${args.messageId} via create-then-delete. The new draft id is ${newId}; the old draft has been deleted. (OFW's update-in-place endpoint silently no-ops on subsequent updates, so we never use it. If you cached the old id anywhere, replace it with the new one.)`;
|
|
37705
|
+
} catch (e) {
|
|
37706
|
+
replaceNote = `WARNING: New draft ${newId} created successfully, but failed to delete the old draft (${args.messageId}): ${e.message}. You may want to clean it up manually with ofw_delete_draft.`;
|
|
37707
|
+
}
|
|
37682
37708
|
}
|
|
37683
37709
|
}
|
|
37684
|
-
const responseObj = persisted ??
|
|
37710
|
+
const responseObj = persisted ?? raw;
|
|
37685
37711
|
const text = responseObj ? JSON.stringify(responseObj, null, 2) : "Draft saved.";
|
|
37686
|
-
const notes = [rewriteNote,
|
|
37712
|
+
const notes = [rewriteNote, replaceNote].filter((n) => n !== null).join("\n\n");
|
|
37687
37713
|
return textResponse(notes ? `${notes}
|
|
37688
37714
|
|
|
37689
37715
|
${text}` : text);
|
|
@@ -37911,7 +37937,7 @@ function registerCalendarTools(server2, client2) {
|
|
|
37911
37937
|
});
|
|
37912
37938
|
server2.registerTool("ofw_update_event", {
|
|
37913
37939
|
description: "Update an existing OurFamilyWizard calendar event",
|
|
37914
|
-
annotations: { destructiveHint:
|
|
37940
|
+
annotations: { destructiveHint: true },
|
|
37915
37941
|
inputSchema: {
|
|
37916
37942
|
eventId: external_exports.string(),
|
|
37917
37943
|
title: external_exports.string().optional(),
|
|
@@ -38013,7 +38039,7 @@ process.emit = function(event, ...args) {
|
|
|
38013
38039
|
}
|
|
38014
38040
|
return originalEmit(event, ...args);
|
|
38015
38041
|
};
|
|
38016
|
-
var server = new McpServer({ name: "ofw", version: "2.0.
|
|
38042
|
+
var server = new McpServer({ name: "ofw", version: "2.0.17" });
|
|
38017
38043
|
registerUserTools(server, client);
|
|
38018
38044
|
registerMessageTools(server, client);
|
|
38019
38045
|
registerCalendarTools(server, client);
|
package/dist/cache.js
CHANGED
|
@@ -131,6 +131,16 @@ export function getMessage(id) {
|
|
|
131
131
|
const r = db.prepare('SELECT * FROM messages WHERE id = ?').get(id);
|
|
132
132
|
return r ? rowFromDb(r) : null;
|
|
133
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* Remove a row from the `messages` table. Used by syncDrafts to evict
|
|
136
|
+
* stale rows that were cached when a draft was previously read through
|
|
137
|
+
* `ofw_get_message` (which would have wrongly classified it as `inbox`)
|
|
138
|
+
* — the drafts table is the authoritative source for that id now.
|
|
139
|
+
*/
|
|
140
|
+
export function deleteMessage(id) {
|
|
141
|
+
const { db } = openCache();
|
|
142
|
+
db.prepare('DELETE FROM messages WHERE id = ?').run(id);
|
|
143
|
+
}
|
|
134
144
|
// Build the WHERE clause + bound params for message queries. listMessages and
|
|
135
145
|
// countMessages share this so the filter semantics can't drift.
|
|
136
146
|
function buildMessageFilter(opts) {
|
package/dist/client.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { dirname, join } from 'path';
|
|
2
2
|
import { fileURLToPath } from 'url';
|
|
3
3
|
import { resolveAuth } from './auth.js';
|
|
4
|
+
import { parseBoolEnv } from './config.js';
|
|
5
|
+
import { BASE_URL, OFW_PROTOCOL_HEADERS, OFW_TOKEN_TTL_MS, OFW_TOKEN_EXPIRY_SKEW_MS } from './protocol.js';
|
|
4
6
|
// Load .env for local dev; silently skip if dotenv is unavailable (e.g. mcpb bundle)
|
|
5
7
|
try {
|
|
6
8
|
const { config } = await import('dotenv');
|
|
@@ -10,11 +12,6 @@ try {
|
|
|
10
12
|
catch {
|
|
11
13
|
// not available — rely on process.env (mcpb sets credentials via mcp_config.env)
|
|
12
14
|
}
|
|
13
|
-
const BASE_URL = 'https://ofw.ourfamilywizard.com';
|
|
14
|
-
const OFW_PROTOCOL_HEADERS = {
|
|
15
|
-
'ofw-client': 'WebApplication',
|
|
16
|
-
'ofw-version': '1.0.0',
|
|
17
|
-
};
|
|
18
15
|
// Parse a Content-Disposition header for a filename. Prefers RFC 6266
|
|
19
16
|
// `filename*=UTF-8''…` (percent-decoded) and falls back to `filename="…"`.
|
|
20
17
|
function parseContentDispositionFilename(cd) {
|
|
@@ -35,8 +32,7 @@ function parseContentDispositionFilename(cd) {
|
|
|
35
32
|
// stderr. Authorization is redacted. Bodies are logged in full — set this
|
|
36
33
|
// only when debugging, never in normal use.
|
|
37
34
|
function debugLogEnabled() {
|
|
38
|
-
|
|
39
|
-
return v === '1' || v === 'true' || v === 'yes' || v === 'on';
|
|
35
|
+
return parseBoolEnv('OFW_DEBUG_LOG');
|
|
40
36
|
}
|
|
41
37
|
function redactHeaders(h) {
|
|
42
38
|
const out = { ...h };
|
|
@@ -131,12 +127,12 @@ export class OFWClient {
|
|
|
131
127
|
async login() {
|
|
132
128
|
const { token, expiresAt } = await resolveAuth();
|
|
133
129
|
this.token = token;
|
|
134
|
-
this.tokenExpiry = expiresAt ?? new Date(Date.now() +
|
|
130
|
+
this.tokenExpiry = expiresAt ?? new Date(Date.now() + OFW_TOKEN_TTL_MS);
|
|
135
131
|
}
|
|
136
132
|
isTokenExpiredSoon() {
|
|
137
133
|
if (!this.token || !this.tokenExpiry)
|
|
138
134
|
return true;
|
|
139
|
-
return this.tokenExpiry.getTime() - Date.now() <
|
|
135
|
+
return this.tokenExpiry.getTime() - Date.now() < OFW_TOKEN_EXPIRY_SKEW_MS;
|
|
140
136
|
}
|
|
141
137
|
}
|
|
142
138
|
export const client = new OFWClient();
|
package/dist/config.js
CHANGED
|
@@ -40,14 +40,22 @@ export function getAttachmentsDir() {
|
|
|
40
40
|
// location across macOS/Linux/Windows.
|
|
41
41
|
return join(homedir(), 'Downloads', 'ofw-mcp');
|
|
42
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* True when a boolean-shaped env var is set to "1", "true", "yes", or "on"
|
|
45
|
+
* (case-insensitive, trimmed). Anything else — unset, empty, or other
|
|
46
|
+
* values — is false. Used for OFW_INLINE_ATTACHMENTS, OFW_DISABLE_FETCHPROXY,
|
|
47
|
+
* OFW_DEBUG_LOG, etc.
|
|
48
|
+
*/
|
|
49
|
+
export function parseBoolEnv(name) {
|
|
50
|
+
const raw = process.env[name];
|
|
51
|
+
if (typeof raw !== 'string')
|
|
52
|
+
return false;
|
|
53
|
+
return ['1', 'true', 'yes', 'on'].includes(raw.trim().toLowerCase());
|
|
54
|
+
}
|
|
43
55
|
// Default for ofw_download_attachment's `inline` arg when the caller doesn't
|
|
44
56
|
// pass one. Set OFW_INLINE_ATTACHMENTS=true to have attachments returned as
|
|
45
57
|
// MCP content blocks by default (skipping disk) — useful on sandboxed MCP
|
|
46
58
|
// hosts where filesystem reads back to the model aren't available.
|
|
47
|
-
// Accepts: "1", "true", "yes", "on" (case-insensitive) → true; anything else → false.
|
|
48
59
|
export function getDefaultInlineAttachments() {
|
|
49
|
-
|
|
50
|
-
if (typeof raw !== 'string')
|
|
51
|
-
return false;
|
|
52
|
-
return ['1', 'true', 'yes', 'on'].includes(raw.trim().toLowerCase());
|
|
60
|
+
return parseBoolEnv('OFW_INLINE_ATTACHMENTS');
|
|
53
61
|
}
|
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.17' }); // x-release-please-version
|
|
21
21
|
registerUserTools(server, client);
|
|
22
22
|
registerMessageTools(server, client);
|
|
23
23
|
registerCalendarTools(server, client);
|
package/dist/protocol.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Wire-level constants shared by client.ts (general API calls) and
|
|
2
|
+
// auth-password.ts (form-login). Kept in a leaf module to avoid an import
|
|
3
|
+
// cycle between client.ts → auth.ts → auth-password.ts.
|
|
4
|
+
export const BASE_URL = 'https://ofw.ourfamilywizard.com';
|
|
5
|
+
// Required on every OFW API request. `ofw-version` is the OFW protocol
|
|
6
|
+
// version, not this package's version — do NOT bump it during a release.
|
|
7
|
+
export const OFW_PROTOCOL_HEADERS = {
|
|
8
|
+
'ofw-client': 'WebApplication',
|
|
9
|
+
'ofw-version': '1.0.0',
|
|
10
|
+
};
|
|
11
|
+
// OFW doesn't return a token expiry, so we synthesize one. Six hours is
|
|
12
|
+
// empirically long enough to be useful and short enough that the 401
|
|
13
|
+
// re-auth replay path stays a rare event rather than the common case.
|
|
14
|
+
export const OFW_TOKEN_TTL_MS = 6 * 60 * 60 * 1000;
|
|
15
|
+
// How early we treat a token as expiring. Re-auth before this skew so a
|
|
16
|
+
// long-running request doesn't get a stale token mid-flight.
|
|
17
|
+
export const OFW_TOKEN_EXPIRY_SKEW_MS = 5 * 60 * 1000;
|
package/dist/sync.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { setMeta, upsertMessage, getMessage, setSyncState, upsertDraft, getDraft, deleteDraft, listDraftIds, upsertAttachmentForMessage, } from './cache.js';
|
|
1
|
+
import { setMeta, upsertMessage, getMessage, deleteMessage, setSyncState, upsertDraft, getDraft, deleteDraft, listDraftIds, upsertAttachmentForMessage, } from './cache.js';
|
|
2
2
|
import { mapRecipients } from './tools/_shared.js';
|
|
3
3
|
// Fetches OFW attachment metadata for one file id and writes it to the cache.
|
|
4
4
|
// Throws on network/HTTP errors — callers in bulk-sync paths wrap this in the
|
|
@@ -17,15 +17,11 @@ export async function fetchAttachmentMeta(client, fileId, messageId) {
|
|
|
17
17
|
});
|
|
18
18
|
}
|
|
19
19
|
export async function fetchAttachmentMetaForMessage(client, messageId, fileIds) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
await fetchAttachmentMeta(client, fid, messageId);
|
|
26
|
-
}
|
|
27
|
-
catch { /* swallow */ }
|
|
28
|
-
}
|
|
20
|
+
// Fan out in parallel — each fetch is independent and the file id stays
|
|
21
|
+
// in listData on failure (model can retry via ofw_download_attachment,
|
|
22
|
+
// which surfaces the real error). Promise.allSettled so one bad
|
|
23
|
+
// attachment doesn't break the surrounding sync.
|
|
24
|
+
await Promise.allSettled(fileIds.map((fid) => fetchAttachmentMeta(client, fid, messageId)));
|
|
29
25
|
}
|
|
30
26
|
export async function resolveFolderIds(client) {
|
|
31
27
|
const data = await client.request('GET', '/pub/v1/messageFolders?includeFolderCounts=true');
|
|
@@ -142,6 +138,12 @@ export async function syncDrafts(client, draftsFolderId) {
|
|
|
142
138
|
listData: item,
|
|
143
139
|
};
|
|
144
140
|
upsertDraft(row);
|
|
141
|
+
// If a stale `messages` row exists for this id (cached by a prior
|
|
142
|
+
// ofw_get_message call before the drafts table knew about this id),
|
|
143
|
+
// evict it. The drafts table is the source of truth for drafts; we
|
|
144
|
+
// don't want ofw_get_message returning a stale messages-table copy.
|
|
145
|
+
if (getMessage(item.id))
|
|
146
|
+
deleteMessage(item.id);
|
|
145
147
|
if (!existing
|
|
146
148
|
|| existing.body !== row.body
|
|
147
149
|
|| existing.subject !== row.subject
|
package/dist/tools/_shared.js
CHANGED
|
@@ -20,3 +20,30 @@ export function expandPath(p) {
|
|
|
20
20
|
const expanded = p.startsWith('~/') ? join(process.env.HOME ?? '', p.slice(2)) : p;
|
|
21
21
|
return isAbsolute(expanded) ? expanded : resolve(expanded);
|
|
22
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* POST a payload to /pub/v3/messages, then immediately GET the detail
|
|
25
|
+
* endpoint for the resulting message id. This is the only correct way to
|
|
26
|
+
* populate the cache after `ofw_send_message` or `ofw_save_draft`:
|
|
27
|
+
*
|
|
28
|
+
* - OFW's POST response is minimal (typically just `{entityId: <id>}`
|
|
29
|
+
* or sometimes legacy `{id: <id>}`), so we can't build a full row
|
|
30
|
+
* from it directly.
|
|
31
|
+
* - Worse, on draft updates OFW returns the same success shape even
|
|
32
|
+
* when the server silently no-ops, so the GET is also how we verify
|
|
33
|
+
* the write landed (callers compare detail.body to args.body).
|
|
34
|
+
*
|
|
35
|
+
* Returns a discriminated union so callers can narrow with
|
|
36
|
+
* `if (result.id !== null)`. When id is null (no id field in the
|
|
37
|
+
* response — never observed in production, but defensive), `raw`
|
|
38
|
+
* carries the POST response so the caller can still surface it.
|
|
39
|
+
*/
|
|
40
|
+
export async function postMessageAndRefetch(client, payload) {
|
|
41
|
+
const raw = await client.request('POST', '/pub/v3/messages', payload);
|
|
42
|
+
const id = typeof raw?.id === 'number' ? raw.id
|
|
43
|
+
: typeof raw?.entityId === 'number' ? raw.entityId
|
|
44
|
+
: null;
|
|
45
|
+
if (id === null)
|
|
46
|
+
return { id: null, detail: null, raw };
|
|
47
|
+
const detail = await client.request('GET', `/pub/v3/messages/${id}`);
|
|
48
|
+
return { id, detail, raw };
|
|
49
|
+
}
|
package/dist/tools/calendar.js
CHANGED
|
@@ -36,7 +36,7 @@ export function registerCalendarTools(server, client) {
|
|
|
36
36
|
});
|
|
37
37
|
server.registerTool('ofw_update_event', {
|
|
38
38
|
description: 'Update an existing OurFamilyWizard calendar event',
|
|
39
|
-
annotations: { destructiveHint:
|
|
39
|
+
annotations: { destructiveHint: true },
|
|
40
40
|
inputSchema: {
|
|
41
41
|
eventId: z.string(),
|
|
42
42
|
title: z.string().optional(),
|
package/dist/tools/messages.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { syncAll, fetchAttachmentMeta, fetchAttachmentMetaForMessage } from '../sync.js';
|
|
3
|
-
import { listMessages, countMessages, listDrafts, getMessage, upsertMessage, upsertDraft, deleteDraft, findLatestReplyTip, listAttachmentsForMessage, getAttachment, upsertAttachmentForMessage, markAttachmentDownloaded, } from '../cache.js';
|
|
3
|
+
import { listMessages, countMessages, listDrafts, getMessage, upsertMessage, upsertDraft, deleteDraft, findLatestReplyTip, listAttachmentsForMessage, getAttachment, upsertAttachmentForMessage, markAttachmentDownloaded, getDraft, } from '../cache.js';
|
|
4
4
|
import { getAttachmentsDir, getDefaultInlineAttachments } from '../config.js';
|
|
5
5
|
import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
6
6
|
import { basename, dirname, extname, join } from 'node:path';
|
|
7
|
-
import { expandPath, jsonResponse, mapRecipients, textResponse } from './_shared.js';
|
|
7
|
+
import { expandPath, jsonResponse, mapRecipients, postMessageAndRefetch, textResponse } from './_shared.js';
|
|
8
8
|
// Lightweight mime sniff from extension. OFW re-derives mime from the filename
|
|
9
9
|
// server-side anyway, so this is just a polite Content-Type for the Blob.
|
|
10
10
|
const MIME_BY_EXT = {
|
|
@@ -95,13 +95,39 @@ export function registerMessageTools(server, client) {
|
|
|
95
95
|
return jsonResponse(payload);
|
|
96
96
|
});
|
|
97
97
|
server.registerTool('ofw_get_message', {
|
|
98
|
-
description: 'Get a single OurFamilyWizard message by ID. Reads from local cache when available; otherwise fetches from OFW (which will mark unread inbox messages as read on OFW).',
|
|
98
|
+
description: 'Get a single OurFamilyWizard message OR draft by ID. Reads from local cache when available; otherwise fetches from OFW (which will mark unread inbox messages as read on OFW). For ids that match a draft (in the drafts cache), the response carries folder="drafts" and the body/subject/recipients reflect the drafts cache (which ofw_sync_messages keeps fresh) — drafts have no `fromUser`, and `sentAt`/`fetchedBodyAt` mirror the draft\'s `modifiedAt`. For inbox/sent messages, folder is "inbox" or "sent" as before.',
|
|
99
99
|
annotations: { readOnlyHint: false },
|
|
100
100
|
inputSchema: {
|
|
101
|
-
messageId: z.string().describe('Message ID'),
|
|
101
|
+
messageId: z.string().describe('Message ID (also accepts draft IDs — drafts are routed via the drafts cache)'),
|
|
102
102
|
},
|
|
103
103
|
}, async (args) => {
|
|
104
104
|
const id = Number(args.messageId);
|
|
105
|
+
// Draft routing: if this id is in the drafts cache, return a
|
|
106
|
+
// MessageRow-shaped synthesis built from the draft. The drafts table
|
|
107
|
+
// is the source of truth for draft bodies (sync keeps it fresh);
|
|
108
|
+
// the messages-table cache for the same id is stale by construction
|
|
109
|
+
// when ofw_get_message was called on a draft id before sync caught
|
|
110
|
+
// up — see syncDrafts, which also evicts these stale rows.
|
|
111
|
+
const draftRow = getDraft(id);
|
|
112
|
+
if (draftRow !== null) {
|
|
113
|
+
return jsonResponse({
|
|
114
|
+
id: draftRow.id,
|
|
115
|
+
folder: 'drafts',
|
|
116
|
+
subject: draftRow.subject,
|
|
117
|
+
fromUser: '',
|
|
118
|
+
sentAt: draftRow.modifiedAt,
|
|
119
|
+
recipients: draftRow.recipients,
|
|
120
|
+
body: draftRow.body,
|
|
121
|
+
// Best approximation: drafts don't separately track when the body
|
|
122
|
+
// was last *fetched* — we last wrote it on the last sync, which
|
|
123
|
+
// also updates modifiedAt.
|
|
124
|
+
fetchedBodyAt: draftRow.modifiedAt,
|
|
125
|
+
replyToId: draftRow.replyToId,
|
|
126
|
+
chainRootId: null,
|
|
127
|
+
listData: draftRow.listData,
|
|
128
|
+
attachments: [],
|
|
129
|
+
});
|
|
130
|
+
}
|
|
105
131
|
const cached = getMessage(id);
|
|
106
132
|
if (cached && cached.body !== null) {
|
|
107
133
|
let attachments = listAttachmentsForMessage(id);
|
|
@@ -173,10 +199,7 @@ export function registerMessageTools(server, client) {
|
|
|
173
199
|
chainRootId = parent?.chainRootId ?? parent?.id ?? requestedReplyTo;
|
|
174
200
|
}
|
|
175
201
|
const myFileIDs = args.myFileIDs ?? [];
|
|
176
|
-
|
|
177
|
-
// `{entityId: <id>}` — so the cache write needs to fetch detail
|
|
178
|
-
// afterwards (same shape as ofw_save_draft).
|
|
179
|
-
const data = await client.request('POST', '/pub/v3/messages', {
|
|
202
|
+
const { id: newId, detail, raw } = await postMessageAndRefetch(client, {
|
|
180
203
|
subject: args.subject,
|
|
181
204
|
body: args.body,
|
|
182
205
|
recipientIds: args.recipientIds,
|
|
@@ -185,12 +208,8 @@ export function registerMessageTools(server, client) {
|
|
|
185
208
|
includeOriginal: resolvedReplyTo !== null,
|
|
186
209
|
replyToId: resolvedReplyTo,
|
|
187
210
|
});
|
|
188
|
-
const newId = typeof data?.id === 'number' ? data.id
|
|
189
|
-
: typeof data?.entityId === 'number' ? data.entityId
|
|
190
|
-
: null;
|
|
191
211
|
let persisted = null;
|
|
192
212
|
if (newId !== null) {
|
|
193
|
-
const detail = await client.request('GET', `/pub/v3/messages/${newId}`);
|
|
194
213
|
persisted = {
|
|
195
214
|
id: newId,
|
|
196
215
|
folder: 'sent',
|
|
@@ -225,7 +244,7 @@ export function registerMessageTools(server, client) {
|
|
|
225
244
|
await deleteOFWMessages(client, [args.draftId]);
|
|
226
245
|
deleteDraft(args.draftId);
|
|
227
246
|
}
|
|
228
|
-
const responseObj = persisted ??
|
|
247
|
+
const responseObj = persisted ?? raw;
|
|
229
248
|
const text = responseObj ? JSON.stringify(responseObj, null, 2) : 'Message sent successfully.';
|
|
230
249
|
return textResponse(rewriteNote ? `${rewriteNote}\n\n${text}` : text);
|
|
231
250
|
});
|
|
@@ -246,13 +265,13 @@ export function registerMessageTools(server, client) {
|
|
|
246
265
|
return jsonResponse(payload);
|
|
247
266
|
});
|
|
248
267
|
server.registerTool('ofw_save_draft', {
|
|
249
|
-
description: 'Save a message as a draft in OurFamilyWizard. Recipients are optional.
|
|
268
|
+
description: 'Save a message as a draft in OurFamilyWizard. Recipients are optional. Pass messageId to replace an existing draft — note that under the hood this creates a NEW draft and deletes the old one (OFW\'s update-in-place endpoint silently no-ops while echoing the posted body, so we don\'t use it); the response.id will be the NEW id, not the messageId you passed, and the change is documented in a transparency NOTE in the response. If replyToId is provided, the cache may rewrite it to the latest reply in the thread (note included in response). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs. After saving, the tool re-fetches the draft from OFW to populate the local cache from authoritative server state.',
|
|
250
269
|
annotations: { readOnlyHint: false },
|
|
251
270
|
inputSchema: {
|
|
252
271
|
subject: z.string().describe('Message subject'),
|
|
253
272
|
body: z.string().describe('Message body text'),
|
|
254
273
|
recipientIds: z.array(z.number()).describe('Array of recipient user IDs (optional for drafts)').optional(),
|
|
255
|
-
messageId: z.number().describe('ID of an existing draft to
|
|
274
|
+
messageId: z.number().describe('ID of an existing draft to replace (the new draft will have a new id; the old is deleted)').optional(),
|
|
256
275
|
replyToId: z.number().describe('ID of the message this draft replies to').optional(),
|
|
257
276
|
myFileIDs: z.array(z.number()).describe('Attachment file ids (from ofw_upload_attachment)').optional(),
|
|
258
277
|
},
|
|
@@ -267,6 +286,12 @@ export function registerMessageTools(server, client) {
|
|
|
267
286
|
}
|
|
268
287
|
}
|
|
269
288
|
const myFileIDs = args.myFileIDs ?? [];
|
|
289
|
+
// Deliberately do NOT pass `args.messageId` to OFW's POST payload.
|
|
290
|
+
// OFW's update-by-messageId path silently no-ops on subsequent
|
|
291
|
+
// updates while echoing the posted body in the immediate GET — so
|
|
292
|
+
// there is no honest way to detect a failure from the response.
|
|
293
|
+
// We always create a fresh draft; if the caller provided a
|
|
294
|
+
// messageId, we delete the old draft afterward (the "replace" path).
|
|
270
295
|
const payload = {
|
|
271
296
|
subject: args.subject,
|
|
272
297
|
body: args.body,
|
|
@@ -276,22 +301,10 @@ export function registerMessageTools(server, client) {
|
|
|
276
301
|
includeOriginal: resolvedReplyTo !== null,
|
|
277
302
|
replyToId: resolvedReplyTo,
|
|
278
303
|
};
|
|
279
|
-
|
|
280
|
-
payload.messageId = args.messageId;
|
|
281
|
-
// OFW's POST /pub/v3/messages response for drafts is minimal — typically
|
|
282
|
-
// just `{entityId: <id>}` — and worse, it returns the same success shape
|
|
283
|
-
// even when the server silently no-ops on a subsequent update to the
|
|
284
|
-
// same draft. Don't trust the POST response: extract the id from it,
|
|
285
|
-
// then GET the detail endpoint to repopulate the cache from
|
|
286
|
-
// authoritative server state.
|
|
287
|
-
const data = await client.request('POST', '/pub/v3/messages', payload);
|
|
288
|
-
const newId = typeof data?.id === 'number' ? data.id
|
|
289
|
-
: typeof data?.entityId === 'number' ? data.entityId
|
|
290
|
-
: null;
|
|
304
|
+
const { id: newId, detail, raw } = await postMessageAndRefetch(client, payload);
|
|
291
305
|
let persisted = null;
|
|
292
|
-
let
|
|
306
|
+
let replaceNote = null;
|
|
293
307
|
if (newId !== null) {
|
|
294
|
-
const detail = await client.request('GET', `/pub/v3/messages/${newId}`);
|
|
295
308
|
persisted = {
|
|
296
309
|
id: newId,
|
|
297
310
|
subject: detail.subject ?? args.subject,
|
|
@@ -302,17 +315,22 @@ export function registerMessageTools(server, client) {
|
|
|
302
315
|
listData: detail,
|
|
303
316
|
};
|
|
304
317
|
upsertDraft(persisted);
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
318
|
+
// Replace-path: caller passed messageId, so they want the old draft
|
|
319
|
+
// gone. Delete it after the new one is safely created+cached.
|
|
320
|
+
if (args.messageId !== undefined && args.messageId !== newId) {
|
|
321
|
+
try {
|
|
322
|
+
await deleteOFWMessages(client, [args.messageId]);
|
|
323
|
+
deleteDraft(args.messageId);
|
|
324
|
+
replaceNote = `NOTE: ofw_save_draft replaced draft ${args.messageId} via create-then-delete. The new draft id is ${newId}; the old draft has been deleted. (OFW's update-in-place endpoint silently no-ops on subsequent updates, so we never use it. If you cached the old id anywhere, replace it with the new one.)`;
|
|
325
|
+
}
|
|
326
|
+
catch (e) {
|
|
327
|
+
replaceNote = `WARNING: New draft ${newId} created successfully, but failed to delete the old draft (${args.messageId}): ${e.message}. You may want to clean it up manually with ofw_delete_draft.`;
|
|
328
|
+
}
|
|
311
329
|
}
|
|
312
330
|
}
|
|
313
|
-
const responseObj = persisted ??
|
|
331
|
+
const responseObj = persisted ?? raw;
|
|
314
332
|
const text = responseObj ? JSON.stringify(responseObj, null, 2) : 'Draft saved.';
|
|
315
|
-
const notes = [rewriteNote,
|
|
333
|
+
const notes = [rewriteNote, replaceNote].filter((n) => n !== null).join('\n\n');
|
|
316
334
|
return textResponse(notes ? `${notes}\n\n${text}` : text);
|
|
317
335
|
});
|
|
318
336
|
server.registerTool('ofw_delete_draft', {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ofw-mcp",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.17",
|
|
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,26 +6,26 @@
|
|
|
6
6
|
"url": "https://github.com/chrischall/ofw-mcp",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "2.0.
|
|
9
|
+
"version": "2.0.17",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "ofw-mcp",
|
|
14
|
-
"version": "2.0.
|
|
14
|
+
"version": "2.0.17",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|
|
18
18
|
"environmentVariables": [
|
|
19
19
|
{
|
|
20
20
|
"name": "OFW_USERNAME",
|
|
21
|
-
"description": "Your OurFamilyWizard login email address",
|
|
22
|
-
"isRequired":
|
|
21
|
+
"description": "Your OurFamilyWizard login email address. Optional — if omitted, the server falls back to the fetchproxy browser extension (requires being signed in to ourfamilywizard.com).",
|
|
22
|
+
"isRequired": false,
|
|
23
23
|
"format": "string"
|
|
24
24
|
},
|
|
25
25
|
{
|
|
26
26
|
"name": "OFW_PASSWORD",
|
|
27
|
-
"description": "Your OurFamilyWizard password",
|
|
28
|
-
"isRequired":
|
|
27
|
+
"description": "Your OurFamilyWizard password. Optional — see OFW_USERNAME.",
|
|
28
|
+
"isRequired": false,
|
|
29
29
|
"format": "string",
|
|
30
30
|
"isSecret": true
|
|
31
31
|
}
|
package/skills/ofw/SKILL.md
CHANGED
|
@@ -89,13 +89,17 @@ Always pass `--config ~/.mcporter/mcporter.json` unless a local `config/mcporter
|
|
|
89
89
|
### Messages
|
|
90
90
|
| Tool | Notes |
|
|
91
91
|
|------|-------|
|
|
92
|
-
| `
|
|
93
|
-
| `
|
|
94
|
-
| `
|
|
95
|
-
| `
|
|
96
|
-
| `
|
|
97
|
-
| `
|
|
98
|
-
| `
|
|
92
|
+
| `ofw_sync_messages(folders?, deep?, fetchUnreadBodies?)` | Sync OFW → local cache. **Call first if the cache might be stale.** Returns unread inbox hints (bodies not fetched, to avoid mark-as-read). |
|
|
93
|
+
| `ofw_list_message_folders` | List OFW folders with unread counts. Most reads use the cache; this is mainly for folder IDs and live unread counts. |
|
|
94
|
+
| `ofw_list_messages(folderId?, since?, until?, q?, page?, size?)` | Cache-backed list. Supports folder ("inbox"/"sent"/"both"), date range, and substring search. |
|
|
95
|
+
| `ofw_get_message(messageId)` | Read a message OR draft body. Cache-first. Ids in the drafts cache return `folder: "drafts"`. ⚠️ Falls through to OFW for unread inbox messages, which marks them as read. |
|
|
96
|
+
| `ofw_send_message(subject, body, recipientIds[], replyToId?, draftId?, myFileIDs?)` | Send a message. Pass `replyToId` to thread original history. Pass `draftId` to auto-delete the draft after sending. Pass `myFileIDs` (from `ofw_upload_attachment`) to attach files. |
|
|
97
|
+
| `ofw_get_unread_sent` | Sent messages your co-parent hasn't read yet (from cache). |
|
|
98
|
+
| `ofw_list_drafts` | List saved drafts (cache-backed). |
|
|
99
|
+
| `ofw_save_draft(subject, body, recipientIds?, messageId?, replyToId?, myFileIDs?)` | Create a new draft. Pass `messageId` to **replace** an existing draft: the tool creates a fresh draft and deletes the old one (OFW's update-in-place endpoint silently no-ops). The returned `id` is the NEW id; the response includes a `NOTE` documenting the swap. |
|
|
100
|
+
| `ofw_delete_draft(messageId)` | Delete a draft. |
|
|
101
|
+
| `ofw_upload_attachment(path, shareClass?, label?, description?)` | Upload a local file to My Files; returns a fileId to pass into `myFileIDs`. |
|
|
102
|
+
| `ofw_download_attachment(fileId, inline?, saveTo?, force?)` | Download an attachment. `inline:true` returns bytes as MCP content; default writes to `~/Downloads/ofw-mcp/`. |
|
|
99
103
|
|
|
100
104
|
### Calendar
|
|
101
105
|
| Tool | Notes |
|