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..343663c 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,20 @@ 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 +``` + +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). + ### 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..4cb7b43 --- /dev/null +++ b/mcp/LICENSE @@ -0,0 +1,21 @@ +MIT License + +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 +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..190dc54 --- /dev/null +++ b/mcp/README.md @@ -0,0 +1,103 @@ +# 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. + +| 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. + +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` 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. + +## 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..6984199 --- /dev/null +++ b/mcp/src/server.ts @@ -0,0 +1,699 @@ +import { stat } from "node:fs/promises"; +import { z } from "zod"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + compress, + 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"; +} + +/** + * 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. + * + * 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: forAgent(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, + // 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: forAgent(result.error.message) }, + } + : result.status === "skipped" + ? { + status: result.status, + inputPath: result.inputPath, + outputPath: result.outputPath, + 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."), + 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() + .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."), + 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() + .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, + 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"] } + : {}), + ...(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. + return failure( + err instanceof Error ? err.message : String(err), + errorCode(err), + ); + } + }, + ); + + 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. 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."), + }, + 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 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); + + 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( + "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", + { + 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..870b9e3 --- /dev/null +++ b/test/mcp.test.ts @@ -0,0 +1,304 @@ +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, hasFfmpeg } from "./helpers.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; + 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] }), + ); + }); + + afterAll(async () => { + await client?.close(); + await cleanup?.(); + }); + + /** + * 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 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", + ]); + + // 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("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: {} }), + ); + + 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(fixtures, "dry-output"); + const report = text<{ dryRun: boolean; summary: { totalFiles: number } }>( + await client.callTool({ + name: "compress_media", + 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("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("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", + arguments: { paths: ["x"], kind: "banana" }, + }); + + 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", + arguments: { path: join(root, "definitely-not-here.jpg") }, + }); + + expect(result.isError).toBe(true); + }); +});