@tinify-dev/mcp
Advanced tools
+18
-0
@@ -10,2 +10,20 @@ # Changelog | ||
| ## [0.1.5] - 2026-07-26 | ||
| ### Changed | ||
| - **Starting without `TINIFY_API_KEY` no longer exits.** The server completes | ||
| the MCP handshake and registers its tools; calling one returns the | ||
| "create a key" instruction as a tool result. Previously the process exited 1 | ||
| before the handshake, so MCP clients showed only a failed connection and the | ||
| explanation — written to stderr on an already-dead process — never reached | ||
| the user. This is the state anyone is in immediately after installing the | ||
| Claude Code plugin but before exporting a key. The message is still printed | ||
| to stderr at startup for people running the binary directly. | ||
| ### Added | ||
| - `repository`, `homepage`, and `bugs` in `package.json`, so the npm page links | ||
| back to the source and to tinify.dev. | ||
| ## [0.1.0] - 2026-07-24 | ||
@@ -12,0 +30,0 @@ |
+157
-105
@@ -6,3 +6,3 @@ #!/usr/bin/env node | ||
| // node_modules/@tinify-dev/client/dist/chunk-NYRKB5BB.js | ||
| // node_modules/@tinify-dev/client/dist/chunk-F4JYNVER.js | ||
| var DEFAULT_BASE_URL = "https://api.tinify.dev"; | ||
@@ -15,2 +15,3 @@ var ENV_API_KEY = "TINIFY_API_KEY"; | ||
| var BACKOFF_CAP_MS = 8e3; | ||
| var MAX_RETRY_AFTER_MS = 3e4; | ||
| var MAX_FILE_BYTES = 41943040; | ||
@@ -112,52 +113,2 @@ var DEFAULT_UPLOAD_CONCURRENCY = 4; | ||
| } | ||
| var TERMINAL = new Set(BATCH_TERMINAL_STATUSES); | ||
| function isTerminalBatchStatus(status) { | ||
| return TERMINAL.has(status); | ||
| } | ||
| async function uploadBatchFiles(fetchImpl, session, files, options = {}) { | ||
| const missing = session.uploads.map((upload) => upload.client_id).filter((clientId) => !(clientId in files)); | ||
| if (missing.length > 0) { | ||
| throw new TinifyError( | ||
| `Missing input for batch client_id(s): ${missing.join(", ")}. Pass a { client_id: input } record covering every file in the session.` | ||
| ); | ||
| } | ||
| const queue = [...session.uploads]; | ||
| const concurrency = Math.max( | ||
| 1, | ||
| Math.min(options.concurrency ?? DEFAULT_UPLOAD_CONCURRENCY, queue.length) | ||
| ); | ||
| const workers = Array.from({ length: concurrency }, async () => { | ||
| for (; ; ) { | ||
| const upload = queue.shift(); | ||
| if (upload === void 0) return; | ||
| options.signal?.throwIfAborted(); | ||
| await putOne(fetchImpl, upload, files[upload.client_id], options.signal); | ||
| } | ||
| }); | ||
| await Promise.all(workers); | ||
| } | ||
| async function putOne(fetchImpl, upload, input, signal) { | ||
| const { blob } = await normalizeInput(input); | ||
| let response; | ||
| try { | ||
| response = await fetchImpl(upload.upload_url, { | ||
| method: "PUT", | ||
| headers: upload.headers, | ||
| body: blob, | ||
| ...signal !== void 0 ? { signal } : {} | ||
| }); | ||
| } catch (error) { | ||
| throw new TinifyNetworkError( | ||
| `Upload for client_id "${upload.client_id}" failed: ${error instanceof Error ? error.message : String(error)}`, | ||
| { cause: error } | ||
| ); | ||
| } | ||
| if (!response.ok) { | ||
| const text = await response.text().catch(() => ""); | ||
| throw new TinifyError( | ||
| `Upload for client_id "${upload.client_id}" was rejected with HTTP ${response.status}` + (text ? `: ${text.slice(0, 200)}` : ".") | ||
| ); | ||
| } | ||
| await response.arrayBuffer().catch(() => void 0); | ||
| } | ||
| var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([429, 503]); | ||
@@ -232,2 +183,34 @@ function generateIdempotencyKey() { | ||
| } | ||
| function withTimeoutSignal(timeoutMs, signal) { | ||
| const timeout = AbortSignal.timeout(timeoutMs); | ||
| if (signal === void 0) return { signal: timeout, timeout }; | ||
| const anyOf = AbortSignal.any; | ||
| if (typeof anyOf === "function") return { signal: anyOf([signal, timeout]), timeout }; | ||
| const controller = new AbortController(); | ||
| const abortFrom = (src) => controller.abort(src.reason); | ||
| if (signal.aborted) controller.abort(signal.reason); | ||
| else if (timeout.aborted) controller.abort(timeout.reason); | ||
| else { | ||
| signal.addEventListener("abort", () => abortFrom(signal), { once: true }); | ||
| timeout.addEventListener("abort", () => abortFrom(timeout), { once: true }); | ||
| } | ||
| return { signal: controller.signal, timeout }; | ||
| } | ||
| function asTimeoutError(error, timeoutMs) { | ||
| if (typeof error === "object" && error !== null && error.name === "TimeoutError") { | ||
| return new TinifyTimeoutError(`Request timed out after ${timeoutMs} ms.`, timeoutMs); | ||
| } | ||
| return error; | ||
| } | ||
| async function fetchWithTimeout(fetchImpl, url, init, timeoutMs, userSignal) { | ||
| const { signal, timeout } = withTimeoutSignal(timeoutMs, userSignal); | ||
| try { | ||
| return await fetchImpl(url, { ...init, signal }); | ||
| } catch (error) { | ||
| if (timeout.aborted) { | ||
| throw new TinifyTimeoutError(`Request timed out after ${timeoutMs} ms.`, timeoutMs); | ||
| } | ||
| throw error; | ||
| } | ||
| } | ||
| var HttpClient = class { | ||
@@ -265,3 +248,3 @@ config; | ||
| accept: "application/json", | ||
| "user-agent": "@tinify-dev/client/0.1.0" | ||
| "user-agent": "@tinify-dev/client/0.1.1" | ||
| }; | ||
@@ -272,3 +255,4 @@ if (idempotencyKey !== void 0) headers["idempotency-key"] = idempotencyKey; | ||
| try { | ||
| response = await this.fetchWithTimeout( | ||
| response = await fetchWithTimeout( | ||
| this.config.fetchImpl, | ||
| url, | ||
@@ -280,2 +264,3 @@ { | ||
| }, | ||
| this.config.timeoutMs, | ||
| options.signal | ||
@@ -299,3 +284,4 @@ ); | ||
| await response.arrayBuffer().catch(() => void 0); | ||
| await sleep(retryAfter ?? backoffMs(attempt), options.signal); | ||
| const wait = Math.min(retryAfter ?? backoffMs(attempt), MAX_RETRY_AFTER_MS); | ||
| await sleep(wait, options.signal); | ||
| attempt += 1; | ||
@@ -316,6 +302,10 @@ continue; | ||
| let body; | ||
| if (result.response.status === 204) { | ||
| body = void 0; | ||
| } else { | ||
| body = await result.response.json(); | ||
| try { | ||
| if (result.response.status === 204) { | ||
| body = void 0; | ||
| } else { | ||
| body = await result.response.json(); | ||
| } | ||
| } catch (error) { | ||
| throw asTimeoutError(error, this.config.timeoutMs); | ||
| } | ||
@@ -329,25 +319,59 @@ const bodyRequestId = body?.request_id; | ||
| } | ||
| async fetchWithTimeout(url, init, signal) { | ||
| const controller = new AbortController(); | ||
| const timeoutError = new TinifyTimeoutError( | ||
| `Request timed out after ${this.config.timeoutMs} ms.`, | ||
| this.config.timeoutMs | ||
| /** The configured per-request timeout (ms); used by download/upload paths. */ | ||
| get timeoutMs() { | ||
| return this.config.timeoutMs; | ||
| } | ||
| }; | ||
| var TERMINAL = new Set(BATCH_TERMINAL_STATUSES); | ||
| function isTerminalBatchStatus(status) { | ||
| return TERMINAL.has(status); | ||
| } | ||
| async function uploadBatchFiles(fetchImpl, session, files, options, timeoutMs) { | ||
| const missing = session.uploads.map((upload) => upload.client_id).filter((clientId) => !(clientId in files)); | ||
| if (missing.length > 0) { | ||
| throw new TinifyError( | ||
| `Missing input for batch client_id(s): ${missing.join(", ")}. Pass a { client_id: input } record covering every file in the session.` | ||
| ); | ||
| const timer = setTimeout(() => controller.abort(timeoutError), this.config.timeoutMs); | ||
| const onAbort = () => controller.abort(abortReason(signal)); | ||
| signal?.addEventListener("abort", onAbort, { once: true }); | ||
| try { | ||
| return await this.config.fetchImpl(url, { ...init, signal: controller.signal }); | ||
| } catch (error) { | ||
| if (error === timeoutError) throw error; | ||
| if (controller.signal.aborted && controller.signal.reason === timeoutError) { | ||
| throw timeoutError; | ||
| } | ||
| throw error; | ||
| } finally { | ||
| clearTimeout(timer); | ||
| signal?.removeEventListener("abort", onAbort); | ||
| } | ||
| const queue = [...session.uploads]; | ||
| const concurrency = Math.max( | ||
| 1, | ||
| Math.min(options.concurrency ?? DEFAULT_UPLOAD_CONCURRENCY, queue.length) | ||
| ); | ||
| const workers = Array.from({ length: concurrency }, async () => { | ||
| for (; ; ) { | ||
| const upload = queue.shift(); | ||
| if (upload === void 0) return; | ||
| options.signal?.throwIfAborted(); | ||
| await putOne(fetchImpl, upload, files[upload.client_id], timeoutMs, options.signal); | ||
| } | ||
| }); | ||
| await Promise.all(workers); | ||
| } | ||
| async function putOne(fetchImpl, upload, input, timeoutMs, signal) { | ||
| const { blob } = await normalizeInput(input); | ||
| let response; | ||
| try { | ||
| response = await fetchWithTimeout( | ||
| fetchImpl, | ||
| upload.upload_url, | ||
| { method: "PUT", headers: upload.headers, body: blob }, | ||
| timeoutMs, | ||
| signal | ||
| ); | ||
| } catch (error) { | ||
| if (error instanceof TinifyTimeoutError) throw error; | ||
| throw new TinifyNetworkError( | ||
| `Upload for client_id "${upload.client_id}" failed: ${error instanceof Error ? error.message : String(error)}`, | ||
| { cause: error } | ||
| ); | ||
| } | ||
| }; | ||
| if (!response.ok) { | ||
| const text = await response.text().catch(() => ""); | ||
| throw new TinifyError( | ||
| `Upload for client_id "${upload.client_id}" was rejected with HTTP ${response.status}` + (text ? `: ${text.slice(0, 200)}` : ".") | ||
| ); | ||
| } | ||
| await response.arrayBuffer().catch(() => void 0); | ||
| } | ||
| var TinifyClient = class { | ||
@@ -454,8 +478,15 @@ http; | ||
| const url = new URL(raw, `${this.http.baseUrl}/`).toString(); | ||
| const response = await this.http.fetchImpl(url, { | ||
| method: "GET", | ||
| ...options.signal !== void 0 ? { signal: options.signal } : {} | ||
| }); | ||
| const response = await fetchWithTimeout( | ||
| this.http.fetchImpl, | ||
| url, | ||
| { method: "GET" }, | ||
| this.http.timeoutMs, | ||
| options.signal | ||
| ); | ||
| if (!response.ok) await throwApiError(response); | ||
| return response.blob(); | ||
| try { | ||
| return await response.blob(); | ||
| } catch (error) { | ||
| throw asTimeoutError(error, this.http.timeoutMs); | ||
| } | ||
| } | ||
@@ -485,3 +516,3 @@ // ------------------------------------------------------------------------- | ||
| uploadBatchFiles(session, files, options = {}) { | ||
| return uploadBatchFiles(this.http.fetchImpl, session, files, options); | ||
| return uploadBatchFiles(this.http.fetchImpl, session, files, options, this.http.timeoutMs); | ||
| } | ||
@@ -551,3 +582,7 @@ /** Commits an uploaded batch for processing. */ | ||
| }); | ||
| return result.response.blob(); | ||
| try { | ||
| return await result.response.blob(); | ||
| } catch (error) { | ||
| throw asTimeoutError(error, this.http.timeoutMs); | ||
| } | ||
| } | ||
@@ -579,6 +614,6 @@ // ------------------------------------------------------------------------- | ||
| // src/config.ts | ||
| var ConfigError = class extends Error { | ||
| var MissingApiKeyError = class extends Error { | ||
| constructor(message) { | ||
| super(message); | ||
| this.name = "ConfigError"; | ||
| this.name = "MissingApiKeyError"; | ||
| } | ||
@@ -605,9 +640,10 @@ }; | ||
| ].join("\n"); | ||
| function loadConfig(env) { | ||
| var missingApiKeyMessage = MISSING_KEY_MESSAGE; | ||
| function readConfig(env) { | ||
| const apiKey = env["TINIFY_API_KEY"]; | ||
| if (apiKey === void 0 || apiKey.trim() === "") { | ||
| throw new ConfigError(MISSING_KEY_MESSAGE); | ||
| } | ||
| const baseUrl = env["TINIFY_BASE_URL"]; | ||
| return baseUrl !== void 0 && baseUrl !== "" ? { apiKey, baseUrl } : { apiKey }; | ||
| return { | ||
| ...apiKey !== void 0 && apiKey.trim() !== "" ? { apiKey } : {}, | ||
| ...baseUrl !== void 0 && baseUrl !== "" ? { baseUrl } : {} | ||
| }; | ||
| } | ||
@@ -720,2 +756,5 @@ | ||
| function toErrorResult(error) { | ||
| if (error instanceof MissingApiKeyError) { | ||
| return errorResult(error.message); | ||
| } | ||
| if (error instanceof ToolInputError) { | ||
@@ -1103,3 +1142,3 @@ return errorResult(error.message); | ||
| var SERVER_NAME = "tinify"; | ||
| var SERVER_VERSION = "0.1.4"; | ||
| var SERVER_VERSION = "0.1.5"; | ||
| var SERVER_TITLE = "Tinify image tools"; | ||
@@ -1145,15 +1184,21 @@ var SERVER_DESCRIPTION = "Compress, resize, crop, and convert images with the Tinify.dev API. Honest results: never returns a larger file."; | ||
| // src/unconfigured-client.ts | ||
| function createUnconfiguredClient() { | ||
| const refuse = () => { | ||
| throw new MissingApiKeyError(missingApiKeyMessage); | ||
| }; | ||
| return { | ||
| compress: refuse, | ||
| resize: refuse, | ||
| crop: refuse, | ||
| convert: refuse, | ||
| usage: refuse, | ||
| download: refuse | ||
| }; | ||
| } | ||
| // src/index.ts | ||
| async function main() { | ||
| let config; | ||
| try { | ||
| config = loadConfig(process.env); | ||
| } catch (error) { | ||
| if (error instanceof ConfigError) { | ||
| console.error(error.message); | ||
| process.exit(1); | ||
| } | ||
| throw error; | ||
| } | ||
| const client = new TinifyClient({ | ||
| const config = readConfig(process.env); | ||
| const client = config.apiKey === void 0 ? createUnconfiguredClient() : new TinifyClient({ | ||
| apiKey: config.apiKey, | ||
@@ -1165,3 +1210,10 @@ ...config.baseUrl !== void 0 ? { baseUrl: config.baseUrl } : {} | ||
| await server.connect(transport); | ||
| console.error("tinify-mcp: ready on stdio (5 tools registered)"); | ||
| if (config.apiKey === void 0) { | ||
| console.error(missingApiKeyMessage); | ||
| console.error( | ||
| "\ntinify-mcp: started without a key - tools are listed but will refuse until TINIFY_API_KEY is set." | ||
| ); | ||
| } else { | ||
| console.error("tinify-mcp: ready on stdio (5 tools registered)"); | ||
| } | ||
| } | ||
@@ -1168,0 +1220,0 @@ main().catch((error) => { |
+10
-2
| { | ||
| "name": "@tinify-dev/mcp", | ||
| "version": "0.1.4", | ||
| "version": "0.1.5", | ||
| "description": "MCP server for the Tinify.dev image API: compress, resize, crop, and convert images from Claude, Cursor, or any MCP client.", | ||
| "license": "MIT", | ||
| "author": "Stian Larsen", | ||
| "homepage": "https://tinify.dev/mcp", | ||
| "repository": { | ||
| "type": "git", | ||
| "url": "git+https://github.com/Stianlars1/tinify-mcp.git" | ||
| }, | ||
| "bugs": { | ||
| "url": "https://github.com/Stianlars1/tinify-mcp/issues" | ||
| }, | ||
| "type": "module", | ||
@@ -44,3 +52,3 @@ "sideEffects": false, | ||
| "@modelcontextprotocol/sdk": "^1.29.0", | ||
| "@tinify-dev/client": "^0.1.0", | ||
| "@tinify-dev/client": "^0.1.1", | ||
| "zod": "^3.25.76" | ||
@@ -47,0 +55,0 @@ }, |
+2
-2
@@ -10,3 +10,3 @@ { | ||
| }, | ||
| "version": "0.1.4", | ||
| "version": "0.1.5", | ||
| "websiteUrl": "https://tinify.dev/mcp", | ||
@@ -25,3 +25,3 @@ "icons": [ | ||
| "identifier": "@tinify-dev/mcp", | ||
| "version": "0.1.4", | ||
| "version": "0.1.5", | ||
| "transport": { | ||
@@ -28,0 +28,0 @@ "type": "stdio" |
No bug tracker
MaintenancePackage does not have a linked bug tracker in package.json.
No repository
Supply chain riskPackage does not have a linked source code repository. Without this field, a package will have no reference to the location of the source code use to generate the package.
No website
QualityPackage does not have a website.
56333
5.07%1238
4.3%0
-100%1
-50%10
-9.09%Updated