ofw-mcp 2.4.2 → 2.4.4
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/dist/auth.js +6 -14
- package/dist/bundle.js +297 -145
- package/dist/client.js +4 -10
- package/dist/config.js +2 -21
- package/dist/index.js +1 -1
- package/dist/sync.js +7 -7
- package/dist/tools/_shared.js +12 -3
- package/dist/tools/messages.js +11 -6
- package/package.json +3 -3
- package/server.json +2 -2
- package/dist/validate.js +0 -35
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
},
|
|
7
7
|
"metadata": {
|
|
8
8
|
"description": "OurFamilyWizard tools for Claude Code",
|
|
9
|
-
"version": "2.4.
|
|
9
|
+
"version": "2.4.4"
|
|
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.4.
|
|
17
|
+
"version": "2.4.4",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "Chris Chall"
|
|
20
20
|
},
|
package/dist/auth.js
CHANGED
|
@@ -45,22 +45,11 @@
|
|
|
45
45
|
// - `./auth-password.js` (loginWithPassword) is a separate module
|
|
46
46
|
// specifically so it can be mocked here too. This keeps the
|
|
47
47
|
// selection logic independent of either implementation.
|
|
48
|
-
import { readEnvVar } from '@chrischall/mcp-utils';
|
|
48
|
+
import { parseBoolEnv, readEnvVar } from '@chrischall/mcp-utils';
|
|
49
49
|
import { bootstrap } from '@fetchproxy/bootstrap';
|
|
50
50
|
import { classifyBridgeError } from '@chrischall/mcp-utils/fetchproxy';
|
|
51
51
|
import { loginWithPassword } from './auth-password.js';
|
|
52
|
-
import { parseBoolEnv } from './config.js';
|
|
53
52
|
import pkg from '../package.json' with { type: 'json' };
|
|
54
|
-
/**
|
|
55
|
-
* Read an env var, trim, and treat blank / `${UNEXPANDED}` placeholders as
|
|
56
|
-
* unset. Defends against MCP hosts that pass `.mcp.json` env blocks through
|
|
57
|
-
* without variable expansion. Delegates to @chrischall/mcp-utils' `readEnvVar`,
|
|
58
|
-
* which applies the identical sanitization (blank, `undefined`/`null`,
|
|
59
|
-
* `${...}` placeholder → unset).
|
|
60
|
-
*/
|
|
61
|
-
function readEnv(key) {
|
|
62
|
-
return readEnvVar(key);
|
|
63
|
-
}
|
|
64
53
|
/** True if the user has explicitly disabled the fetchproxy fallback. */
|
|
65
54
|
function fetchproxyDisabled() {
|
|
66
55
|
return parseBoolEnv('OFW_DISABLE_FETCHPROXY');
|
|
@@ -75,8 +64,11 @@ function fetchproxyDisabled() {
|
|
|
75
64
|
*/
|
|
76
65
|
export async function resolveAuth() {
|
|
77
66
|
// ── Path 1: env-var credentials (unchanged from pre-fetchproxy behavior).
|
|
78
|
-
|
|
79
|
-
|
|
67
|
+
// `readEnvVar` trims and treats blank / `"undefined"` / `"null"` /
|
|
68
|
+
// `${UNEXPANDED}` placeholders as unset — defends against MCP hosts that
|
|
69
|
+
// pass `.mcp.json` env blocks through without variable expansion.
|
|
70
|
+
const username = readEnvVar('OFW_USERNAME');
|
|
71
|
+
const password = readEnvVar('OFW_PASSWORD');
|
|
80
72
|
if (username && password) {
|
|
81
73
|
const { token, expiresAt } = await loginWithPassword(username, password);
|
|
82
74
|
return { token, expiresAt, source: 'env' };
|
package/dist/bundle.js
CHANGED
|
@@ -34686,6 +34686,22 @@ async function runMcp(opts) {
|
|
|
34686
34686
|
}
|
|
34687
34687
|
|
|
34688
34688
|
// node_modules/@chrischall/mcp-utils/dist/errors/index.js
|
|
34689
|
+
var McpToolError = class extends Error {
|
|
34690
|
+
/** Actionable remediation text, when one applies. */
|
|
34691
|
+
hint;
|
|
34692
|
+
constructor(message, opts) {
|
|
34693
|
+
super(message, opts?.cause !== void 0 ? { cause: opts.cause } : void 0);
|
|
34694
|
+
this.name = "McpToolError";
|
|
34695
|
+
if (opts?.hint !== void 0)
|
|
34696
|
+
this.hint = opts.hint;
|
|
34697
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
34698
|
+
}
|
|
34699
|
+
};
|
|
34700
|
+
var BEARER_RE = /(bearer\s+)[A-Za-z0-9._~+/=-]{8,}/gi;
|
|
34701
|
+
var JWT_RE = /\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{8,}\b/g;
|
|
34702
|
+
var BASIC_AUTH_RE = /(authorization\s*:\s*basic\s+)[A-Za-z0-9+/=_-]{6,}/gi;
|
|
34703
|
+
var SET_COOKIE_RE = /(\bset-cookie\s*:\s*)([^=;,\s]+)=[^;,\s]*/gi;
|
|
34704
|
+
var COOKIE_HEADER_RE = /((?<!set-)\bcookie\s*:\s*)((?:[^=;,\s]+=[^;,\s]*)(?:;\s*[^=;,\s]+=[^;,\s]*)*)/gi;
|
|
34689
34705
|
var API_KEY_RE = new RegExp([
|
|
34690
34706
|
"sk-[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_-])",
|
|
34691
34707
|
// OpenAI / Anthropic (incl. sk-ant-…)
|
|
@@ -34700,6 +34716,14 @@ var API_KEY_RE = new RegExp([
|
|
|
34700
34716
|
"whsec_[A-Za-z0-9]{16,}\\b"
|
|
34701
34717
|
// webhook signing secret (Stripe-style)
|
|
34702
34718
|
].map((p) => `\\b${p}`).join("|"), "g");
|
|
34719
|
+
var QUERY_SECRET_RE = /([?&](?:access_token|refresh_token|client_secret|api_?key|signature|token|key|sig)=)[^&#\s"'<>`]+/gi;
|
|
34720
|
+
var AWS_SIGV4_RE = /([?&]X-Amz-(?:Signature|Security-Token|Credential)=)[^&#\s"'<>`]+/gi;
|
|
34721
|
+
var JSON_SECRET_KEYS = "access_token|refresh_token|client_secret|api_?key|password|passwd|secret|token";
|
|
34722
|
+
var JSON_SECRET_DQ_RE = new RegExp(`("(?:${JSON_SECRET_KEYS})"\\s*:\\s*")[^"]*(")`, "gi");
|
|
34723
|
+
var JSON_SECRET_SQ_RE = new RegExp(`('(?:${JSON_SECRET_KEYS})'\\s*:\\s*')[^']*(')`, "gi");
|
|
34724
|
+
function redactSecrets(text) {
|
|
34725
|
+
return text.replace(BEARER_RE, "$1[REDACTED]").replace(BASIC_AUTH_RE, "$1[REDACTED]").replace(SET_COOKIE_RE, "$1$2=[REDACTED]").replace(COOKIE_HEADER_RE, (_m, prefix, pairs) => `${prefix}${pairs.replace(/=[^;,\s]*/g, "=[REDACTED]")}`).replace(API_KEY_RE, "[REDACTED]").replace(QUERY_SECRET_RE, "$1[REDACTED]").replace(AWS_SIGV4_RE, "$1[REDACTED]").replace(JSON_SECRET_DQ_RE, "$1[REDACTED]$2").replace(JSON_SECRET_SQ_RE, "$1[REDACTED]$2").replace(JWT_RE, "[REDACTED]");
|
|
34726
|
+
}
|
|
34703
34727
|
|
|
34704
34728
|
// node_modules/@chrischall/mcp-utils/dist/response/index.js
|
|
34705
34729
|
function textResult(data) {
|
|
@@ -34781,6 +34805,21 @@ async function fileBlob(path, opts = {}) {
|
|
|
34781
34805
|
return blob;
|
|
34782
34806
|
}
|
|
34783
34807
|
|
|
34808
|
+
// node_modules/@chrischall/mcp-utils/dist/zod/parse-lenient.js
|
|
34809
|
+
function parseLenient(schema, raw, opts) {
|
|
34810
|
+
const result = schema.safeParse(raw);
|
|
34811
|
+
if (result.success)
|
|
34812
|
+
return result.data;
|
|
34813
|
+
const issues = result.error.issues.map((issue2) => `${issue2.path.join(".") || "(root)"}: ${issue2.message}`).join("; ");
|
|
34814
|
+
if (opts.mode === "strict") {
|
|
34815
|
+
throw new McpToolError(`Unexpected ${opts.context} shape from the upstream API. ${issues}`, {
|
|
34816
|
+
hint: "The upstream API may have changed; the schema needs updating."
|
|
34817
|
+
});
|
|
34818
|
+
}
|
|
34819
|
+
console.error(`[${opts.label}] WARNING: unexpected ${opts.context} shape \u2014 proceeding with the raw response. ${issues}`);
|
|
34820
|
+
return raw;
|
|
34821
|
+
}
|
|
34822
|
+
|
|
34784
34823
|
// node_modules/@chrischall/mcp-utils/dist/zod/index.js
|
|
34785
34824
|
var PositiveInt = external_exports.number().int().positive();
|
|
34786
34825
|
var NonNegInt = external_exports.number().int().nonnegative();
|
|
@@ -34878,7 +34917,7 @@ var TokenManager = class {
|
|
|
34878
34917
|
};
|
|
34879
34918
|
|
|
34880
34919
|
// src/client.ts
|
|
34881
|
-
import { dirname, join as
|
|
34920
|
+
import { dirname, join as join3 } from "path";
|
|
34882
34921
|
import { fileURLToPath } from "url";
|
|
34883
34922
|
|
|
34884
34923
|
// node_modules/@fetchproxy/protocol/dist/frames.js
|
|
@@ -35837,10 +35876,20 @@ async function openEncryptedFrame(sessionKey, frame) {
|
|
|
35837
35876
|
|
|
35838
35877
|
// node_modules/@fetchproxy/server/dist/election.js
|
|
35839
35878
|
import { createServer, Server as HttpServer } from "node:http";
|
|
35879
|
+
var DEFAULT_BIND_TIMEOUT_MS = 5e3;
|
|
35840
35880
|
async function electRole(opts) {
|
|
35841
35881
|
const server = createServer();
|
|
35882
|
+
const bindTimeoutMs = opts.bindTimeoutMs ?? DEFAULT_BIND_TIMEOUT_MS;
|
|
35842
35883
|
return new Promise((resolve2, reject) => {
|
|
35884
|
+
let timer;
|
|
35885
|
+
const clearBindTimer = () => {
|
|
35886
|
+
if (timer) {
|
|
35887
|
+
clearTimeout(timer);
|
|
35888
|
+
timer = void 0;
|
|
35889
|
+
}
|
|
35890
|
+
};
|
|
35843
35891
|
const onError = (e) => {
|
|
35892
|
+
clearBindTimer();
|
|
35844
35893
|
server.removeListener("listening", onListening);
|
|
35845
35894
|
if (e.code === "EADDRINUSE") {
|
|
35846
35895
|
try {
|
|
@@ -35853,11 +35902,25 @@ async function electRole(opts) {
|
|
|
35853
35902
|
}
|
|
35854
35903
|
};
|
|
35855
35904
|
const onListening = () => {
|
|
35905
|
+
clearBindTimer();
|
|
35856
35906
|
server.removeListener("error", onError);
|
|
35857
35907
|
resolve2({ role: "host", server });
|
|
35858
35908
|
};
|
|
35859
35909
|
server.once("error", onError);
|
|
35860
35910
|
server.once("listening", onListening);
|
|
35911
|
+
if (bindTimeoutMs > 0) {
|
|
35912
|
+
timer = setTimeout(() => {
|
|
35913
|
+
server.removeListener("error", onError);
|
|
35914
|
+
server.removeListener("listening", onListening);
|
|
35915
|
+
try {
|
|
35916
|
+
server.close();
|
|
35917
|
+
} catch {
|
|
35918
|
+
}
|
|
35919
|
+
reject(new Error(`fetchproxy: bind to ${opts.host}:${opts.port} timed out after ${bindTimeoutMs}ms (port may be in a bad state)`));
|
|
35920
|
+
}, bindTimeoutMs);
|
|
35921
|
+
if (typeof timer.unref === "function")
|
|
35922
|
+
timer.unref();
|
|
35923
|
+
}
|
|
35861
35924
|
server.listen(opts.port, opts.host);
|
|
35862
35925
|
});
|
|
35863
35926
|
}
|
|
@@ -35950,6 +36013,44 @@ var SessionState = class {
|
|
|
35950
36013
|
}
|
|
35951
36014
|
};
|
|
35952
36015
|
|
|
36016
|
+
// node_modules/@fetchproxy/server/dist/session-ready.js
|
|
36017
|
+
var SESSION_READY_TIMEOUT_MS = 3e4;
|
|
36018
|
+
var FetchproxySessionNotReadyError = class extends Error {
|
|
36019
|
+
reason;
|
|
36020
|
+
pairCode;
|
|
36021
|
+
mcpId;
|
|
36022
|
+
hint;
|
|
36023
|
+
constructor(info) {
|
|
36024
|
+
const pairing = info.pairCode !== null && info.pairCode !== "";
|
|
36025
|
+
const hint = pairing ? `Open the Transporter extension popup and approve pair code ${info.pairCode} for "${info.mcpId}", then retry.` : `The extension is connected but hasn't confirmed a session for "${info.mcpId}" \u2014 sign in to the target site in that browser (and approve the requested scope if it changed), then retry.`;
|
|
36026
|
+
super(`fetchproxy: ${pairing ? "pairing not yet approved" : "no confirmed browser session"} for "${info.mcpId}". ${hint}`);
|
|
36027
|
+
this.name = "FetchproxySessionNotReadyError";
|
|
36028
|
+
this.reason = pairing ? "pair-required" : "not-ready";
|
|
36029
|
+
this.pairCode = pairing ? info.pairCode : null;
|
|
36030
|
+
this.mcpId = info.mcpId;
|
|
36031
|
+
this.hint = hint;
|
|
36032
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
36033
|
+
}
|
|
36034
|
+
};
|
|
36035
|
+
async function awaitSessionReady(ready, opts) {
|
|
36036
|
+
const ms = opts.timeoutMs ?? SESSION_READY_TIMEOUT_MS;
|
|
36037
|
+
if (ms <= 0)
|
|
36038
|
+
return ready;
|
|
36039
|
+
let timer;
|
|
36040
|
+
const timeout = new Promise((_, reject) => {
|
|
36041
|
+
timer = setTimeout(() => {
|
|
36042
|
+
reject(new FetchproxySessionNotReadyError({ mcpId: opts.mcpId, pairCode: opts.pendingPairCode() }));
|
|
36043
|
+
}, ms);
|
|
36044
|
+
timer.unref?.();
|
|
36045
|
+
});
|
|
36046
|
+
try {
|
|
36047
|
+
return await Promise.race([ready, timeout]);
|
|
36048
|
+
} finally {
|
|
36049
|
+
if (timer)
|
|
36050
|
+
clearTimeout(timer);
|
|
36051
|
+
}
|
|
36052
|
+
}
|
|
36053
|
+
|
|
35953
36054
|
// node_modules/@fetchproxy/server/dist/host.js
|
|
35954
36055
|
var PUBLIC_ORIGIN_RE = /^https?:\/\/(?!(127\.0\.0\.1|localhost)(:|$))/i;
|
|
35955
36056
|
var enc2 = new TextEncoder();
|
|
@@ -36037,6 +36138,29 @@ async function startHost(opts) {
|
|
|
36037
36138
|
return;
|
|
36038
36139
|
}
|
|
36039
36140
|
if (frame.type === "hello" && frame.role === "server") {
|
|
36141
|
+
const peerEdPub = fromB64(frame.identityEd25519Pub);
|
|
36142
|
+
const peerSigMsg = concatBytes(enc2.encode(frame.mcpId), fromB64(frame.sessionNonce));
|
|
36143
|
+
const peerSig = fromB64(frame.sessionSig);
|
|
36144
|
+
let peerSigOk = false;
|
|
36145
|
+
try {
|
|
36146
|
+
peerSigOk = await ed25519Verify(peerEdPub, peerSigMsg, peerSig);
|
|
36147
|
+
} catch {
|
|
36148
|
+
peerSigOk = false;
|
|
36149
|
+
}
|
|
36150
|
+
if (!peerSigOk) {
|
|
36151
|
+
console.warn("[fetchproxy] peer hello signature invalid \u2014 refusing registration (possible squatter)");
|
|
36152
|
+
ws.close(1008, "peer hello signature invalid");
|
|
36153
|
+
return;
|
|
36154
|
+
}
|
|
36155
|
+
const existing = peers.get(frame.mcpId);
|
|
36156
|
+
if (existing && existing.ws !== ws) {
|
|
36157
|
+
const existingEdPub = existing.helloFrame.identityEd25519Pub;
|
|
36158
|
+
if (existingEdPub !== frame.identityEd25519Pub) {
|
|
36159
|
+
console.warn("[fetchproxy] peer mcpId already mapped to a different identity \u2014 refusing (mcpId squatting)");
|
|
36160
|
+
ws.close(1008, "mcpId already registered to another identity");
|
|
36161
|
+
return;
|
|
36162
|
+
}
|
|
36163
|
+
}
|
|
36040
36164
|
identified = "peer";
|
|
36041
36165
|
peerMcpId = frame.mcpId;
|
|
36042
36166
|
peers.set(frame.mcpId, { ws, helloFrame: frame });
|
|
@@ -36129,8 +36253,10 @@ async function startHost(opts) {
|
|
|
36129
36253
|
resetSessionPromise();
|
|
36130
36254
|
disconnectListeners.forEach((cb) => cb());
|
|
36131
36255
|
}
|
|
36132
|
-
if (identified === "peer" && peerMcpId)
|
|
36133
|
-
peers.
|
|
36256
|
+
if (identified === "peer" && peerMcpId) {
|
|
36257
|
+
if (peers.get(peerMcpId)?.ws === ws)
|
|
36258
|
+
peers.delete(peerMcpId);
|
|
36259
|
+
}
|
|
36134
36260
|
});
|
|
36135
36261
|
});
|
|
36136
36262
|
return {
|
|
@@ -36146,7 +36272,10 @@ async function startHost(opts) {
|
|
|
36146
36272
|
});
|
|
36147
36273
|
}),
|
|
36148
36274
|
sendOwnInner: async (inner) => {
|
|
36149
|
-
const session = await ownSessionReady
|
|
36275
|
+
const session = await awaitSessionReady(ownSessionReady, {
|
|
36276
|
+
mcpId: opts.ownMcpId,
|
|
36277
|
+
pendingPairCode: () => ownPendingPairCode
|
|
36278
|
+
});
|
|
36150
36279
|
if (!extensionWs)
|
|
36151
36280
|
throw new Error("host: no extension connected");
|
|
36152
36281
|
const sealed = await sealInnerFrame(session.sessionKey, opts.ownMcpId, session.nextOutboundSeq(), inner);
|
|
@@ -36251,7 +36380,10 @@ async function startPeer(opts) {
|
|
|
36251
36380
|
ws,
|
|
36252
36381
|
session: sessionPromise,
|
|
36253
36382
|
sendInner: async (inner) => {
|
|
36254
|
-
await sessionPromise
|
|
36383
|
+
await awaitSessionReady(sessionPromise, {
|
|
36384
|
+
mcpId: opts.mcpId,
|
|
36385
|
+
pendingPairCode: () => pendingPairCode
|
|
36386
|
+
});
|
|
36255
36387
|
const s = session;
|
|
36256
36388
|
const sealed = await sealInnerFrame(s.sessionKey, opts.mcpId, s.nextOutboundSeq(), inner);
|
|
36257
36389
|
ws.send(JSON.stringify(sealed));
|
|
@@ -36917,6 +37049,35 @@ var FetchproxyServer = class {
|
|
|
36917
37049
|
this.keepAliveTimer = null;
|
|
36918
37050
|
}
|
|
36919
37051
|
}
|
|
37052
|
+
/**
|
|
37053
|
+
* Send an inner request frame via whichever bridge handle is active. If the
|
|
37054
|
+
* send throws (e.g. `FetchproxySessionNotReadyError` — the session never
|
|
37055
|
+
* confirmed), the frame never reached the bridge, so no reply will arrive:
|
|
37056
|
+
* drop the just-registered pending resolver for this id (it lives in exactly
|
|
37057
|
+
* one of the op maps — request ids are unique) so it doesn't leak until the
|
|
37058
|
+
* server closes, then rethrow.
|
|
37059
|
+
*/
|
|
37060
|
+
async sendInnerFrame(inner) {
|
|
37061
|
+
try {
|
|
37062
|
+
if (this.hostHandle) {
|
|
37063
|
+
await this.hostHandle.sendOwnInner(inner);
|
|
37064
|
+
} else if (this.peerHandle) {
|
|
37065
|
+
await this.peerHandle.sendInner(inner);
|
|
37066
|
+
}
|
|
37067
|
+
} catch (err) {
|
|
37068
|
+
if ("id" in inner && typeof inner.id === "number") {
|
|
37069
|
+
const { id } = inner;
|
|
37070
|
+
this.pending.delete(id);
|
|
37071
|
+
this.pendingReadCookies.delete(id);
|
|
37072
|
+
this.pendingStorage.delete(id);
|
|
37073
|
+
this.pendingCapture.delete(id);
|
|
37074
|
+
this.pendingRedirect.delete(id);
|
|
37075
|
+
this.pendingDownload.delete(id);
|
|
37076
|
+
this.pendingIdb.delete(id);
|
|
37077
|
+
}
|
|
37078
|
+
throw err;
|
|
37079
|
+
}
|
|
37080
|
+
}
|
|
36920
37081
|
/**
|
|
36921
37082
|
* Single bridge round-trip, wrapped by `fetchTimeoutMs` when set.
|
|
36922
37083
|
* On timeout returns the `{ok:false, kind:'timeout'}` envelope —
|
|
@@ -36928,11 +37089,7 @@ var FetchproxyServer = class {
|
|
|
36928
37089
|
const pending = new Promise((resolve2) => {
|
|
36929
37090
|
this.pending.set(id, resolve2);
|
|
36930
37091
|
});
|
|
36931
|
-
|
|
36932
|
-
await this.hostHandle.sendOwnInner(inner);
|
|
36933
|
-
} else if (this.peerHandle) {
|
|
36934
|
-
await this.peerHandle.sendInner(inner);
|
|
36935
|
-
}
|
|
37092
|
+
await this.sendInnerFrame(inner);
|
|
36936
37093
|
const timeoutMs = this.opts.fetchTimeoutMs;
|
|
36937
37094
|
if (timeoutMs === void 0 || timeoutMs <= 0)
|
|
36938
37095
|
return pending;
|
|
@@ -36961,6 +37118,51 @@ var FetchproxyServer = class {
|
|
|
36961
37118
|
clearTimeout(timer);
|
|
36962
37119
|
}
|
|
36963
37120
|
}
|
|
37121
|
+
/**
|
|
37122
|
+
* FP-B2: bound a non-`fetch` verb's reply wait by `fetchTimeoutMs`.
|
|
37123
|
+
*
|
|
37124
|
+
* Before this, only `fetch()` raced its `pending` reply against a timer
|
|
37125
|
+
* (`_fetchOnceWithTimeout`); `readCookies`, the storage reads,
|
|
37126
|
+
* `capture_request_header`, `capture_redirect`, `download`, and
|
|
37127
|
+
* `read_indexed_db` awaited their `pending` promise with no race, so a
|
|
37128
|
+
* wedged extension hung the tool call indefinitely.
|
|
37129
|
+
*
|
|
37130
|
+
* `pending` is the already-registered reply promise. `pendingMap`/`id`
|
|
37131
|
+
* point at the op-specific map entry so we can drop it on expiry exactly
|
|
37132
|
+
* as `_fetchOnceWithTimeout` does — otherwise a late bridge reply would
|
|
37133
|
+
* resolve into a stale resolver / leak. On timeout we reject with a
|
|
37134
|
+
* `FetchproxyTimeoutError` (the same throwable the convenience methods
|
|
37135
|
+
* already surface for fetch timeouts). `0`/unset opts out (unbounded),
|
|
37136
|
+
* matching the fetch path.
|
|
37137
|
+
*/
|
|
37138
|
+
async _withVerbTimeout(pending, pendingMap, id, url2) {
|
|
37139
|
+
const timeoutMs = this.opts.fetchTimeoutMs;
|
|
37140
|
+
if (timeoutMs === void 0 || timeoutMs <= 0)
|
|
37141
|
+
return pending;
|
|
37142
|
+
let timer;
|
|
37143
|
+
const start = Date.now();
|
|
37144
|
+
try {
|
|
37145
|
+
return await Promise.race([
|
|
37146
|
+
pending,
|
|
37147
|
+
new Promise((_resolve, reject) => {
|
|
37148
|
+
timer = setTimeout(() => {
|
|
37149
|
+
pendingMap.delete(id);
|
|
37150
|
+
reject(new FetchproxyTimeoutError({
|
|
37151
|
+
url: url2,
|
|
37152
|
+
timeoutMs,
|
|
37153
|
+
role: this.role,
|
|
37154
|
+
port: this.opts.port,
|
|
37155
|
+
elapsedMs: Date.now() - start,
|
|
37156
|
+
retryAttempted: false
|
|
37157
|
+
}));
|
|
37158
|
+
}, timeoutMs);
|
|
37159
|
+
})
|
|
37160
|
+
]);
|
|
37161
|
+
} finally {
|
|
37162
|
+
if (timer)
|
|
37163
|
+
clearTimeout(timer);
|
|
37164
|
+
}
|
|
37165
|
+
}
|
|
36964
37166
|
/**
|
|
36965
37167
|
* Map an `ok:false` fetch result to its typed throwable. Centralizes
|
|
36966
37168
|
* the kind-to-error-class switch so `request()` and (via the same
|
|
@@ -37251,12 +37453,8 @@ var FetchproxyServer = class {
|
|
|
37251
37453
|
const pending = new Promise((resolve2) => {
|
|
37252
37454
|
this.pendingReadCookies.set(id, resolve2);
|
|
37253
37455
|
});
|
|
37254
|
-
|
|
37255
|
-
|
|
37256
|
-
} else if (this.peerHandle) {
|
|
37257
|
-
await this.peerHandle.sendInner(inner);
|
|
37258
|
-
}
|
|
37259
|
-
const result = await pending;
|
|
37456
|
+
await this.sendInnerFrame(inner);
|
|
37457
|
+
const result = await this._withVerbTimeout(pending, this.pendingReadCookies, id, `https://${host}`);
|
|
37260
37458
|
if (!result.ok) {
|
|
37261
37459
|
throw new FetchproxyProtocolError(result.error);
|
|
37262
37460
|
}
|
|
@@ -37322,12 +37520,8 @@ var FetchproxyServer = class {
|
|
|
37322
37520
|
const pending = new Promise((resolve2, reject) => {
|
|
37323
37521
|
this.pendingStorage.set(id, { resolve: resolve2, reject });
|
|
37324
37522
|
});
|
|
37325
|
-
|
|
37326
|
-
|
|
37327
|
-
} else if (this.peerHandle) {
|
|
37328
|
-
await this.peerHandle.sendInner(inner);
|
|
37329
|
-
}
|
|
37330
|
-
return pending;
|
|
37523
|
+
await this.sendInnerFrame(inner);
|
|
37524
|
+
return this._withVerbTimeout(pending, this.pendingStorage, id, `https://${host}`);
|
|
37331
37525
|
}
|
|
37332
37526
|
/**
|
|
37333
37527
|
* 0.3.0+: snapshot the next outgoing request's named header. Single-
|
|
@@ -37431,12 +37625,8 @@ var FetchproxyServer = class {
|
|
|
37431
37625
|
const pending = new Promise((resolve2, reject) => {
|
|
37432
37626
|
this.pendingCapture.set(id, { resolve: resolve2, reject });
|
|
37433
37627
|
});
|
|
37434
|
-
|
|
37435
|
-
|
|
37436
|
-
} else if (this.peerHandle) {
|
|
37437
|
-
await this.peerHandle.sendInner(inner);
|
|
37438
|
-
}
|
|
37439
|
-
return pending;
|
|
37628
|
+
await this.sendInnerFrame(inner);
|
|
37629
|
+
return this._withVerbTimeout(pending, this.pendingCapture, id, `https://${opts.host}${opts.path ?? "/*"}`);
|
|
37440
37630
|
}
|
|
37441
37631
|
/**
|
|
37442
37632
|
* Snapshot the redirect target URL of the next request the browser
|
|
@@ -37520,12 +37710,8 @@ var FetchproxyServer = class {
|
|
|
37520
37710
|
const pending = new Promise((resolve2, reject) => {
|
|
37521
37711
|
this.pendingRedirect.set(id, { resolve: resolve2, reject });
|
|
37522
37712
|
});
|
|
37523
|
-
|
|
37524
|
-
|
|
37525
|
-
} else if (this.peerHandle) {
|
|
37526
|
-
await this.peerHandle.sendInner(inner);
|
|
37527
|
-
}
|
|
37528
|
-
return pending;
|
|
37713
|
+
await this.sendInnerFrame(inner);
|
|
37714
|
+
return this._withVerbTimeout(pending, this.pendingRedirect, id, `https://${opts.host}${opts.path ?? "/*"}`);
|
|
37529
37715
|
}
|
|
37530
37716
|
/**
|
|
37531
37717
|
* Download `url` through the BROWSER's own network stack via
|
|
@@ -37606,12 +37792,8 @@ var FetchproxyServer = class {
|
|
|
37606
37792
|
const pending = new Promise((resolve2, reject) => {
|
|
37607
37793
|
this.pendingDownload.set(id, { resolve: resolve2, reject });
|
|
37608
37794
|
});
|
|
37609
|
-
|
|
37610
|
-
|
|
37611
|
-
} else if (this.peerHandle) {
|
|
37612
|
-
await this.peerHandle.sendInner(inner);
|
|
37613
|
-
}
|
|
37614
|
-
return pending;
|
|
37795
|
+
await this.sendInnerFrame(inner);
|
|
37796
|
+
return this._withVerbTimeout(pending, this.pendingDownload, id, opts.url);
|
|
37615
37797
|
}
|
|
37616
37798
|
/**
|
|
37617
37799
|
* 0.4.0+: read declared IndexedDB keys from the user's signed-in
|
|
@@ -37658,12 +37840,8 @@ var FetchproxyServer = class {
|
|
|
37658
37840
|
const pending = new Promise((resolve2, reject) => {
|
|
37659
37841
|
this.pendingIdb.set(id, { resolve: resolve2, reject });
|
|
37660
37842
|
});
|
|
37661
|
-
|
|
37662
|
-
|
|
37663
|
-
} else if (this.peerHandle) {
|
|
37664
|
-
await this.peerHandle.sendInner(inner);
|
|
37665
|
-
}
|
|
37666
|
-
return pending;
|
|
37843
|
+
await this.sendInnerFrame(inner);
|
|
37844
|
+
return this._withVerbTimeout(pending, this.pendingIdb, id, origin);
|
|
37667
37845
|
}
|
|
37668
37846
|
assertScopeSubset(requested, declared, label) {
|
|
37669
37847
|
const undeclared = undeclaredKeys(requested, declared);
|
|
@@ -38104,53 +38282,10 @@ async function loginWithPassword(username, password) {
|
|
|
38104
38282
|
};
|
|
38105
38283
|
}
|
|
38106
38284
|
|
|
38107
|
-
// src/config.ts
|
|
38108
|
-
import { createHash } from "node:crypto";
|
|
38109
|
-
import { homedir as homedir3 } from "node:os";
|
|
38110
|
-
import { join as join3 } from "node:path";
|
|
38111
|
-
function readCacheIdentity() {
|
|
38112
|
-
const explicit = process.env.OFW_CACHE_IDENTITY;
|
|
38113
|
-
if (typeof explicit === "string" && explicit.trim().length > 0) return explicit.trim();
|
|
38114
|
-
const username = process.env.OFW_USERNAME;
|
|
38115
|
-
if (typeof username === "string" && username.trim().length > 0) return username.trim();
|
|
38116
|
-
return "_default";
|
|
38117
|
-
}
|
|
38118
|
-
function getCacheDir() {
|
|
38119
|
-
const override = process.env.OFW_CACHE_DIR;
|
|
38120
|
-
if (override && override.trim().length > 0) return override.trim();
|
|
38121
|
-
return join3(homedir3(), ".cache", "ofw-mcp");
|
|
38122
|
-
}
|
|
38123
|
-
function getCacheDbPath() {
|
|
38124
|
-
const identity = readCacheIdentity();
|
|
38125
|
-
const hash2 = createHash("sha256").update(identity).digest("hex").slice(0, 16);
|
|
38126
|
-
return join3(getCacheDir(), `${hash2}.db`);
|
|
38127
|
-
}
|
|
38128
|
-
function getAttachmentsDir() {
|
|
38129
|
-
const override = process.env.OFW_ATTACHMENTS_DIR;
|
|
38130
|
-
if (override && override.trim().length > 0) return override.trim();
|
|
38131
|
-
return join3(homedir3(), "Downloads", "ofw-mcp");
|
|
38132
|
-
}
|
|
38133
|
-
function parseBoolEnv2(name) {
|
|
38134
|
-
return parseBoolEnv(name);
|
|
38135
|
-
}
|
|
38136
|
-
function getWriteMode() {
|
|
38137
|
-
const raw = process.env.OFW_WRITE_MODE;
|
|
38138
|
-
if (typeof raw !== "string" || raw.trim().length === 0) return "all";
|
|
38139
|
-
const mode = raw.trim().toLowerCase();
|
|
38140
|
-
if (mode === "none" || mode === "drafts" || mode === "all") return mode;
|
|
38141
|
-
console.error(
|
|
38142
|
-
`[ofw-mcp] Unrecognized OFW_WRITE_MODE "${raw.trim()}" \u2014 failing closed to "none" (no write tools registered). Valid values: none, drafts, all.`
|
|
38143
|
-
);
|
|
38144
|
-
return "none";
|
|
38145
|
-
}
|
|
38146
|
-
function getDefaultInlineAttachments() {
|
|
38147
|
-
return parseBoolEnv2("OFW_INLINE_ATTACHMENTS");
|
|
38148
|
-
}
|
|
38149
|
-
|
|
38150
38285
|
// package.json
|
|
38151
38286
|
var package_default = {
|
|
38152
38287
|
name: "ofw-mcp",
|
|
38153
|
-
version: "2.4.
|
|
38288
|
+
version: "2.4.4",
|
|
38154
38289
|
license: "MIT",
|
|
38155
38290
|
mcpName: "io.github.chrischall/ofw-mcp",
|
|
38156
38291
|
description: "OurFamilyWizard MCP server for Claude \u2014 developed and maintained by AI (Claude Code)",
|
|
@@ -38182,14 +38317,14 @@ var package_default = {
|
|
|
38182
38317
|
"test:watch": "vitest"
|
|
38183
38318
|
},
|
|
38184
38319
|
dependencies: {
|
|
38185
|
-
"@chrischall/mcp-utils": "^0.
|
|
38320
|
+
"@chrischall/mcp-utils": "^0.12.0",
|
|
38186
38321
|
"@fetchproxy/bootstrap": "^1.3.0",
|
|
38187
38322
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
38188
38323
|
dotenv: "^17.4.2",
|
|
38189
38324
|
zod: "^4.4.3"
|
|
38190
38325
|
},
|
|
38191
38326
|
devDependencies: {
|
|
38192
|
-
"@types/node": "^
|
|
38327
|
+
"@types/node": "^26.0.0",
|
|
38193
38328
|
"@vitest/coverage-v8": "^4.1.7",
|
|
38194
38329
|
esbuild: "^0.28.0",
|
|
38195
38330
|
typescript: "^6.0.3",
|
|
@@ -38198,15 +38333,12 @@ var package_default = {
|
|
|
38198
38333
|
};
|
|
38199
38334
|
|
|
38200
38335
|
// src/auth.ts
|
|
38201
|
-
function readEnv(key) {
|
|
38202
|
-
return readEnvVar(key);
|
|
38203
|
-
}
|
|
38204
38336
|
function fetchproxyDisabled() {
|
|
38205
|
-
return
|
|
38337
|
+
return parseBoolEnv("OFW_DISABLE_FETCHPROXY");
|
|
38206
38338
|
}
|
|
38207
38339
|
async function resolveAuth() {
|
|
38208
|
-
const username =
|
|
38209
|
-
const password =
|
|
38340
|
+
const username = readEnvVar("OFW_USERNAME");
|
|
38341
|
+
const password = readEnvVar("OFW_PASSWORD");
|
|
38210
38342
|
if (username && password) {
|
|
38211
38343
|
const { token, expiresAt } = await loginWithPassword(username, password);
|
|
38212
38344
|
return { token, expiresAt, source: "env" };
|
|
@@ -38263,7 +38395,7 @@ async function resolveAuth() {
|
|
|
38263
38395
|
|
|
38264
38396
|
// src/client.ts
|
|
38265
38397
|
var __dirname = dirname(fileURLToPath(import.meta.url));
|
|
38266
|
-
await loadDotenvSafely({ path:
|
|
38398
|
+
await loadDotenvSafely({ path: join3(__dirname, "..", ".env") });
|
|
38267
38399
|
function parseContentDispositionFilename(cd) {
|
|
38268
38400
|
const extMatch = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(cd);
|
|
38269
38401
|
if (extMatch) {
|
|
@@ -38278,12 +38410,7 @@ function parseContentDispositionFilename(cd) {
|
|
|
38278
38410
|
return m ? m[1] : null;
|
|
38279
38411
|
}
|
|
38280
38412
|
function debugLogEnabled() {
|
|
38281
|
-
return
|
|
38282
|
-
}
|
|
38283
|
-
function redactHeaders(h) {
|
|
38284
|
-
const out = { ...h };
|
|
38285
|
-
if (out.Authorization) out.Authorization = `Bearer ${out.Authorization.slice(7, 17)}\u2026`;
|
|
38286
|
-
return out;
|
|
38413
|
+
return parseBoolEnv("OFW_DEBUG_LOG");
|
|
38287
38414
|
}
|
|
38288
38415
|
var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
|
|
38289
38416
|
function getRequestTimeoutMs() {
|
|
@@ -38378,7 +38505,7 @@ var OFWClient = class {
|
|
|
38378
38505
|
if (debugLogEnabled()) {
|
|
38379
38506
|
const bodyPreview = body === void 0 ? "<none>" : isFormData ? `<FormData entries=${Array.from(body.keys()).join(",")}>` : JSON.stringify(body);
|
|
38380
38507
|
console.error(`[ofw-debug] \u2192 ${method} ${url2}${isRetry ? " (retry)" : ""}`);
|
|
38381
|
-
console.error(`[ofw-debug] headers: ${JSON.stringify(
|
|
38508
|
+
console.error(`[ofw-debug] headers: ${redactSecrets(JSON.stringify(headers))}`);
|
|
38382
38509
|
console.error(`[ofw-debug] body: ${bodyPreview}`);
|
|
38383
38510
|
}
|
|
38384
38511
|
const timeoutMs = getRequestTimeoutMs();
|
|
@@ -38418,17 +38545,6 @@ var OFWClient = class {
|
|
|
38418
38545
|
};
|
|
38419
38546
|
var client = new OFWClient();
|
|
38420
38547
|
|
|
38421
|
-
// src/validate.ts
|
|
38422
|
-
function parseOFW(schema, raw, ctx, mode = "lenient") {
|
|
38423
|
-
const result = schema.safeParse(raw);
|
|
38424
|
-
if (result.success) return result.data;
|
|
38425
|
-
const issues = result.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
|
|
38426
|
-
const message = `OFW response for ${ctx} failed validation: ${issues}`;
|
|
38427
|
-
if (mode === "strict") throw new Error(message);
|
|
38428
|
-
console.error(`[ofw-mcp] WARNING: ${message} \u2014 continuing with the raw response; fields derived from it may be missing or wrong.`);
|
|
38429
|
-
return raw;
|
|
38430
|
-
}
|
|
38431
|
-
|
|
38432
38548
|
// src/tools/_shared.ts
|
|
38433
38549
|
var jsonResponse = textResult;
|
|
38434
38550
|
var textResponse = rawTextResult;
|
|
@@ -38460,19 +38576,17 @@ var PostMessagesResponseSchema = external_exports.looseObject({
|
|
|
38460
38576
|
entityId: external_exports.number().optional()
|
|
38461
38577
|
}).nullable();
|
|
38462
38578
|
async function postMessageAndRefetch(client2, payload, detailSchema, ctx) {
|
|
38463
|
-
const raw =
|
|
38579
|
+
const raw = parseLenient(
|
|
38464
38580
|
PostMessagesResponseSchema,
|
|
38465
38581
|
await client2.request("POST", "/pub/v3/messages", payload),
|
|
38466
|
-
`POST /pub/v3/messages (${ctx})`,
|
|
38467
|
-
"strict"
|
|
38582
|
+
{ label: "ofw-mcp", context: `POST /pub/v3/messages (${ctx})`, mode: "strict" }
|
|
38468
38583
|
);
|
|
38469
38584
|
const id = typeof raw?.id === "number" ? raw.id : typeof raw?.entityId === "number" ? raw.entityId : null;
|
|
38470
38585
|
if (id === null) return { id: null, detail: null, raw };
|
|
38471
|
-
const detail =
|
|
38586
|
+
const detail = parseLenient(
|
|
38472
38587
|
detailSchema,
|
|
38473
38588
|
await client2.request("GET", `/pub/v3/messages/${id}`),
|
|
38474
|
-
`GET /pub/v3/messages/{id} (${ctx})`,
|
|
38475
|
-
"strict"
|
|
38589
|
+
{ label: "ofw-mcp", context: `GET /pub/v3/messages/{id} (${ctx})`, mode: "strict" }
|
|
38476
38590
|
);
|
|
38477
38591
|
return { id, detail, raw };
|
|
38478
38592
|
}
|
|
@@ -38499,6 +38613,44 @@ function registerUserTools(server, client2) {
|
|
|
38499
38613
|
import { DatabaseSync } from "node:sqlite";
|
|
38500
38614
|
import { mkdirSync, chmodSync, existsSync } from "node:fs";
|
|
38501
38615
|
import { dirname as dirname2 } from "node:path";
|
|
38616
|
+
|
|
38617
|
+
// src/config.ts
|
|
38618
|
+
import { createHash } from "node:crypto";
|
|
38619
|
+
import { homedir as homedir3 } from "node:os";
|
|
38620
|
+
import { join as join4 } from "node:path";
|
|
38621
|
+
function readCacheIdentity() {
|
|
38622
|
+
return readEnvVar("OFW_CACHE_IDENTITY") ?? readEnvVar("OFW_USERNAME") ?? "_default";
|
|
38623
|
+
}
|
|
38624
|
+
function getCacheDir() {
|
|
38625
|
+
const override = process.env.OFW_CACHE_DIR;
|
|
38626
|
+
if (override && override.trim().length > 0) return override.trim();
|
|
38627
|
+
return join4(homedir3(), ".cache", "ofw-mcp");
|
|
38628
|
+
}
|
|
38629
|
+
function getCacheDbPath() {
|
|
38630
|
+
const identity = readCacheIdentity();
|
|
38631
|
+
const hash2 = createHash("sha256").update(identity).digest("hex").slice(0, 16);
|
|
38632
|
+
return join4(getCacheDir(), `${hash2}.db`);
|
|
38633
|
+
}
|
|
38634
|
+
function getAttachmentsDir() {
|
|
38635
|
+
const override = process.env.OFW_ATTACHMENTS_DIR;
|
|
38636
|
+
if (override && override.trim().length > 0) return override.trim();
|
|
38637
|
+
return join4(homedir3(), "Downloads", "ofw-mcp");
|
|
38638
|
+
}
|
|
38639
|
+
function getWriteMode() {
|
|
38640
|
+
const raw = process.env.OFW_WRITE_MODE;
|
|
38641
|
+
if (typeof raw !== "string" || raw.trim().length === 0) return "all";
|
|
38642
|
+
const mode = raw.trim().toLowerCase();
|
|
38643
|
+
if (mode === "none" || mode === "drafts" || mode === "all") return mode;
|
|
38644
|
+
console.error(
|
|
38645
|
+
`[ofw-mcp] Unrecognized OFW_WRITE_MODE "${raw.trim()}" \u2014 failing closed to "none" (no write tools registered). Valid values: none, drafts, all.`
|
|
38646
|
+
);
|
|
38647
|
+
return "none";
|
|
38648
|
+
}
|
|
38649
|
+
function getDefaultInlineAttachments() {
|
|
38650
|
+
return parseBoolEnv("OFW_INLINE_ATTACHMENTS");
|
|
38651
|
+
}
|
|
38652
|
+
|
|
38653
|
+
// src/cache.ts
|
|
38502
38654
|
var instance = null;
|
|
38503
38655
|
var SCHEMA_V1 = `
|
|
38504
38656
|
CREATE TABLE IF NOT EXISTS messages (
|
|
@@ -38851,10 +39003,10 @@ var FileMetaSchema = external_exports.looseObject({
|
|
|
38851
39003
|
fileSize: external_exports.number().optional()
|
|
38852
39004
|
});
|
|
38853
39005
|
async function fetchAttachmentMeta(client2, fileId, messageId) {
|
|
38854
|
-
const meta3 =
|
|
39006
|
+
const meta3 = parseLenient(
|
|
38855
39007
|
FileMetaSchema,
|
|
38856
39008
|
await client2.request("GET", `/pub/v1/myfiles/${fileId}`),
|
|
38857
|
-
"GET /pub/v1/myfiles/{fileId}"
|
|
39009
|
+
{ label: "ofw-mcp", context: "GET /pub/v1/myfiles/{fileId}" }
|
|
38858
39010
|
);
|
|
38859
39011
|
upsertAttachmentForMessage({
|
|
38860
39012
|
fileId: meta3.fileId ?? fileId,
|
|
@@ -38873,10 +39025,10 @@ var FoldersSchema = external_exports.looseObject({
|
|
|
38873
39025
|
systemFolders: external_exports.array(external_exports.looseObject({ id: external_exports.string(), folderType: external_exports.string() })).optional()
|
|
38874
39026
|
});
|
|
38875
39027
|
async function resolveFolderIds(client2) {
|
|
38876
|
-
const data =
|
|
39028
|
+
const data = parseLenient(
|
|
38877
39029
|
FoldersSchema,
|
|
38878
39030
|
await client2.request("GET", "/pub/v1/messageFolders?includeFolderCounts=true"),
|
|
38879
|
-
"GET /pub/v1/messageFolders"
|
|
39031
|
+
{ label: "ofw-mcp", context: "GET /pub/v1/messageFolders" }
|
|
38880
39032
|
);
|
|
38881
39033
|
const sys = data.systemFolders ?? [];
|
|
38882
39034
|
const find = (type) => {
|
|
@@ -38912,10 +39064,10 @@ async function syncMessageFolder(client2, folder, folderId, opts) {
|
|
|
38912
39064
|
const unread = [];
|
|
38913
39065
|
while (true) {
|
|
38914
39066
|
const path = `/pub/v3/messages?folders=${encodeURIComponent(folderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
|
|
38915
|
-
const list =
|
|
39067
|
+
const list = parseLenient(
|
|
38916
39068
|
ListResponseSchema,
|
|
38917
39069
|
await client2.request("GET", path),
|
|
38918
|
-
`GET /pub/v3/messages?folders={${folder}}`
|
|
39070
|
+
{ label: "ofw-mcp", context: `GET /pub/v3/messages?folders={${folder}}` }
|
|
38919
39071
|
);
|
|
38920
39072
|
const items = list.data ?? [];
|
|
38921
39073
|
if (items.length === 0) break;
|
|
@@ -38931,10 +39083,10 @@ async function syncMessageFolder(client2, folder, folderId, opts) {
|
|
|
38931
39083
|
let fetchedBodyAt = null;
|
|
38932
39084
|
let detailFileIds = [];
|
|
38933
39085
|
if (shouldFetchBody) {
|
|
38934
|
-
const detail =
|
|
39086
|
+
const detail = parseLenient(
|
|
38935
39087
|
DetailResponseSchema,
|
|
38936
39088
|
await client2.request("GET", `/pub/v3/messages/${item.id}`),
|
|
38937
|
-
"GET /pub/v3/messages/{id} (sync)"
|
|
39089
|
+
{ label: "ofw-mcp", context: "GET /pub/v3/messages/{id} (sync)" }
|
|
38938
39090
|
);
|
|
38939
39091
|
body = detail.body ?? "";
|
|
38940
39092
|
fetchedBodyAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -38994,10 +39146,10 @@ async function syncDrafts(client2, draftsFolderId) {
|
|
|
38994
39146
|
let page = 1;
|
|
38995
39147
|
while (true) {
|
|
38996
39148
|
const path = `/pub/v3/messages?folders=${encodeURIComponent(draftsFolderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
|
|
38997
|
-
const list =
|
|
39149
|
+
const list = parseLenient(
|
|
38998
39150
|
DraftListResponseSchema,
|
|
38999
39151
|
await client2.request("GET", path),
|
|
39000
|
-
"GET /pub/v3/messages?folders={drafts}"
|
|
39152
|
+
{ label: "ofw-mcp", context: "GET /pub/v3/messages?folders={drafts}" }
|
|
39001
39153
|
);
|
|
39002
39154
|
const pageItems = list.data ?? [];
|
|
39003
39155
|
items.push(...pageItems);
|
|
@@ -39010,10 +39162,10 @@ async function syncDrafts(client2, draftsFolderId) {
|
|
|
39010
39162
|
seenIds.add(item.id);
|
|
39011
39163
|
const modifiedAt = item.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
39012
39164
|
const existing = getDraft(item.id);
|
|
39013
|
-
const detail =
|
|
39165
|
+
const detail = parseLenient(
|
|
39014
39166
|
DraftDetailSchema,
|
|
39015
39167
|
await client2.request("GET", `/pub/v3/messages/${item.id}`),
|
|
39016
|
-
"GET /pub/v3/messages/{id} (drafts sync)"
|
|
39168
|
+
{ label: "ofw-mcp", context: "GET /pub/v3/messages/{id} (drafts sync)" }
|
|
39017
39169
|
);
|
|
39018
39170
|
const row = {
|
|
39019
39171
|
id: item.id,
|
|
@@ -39213,10 +39365,10 @@ function registerMessageTools(server, client2) {
|
|
|
39213
39365
|
let attachments2 = listAttachmentsForMessage(id);
|
|
39214
39366
|
if (attachments2.length === 0 && listDataHintsAtFiles(cached2.listData)) {
|
|
39215
39367
|
try {
|
|
39216
|
-
const detail2 =
|
|
39368
|
+
const detail2 = parseLenient(
|
|
39217
39369
|
DetailFilesSchema,
|
|
39218
39370
|
await client2.request("GET", `/pub/v3/messages/${id}`),
|
|
39219
|
-
"GET /pub/v3/messages/{id} (attachment backfill)"
|
|
39371
|
+
{ label: "ofw-mcp", context: "GET /pub/v3/messages/{id} (attachment backfill)" }
|
|
39220
39372
|
);
|
|
39221
39373
|
if (Array.isArray(detail2.files) && detail2.files.length > 0) {
|
|
39222
39374
|
await fetchAttachmentMetaForMessage(client2, id, detail2.files);
|
|
@@ -39227,10 +39379,10 @@ function registerMessageTools(server, client2) {
|
|
|
39227
39379
|
}
|
|
39228
39380
|
return jsonResponse({ ...cached2, attachments: attachments2 });
|
|
39229
39381
|
}
|
|
39230
|
-
const detail =
|
|
39382
|
+
const detail = parseLenient(
|
|
39231
39383
|
MessageDetailSchema,
|
|
39232
39384
|
await client2.request("GET", `/pub/v3/messages/${encodeURIComponent(args.messageId)}`),
|
|
39233
|
-
"GET /pub/v3/messages/{id} (ofw_get_message)"
|
|
39385
|
+
{ label: "ofw-mcp", context: "GET /pub/v3/messages/{id} (ofw_get_message)" }
|
|
39234
39386
|
);
|
|
39235
39387
|
const folder = cached2?.folder ?? "inbox";
|
|
39236
39388
|
const row = {
|
|
@@ -39512,11 +39664,10 @@ ${text}` : text);
|
|
|
39512
39664
|
form.append("label", args.label ?? fileName);
|
|
39513
39665
|
form.append("fileName", fileName);
|
|
39514
39666
|
form.append("shareClass", args.shareClass ?? "PRIVATE");
|
|
39515
|
-
const meta3 =
|
|
39667
|
+
const meta3 = parseLenient(
|
|
39516
39668
|
UploadedFileSchema,
|
|
39517
39669
|
await client2.request("POST", "/pub/v3/myfiles/multipart", form),
|
|
39518
|
-
"POST /pub/v3/myfiles/multipart (ofw_upload_attachment)",
|
|
39519
|
-
"strict"
|
|
39670
|
+
{ label: "ofw-mcp", context: "POST /pub/v3/myfiles/multipart (ofw_upload_attachment)", mode: "strict" }
|
|
39520
39671
|
);
|
|
39521
39672
|
upsertAttachmentForMessage({
|
|
39522
39673
|
fileId: meta3.fileId,
|
|
@@ -39588,12 +39739,13 @@ ${text}` : text);
|
|
|
39588
39739
|
} }] };
|
|
39589
39740
|
}
|
|
39590
39741
|
let dest;
|
|
39742
|
+
const safeName = basename(cached2.fileName);
|
|
39591
39743
|
if (args.saveTo) {
|
|
39592
39744
|
const isDirArg = args.saveTo.endsWith("/") || args.saveTo.endsWith("\\");
|
|
39593
39745
|
const abs = expandPath2(args.saveTo);
|
|
39594
|
-
dest = isDirArg ? join5(abs, `${fileId}-${
|
|
39746
|
+
dest = isDirArg ? join5(abs, `${fileId}-${safeName}`) : abs;
|
|
39595
39747
|
} else {
|
|
39596
|
-
dest = join5(getAttachmentsDir(), `${fileId}-${
|
|
39748
|
+
dest = join5(getAttachmentsDir(), `${fileId}-${safeName}`);
|
|
39597
39749
|
}
|
|
39598
39750
|
if (!args.force && cached2.downloadedPath === dest) {
|
|
39599
39751
|
return jsonResponse({
|
|
@@ -39787,7 +39939,7 @@ process.emit = function(event, ...args) {
|
|
|
39787
39939
|
};
|
|
39788
39940
|
await runMcp({
|
|
39789
39941
|
name: "ofw",
|
|
39790
|
-
version: "2.4.
|
|
39942
|
+
version: "2.4.4",
|
|
39791
39943
|
// x-release-please-version
|
|
39792
39944
|
deps: client,
|
|
39793
39945
|
tools: [
|
package/dist/client.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import { loadDotenvSafely } from '@chrischall/mcp-utils';
|
|
1
|
+
import { loadDotenvSafely, parseBoolEnv, redactSecrets } from '@chrischall/mcp-utils';
|
|
2
2
|
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 { parseBoolEnv } from './config.js';
|
|
7
6
|
import { BASE_URL, OFW_PROTOCOL_HEADERS, OFW_TOKEN_TTL_MS, OFW_TOKEN_EXPIRY_SKEW_MS } from './protocol.js';
|
|
8
7
|
// Load .env for local dev; silently skip if dotenv is unavailable (e.g. mcpb
|
|
9
8
|
// bundle). loadDotenvSafely applies override:false + quiet:true and swallows a
|
|
@@ -32,13 +31,6 @@ function parseContentDispositionFilename(cd) {
|
|
|
32
31
|
function debugLogEnabled() {
|
|
33
32
|
return parseBoolEnv('OFW_DEBUG_LOG');
|
|
34
33
|
}
|
|
35
|
-
function redactHeaders(h) {
|
|
36
|
-
const out = { ...h };
|
|
37
|
-
/* v8 ignore next -- request headers always carry Authorization (set in request()); the guard is defensive for arbitrary header maps */
|
|
38
|
-
if (out.Authorization)
|
|
39
|
-
out.Authorization = `Bearer ${out.Authorization.slice(7, 17)}…`;
|
|
40
|
-
return out;
|
|
41
|
-
}
|
|
42
34
|
// Per-request timeout. Overridable via OFW_REQUEST_TIMEOUT_MS. The default
|
|
43
35
|
// (30s) is comfortably above OFW's typical p99 but low enough that a stuck
|
|
44
36
|
// upstream fails fast instead of burning the MCP client-side budget — which
|
|
@@ -148,7 +140,9 @@ export class OFWClient {
|
|
|
148
140
|
? `<FormData entries=${Array.from(body.keys()).join(',')}>`
|
|
149
141
|
: JSON.stringify(body);
|
|
150
142
|
console.error(`[ofw-debug] → ${method} ${url}${isRetry ? ' (retry)' : ''}`);
|
|
151
|
-
|
|
143
|
+
// redactSecrets scrubs the Bearer token (and any other secret shapes)
|
|
144
|
+
// from the serialized header map — shared fleet redaction, never bespoke.
|
|
145
|
+
console.error(`[ofw-debug] headers: ${redactSecrets(JSON.stringify(headers))}`);
|
|
152
146
|
console.error(`[ofw-debug] body: ${bodyPreview}`);
|
|
153
147
|
}
|
|
154
148
|
// AbortController + setTimeout (not AbortSignal.timeout) so vitest fake
|
package/dist/config.js
CHANGED
|
@@ -1,7 +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
|
|
4
|
+
import { parseBoolEnv, readEnvVar } from '@chrischall/mcp-utils';
|
|
5
5
|
// Cache identity drives the per-user SQLite DB filename. Order of preference:
|
|
6
6
|
// 1. OFW_CACHE_IDENTITY — explicit override for users who want to label the
|
|
7
7
|
// cache themselves (e.g. when authing via fetchproxy and OFW_USERNAME is
|
|
@@ -11,13 +11,7 @@ import { parseBoolEnv as parseBoolEnvUtil } from '@chrischall/mcp-utils';
|
|
|
11
11
|
// Single-user installs are fine on this; multi-account users should set
|
|
12
12
|
// OFW_CACHE_IDENTITY explicitly so their caches don't collide.
|
|
13
13
|
function readCacheIdentity() {
|
|
14
|
-
|
|
15
|
-
if (typeof explicit === 'string' && explicit.trim().length > 0)
|
|
16
|
-
return explicit.trim();
|
|
17
|
-
const username = process.env.OFW_USERNAME;
|
|
18
|
-
if (typeof username === 'string' && username.trim().length > 0)
|
|
19
|
-
return username.trim();
|
|
20
|
-
return '_default';
|
|
14
|
+
return readEnvVar('OFW_CACHE_IDENTITY') ?? readEnvVar('OFW_USERNAME') ?? '_default';
|
|
21
15
|
}
|
|
22
16
|
export function getCacheDir() {
|
|
23
17
|
const override = process.env.OFW_CACHE_DIR;
|
|
@@ -41,19 +35,6 @@ export function getAttachmentsDir() {
|
|
|
41
35
|
// location across macOS/Linux/Windows.
|
|
42
36
|
return join(homedir(), 'Downloads', 'ofw-mcp');
|
|
43
37
|
}
|
|
44
|
-
/**
|
|
45
|
-
* True when a boolean-shaped env var is set to "1", "true", "yes", or "on"
|
|
46
|
-
* (case-insensitive, trimmed). Anything else — unset, empty, or other
|
|
47
|
-
* values — is false. Used for OFW_INLINE_ATTACHMENTS, OFW_DISABLE_FETCHPROXY,
|
|
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).
|
|
53
|
-
*/
|
|
54
|
-
export function parseBoolEnv(name) {
|
|
55
|
-
return parseBoolEnvUtil(name);
|
|
56
|
-
}
|
|
57
38
|
/**
|
|
58
39
|
* Gate for write-tool registration, read at registration time (startup).
|
|
59
40
|
*
|
package/dist/index.js
CHANGED
|
@@ -24,7 +24,7 @@ import { registerJournalTools } from './tools/journal.js';
|
|
|
24
24
|
// always succeeds before any credential check runs.
|
|
25
25
|
await runMcp({
|
|
26
26
|
name: 'ofw',
|
|
27
|
-
version: '2.4.
|
|
27
|
+
version: '2.4.4', // x-release-please-version
|
|
28
28
|
deps: client,
|
|
29
29
|
tools: [
|
|
30
30
|
registerUserTools,
|
package/dist/sync.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { setMeta, upsertMessage, getMessage, deleteMessage, setSyncState, upsertDraft, getDraft, deleteDraft, listDraftIds, upsertAttachmentForMessage, } from './cache.js';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { ApiRecipientSchema, mapRecipients } from './tools/_shared.js';
|
|
4
|
-
import {
|
|
4
|
+
import { parseLenient } from '@chrischall/mcp-utils';
|
|
5
5
|
// Each OFW message detail returns `files: [fileId, ...]`. We fetch the metadata
|
|
6
6
|
// for each file id (cheap JSON call) so the model can see filenames/mime types
|
|
7
7
|
// without downloading bytes. Bytes are pulled lazily by ofw_download_attachment.
|
|
@@ -22,7 +22,7 @@ const FileMetaSchema = z.looseObject({
|
|
|
22
22
|
// best-effort helper below; callers that need the result (download tool) let
|
|
23
23
|
// the throw propagate.
|
|
24
24
|
export async function fetchAttachmentMeta(client, fileId, messageId) {
|
|
25
|
-
const meta =
|
|
25
|
+
const meta = parseLenient(FileMetaSchema, await client.request('GET', `/pub/v1/myfiles/${fileId}`), { label: 'ofw-mcp', context: 'GET /pub/v1/myfiles/{fileId}' });
|
|
26
26
|
upsertAttachmentForMessage({
|
|
27
27
|
fileId: meta.fileId ?? fileId,
|
|
28
28
|
fileName: meta.fileName ?? `file-${fileId}`,
|
|
@@ -44,7 +44,7 @@ const FoldersSchema = z.looseObject({
|
|
|
44
44
|
systemFolders: z.array(z.looseObject({ id: z.string(), folderType: z.string() })).optional(),
|
|
45
45
|
});
|
|
46
46
|
export async function resolveFolderIds(client) {
|
|
47
|
-
const data =
|
|
47
|
+
const data = parseLenient(FoldersSchema, await client.request('GET', '/pub/v1/messageFolders?includeFolderCounts=true'), { label: 'ofw-mcp', context: 'GET /pub/v1/messageFolders' });
|
|
48
48
|
const sys = data.systemFolders ?? [];
|
|
49
49
|
const find = (type) => {
|
|
50
50
|
const f = sys.find((x) => x.folderType === type);
|
|
@@ -83,7 +83,7 @@ export async function syncMessageFolder(client, folder, folderId, opts) {
|
|
|
83
83
|
const unread = [];
|
|
84
84
|
while (true) {
|
|
85
85
|
const path = `/pub/v3/messages?folders=${encodeURIComponent(folderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
|
|
86
|
-
const list =
|
|
86
|
+
const list = parseLenient(ListResponseSchema, await client.request('GET', path), { label: 'ofw-mcp', context: `GET /pub/v3/messages?folders={${folder}}` });
|
|
87
87
|
const items = list.data ?? [];
|
|
88
88
|
if (items.length === 0)
|
|
89
89
|
break;
|
|
@@ -101,7 +101,7 @@ export async function syncMessageFolder(client, folder, folderId, opts) {
|
|
|
101
101
|
let fetchedBodyAt = null;
|
|
102
102
|
let detailFileIds = [];
|
|
103
103
|
if (shouldFetchBody) {
|
|
104
|
-
const detail =
|
|
104
|
+
const detail = parseLenient(DetailResponseSchema, await client.request('GET', `/pub/v3/messages/${item.id}`), { label: 'ofw-mcp', context: 'GET /pub/v3/messages/{id} (sync)' });
|
|
105
105
|
body = detail.body ?? '';
|
|
106
106
|
fetchedBodyAt = new Date().toISOString();
|
|
107
107
|
if (Array.isArray(detail.files) && detail.files.length > 0) {
|
|
@@ -170,7 +170,7 @@ export async function syncDrafts(client, draftsFolderId) {
|
|
|
170
170
|
let page = 1;
|
|
171
171
|
while (true) {
|
|
172
172
|
const path = `/pub/v3/messages?folders=${encodeURIComponent(draftsFolderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
|
|
173
|
-
const list =
|
|
173
|
+
const list = parseLenient(DraftListResponseSchema, await client.request('GET', path), { label: 'ofw-mcp', context: 'GET /pub/v3/messages?folders={drafts}' });
|
|
174
174
|
const pageItems = list.data ?? [];
|
|
175
175
|
items.push(...pageItems);
|
|
176
176
|
if (pageItems.length < 50)
|
|
@@ -186,7 +186,7 @@ export async function syncDrafts(client, draftsFolderId) {
|
|
|
186
186
|
// timestamp for drafts — direct UI edits don't bump it — so we can't
|
|
187
187
|
// use it to skip the detail fetch. Always re-fetch; drafts are few.
|
|
188
188
|
const existing = getDraft(item.id);
|
|
189
|
-
const detail =
|
|
189
|
+
const detail = parseLenient(DraftDetailSchema, await client.request('GET', `/pub/v3/messages/${item.id}`), { label: 'ofw-mcp', context: 'GET /pub/v3/messages/{id} (drafts sync)' });
|
|
190
190
|
const row = {
|
|
191
191
|
id: item.id,
|
|
192
192
|
subject: detail.subject ?? item.subject ?? '(no subject)',
|
package/dist/tools/_shared.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { expandPath as expandPathUtil, rawTextResult, textResult } from '@chrischall/mcp-utils';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import {
|
|
3
|
+
import { parseLenient } from '@chrischall/mcp-utils';
|
|
4
4
|
// Pretty-printed JSON tool result. Thin wrapper over @chrischall/mcp-utils'
|
|
5
5
|
// `textResult` so the rest of the codebase keeps the local name.
|
|
6
6
|
export const jsonResponse = textResult;
|
|
@@ -77,14 +77,23 @@ const PostMessagesResponseSchema = z.looseObject({
|
|
|
77
77
|
* `if (result.id !== null)`. When id is null (no id field in the
|
|
78
78
|
* response — never observed in production, but defensive), `raw`
|
|
79
79
|
* carries the POST response so the caller can still surface it.
|
|
80
|
+
*
|
|
81
|
+
* The generic is parametrized on the schema's OUTPUT type `T`
|
|
82
|
+
* (`detailSchema: z.ZodType<T>`, `detail: T`) rather than on the schema
|
|
83
|
+
* type itself. This mirrors `parseLenient`'s own signature
|
|
84
|
+
* (`<T>(schema: ZodType<T>, …): T`) exactly, so `T` is inferred straight
|
|
85
|
+
* from the schema and flows into the return type with no `as` cast — the
|
|
86
|
+
* compiler verifies that `detail` matches `detailSchema`'s output. (A
|
|
87
|
+
* `<S extends z.ZodType>` constraint would widen the output to `unknown`
|
|
88
|
+
* and force a cast at this call site.)
|
|
80
89
|
*/
|
|
81
90
|
export async function postMessageAndRefetch(client, payload, detailSchema, ctx) {
|
|
82
|
-
const raw =
|
|
91
|
+
const raw = parseLenient(PostMessagesResponseSchema, await client.request('POST', '/pub/v3/messages', payload), { label: 'ofw-mcp', context: `POST /pub/v3/messages (${ctx})`, mode: 'strict' });
|
|
83
92
|
const id = typeof raw?.id === 'number' ? raw.id
|
|
84
93
|
: typeof raw?.entityId === 'number' ? raw.entityId
|
|
85
94
|
: null;
|
|
86
95
|
if (id === null)
|
|
87
96
|
return { id: null, detail: null, raw };
|
|
88
|
-
const detail =
|
|
97
|
+
const detail = parseLenient(detailSchema, await client.request('GET', `/pub/v3/messages/${id}`), { label: 'ofw-mcp', context: `GET /pub/v3/messages/{id} (${ctx})`, mode: 'strict' });
|
|
89
98
|
return { id, detail, raw };
|
|
90
99
|
}
|
package/dist/tools/messages.js
CHANGED
|
@@ -6,7 +6,7 @@ import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
|
6
6
|
import { basename, dirname, extname, join } from 'node:path';
|
|
7
7
|
import { fileBlob } from '@chrischall/mcp-utils';
|
|
8
8
|
import { ApiRecipientSchema, expandPath, jsonResponse, mapRecipients, postMessageAndRefetch, textResponse, verifyWriteLanded } from './_shared.js';
|
|
9
|
-
import {
|
|
9
|
+
import { parseLenient } from '@chrischall/mcp-utils';
|
|
10
10
|
// Schemas for the load-bearing fields of each /pub/v3 response this file
|
|
11
11
|
// reads (issue #83). Loose: unknown keys pass through into cached listData.
|
|
12
12
|
const DateSchema = z.looseObject({ dateTime: z.string() });
|
|
@@ -193,7 +193,7 @@ export function registerMessageTools(server, client) {
|
|
|
193
193
|
// OFW state isn't changing).
|
|
194
194
|
if (attachments.length === 0 && listDataHintsAtFiles(cached.listData)) {
|
|
195
195
|
try {
|
|
196
|
-
const detail =
|
|
196
|
+
const detail = parseLenient(DetailFilesSchema, await client.request('GET', `/pub/v3/messages/${id}`), { label: 'ofw-mcp', context: 'GET /pub/v3/messages/{id} (attachment backfill)' });
|
|
197
197
|
if (Array.isArray(detail.files) && detail.files.length > 0) {
|
|
198
198
|
await fetchAttachmentMetaForMessage(client, id, detail.files);
|
|
199
199
|
attachments = listAttachmentsForMessage(id);
|
|
@@ -205,7 +205,7 @@ export function registerMessageTools(server, client) {
|
|
|
205
205
|
}
|
|
206
206
|
return jsonResponse({ ...cached, attachments });
|
|
207
207
|
}
|
|
208
|
-
const detail =
|
|
208
|
+
const detail = parseLenient(MessageDetailSchema, await client.request('GET', `/pub/v3/messages/${encodeURIComponent(args.messageId)}`), { label: 'ofw-mcp', context: 'GET /pub/v3/messages/{id} (ofw_get_message)' });
|
|
209
209
|
const folder = cached?.folder ?? 'inbox';
|
|
210
210
|
const row = {
|
|
211
211
|
id: detail.id,
|
|
@@ -508,7 +508,7 @@ export function registerMessageTools(server, client) {
|
|
|
508
508
|
form.append('label', args.label ?? fileName);
|
|
509
509
|
form.append('fileName', fileName);
|
|
510
510
|
form.append('shareClass', args.shareClass ?? 'PRIVATE');
|
|
511
|
-
const meta =
|
|
511
|
+
const meta = parseLenient(UploadedFileSchema, await client.request('POST', '/pub/v3/myfiles/multipart', form), { label: 'ofw-mcp', context: 'POST /pub/v3/myfiles/multipart (ofw_upload_attachment)', mode: 'strict' });
|
|
512
512
|
// Cache metadata so subsequent ofw_get_message calls can surface it and
|
|
513
513
|
// ofw_download_attachment can short-circuit. messageId is 0 (the
|
|
514
514
|
// not-yet-linked sentinel) until a message actually references this file.
|
|
@@ -583,14 +583,19 @@ export function registerMessageTools(server, client) {
|
|
|
583
583
|
} }] };
|
|
584
584
|
}
|
|
585
585
|
let dest;
|
|
586
|
+
// The filename comes from OFW file metadata — i.e. it is controlled by the
|
|
587
|
+
// co-parent who uploaded the attachment. basename() it before interpolating
|
|
588
|
+
// into a path so a crafted `../…` name can't escape the target directory
|
|
589
|
+
// (the upload path at :549 already applies basename to its input).
|
|
590
|
+
const safeName = basename(cached.fileName);
|
|
586
591
|
if (args.saveTo) {
|
|
587
592
|
// Treat saveTo as a directory if it ends with a separator; otherwise as a full path.
|
|
588
593
|
const isDirArg = args.saveTo.endsWith('/') || args.saveTo.endsWith('\\');
|
|
589
594
|
const abs = expandPath(args.saveTo);
|
|
590
|
-
dest = isDirArg ? join(abs, `${fileId}-${
|
|
595
|
+
dest = isDirArg ? join(abs, `${fileId}-${safeName}`) : abs;
|
|
591
596
|
}
|
|
592
597
|
else {
|
|
593
|
-
dest = join(getAttachmentsDir(), `${fileId}-${
|
|
598
|
+
dest = join(getAttachmentsDir(), `${fileId}-${safeName}`);
|
|
594
599
|
}
|
|
595
600
|
if (!args.force && cached.downloadedPath === dest) {
|
|
596
601
|
return jsonResponse({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ofw-mcp",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.4",
|
|
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)",
|
|
@@ -32,14 +32,14 @@
|
|
|
32
32
|
"test:watch": "vitest"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@chrischall/mcp-utils": "^0.
|
|
35
|
+
"@chrischall/mcp-utils": "^0.12.0",
|
|
36
36
|
"@fetchproxy/bootstrap": "^1.3.0",
|
|
37
37
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
38
38
|
"dotenv": "^17.4.2",
|
|
39
39
|
"zod": "^4.4.3"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
|
-
"@types/node": "^
|
|
42
|
+
"@types/node": "^26.0.0",
|
|
43
43
|
"@vitest/coverage-v8": "^4.1.7",
|
|
44
44
|
"esbuild": "^0.28.0",
|
|
45
45
|
"typescript": "^6.0.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.4.
|
|
9
|
+
"version": "2.4.4",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "ofw-mcp",
|
|
14
|
-
"version": "2.4.
|
|
14
|
+
"version": "2.4.4",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|
package/dist/validate.js
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Validate an OFW API response against a zod schema at the call site.
|
|
3
|
-
*
|
|
4
|
-
* Every OFW endpoint is reverse-engineered and undocumented, so a backend
|
|
5
|
-
* change on their side would otherwise flow `undefined` silently into the
|
|
6
|
-
* SQLite cache and persist (issue #83). Schemas are `.looseObject(...)`
|
|
7
|
-
* covering ONLY the fields the code actually reads — cosmetic API additions
|
|
8
|
-
* pass through untouched (and stay present in the parsed output, which
|
|
9
|
-
* matters for `listData`/`metadata` blobs cached verbatim).
|
|
10
|
-
*
|
|
11
|
-
* Two modes, chosen per call site:
|
|
12
|
-
* - `'lenient'` (default) — read/sync paths. On mismatch, log a structured
|
|
13
|
-
* warning to stderr naming the endpoint and fields, then return the RAW
|
|
14
|
-
* response unchanged so the existing `??` fallbacks keep the tool useful.
|
|
15
|
-
* - `'strict'` — write paths (send/save_draft verification, upload). On
|
|
16
|
-
* mismatch, throw: proceeding on an unverifiable response risks deleting
|
|
17
|
-
* a draft, mis-reporting a send, or caching an unusable fileId.
|
|
18
|
-
*
|
|
19
|
-
* The error/warning text is deliberately precise ("date.dateTime: expected
|
|
20
|
-
* string…") — it's the failure signal a maintainer (human or Claude) fixes
|
|
21
|
-
* in one session, vs. "some messages show the wrong date sometimes".
|
|
22
|
-
*/
|
|
23
|
-
export function parseOFW(schema, raw, ctx, mode = 'lenient') {
|
|
24
|
-
const result = schema.safeParse(raw);
|
|
25
|
-
if (result.success)
|
|
26
|
-
return result.data;
|
|
27
|
-
const issues = result.error.issues
|
|
28
|
-
.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`)
|
|
29
|
-
.join('; ');
|
|
30
|
-
const message = `OFW response for ${ctx} failed validation: ${issues}`;
|
|
31
|
-
if (mode === 'strict')
|
|
32
|
-
throw new Error(message);
|
|
33
|
-
console.error(`[ofw-mcp] WARNING: ${message} — continuing with the raw response; fields derived from it may be missing or wrong.`);
|
|
34
|
-
return raw;
|
|
35
|
-
}
|