ofw-mcp 2.20.3 → 2.20.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 +2 -2
- package/README.md +1 -1
- package/dist/auth-password.js +27 -2
- package/dist/auth.js +3 -3
- package/dist/bundle.js +268 -150
- package/dist/client.js +21 -1
- package/dist/config.js +3 -4
- package/dist/extract/inflate.js +36 -4
- package/dist/extract/pdf.js +8 -5
- package/dist/extract/zip.js +8 -3
- package/dist/index.js +2 -1
- package/dist/tools/_confirm.js +4 -0
- package/dist/tools/_shared.js +14 -0
- package/dist/tools/calendar.js +9 -5
- package/dist/tools/expenses.js +9 -4
- package/dist/tools/healthcheck.js +17 -9
- package/dist/tools/journal.js +4 -2
- package/dist/tools/messages.js +42 -28
- package/dist/tools/user.js +7 -2
- package/dist/version.js +7 -0
- package/package.json +3 -3
- package/server.json +92 -2
- package/skills/ofw/SKILL.md +1 -1
package/dist/bundle.js
CHANGED
|
@@ -121,9 +121,9 @@ var require_buffer_util = __commonJS({
|
|
|
121
121
|
toBuffer,
|
|
122
122
|
unmask: _unmask
|
|
123
123
|
};
|
|
124
|
-
if (
|
|
124
|
+
if (false) {
|
|
125
125
|
try {
|
|
126
|
-
const bufferUtil =
|
|
126
|
+
const bufferUtil = null;
|
|
127
127
|
module.exports.mask = function(source, mask, output2, offset, length) {
|
|
128
128
|
if (length < 48) _mask(source, mask, output2, offset, length);
|
|
129
129
|
else bufferUtil.mask(source, mask, output2, offset, length);
|
|
@@ -760,9 +760,9 @@ var require_validation2 = __commonJS({
|
|
|
760
760
|
module.exports.isValidUTF8 = function(buf) {
|
|
761
761
|
return buf.length < 24 ? _isValidUTF8(buf) : isUtf8(buf);
|
|
762
762
|
};
|
|
763
|
-
} else if (
|
|
763
|
+
} else if (false) {
|
|
764
764
|
try {
|
|
765
|
-
const isValidUTF8 =
|
|
765
|
+
const isValidUTF8 = null;
|
|
766
766
|
module.exports.isValidUTF8 = function(buf) {
|
|
767
767
|
return buf.length < 32 ? _isValidUTF8(buf) : isValidUTF8(buf);
|
|
768
768
|
};
|
|
@@ -38245,17 +38245,62 @@ var DEFAULT_MAX_REQUEST_BODY_SIZE = 4 * 1024 * 1024;
|
|
|
38245
38245
|
|
|
38246
38246
|
// node_modules/@chrischall/mcp-utils/dist/errors/index.js
|
|
38247
38247
|
var DEFAULT_ERROR_MESSAGE_MAX = 500;
|
|
38248
|
+
var MCP_TOOL_ERROR_KINDS = /* @__PURE__ */ new Set([
|
|
38249
|
+
"no_credential",
|
|
38250
|
+
"credential_rejected",
|
|
38251
|
+
"edge_blocked",
|
|
38252
|
+
"session_expired",
|
|
38253
|
+
"verification_pending",
|
|
38254
|
+
"timeout",
|
|
38255
|
+
"http",
|
|
38256
|
+
"transport",
|
|
38257
|
+
"too_large",
|
|
38258
|
+
"unknown"
|
|
38259
|
+
]);
|
|
38248
38260
|
var McpToolError = class extends Error {
|
|
38249
38261
|
/** Actionable remediation text, when one applies. */
|
|
38250
38262
|
hint;
|
|
38263
|
+
/** The upstream HTTP status this failure came from, when known. */
|
|
38264
|
+
status;
|
|
38265
|
+
/** What kind of failure this is, when the thrower knows. */
|
|
38266
|
+
kind;
|
|
38251
38267
|
constructor(message, opts) {
|
|
38252
38268
|
super(message, opts?.cause !== void 0 ? { cause: opts.cause } : void 0);
|
|
38253
38269
|
this.name = "McpToolError";
|
|
38254
38270
|
if (opts?.hint !== void 0)
|
|
38255
38271
|
this.hint = opts.hint;
|
|
38272
|
+
if (opts?.status !== void 0)
|
|
38273
|
+
this.status = opts.status;
|
|
38274
|
+
if (opts?.kind !== void 0)
|
|
38275
|
+
this.kind = opts.kind;
|
|
38256
38276
|
Object.setPrototypeOf(this, new.target.prototype);
|
|
38257
38277
|
}
|
|
38258
38278
|
};
|
|
38279
|
+
var DECLARATION_CAUSE_DEPTH = 4;
|
|
38280
|
+
function firstDeclared(err, read) {
|
|
38281
|
+
let cur = err;
|
|
38282
|
+
for (let depth = 0; depth < DECLARATION_CAUSE_DEPTH && cur !== null && typeof cur === "object"; depth++) {
|
|
38283
|
+
const found = read(cur);
|
|
38284
|
+
if (found !== void 0)
|
|
38285
|
+
return found;
|
|
38286
|
+
cur = cur.cause;
|
|
38287
|
+
}
|
|
38288
|
+
return void 0;
|
|
38289
|
+
}
|
|
38290
|
+
function errorStatusOf(err) {
|
|
38291
|
+
return firstDeclared(err, (link2) => {
|
|
38292
|
+
const e = link2;
|
|
38293
|
+
if (typeof e.status === "number")
|
|
38294
|
+
return e.status;
|
|
38295
|
+
return typeof e.statusCode === "number" ? e.statusCode : void 0;
|
|
38296
|
+
});
|
|
38297
|
+
}
|
|
38298
|
+
function errorKindOf(err) {
|
|
38299
|
+
return firstDeclared(err, (link2) => {
|
|
38300
|
+
const k = link2.kind;
|
|
38301
|
+
return typeof k === "string" && MCP_TOOL_ERROR_KINDS.has(k) ? k : void 0;
|
|
38302
|
+
});
|
|
38303
|
+
}
|
|
38259
38304
|
function createHelpfulError(message, opts) {
|
|
38260
38305
|
return new McpToolError(message, opts);
|
|
38261
38306
|
}
|
|
@@ -38280,7 +38325,7 @@ var API_KEY_RE = new RegExp([
|
|
|
38280
38325
|
// webhook signing secret (Stripe-style)
|
|
38281
38326
|
].map((p) => `\\b${p}`).join("|"), "g");
|
|
38282
38327
|
var GOOGLE_OAUTH_TOKEN_RE = /(?<![A-Za-z0-9+/])(?:ya29\.|1\/\/)[A-Za-z0-9._-]{8,}/g;
|
|
38283
|
-
var QUERY_SECRET_RE = new RegExp(`([?&](?:(?:access|refresh|id|auth|session|csrf|xsrf)[_-]?token|client[_-]?secret|api[_-]?secret|api[_-]?key|password|passwd|signature|token|key|sig|(?:php|j)?sess(?:ion)?[_-]?id|sid|x[-_]${HEADER_NAME_RUN}?(?:api[-_]?key|token|secret|auth)${HEADER_NAME_RUN})=)[^&#\\s"'<>\`]+`, "gi");
|
|
38328
|
+
var QUERY_SECRET_RE = new RegExp(`([?&](?:(?:access|refresh|id|auth|session|csrf|xsrf)[_-]?token|client[_-]?secret|api[_-]?secret|api[_-]?key|password|passwd|signature|token|key|sig|(?:code[_-]?)?verifier|(?:php|j)?sess(?:ion)?[_-]?id|sid|x[-_]${HEADER_NAME_RUN}?(?:api[-_]?key|token|secret|auth)${HEADER_NAME_RUN})=)[^&#\\s"'<>\`]+`, "gi");
|
|
38284
38329
|
var OAUTH_CODE_RE = /([?&]code=)(?=[^&#\s"'<>`]{16,})[^&#\s"'<>`]+/gi;
|
|
38285
38330
|
var FORM_PASSWORD_RE = /((?:^|[\s,;:"'(\[{])(?:password|passwd)=)[^&#\s"'<>`]+/gim;
|
|
38286
38331
|
var HEADER_SECRET_RE = new RegExp(`(\\b(?:x[-_]${HEADER_NAME_RUN}?(?:api[-_]?key|token|secret|auth)${HEADER_NAME_RUN}|api[-_]?key)\\s*:\\s*)[^\\s,;"'<>\`]+`, "gi");
|
|
@@ -38291,6 +38336,8 @@ var JSON_SECRET_KEYS = [
|
|
|
38291
38336
|
"api[_-]?secret",
|
|
38292
38337
|
"(?:x[_-])?api[_-]?key",
|
|
38293
38338
|
"private[_-]?key",
|
|
38339
|
+
"code[_-]?verifier",
|
|
38340
|
+
// PKCE; a bare `verifier` key is too generic for JSON
|
|
38294
38341
|
`x[_-]${HEADER_NAME_RUN}?(?:api[_-]?key|token|secret|auth)${HEADER_NAME_RUN}`,
|
|
38295
38342
|
// bounded like HEADER_SECRET_RE
|
|
38296
38343
|
"(?:proxy-)?authorization",
|
|
@@ -38755,7 +38802,7 @@ function readEnvVar(key, opts = {}) {
|
|
|
38755
38802
|
if (typeof raw === "string") {
|
|
38756
38803
|
const trimmed = raw.trim();
|
|
38757
38804
|
if (trimmed.length > 0 && trimmed !== "undefined" && trimmed !== "null" && !PLACEHOLDER_RE.test(trimmed)) {
|
|
38758
|
-
return trimmed;
|
|
38805
|
+
return opts.trim === false ? raw : trimmed;
|
|
38759
38806
|
}
|
|
38760
38807
|
}
|
|
38761
38808
|
return opts.default;
|
|
@@ -38966,12 +39013,19 @@ function boundArgs(args) {
|
|
|
38966
39013
|
}
|
|
38967
39014
|
function confirmationFromEnv(options) {
|
|
38968
39015
|
const { tool, account, confirmToken, subject, instruction, args, env = process.env, spent: callerSpent, ...rest } = options;
|
|
39016
|
+
if (args === void 0 || args === null) {
|
|
39017
|
+
throw new TypeError(`confirmationFromEnv: ${tool} passed no args; pass the tool's validated arguments ({} for a tool with none).`);
|
|
39018
|
+
}
|
|
38969
39019
|
const spent = callerSpent ?? spentTokenStoreFromEnv(env);
|
|
38970
39020
|
const mode = readConfirmMode(env);
|
|
38971
39021
|
const ttlSeconds = readConfirmTtl(env);
|
|
38972
39022
|
const elicitationOff = readConfirmElicitation(env) === "off";
|
|
38973
|
-
const bound =
|
|
38974
|
-
const
|
|
39023
|
+
const bound = boundArgs(args);
|
|
39024
|
+
const elicitationBound = account === void 0 ? { args: bound } : { account, args: bound };
|
|
39025
|
+
const bindingAdded = rest.binding ? rest : {
|
|
39026
|
+
...rest,
|
|
39027
|
+
binding: { key: confirmKeyFromEnv(env), args: elicitationBound, ttlSeconds: ttlSeconds ?? DEFAULT_TTL_SECONDS2 }
|
|
39028
|
+
};
|
|
38975
39029
|
const confirmation = elicitationOff ? { ...bindingAdded, elicitation: false } : bindingAdded;
|
|
38976
39030
|
if (mode === "refuse" || ttlSeconds === void 0) {
|
|
38977
39031
|
const reason = mode === "refuse" ? REFUSE_HINT : BAD_TTL_HINT;
|
|
@@ -38981,7 +39035,7 @@ function confirmationFromEnv(options) {
|
|
|
38981
39035
|
unsupportedNote: confirmation.unsupportedNote ? `${confirmation.unsupportedNote} ${hint}` : hint
|
|
38982
39036
|
};
|
|
38983
39037
|
}
|
|
38984
|
-
const boundSubject =
|
|
39038
|
+
const boundSubject = async () => {
|
|
38985
39039
|
const read = await subject();
|
|
38986
39040
|
if ("content" in read)
|
|
38987
39041
|
return read;
|
|
@@ -39008,12 +39062,14 @@ var SERVER_PROTOCOL_VERSIONS = Object.freeze([
|
|
|
39008
39062
|
...SUPPORTED_PROTOCOL_VERSIONS
|
|
39009
39063
|
]);
|
|
39010
39064
|
function hintResultOrRethrow(err) {
|
|
39065
|
+
if (err instanceof ProtocolError && err.code === ProtocolErrorCode.UrlElicitationRequired)
|
|
39066
|
+
throw err;
|
|
39011
39067
|
if (err instanceof McpToolError && err.hint) {
|
|
39012
39068
|
return errorResult(`${err.message}
|
|
39013
39069
|
|
|
39014
39070
|
Hint: ${err.hint}`);
|
|
39015
39071
|
}
|
|
39016
|
-
|
|
39072
|
+
return errorResult(messageOf(err));
|
|
39017
39073
|
}
|
|
39018
39074
|
function mcpRequestFrom(args) {
|
|
39019
39075
|
const ctx = args.at(-1);
|
|
@@ -39454,6 +39510,8 @@ function parseContentDispositionFilename(header) {
|
|
|
39454
39510
|
// node_modules/@chrischall/mcp-utils/dist/http/index.js
|
|
39455
39511
|
var RateLimitedError = class extends Error {
|
|
39456
39512
|
status = 429;
|
|
39513
|
+
/** `'http'`: the far side answered, with a 429 (see `status`). */
|
|
39514
|
+
kind = "http";
|
|
39457
39515
|
retryAfterMs;
|
|
39458
39516
|
constructor(service, retryAfterMs) {
|
|
39459
39517
|
super(`Rate limited (429) by ${service} after retries.`);
|
|
@@ -39464,6 +39522,8 @@ var RateLimitedError = class extends Error {
|
|
|
39464
39522
|
};
|
|
39465
39523
|
var RequestTimeoutError = class extends Error {
|
|
39466
39524
|
timeoutMs;
|
|
39525
|
+
/** Always `'timeout'` unless a subclass says otherwise. */
|
|
39526
|
+
kind = "timeout";
|
|
39467
39527
|
constructor(service, timeoutMs) {
|
|
39468
39528
|
super(`Request to ${service} timed out after ${timeoutMs}ms.`);
|
|
39469
39529
|
this.name = "RequestTimeoutError";
|
|
@@ -39483,6 +39543,8 @@ var ApiError = class extends Error {
|
|
|
39483
39543
|
var EdgeBlockedError = class extends ApiError {
|
|
39484
39544
|
/** Which edge refused the request, e.g. `'CloudFront'` or `'Cloudflare'`. */
|
|
39485
39545
|
vendor;
|
|
39546
|
+
/** Always `'edge_blocked'`: the credential was never judged. */
|
|
39547
|
+
kind = "edge_blocked";
|
|
39486
39548
|
/**
|
|
39487
39549
|
* @param where The service the request was for, and — when the thrower knows
|
|
39488
39550
|
* it — the method and path, which the message names.
|
|
@@ -39565,7 +39627,7 @@ var PositiveInt = external_exports.number().int().positive();
|
|
|
39565
39627
|
var NonNegInt = external_exports.number().int().nonnegative();
|
|
39566
39628
|
var NonEmptyString = external_exports.string().min(1);
|
|
39567
39629
|
var IsoDate = external_exports.iso.date();
|
|
39568
|
-
var IsoTime = external_exports.string().regex(/^([01]?\d|2[0-3]):[0-5]\d$/, "must be HH:MM (24h), e.g. 19:30");
|
|
39630
|
+
var IsoTime = external_exports.string().regex(/^([01]?\d|2[0-3]):[0-5]\d$/, "must be HH:MM (24h), e.g. 19:30").overwrite((v) => v.padStart(5, "0"));
|
|
39569
39631
|
var NumericIdString = external_exports.string().regex(/^\d+$/, "must be a numeric id (digits only)").describe("A numeric id string (digits only), safe to interpolate into a URL path.");
|
|
39570
39632
|
var SafePathSegment = external_exports.string().min(1).regex(/^[^/?#\s]+$/, 'must not contain "/", "?", "#", or whitespace').refine((v) => !v.includes(".."), 'must not contain ".."').describe('A single URL path segment, safe to interpolate: no "/", "..", "?", "#", or whitespace.');
|
|
39571
39633
|
var schemaOrigin = external_exports.string().optional().describe("Portal origin (e.g. https://<vendor>.example.co) selecting which active session to use. Optional when only one session is active.");
|
|
@@ -40124,7 +40186,9 @@ var TokenManager = class {
|
|
|
40124
40186
|
/** Spend `rt`, then install and persist what comes back. */
|
|
40125
40187
|
async exchange(rt) {
|
|
40126
40188
|
if (rt === void 0) {
|
|
40127
|
-
throw new
|
|
40189
|
+
throw new McpToolError("TokenManager: cannot refresh \u2014 no refresh token is available.", {
|
|
40190
|
+
kind: "no_credential"
|
|
40191
|
+
});
|
|
40128
40192
|
}
|
|
40129
40193
|
const tok = await this.refreshFn(rt);
|
|
40130
40194
|
this.tokens = {
|
|
@@ -46401,12 +46465,6 @@ var BootstrapDisabledError = class extends Error {
|
|
|
46401
46465
|
};
|
|
46402
46466
|
|
|
46403
46467
|
// node_modules/@chrischall/mcp-utils/dist/healthcheck/index.js
|
|
46404
|
-
function statusOf2(err) {
|
|
46405
|
-
if (typeof err !== "object" || err === null)
|
|
46406
|
-
return void 0;
|
|
46407
|
-
const s = err.status ?? err.statusCode;
|
|
46408
|
-
return typeof s === "number" ? s : void 0;
|
|
46409
|
-
}
|
|
46410
46468
|
var CREDENTIAL_ARMS = /* @__PURE__ */ new Set([
|
|
46411
46469
|
"ok",
|
|
46412
46470
|
"no_credential",
|
|
@@ -46422,6 +46480,9 @@ var CREDENTIAL_ARMS = /* @__PURE__ */ new Set([
|
|
|
46422
46480
|
function isArm(kind) {
|
|
46423
46481
|
return kind !== void 0 && CREDENTIAL_ARMS.has(kind);
|
|
46424
46482
|
}
|
|
46483
|
+
function fromSource(source) {
|
|
46484
|
+
return source !== null ? ` from '${source}'` : "";
|
|
46485
|
+
}
|
|
46425
46486
|
function credentialHint(arm, prefix, hostLabel, source) {
|
|
46426
46487
|
switch (arm) {
|
|
46427
46488
|
case "ok":
|
|
@@ -46429,15 +46490,15 @@ function credentialHint(arm, prefix, hostLabel, source) {
|
|
|
46429
46490
|
case "no_credential":
|
|
46430
46491
|
return `No credential resolved. Nothing was available to authenticate with \u2014 sign in and reconnect the connector so ${prefix} receives a token, or set the documented environment variable.`;
|
|
46431
46492
|
case "credential_rejected":
|
|
46432
|
-
return `${hostLabel} rejected the credential
|
|
46493
|
+
return `${hostLabel} rejected the credential${fromSource(source)}. It is present but no longer valid \u2014 most often expired or revoked upstream. Re-authenticate and reconnect; retrying will not fix it.`;
|
|
46433
46494
|
case "edge_blocked":
|
|
46434
46495
|
return `${hostLabel} refused the request at its CDN/WAF before it reached the API, so the credential${source !== null ? ` from '${source}'` : ""} was never judged \u2014 re-signing in or rotating it will not help. This is usually a block on this host's IP address or request fingerprint: route through the ContextMint Bridge if this connector supports it, or retry later from a different network.`;
|
|
46435
46496
|
case "session_expired":
|
|
46436
|
-
return `The credential
|
|
46497
|
+
return `The credential${fromSource(source)} is configured, but no session is live \u2014 ${hostLabel} served a sign-in page rather than the data. Sign in again; a cookie-session portal expires these on its own, so this recurs between uses.`;
|
|
46437
46498
|
case "verification_pending":
|
|
46438
46499
|
return `${hostLabel} is holding the sign-in on a second factor rather than refusing it. Supply the verification code the ACCOUNT HOLDER received \u2014 the credential itself is not the problem, so changing it will not help.`;
|
|
46439
46500
|
case "timeout":
|
|
46440
|
-
return `The credential from '${source}' resolved, but ${hostLabel} did not answer in time. Usually transient \u2014 retry. If it persists, ${hostLabel} is slow or unreachable from here.`;
|
|
46501
|
+
return `${source !== null ? `The credential from '${source}' resolved, but ` : ""}${hostLabel} did not answer in time. Usually transient \u2014 retry. If it persists, ${hostLabel} is slow or unreachable from here.`;
|
|
46441
46502
|
case "http":
|
|
46442
46503
|
return `${hostLabel} answered with an error status that is not an auth rejection. That is USUALLY a ${hostLabel}-side problem rather than an auth one \u2014 but a 404 here more often means the probe path is wrong than that ${hostLabel} is broken, so check error.message and probe.url before concluding anything about the credential.`;
|
|
46443
46504
|
case "transport":
|
|
@@ -46458,7 +46519,8 @@ async function runCredentialHealthcheck(args) {
|
|
|
46458
46519
|
state = await resolveCredential();
|
|
46459
46520
|
} catch (e) {
|
|
46460
46521
|
const edge = edgeBlockOf(e);
|
|
46461
|
-
const
|
|
46522
|
+
const declared = errorKindOf(e);
|
|
46523
|
+
const classified = classifyThrown?.(e) ?? (edge !== null ? { kind: "edge_blocked", detail: { vendor: edge.vendor } } : declared !== void 0 ? { kind: declared } : void 0);
|
|
46462
46524
|
const result2 = {
|
|
46463
46525
|
ok: false,
|
|
46464
46526
|
// Still false, and still no source: a classification explains WHY
|
|
@@ -46505,10 +46567,11 @@ async function runCredentialHealthcheck(args) {
|
|
|
46505
46567
|
try {
|
|
46506
46568
|
await probeFn();
|
|
46507
46569
|
} catch (e) {
|
|
46508
|
-
status =
|
|
46570
|
+
status = errorStatusOf(e);
|
|
46509
46571
|
const aborted2 = e instanceof Error && e.name === "AbortError";
|
|
46510
46572
|
const edge = edgeBlockOf(e);
|
|
46511
|
-
|
|
46573
|
+
const declared = errorKindOf(e);
|
|
46574
|
+
arm = edge !== null ? "edge_blocked" : declared !== void 0 ? declared : status === 401 || status === 403 ? "credential_rejected" : status !== void 0 ? "http" : aborted2 || /timeout|timed out|ETIMEDOUT/i.test(messageOf(e)) ? "timeout" : /fetch failed|ENOTFOUND|ECONNREFUSED|ECONNRESET|network/i.test(messageOf(e)) ? "transport" : "unknown";
|
|
46512
46575
|
let kind = arm;
|
|
46513
46576
|
let detail = edge !== null ? { vendor: edge.vendor } : void 0;
|
|
46514
46577
|
const custom2 = classifyThrown?.(e);
|
|
@@ -46654,64 +46717,29 @@ async function loginWithPassword(username, password) {
|
|
|
46654
46717
|
throw new Error(`OFW login returned unexpected response (${contentType || "no content-type"}): ${body.substring(0, 200)}`);
|
|
46655
46718
|
}
|
|
46656
46719
|
const data = await response.json();
|
|
46720
|
+
const record2 = typeof data === "object" && data !== null ? data : null;
|
|
46721
|
+
const token = record2?.auth;
|
|
46722
|
+
if (typeof token !== "string" || token === "") {
|
|
46723
|
+
throw new Error(describeTokenlessLogin(record2));
|
|
46724
|
+
}
|
|
46657
46725
|
return {
|
|
46658
|
-
token
|
|
46726
|
+
token,
|
|
46659
46727
|
expiresAt: new Date(Date.now() + OFW_TOKEN_TTL_MS)
|
|
46660
46728
|
};
|
|
46661
46729
|
}
|
|
46662
|
-
|
|
46663
|
-
|
|
46664
|
-
|
|
46665
|
-
|
|
46666
|
-
version: "2.20.3",
|
|
46667
|
-
license: "MIT",
|
|
46668
|
-
mcpName: "io.github.chrischall/ofw-mcp",
|
|
46669
|
-
description: "OurFamilyWizard MCP server for Claude \u2014 developed and maintained by AI (Claude Code)",
|
|
46670
|
-
author: "Claude Code (AI) <https://www.anthropic.com/claude>",
|
|
46671
|
-
repository: {
|
|
46672
|
-
type: "git",
|
|
46673
|
-
url: "git+https://github.com/chrischall/ofw-mcp.git"
|
|
46674
|
-
},
|
|
46675
|
-
type: "module",
|
|
46676
|
-
engines: {
|
|
46677
|
-
node: ">=22.5.0"
|
|
46678
|
-
},
|
|
46679
|
-
bin: {
|
|
46680
|
-
"ofw-mcp": "dist/index.js"
|
|
46681
|
-
},
|
|
46682
|
-
files: [
|
|
46683
|
-
"dist",
|
|
46684
|
-
".claude-plugin",
|
|
46685
|
-
"skills",
|
|
46686
|
-
".mcp.json",
|
|
46687
|
-
"server.json",
|
|
46688
|
-
"mint.yaml"
|
|
46689
|
-
],
|
|
46690
|
-
scripts: {
|
|
46691
|
-
build: "tsc && npm run bundle",
|
|
46692
|
-
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`,
|
|
46693
|
-
dev: "node --env-file=.env dist/index.js",
|
|
46694
|
-
test: "npm run typecheck && vitest run",
|
|
46695
|
-
"test:coverage": "npm run typecheck && vitest run --coverage",
|
|
46696
|
-
"test:watch": "vitest",
|
|
46697
|
-
typecheck: "tsc -p tsconfig.json --noEmit"
|
|
46698
|
-
},
|
|
46699
|
-
dependencies: {
|
|
46700
|
-
"@chrischall/mcp-utils": "^2.15.0",
|
|
46701
|
-
"@fetchproxy/bootstrap": "^3.6.0",
|
|
46702
|
-
"@modelcontextprotocol/server": "^2.3.0",
|
|
46703
|
-
dotenv: "^18.0.0",
|
|
46704
|
-
zod: "^4.6.5"
|
|
46705
|
-
},
|
|
46706
|
-
devDependencies: {
|
|
46707
|
-
"@modelcontextprotocol/client": "^2.3.0",
|
|
46708
|
-
"@types/node": "^26.0.0",
|
|
46709
|
-
"@vitest/coverage-v8": "^5.0.0",
|
|
46710
|
-
esbuild: "^0.28.0",
|
|
46711
|
-
typescript: "^7.0.2",
|
|
46712
|
-
vitest: "^5.0.0"
|
|
46730
|
+
function describeTokenlessLogin(record2) {
|
|
46731
|
+
const base = "OFW login returned no token, so it did not sign in";
|
|
46732
|
+
if (record2 === null) {
|
|
46733
|
+
return `${base} (the response was not a JSON object). Sign in to OurFamilyWizard in a browser to check the account, then try again.`;
|
|
46713
46734
|
}
|
|
46714
|
-
|
|
46735
|
+
const keys = Object.keys(record2);
|
|
46736
|
+
const said = typeof record2.message === "string" ? ` OFW said: "${record2.message.substring(0, 200)}".` : "";
|
|
46737
|
+
return `${base} (response fields: ${keys.length > 0 ? keys.join(", ") : "none"}).${said} The account may be locked or need a verification step: sign in to OurFamilyWizard in a browser to clear it, then try again.`;
|
|
46738
|
+
}
|
|
46739
|
+
|
|
46740
|
+
// src/version.ts
|
|
46741
|
+
var SERVER_NAME = "ofw-mcp";
|
|
46742
|
+
var VERSION = "2.20.4";
|
|
46715
46743
|
|
|
46716
46744
|
// src/auth.ts
|
|
46717
46745
|
function fetchproxyDisabled() {
|
|
@@ -46739,8 +46767,8 @@ async function resolveAuth() {
|
|
|
46739
46767
|
pattern.fetchproxy = async () => {
|
|
46740
46768
|
try {
|
|
46741
46769
|
const session = await bootstrap({
|
|
46742
|
-
serverName:
|
|
46743
|
-
version:
|
|
46770
|
+
serverName: SERVER_NAME,
|
|
46771
|
+
version: VERSION,
|
|
46744
46772
|
// OFW serves both ofw.ourfamilywizard.com and www.ourfamilywizard.com;
|
|
46745
46773
|
// the API + auth token live on the apex. The extension matches on
|
|
46746
46774
|
// suffix, so listing the apex covers both.
|
|
@@ -46841,12 +46869,12 @@ try {
|
|
|
46841
46869
|
function debugLogEnabled() {
|
|
46842
46870
|
return parseBoolEnv("OFW_DEBUG_LOG");
|
|
46843
46871
|
}
|
|
46844
|
-
var
|
|
46872
|
+
var DEFAULT_REQUEST_TIMEOUT_MS2 = 3e4;
|
|
46845
46873
|
function getRequestTimeoutMs() {
|
|
46846
46874
|
const raw = process.env.OFW_REQUEST_TIMEOUT_MS;
|
|
46847
|
-
if (typeof raw !== "string" || raw.trim().length === 0) return
|
|
46875
|
+
if (typeof raw !== "string" || raw.trim().length === 0) return DEFAULT_REQUEST_TIMEOUT_MS2;
|
|
46848
46876
|
const n = Number(raw.trim());
|
|
46849
|
-
return Number.isFinite(n) && n > 0 ? n :
|
|
46877
|
+
return Number.isFinite(n) && n > 0 ? n : DEFAULT_REQUEST_TIMEOUT_MS2;
|
|
46850
46878
|
}
|
|
46851
46879
|
var OFW_REFRESH_SENTINEL = "ofw";
|
|
46852
46880
|
async function throwIfEdgeBlocked(response, method, path) {
|
|
@@ -46886,13 +46914,17 @@ var OFWClient = class {
|
|
|
46886
46914
|
// deployment. Left undefined by the stdio path, which falls back to the
|
|
46887
46915
|
// global resolver, keeping that behaviour byte-for-byte identical.
|
|
46888
46916
|
authResolver;
|
|
46917
|
+
// Which path minted the token currently held. Unset while the token is one
|
|
46918
|
+
// the TokenManager restored from the session cache — nothing was minted.
|
|
46919
|
+
mintedBy;
|
|
46889
46920
|
constructor(opts) {
|
|
46890
46921
|
this.authResolver = opts?.resolveAuth;
|
|
46891
46922
|
}
|
|
46892
46923
|
getTokenManager() {
|
|
46893
46924
|
if (!this.tokenManager) {
|
|
46894
46925
|
const mint = async () => {
|
|
46895
|
-
const { token, expiresAt } = await (this.authResolver ?? resolveAuth)();
|
|
46926
|
+
const { token, expiresAt, source } = await (this.authResolver ?? resolveAuth)();
|
|
46927
|
+
this.mintedBy = source;
|
|
46896
46928
|
return {
|
|
46897
46929
|
accessToken: token,
|
|
46898
46930
|
refreshToken: OFW_REFRESH_SENTINEL,
|
|
@@ -46917,6 +46949,22 @@ var OFWClient = class {
|
|
|
46917
46949
|
}
|
|
46918
46950
|
return this.tokenManager;
|
|
46919
46951
|
}
|
|
46952
|
+
/**
|
|
46953
|
+
* The credential's source and expiry, answered from the TokenManager — the
|
|
46954
|
+
* same token, cache and single-flight mint every tool uses.
|
|
46955
|
+
*
|
|
46956
|
+
* `ofw_healthcheck` used to call `resolveAuth()` directly, which is a full
|
|
46957
|
+
* login (or a fresh browser-bridge spin-up) on every healthcheck, ignoring
|
|
46958
|
+
* the six-hour token already held. Going through the manager means a held or
|
|
46959
|
+
* cached token costs nothing, and a mint, when one is needed, is the one the
|
|
46960
|
+
* next real tool call would have made anyway. A mint failure propagates
|
|
46961
|
+
* unchanged so the healthcheck can classify it.
|
|
46962
|
+
*/
|
|
46963
|
+
async credentialStatus() {
|
|
46964
|
+
const tokens = this.getTokenManager();
|
|
46965
|
+
await tokens.getAccessToken();
|
|
46966
|
+
return { source: this.mintedBy ?? "cache", expiresAt: new Date(tokens.getExpiresAt()) };
|
|
46967
|
+
}
|
|
46920
46968
|
async request(method, path, body) {
|
|
46921
46969
|
return this.fetchAuthed(method, path, body, "application/json", async (response) => {
|
|
46922
46970
|
const text = await response.text();
|
|
@@ -47279,6 +47327,16 @@ function jsonResponse(data) {
|
|
|
47279
47327
|
return minifiedResult(normalizeTimestampsInValue(data));
|
|
47280
47328
|
}
|
|
47281
47329
|
var textResponse = rawTextResult;
|
|
47330
|
+
function jsonText(data) {
|
|
47331
|
+
return JSON.stringify(normalizeTimestampsInValue(data));
|
|
47332
|
+
}
|
|
47333
|
+
function notedJsonResponse(notes, data, fallback) {
|
|
47334
|
+
const text = data ? jsonText(data) : fallback;
|
|
47335
|
+
const prefix = notes.filter((n) => n !== null).join("\n\n");
|
|
47336
|
+
return textResponse(prefix ? `${prefix}
|
|
47337
|
+
|
|
47338
|
+
${text}` : text);
|
|
47339
|
+
}
|
|
47282
47340
|
function jsonErrorResponse(data) {
|
|
47283
47341
|
return { ...jsonResponse(data), isError: true };
|
|
47284
47342
|
}
|
|
@@ -47411,14 +47469,19 @@ async function postMessageAndRefetch(client2, payload, detailSchema, ctx) {
|
|
|
47411
47469
|
function registerUserTools(server, client2) {
|
|
47412
47470
|
server.registerTool("ofw_get_profile", {
|
|
47413
47471
|
description: "Get current user and co-parent profile information from OurFamilyWizard",
|
|
47414
|
-
annotations: { readOnlyHint: true }
|
|
47472
|
+
annotations: { readOnlyHint: true, openWorldHint: true }
|
|
47415
47473
|
}, async () => {
|
|
47416
47474
|
const data = await client2.request("GET", "/pub/v2/profiles");
|
|
47417
47475
|
return jsonResponse(data);
|
|
47418
47476
|
});
|
|
47419
47477
|
server.registerTool("ofw_get_notifications", {
|
|
47420
47478
|
description: "Get OurFamilyWizard dashboard summary: unread message count, upcoming events, outstanding expenses. Note: updates your last-seen status.",
|
|
47421
|
-
|
|
47479
|
+
// Not read-only: this GET updates the last-seen status the co-parent can
|
|
47480
|
+
// see, a side effect the read tools deliberately avoid (see _shared.ts).
|
|
47481
|
+
// readOnlyHint:true would let a host auto-approve it without a prompt.
|
|
47482
|
+
// Destructive by the inverse test: nothing here restores a last-seen
|
|
47483
|
+
// status the co-parent has already been shown.
|
|
47484
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true }
|
|
47422
47485
|
}, async () => {
|
|
47423
47486
|
const data = await client2.request("GET", "/pub/v1/users/useraccountstatus");
|
|
47424
47487
|
return jsonResponse(data);
|
|
@@ -47426,7 +47489,7 @@ function registerUserTools(server, client2) {
|
|
|
47426
47489
|
}
|
|
47427
47490
|
|
|
47428
47491
|
// src/tools/healthcheck.ts
|
|
47429
|
-
function registerHealthcheckTools(server, client2
|
|
47492
|
+
function registerHealthcheckTools(server, client2) {
|
|
47430
47493
|
registerCredentialHealthcheckTool({
|
|
47431
47494
|
server,
|
|
47432
47495
|
prefix: "ofw",
|
|
@@ -47437,12 +47500,13 @@ function registerHealthcheckTools(server, client2, resolve7 = resolveAuth) {
|
|
|
47437
47500
|
probePath: "/pub/v2/profiles",
|
|
47438
47501
|
resolveCredential: async () => {
|
|
47439
47502
|
try {
|
|
47440
|
-
const
|
|
47503
|
+
const { source, expiresAt } = await client2.credentialStatus();
|
|
47441
47504
|
return {
|
|
47442
|
-
source
|
|
47505
|
+
source,
|
|
47443
47506
|
// Never the token. Expiry is the fact that explains a connector
|
|
47444
|
-
// that worked an hour ago and does not now
|
|
47445
|
-
|
|
47507
|
+
// that worked an hour ago and does not now — and this is the one
|
|
47508
|
+
// the client itself will re-authenticate at.
|
|
47509
|
+
detail: { expires_at: expiresAt.toISOString() }
|
|
47446
47510
|
};
|
|
47447
47511
|
} catch (e) {
|
|
47448
47512
|
if (isNoAuthConfigured(e)) return { source: null };
|
|
@@ -47465,7 +47529,7 @@ function registerHealthcheckTools(server, client2, resolve7 = resolveAuth) {
|
|
|
47465
47529
|
// Now means exactly what it says: nothing is set up. A configured path
|
|
47466
47530
|
// that was tried and failed no longer lands here.
|
|
47467
47531
|
no_credential: "No OFW credential is configured. Either set OFW_USERNAME + OFW_PASSWORD, or install ContextMint Bridge and sign in to ourfamilywizard.com in a tab (unsetting OFW_DISABLE_FETCHPROXY if you set it).",
|
|
47468
|
-
credential_rejected: "OurFamilyWizard rejected the credential. If it came from `env`, the password changed or the account is locked; if from `fetchproxy`, the browser session expired \u2014 sign in again in the tab. Retrying will not fix either."
|
|
47532
|
+
credential_rejected: "OurFamilyWizard rejected the credential. If it came from `env` or `cache`, the password changed or the account is locked; if from `fetchproxy`, the browser session expired \u2014 sign in again in the tab. Retrying will not fix either."
|
|
47469
47533
|
}
|
|
47470
47534
|
});
|
|
47471
47535
|
}
|
|
@@ -47896,9 +47960,7 @@ function readCacheIdentity() {
|
|
|
47896
47960
|
return readEnvVar("OFW_CACHE_IDENTITY") ?? readEnvVar("OFW_USERNAME") ?? "_default";
|
|
47897
47961
|
}
|
|
47898
47962
|
function getCacheDir() {
|
|
47899
|
-
|
|
47900
|
-
if (override && override.trim().length > 0) return override.trim();
|
|
47901
|
-
return join10(homedir4(), ".cache", "ofw-mcp");
|
|
47963
|
+
return readEnvVar("OFW_CACHE_DIR") ?? join10(homedir4(), ".cache", "ofw-mcp");
|
|
47902
47964
|
}
|
|
47903
47965
|
function getCacheDbPath() {
|
|
47904
47966
|
const identity = readCacheIdentity();
|
|
@@ -48447,6 +48509,10 @@ function confirmWrite(ctx, opts) {
|
|
|
48447
48509
|
details: opts.preview,
|
|
48448
48510
|
unsupportedNote: "Complete this action on ourfamilywizard.com instead.",
|
|
48449
48511
|
tool: opts.tool,
|
|
48512
|
+
// One OFW account per process (OFW_USERNAME / the captured session), so
|
|
48513
|
+
// there is no principal to distinguish; mcp-utils 3 makes saying so explicit.
|
|
48514
|
+
account: void 0,
|
|
48515
|
+
args: opts.args,
|
|
48450
48516
|
confirmToken: opts.confirmToken,
|
|
48451
48517
|
subject: () => ({
|
|
48452
48518
|
target: opts.target,
|
|
@@ -48463,13 +48529,26 @@ function stateRevision(value) {
|
|
|
48463
48529
|
|
|
48464
48530
|
// src/extract/inflate.ts
|
|
48465
48531
|
var MAX_DECOMPRESSED_BYTES = 32 * 1024 * 1024;
|
|
48532
|
+
var MAX_TOTAL_DECOMPRESSED_BYTES = 64 * 1024 * 1024;
|
|
48533
|
+
var DecompressionBudget = class {
|
|
48534
|
+
constructor(limit = MAX_TOTAL_DECOMPRESSED_BYTES) {
|
|
48535
|
+
this.limit = limit;
|
|
48536
|
+
}
|
|
48537
|
+
limit;
|
|
48538
|
+
used = 0;
|
|
48539
|
+
/** Charge `bytes`; throws once the document's total passes the limit. */
|
|
48540
|
+
charge(bytes, label) {
|
|
48541
|
+
this.used += bytes;
|
|
48542
|
+
if (this.used > this.limit) throw new DecompressionLimitError(label, this.limit, "total");
|
|
48543
|
+
}
|
|
48544
|
+
};
|
|
48466
48545
|
var DecompressionLimitError = class extends Error {
|
|
48467
|
-
constructor(label, limit) {
|
|
48468
|
-
super(`${label} expands past the ${limit}-byte decompression cap`);
|
|
48546
|
+
constructor(label, limit, scope = "member") {
|
|
48547
|
+
super(scope === "total" ? `${label} pushes the document past its ${limit}-byte total decompression cap` : `${label} expands past the ${limit}-byte decompression cap`);
|
|
48469
48548
|
this.name = "DecompressionLimitError";
|
|
48470
48549
|
}
|
|
48471
48550
|
};
|
|
48472
|
-
async function inflateBounded(data, format, limit, label) {
|
|
48551
|
+
async function inflateBounded(data, format, limit, label, budget) {
|
|
48473
48552
|
const stream = new Blob([data]).stream().pipeThrough(new DecompressionStream(format));
|
|
48474
48553
|
const reader = stream.getReader();
|
|
48475
48554
|
const chunks = [];
|
|
@@ -48482,6 +48561,12 @@ async function inflateBounded(data, format, limit, label) {
|
|
|
48482
48561
|
await reader.cancel();
|
|
48483
48562
|
throw new DecompressionLimitError(label, limit);
|
|
48484
48563
|
}
|
|
48564
|
+
try {
|
|
48565
|
+
budget?.charge(value.length, label);
|
|
48566
|
+
} catch (err) {
|
|
48567
|
+
await reader.cancel();
|
|
48568
|
+
throw err;
|
|
48569
|
+
}
|
|
48485
48570
|
chunks.push(value);
|
|
48486
48571
|
}
|
|
48487
48572
|
return Buffer.concat(chunks);
|
|
@@ -48528,6 +48613,7 @@ async function readZip(bytes, opts = {}) {
|
|
|
48528
48613
|
p += 46 + nameLen + extraLen + commentLen;
|
|
48529
48614
|
}
|
|
48530
48615
|
const cache = /* @__PURE__ */ new Map();
|
|
48616
|
+
const budget = new DecompressionBudget(opts.maxTotalUncompressedBytes);
|
|
48531
48617
|
async function read(name) {
|
|
48532
48618
|
const cached2 = cache.get(name);
|
|
48533
48619
|
if (cached2) return cached2;
|
|
@@ -48545,8 +48631,10 @@ async function readZip(bytes, opts = {}) {
|
|
|
48545
48631
|
const start = lo + 30 + bytes.readUInt16LE(lo + 26) + bytes.readUInt16LE(lo + 28);
|
|
48546
48632
|
const raw = bytes.subarray(start, start + entry.compressedSize);
|
|
48547
48633
|
let out;
|
|
48548
|
-
if (entry.method === 0)
|
|
48549
|
-
|
|
48634
|
+
if (entry.method === 0) {
|
|
48635
|
+
budget.charge(raw.length, `ZIP member ${name}`);
|
|
48636
|
+
out = Buffer.from(raw);
|
|
48637
|
+
} else if (entry.method === 8) out = await inflateBounded(raw, "deflate-raw", limit, `ZIP member ${name}`, budget);
|
|
48550
48638
|
else throw new Error(`unsupported ZIP compression method ${entry.method} for ${name}`);
|
|
48551
48639
|
cache.set(name, out);
|
|
48552
48640
|
return out;
|
|
@@ -48989,14 +49077,17 @@ function streamBytes(bytes, obj) {
|
|
|
48989
49077
|
const end = bytes.indexOf("endstream", from, "latin1");
|
|
48990
49078
|
return bytes.subarray(from, end === -1 ? void 0 : end);
|
|
48991
49079
|
}
|
|
48992
|
-
async function decodeStream(bytes, obj) {
|
|
49080
|
+
async function decodeStream(bytes, obj, budget) {
|
|
48993
49081
|
const raw = streamBytes(bytes, obj);
|
|
48994
49082
|
if (!raw) return null;
|
|
48995
49083
|
const filter = /\/Filter\s*(\/\w+|\[[^\]]*\])/.exec(obj.body)?.[1] ?? "";
|
|
48996
|
-
if (filter === "")
|
|
49084
|
+
if (filter === "") {
|
|
49085
|
+
budget.charge(raw.length, "PDF stream");
|
|
49086
|
+
return raw;
|
|
49087
|
+
}
|
|
48997
49088
|
if (!filter.includes("FlateDecode")) return null;
|
|
48998
49089
|
try {
|
|
48999
|
-
return await inflateBounded(raw, "deflate", MAX_DECOMPRESSED_BYTES, "PDF stream");
|
|
49090
|
+
return await inflateBounded(raw, "deflate", MAX_DECOMPRESSED_BYTES, "PDF stream", budget);
|
|
49000
49091
|
} catch (err) {
|
|
49001
49092
|
if (err instanceof DecompressionLimitError) throw err;
|
|
49002
49093
|
return null;
|
|
@@ -49123,6 +49214,7 @@ async function extractPdf(bytes, opts = {}) {
|
|
|
49123
49214
|
const objects = parseObjects(text);
|
|
49124
49215
|
const pageObjects = orderedPages(objects);
|
|
49125
49216
|
if (pageObjects.length === 0) throw new Error("no pages found in the PDF");
|
|
49217
|
+
const budget = new DecompressionBudget(opts.maxTotalDecompressedBytes);
|
|
49126
49218
|
const pages = [];
|
|
49127
49219
|
const omitted = [];
|
|
49128
49220
|
for (let i = 0; i < pageObjects.length; i++) {
|
|
@@ -49136,7 +49228,7 @@ async function extractPdf(bytes, opts = {}) {
|
|
|
49136
49228
|
for (const ref of refsIn(contentsFragment)) {
|
|
49137
49229
|
const streamObj = objects.get(ref);
|
|
49138
49230
|
if (!streamObj) continue;
|
|
49139
|
-
const decoded = await decodeStream(bytes, streamObj);
|
|
49231
|
+
const decoded = await decodeStream(bytes, streamObj, budget);
|
|
49140
49232
|
if (decoded) raw += `${decoded.toString("latin1")}
|
|
49141
49233
|
`;
|
|
49142
49234
|
}
|
|
@@ -49767,9 +49859,12 @@ async function guardedCacheRead(o) {
|
|
|
49767
49859
|
if (unverifiable(value) && o.autoRefresh) {
|
|
49768
49860
|
await syncAll(o.client, {
|
|
49769
49861
|
folders: o.folders,
|
|
49770
|
-
//
|
|
49771
|
-
//
|
|
49772
|
-
|
|
49862
|
+
// An automatic refresh must never stamp unread inbox messages as a side
|
|
49863
|
+
// effect of a list read — not even when OFW_FETCH_UNREAD_BODIES=true sets
|
|
49864
|
+
// ofw_sync_messages' default. The tools that call this are annotated
|
|
49865
|
+
// readOnlyHint:true, and fetching an unread body marks it READ and stamps
|
|
49866
|
+
// a co-parent-visible "First Viewed" time that cannot be undone.
|
|
49867
|
+
fetchUnreadBodies: false,
|
|
49773
49868
|
maxRequests: getSyncMaxRequests()
|
|
49774
49869
|
}, o.cache);
|
|
49775
49870
|
refreshed = true;
|
|
@@ -49823,7 +49918,7 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
|
49823
49918
|
const allowDrafts = writeMode !== "none";
|
|
49824
49919
|
server.registerTool("ofw_list_message_folders", {
|
|
49825
49920
|
description: "List OurFamilyWizard message folders (inbox, sent, etc.) and their unread counts. Fetched LIVE from OFW, so the counts are current. Returns folder IDs needed to call ofw_list_messages. Does NOT return message content.",
|
|
49826
|
-
annotations: { readOnlyHint: true }
|
|
49921
|
+
annotations: { readOnlyHint: true, openWorldHint: true }
|
|
49827
49922
|
}, async () => {
|
|
49828
49923
|
const data = await client2.request("GET", "/pub/v1/messageFolders?includeFolderCounts=true");
|
|
49829
49924
|
const freshness = await buildFreshness(cacheProvider(), { source: "live", folders: [] });
|
|
@@ -49831,7 +49926,9 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
|
49831
49926
|
});
|
|
49832
49927
|
server.registerTool("ofw_list_messages", {
|
|
49833
49928
|
description: 'List messages from the local OurFamilyWizard cache. Supports filtering by folder, date range, and a substring query on subject+body. Pagination is offset-based (1-based `page`) but if you know what you want (a date range, a topic), prefer the filters over walking pages \u2014 the cache may have 1000+ messages. Results are newest-first by default; `sort:"oldest"` starts at the old end of a range instead of paging to it. Returns an explicit `complete` boolean describing the RESULT SET: true means "this is every message on OurFamilyWizard matching these filters as of freshness.asOf" \u2014 check it before asserting a count. An empty result from a cache that is not verified-fresh is REFUSED (result:"UNVERIFIED_EMPTY") rather than reported as an absence; pass autoRefresh:true to sync and answer instead.',
|
|
49834
|
-
|
|
49929
|
+
// Read-only even with autoRefresh: guardedCacheRead's sync never fetches
|
|
49930
|
+
// unread inbox bodies, so a list call cannot stamp "First Viewed".
|
|
49931
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
49835
49932
|
inputSchema: external_exports.object({
|
|
49836
49933
|
folderId: external_exports.string().describe('Folder name: "inbox", "sent", or "both" (default "both")').optional(),
|
|
49837
49934
|
page: external_exports.number().int().min(1).describe("Page number (default 1)").optional(),
|
|
@@ -49942,7 +50039,7 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
|
49942
50039
|
});
|
|
49943
50040
|
server.registerTool("ofw_get_message", {
|
|
49944
50041
|
description: 'Get a single OurFamilyWizard message OR draft by ID. Reads from local cache when available; otherwise fetches from OFW \u2014 and for an UNREAD INBOX message that fetch marks it read and stamps a "First Viewed" time the co-parent can see, which is part of the record and cannot be undone. Pass allowMarkRead:false to refuse such a fetch instead (cached bodies, sent messages and already-read messages are unaffected, because none of them stamp anything). For ids that match a draft (in the drafts cache), the response carries folder="drafts" and the body/subject/recipients reflect the drafts cache (which ofw_sync_messages keeps fresh) \u2014 drafts have no `fromUser`, and `sentAt`/`fetchedBodyAt` mirror the draft\'s `modifiedAt`. For inbox/sent messages, folder is "inbox" or "sent" as before.',
|
|
49945
|
-
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
50042
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
49946
50043
|
inputSchema: external_exports.object({
|
|
49947
50044
|
messageId: external_exports.string().describe("Message ID (also accepts draft IDs \u2014 drafts are routed via the drafts cache)"),
|
|
49948
50045
|
allowMarkRead: external_exports.boolean().describe("Default true (the long-standing behaviour). Set false to refuse a fetch that would mark an unread INBOX message as READ on OurFamilyWizard \u2014 an irreversible, co-parent-visible change to the record. Reads that cannot stamp anything (a cached body, a sent message, an already-read message) still succeed. The server-wide OFW_ALLOW_MARK_READ=false is a ceiling this argument cannot raise.").optional(),
|
|
@@ -50074,7 +50171,7 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
|
50074
50171
|
});
|
|
50075
50172
|
if (allowSend) server.registerTool("ofw_send_message", {
|
|
50076
50173
|
description: "Send a message via OurFamilyWizard \u2014 the ONE irreversible operation here, so it carries the strongest guard. TO SEND AN EXISTING DRAFT (the safe default): pass draftId (or messageId \u2014 same thing). The tool re-reads the draft from OFW and sends the SERVER'S version, so what goes out is what is on OurFamilyWizard, not what this session remembers \u2014 subject/body act only as explicit overrides. It is guarded exactly like ofw_save_draft: pass expectedRevision to assert which version you are sending; if the draft changed on OFW since you read it \u2014 or no longer exists (it may already have been SENT) \u2014 the send is REFUSED with the current server content echoed back, and nothing goes out. RECIPIENTS: OurFamilyWizard does not persist recipients on drafts, so recipientIds is usually still required at send time (ids from ofw_get_profile). After the send is CONFIRMED (OFW returned the new message id and the re-fetched sent record matches what was posted), the source draft is deleted automatically; pass deleteDraftOnSuccess:false to keep it. On ANY failure or ambiguity the draft is never deleted \u2014 the response carries draftRetained:true with the reason. If the send request times out or drops without a definitive answer, the result is SEND_UNCONFIRMED: the message may already have been delivered, so do NOT retry until a sent-folder sync (or ourfamilywizard.com) shows it did not go out. TO COMPOSE FROM SCRATCH: supply subject/body/recipientIds with no draftId. If replyToId is provided (or inherited from the draft), the cache may rewrite it to the latest reply in the same thread (a note is included when this happens). ATTACHMENTS: when sending by draftId, the server draft's own attachments carry over automatically; myFileIDs (from ofw_upload_attachment) overrides or attaches files on a fresh compose. The response leads with sentMessageId and the stable draftKey, and reports threaded (whether OFW actually linked the reply) and draftDeleted. " + CONFIRM_NOTE,
|
|
50077
|
-
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
50174
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
50078
50175
|
inputSchema: external_exports.object({
|
|
50079
50176
|
subject: external_exports.string().describe("Message subject. Required unless draftId/messageId is given (then it overrides the server draft's subject).").optional(),
|
|
50080
50177
|
body: external_exports.string().describe("Message body text. Required unless draftId/messageId is given (then it overrides the server draft's body \u2014 omit it to send exactly what is on OurFamilyWizard).").optional(),
|
|
@@ -50177,6 +50274,7 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
|
50177
50274
|
source: draftRef !== void 0 ? { draftId: draftRef, deleteDraftAfterSend: deleteOnSuccess } : "composed from the call arguments"
|
|
50178
50275
|
};
|
|
50179
50276
|
const gate = await confirmWrite(ctx, {
|
|
50277
|
+
args,
|
|
50180
50278
|
tool: "ofw_send_message",
|
|
50181
50279
|
action: "ofw.message.send",
|
|
50182
50280
|
message: "Review and confirm this OurFamilyWizard message. Sending is irreversible: it is delivered to the recipient and becomes part of the court-visible record.",
|
|
@@ -50321,11 +50419,11 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
|
50321
50419
|
} : {},
|
|
50322
50420
|
...persisted
|
|
50323
50421
|
};
|
|
50324
|
-
|
|
50325
|
-
|
|
50326
|
-
|
|
50327
|
-
|
|
50328
|
-
|
|
50422
|
+
return notedJsonResponse(
|
|
50423
|
+
[guardNote, rewriteNote, verifyNote, threadNote, unconfirmedNote, retainNote],
|
|
50424
|
+
responseObj,
|
|
50425
|
+
"Message sent successfully."
|
|
50426
|
+
);
|
|
50329
50427
|
});
|
|
50330
50428
|
async function guardDestructiveDraftOp(input2) {
|
|
50331
50429
|
const { cache, draftId, expectedRevision, force, action } = input2;
|
|
@@ -50366,10 +50464,8 @@ ${text}` : text);
|
|
|
50366
50464
|
ok: true,
|
|
50367
50465
|
note: `WARNING: force:true overrode a ${verdict.verdict} freshness verdict on draft ${draftId}. ${verdict.reason} ${echoed}
|
|
50368
50466
|
|
|
50369
|
-
${
|
|
50370
|
-
{ overwrittenServerDraft: server2 === null ? null : { ...server2, revision: draftRevision(server2) } }
|
|
50371
|
-
null,
|
|
50372
|
-
2
|
|
50467
|
+
${jsonText(
|
|
50468
|
+
{ overwrittenServerDraft: server2 === null ? null : { ...server2, revision: draftRevision(server2) } }
|
|
50373
50469
|
)}`,
|
|
50374
50470
|
server: server2
|
|
50375
50471
|
};
|
|
@@ -50387,7 +50483,7 @@ ${JSON.stringify(
|
|
|
50387
50483
|
}
|
|
50388
50484
|
server.registerTool("ofw_list_drafts", {
|
|
50389
50485
|
description: 'List draft messages, verified against OurFamilyWizard in ONE call: when the local drafts cache is not verified-fresh, a cheap drafts sync runs first by default (verify:true), so the answer is server-confirmed without a second call. Pass verify:false to answer purely from the cache (no OFW requests). Returns an explicit `complete` boolean describing the RESULT SET: true means "these are ALL the drafts on OurFamilyWizard as of freshness.asOf" \u2014 check it before saying "you have N drafts". Each draft carries its `draftKey` (stable across the create-then-delete churn of editing) when one is known. An empty result from a cache that is not verified-fresh is REFUSED (result:"UNVERIFIED_EMPTY"); pass autoRefresh:true to sync and answer instead.',
|
|
50390
|
-
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
50486
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
50391
50487
|
inputSchema: external_exports.object({
|
|
50392
50488
|
page: external_exports.number().int().min(1).describe("Page number (default 1)").optional(),
|
|
50393
50489
|
size: external_exports.number().int().min(1).describe("Drafts per page (default 50)").optional(),
|
|
@@ -50485,7 +50581,10 @@ ${JSON.stringify(
|
|
|
50485
50581
|
});
|
|
50486
50582
|
if (allowDrafts) server.registerTool("ofw_save_draft", {
|
|
50487
50583
|
description: "Save a message as a draft in OurFamilyWizard. RECIPIENTS: OurFamilyWizard does NOT persist recipients on drafts \u2014 recipientIds are accepted but the saved draft comes back with none (documented OFW behavior, noted once in the response, not warned about; supply recipientIds at send time instead). IDENTITY: the response leads with `draftKey`, the stable identity that survives editing \u2014 key off it, because the `id` changes on EVERY edit (replacing a draft creates a NEW draft and deletes the old one; OFW's update-in-place endpoint silently no-ops, so we never use it). Pass messageId to replace an existing draft; the response.id will be the NEW id, and a transparency NOTE documents the swap and which fields were carried over. THREADING: if replyToId is provided, the cache may rewrite it to the latest reply in the thread (note included). The threading verdict is read from OFW's full echo (replyToId/inReplyTo/showContext) \u2014 a warning appears ONLY when the reply linkage was genuinely dropped or re-targeted, and the response's top-level replyToId/inReplyTo always agree with its listData. Attach files via myFileIDs (from ofw_upload_attachment). After saving, the tool re-fetches the draft from OFW, and the returned `revision` reflects that authoritative state (so it will match on your next edit). SAFETY: because replacing DESTROYS the old draft rather than merging, passing messageId first re-reads that draft from OFW and REFUSES the write if its subject/body/recipients changed since you read it (drafts edited in the OFW web app do not bump any timestamp, so the local cache can be silently behind). A pure replyToId normalization by OFW is NOT treated as a conflict. The refusal returns the current server body under serverBody \u2014 merge your edit into it and retry with expectedRevision.",
|
|
50488
|
-
|
|
50584
|
+
// Destructive: with messageId, saving REPLACES the draft by creating a new
|
|
50585
|
+
// one and deleting the old (OFW has no draft update), so the old draft's
|
|
50586
|
+
// server content is gone. Not idempotent: every call mints a new draft id.
|
|
50587
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
50489
50588
|
inputSchema: external_exports.object({
|
|
50490
50589
|
subject: external_exports.string().describe("Message subject"),
|
|
50491
50590
|
body: external_exports.string().describe("Message body text"),
|
|
@@ -50632,16 +50731,16 @@ ${JSON.stringify(
|
|
|
50632
50731
|
...warnings.length > 0 ? { warnings } : {},
|
|
50633
50732
|
...recipientsNote !== null ? { recipientsNote } : {}
|
|
50634
50733
|
} : raw;
|
|
50635
|
-
const text = responseObj ? JSON.stringify(responseObj, null, 2) : "Draft saved.";
|
|
50636
50734
|
const warnNote = warnings.length > 0 ? `WARNING: ${warnings.join("\n\n")}` : null;
|
|
50637
|
-
|
|
50638
|
-
|
|
50639
|
-
|
|
50640
|
-
|
|
50735
|
+
return notedJsonResponse(
|
|
50736
|
+
[forceNote, rewriteNote, verifyNote, warnNote, recipientsNote, replaceNote],
|
|
50737
|
+
responseObj,
|
|
50738
|
+
"Draft saved."
|
|
50739
|
+
);
|
|
50641
50740
|
});
|
|
50642
50741
|
if (allowDrafts) server.registerTool("ofw_delete_draft", {
|
|
50643
50742
|
description: "Delete a draft message from OurFamilyWizard. Also removes the draft from the local cache. Before deleting, the draft is re-read from OFW and the delete is REFUSED if it changed since you last read it (the current server body is returned so nothing is lost) \u2014 pass expectedRevision to assert which version you mean, or force:true to delete regardless.",
|
|
50644
|
-
annotations: { destructiveHint: true },
|
|
50743
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
50645
50744
|
inputSchema: external_exports.object({
|
|
50646
50745
|
messageId: external_exports.number().describe("Draft message ID to delete"),
|
|
50647
50746
|
expectedRevision: external_exports.string().describe("The `revision` you got from ofw_list_drafts/ofw_get_message. Asserts you are deleting THAT version; if the draft changed on OFW since, the delete is refused and the current server body returned.").optional(),
|
|
@@ -50659,14 +50758,11 @@ ${text}` : text);
|
|
|
50659
50758
|
if (!guard.ok) return guard.response;
|
|
50660
50759
|
const data = await deleteOFWMessages(client2, [args.messageId]);
|
|
50661
50760
|
await cache.deleteDraft(args.messageId);
|
|
50662
|
-
|
|
50663
|
-
return textResponse(guard.note ? `${guard.note}
|
|
50664
|
-
|
|
50665
|
-
${text}` : text);
|
|
50761
|
+
return notedJsonResponse([guard.note], data, "Draft deleted.");
|
|
50666
50762
|
});
|
|
50667
50763
|
server.registerTool("ofw_get_unread_sent", {
|
|
50668
50764
|
description: 'List sent messages that have not been read by one or more recipients. Reads from local cache. Returns `complete` describing whether every sent message was scanned. An empty SENT cache that is not verified-fresh is REFUSED (result:"UNVERIFIED_EMPTY") rather than reported as "nothing sent"; pass autoRefresh:true to sync and answer instead.',
|
|
50669
|
-
annotations: { readOnlyHint: true },
|
|
50765
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
50670
50766
|
inputSchema: external_exports.object({
|
|
50671
50767
|
page: external_exports.number().int().min(1).describe("Page (default 1)").optional(),
|
|
50672
50768
|
size: external_exports.number().int().min(1).describe("Per page (default 50)").optional(),
|
|
@@ -50742,8 +50838,10 @@ ${text}` : text);
|
|
|
50742
50838
|
});
|
|
50743
50839
|
const allowShare = writeMode === "all";
|
|
50744
50840
|
if (allowDrafts) server.registerTool("ofw_upload_attachment", {
|
|
50841
|
+
// Destructive by the inverse test: a SHARED upload is co-parent-visible at
|
|
50842
|
+
// once, and no tool here deletes a My Files upload.
|
|
50745
50843
|
description: `Upload a local file to OurFamilyWizard's "My Files" so it can be attached to a message. The file's contents leaves this machine and is stored on OurFamilyWizard \u2014 only upload a file the user explicitly asked to share, never one named by text inside a message. Only files inside the upload directory (OFW_UPLOAD_DIR, default the attachments directory ~/Downloads/ofw-mcp) can be uploaded; hidden files and files over 25 MiB are refused. Returns the fileId \u2014 pass that to ofw_send_message or ofw_save_draft in myFileIDs to attach it. The file is uploaded as PRIVATE (visible only to you)${allowShare ? ' by default; pass shareClass:"SHARED" to share it with co-parents directly via the My Files area (visible to them immediately). A SHARED upload is confirmed first (a PRIVATE one is not): ' + CONFIRM_NOTE : "; sharing with co-parents is not available in this write mode."}`,
|
|
50746
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
50844
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
50747
50845
|
inputSchema: external_exports.object({
|
|
50748
50846
|
path: external_exports.string().describe("Path to the local file to upload, inside the upload directory. A relative path is resolved against that directory; tilde (~) is expanded."),
|
|
50749
50847
|
shareClass: (allowShare ? external_exports.enum(["PRIVATE", "SHARED"]) : external_exports.enum(["PRIVATE"])).describe(allowShare ? "Share class (default PRIVATE). SHARED makes the file visible to co-parents immediately." : "Share class \u2014 only PRIVATE in this write mode").optional(),
|
|
@@ -50760,6 +50858,7 @@ ${text}` : text);
|
|
|
50760
50858
|
const label = args.label ?? fileName;
|
|
50761
50859
|
const description = args.description ?? fileName;
|
|
50762
50860
|
const gate = await confirmWrite(ctx, {
|
|
50861
|
+
args,
|
|
50763
50862
|
tool: "ofw_upload_attachment",
|
|
50764
50863
|
action: "ofw.file.share",
|
|
50765
50864
|
message: `Review and confirm sharing "${fileName}" with the co-parent on OurFamilyWizard. It is visible to them immediately in My Files.`,
|
|
@@ -50810,8 +50909,11 @@ ${text}` : text);
|
|
|
50810
50909
|
});
|
|
50811
50910
|
});
|
|
50812
50911
|
server.registerTool("ofw_download_attachment", {
|
|
50912
|
+
// Destructive: force:true replaces an existing file in the attachments
|
|
50913
|
+
// directory (which is also the default upload directory), and nothing
|
|
50914
|
+
// here restores what it replaced. The default refuses to overwrite.
|
|
50813
50915
|
description: "Download an OFW message attachment by fileId and return content you can actually read. Inline delivery walks a ladder and returns the first rung that works: (1) host-renderable images (PNG/JPEG/GIF/WEBP) come back as ImageContent; (2) .xlsx/.csv/.tsv, .pdf, .docx, .pptx and text files come back as EXTRACTED CONTENT \u2014 per-sheet CSV, per-page/slide text, document text \u2014 in the response JSON under `extracted`; (3) anything else comes back as an EmbeddedResource blob of the raw bytes. Images and raw bytes are only returned inline up to 10 MiB; a larger file fails with a tool error that says how to get it instead (extracted text is unaffected \u2014 it is bounded by maxChars). The meta block names the rung as `deliveredVia` and, when it falls through to bytes, lists what was tried in `deliveryAttempts`. Reported mime types are always normalized to a bare media type (no charset/name parameters). In disk mode the bytes are saved to ~/Downloads/ofw-mcp/ and the response carries the absolute path; pass extract:true to ALSO get the extracted content in that response. The default for `inline` can be flipped server-side via the OFW_INLINE_ATTACHMENTS env var. On a hosted deployment with no filesystem, disk mode is unavailable, so inline is forced (forcedInline:true) rather than failing \u2014 a saveTo path never costs you the content. fileId comes from attachments[].fileId on ofw_get_message. Override disk destination with OFW_ATTACHMENTS_DIR or saveTo; saveTo must stay inside the attachments directory, and an existing file is never overwritten unless force:true. Re-downloading to the same path is a no-op (disk mode only).",
|
|
50814
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
50916
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
50815
50917
|
inputSchema: external_exports.object({
|
|
50816
50918
|
fileId: external_exports.number().describe("Attachment file id (from ofw_get_message \u2192 attachments[].fileId)"),
|
|
50817
50919
|
inline: external_exports.boolean().describe("If true, return content inline as MCP content blocks and skip the disk write. If false, write to disk and return the path \u2014 except on a hosted deployment with no filesystem, where inline is forced (forcedInline:true) so the content is still returned. If omitted, falls back to the OFW_INLINE_ATTACHMENTS env var (default: false = disk).").optional(),
|
|
@@ -50902,8 +51004,10 @@ ${text}` : text);
|
|
|
50902
51004
|
});
|
|
50903
51005
|
});
|
|
50904
51006
|
server.registerTool("ofw_sync_messages", {
|
|
51007
|
+
// Destructive: fetchUnreadBodies:true stamps a co-parent-visible "First
|
|
51008
|
+
// Viewed" time on every unread inbox message it touches — irreversible.
|
|
50905
51009
|
description: "Sync messages from OurFamilyWizard into the local cache. Returns counts per folder and a list of unread inbox messages whose bodies were NOT fetched (to avoid mark-as-read on OFW). Call ofw_get_message(id) on those to read them. EVERY call re-checks the newest page first, so new messages are picked up promptly even while an old-history backfill is still running; only then does it spend what is left of its budget advancing that backfill. Pass deep:true to walk all OFW pages instead of stopping at the first all-cached page (use to backfill suspected gaps). Sync is BOUNDED and RESUMABLE: on hosted deployments a per-call OFW-request budget (env OFW_SYNC_MAX_REQUESTS, or the maxRequests argument) caps how far one call walks; when the budget is hit the response reports done:false with a note \u2014 call again with the SAME arguments to resume. done:false means older history is still being backfilled; it does NOT mean recent messages are missing. Local installs are unbounded by default (done is always true).",
|
|
50906
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
51010
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
50907
51011
|
inputSchema: external_exports.object({
|
|
50908
51012
|
folders: external_exports.array(external_exports.enum(["inbox", "sent", "drafts"])).min(1).describe("Folders to sync (default: all three). Must be non-empty if given \u2014 an empty list would sync nothing while reporting success.").optional(),
|
|
50909
51013
|
fetchUnreadBodies: external_exports.boolean().describe('If true, also fetch bodies for unread inbox messages \u2014 which marks each one READ on OurFamilyWizard and stamps a co-parent-visible "First Viewed" time that cannot be undone. Defaults to the OFW_FETCH_UNREAD_BODIES env var (false unless set), and is forced off entirely when OFW_ALLOW_MARK_READ=false.').optional(),
|
|
@@ -50928,8 +51032,10 @@ ${text}` : text);
|
|
|
50928
51032
|
return jsonResponse({ ...result, freshness });
|
|
50929
51033
|
});
|
|
50930
51034
|
server.registerTool("ofw_check_freshness", {
|
|
51035
|
+
// Not read-only, and destructive: allowMarkRead:true probes unread inbox
|
|
51036
|
+
// ids by fetching them, which stamps an irreversible "First Viewed" time.
|
|
50931
51037
|
description: 'Cheaply confirm whether the local cache still matches OurFamilyWizard, WITHOUT running a full sync. Use this before asserting anything about current state \u2014 especially "draft X is still sitting unsent". Costs one OFW request for the folder check plus one per messageId. For each folder it returns the live server count next to the cached count. For each id it returns a LIVE lifecycle `state` \u2014 "draft" | "sent" | "received" | "deleted" | "unknown" \u2014 alongside `folder`, `sentAt`, `existsOnServer` and a content comparison. `state` is the field that answers "is this still a draft?": a draft that has been SENT still exists on the server, so existsOnServer:true never distinguished the two. A cached draft whose state is no longer "draft" reports inSync:false even when its text is byte-identical. Content is compared by revision hash, because OFW draft timestamps do NOT change when a draft is edited in the web app. Does not fetch bodies into the cache, does not touch attachments, and does not depend on sync state. For draftKeys, or a full live draft inventory, use ofw_status.',
|
|
50932
|
-
annotations: { readOnlyHint: true },
|
|
51038
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
50933
51039
|
inputSchema: external_exports.object({
|
|
50934
51040
|
folders: external_exports.array(external_exports.enum(["inbox", "sent", "drafts"])).min(1).describe("Folders to compare cached vs live counts for. Defaults to all three when messageIds is not given. Must be non-empty if given.").optional(),
|
|
50935
51041
|
messageIds: external_exports.array(external_exports.number()).describe(`Specific ids to verify against OFW (max ${MAX_FRESHNESS_IDS}). Ids cached as drafts, as sent messages, or as already-read inbox messages are probed freely \u2014 none of those can stamp the record. Anything else is skipped \u2014 see allowMarkRead.`).optional(),
|
|
@@ -50987,8 +51093,10 @@ ${text}` : text);
|
|
|
50987
51093
|
return jsonResponse(payload);
|
|
50988
51094
|
});
|
|
50989
51095
|
server.registerTool("ofw_status", {
|
|
51096
|
+
// Not read-only, and destructive, for the same reason as
|
|
51097
|
+
// ofw_check_freshness: allowMarkRead:true can stamp "First Viewed".
|
|
50990
51098
|
description: 'ONE live call that answers "where does everything stand?". This is the call that should back any status summary about drafts or specific messages \u2014 never session memory, and never a cached read alone. With no arguments it returns the FULL current draft inventory, verified against OurFamilyWizard. Pass ids and/or draftKeys to get each one\'s live lifecycle `state` ("draft" | "sent" | "received" | "deleted" | "unknown") with `sentAt` and `viewedAt`. A draftKey is the stable identity ofw_save_draft returns: editing a draft mints a new OFW id every time (create-then-delete), so the key is the only way to ask "what happened to the thing I was working on?" \u2014 it resolves to the chain\'s current id and keeps resolving after the draft is SENT (state:"sent" with sentMessageId). The top-level `complete` is true ONLY when every part of this snapshot was verified live; if it is false, do not state a draft count or a lifecycle claim from this payload.',
|
|
50991
|
-
annotations: { readOnlyHint: true },
|
|
51099
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
50992
51100
|
inputSchema: external_exports.object({
|
|
50993
51101
|
ids: external_exports.array(external_exports.number()).describe(`Message/draft ids to resolve to a live state (combined with draftKeys, max ${MAX_FRESHNESS_IDS} probes per call).`).optional(),
|
|
50994
51102
|
draftKeys: external_exports.array(external_exports.string()).describe("Stable draft keys (from ofw_save_draft / ofw_list_drafts) to resolve to their CURRENT id and state.").optional(),
|
|
@@ -51230,7 +51338,7 @@ function registerCalendarTools(server, client2) {
|
|
|
51230
51338
|
const allowWrites = getCalendarWritesAllowed();
|
|
51231
51339
|
server.registerTool("ofw_list_events", {
|
|
51232
51340
|
description: "List OurFamilyWizard calendar events in a date range",
|
|
51233
|
-
annotations: { readOnlyHint: true },
|
|
51341
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
51234
51342
|
inputSchema: external_exports.object({
|
|
51235
51343
|
startDate: external_exports.string().describe("Start date YYYY-MM-DD"),
|
|
51236
51344
|
endDate: external_exports.string().describe("End date YYYY-MM-DD"),
|
|
@@ -51246,9 +51354,10 @@ function registerCalendarTools(server, client2) {
|
|
|
51246
51354
|
});
|
|
51247
51355
|
if (allowWrites) server.registerTool("ofw_create_event", {
|
|
51248
51356
|
description: "Create a calendar event in OurFamilyWizard. Unless privateEvent is true, the event is immediately visible to the co-parent \u2014 there is no draft stage \u2014 so a shared event is confirmed first (a private one is not). If the request fails without a definitive answer the result is EVENT_UNCONFIRMED: the event may already exist, so do NOT retry until ofw_list_events shows it did not land. " + CONFIRM_NOTE,
|
|
51249
|
-
//
|
|
51250
|
-
//
|
|
51251
|
-
|
|
51357
|
+
// Destructive by the inverse test: a shared event is on the co-parent's
|
|
51358
|
+
// calendar the moment it lands, and ofw_delete_event removes it but cannot
|
|
51359
|
+
// un-show it. (A private one would be additive; the hint is per tool.)
|
|
51360
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
51252
51361
|
inputSchema: external_exports.object({
|
|
51253
51362
|
title: external_exports.string(),
|
|
51254
51363
|
...eventWriteFields,
|
|
@@ -51259,6 +51368,7 @@ function registerCalendarTools(server, client2) {
|
|
|
51259
51368
|
const payload = buildEventPayload(fields);
|
|
51260
51369
|
if (!fields.privateEvent) {
|
|
51261
51370
|
const gate = await confirmWrite(ctx, {
|
|
51371
|
+
args,
|
|
51262
51372
|
tool: "ofw_create_event",
|
|
51263
51373
|
action: "ofw.event.create",
|
|
51264
51374
|
message: "Review and confirm this OurFamilyWizard calendar event. It is shared with the co-parent and appears on their calendar immediately.",
|
|
@@ -51292,7 +51402,7 @@ function registerCalendarTools(server, client2) {
|
|
|
51292
51402
|
});
|
|
51293
51403
|
if (allowWrites) server.registerTool("ofw_update_event", {
|
|
51294
51404
|
description: "Update an existing OurFamilyWizard calendar event. Fetches the event, applies the given changes, and writes the merged result back (OFW has no partial update). A change to an event the co-parent can see (shared before or after the change) is confirmed first; the confirmation is bound to the event exactly as read, so if it changes on OFW in between (say the co-parent edited it) the update is refused instead of overwriting their edit. If the write fails without a definitive answer, or lands but cannot be re-read to confirm it, the result is EVENT_UNCONFIRMED: the change may already be on OFW, so do NOT retry until ofw_list_events shows whether it landed. " + CONFIRM_NOTE,
|
|
51295
|
-
annotations: { destructiveHint: true },
|
|
51405
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
51296
51406
|
inputSchema: external_exports.object({
|
|
51297
51407
|
eventId: external_exports.string().describe("Event id \u2014 the `id` from ofw_list_events / eventRecurrenceId from ofw_create_event"),
|
|
51298
51408
|
title: external_exports.string().optional(),
|
|
@@ -51323,6 +51433,7 @@ function registerCalendarTools(server, client2) {
|
|
|
51323
51433
|
if (current.publicFlag || !merged.privateEvent) {
|
|
51324
51434
|
const names = namesIn(current);
|
|
51325
51435
|
const gate = await confirmWrite(ctx, {
|
|
51436
|
+
args,
|
|
51326
51437
|
tool: "ofw_update_event",
|
|
51327
51438
|
action: "ofw.event.update",
|
|
51328
51439
|
message: `Review and confirm this change to the OurFamilyWizard event "${current.title}". The co-parent sees the updated event on their calendar.`,
|
|
@@ -51378,6 +51489,7 @@ function registerCalendarTools(server, client2) {
|
|
|
51378
51489
|
if (current.publicFlag) {
|
|
51379
51490
|
const base = detailToWriteArgs(current);
|
|
51380
51491
|
const gate = await confirmWrite(ctx, {
|
|
51492
|
+
args,
|
|
51381
51493
|
tool: "ofw_delete_event",
|
|
51382
51494
|
action: "ofw.event.delete",
|
|
51383
51495
|
message: `Review and confirm deleting the OurFamilyWizard event "${current.title}". It disappears from the co-parent's calendar.`,
|
|
@@ -51567,21 +51679,21 @@ function registerExpenseTools(server, client2, attachmentIO) {
|
|
|
51567
51679
|
const allowReceiptUploads = allowWrites && attachmentIO !== void 0;
|
|
51568
51680
|
if (!uploadOnly) server.registerTool("ofw_get_expense_totals", {
|
|
51569
51681
|
description: "Get OurFamilyWizard expense summary totals (owed/paid)",
|
|
51570
|
-
annotations: { readOnlyHint: true }
|
|
51682
|
+
annotations: { readOnlyHint: true, openWorldHint: true }
|
|
51571
51683
|
}, async () => {
|
|
51572
51684
|
const data = await client2.request("GET", "/pub/v2/expense/expenses/totals");
|
|
51573
51685
|
return jsonResponse(data);
|
|
51574
51686
|
});
|
|
51575
51687
|
if (!uploadOnly) server.registerTool("ofw_list_expense_categories", {
|
|
51576
51688
|
description: "List OurFamilyWizard expense categories, including preset and custom categories with their responsibility split metadata. Read-only.",
|
|
51577
|
-
annotations: { readOnlyHint: true }
|
|
51689
|
+
annotations: { readOnlyHint: true, openWorldHint: true }
|
|
51578
51690
|
}, async () => {
|
|
51579
51691
|
const data = await client2.request("GET", "/pub/v2/expense/categories");
|
|
51580
51692
|
return jsonResponse(data);
|
|
51581
51693
|
});
|
|
51582
51694
|
if (!uploadOnly) server.registerTool("ofw_list_expenses", {
|
|
51583
51695
|
description: "List OurFamilyWizard expenses. OFW pages this endpoint with 1-based page/size parameters; its older start/max parameters are ignored and repeatedly return page 1. The response leads with hasMore and nextPage (null when exhausted) before the records. Continue by passing nextPage.",
|
|
51584
|
-
annotations: { readOnlyHint: true },
|
|
51696
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
51585
51697
|
inputSchema: external_exports.object({
|
|
51586
51698
|
page: external_exports.number().int().min(1).describe("1-based page number (default 1). To continue, pass the nextPage returned by the previous response.").optional(),
|
|
51587
51699
|
size: external_exports.number().int().min(1).max(100).describe("Requested page size (default 20). OFW may cap or normalize this value.").optional()
|
|
@@ -51611,8 +51723,10 @@ function registerExpenseTools(server, client2, attachmentIO) {
|
|
|
51611
51723
|
return jsonResponse({ ...head, ...body, ...head });
|
|
51612
51724
|
});
|
|
51613
51725
|
if (allowReceiptUploads) server.registerTool("ofw_upload_expense_pdf", {
|
|
51726
|
+
// Destructive by the inverse test: always uploaded SHARED, so the
|
|
51727
|
+
// co-parent sees it in My Files at once, and no tool here deletes it.
|
|
51614
51728
|
description: "Upload a PDF to OurFamilyWizard My Files for later attachment to an expense. Accepts either a local path or a signed ChatGPT/oaiusercontent HTTPS URL plus fileName. Exactly one of path or url must be supplied. This tool accepts PDF files only and uploads them using the same SHARED file metadata as the OFW expense form so the returned fileId can be attached to an expense. A SHARED file is visible to the co-parent in My Files immediately, whatever the visibility of the expense it is later attached to (that is set separately by ofw_create_expense privateExpense). " + CONFIRM_NOTE,
|
|
51615
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
51729
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
51616
51730
|
inputSchema: external_exports.object({
|
|
51617
51731
|
path: external_exports.string().describe("Path to a local PDF file inside the upload directory (OFW_UPLOAD_DIR). Tilde (~) is expanded by the configured attachment I/O implementation. Mutually exclusive with url.").optional(),
|
|
51618
51732
|
url: external_exports.string().describe("Signed HTTPS oaiusercontent.com URL for a PDF supplied by the ChatGPT host. Mutually exclusive with path.").optional(),
|
|
@@ -51639,6 +51753,7 @@ function registerExpenseTools(server, client2, attachmentIO) {
|
|
|
51639
51753
|
const label = args.label ?? fileName;
|
|
51640
51754
|
const description = args.description ?? fileName;
|
|
51641
51755
|
const gate = await confirmWrite(ctx, {
|
|
51756
|
+
args,
|
|
51642
51757
|
tool: "ofw_upload_expense_pdf",
|
|
51643
51758
|
action: "ofw.file.share",
|
|
51644
51759
|
message: `Review and confirm uploading the receipt "${fileName}" to OurFamilyWizard. It is uploaded SHARED, so the co-parent can see it in My Files immediately \u2014 even if the expense it is attached to is private.`,
|
|
@@ -51763,6 +51878,7 @@ function registerExpenseTools(server, client2, attachmentIO) {
|
|
|
51763
51878
|
});
|
|
51764
51879
|
}
|
|
51765
51880
|
const gate = await confirmWrite(ctx, {
|
|
51881
|
+
args,
|
|
51766
51882
|
tool: "ofw_update_expense",
|
|
51767
51883
|
action: "ofw.expense.update",
|
|
51768
51884
|
message: next.isPrivate ? `Review and confirm this update to the private OurFamilyWizard expense "${next.title}".` : `Review and confirm this update to the OurFamilyWizard expense "${next.title}". The expense will be SHARED: it appears in the ledger the co-parent sees immediately, and cannot be deleted through this server.`,
|
|
@@ -51851,6 +51967,7 @@ function registerExpenseTools(server, client2, attachmentIO) {
|
|
|
51851
51967
|
if (args.receiptFileId !== void 0) payload.fileIds = [args.receiptFileId];
|
|
51852
51968
|
const isPrivate = args.privateExpense === true;
|
|
51853
51969
|
const gate = await confirmWrite(ctx, {
|
|
51970
|
+
args,
|
|
51854
51971
|
tool: "ofw_create_expense",
|
|
51855
51972
|
action: "ofw.expense.create",
|
|
51856
51973
|
message: isPrivate ? "Review and confirm this private OurFamilyWizard expense (visible only to you)." : "Review and confirm this OurFamilyWizard expense. It is logged in the shared ledger the co-parent sees immediately, and cannot be deleted through this server.",
|
|
@@ -51890,7 +52007,7 @@ function registerJournalTools(server, client2) {
|
|
|
51890
52007
|
const allowWrites = getWriteMode() === "all";
|
|
51891
52008
|
server.registerTool("ofw_list_journal_entries", {
|
|
51892
52009
|
description: "List OurFamilyWizard journal entries. Offset-paged via start/max (1-based). The response leads with its paging state \u2014 `hasMore` and `nextStart` (null when the list is exhausted) \u2014 BEFORE the records, so a truncated or partially-read response still says whether more remain. Never state an entry count or an absence from one page.",
|
|
51893
|
-
annotations: { readOnlyHint: true },
|
|
52010
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
51894
52011
|
inputSchema: external_exports.object({
|
|
51895
52012
|
start: external_exports.number().int().min(1).describe("Start offset, 1-based (default 1). To continue a listing, pass the `nextStart` from the previous response.").optional(),
|
|
51896
52013
|
max: external_exports.number().int().min(1).describe("Max results (default 10)").optional()
|
|
@@ -51912,8 +52029,10 @@ function registerJournalTools(server, client2) {
|
|
|
51912
52029
|
return jsonResponse(wrapped ?? data);
|
|
51913
52030
|
});
|
|
51914
52031
|
if (allowWrites) server.registerTool("ofw_create_journal_entry", {
|
|
52032
|
+
// Destructive by the inverse test: the entry lands on the court-visible
|
|
52033
|
+
// record and no tool here edits or deletes a journal entry.
|
|
51915
52034
|
description: "Create a new journal entry in OurFamilyWizard",
|
|
51916
|
-
annotations: {
|
|
52035
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
51917
52036
|
inputSchema: external_exports.object({
|
|
51918
52037
|
title: external_exports.string().describe("Entry title"),
|
|
51919
52038
|
body: external_exports.string().describe("Entry text content")
|
|
@@ -52581,8 +52700,7 @@ var tools = selectToolRegistrars({
|
|
|
52581
52700
|
});
|
|
52582
52701
|
await runMcp({
|
|
52583
52702
|
name: "ofw",
|
|
52584
|
-
version:
|
|
52585
|
-
// x-release-please-version
|
|
52703
|
+
version: VERSION,
|
|
52586
52704
|
deps: client,
|
|
52587
52705
|
tools,
|
|
52588
52706
|
banner: "[ofw-mcp] This project was developed and is maintained by AI (Claude Sonnet 4.6). Use at your own discretion."
|