@panelwave/mcp 0.0.0-stage → 0.1.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/upload.js ADDED
@@ -0,0 +1,582 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.uploadFiles = exports.MAX_ATTEMPTS = exports.MAX_FILES = exports.UPLOADABLE_KINDS = void 0;
37
+ exports.sha256File = sha256File;
38
+ exports.remoteData = remoteData;
39
+ exports.assertUploadTarget = assertUploadTarget;
40
+ exports.safeUploadHeaders = safeUploadHeaders;
41
+ exports.uploadBatch = uploadBatch;
42
+ exports.ensureFolder = ensureFolder;
43
+ const node_crypto_1 = require("node:crypto");
44
+ const node_fs_1 = require("node:fs");
45
+ const fs = __importStar(require("node:fs/promises"));
46
+ const path = __importStar(require("node:path"));
47
+ const zod_1 = require("zod");
48
+ const errors_1 = require("./errors");
49
+ const files_1 = require("./files");
50
+ const sandbox_1 = require("./sandbox");
51
+ const tool_1 = require("./tool");
52
+ /** Kinds the asset library accepts. */
53
+ exports.UPLOADABLE_KINDS = new Set(['image', 'video', 'audio', 'font']);
54
+ exports.MAX_FILES = 500;
55
+ exports.MAX_ATTEMPTS = 3;
56
+ async function sha256File(file, signal) {
57
+ const hash = (0, node_crypto_1.createHash)('sha256');
58
+ const stream = (0, node_fs_1.createReadStream)(file, { highWaterMark: 1024 * 1024, signal });
59
+ for await (const chunk of stream)
60
+ hash.update(chunk);
61
+ return hash.digest('hex');
62
+ }
63
+ async function readRange(file, offset, length) {
64
+ const handle = await fs.open(file, 'r');
65
+ try {
66
+ const buf = Buffer.alloc(length);
67
+ let read = 0;
68
+ while (read < length) {
69
+ const { bytesRead } = await handle.read(buf, read, length - read, offset + read);
70
+ if (!bytesRead)
71
+ break;
72
+ read += bytesRead;
73
+ }
74
+ if (read !== length)
75
+ throw new errors_1.BridgeError('PRECONDITION_FAILED', `${path.basename(file)} changed while uploading (expected ${length} bytes at offset ${offset}, read ${read}).`);
76
+ return buf;
77
+ }
78
+ finally {
79
+ await handle.close();
80
+ }
81
+ }
82
+ /** Call a hosted tool and return its structured result; an isError result is re-raised with the remote code. */
83
+ async function remoteData(call, name, args, signal) {
84
+ let res;
85
+ try {
86
+ res = await call(name, args, signal);
87
+ }
88
+ catch (e) {
89
+ const err = e;
90
+ // the gateway only lists the tools the token's scopes allow — an unknown tool means a missing scope
91
+ if (typeof err?.code === 'number' && /not found/i.test(err.message ?? '')) {
92
+ throw new errors_1.BridgeError('PERMISSION_DENIED', `${name} is not available on this connection — the access token probably lacks the scope (uploads need assets:write, folder lookups assets:read). Create a token with those scopes.`);
93
+ }
94
+ throw (0, errors_1.toBridgeError)(e);
95
+ }
96
+ const body = (res.structuredContent ?? {});
97
+ if (res.isError) {
98
+ const code = (0, errors_1.isBridgeErrorCode)(body.code) ? body.code : 'INTERNAL';
99
+ const text = res.content?.find((c) => c.type === 'text')?.text;
100
+ throw new errors_1.BridgeError(code, String(body.message ?? text ?? `${name} failed`), body.details);
101
+ }
102
+ return body;
103
+ }
104
+ const sleep = (ms, signal) => new Promise((resolve, reject) => {
105
+ if (signal?.aborted)
106
+ return reject(new errors_1.BridgeError('CANCELLED', 'The call was cancelled.'));
107
+ const t = setTimeout(resolve, ms);
108
+ signal?.addEventListener('abort', () => {
109
+ clearTimeout(t);
110
+ reject(new errors_1.BridgeError('CANCELLED', 'The call was cancelled.'));
111
+ }, { once: true });
112
+ });
113
+ /** Storage refusals that retrying will not fix (403 is retried with a fresh URL). */
114
+ const RETRYABLE_STATUS = new Set([403, 408, 425, 429, 500, 502, 503, 504]);
115
+ /**
116
+ * PUT one body with up to MAX_ATTEMPTS attempts. `target(attempt, lastStatus)`
117
+ * supplies the URL — callers re-sign after an expired/refused URL (403).
118
+ * Returns the ETag (may be empty) and the number of attempts used.
119
+ */
120
+ async function putWithRetry(opts, label, target, body) {
121
+ const doFetch = opts.fetch ?? fetch;
122
+ let last;
123
+ let lastStatus;
124
+ for (let attempt = 1; attempt <= exports.MAX_ATTEMPTS; attempt++) {
125
+ if (opts.signal?.aborted)
126
+ throw new errors_1.BridgeError('CANCELLED', 'The call was cancelled.');
127
+ // signing (a hosted tool call) and reading the file are not retried here
128
+ const next = await target(attempt, lastStatus);
129
+ const url = assertUploadTarget(next.url, opts);
130
+ const headers = safeUploadHeaders(next.headers);
131
+ const bytes = await body();
132
+ let res;
133
+ try {
134
+ res = await doFetch(url, { method: 'PUT', headers, body: bytes, signal: opts.signal });
135
+ }
136
+ catch (e) {
137
+ if (opts.signal?.aborted)
138
+ throw new errors_1.BridgeError('CANCELLED', 'The call was cancelled.');
139
+ lastStatus = undefined;
140
+ last = new errors_1.BridgeError('UPSTREAM_UNAVAILABLE', `Uploading ${label} failed: ${e instanceof Error ? (e.cause instanceof Error ? e.cause.message : e.message) : String(e)}.`);
141
+ }
142
+ if (res?.ok) {
143
+ await res.arrayBuffer().catch(() => undefined);
144
+ return { etag: res.headers.get('etag') ?? '', attempts: attempt };
145
+ }
146
+ if (res) {
147
+ lastStatus = res.status;
148
+ const snippet = (await res.text().catch(() => '')).replace(/\s+/g, ' ').slice(0, 200);
149
+ last = new errors_1.BridgeError('UPSTREAM_UNAVAILABLE', `Storage refused ${label} (HTTP ${res.status}${snippet ? `: ${snippet}` : ''}).`);
150
+ if (!RETRYABLE_STATUS.has(res.status))
151
+ throw last;
152
+ }
153
+ if (attempt < exports.MAX_ATTEMPTS)
154
+ await sleep((opts.retryDelayMs ?? 500) * 3 ** (attempt - 1), opts.signal);
155
+ }
156
+ throw new errors_1.BridgeError('UPSTREAM_UNAVAILABLE', `${last?.message ?? `Uploading ${label} failed.`} Gave up after ${exports.MAX_ATTEMPTS} attempts — run the tool again to resume (finished files are skipped).`);
157
+ }
158
+ /**
159
+ * Upload URLs come from the hosted server. Accept only https, or plain http to a
160
+ * loopback host (a local MinIO in development) — never another scheme or a
161
+ * plain-http host on the network (security review 2026-09-30).
162
+ */
163
+ function assertUploadTarget(url, opts = {}) {
164
+ let parsed;
165
+ try {
166
+ parsed = new URL(url);
167
+ }
168
+ catch {
169
+ throw new errors_1.BridgeError('UPSTREAM_UNAVAILABLE', 'The server returned an invalid upload URL.');
170
+ }
171
+ const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(parsed.hostname);
172
+ const ok = parsed.protocol === 'https:' || (parsed.protocol === 'http:' && (loopback || opts.allowInsecureUploadHosts === true));
173
+ if (!ok || parsed.username || parsed.password) {
174
+ throw new errors_1.BridgeError('PRECONDITION_FAILED', `Refusing to upload to ${parsed.protocol}//${parsed.host}: uploads go to https storage only.`);
175
+ }
176
+ return parsed.toString();
177
+ }
178
+ /** Headers for the storage PUT: never credentials or hop-by-hop headers, whatever the server suggests. */
179
+ function safeUploadHeaders(headers) {
180
+ const out = {};
181
+ for (const [k, v] of Object.entries(headers ?? {})) {
182
+ if (/^(authorization|proxy-authorization|cookie|host|connection|transfer-encoding|content-length)$/i.test(k))
183
+ continue;
184
+ if (typeof v === 'string')
185
+ out[k] = v;
186
+ }
187
+ return out;
188
+ }
189
+ /**
190
+ * Upload local files into a work's asset library through the hosted tools
191
+ * (pw_assets_begin_upload → PUT / multipart parts → pw_assets_complete_upload).
192
+ * Files run `concurrency` at a time; parts of one file run in order, each read
193
+ * from disk just before its PUT, so memory stays at one part per file.
194
+ */
195
+ async function uploadBatch(opts) {
196
+ const total = opts.files.reduce((n, f) => n + f.size, 0);
197
+ let done = 0;
198
+ const advance = (bytes, message) => {
199
+ if (bytes <= 0)
200
+ return;
201
+ done += bytes;
202
+ opts.onProgress?.(done, total, message);
203
+ };
204
+ const prepared = await (0, tool_1.mapLimit)(opts.files, opts.concurrency, async (file) => {
205
+ const filename = path.basename(file.abs);
206
+ const mimeType = (0, files_1.mimeOf)(filename);
207
+ const kind = (0, files_1.kindOfMime)(mimeType);
208
+ const row = { path: file.abs, filename, ok: false, status: 'failed', deduplicated: false, mimeType, sizeBytes: file.size, warnings: [] };
209
+ try {
210
+ if (opts.signal?.aborted)
211
+ throw new errors_1.BridgeError('CANCELLED', 'The call was cancelled.');
212
+ if (!exports.UPLOADABLE_KINDS.has(kind))
213
+ throw new errors_1.BridgeError('INVALID_INPUT', `${filename} is not an image, video, audio or font file (${mimeType}).`);
214
+ if (file.size <= 0)
215
+ throw new errors_1.BridgeError('INVALID_INPUT', `${filename} is empty.`);
216
+ row.kind = kind;
217
+ const sha256 = await sha256File(file.abs, opts.signal);
218
+ row.sha256 = sha256;
219
+ if (kind === 'image') {
220
+ const dim = await (0, files_1.imageDimensions)(file.abs);
221
+ if (dim) {
222
+ row.width = dim.width;
223
+ row.height = dim.height;
224
+ }
225
+ else
226
+ row.warnings.push('Image dimensions could not be read locally; the server fills them in.');
227
+ }
228
+ return { file, row, sha256 };
229
+ }
230
+ catch (e) {
231
+ return { file, row, error: opts.signal?.aborted ? new errors_1.BridgeError('CANCELLED', 'The call was cancelled.') : (0, errors_1.toBridgeError)(e) };
232
+ }
233
+ });
234
+ // The first file per content hash owns the upload; later ones reuse its asset.
235
+ const owners = new Map();
236
+ prepared.forEach((p, index) => {
237
+ if (!p.sha256 || owners.has(p.sha256))
238
+ return;
239
+ let settle;
240
+ const promise = new Promise((r) => (settle = r));
241
+ owners.set(p.sha256, { index, promise, settle });
242
+ });
243
+ const finish = (p, e, accounted) => {
244
+ const err = (0, errors_1.toBridgeError)(e);
245
+ p.row.ok = false;
246
+ p.row.status = err.code === 'CANCELLED' ? 'cancelled' : 'failed';
247
+ p.row.error = { code: err.code, message: err.message };
248
+ advance(p.file.size - accounted, `${p.row.filename}: ${p.row.status}`);
249
+ return p.row;
250
+ };
251
+ // Phase 2 — transfer one file: begin → PUT (single) or signed parts (multipart) → complete.
252
+ const transfer = async (p, account) => {
253
+ const { file, row } = p;
254
+ const sha256 = p.sha256;
255
+ const { filename, mimeType } = row;
256
+ const kind = row.kind;
257
+ let multipart;
258
+ try {
259
+ const begin = await remoteData(opts.callRemote, 'pw_assets_begin_upload', { workId: opts.workId, filename, mimeType, sizeBytes: file.size, sha256, kind }, opts.signal);
260
+ const beginWarnings = begin.warnings ?? [];
261
+ row.warnings.push(...beginWarnings);
262
+ if (begin.mode === 'exists') {
263
+ if (!opts.skipDuplicates)
264
+ throw new errors_1.BridgeError('CONFLICT', `${filename} is already in the library (asset ${String(begin.assetId)}); skipDuplicates is false.`);
265
+ Object.assign(row, { ok: true, status: 'exists', assetId: String(begin.assetId), deduplicated: true });
266
+ account(file.size, `${filename}: already in the library`);
267
+ return row;
268
+ }
269
+ let s3Key = String(begin.s3Key ?? '');
270
+ let parts;
271
+ if (begin.mode === 'single') {
272
+ row.mode = 'single';
273
+ let current = { url: String(begin.uploadUrl ?? ''), headers: begin.headers ?? { 'Content-Type': mimeType } };
274
+ const put = await putWithRetry(opts, filename, async (attempt, lastStatus) => {
275
+ if (attempt > 1 && lastStatus === 403) {
276
+ // the presigned URL expired or was refused — ask for a fresh one
277
+ const again = await remoteData(opts.callRemote, 'pw_assets_begin_upload', { workId: opts.workId, filename, mimeType, sizeBytes: file.size, sha256, kind }, opts.signal);
278
+ if (again.mode !== 'single')
279
+ throw new errors_1.BridgeError('CONFLICT', `${filename}: the server changed the upload mode to ${String(again.mode)}; run the tool again.`);
280
+ s3Key = String(again.s3Key ?? '');
281
+ current = { url: String(again.uploadUrl ?? ''), headers: again.headers ?? { 'Content-Type': mimeType } };
282
+ }
283
+ return current;
284
+ }, () => fs.readFile(file.abs));
285
+ row.attempts = put.attempts;
286
+ account(file.size, `${filename}: uploaded`);
287
+ }
288
+ else if (begin.mode === 'multipart') {
289
+ row.mode = 'multipart';
290
+ const uploadId = String(begin.uploadId ?? '');
291
+ multipart = { uploadId, s3Key };
292
+ const partSize = Number(begin.partSize) || 5 * 1024 * 1024;
293
+ const partCount = Math.ceil(file.size / partSize);
294
+ parts = [];
295
+ let attempts = 0;
296
+ for (let partNumber = 1; partNumber <= partCount; partNumber++) {
297
+ const offset = (partNumber - 1) * partSize;
298
+ const length = Math.min(partSize, file.size - offset);
299
+ const put = await putWithRetry(opts, `${filename} part ${partNumber}/${partCount}`, async () => {
300
+ const signed = await remoteData(opts.callRemote, 'pw_assets_sign_part', { workId: opts.workId, uploadId, s3Key, partNumber }, opts.signal);
301
+ return { url: String(signed.url ?? ''), headers: {} };
302
+ }, () => readRange(file.abs, offset, length));
303
+ attempts = Math.max(attempts, put.attempts);
304
+ parts.push({ partNumber, ...(put.etag ? { etag: put.etag } : {}) });
305
+ account(length, `${filename}: part ${partNumber}/${partCount}`);
306
+ }
307
+ row.attempts = attempts;
308
+ }
309
+ else {
310
+ throw new errors_1.BridgeError('INTERNAL', `Unknown upload mode ${String(begin.mode)} for ${filename}.`);
311
+ }
312
+ const complete = await remoteData(opts.callRemote, 'pw_assets_complete_upload', {
313
+ workId: opts.workId,
314
+ s3Key,
315
+ sha256,
316
+ kind,
317
+ mimeType,
318
+ sizeBytes: file.size,
319
+ filename,
320
+ ...(row.width && row.height ? { width: row.width, height: row.height } : {}),
321
+ ...(opts.folderId ? { folderId: opts.folderId } : {}),
322
+ ...(opts.tags?.length ? { tags: opts.tags } : {}),
323
+ ...(multipart && parts ? { multipart: { uploadId: multipart.uploadId, parts } } : {}),
324
+ }, opts.signal);
325
+ multipart = undefined;
326
+ row.assetId = String(complete.id ?? '');
327
+ row.deduplicated = complete.deduplicated === true;
328
+ row.status = row.deduplicated ? 'exists' : 'uploaded';
329
+ row.processing = complete.processing === true;
330
+ row.ok = true;
331
+ // the asset's own warnings replace the provisional ones from begin (same facts, final state)
332
+ const finalWarnings = complete.warnings ?? [];
333
+ if (finalWarnings.length)
334
+ row.warnings = row.warnings.filter((w) => !beginWarnings.includes(w));
335
+ for (const w of finalWarnings)
336
+ if (!row.warnings.includes(w))
337
+ row.warnings.push(w);
338
+ return row;
339
+ }
340
+ catch (e) {
341
+ if (multipart) {
342
+ // best effort: let storage discard the parts of the unfinished upload
343
+ await remoteData(opts.callRemote, 'pw_assets_abort_upload', { workId: opts.workId, uploadId: multipart.uploadId, s3Key: multipart.s3Key }).catch(() => undefined);
344
+ }
345
+ throw e;
346
+ }
347
+ };
348
+ return (0, tool_1.mapLimit)(prepared, opts.concurrency, async (p, index) => {
349
+ let accounted = 0;
350
+ const account = (bytes, message) => {
351
+ accounted += bytes;
352
+ advance(bytes, message);
353
+ };
354
+ if (p.error)
355
+ return finish(p, p.error, 0);
356
+ const owner = owners.get(p.sha256);
357
+ if (owner.index !== index) {
358
+ // same bytes as an earlier file of this batch: wait for it and reuse its asset
359
+ const prior = await owner.promise;
360
+ try {
361
+ if (!prior.ok || !prior.assetId)
362
+ throw new errors_1.BridgeError('PRECONDITION_FAILED', `Same content as ${prior.filename}, which was not uploaded.`);
363
+ if (!opts.skipDuplicates)
364
+ throw new errors_1.BridgeError('CONFLICT', `${p.row.filename} has the same content as ${prior.filename} in this batch (skipDuplicates is false).`);
365
+ }
366
+ catch (e) {
367
+ return finish(p, e, 0);
368
+ }
369
+ Object.assign(p.row, { ok: true, status: 'exists', assetId: prior.assetId, deduplicated: true });
370
+ p.row.warnings.push(`Same content as ${prior.filename} — reused its asset.`);
371
+ account(p.file.size, `${p.row.filename}: duplicate of ${prior.filename}`);
372
+ return p.row;
373
+ }
374
+ try {
375
+ if (opts.signal?.aborted)
376
+ throw new errors_1.BridgeError('CANCELLED', 'The call was cancelled.');
377
+ return await transfer(p, account);
378
+ }
379
+ catch (e) {
380
+ return finish(p, e, accounted);
381
+ }
382
+ finally {
383
+ owner.settle(p.row);
384
+ }
385
+ });
386
+ }
387
+ /** Resolve (and create when missing) a folder path like "Chapter 1/Panels". */
388
+ async function ensureFolder(call, workId, folderPath, signal) {
389
+ const segments = folderPath
390
+ .split('/')
391
+ .map((s) => s.trim())
392
+ .filter(Boolean);
393
+ if (!segments.length)
394
+ throw new errors_1.BridgeError('INVALID_INPUT', 'folderName is empty.');
395
+ let items = [];
396
+ try {
397
+ items = (await remoteData(call, 'pw_folders_list', { workId }, signal)).items ?? [];
398
+ }
399
+ catch (e) {
400
+ const err = (0, errors_1.toBridgeError)(e);
401
+ if (err.code !== 'PERMISSION_DENIED')
402
+ throw err; // without assets:read, fall back to creating
403
+ }
404
+ let parentId;
405
+ let created = false;
406
+ for (let i = 0; i < segments.length; i++) {
407
+ const wanted = segments.slice(0, i + 1).join('/').toLowerCase();
408
+ const hit = items.find((f) => f.path.toLowerCase() === wanted);
409
+ if (hit) {
410
+ parentId = hit.id;
411
+ continue;
412
+ }
413
+ const res = await remoteData(call, 'pw_folders_manage', { action: 'create', workId, name: segments[i], ...(parentId ? { parentId } : {}) }, signal);
414
+ parentId = String(res.folderId ?? '');
415
+ if (!parentId)
416
+ throw new errors_1.BridgeError('INTERNAL', `Creating folder ${segments[i]} returned no id.`);
417
+ items = res.items ?? items;
418
+ created = true;
419
+ }
420
+ return { folderId: parentId, created };
421
+ }
422
+ // ---------------------------------------------------------------------------
423
+ // pw_local_upload_files
424
+ // ---------------------------------------------------------------------------
425
+ const MEDIA_GLOB_HINT = 'images, videos, audio and fonts';
426
+ const uploadInput = zod_1.z
427
+ .object({
428
+ workId: zod_1.z.string().min(1).describe('Work whose asset library receives the files.'),
429
+ folder: zod_1.z.string().optional().describe('Upload the media files of this local folder (absolute, or relative to the first allowed folder).'),
430
+ glob: zod_1.z.string().max(500).optional().describe(`File-name filter for folder, e.g. "*.{png,jpg}" — default: all ${MEDIA_GLOB_HINT}.`),
431
+ recursive: zod_1.z.boolean().default(false).describe('Include subfolders of folder.'),
432
+ paths: zod_1.z.array(zod_1.z.string().min(1)).max(exports.MAX_FILES).optional().describe('Explicit files to upload (instead of or in addition to folder).'),
433
+ folderName: zod_1.z.string().min(1).max(250).optional().describe('Asset-library folder to file the uploads into; created when missing. "A/B" = nested.'),
434
+ folderId: zod_1.z.string().optional().describe('Existing asset-library folder id (instead of folderName).'),
435
+ tags: zod_1.z.array(zod_1.z.string().min(1).max(60)).max(30).optional(),
436
+ skipDuplicates: zod_1.z
437
+ .boolean()
438
+ .default(true)
439
+ .describe('Files whose bytes are already in the library (or twice in this batch) are not uploaded again; their row reports the existing assetId. false = report them as CONFLICT instead.'),
440
+ concurrency: zod_1.z.number().int().min(1).max(6).default(3).describe('Files uploaded in parallel.'),
441
+ })
442
+ .refine((a) => a.folder || a.paths?.length, { message: 'Pass folder or paths.', path: ['folder'] })
443
+ .refine((a) => !(a.folderName && a.folderId), { message: 'Pass folderName or folderId, not both.', path: ['folderId'] });
444
+ const zRow = zod_1.z.object({
445
+ path: zod_1.z.string(),
446
+ filename: zod_1.z.string(),
447
+ ok: zod_1.z.boolean(),
448
+ status: zod_1.z.enum(['uploaded', 'exists', 'failed', 'cancelled']),
449
+ assetId: zod_1.z.string().optional(),
450
+ deduplicated: zod_1.z.boolean(),
451
+ kind: zod_1.z.string().optional(),
452
+ mimeType: zod_1.z.string(),
453
+ sizeBytes: zod_1.z.number().int(),
454
+ sha256: zod_1.z.string().optional(),
455
+ width: zod_1.z.number().int().optional(),
456
+ height: zod_1.z.number().int().optional(),
457
+ mode: zod_1.z.enum(['single', 'multipart']).optional(),
458
+ attempts: zod_1.z.number().int().optional(),
459
+ processing: zod_1.z.boolean().optional(),
460
+ warnings: zod_1.z.array(zod_1.z.string()),
461
+ error: zod_1.z.object({ code: zod_1.z.string(), message: zod_1.z.string() }).optional(),
462
+ });
463
+ const uploadOutput = zod_1.z.object({
464
+ workId: zod_1.z.string(),
465
+ folderId: zod_1.z.string().optional(),
466
+ folderCreated: zod_1.z.boolean().optional(),
467
+ files: zod_1.z.array(zRow),
468
+ uploaded: zod_1.z.number().int(),
469
+ existing: zod_1.z.number().int(),
470
+ failed: zod_1.z.number().int(),
471
+ totalBytes: zod_1.z.number().int(),
472
+ durationMs: zod_1.z.number().int(),
473
+ skipped: zod_1.z.array(zod_1.z.string()).describe('Folder entries left out (not media, unreadable or outside the allowed folders).'),
474
+ });
475
+ exports.uploadFiles = (0, tool_1.defineLocalTool)({
476
+ name: 'pw_local_upload_files',
477
+ title: 'Upload local files to PanelWave',
478
+ description: 'Upload images, videos, audio or fonts from this computer into a work’s asset library — a whole folder (optionally filtered by glob) or a list of paths. Each file is hashed (sha-256) so content already in the library is reused instead of uploaded again; image dimensions are read locally (EXIF orientation applied). Files ≥ 10 MB go up in 5 MiB parts with retries. Optionally files everything into an asset folder (folderName is created when missing). Re-running after a failure resumes: finished files are recognised and skipped. Afterwards attach artwork with pw_panels_attach_artwork (by file name).',
479
+ input: uploadInput,
480
+ output: uploadOutput,
481
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
482
+ async run(args, ctx) {
483
+ if (!ctx.callRemote)
484
+ throw new errors_1.BridgeError('UPSTREAM_UNAVAILABLE', 'Uploading needs the connection to the PanelWave server.');
485
+ const started = Date.now();
486
+ const files = [];
487
+ const skipped = [];
488
+ const seen = new Set();
489
+ const add = (abs, display, size) => {
490
+ const key = process.platform === 'win32' ? abs.toLowerCase() : abs;
491
+ if (seen.has(key))
492
+ return;
493
+ seen.add(key);
494
+ files.push({ abs, display, size });
495
+ };
496
+ if (args.folder) {
497
+ const scan = await (0, files_1.scanFolder)(args.folder, ctx.allowedDirs, {
498
+ glob: args.glob,
499
+ accept: args.glob ? undefined : (name) => exports.UPLOADABLE_KINDS.has((0, files_1.kindOfMime)((0, files_1.mimeOf)(name))),
500
+ recursive: args.recursive,
501
+ limit: exports.MAX_FILES,
502
+ signal: ctx.signal,
503
+ });
504
+ if (scan.truncated)
505
+ throw new errors_1.BridgeError('INVALID_INPUT', `More than ${exports.MAX_FILES} files match in ${scan.root}; narrow the glob or upload subfolders one at a time.`);
506
+ skipped.push(...scan.skipped);
507
+ for (const f of scan.found)
508
+ add(f.abs, f.rel, f.size);
509
+ }
510
+ for (const p of args.paths ?? []) {
511
+ const abs = (0, sandbox_1.resolveAllowed)(p, ctx.allowedDirs);
512
+ const stat = await fs.stat(abs).catch((e) => {
513
+ throw new errors_1.BridgeError((0, errors_1.toBridgeError)(e).code, `${p}: ${(0, errors_1.toBridgeError)(e).message}`);
514
+ });
515
+ if (!stat.isFile())
516
+ throw new errors_1.BridgeError('INVALID_INPUT', `${p} is not a file.`);
517
+ add(abs, p, stat.size);
518
+ }
519
+ if (files.length > exports.MAX_FILES)
520
+ throw new errors_1.BridgeError('INVALID_INPUT', `At most ${exports.MAX_FILES} files per call.`);
521
+ if (!files.length)
522
+ throw new errors_1.BridgeError('NOT_FOUND', `No ${args.glob ? `files matching ${args.glob}` : MEDIA_GLOB_HINT} to upload.`);
523
+ if (!args.paths?.length)
524
+ files.sort((a, b) => (0, files_1.naturalCompare)(a.display, b.display));
525
+ let folderId = args.folderId;
526
+ let folderCreated;
527
+ if (args.folderName) {
528
+ const f = await ensureFolder(ctx.callRemote, args.workId, args.folderName, ctx.signal);
529
+ folderId = f.folderId;
530
+ folderCreated = f.created;
531
+ }
532
+ let lastSent = 0;
533
+ const rows = await uploadBatch({
534
+ workId: args.workId,
535
+ files,
536
+ callRemote: ctx.callRemote,
537
+ folderId,
538
+ tags: args.tags,
539
+ skipDuplicates: args.skipDuplicates,
540
+ concurrency: args.concurrency,
541
+ signal: ctx.signal,
542
+ onProgress: (doneBytes, totalBytes, message) => {
543
+ // throttle: every 2 % (and always the last one)
544
+ if (doneBytes < totalBytes && doneBytes - lastSent < totalBytes / 50)
545
+ return;
546
+ lastSent = doneBytes;
547
+ void ctx.progress?.(doneBytes, totalBytes, message);
548
+ },
549
+ });
550
+ // existing assets were not uploaded with the folder — file them too
551
+ const warnings = [];
552
+ const reused = rows.filter((r) => r.ok && r.status === 'exists' && r.assetId).map((r) => r.assetId);
553
+ if (folderId && reused.length) {
554
+ try {
555
+ await remoteData(ctx.callRemote, 'pw_folders_manage', { action: 'assign', workId: args.workId, assetIds: [...new Set(reused)], folderIds: [folderId], mode: 'add' }, ctx.signal);
556
+ }
557
+ catch (e) {
558
+ warnings.push(`Existing assets could not be filed into the folder (${(0, errors_1.toBridgeError)(e).message}).`);
559
+ }
560
+ }
561
+ const uploaded = rows.filter((r) => r.status === 'uploaded').length;
562
+ const existing = rows.filter((r) => r.status === 'exists').length;
563
+ const failed = rows.filter((r) => !r.ok).length;
564
+ const totalBytes = files.reduce((n, f) => n + f.size, 0);
565
+ const durationMs = Date.now() - started;
566
+ const lines = rows.slice(0, 200).map((r) => r.ok
567
+ ? `- ${r.filename} → ${r.assetId}${r.status === 'exists' ? ' (already in the library)' : ''}${r.width ? ` ${r.width}×${r.height}` : ''}`
568
+ : `- ${r.filename}: ${r.status === 'cancelled' ? 'cancelled' : `${r.error?.code}: ${r.error?.message}`}`);
569
+ const text = [
570
+ `${uploaded} uploaded, ${existing} already in the library, ${failed} failed — ${rows.length} file${rows.length === 1 ? '' : 's'}, ${(0, tool_1.formatBytes)(totalBytes)} in ${(durationMs / 1000).toFixed(1)} s${folderId ? `, filed into folder ${args.folderName ?? folderId}` : ''}.`,
571
+ ...lines,
572
+ ...(rows.length > lines.length ? [`… and ${rows.length - lines.length} more.`] : []),
573
+ ...warnings,
574
+ ...(failed ? ['Run the tool again to retry the failed files — finished ones are skipped.'] : []),
575
+ ...(uploaded || existing ? ['Next: pw_panels_attach_artwork attaches the images to panels by file name.'] : []),
576
+ ].join('\n');
577
+ return {
578
+ text,
579
+ data: { workId: args.workId, ...(folderId ? { folderId } : {}), ...(folderCreated !== undefined ? { folderCreated } : {}), files: rows, uploaded, existing, failed, totalBytes, durationMs, skipped },
580
+ };
581
+ },
582
+ });
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
- {
2
- "name": "@panelwave/mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "name": "@panelwave/mcp",
3
+ "version": "0.1.0",
4
+ "description": "Local MCP bridge for PanelWave: connects Claude Desktop, Claude Code, Cursor and other MCP clients to the hosted PanelWave MCP server and adds local file tools (list, read, upload a folder of panels).",
5
+ "main": "dist/index.js",
6
+ "bin": {
7
+ "panelwave-mcp": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "engines": {
13
+ "node": ">=20"
14
+ },
15
+ "scripts": {
16
+ "build": "tsc -p tsconfig.build.json",
17
+ "test": "jest",
18
+ "prepublishOnly": "npm run build"
19
+ },
20
+ "keywords": [
21
+ "panelwave",
22
+ "mcp",
23
+ "model-context-protocol",
24
+ "graphic-novel",
25
+ "claude"
26
+ ],
27
+ "license": "MIT",
28
+ "homepage": "https://github.com/panelwave/packages/tree/master/packages/mcp#readme",
29
+ "bugs": {
30
+ "url": "https://github.com/panelwave/packages/issues"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/panelwave/packages.git",
35
+ "directory": "packages/mcp"
36
+ },
37
+ "dependencies": {
38
+ "@modelcontextprotocol/client": "^2.2.0",
39
+ "@modelcontextprotocol/server": "^2.2.0",
40
+ "image-size": "^2.0.2",
41
+ "zod": "^4.2.0"
42
+ },
43
+ "devDependencies": {
44
+ "@types/jest": "^29.5.14",
45
+ "@types/node": "^20.14.0",
46
+ "jest": "^29.7.0",
47
+ "ts-jest": "^29.2.5",
48
+ "typescript": "^5.4.0"
49
+ }
50
+ }