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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/dist/auth.js +86 -56
- package/dist/bundle.js +630 -224
- package/dist/client.js +24 -12
- package/dist/index.js +1 -1
- package/dist/session-cache.js +78 -0
- package/mint.yaml +143 -0
- package/package.json +9 -7
- package/server.json +14 -2
- package/skills/ofw/SKILL.md +1 -1
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,
|
|
66
|
-
//
|
|
67
|
-
//
|
|
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:
|
|
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:
|
|
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.
|
|
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.
|
|
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.
|
|
36
|
-
"@fetchproxy/bootstrap": "^2.
|
|
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.
|
|
9
|
+
"version": "2.13.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "ofw-mcp",
|
|
14
|
-
"version": "2.
|
|
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
|
}
|
package/skills/ofw/SKILL.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
name: ofw
|
|
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
|
|