ofw-mcp 2.7.1 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +15 -2
- package/dist/bundle.js +1009 -49
- package/dist/config.js +42 -0
- package/dist/extract/document.js +83 -0
- package/dist/extract/index.js +222 -0
- package/dist/extract/inflate.js +55 -0
- package/dist/extract/ooxml.js +58 -0
- package/dist/extract/pdf.js +278 -0
- package/dist/extract/presentation.js +54 -0
- package/dist/extract/spreadsheet.js +258 -0
- package/dist/extract/types.js +4 -0
- package/dist/extract/xml.js +61 -0
- package/dist/extract/zip.js +110 -0
- package/dist/index.js +1 -1
- package/dist/sync.js +7 -2
- package/dist/tools/delivery.js +99 -0
- package/dist/tools/draft-freshness.js +7 -2
- package/dist/tools/messages.js +125 -43
- package/package.json +1 -1
- package/server.json +14 -2
- package/skills/ofw/SKILL.md +2 -2
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// A minimal, dependency-free ZIP reader.
|
|
2
|
+
//
|
|
3
|
+
// OOXML attachments (.xlsx/.docx/.pptx) are ZIP containers of XML parts, so
|
|
4
|
+
// reading one is the first step of every office-document extractor. This is
|
|
5
|
+
// deliberately not a general ZIP library: it reads the central directory,
|
|
6
|
+
// slices an entry's bytes, and inflates DEFLATE members via the WHATWG
|
|
7
|
+
// `DecompressionStream` — which exists in BOTH Node ≥18 and workerd, so the
|
|
8
|
+
// same code runs on the stdio server and the hosted connector. Using
|
|
9
|
+
// `node:zlib` here would break the Worker build; adding a userland inflate
|
|
10
|
+
// dependency would bloat it. Neither is necessary.
|
|
11
|
+
import { inflateBounded, MAX_DECOMPRESSED_BYTES } from './inflate.js';
|
|
12
|
+
const EOCD_SIG = 0x06054b50;
|
|
13
|
+
const CENTRAL_SIG = 0x02014b50;
|
|
14
|
+
const LOCAL_SIG = 0x04034b50;
|
|
15
|
+
const ZIP64_SENTINEL = 0xffffffff;
|
|
16
|
+
/**
|
|
17
|
+
* Hard ceiling on a single decompressed member (32 MiB). An attachment is a
|
|
18
|
+
* co-parent-supplied file, so a zip bomb is a real (if unlikely) input, and the
|
|
19
|
+
* Worker's memory budget is what is being protected.
|
|
20
|
+
*
|
|
21
|
+
* The cap is enforced on the bytes as they arrive ({@link inflateBounded}), NOT
|
|
22
|
+
* on the size the archive declares for itself. The declared size is checked too
|
|
23
|
+
* — it rejects an HONEST oversized member without inflating anything — but it
|
|
24
|
+
* is an optimization, not the guarantee: a central directory is free to claim
|
|
25
|
+
* 1 KB in front of a member that expands to a gigabyte.
|
|
26
|
+
*/
|
|
27
|
+
export const ZIP_MAX_UNCOMPRESSED_BYTES = MAX_DECOMPRESSED_BYTES;
|
|
28
|
+
/** Locate the end-of-central-directory record, scanning back past any comment. */
|
|
29
|
+
function findEocd(bytes) {
|
|
30
|
+
// The comment field is a uint16, so the record starts at most 22+65535 bytes
|
|
31
|
+
// from the end. Scan backwards for the signature.
|
|
32
|
+
const earliest = Math.max(0, bytes.length - (22 + 0xffff));
|
|
33
|
+
for (let i = bytes.length - 22; i >= earliest; i--) {
|
|
34
|
+
if (bytes.readUInt32LE(i) === EOCD_SIG)
|
|
35
|
+
return i;
|
|
36
|
+
}
|
|
37
|
+
throw new Error('not a ZIP archive (no end-of-central-directory record)');
|
|
38
|
+
}
|
|
39
|
+
export async function readZip(bytes, opts = {}) {
|
|
40
|
+
const limit = opts.maxUncompressedBytes ?? ZIP_MAX_UNCOMPRESSED_BYTES;
|
|
41
|
+
const eocd = findEocd(bytes);
|
|
42
|
+
const count = bytes.readUInt16LE(eocd + 10);
|
|
43
|
+
const cdOffset = bytes.readUInt32LE(eocd + 16);
|
|
44
|
+
if (cdOffset === ZIP64_SENTINEL || count === 0xffff) {
|
|
45
|
+
throw new Error('ZIP64 archives are not supported');
|
|
46
|
+
}
|
|
47
|
+
const entries = new Map();
|
|
48
|
+
let p = cdOffset;
|
|
49
|
+
for (let i = 0; i < count; i++) {
|
|
50
|
+
if (bytes.readUInt32LE(p) !== CENTRAL_SIG) {
|
|
51
|
+
throw new Error(`corrupt ZIP central directory at offset ${p}`);
|
|
52
|
+
}
|
|
53
|
+
const nameLen = bytes.readUInt16LE(p + 28);
|
|
54
|
+
const extraLen = bytes.readUInt16LE(p + 30);
|
|
55
|
+
const commentLen = bytes.readUInt16LE(p + 32);
|
|
56
|
+
const name = bytes.toString('utf8', p + 46, p + 46 + nameLen);
|
|
57
|
+
entries.set(name, {
|
|
58
|
+
name,
|
|
59
|
+
method: bytes.readUInt16LE(p + 10),
|
|
60
|
+
compressedSize: bytes.readUInt32LE(p + 20),
|
|
61
|
+
uncompressedSize: bytes.readUInt32LE(p + 24),
|
|
62
|
+
localOffset: bytes.readUInt32LE(p + 42),
|
|
63
|
+
});
|
|
64
|
+
p += 46 + nameLen + extraLen + commentLen;
|
|
65
|
+
}
|
|
66
|
+
const cache = new Map();
|
|
67
|
+
async function read(name) {
|
|
68
|
+
const cached = cache.get(name);
|
|
69
|
+
if (cached)
|
|
70
|
+
return cached;
|
|
71
|
+
const entry = entries.get(name);
|
|
72
|
+
if (!entry)
|
|
73
|
+
return null;
|
|
74
|
+
// Cheap pre-check for an honest oversized member. A lying header falls
|
|
75
|
+
// through to the streaming cap below, which is the real guarantee.
|
|
76
|
+
if (entry.uncompressedSize > limit) {
|
|
77
|
+
throw new Error(`ZIP member ${name} is too large to extract (${entry.uncompressedSize} bytes)`);
|
|
78
|
+
}
|
|
79
|
+
// The central directory records the local header's offset, but the local
|
|
80
|
+
// header carries its OWN name/extra lengths (they can differ from the
|
|
81
|
+
// central copy), so the data offset must be computed from it.
|
|
82
|
+
const lo = entry.localOffset;
|
|
83
|
+
if (bytes.readUInt32LE(lo) !== LOCAL_SIG) {
|
|
84
|
+
throw new Error(`corrupt ZIP local header for ${name}`);
|
|
85
|
+
}
|
|
86
|
+
const start = lo + 30 + bytes.readUInt16LE(lo + 26) + bytes.readUInt16LE(lo + 28);
|
|
87
|
+
const raw = bytes.subarray(start, start + entry.compressedSize);
|
|
88
|
+
let out;
|
|
89
|
+
if (entry.method === 0)
|
|
90
|
+
out = Buffer.from(raw);
|
|
91
|
+
else if (entry.method === 8)
|
|
92
|
+
out = await inflateBounded(raw, 'deflate-raw', limit, `ZIP member ${name}`);
|
|
93
|
+
else
|
|
94
|
+
throw new Error(`unsupported ZIP compression method ${entry.method} for ${name}`);
|
|
95
|
+
cache.set(name, out);
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
names: () => [...entries.keys()],
|
|
100
|
+
has: (name) => entries.has(name),
|
|
101
|
+
read,
|
|
102
|
+
async readText(name) {
|
|
103
|
+
const buf = await read(name);
|
|
104
|
+
if (!buf)
|
|
105
|
+
return null;
|
|
106
|
+
const text = buf.toString('utf8');
|
|
107
|
+
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -35,7 +35,7 @@ const nodeAttachmentIO = new NodeAttachmentIO();
|
|
|
35
35
|
// always succeeds before any credential check runs.
|
|
36
36
|
await runMcp({
|
|
37
37
|
name: 'ofw',
|
|
38
|
-
version: '2.
|
|
38
|
+
version: '2.8.0', // x-release-please-version
|
|
39
39
|
deps: client,
|
|
40
40
|
tools: [
|
|
41
41
|
registerUserTools,
|
package/dist/sync.js
CHANGED
|
@@ -222,8 +222,13 @@ async function walkPages(client, folder, folderId, opts, store) {
|
|
|
222
222
|
await fetchAttachmentMetaBudgeted(client, item.id, detailFileIds, store, budget);
|
|
223
223
|
}
|
|
224
224
|
}
|
|
225
|
-
// Flush the page's rows in one transaction/RPC.
|
|
226
|
-
|
|
225
|
+
// Flush the page's rows in one transaction/RPC. Skipped entirely when the
|
|
226
|
+
// page held nothing new: on the Worker this call is a Durable-Object RPC,
|
|
227
|
+
// and a DO RPC counts against the same subrequest budget as an OFW fetch.
|
|
228
|
+
// A deep re-walk crosses page after page of already-cached messages, so an
|
|
229
|
+
// unconditional "no-op" write spends the caller's budget to store nothing.
|
|
230
|
+
if (toUpsert.length > 0)
|
|
231
|
+
await store.upsertMessages(toUpsert);
|
|
227
232
|
if (pageBudgetHit) {
|
|
228
233
|
// Paused mid-page. Resume at THIS page: the partial rows are cached, so
|
|
229
234
|
// getMessages skips them next time and upserts are idempotent.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// The attachment delivery ladder.
|
|
2
|
+
//
|
|
3
|
+
// A successful fetch must always produce retrievable content. "The host cannot
|
|
4
|
+
// render this type" is a DISPLAY limit, and letting it become a DATA limit is
|
|
5
|
+
// the bug this module exists to close: `ofw_download_attachment` used to fetch
|
|
6
|
+
// a 10 KB custody-schedule spreadsheet, hand back an EmbeddedResource, and have
|
|
7
|
+
// the host reject it with "Resources of type '…spreadsheetml.sheet' are not
|
|
8
|
+
// currently supported" — leaving the caller holding nothing at all.
|
|
9
|
+
//
|
|
10
|
+
// Every inline delivery now walks the same rungs and returns the first that
|
|
11
|
+
// works:
|
|
12
|
+
//
|
|
13
|
+
// 1. host-renderable image → ImageContent (the model sees the picture)
|
|
14
|
+
// 2. extractable document → the FILE'S TEXT, as text (see src/extract)
|
|
15
|
+
// 3. raw bytes → base64 EmbeddedResource, as before
|
|
16
|
+
//
|
|
17
|
+
// Rung 3 never disappears, so nothing regresses; rung 2 is what makes a
|
|
18
|
+
// spreadsheet, PDF, Word or PowerPoint attachment readable at all. When a rung
|
|
19
|
+
// is skipped or fails, the response says so by name in `deliveryAttempts` —
|
|
20
|
+
// a caller must never be left guessing why it got bytes instead of content.
|
|
21
|
+
import { extractAttachment } from '../extract/index.js';
|
|
22
|
+
import { isHostRenderableImage } from './attachments.js';
|
|
23
|
+
/**
|
|
24
|
+
* Attempt extraction, converting every failure into a REASON rather than an
|
|
25
|
+
* exception: a format we cannot read must still be delivered as bytes, and the
|
|
26
|
+
* caller is owed the explanation either way.
|
|
27
|
+
*/
|
|
28
|
+
export async function tryExtract(bytes, mimeType, fileName, opts) {
|
|
29
|
+
try {
|
|
30
|
+
const extracted = await extractAttachment(bytes, mimeType, fileName, {
|
|
31
|
+
maxChars: opts.maxChars,
|
|
32
|
+
parts: opts.parts,
|
|
33
|
+
});
|
|
34
|
+
if (!extracted) {
|
|
35
|
+
return { reason: `no text extractor for ${mimeType} (${fileName})` };
|
|
36
|
+
}
|
|
37
|
+
return { extracted, truncated: extracted.truncated ?? false };
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
// A malformed .xlsx is still an .xlsx: report why it could not be read and
|
|
41
|
+
// fall through to the bytes, rather than failing the whole call.
|
|
42
|
+
return { reason: `extraction failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Build the content blocks for an inline download by walking the ladder.
|
|
47
|
+
* The first block is always a JSON meta block naming `deliveredVia`, so the
|
|
48
|
+
* caller can tell how the content arrived without inspecting block types.
|
|
49
|
+
*/
|
|
50
|
+
export async function buildInlineDelivery(input) {
|
|
51
|
+
const { fileId, fileName, mimeType, bytes, forcedInline, options } = input;
|
|
52
|
+
const meta = {
|
|
53
|
+
fileId, fileName, mimeType, sizeBytes: bytes.length, mode: 'inline',
|
|
54
|
+
};
|
|
55
|
+
if (forcedInline)
|
|
56
|
+
meta.forcedInline = true;
|
|
57
|
+
const block = () => ({ type: 'text', text: JSON.stringify(meta, null, 2) });
|
|
58
|
+
// Rung 1 — the host renders these itself, and a picture beats a description.
|
|
59
|
+
if (isHostRenderableImage(mimeType)) {
|
|
60
|
+
meta.deliveredVia = 'image';
|
|
61
|
+
return { content: [block(), { type: 'image', data: bytes.toString('base64'), mimeType }] };
|
|
62
|
+
}
|
|
63
|
+
// Rung 2 — extraction. Skipped only when the caller explicitly opts out.
|
|
64
|
+
const attempts = [];
|
|
65
|
+
if (options.extract === false) {
|
|
66
|
+
attempts.push('extraction skipped (extract:false)');
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
const outcome = await tryExtract(bytes, mimeType, fileName, options);
|
|
70
|
+
if (outcome.extracted) {
|
|
71
|
+
meta.deliveredVia = 'extracted';
|
|
72
|
+
meta.extracted = outcome.extracted;
|
|
73
|
+
meta.truncated = outcome.truncated;
|
|
74
|
+
// The bytes are deliberately NOT also attached: the extracted text is the
|
|
75
|
+
// readable form, and a duplicate base64 blob would be the very payload
|
|
76
|
+
// the host rejects — plus double the response size.
|
|
77
|
+
meta.note = 'Content extracted from the file. Pass extract:false to get the raw bytes instead.';
|
|
78
|
+
return { content: [block()] };
|
|
79
|
+
}
|
|
80
|
+
/* v8 ignore next -- tryExtract always sets `reason` when it returns no extraction */
|
|
81
|
+
attempts.push(outcome.reason ?? 'extraction produced no content');
|
|
82
|
+
}
|
|
83
|
+
// Rung 3 — the bytes themselves. Always available, so a fetch that succeeded
|
|
84
|
+
// never ends with the caller holding nothing.
|
|
85
|
+
meta.deliveredVia = 'blob';
|
|
86
|
+
meta.deliveryAttempts = attempts;
|
|
87
|
+
meta.note = 'Returned as raw bytes. Some hosts cannot render an embedded resource of this type; '
|
|
88
|
+
+ 'if it came back unreadable, the file has no text extractor here (see deliveryAttempts).';
|
|
89
|
+
return {
|
|
90
|
+
content: [block(), {
|
|
91
|
+
type: 'resource',
|
|
92
|
+
resource: {
|
|
93
|
+
uri: `ofw://attachment/${fileId}/${encodeURIComponent(fileName)}`,
|
|
94
|
+
mimeType,
|
|
95
|
+
blob: bytes.toString('base64'),
|
|
96
|
+
},
|
|
97
|
+
}],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -47,8 +47,13 @@ function isNotFound(e) {
|
|
|
47
47
|
* Read a draft's AUTHORITATIVE state straight from OFW, bypassing the cache.
|
|
48
48
|
*
|
|
49
49
|
* Returns `null` when the draft no longer exists (404). Any other failure
|
|
50
|
-
* throws
|
|
51
|
-
*
|
|
50
|
+
* throws, and the callers in messages.ts abort on ALL of them: a freshness
|
|
51
|
+
* check that could not run must never wave the write through.
|
|
52
|
+
*
|
|
53
|
+
* Most failures throw `DraftFreshnessError` from this function, but not all —
|
|
54
|
+
* a strict `parseLenient` mismatch on the response throws `McpToolError`
|
|
55
|
+
* instead. Callers must not assume the narrower type (the catch blocks read
|
|
56
|
+
* only `.message`, which every Error carries).
|
|
52
57
|
*/
|
|
53
58
|
export async function fetchServerDraft(client, id) {
|
|
54
59
|
let raw;
|
package/dist/tools/messages.js
CHANGED
|
@@ -3,10 +3,11 @@ import { syncAll, fetchAttachmentMeta, fetchAttachmentMetaForMessage, getDraftsC
|
|
|
3
3
|
import { buildFreshness } from './freshness.js';
|
|
4
4
|
import { checkDraftFreshness, draftRevision, fetchServerDraft, staleDraftPayload, } from './draft-freshness.js';
|
|
5
5
|
import { getFolderVerifiedAt } from '../sync.js';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { buildInlineDelivery, tryExtract } from './delivery.js';
|
|
7
|
+
import { resolveDownloadMime } from './attachments.js';
|
|
8
|
+
import { getAllowMarkRead, getAttachmentsDir, getDefaultInlineAttachments, getFetchUnreadBodies, getSyncMaxRequests, getWriteMode, } from '../config.js';
|
|
8
9
|
import { basename, join } from 'node:path';
|
|
9
|
-
import { ApiRecipientSchema, expandPath, hasRealView, jsonErrorResponse, jsonResponse, mapRecipients, postMessageAndRefetch, textResponse, verifyWriteLanded, withReadState } from './_shared.js';
|
|
10
|
+
import { ApiRecipientSchema, deriveRead, expandPath, hasRealView, jsonErrorResponse, jsonResponse, mapRecipients, postMessageAndRefetch, textResponse, verifyWriteLanded, withReadState } from './_shared.js';
|
|
10
11
|
import { parseLenient } from '@chrischall/mcp-utils';
|
|
11
12
|
// Schemas for the load-bearing fields of each /pub/v3 response this file
|
|
12
13
|
// reads (issue #83). Loose: unknown keys pass through into cached listData.
|
|
@@ -125,6 +126,48 @@ async function draftsFreshness(cache) {
|
|
|
125
126
|
: 'unverified';
|
|
126
127
|
return { freshness, serverConfirmed: cacheStatus === 'fresh', cacheStatus };
|
|
127
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* Decide whether fetching this message's body from OFW would stamp the record,
|
|
131
|
+
* and refuse when the caller (or the deployment) has opted out of that.
|
|
132
|
+
*
|
|
133
|
+
* Returns null to proceed, or a structured refusal to return as-is.
|
|
134
|
+
*
|
|
135
|
+
* Only ONE case actually stamps: fetching the body of an UNREAD INBOX message.
|
|
136
|
+
* Everything else is waved through, because refusing a read that changes
|
|
137
|
+
* nothing would be friction with no safety to show for it:
|
|
138
|
+
* - a SENT message — the "First Viewed" times on it belong to the recipient,
|
|
139
|
+
* and our own fetch never writes one;
|
|
140
|
+
* - an already-read inbox message — the stamp exists; re-reading cannot add
|
|
141
|
+
* a second one (`deriveRead` is monotonic, so this cannot flip back);
|
|
142
|
+
* - a cached body — this function is never reached, the cache served it.
|
|
143
|
+
*
|
|
144
|
+
* An id with NO cached row is refused: whether it would stamp is exactly what
|
|
145
|
+
* we cannot know without making the request that stamps it. Syncing first
|
|
146
|
+
* (which reads list pages, not bodies) resolves it.
|
|
147
|
+
*/
|
|
148
|
+
export function markReadVerdict(cached, requested) {
|
|
149
|
+
const ceiling = getAllowMarkRead();
|
|
150
|
+
if (ceiling && (requested ?? true))
|
|
151
|
+
return null;
|
|
152
|
+
const wouldStamp = cached === null
|
|
153
|
+
|| (cached.folder === 'inbox' && !deriveRead(cached));
|
|
154
|
+
if (!wouldStamp)
|
|
155
|
+
return null;
|
|
156
|
+
const because = ceiling
|
|
157
|
+
? 'you passed allowMarkRead:false'
|
|
158
|
+
: 'this server runs with OFW_ALLOW_MARK_READ=false';
|
|
159
|
+
return jsonErrorResponse({
|
|
160
|
+
error: 'MARK_READ_BLOCKED',
|
|
161
|
+
messageId: cached?.id ?? null,
|
|
162
|
+
reason: cached === null
|
|
163
|
+
? 'This id is not in the cache, so whether reading it would mark it read is unknowable without making the request that would.'
|
|
164
|
+
: 'This is an unread inbox message; fetching its body would mark it read on OurFamilyWizard.',
|
|
165
|
+
note: `Refused because ${because}. Reading a message for the first time stamps a "First Viewed" timestamp that your co-parent can see and that forms part of the record — it cannot be undone. To read it anyway, call again with allowMarkRead:true${ceiling ? '' : ' (which this deployment does not permit — clear OFW_ALLOW_MARK_READ to re-enable)'}.`,
|
|
166
|
+
...(cached === null
|
|
167
|
+
? { hint: 'Run ofw_sync_messages first: it walks list pages, not bodies, so it can tell you what this id is without stamping anything.' }
|
|
168
|
+
: { subject: cached.subject, fromUser: cached.fromUser, sentAt: cached.sentAt }),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
128
171
|
export function registerMessageTools(server, client, cacheProvider, attachmentIO) {
|
|
129
172
|
// OFW_WRITE_MODE gate (see config.ts). Send lands on the court-visible
|
|
130
173
|
// record, so it is 'all'-only; draft-level writes (save/delete drafts,
|
|
@@ -201,10 +244,11 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
201
244
|
return jsonResponse(payload);
|
|
202
245
|
});
|
|
203
246
|
server.registerTool('ofw_get_message', {
|
|
204
|
-
description: 'Get a single OurFamilyWizard message OR draft by ID. Reads from local cache when available; otherwise fetches from OFW
|
|
247
|
+
description: 'Get a single OurFamilyWizard message OR draft by ID. Reads from local cache when available; otherwise fetches from OFW — 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) — drafts have no `fromUser`, and `sentAt`/`fetchedBodyAt` mirror the draft\'s `modifiedAt`. For inbox/sent messages, folder is "inbox" or "sent" as before.',
|
|
205
248
|
annotations: { readOnlyHint: false },
|
|
206
249
|
inputSchema: {
|
|
207
250
|
messageId: z.string().describe('Message ID (also accepts draft IDs — drafts are routed via the drafts cache)'),
|
|
251
|
+
allowMarkRead: z.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 — 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(),
|
|
208
252
|
},
|
|
209
253
|
}, async (args) => {
|
|
210
254
|
const id = Number(args.messageId);
|
|
@@ -299,6 +343,14 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
299
343
|
const freshness = await buildFreshness(cache, { source: 'cache', folders: [row.folder] });
|
|
300
344
|
return jsonResponse({ ...withReadState(row), attachments, freshness });
|
|
301
345
|
}
|
|
346
|
+
// Everything above this line was served without asking OFW for a body.
|
|
347
|
+
// This is the one path that fetches one — and fetching the body of an
|
|
348
|
+
// unread INBOX message marks it read on OFW, stamping a "First Viewed"
|
|
349
|
+
// time the co-parent can see. That is a court-visible, irreversible change
|
|
350
|
+
// made as a side effect of an ordinary read, so it gets an explicit gate.
|
|
351
|
+
const markReadCheck = markReadVerdict(cached, args.allowMarkRead);
|
|
352
|
+
if (markReadCheck !== null)
|
|
353
|
+
return markReadCheck;
|
|
302
354
|
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)' });
|
|
303
355
|
// Derive the folder for a live-fetched message. A cached row (reached here
|
|
304
356
|
// only when its body was NULL) already knows its folder, so keep it.
|
|
@@ -480,10 +532,13 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
480
532
|
server = await fetchServerDraft(client, draftId);
|
|
481
533
|
}
|
|
482
534
|
catch (e) {
|
|
483
|
-
//
|
|
484
|
-
//
|
|
485
|
-
//
|
|
486
|
-
//
|
|
535
|
+
// Anything landing here means the check could not RUN. Most failures
|
|
536
|
+
// arrive as DraftFreshnessError from fetchServerDraft, but not all of
|
|
537
|
+
// them: a strict parseLenient mismatch on the server draft throws
|
|
538
|
+
// McpToolError instead. Both are caught, and both abort — which is the
|
|
539
|
+
// point. A failed check is not permission to proceed: a transient 5xx
|
|
540
|
+
// must not degrade into a blind overwrite. (The cast below only reads
|
|
541
|
+
// `.message`, which every Error carries.)
|
|
487
542
|
const reason = e.message;
|
|
488
543
|
if (force) {
|
|
489
544
|
return { ok: true, note: `WARNING: force:true — proceeded with ${action} on draft ${draftId} even though its current state could not be read from OurFamilyWizard (${reason}). Any newer server-side version was destroyed and is NOT recoverable from this response.` };
|
|
@@ -659,7 +714,16 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
659
714
|
// silent normalization becomes a visible warning rather than a surprise.
|
|
660
715
|
if (resolvedReplyTo !== null && effectiveReplyTo !== resolvedReplyTo) {
|
|
661
716
|
const rewrittenFrom = requestedReplyTo !== resolvedReplyTo ? ` (rewritten from ${requestedReplyTo})` : '';
|
|
662
|
-
|
|
717
|
+
// Two different outcomes reach this branch, and they need different
|
|
718
|
+
// warnings. OFW either DROPPED the link (null) or RE-TARGETED it to
|
|
719
|
+
// another message in the thread. Describing both as "did not thread
|
|
720
|
+
// this draft (its inReplyTo/showContext will be empty)" contradicted
|
|
721
|
+
// the non-null inReplyTo the same response echoes — and a warning the
|
|
722
|
+
// caller can see is false is a warning it learns to skip.
|
|
723
|
+
const outcome = effectiveReplyTo === null
|
|
724
|
+
? 'OurFamilyWizard did not thread this draft (its inReplyTo/showContext will be empty). The subject and body were saved; only the reply linkage was dropped.'
|
|
725
|
+
: `OurFamilyWizard re-targeted the reply to message ${effectiveReplyTo} instead. The draft IS threaded — to that message, not the one requested — and the inReplyTo in this response reflects where it actually landed.`;
|
|
726
|
+
warnings.push(`replyToId was requested as ${resolvedReplyTo}${rewrittenFrom} but the saved draft came back with replyToId ${effectiveReplyTo === null ? 'null' : effectiveReplyTo} — ${outcome} If threading matters, verify on ourfamilywizard.com.`);
|
|
663
727
|
}
|
|
664
728
|
// Only warn on recipients/attachments when the detail actually reported
|
|
665
729
|
// them — an omitted array is "not echoed", not "dropped", and crying wolf
|
|
@@ -825,13 +889,16 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
825
889
|
});
|
|
826
890
|
});
|
|
827
891
|
server.registerTool('ofw_download_attachment', {
|
|
828
|
-
description: 'Download an OFW message attachment by fileId
|
|
892
|
+
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 — per-sheet CSV, per-page/slide text, document text — in the response JSON under `extracted`; (3) anything else comes back as an EmbeddedResource blob of the raw bytes. 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 — 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. Re-downloading to the same path is a no-op (disk mode only).',
|
|
829
893
|
annotations: { readOnlyHint: false },
|
|
830
894
|
inputSchema: {
|
|
831
895
|
fileId: z.number().describe('Attachment file id (from ofw_get_message → attachments[].fileId)'),
|
|
832
|
-
inline: z.boolean().describe('If true, return
|
|
896
|
+
inline: z.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 — 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(),
|
|
833
897
|
saveTo: z.string().describe('Absolute path or directory to write to. If a directory, the OFW filename is used. Default: ~/Downloads/ofw-mcp/<fileId>-<filename>. Ignored when inline is in effect.').optional(),
|
|
834
898
|
force: z.boolean().describe('Re-download even if already on disk. Default false. Ignored when inline:true (inline always fetches fresh bytes, or reuses an on-disk copy if present).').optional(),
|
|
899
|
+
extract: z.boolean().describe('Whether to extract readable content from the file. Default: on for inline delivery of any non-image type, off in disk mode. Set false to get the raw bytes inline instead of extracted text (e.g. to hash or re-upload the file); set true in disk mode to get both the saved path and the extracted content.').optional(),
|
|
900
|
+
maxChars: z.number().int().min(500).max(500_000).describe('Ceiling on extracted characters (default 50000). Over it, content is clipped on a row/line boundary, `truncated` is set, and anything dropped whole is listed in `extracted.omitted`.').optional(),
|
|
901
|
+
parts: z.string().describe('Which sheets / slides / pages to extract, e.g. "1-3,5" (1-based positions) or a sheet name like "2026". A bare number matches either a position or a name. Omit for everything. Unselected parts are listed in `extracted.omitted`.').optional(),
|
|
835
902
|
},
|
|
836
903
|
}, async (args) => {
|
|
837
904
|
const fileId = args.fileId;
|
|
@@ -843,6 +910,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
843
910
|
// response is honest about it instead of silently ignoring the argument.
|
|
844
911
|
const inline = requestedInline || !attachmentIO.supportsDisk;
|
|
845
912
|
const forcedInline = inline && !requestedInline;
|
|
913
|
+
const deliveryOptions = { extract: args.extract, maxChars: args.maxChars, parts: args.parts };
|
|
846
914
|
let cached = await cache.getAttachment(fileId);
|
|
847
915
|
if (!cached) {
|
|
848
916
|
// Not in cache. Fetch metadata and store under the messageId=0
|
|
@@ -871,25 +939,12 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
871
939
|
// charset onto binaries), then fall back to the stripped header, then the
|
|
872
940
|
// extension. A parameter suffix would make the host reject an image.
|
|
873
941
|
const mimeType = resolveDownloadMime(bytes, headerMime, fileName);
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
const metaBlock = { type: 'text', text: JSON.stringify(meta, null, 2) };
|
|
881
|
-
// Only host-renderable image types go back as ImageContent (with the bare
|
|
882
|
-
// media type the renderer accepts); everything else — non-renderable
|
|
883
|
-
// images included — goes back as an EmbeddedResource so the caller always
|
|
884
|
-
// gets the bytes.
|
|
885
|
-
if (isHostRenderableImage(mimeType)) {
|
|
886
|
-
return { content: [metaBlock, { type: 'image', data: base64, mimeType }] };
|
|
887
|
-
}
|
|
888
|
-
return { content: [metaBlock, { type: 'resource', resource: {
|
|
889
|
-
uri: `ofw://attachment/${fileId}/${encodeURIComponent(fileName)}`,
|
|
890
|
-
mimeType,
|
|
891
|
-
blob: base64,
|
|
892
|
-
} }] };
|
|
942
|
+
// The ladder decides between rendering, extracted content, and raw bytes
|
|
943
|
+
// — see src/tools/delivery.ts. Whatever the host can draw, the caller
|
|
944
|
+
// ends up holding something readable.
|
|
945
|
+
return await buildInlineDelivery({
|
|
946
|
+
fileId, fileName, mimeType, bytes, forcedInline, options: deliveryOptions,
|
|
947
|
+
});
|
|
893
948
|
}
|
|
894
949
|
let dest;
|
|
895
950
|
// The filename comes from OFW file metadata — i.e. it is controlled by the
|
|
@@ -906,25 +961,38 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
906
961
|
else {
|
|
907
962
|
dest = join(getAttachmentsDir(), `${fileId}-${safeName}`);
|
|
908
963
|
}
|
|
964
|
+
// Disk mode extracts only on request: the caller already has a real file to
|
|
965
|
+
// open, so extraction is an add-on here rather than the point.
|
|
966
|
+
const extractOnDisk = args.extract === true;
|
|
909
967
|
if (!args.force && cached.downloadedPath === dest) {
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
968
|
+
// Nothing was re-fetched, so an extraction has to come off the copy on
|
|
969
|
+
// disk. If that copy has gone missing, fall through and download again
|
|
970
|
+
// rather than answering "already downloaded" with no content.
|
|
971
|
+
const onDisk = extractOnDisk ? attachmentIO.readDownloaded(dest) : null;
|
|
972
|
+
if (!extractOnDisk || onDisk) {
|
|
973
|
+
// No bytes on hand for the plain no-op case: normalize the
|
|
974
|
+
// cached/extension MIME (an empty buffer sniffs nothing) so a stored
|
|
975
|
+
// `image/png;charset=…` still reports bare.
|
|
976
|
+
const mimeType = resolveDownloadMime(onDisk ?? Buffer.alloc(0), cached.mimeType, cached.fileName);
|
|
977
|
+
return jsonResponse({
|
|
978
|
+
fileId, path: dest, mimeType,
|
|
979
|
+
sizeBytes: cached.sizeBytes, fileName: cached.fileName, note: 'already downloaded',
|
|
980
|
+
...(onDisk ? await tryExtract(onDisk, mimeType, cached.fileName, deliveryOptions) : {}),
|
|
981
|
+
});
|
|
982
|
+
}
|
|
917
983
|
}
|
|
918
984
|
const response = await client.requestBinary('GET', `/pub/v1/myfiles/${fileId}/data`);
|
|
919
985
|
attachmentIO.writeDownload(dest, response.body);
|
|
920
986
|
await cache.markAttachmentDownloaded(fileId, dest);
|
|
921
987
|
const fileName = response.suggestedFileName ?? cached.fileName;
|
|
988
|
+
const mimeType = resolveDownloadMime(response.body, response.contentType ?? cached.mimeType, fileName);
|
|
922
989
|
return jsonResponse({
|
|
923
990
|
fileId,
|
|
924
991
|
path: dest,
|
|
925
|
-
mimeType
|
|
992
|
+
mimeType,
|
|
926
993
|
sizeBytes: response.body.length,
|
|
927
994
|
fileName,
|
|
995
|
+
...(extractOnDisk ? await tryExtract(response.body, mimeType, fileName, deliveryOptions) : {}),
|
|
928
996
|
});
|
|
929
997
|
});
|
|
930
998
|
server.registerTool('ofw_sync_messages', {
|
|
@@ -932,7 +1000,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
932
1000
|
annotations: { readOnlyHint: false },
|
|
933
1001
|
inputSchema: {
|
|
934
1002
|
folders: z.array(z.enum(['inbox', 'sent', 'drafts'])).min(1).describe('Folders to sync (default: all three). Must be non-empty if given — an empty list would sync nothing while reporting success.').optional(),
|
|
935
|
-
fetchUnreadBodies: z.boolean().describe('If true, also fetch bodies for unread inbox messages
|
|
1003
|
+
fetchUnreadBodies: z.boolean().describe('If true, also fetch bodies for unread inbox messages — 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(),
|
|
936
1004
|
deep: z.boolean().describe('If true, walk every OFW page until empty regardless of cache state. Use to backfill gaps. Default false.').optional(),
|
|
937
1005
|
maxRequests: z.number().int().min(1).describe('Maximum OFW requests this single call may make before pausing. When hit, the response reports done:false — call again with the same arguments to continue. Omit to use the server default (OFW_SYNC_MAX_REQUESTS, or unbounded on local installs).').optional(),
|
|
938
1006
|
},
|
|
@@ -940,7 +1008,10 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
940
1008
|
const cache = cacheProvider();
|
|
941
1009
|
const result = await syncAll(client, {
|
|
942
1010
|
folders: args.folders,
|
|
943
|
-
|
|
1011
|
+
// Default from OFW_FETCH_UNREAD_BODIES (false unless set), and capped by
|
|
1012
|
+
// the OFW_ALLOW_MARK_READ ceiling — fetching those bodies is exactly what
|
|
1013
|
+
// stamps a First Viewed time on every unread message it touches.
|
|
1014
|
+
fetchUnreadBodies: getAllowMarkRead() && (args.fetchUnreadBodies ?? getFetchUnreadBodies()),
|
|
944
1015
|
deep: args.deep,
|
|
945
1016
|
maxRequests: args.maxRequests ?? getSyncMaxRequests(),
|
|
946
1017
|
}, cache);
|
|
@@ -962,7 +1033,10 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
962
1033
|
},
|
|
963
1034
|
}, async (args) => {
|
|
964
1035
|
const cache = cacheProvider();
|
|
965
|
-
|
|
1036
|
+
// The server-wide ceiling wins: a per-call allowMarkRead:true (or an
|
|
1037
|
+
// instruction injected into one) must not be able to stamp the record on a
|
|
1038
|
+
// deployment configured to never do so.
|
|
1039
|
+
const allowMarkRead = getAllowMarkRead() && (args.allowMarkRead ?? false);
|
|
966
1040
|
const requestedIds = args.messageIds ?? [];
|
|
967
1041
|
const ids = requestedIds.slice(0, MAX_FRESHNESS_IDS);
|
|
968
1042
|
// Folders default to "all three" only when the caller asked about nothing
|
|
@@ -982,7 +1056,13 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
982
1056
|
? (await cache.listDraftIds()).length
|
|
983
1057
|
: await cache.countMessages({ folder });
|
|
984
1058
|
const state = await cache.getSyncState(folder);
|
|
985
|
-
|
|
1059
|
+
// Never synced at all is a DIFFERENT state from "backfill in progress",
|
|
1060
|
+
// and both leave historyComplete false. Conflating them told the caller
|
|
1061
|
+
// that older history was still being backfilled for a folder whose
|
|
1062
|
+
// backfill had never started — advice that reads as "wait it out" when
|
|
1063
|
+
// the real answer is "run a sync".
|
|
1064
|
+
const neverSynced = state === null;
|
|
1065
|
+
const historyComplete = !neverSynced && state.resumePage === null;
|
|
986
1066
|
// A partially backfilled folder legitimately holds fewer messages than
|
|
987
1067
|
// the server, so a count mismatch there proves nothing. Report both
|
|
988
1068
|
// numbers and leave the verdict null rather than crying wolf for the
|
|
@@ -1001,7 +1081,9 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
1001
1081
|
...(inSync === null
|
|
1002
1082
|
? { note: serverCount === null
|
|
1003
1083
|
? 'OFW did not report a count for this folder, so cached-vs-server cannot be compared. Use the per-id check instead.'
|
|
1004
|
-
:
|
|
1084
|
+
: neverSynced
|
|
1085
|
+
? 'This folder has never been synced, so the cache holds nothing to compare. Run ofw_sync_messages.'
|
|
1086
|
+
: 'Older history is still being backfilled, so a lower cachedCount is expected and does not indicate drift.' }
|
|
1005
1087
|
: {}),
|
|
1006
1088
|
});
|
|
1007
1089
|
}
|
package/package.json
CHANGED
package/server.json
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"url": "https://github.com/chrischall/ofw-mcp",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "2.
|
|
9
|
+
"version": "2.8.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "ofw-mcp",
|
|
14
|
-
"version": "2.
|
|
14
|
+
"version": "2.8.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|
|
@@ -40,6 +40,18 @@
|
|
|
40
40
|
"description": "Set to \"true\" to register calendar write tools (create/update/delete event) in \"drafts\" write mode. Events have no draft stage but are reversible. Never overrides \"none\".",
|
|
41
41
|
"isRequired": false,
|
|
42
42
|
"format": "string"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"name": "OFW_ALLOW_MARK_READ",
|
|
46
|
+
"description": "Default \"true\". Set \"false\" to forbid any tool from marking a message read on OurFamilyWizard - reading a body for the first time stamps a co-parent-visible \"First Viewed\" time that cannot be undone. A ceiling: no per-call argument can raise it.",
|
|
47
|
+
"isRequired": false,
|
|
48
|
+
"format": "string"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"name": "OFW_FETCH_UNREAD_BODIES",
|
|
52
|
+
"description": "Default \"false\". Whether ofw_sync_messages fetches unread inbox bodies by default (each fetch stamps a First Viewed time). Capped by OFW_ALLOW_MARK_READ.",
|
|
53
|
+
"isRequired": false,
|
|
54
|
+
"format": "string"
|
|
43
55
|
}
|
|
44
56
|
]
|
|
45
57
|
}
|
package/skills/ofw/SKILL.md
CHANGED
|
@@ -94,14 +94,14 @@ Always pass `--config ~/.mcporter/mcporter.json` unless a local `config/mcporter
|
|
|
94
94
|
| `ofw_sync_messages(folders?, deep?, fetchUnreadBodies?)` | Sync OFW → local cache. **Call first if the cache might be stale.** Returns unread inbox hints (bodies not fetched, to avoid mark-as-read). |
|
|
95
95
|
| `ofw_list_message_folders` | List OFW folders with unread counts. Most reads use the cache; this is mainly for folder IDs and live unread counts. |
|
|
96
96
|
| `ofw_list_messages(folderId?, since?, until?, q?, page?, size?)` | Cache-backed list. Supports folder ("inbox"/"sent"/"both"), date range, and substring search. |
|
|
97
|
-
| `ofw_get_message(messageId)` | Read a message OR draft body. Cache-first. Ids in the drafts cache return `folder: "drafts"`. ⚠️ Falls through to OFW for unread inbox messages, which marks them
|
|
97
|
+
| `ofw_get_message(messageId, allowMarkRead?)` | Read a message OR draft body. Cache-first. Ids in the drafts cache return `folder: "drafts"`. ⚠️ Falls through to OFW for unread inbox messages, which marks them read AND stamps a "First Viewed" time the co-parent can see — irreversible. Pass `allowMarkRead:false` to refuse that fetch instead; cached, sent and already-read messages are unaffected. |
|
|
98
98
|
| `ofw_send_message(subject, body, recipientIds[], replyToId?, draftId?, myFileIDs?)` | Send a message. Pass `replyToId` to thread original history. Pass `draftId` to auto-delete the draft after sending. Pass `myFileIDs` (from `ofw_upload_attachment`) to attach files. |
|
|
99
99
|
| `ofw_get_unread_sent` | Sent messages your co-parent hasn't read yet (from cache). |
|
|
100
100
|
| `ofw_list_drafts` | List saved drafts (cache-backed). Each draft carries `serverConfirmed` — see [Freshness](#freshness). |
|
|
101
101
|
| `ofw_save_draft(subject, body, recipientIds?, messageId?, replyToId?, myFileIDs?)` | Create a new draft. Pass `messageId` to **replace** an existing draft: the tool creates a fresh draft and deletes the old one (OFW's update-in-place endpoint silently no-ops). The returned `id` is the NEW id; the response includes a `NOTE` documenting the swap. |
|
|
102
102
|
| `ofw_delete_draft(messageId)` | Delete a draft. |
|
|
103
103
|
| `ofw_upload_attachment(path, shareClass?, label?, description?)` | Upload a local file to My Files; returns a fileId to pass into `myFileIDs`. |
|
|
104
|
-
| `ofw_download_attachment(fileId, inline?, saveTo?, force?)` | Download an attachment.
|
|
104
|
+
| `ofw_download_attachment(fileId, inline?, saveTo?, force?, extract?, maxChars?, parts?)` | Download an attachment. Inline delivery returns the first rung that works: image → `ImageContent`; .xlsx/.csv/.pdf/.docx/.pptx/text → **extracted content** under `extracted` (per-sheet CSV, per-page/slide text); anything else → raw bytes. Default writes to `~/Downloads/ofw-mcp/` (add `extract:true` for content too). Use `parts:"1-2"` / a sheet name and `maxChars` on large files. |
|
|
105
105
|
| `ofw_check_freshness(folders?, messageIds?, allowMarkRead?)` | Cheap live check that the cache still matches OFW — one request for folder counts plus one per id, no bodies, no sync. Use before asserting current state. Only probes ids in the drafts cache unless `allowMarkRead:true` (probing others marks inbox messages read). |
|
|
106
106
|
|
|
107
107
|
### Calendar
|