@vitalsend/mcp 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1515 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ // src/index.js
5
+ import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
6
+
7
+ // src/server.js
8
+ import { McpServer } from "@modelcontextprotocol/server";
9
+ import { z } from "zod";
10
+
11
+ // src/send.js
12
+ import { openAsBlob } from "node:fs";
13
+ import { basename } from "node:path";
14
+ import { randomBytes as randomBytes4 } from "node:crypto";
15
+
16
+ // src/crypto.js
17
+ import { randomInt } from "node:crypto";
18
+
19
+ // vendor/crypto/encrypt.js
20
+ import sodium3 from "libsodium-wrappers-sumo";
21
+
22
+ // vendor/crypto/format.js
23
+ import sodium from "libsodium-wrappers-sumo";
24
+ var enc = new TextEncoder();
25
+ var dec = new TextDecoder();
26
+ var HEADER_MAGIC = "E2EEFILE";
27
+ var HEADER_VERSION = 3;
28
+ var HEADER_MAC_BYTES = 32;
29
+ var PASSWORD_MIN_LENGTH = 10;
30
+ var MAX_HEADER_BYTES = 128 * 1024;
31
+ var MAX_METADATA_BYTES = 32 * 1024;
32
+ var MAX_CHUNK_SIZE = 64 * 1024 * 1024;
33
+ var DEFAULT_CHUNK_SIZE = 32 * 1024 * 1024;
34
+ var KDF_CONTEXT_CHUNK = "vitalsend:chunk:v3";
35
+ var KDF_CONTEXT_META = "vitalsend:meta:v3";
36
+ var KDF_CONTEXT_HEADER = "vitalsend:header:v3";
37
+ function u8concat(...chunks) {
38
+ const len = chunks.reduce((a, c) => a + c.length, 0);
39
+ const out = new Uint8Array(len);
40
+ let off = 0;
41
+ for (const c of chunks) {
42
+ out.set(c, off);
43
+ off += c.length;
44
+ }
45
+ return out;
46
+ }
47
+ function u32be(n) {
48
+ const b = new Uint8Array(4);
49
+ b[0] = n >>> 24 & 255;
50
+ b[1] = n >>> 16 & 255;
51
+ b[2] = n >>> 8 & 255;
52
+ b[3] = n & 255;
53
+ return b;
54
+ }
55
+ function u64be(n) {
56
+ const out = new Uint8Array(8);
57
+ const dv = new DataView(out.buffer);
58
+ dv.setBigUint64(0, BigInt(n), false);
59
+ return out;
60
+ }
61
+ function readU32be(u8, off) {
62
+ return (u8[off] << 24 | u8[off + 1] << 16 | u8[off + 2] << 8 | u8[off + 3]) >>> 0;
63
+ }
64
+ function readU64be(u8, off) {
65
+ const dv = new DataView(u8.buffer, u8.byteOffset + off, 8);
66
+ return Number(dv.getBigUint64(0, false));
67
+ }
68
+ function ensureAvailable(u8, off, needed, label) {
69
+ if (off + needed > u8.length) {
70
+ throw new Error(`Truncated header while reading ${label}.`);
71
+ }
72
+ }
73
+ function deriveSubkey(rootKey, context) {
74
+ return sodium.crypto_generichash(32, context, rootKey);
75
+ }
76
+ function parseHeaderV3(headerBytes) {
77
+ if (headerBytes.length > MAX_HEADER_BYTES) throw new Error("Header too large.");
78
+ let off = 0;
79
+ ensureAvailable(headerBytes, off, 8, "magic");
80
+ const magic = dec.decode(headerBytes.slice(off, off + 8));
81
+ off += 8;
82
+ if (magic !== HEADER_MAGIC) throw new Error("Bad magic");
83
+ ensureAvailable(headerBytes, off, 1, "version");
84
+ const ver = headerBytes[off++];
85
+ if (ver !== HEADER_VERSION) throw new Error("Unsupported version");
86
+ ensureAvailable(headerBytes, off, 4, "chunk size");
87
+ const chunkSize = readU32be(headerBytes, off);
88
+ off += 4;
89
+ if (chunkSize < 1 || chunkSize > MAX_CHUNK_SIZE) throw new Error("Invalid chunk size");
90
+ ensureAvailable(headerBytes, off, 1, "flags");
91
+ const flags = headerBytes[off++];
92
+ const hasPwhash = (flags & 1) !== 0;
93
+ ensureAvailable(headerBytes, off, 8, "total chunks");
94
+ const totalChunks = readU64be(headerBytes, off);
95
+ off += 8;
96
+ if (totalChunks < 1) throw new Error("Invalid total chunks");
97
+ ensureAvailable(headerBytes, off, 16, "file nonce");
98
+ const fileNonce = headerBytes.slice(off, off + 16);
99
+ off += 16;
100
+ ensureAvailable(headerBytes, off, 4, "pwhash length");
101
+ const pwhashLen = readU32be(headerBytes, off);
102
+ off += 4;
103
+ ensureAvailable(headerBytes, off, pwhashLen, "pwhash block");
104
+ const pwhashBlock = headerBytes.slice(off, off + pwhashLen);
105
+ off += pwhashLen;
106
+ ensureAvailable(headerBytes, off, sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES, "metadata nonce");
107
+ const metaNonce = headerBytes.slice(off, off + sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
108
+ off += sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES;
109
+ ensureAvailable(headerBytes, off, 4, "metadata length");
110
+ const metaLen = readU32be(headerBytes, off);
111
+ off += 4;
112
+ if (metaLen < sodium.crypto_aead_xchacha20poly1305_ietf_ABYTES || metaLen > MAX_METADATA_BYTES) {
113
+ throw new Error("Invalid metadata length");
114
+ }
115
+ ensureAvailable(headerBytes, off, metaLen, "metadata cipher");
116
+ const metaCipher = headerBytes.slice(off, off + metaLen);
117
+ off += metaLen;
118
+ ensureAvailable(headerBytes, off, HEADER_MAC_BYTES, "header MAC");
119
+ const headerMac = headerBytes.slice(off, off + HEADER_MAC_BYTES);
120
+ off += HEADER_MAC_BYTES;
121
+ if (off !== headerBytes.length) throw new Error("Unexpected header size");
122
+ return {
123
+ chunkSize,
124
+ totalChunks,
125
+ hasPwhash,
126
+ fileNonce,
127
+ pwhashLen,
128
+ pwhashBlock,
129
+ metaNonce,
130
+ metaCipher,
131
+ headerMac,
132
+ headerBody: headerBytes.slice(0, headerBytes.length - HEADER_MAC_BYTES)
133
+ };
134
+ }
135
+ function chunkNonce(fileNonce, index) {
136
+ return u8concat(fileNonce, u64be(index));
137
+ }
138
+ function sealChunk(chunkKey, fileNonce, index, plain) {
139
+ return sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(
140
+ plain,
141
+ u64be(index),
142
+ null,
143
+ chunkNonce(fileNonce, index),
144
+ chunkKey
145
+ );
146
+ }
147
+ function buildHeader({ chunkSize, hasPassword, totalChunks, fileNonce, pwhashBlock, metaNonce, metaCipher, headerMacKey }) {
148
+ const headerBody = u8concat(
149
+ enc.encode(HEADER_MAGIC),
150
+ new Uint8Array([HEADER_VERSION]),
151
+ u32be(chunkSize),
152
+ new Uint8Array([hasPassword ? 1 : 0]),
153
+ u64be(totalChunks),
154
+ fileNonce,
155
+ u32be(pwhashBlock.length),
156
+ pwhashBlock,
157
+ metaNonce,
158
+ u32be(metaCipher.length),
159
+ metaCipher
160
+ );
161
+ const headerMac = sodium.crypto_generichash(HEADER_MAC_BYTES, headerBody, headerMacKey);
162
+ return u8concat(headerBody, headerMac);
163
+ }
164
+ function verifyHeaderMac(parsed, headerMacKey) {
165
+ const expected = sodium.crypto_generichash(HEADER_MAC_BYTES, parsed.headerBody, headerMacKey);
166
+ return sodium.memcmp(parsed.headerMac, expected);
167
+ }
168
+
169
+ // vendor/crypto/kdf.js
170
+ import sodium2 from "libsodium-wrappers-sumo";
171
+ var PASSWORD_VERIFIER_CONTEXT = "vitalsend:password-verifier:v1";
172
+ function normalizePassword(password) {
173
+ if (password == null) return null;
174
+ return String(password).trim().toUpperCase();
175
+ }
176
+ function assertPassword(password) {
177
+ if (typeof password !== "string" || password.length < PASSWORD_MIN_LENGTH) {
178
+ throw new Error(`Password must be at least ${PASSWORD_MIN_LENGTH} characters.`);
179
+ }
180
+ }
181
+ function buildPasswordHashBlock(password, linkKeyBytes) {
182
+ if (password == null) {
183
+ return { pwhashBlock: new Uint8Array(0), rootKey: linkKeyBytes };
184
+ }
185
+ const normalized = normalizePassword(password);
186
+ assertPassword(normalized);
187
+ const salt = sodium2.randombytes_buf(sodium2.crypto_pwhash_SALTBYTES);
188
+ const ops = sodium2.crypto_pwhash_OPSLIMIT_MODERATE;
189
+ const mem = sodium2.crypto_pwhash_MEMLIMIT_MODERATE;
190
+ const passwordKey = sodium2.crypto_pwhash(32, normalized, salt, ops, mem, sodium2.crypto_pwhash_ALG_DEFAULT);
191
+ const rootKey = sodium2.crypto_generichash(32, linkKeyBytes, passwordKey);
192
+ sodium2.memzero(passwordKey);
193
+ return { pwhashBlock: u8concat(salt, u64be(ops), u64be(mem)), rootKey };
194
+ }
195
+ function deriveRootKey(linkKeyBytes, password, pwhashBlock) {
196
+ if (!pwhashBlock || pwhashBlock.length === 0) {
197
+ return linkKeyBytes;
198
+ }
199
+ if (password == null) throw new Error("Password required to decrypt");
200
+ const normalized = normalizePassword(password);
201
+ assertPassword(normalized);
202
+ if (pwhashBlock.length !== sodium2.crypto_pwhash_SALTBYTES + 16) {
203
+ throw new Error("Invalid pwhash block");
204
+ }
205
+ const salt = pwhashBlock.slice(0, sodium2.crypto_pwhash_SALTBYTES);
206
+ const ops = readU64be(pwhashBlock, sodium2.crypto_pwhash_SALTBYTES);
207
+ const mem = readU64be(pwhashBlock, sodium2.crypto_pwhash_SALTBYTES + 8);
208
+ if (ops < 1 || ops > sodium2.crypto_pwhash_OPSLIMIT_MODERATE || mem < 1 || mem > sodium2.crypto_pwhash_MEMLIMIT_MODERATE) {
209
+ throw new Error("Invalid password parameters");
210
+ }
211
+ const passwordKey = sodium2.crypto_pwhash(32, normalized, salt, ops, mem, sodium2.crypto_pwhash_ALG_DEFAULT);
212
+ const rootKey = sodium2.crypto_generichash(32, linkKeyBytes, passwordKey);
213
+ sodium2.memzero(passwordKey);
214
+ return rootKey;
215
+ }
216
+ async function derivePasswordVerifier(linkKeyBytes, password) {
217
+ await sodium2.ready;
218
+ const normalized = normalizePassword(password);
219
+ assertPassword(normalized);
220
+ const salt = sodium2.crypto_generichash(sodium2.crypto_pwhash_SALTBYTES, PASSWORD_VERIFIER_CONTEXT, linkKeyBytes);
221
+ const verifier = sodium2.crypto_pwhash(
222
+ 32,
223
+ normalized,
224
+ salt,
225
+ sodium2.crypto_pwhash_OPSLIMIT_MODERATE,
226
+ sodium2.crypto_pwhash_MEMLIMIT_MODERATE,
227
+ sodium2.crypto_pwhash_ALG_DEFAULT
228
+ );
229
+ const encoded = sodium2.to_base64(verifier, sodium2.base64_variants.URLSAFE_NO_PADDING);
230
+ sodium2.memzero(verifier);
231
+ return encoded;
232
+ }
233
+
234
+ // vendor/crypto/encrypt.js
235
+ var enc2 = new TextEncoder();
236
+ var dec2 = new TextDecoder();
237
+ var nowMs = () => typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
238
+ function normalizeResumeOffset(resumeFromBytes, headerLength, chunkStride) {
239
+ const offset = Number(resumeFromBytes || 0);
240
+ if (!Number.isFinite(offset) || offset <= 0) {
241
+ return { resumeChunkIndex: 0, shouldUploadHeader: true };
242
+ }
243
+ if (offset < headerLength) {
244
+ return { resumeChunkIndex: 0, shouldUploadHeader: true };
245
+ }
246
+ const remaining = offset - headerLength;
247
+ const alignedRemaining = Math.floor(remaining / chunkStride) * chunkStride;
248
+ const resumeChunkIndex = Math.floor(alignedRemaining / chunkStride);
249
+ return {
250
+ resumeChunkIndex,
251
+ shouldUploadHeader: resumeChunkIndex === 0
252
+ };
253
+ }
254
+ function verifyResumeMetadata(parsed, metaKey, file) {
255
+ const metaPlain = sodium3.crypto_aead_xchacha20poly1305_ietf_decrypt(
256
+ null,
257
+ parsed.metaCipher,
258
+ parsed.fileNonce,
259
+ parsed.metaNonce,
260
+ metaKey
261
+ );
262
+ const meta = JSON.parse(dec2.decode(metaPlain));
263
+ if (Number(meta?.size || 0) !== Number(file.size || 0)) {
264
+ throw new Error("Resume data mismatch.");
265
+ }
266
+ if (meta?.name && file.name && meta.name !== file.name) {
267
+ throw new Error("Resume data mismatch.");
268
+ }
269
+ const metaMtime = meta?.lastModified ?? null;
270
+ const fileMtime = file?.lastModified ?? null;
271
+ if (metaMtime !== null && fileMtime !== null && Number(metaMtime) !== Number(fileMtime)) {
272
+ throw new Error("Resume data mismatch.");
273
+ }
274
+ }
275
+ function buildEncryptionState({ file, linkKeyBytes, password, chunkSize, totalChunks, resumeHeaderBytes }) {
276
+ if (resumeHeaderBytes) {
277
+ const headerBytes2 = resumeHeaderBytes instanceof Uint8Array ? resumeHeaderBytes : new Uint8Array(resumeHeaderBytes || []);
278
+ let parsed;
279
+ try {
280
+ parsed = parseHeaderV3(headerBytes2);
281
+ } catch {
282
+ throw new Error("Resume data mismatch.");
283
+ }
284
+ if (parsed.chunkSize !== chunkSize || parsed.totalChunks !== totalChunks) {
285
+ throw new Error("Resume data mismatch.");
286
+ }
287
+ const hasPassword = parsed.hasPwhash;
288
+ if (hasPassword !== (password != null)) {
289
+ throw new Error("Resume data mismatch.");
290
+ }
291
+ let rootKey2;
292
+ try {
293
+ rootKey2 = deriveRootKey(linkKeyBytes, password, parsed.pwhashBlock);
294
+ } catch {
295
+ throw new Error("Resume data mismatch.");
296
+ }
297
+ const chunkKey2 = deriveSubkey(rootKey2, KDF_CONTEXT_CHUNK);
298
+ const metaKey2 = deriveSubkey(rootKey2, KDF_CONTEXT_META);
299
+ const headerMacKey2 = deriveSubkey(rootKey2, KDF_CONTEXT_HEADER);
300
+ if (!verifyHeaderMac(parsed, headerMacKey2)) {
301
+ throw new Error("Resume data mismatch.");
302
+ }
303
+ verifyResumeMetadata(parsed, metaKey2, file);
304
+ return { headerBytes: headerBytes2, fileNonce: parsed.fileNonce, rootKey: rootKey2, chunkKey: chunkKey2, metaKey: metaKey2, headerMacKey: headerMacKey2 };
305
+ }
306
+ const { pwhashBlock, rootKey } = buildPasswordHashBlock(password, linkKeyBytes);
307
+ const chunkKey = deriveSubkey(rootKey, KDF_CONTEXT_CHUNK);
308
+ const metaKey = deriveSubkey(rootKey, KDF_CONTEXT_META);
309
+ const headerMacKey = deriveSubkey(rootKey, KDF_CONTEXT_HEADER);
310
+ const meta = {
311
+ name: file.name,
312
+ size: file.size,
313
+ type: file.type || "application/octet-stream",
314
+ lastModified: file.lastModified ?? null
315
+ };
316
+ const metaBytes = enc2.encode(JSON.stringify(meta));
317
+ if (metaBytes.length + sodium3.crypto_aead_xchacha20poly1305_ietf_ABYTES > MAX_METADATA_BYTES) {
318
+ throw new Error("Metadata too large.");
319
+ }
320
+ const metaNonce = sodium3.randombytes_buf(sodium3.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
321
+ const fileNonce = sodium3.randombytes_buf(16);
322
+ const metaCipher = sodium3.crypto_aead_xchacha20poly1305_ietf_encrypt(
323
+ metaBytes,
324
+ fileNonce,
325
+ null,
326
+ metaNonce,
327
+ metaKey
328
+ );
329
+ const headerBytes = buildHeader({
330
+ chunkSize,
331
+ hasPassword: password != null,
332
+ totalChunks,
333
+ fileNonce,
334
+ pwhashBlock,
335
+ metaNonce,
336
+ metaCipher,
337
+ headerMacKey
338
+ });
339
+ return { headerBytes, fileNonce, rootKey, chunkKey, metaKey, headerMacKey };
340
+ }
341
+ async function encryptAndUpload({
342
+ file,
343
+ // File
344
+ uploadPart,
345
+ // async (partIndex:number, bytes:Uint8Array) => void
346
+ uploadHeader,
347
+ // async (bytes:Uint8Array) => void
348
+ linkKey = null,
349
+ // Uint8Array(32) or null to generate (shared in URL fragment)
350
+ outOfBandFileKey = null,
351
+ // legacy alias for linkKey
352
+ password = null,
353
+ // optional string (second factor)
354
+ chunkSize = DEFAULT_CHUNK_SIZE,
355
+ padToChunkBoundary = false,
356
+ // optional tail padding (disabled by default)
357
+ onProgress = null,
358
+ // optional (percent:number) => void
359
+ resumeFromBytes = 0,
360
+ // optional ciphertext offset to resume from
361
+ resumeHeaderBytes = null,
362
+ // encrypted header from original upload when resuming
363
+ onHeader = null,
364
+ // optional (bytes:Uint8Array) => void
365
+ debug = false
366
+ }) {
367
+ await sodium3.ready;
368
+ if (!Number.isInteger(chunkSize) || chunkSize < 1 || chunkSize > MAX_CHUNK_SIZE) {
369
+ throw new Error("Invalid chunk size.");
370
+ }
371
+ const totalBytes = Number(file.size || 0);
372
+ const totalChunks = totalBytes === 0 ? 1 : Math.ceil(totalBytes / chunkSize);
373
+ const providedKey = linkKey ?? outOfBandFileKey;
374
+ const linkKeyBytes = providedKey ? new Uint8Array(providedKey) : sodium3.randombytes_buf(
375
+ sodium3.crypto_secretstream_xchacha20poly1305_KEYBYTES
376
+ );
377
+ const shareKey = new Uint8Array(linkKeyBytes);
378
+ const {
379
+ headerBytes,
380
+ fileNonce,
381
+ rootKey,
382
+ chunkKey,
383
+ metaKey,
384
+ headerMacKey
385
+ } = buildEncryptionState({ file, linkKeyBytes, password, chunkSize, totalChunks, resumeHeaderBytes });
386
+ if (typeof onHeader === "function") {
387
+ onHeader(headerBytes);
388
+ }
389
+ const cipherOverhead = sodium3.crypto_aead_xchacha20poly1305_ietf_ABYTES;
390
+ const chunkStride = chunkSize + cipherOverhead;
391
+ const { resumeChunkIndex, shouldUploadHeader } = normalizeResumeOffset(
392
+ resumeFromBytes,
393
+ headerBytes.length,
394
+ chunkStride
395
+ );
396
+ if (shouldUploadHeader) {
397
+ await uploadHeader(headerBytes, 0);
398
+ }
399
+ const reader = file.stream().getReader();
400
+ let buf = new Uint8Array(0);
401
+ let part = 0;
402
+ let processedBytes = Math.min(totalBytes, resumeChunkIndex * chunkSize);
403
+ const reportProgress = () => {
404
+ if (typeof onProgress !== "function") return;
405
+ if (!totalBytes) {
406
+ onProgress(100);
407
+ return;
408
+ }
409
+ const percent = Math.min(100, Math.floor(processedBytes / totalBytes * 100));
410
+ onProgress(percent);
411
+ };
412
+ const encryptChunk = async (plain, index) => sealChunk(chunkKey, fileNonce, index, plain);
413
+ const partOffset = (index) => headerBytes.length + index * chunkStride;
414
+ const pipelineStats = debug ? { chunks: 0, encryptMs: 0, uploadMs: 0 } : null;
415
+ while (true) {
416
+ const { value, done } = await reader.read();
417
+ if (done) break;
418
+ const chunk = new Uint8Array(value);
419
+ buf = buf.length ? u8concat(buf, chunk) : chunk;
420
+ while (buf.length >= chunkSize) {
421
+ const plain = buf.slice(0, chunkSize);
422
+ buf = buf.slice(chunkSize);
423
+ const encryptStart = debug ? nowMs() : 0;
424
+ const ct = await encryptChunk(plain, part);
425
+ const encryptMs = debug ? nowMs() - encryptStart : 0;
426
+ if (part >= resumeChunkIndex) {
427
+ const uploadStart = debug ? nowMs() : 0;
428
+ await uploadPart(part, ct, partOffset(part));
429
+ const uploadMs = debug ? nowMs() - uploadStart : 0;
430
+ if (pipelineStats) {
431
+ pipelineStats.chunks += 1;
432
+ pipelineStats.encryptMs += encryptMs;
433
+ pipelineStats.uploadMs += uploadMs;
434
+ console.info("[vitalsend] upload chunk", part, "encryptMs", Math.round(encryptMs), "uploadMs", Math.round(uploadMs));
435
+ }
436
+ }
437
+ part += 1;
438
+ processedBytes += plain.length;
439
+ reportProgress();
440
+ }
441
+ }
442
+ if (totalBytes === 0) {
443
+ const encryptStart = debug ? nowMs() : 0;
444
+ const ct = await encryptChunk(new Uint8Array(0), part);
445
+ const encryptMs = debug ? nowMs() - encryptStart : 0;
446
+ if (part >= resumeChunkIndex) {
447
+ const uploadStart = debug ? nowMs() : 0;
448
+ await uploadPart(part, ct, partOffset(part));
449
+ const uploadMs = debug ? nowMs() - uploadStart : 0;
450
+ if (pipelineStats) {
451
+ pipelineStats.chunks += 1;
452
+ pipelineStats.encryptMs += encryptMs;
453
+ pipelineStats.uploadMs += uploadMs;
454
+ console.info("[vitalsend] upload chunk", part, "encryptMs", Math.round(encryptMs), "uploadMs", Math.round(uploadMs));
455
+ }
456
+ }
457
+ part += 1;
458
+ reportProgress();
459
+ } else if (buf.length > 0) {
460
+ let finalPlain = buf;
461
+ if (padToChunkBoundary && finalPlain.length < chunkSize) {
462
+ const padLen = chunkSize - finalPlain.length;
463
+ const pad = new Uint8Array(padLen);
464
+ pad.fill(padLen);
465
+ finalPlain = u8concat(finalPlain, pad);
466
+ }
467
+ const encryptStart = debug ? nowMs() : 0;
468
+ const ct = await encryptChunk(finalPlain, part);
469
+ const encryptMs = debug ? nowMs() - encryptStart : 0;
470
+ if (part >= resumeChunkIndex) {
471
+ const uploadStart = debug ? nowMs() : 0;
472
+ await uploadPart(part, ct, partOffset(part));
473
+ const uploadMs = debug ? nowMs() - uploadStart : 0;
474
+ if (pipelineStats) {
475
+ pipelineStats.chunks += 1;
476
+ pipelineStats.encryptMs += encryptMs;
477
+ pipelineStats.uploadMs += uploadMs;
478
+ console.info("[vitalsend] upload chunk", part, "encryptMs", Math.round(encryptMs), "uploadMs", Math.round(uploadMs));
479
+ }
480
+ }
481
+ part += 1;
482
+ processedBytes += buf.length;
483
+ reportProgress();
484
+ }
485
+ if (pipelineStats && pipelineStats.chunks > 0) {
486
+ console.info(
487
+ "[vitalsend] upload pipeline totals",
488
+ "chunks",
489
+ pipelineStats.chunks,
490
+ "encryptMs",
491
+ Math.round(pipelineStats.encryptMs),
492
+ "uploadMs",
493
+ Math.round(pipelineStats.uploadMs)
494
+ );
495
+ }
496
+ sodium3.memzero(chunkKey);
497
+ sodium3.memzero(metaKey);
498
+ sodium3.memzero(headerMacKey);
499
+ sodium3.memzero(rootKey);
500
+ sodium3.memzero(linkKeyBytes);
501
+ return shareKey;
502
+ }
503
+
504
+ // vendor/crypto/decrypt.js
505
+ import sodium4 from "libsodium-wrappers-sumo";
506
+ var dec3 = new TextDecoder();
507
+
508
+ // vendor/crypto/link-key.js
509
+ function toBase64(bytes) {
510
+ if (typeof Buffer !== "undefined") {
511
+ return Buffer.from(bytes).toString("base64");
512
+ }
513
+ let binary = "";
514
+ for (const byte of bytes) {
515
+ binary += String.fromCharCode(byte);
516
+ }
517
+ return btoa(binary);
518
+ }
519
+ function toBase64Url(value) {
520
+ return value.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
521
+ }
522
+ function encodeLinkKey(bytes) {
523
+ if (!(bytes instanceof Uint8Array) || bytes.length !== 32) {
524
+ throw new Error("Link key must be 32 bytes.");
525
+ }
526
+ return toBase64Url(toBase64(bytes));
527
+ }
528
+ function buildShareLink(baseLink, encodedKey, requiresPassword) {
529
+ if (!baseLink) return "";
530
+ if (!encodedKey) return baseLink;
531
+ const base = String(baseLink).split("#")[0];
532
+ const params = new URLSearchParams();
533
+ params.set("k", encodedKey);
534
+ if (requiresPassword) params.set("p", "1");
535
+ return `${base}#${params.toString()}`;
536
+ }
537
+
538
+ // src/crypto.js
539
+ var PASSWORD_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
540
+ var PASSWORD_LENGTH = 10;
541
+ function generatePassword() {
542
+ let out = "";
543
+ for (let i = 0; i < PASSWORD_LENGTH; i += 1) {
544
+ out += PASSWORD_ALPHABET[randomInt(PASSWORD_ALPHABET.length)];
545
+ }
546
+ return out;
547
+ }
548
+
549
+ // src/paths.js
550
+ import { realpath, stat } from "node:fs/promises";
551
+ import { isAbsolute, relative, resolve, sep } from "node:path";
552
+ import { homedir } from "node:os";
553
+ import { fileURLToPath } from "node:url";
554
+ var PathNotAllowed = class extends Error {
555
+ constructor() {
556
+ super("That file is outside the folders this tool may read, or is not a file.");
557
+ this.name = "PathNotAllowed";
558
+ }
559
+ };
560
+ var RootRequired = class extends Error {
561
+ constructor() {
562
+ super("This app does not tell the tool which folder you work in. Set VITALSEND_ROOT to that folder in the tool's MCP configuration.");
563
+ this.name = "RootRequired";
564
+ }
565
+ };
566
+ var inside = (child, parent) => {
567
+ const rel = relative(parent, child);
568
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel) && rel.split(sep)[0] !== "..";
569
+ };
570
+ async function realOrNull(p) {
571
+ try {
572
+ return await realpath(p);
573
+ } catch (_err) {
574
+ return null;
575
+ }
576
+ }
577
+ async function resolveAllowedFile(path, { roots, cwd, envRoot = "", home = homedir() }) {
578
+ let rootDirs;
579
+ if (roots && roots.length) {
580
+ rootDirs = roots.map((r) => String(r).startsWith("file:") ? fileURLToPath(r) : String(r));
581
+ } else if (envRoot) {
582
+ rootDirs = [envRoot];
583
+ } else {
584
+ const start = resolve(cwd);
585
+ if (start === resolve("/") || start === resolve(home)) throw new RootRequired();
586
+ rootDirs = [start];
587
+ }
588
+ const realRoots = (await Promise.all(rootDirs.map(realOrNull))).filter(Boolean);
589
+ const base = realRoots[0] || cwd;
590
+ const real = await realOrNull(resolve(base, String(path || "")));
591
+ if (!real || !realRoots.some((root) => inside(real, root))) throw new PathNotAllowed();
592
+ const info = await stat(real).catch(() => null);
593
+ if (!info?.isFile()) throw new PathNotAllowed();
594
+ return real;
595
+ }
596
+
597
+ // src/api.js
598
+ var CLIENT_ID = "vitalsend-mcp";
599
+ var API_KEY_GRANT = "urn:vitalsend:params:oauth:grant-type:api-key";
600
+ var FLOW = "/api/v1/managedsend";
601
+ var STEP_TOKEN_SKEW_MS = 3e4;
602
+ var PART_ATTEMPTS = 3;
603
+ var DEFAULT_CHUNK_SIZE2 = 8 * 1024 * 1024;
604
+ var MESSAGES = {
605
+ credits_insufficient: "Not enough credits.",
606
+ recipient_not_allowed: "This API key may not send to that recipient.",
607
+ invalid_grant: "The VitalSend login is no longer valid.",
608
+ unauthorized: "VitalSend refused the request.",
609
+ network: "VitalSend could not be reached.",
610
+ server: "VitalSend could not complete the request."
611
+ };
612
+ var ApiError = class extends Error {
613
+ constructor(code, status = 0) {
614
+ super(MESSAGES[code] || MESSAGES.server);
615
+ this.name = "ApiError";
616
+ this.code = code;
617
+ this.status = status;
618
+ }
619
+ };
620
+ async function send(fetchImpl, url, init) {
621
+ try {
622
+ return await fetchImpl(url, { ...init, redirect: "error" });
623
+ } catch (_err) {
624
+ throw new ApiError("network");
625
+ }
626
+ }
627
+ async function readJSON(res) {
628
+ try {
629
+ return await res.json();
630
+ } catch (_err) {
631
+ return {};
632
+ }
633
+ }
634
+ async function failure(res) {
635
+ const data = await readJSON(res);
636
+ if (data?.code === "credits_insufficient" || data?.code === "recipient_not_allowed") {
637
+ return new ApiError(data.code, res.status);
638
+ }
639
+ if (data?.error === "invalid_grant") return new ApiError("invalid_grant", res.status);
640
+ if (res.status === 401 || res.status === 403) return new ApiError("unauthorized", res.status);
641
+ return new ApiError("server", res.status);
642
+ }
643
+ function createTokenClient({ baseUrl: baseUrl2, fetchImpl = fetch }) {
644
+ const token = async (form) => {
645
+ const res = await send(fetchImpl, `${baseUrl2}/api/v1/oauth/token`, {
646
+ method: "POST",
647
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
648
+ body: new URLSearchParams(form).toString()
649
+ });
650
+ if (!res.ok) throw await failure(res);
651
+ const data = await readJSON(res);
652
+ return {
653
+ accessToken: data.access_token || "",
654
+ expiresIn: Number(data.expires_in || 0),
655
+ refreshToken: data.refresh_token || ""
656
+ };
657
+ };
658
+ return {
659
+ exchangeCode: ({ code, verifier, redirectUri }) => token({
660
+ grant_type: "authorization_code",
661
+ code,
662
+ code_verifier: verifier,
663
+ redirect_uri: redirectUri,
664
+ client_id: CLIENT_ID
665
+ }),
666
+ refresh: (refreshToken) => token({ grant_type: "refresh_token", refresh_token: refreshToken }),
667
+ exchangeAPIKey: (apiKey) => token({ grant_type: API_KEY_GRANT, api_key: apiKey })
668
+ };
669
+ }
670
+ function jwtExpiresAt(token) {
671
+ try {
672
+ const payload = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString());
673
+ return Number(payload.exp || 0) * 1e3;
674
+ } catch (_err) {
675
+ return 0;
676
+ }
677
+ }
678
+ function createSendSession({ baseUrl: baseUrl2, accessToken, fetchImpl = fetch }) {
679
+ const cookies = /* @__PURE__ */ new Map([["vitalsend_jwt", accessToken]]);
680
+ let stepToken = "";
681
+ let fileId = "";
682
+ let uploadId = "";
683
+ const cookieHeader = () => [...cookies].map(([k, v]) => `${k}=${v}`).join("; ");
684
+ const keepCookies = (res) => {
685
+ for (const line of res.headers.getSetCookie?.() || []) {
686
+ const [pair] = line.split(";");
687
+ const eq = pair.indexOf("=");
688
+ if (eq > 0) cookies.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
689
+ }
690
+ };
691
+ const request = async (path, { body, headers = {}, raw = false } = {}) => {
692
+ const res = await send(fetchImpl, `${baseUrl2}${FLOW}${path}`, {
693
+ method: "POST",
694
+ headers: {
695
+ Cookie: cookieHeader(),
696
+ ...stepToken ? { Authorization: `Bearer ${stepToken}` } : {},
697
+ ...raw ? {} : { "Content-Type": "application/json" },
698
+ ...headers
699
+ },
700
+ body: raw ? body : JSON.stringify(body ?? {})
701
+ });
702
+ keepCookies(res);
703
+ return res;
704
+ };
705
+ const refreshStep = async () => {
706
+ const res = await request("/prepare/refresh", { body: { file_id: fileId } });
707
+ if (!res.ok) throw await failure(res);
708
+ const data = await readJSON(res);
709
+ if (data.step_token) stepToken = data.step_token;
710
+ };
711
+ const call = async (path, body) => {
712
+ if (fileId && stepToken && jwtExpiresAt(stepToken) - Date.now() < STEP_TOKEN_SKEW_MS && jwtExpiresAt(stepToken) > 0) {
713
+ await refreshStep();
714
+ }
715
+ let res = await request(path, { body });
716
+ if (res.status === 401 && fileId) {
717
+ await refreshStep();
718
+ res = await request(path, { body });
719
+ }
720
+ if (!res.ok) throw await failure(res);
721
+ const data = await readJSON(res);
722
+ if (data.step_token) stepToken = data.step_token;
723
+ return data;
724
+ };
725
+ const postPart = async (bytes, offset, chunkIndex) => {
726
+ for (let attempt = 0; attempt < PART_ATTEMPTS; attempt += 1) {
727
+ const res = await request("/prepare/part", {
728
+ raw: true,
729
+ body: bytes,
730
+ headers: {
731
+ "Content-Type": "application/octet-stream",
732
+ "X-Vitalsend-Upload-Id": uploadId,
733
+ "X-Vitalsend-Upload-Offset": String(offset),
734
+ ...Number.isInteger(chunkIndex) ? { "X-Vitalsend-Chunk-Index": String(chunkIndex) } : {}
735
+ }
736
+ });
737
+ if (res.ok) return;
738
+ if (res.status === 401) {
739
+ await refreshStep();
740
+ continue;
741
+ }
742
+ if (res.status === 429 && attempt < PART_ATTEMPTS - 1) {
743
+ await new Promise((resolve2) => setTimeout(resolve2, 1e3));
744
+ continue;
745
+ }
746
+ throw await failure(res);
747
+ }
748
+ throw new ApiError("server");
749
+ };
750
+ return {
751
+ async init() {
752
+ stepToken = "";
753
+ await call("/init");
754
+ },
755
+ // The recipient lets a connection's allowlist refuse before anything is spent.
756
+ async quote({ sizeBytes, password, recipient = "" }) {
757
+ const data = await call("/quote", { file_size_bytes: sizeBytes, extra_password_enabled: !!password, recipient_email: recipient });
758
+ return { creditsNeeded: Number(data.credits_needed || 0) };
759
+ },
760
+ async reserve({ name, type, sizeBytes, passwordVerifier }) {
761
+ const data = await call("/reserve", {
762
+ filename: name,
763
+ file_type: type || "",
764
+ file_size_bytes: sizeBytes,
765
+ extra_password: passwordVerifier || ""
766
+ });
767
+ fileId = data.file_id || "";
768
+ return { expiresAt: data.expires_at || "", creditsNeeded: Number(data.credits_needed || 0) };
769
+ },
770
+ async upload({ blob, linkKey, password, chunkSize = DEFAULT_CHUNK_SIZE2, onProgress }) {
771
+ const started = await call("/prepare/start", { file_id: fileId, restart: false });
772
+ uploadId = started.upload_id || fileId;
773
+ let offset = 0;
774
+ await encryptAndUpload({
775
+ file: blob,
776
+ linkKey,
777
+ password: password || null,
778
+ chunkSize,
779
+ onProgress,
780
+ uploadHeader: async (bytes) => {
781
+ await postPart(bytes, offset);
782
+ offset += bytes.length;
783
+ },
784
+ uploadPart: async (index, bytes) => {
785
+ await postPart(bytes, offset, index);
786
+ offset += bytes.length;
787
+ }
788
+ });
789
+ },
790
+ async complete({ name, type, sizeBytes }) {
791
+ await call("/prepare/complete", {
792
+ upload_id: uploadId || fileId,
793
+ file_id: fileId,
794
+ original_filename: name,
795
+ file_type: type || "",
796
+ file_size_bytes: sizeBytes
797
+ });
798
+ },
799
+ async link() {
800
+ const data = await call("/link", { file_id: fileId, payment_id: "" });
801
+ return data.link || "";
802
+ },
803
+ async sendEmail({ recipient, link }) {
804
+ await call("/send-email", { file_id: fileId, recipient_email: recipient, link });
805
+ }
806
+ };
807
+ }
808
+
809
+ // src/credentials.js
810
+ import { mkdir, readFile, writeFile, rename, rm, chmod, open, stat as stat2 } from "node:fs/promises";
811
+ import { homedir as homedir2 } from "node:os";
812
+ import { join } from "node:path";
813
+ import { randomBytes } from "node:crypto";
814
+ var FILE = "mcp-credentials.json";
815
+ var EXPIRY_SKEW_MS = 3e4;
816
+ var LOCK = "mcp-credentials.lock";
817
+ var LOCK_STALE_MS = 3e4;
818
+ var LOCK_WAIT_MS = 15e3;
819
+ var LoginRequired = class extends Error {
820
+ constructor() {
821
+ super("Sign in to VitalSend to send files.");
822
+ this.name = "LoginRequired";
823
+ }
824
+ };
825
+ function defaultCredentialsDir(env = process.env, platform = process.platform) {
826
+ if (platform === "win32" && env.APPDATA) return join(env.APPDATA, "vitalsend");
827
+ return join(env.XDG_CONFIG_HOME || join(homedir2(), ".config"), "vitalsend");
828
+ }
829
+ function expiresAt(accessToken, expiresIn, now) {
830
+ try {
831
+ const payload = JSON.parse(Buffer.from(accessToken.split(".")[1], "base64url").toString());
832
+ if (payload.exp) return payload.exp * 1e3;
833
+ } catch (_err) {
834
+ }
835
+ return now() + Number(expiresIn || 0) * 1e3;
836
+ }
837
+ function createCredentials({ dir = defaultCredentialsDir(), env = process.env, tokens: tokens2, now = Date.now }) {
838
+ const file = join(dir, FILE);
839
+ let cached = null;
840
+ const load = async () => {
841
+ try {
842
+ const data = JSON.parse(await readFile(file, "utf8"));
843
+ return typeof data?.refreshToken === "string" && data.refreshToken ? data : null;
844
+ } catch (_err) {
845
+ return null;
846
+ }
847
+ };
848
+ const withLock = async (fn) => {
849
+ await mkdir(dir, { recursive: true, mode: 448 });
850
+ const lock = join(dir, LOCK);
851
+ const started = now();
852
+ for (; ; ) {
853
+ try {
854
+ const handle = await open(lock, "wx", 384);
855
+ await handle.close();
856
+ break;
857
+ } catch (err) {
858
+ if (err.code !== "EEXIST") throw err;
859
+ const info = await stat2(lock).catch(() => null);
860
+ if (info && Date.now() - info.mtimeMs > LOCK_STALE_MS) await rm(lock, { force: true });
861
+ else if (now() - started > LOCK_WAIT_MS) throw err;
862
+ else await new Promise((resolve2) => setTimeout(resolve2, 25));
863
+ }
864
+ }
865
+ try {
866
+ return await fn();
867
+ } finally {
868
+ await rm(lock, { force: true });
869
+ }
870
+ };
871
+ const fresh = () => cached && cached.expiresAt - now() > EXPIRY_SKEW_MS;
872
+ const remember = (t) => {
873
+ cached = { accessToken: t.accessToken, expiresAt: expiresAt(t.accessToken, t.expiresIn, now) };
874
+ return t.accessToken;
875
+ };
876
+ const self = {
877
+ async mode() {
878
+ if (env.VITALSEND_API_KEY) return "api_key";
879
+ return await load() ? "oauth" : "none";
880
+ },
881
+ // Written to a private temp file and renamed into place, so a reader
882
+ // never sees half a file.
883
+ async save({ refreshToken }) {
884
+ await mkdir(dir, { recursive: true, mode: 448 });
885
+ await chmod(dir, 448);
886
+ const tmp = join(dir, `.${FILE}.${randomBytes(6).toString("hex")}`);
887
+ await writeFile(tmp, JSON.stringify({ refreshToken }), { mode: 384 });
888
+ await rename(tmp, file);
889
+ },
890
+ async clear() {
891
+ cached = null;
892
+ await rm(file, { force: true });
893
+ },
894
+ async accessToken() {
895
+ if (fresh()) return cached.accessToken;
896
+ if (env.VITALSEND_API_KEY) {
897
+ return remember(await tokens2.exchangeAPIKey(env.VITALSEND_API_KEY));
898
+ }
899
+ return withLock(async () => {
900
+ if (fresh()) return cached.accessToken;
901
+ const stored = await load();
902
+ if (!stored) throw new LoginRequired();
903
+ let next;
904
+ try {
905
+ next = await tokens2.refresh(stored.refreshToken);
906
+ } catch (err) {
907
+ if (err?.code === "invalid_grant") {
908
+ await self.clear();
909
+ throw new LoginRequired();
910
+ }
911
+ throw err;
912
+ }
913
+ await self.save({ refreshToken: next.refreshToken });
914
+ return remember(next);
915
+ });
916
+ },
917
+ // After a login: keep the refresh token, use the access token.
918
+ async adopt(loginTokens) {
919
+ await self.save({ refreshToken: loginTokens.refreshToken });
920
+ return remember(loginTokens);
921
+ }
922
+ };
923
+ return self;
924
+ }
925
+
926
+ // src/login.js
927
+ import { createServer } from "node:http";
928
+ import { randomBytes as randomBytes2, createHash } from "node:crypto";
929
+ var DEFAULT_TIMEOUT_MS = 5 * 60 * 1e3;
930
+ var LoginDeclined = class extends Error {
931
+ constructor() {
932
+ super("The VitalSend login was declined.");
933
+ this.name = "LoginDeclined";
934
+ }
935
+ };
936
+ var LoginTimeout = class extends Error {
937
+ constructor() {
938
+ super("The VitalSend login was not completed in time.");
939
+ this.name = "LoginTimeout";
940
+ }
941
+ };
942
+ var PAGE = (text) => `<!doctype html><meta charset="utf-8"><title>VitalSend</title><p style="font-family:system-ui;margin:3rem">${text}</p>`;
943
+ async function startLogin({ baseUrl: baseUrl2, tokens: tokens2, connectionName, timeoutMs = DEFAULT_TIMEOUT_MS }) {
944
+ const verifier = randomBytes2(32).toString("base64url");
945
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
946
+ const state = randomBytes2(24).toString("base64url");
947
+ let settle;
948
+ const done = new Promise((resolve2, reject) => {
949
+ settle = { resolve: resolve2, reject };
950
+ });
951
+ done.catch(() => {
952
+ });
953
+ let finished = false;
954
+ const server2 = createServer();
955
+ const finish = (fn) => {
956
+ if (finished) return;
957
+ finished = true;
958
+ clearTimeout(timer);
959
+ server2.close();
960
+ server2.closeAllConnections?.();
961
+ fn();
962
+ };
963
+ server2.on("request", async (req, res) => {
964
+ const url2 = new URL(req.url, "http://127.0.0.1");
965
+ if (finished || url2.pathname !== "/callback") {
966
+ res.writeHead(404).end();
967
+ return;
968
+ }
969
+ if (url2.searchParams.get("state") !== state) {
970
+ res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }).end(PAGE("This sign-in link is not valid."));
971
+ return;
972
+ }
973
+ if (url2.searchParams.get("error")) {
974
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", Connection: "close" }).end(PAGE("Not connected. You can close this tab."));
975
+ finish(() => settle.reject(new LoginDeclined()));
976
+ return;
977
+ }
978
+ try {
979
+ const result = await tokens2.exchangeCode({ code: url2.searchParams.get("code") || "", verifier, redirectUri });
980
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", Connection: "close" }).end(PAGE("Connected. You can go back to your app."));
981
+ finish(() => settle.resolve(result));
982
+ } catch (err) {
983
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", Connection: "close" }).end(PAGE("Sign-in failed. Try again from your app."));
984
+ finish(() => settle.reject(err));
985
+ }
986
+ });
987
+ await new Promise((resolve2) => server2.listen(0, "127.0.0.1", resolve2));
988
+ const redirectUri = `http://127.0.0.1:${server2.address().port}/callback`;
989
+ const timer = setTimeout(() => finish(() => settle.reject(new LoginTimeout())), timeoutMs);
990
+ const url = new URL("/oauth/authorize/", baseUrl2);
991
+ url.search = new URLSearchParams({
992
+ client_id: CLIENT_ID,
993
+ redirect_uri: redirectUri,
994
+ code_challenge: challenge,
995
+ code_challenge_method: "S256",
996
+ state,
997
+ connection_name: connectionName || "AI app"
998
+ }).toString();
999
+ return {
1000
+ url: url.toString(),
1001
+ done,
1002
+ cancel: () => finish(() => settle.reject(new LoginDeclined()))
1003
+ };
1004
+ }
1005
+
1006
+ // src/delivery-page.js
1007
+ import { createServer as createServer2 } from "node:http";
1008
+ import { randomBytes as randomBytes3, createHash as createHash2 } from "node:crypto";
1009
+
1010
+ // vendor/vitalsend-share.js
1011
+ var SHARE_ROUTES = ["sms", "whatsapp", "telegram", "email"];
1012
+ function shareText(item, value) {
1013
+ if (item === "password") return `The password for the file I sent you: ${value}`;
1014
+ return `A file for you. It can be downloaded once: ${value}`;
1015
+ }
1016
+ function shareUrl(route, text) {
1017
+ const enc3 = encodeURIComponent(text);
1018
+ switch (route) {
1019
+ case "sms":
1020
+ return `sms:?&body=${enc3}`;
1021
+ case "whatsapp":
1022
+ return `https://wa.me/?text=${enc3}`;
1023
+ case "telegram":
1024
+ return `https://t.me/share/url?url=&text=${enc3}`;
1025
+ case "email":
1026
+ return `mailto:?body=${enc3}`;
1027
+ default:
1028
+ return "";
1029
+ }
1030
+ }
1031
+
1032
+ // src/delivery-page.js
1033
+ var STYLE = `body{font-family:system-ui,sans-serif;max-width:36rem;margin:3rem auto;padding:0 1rem;line-height:1.5}
1034
+ .card{border:1px solid #ccc;border-radius:1rem;padding:1.5rem}code{display:block;font-size:1.4rem;letter-spacing:.15em;margin:1rem 0;word-break:break-all}
1035
+ .row{display:flex;flex-wrap:wrap;gap:.5rem;margin:.75rem 0}a,button{padding:.5rem .9rem;border-radius:.5rem;border:1px solid #888;background:none;color:inherit;text-decoration:none;font:inherit;cursor:pointer}
1036
+ .dim{opacity:.4}.primary{background:#111;color:#fff}[hidden]{display:none}`;
1037
+ var SCRIPT = `(() => {
1038
+ const base = location.pathname;
1039
+ const $ = (id) => document.getElementById(id);
1040
+ const post = (action) => fetch(base + '/action', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action }) });
1041
+ const labels = { sms: 'SMS', whatsapp: 'WhatsApp', telegram: 'Telegram', email: 'Email' };
1042
+ // Rebuilt only when what it shows changes: a button replaced by the next poll
1043
+ // loses a click that is under way.
1044
+ function shares(el, item, s) {
1045
+ const key = JSON.stringify([s[item], s.dimmed[item]]);
1046
+ if (el.dataset.key === key) return;
1047
+ el.dataset.key = key;
1048
+ el.textContent = '';
1049
+ const copy = document.createElement('button');
1050
+ copy.textContent = 'Copy';
1051
+ copy.onclick = () => navigator.clipboard?.writeText(item === 'password' ? s.password.value : s.link.value).then(() => {
1052
+ copy.textContent = '\u2713 Done';
1053
+ setTimeout(() => { copy.textContent = 'Copy'; }, 2000);
1054
+ }, () => {});
1055
+ el.append(copy);
1056
+ if (navigator.share) {
1057
+ const b = document.createElement('button');
1058
+ b.textContent = 'Share';
1059
+ b.onclick = () => navigator.share({ text: s[item].text }).then(() => post('shared:' + item + ':sheet')).catch(() => {});
1060
+ el.append(b);
1061
+ }
1062
+ for (const [route, url] of Object.entries(s[item].routes)) {
1063
+ const a = document.createElement('a');
1064
+ a.href = url; a.target = '_blank'; a.rel = 'noopener noreferrer';
1065
+ a.textContent = labels[route] || route;
1066
+ if ((s.dimmed[item] || []).includes(route)) a.className = 'dim';
1067
+ a.onclick = () => post('shared:' + item + ':' + route);
1068
+ el.append(a);
1069
+ }
1070
+ }
1071
+ async function tick() {
1072
+ let s;
1073
+ try { s = await (await fetch(base + '/state')).json(); } catch (_e) { return; }
1074
+ for (const v of ['confirm', 'password', 'link', 'closed']) $(v).hidden = s.view !== v;
1075
+ if (s.view === 'confirm') {
1076
+ $('c-name').textContent = s.confirm.name;
1077
+ $('c-size').textContent = s.confirm.size;
1078
+ $('c-to').textContent = s.confirm.recipient || 'you share the link yourself';
1079
+ $('c-credits').textContent = String(s.confirm.credits);
1080
+ }
1081
+ // A secret is only in the page while its view is on screen.
1082
+ if (s.view !== 'password') { $('p-value').textContent = ''; $('p-share').textContent = ''; delete $('p-share').dataset.key; }
1083
+ if (s.view !== 'link') { $('l-value').textContent = ''; $('l-share').textContent = ''; delete $('l-share').dataset.key; }
1084
+ if (s.view === 'password') { $('p-value').textContent = s.password.value; shares($('p-share'), 'password', s); }
1085
+ if (s.view === 'link') {
1086
+ $('l-value').textContent = s.link.value;
1087
+ $('l-mailed').hidden = !s.link.emailedTo;
1088
+ $('l-mailed-to').textContent = s.link.emailedTo || '';
1089
+ shares($('l-share'), 'link', s);
1090
+ }
1091
+ }
1092
+ $('send').onclick = () => post('send');
1093
+ $('cancel').onclick = () => post('cancel');
1094
+ $('saved').onclick = () => post('saved');
1095
+ $('done').onclick = () => post('done');
1096
+ tick();
1097
+ setInterval(tick, 1000);
1098
+ })();`;
1099
+ var BODY = `<h1>VitalSend</h1>
1100
+ <div class="card" id="confirm" hidden><p>Send <strong id="c-name"></strong> (<span id="c-size"></span>) to <strong id="c-to"></strong>?</p>
1101
+ <p>It costs <span id="c-credits"></span> credit(s).</p><div class="row"><button class="primary" id="send">Send</button><button id="cancel">Cancel</button></div></div>
1102
+ <div class="card" id="password" hidden><p>The password for this file. The recipient needs it to open the file. Send it another way than the link.</p>
1103
+ <code id="p-value"></code><div class="row" id="p-share"></div><div class="row"><button class="primary" id="saved">I have saved the password</button></div></div>
1104
+ <div class="card" id="link" hidden><p id="l-mailed" hidden>The link was emailed to <strong id="l-mailed-to"></strong>.</p>
1105
+ <p>The link. It can be downloaded once.</p><code id="l-value" style="font-size:.9rem;letter-spacing:0"></code><div class="row" id="l-share"></div>
1106
+ <div class="row"><button class="primary" id="done">Done</button></div></div>
1107
+ <div class="card" id="closed" hidden><p>You can close this tab.</p></div>`;
1108
+ var sha256 = (text) => `'sha256-${createHash2("sha256").update(text).digest("base64")}'`;
1109
+ var CSP = [
1110
+ "default-src 'none'",
1111
+ `script-src ${sha256(SCRIPT)}`,
1112
+ `style-src ${sha256(STYLE)}`,
1113
+ "connect-src 'self'",
1114
+ "base-uri 'none'",
1115
+ "form-action 'none'",
1116
+ "frame-ancestors 'none'"
1117
+ ].join("; ");
1118
+ var HTML = `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
1119
+ <title>VitalSend</title><style>${STYLE}</style><body>${BODY}<script>${SCRIPT}</script></body></html>`;
1120
+ function formatSize(bytes) {
1121
+ if (bytes < 1024) return `${bytes} B`;
1122
+ if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
1123
+ if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
1124
+ return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
1125
+ }
1126
+ var BrowserUnavailable = class extends Error {
1127
+ constructor() {
1128
+ super("No browser could be opened on this computer.");
1129
+ this.name = "BrowserUnavailable";
1130
+ }
1131
+ };
1132
+ var COOKIE = "vs_page";
1133
+ var SECRET_VIEWS = /* @__PURE__ */ new Set(["confirm", "password"]);
1134
+ var routesFor = (item, value) => Object.fromEntries(SHARE_ROUTES.map((r) => [r, shareUrl(r, shareText(item, value))]));
1135
+ async function openDeliveryPage({ openBrowser: openBrowser2, timeoutMs = 5 * 60 * 1e3, linkTimeoutMs = 30 * 60 * 1e3 }) {
1136
+ const path = `/${randomBytes3(32).toString("base64url")}`;
1137
+ const pageSecret = randomBytes3(32).toString("base64url");
1138
+ const used = { link: /* @__PURE__ */ new Set(), password: /* @__PURE__ */ new Set() };
1139
+ let view = "idle";
1140
+ let data = {};
1141
+ let pageServed = false;
1142
+ let browserOpened = false;
1143
+ let pending = null;
1144
+ const server2 = createServer2();
1145
+ await new Promise((resolve2) => server2.listen(0, "127.0.0.1", resolve2));
1146
+ const origin = `http://127.0.0.1:${server2.address().port}`;
1147
+ const host = origin.slice("http://".length);
1148
+ const dimmed = () => ({
1149
+ password: [...used.link].filter((r) => r !== "sheet"),
1150
+ link: [...used.password].filter((r) => r !== "sheet")
1151
+ });
1152
+ const snapshot = () => ({ view, ...data, dimmed: dimmed() });
1153
+ const settle = (result) => {
1154
+ if (!pending) return;
1155
+ const { resolve: resolve2, timer } = pending;
1156
+ pending = null;
1157
+ clearTimeout(timer);
1158
+ resolve2(result);
1159
+ };
1160
+ const openOnce = async () => {
1161
+ if (browserOpened) return;
1162
+ browserOpened = true;
1163
+ try {
1164
+ await openBrowser2(`${origin}${path}`);
1165
+ } catch (_err) {
1166
+ throw new BrowserUnavailable();
1167
+ }
1168
+ };
1169
+ const show = async (nextView, nextData, accepts) => {
1170
+ settle("superseded");
1171
+ await openOnce();
1172
+ view = nextView;
1173
+ data = nextData;
1174
+ const wait = nextView === "link" ? linkTimeoutMs : timeoutMs;
1175
+ return new Promise((resolve2) => {
1176
+ pending = { accepts: new Set(accepts), resolve: resolve2, timer: setTimeout(() => settle("timeout"), wait) };
1177
+ });
1178
+ };
1179
+ const hasCookie = (req) => (req.headers.cookie || "").split(";").some((c) => c.trim() === `${COOKIE}=${pageSecret}`);
1180
+ server2.on("request", async (req, res) => {
1181
+ const url = new URL(req.url, origin);
1182
+ const empty = (status) => res.writeHead(status, { "Cache-Control": "no-store" }).end();
1183
+ if (req.headers.host !== host || !url.pathname.startsWith(path)) return empty(404);
1184
+ const rest = url.pathname.slice(path.length);
1185
+ if (rest === "" && req.method === "GET") {
1186
+ if (pageServed) {
1187
+ if (SECRET_VIEWS.has(view)) settle("cancel");
1188
+ return empty(404);
1189
+ }
1190
+ pageServed = true;
1191
+ res.writeHead(200, {
1192
+ "Content-Type": "text/html; charset=utf-8",
1193
+ "Content-Security-Policy": CSP,
1194
+ "Cache-Control": "no-store",
1195
+ "Referrer-Policy": "no-referrer",
1196
+ "X-Content-Type-Options": "nosniff",
1197
+ "Set-Cookie": `${COOKIE}=${pageSecret}; HttpOnly; SameSite=Strict; Path=${path}`
1198
+ });
1199
+ return res.end(HTML);
1200
+ }
1201
+ if (!hasCookie(req)) return empty(404);
1202
+ if (rest === "/state" && req.method === "GET") {
1203
+ res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
1204
+ return res.end(JSON.stringify(snapshot()));
1205
+ }
1206
+ if (rest === "/action" && req.method === "POST") {
1207
+ if (req.headers.origin && req.headers.origin !== origin) return empty(403);
1208
+ let body = "";
1209
+ for await (const chunk of req) {
1210
+ body += chunk;
1211
+ if (body.length > 1024) return empty(413);
1212
+ }
1213
+ let action = "";
1214
+ try {
1215
+ action = String(JSON.parse(body).action || "");
1216
+ } catch (_err) {
1217
+ return empty(400);
1218
+ }
1219
+ const shared = /^shared:(link|password):(sheet|sms|whatsapp|telegram|email)$/.exec(action);
1220
+ if (shared) used[shared[1]].add(shared[2]);
1221
+ else if (pending?.accepts.has(action)) settle(action);
1222
+ return empty(204);
1223
+ }
1224
+ return empty(404);
1225
+ });
1226
+ return {
1227
+ open: openOnce,
1228
+ confirm({ recipient, name, sizeBytes, credits }) {
1229
+ return show("confirm", { confirm: { recipient: recipient || "", name, size: formatSize(sizeBytes), credits } }, ["send", "cancel"]);
1230
+ },
1231
+ showPassword(password) {
1232
+ return show("password", { password: { value: password, text: shareText("password", password), routes: routesFor("password", password) } }, ["saved"]);
1233
+ },
1234
+ showLink({ link, emailedTo }) {
1235
+ if (emailedTo) used.link.add("email");
1236
+ return show("link", { link: { value: link, text: shareText("link", link), routes: routesFor("link", link), emailedTo: emailedTo || "" } }, ["done"]);
1237
+ },
1238
+ close() {
1239
+ settle("closed");
1240
+ view = "closed";
1241
+ data = {};
1242
+ setTimeout(() => {
1243
+ server2.close();
1244
+ server2.closeAllConnections?.();
1245
+ }, 3e3).unref();
1246
+ }
1247
+ };
1248
+ }
1249
+
1250
+ // src/send.js
1251
+ var EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
1252
+ var fail = (text) => ({ isError: true, text: `Not sent. ${text}` });
1253
+ function formatSize2(bytes) {
1254
+ if (bytes < 1024) return `${bytes} B`;
1255
+ if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
1256
+ if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
1257
+ return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
1258
+ }
1259
+ function formatExpiry(iso) {
1260
+ const d = new Date(iso);
1261
+ return Number.isNaN(d.getTime()) ? "" : `${d.toISOString().slice(0, 16).replace("T", " ")} UTC`;
1262
+ }
1263
+ function explain(err) {
1264
+ if (err instanceof PathNotAllowed || err instanceof RootRequired) return fail(err.message);
1265
+ if (err instanceof BrowserUnavailable) return fail("No browser could be opened on this computer, so nothing was sent. Use an API key with recipient_email instead.");
1266
+ if (err instanceof LoginDeclined || err instanceof LoginTimeout) return fail("The VitalSend login was not completed.");
1267
+ if (err instanceof LoginRequired) return fail("Sign in to VitalSend first.");
1268
+ if (err instanceof ApiError) {
1269
+ if (err.code === "credits_insufficient") return fail("Not enough credits. Top up at https://vitalsend.eu/me/.");
1270
+ return fail(err.message);
1271
+ }
1272
+ return fail("Something went wrong.");
1273
+ }
1274
+ async function sendFile(input, deps) {
1275
+ const { credentials: credentials2, newSession, openPage, startLogin: startLogin2, io, clientName, cwd } = deps;
1276
+ let page = null;
1277
+ let pageHandedOff = false;
1278
+ const getPage = async () => page ||= await openPage();
1279
+ try {
1280
+ const mode = await credentials2.mode();
1281
+ const recipient = String(input.recipient_email || "").trim();
1282
+ const withPassword = input.password ?? mode !== "api_key";
1283
+ if (mode === "api_key" && withPassword) return fail("An API key cannot send with a password; sign in through the browser instead, or set password to false.");
1284
+ if (mode === "api_key" && !recipient) return fail("With an API key the link is emailed, so recipient_email is required.");
1285
+ if (recipient && !EMAIL.test(recipient)) return fail("recipient_email is not a valid email address.");
1286
+ const path = await resolveAllowedFile(input.path, { roots: await io.roots(), cwd, envRoot: deps.envRoot || "" });
1287
+ const blob = await openAsBlob(path);
1288
+ const name = basename(path);
1289
+ const sizeBytes = blob.size;
1290
+ let accessToken;
1291
+ try {
1292
+ accessToken = await credentials2.accessToken();
1293
+ } catch (err) {
1294
+ if (!(err instanceof LoginRequired)) throw err;
1295
+ const login2 = await startLogin2({ connectionName: clientName });
1296
+ await io.openLogin(login2.url, login2);
1297
+ accessToken = await credentials2.adopt(await login2.done);
1298
+ }
1299
+ const session = newSession(accessToken);
1300
+ await session.init();
1301
+ const { creditsNeeded } = await session.quote({ sizeBytes, password: withPassword, recipient });
1302
+ const password = withPassword ? generatePassword() : null;
1303
+ const inDialogs = mode !== "api_key" && !!io.canShowSecrets;
1304
+ if (mode !== "api_key") {
1305
+ const details = { recipient, name, sizeBytes, size: formatSize2(sizeBytes), credits: creditsNeeded, password: inDialogs ? password : null };
1306
+ let decision = await io.confirm(details);
1307
+ if (decision === "unavailable") decision = await (await getPage()).confirm(details);
1308
+ if (decision !== "send") return fail("The send was cancelled.");
1309
+ }
1310
+ if (mode !== "api_key" && !inDialogs) {
1311
+ await (await getPage()).open();
1312
+ if (password) {
1313
+ const shown = await (await getPage()).showPassword(password);
1314
+ if (shown !== "saved") return fail("The password was not confirmed in the browser, so nothing was sent and no credits were used.");
1315
+ }
1316
+ }
1317
+ const linkKey = new Uint8Array(randomBytes4(32));
1318
+ const reserved = await session.reserve({
1319
+ name,
1320
+ type: blob.type || "",
1321
+ sizeBytes,
1322
+ passwordVerifier: password ? await derivePasswordVerifier(linkKey, password) : ""
1323
+ });
1324
+ await session.upload({ blob, linkKey, password });
1325
+ await session.complete({ name, type: blob.type || "", sizeBytes });
1326
+ const link = buildShareLink(await session.link(), encodeLinkKey(linkKey), !!password);
1327
+ let emailed = false;
1328
+ if (recipient) {
1329
+ try {
1330
+ await session.sendEmail({ recipient, link });
1331
+ emailed = true;
1332
+ } catch (err) {
1333
+ if (mode === "api_key") {
1334
+ return fail(`The file was uploaded and ${creditsNeeded} credit${creditsNeeded === 1 ? " was" : "s were"} used, but VitalSend could not email the link, and it cannot be shown here. ${explain(err).text.replace(/^Not sent\. /, "")}`);
1335
+ }
1336
+ }
1337
+ }
1338
+ let linkShown = emailed || mode === "api_key";
1339
+ if (!linkShown && inDialogs) linkShown = await io.showLink(link, { password: !!password }).catch(() => "cancel") === "accept";
1340
+ if (mode !== "api_key" && (!inDialogs || !linkShown)) {
1341
+ const p = await getPage();
1342
+ pageHandedOff = true;
1343
+ p.showLink({ link, emailedTo: emailed ? recipient : "" }).catch(() => {
1344
+ }).finally(() => p.close());
1345
+ }
1346
+ const lines = [`Sent ${name} (${formatSize2(sizeBytes)}).`];
1347
+ if (emailed) {
1348
+ lines.push(`VitalSend emailed the download link to ${recipient}.`);
1349
+ } else {
1350
+ if (recipient) lines.push(`VitalSend could not email the link to ${recipient}.`);
1351
+ lines.push(linkShown ? "The download link was shown to the user to share; it was not given to you." : "The download link is waiting in the user's browser to be shared; it was not given to you.");
1352
+ }
1353
+ const expiry = formatExpiry(reserved.expiresAt);
1354
+ if (expiry) lines.push(`It can be downloaded once, until ${expiry}.`);
1355
+ if (password) lines.push("A password was set and shown to the user" + (inDialogs ? "" : " in the browser") + ". The recipient needs it; the user should send it another way than the link.");
1356
+ lines.push(`Cost: ${creditsNeeded} credit${creditsNeeded === 1 ? "" : "s"}.`);
1357
+ return { isError: false, text: lines.join(" ") };
1358
+ } catch (err) {
1359
+ if (err?.inputRequired) throw err;
1360
+ return explain(err);
1361
+ } finally {
1362
+ if (page && !pageHandedOff) page.close();
1363
+ }
1364
+ }
1365
+
1366
+ // src/io.js
1367
+ import { inputRequired } from "@modelcontextprotocol/server";
1368
+ var NeedInput = class extends Error {
1369
+ constructor(value) {
1370
+ super("input required");
1371
+ this.inputRequired = true;
1372
+ this.value = value;
1373
+ }
1374
+ };
1375
+ var LOGIN_MESSAGE = "Sign in to VitalSend to send this file. The page opens on vitalsend.eu.";
1376
+ function createIo({ server: server2, ctx, openBrowser: openBrowser2 }) {
1377
+ const era2026 = !!ctx?.mcpReq?.envelope;
1378
+ const caps = server2.server.getClientCapabilities?.() || {};
1379
+ const answers = ctx?.mcpReq?.inputResponses || {};
1380
+ const elicitation = caps.elicitation;
1381
+ const canForm = !!elicitation && (Object.keys(elicitation).length === 0 || !!elicitation.form);
1382
+ const canUrl = !!elicitation?.url;
1383
+ const canShowSecrets = canForm && !era2026;
1384
+ const ask = (key, request) => {
1385
+ throw new NeedInput(inputRequired({ inputRequests: { [key]: request } }));
1386
+ };
1387
+ return {
1388
+ async roots() {
1389
+ if (!caps.roots) return null;
1390
+ if (!era2026) return (await server2.server.listRoots()).roots.map((r) => r.uri);
1391
+ if (answers.roots?.roots) return answers.roots.roots.map((r) => r.uri);
1392
+ return ask("roots", inputRequired.listRoots());
1393
+ },
1394
+ // login: { done, cancel } from startLogin. On 2025-era connections the
1395
+ // client may hold its dialog open until told the login is complete, so the
1396
+ // dialog is not awaited: completing the login closes it, declining it
1397
+ // cancels the login.
1398
+ async openLogin(url, login2) {
1399
+ if (!canUrl) return openBrowser2(url);
1400
+ if (!era2026) {
1401
+ const elicitationId = `login-${Date.now()}`;
1402
+ ctx.mcpReq.elicitInput({ mode: "url", url, message: LOGIN_MESSAGE, elicitationId }).then((res) => {
1403
+ if (res?.action !== "accept") login2?.cancel();
1404
+ }, () => login2?.cancel());
1405
+ login2?.done.then(() => server2.server.createElicitationCompletionNotifier(elicitationId)(), () => {
1406
+ }).catch(() => {
1407
+ });
1408
+ return void 0;
1409
+ }
1410
+ if (answers.login) return void 0;
1411
+ return ask("login", inputRequired.elicitUrl({ url, message: LOGIN_MESSAGE }));
1412
+ },
1413
+ canShowSecrets,
1414
+ async confirm({ recipient, name, sizeBytes, size, credits, password }) {
1415
+ if (!canForm) return "unavailable";
1416
+ const cost = `${credits} credit${credits === 1 ? "" : "s"}`;
1417
+ let message = `Send ${name} (${size || `${sizeBytes} bytes`}) ${recipient ? `to ${recipient}` : "and share the link yourself"} for ${cost}?`;
1418
+ if (password && canShowSecrets) {
1419
+ const to = recipient || "the recipient";
1420
+ message += `
1421
+
1422
+ Password: ${password}
1423
+
1424
+ Copy the password now and send it to ${to} yourself, by text message for example. ` + (recipient ? "Not by email: the link arrives by email, and the two must travel separately." : "Not together with the link.") + " The password is shown only here.";
1425
+ }
1426
+ const request = { mode: "form", message, requestedSchema: { type: "object", properties: {} } };
1427
+ if (!era2026) {
1428
+ const res = await ctx.mcpReq.elicitInput(request);
1429
+ return res?.action === "accept" ? "send" : "cancel";
1430
+ }
1431
+ if (answers.confirm) return answers.confirm.action === "accept" ? "send" : "cancel";
1432
+ return ask("confirm", inputRequired.elicit({ message, requestedSchema: request.requestedSchema }));
1433
+ },
1434
+ async showLink(link, { password = false } = {}) {
1435
+ if (!canShowSecrets) return "unavailable";
1436
+ const res = await ctx.mcpReq.elicitInput({
1437
+ mode: "form",
1438
+ requestedSchema: { type: "object", properties: {} },
1439
+ message: `Uploaded. Copy the link and send it to the recipient; it can be downloaded once:
1440
+
1441
+ ${link}` + (password ? "\n\nSend the password another way than the link." : "")
1442
+ });
1443
+ return res?.action || "cancel";
1444
+ }
1445
+ };
1446
+ }
1447
+
1448
+ // src/server.js
1449
+ var VERSION = "0.2.0";
1450
+ var DESCRIPTION = `Send a file end-to-end encrypted through VitalSend. The file is encrypted on this computer; the recipient can download it once. With recipient_email, VitalSend emails the download link; without it, the link is shown to the user to share. You never see the link, its key or the password. By default a password is set and shown to the user, who sends it to the recipient another way. Each send is confirmed by the user first.`;
1451
+ function buildServer({ sendFileDeps, openBrowser: openBrowser2 }) {
1452
+ const server2 = new McpServer({ name: "vitalsend", version: VERSION });
1453
+ server2.registerTool("send_file", {
1454
+ description: DESCRIPTION,
1455
+ inputSchema: {
1456
+ path: z.string().describe("Path of the file to send, inside the folders this client shares."),
1457
+ recipient_email: z.string().optional().describe("Email the download link to this address. Leave out to let the user share the link."),
1458
+ password: z.boolean().optional().describe("Protect the file with a password shown to the user. Default: yes when signed in through the browser.")
1459
+ }
1460
+ }, async (args, ctx) => {
1461
+ const io = createIo({ server: server2, ctx, openBrowser: openBrowser2 });
1462
+ try {
1463
+ const out = await sendFile(args, { ...sendFileDeps(server2), io });
1464
+ return { content: [{ type: "text", text: out.text }], isError: out.isError };
1465
+ } catch (err) {
1466
+ if (err?.inputRequired) return err.value;
1467
+ return { content: [{ type: "text", text: "Not sent. Something went wrong." }], isError: true };
1468
+ }
1469
+ });
1470
+ return server2;
1471
+ }
1472
+
1473
+ // src/browser.js
1474
+ import { spawn } from "node:child_process";
1475
+ async function openBrowser(url, platform = process.platform) {
1476
+ const [cmd, args] = platform === "darwin" ? ["open", [url]] : platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : ["xdg-open", [url]];
1477
+ await new Promise((resolve2, reject) => {
1478
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
1479
+ child.once("error", reject);
1480
+ child.once("spawn", () => {
1481
+ child.unref();
1482
+ resolve2();
1483
+ });
1484
+ });
1485
+ }
1486
+
1487
+ // src/index.js
1488
+ var baseUrl = (process.env.VITALSEND_BASE_URL || "https://vitalsend.eu").replace(/\/+$/, "");
1489
+ var tokens = createTokenClient({ baseUrl });
1490
+ var credentials = createCredentials({ tokens });
1491
+ var activeLogin = null;
1492
+ async function login({ connectionName }) {
1493
+ if (activeLogin && !activeLogin.settled) return activeLogin;
1494
+ const started = await startLogin({ baseUrl, tokens, connectionName });
1495
+ activeLogin = started;
1496
+ started.done.then(() => {
1497
+ }, () => {
1498
+ }).finally(() => {
1499
+ started.settled = true;
1500
+ });
1501
+ return started;
1502
+ }
1503
+ var server = buildServer({
1504
+ openBrowser,
1505
+ sendFileDeps: (srv) => ({
1506
+ credentials,
1507
+ newSession: (accessToken) => createSendSession({ baseUrl, accessToken }),
1508
+ openPage: () => openDeliveryPage({ openBrowser }),
1509
+ startLogin: login,
1510
+ clientName: srv.server.getClientVersion?.()?.name || "AI app",
1511
+ cwd: process.cwd(),
1512
+ envRoot: process.env.VITALSEND_ROOT || ""
1513
+ })
1514
+ });
1515
+ await server.connect(new StdioServerTransport());