blujay-mcp 0.0.0-stage → 1.0.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/files.js ADDED
@@ -0,0 +1,405 @@
1
+ // Reading a file the user named, for send_message. Only photos, videos, documents (PDF, Word,
2
+ // Excel, PowerPoint as .docx/.xlsx/.pptx), contact cards and audio (sent as a voice memo) are sent, judged by the
3
+ // file's bytes rather than its name, the same way Blujay checks an upload. Text files, code,
4
+ // keys, secrets, plain archives and programs are never sent.
5
+ import { createHash } from "node:crypto";
6
+ import { constants } from "node:fs";
7
+ import { open } from "node:fs/promises";
8
+ import { homedir } from "node:os";
9
+ import { basename, extname, isAbsolute, join } from "node:path";
10
+ import { inflateRawSync } from "node:zlib";
11
+ /** Blujay's limit for one attachment (`POST /v1/attachments`). */
12
+ export const MAX_FILE_BYTES = 100 * 1024 * 1024;
13
+ /** The file can't be sent; the message says why in plain words. */
14
+ export class FileRefusedError extends Error {
15
+ }
16
+ const ISO_IMAGE_BRANDS = new Set(["avif", "avis", "heic", "heix", "hevc", "hevx", "heim", "heis", "hevm", "hevs", "mif1", "msf1"]);
17
+ // Boxes that hold the track boxes, on the way to each track's handler.
18
+ const ISO_CONTAINERS = new Set(["moov", "trak", "mdia", "minf", "edts", "udta", "mvex", "moof", "traf"]);
19
+ function startsWith(bytes, prefix) {
20
+ return bytes.length >= prefix.length && prefix.every((byte, i) => bytes[i] === byte);
21
+ }
22
+ function ascii(bytes, start, end) {
23
+ return bytes.toString("latin1", start, end);
24
+ }
25
+ function readBox(bytes, offset, limit) {
26
+ if (offset + 8 > limit)
27
+ return null;
28
+ const size = bytes.readUInt32BE(offset);
29
+ const type = ascii(bytes, offset + 4, offset + 8);
30
+ let header = 8;
31
+ let end;
32
+ if (size === 1) {
33
+ if (offset + 16 > limit)
34
+ return null;
35
+ const large = bytes.readBigUInt64BE(offset + 8);
36
+ if (large > BigInt(limit - offset))
37
+ return null;
38
+ header = 16;
39
+ end = offset + Number(large);
40
+ }
41
+ else if (size === 0) {
42
+ end = limit;
43
+ }
44
+ else {
45
+ end = offset + size;
46
+ }
47
+ if (end < offset + header || end > limit)
48
+ return null;
49
+ return { type, start: offset + header, end };
50
+ }
51
+ /** Whether an MP4/MOV file has a video track: a track handler (`hdlr`) of type `vide`. */
52
+ function hasVideoTrack(bytes, start, limit, depth = 0) {
53
+ for (let offset = start; offset + 8 <= limit;) {
54
+ const box = readBox(bytes, offset, limit);
55
+ if (!box)
56
+ return false;
57
+ if (box.type === "hdlr" && box.start + 12 <= box.end && ascii(bytes, box.start + 8, box.start + 12) === "vide")
58
+ return true;
59
+ if (depth < 8 && ISO_CONTAINERS.has(box.type) && hasVideoTrack(bytes, box.start, box.end, depth + 1))
60
+ return true;
61
+ offset = box.end;
62
+ }
63
+ return false;
64
+ }
65
+ function detectIsoMedia(bytes) {
66
+ const ftyp = readBox(bytes, 0, bytes.length);
67
+ if (!ftyp || ftyp.type !== "ftyp" || ftyp.start + 8 > ftyp.end)
68
+ return null;
69
+ const brands = [ascii(bytes, ftyp.start, ftyp.start + 4)];
70
+ for (let offset = ftyp.start + 8; offset + 4 <= ftyp.end; offset += 4)
71
+ brands.push(ascii(bytes, offset, offset + 4));
72
+ if (brands.some((brand) => ISO_IMAGE_BRANDS.has(brand))) {
73
+ return brands.includes("avif")
74
+ ? { contentType: "image/avif", extension: ".avif", kind: "photo" }
75
+ : { contentType: "image/heic", extension: ".heic", kind: "photo" };
76
+ }
77
+ if (hasVideoTrack(bytes, ftyp.end, bytes.length)) {
78
+ return brands.includes("qt ")
79
+ ? { contentType: "video/quicktime", extension: ".mov", kind: "video" }
80
+ : { contentType: "video/mp4", extension: ".mp4", kind: "video" };
81
+ }
82
+ // An MPEG-4 audio file (major brand M4A, M4B or M4P), as Blujay recognises it.
83
+ if (["M4A ", "M4B ", "M4P "].includes(brands[0]))
84
+ return { contentType: "audio/mp4", extension: ".m4a", kind: "audio" };
85
+ return null;
86
+ }
87
+ /** A whole-file vCard: UTF-8 with no NUL bytes, from BEGIN:VCARD to END:VCARD. */
88
+ function isVcard(bytes) {
89
+ if (bytes.includes(0))
90
+ return false;
91
+ let text;
92
+ try {
93
+ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
94
+ }
95
+ catch {
96
+ return false;
97
+ }
98
+ return /^?\s*BEGIN:VCARD\r?\n/i.test(text) && /\r?\nEND:VCARD\s*$/i.test(text);
99
+ }
100
+ /**
101
+ * An MP3 that starts with a frame header rather than an ID3 tag: frame sync, a real MPEG version,
102
+ * Layer III, and valid bitrate and sample-rate indexes. A bare FF Ex check also matches the FF FE
103
+ * that starts UTF-16 text files, which would let a text file go out as a "voice memo".
104
+ */
105
+ function isMp3Frame(bytes) {
106
+ if (bytes.length < 4 || bytes[0] !== 0xff || (bytes[1] & 0xe0) !== 0xe0)
107
+ return false;
108
+ const version = (bytes[1] >> 3) & 0x03;
109
+ const layer = (bytes[1] >> 1) & 0x03;
110
+ const bitrate = bytes[2] >> 4;
111
+ const sampleRate = (bytes[2] >> 2) & 0x03;
112
+ return version !== 0x01 && layer === 0x01 && bitrate !== 0x00 && bitrate !== 0x0f && sampleRate !== 0x03;
113
+ }
114
+ /**
115
+ * Audio Blujay sends as a voice memo, by the same byte checks as its upload policy: MP3, AAC,
116
+ * WAV, CAF, FLAC and Ogg (M4A is found with the other MPEG-4 files). WebM is left out because
117
+ * the first bytes don't tell WebM audio from WebM video.
118
+ */
119
+ function detectAudio(bytes) {
120
+ if (bytes.length >= 3 && ascii(bytes, 0, 3) === "ID3")
121
+ return { contentType: "audio/mpeg", extension: ".mp3", kind: "audio" };
122
+ if (bytes.length >= 2 && bytes[0] === 0xff && (bytes[1] & 0xf6) === 0xf0)
123
+ return { contentType: "audio/aac", extension: ".aac", kind: "audio" };
124
+ if (isMp3Frame(bytes))
125
+ return { contentType: "audio/mpeg", extension: ".mp3", kind: "audio" };
126
+ if (bytes.length >= 12 && ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 12) === "WAVE")
127
+ return { contentType: "audio/wav", extension: ".wav", kind: "audio" };
128
+ if (bytes.length >= 4 && ascii(bytes, 0, 4) === "caff")
129
+ return { contentType: "audio/x-caf", extension: ".caf", kind: "audio" };
130
+ if (bytes.length >= 4 && ascii(bytes, 0, 4) === "fLaC")
131
+ return { contentType: "audio/flac", extension: ".flac", kind: "audio" };
132
+ if (bytes.length >= 4 && ascii(bytes, 0, 4) === "OggS")
133
+ return { contentType: "audio/ogg", extension: ".ogg", kind: "audio" };
134
+ return null;
135
+ }
136
+ const OOXML_PARTS = [
137
+ ["word/document.xml", { contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", extension: ".docx", kind: "document" }],
138
+ ["xl/workbook.xml", { contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", extension: ".xlsx", kind: "document" }],
139
+ ["ppt/presentation.xml", { contentType: "application/vnd.openxmlformats-officedocument.presentationml.presentation", extension: ".pptx", kind: "document" }],
140
+ ];
141
+ const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
142
+ let c = n;
143
+ for (let k = 0; k < 8; k++)
144
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
145
+ return c >>> 0;
146
+ });
147
+ /** The CRC-32 a zip records for each entry. */
148
+ export function crc32(bytes) {
149
+ let crc = 0xffffffff;
150
+ for (const byte of bytes)
151
+ crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
152
+ return (crc ^ 0xffffffff) >>> 0;
153
+ }
154
+ /** Uncompressed size a document's parts may add up to; a zip that inflates past it is refused. */
155
+ const MAX_UNZIPPED_BYTES = 300 * 1024 * 1024;
156
+ /**
157
+ * The entry names of a zip, or null unless the zip is complete and self-consistent: one end
158
+ * record that finishes the file, a central directory that ends where that record starts and lists
159
+ * exactly the entries it says, and local entries that fill every byte before the directory with
160
+ * the same names. So no file can hide in the zip where another zip tool would still find it.
161
+ */
162
+ function zipEntryNames(bytes) {
163
+ let end = -1;
164
+ for (let at = bytes.length - 22; at >= Math.max(0, bytes.length - 22 - 0xffff); at--) {
165
+ if (bytes.readUInt32LE(at) === 0x06054b50 && at + 22 + bytes.readUInt16LE(at + 20) === bytes.length) {
166
+ end = at;
167
+ break;
168
+ }
169
+ }
170
+ if (end < 0)
171
+ return null;
172
+ const count = bytes.readUInt16LE(end + 10);
173
+ const directorySize = bytes.readUInt32LE(end + 12);
174
+ const directoryOffset = bytes.readUInt32LE(end + 16);
175
+ if (bytes.readUInt16LE(end + 4) !== 0 || bytes.readUInt16LE(end + 6) !== 0 || bytes.readUInt16LE(end + 8) !== count)
176
+ return null;
177
+ if (count === 0 || count === 0xffff || directoryOffset + directorySize !== end)
178
+ return null;
179
+ const entries = [];
180
+ let offset = directoryOffset;
181
+ while (offset < end) {
182
+ if (offset + 46 > end || bytes.readUInt32LE(offset) !== 0x02014b50)
183
+ return null;
184
+ const flags = bytes.readUInt16LE(offset + 8);
185
+ const method = bytes.readUInt16LE(offset + 10);
186
+ const crc = bytes.readUInt32LE(offset + 16);
187
+ const size = bytes.readUInt32LE(offset + 20);
188
+ const unzipped = bytes.readUInt32LE(offset + 24);
189
+ const nameLength = bytes.readUInt16LE(offset + 28);
190
+ const next = offset + 46 + nameLength + bytes.readUInt16LE(offset + 30) + bytes.readUInt16LE(offset + 32);
191
+ const local = bytes.readUInt32LE(offset + 42);
192
+ if (next > end || size === 0xffffffff || unzipped === 0xffffffff || local === 0xffffffff)
193
+ return null;
194
+ // Stored or deflated only, never encrypted, and a stored entry can't defer its sizes to a data descriptor.
195
+ if ((method !== 0 && method !== 8) || flags & 0x01 || (method === 0 && flags & 0x08))
196
+ return null;
197
+ entries.push({ name: bytes.toString("utf8", offset + 46, offset + 46 + nameLength), local, size, unzipped, crc, method, flags });
198
+ offset = next;
199
+ }
200
+ if (offset !== end || entries.length !== count)
201
+ return null;
202
+ if (entries.reduce((total, entry) => total + entry.unzipped, 0) > MAX_UNZIPPED_BYTES)
203
+ return null;
204
+ // Local entries must follow one another from the first byte to the directory, each with the
205
+ // same name, sizes and checksum as its directory record, and data that really is that long:
206
+ // a stored entry's checksum must match, and a deflated entry must inflate to exactly its size
207
+ // and checksum, ending exactly where the next entry starts. So what a streaming unzip tool
208
+ // reads is what the directory says.
209
+ let position = 0;
210
+ for (const entry of [...entries].sort((a, b) => a.local - b.local)) {
211
+ if (entry.local !== position || position + 30 > directoryOffset || bytes.readUInt32LE(position) !== 0x04034b50)
212
+ return null;
213
+ if (bytes.readUInt16LE(position + 6) !== entry.flags || bytes.readUInt16LE(position + 8) !== entry.method)
214
+ return null;
215
+ const nameLength = bytes.readUInt16LE(position + 26);
216
+ const extraLength = bytes.readUInt16LE(position + 28);
217
+ if (bytes.toString("utf8", position + 30, position + 30 + nameLength) !== entry.name)
218
+ return null;
219
+ const recorded = { crc: bytes.readUInt32LE(position + 14), size: bytes.readUInt32LE(position + 18), unzipped: bytes.readUInt32LE(position + 22) };
220
+ const dataStart = position + 30 + nameLength + extraLength;
221
+ const dataEnd = dataStart + entry.size;
222
+ if (dataEnd > directoryOffset)
223
+ return null;
224
+ const data = bytes.subarray(dataStart, dataEnd);
225
+ let unzipped;
226
+ try {
227
+ if (entry.method === 0) {
228
+ unzipped = data;
229
+ }
230
+ else {
231
+ const result = inflateRawSync(data, { info: true, maxOutputLength: Math.max(1, entry.unzipped) });
232
+ if (result.engine.bytesWritten !== entry.size)
233
+ return null;
234
+ unzipped = result.buffer;
235
+ }
236
+ }
237
+ catch {
238
+ return null;
239
+ }
240
+ if (unzipped.length !== entry.unzipped || crc32(unzipped) !== entry.crc)
241
+ return null;
242
+ position = dataEnd;
243
+ if (entry.flags & 0x08) {
244
+ // The sizes follow the data in a descriptor (with or without its signature); they must match too.
245
+ if (position + 4 <= directoryOffset && bytes.readUInt32LE(position) === 0x08074b50)
246
+ position += 4;
247
+ if (position + 12 > directoryOffset)
248
+ return null;
249
+ const described = { crc: bytes.readUInt32LE(position), size: bytes.readUInt32LE(position + 4), unzipped: bytes.readUInt32LE(position + 8) };
250
+ if (described.crc !== entry.crc || described.size !== entry.size || described.unzipped !== entry.unzipped)
251
+ return null;
252
+ position += 12;
253
+ }
254
+ else if (recorded.crc !== entry.crc || recorded.size !== entry.size || recorded.unzipped !== entry.unzipped) {
255
+ return null;
256
+ }
257
+ }
258
+ return position === directoryOffset ? entries.map((entry) => entry.name) : null;
259
+ }
260
+ const OFFICE_FOLDERS = ["word/", "xl/", "ppt/", "docProps/", "_rels/", "customXml/", "docMetadata/", "customUI/", "[trash]/"];
261
+ // Parts an Office package holds: XML and relationships, pictures, fonts, embedded media and objects.
262
+ const OFFICE_PART_EXTENSIONS = new Set([
263
+ ".xml", ".rels", ".vml", ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tif", ".tiff", ".emf", ".wmf", ".svg", ".wdp",
264
+ ".odttf", ".fntdata", ".bin", ".mp4", ".m4a", ".mp3", ".wav", ".docx", ".xlsx", ".pptx",
265
+ ]);
266
+ /** A part of a Word, Excel or PowerPoint package: in an Office folder, with an Office part's extension, never macros or a path that climbs out. */
267
+ function isOfficePart(name) {
268
+ if (name === "[Content_Types].xml")
269
+ return true;
270
+ if (name.includes("..") || name.includes("\\") || name.startsWith("/") || /(^|\/)vbaProject\.bin$/i.test(name))
271
+ return false;
272
+ const dot = name.lastIndexOf(".");
273
+ const extension = dot > name.lastIndexOf("/") ? name.slice(dot).toLowerCase() : "";
274
+ return OFFICE_FOLDERS.some((folder) => name.startsWith(folder)) && OFFICE_PART_EXTENSIONS.has(extension);
275
+ }
276
+ /**
277
+ * A Word, Excel or PowerPoint file (.docx, .xlsx, .pptx). These are zips, so the zip must be
278
+ * complete and hold the package's content-type list, its main part and only Office parts: a zip
279
+ * of anything else renamed .docx is refused. Legacy .doc/.xls/.ppt compound files aren't sent:
280
+ * telling them from other compound files (saved emails, installers) safely needs a full parser.
281
+ */
282
+ function detectOfficeDocument(bytes) {
283
+ if (startsWith(bytes, [0x50, 0x4b, 0x03, 0x04])) {
284
+ const names = zipEntryNames(bytes);
285
+ if (!names || !names.includes("[Content_Types].xml"))
286
+ return null;
287
+ const match = OOXML_PARTS.find(([part]) => names.includes(part));
288
+ if (!match)
289
+ return null;
290
+ return names.every(isOfficePart) ? match[1] : null;
291
+ }
292
+ return null;
293
+ }
294
+ /** A PDF starts with %PDF- and ends with an %%EOF marker, with nothing but whitespace after it. */
295
+ function isPdf(bytes) {
296
+ return bytes.length >= 12 && ascii(bytes, 0, 5) === "%PDF-" && /%%EOF[\s\0]*$/.test(ascii(bytes, Math.max(0, bytes.length - 1024), bytes.length));
297
+ }
298
+ /** What a file is from its bytes, or null when it isn't a photo, a video, a document, a contact card or audio. */
299
+ export function detectFileType(bytes) {
300
+ if (startsWith(bytes, [0xff, 0xd8, 0xff]))
301
+ return { contentType: "image/jpeg", extension: ".jpg", kind: "photo" };
302
+ if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
303
+ return { contentType: "image/png", extension: ".png", kind: "photo" };
304
+ if (bytes.length >= 6 && ["GIF87a", "GIF89a"].includes(ascii(bytes, 0, 6)))
305
+ return { contentType: "image/gif", extension: ".gif", kind: "photo" };
306
+ if (bytes.length >= 12 && ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 12) === "WEBP")
307
+ return { contentType: "image/webp", extension: ".webp", kind: "photo" };
308
+ const iso = detectIsoMedia(bytes);
309
+ if (iso)
310
+ return iso;
311
+ const audio = detectAudio(bytes);
312
+ if (audio)
313
+ return audio;
314
+ if (isPdf(bytes))
315
+ return { contentType: "application/pdf", extension: ".pdf", kind: "document" };
316
+ const office = detectOfficeDocument(bytes);
317
+ if (office)
318
+ return office;
319
+ if (isVcard(bytes))
320
+ return { contentType: "text/vcard", extension: ".vcf", kind: "contact card" };
321
+ return null;
322
+ }
323
+ /** `~/Desktop/a.jpg` or `/Users/ava/Desktop/a.jpg`. A relative path depends on where the server started, so it's refused. */
324
+ export function resolveUserPath(path) {
325
+ const trimmed = path.trim();
326
+ if (trimmed === "~" || trimmed.startsWith("~/"))
327
+ return join(homedir(), trimmed.slice(1));
328
+ if (!isAbsolute(trimmed)) {
329
+ throw new FileRefusedError("Not sent: give the file's full path, like ~/Desktop/photo.jpg or /Users/you/Desktop/photo.jpg.");
330
+ }
331
+ return trimmed;
332
+ }
333
+ /** The recipient sees this name: the file's own name with the extension that matches its contents. */
334
+ function sendName(path, extension) {
335
+ const stem = basename(path, extname(path)).replace(/[\u0000-\u001f\u007f/\\]/g, "").trim().slice(0, 200);
336
+ return `${stem || "attachment"}${extension}`;
337
+ }
338
+ function unreadable(resolved, error) {
339
+ const code = error.code;
340
+ if (code === "ENOENT" || code === "ENOTDIR")
341
+ return new FileRefusedError(`Not sent: there's no file at ${resolved}.`);
342
+ if (code === "EACCES" || code === "EPERM")
343
+ return new FileRefusedError(`Not sent: this server isn't allowed to read ${resolved}.`);
344
+ return new FileRefusedError(`Not sent: couldn't read ${resolved} (${code ?? "unknown error"}).`);
345
+ }
346
+ /**
347
+ * Reads exactly `size` bytes from an open file. If it holds fewer or more than when it was
348
+ * checked, it changed while being read, and it's refused rather than sent half-written.
349
+ */
350
+ export async function readExactly(handle, size, resolved) {
351
+ const bytes = Buffer.alloc(size);
352
+ let filled = 0;
353
+ while (filled < size) {
354
+ const { bytesRead } = await handle.read(bytes, filled, size - filled, filled);
355
+ if (bytesRead === 0)
356
+ break;
357
+ filled += bytesRead;
358
+ }
359
+ const { bytesRead: extra } = await handle.read(Buffer.alloc(1), 0, 1, size);
360
+ if (filled !== size || extra !== 0)
361
+ throw new FileRefusedError(`Not sent: ${resolved} changed while it was being read. Try again once it's saved.`);
362
+ return bytes;
363
+ }
364
+ /** Reads a file to send, refusing anything that isn't a readable photo, video, document, contact card or audio file within the limit. */
365
+ export async function readSendableFile(path) {
366
+ const resolved = resolveUserPath(path);
367
+ // Open once and check what was opened, so the path can't be swapped between the check and the
368
+ // read. O_NONBLOCK keeps a named pipe from blocking the open; it's refused as "not a file".
369
+ let handle;
370
+ try {
371
+ handle = await open(resolved, constants.O_RDONLY | constants.O_NONBLOCK);
372
+ }
373
+ catch (error) {
374
+ throw unreadable(resolved, error);
375
+ }
376
+ let bytes;
377
+ try {
378
+ const info = await handle.stat();
379
+ if (!info.isFile())
380
+ throw new FileRefusedError(`Not sent: ${resolved} is not a file.`);
381
+ if (info.size === 0)
382
+ throw new FileRefusedError(`Not sent: ${resolved} is empty.`);
383
+ if (info.size > MAX_FILE_BYTES) {
384
+ throw new FileRefusedError("Not sent: the file is larger than 100 MiB, Blujay's limit for one attachment.");
385
+ }
386
+ bytes = await readExactly(handle, info.size, resolved);
387
+ }
388
+ catch (error) {
389
+ throw error instanceof FileRefusedError ? error : unreadable(resolved, error);
390
+ }
391
+ finally {
392
+ await handle.close().catch(() => undefined);
393
+ }
394
+ const type = detectFileType(bytes);
395
+ if (!type) {
396
+ throw new FileRefusedError(`Not sent: send_message can only send a photo, a video, a document (PDF, Word, Excel or PowerPoint), a contact card (vCard) or an audio file (as a voice memo), and ${basename(resolved)} is none of those.`);
397
+ }
398
+ return {
399
+ bytes,
400
+ kind: type.kind,
401
+ contentType: type.contentType,
402
+ filename: sendName(resolved, type.extension),
403
+ sha256: createHash("sha256").update(bytes).digest("hex"),
404
+ };
405
+ }
@@ -0,0 +1,11 @@
1
+ import type { Blujay } from "blujay";
2
+ export declare function when(iso: string | null | undefined): string;
3
+ /** The readable content of a message: its text, plus a note for each attachment. */
4
+ export declare function partsText(parts: unknown[] | undefined): string;
5
+ /** "Ava Chen (+15555550123)", or just the address when there's no name. */
6
+ export declare function personLabel(participant: Blujay.ChatParticipant | undefined): string;
7
+ export declare function chatTitle(chat: Blujay.Chat): string;
8
+ export declare function chatLine(chat: Blujay.Chat): string;
9
+ export declare function messageStatus(message: Blujay.Message): string;
10
+ /** A conversation as a transcript, oldest first. */
11
+ export declare function transcript(chat: Blujay.Chat, messages: Blujay.Message[]): string;
package/dist/format.js ADDED
@@ -0,0 +1,96 @@
1
+ export function when(iso) {
2
+ if (!iso)
3
+ return "unknown time";
4
+ const date = new Date(iso);
5
+ if (Number.isNaN(date.getTime()))
6
+ return iso;
7
+ return date.toLocaleString("en-US", {
8
+ month: "short",
9
+ day: "numeric",
10
+ hour: "numeric",
11
+ minute: "2-digit",
12
+ timeZoneName: "short",
13
+ });
14
+ }
15
+ /** The readable content of a message: its text, plus a note for each attachment. */
16
+ export function partsText(parts) {
17
+ const pieces = [];
18
+ for (const raw of parts ?? []) {
19
+ const part = raw;
20
+ switch (part.type) {
21
+ case "text":
22
+ if (typeof part.text === "string")
23
+ pieces.push(part.text);
24
+ break;
25
+ case "link":
26
+ pieces.push(String(part.value ?? part.url ?? "[link]"));
27
+ break;
28
+ case "audio":
29
+ pieces.push("[voice memo]");
30
+ break;
31
+ case "contact_card":
32
+ pieces.push("[contact card]");
33
+ break;
34
+ case "media": {
35
+ const name = typeof part.filename === "string" ? `: ${part.filename}` : "";
36
+ const kind = typeof part.content_type === "string" && part.content_type.startsWith("image/") ? "photo" : "file";
37
+ pieces.push(`[${kind}${name}]`);
38
+ break;
39
+ }
40
+ default:
41
+ pieces.push(`[${part.type ?? "attachment"}]`);
42
+ }
43
+ }
44
+ return pieces.join(" ").trim() || "[no text]";
45
+ }
46
+ /** "Ava Chen (+15555550123)", or just the address when there's no name. */
47
+ export function personLabel(participant) {
48
+ if (!participant)
49
+ return "unknown";
50
+ return participant.display_name ? `${participant.display_name} (${participant.address})` : participant.address;
51
+ }
52
+ export function chatTitle(chat) {
53
+ if (chat.display_name)
54
+ return chat.display_name;
55
+ const people = (chat.participants ?? []).map(personLabel);
56
+ if (chat.type === "group")
57
+ return people.length ? `Group with ${people.join(", ")}` : "Unnamed group";
58
+ return people[0] ?? "Unknown contact";
59
+ }
60
+ export function chatLine(chat) {
61
+ const service = chat.service ? `, ${chat.service}` : "";
62
+ return `- ${chatTitle(chat)} — ${chat.type} chat${service}, last activity ${when(chat.updated_at)} (chat_id: ${chat.chat_id})`;
63
+ }
64
+ function senderLabel(message, people) {
65
+ if (message.direction === "outbound")
66
+ return "You";
67
+ if (message.sender)
68
+ return people.get(message.sender.address) ?? message.sender.address;
69
+ return people.values().next().value ?? "Them";
70
+ }
71
+ export function messageStatus(message) {
72
+ if (message.unsent_at)
73
+ return "unsent";
74
+ if (message.status === "failed") {
75
+ return `failed${message.failure?.reason ? `: ${message.failure.reason}` : ""}`;
76
+ }
77
+ return String(message.status);
78
+ }
79
+ /** A conversation as a transcript, oldest first. */
80
+ export function transcript(chat, messages) {
81
+ const people = new Map();
82
+ for (const participant of chat.participants ?? [])
83
+ people.set(participant.address, personLabel(participant));
84
+ const lines = [...messages]
85
+ .sort((a, b) => a.created_at.localeCompare(b.created_at))
86
+ .map((message) => {
87
+ const status = message.direction === "outbound" ? ` (${messageStatus(message)})` : "";
88
+ const edited = message.edited_at ? " (edited)" : "";
89
+ const text = message.unsent_at ? "[unsent]" : partsText(message.parts);
90
+ return `[${when(message.created_at)}] ${senderLabel(message, people)}: ${text}${edited}${status} (message_id: ${message.message_id})`;
91
+ });
92
+ return [
93
+ `${chatTitle(chat)} — ${chat.type} chat${chat.service ? ` over ${chat.service}` : ""} (chat_id: ${chat.chat_id})`,
94
+ lines.length ? lines.join("\n") : "No messages yet.",
95
+ ].join("\n");
96
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+ // blujay-mcp: run Blujay as an MCP server over stdio.
3
+ import { parseArgs } from "node:util";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { BlujayClient } from "blujay";
6
+ import { createBlujayMcpServer, VERSION } from "./server.js";
7
+ const HELP = `blujay-mcp ${VERSION}: Blujay for Claude, Cursor and other MCP clients.
8
+
9
+ Usage: npx -y blujay-mcp [options]
10
+
11
+ Environment:
12
+ BLUJAY_API_KEY Your API key, from API in the Blujay sidebar (required)
13
+
14
+ Options:
15
+ --read-only Only reading tools: nothing can be sent, changed or deleted
16
+ --allow-send-to <numbers> Only text, react to or otherwise reach these numbers
17
+ (comma-separated), for safe testing
18
+ --enable-invoke-api Add invoke_api, which can call any Blujay endpoint
19
+ --help, --version
20
+ `;
21
+ function main() {
22
+ let parsed;
23
+ try {
24
+ parsed = parseArgs({
25
+ options: {
26
+ "read-only": { type: "boolean", default: false },
27
+ "allow-send-to": { type: "string" },
28
+ "enable-invoke-api": { type: "boolean", default: false },
29
+ help: { type: "boolean", default: false },
30
+ version: { type: "boolean", default: false },
31
+ },
32
+ strict: true,
33
+ });
34
+ }
35
+ catch (error) {
36
+ process.stderr.write(`blujay-mcp: ${error instanceof Error ? error.message : String(error)}\n\n${HELP}`);
37
+ process.exit(1);
38
+ }
39
+ const { values } = parsed;
40
+ if (values.help) {
41
+ process.stdout.write(HELP);
42
+ return;
43
+ }
44
+ if (values.version) {
45
+ process.stdout.write(`${VERSION}\n`);
46
+ return;
47
+ }
48
+ const apiKey = process.env.BLUJAY_API_KEY;
49
+ if (!apiKey) {
50
+ process.stderr.write("blujay-mcp: set BLUJAY_API_KEY to your API key, from API in the Blujay sidebar.\n");
51
+ process.exit(1);
52
+ }
53
+ const allowSendTo = values["allow-send-to"]
54
+ ?.split(",")
55
+ .map((value) => value.trim())
56
+ .filter(Boolean);
57
+ if (allowSendTo && allowSendTo.length === 0) {
58
+ // An empty value (often an unset variable) must never turn the limit off.
59
+ process.stderr.write("blujay-mcp: --allow-send-to is empty. List at least one number, or remove the option.\n");
60
+ process.exit(1);
61
+ }
62
+ const server = createBlujayMcpServer({
63
+ client: new BlujayClient({
64
+ apiKey,
65
+ ...(process.env.BLUJAY_BASE_URL ? { baseUrl: process.env.BLUJAY_BASE_URL } : {}),
66
+ }),
67
+ readOnly: values["read-only"],
68
+ allowSendTo: allowSendTo ?? null,
69
+ enableInvokeApi: values["enable-invoke-api"],
70
+ });
71
+ // stdout carries the MCP protocol; anything else goes to stderr.
72
+ server.connect(new StdioServerTransport()).catch((error) => {
73
+ process.stderr.write(`blujay-mcp: ${error instanceof Error ? error.message : String(error)}\n`);
74
+ process.exit(1);
75
+ });
76
+ }
77
+ main();
@@ -0,0 +1,25 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { BlujayClient } from "blujay";
3
+ /** This package's version, from package.json (one level above dist/ and src/). */
4
+ export declare const VERSION: string;
5
+ export interface BlujayMcpOptions {
6
+ client: BlujayClient;
7
+ /** Only reading tools: nothing that sends, changes or deletes. */
8
+ readOnly?: boolean;
9
+ /** When set, anything that reaches a person (texts, reactions, typing, group changes) is limited to these numbers. */
10
+ allowSendTo?: string[] | null;
11
+ /** Adds invoke_api, which can call any endpoint. */
12
+ enableInvokeApi?: boolean;
13
+ /** For tests. */
14
+ fetchFn?: typeof fetch;
15
+ /** For tests: the fetch that PUTs a file to its upload URL. */
16
+ uploadFetchFn?: typeof fetch;
17
+ }
18
+ export declare function isSingleEmoji(value: string): boolean;
19
+ /**
20
+ * US numbers to +1XXXXXXXXXX and emails lowercased, the way Blujay compares them. A "+" followed by
21
+ * a country code other than 1 is not a US number and is left as typed, so it never matches one.
22
+ */
23
+ export declare function normalizeAddress(value: string): string;
24
+ export declare function createBlujayMcpServer(options: BlujayMcpOptions): McpServer;
25
+ export { BlujayClient };