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/dist/config.js CHANGED
@@ -82,6 +82,48 @@ export function getCalendarWritesAllowed() {
82
82
  return true;
83
83
  return mode === 'drafts' && parseBoolEnv('OFW_CALENDAR_WRITES');
84
84
  }
85
+ /**
86
+ * Deployment-wide ceiling on reads that STAMP the record.
87
+ *
88
+ * Fetching a message body from OFW marks it read and stamps a "First Viewed"
89
+ * timestamp the co-parent can see. That is part of the court-visible record and
90
+ * it cannot be undone — and unlike a send, it happens as a side effect of an
91
+ * ordinary read, so nothing about the caller's intent signals it.
92
+ *
93
+ * Default TRUE: unset means exactly the behaviour that shipped before this flag
94
+ * existed. Set OFW_ALLOW_MARK_READ=false and it becomes a hard ceiling rather
95
+ * than a default — `ofw_get_message` refuses a fetch that would stamp,
96
+ * `ofw_check_freshness` ignores `allowMarkRead:true`, and `ofw_sync_messages`
97
+ * ignores `fetchUnreadBodies:true`. A per-call argument (or an instruction
98
+ * injected into one) cannot raise it, which is the same structural posture
99
+ * OFW_WRITE_MODE takes for writes.
100
+ *
101
+ * An unrecognized value fails CLOSED, with a warning: someone who wrote
102
+ * "flase" meant to disable this, and honouring the typo as the permissive
103
+ * default would keep stamping the record while looking configured.
104
+ */
105
+ export function getAllowMarkRead() {
106
+ const raw = process.env.OFW_ALLOW_MARK_READ;
107
+ if (typeof raw !== 'string' || raw.trim().length === 0)
108
+ return true;
109
+ const value = raw.trim().toLowerCase();
110
+ if (['1', 'true', 'yes', 'on'].includes(value))
111
+ return true;
112
+ if (['0', 'false', 'no', 'off'].includes(value))
113
+ return false;
114
+ // stdio transport: stderr only — stdout is reserved for JSON-RPC.
115
+ console.error(`[ofw-mcp] Unrecognized OFW_ALLOW_MARK_READ "${raw.trim()}" — failing closed to "false" (no tool may mark a message read on OFW). Valid values: true, false.`);
116
+ return false;
117
+ }
118
+ /**
119
+ * Default for ofw_sync_messages' `fetchUnreadBodies` arg. False (the shipped
120
+ * default) so an ordinary sync never stamps unread inbox messages; set
121
+ * OFW_FETCH_UNREAD_BODIES=true on a deployment where read receipts are routine
122
+ * and you would rather have the bodies cached. Capped by getAllowMarkRead().
123
+ */
124
+ export function getFetchUnreadBodies() {
125
+ return parseBoolEnv('OFW_FETCH_UNREAD_BODIES');
126
+ }
85
127
  // Default for ofw_download_attachment's `inline` arg when the caller doesn't
86
128
  // pass one. Set OFW_INLINE_ATTACHMENTS=true to have attachments returned as
87
129
  // MCP content blocks by default (skipping disk) — useful on sandboxed MCP
