@ni-c/imap-mcp 0.3.0 → 0.5.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/README.md +35 -10
- package/dist/analyze.d.ts +33 -3
- package/dist/analyze.js +135 -23
- package/dist/attachments.d.ts +12 -0
- package/dist/attachments.js +52 -5
- package/dist/config.d.ts +18 -0
- package/dist/config.js +147 -14
- package/dist/extract/child.d.ts +1 -0
- package/dist/extract/child.js +83 -0
- package/dist/extract/index.d.ts +41 -0
- package/dist/extract/index.js +183 -0
- package/dist/extract/ooxml.d.ts +35 -0
- package/dist/extract/ooxml.js +634 -0
- package/dist/extract/pdf.d.ts +62 -0
- package/dist/extract/pdf.js +539 -0
- package/dist/extract/types.d.ts +56 -0
- package/dist/extract/types.js +13 -0
- package/dist/imap.d.ts +48 -2
- package/dist/imap.js +133 -28
- package/dist/message.d.ts +11 -0
- package/dist/message.js +26 -3
- package/dist/output-schema.d.ts +1 -0
- package/dist/output-schema.js +6 -0
- package/dist/resources.js +10 -3
- package/dist/result.d.ts +7 -1
- package/dist/result.js +32 -6
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +2 -0
- package/dist/server.js +15 -0
- package/dist/tools/read.js +473 -58
- package/dist/tools/write.js +18 -4
- package/package.json +11 -7
- package/dist/analyze.js.map +0 -1
- package/dist/attachments.js.map +0 -1
- package/dist/audit.js.map +0 -1
- package/dist/config.js.map +0 -1
- package/dist/download.js.map +0 -1
- package/dist/draft.js.map +0 -1
- package/dist/errors.js.map +0 -1
- package/dist/imap.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/message.js.map +0 -1
- package/dist/output-schema.js.map +0 -1
- package/dist/resources.js.map +0 -1
- package/dist/result.js.map +0 -1
- package/dist/schema.js.map +0 -1
- package/dist/server.js.map +0 -1
- package/dist/stream.js.map +0 -1
- package/dist/tools/annotations.js.map +0 -1
- package/dist/tools/catalogue.js.map +0 -1
- package/dist/tools/read.js.map +0 -1
- package/dist/tools/write.js.map +0 -1
package/dist/config.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { isMediaType } from './attachments.js';
|
|
3
|
+
import { MAILBOX_CONTROL_CHARS } from './schema.js';
|
|
1
4
|
export const DEFAULT_ATTACHMENT_TYPES = [
|
|
2
5
|
'application/pdf',
|
|
3
6
|
'application/json',
|
|
@@ -23,7 +26,31 @@ const DEFAULT_MAX_MESSAGES = 100;
|
|
|
23
26
|
const DEFAULT_MAX_ATTACHMENT_BYTES = 1024 * 1024;
|
|
24
27
|
/** Disk cap: this one protects the filesystem, which is a different concern. */
|
|
25
28
|
const DEFAULT_MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024;
|
|
29
|
+
/** Extraction cap: how much hostile input one parser is asked to chew on. */
|
|
30
|
+
const DEFAULT_MAX_EXTRACT_BYTES = 10 * 1024 * 1024;
|
|
31
|
+
/**
|
|
32
|
+
* Hard ceiling on `IMAP_MAX_EXTRACT_BYTES`.
|
|
33
|
+
*
|
|
34
|
+
* The other two size variables have no maximum because their cost is linear and
|
|
35
|
+
* paid somewhere visible — a big result, a big file. This one buys a buffer
|
|
36
|
+
* inside a parser working on bytes a stranger chose, so a typo of "104857600000"
|
|
37
|
+
* would not produce a big answer, it would produce an operator who believes
|
|
38
|
+
* there is a limit. 64 MiB is past any document that arrives by mail.
|
|
39
|
+
*/
|
|
40
|
+
const MAX_MAX_EXTRACT_BYTES = 64 * 1024 * 1024;
|
|
26
41
|
const DEFAULT_SEEN_KEYWORD = 'AiSeen';
|
|
42
|
+
/** A hostname is at most 253 characters; an IPv6 literal far fewer. */
|
|
43
|
+
const MAX_HOST_LENGTH = 253;
|
|
44
|
+
/** IMAP allows 255 bytes of mailbox name; the tool parameter says the same. */
|
|
45
|
+
const MAX_MAILBOX_LENGTH = 255;
|
|
46
|
+
/** Matches the `keyword` tool parameter, which is the other place one is typed. */
|
|
47
|
+
const MAX_KEYWORD_LENGTH = 64;
|
|
48
|
+
const MAX_ATTACHMENT_TYPES = 64;
|
|
49
|
+
/**
|
|
50
|
+
* The rule the `mailbox` tool parameter applies, imported rather than spelled
|
|
51
|
+
* again: a second copy of a control-character class is how two of them drift.
|
|
52
|
+
*/
|
|
53
|
+
const CONTROL_CHARS = MAILBOX_CONTROL_CHARS;
|
|
27
54
|
/** Shown when the configuration is incomplete — at startup and on every call. */
|
|
28
55
|
export function missingConfigMessage(missing) {
|
|
29
56
|
return (`missing required environment variable(s): ${missing.join(', ')}\n` +
|
|
@@ -66,13 +93,20 @@ export function loadConfig(env = process.env) {
|
|
|
66
93
|
const elicitation = parseElicitation(env.ELICITATION);
|
|
67
94
|
if (host !== undefined)
|
|
68
95
|
assertSafeHost(host, 'IMAP_HOST');
|
|
96
|
+
// A user name is written into a LOGIN command and into the From header of
|
|
97
|
+
// every draft. Neither tolerates a line break, and neither is a place for a
|
|
98
|
+
// value that was meant for the line above it.
|
|
99
|
+
if (user !== undefined)
|
|
100
|
+
assertSingleLine(user, 'IMAP_USER');
|
|
101
|
+
const mailbox = env.IMAP_MAILBOX || 'INBOX';
|
|
102
|
+
assertMailboxName(mailbox, 'IMAP_MAILBOX');
|
|
69
103
|
const draftsMailbox = env.IMAP_DRAFTS_MAILBOX;
|
|
70
104
|
if (draftsMailbox !== undefined) {
|
|
71
|
-
|
|
105
|
+
assertMailboxName(draftsMailbox, 'IMAP_DRAFTS_MAILBOX');
|
|
72
106
|
}
|
|
73
107
|
const trustedAuthservId = env.IMAP_TRUSTED_AUTHSERV_ID?.trim() || undefined;
|
|
74
108
|
if (trustedAuthservId !== undefined) {
|
|
75
|
-
|
|
109
|
+
assertSafeHost(trustedAuthservId, 'IMAP_TRUSTED_AUTHSERV_ID');
|
|
76
110
|
}
|
|
77
111
|
const config = {
|
|
78
112
|
imap: {
|
|
@@ -82,15 +116,16 @@ export function loadConfig(env = process.env) {
|
|
|
82
116
|
password,
|
|
83
117
|
tls,
|
|
84
118
|
insecureTls: env.IMAP_INSECURE_TLS === 'true',
|
|
85
|
-
mailbox
|
|
119
|
+
mailbox,
|
|
86
120
|
seenKeyword: parseKeyword(env.IMAP_SEEN_KEYWORD),
|
|
87
121
|
draftsMailbox,
|
|
88
122
|
trustedAuthservId,
|
|
89
123
|
maxMessages: parseCount(env.IMAP_MAX_MESSAGES, DEFAULT_MAX_MESSAGES, 'IMAP_MAX_MESSAGES'),
|
|
90
124
|
maxAttachmentBytes: parseCount(env.IMAP_MAX_ATTACHMENT_BYTES, DEFAULT_MAX_ATTACHMENT_BYTES, 'IMAP_MAX_ATTACHMENT_BYTES'),
|
|
91
125
|
allowedAttachmentTypes: parseTypes(env.IMAP_ATTACHMENT_TYPES),
|
|
92
|
-
downloadDir: env.IMAP_DOWNLOAD_DIR,
|
|
126
|
+
downloadDir: parseDownloadDir(env.IMAP_DOWNLOAD_DIR),
|
|
93
127
|
maxDownloadBytes: parseCount(env.IMAP_MAX_DOWNLOAD_BYTES, DEFAULT_MAX_DOWNLOAD_BYTES, 'IMAP_MAX_DOWNLOAD_BYTES'),
|
|
128
|
+
maxExtractBytes: parseCount(env.IMAP_MAX_EXTRACT_BYTES, DEFAULT_MAX_EXTRACT_BYTES, 'IMAP_MAX_EXTRACT_BYTES', MAX_MAX_EXTRACT_BYTES),
|
|
94
129
|
},
|
|
95
130
|
// Defaults to true, unlike the rest of the family — see the field comment.
|
|
96
131
|
readOnly: env.IMAP_READ_ONLY !== 'false',
|
|
@@ -146,10 +181,22 @@ export function parseElicitation(raw) {
|
|
|
146
181
|
return true;
|
|
147
182
|
if (value === 'false')
|
|
148
183
|
return false;
|
|
149
|
-
|
|
184
|
+
// Described, not quoted. The variable is unprefixed and sits in the same
|
|
185
|
+
// block as IMAP_PASSWORD in every compose file; what lands in it by mistake
|
|
186
|
+
// is exactly the value that must not be printed into the client's log.
|
|
187
|
+
console.error(`imap-mcp: ELICITATION must be "true" or "false" — got ${describeValue(raw ?? '')}. ` +
|
|
150
188
|
'Refusing to start rather than guess.');
|
|
151
189
|
process.exit(1);
|
|
152
190
|
}
|
|
191
|
+
/**
|
|
192
|
+
* A configuration value for an error message: its length and nothing else.
|
|
193
|
+
*
|
|
194
|
+
* Every variable this file reads has a neighbour that is a secret, and the
|
|
195
|
+
* value that fails a shape check is the one most likely to be that neighbour.
|
|
196
|
+
*/
|
|
197
|
+
function describeValue(raw) {
|
|
198
|
+
return `a ${raw.length}-character value`;
|
|
199
|
+
}
|
|
153
200
|
function parsePort(raw, fallback, name) {
|
|
154
201
|
if (raw === undefined || raw === '')
|
|
155
202
|
return fallback;
|
|
@@ -161,7 +208,7 @@ function parsePort(raw, fallback, name) {
|
|
|
161
208
|
}
|
|
162
209
|
return value;
|
|
163
210
|
}
|
|
164
|
-
function parseCount(raw, fallback, name) {
|
|
211
|
+
function parseCount(raw, fallback, name, max) {
|
|
165
212
|
if (raw === undefined || raw === '')
|
|
166
213
|
return fallback;
|
|
167
214
|
const value = Number(raw);
|
|
@@ -169,6 +216,13 @@ function parseCount(raw, fallback, name) {
|
|
|
169
216
|
console.error(`imap-mcp: ${name} must be a positive integer`);
|
|
170
217
|
process.exit(1);
|
|
171
218
|
}
|
|
219
|
+
if (max !== undefined && value > max) {
|
|
220
|
+
// The limit is named rather than clamped to: an operator who asked for more
|
|
221
|
+
// than the server will do should learn that here, not from a refusal later
|
|
222
|
+
// that looks like the document was the problem.
|
|
223
|
+
console.error(`imap-mcp: ${name} must not exceed ${max}`);
|
|
224
|
+
process.exit(1);
|
|
225
|
+
}
|
|
172
226
|
return value;
|
|
173
227
|
}
|
|
174
228
|
/**
|
|
@@ -180,19 +234,72 @@ function parseKeyword(raw) {
|
|
|
180
234
|
return DEFAULT_SEEN_KEYWORD;
|
|
181
235
|
if (raw === '')
|
|
182
236
|
return '';
|
|
183
|
-
|
|
184
|
-
|
|
237
|
+
// Bounded like the `keyword` tool parameter. The value is written into a
|
|
238
|
+
// tool description and into every `get_server_info` answer, so a length has
|
|
239
|
+
// to be a length and not whatever was pasted.
|
|
240
|
+
if (raw.length > MAX_KEYWORD_LENGTH || !/^[A-Za-z0-9$_.-]+$/.test(raw)) {
|
|
241
|
+
console.error('imap-mcp: IMAP_SEEN_KEYWORD must consist of letters, digits, $, _, . or -, ' +
|
|
242
|
+
`at most ${MAX_KEYWORD_LENGTH} of them (got ${describeValue(raw)})`);
|
|
185
243
|
process.exit(1);
|
|
186
244
|
}
|
|
187
245
|
return raw;
|
|
188
246
|
}
|
|
247
|
+
/**
|
|
248
|
+
* The attachment allowlist, one media type per entry.
|
|
249
|
+
*
|
|
250
|
+
* Every entry is answered back by `get_server_info` as `allowed_attachment_types`
|
|
251
|
+
* and compared against what messages declare. An entry that is not shaped like
|
|
252
|
+
* a media type can never match an attachment, so it is either a typo or a value
|
|
253
|
+
* meant for another variable — and in both cases the operator should hear
|
|
254
|
+
* about it at startup rather than read it in a tool result.
|
|
255
|
+
*/
|
|
189
256
|
function parseTypes(raw) {
|
|
190
257
|
if (raw === undefined || raw.trim() === '')
|
|
191
258
|
return DEFAULT_ATTACHMENT_TYPES;
|
|
192
|
-
|
|
259
|
+
const entries = raw
|
|
193
260
|
.split(',')
|
|
194
261
|
.map((t) => t.trim().toLowerCase())
|
|
195
262
|
.filter((t) => t !== '');
|
|
263
|
+
if (entries.length > MAX_ATTACHMENT_TYPES) {
|
|
264
|
+
console.error(`imap-mcp: IMAP_ATTACHMENT_TYPES lists ${entries.length} entries; at most ${MAX_ATTACHMENT_TYPES} are accepted`);
|
|
265
|
+
process.exit(1);
|
|
266
|
+
}
|
|
267
|
+
const bad = entries.findIndex((entry) => !isMediaType(entry));
|
|
268
|
+
if (bad >= 0) {
|
|
269
|
+
console.error(`imap-mcp: IMAP_ATTACHMENT_TYPES entry ${bad + 1} is not a media type ` +
|
|
270
|
+
`such as application/pdf (got ${describeValue(entries[bad])})`);
|
|
271
|
+
process.exit(1);
|
|
272
|
+
}
|
|
273
|
+
return entries;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* The download directory, resolved and checked before anything can print it.
|
|
277
|
+
*
|
|
278
|
+
* The path is answered by `get_server_info` and by every attachment listing,
|
|
279
|
+
* so it has to be a path — an existing directory, resolved through symlinks so
|
|
280
|
+
* that the containment check in `download.ts` compares against the place files
|
|
281
|
+
* really land. A value that is not a directory ends the process, and the
|
|
282
|
+
* message says how long it was, not what it said.
|
|
283
|
+
*/
|
|
284
|
+
function parseDownloadDir(raw) {
|
|
285
|
+
if (raw === undefined)
|
|
286
|
+
return undefined;
|
|
287
|
+
const trimmed = raw.trim();
|
|
288
|
+
if (trimmed === '')
|
|
289
|
+
return undefined;
|
|
290
|
+
let resolved;
|
|
291
|
+
try {
|
|
292
|
+
resolved = realpathSync(trimmed);
|
|
293
|
+
if (!statSync(resolved).isDirectory())
|
|
294
|
+
throw new Error('not a directory');
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
console.error('imap-mcp: IMAP_DOWNLOAD_DIR must name an existing directory ' +
|
|
298
|
+
`(got ${describeValue(trimmed)} that does not resolve to one). ` +
|
|
299
|
+
'Create it first, or unset the variable to keep this server off the filesystem.');
|
|
300
|
+
process.exit(1);
|
|
301
|
+
}
|
|
302
|
+
return resolved;
|
|
196
303
|
}
|
|
197
304
|
/**
|
|
198
305
|
* Rejects anything that could break out of the line it is written on. IMAP is a
|
|
@@ -203,11 +310,13 @@ function assertSafeHost(value, name) {
|
|
|
203
310
|
// A hostname or IPv4 address — or an IPv6 address, which is the only place
|
|
204
311
|
// a colon is legal. Allowing ":" everywhere would silently accept
|
|
205
312
|
// "imap.example.net:993", which the error message promises to reject.
|
|
313
|
+
// The length is checked first: nothing below walks a value longer than a
|
|
314
|
+
// hostname can be.
|
|
206
315
|
const hostname = /^[A-Za-z0-9._-]+$/.test(value);
|
|
207
316
|
const ipv6 = /^\[?[0-9A-Fa-f:.]*:[0-9A-Fa-f:.]*\]?$/.test(value);
|
|
208
|
-
if (!hostname && !ipv6) {
|
|
317
|
+
if (value.length > MAX_HOST_LENGTH || (!hostname && !ipv6)) {
|
|
209
318
|
console.error(`imap-mcp: ${name} must be a plain hostname or IP address without ` +
|
|
210
|
-
|
|
319
|
+
`scheme, port, credentials or whitespace (got ${describeValue(value)})`);
|
|
211
320
|
process.exit(1);
|
|
212
321
|
}
|
|
213
322
|
}
|
|
@@ -218,6 +327,22 @@ function assertSingleLine(value, name) {
|
|
|
218
327
|
process.exit(1);
|
|
219
328
|
}
|
|
220
329
|
}
|
|
330
|
+
/**
|
|
331
|
+
* The rule the `mailbox` tool parameter enforces, for a name that arrives
|
|
332
|
+
* through the environment instead: bounded, no control characters, no LIST
|
|
333
|
+
* wildcards. The value is answered by every listing tool and printed on the
|
|
334
|
+
* startup line, so it has to look like a folder before it is printed anywhere.
|
|
335
|
+
*/
|
|
336
|
+
function assertMailboxName(value, name) {
|
|
337
|
+
if (value.length > MAX_MAILBOX_LENGTH ||
|
|
338
|
+
CONTROL_CHARS.test(value) ||
|
|
339
|
+
/[%*]/.test(value)) {
|
|
340
|
+
console.error(`imap-mcp: ${name} must be a mailbox name of at most ${MAX_MAILBOX_LENGTH} ` +
|
|
341
|
+
'characters without control characters or the wildcards % and * ' +
|
|
342
|
+
`(got ${describeValue(value)})`);
|
|
343
|
+
process.exit(1);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
221
346
|
function isLoopbackHost(hostname) {
|
|
222
347
|
// URL.hostname keeps the brackets around an IPv6 literal, may carry a %zone
|
|
223
348
|
// suffix, and 'localhost.' with its root label is the same name as
|
|
@@ -225,11 +350,19 @@ function isLoopbackHost(hostname) {
|
|
|
225
350
|
// its bare '::1' branch could never match a hostname taken from a URL.
|
|
226
351
|
if (hostname === undefined)
|
|
227
352
|
return false;
|
|
228
|
-
|
|
353
|
+
let host = hostname
|
|
229
354
|
.toLowerCase()
|
|
230
355
|
.replace(/^\[|]$/g, '')
|
|
231
|
-
.replace(/%.*$/, '')
|
|
232
|
-
|
|
356
|
+
.replace(/%.*$/, '');
|
|
357
|
+
// Trailing root labels, walked from the end rather than matched with `\.+$`:
|
|
358
|
+
// that pattern is tried from every position of a run of dots and consumes
|
|
359
|
+
// the run each time, which is quadratic. The host is bounded to 253
|
|
360
|
+
// characters above, so this is a habit rather than a measured risk here —
|
|
361
|
+
// the same pattern on an unbounded value is the measured one.
|
|
362
|
+
let end = host.length;
|
|
363
|
+
while (end > 0 && host[end - 1] === '.')
|
|
364
|
+
end -= 1;
|
|
365
|
+
host = host.slice(0, end);
|
|
233
366
|
return (host === 'localhost' ||
|
|
234
367
|
host.endsWith('.localhost') ||
|
|
235
368
|
host.startsWith('127.') ||
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The extraction child: the only code in this server that parses a binary a
|
|
3
|
+
* stranger sent, and the reason it runs in a process of its own.
|
|
4
|
+
*
|
|
5
|
+
* A process rather than a worker thread, and that was a lesson rather than a
|
|
6
|
+
* preference. A worker's `resourceLimits` promise to turn a runaway parse into
|
|
7
|
+
* `ERR_WORKER_OUT_OF_MEMORY`, and for most allocation patterns they do; for
|
|
8
|
+
* the one a 3 kB spreadsheet produced they did not, and the whole server died
|
|
9
|
+
* with `FATAL ERROR: Reached heap limit`. A thread also cannot give back the
|
|
10
|
+
* memory a terminated parse left behind — a gigabyte stayed resident after
|
|
11
|
+
* `terminate()`. A process that is killed takes its memory with it, whatever
|
|
12
|
+
* it was doing, and a process that aborts aborts alone.
|
|
13
|
+
*
|
|
14
|
+
* Read the import rules before editing this file. It is started from its own
|
|
15
|
+
* source — `child.ts` under vitest, which Node runs with type stripping, and
|
|
16
|
+
* `child.js` from `dist/` in production — so Node loads it and everything it
|
|
17
|
+
* reaches, without vitest's resolver. Node strips types; it does not rewrite a
|
|
18
|
+
* `./x.js` specifier to the `./x.ts` sitting beside it. So:
|
|
19
|
+
*
|
|
20
|
+
* - static imports here may only be bare specifiers (`node:*`, a package) or
|
|
21
|
+
* `import type`, which is erased before it can fail to resolve;
|
|
22
|
+
* - anything relative is loaded through {@link sibling}, which picks the
|
|
23
|
+
* extension from this module's own;
|
|
24
|
+
* - no enums, no namespaces, no parameter properties — type stripping cannot
|
|
25
|
+
* erase syntax that emits code.
|
|
26
|
+
*
|
|
27
|
+
* Get this wrong in one direction and every test fails while the build stays
|
|
28
|
+
* green; get it wrong in the other and every test passes while the published
|
|
29
|
+
* package throws on first use. The second is why `npm run build` is followed by
|
|
30
|
+
* a real extraction against `dist/`.
|
|
31
|
+
*/
|
|
32
|
+
const EXTENSION = import.meta.url.endsWith('.ts') ? 'ts' : 'js';
|
|
33
|
+
function sibling(path) {
|
|
34
|
+
return new URL(`${path}.${EXTENSION}`, import.meta.url).href;
|
|
35
|
+
}
|
|
36
|
+
async function run(request) {
|
|
37
|
+
if (request.kind === 'pdf') {
|
|
38
|
+
const { extractPdf } = (await import(sibling('./pdf')));
|
|
39
|
+
return extractPdf(request.bytes, request.maxChars);
|
|
40
|
+
}
|
|
41
|
+
const [{ extractZipDocument }, { htmlToText }] = await Promise.all([
|
|
42
|
+
import(sibling('./ooxml')),
|
|
43
|
+
import(sibling('../analyze')),
|
|
44
|
+
]);
|
|
45
|
+
return extractZipDocument(request.kind, request.bytes, request.maxChars, htmlToText);
|
|
46
|
+
}
|
|
47
|
+
/** A code for the log, never a message: the message quotes the document. */
|
|
48
|
+
function codeOf(error) {
|
|
49
|
+
const value = error;
|
|
50
|
+
if (typeof value?.code === 'string')
|
|
51
|
+
return value.code;
|
|
52
|
+
if (typeof value?.name === 'string')
|
|
53
|
+
return value.name;
|
|
54
|
+
return 'unknown';
|
|
55
|
+
}
|
|
56
|
+
const send = process.send?.bind(process);
|
|
57
|
+
if (send !== undefined) {
|
|
58
|
+
// The parent is gone: nothing is waiting for an answer, and a parse that
|
|
59
|
+
// outlives the server it was started by is a parse nobody asked for.
|
|
60
|
+
process.on('disconnect', () => {
|
|
61
|
+
process.exit(0);
|
|
62
|
+
});
|
|
63
|
+
process.once('message', (request) => {
|
|
64
|
+
run(request).then((response) => {
|
|
65
|
+
send(response, () => {
|
|
66
|
+
process.exit(0);
|
|
67
|
+
});
|
|
68
|
+
}, (error) => {
|
|
69
|
+
// Never the caught error. pdf.js and fflate quote the document in
|
|
70
|
+
// their exception messages — byte offsets, object fragments, what they
|
|
71
|
+
// found where they expected something else. That is text a stranger
|
|
72
|
+
// wrote, and an error message is read as the server's own voice,
|
|
73
|
+
// outside the fence every other piece of message content passes
|
|
74
|
+
// through. A code crosses; the words are the host's.
|
|
75
|
+
console.error(`imap-mcp: extraction failed: ${codeOf(error)}`);
|
|
76
|
+
send({ ok: false, reason: 'internal' }, () => {
|
|
77
|
+
process.exit(0);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
export {};
|
|
83
|
+
//# sourceMappingURL=child.js.map
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ExtractKind, ExtractRequest, ExtractResponse } from './types.js';
|
|
2
|
+
export type { ExtractKind, ExtractReason, ExtractRequest, ExtractResponse, } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* How long a document may be parsed before the process doing it is killed.
|
|
5
|
+
*
|
|
6
|
+
* Generous next to the five seconds a regular expression gets in the sibling
|
|
7
|
+
* servers, because a hundred-page PDF is honest work — and finite, because a
|
|
8
|
+
* document that has not finished by now is not going to.
|
|
9
|
+
*/
|
|
10
|
+
export declare const EXTRACT_TIMEOUT_MS = 20000;
|
|
11
|
+
/**
|
|
12
|
+
* Characters the child may return, before any of the paging below.
|
|
13
|
+
*
|
|
14
|
+
* Not a context budget — that is `max_chars` at the tool, and it is much
|
|
15
|
+
* smaller. This is the ceiling on what is held in memory and paged through,
|
|
16
|
+
* and the child enforces it for every format: nothing larger is ever built
|
|
17
|
+
* there, let alone sent back.
|
|
18
|
+
*/
|
|
19
|
+
export declare const MAX_EXTRACT_CHARS = 1000000;
|
|
20
|
+
/** The same set as content types, for `get_server_info`. */
|
|
21
|
+
export declare const EXTRACTABLE_TYPES: string[];
|
|
22
|
+
/** Prose for the refusals, so every one of them names the same set. */
|
|
23
|
+
export declare const EXTRACTABLE_TYPE_NAMES: string;
|
|
24
|
+
export declare function extractKindOf(contentType: string): ExtractKind | undefined;
|
|
25
|
+
export declare function isExtractable(contentType: string): boolean;
|
|
26
|
+
/** The magic-byte verdict an honest container of this kind produces. */
|
|
27
|
+
export declare function expectedSignature(kind: ExtractKind): string;
|
|
28
|
+
export declare function extractDocumentText(request: ExtractRequest, limits?: ChildLimits): Promise<ExtractResponse>;
|
|
29
|
+
/**
|
|
30
|
+
* Narrowed by the tests, never by the server.
|
|
31
|
+
*
|
|
32
|
+
* The timeout and the memory ceiling are the two guards whose whole purpose is
|
|
33
|
+
* what happens when they fire, and neither can be reached in a test at its real
|
|
34
|
+
* value without a document engineered to spend twenty seconds or a quarter of a
|
|
35
|
+
* gigabyte. Making them arguments is what lets the failure paths be exercised
|
|
36
|
+
* in milliseconds; nothing in `src/` passes them.
|
|
37
|
+
*/
|
|
38
|
+
export interface ChildLimits {
|
|
39
|
+
timeoutMs?: number;
|
|
40
|
+
memoryMb?: number;
|
|
41
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { fork } from 'node:child_process';
|
|
2
|
+
import { once } from 'node:events';
|
|
3
|
+
/**
|
|
4
|
+
* How long a document may be parsed before the process doing it is killed.
|
|
5
|
+
*
|
|
6
|
+
* Generous next to the five seconds a regular expression gets in the sibling
|
|
7
|
+
* servers, because a hundred-page PDF is honest work — and finite, because a
|
|
8
|
+
* document that has not finished by now is not going to.
|
|
9
|
+
*/
|
|
10
|
+
export const EXTRACT_TIMEOUT_MS = 20_000;
|
|
11
|
+
/**
|
|
12
|
+
* Characters the child may return, before any of the paging below.
|
|
13
|
+
*
|
|
14
|
+
* Not a context budget — that is `max_chars` at the tool, and it is much
|
|
15
|
+
* smaller. This is the ceiling on what is held in memory and paged through,
|
|
16
|
+
* and the child enforces it for every format: nothing larger is ever built
|
|
17
|
+
* there, let alone sent back.
|
|
18
|
+
*/
|
|
19
|
+
export const MAX_EXTRACT_CHARS = 1_000_000;
|
|
20
|
+
/**
|
|
21
|
+
* V8 heap the parsing process may use.
|
|
22
|
+
*
|
|
23
|
+
* Best effort, and named as such. It bounds a pathological object graph, and
|
|
24
|
+
* when it fires the child aborts — on its own, which is the whole reason the
|
|
25
|
+
* parse is in a process. It does not bound typed arrays, which are external
|
|
26
|
+
* memory; the deflate pre-scan in `pdf.ts` and the entry caps in `ooxml.ts`
|
|
27
|
+
* are what cover those.
|
|
28
|
+
*/
|
|
29
|
+
const CHILD_MEMORY_MB = 256;
|
|
30
|
+
/**
|
|
31
|
+
* Requests admitted at once, running and waiting together.
|
|
32
|
+
*
|
|
33
|
+
* Extractions run one at a time (see {@link queue}), so a request that arrives
|
|
34
|
+
* behind seven others would wait up to seven timeouts for its turn, holding
|
|
35
|
+
* its mailbox lock throughout. Past this many it is refused outright, which is
|
|
36
|
+
* an answer the caller can act on.
|
|
37
|
+
*/
|
|
38
|
+
const MAX_IN_FLIGHT = 8;
|
|
39
|
+
/** Content types this server can read text out of, and what each one is. */
|
|
40
|
+
const EXTRACTABLE = new Map([
|
|
41
|
+
['application/pdf', 'pdf'],
|
|
42
|
+
[
|
|
43
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
44
|
+
'docx',
|
|
45
|
+
],
|
|
46
|
+
['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'xlsx'],
|
|
47
|
+
[
|
|
48
|
+
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
49
|
+
'pptx',
|
|
50
|
+
],
|
|
51
|
+
['application/vnd.oasis.opendocument.text', 'odt'],
|
|
52
|
+
['application/vnd.oasis.opendocument.spreadsheet', 'ods'],
|
|
53
|
+
]);
|
|
54
|
+
/** The same set as content types, for `get_server_info`. */
|
|
55
|
+
export const EXTRACTABLE_TYPES = [...EXTRACTABLE.keys()];
|
|
56
|
+
/** Prose for the refusals, so every one of them names the same set. */
|
|
57
|
+
export const EXTRACTABLE_TYPE_NAMES = 'PDF, Word (.docx), Excel (.xlsx), PowerPoint (.pptx) and OpenDocument text ' +
|
|
58
|
+
'and spreadsheets (.odt, .ods)';
|
|
59
|
+
export function extractKindOf(contentType) {
|
|
60
|
+
return EXTRACTABLE.get(contentType.toLowerCase());
|
|
61
|
+
}
|
|
62
|
+
export function isExtractable(contentType) {
|
|
63
|
+
return EXTRACTABLE.has(contentType.toLowerCase());
|
|
64
|
+
}
|
|
65
|
+
/** The magic-byte verdict an honest container of this kind produces. */
|
|
66
|
+
export function expectedSignature(kind) {
|
|
67
|
+
// Every OOXML and OpenDocument file is a zip, which is why `sniffContent`
|
|
68
|
+
// reports one for all five of them.
|
|
69
|
+
return kind === 'pdf' ? 'application/pdf' : 'application/zip';
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Serialises extractions.
|
|
73
|
+
*
|
|
74
|
+
* One process per call and no limit would mean N concurrent tool calls holding
|
|
75
|
+
* N processes of {@link CHILD_MEMORY_MB} each, which is a memory limit that
|
|
76
|
+
* multiplies by a number the caller chooses. The queue is the whole mechanism:
|
|
77
|
+
* the next extraction starts when the previous process is gone.
|
|
78
|
+
*/
|
|
79
|
+
let queue = Promise.resolve();
|
|
80
|
+
let inFlight = 0;
|
|
81
|
+
export async function extractDocumentText(request, limits = {}) {
|
|
82
|
+
if (inFlight >= MAX_IN_FLIGHT)
|
|
83
|
+
return { ok: false, reason: 'busy' };
|
|
84
|
+
inFlight += 1;
|
|
85
|
+
try {
|
|
86
|
+
const run = queue.then(() => runInChild(request, limits), () => runInChild(request, limits));
|
|
87
|
+
queue = run.catch(() => undefined);
|
|
88
|
+
return await run;
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
inFlight -= 1;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** A code for the log, never a message. */
|
|
95
|
+
function codeOf(error) {
|
|
96
|
+
const value = error;
|
|
97
|
+
if (typeof value?.code === 'string')
|
|
98
|
+
return value.code;
|
|
99
|
+
if (typeof value?.name === 'string')
|
|
100
|
+
return value.name;
|
|
101
|
+
return 'unknown';
|
|
102
|
+
}
|
|
103
|
+
async function runInChild(request, limits = {}) {
|
|
104
|
+
const timeoutMs = limits.timeoutMs ?? EXTRACT_TIMEOUT_MS;
|
|
105
|
+
const memoryMb = limits.memoryMb ?? CHILD_MEMORY_MB;
|
|
106
|
+
const child = fork(new URL(import.meta.url.endsWith('.ts') ? './child.ts' : './child.js', import.meta.url), [], {
|
|
107
|
+
// Stated rather than inherited. The parent's own flags may be ones a
|
|
108
|
+
// child cannot take — `--input-type` is one — and an inherited flag that
|
|
109
|
+
// fails to parse would turn every extraction into a silent `internal`.
|
|
110
|
+
execArgv: [`--max-old-space-size=${memoryMb}`],
|
|
111
|
+
// Not tidiness — correctness. The parent's stdout is this server's
|
|
112
|
+
// JSON-RPC transport, and pdf.js logs. One line from inside the parser
|
|
113
|
+
// would corrupt the framing and hang the session. Discarded; stderr is
|
|
114
|
+
// shared, because that is where every other diagnostic in this server
|
|
115
|
+
// already goes.
|
|
116
|
+
stdio: ['ignore', 'ignore', 'inherit', 'ipc'],
|
|
117
|
+
// Structured clone rather than JSON: the request carries the document as
|
|
118
|
+
// a typed array, and JSON would turn it into an array of numbers ten
|
|
119
|
+
// times its size.
|
|
120
|
+
serialization: 'advanced',
|
|
121
|
+
});
|
|
122
|
+
let timer;
|
|
123
|
+
try {
|
|
124
|
+
return await new Promise((resolve) => {
|
|
125
|
+
// The first answer wins. Everything after it — the exit of a child that
|
|
126
|
+
// was killed because it had already answered, most of all — is silence,
|
|
127
|
+
// not a second verdict.
|
|
128
|
+
let settled = false;
|
|
129
|
+
const settle = (value) => {
|
|
130
|
+
if (settled)
|
|
131
|
+
return;
|
|
132
|
+
settled = true;
|
|
133
|
+
resolve(value);
|
|
134
|
+
};
|
|
135
|
+
timer = setTimeout(() => {
|
|
136
|
+
settle({ ok: false, reason: 'timeout' });
|
|
137
|
+
}, timeoutMs);
|
|
138
|
+
child.once('message', (value) => {
|
|
139
|
+
settle(value);
|
|
140
|
+
});
|
|
141
|
+
child.once('error', (error) => {
|
|
142
|
+
if (settled)
|
|
143
|
+
return;
|
|
144
|
+
console.error(`imap-mcp: extraction process failed: ${codeOf(error)}`);
|
|
145
|
+
settle({ ok: false, reason: 'internal' });
|
|
146
|
+
});
|
|
147
|
+
child.once('exit', (code, signal) => {
|
|
148
|
+
// Only reached when the child left without answering. An abort is what
|
|
149
|
+
// V8 does when the heap limit is hit — and what the process does
|
|
150
|
+
// *instead of* taking the server with it, which is the property being
|
|
151
|
+
// bought here.
|
|
152
|
+
if (settled)
|
|
153
|
+
return;
|
|
154
|
+
if (signal === 'SIGABRT' || code === 134) {
|
|
155
|
+
settle({ ok: false, reason: 'out-of-memory' });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
console.error(`imap-mcp: extraction process exited early: ${signal ?? `code ${code}`}`);
|
|
159
|
+
settle({ ok: false, reason: 'internal' });
|
|
160
|
+
});
|
|
161
|
+
try {
|
|
162
|
+
child.send(request);
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
console.error(`imap-mcp: extraction request failed: ${codeOf(error)}`);
|
|
166
|
+
settle({ ok: false, reason: 'internal' });
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
if (timer)
|
|
172
|
+
clearTimeout(timer);
|
|
173
|
+
// Unconditional: on the timeout path the process is still inside the parse
|
|
174
|
+
// and will never exit on its own. SIGKILL, because a parser stuck in native
|
|
175
|
+
// code does not check for anything gentler — and because a process, unlike
|
|
176
|
+
// a thread, can be killed from outside whatever it is doing.
|
|
177
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
178
|
+
child.kill('SIGKILL');
|
|
179
|
+
await once(child, 'exit');
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ExtractKind, ExtractResponse } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Turns markup into readable text. Injected rather than imported.
|
|
4
|
+
*
|
|
5
|
+
* `htmlToText` lives in `../analyze.ts`, and this module is reached from the
|
|
6
|
+
* extraction child, which Node loads directly — where a `../analyze.js`
|
|
7
|
+
* specifier does not resolve to the `.ts` beside it. Passing the function in
|
|
8
|
+
* keeps this file free of relative value imports, which is the property that
|
|
9
|
+
* lets it run in the child at all. It also makes the seam explicit: the tests
|
|
10
|
+
* hand it the same `htmlToText` the child does.
|
|
11
|
+
*/
|
|
12
|
+
export type MarkupToText = (markup: string, maxChars: number) => string;
|
|
13
|
+
/**
|
|
14
|
+
* Reads the text of an OOXML or OpenDocument container.
|
|
15
|
+
*
|
|
16
|
+
* The whole defence lives in the `filter` callback below, and it is worth
|
|
17
|
+
* saying why that specific place. `unzipSync` inflates an entry into a buffer
|
|
18
|
+
* sized by the *declared* uncompressed size out of the central directory — a
|
|
19
|
+
* number the sender chose, checked against nothing. The filter is the last
|
|
20
|
+
* point before that allocation, and returning `false` there means the entry is
|
|
21
|
+
* never inflated and never sized.
|
|
22
|
+
*
|
|
23
|
+
* Measured on fflate 0.8.3: a declared size far past the real one does not blow
|
|
24
|
+
* up resident memory on Linux, because the allocation is virtual and untouched
|
|
25
|
+
* pages cost nothing; and a declared size *below* the real one truncates the
|
|
26
|
+
* output to what was declared, because fflate does not grow a caller-sized
|
|
27
|
+
* buffer. The guard stays regardless — it is free, it is the only thing
|
|
28
|
+
* standing between an *honest* high-ratio entry and its real expansion, and a
|
|
29
|
+
* host that does not overcommit would pay the full price.
|
|
30
|
+
*
|
|
31
|
+
* This never recurses. An entry that is itself an archive is not in the name
|
|
32
|
+
* allowlist, so a nested bomb is not descended into; that is a property to keep
|
|
33
|
+
* rather than an omission to fix.
|
|
34
|
+
*/
|
|
35
|
+
export declare function extractZipDocument(kind: Exclude<ExtractKind, 'pdf'>, bytes: Uint8Array, maxChars: number, toText: MarkupToText): Promise<ExtractResponse>;
|