ofw-mcp 2.11.0 → 2.13.0

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
@@ -3,6 +3,7 @@ import { TokenManager } from '@chrischall/mcp-utils/session';
3
3
  import { dirname, join } from 'path';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { resolveAuth } from './auth.js';
6
+ import { createSessionCache, reportCacheWriteFailure } from './session-cache.js';
6
7
  import { BASE_URL, OFW_PROTOCOL_HEADERS, OFW_TOKEN_TTL_MS, OFW_TOKEN_EXPIRY_SKEW_MS } from './protocol.js';
7
8
  // Load .env for local dev; silently skip if dotenv is unavailable (e.g. mcpb
8
9
  // bundle). loadDotenvSafely applies override:false + quiet:true and swallows a
@@ -62,9 +63,9 @@ export class OFWClient {
62
63
  // Bearer-token lifecycle is delegated to the shared, race-safe TokenManager
63
64
  // (proactive refresh inside the skew window, single-flight refresh so a burst
64
65
  // of concurrent callers coalesces onto ONE `resolveAuth()`, and a 401-replay
65
- // guarded against double-refresh). It is created lazily, seeded with an
66
- // already-expired placeholder token so the first request drives the refresh
67
- // callback — i.e. the original "log in on first request" behavior.
66
+ // guarded against double-refresh). It is created lazily, and mints on first
67
+ // use through the function form of `initial` — see `mint` below for why that
68
+ // form rather than a seeded placeholder.
68
69
  tokenManager;
69
70
  // Optional injected auth resolver. When set, the refresh callback uses it
70
71
  // instead of the module-level global `resolveAuth` (env-var → fetchproxy
@@ -78,22 +79,33 @@ export class OFWClient {
78
79
  }
79
80
  getTokenManager() {
80
81
  if (!this.tokenManager) {
82
+ // Minting and renewing are the SAME operation here — OFW has no refresh
83
+ // grant — so one function serves as both `initial` and `refresh`. It has
84
+ // to be the function form: the eager object form skips persistence, so
85
+ // the expired placeholder that used to sit here would have meant the
86
+ // cache was written but never read.
87
+ const mint = async () => {
88
+ const { token, expiresAt } = await (this.authResolver ?? resolveAuth)();
89
+ return {
90
+ accessToken: token,
91
+ refreshToken: OFW_REFRESH_SENTINEL,
92
+ expiresAt: (expiresAt ?? new Date(Date.now() + OFW_TOKEN_TTL_MS)).getTime(),
93
+ };
94
+ };
81
95
  this.tokenManager = new TokenManager({
82
- initial: { accessToken: '', refreshToken: OFW_REFRESH_SENTINEL, expiresAt: 0 },
96
+ initial: mint,
97
+ persistence: createSessionCache({ injectedResolver: this.authResolver !== undefined }) ?? undefined,
98
+ onPersistError: reportCacheWriteFailure,
99
+ // A failed renewal IS a failed login here, so the library's
100
+ // re-mint-on-revoked recovery would just repeat the call that failed.
101
+ isRefreshRevoked: () => false,
83
102
  skewMs: OFW_TOKEN_EXPIRY_SKEW_MS,
84
103
  // Map OFW's mint/refresh onto the refresh callback. `resolveAuth()`
85
104
  // returns a token and a best-effort expiry; when the fetchproxy path
86
105
  // can't supply one we fall back to the same 6h estimate the password
87
106
  // path uses (the 401-replay covers a wrong guess). We re-arm the
88
107
  // sentinel so the manager can refresh again later.
89
- refresh: async () => {
90
- const { token, expiresAt } = await (this.authResolver ?? resolveAuth)();
91
- return {
92
- accessToken: token,
93
- refreshToken: OFW_REFRESH_SENTINEL,
94
- expiresAt: (expiresAt ?? new Date(Date.now() + OFW_TOKEN_TTL_MS)).getTime(),
95
- };
96
- },
108
+ refresh: mint,
97
109
  });
98
110
  }
99
111
  return this.tokenManager;
package/dist/index.js CHANGED
@@ -35,7 +35,7 @@ const nodeAttachmentIO = new NodeAttachmentIO();
35
35
  // always succeeds before any credential check runs.
36
36
  await runMcp({
37
37
  name: 'ofw',
38
- version: '2.11.0', // x-release-please-version
38
+ version: '2.13.0', // x-release-please-version
39
39
  deps: client,
40
40
  tools: [
41
41
  registerUserTools,
@@ -0,0 +1,78 @@
1
+ import { createFileStatePersistence, resolveStateFile, } from '@chrischall/mcp-utils/session';
2
+ import { readEnvVar, parseBoolEnv } from '@chrischall/mcp-utils';
3
+ /**
4
+ * Where the OFW session token is cached between runs.
5
+ *
6
+ * OFW has no OAuth refresh token — every renewal re-runs the full
7
+ * `resolveAuth()` (a password POST, or a fetchproxy snapshot). The token itself
8
+ * is good for six hours (`OFW_TOKEN_TTL_MS`), so on a scale-to-zero host, where
9
+ * children idle out after ten minutes and every start is a cold one, the same
10
+ * six-hour token was being re-minted many times over. Caching it turns those
11
+ * restarts into zero-cost ones; an expired token still costs exactly what it did
12
+ * before.
13
+ */
14
+ export function sessionCachePath(env = process.env) {
15
+ return resolveStateFile({
16
+ env,
17
+ envVar: 'OFW_SESSION_FILE',
18
+ subdir: '.ofw-mcp',
19
+ fileName: 'session.json',
20
+ });
21
+ }
22
+ /** Only a token pair is ever stored — never the username or password. */
23
+ function isTokens(raw) {
24
+ if (raw === null || typeof raw !== 'object')
25
+ return false;
26
+ const t = raw;
27
+ return (typeof t.accessToken === 'string' &&
28
+ t.accessToken !== '' &&
29
+ typeof t.expiresAt === 'number' &&
30
+ (t.refreshToken === undefined || typeof t.refreshToken === 'string'));
31
+ }
32
+ /**
33
+ * The session cache, or `null` when it must not be used.
34
+ *
35
+ * Three ways it comes back `null`, each deliberate:
36
+ *
37
+ * - `OFW_SESSION_CACHE=false` — the operator opted out.
38
+ * - **A caller-injected auth resolver.** That is the per-user hosted path, and
39
+ * this registration declares no `identity.perUserChild`, so one process can
40
+ * serve several people. A single cache file would hand one user's session to
41
+ * the next; until the child is per-user, that path simply does not cache.
42
+ * - **No env credentials.** The fetchproxy path authenticates from a signed-in
43
+ * browser tab rather than a stored secret, so there is nothing stable to bind
44
+ * a cached token to — and it is local-only, where cold starts are rare.
45
+ *
46
+ * When it is used, the record is bound to the credentials that minted it, so
47
+ * rotating either discards it. Only a salted digest is written.
48
+ */
49
+ export function createSessionCache(opts = {}) {
50
+ const env = opts.env ?? process.env;
51
+ if (opts.injectedResolver === true)
52
+ return null;
53
+ if (!parseBoolEnv('OFW_SESSION_CACHE', { env, default: true }))
54
+ return null;
55
+ const username = readEnvVar('OFW_USERNAME', { env });
56
+ const password = readEnvVar('OFW_PASSWORD', { env });
57
+ if (username === undefined || password === undefined)
58
+ return null;
59
+ return createFileStatePersistence({
60
+ filePath: sessionCachePath(env),
61
+ // Joined on a NUL, written as an escape rather than a literal byte: a
62
+ // password may contain spaces, so a space-joined pair could collide with
63
+ // a different pair by shifting the boundary between the two halves.
64
+ boundTo: [username.trim().toLowerCase(), password].join('\u0000'),
65
+ validate: (raw) => (isTokens(raw) ? raw : null),
66
+ });
67
+ }
68
+ /**
69
+ * Report a cache write that failed. Not fatal: OFW tokens are re-mintable from
70
+ * the credentials in the environment, so a lost write costs the next start a
71
+ * login rather than access. Still worth saying — a read-only data dir otherwise
72
+ * looks exactly like a server that never caches. stderr only; stdout is JSON-RPC.
73
+ */
74
+ export function reportCacheWriteFailure(err) {
75
+ const detail = err instanceof Error ? err.message : String(err);
76
+ console.error(`[ofw-mcp] could not cache the session token (${detail}); continuing without the ` +
77
+ 'cache — every restart will re-authenticate until this is fixed.');
78
+ }
package/mint.yaml ADDED
@@ -0,0 +1,143 @@
1
+ version: 1
2
+ name: OurFamilyWizard
3
+ slug: ofw
4
+ summary: >-
5
+ OurFamilyWizard co-parenting tools for Claude — messages, calendar,
6
+ expenses, and journal
7
+ #
8
+ # Hosting note (a comment, not user-facing summary text): this is a
9
+ # BROWSER-BRIDGE MCP — it reaches its site through the user's signed-in
10
+ # tab via the fetchproxy bridge. A bridged registration also needs runtime
11
+ # `fly-shared`, `bridge: true` and a `bridgePortEnv`, which are registration
12
+ # fields this manifest has no schema for (set them over the control API).
13
+ # `state.dataDir` below is required for `bridge`.
14
+ env:
15
+ - name: OFW_USERNAME
16
+ required: false
17
+ help: >-
18
+ Your OurFamilyWizard login email address. Optional — if omitted, the
19
+ server falls back to the fetchproxy browser extension (requires being
20
+ signed in to ourfamilywizard.com).
21
+ - name: OFW_PASSWORD
22
+ secret: true
23
+ required: false
24
+ help: >-
25
+ Your OurFamilyWizard password. Optional — see OFW_USERNAME.
26
+ - name: OFW_WRITE_MODE
27
+ required: false
28
+ help: >-
29
+ Write-tool gate: "none" registers no write tools; "drafts" registers
30
+ draft-level writes only (save/delete drafts, upload attachments); "all"
31
+ registers everything (default). Unrecognized values fail closed to "none".
32
+ - name: OFW_CALENDAR_WRITES
33
+ required: false
34
+ help: >-
35
+ Set to "true" to register calendar write tools (create/update/delete
36
+ event) in "drafts" write mode. Events have no draft stage but are
37
+ reversible. Never overrides "none".
38
+ - name: OFW_ALLOW_MARK_READ
39
+ required: false
40
+ help: >-
41
+ Default "true". Set "false" to forbid any tool from marking a message read
42
+ on OurFamilyWizard - reading a body for the first time stamps a
43
+ co-parent-visible "First Viewed" time that cannot be undone. A ceiling: no
44
+ per-call argument can raise it.
45
+ - name: OFW_FETCH_UNREAD_BODIES
46
+ required: false
47
+ help: >-
48
+ Default "false". Whether ofw_sync_messages fetches unread inbox bodies by
49
+ default (each fetch stamps a First Viewed time). Capped by
50
+ OFW_ALLOW_MARK_READ.
51
+ - name: OFW_INLINE_ATTACHMENTS
52
+ required: false
53
+ help: >-
54
+ When on, ofw_download_attachment returns bytes inline as MCP content
55
+ (images render directly; other files come back as embedded resources)
56
+ instead of writing to disk. Recommended for sandboxed hosts like Claude
57
+ Desktop where the model cannot read files written to ~/Downloads. Callers
58
+ can still override per-call via the tool's `inline` argument.
59
+ - name: OFW_ATTACHMENTS_DIR
60
+ required: false
61
+ help: >-
62
+ Directory where ofw_download_attachment writes files when not returning
63
+ inline. Defaults to ~/Downloads/ofw-mcp/. Pick a directory that is
64
+ readable by your MCP host.
65
+ - name: DISPLAY_TZ
66
+ required: false
67
+ help: >-
68
+ IANA time zone (e.g. America/New_York) that every rendered *…Display*
69
+ field uses, and the zone a naive OFW timestamp is assumed to be in. Must
70
+ match the OFW account's own zone. Never a fixed offset — that would be an
71
+ hour wrong for half the year.
72
+ - name: OFW_DEBUG_LOG
73
+ required: false
74
+ help: >-
75
+ Set to 1 to write verbose request/response diagnostics to stderr. Off by
76
+ default; useful when a read is failing and you need to see the upstream
77
+ exchange.
78
+ - name: OFW_AUTO_REFRESH
79
+ required: false
80
+ help: >-
81
+ Set to true and read tools sync the backing folders themselves and answer
82
+ from the refreshed cache, instead of refusing a stale read. The refusal
83
+ still fires if the refresh does not make the read verifiable.
84
+ - name: OFW_CACHE_DIR
85
+ required: false
86
+ help: >-
87
+ Directory for persisted session/cache state. Relates to state.dataDir
88
+ below; leave unset to use the default under $HOME.
89
+ - name: OFW_CACHE_IDENTITY
90
+ required: false
91
+ help: >-
92
+ Labels the per-user SQLite cache file. Useful when authenticating via the
93
+ browser bridge, where OFW_USERNAME is not set and the cache would
94
+ otherwise fall back to a shared default name.
95
+ - name: OFW_DISABLE_FETCHPROXY
96
+ required: false
97
+ help: >-
98
+ Set to 1 to disable the browser-bridge fallback, making missing
99
+ credentials a hard error. Recommended for a hosted registration, which has
100
+ no browser to fall back to.
101
+ - name: OFW_FRESHNESS_TTL_SECONDS
102
+ required: false
103
+ help: >-
104
+ Age in seconds past which a read result's freshness block downgrades to
105
+ "unverified" and grows a warning (default 300). Unset means never
106
+ downgrade.
107
+ - name: OFW_REQUEST_TIMEOUT_MS
108
+ required: false
109
+ help: >-
110
+ Per-request timeout in milliseconds. Raise it if calls time out on slow
111
+ upstream responses.
112
+ - name: OFW_SYNC_MAX_REQUESTS
113
+ required: false
114
+ help: >-
115
+ Caps the OFW requests one ofw_sync_messages call may make before pausing;
116
+ the next call resumes where it left off. Set a positive integer when
117
+ hosted, where a per-call request cap can otherwise truncate a deep
118
+ backfill.
119
+ - name: OFW_SESSION_CACHE
120
+ required: false
121
+ help: >-
122
+ Set to false to disable the on-disk session cache and re-authenticate on
123
+ every process start. Defaults to enabled.
124
+ - name: OFW_SESSION_FILE
125
+ required: false
126
+ help: >-
127
+ Absolute path for the session cache file. Defaults to
128
+ $MCP_DATA_DIR/.ofw-mcp/session.json.
129
+ state:
130
+ dataDir: true
131
+ reason: >-
132
+ Two things live here. The fetchproxy identity is at
133
+ $HOME/.fetchproxy/identity/<name>.json and the pair code derives from it —
134
+ without a persistent $HOME every cold start mints a fresh identity and
135
+ re-prompts pairing in the browser, and the API refuses bridge without it.
136
+ The session token is cached at $MCP_DATA_DIR/.ofw-mcp/session.json (0600);
137
+ OFW has no refresh grant, so without it every cold start re-runs the full
138
+ login for a token that was still good for up to six hours.
139
+ egress:
140
+ allow:
141
+ # Only hosts the SERVER process actually fetches. Hosts that appear
142
+ # solely in a URL this server BUILDS and returns are excluded.
143
+ - ofw.ourfamilywizard.com
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ofw-mcp",
3
- "version": "2.11.0",
3
+ "version": "2.13.0",
4
4
  "license": "MIT",
5
5
  "mcpName": "io.github.chrischall/ofw-mcp",
6
6
  "description": "OurFamilyWizard MCP server for Claude — developed and maintained by AI (Claude Code)",
@@ -21,19 +21,21 @@
21
21
  ".claude-plugin",
22
22
  "skills",
23
23
  ".mcp.json",
24
- "server.json"
24
+ "server.json",
25
+ "mint.yaml"
25
26
  ],
26
27
  "scripts": {
27
28
  "build": "tsc && npm run bundle",
28
29
  "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",
29
30
  "dev": "node --env-file=.env dist/index.js",
30
- "test": "vitest run",
31
- "test:coverage": "vitest run --coverage",
32
- "test:watch": "vitest"
31
+ "test": "npm run typecheck && vitest run",
32
+ "test:coverage": "npm run typecheck && vitest run --coverage",
33
+ "test:watch": "vitest",
34
+ "typecheck": "tsc -p tsconfig.json --noEmit"
33
35
  },
34
36
  "dependencies": {
35
- "@chrischall/mcp-utils": "^0.14.0",
36
- "@fetchproxy/bootstrap": "^2.0.0",
37
+ "@chrischall/mcp-utils": "^0.18.0",
38
+ "@fetchproxy/bootstrap": "^2.2.0",
37
39
  "@modelcontextprotocol/sdk": "^1.29.0",
38
40
  "dotenv": "^17.4.2",
39
41
  "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.11.0",
9
+ "version": "2.13.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ofw-mcp",
14
- "version": "2.11.0",
14
+ "version": "2.13.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -52,6 +52,18 @@
52
52
  "description": "Default \"false\". Whether ofw_sync_messages fetches unread inbox bodies by default (each fetch stamps a First Viewed time). Capped by OFW_ALLOW_MARK_READ.",
53
53
  "isRequired": false,
54
54
  "format": "string"
55
+ },
56
+ {
57
+ "name": "OFW_SESSION_CACHE",
58
+ "description": "Set to false to disable the on-disk session cache and re-authenticate on every process start. Defaults to enabled.",
59
+ "isRequired": false,
60
+ "format": "string"
61
+ },
62
+ {
63
+ "name": "OFW_SESSION_FILE",
64
+ "description": "Absolute path for the session cache file. Defaults to $MCP_DATA_DIR/.ofw-mcp/session.json.",
65
+ "isRequired": false,
66
+ "format": "string"
55
67
  }
56
68
  ]
57
69
  }
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: ofw-mcp
2
+ name: ofw
3
3
  description: This skill should be used when the user asks about OurFamilyWizard (OFW) co-parenting data. Triggers on phrases like "check OFW", "OurFamilyWizard inbox", "OFW messages", "OFW calendar", "OFW expenses", "what did my co-parent say", "log an expense in OFW", "OFW journal", or any request involving co-parenting messages, calendar events, shared expenses, or journal entries.
4
4
  ---
5
5