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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +15 -2
- package/dist/bundle.js +1081 -58
- 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 +56 -4
- package/dist/tools/messages.js +191 -51
- package/package.json +3 -3
- 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;
|
|
@@ -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
|
-
*
|
|
106
|
-
*
|
|
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.`,
|