@obrahaus/fileforge-mcp 2.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.
Files changed (3) hide show
  1. package/README.md +71 -0
  2. package/dist/index.js +1380 -0
  3. package/package.json +40 -0
package/dist/index.js ADDED
@@ -0,0 +1,1380 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+
6
+ // src/config.ts
7
+ var DEFAULT_BASE_URL = "https://fileforge-api.obrahaus.com/v1";
8
+ var ConfigError = class extends Error {
9
+ };
10
+ function loadConfig(env = process.env) {
11
+ const apiKey = (env.FILEFORGE_API_KEY ?? "").trim();
12
+ if (!apiKey) {
13
+ throw new ConfigError(
14
+ 'FILEFORGE_API_KEY is not set. Add it to your MCP client config, e.g. {"command":"npx","args":["-y","@obrahaus/fileforge-mcp"],"env":{"FILEFORGE_API_KEY":"ff_live_..."}}. Create a key at your FileForge account \u2192 Settings \u2192 API keys.'
15
+ );
16
+ }
17
+ if (!/^ff_live_[0-9A-Za-z]{8,}$/.test(apiKey)) {
18
+ throw new ConfigError(
19
+ 'FILEFORGE_API_KEY does not look like a FileForge key (expected "ff_live_\u2026"). Double-check the value.'
20
+ );
21
+ }
22
+ const rawBaseUrl = (env.FILEFORGE_API_BASE_URL ?? "").trim();
23
+ const baseUrl = stripTrailingSlash(rawBaseUrl.length > 0 ? rawBaseUrl : DEFAULT_BASE_URL);
24
+ if (!/^https?:\/\/.+/i.test(baseUrl)) {
25
+ throw new ConfigError(
26
+ `FILEFORGE_API_BASE_URL must be an http(s) URL (got "${baseUrl}"). Omit it to use the default ${DEFAULT_BASE_URL}.`
27
+ );
28
+ }
29
+ return {
30
+ apiKey,
31
+ baseUrl,
32
+ defaultPreset: env.FILEFORGE_DEFAULT_PRESET?.trim() || void 0,
33
+ maxPollSeconds: clampInt(env.FF_MCP_MAX_POLL_SECONDS, 300, 1, 3600),
34
+ maxInlineBytes: clampInt(env.FF_MCP_MAX_INLINE_BYTES, 5 * 1024 * 1024, 0, 64 * 1024 * 1024),
35
+ // URL / base64 sources are buffered in memory before upload, so cap them by
36
+ // default (override per-deployment). Large files should use a local `path`,
37
+ // which streams to storage with flat memory.
38
+ maxDownloadUrlBytes: clampInt(env.FF_MCP_MAX_DOWNLOAD_URL_BYTES, 100 * 1024 * 1024, 1, Number.MAX_SAFE_INTEGER),
39
+ outputDir: env.FF_MCP_OUTPUT_DIR?.trim() || void 0,
40
+ controlTimeoutMs: clampInt(env.FF_MCP_HTTP_TIMEOUT_MS, 3e4, 1e3, 6e5),
41
+ streamTimeoutMs: clampInt(env.FF_MCP_STREAM_TIMEOUT_MS, 6e5, 5e3, 36e5),
42
+ logLevel: parseLogLevel(env.FF_MCP_LOG_LEVEL)
43
+ };
44
+ }
45
+ function stripTrailingSlash(s) {
46
+ return s.endsWith("/") ? s.slice(0, -1) : s;
47
+ }
48
+ function clampInt(raw, fallback, lo, hi) {
49
+ if (raw === void 0 || raw === "") return fallback;
50
+ const n = Number.parseInt(raw, 10);
51
+ if (!Number.isFinite(n)) return fallback;
52
+ return Math.max(lo, Math.min(hi, n));
53
+ }
54
+ function parseLogLevel(raw) {
55
+ const v = (raw ?? "info").toLowerCase();
56
+ return v === "debug" || v === "info" || v === "warn" || v === "error" ? v : "info";
57
+ }
58
+
59
+ // src/errors.ts
60
+ var ToolError = class extends Error {
61
+ code;
62
+ retryAfterSeconds;
63
+ constructor(code, message, retryAfterSeconds) {
64
+ super(message);
65
+ this.name = "ToolError";
66
+ this.code = code;
67
+ this.retryAfterSeconds = retryAfterSeconds;
68
+ }
69
+ };
70
+ function friendlyApiError(status, body, ctx) {
71
+ const code = body?.error ?? body?.code ?? `http_${status}`;
72
+ const apiMsg = body?.message ?? `request failed with HTTP ${status}`;
73
+ switch (code) {
74
+ case "unauthorized":
75
+ return new ToolError(code, "FileForge rejected the API key (401). Check FILEFORGE_API_KEY is a valid, non-revoked ff_live_ key.");
76
+ case "forbidden":
77
+ case "pro_option":
78
+ return new ToolError(code, `${apiMsg} This requires a Forge Pro plan. Use a Pro API key or drop the Pro-only option/target.`);
79
+ case "unsupported_pair":
80
+ return new ToolError(code, ctx?.validTargets?.length ? `${apiMsg} Valid targets for this source: ${ctx.validTargets.join(", ")}. Call list_formats to confirm.` : `${apiMsg} Call list_formats to see valid target formats for this source.`);
81
+ case "unknown_preset":
82
+ return new ToolError(code, `${apiMsg} Call list_formats to see valid preset ids for this source\u2192target pair.`);
83
+ case "unknown_option":
84
+ case "invalid_value":
85
+ case "not_applicable":
86
+ return new ToolError(code, `${apiMsg} Call list_formats to see the valid options (with ranges) for this target.`);
87
+ case "file_too_large":
88
+ return new ToolError(code, ctx?.tierMaxMb ? `File too large: your plan allows up to ${ctx.tierMaxMb} MB${ctx.fileMb ? ` and this file is ${ctx.fileMb} MB` : ""}. Upgrade or use a smaller file.` : `${apiMsg}`);
89
+ case "mime_mismatch":
90
+ return new ToolError(code, `${apiMsg} The file's real content type didn't match its extension \u2014 make sure the file isn't renamed/corrupt.`);
91
+ case "malware_detected":
92
+ return new ToolError(code, "The file failed the virus scan and was rejected. It was not converted.");
93
+ case "scan_size_limit_exceeded":
94
+ return new ToolError(code, "The file is larger than the antivirus scanner accepts, so it could not be processed.");
95
+ case "rate_limited":
96
+ return new ToolError(code, `Rate limit hit. ${ctx?.retryAfterSeconds ? `Retry after ${ctx.retryAfterSeconds}s.` : "Wait a bit and retry."}`, ctx?.retryAfterSeconds);
97
+ case "maintenance":
98
+ return new ToolError(code, "FileForge is in maintenance mode right now. Retry shortly.");
99
+ default:
100
+ if (status === 429) return new ToolError("rate_limited", `Rate limit hit (429). ${ctx?.retryAfterSeconds ? `Retry after ${ctx.retryAfterSeconds}s.` : "Wait and retry."}`, ctx?.retryAfterSeconds);
101
+ if (status >= 500) return new ToolError(code, `FileForge had a server error (${status}). Retry shortly.`);
102
+ return new ToolError(code, apiMsg);
103
+ }
104
+ }
105
+
106
+ // src/client.ts
107
+ var FileForgeClient = class {
108
+ constructor(cfg) {
109
+ this.cfg = cfg;
110
+ }
111
+ getFormats() {
112
+ return this.req("GET", "/formats");
113
+ }
114
+ presignUpload(body) {
115
+ return this.req("POST", "/files/presign-upload", body);
116
+ }
117
+ finalize(fileId, body) {
118
+ return this.req("POST", `/files/${fileId}/finalize`, body);
119
+ }
120
+ createConversion(body) {
121
+ return this.req("POST", "/conversions", body);
122
+ }
123
+ getConversion(id) {
124
+ return this.req("GET", `/conversions/${id}`);
125
+ }
126
+ createBulk(body) {
127
+ return this.req("POST", "/conversions/bulk", body);
128
+ }
129
+ async req(method, path, jsonBody) {
130
+ const ctrl = new AbortController();
131
+ const timer = setTimeout(() => ctrl.abort(), this.cfg.controlTimeoutMs);
132
+ let res;
133
+ try {
134
+ res = await fetch(this.cfg.baseUrl + path, {
135
+ method,
136
+ headers: {
137
+ Authorization: `Bearer ${this.cfg.apiKey}`,
138
+ Accept: "application/json",
139
+ ...jsonBody !== void 0 ? { "Content-Type": "application/json" } : {}
140
+ },
141
+ body: jsonBody !== void 0 ? JSON.stringify(jsonBody) : void 0,
142
+ signal: ctrl.signal
143
+ });
144
+ } catch (e) {
145
+ throw new ToolError("network_error", `could not reach FileForge at ${this.cfg.baseUrl} \u2014 ${e instanceof Error ? e.message : String(e)}`);
146
+ } finally {
147
+ clearTimeout(timer);
148
+ }
149
+ const text = await res.text();
150
+ const data = text ? safeParse(text) : null;
151
+ if (!res.ok) {
152
+ const retryAfter = parseRetryAfterSeconds(res.headers.get("retry-after"));
153
+ throw friendlyApiError(res.status, data, { retryAfterSeconds: retryAfter });
154
+ }
155
+ return data;
156
+ }
157
+ };
158
+ function safeParse(text) {
159
+ try {
160
+ return JSON.parse(text);
161
+ } catch {
162
+ return { message: text.slice(0, 300) };
163
+ }
164
+ }
165
+ function parseRetryAfterSeconds(value) {
166
+ if (!value) return void 0;
167
+ const delta = Number(value);
168
+ if (Number.isFinite(delta)) return delta >= 0 ? delta : void 0;
169
+ const at = Date.parse(value);
170
+ if (Number.isFinite(at)) return Math.max(0, Math.ceil((at - Date.now()) / 1e3));
171
+ return void 0;
172
+ }
173
+
174
+ // src/log.ts
175
+ var ORDER = { debug: 10, info: 20, warn: 30, error: 40 };
176
+ var threshold = ORDER.info;
177
+ function setLogLevel(level) {
178
+ threshold = ORDER[level];
179
+ }
180
+ function log(level, message, extra) {
181
+ if (ORDER[level] < threshold) return;
182
+ const suffix = extra ? " " + safeJson(extra) : "";
183
+ process.stderr.write(`[fileforge-mcp] ${level}: ${message}${suffix}
184
+ `);
185
+ }
186
+ function safeJson(o) {
187
+ try {
188
+ return JSON.stringify(o);
189
+ } catch {
190
+ return "[unserializable]";
191
+ }
192
+ }
193
+
194
+ // src/server.ts
195
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
196
+
197
+ // src/tools/list-formats.ts
198
+ import { z as z2 } from "zod";
199
+
200
+ // src/tools/shared.ts
201
+ import { z } from "zod";
202
+
203
+ // ../../packages/config/src/tiers.ts
204
+ var TIERS = {
205
+ free: {
206
+ daily_limit: 5,
207
+ max_file_mb: 10,
208
+ ttl_hours: 24,
209
+ bulk_max: 1,
210
+ priority: 5,
211
+ allow_metadata_preserve: false
212
+ },
213
+ pro: {
214
+ daily_limit: null,
215
+ max_file_mb: 500,
216
+ ttl_hours: 24 * 7,
217
+ bulk_max: 20,
218
+ priority: 1,
219
+ allow_metadata_preserve: true
220
+ },
221
+ max: {
222
+ daily_limit: null,
223
+ max_file_mb: 2048,
224
+ ttl_hours: 24 * 30,
225
+ bulk_max: 100,
226
+ priority: 1,
227
+ allow_metadata_preserve: true
228
+ }
229
+ };
230
+
231
+ // ../../packages/config/src/formats.ts
232
+ var IMAGE_PRESETS = [
233
+ { id: "web", label: "Web (q=82)", params: { quality: 82 } },
234
+ { id: "high", label: "High quality (q=92)", params: { quality: 92 } },
235
+ { id: "lossless", label: "Lossless / max", params: { quality: 100 } }
236
+ ];
237
+ var ICO_PRESETS = [{ id: "standard", label: "Standard", params: {} }];
238
+ var AUDIO_LOSSY_PRESETS = [
239
+ { id: "low", label: "Low (96 kbps)", params: { bitrate: "96k" } },
240
+ { id: "web", label: "Web (128 kbps)", params: { bitrate: "128k" } },
241
+ { id: "high", label: "High (192 kbps)", params: { bitrate: "192k" } },
242
+ { id: "very-high", label: "Very high (320 kbps)", params: { bitrate: "320k" } }
243
+ ];
244
+ var AUDIO_LOSSLESS_PRESETS = [
245
+ { id: "standard", label: "Standard (44.1 kHz)", params: { sample_rate: 44100 } },
246
+ { id: "studio", label: "Studio (48 kHz)", params: { sample_rate: 48e3 } }
247
+ ];
248
+ var FLAC_PRESETS = [
249
+ { id: "fast", label: "Fast (level 5)", params: { compression: 5 } },
250
+ { id: "best", label: "Best (level 8)", params: { compression: 8 } }
251
+ ];
252
+ var VIDEO_PRESETS = [
253
+ { id: "web-720p", label: "720p web", params: { resolution: "1280x720", crf: 23, preset: "p4" } },
254
+ { id: "web-1080p", label: "1080p web", params: { resolution: "1920x1080", crf: 22, preset: "p4" } },
255
+ { id: "high-1080p", label: "1080p high", params: { resolution: "1920x1080", crf: 18, preset: "p5" } }
256
+ ];
257
+ var DOCUMENT_PRESETS = [
258
+ { id: "standard", label: "Standard", params: {} }
259
+ ];
260
+ var PDF_PRESETS = [
261
+ { id: "standard", label: "Standard", params: {} },
262
+ { id: "linearize", label: "Linearized (web-optimized)", params: { linearize: true } }
263
+ ];
264
+ var ARCHIVE_PRESETS = [
265
+ { id: "fast", label: "Fast (level 1)", params: { compression: 1 } },
266
+ { id: "standard", label: "Standard (level 5)", params: { compression: 5 } },
267
+ { id: "best", label: "Best (level 9)", params: { compression: 9 } }
268
+ ];
269
+ var IMAGE_TARGETS = ["png", "jpg", "webp", "gif", "tiff", "avif", "ico"];
270
+ function imageTargetsFor(sourceSlug) {
271
+ const out = {};
272
+ for (const t of IMAGE_TARGETS) {
273
+ if (t === sourceSlug) continue;
274
+ out[t] = {
275
+ worker: "image",
276
+ freeAllowed: true,
277
+ presets: t === "ico" ? ICO_PRESETS : IMAGE_PRESETS
278
+ };
279
+ }
280
+ return out;
281
+ }
282
+ function audioEntry(target, opts) {
283
+ let presets;
284
+ if (target === "flac") presets = FLAC_PRESETS;
285
+ else if (target === "wav") presets = AUDIO_LOSSLESS_PRESETS;
286
+ else presets = AUDIO_LOSSY_PRESETS;
287
+ return { worker: "audio", freeAllowed: opts.freeAllowed, presets };
288
+ }
289
+ function audioTargetsFor(source) {
290
+ const free = /* @__PURE__ */ new Set(["mp3", "wav", "ogg", "opus"]);
291
+ const all = ["mp3", "wav", "ogg", "opus", "flac", "aac", "m4a"];
292
+ const out = {};
293
+ for (const t of all) {
294
+ if (t === source) continue;
295
+ out[t] = audioEntry(t, { freeAllowed: free.has(t) });
296
+ }
297
+ return out;
298
+ }
299
+ function videoTargetsFor(source) {
300
+ const free = /* @__PURE__ */ new Set(["mp4", "webm"]);
301
+ const all = ["mp4", "webm", "mov", "mkv"];
302
+ const out = {};
303
+ for (const t of all) {
304
+ if (t === source) continue;
305
+ out[t] = { worker: "video", freeAllowed: free.has(t), presets: VIDEO_PRESETS };
306
+ }
307
+ return out;
308
+ }
309
+ var DOC_FAMILY = ["pdf", "docx", "doc", "odt", "rtf", "txt", "html", "md"];
310
+ function documentTargetsFor(source) {
311
+ const free = /* @__PURE__ */ new Set(["txt", "md", "html", "pdf"]);
312
+ const out = {};
313
+ for (const t of DOC_FAMILY) {
314
+ if (t === source) continue;
315
+ out[t] = {
316
+ worker: "document",
317
+ freeAllowed: free.has(t),
318
+ presets: t === "pdf" ? PDF_PRESETS : DOCUMENT_PRESETS
319
+ };
320
+ }
321
+ return out;
322
+ }
323
+ function spreadsheetTargets() {
324
+ return {
325
+ pdf: { worker: "document", freeAllowed: false, presets: PDF_PRESETS }
326
+ };
327
+ }
328
+ function presentationTargets() {
329
+ return {
330
+ pdf: { worker: "document", freeAllowed: false, presets: PDF_PRESETS }
331
+ };
332
+ }
333
+ var ARCHIVE_FAMILY = ["zip", "7z", "tar", "tar.gz", "tar.bz2"];
334
+ function archiveTargetsFor(source) {
335
+ const free = /* @__PURE__ */ new Set(["zip", "tar", "tar.gz", "tar.bz2"]);
336
+ const out = {};
337
+ for (const t of ARCHIVE_FAMILY) {
338
+ if (t === source) continue;
339
+ out[t] = {
340
+ worker: "archive",
341
+ freeAllowed: free.has(t),
342
+ presets: ARCHIVE_PRESETS
343
+ };
344
+ }
345
+ return out;
346
+ }
347
+ var FORMATS = {
348
+ // Image — PNG/JPEG/WebP/GIF/TIFF/AVIF interconvert + ICO favicon output.
349
+ // SVG is raster-in only (no SVG target). 'jpg' is the slug for image/jpeg.
350
+ "image/png": imageTargetsFor("png"),
351
+ "image/jpeg": imageTargetsFor("jpg"),
352
+ "image/webp": imageTargetsFor("webp"),
353
+ "image/gif": imageTargetsFor("gif"),
354
+ "image/tiff": imageTargetsFor("tiff"),
355
+ "image/avif": imageTargetsFor("avif"),
356
+ "image/svg+xml": imageTargetsFor(null),
357
+ // Audio
358
+ "audio/mpeg": audioTargetsFor("mp3"),
359
+ "audio/wav": audioTargetsFor("wav"),
360
+ "audio/flac": audioTargetsFor("flac"),
361
+ "audio/ogg": audioTargetsFor("ogg"),
362
+ "audio/opus": audioTargetsFor("opus"),
363
+ "audio/aac": audioTargetsFor("aac"),
364
+ "audio/mp4": audioTargetsFor("m4a"),
365
+ // Video
366
+ "video/mp4": videoTargetsFor("mp4"),
367
+ "video/webm": videoTargetsFor("webm"),
368
+ "video/quicktime": videoTargetsFor("mov"),
369
+ "video/x-matroska": videoTargetsFor("mkv"),
370
+ // Document — word-processor family
371
+ "application/pdf": documentTargetsFor("pdf"),
372
+ "application/msword": documentTargetsFor("doc"),
373
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": documentTargetsFor("docx"),
374
+ "application/vnd.oasis.opendocument.text": documentTargetsFor("odt"),
375
+ "application/rtf": documentTargetsFor("rtf"),
376
+ "text/plain": documentTargetsFor("txt"),
377
+ "text/html": documentTargetsFor("html"),
378
+ "text/markdown": documentTargetsFor("md"),
379
+ // Spreadsheets and presentations: PDF-only output lane (Pro).
380
+ "application/vnd.ms-excel": spreadsheetTargets(),
381
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": spreadsheetTargets(),
382
+ "application/vnd.oasis.opendocument.spreadsheet": spreadsheetTargets(),
383
+ "application/vnd.ms-powerpoint": presentationTargets(),
384
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation": presentationTargets(),
385
+ "application/vnd.oasis.opendocument.presentation": presentationTargets(),
386
+ // Archive family — repack, content-passthrough.
387
+ "application/zip": archiveTargetsFor("zip"),
388
+ "application/x-7z-compressed": archiveTargetsFor("7z"),
389
+ "application/x-tar": archiveTargetsFor("tar"),
390
+ "application/gzip": archiveTargetsFor("tar.gz"),
391
+ "application/x-gzip": archiveTargetsFor("tar.gz"),
392
+ "application/x-bzip2": archiveTargetsFor("tar.bz2"),
393
+ "application/vnd.rar": archiveTargetsFor("rar"),
394
+ "application/x-rar-compressed": archiveTargetsFor("rar")
395
+ };
396
+ var MIME_ALIASES = {
397
+ // Image
398
+ "image/jpg": "image/jpeg",
399
+ "image/pjpeg": "image/jpeg",
400
+ "image/x-png": "image/png",
401
+ "image/tif": "image/tiff",
402
+ "image/x-tiff": "image/tiff",
403
+ "image/svg": "image/svg+xml",
404
+ // Audio
405
+ "audio/mp3": "audio/mpeg",
406
+ "audio/mpeg3": "audio/mpeg",
407
+ "audio/x-mpeg": "audio/mpeg",
408
+ "audio/x-wav": "audio/wav",
409
+ "audio/wave": "audio/wav",
410
+ "audio/vnd.wave": "audio/wav",
411
+ "audio/x-flac": "audio/flac",
412
+ "audio/x-aac": "audio/aac",
413
+ "audio/x-m4a": "audio/mp4",
414
+ "audio/m4a": "audio/mp4",
415
+ "audio/x-ogg": "audio/ogg",
416
+ // Video
417
+ "video/x-matroska-3d": "video/x-matroska",
418
+ "video/x-quicktime": "video/quicktime",
419
+ // Document — common aliases that file-type / browsers emit.
420
+ "application/x-pdf": "application/pdf",
421
+ "text/x-markdown": "text/markdown",
422
+ "text/x-md": "text/markdown",
423
+ "application/x-rtf": "application/rtf",
424
+ "text/rtf": "application/rtf",
425
+ // Archive
426
+ "application/x-zip-compressed": "application/zip",
427
+ "application/x-7z": "application/x-7z-compressed",
428
+ "application/x-gtar": "application/x-tar",
429
+ "application/x-bzip": "application/x-bzip2",
430
+ "application/bzip2": "application/x-bzip2",
431
+ "application/x-rar": "application/vnd.rar"
432
+ };
433
+ function canonicalMime(mime) {
434
+ return MIME_ALIASES[mime] ?? mime;
435
+ }
436
+ function lookupFormat(sourceMime, targetFormat) {
437
+ const targets = FORMATS[canonicalMime(sourceMime)];
438
+ if (!targets) return null;
439
+ return targets[targetFormat] ?? null;
440
+ }
441
+ function findPreset(entry, presetId) {
442
+ return entry.presets.find((p) => p.id === presetId) ?? null;
443
+ }
444
+
445
+ // ../../packages/config/src/options.ts
446
+ var SECRET_OPTION_KEYS = /* @__PURE__ */ new Set(["password", "pdf_password"]);
447
+ var PRINTABLE_ASCII = /^[\x20-\x7E]+$/;
448
+ var IMAGE_OPTIONS = [
449
+ { key: "width", label: "Width (px)", type: "integer", min: 1, max: 3e4, group: "Resize", help: "Output width in pixels", excludeTargets: ["ico"] },
450
+ { key: "height", label: "Height (px)", type: "integer", min: 1, max: 3e4, group: "Resize", help: "Output height in pixels", excludeTargets: ["ico"] },
451
+ {
452
+ key: "fit",
453
+ label: "Resize fit",
454
+ type: "enum",
455
+ values: ["inside", "outside", "cover", "contain", "fill"],
456
+ default: "inside",
457
+ group: "Resize",
458
+ help: "How the image fits the width/height box",
459
+ excludeTargets: ["ico"]
460
+ },
461
+ { key: "rotate", label: "Rotate", type: "enum", values: ["0", "90", "180", "270"], default: "0", group: "Transform" },
462
+ { key: "flip", label: "Flip (vertical)", type: "boolean", group: "Transform" },
463
+ { key: "flop", label: "Flop (horizontal)", type: "boolean", group: "Transform" },
464
+ { key: "grayscale", label: "Grayscale", type: "boolean", group: "Color" },
465
+ { key: "quality", label: "Quality", type: "integer", min: 1, max: 100, step: 1, group: "Quality", help: "Overrides the preset quality", excludeTargets: ["ico"] },
466
+ { key: "dpi", label: "DPI", type: "integer", min: 1, max: 2400, group: "Metadata", excludeTargets: ["ico"] },
467
+ {
468
+ key: "sizes",
469
+ label: "Icon sizes",
470
+ type: "string",
471
+ default: "16,32,48",
472
+ pattern: "^(16|32|48|64|128|256)(,(16|32|48|64|128|256))*$",
473
+ maxLength: 64,
474
+ group: "Favicon",
475
+ help: "Comma-separated icon sizes: 16,32,48,64,128,256",
476
+ targets: ["ico"]
477
+ }
478
+ ];
479
+ var AUDIO_OPTIONS = [
480
+ { key: "channels", label: "Channels", type: "enum", values: ["mono", "stereo"], group: "Audio" },
481
+ { key: "normalize", label: "Normalize loudness", type: "boolean", group: "Audio", help: "EBU R128 loudness normalization" },
482
+ { key: "trim_start", label: "Trim start (sec)", type: "number", min: 0, max: 86400, group: "Trim" },
483
+ { key: "trim_end", label: "Trim end (sec)", type: "number", min: 0, max: 86400, group: "Trim", help: "Must be greater than trim start" }
484
+ ];
485
+ var VIDEO_OPTIONS = [
486
+ { key: "fps", label: "Frame rate (fps)", type: "integer", min: 1, max: 120, group: "Video" },
487
+ { key: "mute", label: "Mute (remove audio)", type: "boolean", group: "Audio" },
488
+ { key: "rotate", label: "Rotate", type: "enum", values: ["0", "90", "180", "270"], default: "0", group: "Transform" },
489
+ { key: "trim_start", label: "Trim start (sec)", type: "number", min: 0, max: 86400, group: "Trim" },
490
+ { key: "trim_end", label: "Trim end (sec)", type: "number", min: 0, max: 86400, group: "Trim", help: "Must be greater than trim start" }
491
+ ];
492
+ var DOCUMENT_OPTIONS = [
493
+ {
494
+ key: "page_range",
495
+ label: "Page range",
496
+ type: "string",
497
+ pattern: "^[0-9]+(-[0-9]+)?(,[0-9]+(-[0-9]+)?)*$",
498
+ maxLength: 64,
499
+ group: "PDF",
500
+ help: "Pages to keep, e.g. 1-3,5 (PDF output only)",
501
+ targets: ["pdf"]
502
+ },
503
+ {
504
+ key: "password",
505
+ label: "Password (encrypt)",
506
+ type: "string",
507
+ maxLength: 128,
508
+ proOnly: true,
509
+ group: "PDF",
510
+ help: "Encrypt the output PDF (AES-256)",
511
+ targets: ["pdf"]
512
+ }
513
+ ];
514
+ var ARCHIVE_OPTIONS = [
515
+ {
516
+ key: "password",
517
+ label: "Password (encrypt)",
518
+ type: "string",
519
+ maxLength: 128,
520
+ proOnly: true,
521
+ group: "Security",
522
+ help: "Produce an AES-256 encrypted archive",
523
+ targets: ["zip", "7z"]
524
+ }
525
+ ];
526
+ var OPTION_SPECS = {
527
+ image: IMAGE_OPTIONS,
528
+ audio: AUDIO_OPTIONS,
529
+ video: VIDEO_OPTIONS,
530
+ document: DOCUMENT_OPTIONS,
531
+ archive: ARCHIVE_OPTIONS
532
+ };
533
+ function appliesTo(spec, target) {
534
+ if (spec.targets && spec.targets.length > 0) return spec.targets.includes(target);
535
+ if (spec.excludeTargets && spec.excludeTargets.length > 0) return !spec.excludeTargets.includes(target);
536
+ return true;
537
+ }
538
+ var INVALID = Symbol("invalid");
539
+ function coerce(spec, raw) {
540
+ switch (spec.type) {
541
+ case "integer": {
542
+ let n;
543
+ if (typeof raw === "number" && Number.isInteger(raw)) n = raw;
544
+ else if (typeof raw === "string" && /^-?\d+$/.test(raw)) n = Number.parseInt(raw, 10);
545
+ else return INVALID;
546
+ if (spec.min !== void 0 && n < spec.min) return INVALID;
547
+ if (spec.max !== void 0 && n > spec.max) return INVALID;
548
+ return n;
549
+ }
550
+ case "number": {
551
+ let n;
552
+ if (typeof raw === "number" && Number.isFinite(raw)) n = raw;
553
+ else if (typeof raw === "string" && /^-?\d+(\.\d+)?$/.test(raw)) n = Number.parseFloat(raw);
554
+ else return INVALID;
555
+ if (spec.min !== void 0 && n < spec.min) return INVALID;
556
+ if (spec.max !== void 0 && n > spec.max) return INVALID;
557
+ return n;
558
+ }
559
+ case "boolean": {
560
+ if (typeof raw === "boolean") return raw;
561
+ if (raw === "true") return true;
562
+ if (raw === "false") return false;
563
+ return INVALID;
564
+ }
565
+ case "enum": {
566
+ if (typeof raw !== "string") return INVALID;
567
+ return spec.values?.includes(raw) ? raw : INVALID;
568
+ }
569
+ case "string": {
570
+ if (typeof raw !== "string") return INVALID;
571
+ if (raw.length === 0) return INVALID;
572
+ if (spec.maxLength !== void 0 && raw.length > spec.maxLength) return INVALID;
573
+ if (spec.pattern) {
574
+ if (!new RegExp(spec.pattern).test(raw)) return INVALID;
575
+ } else if (!PRINTABLE_ASCII.test(raw)) {
576
+ return INVALID;
577
+ }
578
+ return raw;
579
+ }
580
+ default:
581
+ return INVALID;
582
+ }
583
+ }
584
+ function validateOptions(worker, target, submitted, opts) {
585
+ if (submitted === void 0 || submitted === null) return { ok: true, value: {} };
586
+ if (typeof submitted !== "object" || Array.isArray(submitted)) {
587
+ return { ok: false, code: "invalid_value", message: "options must be an object", key: "" };
588
+ }
589
+ const specs = OPTION_SPECS[worker] ?? [];
590
+ const byKey = new Map(specs.map((s) => [s.key, s]));
591
+ const out = {};
592
+ for (const [key, raw] of Object.entries(submitted)) {
593
+ if (raw === void 0 || raw === null || raw === "") continue;
594
+ const spec = byKey.get(key);
595
+ if (!spec) return { ok: false, code: "unknown_option", message: `unknown option "${key}"`, key };
596
+ if (!appliesTo(spec, target)) {
597
+ return { ok: false, code: "not_applicable", message: `option "${key}" is not available for target ${target}`, key };
598
+ }
599
+ if (spec.proOnly && !opts.isPro) {
600
+ return { ok: false, code: "pro_option", message: `option "${key}" is a Forge Pro feature`, key };
601
+ }
602
+ const value = coerce(spec, raw);
603
+ if (value === INVALID) {
604
+ return { ok: false, code: "invalid_value", message: `invalid value for option "${key}"`, key };
605
+ }
606
+ out[key] = value;
607
+ }
608
+ if (out.trim_start !== void 0 && out.trim_end !== void 0 && Number(out.trim_end) <= Number(out.trim_start)) {
609
+ return { ok: false, code: "invalid_value", message: "trim_end must be greater than trim_start", key: "trim_end" };
610
+ }
611
+ return { ok: true, value: out };
612
+ }
613
+ function stripSecretOptions(options) {
614
+ const safe = {};
615
+ let secured = false;
616
+ for (const [k, v] of Object.entries(options ?? {})) {
617
+ if (SECRET_OPTION_KEYS.has(k)) secured = true;
618
+ else safe[k] = v;
619
+ }
620
+ return { safe, secured };
621
+ }
622
+
623
+ // src/mime.ts
624
+ import { basename, extname } from "node:path";
625
+ var EXT_MIME = {
626
+ // image
627
+ png: "image/png",
628
+ jpg: "image/jpeg",
629
+ jpeg: "image/jpeg",
630
+ webp: "image/webp",
631
+ gif: "image/gif",
632
+ tif: "image/tiff",
633
+ tiff: "image/tiff",
634
+ avif: "image/avif",
635
+ svg: "image/svg+xml",
636
+ ico: "image/vnd.microsoft.icon",
637
+ bmp: "image/bmp",
638
+ // audio
639
+ mp3: "audio/mpeg",
640
+ wav: "audio/wav",
641
+ flac: "audio/flac",
642
+ ogg: "audio/ogg",
643
+ opus: "audio/opus",
644
+ aac: "audio/aac",
645
+ m4a: "audio/mp4",
646
+ // video
647
+ mp4: "video/mp4",
648
+ webm: "video/webm",
649
+ mov: "video/quicktime",
650
+ mkv: "video/x-matroska",
651
+ // documents
652
+ pdf: "application/pdf",
653
+ doc: "application/msword",
654
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
655
+ odt: "application/vnd.oasis.opendocument.text",
656
+ rtf: "application/rtf",
657
+ txt: "text/plain",
658
+ html: "text/html",
659
+ htm: "text/html",
660
+ md: "text/markdown",
661
+ xls: "application/vnd.ms-excel",
662
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
663
+ ods: "application/vnd.oasis.opendocument.spreadsheet",
664
+ ppt: "application/vnd.ms-powerpoint",
665
+ pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
666
+ odp: "application/vnd.oasis.opendocument.presentation",
667
+ // archives
668
+ zip: "application/zip",
669
+ "7z": "application/x-7z-compressed",
670
+ tar: "application/x-tar",
671
+ gz: "application/gzip",
672
+ tgz: "application/gzip",
673
+ bz2: "application/x-bzip2",
674
+ rar: "application/vnd.rar"
675
+ };
676
+ function mimeFromFilename(filename) {
677
+ const lower = basename(filename).toLowerCase();
678
+ if (lower.endsWith(".tar.gz")) return "application/gzip";
679
+ if (lower.endsWith(".tar.bz2")) return "application/x-bzip2";
680
+ const ext = extname(lower).replace(/^\./, "");
681
+ const mime = EXT_MIME[ext];
682
+ return mime ? canonicalMime(mime) : "application/octet-stream";
683
+ }
684
+ function filenameFromUrl(url, fallback = "download") {
685
+ try {
686
+ const u = new URL(url);
687
+ const last = u.pathname.split("/").filter(Boolean).pop();
688
+ if (last) return decodeURIComponent(last);
689
+ } catch {
690
+ }
691
+ return fallback;
692
+ }
693
+
694
+ // src/tools/shared.ts
695
+ var sourceShape = z.object({
696
+ path: z.string().optional().describe("Absolute or cwd-relative path to a local file (stdio transport)."),
697
+ url: z.string().optional().describe("http(s) URL the server fetches."),
698
+ bytes: z.string().optional().describe("Base64-encoded file contents."),
699
+ filename: z.string().optional().describe("File name; required with bytes (and useful with url) to name the output and infer type.")
700
+ }).describe("The input file \u2014 provide exactly one of path, url, or bytes.");
701
+ var outputShape = z.object({
702
+ path: z.string().optional().describe("Where to write the result (stdio). Defaults next to a path-input as <stem>.<target>."),
703
+ overwrite: z.boolean().optional().describe("Overwrite an existing output file (default false \u2192 a de-duped name is used)."),
704
+ return_bytes: z.boolean().optional().describe("Also return the result as base64 (only when under the inline size cap).")
705
+ }).describe("Where/how to return the result.");
706
+ var optionsShape = z.record(z.union([z.string(), z.number(), z.boolean()])).describe('Per-conversion options validated by FileForge, e.g. {"width":800,"quality":90} or {"page_range":"1-3"}. See list_formats.');
707
+ async function runTool(fn) {
708
+ try {
709
+ const result = await fn();
710
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
711
+ } catch (e) {
712
+ const code = e instanceof ToolError ? e.code : "error";
713
+ const msg = e instanceof Error ? e.message : String(e);
714
+ return { content: [{ type: "text", text: `Error (${code}): ${msg}` }], isError: true };
715
+ }
716
+ }
717
+ function resolveSourceMime(inputPath, sourceFormat) {
718
+ if (inputPath) return canonicalMime(mimeFromFilename(inputPath));
719
+ if (sourceFormat) {
720
+ if (sourceFormat.includes("/")) return canonicalMime(sourceFormat);
721
+ return canonicalMime(mimeFromFilename(`x.${sourceFormat.replace(/^\./, "")}`));
722
+ }
723
+ return void 0;
724
+ }
725
+ function normalizeOption(o) {
726
+ return {
727
+ key: o.key,
728
+ label: o.label,
729
+ type: o.type,
730
+ ...o.values ? { values: o.values } : {},
731
+ ...o.min !== void 0 ? { min: o.min } : {},
732
+ ...o.max !== void 0 ? { max: o.max } : {},
733
+ ...o.step !== void 0 ? { step: o.step } : {},
734
+ ...o.default !== void 0 ? { default: o.default } : {},
735
+ pro_only: Boolean(o.proOnly),
736
+ ...o.group ? { group: o.group } : {},
737
+ ...o.help ? { help: o.help } : {}
738
+ };
739
+ }
740
+
741
+ // src/tools/list-formats.ts
742
+ function registerListFormats(server, ctx) {
743
+ server.registerTool(
744
+ "list_formats",
745
+ {
746
+ title: "List conversion formats & options",
747
+ description: "Discover which file conversions FileForge supports and the options each accepts. Call this BEFORE convert_file when unsure whether a source\u2192target pair is allowed, what preset ids exist, or what tuning options (quality, resize, bitrate, page_range, \u2026) are valid. Results are filtered to your API key tier, so anything returned here is usable by convert_file \u2014 it is the substitute for reading the REST docs.",
748
+ inputSchema: {
749
+ input_path: z2.string().optional().describe("A local file whose source type is sniffed from its extension (stdio)."),
750
+ source_format: z2.string().optional().describe("Extension like 'png' or MIME like 'image/png'. Provide this OR input_path; omit both for the full matrix."),
751
+ target_format: z2.string().optional().describe("Optional filter: only return pairs producing this slug, e.g. 'webp'.")
752
+ }
753
+ },
754
+ async (args) => runTool(async () => {
755
+ const { formats } = await ctx.client.getFormats();
756
+ const entriesFor = (mime) => Object.entries(formats[mime] ?? {}).map(([target, e]) => ({
757
+ target_format: target,
758
+ worker: e.worker,
759
+ free_allowed: e.freeAllowed,
760
+ presets: e.presets.map((p) => ({ id: p.id, label: p.label })),
761
+ options: (e.options ?? []).map(normalizeOption)
762
+ }));
763
+ const sourceMime = resolveSourceMime(args.input_path, args.source_format);
764
+ if (sourceMime) {
765
+ if (!formats[sourceMime]) {
766
+ return {
767
+ source_mime: sourceMime,
768
+ targets: [],
769
+ note: "No conversions available from this source on your tier.",
770
+ known_sources: Object.keys(formats)
771
+ };
772
+ }
773
+ let targets = entriesFor(sourceMime);
774
+ if (args.target_format) targets = targets.filter((t) => t.target_format === args.target_format);
775
+ return { source_mime: sourceMime, targets };
776
+ }
777
+ return { sources: Object.fromEntries(Object.keys(formats).map((m) => [m, entriesFor(m)])) };
778
+ })
779
+ );
780
+ }
781
+
782
+ // src/tools/convert-file.ts
783
+ import { z as z3 } from "zod";
784
+
785
+ // src/flow.ts
786
+ import { stat as stat2 } from "node:fs/promises";
787
+ import { basename as basename3, resolve as resolvePath } from "node:path";
788
+
789
+ // src/httpio.ts
790
+ import { createReadStream, createWriteStream } from "node:fs";
791
+ import { stat } from "node:fs/promises";
792
+ import { request as httpsRequest } from "node:https";
793
+ import { request as httpRequest } from "node:http";
794
+ import { Readable } from "node:stream";
795
+ import { pipeline } from "node:stream/promises";
796
+ function requester(urlStr) {
797
+ const url = new URL(urlStr);
798
+ return { url, fn: url.protocol === "http:" ? httpRequest : httpsRequest };
799
+ }
800
+ async function putToPresigned(uploadUrl, body, contentType, contentLength, timeoutMs) {
801
+ const { url, fn } = requester(uploadUrl);
802
+ const opts = {
803
+ method: "PUT",
804
+ headers: { "Content-Type": contentType, "Content-Length": String(contentLength) },
805
+ timeout: timeoutMs
806
+ };
807
+ await new Promise((resolveP, rejectP) => {
808
+ const req = fn(url, opts, (res) => {
809
+ const chunks = [];
810
+ res.on("data", (c) => chunks.push(c));
811
+ res.on("end", () => {
812
+ const code = res.statusCode ?? 0;
813
+ if (code >= 200 && code < 300) return resolveP();
814
+ rejectP(new ToolError("upload_failed", `upload PUT failed (HTTP ${code}): ${Buffer.concat(chunks).toString("utf8").slice(0, 300)}`));
815
+ });
816
+ });
817
+ req.on("error", (e) => rejectP(new ToolError("upload_failed", `upload PUT error: ${e.message}`)));
818
+ req.on("timeout", () => req.destroy(new ToolError("upload_failed", `upload PUT timed out after ${timeoutMs}ms`)));
819
+ if (body.buffer) {
820
+ req.end(body.buffer);
821
+ } else if (body.filePath) {
822
+ const rs = createReadStream(body.filePath);
823
+ rs.on("error", (e) => {
824
+ req.destroy();
825
+ rejectP(new ToolError("upload_failed", `read error: ${e.message}`));
826
+ });
827
+ rs.pipe(req);
828
+ } else {
829
+ req.destroy();
830
+ rejectP(new ToolError("upload_failed", "no upload body provided"));
831
+ }
832
+ });
833
+ }
834
+ async function downloadToFile(url, destPath, timeoutMs, opts = {}) {
835
+ const res = await fetchWithTimeout(url, timeoutMs);
836
+ if (!res.ok || !res.body) {
837
+ throw new ToolError("download_failed", `download failed (HTTP ${res.status})`);
838
+ }
839
+ try {
840
+ await pipeline(
841
+ Readable.fromWeb(res.body),
842
+ createWriteStream(destPath, { flags: opts.exclusive ? "wx" : "w" })
843
+ );
844
+ } catch (e) {
845
+ if (e.code === "EEXIST") {
846
+ throw new ToolError("output_exists", `output file already exists: ${destPath} (set output.overwrite to replace it).`);
847
+ }
848
+ throw e;
849
+ }
850
+ return (await stat(destPath)).size;
851
+ }
852
+ async function downloadToBuffer(url, maxBytes, timeoutMs, opts = {}) {
853
+ const res = await fetchWithTimeout(url, timeoutMs, opts.noRedirect ? "error" : "follow");
854
+ if (!res.ok || !res.body) throw new ToolError("download_failed", `fetch failed (HTTP ${res.status})`);
855
+ const declared = Number(res.headers.get("content-length") ?? "0");
856
+ if (maxBytes > 0 && declared > maxBytes) {
857
+ throw new ToolError("source_too_large", `source is ${declared} bytes, over the ${maxBytes}-byte limit`);
858
+ }
859
+ const chunks = [];
860
+ let total = 0;
861
+ for await (const chunk of Readable.fromWeb(res.body)) {
862
+ const buf = chunk;
863
+ total += buf.length;
864
+ if (maxBytes > 0 && total > maxBytes) {
865
+ throw new ToolError("source_too_large", `source exceeds the ${maxBytes}-byte limit`);
866
+ }
867
+ chunks.push(buf);
868
+ }
869
+ return Buffer.concat(chunks);
870
+ }
871
+ async function fetchWithTimeout(url, timeoutMs, redirect = "follow") {
872
+ const ctrl = new AbortController();
873
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
874
+ try {
875
+ return await fetch(url, { signal: ctrl.signal, redirect });
876
+ } catch (e) {
877
+ throw new ToolError("download_failed", `request error: ${e instanceof Error ? e.message : String(e)}`);
878
+ } finally {
879
+ clearTimeout(t);
880
+ }
881
+ }
882
+
883
+ // src/paths.ts
884
+ import { existsSync } from "node:fs";
885
+ import { basename as basename2, dirname, extname as extname2, isAbsolute, join, relative, resolve } from "node:path";
886
+ function resolveOutputPath(opts) {
887
+ const { explicitPath, sourcePath, outputDir, targetFormat, sourceFilename } = opts;
888
+ if (explicitPath) return resolve(process.cwd(), explicitPath);
889
+ const stem = stripExt(basename2(sourceFilename)) || "output";
890
+ const filename = `${stem}.${targetFormat}`;
891
+ if (sourcePath) return join(dirname(resolve(process.cwd(), sourcePath)), filename);
892
+ if (outputDir) return join(resolve(process.cwd(), outputDir), filename);
893
+ return null;
894
+ }
895
+ function dedupePath(path, overwrite, exists = existsSync) {
896
+ if (overwrite || !exists(path)) return path;
897
+ const dir = dirname(path);
898
+ const ext = extname2(path);
899
+ const stem = basename2(path, ext);
900
+ for (let i = 1; i < 1e4; i++) {
901
+ const candidate = join(dir, `${stem}-${i}${ext}`);
902
+ if (!exists(candidate)) return candidate;
903
+ }
904
+ throw new ToolError("output_exists", `could not find a free output filename next to ${path}`);
905
+ }
906
+ function assertWithin(baseDir, target) {
907
+ if (!baseDir) return;
908
+ const base = resolve(process.cwd(), baseDir);
909
+ const rel = relative(base, resolve(target));
910
+ if (rel.startsWith("..") || isAbsolute(rel)) {
911
+ throw new ToolError("path_traversal", `output path ${target} escapes the allowed directory ${baseDir}`);
912
+ }
913
+ }
914
+ function stripExt(name) {
915
+ return name.replace(/\.[^./]+$/, "");
916
+ }
917
+
918
+ // src/ssrf.ts
919
+ import { lookup } from "node:dns/promises";
920
+ import { isIP } from "node:net";
921
+ async function assertPublicHttpUrl(raw) {
922
+ let url;
923
+ try {
924
+ url = new URL(raw);
925
+ } catch {
926
+ throw new ToolError("blocked_url", `source.url is not a valid URL: ${raw}`);
927
+ }
928
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
929
+ throw new ToolError("blocked_url", `only http(s) source URLs are allowed (got "${url.protocol}").`);
930
+ }
931
+ const host = url.hostname.replace(/^\[|\]$/g, "");
932
+ let addresses;
933
+ if (isIP(host)) {
934
+ addresses = [host];
935
+ } else {
936
+ if (isBlockedHostname(host)) {
937
+ throw new ToolError("blocked_url", `source URL host is not allowed: ${host}`);
938
+ }
939
+ try {
940
+ addresses = (await lookup(host, { all: true })).map((r) => r.address);
941
+ } catch {
942
+ throw new ToolError("blocked_url", `could not resolve source URL host: ${host}`);
943
+ }
944
+ }
945
+ for (const addr of addresses) {
946
+ if (isPrivateAddress(addr)) {
947
+ throw new ToolError("blocked_url", `source URL resolves to a private/reserved address (${addr}); refusing to fetch.`);
948
+ }
949
+ }
950
+ }
951
+ function isBlockedHostname(host) {
952
+ const h = host.toLowerCase();
953
+ return h === "localhost" || h.endsWith(".localhost") || h.endsWith(".local") || h.endsWith(".internal");
954
+ }
955
+ function isPrivateAddress(ip) {
956
+ const kind = isIP(ip);
957
+ if (kind === 4) return isPrivateV4(ip);
958
+ if (kind === 6) return isPrivateV6(ip);
959
+ return true;
960
+ }
961
+ function isPrivateV4(ip) {
962
+ const parts = ip.split(".").map((n) => Number.parseInt(n, 10));
963
+ if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return true;
964
+ const [a, b] = parts;
965
+ if (a === 0 || a === 10 || a === 127) return true;
966
+ if (a === 169 && b === 254) return true;
967
+ if (a === 172 && b >= 16 && b <= 31) return true;
968
+ if (a === 192 && b === 168) return true;
969
+ if (a === 100 && b >= 64 && b <= 127) return true;
970
+ if (a === 192 && b === 0) return true;
971
+ if (a === 198 && (b === 18 || b === 19)) return true;
972
+ if (a >= 224) return true;
973
+ return false;
974
+ }
975
+ function isPrivateV6(ip) {
976
+ const a = ip.toLowerCase();
977
+ if (a === "::1" || a === "::") return true;
978
+ if (a.startsWith("fe80") || a.startsWith("fc") || a.startsWith("fd")) return true;
979
+ if (a.startsWith("ff")) return true;
980
+ const mapped = a.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/);
981
+ if (mapped) return isPrivateV4(mapped[1]);
982
+ return false;
983
+ }
984
+
985
+ // src/flow.ts
986
+ var TERMINAL = /* @__PURE__ */ new Set(["completed", "failed", "cancelled"]);
987
+ function preflight(sourceMime, target, options) {
988
+ const entry = lookupFormat(sourceMime, target);
989
+ const known = FORMATS[sourceMime];
990
+ if (known && !entry) {
991
+ throw new ToolError("unsupported_pair", `Cannot convert ${sourceMime} \u2192 ${target}. Valid targets: ${Object.keys(known).join(", ")}. Use list_formats to confirm.`);
992
+ }
993
+ if (entry && options && Object.keys(options).length > 0) {
994
+ const v = validateOptions(entry.worker, target, options, { isPro: true });
995
+ if (!v.ok) throw new ToolError(v.code, `${v.message}. Use list_formats to see valid options for ${target}.`);
996
+ }
997
+ return entry;
998
+ }
999
+ async function convertOne(client, cfg, params) {
1000
+ const src = await resolveSource(params.source, cfg);
1001
+ const sourceMime = canonicalMime(src.declaredMime);
1002
+ const target = params.target_format;
1003
+ const entry = preflight(sourceMime, target, params.options);
1004
+ const presetId = pickPreset(entry, sourceMime, target, params.preset, cfg);
1005
+ const presign = await client.presignUpload({ filename: src.filename, size: src.size, mime: src.declaredMime });
1006
+ await putToPresigned(
1007
+ presign.upload_url,
1008
+ src.filePath ? { filePath: src.filePath } : { buffer: src.buffer },
1009
+ src.declaredMime,
1010
+ src.size,
1011
+ cfg.streamTimeoutMs
1012
+ );
1013
+ const finalized = await client.finalize(presign.file_id, {
1014
+ storage_key: presign.storage_key,
1015
+ original_name: src.filename,
1016
+ declared_mime: src.declaredMime
1017
+ });
1018
+ const created = await client.createConversion({
1019
+ input_file_id: finalized.id,
1020
+ target_format: target,
1021
+ preset: { id: presetId },
1022
+ options: params.options,
1023
+ preserve_metadata: params.preserve_metadata
1024
+ });
1025
+ log("debug", "conversion created", { id: created.id, target, preset: presetId, options: Object.keys(stripSecretOptions(params.options ?? {}).safe) });
1026
+ const { conv, timedOut } = await pollConversion(client, created.id, params.timeout_seconds, cfg);
1027
+ if (timedOut) {
1028
+ return {
1029
+ conversion_id: created.id,
1030
+ status: "processing",
1031
+ target_format: target,
1032
+ progress_pct: conv?.progress_pct ?? null,
1033
+ hint: "The conversion is still running. Call get_conversion_status with this conversion_id (and an output.path) to fetch the result when it finishes \u2014 no need to re-upload."
1034
+ };
1035
+ }
1036
+ if (conv.status !== "completed") {
1037
+ throw new ToolError(conv.error_code ?? "conversion_failed", `Conversion ${conv.status}: ${conv.error_message ?? "no detail"}`);
1038
+ }
1039
+ return finishCompleted(conv, cfg, {
1040
+ target_format: target,
1041
+ output: params.output,
1042
+ sourceWasLocalPath: src.sourceWasLocalPath,
1043
+ sourcePath: src.filePath,
1044
+ sourceFilename: src.filename,
1045
+ outputDir: params.outputDir
1046
+ });
1047
+ }
1048
+ async function convertMany(client, cfg, items, opts) {
1049
+ const prepared = await mapLimit(items, 4, async (p, i) => {
1050
+ const src = await resolveSource(p.source, cfg);
1051
+ const sourceMime = canonicalMime(src.declaredMime);
1052
+ const entry = preflight(sourceMime, p.target_format, p.options);
1053
+ const presetId = pickPreset(entry, sourceMime, p.target_format, p.preset, cfg);
1054
+ const presign = await client.presignUpload({ filename: src.filename, size: src.size, mime: src.declaredMime });
1055
+ await putToPresigned(presign.upload_url, src.filePath ? { filePath: src.filePath } : { buffer: src.buffer }, src.declaredMime, src.size, cfg.streamTimeoutMs);
1056
+ const finalized = await client.finalize(presign.file_id, { storage_key: presign.storage_key, original_name: src.filename, declared_mime: src.declaredMime });
1057
+ return { index: i, src, params: p, presetId, input_file_id: finalized.id };
1058
+ });
1059
+ const bulk = await client.createBulk({
1060
+ items: prepared.map((x) => ({
1061
+ input_file_id: x.input_file_id,
1062
+ target_format: x.params.target_format,
1063
+ preset: { id: x.presetId },
1064
+ options: x.params.options,
1065
+ preserve_metadata: x.params.preserve_metadata
1066
+ }))
1067
+ });
1068
+ const idByIndex = new Map(prepared.map((x, i) => [i, bulk.items[i]?.conversion_id]));
1069
+ const deadline = Date.now() + clampTimeout(opts.timeout_seconds ?? 600, cfg) * 1e3;
1070
+ const results = [];
1071
+ let completed = 0;
1072
+ let failed = 0;
1073
+ for (const x of prepared) {
1074
+ const convId = idByIndex.get(x.index);
1075
+ if (!convId) {
1076
+ failed++;
1077
+ results.push({ status: "failed", error_code: "no_conversion_id", source: describeSource(x.src) });
1078
+ continue;
1079
+ }
1080
+ const { conv, timedOut } = await pollConversion(client, convId, Math.max(1, Math.ceil((deadline - Date.now()) / 1e3)), cfg);
1081
+ if (timedOut) {
1082
+ results.push({ conversion_id: convId, status: "processing", source: describeSource(x.src), hint: "still running; poll get_conversion_status" });
1083
+ continue;
1084
+ }
1085
+ if (conv.status === "completed") {
1086
+ try {
1087
+ const out = await finishCompleted(conv, cfg, {
1088
+ target_format: x.params.target_format,
1089
+ output: x.params.output,
1090
+ sourceWasLocalPath: x.src.sourceWasLocalPath,
1091
+ sourcePath: x.src.filePath,
1092
+ sourceFilename: x.src.filename,
1093
+ outputDir: opts.outputDir,
1094
+ overwrite: opts.overwrite
1095
+ });
1096
+ completed++;
1097
+ results.push({ ...out, source: describeSource(x.src) });
1098
+ } catch (e) {
1099
+ failed++;
1100
+ results.push({ conversion_id: convId, status: "failed", error_code: e instanceof ToolError ? e.code : "download_failed", error_message: e instanceof Error ? e.message : String(e) });
1101
+ }
1102
+ } else {
1103
+ failed++;
1104
+ results.push({ conversion_id: convId, status: conv.status, error_code: conv.error_code, error_message: conv.error_message, source: describeSource(x.src) });
1105
+ }
1106
+ }
1107
+ return { bulk_id: bulk.bulk_id, total: bulk.total, completed, failed, results };
1108
+ }
1109
+ async function fetchStatus(client, cfg, conversionId, output) {
1110
+ const conv = await client.getConversion(conversionId);
1111
+ const base = {
1112
+ id: conv.id,
1113
+ status: conv.status,
1114
+ progress_pct: conv.progress_pct ?? null,
1115
+ worker_type: conv.worker_type,
1116
+ source_format: conv.source_format,
1117
+ target_format: conv.target_format,
1118
+ error_code: conv.error_code ?? void 0,
1119
+ error_message: conv.error_message ?? void 0,
1120
+ duration_ms: conv.duration_ms ?? void 0
1121
+ };
1122
+ if (conv.status === "completed" && (output?.path || output?.return_bytes)) {
1123
+ const fin = await finishCompleted(conv, cfg, {
1124
+ target_format: conv.target_format,
1125
+ output,
1126
+ sourceWasLocalPath: Boolean(output?.path),
1127
+ sourceFilename: `download.${conv.target_format}`
1128
+ });
1129
+ return { ...base, ...fin };
1130
+ }
1131
+ if (conv.status === "completed" && conv.output) {
1132
+ return { ...base, download_url: conv.output.download_url, expires_in: conv.output.expires_in };
1133
+ }
1134
+ return base;
1135
+ }
1136
+ async function finishCompleted(conv, cfg, o) {
1137
+ if (!conv.output?.download_url) {
1138
+ throw new ToolError("no_output", "conversion completed but no download URL was returned");
1139
+ }
1140
+ const overwrite = o.output?.overwrite ?? o.overwrite ?? false;
1141
+ let outPath = resolveOutputPath({
1142
+ explicitPath: o.output?.path,
1143
+ sourcePath: o.sourceWasLocalPath ? o.sourcePath : void 0,
1144
+ outputDir: o.outputDir,
1145
+ targetFormat: o.target_format,
1146
+ sourceFilename: o.sourceFilename
1147
+ });
1148
+ if (outPath) {
1149
+ if (o.outputDir && !o.output?.path) assertWithin(o.outputDir, outPath);
1150
+ outPath = dedupePath(outPath, overwrite);
1151
+ const bytes = await downloadToFile(conv.output.download_url, outPath, cfg.streamTimeoutMs, { exclusive: !overwrite });
1152
+ const res2 = {
1153
+ conversion_id: conv.id,
1154
+ status: "completed",
1155
+ target_format: o.target_format,
1156
+ duration_ms: conv.duration_ms ?? null,
1157
+ output_path: outPath,
1158
+ bytes
1159
+ };
1160
+ if (o.output?.return_bytes) await attachBytes(res2, conv.output.download_url, cfg);
1161
+ return res2;
1162
+ }
1163
+ const res = {
1164
+ conversion_id: conv.id,
1165
+ status: "completed",
1166
+ target_format: o.target_format,
1167
+ duration_ms: conv.duration_ms ?? null,
1168
+ download_url: conv.output.download_url,
1169
+ expires_in: conv.output.expires_in
1170
+ };
1171
+ if (o.output?.return_bytes) await attachBytes(res, conv.output.download_url, cfg);
1172
+ return res;
1173
+ }
1174
+ async function attachBytes(res, url, cfg) {
1175
+ try {
1176
+ const buf = await downloadToBuffer(url, cfg.maxInlineBytes, cfg.streamTimeoutMs);
1177
+ res.output_base64 = buf.toString("base64");
1178
+ res.bytes = buf.length;
1179
+ } catch (e) {
1180
+ if (e instanceof ToolError && e.code === "source_too_large") {
1181
+ res.note = `output too large to inline as base64 (cap ${cfg.maxInlineBytes} bytes); use output_path or download_url instead`;
1182
+ } else {
1183
+ res.note = `could not inline output as base64 (${e instanceof Error ? e.message : String(e)}); use output_path or download_url instead`;
1184
+ }
1185
+ log("debug", "inline bytes skipped", { reason: e instanceof Error ? e.message : String(e) });
1186
+ }
1187
+ }
1188
+ async function pollConversion(client, id, timeoutSeconds, cfg) {
1189
+ const deadline = Date.now() + clampTimeout(timeoutSeconds ?? 120, cfg) * 1e3;
1190
+ let delay = 1e3;
1191
+ let last = null;
1192
+ while (Date.now() < deadline) {
1193
+ last = await client.getConversion(id);
1194
+ if (TERMINAL.has(last.status)) return { conv: last, timedOut: false };
1195
+ const jitter = Math.floor(Math.random() * 250);
1196
+ await sleep(Math.min(delay, Math.max(0, deadline - Date.now())) + jitter);
1197
+ delay = Math.min(5e3, Math.floor(delay * 1.5));
1198
+ }
1199
+ return { conv: last, timedOut: true };
1200
+ }
1201
+ function clampTimeout(seconds, cfg) {
1202
+ return Math.max(1, Math.min(seconds, cfg.maxPollSeconds));
1203
+ }
1204
+ function pickPreset(entry, _sourceMime, _target, explicit, cfg) {
1205
+ if (explicit) return explicit;
1206
+ if (cfg.defaultPreset && entry && findPreset(entry, cfg.defaultPreset)) return cfg.defaultPreset;
1207
+ if (entry && entry.presets.length > 0) return entry.presets[0].id;
1208
+ return cfg.defaultPreset ?? "standard";
1209
+ }
1210
+ async function resolveSource(source, cfg) {
1211
+ const provided = [source.path, source.url, source.bytes].filter((v) => v !== void 0 && v !== "");
1212
+ if (provided.length !== 1) {
1213
+ throw new ToolError("invalid_source", "Provide exactly one of source.path, source.url, or source.bytes.");
1214
+ }
1215
+ if (source.path) {
1216
+ const abs = resolvePath(process.cwd(), source.path);
1217
+ const st = await stat2(abs).catch(() => null);
1218
+ if (!st || !st.isFile()) throw new ToolError("source_not_found", `No file at ${source.path}`);
1219
+ const filename = source.filename ?? basename3(abs);
1220
+ return { filePath: abs, size: st.size, filename, declaredMime: mimeFromFilename(filename), sourceWasLocalPath: true };
1221
+ }
1222
+ if (source.url) {
1223
+ await assertPublicHttpUrl(source.url);
1224
+ const buffer2 = await downloadToBuffer(source.url, cfg.maxDownloadUrlBytes, cfg.streamTimeoutMs, { noRedirect: true });
1225
+ const filename = source.filename ?? filenameFromUrl(source.url);
1226
+ return { buffer: buffer2, size: buffer2.length, filename, declaredMime: mimeFromFilename(filename), sourceWasLocalPath: false };
1227
+ }
1228
+ if (!source.filename) throw new ToolError("invalid_source", "source.filename is required when supplying source.bytes (used to name the output and infer the type).");
1229
+ const b64 = source.bytes.replace(/\s+/g, "");
1230
+ if (b64.length === 0 || b64.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(b64)) {
1231
+ throw new ToolError("invalid_source", "source.bytes is not valid base64.");
1232
+ }
1233
+ const buffer = Buffer.from(b64, "base64");
1234
+ if (buffer.length === 0) throw new ToolError("invalid_source", "source.bytes decoded to 0 bytes.");
1235
+ return { buffer, size: buffer.length, filename: source.filename, declaredMime: mimeFromFilename(source.filename), sourceWasLocalPath: false };
1236
+ }
1237
+ function describeSource(src) {
1238
+ return src.filePath ?? src.filename;
1239
+ }
1240
+ function sleep(ms) {
1241
+ return new Promise((r) => setTimeout(r, Math.max(0, ms)));
1242
+ }
1243
+ async function mapLimit(items, limit, fn) {
1244
+ const out = new Array(items.length);
1245
+ let next = 0;
1246
+ async function worker() {
1247
+ while (next < items.length) {
1248
+ const i = next++;
1249
+ out[i] = await fn(items[i], i);
1250
+ }
1251
+ }
1252
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()));
1253
+ return out;
1254
+ }
1255
+
1256
+ // src/tools/convert-file.ts
1257
+ function registerConvertFile(server, ctx) {
1258
+ server.registerTool(
1259
+ "convert_file",
1260
+ {
1261
+ title: "Convert a file",
1262
+ description: 'Convert ONE file end-to-end and get the result back in a single call \u2014 the primary, do-everything tool. Supply the file as a local path (default), an http(s) URL, or base64 bytes; FileForge runs upload \u2192 virus-scan \u2192 convert \u2192 download internally so you never touch the REST flow. Use list_formats first if unsure which target_format/preset/options are valid. On a slow conversion that outlasts the wait, this returns status "processing" with a conversion_id you can resume via get_conversion_status (no re-upload).',
1263
+ inputSchema: {
1264
+ source: sourceShape,
1265
+ target_format: z3.string().describe("Target format slug, e.g. 'jpg', 'webp', 'mp3', 'pdf', 'ico'."),
1266
+ preset: z3.string().optional().describe("Preset id (e.g. 'web','high','standard'). Defaults to the first preset for the pair."),
1267
+ options: optionsShape.optional(),
1268
+ preserve_metadata: z3.boolean().optional().describe("Keep EXIF/ID3 metadata (Forge Pro; default false)."),
1269
+ output: outputShape.optional(),
1270
+ timeout_seconds: z3.number().optional().describe("Max seconds to wait for completion (default 120; capped by the server).")
1271
+ }
1272
+ },
1273
+ async (args) => runTool(
1274
+ () => convertOne(ctx.client, ctx.config, {
1275
+ source: args.source,
1276
+ target_format: args.target_format,
1277
+ preset: args.preset,
1278
+ options: args.options,
1279
+ preserve_metadata: args.preserve_metadata,
1280
+ output: args.output,
1281
+ timeout_seconds: args.timeout_seconds
1282
+ })
1283
+ )
1284
+ );
1285
+ }
1286
+
1287
+ // src/tools/get-conversion-status.ts
1288
+ import { z as z4 } from "zod";
1289
+ function registerGetConversionStatus(server, ctx) {
1290
+ server.registerTool(
1291
+ "get_conversion_status",
1292
+ {
1293
+ title: "Get conversion status",
1294
+ description: 'Check a conversion you already started \u2014 use it when convert_file returned status "processing" because the job outlasted the wait, or to re-fetch a completed result whose download URL expired (~5 min). Returns progress and, when finished, a fresh download URL or writes the output to a path you choose. Lets a long job survive across turns without re-uploading.',
1295
+ inputSchema: {
1296
+ conversion_id: z4.string().describe("The conversion id from a prior convert_file / convert_files call."),
1297
+ output: outputShape.optional().describe("When provided and the job is complete, download the result (stdio) or inline it.")
1298
+ }
1299
+ },
1300
+ async (args) => runTool(() => fetchStatus(ctx.client, ctx.config, args.conversion_id, args.output))
1301
+ );
1302
+ }
1303
+
1304
+ // src/tools/convert-files.ts
1305
+ import { z as z5 } from "zod";
1306
+ function registerConvertFiles(server, ctx) {
1307
+ server.registerTool(
1308
+ "convert_files",
1309
+ {
1310
+ title: "Convert many files (bulk)",
1311
+ description: "Convert MANY files in one call (Forge Pro \u2014 the Free tier bulk limit is 1). Each item gets its own target/preset/options. Files are uploaded, submitted as a single bulk job, polled together, and downloaded. Use this instead of looping convert_file when you have several files. If your key is not Pro, this surfaces the Pro requirement instead of silently falling back.",
1312
+ inputSchema: {
1313
+ items: z5.array(
1314
+ z5.object({
1315
+ source: sourceShape,
1316
+ target_format: z5.string(),
1317
+ preset: z5.string().optional(),
1318
+ options: optionsShape.optional(),
1319
+ preserve_metadata: z5.boolean().optional(),
1320
+ output: z5.object({ path: z5.string().optional() }).optional()
1321
+ })
1322
+ ).min(1).describe("1..tier.bulk_max items (Free 1, Pro 20)."),
1323
+ output_dir: z5.string().optional().describe("Base directory for item outputs that omit their own path (stdio)."),
1324
+ overwrite: z5.boolean().optional().describe("Overwrite existing outputs (default false)."),
1325
+ timeout_seconds: z5.number().optional().describe("Max seconds to wait for the whole batch (default 600; capped).")
1326
+ }
1327
+ },
1328
+ async (args) => runTool(
1329
+ () => convertMany(
1330
+ ctx.client,
1331
+ ctx.config,
1332
+ args.items.map((it) => ({
1333
+ source: it.source,
1334
+ target_format: it.target_format,
1335
+ preset: it.preset,
1336
+ options: it.options,
1337
+ preserve_metadata: it.preserve_metadata,
1338
+ output: it.output
1339
+ })),
1340
+ { timeout_seconds: args.timeout_seconds, outputDir: args.output_dir, overwrite: args.overwrite }
1341
+ )
1342
+ )
1343
+ );
1344
+ }
1345
+
1346
+ // src/server.ts
1347
+ var SERVER_NAME = "fileforge";
1348
+ var SERVER_VERSION = "2.2.0";
1349
+ function buildServer(ctx) {
1350
+ const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION });
1351
+ registerListFormats(server, ctx);
1352
+ registerConvertFile(server, ctx);
1353
+ registerGetConversionStatus(server, ctx);
1354
+ registerConvertFiles(server, ctx);
1355
+ return server;
1356
+ }
1357
+
1358
+ // src/index.ts
1359
+ async function main() {
1360
+ let config;
1361
+ try {
1362
+ config = loadConfig();
1363
+ } catch (e) {
1364
+ const msg = e instanceof ConfigError ? e.message : e instanceof Error ? e.message : String(e);
1365
+ process.stderr.write(`[fileforge-mcp] config error: ${msg}
1366
+ `);
1367
+ process.exit(1);
1368
+ }
1369
+ setLogLevel(config.logLevel);
1370
+ const client = new FileForgeClient(config);
1371
+ const server = buildServer({ client, config });
1372
+ const transport = new StdioServerTransport();
1373
+ await server.connect(transport);
1374
+ log("info", `fileforge-mcp v${SERVER_VERSION} ready on stdio`, { baseUrl: config.baseUrl });
1375
+ }
1376
+ main().catch((e) => {
1377
+ process.stderr.write(`[fileforge-mcp] fatal: ${e?.stack ?? e}
1378
+ `);
1379
+ process.exit(1);
1380
+ });