From 2d237a1651cc8bc64f85aac333bdf777426a5c8c Mon Sep 17 00:00:00 2001 From: Rohan Poudel Date: Wed, 5 Aug 2026 12:40:46 -0600 Subject: [PATCH 1/5] feat(mcp): add MCP server so agents can use the compressor directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An agent that has this as a tool does not need to discover the CLI, get the flags right, or parse output. The v2 API was already most of the way there — stable error codes, a report modelled as data rather than exceptions, and runtime capability discovery. Three tools over stdio: - compress_media — files or directories, with dryRun to preview - probe_media — identify a file, with ffprobe stream detail for video - list_capabilities — what sharp and ffmpeg on THIS machine can encode list_capabilities is the one that earns its place. Format support is build-dependent, so an agent that assumes AVIF or AV1 exists will fail on a stock macOS sharp or a minimal ffmpeg. Asking is cheap. Published as a separate package, not a second bin on this one. The SDK pulls express, hono, ajv, jose and cors for HTTP transports that a stdio server never uses — roughly 15MB that every CLI and library consumer would otherwise pay for. An npm workspace keeps it in this repo without touching the published tarball, which is byte-for-byte the same 11 files. Tool descriptions are written for a reader that has never seen the CLI: that compression never touches the source, that a "skipped" file means the original was better, and that dryRun is free. No structuredContent/outputSchema. The report is a discriminated union nested two levels deep and the SDK validates declared output strictly, so one unanticipated shape would turn a successful compression into a protocol error. Text JSON cannot fail that way. Verified against a real MCP client over stdio, not just unit-mocked: the handshake, tool listing, capability report, a dry run that provably wrote nothing, a real compression of the sample images, schema rejection of a bad enum, and a missing file surfacing as isError. Those checks are now test/mcp.test.ts, and CI builds the workspace before running them — the test skips when the server is unbuilt, so without that step the suite would have passed while never exercising any of this. --- .github/workflows/ci.yml | 7 + README.md | 13 + eslint.config.js | 3 +- mcp/LICENSE | 21 + mcp/README.md | 65 +++ mcp/package.json | 55 ++ mcp/src/bin.ts | 25 + mcp/src/server.ts | 398 +++++++++++++ mcp/tsconfig.json | 26 + mcp/tsup.config.ts | 15 + package-lock.json | 1152 +++++++++++++++++++++++++++++++++++++- package.json | 9 +- test/mcp.test.ts | 102 ++++ 13 files changed, 1872 insertions(+), 19 deletions(-) create mode 100644 mcp/LICENSE create mode 100644 mcp/README.md create mode 100644 mcp/package.json create mode 100644 mcp/src/bin.ts create mode 100644 mcp/src/server.ts create mode 100644 mcp/tsconfig.json create mode 100644 mcp/tsup.config.ts create mode 100644 test/mcp.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5eb2959..778791d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,7 @@ jobs: cache: npm - run: npm ci - run: npm run typecheck + - run: npm run typecheck -w image-and-video-compressor-mcp - run: npm run lint - run: npm run format:check @@ -64,6 +65,12 @@ jobs: - run: npm ci - run: npm run build + + # The MCP server is a separate workspace package. Its test skips itself + # when the server is not built, so without this the suite would pass + # while never exercising it. + - run: npm run build:mcp + - run: npx vitest run - name: Confirm the video suite was not silently skipped diff --git a/README.md b/README.md index 9b63572..4a2ff9c 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ That is the whole quick start. No install, no config file, no flags. - [Progress and cancellation](#progress-and-cancellation) - [Branded types](#branded-types) - [Automation and AI agents](#automation-and-ai-agents) + - [MCP server](#mcp-server) - [JSON output contract](#json-output-contract) - [Exit codes](#exit-codes) - [Error codes](#error-codes) @@ -357,6 +358,18 @@ This is what stops a 1-100 quality value being fed to a flag that expects a 0-51 The tool is built to be driven by something that is not a person: a build script, a CI job, or a coding agent. +### MCP server + +There is an MCP server, so an agent can compress media directly instead of being told a command to run: + +```bash +claude mcp add image-video-compressor -- npx -y image-and-video-compressor-mcp +``` + +It exposes three tools — `compress_media`, `probe_media` and `list_capabilities` — over stdio. `list_capabilities` matters most: it reports what the local sharp and ffmpeg builds genuinely support, so an agent can pick a format that exists on this machine rather than assuming AVIF or AV1 is available. + +Published separately as [`image-and-video-compressor-mcp`](https://www.npmjs.com/package/image-and-video-compressor-mcp) to keep the MCP SDK's dependency tree out of this package. Details in [mcp/README.md](./mcp/README.md). + ### JSON output contract `--json` writes exactly one JSON document to stdout. Every human-facing byte — banner, progress, summary, warnings — goes to stderr, so the stream is always safe to parse. diff --git a/eslint.config.js b/eslint.config.js index 68b5d9b..41d663b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2,7 +2,8 @@ import js from "@eslint/js"; import tseslint from "typescript-eslint"; export default tseslint.config( - { ignores: ["dist/**", "node_modules/**", "coverage/**"] }, + // Globbed at every depth: the mcp workspace has its own dist/ and node_modules/. + { ignores: ["**/dist/**", "**/node_modules/**", "coverage/**"] }, js.configs.recommended, ...tseslint.configs.recommendedTypeChecked, { diff --git a/mcp/LICENSE b/mcp/LICENSE new file mode 100644 index 0000000..6b3a7a8 --- /dev/null +++ b/mcp/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Rohan Poudel + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/mcp/README.md b/mcp/README.md new file mode 100644 index 0000000..154c3b8 --- /dev/null +++ b/mcp/README.md @@ -0,0 +1,65 @@ +# image-and-video-compressor-mcp + +An [MCP](https://modelcontextprotocol.io) server that lets a coding agent compress images and videos, inspect media files, and find out what the local ffmpeg and sharp builds can actually do. + +Wraps [`image-and-video-compressor`](https://www.npmjs.com/package/image-and-video-compressor). Source files are never modified — output goes to a separate directory. + +## Install + +Add it to your MCP client's config. Nothing to install first; `npx` fetches it on demand. + +**Claude Code** + +```bash +claude mcp add image-video-compressor -- npx -y image-and-video-compressor-mcp +``` + +**Claude Desktop** — in `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "image-video-compressor": { + "command": "npx", + "args": ["-y", "image-and-video-compressor-mcp"] + } + } +} +``` + +Requires **Node 20.11+**. Images work out of the box. Video needs `ffmpeg` on your `PATH` — without it the image tools still work and the video ones say so rather than failing obscurely. + +## Tools + +### `compress_media` + +Compress images and/or videos. Accepts files or directories. + +Source files are never modified or overwritten; output is written to a `compressed` folder beside the source, or wherever `outDir` points. If compressing would make a file bigger, the original is kept and the file is reported as skipped rather than quietly made worse. + +Key parameters: `paths` (required), `kind` (`auto`/`image`/`video`), `quality` (1-100), `to` (e.g. `.webp`, `.avif`, `.mp4`, `.webm`), `outDir`, `recursive`, `dryRun`, `maxWidth`, `maxHeight`, `overwrite`, plus `videoCodec`, `audioCodec`, `fps` and `ffmpegPath` for video. + +Set `dryRun: true` to see the plan and the output paths without writing anything. + +### `probe_media` + +Identify one file without modifying it: image or video, size on disk, and for video the duration and stream layout from ffprobe. + +Detection uses content bytes when the extension is missing or wrong, so a `.jpg` that is really an MP4 is reported correctly. + +### `list_capabilities` + +What this machine can genuinely encode and decode. + +Worth calling before choosing an output format. Support is build-dependent rather than fixed by the package: AVIF, JPEG XL and HEIC are frequently missing from sharp, and a minimal ffmpeg carries a fraction of the containers and codecs a full one does. The image list is produced by actually encoding a pixel with each candidate, so it reflects the binary you have rather than what the package hopes is there. + +## Notes for agents + +- **Preview before writing.** `compress_media` with `dryRun: true` is free and shows exactly which files would be written where. +- **Check the format first.** `list_capabilities` prevents asking for an encoder this machine does not have. +- **Failures are per-file.** A corrupt file is reported in `results` with a stable error code; the rest of the batch still completes. Branch on `error.code`, not on the message text. +- **Skips are not failures.** `status: "skipped"` with `reason: "output-larger-than-input"` means compression was attempted and the original was better. + +## License + +MIT © Rohan Poudel diff --git a/mcp/package.json b/mcp/package.json new file mode 100644 index 0000000..5115471 --- /dev/null +++ b/mcp/package.json @@ -0,0 +1,55 @@ +{ + "name": "image-and-video-compressor-mcp", + "version": "0.1.0", + "description": "MCP server for image-and-video-compressor — lets coding agents compress images and videos, probe media, and discover what the local ffmpeg and sharp builds actually support.", + "author": "Rohan Poudel", + "license": "MIT", + "type": "module", + "keywords": [ + "mcp", + "model-context-protocol", + "mcp-server", + "image-compression", + "video-compression", + "ffmpeg", + "sharp", + "ai-agent", + "claude", + "llm" + ], + "bin": { + "image-and-video-compressor-mcp": "./dist/bin.js" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "engines": { + "node": ">=20.11" + }, + "scripts": { + "build": "tsup", + "typecheck": "tsc --noEmit", + "prepublishOnly": "npm run typecheck && npm run build" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.30.0", + "image-and-video-compressor": "^2.0.2", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "tsup": "^8.5.1", + "typescript": "^5.7.2" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/rohanpoudel2/image_video_compressor.git", + "directory": "mcp" + }, + "bugs": { + "url": "https://github.com/rohanpoudel2/image_video_compressor/issues" + }, + "homepage": "https://github.com/rohanpoudel2/image_video_compressor/tree/main/mcp#readme" +} diff --git a/mcp/src/bin.ts b/mcp/src/bin.ts new file mode 100644 index 0000000..5bf966d --- /dev/null +++ b/mcp/src/bin.ts @@ -0,0 +1,25 @@ +/** + * stdio entry point. + * + * The shebang comes from the tsup banner, matching the root CLI build — adding + * one here too would emit it twice and the file would not parse. + * + * stdout is the JSON-RPC channel and nothing else may touch it. The library is + * safe here — it writes no stdout of its own, and the CLI renderer that does + * print is not used by this server — but anything added later must log to + * stderr, or it will corrupt the protocol stream. + */ +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { createServer } from "./server.js"; + +async function main(): Promise { + const server = createServer(); + await server.connect(new StdioServerTransport()); +} + +main().catch((err: unknown) => { + process.stderr.write( + `image-and-video-compressor-mcp: ${err instanceof Error ? err.message : String(err)}\n`, + ); + process.exit(1); +}); diff --git a/mcp/src/server.ts b/mcp/src/server.ts new file mode 100644 index 0000000..c4237b7 --- /dev/null +++ b/mcp/src/server.ts @@ -0,0 +1,398 @@ +import { stat } from "node:fs/promises"; +import { z } from "zod"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + compress, + compressImages, + compressVideos, + classifyFile, + sniffFile, + probeMedia, + resolveFfmpeg, + imageCapabilities, + ffmpegCapabilities, + toQuality, + toPixels, + VIDEO_CONTAINERS, + type CompressOptions, + type CompressionReport, +} from "image-and-video-compressor"; + +/** + * Every tool returns JSON as text content. + * + * Deliberately *not* an `outputSchema` with `structuredContent`: the report + * shape is a discriminated union nested two levels deep, and the SDK validates + * declared output strictly, so a shape the schema failed to anticipate turns a + * successful compression into a protocol error. Text JSON is understood by + * every client and cannot fail that way. + */ +function json(value: unknown) { + return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] }; +} + +function failure(message: string, code = "TOOL_ERROR") { + return { + isError: true, + content: [ + { + type: "text" as const, + text: JSON.stringify({ ok: false, code, message }, null, 2), + }, + ], + }; +} + +/** + * Trim the library report for an agent. + * + * The full report repeats absolute paths and per-file timings that cost tokens + * without changing a decision. What an agent acts on is: did it work, how much + * was saved, and which files need attention. + */ +function summarise(report: CompressionReport) { + return { + ok: report.summary.failed === 0, + dryRun: report.dryRun, + summary: report.summary, + results: report.results.map((result) => + result.status === "failed" + ? { + status: result.status, + inputPath: result.inputPath, + error: { code: result.error.code, message: result.error.message }, + } + : result.status === "skipped" + ? { + status: result.status, + inputPath: result.inputPath, + reason: result.reason, + ...(result.warnings?.length ? { warnings: result.warnings } : {}), + } + : { + status: result.status, + inputPath: result.inputPath, + outputPath: result.outputPath, + inputBytes: result.inputBytes, + outputBytes: result.outputBytes, + savedBytes: result.savedBytes, + savedRatio: Math.round(result.savedRatio * 10_000) / 10_000, + ...(result.warnings?.length ? { warnings: result.warnings } : {}), + }, + ), + }; +} + +export function createServer(): McpServer { + const server = new McpServer( + { name: "image-and-video-compressor", version: "0.1.0" }, + { + instructions: + "Compress images and videos on this machine. Compression never modifies the " + + "source: output is written to a separate directory. Call list_capabilities " + + "before choosing an output format — support depends on how sharp and ffmpeg " + + "were built here, and formats like AVIF, JPEG XL, HEIC and AV1 are commonly " + + "absent. Use compress_media with dryRun to preview before writing.", + }, + ); + + server.registerTool( + "compress_media", + { + title: "Compress images and videos", + description: + "Compress images and/or videos, writing results to a separate output directory. " + + "Source files are never modified or overwritten. Accepts files or directories. " + + "If compressing a file would make it larger, the original is kept and the file " + + "is reported as skipped rather than silently made worse. Set dryRun=true to see " + + "the plan and projected output paths without writing anything. Video requires " + + "ffmpeg on PATH; images do not.", + inputSchema: { + paths: z + .array(z.string()) + .min(1) + .describe( + "Files or directories to compress. Directories are scanned for media.", + ), + kind: z + .enum(["auto", "image", "video"]) + .default("auto") + .describe("Restrict to one media type. 'auto' handles both."), + quality: z + .number() + .int() + .min(1) + .max(100) + .optional() + .describe( + "1-100, higher looks better. Default 75. Mapped onto each codec's own scale.", + ), + to: z + .string() + .optional() + .describe( + "Output format, e.g. '.webp', '.avif', '.mp4', '.webm'. Default: WebP for images " + + "(sources already in AVIF/WebP keep their format) and .mp4 for video. " + + "Verify availability with list_capabilities first.", + ), + outDir: z + .string() + .optional() + .describe( + "Output directory. Default is a 'compressed' folder beside the source.", + ), + recursive: z.boolean().default(false).describe("Descend into subdirectories."), + overwrite: z + .boolean() + .default(false) + .describe("Replace existing output files instead of skipping them."), + dryRun: z + .boolean() + .default(false) + .describe("Report the plan without writing any file. Safe to call freely."), + maxWidth: z + .number() + .int() + .positive() + .optional() + .describe("Shrink anything wider. Never enlarges."), + maxHeight: z + .number() + .int() + .positive() + .optional() + .describe("Shrink anything taller. Never enlarges."), + concurrency: z + .number() + .int() + .positive() + .optional() + .describe("Files processed at once."), + skipLarger: z + .boolean() + .default(true) + .describe("Keep the original when compression would make it bigger."), + videoCodec: z + .string() + .optional() + .describe("Video encoder, e.g. 'libx264', 'libvpx-vp9', 'libsvtav1'."), + audioCodec: z + .string() + .optional() + .describe("Audio encoder: 'aac', 'libopus', or 'copy'."), + fps: z + .number() + .int() + .min(1) + .max(240) + .optional() + .describe("Cap frame rate. Default keeps the source rate."), + ffmpegPath: z + .string() + .optional() + .describe("Explicit path to the ffmpeg binary."), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + }, + async (args) => { + try { + const options: CompressOptions = { + ...(args.quality !== undefined ? { quality: toQuality(args.quality) } : {}), + ...(args.to !== undefined ? { to: args.to } : {}), + ...(args.outDir !== undefined ? { outDir: args.outDir } : {}), + recursive: args.recursive, + overwrite: args.overwrite, + dryRun: args.dryRun, + skipLarger: args.skipLarger, + ...(args.concurrency !== undefined ? { concurrency: args.concurrency } : {}), + ...(args.videoCodec !== undefined + ? { videoCodec: args.videoCodec as CompressOptions["videoCodec"] } + : {}), + ...(args.audioCodec !== undefined + ? { audioCodec: args.audioCodec as CompressOptions["audioCodec"] } + : {}), + ...(args.fps !== undefined ? { fps: args.fps } : {}), + ...(args.ffmpegPath !== undefined ? { ffmpegPath: args.ffmpegPath } : {}), + ...(args.maxWidth !== undefined || args.maxHeight !== undefined + ? { + resize: { + ...(args.maxWidth !== undefined + ? { maxWidth: toPixels(args.maxWidth) } + : {}), + ...(args.maxHeight !== undefined + ? { maxHeight: toPixels(args.maxHeight) } + : {}), + withoutEnlargement: true, + }, + } + : {}), + }; + + const report = + args.kind === "image" + ? await compressImages(args.paths, options) + : args.kind === "video" + ? await compressVideos(args.paths, options) + : await compress(args.paths, options); + + return json(summarise(report)); + } catch (err) { + // Setup errors (no inputs, bad option, ffmpeg missing) reject; per-file + // problems already arrive as failed results inside the report. + const code = + typeof err === "object" && err !== null && "code" in err + ? String(err.code) + : "UNKNOWN"; + return failure(err instanceof Error ? err.message : String(err), code); + } + }, + ); + + server.registerTool( + "probe_media", + { + title: "Inspect a media file", + description: + "Identify a media file without modifying it: whether it is an image or a video, " + + "its size on disk, and for video its duration and stream layout (video, audio, " + + "subtitle tracks) via ffprobe. Detection uses content bytes when the extension is " + + "missing or wrong, so it correctly reports a .jpg that is really an MP4.", + inputSchema: { + path: z.string().describe("Path to a single media file."), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async ({ path }) => { + try { + const info = await stat(path); + if (!info.isFile()) return failure(`Not a file: ${path}`, "INPUT_NOT_FOUND"); + + const kind = await classifyFile(path); + const sniffed = await sniffFile(path); + + const base = { + path, + kind, + bytes: info.size, + ...(sniffed ? { detected: sniffed } : {}), + }; + + if (kind !== "video") return json(base); + + try { + const tools = await resolveFfmpeg(); + if (!tools.ffprobe) + return json({ + ...base, + note: "ffprobe unavailable; stream detail omitted.", + }); + + const probe = await probeMedia(tools.ffprobe, path); + return json({ + ...base, + durationSeconds: probe.durationSeconds, + video: probe.video, + audio: probe.audio, + subtitles: probe.subtitles, + hasAttachments: probe.hasAttachments, + }); + } catch { + return json({ ...base, note: "ffmpeg not found; stream detail omitted." }); + } + } catch (err) { + return failure( + err instanceof Error ? err.message : String(err), + "INPUT_NOT_FOUND", + ); + } + }, + ); + + server.registerTool( + "list_capabilities", + { + title: "List supported formats on this machine", + description: + "Report what this machine can actually encode and decode. Support is build-dependent, " + + "not fixed by the package: AVIF, JPEG XL and HEIC are frequently missing from sharp, " + + "and a minimal ffmpeg carries a fraction of the containers and codecs a full one does. " + + "Call this before choosing an output format instead of assuming one is available.", + inputSchema: { + ffmpegPath: z + .string() + .optional() + .describe("Explicit path to the ffmpeg binary."), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async ({ ffmpegPath }) => { + const images = await imageCapabilities(); + + const imageReport = { + // Established by actually encoding a pixel with each candidate, so this + // is what the build can do rather than what it claims. + write: images.writable.map((format) => ({ + extensions: format.extensions, + label: format.label, + supportsQuality: format.supportsQuality, + supportsAnimation: format.supportsAnimation, + })), + read: [...images.readableExtensions].sort(), + }; + + try { + const tools = await resolveFfmpeg(ffmpegPath); + const caps = await ffmpegCapabilities(tools.ffmpeg); + + const containers = Object.entries(VIDEO_CONTAINERS) + .filter(([, spec]) => caps.muxers.has(spec.muxer)) + .map(([extension, spec]) => ({ + extension, + label: spec.label, + videoCodecs: spec.video.filter((codec) => caps.videoEncoders.has(codec)), + audioCodecs: spec.audio.filter( + (codec) => codec === "copy" || caps.audioEncoders.has(codec), + ), + })); + + return json({ + image: imageReport, + video: { + available: true, + ffmpeg: tools.ffmpeg, + ffmpegVersion: tools.version, + containers, + muxerCount: caps.muxers.size, + videoEncoderCount: caps.videoEncoders.size, + audioEncoderCount: caps.audioEncoders.size, + }, + }); + } catch { + return json({ + image: imageReport, + video: { + available: false, + note: "ffmpeg not found. Images still work. Install ffmpeg or pass ffmpegPath to enable video.", + }, + }); + } + }, + ); + + return server; +} diff --git a/mcp/tsconfig.json b/mcp/tsconfig.json new file mode 100644 index 0000000..7dfbc14 --- /dev/null +++ b/mcp/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "moduleResolution": "Bundler", + "types": ["node"], + + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "forceConsistentCasingInFileNames": true, + + "declaration": false, + "sourceMap": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src", "*.config.ts"] +} diff --git a/mcp/tsup.config.ts b/mcp/tsup.config.ts new file mode 100644 index 0000000..67bd3e1 --- /dev/null +++ b/mcp/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { bin: "src/bin.ts" }, + format: ["esm"], + target: "node20.11", + platform: "node", + clean: true, + sourcemap: true, + // The SDK and the compressor stay external: bundling either would duplicate + // code the consumer already installs, and sharp carries native binaries that + // cannot be bundled at all. + external: ["@modelcontextprotocol/sdk", "image-and-video-compressor", "zod", "sharp"], + banner: { js: "#!/usr/bin/env node" }, +}); diff --git a/package-lock.json b/package-lock.json index f4159d5..439292d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "image-and-video-compressor", "version": "2.0.2", "license": "MIT", + "workspaces": [ + "mcp" + ], "dependencies": { "commander": "^15.0.0", "picocolors": "^1.1.1", @@ -18,6 +21,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.5", + "@modelcontextprotocol/sdk": "^1.30.0", "@types/node": "^22.10.2", "@vitest/coverage-v8": "^4.1.10", "eslint": "^9.17.0", @@ -31,6 +35,27 @@ "node": ">=20.11" } }, + "mcp": { + "name": "image-and-video-compressor-mcp", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.30.0", + "image-and-video-compressor": "^2.0.2", + "zod": "^4.4.3" + }, + "bin": { + "image-and-video-compressor-mcp": "dist/bin.js" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "tsup": "^8.5.1", + "typescript": "^5.7.2" + }, + "engines": { + "node": ">=20.11" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", @@ -687,6 +712,18 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@hono/node-server": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.0.tgz", + "integrity": "sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -1292,6 +1329,68 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", @@ -1959,7 +2058,6 @@ "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2392,6 +2490,19 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -2433,6 +2544,45 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -2492,6 +2642,43 @@ "dev": true, "license": "MIT" }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/brace-expansion": { "version": "1.1.18", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", @@ -2519,6 +2706,15 @@ "esbuild": ">=0.18" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -2529,6 +2725,35 @@ "node": ">=8" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2635,6 +2860,28 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2642,11 +2889,45 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -2661,7 +2942,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2682,6 +2962,15 @@ "dev": true, "license": "MIT" }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2691,6 +2980,53 @@ "node": ">=8" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", @@ -2698,6 +3034,18 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -2741,6 +3089,12 @@ "@esbuild/win32-x64": "0.27.7" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -2919,6 +3273,36 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -2929,11 +3313,73 @@ "node": ">=12.0.0" } }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "peer": true, + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.6.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz", + "integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { @@ -2950,6 +3396,22 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2981,6 +3443,27 @@ "node": ">=16.0.0" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -3031,11 +3514,29 @@ "dev": true, "license": "ISC" }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -3046,6 +3547,52 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -3072,6 +3619,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3082,6 +3641,40 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz", + "integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -3089,6 +3682,42 @@ "dev": true, "license": "MIT" }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3099,6 +3728,27 @@ "node": ">= 4" } }, + "node_modules/image-and-video-compressor": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/image-and-video-compressor/-/image-and-video-compressor-2.0.2.tgz", + "integrity": "sha512-KnoAsBs+08XthPbHtjS4yblP6QJnpO9sAA+9ZqIbyDdSb/XI8r1afczcjfAn7agDgCiP33AUWef3C8zEaz9qxg==", + "license": "MIT", + "dependencies": { + "commander": "^15.0.0", + "picocolors": "^1.1.1", + "sharp": "^0.35.3" + }, + "bin": { + "imgvidcompress": "dist/cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, + "node_modules/image-and-video-compressor-mcp": { + "resolved": "mcp", + "link": true + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -3126,6 +3776,30 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3149,11 +3823,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -3195,6 +3874,15 @@ "node": ">=8" } }, + "node_modules/jose": { + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.8.tgz", + "integrity": "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -3249,6 +3937,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -3632,6 +4326,65 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -3662,7 +4415,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -3703,16 +4455,36 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/obug": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", @@ -3727,6 +4499,27 @@ "node": ">=12.20.0" } }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3790,6 +4583,15 @@ "node": ">=6" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3804,12 +4606,21 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -3847,6 +4658,15 @@ "node": ">= 6" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-types": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", @@ -3958,6 +4778,19 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3968,6 +4801,50 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -3982,6 +4859,15 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -4071,6 +4957,28 @@ "fsevents": "~2.3.2" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -4083,6 +4991,57 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/sharp": { "version": "0.35.3", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", @@ -4136,7 +5095,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -4149,12 +5107,83 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -4189,6 +5218,15 @@ "dev": true, "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", @@ -4319,6 +5357,15 @@ "node": ">=14.0.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -4432,6 +5479,37 @@ "node": ">= 0.8.0" } }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -4485,6 +5563,15 @@ "dev": true, "license": "MIT" }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -4495,6 +5582,15 @@ "punycode": "^2.1.0" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vite": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", @@ -4679,7 +5775,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -4718,6 +5813,12 @@ "node": ">=0.10.0" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -4730,6 +5831,25 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/package.json b/package.json index 5faff39..a0216b5 100644 --- a/package.json +++ b/package.json @@ -42,11 +42,14 @@ "engines": { "node": ">=20.11" }, + "workspaces": [ + "mcp" + ], "scripts": { "build": "tsup", "dev": "tsup --watch", "typecheck": "tsc --noEmit", - "test": "npm run build && vitest run", + "test": "npm run build && npm run build:mcp && vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "lint": "eslint .", @@ -54,7 +57,8 @@ "format": "prettier --write .", "format:check": "prettier --check .", "prepublishOnly": "npm run typecheck && npm run test && npm run build", - "samples": "node scripts/make-samples.mjs" + "samples": "node scripts/make-samples.mjs", + "build:mcp": "npm run build -w image-and-video-compressor-mcp" }, "dependencies": { "commander": "^15.0.0", @@ -63,6 +67,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.5", + "@modelcontextprotocol/sdk": "^1.30.0", "@types/node": "^22.10.2", "@vitest/coverage-v8": "^4.1.10", "eslint": "^9.17.0", diff --git a/test/mcp.test.ts b/test/mcp.test.ts new file mode 100644 index 0000000..6e5e08b --- /dev/null +++ b/test/mcp.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const serverPath = join(root, "mcp", "dist", "bin.js"); + +/** + * The MCP server is a separate workspace package, so it may not be built when + * only the library has been. Skipping mirrors how the video suite behaves + * without ffmpeg: absent tooling means untested, never a false pass. + */ +const built = existsSync(serverPath); + +describe.skipIf(!built)("mcp server", () => { + let client: Client; + + beforeAll(async () => { + client = new Client({ name: "test", version: "1.0.0" }); + await client.connect( + new StdioClientTransport({ command: process.execPath, args: [serverPath] }), + ); + }); + + afterAll(async () => { + await client?.close(); + }); + + /** + * Every tool answers with JSON in a single text block. + * + * Takes `unknown` because `callTool` is typed as a union that still carries + * the legacy `{ toolResult }` shape, which has no `content` at all. + */ + function text(result: unknown): T { + const { content } = result as { content?: { type: string; text: string }[] }; + if (!content?.[0]) throw new Error("tool returned no content"); + return JSON.parse(content[0].text) as T; + } + + it("advertises its three tools, each documented", async () => { + const { tools } = await client.listTools(); + expect(tools.map((t) => t.name).sort()).toEqual([ + "compress_media", + "list_capabilities", + "probe_media", + ]); + + // The description is the only thing an agent reads before choosing a tool, + // so an empty or throwaway one is a real defect. + for (const tool of tools) { + expect(tool.description, `${tool.name} description`).toBeTruthy(); + expect(tool.description!.length, `${tool.name} description`).toBeGreaterThan(40); + expect(tool.inputSchema, `${tool.name} schema`).toBeTruthy(); + } + }); + + it("reports capabilities actually present on this machine", async () => { + const caps = text<{ image: { write: { extensions: string[] }[] } }>( + await client.callTool({ name: "list_capabilities", arguments: {} }), + ); + + expect(caps.image.write.length).toBeGreaterThan(0); + // WebP is the default output format; if it is missing the tool is unusable. + const extensions = caps.image.write.flatMap((format) => format.extensions); + expect(extensions).toContain(".webp"); + }); + + it("never writes anything on a dry run", async () => { + const outDir = join(root, "test-mcp-dry-output"); + const report = text<{ dryRun: boolean }>( + await client.callTool({ + name: "compress_media", + arguments: { paths: [join(root, "samples", "images")], dryRun: true, outDir }, + }), + ); + + expect(report.dryRun).toBe(true); + expect(existsSync(outDir)).toBe(false); + }); + + it("rejects arguments that violate the schema", async () => { + const result = await client.callTool({ + name: "compress_media", + arguments: { paths: ["x"], kind: "banana" }, + }); + + expect(result.isError).toBe(true); + }); + + it("reports a missing file as an error rather than a crash", async () => { + const result = await client.callTool({ + name: "probe_media", + arguments: { path: join(root, "definitely-not-here.jpg") }, + }); + + expect(result.isError).toBe(true); + }); +}); From 5db95c3e209adb2fbb76a7778f12524a4837ed18 Mon Sep 17 00:00:00 2001 From: Rohan Poudel Date: Wed, 5 Aug 2026 12:43:17 -0600 Subject: [PATCH 2/5] test(mcp): generate fixtures instead of relying on samples/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dry-run test pointed at samples/images, which is gitignored and only exists after 'npm run samples'. It is absent on a CI runner, so the tool correctly returned NO_INPUT_FILES, the report had no dryRun field, and the assertion read 'expected undefined to be true' — a confusing failure for what was really a missing fixture. Now uses the tempDir/makeImage helpers the rest of the suite uses, so the tests are hermetic. Verified by running the full suite with samples/ moved aside: 173 passed. Also covers more while it is here — a real compression that checks the output files exist on disk, and probe_media identifying a generated file. --- test/mcp.test.ts | 61 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 58 insertions(+), 3 deletions(-) diff --git a/test/mcp.test.ts b/test/mcp.test.ts index 6e5e08b..65fd87f 100644 --- a/test/mcp.test.ts +++ b/test/mcp.test.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { tempDir, makeImage } from "./helpers.js"; const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const serverPath = join(root, "mcp", "dist", "bin.js"); @@ -17,8 +18,20 @@ const built = existsSync(serverPath); describe.skipIf(!built)("mcp server", () => { let client: Client; + let fixtures: string; + let cleanup: () => Promise; beforeAll(async () => { + // Generated rather than read from samples/, which is gitignored and absent + // in CI — depending on it made this suite pass locally and fail on a runner. + ({ dir: fixtures, cleanup } = await tempDir()); + await makeImage(join(fixtures, "photo.png"), { + width: 400, + height: 300, + noise: true, + }); + await makeImage(join(fixtures, "flat.png"), { width: 300, height: 200 }); + client = new Client({ name: "test", version: "1.0.0" }); await client.connect( new StdioClientTransport({ command: process.execPath, args: [serverPath] }), @@ -27,6 +40,7 @@ describe.skipIf(!built)("mcp server", () => { afterAll(async () => { await client?.close(); + await cleanup?.(); }); /** @@ -70,18 +84,59 @@ describe.skipIf(!built)("mcp server", () => { }); it("never writes anything on a dry run", async () => { - const outDir = join(root, "test-mcp-dry-output"); - const report = text<{ dryRun: boolean }>( + const outDir = join(fixtures, "dry-output"); + const report = text<{ dryRun: boolean; summary: { totalFiles: number } }>( await client.callTool({ name: "compress_media", - arguments: { paths: [join(root, "samples", "images")], dryRun: true, outDir }, + arguments: { paths: [fixtures], dryRun: true, outDir }, }), ); expect(report.dryRun).toBe(true); + expect(report.summary.totalFiles).toBeGreaterThan(0); expect(existsSync(outDir)).toBe(false); }); + it("compresses for real and reports what it saved", async () => { + const outDir = join(fixtures, "real-output"); + const report = text<{ + ok: boolean; + summary: { compressed: number; savedBytes: number }; + results: { status: string; outputPath?: string }[]; + }>( + await client.callTool({ + name: "compress_media", + arguments: { + paths: [fixtures], + kind: "image", + to: ".webp", + outDir, + overwrite: true, + }, + }), + ); + + expect(report.ok).toBe(true); + expect(report.summary.compressed).toBeGreaterThan(0); + + for (const result of report.results) { + if (result.status === "compressed") + expect(existsSync(result.outputPath!)).toBe(true); + } + }); + + it("identifies a file it was given", async () => { + const probe = text<{ kind: string; bytes: number }>( + await client.callTool({ + name: "probe_media", + arguments: { path: join(fixtures, "photo.png") }, + }), + ); + + expect(probe.kind).toBe("image"); + expect(probe.bytes).toBeGreaterThan(0); + }); + it("rejects arguments that violate the schema", async () => { const result = await client.callTool({ name: "compress_media", From 484045c33498fbbfb0c4c4f08cea88430b7b837b Mon Sep 17 00:00:00 2001 From: Rohan Poudel Date: Wed, 5 Aug 2026 12:49:54 -0600 Subject: [PATCH 3/5] chore(mcp): correct copyright year on the new package It was copied from the root LICENSE, which carries 2024 from the original project. The MCP package is new work in 2026. --- mcp/LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mcp/LICENSE b/mcp/LICENSE index 6b3a7a8..4cb7b43 100644 --- a/mcp/LICENSE +++ b/mcp/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 Rohan Poudel +Copyright (c) 2026 Rohan Poudel Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal From 40f8b7cf6a04479afb51be90c893c3bdb01f8b4b Mon Sep 17 00:00:00 2001 From: Rohan Poudel Date: Wed, 5 Aug 2026 13:07:46 -0600 Subject: [PATCH 4/5] feat(mcp): full option coverage, two new tools, and three bug fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parallel agent testing drove every tool against real files and verified the results with ffprobe and sharp rather than trusting the server's own JSON. That found real problems. Coverage gaps closed: - keepMetadata, autoRotate and preset were library options with no way to reach them through MCP. compress_media now takes all 19. - discover_media: list media under paths without decoding anything. - plan_video_conversion: for a file and a target container, which streams survive, which are dropped and why, the codec, and what the quality maps to on that codec's own scale. Exposes planStreams, previously library-only. Bugs found and fixed: 1. probe_media reported a JPEG named .mp4 as a video, then ran ffprobe on it. The image2 demuxer duly reported a one-frame "mjpeg video stream", so the tool returned a fabricated stream list that looked entirely plausible. Content now decides, with a note when the extension disagrees. Fabricating data is worse than being unhelpful. 2. plan_video_conversion promised libtheora for .ogv on builds with no Theora encoder, reporting "nothing dropped" for a conversion that then died on "Unknown encoder". It now checks the real encoder set, the way list_capabilities already did. A plan that predicts success for something unrunnable is worse than no plan. 3. summarise() stripped outputPath from skipped and failed results, so a dry run could not say where files would go — the one thing it exists for — and an "output-exists" skip never named the colliding file. The library had the path all along. Tests grew to 13, including a schema assertion that every library option stays reachable, so this class of gap cannot reopen quietly. 180 passing. --- README.md | 4 +- mcp/README.md | 42 ++++++- mcp/src/server.ts | 275 ++++++++++++++++++++++++++++++++++++++++++++-- test/mcp.test.ts | 126 ++++++++++++++++++++- 4 files changed, 434 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 4a2ff9c..343663c 100644 --- a/README.md +++ b/README.md @@ -366,7 +366,9 @@ There is an MCP server, so an agent can compress media directly instead of being claude mcp add image-video-compressor -- npx -y image-and-video-compressor-mcp ``` -It exposes three tools — `compress_media`, `probe_media` and `list_capabilities` — over stdio. `list_capabilities` matters most: it reports what the local sharp and ffmpeg builds genuinely support, so an agent can pick a format that exists on this machine rather than assuming AVIF or AV1 is available. +Five tools over stdio: `compress_media` (every option the library takes), `discover_media`, `probe_media`, `list_capabilities` and `plan_video_conversion`. + +Two of those exist because agents guess badly without them. `list_capabilities` reports what the local sharp and ffmpeg builds genuinely support, so a format is chosen rather than assumed. `plan_video_conversion` says which streams a target container would drop — the answer to "will this lose my subtitles" before a file is written. Published separately as [`image-and-video-compressor-mcp`](https://www.npmjs.com/package/image-and-video-compressor-mcp) to keep the MCP SDK's dependency tree out of this package. Details in [mcp/README.md](./mcp/README.md). diff --git a/mcp/README.md b/mcp/README.md index 154c3b8..190dc54 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -37,10 +37,47 @@ Compress images and/or videos. Accepts files or directories. Source files are never modified or overwritten; output is written to a `compressed` folder beside the source, or wherever `outDir` points. If compressing would make a file bigger, the original is kept and the file is reported as skipped rather than quietly made worse. -Key parameters: `paths` (required), `kind` (`auto`/`image`/`video`), `quality` (1-100), `to` (e.g. `.webp`, `.avif`, `.mp4`, `.webm`), `outDir`, `recursive`, `dryRun`, `maxWidth`, `maxHeight`, `overwrite`, plus `videoCodec`, `audioCodec`, `fps` and `ffmpegPath` for video. +| Parameter | Applies to | Notes | +| ------------------------ | ---------- | --------------------------------------------------------------- | +| `paths` | both | Required. Files or directories. | +| `kind` | both | `auto` (default), `image`, `video`. | +| `quality` | both | 1-100, default 75. Mapped onto each codec's own scale. | +| `to` | both | `.webp`, `.avif`, `.mp4`, `.webm`, … | +| `outDir` | both | Default: a `compressed` folder beside the source. | +| `recursive` | both | Mirrors the input tree in the output. | +| `overwrite` | both | Otherwise an existing output is skipped. | +| `dryRun` | both | Plan only, writes nothing. | +| `maxWidth` / `maxHeight` | both | Shrink to fit. Never enlarges. | +| `concurrency` | both | Default is per media kind. | +| `skipLarger` | both | Default true: keep the original when compression would grow it. | +| `keepMetadata` | images | Preserve EXIF/ICC. Default false — stripping saves real bytes. | +| `autoRotate` | images | Apply EXIF orientation. Default true. | +| `videoCodec` | video | `libx264`, `libx265`, `libsvtav1`, `libvpx-vp9`, … | +| `audioCodec` | video | `aac`, `libopus`, or `copy`. | +| `fps` | video | Cap the frame rate. Default keeps the source rate. | +| `preset` | video | Codec-specific speed/efficiency tradeoff. | +| `ffmpegPath` | video | Explicit binary path. | Set `dryRun: true` to see the plan and the output paths without writing anything. +### `discover_media` + +List the image and video files under given paths without compressing or opening them. Classification is by extension, so it scans large trees cheaply. + +Answers "what media is in this project and how much does it weigh" before deciding what to act on. Returns per-file path, kind and size, plus totals. + +Parameters: `paths` (required), `kind`, `recursive`. + +### `plan_video_conversion` + +For a video and a target container, report what converting would do — before writing anything. + +Returns the codec that would be used, the audio codec, what the given quality maps to on that codec's own scale (`encoderValue` — CRF counts down, Theora's scale counts up), the duration, which streams survive, and **which streams would be dropped and why**. + +This is the tool for "will I lose my subtitles or the commentary track if I convert this to mp4". Losing a track silently is worse than refusing outright, because nobody notices until they need it. + +Parameters: `path` (required), `to` (required, a curated container), `quality`, `videoCodec`, `ffmpegPath`. + ### `probe_media` Identify one file without modifying it: image or video, size on disk, and for video the duration and stream layout from ffprobe. @@ -55,8 +92,9 @@ Worth calling before choosing an output format. Support is build-dependent rathe ## Notes for agents -- **Preview before writing.** `compress_media` with `dryRun: true` is free and shows exactly which files would be written where. +- **Preview before writing.** `compress_media` with `dryRun: true` shows exactly which files would be written where; `plan_video_conversion` adds the stream-level detail. Both are free. - **Check the format first.** `list_capabilities` prevents asking for an encoder this machine does not have. +- **Survey before acting.** `discover_media` is the cheap way to see what is there without decoding anything. - **Failures are per-file.** A corrupt file is reported in `results` with a stable error code; the rest of the batch still completes. Branch on `error.code`, not on the message text. - **Skips are not failures.** `status: "skipped"` with `reason: "output-larger-than-input"` means compression was attempted and the original was better. diff --git a/mcp/src/server.ts b/mcp/src/server.ts index c4237b7..c9777ec 100644 --- a/mcp/src/server.ts +++ b/mcp/src/server.ts @@ -6,18 +6,34 @@ import { compressImages, compressVideos, classifyFile, + discoverFiles, sniffFile, probeMedia, + planStreams, resolveFfmpeg, imageCapabilities, ffmpegCapabilities, toQuality, toPixels, + isVideoContainer, + isCodecAllowedIn, + defaultVideoCodec, + defaultAudioCodec, + qualityModelFor, + mapQuality, VIDEO_CONTAINERS, + VIDEO_OUTPUT_FORMATS, type CompressOptions, type CompressionReport, } from "image-and-video-compressor"; +/** Library errors carry a stable `code`; anything else is unknown. */ +function errorCode(err: unknown): string { + return typeof err === "object" && err !== null && "code" in err + ? String(err.code) + : "UNKNOWN"; +} + /** * Every tool returns JSON as text content. * @@ -60,12 +76,17 @@ function summarise(report: CompressionReport) { ? { status: result.status, inputPath: result.inputPath, + // Kept on every branch: a dry run is useless without it, and an + // "output-exists" skip that does not say WHICH file collided + // sends the caller hunting through the output directory. + outputPath: result.outputPath, error: { code: result.error.code, message: result.error.message }, } : result.status === "skipped" ? { status: result.status, inputPath: result.inputPath, + outputPath: result.outputPath, reason: result.reason, ...(result.warnings?.length ? { warnings: result.warnings } : {}), } @@ -172,6 +193,18 @@ export function createServer(): McpServer { .boolean() .default(true) .describe("Keep the original when compression would make it bigger."), + keepMetadata: z + .boolean() + .default(false) + .describe( + "Images: preserve EXIF and ICC instead of stripping it. Stripping saves real bytes.", + ), + autoRotate: z + .boolean() + .default(true) + .describe( + "Images: apply EXIF orientation, so portrait shots are not left sideways.", + ), videoCodec: z .string() .optional() @@ -187,6 +220,13 @@ export function createServer(): McpServer { .max(240) .optional() .describe("Cap frame rate. Default keeps the source rate."), + preset: z + .string() + .optional() + .describe( + "Encoder speed/efficiency tradeoff. Codec-specific: 'slow'/'medium'/'fast' for " + + "x264 and x265, a numeric cpu-used for VP9 and AV1. Sane default per codec.", + ), ffmpegPath: z .string() .optional() @@ -209,7 +249,10 @@ export function createServer(): McpServer { overwrite: args.overwrite, dryRun: args.dryRun, skipLarger: args.skipLarger, + keepMetadata: args.keepMetadata, + autoRotate: args.autoRotate, ...(args.concurrency !== undefined ? { concurrency: args.concurrency } : {}), + ...(args.preset !== undefined ? { preset: args.preset } : {}), ...(args.videoCodec !== undefined ? { videoCodec: args.videoCodec as CompressOptions["videoCodec"] } : {}), @@ -244,11 +287,10 @@ export function createServer(): McpServer { } catch (err) { // Setup errors (no inputs, bad option, ffmpeg missing) reject; per-file // problems already arrive as failed results inside the report. - const code = - typeof err === "object" && err !== null && "code" in err - ? String(err.code) - : "UNKNOWN"; - return failure(err instanceof Error ? err.message : String(err), code); + return failure( + err instanceof Error ? err.message : String(err), + errorCode(err), + ); } }, ); @@ -260,8 +302,9 @@ export function createServer(): McpServer { description: "Identify a media file without modifying it: whether it is an image or a video, " + "its size on disk, and for video its duration and stream layout (video, audio, " + - "subtitle tracks) via ffprobe. Detection uses content bytes when the extension is " + - "missing or wrong, so it correctly reports a .jpg that is really an MP4.", + "subtitle tracks) via ffprobe. Identification is by content, not by filename, so " + + "a .jpg that is really an MP4 is reported as the video it is — with a note saying " + + "the extension disagrees.", inputSchema: { path: z.string().describe("Path to a single media file."), }, @@ -277,14 +320,38 @@ export function createServer(): McpServer { const info = await stat(path); if (!info.isFile()) return failure(`Not a file: ${path}`, "INPUT_NOT_FOUND"); - const kind = await classifyFile(path); + const byExtension = await classifyFile(path); const sniffed = await sniffFile(path); + /** + * Content wins over the name. + * + * `classifyFile` answers by extension first, which is the right default + * for bulk discovery where opening every file is too costly. Here the + * caller has named one file and wants the truth about it. Trusting the + * extension meant a JPEG called `.mp4` was reported as a video, and + * ffprobe was then run on it — the image2 demuxer duly reported a + * one-frame "mjpeg video stream", so the answer looked plausible and + * was entirely fabricated. + */ + const kind = sniffed?.kind ?? byExtension; + const misnamed = + sniffed !== null && byExtension !== null && sniffed.kind !== byExtension; + const base = { path, kind, bytes: info.size, ...(sniffed ? { detected: sniffed } : {}), + ...(misnamed + ? { + extensionSuggests: byExtension, + note: + `The extension says ${byExtension} but the contents are ${sniffed.format}. ` + + "Reporting what the bytes say. Encoders read the real format too, so this " + + "file still compresses correctly.", + } + : {}), }; if (kind !== "video") return json(base); @@ -318,6 +385,198 @@ export function createServer(): McpServer { }, ); + server.registerTool( + "discover_media", + { + title: "List media files under a path", + description: + "List the image and video files under the given paths without compressing or " + + "opening them. Cheap: classification is by extension against what this sharp and " + + "ffmpeg build handles, so it scans large trees quickly. Use it to answer 'what " + + "media is in this project and how big is it' before deciding what to compress.", + inputSchema: { + paths: z.array(z.string()).min(1).describe("Files or directories to scan."), + kind: z + .enum(["auto", "image", "video"]) + .default("auto") + .describe("Restrict the listing to one media type."), + recursive: z.boolean().default(false).describe("Descend into subdirectories."), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async (args) => { + try { + const files = await discoverFiles(args.paths, { + recursive: args.recursive, + ...(args.kind !== "auto" ? { kind: args.kind } : {}), + }); + + const totalBytes = files.reduce((sum, file) => sum + file.bytes, 0); + return json({ + totalFiles: files.length, + totalBytes, + images: files.filter((file) => file.kind === "image").length, + videos: files.filter((file) => file.kind === "video").length, + files: files.map((file) => ({ + path: file.path, + kind: file.kind, + bytes: file.bytes, + })), + }); + } catch (err) { + return failure( + err instanceof Error ? err.message : String(err), + errorCode(err), + ); + } + }, + ); + + server.registerTool( + "plan_video_conversion", + { + title: "Preview what converting a video would do", + description: + "For a video and a target container, report which streams survive, which are " + + "dropped and why, the codec that would be used, and what a given quality maps to " + + "on that codec's own scale. Answers 'will I lose my subtitles or commentary track " + + "if I convert this to mp4' before any file is written. Read-only.", + inputSchema: { + path: z.string().describe("Path to the source video."), + to: z + .string() + .describe( + "Target container, e.g. '.mp4', '.webm', '.mkv'. Must be a curated container.", + ), + quality: z + .number() + .int() + .min(1) + .max(100) + .default(75) + .describe("Quality to translate onto the target codec's scale."), + videoCodec: z + .string() + .optional() + .describe("Override the codec. Must be legal for the container."), + ffmpegPath: z + .string() + .optional() + .describe("Explicit path to the ffmpeg binary."), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async (args) => { + const container = args.to.startsWith(".") + ? args.to.toLowerCase() + : `.${args.to.toLowerCase()}`; + + if (!isVideoContainer(container)) { + return failure( + `${container} is not a curated container. Curated: ${VIDEO_OUTPUT_FORMATS.join(", ")}. ` + + "Other containers your ffmpeg can mux still work in compress_media, but cannot be planned here.", + "UNSUPPORTED_FORMAT", + ); + } + + const codec = args.videoCodec ?? defaultVideoCodec(container); + if (!isCodecAllowedIn(container, codec)) { + return failure( + `${VIDEO_CONTAINERS[container].label} cannot carry ${codec}. ` + + `Allowed: ${VIDEO_CONTAINERS[container].video.join(", ")}.`, + "UNSUPPORTED_FORMAT", + ); + } + + try { + const tools = await resolveFfmpeg(args.ffmpegPath); + if (!tools.ffprobe) + return failure( + "ffprobe unavailable; cannot inspect streams.", + "FFMPEG_NOT_FOUND", + ); + + /** + * The curated table says which codecs a container *may* carry; it does + * not know what this ffmpeg was built with. Without this check the plan + * cheerfully promised libtheora for .ogv on a build with no Theora + * encoder, and the compression that followed died with "Unknown + * encoder". A plan that predicts success for something that cannot run + * is worse than no plan at all. + */ + const caps = await ffmpegCapabilities(tools.ffmpeg); + if (!caps.videoEncoders.has(codec)) { + const usable = VIDEO_CONTAINERS[container].video.filter((candidate) => + caps.videoEncoders.has(candidate), + ); + return failure( + `This ffmpeg has no ${codec} encoder, so ${container} cannot be produced with it. ` + + (usable.length > 0 + ? `Available for ${container} on this machine: ${usable.join(", ")}.` + : `No encoder for ${container} is available in this build at all.`), + "UNSUPPORTED_FORMAT", + ); + } + + const probe = await probeMedia(tools.ffprobe, args.path); + const { plan, dropped } = planStreams(container, probe); + + // Quality is mapped onto the codec's own scale and direction — CRF + // counts down, Theora counts up — so report the real encoder value. + const model = qualityModelFor(codec); + const encoderValue = model ? mapQuality(toQuality(args.quality), model) : null; + + // Same reasoning as the video encoder: only promise an audio codec the + // binary can actually produce. + // A container's default audio codec is always a real encoder, never + // "copy", so availability is a straight lookup. + const audioCodec = defaultAudioCodec(container); + const audioAvailable = caps.audioEncoders.has(audioCodec); + + return json({ + container, + label: VIDEO_CONTAINERS[container].label, + videoCodec: codec, + audioCodec, + ...(audioAvailable + ? {} + : { + audioNote: + `This ffmpeg has no ${audioCodec} encoder. Audio would fail unless the ` + + "source track can be copied through, so pass audioCodec explicitly.", + }), + quality: args.quality, + encoderValue, + durationSeconds: probe.durationSeconds, + keeps: { + video: plan.video.length, + audio: plan.audio.length, + subtitles: plan.subtitles.length, + attachments: plan.attachments, + }, + // Named explicitly: losing a commentary track silently is worse than + // refusing outright, because nobody notices until they need it. + dropped, + }); + } catch (err) { + return failure( + err instanceof Error ? err.message : String(err), + errorCode(err), + ); + } + }, + ); + server.registerTool( "list_capabilities", { diff --git a/test/mcp.test.ts b/test/mcp.test.ts index 65fd87f..286e743 100644 --- a/test/mcp.test.ts +++ b/test/mcp.test.ts @@ -1,10 +1,11 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest"; import { existsSync } from "node:fs"; +import { copyFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; -import { tempDir, makeImage } from "./helpers.js"; +import { tempDir, makeImage, hasFfmpeg } from "./helpers.js"; const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const serverPath = join(root, "mcp", "dist", "bin.js"); @@ -55,11 +56,13 @@ describe.skipIf(!built)("mcp server", () => { return JSON.parse(content[0].text) as T; } - it("advertises its three tools, each documented", async () => { + it("advertises every tool, each documented", async () => { const { tools } = await client.listTools(); expect(tools.map((t) => t.name).sort()).toEqual([ "compress_media", + "discover_media", "list_capabilities", + "plan_video_conversion", "probe_media", ]); @@ -72,6 +75,38 @@ describe.skipIf(!built)("mcp server", () => { } }); + it("exposes every library option through compress_media", async () => { + const { tools } = await client.listTools(); + const schema = tools.find((tool) => tool.name === "compress_media")!.inputSchema; + const params = Object.keys(schema.properties ?? {}); + + // Guards against the gap this list was written to close: keepMetadata, + // autoRotate and preset were library options with no way to reach them. + for (const option of [ + "paths", + "kind", + "quality", + "to", + "outDir", + "recursive", + "overwrite", + "dryRun", + "maxWidth", + "maxHeight", + "concurrency", + "skipLarger", + "keepMetadata", + "autoRotate", + "videoCodec", + "audioCodec", + "fps", + "preset", + "ffmpegPath", + ]) { + expect(params, `compress_media is missing ${option}`).toContain(option); + } + }); + it("reports capabilities actually present on this machine", async () => { const caps = text<{ image: { write: { extensions: string[] }[] } }>( await client.callTool({ name: "list_capabilities", arguments: {} }), @@ -137,6 +172,37 @@ describe.skipIf(!built)("mcp server", () => { expect(probe.bytes).toBeGreaterThan(0); }); + it("tells a dry run WHERE each file would be written", async () => { + const report = text<{ results: { status: string; outputPath?: string }[] }>( + await client.callTool({ + name: "compress_media", + arguments: { paths: [fixtures], kind: "image", dryRun: true }, + }), + ); + + // Without this the dry-run preview cannot answer the one question it + // exists to answer, even though the library computed the path already. + for (const result of report.results) { + expect(result.outputPath, JSON.stringify(result)).toBeTruthy(); + } + }); + + it("identifies a misnamed file by its contents, not its extension", async () => { + // A real JPEG called .mp4. Trusting the name meant ffprobe ran on it and + // the image2 demuxer reported a one-frame "mjpeg video stream" — a + // fabricated answer that looked entirely plausible. + const liar = join(fixtures, "actually-an-image.mp4"); + await copyFile(join(fixtures, "photo.png"), liar); + + const probe = text<{ kind: string; note?: string; video?: unknown }>( + await client.callTool({ name: "probe_media", arguments: { path: liar } }), + ); + + expect(probe.kind).toBe("image"); + expect(probe.note).toBeTruthy(); + expect(probe.video).toBeUndefined(); + }); + it("rejects arguments that violate the schema", async () => { const result = await client.callTool({ name: "compress_media", @@ -146,6 +212,62 @@ describe.skipIf(!built)("mcp server", () => { expect(result.isError).toBe(true); }); + it("refuses to plan a conversion this ffmpeg cannot actually perform", async () => { + if (!(await hasFfmpeg())) return; + + // Which encoders exist is build-dependent, so ask rather than assume, then + // name one that is genuinely absent. The curated table alone would happily + // promise it and the compression that followed would die on "Unknown + // encoder" — a plan predicting success for something unrunnable. + const caps = text<{ + video: { containers: { extension: string; videoCodecs: string[] }[] }; + }>(await client.callTool({ name: "list_capabilities", arguments: {} })); + const empty = caps.video.containers.find((c) => c.videoCodecs.length === 0); + if (!empty) return; + + const result = await client.callTool({ + name: "plan_video_conversion", + arguments: { path: join(fixtures, "photo.png"), to: empty.extension }, + }); + + expect(result.isError).toBe(true); + }); + + it("rejects a container it cannot plan for", async () => { + const result = await client.callTool({ + name: "plan_video_conversion", + arguments: { path: join(fixtures, "photo.png"), to: ".nonsense" }, + }); + + expect(result.isError).toBe(true); + }); + + it("rejects a codec the target container cannot carry", async () => { + // WebM genuinely cannot carry H.264. Caught before any work starts. + const result = await client.callTool({ + name: "plan_video_conversion", + arguments: { + path: join(fixtures, "photo.png"), + to: ".webm", + videoCodec: "libx264", + }, + }); + + expect(result.isError).toBe(true); + }); + + it("lists what is on disk without decoding it", async () => { + const found = text<{ totalFiles: number; images: number; videos: number }>( + await client.callTool({ + name: "discover_media", + arguments: { paths: [fixtures] }, + }), + ); + + expect(found.totalFiles).toBeGreaterThan(0); + expect(found.images + found.videos).toBe(found.totalFiles); + }); + it("reports a missing file as an error rather than a crash", async () => { const result = await client.callTool({ name: "probe_media", From e35c41cb1808f3f2576b0cd75d791173ef48f6dc Mon Sep 17 00:00:00 2001 From: Rohan Poudel Date: Wed, 5 Aug 2026 13:16:05 -0600 Subject: [PATCH 5/5] fix(mcp): phrase errors in parameters an agent can actually pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Library error messages name CLI flags, because they were written for the CLI: "Use --recursive to preserve the directory structure", "Choose a different --out directory or --to format", "Run `imgvidcompress formats`". That is good advice with no way to act on it here. An agent has no CLI, so following it literally means passing a parameter called "--recursive" and getting a schema error — the remedy the message offers is unreachable. Translated at the MCP boundary rather than by changing the library, so CLI users keep messages that name the flags they actually type. Longest flag first, and a word boundary, so --out does not turn --output into outDirput. before: Use --recursive to preserve the directory structure... after: Use recursive to preserve the directory structure... before: Run `imgvidcompress formats` for the full capability list. after: Run the list_capabilities tool for the full capability list. Tested against a real collision error, asserting no CLI-style flag survives in the message. 181 passing. --- mcp/src/server.ts | 46 ++++++++++++++++++++++++++++++++++++++++++++-- test/mcp.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/mcp/src/server.ts b/mcp/src/server.ts index c9777ec..6984199 100644 --- a/mcp/src/server.ts +++ b/mcp/src/server.ts @@ -34,6 +34,48 @@ function errorCode(err: unknown): string { : "UNKNOWN"; } +/** + * CLI flag names, as they appear in library error messages, mapped to the + * parameters this server actually accepts. + * + * The messages are good advice written for the CLI — "Use --recursive to + * preserve the directory structure" — but an agent has no CLI. Following that + * literally means passing a parameter called `--recursive` and getting a schema + * error, so the remedy the message offers is unreachable. Longest first, so + * `--audio-codec` is not clipped by `--codec`. + */ +const CLI_FLAG_TO_PARAMETER: readonly (readonly [string, string])[] = [ + ["--no-skip-larger", "skipLarger: false"], + ["--audio-codec", "audioCodec"], + ["--ffmpeg-path", "ffmpegPath"], + ["--concurrency", "concurrency"], + ["--max-height", "maxHeight"], + ["--max-width", "maxWidth"], + ["--overwrite", "overwrite"], + ["--recursive", "recursive"], + ["--dry-run", "dryRun"], + ["--quality", "quality"], + ["--output", "to"], + ["--preset", "preset"], + ["--codec", "videoCodec"], + ["--out", "outDir"], + ["--fps", "fps"], + ["--to", "to"], +]; + +/** Rewrite a library message so its advice is actionable through this server. */ +function forAgent(message: string): string { + let out = message.replace( + /`?imgvidcompress formats`?/g, + "the list_capabilities tool", + ); + for (const [flag, parameter] of CLI_FLAG_TO_PARAMETER) { + // \b stops `--out` from turning `--output` into `outDirput`. + out = out.replace(new RegExp(`${flag}\\b`, "g"), parameter); + } + return out; +} + /** * Every tool returns JSON as text content. * @@ -53,7 +95,7 @@ function failure(message: string, code = "TOOL_ERROR") { content: [ { type: "text" as const, - text: JSON.stringify({ ok: false, code, message }, null, 2), + text: JSON.stringify({ ok: false, code, message: forAgent(message) }, null, 2), }, ], }; @@ -80,7 +122,7 @@ function summarise(report: CompressionReport) { // "output-exists" skip that does not say WHICH file collided // sends the caller hunting through the output directory. outputPath: result.outputPath, - error: { code: result.error.code, message: result.error.message }, + error: { code: result.error.code, message: forAgent(result.error.message) }, } : result.status === "skipped" ? { diff --git a/test/mcp.test.ts b/test/mcp.test.ts index 286e743..870b9e3 100644 --- a/test/mcp.test.ts +++ b/test/mcp.test.ts @@ -203,6 +203,31 @@ describe.skipIf(!built)("mcp server", () => { expect(probe.video).toBeUndefined(); }); + it("phrases errors in parameters an agent can actually pass", async () => { + // Two same-named files in different directories collide on one output + // path. The library's advice for that is "Use --recursive", which is good + // CLI guidance and unusable here: there is no CLI, and passing a parameter + // named "--recursive" is a schema error. + const left = join(fixtures, "collide-a"); + const right = join(fixtures, "collide-b"); + await makeImage(join(left, "same.png"), { width: 120, height: 90 }); + await makeImage(join(right, "same.png"), { width: 130, height: 100 }); + + const result = await client.callTool({ + name: "compress_media", + arguments: { + paths: [left, right], + kind: "image", + outDir: join(fixtures, "collide-out"), + }, + }); + + expect(result.isError).toBe(true); + const { message } = text<{ message: string }>(result); + expect(message).not.toMatch(/--[a-z]/); + expect(message).toContain("recursive"); + }); + it("rejects arguments that violate the schema", async () => { const result = await client.callTool({ name: "compress_media",