@@ -0,0 +1,83 @@
1
+ // Word (.docx) text extraction.
2
+ //
3
+ // The goal is a readable transcript, not a faithful re-render: headings keep
4
+ // their level as `#` prefixes, list paragraphs keep their bullet, and table
5
+ // rows come back pipe-delimited. Structure is what makes an extracted custody
6
+ // schedule answerable ("which row is Thanksgiving?"); a flat wall of runs is
7
+ // not much better than the blob we are replacing.
8
+ import { readZip } from './zip.js';
9
+ import { decodeXmlEntities, elements } from './xml.js';
10
+ import { findPart } from './ooxml.js';
11
+ // A run's text, a tab, or a break — matched in document order so an inline tab
12
+ // does not get hoisted out of position by a tags-then-text pass.
13
+ const RUN_CONTENT = /<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>|<w:(tab|br|cr)\s*\/>/g;
14
+ function paragraphText(inner) {
15
+ let text = '';
16
+ for (let m = RUN_CONTENT.exec(inner); m !== null; m = RUN_CONTENT.exec(inner)) {
17
+ if (m[1] !== undefined)
18
+ text += decodeXmlEntities(m[1]);
19
+ else
20
+ text += m[2] === 'tab' ? '\t' : '\n';
21
+ }
22
+ RUN_CONTENT.lastIndex = 0;
23
+ return text;
24
+ }
25
+ /** Prefix a paragraph according to its `<w:pStyle>`, if it has a known one. */
26
+ function styledParagraph(inner) {
27
+ const text = paragraphText(inner);
28
+ if (text === '')
29
+ return '';
30
+ const style = /<w:pStyle\s[^>]*w:val="([^"]*)"/.exec(inner)?.[1] ?? '';
31
+ const heading = /^Heading(\d)$/.exec(style);
32
+ if (heading)
33
+ return `${'#'.repeat(Math.min(Number(heading[1]), 6))} ${text}`;
34
+ if (style === 'Title' || style === 'Subtitle')
35
+ return `# ${text}`;
36
+ if (style === 'ListParagraph')
37
+ return `- ${text}`;
38
+ return text;
39
+ }
40
+ function tableText(inner) {
41
+ const rows = [];
42
+ for (const tr of elements(inner, 'w:tr')) {
43
+ const cells = [];
44
+ for (const tc of elements(tr.inner, 'w:tc')) {
45
+ // A cell holds paragraphs; keep it on one line so the row stays a row.
46
+ const parts = [];
47
+ for (const p of elements(tc.inner, 'w:p')) {
48
+ const text = paragraphText(p.inner);
49
+ if (text !== '')
50
+ parts.push(text);
51
+ }
52
+ cells.push(parts.join(' '));
53
+ }
54
+ rows.push(`| ${cells.join(' | ')} |`);
55
+ }
56
+ return rows.join('\n');
57
+ }
58
+ // Top-level block scan: a table swallows its own paragraphs (the match spans
59
+ // them), so the alternation yields tables and stray paragraphs in order without
60
+ // emitting a table's cell text twice.
61
+ // The paragraph arm mirrors `elements()`: a lazy attribute run that does not
62
+ // eat a self-closing slash, so `<w:p w14:paraId="…"/>` cannot swallow the
63
+ // following paragraph.
64
+ const BLOCK = /<w:tbl(?:\s[^>]*?)?>[\s\S]*?<\/w:tbl>|<w:p(?:\s[^>]*?)?(?:\s*\/>|>([\s\S]*?)<\/w:p>)/g;
65
+ /** Extract the text of a .docx document part. */
66
+ export async function extractDocx(bytes) {
67
+ const zip = await readZip(bytes);
68
+ const path = findPart(zip, 'word/document.xml', 'document.xml');
69
+ if (!path)
70
+ throw new Error('no document part found in the .docx archive');
71
+ /* v8 ignore next -- findPart only returns a name the archive contains, so readText cannot be null here */
72
+ const xml = (await zip.readText(path)) ?? '';
73
+ const blocks = [];
74
+ for (let m = BLOCK.exec(xml); m !== null; m = BLOCK.exec(xml)) {
75
+ // Group 1 is the paragraph's inner XML — undefined for a table match and
76
+ // for a self-closing (empty) paragraph.
77
+ const text = m[0].startsWith('<w:tbl') ? tableText(m[0]) : styledParagraph(m[1] ?? '');
78
+ if (text !== '')
79
+ blocks.push(text);
80
+ }
81
+ BLOCK.lastIndex = 0;
82
+ return { kind: 'document', text: blocks.join('\n\n') };
83
+ }
@@ -0,0 +1,222 @@
1
+ // The delivery ladder's extraction rung.
2
+ //
3
+ // `ofw_download_attachment` fetches bytes successfully and then has to hand the
4
+ // caller something it can actually read. A host renders four image types
5
+ // inline and rejects every other embedded resource outright ("Resources of
6
+ // type 'application/vnd.…sheet' are not currently supported"), so for a
7
+ // spreadsheet, PDF, Word or PowerPoint attachment the bytes are delivered and
8
+ // unreadable at the same time. Rendering is a display concern; content is a
9
+ // data concern, and this module is what keeps the second from being decided by
10
+ // the first: whatever the host can draw, the FILE's text comes back as text.
11
+ import { extractXlsx, extractDelimited } from './spreadsheet.js';
12
+ import { extractDocx } from './document.js';
13
+ import { extractPptx } from './presentation.js';
14
+ import { extractPdf } from './pdf.js';
15
+ const MIME_KINDS = {
16
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
17
+ 'application/vnd.ms-excel.sheet.macroenabled.12': 'xlsx',
18
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
19
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
20
+ 'application/pdf': 'pdf',
21
+ 'text/csv': 'csv',
22
+ 'text/tab-separated-values': 'tsv',
23
+ 'application/json': 'text',
24
+ 'application/xml': 'text',
25
+ 'application/xhtml+xml': 'text',
26
+ 'application/javascript': 'text',
27
+ 'application/x-yaml': 'text',
28
+ };
29
+ const EXT_KINDS = {
30
+ '.xlsx': 'xlsx', '.xlsm': 'xlsx',
31
+ '.docx': 'docx',
32
+ '.pptx': 'pptx',
33
+ '.pdf': 'pdf',
34
+ '.csv': 'csv',
35
+ '.tsv': 'tsv', '.tab': 'tsv',
36
+ '.txt': 'text', '.md': 'text', '.json': 'text', '.xml': 'text',
37
+ '.html': 'text', '.htm': 'text', '.log': 'text', '.yaml': 'text', '.yml': 'text',
38
+ '.ics': 'text', '.vcf': 'text', '.srt': 'text',
39
+ };
40
+ /**
41
+ * Which extractor handles this attachment, by MIME then by extension. The
42
+ * extension is a real fallback, not a formality: OFW serves binaries with a
43
+ * `charset` bolted on and sometimes with no useful type at all.
44
+ */
45
+ export function extractKindFor(mimeType, fileName) {
46
+ const byMime = MIME_KINDS[mimeType];
47
+ if (byMime)
48
+ return byMime;
49
+ const dot = fileName.lastIndexOf('.');
50
+ const byExt = dot === -1 ? undefined : EXT_KINDS[fileName.slice(dot).toLowerCase()];
51
+ if (byExt)
52
+ return byExt;
53
+ // Any other text/* type (text/plain, text/markdown, text/html, …) is text.
54
+ return mimeType.startsWith('text/') ? 'text' : null;
55
+ }
56
+ /**
57
+ * Build a part selector from a `"1-3,5"` / `"Summary,2026"` spec. Numbers are
58
+ * 1-based positions; anything else matches a sheet or slide/page label. An
59
+ * empty spec selects everything rather than nothing — a filter that silently
60
+ * excluded all content would look exactly like an empty file.
61
+ */
62
+ export function parsePartSpec(spec) {
63
+ const ranges = [];
64
+ const names = new Set();
65
+ for (const token of spec.split(',').map((t) => t.trim()).filter(Boolean)) {
66
+ const range = /^(\d+)\s*-\s*(\d+)$/.exec(token);
67
+ if (range) {
68
+ ranges.push([Number(range[1]), Number(range[2])]);
69
+ continue;
70
+ }
71
+ // A bare number is a position AND a possible name: spreadsheet tabs are
72
+ // routinely named for a year ("2026"), and reading that token as a position
73
+ // only would make the obvious `parts: "2026"` select nothing at all.
74
+ if (/^\d+$/.test(token))
75
+ ranges.push([Number(token), Number(token)]);
76
+ names.add(token.toLowerCase());
77
+ }
78
+ if (ranges.length === 0 && names.size === 0)
79
+ return () => true;
80
+ return (index, name) => ranges.some(([from, to]) => index + 1 >= from && index + 1 <= to)
81
+ || names.has(name.toLowerCase());
82
+ }
83
+ /** Cut `text` to `budget` characters on a line boundary where possible. */
84
+ function clip(text, budget) {
85
+ const cut = text.slice(0, budget);
86
+ const lastBreak = cut.lastIndexOf('\n');
87
+ return lastBreak > 0 ? cut.slice(0, lastBreak) : cut;
88
+ }
89
+ function omissionNote(label) {
90
+ return `${label} (omitted: response character budget)`;
91
+ }
92
+ /**
93
+ * Trim an extraction to `maxChars`, recording exactly what was dropped. A
94
+ * truncated payload that does not say so is worse than a short one: the caller
95
+ * reads a partial custody schedule as the whole schedule.
96
+ */
97
+ export function applyCharBudget(extracted, maxChars) {
98
+ switch (extracted.kind) {
99
+ case 'text':
100
+ case 'document': {
101
+ if (extracted.text.length <= maxChars)
102
+ return extracted;
103
+ return { ...extracted, text: clip(extracted.text, maxChars), truncated: true };
104
+ }
105
+ case 'spreadsheet': {
106
+ const sheets = [];
107
+ const omitted = [...(extracted.omitted ?? [])];
108
+ let budget = maxChars;
109
+ let truncated = extracted.truncated ?? false;
110
+ for (const sheet of extracted.sheets) {
111
+ if (budget <= 0) {
112
+ omitted.push(omissionNote(sheet.name));
113
+ truncated = true;
114
+ continue;
115
+ }
116
+ if (sheet.csv.length <= budget) {
117
+ sheets.push(sheet);
118
+ budget -= sheet.csv.length;
119
+ continue;
120
+ }
121
+ const csv = clip(sheet.csv, budget);
122
+ sheets.push({ ...sheet, csv, rows: csv.split('\n').length, truncated: true });
123
+ budget = 0;
124
+ truncated = true;
125
+ }
126
+ return {
127
+ ...extracted, sheets, truncated,
128
+ ...(omitted.length ? { omitted } : {}),
129
+ };
130
+ }
131
+ case 'presentation': {
132
+ const slides = [];
133
+ const omitted = [...(extracted.omitted ?? [])];
134
+ let budget = maxChars;
135
+ let truncated = extracted.truncated ?? false;
136
+ for (const slide of extracted.slides) {
137
+ const size = slide.text.length + (slide.notes?.length ?? 0);
138
+ if (budget <= 0) {
139
+ omitted.push(omissionNote(`slide ${slide.number}`));
140
+ truncated = true;
141
+ continue;
142
+ }
143
+ if (size <= budget) {
144
+ slides.push(slide);
145
+ budget -= size;
146
+ continue;
147
+ }
148
+ slides.push({ ...slide, text: clip(slide.text, budget), notes: undefined });
149
+ budget = 0;
150
+ truncated = true;
151
+ }
152
+ return { ...extracted, slides, truncated, ...(omitted.length ? { omitted } : {}) };
153
+ }
154
+ case 'pdf': {
155
+ const pages = [];
156
+ const omitted = [...(extracted.omitted ?? [])];
157
+ let budget = maxChars;
158
+ let truncated = extracted.truncated ?? false;
159
+ for (const page of extracted.pages) {
160
+ if (budget <= 0) {
161
+ omitted.push(omissionNote(`page ${page.number}`));
162
+ truncated = true;
163
+ continue;
164
+ }
165
+ if (page.text.length <= budget) {
166
+ pages.push(page);
167
+ budget -= page.text.length;
168
+ continue;
169
+ }
170
+ pages.push({ ...page, text: clip(page.text, budget) });
171
+ budget = 0;
172
+ truncated = true;
173
+ }
174
+ return { ...extracted, pages, truncated, ...(omitted.length ? { omitted } : {}) };
175
+ }
176
+ }
177
+ }
178
+ /** Default response budget: roughly 12k tokens of extracted text. */
179
+ export const DEFAULT_MAX_CHARS = 50_000;
180
+ function decodeText(bytes) {
181
+ const text = bytes.toString('utf8');
182
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
183
+ }
184
+ /**
185
+ * Extract readable content from an attachment, or return null when the format
186
+ * has no extractor (a .zip, a .heic, an unknown binary) and the caller should
187
+ * fall through to the next rung of the delivery ladder.
188
+ *
189
+ * Throws when a format that SHOULD be extractable cannot be read, so the
190
+ * failure can be reported by name rather than swallowed.
191
+ */
192
+ export async function extractAttachment(bytes, mimeType, fileName, opts = {}) {
193
+ const kind = extractKindFor(mimeType, fileName);
194
+ if (kind === null)
195
+ return null;
196
+ const select = opts.parts === undefined ? undefined : parsePartSpec(opts.parts);
197
+ let extracted;
198
+ switch (kind) {
199
+ case 'xlsx':
200
+ extracted = await extractXlsx(bytes, { select });
201
+ break;
202
+ case 'csv':
203
+ extracted = extractDelimited(decodeText(bytes), fileName, ',');
204
+ break;
205
+ case 'tsv':
206
+ extracted = extractDelimited(decodeText(bytes), fileName, '\t');
207
+ break;
208
+ case 'docx':
209
+ extracted = await extractDocx(bytes);
210
+ break;
211
+ case 'pptx':
212
+ extracted = await extractPptx(bytes, { select });
213
+ break;
214
+ case 'pdf':
215
+ extracted = await extractPdf(bytes, { select });
216
+ break;
217
+ case 'text':
218
+ extracted = { kind: 'text', text: decodeText(bytes) };
219
+ break;
220
+ }
221
+ return applyCharBudget(extracted, opts.maxChars ?? DEFAULT_MAX_CHARS);
222
+ }
@@ -0,0 +1,55 @@
1
+ // Bounded decompression, shared by the ZIP reader and the PDF stream decoder.
2
+ //
3
+ // Both formats let the FILE state how big a member expands to, and both are
4
+ // read from co-parent-supplied attachments. A declared size is therefore a
5
+ // hint, never a guarantee: a ZIP central directory can claim 1 KB in front of a
6
+ // member that expands to a gigabyte, and a PDF stream dictionary declares
7
+ // nothing about its inflated length at all. Checking the declared number before
8
+ // inflating rejects an HONEST oversized member cheaply — it does nothing about
9
+ // a lying one.
10
+ //
11
+ // So the real cap is enforced on the bytes as they actually arrive: read the
12
+ // decompressed stream chunk by chunk, and abort the moment the running total
13
+ // passes the limit. Peak memory is then bounded by the limit rather than by
14
+ // whatever the file felt like claiming.
15
+ /** 32 MiB. Sized to fit comfortably inside the Worker's memory budget. */
16
+ export const MAX_DECOMPRESSED_BYTES = 32 * 1024 * 1024;
17
+ /**
18
+ * Thrown when decompression is aborted for exceeding its cap. Distinct from a
19
+ * decode failure so callers can tell "this file is hostile or absurd" from
20
+ * "this stream is corrupt" — the first deserves to be reported, the second is
21
+ * routinely survivable.
22
+ */
23
+ export class DecompressionLimitError extends Error {
24
+ constructor(label, limit) {
25
+ super(`${label} expands past the ${limit}-byte decompression cap`);
26
+ this.name = 'DecompressionLimitError';
27
+ }
28
+ }
29
+ /**
30
+ * Inflate `data`, aborting if the OUTPUT exceeds `limit` bytes.
31
+ *
32
+ * `deflate-raw` is the ZIP member format; `deflate` is the zlib-wrapped form a
33
+ * PDF `/FlateDecode` stream uses. Both go through the WHATWG
34
+ * `DecompressionStream` so this runs unchanged on Node and workerd.
35
+ */
36
+ export async function inflateBounded(data, format, limit, label) {
37
+ const stream = new Blob([data]).stream()
38
+ .pipeThrough(new DecompressionStream(format));
39
+ const reader = stream.getReader();
40
+ const chunks = [];
41
+ let total = 0;
42
+ for (;;) {
43
+ const { done, value } = await reader.read();
44
+ if (done)
45
+ break;
46
+ total += value.length;
47
+ if (total > limit) {
48
+ // Stop pulling: the rest of the payload is never allocated.
49
+ await reader.cancel();
50
+ throw new DecompressionLimitError(label, limit);
51
+ }
52
+ chunks.push(value);
53
+ }
54
+ return Buffer.concat(chunks);
55
+ }
@@ -0,0 +1,58 @@
1
+ // Shared OOXML plumbing: part-path resolution and relationship lookup.
2
+ //
3
+ // Every OOXML format points from one part to another through a `_rels` file
4
+ // (`r:id="rId3"` → `Target="worksheets/sheet3.xml"`), and the target is
5
+ // relative to the REFERRING part's directory. Guessing the target from the id
6
+ // number instead works right up until a document has been edited enough for the
7
+ // numbering to drift, which is why this indirection is followed properly.
8
+ import { elements, attr } from './xml.js';
9
+ /** Resolve a relationship target against the referring part's directory. */
10
+ export function resolvePartPath(baseDir, target) {
11
+ // A leading "/" means package-absolute.
12
+ if (target.startsWith('/'))
13
+ return target.slice(1);
14
+ const segments = (baseDir + target).split('/');
15
+ const out = [];
16
+ for (const segment of segments) {
17
+ if (segment === '.' || segment === '')
18
+ continue;
19
+ if (segment === '..')
20
+ out.pop();
21
+ else
22
+ out.push(segment);
23
+ }
24
+ return out.join('/');
25
+ }
26
+ /** The directory of a part path, with a trailing slash (`""` at the root). */
27
+ export function dirOf(partPath) {
28
+ const i = partPath.lastIndexOf('/');
29
+ return i === -1 ? '' : partPath.slice(0, i + 1);
30
+ }
31
+ /** Read the `_rels` companion of `partPath`. Empty when the part has none. */
32
+ export async function readRels(zip, partPath) {
33
+ const dir = dirOf(partPath);
34
+ const base = partPath.slice(dir.length);
35
+ const xml = await zip.readText(`${dir}_rels/${base}.rels`);
36
+ if (!xml)
37
+ return [];
38
+ const rels = [];
39
+ for (const el of elements(xml, 'Relationship')) {
40
+ const id = attr(el.attrs, 'Id');
41
+ const target = attr(el.attrs, 'Target');
42
+ /* v8 ignore next -- a Relationship without Id/Target is malformed OOXML; skipping keeps one bad entry from failing the whole read */
43
+ if (!id || !target)
44
+ continue;
45
+ rels.push({ id, target: resolvePartPath(dir, target), type: attr(el.attrs, 'Type') ?? '' });
46
+ }
47
+ return rels;
48
+ }
49
+ /**
50
+ * Locate a top-level part: the conventional path when present, otherwise the
51
+ * first entry whose name ends with the same file name. Producers other than
52
+ * Microsoft's occasionally use a different directory.
53
+ */
54
+ export function findPart(zip, conventional, fileName) {
55
+ if (zip.has(conventional))
56
+ return conventional;
57
+ return zip.names().find((n) => n.endsWith(`/${fileName}`) || n === fileName) ?? null;
58
+ }