ofw-mcp 2.0.13 → 2.0.15

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/dist/client.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { dirname, join } from 'path';
2
2
  import { fileURLToPath } from 'url';
3
+ import { resolveAuth } from './auth.js';
3
4
  // Load .env for local dev; silently skip if dotenv is unavailable (e.g. mcpb bundle)
4
5
  try {
5
6
  const { config } = await import('dotenv');
@@ -9,24 +10,6 @@ try {
9
10
  catch {
10
11
  // not available — rely on process.env (mcpb sets credentials via mcp_config.env)
11
12
  }
12
- /**
13
- * Read an env var, trim whitespace, and treat as unset if blank or if the value
14
- * looks like an unsubstituted shell placeholder (e.g. `${FOO}`) — defends
15
- * against MCP hosts that pass .mcp.json env blocks through unexpanded.
16
- */
17
- function readVar(key) {
18
- const raw = process.env[key];
19
- if (typeof raw !== 'string')
20
- return undefined;
21
- const trimmed = raw.trim();
22
- if (trimmed.length === 0)
23
- return undefined;
24
- if (trimmed === 'undefined' || trimmed === 'null')
25
- return undefined;
26
- if (/^\$\{[^}]*\}$/.test(trimmed))
27
- return undefined;
28
- return trimmed;
29
- }
30
13
  const BASE_URL = 'https://ofw.ourfamilywizard.com';
31
14
  const OFW_PROTOCOL_HEADERS = {
32
15
  'ofw-client': 'WebApplication',
@@ -48,6 +31,19 @@ function parseContentDispositionFilename(cd) {
48
31
  const m = /filename="?([^";]+)"?/i.exec(cd);
49
32
  return m ? m[1] : null;
50
33
  }
