ofw-mcp 2.7.0 → 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.
@@ -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.7.0', // x-release-please-version
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. Empty array is a no-op.
226
- await store.upsertMessages(toUpsert);
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 `DraftFreshnessError`: a freshness check that could not run must
51
- * abort the write, never wave it through — see the callers in messages.ts.
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;
@@ -77,6 +82,22 @@ export async function fetchServerDraft(client, id) {
77
82
  recipients: mapRecipients(detail.recipients),
78
83
  };
79
84
  }
85
+ /**
86
+ * The fields whose divergence constitutes a REAL conflict — the actual message
87
+ * content a caller would lose if we overwrote a copy edited elsewhere. Anything
88
+ * NOT listed here (currently only `replyToId`) is connector/server-authored
89
+ * metadata: OFW normalizes `replyToId` after a draft is saved (dropping it, or
90
+ * re-targeting it to the thread tip), which is the connector's own mutation
91
+ * surfacing later — not third-party interference. A divergence in metadata
92
+ * alone must never refuse the write, or the guard manufactures a false STALE
93
+ * for a change the caller did not make. See issue thread on save/edit round
94
+ * trips.
95
+ */
96
+ const SUBSTANTIVE_FIELDS = ['subject', 'body', 'recipients'];
97
+ /** The substantive subset of a changed-field list (drops metadata like replyToId). */
98
+ function substantiveChanges(changed) {
99
+ return changed.filter((f) => SUBSTANTIVE_FIELDS.includes(f));
100
+ }
80
101
  function diffFields(a, b) {
81
102
  const changed = [];
82
103
  if (a.subject !== b.subject)
@@ -102,8 +123,17 @@ function diffFields(a, b) {
102
123
  * 2. No token supplied → the cached base must match the server EXACTLY. This
103
124
  * is the safe default: "no token" never means "force".
104
125
  *
105
- * Everything else — server ahead of cache, no cached base to compare, draft
106
- * gone from the server — refuses.
126
+ * In BOTH modes the conflict decision turns on the SUBSTANTIVE fields
127
+ * (subject/body/recipients), not on any revision delta. When the only thing
128
+ * that moved is connector/server-authored metadata — `replyToId` normalized
129
+ * after the save — the content is intact, so it is FRESH (with `metadataOnly`
130
+ * set) rather than STALE. That is the connector's own mutation resurfacing, not
131
+ * a third party editing the draft; refusing it would be a false positive that
132
+ * trains callers to distrust the guard. The fail-safe direction is untouched:
133
+ * the moment subject, body or recipients differ, it still refuses.
134
+ *
135
+ * Everything else — server ahead of cache on real content, no cached base to
136
+ * compare, draft gone from the server — refuses.
107
137
  */
108
138
  export function checkDraftFreshness(input) {
109
139
  const { server, cached, expectedRevision } = input;
@@ -119,6 +149,20 @@ export function checkDraftFreshness(input) {
119
149
  if (expectedRevision === actual) {
120
150
  return { verdict: 'FRESH', reason: 'expectedRevision matches the live server draft.', changedFields: [] };
121
151
  }
152
+ // Token mismatch. When the caller's token is the one WE cached, we can name
153
+ // exactly what drifted — and if that is metadata alone, it is our own
154
+ // post-save normalization, not a conflict.
155
+ if (cached !== null && draftRevision(cached) === expectedRevision) {
156
+ const changedFields = diffFields(server, cached);
157
+ if (substantiveChanges(changedFields).length === 0) {
158
+ return {
159
+ verdict: 'FRESH',
160
+ reason: `Only connector-authored metadata (${changedFields.join(', ')}) changed since you read the draft; its subject, body and recipients are unchanged, so this is not a conflict.`,
161
+ changedFields,
162
+ metadataOnly: true,
163
+ };
164
+ }
165
+ }
122
166
  return {
123
167
  verdict: 'STALE',
124
168
  reason: `expectedRevision ${expectedRevision} does not match the live server draft (${actual}) — it changed after you read it.`,
@@ -136,6 +180,14 @@ export function checkDraftFreshness(input) {
136
180
  if (changedFields.length === 0) {
137
181
  return { verdict: 'FRESH', reason: 'The cached draft matches the live server draft.', changedFields: [] };
138
182
  }
183
+ if (substantiveChanges(changedFields).length === 0) {
184
+ return {
185
+ verdict: 'FRESH',
186
+ reason: `Only connector-authored metadata (${changedFields.join(', ')}) differs from the cached copy; subject, body and recipients match, so this is not a conflict.`,
187
+ changedFields,
188
+ metadataOnly: true,
189
+ };
190
+ }
139
191
  return {
140
192
  verdict: 'STALE',
141
193
  reason: `The draft on OurFamilyWizard differs from the cached copy (${changedFields.join(', ')}) — it was edited outside this tool.`,