@cheapbuddy/media-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/src/errors.mjs ADDED
@@ -0,0 +1,98 @@
1
+ const SECRET_PATTERNS = [
2
+ /Bearer\s+[A-Za-z0-9._~+\/-]+=*/gi,
3
+ /(?:api[_-]?key|authorization|token|secret|password)\s*[:=]\s*[^\s,;]+/gi,
4
+ ];
5
+
6
+ export class MediaMcpError extends Error {
7
+ constructor(code, message, options = {}) {
8
+ super(message);
9
+ this.name = "MediaMcpError";
10
+ this.code = code;
11
+ this.status = options.status;
12
+ this.retryAfterMs = options.retryAfterMs;
13
+ this.retryable = options.retryable === true;
14
+ this.ambiguous = options.ambiguous === true;
15
+ this.details = options.details;
16
+ this.cause = options.cause;
17
+ }
18
+ }
19
+
20
+ export function redactSecrets(value) {
21
+ let text = String(value ?? "");
22
+ for (const pattern of SECRET_PATTERNS) {
23
+ text = text.replace(pattern, (match) => {
24
+ const separator = match.indexOf("=") >= 0 ? "=" : ":";
25
+ return match.slice(0, Math.max(0, match.indexOf(separator) + 1)) + "[redacted]";
26
+ });
27
+ }
28
+ return text.replace(/https?:\/\/[^\s/]+\/[^\s]*/gi, (url) => {
29
+ try {
30
+ const parsed = new URL(url);
31
+ parsed.username = "";
32
+ parsed.password = "";
33
+ for (const key of ["key", "token", "signature", "sig", "secret"]) {
34
+ if (parsed.searchParams.has(key)) parsed.searchParams.set(key, "[redacted]");
35
+ }
36
+ return parsed.toString();
37
+ } catch {
38
+ return "[redacted-url]";
39
+ }
40
+ });
41
+ }
42
+
43
+ export function publicError(error) {
44
+ if (error instanceof MediaMcpError) {
45
+ return {
46
+ code: error.code,
47
+ message: redactSecrets(error.message),
48
+ ...(error.details === undefined ? {} : { details: error.details }),
49
+ ...(error.retryAfterMs === undefined ? {} : { retry_after_ms: error.retryAfterMs }),
50
+ };
51
+ }
52
+ return {
53
+ code: "internal_error",
54
+ message: "CheapBuddy Media MCP could not complete the request.",
55
+ };
56
+ }
57
+
58
+ export function validationError(message, details) {
59
+ return new MediaMcpError("invalid_request", message, { details });
60
+ }
61
+
62
+ export function configurationError(message) {
63
+ return new MediaMcpError("configuration_error", message);
64
+ }
65
+
66
+ export function httpError(status, message, options = {}) {
67
+ const retryAfterMs = options.retryAfterMs;
68
+ return new MediaMcpError(options.code || httpCode(status), message, {
69
+ status,
70
+ retryAfterMs,
71
+ retryable: options.retryable,
72
+ ambiguous: options.ambiguous,
73
+ details: options.details,
74
+ });
75
+ }
76
+
77
+ function httpCode(status) {
78
+ if (status === 401 || status === 403) return "authentication_error";
79
+ if (status === 404) return "not_found";
80
+ if (status === 409) return "idempotency_conflict";
81
+ if (status === 429) return "rate_limited";
82
+ if (status >= 500) return "upstream_error";
83
+ return "request_failed";
84
+ }
85
+
86
+ export function assertPlainObject(value, name) {
87
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
88
+ throw validationError(`${name} must be an object.`);
89
+ }
90
+ return value;
91
+ }
92
+
93
+ export function assertNoUnknownKeys(value, allowed, name = "request") {
94
+ for (const key of Object.keys(value || {})) {
95
+ if (!allowed.has(key)) throw validationError(`${name} contains unsupported field: ${key}.`);
96
+ }
97
+ }
98
+
package/src/http.mjs ADDED
@@ -0,0 +1,168 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { configurationError, httpError, MediaMcpError, redactSecrets } from "./errors.mjs";
3
+
4
+ const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
5
+ const READ_RETRY_DELAYS = [250, 750];
6
+
7
+ export class CheapBuddyHttpClient {
8
+ constructor(options = {}) {
9
+ const apiKey = String(options.apiKey ?? process.env.CHEAPBUDDY_API_KEY ?? "").trim();
10
+ if (!apiKey) throw configurationError("CHEAPBUDDY_API_KEY is required.");
11
+ if (apiKey.length > 4096 || /[\r\n]/u.test(apiKey)) throw configurationError("CHEAPBUDDY_API_KEY is invalid.");
12
+ this.apiKey = apiKey;
13
+ this.baseUrl = normalizeBaseUrl(options.baseUrl || process.env.CHEAPBUDDY_BASE_URL || "https://api.cheapbuddy.cc");
14
+ this.fetchImpl = options.fetchImpl || globalThis.fetch;
15
+ if (typeof this.fetchImpl !== "function") throw configurationError("This Node.js runtime does not provide fetch.");
16
+ this.timeoutMs = Number.isFinite(options.timeoutMs) ? Math.max(1000, options.timeoutMs) : 60000;
17
+ this.sleep = options.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
18
+ this.requestId = options.requestId || (() => randomUUID());
19
+ }
20
+
21
+ async json(method, path, body, options = {}) {
22
+ return this.request(method, path, { ...options, body, json: true });
23
+ }
24
+
25
+ async readJson(method, path, options = {}) {
26
+ let attempt = 0;
27
+ while (true) {
28
+ try {
29
+ return await this.request(method, path, options);
30
+ } catch (error) {
31
+ if (!shouldRetryRead(error) || attempt >= READ_RETRY_DELAYS.length) throw error;
32
+ const delay = error.retryAfterMs ?? READ_RETRY_DELAYS[attempt];
33
+ attempt += 1;
34
+ await this.sleep(Math.min(Math.max(delay, 0), 10000));
35
+ }
36
+ }
37
+ }
38
+
39
+ async uploadMedia(bytes, mimeType, options = {}) {
40
+ if (!Buffer.isBuffer(bytes) && !(bytes instanceof Uint8Array)) throw new TypeError("bytes must be binary data");
41
+ return this.request("POST", "/v1/media", {
42
+ body: bytes,
43
+ contentType: mimeType,
44
+ ...options,
45
+ });
46
+ }
47
+
48
+ async request(method, requestPath, options = {}) {
49
+ const url = this.buildUrl(requestPath);
50
+ const headers = new Headers(options.headers || {});
51
+ headers.set("Accept", "application/json");
52
+ headers.set("Authorization", `Bearer ${this.apiKey}`);
53
+ headers.set("X-Request-ID", this.requestId());
54
+ if (options.idempotencyKey) headers.set("Idempotency-Key", options.idempotencyKey);
55
+ let body = options.body;
56
+ if (options.json) {
57
+ headers.set("Content-Type", "application/json");
58
+ body = JSON.stringify(body ?? {});
59
+ } else if (options.contentType) {
60
+ headers.set("Content-Type", options.contentType);
61
+ }
62
+ const controller = new AbortController();
63
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs || this.timeoutMs);
64
+ let response;
65
+ try {
66
+ response = await this.fetchImpl(url, { method, headers, body, signal: controller.signal });
67
+ } catch (error) {
68
+ throw new MediaMcpError("network_error", "CheapBuddy Relay could not be reached.", {
69
+ retryable: method === "GET" || method === "HEAD",
70
+ ambiguous: method !== "GET" && method !== "HEAD",
71
+ cause: error,
72
+ });
73
+ } finally {
74
+ clearTimeout(timeout);
75
+ }
76
+ const text = await readBoundedText(response);
77
+ const payload = parsePayload(text, response.headers.get("content-type"));
78
+ if (!response.ok) {
79
+ const retryAfterMs = parseRetryAfter(response.headers.get("retry-after"));
80
+ const message = safeRemoteMessage(payload, response.status);
81
+ throw httpError(response.status, message, {
82
+ retryAfterMs,
83
+ retryable: response.status === 429 || response.status >= 500,
84
+ ambiguous: response.status >= 500 && method !== "GET" && method !== "HEAD",
85
+ code: payload?.error?.type || undefined,
86
+ details: { status: response.status },
87
+ });
88
+ }
89
+ return payload;
90
+ }
91
+
92
+ buildUrl(requestPath) {
93
+ if (typeof requestPath !== "string" || !requestPath.startsWith("/")) throw new Error("Relay path must be absolute.");
94
+ return new URL(requestPath, this.baseUrl).toString();
95
+ }
96
+ }
97
+
98
+ export function normalizeBaseUrl(value) {
99
+ let parsed;
100
+ try {
101
+ parsed = new URL(String(value));
102
+ } catch {
103
+ throw configurationError("CHEAPBUDDY_BASE_URL is invalid.");
104
+ }
105
+ if (!/^https?:$/u.test(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash) {
106
+ throw configurationError("CHEAPBUDDY_BASE_URL must be an HTTP(S) origin without credentials or query parameters.");
107
+ }
108
+ return parsed.toString().replace(/\/$/u, "");
109
+ }
110
+
111
+ async function readBoundedText(response) {
112
+ const declared = Number(response.headers.get("content-length"));
113
+ if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) throw new MediaMcpError("response_too_large", "Relay response exceeded the safety limit.");
114
+ if (!response.body?.getReader) {
115
+ const text = await response.text();
116
+ if (Buffer.byteLength(text, "utf8") > MAX_RESPONSE_BYTES) throw new MediaMcpError("response_too_large", "Relay response exceeded the safety limit.");
117
+ return text;
118
+ }
119
+ const reader = response.body.getReader();
120
+ const chunks = [];
121
+ let total = 0;
122
+ try {
123
+ while (true) {
124
+ const { done, value } = await reader.read();
125
+ if (done) break;
126
+ total += value.byteLength;
127
+ if (total > MAX_RESPONSE_BYTES) {
128
+ await reader.cancel();
129
+ throw new MediaMcpError("response_too_large", "Relay response exceeded the safety limit.");
130
+ }
131
+ chunks.push(Buffer.from(value));
132
+ }
133
+ } finally {
134
+ reader.releaseLock?.();
135
+ }
136
+ return Buffer.concat(chunks, total).toString("utf8");
137
+ }
138
+
139
+ function parsePayload(text, contentType) {
140
+ if (!text) return {};
141
+ if (String(contentType || "").includes("json") || /^[\s]*[\[{]/u.test(text)) {
142
+ try { return JSON.parse(text); } catch { return { error: { message: "Relay returned malformed JSON." } }; }
143
+ }
144
+ return { raw: text.slice(0, 512) };
145
+ }
146
+
147
+ function safeRemoteMessage(payload, status) {
148
+ const candidate = payload?.error?.message || payload?.message || payload?.error || `Relay request failed with HTTP ${status}.`;
149
+ const message = redactSecrets(typeof candidate === "string" ? candidate : "Relay request failed.")
150
+ .replace(/https?:\/\/[^\s)]+/giu, "[redacted-url]");
151
+ return message.length > 500 ? message.slice(0, 500) : message;
152
+ }
153
+
154
+ function parseRetryAfter(value) {
155
+ if (!value) return undefined;
156
+ const seconds = Number(value);
157
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds * 1000, 10000);
158
+ const date = Date.parse(value);
159
+ if (Number.isFinite(date)) return Math.min(Math.max(date - Date.now(), 0), 10000);
160
+ return undefined;
161
+ }
162
+
163
+ function shouldRetryRead(error) {
164
+ return error instanceof MediaMcpError && (error.retryable === true || error.code === "rate_limited") && !error.ambiguous;
165
+ }
166
+
167
+ export { MAX_RESPONSE_BYTES };
168
+
package/src/job-id.mjs ADDED
@@ -0,0 +1,34 @@
1
+ import { validationError } from "./errors.mjs";
2
+
3
+ const PREFIXES = Object.freeze({ image: "img_task_", video: "vid_task_" });
4
+
5
+ export function encodeJobId(kind, taskId) {
6
+ const prefix = PREFIXES[kind];
7
+ const value = String(taskId ?? "").trim();
8
+ if (!prefix || !value || value.length > 512 || /[\u0000-\u001f\u007f]/u.test(value)) {
9
+ throw new Error("Relay returned an invalid task identity.");
10
+ }
11
+ return prefix + Buffer.from(value, "utf8").toString("base64url");
12
+ }
13
+
14
+ export function decodeJobId(value) {
15
+ const input = String(value ?? "");
16
+ const entry = Object.entries(PREFIXES).find(([, prefix]) => input.startsWith(prefix));
17
+ if (!entry) throw validationError("job_id must be an MCP-generated media job ID.");
18
+ const [kind, prefix] = entry;
19
+ const encoded = input.slice(prefix.length);
20
+ if (!encoded || !/^[A-Za-z0-9_-]+$/.test(encoded) || encoded.length > 700) {
21
+ throw validationError("job_id is invalid.");
22
+ }
23
+ let taskId;
24
+ try {
25
+ taskId = Buffer.from(encoded, "base64url").toString("utf8");
26
+ } catch {
27
+ throw validationError("job_id is invalid.");
28
+ }
29
+ if (!taskId || taskId.length > 512 || /[\u0000-\u001f\u007f]/u.test(taskId) || Buffer.from(taskId, "utf8").toString("base64url") !== encoded) {
30
+ throw validationError("job_id is invalid.");
31
+ }
32
+ return { kind, taskId };
33
+ }
34
+
@@ -0,0 +1,181 @@
1
+ import { lstat, readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import net from "node:net";
4
+ import { validationError } from "./errors.mjs";
5
+
6
+ export const MEDIA_LIMITS = Object.freeze({
7
+ image: { maxBytes: 30 * 1024 * 1024, aggregateBytes: 60 * 1024 * 1024 },
8
+ video: { maxBytes: 50 * 1024 * 1024, aggregateBytes: 60 * 1024 * 1024 },
9
+ audio: { maxBytes: 15 * 1024 * 1024, aggregateBytes: 60 * 1024 * 1024 },
10
+ });
11
+
12
+ const EXTENSIONS = Object.freeze({
13
+ ".png": ["image/png", "image"],
14
+ ".jpg": ["image/jpeg", "image"],
15
+ ".jpeg": ["image/jpeg", "image"],
16
+ ".webp": ["image/webp", "image"],
17
+ ".gif": ["image/gif", "image"],
18
+ ".heic": ["image/heic", "image"],
19
+ ".heif": ["image/heif", "image"],
20
+ ".mp4": ["video/mp4", "video"],
21
+ ".m4v": ["video/mp4", "video"],
22
+ ".mov": ["video/quicktime", "video"],
23
+ ".webm": ["video/webm", "video"],
24
+ ".wav": ["audio/wav", "audio"],
25
+ ".mp3": ["audio/mpeg", "audio"],
26
+ ".m4a": ["audio/mp4", "audio"],
27
+ ".aac": ["audio/aac", "audio"],
28
+ ".ogg": ["audio/ogg", "audio"],
29
+ });
30
+
31
+ const BLOCKED_HOSTNAMES = new Set([
32
+ "localhost",
33
+ "localhost.localdomain",
34
+ "metadata.google.internal",
35
+ "metadata.google.com",
36
+ "instance-data.ec2.internal",
37
+ ]);
38
+
39
+ const FILE_IO_LIMIT = 4;
40
+ let activeFileReads = 0;
41
+ const pendingFileReads = [];
42
+
43
+ export function validateMediaInput(value, expectedKind, options = {}) {
44
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
45
+ throw validationError(`${expectedKind} input must contain exactly one path or url.`);
46
+ }
47
+ const keys = Object.keys(value);
48
+ if (keys.length !== 1 || (!keys.includes("path") && !keys.includes("url"))) {
49
+ throw validationError(`${expectedKind} input must contain exactly one path or url.`);
50
+ }
51
+ if (value.path !== undefined) {
52
+ if (typeof value.path !== "string" || value.path.length > 4096 || !path.isAbsolute(value.path)) {
53
+ throw validationError("Local media paths must be absolute.");
54
+ }
55
+ return { kind: "path", path: value.path, expectedKind, ...options };
56
+ }
57
+ if (typeof value.url !== "string" || value.url.length > 8192) throw validationError("Media URL must be a string of at most 8192 characters.");
58
+ return { kind: "url", url: validateHttpsUrl(value.url), expectedKind, ...options };
59
+ }
60
+
61
+ export function validateHttpsUrl(value) {
62
+ let parsed;
63
+ try {
64
+ parsed = new URL(value);
65
+ } catch {
66
+ throw validationError("Media URL is invalid.");
67
+ }
68
+ if (parsed.protocol !== "https:") throw validationError("Media URL must use HTTPS.");
69
+ if (parsed.username || parsed.password) throw validationError("Media URL must not contain credentials.");
70
+ const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, "");
71
+ if (BLOCKED_HOSTNAMES.has(hostname) || hostname.endsWith(".localhost") || hostname.endsWith(".local")) {
72
+ throw validationError("Media URL points to a private or local host.");
73
+ }
74
+ if (net.isIP(hostname) && isPrivateAddress(hostname)) {
75
+ throw validationError("Media URL points to a private or metadata address.");
76
+ }
77
+ return parsed.toString();
78
+ }
79
+
80
+ export function isPrivateAddress(hostname) {
81
+ const version = net.isIP(hostname);
82
+ if (version === 4) {
83
+ const parts = hostname.split(".").map(Number);
84
+ return parts[0] === 0 || parts[0] === 10 || parts[0] === 127 ||
85
+ (parts[0] === 169 && parts[1] === 254) ||
86
+ (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) ||
87
+ (parts[0] === 192 && parts[1] === 168);
88
+ }
89
+ if (version === 6) {
90
+ const normalized = hostname.toLowerCase();
91
+ return normalized === "::1" || normalized === "::" || normalized.startsWith("fc") ||
92
+ normalized.startsWith("fd") || normalized.startsWith("fe8") || normalized.startsWith("fe9") ||
93
+ normalized.startsWith("fea") || normalized.startsWith("feb");
94
+ }
95
+ return false;
96
+ }
97
+
98
+ export async function loadLocalMedia(input, expectedKind, options = {}) {
99
+ if (input?.kind === "url" && input.expectedKind === expectedKind && typeof input.url === "string") return input;
100
+ if (input?.kind === "file" && input.mediaKind === expectedKind && Buffer.isBuffer(input.bytes)) return input;
101
+ if (input?.kind === "path" && input.expectedKind === expectedKind && typeof input.path === "string") input = { path: input.path };
102
+ const validated = validateMediaInput(input, expectedKind, options);
103
+ if (validated.kind === "url") return validated;
104
+ await acquireFileSlot();
105
+ try {
106
+ const info = await lstat(validated.path).catch(() => {
107
+ throw validationError("Local media file does not exist or is not accessible.");
108
+ });
109
+ if (info.isSymbolicLink()) throw validationError("Symbolic-link media inputs are not allowed.");
110
+ if (!info.isFile()) throw validationError("Media input must be a regular file.");
111
+ const extension = path.extname(validated.path).toLowerCase();
112
+ const descriptor = EXTENSIONS[extension];
113
+ if (!descriptor || descriptor[1] !== expectedKind) {
114
+ throw validationError(`Unsupported ${expectedKind} file type.`);
115
+ }
116
+ const limits = MEDIA_LIMITS[expectedKind];
117
+ if (info.size > limits.maxBytes) throw validationError(`${expectedKind} input exceeds the per-file size limit.`);
118
+ const bytes = await readFile(validated.path);
119
+ if (bytes.length !== info.size) throw validationError("Media file changed while it was being read.");
120
+ if (!looksLikeKind(bytes, expectedKind, descriptor[0])) throw validationError("Media file content does not match its declared type.");
121
+ return {
122
+ kind: "file",
123
+ path: validated.path,
124
+ filename: path.basename(validated.path),
125
+ mimeType: descriptor[0],
126
+ mediaKind: expectedKind,
127
+ bytes,
128
+ };
129
+ } finally {
130
+ releaseFileSlot();
131
+ }
132
+ }
133
+
134
+ export function validateAggregate(inputs, expectedKind) {
135
+ const total = inputs.filter((item) => item?.kind === "file").reduce((sum, item) => sum + item.bytes.length, 0);
136
+ if (total > MEDIA_LIMITS[expectedKind].aggregateBytes) throw validationError(`${expectedKind} inputs exceed the aggregate size limit.`);
137
+ }
138
+
139
+ function looksLikeKind(bytes, kind, mime) {
140
+ if (bytes.length === 0) return false;
141
+ if (kind === "image") {
142
+ if (mime === "image/png") return bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
143
+ if (mime === "image/jpeg") return bytes.subarray(0, 3).equals(Buffer.from([255, 216, 255]));
144
+ if (mime === "image/webp") return bytes.subarray(0, 4).toString("ascii") === "RIFF" && bytes.subarray(8, 12).toString("ascii") === "WEBP";
145
+ if (mime === "image/gif") return ["GIF87a", "GIF89a"].includes(bytes.subarray(0, 6).toString("ascii"));
146
+ return bytes.subarray(4, 8).toString("ascii") === "ftyp";
147
+ }
148
+ if (kind === "video") {
149
+ if (mime === "video/webm") return bytes.subarray(0, 4).equals(Buffer.from([0x1a, 0x45, 0xdf, 0xa3]));
150
+ return bytes.length > 12 && bytes.subarray(4, 8).toString("ascii") === "ftyp";
151
+ }
152
+ if (mime === "audio/wav") return bytes.subarray(0, 4).toString("ascii") === "RIFF" && bytes.subarray(8, 12).toString("ascii") === "WAVE";
153
+ if (mime === "audio/mpeg") return bytes.subarray(0, 3).toString("ascii") === "ID3" || (bytes[0] === 0xff && (bytes[1] & 0xe0) === 0xe0);
154
+ if (mime === "audio/ogg") return bytes.subarray(0, 4).toString("ascii") === "OggS";
155
+ return bytes.subarray(4, 8).toString("ascii") === "ftyp" || bytes.subarray(0, 4).toString("ascii") === "ADIF";
156
+ }
157
+
158
+ export function inputValue(input) {
159
+ if (input.kind === "url") return input.url;
160
+ return input.bytes;
161
+ }
162
+
163
+ export function fileIoStats() {
164
+ return { active: activeFileReads, pending: pendingFileReads.length, limit: FILE_IO_LIMIT };
165
+ }
166
+
167
+ async function acquireFileSlot() {
168
+ if (activeFileReads < FILE_IO_LIMIT) {
169
+ activeFileReads += 1;
170
+ return;
171
+ }
172
+ await new Promise((resolve) => pendingFileReads.push(resolve));
173
+ activeFileReads += 1;
174
+ }
175
+
176
+ function releaseFileSlot() {
177
+ activeFileReads = Math.max(0, activeFileReads - 1);
178
+ const next = pendingFileReads.shift();
179
+ if (next) next();
180
+ }
181
+