34
+ // Set OFW_DEBUG_LOG=1 (or true/yes/on) to log every OFW request/response to
35
+ // stderr. Authorization is redacted. Bodies are logged in full — set this
36
+ // only when debugging, never in normal use.
37
+ function debugLogEnabled() {
38
+ const v = process.env.OFW_DEBUG_LOG;
39
+ return v === '1' || v === 'true' || v === 'yes' || v === 'on';
40
+ }
41
+ function redactHeaders(h) {
42
+ const out = { ...h };
43
+ if (out.Authorization)
44
+ out.Authorization = `Bearer ${out.Authorization.slice(7, 17)}…`;
45
+ return out;
46
+ }
51
47
  export class OFWClient {
52
48
  token = null;
53
49
  tokenExpiry = null;
@@ -55,6 +51,9 @@ export class OFWClient {
55
51
  await this.ensureAuthenticated();
56
52
  const response = await this.fetchWithRetry(method, path, body, 'application/json', false);
57
53
  const text = await response.text();
54
+ if (debugLogEnabled()) {
55
+ console.error(`[ofw-debug] response body: ${text || '<empty>'}`);
56
+ }
58
57
  return (text ? JSON.parse(text) : null);
59
58
  }
60
59
  /** Like `request`, but returns the raw bytes plus Content-Type/-Disposition metadata. */
@@ -79,11 +78,25 @@ export class OFWClient {
79
78
  };
80
79
  if (body !== undefined && !isFormData)
81
80
  headers['Content-Type'] = 'application/json';
82
- const response = await fetch(`${BASE_URL}${path}`, {
81
+ const url = `${BASE_URL}${path}`;
82
+ if (debugLogEnabled()) {
83
+ const bodyPreview = body === undefined
84
+ ? '<none>'
85
+ : isFormData
86
+ ? `<FormData entries=${Array.from(body.keys()).join(',')}>`
87
+ : JSON.stringify(body);
88
+ console.error(`[ofw-debug] → ${method} ${url}${isRetry ? ' (retry)' : ''}`);
89
+ console.error(`[ofw-debug] headers: ${JSON.stringify(redactHeaders(headers))}`);
90
+ console.error(`[ofw-debug] body: ${bodyPreview}`);
91
+ }
92
+ const response = await fetch(url, {
83
93
  method,
84
94
  headers,
85
95
  ...(body !== undefined ? { body: isFormData ? body : JSON.stringify(body) } : {}),
86
96
  });
97
+ if (debugLogEnabled()) {
98
+ console.error(`[ofw-debug] ← ${response.status} ${response.statusText}`);
99
+ }
87
100
  if (response.status === 401 && !isRetry) {
88
101
  this.token = null;
89
102
  this.tokenExpiry = null;
@@ -107,48 +120,18 @@ export class OFWClient {
107
120
  return;
108
121
  await this.login();
109
122
  }
123
+ // Auth resolution is delegated to `./auth.ts`. This client doesn't care
124
+ // whether the token came from a password POST or from a one-shot
125
+ // fetchproxy session-snapshot — it just consumes the result.
126
+ //
127
+ // If `expiresAt` is missing (the fetchproxy path on a tab whose
128
+ // browser didn't persist tokenExpiry), we fall back to the same 6h
129
+ // estimate the password path uses. The 401-replay path covers us if
130
+ // the estimate is wrong.
110
131
  async login() {
111
- const username = readVar('OFW_USERNAME');
112
- const password = readVar('OFW_PASSWORD');
113
- if (!username || !password) {
114
- throw new Error('OFW_USERNAME and OFW_PASSWORD must be set');
115
- }
116
- // Spring Security requires a SESSION cookie before accepting the login POST.
117
- // GET /ofw/login.form with redirect:manual to capture the Set-Cookie from the 303 response.
118
- const initResponse = await fetch(`${BASE_URL}/ofw/login.form`, {
119
- headers: { ...OFW_PROTOCOL_HEADERS },
120
- redirect: 'manual',
121
- });
122
- // Extract just the SESSION=value part (strip attributes like Path, Secure, etc.)
123
- const setCookie = initResponse.headers.get('set-cookie') ?? '';
124
- const sessionCookie = setCookie.split(';')[0];
125
- const response = await fetch(`${BASE_URL}/ofw/login`, {
126
- method: 'POST',
127
- headers: {
128
- ...OFW_PROTOCOL_HEADERS,
129
- Accept: 'application/json',
130
- 'Content-Type': 'application/x-www-form-urlencoded',
131
- ...(sessionCookie ? { Cookie: sessionCookie } : {}),
132
- },
133
- body: new URLSearchParams({
134
- submit: 'Sign In',
135
- _eventId: 'submit',
136
- username,
137
- password,
138
- }).toString(),
139
- });
140
- if (!response.ok) {
141
- throw new Error(`OFW login failed: ${response.status} ${response.statusText}`);
142
- }
143
- const contentType = response.headers.get('content-type') ?? '';
144
- if (!contentType.includes('application/json')) {
145
- const body = await response.text();
146
- throw new Error(`OFW login returned unexpected response (${contentType}): ${body.substring(0, 200)}`);
147
- }
148
- const data = (await response.json());
149
- this.token = data.auth;
150
- // Token expiry not returned by login endpoint; use 6h as a safe default
151
- this.tokenExpiry = new Date(Date.now() + 6 * 60 * 60 * 1000);
132
+ const { token, expiresAt } = await resolveAuth();
133
+ this.token = token;
134
+ this.tokenExpiry = expiresAt ?? new Date(Date.now() + 6 * 60 * 60 * 1000);
152
135
  }
153
136
  isTokenExpiredSoon() {
154
137
  if (!this.token || !this.tokenExpiry)
package/dist/config.js CHANGED
@@ -1,12 +1,22 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
- function readUsername() {
5
- const raw = process.env.OFW_USERNAME;
6
- if (typeof raw !== 'string' || raw.trim().length === 0) {
7
- throw new Error('OFW_USERNAME must be set to derive cache path');
8
- }
9
- return raw.trim();
4
+ // Cache identity drives the per-user SQLite DB filename. Order of preference:
5
+ // 1. OFW_CACHE_IDENTITY explicit override for users who want to label the
6
+ // cache themselves (e.g. when authing via fetchproxy and OFW_USERNAME is
7
+ // not set).
8
+ // 2. OFW_USERNAME — legacy path; existing users keep their existing DB.
9
+ // 3. "_default" — fallback for fetchproxy-only setups where neither is set.
10
+ // Single-user installs are fine on this; multi-account users should set
11
+ // OFW_CACHE_IDENTITY explicitly so their caches don't collide.
12
+ function readCacheIdentity() {
13
+ const explicit = process.env.OFW_CACHE_IDENTITY;
14
+ if (typeof explicit === 'string' && explicit.trim().length > 0)
15
+ return explicit.trim();
16
+ const username = process.env.OFW_USERNAME;
17
+ if (typeof username === 'string' && username.trim().length > 0)
18
+ return username.trim();
19
+ return '_default';
10
20
  }
11
21
  export function getCacheDir() {
12
22
  const override = process.env.OFW_CACHE_DIR;
@@ -15,8 +25,8 @@ export function getCacheDir() {
15
25
  return join(homedir(), '.cache', 'ofw-mcp');
16
26
  }
17
27
  export function getCacheDbPath() {
18
- const username = readUsername();
19
- const hash = createHash('sha256').update(username).digest('hex').slice(0, 16);
28
+ const identity = readCacheIdentity();
29
+ const hash = createHash('sha256').update(identity).digest('hex').slice(0, 16);
20
30
  return join(getCacheDir(), `${hash}.db`);
21
31
  }
22
32
  export function getAttachmentsDir() {
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.13' });
20
+ const server = new McpServer({ name: 'ofw', version: '2.0.15' });
21
21
  registerUserTools(server, client);
22
22
  registerMessageTools(server, client);
23
23
  registerCalendarTools(server, client);
package/dist/sync.js CHANGED
@@ -127,10 +127,10 @@ export async function syncDrafts(client, draftsFolderId) {
127
127
  for (const item of items) {
128
128
  seenIds.add(item.id);
129
129
  const modifiedAt = item.date?.dateTime ?? new Date().toISOString();
130
+ // OFW's list endpoint's `date.dateTime` is NOT a reliable modification
131
+ // timestamp for drafts — direct UI edits don't bump it — so we can't
132
+ // use it to skip the detail fetch. Always re-fetch; drafts are few.
130
133
  const existing = getDraft(item.id);
131
- if (existing && existing.modifiedAt === modifiedAt) {
132
- continue;
133
- }
134
134
  const detail = await client.request('GET', `/pub/v3/messages/${item.id}`);
135
135
  const row = {
136
136
  id: item.id,
@@ -142,7 +142,12 @@ export async function syncDrafts(client, draftsFolderId) {
142
142
  listData: item,
143
143
  };
144
144
  upsertDraft(row);
145
- synced++;
145
+ if (!existing
146
+ || existing.body !== row.body
147
+ || existing.subject !== row.subject
148
+ || existing.replyToId !== row.replyToId) {
149
+ synced++;
150
+ }
146
151
  }
147
152
  for (const id of listDraftIds()) {
148
153
  if (!seenIds.has(id))
@@ -149,7 +149,7 @@ export function registerMessageTools(server, client) {
149
149
  return jsonResponse({ ...row, attachments });
150
150
  });
151
151
  server.registerTool('ofw_send_message', {
152
- description: 'Send a message via OurFamilyWizard. If sending from a draft, pass draftId to delete the draft after sending. If replyToId is provided, the cache may rewrite it to the latest reply in the same thread (a note is included in the response when this happens). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs.',
152
+ description: 'Send a message via OurFamilyWizard. If sending from a draft, pass draftId to delete the draft after sending. If replyToId is provided, the cache may rewrite it to the latest reply in the same thread (a note is included in the response when this happens). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs. After sending, the tool re-fetches the message from OFW to populate the local cache and link attachments to the new message id.',
153
153
  annotations: { destructiveHint: true },
154
154
  inputSchema: {
155
155
  subject: z.string().describe('Message subject'),
@@ -173,6 +173,9 @@ export function registerMessageTools(server, client) {
173
173
  chainRootId = parent?.chainRootId ?? parent?.id ?? requestedReplyTo;
174
174
  }
175
175
  const myFileIDs = args.myFileIDs ?? [];
176
+ // OFW's POST /pub/v3/messages response is minimal — typically just
177
+ // `{entityId: <id>}` — so the cache write needs to fetch detail
178
+ // afterwards (same shape as ofw_save_draft).
176
179
  const data = await client.request('POST', '/pub/v3/messages', {
177
180
  subject: args.subject,
178
181
  body: args.body,
@@ -182,21 +185,26 @@ export function registerMessageTools(server, client) {
182
185
  includeOriginal: resolvedReplyTo !== null,
183
186
  replyToId: resolvedReplyTo,
184
187
  });
185
- if (data && typeof data.id === 'number') {
186
- const row = {
187
- id: data.id,
188
+ const newId = typeof data?.id === 'number' ? data.id
189
+ : typeof data?.entityId === 'number' ? data.entityId
190
+ : null;
191
+ let persisted = null;
192
+ if (newId !== null) {
193
+ const detail = await client.request('GET', `/pub/v3/messages/${newId}`);
194
+ persisted = {
195
+ id: newId,
188
196
  folder: 'sent',
189
- subject: data.subject ?? args.subject,
190
- fromUser: data.from?.name ?? '',
191
- sentAt: data.date?.dateTime ?? new Date().toISOString(),
192
- recipients: mapRecipients(data.recipients),
193
- body: data.body ?? args.body,
197
+ subject: detail.subject ?? args.subject,
198
+ fromUser: detail.from?.name ?? '',
199
+ sentAt: detail.date?.dateTime ?? new Date().toISOString(),
200
+ recipients: mapRecipients(detail.recipients),
201
+ body: detail.body ?? args.body,
194
202
  fetchedBodyAt: new Date().toISOString(),
195
203
  replyToId: resolvedReplyTo,
196
204
  chainRootId,
197
- listData: data,
205
+ listData: detail,
198
206
  };
199
- upsertMessage(row);
207
+ upsertMessage(persisted);
200
208
  // Link attached files to the new message in the attachments cache.
201
209
  // We may not have full metadata if the upload happened in a prior
202
210
  // session — fall back to what we know.
@@ -209,7 +217,7 @@ export function registerMessageTools(server, client) {
209
217
  mimeType: existing?.mimeType ?? 'application/octet-stream',
210
218
  sizeBytes: existing?.sizeBytes ?? null,
211
219
  metadata: existing?.metadata ?? {},
212
- messageId: data.id,
220
+ messageId: newId,
213
221
  });
214
222
  }
215
223
  }
@@ -217,7 +225,8 @@ export function registerMessageTools(server, client) {
217
225
  await deleteOFWMessages(client, [args.draftId]);
218
226
  deleteDraft(args.draftId);
219
227
  }
220
- const text = data ? JSON.stringify(data, null, 2) : 'Message sent successfully.';
228
+ const responseObj = persisted ?? data;
229
+ const text = responseObj ? JSON.stringify(responseObj, null, 2) : 'Message sent successfully.';
221
230
  return textResponse(rewriteNote ? `${rewriteNote}\n\n${text}` : text);
222
231
  });
223
232
  server.registerTool('ofw_list_drafts', {
@@ -237,7 +246,7 @@ export function registerMessageTools(server, client) {
237
246
  return jsonResponse(payload);
238
247
  });
239
248
  server.registerTool('ofw_save_draft', {
240
- description: 'Save a message as a draft in OurFamilyWizard. Recipients are optional. To update an existing draft, provide its messageId. If replyToId is provided, the cache may rewrite it to the latest reply in the thread (note included in response). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs.',
249
+ description: 'Save a message as a draft in OurFamilyWizard. Recipients are optional. To update an existing draft, provide its messageId. If replyToId is provided, the cache may rewrite it to the latest reply in the thread (note included in response). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs. After saving, the tool re-fetches the draft from OFW to populate the local cache and verify what was actually persisted; if OFW silently no-ops an update (a known issue with repeated updates to the same draft), the response includes a WARNING note with a workaround.',
241
250
  annotations: { readOnlyHint: false },
242
251
  inputSchema: {
243
252
  subject: z.string().describe('Message subject'),
@@ -269,21 +278,42 @@ export function registerMessageTools(server, client) {
269
278
  };
270
279
  if (args.messageId !== undefined)
271
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.
272
287
  const data = await client.request('POST', '/pub/v3/messages', payload);
273
- if (data && typeof data.id === 'number') {
274
- const draft = {
275
- id: data.id,
276
- subject: data.subject ?? args.subject,
277
- body: data.body ?? args.body,
278
- recipients: mapRecipients(data.recipients),
279
- replyToId: data.replyToId ?? resolvedReplyTo,
280
- modifiedAt: data.date?.dateTime ?? new Date().toISOString(),
281
- listData: data,
288
+ const newId = typeof data?.id === 'number' ? data.id
289
+ : typeof data?.entityId === 'number' ? data.entityId
290
+ : null;
291
+ let persisted = null;
292
+ let noOpWarning = null;
293
+ if (newId !== null) {
294
+ const detail = await client.request('GET', `/pub/v3/messages/${newId}`);
295
+ persisted = {
296
+ id: newId,
297
+ subject: detail.subject ?? args.subject,
298
+ body: detail.body ?? '',
299
+ recipients: mapRecipients(detail.recipients),
300
+ replyToId: detail.replyToId ?? resolvedReplyTo,
301
+ modifiedAt: detail.date?.dateTime ?? new Date().toISOString(),
302
+ listData: detail,
282
303
  };
283
- upsertDraft(draft);
304
+ upsertDraft(persisted);
305
+ // If this was an update (messageId provided) and OFW's reported body
306
+ // doesn't match what we asked it to save, the server silently
307
+ // dropped the change. Warn the caller so the model can take the
308
+ // create-then-delete fallback.
309
+ if (args.messageId !== undefined && persisted.body !== args.body) {
310
+ noOpWarning = 'WARNING: OFW reported success but the draft body it returned does not match the requested update. The OFW POST /pub/v3/messages endpoint can silently no-op on subsequent updates to the same draft. Workaround: delete this draft (ofw_delete_draft) and create a new one (ofw_save_draft without messageId).';
311
+ }
284
312
  }
285
- const text = data ? JSON.stringify(data, null, 2) : 'Draft saved.';
286
- return textResponse(rewriteNote ? `${rewriteNote}\n\n${text}` : text);
313
+ const responseObj = persisted ?? data;
314
+ const text = responseObj ? JSON.stringify(responseObj, null, 2) : 'Draft saved.';
315
+ const notes = [rewriteNote, noOpWarning].filter((n) => n !== null).join('\n\n');
316
+ return textResponse(notes ? `${notes}\n\n${text}` : text);
287
317
  });
288
318
  server.registerTool('ofw_delete_draft', {
289
319
  description: 'Delete a draft message from OurFamilyWizard. Also removes the draft from the local cache.',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ofw-mcp",
3
- "version": "2.0.13",
3
+ "version": "2.0.15",
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>",
@@ -21,12 +21,13 @@
21
21
  ],
22
22
  "scripts": {
23
23
  "build": "tsc && npm run bundle",
24
- "bundle": "esbuild src/index.ts --bundle --platform=node --format=esm --external:dotenv --outfile=dist/bundle.js",
24
+ "bundle": "esbuild src/index.ts --bundle --platform=node --format=esm --external:dotenv --banner:js='import { createRequire as __createRequire } from \"module\"; const require = __createRequire(import.meta.url);' --outfile=dist/bundle.js",
25
25
  "dev": "node --env-file=.env dist/index.js",
26
26
  "test": "vitest run",
27
27
  "test:watch": "vitest"
28
28
  },
29
29
  "dependencies": {
30
+ "@fetchproxy/bootstrap": "^0.4.2",
30
31
  "@modelcontextprotocol/sdk": "^1.29.0",
31
32
  "dotenv": "^17.4.0",
32
33
  "zod": "^4.4.2"
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.13",
9
+ "version": "2.0.15",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ofw-mcp",
14
- "version": "2.0.13",
14
+ "version": "2.0.15",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },