ofw-mcp 2.2.0 → 2.3.1

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/cache.js CHANGED
@@ -182,6 +182,7 @@ export function countMessages(opts) {
182
182
  const { where, params } = buildMessageFilter(opts);
183
183
  const r = db.prepare(`SELECT COUNT(*) as n FROM messages ${where}`)
184
184
  .get(...params);
185
+ /* v8 ignore next -- SELECT COUNT(*) always returns exactly one row; the ?./?? are defensive */
185
186
  return r?.n ?? 0;
186
187
  }
187
188
  function draftFromDb(r) {
package/dist/client.js CHANGED
@@ -1,17 +1,14 @@
1
+ import { loadDotenvSafely } from '@chrischall/mcp-utils';
1
2
  import { dirname, join } from 'path';
2
3
  import { fileURLToPath } from 'url';
3
4
  import { resolveAuth } from './auth.js';
4
5
  import { parseBoolEnv } from './config.js';
5
6
  import { BASE_URL, OFW_PROTOCOL_HEADERS, OFW_TOKEN_TTL_MS, OFW_TOKEN_EXPIRY_SKEW_MS } from './protocol.js';
6
- // Load .env for local dev; silently skip if dotenv is unavailable (e.g. mcpb bundle)
7
- try {
8
- const { config } = await import('dotenv');
9
- const __dirname = dirname(fileURLToPath(import.meta.url));
10
- config({ path: join(__dirname, '..', '.env'), override: false, quiet: true });
11
- }
12
- catch {
13
- // not available — rely on process.env (mcpb sets credentials via mcp_config.env)
14
- }
7
+ // Load .env for local dev; silently skip if dotenv is unavailable (e.g. mcpb
8
+ // bundle). loadDotenvSafely applies override:false + quiet:true and swallows a
9
+ // missing dotenv module, matching the prior inline try/catch exactly.
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ await loadDotenvSafely({ path: join(__dirname, '..', '.env') });
15
12
  // Parse a Content-Disposition header for a filename. Prefers RFC 6266
16
13
  // `filename*=UTF-8''…` (percent-decoded) and falls back to `filename="…"`.
17
14
  function parseContentDispositionFilename(cd) {
@@ -36,6 +33,7 @@ function debugLogEnabled() {
36
33
  }
37
34
  function redactHeaders(h) {
38
35
  const out = { ...h };
36
+ /* v8 ignore next -- request headers always carry Authorization (set in request()); the guard is defensive for arbitrary header maps */
39
37
  if (out.Authorization)
40
38
  out.Authorization = `Bearer ${out.Authorization.slice(7, 17)}…`;
41
39
  return out;
package/dist/config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
+ import { parseBoolEnv as parseBoolEnvUtil } from '@chrischall/mcp-utils';
4
5
  // Cache identity drives the per-user SQLite DB filename. Order of preference:
5
6
  // 1. OFW_CACHE_IDENTITY — explicit override for users who want to label the
6
7
  // cache themselves (e.g. when authing via fetchproxy and OFW_USERNAME is
@@ -45,12 +46,13 @@ export function getAttachmentsDir() {
45
46
  * (case-insensitive, trimmed). Anything else — unset, empty, or other
46
47
  * values — is false. Used for OFW_INLINE_ATTACHMENTS, OFW_DISABLE_FETCHPROXY,
47
48
  * OFW_DEBUG_LOG, etc.
49
+ *
50
+ * Delegates to @chrischall/mcp-utils' `parseBoolEnv` (which also recognizes
51
+ * the falsy set 0/false/no/off — behavior-equivalent here since callers only
52
+ * care about the truthy case and everything else defaults to false).
48
53
  */
49
54
  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());
55
+ return parseBoolEnvUtil(name);
54
56
  }
55
57
  // Default for ofw_download_attachment's `inline` arg when the caller doesn't
56
58
  // pass one. Set OFW_INLINE_ATTACHMENTS=true to have attachments returned as
package/dist/index.js CHANGED
@@ -9,20 +9,29 @@ process.emit = function (event, ...args) {
9
9
  }
10
10
  return originalEmit(event, ...args);
11
11
  };
12
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
13
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
12
+ import { runMcp } from '@chrischall/mcp-utils';
14
13
  import { client } from './client.js';
15
14
  import { registerUserTools } from './tools/user.js';
16
15
  import { registerMessageTools } from './tools/messages.js';
17
16
  import { registerCalendarTools } from './tools/calendar.js';
18
17
  import { registerExpenseTools } from './tools/expenses.js';
19
18
  import { registerJournalTools } from './tools/journal.js';
20
- const server = new McpServer({ name: 'ofw', version: '2.2.0' }); // x-release-please-version
21
- registerUserTools(server, client);
22
- registerMessageTools(server, client);
23
- registerCalendarTools(server, client);
24
- registerExpenseTools(server, client);
25
- registerJournalTools(server, client);
26
- console.error('[ofw-mcp] This project was developed and is maintained by AI (Claude Sonnet 4.6). Use at your own discretion.');
27
- const transport = new StdioServerTransport();
28
- await server.connect(transport);
19
+ // runMcp builds the McpServer, applies the registrars (with `client` threaded
20
+ // through as deps), prints the banner to stderr, wires SIGINT/SIGTERM graceful
21
+ // shutdown, and connects the stdio transport. The deferred-config-error pattern
22
+ // is preserved: `client` is constructed at module load in ./client.js (auth is
23
+ // resolved lazily on the first tool call), so the host's initial tools/list
24
+ // always succeeds before any credential check runs.
25
+ await runMcp({
26
+ name: 'ofw',
27
+ version: '2.3.1', // x-release-please-version
28
+ deps: client,
29
+ tools: [
30
+ registerUserTools,
31
+ registerMessageTools,
32
+ registerCalendarTools,
33
+ registerExpenseTools,
34
+ registerJournalTools,
35
+ ],
36
+ banner: '[ofw-mcp] This project was developed and is maintained by AI (Claude Sonnet 4.6). Use at your own discretion.',
37
+ });
@@ -1,10 +1,9 @@
1
- import { isAbsolute, join, resolve } from 'node:path';
2
- export function jsonResponse(payload) {
3
- return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
4
- }
5
- export function textResponse(text) {
6
- return { content: [{ type: 'text', text }] };
7
- }
1
+ import { expandPath as expandPathUtil, rawTextResult, textResult } from '@chrischall/mcp-utils';
2
+ // Pretty-printed JSON tool result. Thin wrapper over @chrischall/mcp-utils'
3
+ // `textResult` so the rest of the codebase keeps the local name.
4
+ export const jsonResponse = textResult;
5
+ // Raw-string tool result. Wrapper over @chrischall/mcp-utils' `rawTextResult`.
6
+ export const textResponse = rawTextResult;
8
7
  // Translates OFW API recipient shape into the cache's normalized Recipient.
9
8
  // Used wherever we surface or persist recipients (sync, get_message, send,
10
9
  // save_draft) — all five call sites had near-identical inline mappings.
@@ -15,11 +14,9 @@ export function mapRecipients(items) {
15
14
  viewedAt: r.viewed?.dateTime ?? null,
16
15
  }));
17
16
  }
18
- // Expand a user-provided path: ~ → $HOME, relative → absolute.
19
- export function expandPath(p) {
20
- const expanded = p.startsWith('~/') ? join(process.env.HOME ?? '', p.slice(2)) : p;
21
- return isAbsolute(expanded) ? expanded : resolve(expanded);
22
- }
17
+ // Expand a user-provided path: ~ → home, relative → absolute. Re-exports
18
+ // @chrischall/mcp-utils' `expandPath`.
19
+ export const expandPath = expandPathUtil;
23
20
  /**
24
21
  * POST a payload to /pub/v3/messages, then immediately GET the detail
25
22
  * endpoint for the resulting message id. This is the only correct way to
@@ -4,6 +4,7 @@ import { listMessages, countMessages, listDrafts, getMessage, upsertMessage, ups
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 { fileBlob } from '@chrischall/mcp-utils';
7
8
  import { expandPath, jsonResponse, mapRecipients, postMessageAndRefetch, textResponse } from './_shared.js';
8
9
  // Lightweight mime sniff from extension. OFW re-derives mime from the filename
9
10
  // server-side anyway, so this is just a polite Content-Type for the Blob.
@@ -425,12 +426,12 @@ export function registerMessageTools(server, client) {
425
426
  const stat = statSync(abs); // throws if missing
426
427
  if (!stat.isFile())
427
428
  throw new Error(`Not a file: ${abs}`);
428
- const buf = readFileSync(abs);
429
429
  const fileName = basename(abs);
430
430
  const mime = mimeFromName(fileName);
431
431
  // Build the multipart payload matching the OFW web UI's request shape.
432
432
  const form = new FormData();
433
- form.append('file', new Blob([new Uint8Array(buf)], { type: mime }), fileName);
433
+ // fileBlob streams the file off disk (a file-backed Blob) instead of buffering it.
434
+ form.append('file', await fileBlob(abs, { type: mime }), fileName);
434
435
  form.append('source', 'message');
435
436
  form.append('description', args.description ?? fileName);
436
437
  form.append('label', args.label ?? fileName);
@@ -445,7 +446,7 @@ export function registerMessageTools(server, client) {
445
446
  fileName: meta.fileName ?? fileName,
446
447
  label: meta.label ?? args.label ?? fileName,
447
448
  mimeType: meta.fileType ?? mime,
448
- sizeBytes: typeof meta.sizeInBytes === 'number' ? meta.sizeInBytes : buf.length,
449
+ sizeBytes: typeof meta.sizeInBytes === 'number' ? meta.sizeInBytes : stat.size,
449
450
  metadata: meta,
450
451
  messageId: 0,
451
452
  });
@@ -453,7 +454,7 @@ export function registerMessageTools(server, client) {
453
454
  fileId: meta.fileId,
454
455
  fileName: meta.fileName ?? fileName,
455
456
  mimeType: meta.fileType ?? mime,
456
- sizeBytes: meta.sizeInBytes ?? buf.length,
457
+ sizeBytes: meta.sizeInBytes ?? stat.size,
457
458
  shareClass: meta.shareClass ?? args.shareClass ?? 'PRIVATE',
458
459
  note: 'Pass this fileId to ofw_send_message or ofw_save_draft in myFileIDs to attach it.',
459
460
  });
@@ -476,6 +477,7 @@ export function registerMessageTools(server, client) {
476
477
  // sentinel — gets re-linked if a message later references this file.
477
478
  await fetchAttachmentMeta(client, fileId, 0);
478
479
  cached = getAttachment(fileId);
480
+ /* v8 ignore next -- fetchAttachmentMeta persists the row it just fetched; a still-null read here is an unreachable storage failure */
479
481
  if (!cached)
480
482
  throw new Error(`failed to fetch metadata for fileId ${fileId}`);
481
483
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ofw-mcp",
3
- "version": "2.2.0",
3
+ "version": "2.3.1",
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>",
@@ -24,11 +24,12 @@
24
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
+ "test:coverage": "vitest run --coverage",
27
28
  "test:watch": "vitest"
28
29
  },
29
30
  "dependencies": {
30
- "@fetchproxy/bootstrap": "^0.8.0",
31
- "@fetchproxy/server": "^0.8.0",
31
+ "@chrischall/mcp-utils": "^0.4.0",
32
+ "@fetchproxy/bootstrap": "^0.11.0",
32
33
  "@modelcontextprotocol/sdk": "^1.29.0",
33
34
  "dotenv": "^17.4.2",
34
35
  "zod": "^4.4.3"
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.2.0",
9
+ "version": "2.3.1",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ofw-mcp",
14
- "version": "2.2.0",
14
+ "version": "2.3.1",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },