roundcut-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Araluma
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # roundcut-mcp
2
+
3
+ Local image tools for AI agents, as an [MCP](https://modelcontextprotocol.io) server.
4
+ Circle crop, crop, resize, compress and convert JPG, PNG, WebP and AVIF on your own
5
+ machine with [sharp](https://sharp.pixelplumbing.com). No network calls, no accounts,
6
+ no upload: the files never leave the disk.
7
+
8
+ Made by [RoundCut](https://roundcut.app), the free image tools site (29 languages).
9
+
10
+ ## Install
11
+
12
+ Node 20 or newer.
13
+
14
+ ```bash
15
+ npx roundcut-mcp
16
+ ```
17
+
18
+ ### Claude Desktop / Claude Code
19
+
20
+ ```json
21
+ {
22
+ "mcpServers": {
23
+ "roundcut": { "command": "npx", "args": ["-y", "roundcut-mcp"] }
24
+ }
25
+ }
26
+ ```
27
+
28
+ Claude Code: `claude mcp add roundcut -- npx -y roundcut-mcp`
29
+
30
+ ### Cursor, Windsurf, VS Code
31
+
32
+ Same shape: a stdio server, command `npx`, args `-y roundcut-mcp`.
33
+
34
+ ## Tools
35
+
36
+ | Tool | What it does |
37
+ |---|---|
38
+ | `image_info` | Format, dimensions, alpha, EXIF orientation. |
39
+ | `circle_crop` | Round profile picture: centered square plus a circular transparent mask. `size`, `format` (png default), `background` for jpeg. |
40
+ | `crop_image` | Pixel box (`left`, `top`, `width`, `height`) or a centered `aspect` such as `1:1`, `4:5`, `16:9`. |
41
+ | `resize_image` | By `width`, `height` or `percent`. Keeps the aspect ratio, never enlarges unless `enlarge: true`. Lanczos3. |
42
+ | `compress_image` | Smaller file, same format. `quality` for jpg/webp/avif (mozjpeg for jpg), lossless recompression for png. |
43
+ | `convert_image` | To `jpeg`, `png`, `webp` or `avif`. Input may also be GIF, TIFF or HEIC. Transparency kept, or flattened onto `background` for jpeg. |
44
+ | `roundcut_web_tools` | Links to the browser tools this server does not run locally: background remover, AI upscaler, batch convert, JPG to PDF. |
45
+
46
+ Every tool takes an `input` path and writes next to it by default
47
+ (`photo.jpg` becomes `photo-circle.png`, `photo-resized.jpg`, `photo.webp`), or to `output`.
48
+ Results carry a text summary, a `resource_link` to the file, structured JSON
49
+ (`output`, `format`, `width`, `height`, `bytes`, `inputBytes`) and a small JPEG preview
50
+ (`preview: false` to skip it).
51
+
52
+ Example, from a chat client with the server attached:
53
+
54
+ > Make `~/Pictures/me.jpg` a 512 px round avatar as WebP.
55
+
56
+ calls `circle_crop` with `{ "input": "~/Pictures/me.jpg", "size": 512, "format": "webp" }`
57
+ and answers with the file path, the dimensions and the byte count.
58
+
59
+ ## Limits
60
+
61
+ - Inputs above 80 megapixels are refused, so a stray call cannot allocate gigabytes.
62
+ - EXIF orientation is applied on read; the output is upright and carries no EXIF.
63
+ - jpeg has no alpha: transparent areas are flattened onto `background` (white by default).
64
+
65
+ ## Develop
66
+
67
+ ```bash
68
+ npm install
69
+ npm test # builds, then node --test
70
+ ```
71
+
72
+ `src/engine.ts` is the pure image layer (tested directly). `src/index.ts` adapts it to MCP tools.
73
+
74
+ ## License
75
+
76
+ MIT. Copyright Araluma.
@@ -0,0 +1,86 @@
1
+ import { type Metadata, type Sharp } from "sharp";
2
+ export type Format = "jpeg" | "png" | "webp" | "avif";
3
+ export declare const FORMATS: readonly ["jpeg", "png", "webp", "avif"];
4
+ export interface Report {
5
+ output: string;
6
+ format: Format;
7
+ width: number;
8
+ height: number;
9
+ bytes: number;
10
+ inputBytes: number;
11
+ }
12
+ export declare function readInput(input: string): Promise<{
13
+ buf: Buffer;
14
+ bytes: number;
15
+ abs: string;
16
+ }>;
17
+ export declare function formatOf(meta: Metadata): Format;
18
+ /** Default output path: same directory, same stem, a suffix, the target extension. */
19
+ export declare function outputPathFor(inputAbs: string, suffix: string, format: Format, explicit?: string): string;
20
+ export interface EncodeOptions {
21
+ /** 1..100 for the lossy formats; png ignores it. Default 80. */
22
+ quality?: number;
23
+ /** CSS color used to flatten alpha when the output is jpeg. Default white. */
24
+ background?: string;
25
+ }
26
+ /** Apply the output encoder. jpeg has no alpha, so transparent pixels are
27
+ * flattened onto `background` instead of turning black. */
28
+ export declare function encode(img: Sharp, format: Format, opts?: EncodeOptions): Sharp;
29
+ export declare function info(input: string): Promise<{
30
+ path: string;
31
+ bytes: number;
32
+ format: keyof import("sharp").FormatEnum;
33
+ width: number;
34
+ height: number;
35
+ hasAlpha: boolean;
36
+ orientation: number;
37
+ space: keyof import("sharp").ColourspaceEnum;
38
+ }>;
39
+ export interface CircleCropOptions extends EncodeOptions {
40
+ output?: string;
41
+ /** Default png, which keeps the transparent corners. */
42
+ format?: Format;
43
+ /** Output side in px. Default: the largest centered square of the input. */
44
+ size?: number;
45
+ }
46
+ /** Center square, then a circular alpha mask. png/webp/avif keep the
47
+ * transparency; jpeg gets the corners flattened onto `background`. */
48
+ export declare function circleCrop(input: string, o?: CircleCropOptions): Promise<Report>;
49
+ export interface CropOptions extends EncodeOptions {
50
+ output?: string;
51
+ format?: Format;
52
+ left?: number;
53
+ top?: number;
54
+ width?: number;
55
+ height?: number;
56
+ /** "1:1", "16:9", "4:5": a centered crop to that ratio, overriding the pixel box. */
57
+ aspect?: string;
58
+ }
59
+ export declare function crop(input: string, o?: CropOptions): Promise<Report>;
60
+ export interface ResizeOptions extends EncodeOptions {
61
+ output?: string;
62
+ format?: Format;
63
+ width?: number;
64
+ height?: number;
65
+ /** 1..400, relative to the input size. */
66
+ percent?: number;
67
+ /** Default inside: keeps the aspect ratio and never crops. */
68
+ fit?: "inside" | "cover" | "fill";
69
+ /** Default false: never upscale. */
70
+ enlarge?: boolean;
71
+ }
72
+ export declare function resize(input: string, o?: ResizeOptions): Promise<Report>;
73
+ export interface CompressOptions extends EncodeOptions {
74
+ output?: string;
75
+ }
76
+ /** Same format in and out, smaller file. png is lossless; the others use `quality`. */
77
+ export declare function compress(input: string, o?: CompressOptions): Promise<Report>;
78
+ export interface ConvertOptions extends EncodeOptions {
79
+ output?: string;
80
+ }
81
+ export declare function convert(input: string, format: Format, o?: ConvertOptions): Promise<Report>;
82
+ /** A small JPEG preview for chat clients that render image content. */
83
+ export declare function preview(file: string, max?: number): Promise<{
84
+ data: string;
85
+ mimeType: "image/jpeg";
86
+ }>;
package/dist/engine.js ADDED
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Pure image operations on top of sharp. No MCP here: every function takes a
3
+ * path, returns a small report, and is unit-tested directly. The MCP layer
4
+ * (index.ts) only adapts these to tool calls.
5
+ */
6
+ import { access, readFile, stat, writeFile } from "node:fs/promises";
7
+ import path from "node:path";
8
+ import sharp from "sharp";
9
+ export const FORMATS = ["jpeg", "png", "webp", "avif"];
10
+ const EXT = { jpeg: ".jpg", png: ".png", webp: ".webp", avif: ".avif" };
11
+ /** Pixel ceiling per image. 80 MP covers every phone camera; beyond that sharp
12
+ * would happily allocate gigabytes for a single call issued by an LLM. */
13
+ const MAX_PIXELS = 80_000_000;
14
+ export async function readInput(input) {
15
+ const abs = path.resolve(input);
16
+ await access(abs);
17
+ const s = await stat(abs);
18
+ if (!s.isFile())
19
+ throw new Error(`not a file: ${abs}`);
20
+ return { buf: await readFile(abs), bytes: s.size, abs };
21
+ }
22
+ function open(buf) {
23
+ return sharp(buf, { limitInputPixels: MAX_PIXELS, failOn: "error" });
24
+ }
25
+ export function formatOf(meta) {
26
+ const f = meta.format;
27
+ if (f === "jpeg" || f === "png" || f === "webp")
28
+ return f;
29
+ // sharp reports AVIF as heif + av1 compression; HEIC is heif + hevc.
30
+ if (f === "heif" && meta.compression === "av1")
31
+ return "avif";
32
+ // HEIC, TIFF, GIF, SVG decode fine but have no matching web encoder: use PNG.
33
+ return "png";
34
+ }
35
+ /** Default output path: same directory, same stem, a suffix, the target extension. */
36
+ export function outputPathFor(inputAbs, suffix, format, explicit) {
37
+ if (explicit)
38
+ return path.resolve(explicit);
39
+ const dir = path.dirname(inputAbs);
40
+ const stem = path.basename(inputAbs, path.extname(inputAbs));
41
+ return path.join(dir, `${stem}${suffix}${EXT[format]}`);
42
+ }
43
+ /** Apply the output encoder. jpeg has no alpha, so transparent pixels are
44
+ * flattened onto `background` instead of turning black. */
45
+ export function encode(img, format, opts = {}) {
46
+ const q = opts.quality ?? 80;
47
+ switch (format) {
48
+ case "jpeg":
49
+ return img.flatten({ background: opts.background ?? "#ffffff" }).jpeg({ quality: q, mozjpeg: true });
50
+ case "png":
51
+ return img.png({ compressionLevel: 9, effort: 7 });
52
+ case "webp":
53
+ return img.webp({ quality: q });
54
+ case "avif":
55
+ return img.avif({ quality: q, effort: 4 });
56
+ }
57
+ }
58
+ async function finish(img, format, opts, output, inputBytes) {
59
+ const { data, info } = await encode(img, format, opts).toBuffer({ resolveWithObject: true });
60
+ await writeFile(output, data);
61
+ return { output, format, width: info.width, height: info.height, bytes: data.length, inputBytes };
62
+ }
63
+ export async function info(input) {
64
+ const { buf, bytes, abs } = await readInput(input);
65
+ const m = await open(buf).metadata();
66
+ return {
67
+ path: abs,
68
+ bytes,
69
+ format: m.format,
70
+ width: m.width,
71
+ height: m.height,
72
+ hasAlpha: m.hasAlpha ?? false,
73
+ orientation: m.orientation ?? 1,
74
+ space: m.space,
75
+ };
76
+ }
77
+ /** Center square, then a circular alpha mask. png/webp/avif keep the
78
+ * transparency; jpeg gets the corners flattened onto `background`. */
79
+ export async function circleCrop(input, o = {}) {
80
+ const { buf, bytes, abs } = await readInput(input);
81
+ const format = o.format ?? "png";
82
+ const base = open(buf).rotate();
83
+ const m = await base.metadata();
84
+ const side = Math.min(m.width ?? 0, m.height ?? 0);
85
+ if (!side)
86
+ throw new Error("could not read image dimensions");
87
+ const size = o.size ?? side;
88
+ const r = size / 2;
89
+ const mask = Buffer.from(`<svg width="${size}" height="${size}"><circle cx="${r}" cy="${r}" r="${r}"/></svg>`);
90
+ // sharp applies composite LAST in its pipeline, after flatten: masking and
91
+ // jpeg-flattening in one pass would flatten first and leave the corners
92
+ // transparent, which jpeg then paints black. Two passes keep the order honest.
93
+ const masked = await base
94
+ .resize(size, size, { fit: "cover", position: "centre", kernel: "lanczos3" })
95
+ .ensureAlpha()
96
+ .composite([{ input: mask, blend: "dest-in" }])
97
+ .png()
98
+ .toBuffer();
99
+ return finish(open(masked), format, o, outputPathFor(abs, "-circle", format, o.output), bytes);
100
+ }
101
+ export async function crop(input, o = {}) {
102
+ const { buf, bytes, abs } = await readInput(input);
103
+ const base = open(buf).rotate();
104
+ const m = await base.metadata();
105
+ const W = m.width ?? 0;
106
+ const H = m.height ?? 0;
107
+ const format = o.format ?? formatOf(m);
108
+ let region;
109
+ if (o.aspect) {
110
+ const [aw, ah] = o.aspect.split(":").map(Number);
111
+ if (!aw || !ah)
112
+ throw new Error(`bad aspect "${o.aspect}", expected like 16:9`);
113
+ let w = W;
114
+ let h = Math.round((W * ah) / aw);
115
+ if (h > H) {
116
+ h = H;
117
+ w = Math.round((H * aw) / ah);
118
+ }
119
+ region = { left: Math.floor((W - w) / 2), top: Math.floor((H - h) / 2), width: w, height: h };
120
+ }
121
+ else {
122
+ const { left = 0, top = 0, width, height } = o;
123
+ if (!width || !height)
124
+ throw new Error("crop needs width and height, or an aspect");
125
+ if (left + width > W || top + height > H)
126
+ throw new Error(`crop box exceeds the ${W}x${H} image`);
127
+ region = { left, top, width, height };
128
+ }
129
+ return finish(base.extract(region), format, o, outputPathFor(abs, "-crop", format, o.output), bytes);
130
+ }
131
+ export async function resize(input, o = {}) {
132
+ const { buf, bytes, abs } = await readInput(input);
133
+ const base = open(buf).rotate();
134
+ const m = await base.metadata();
135
+ const format = o.format ?? formatOf(m);
136
+ let width = o.width;
137
+ let height = o.height;
138
+ if (o.percent) {
139
+ width = Math.round(((m.width ?? 0) * o.percent) / 100);
140
+ height = Math.round(((m.height ?? 0) * o.percent) / 100);
141
+ }
142
+ if (!width && !height)
143
+ throw new Error("resize needs width, height or percent");
144
+ const img = base.resize({
145
+ width,
146
+ height,
147
+ fit: o.fit ?? "inside",
148
+ kernel: "lanczos3",
149
+ withoutEnlargement: !(o.enlarge ?? false),
150
+ });
151
+ return finish(img, format, o, outputPathFor(abs, "-resized", format, o.output), bytes);
152
+ }
153
+ /** Same format in and out, smaller file. png is lossless; the others use `quality`. */
154
+ export async function compress(input, o = {}) {
155
+ const { buf, bytes, abs } = await readInput(input);
156
+ const base = open(buf).rotate();
157
+ const format = formatOf(await base.metadata());
158
+ return finish(base, format, o, outputPathFor(abs, "-compressed", format, o.output), bytes);
159
+ }
160
+ export async function convert(input, format, o = {}) {
161
+ const { buf, bytes, abs } = await readInput(input);
162
+ return finish(open(buf).rotate(), format, o, outputPathFor(abs, "", format, o.output), bytes);
163
+ }
164
+ /** A small JPEG preview for chat clients that render image content. */
165
+ export async function preview(file, max = 320) {
166
+ const buf = await sharp(file, { limitInputPixels: MAX_PIXELS })
167
+ .resize({ width: max, height: max, fit: "inside", withoutEnlargement: true })
168
+ .flatten({ background: "#ffffff" })
169
+ .jpeg({ quality: 70 })
170
+ .toBuffer();
171
+ return { data: buf.toString("base64"), mimeType: "image/jpeg" };
172
+ }
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * roundcut-mcp: local image tools as MCP tools, by RoundCut (https://roundcut.app).
4
+ * stdio transport. stdout is the protocol channel: log only through console.error.
5
+ */
6
+ import { McpServer } from "@modelcontextprotocol/server";
7
+ export declare function buildServer(): McpServer;
package/dist/index.js ADDED
@@ -0,0 +1,176 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * roundcut-mcp: local image tools as MCP tools, by RoundCut (https://roundcut.app).
4
+ * stdio transport. stdout is the protocol channel: log only through console.error.
5
+ */
6
+ import { McpServer } from "@modelcontextprotocol/server";
7
+ import { serveStdio } from "@modelcontextprotocol/server/stdio";
8
+ import * as z from "zod/v4";
9
+ import { FORMATS, circleCrop, compress, convert, crop, info, preview, resize } from "./engine.js";
10
+ const VERSION = "0.1.0";
11
+ const SITE = "https://roundcut.app";
12
+ const format = z.enum(FORMATS);
13
+ const quality = z.number().int().min(1).max(100).optional().describe("Lossy quality 1-100 (default 80). Ignored for png.");
14
+ const background = z.string().optional().describe("CSS color used to flatten transparency when the output is jpeg (default #ffffff).");
15
+ const output = z.string().optional().describe("Output file path. Default: next to the input, with a suffix and the right extension.");
16
+ const withPreview = z.boolean().optional().describe("Attach a small JPEG preview to the result (default true).");
17
+ function sizeDelta(r) {
18
+ if (!r.inputBytes)
19
+ return "";
20
+ const d = Math.round((1 - r.bytes / r.inputBytes) * 100);
21
+ return d >= 0 ? `${d}% smaller than` : `${-d}% larger than`;
22
+ }
23
+ async function result(r, wantPreview, line) {
24
+ const text = [
25
+ line,
26
+ r.output,
27
+ `${r.width}x${r.height} ${r.format}, ${r.bytes.toLocaleString()} bytes (${sizeDelta(r)} the ${r.inputBytes.toLocaleString()}-byte input)`,
28
+ ].join("\n");
29
+ const content = [
30
+ { type: "text", text },
31
+ { type: "resource_link", uri: `file://${r.output}`, name: r.output.split("/").pop() ?? r.output, mimeType: `image/${r.format}` },
32
+ ];
33
+ if (wantPreview ?? true)
34
+ content.push({ type: "image", ...(await preview(r.output)) });
35
+ return { content, structuredContent: { ...r } };
36
+ }
37
+ function fail(err) {
38
+ const msg = err instanceof Error ? err.message : String(err);
39
+ return { content: [{ type: "text", text: msg }], isError: true };
40
+ }
41
+ export function buildServer() {
42
+ const server = new McpServer({ name: "roundcut-mcp", version: VERSION });
43
+ server.registerTool("image_info", {
44
+ title: "Image info",
45
+ description: "Read format, dimensions, alpha and EXIF orientation of an image file.",
46
+ inputSchema: z.object({ input: z.string().describe("Path to a JPG, PNG, WebP, AVIF, GIF, TIFF or HEIC file.") }),
47
+ annotations: { readOnlyHint: true },
48
+ }, async ({ input }) => {
49
+ try {
50
+ const i = await info(input);
51
+ return { content: [{ type: "text", text: JSON.stringify(i, null, 2) }], structuredContent: i };
52
+ }
53
+ catch (e) {
54
+ return fail(e);
55
+ }
56
+ });
57
+ server.registerTool("circle_crop", {
58
+ title: "Circle crop",
59
+ description: "Make a round profile picture: center-square crop plus a circular transparent mask. png (default), webp and avif keep the transparent corners; jpeg flattens them onto a background color.",
60
+ inputSchema: z.object({
61
+ input: z.string(),
62
+ output,
63
+ size: z.number().int().min(16).max(8192).optional().describe("Output side in pixels (default: the largest centered square)."),
64
+ format: format.optional(),
65
+ quality,
66
+ background,
67
+ preview: withPreview,
68
+ }),
69
+ }, async ({ input, preview: p, ...o }) => {
70
+ try {
71
+ return await result(await circleCrop(input, o), p, "Circle crop done.");
72
+ }
73
+ catch (e) {
74
+ return fail(e);
75
+ }
76
+ });
77
+ server.registerTool("crop_image", {
78
+ title: "Crop image",
79
+ description: "Crop to a pixel box (left, top, width, height) or to a centered aspect ratio such as 1:1, 4:5 or 16:9.",
80
+ inputSchema: z.object({
81
+ input: z.string(),
82
+ output,
83
+ left: z.number().int().min(0).optional(),
84
+ top: z.number().int().min(0).optional(),
85
+ width: z.number().int().min(1).optional(),
86
+ height: z.number().int().min(1).optional(),
87
+ aspect: z.string().regex(/^\d+:\d+$/).optional().describe("Centered crop to this ratio, e.g. 16:9. Overrides the pixel box."),
88
+ format: format.optional(),
89
+ quality,
90
+ background,
91
+ preview: withPreview,
92
+ }),
93
+ }, async ({ input, preview: p, ...o }) => {
94
+ try {
95
+ return await result(await crop(input, o), p, "Crop done.");
96
+ }
97
+ catch (e) {
98
+ return fail(e);
99
+ }
100
+ });
101
+ server.registerTool("resize_image", {
102
+ title: "Resize image",
103
+ description: "Resize by width, height or percent. Keeps the aspect ratio (fit=inside) and never enlarges unless enlarge=true. Lanczos3 resampling.",
104
+ inputSchema: z.object({
105
+ input: z.string(),
106
+ output,
107
+ width: z.number().int().min(1).max(16384).optional(),
108
+ height: z.number().int().min(1).max(16384).optional(),
109
+ percent: z.number().min(1).max(400).optional(),
110
+ fit: z.enum(["inside", "cover", "fill"]).optional(),
111
+ enlarge: z.boolean().optional(),
112
+ format: format.optional(),
113
+ quality,
114
+ background,
115
+ preview: withPreview,
116
+ }),
117
+ }, async ({ input, preview: p, ...o }) => {
118
+ try {
119
+ return await result(await resize(input, o), p, "Resize done.");
120
+ }
121
+ catch (e) {
122
+ return fail(e);
123
+ }
124
+ });
125
+ server.registerTool("compress_image", {
126
+ title: "Compress image",
127
+ description: "Smaller file, same format. jpg/webp/avif re-encode at `quality` (default 80, mozjpeg for jpg); png is recompressed losslessly.",
128
+ inputSchema: z.object({ input: z.string(), output, quality, preview: withPreview }),
129
+ }, async ({ input, preview: p, ...o }) => {
130
+ try {
131
+ return await result(await compress(input, o), p, "Compress done.");
132
+ }
133
+ catch (e) {
134
+ return fail(e);
135
+ }
136
+ });
137
+ server.registerTool("convert_image", {
138
+ title: "Convert image",
139
+ description: "Convert between jpeg, png, webp and avif (the input may also be GIF, TIFF or HEIC). Transparency is kept except for jpeg, which is flattened onto `background`.",
140
+ inputSchema: z.object({ input: z.string(), format, output, quality, background, preview: withPreview }),
141
+ }, async ({ input, format: f, preview: p, ...o }) => {
142
+ try {
143
+ return await result(await convert(input, f, o), p, `Converted to ${f}.`);
144
+ }
145
+ catch (e) {
146
+ return fail(e);
147
+ }
148
+ });
149
+ server.registerTool("roundcut_web_tools", {
150
+ title: "RoundCut web tools",
151
+ description: "Links to the RoundCut browser tools for what this server does not do locally: AI background removal, AI upscaling, batch conversion and image-to-PDF. Free, 29 languages.",
152
+ inputSchema: z.object({}),
153
+ annotations: { readOnlyHint: true },
154
+ }, async () => ({
155
+ content: [
156
+ {
157
+ type: "text",
158
+ text: [
159
+ `Background remover: ${SITE}/background-remover/`,
160
+ `AI upscaler: ${SITE}/upscale-2x/`,
161
+ `Batch convert: ${SITE}/convert/`,
162
+ `JPG to PDF: ${SITE}/jpg-to-pdf/`,
163
+ `All tools: ${SITE}/`,
164
+ ].join("\n"),
165
+ },
166
+ ],
167
+ }));
168
+ return server;
169
+ }
170
+ const entry = process.argv[1] ? process.argv[1].split("/").pop() ?? "" : "";
171
+ if (entry && import.meta.url.endsWith(entry)) {
172
+ const handle = serveStdio(buildServer);
173
+ process.on("SIGINT", () => {
174
+ void handle.close();
175
+ });
176
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "roundcut-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for local image tools by RoundCut: circle crop, crop, resize, compress and convert JPG, PNG, WebP and AVIF with sharp. No network, no accounts.",
5
+ "mcpName": "io.github.araluma/roundcut-mcp",
6
+ "type": "module",
7
+ "bin": { "roundcut-mcp": "./dist/index.js" },
8
+ "main": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } },
11
+ "files": ["dist", "src", "server.json"],
12
+ "scripts": {
13
+ "build": "tsc -p tsconfig.json && chmod +x dist/index.js",
14
+ "test": "npm run build && node --test test/*.test.mjs",
15
+ "prepublishOnly": "npm test"
16
+ },
17
+ "keywords": ["mcp", "model-context-protocol", "image", "resize", "compress", "convert", "crop", "circle-crop", "avatar", "webp", "avif", "sharp", "roundcut"],
18
+ "author": "Araluma <dev@araluma.com>",
19
+ "license": "MIT",
20
+ "repository": { "type": "git", "url": "git+https://github.com/Araluma/roundcut-mcp.git" },
21
+ "bugs": { "url": "https://github.com/Araluma/roundcut-mcp/issues" },
22
+ "homepage": "https://roundcut.app",
23
+ "engines": { "node": ">=20" },
24
+ "dependencies": {
25
+ "@modelcontextprotocol/server": "^2.0.0",
26
+ "sharp": "^0.35.4",
27
+ "zod": "^4.2.0"
28
+ },
29
+ "devDependencies": { "@types/node": "^24.0.0", "typescript": "^5.7.0" }
30
+ }
package/server.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.araluma/roundcut-mcp",
4
+ "description": "Local image tools: circle crop, crop, resize, compress and convert JPG/PNG/WebP/AVIF with sharp. No network.",
5
+ "repository": { "url": "https://github.com/Araluma/roundcut-mcp", "source": "github" },
6
+ "version": "0.1.0",
7
+ "websiteUrl": "https://roundcut.app",
8
+ "packages": [
9
+ {
10
+ "registryType": "npm",
11
+ "registryBaseUrl": "https://registry.npmjs.org",
12
+ "identifier": "roundcut-mcp",
13
+ "version": "0.1.0",
14
+ "transport": { "type": "stdio" }
15
+ }
16
+ ]
17
+ }
package/src/engine.ts ADDED
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Pure image operations on top of sharp. No MCP here: every function takes a
3
+ * path, returns a small report, and is unit-tested directly. The MCP layer
4
+ * (index.ts) only adapts these to tool calls.
5
+ */
6
+ import { access, readFile, stat, writeFile } from "node:fs/promises";
7
+ import path from "node:path";
8
+ import sharp, { type Metadata, type Region, type Sharp } from "sharp";
9
+
10
+ export type Format = "jpeg" | "png" | "webp" | "avif";
11
+ export const FORMATS = ["jpeg", "png", "webp", "avif"] as const;
12
+ const EXT: Record<Format, string> = { jpeg: ".jpg", png: ".png", webp: ".webp", avif: ".avif" };
13
+
14
+ /** Pixel ceiling per image. 80 MP covers every phone camera; beyond that sharp
15
+ * would happily allocate gigabytes for a single call issued by an LLM. */
16
+ const MAX_PIXELS = 80_000_000;
17
+
18
+ export interface Report {
19
+ output: string;
20
+ format: Format;
21
+ width: number;
22
+ height: number;
23
+ bytes: number;
24
+ inputBytes: number;
25
+ }
26
+
27
+ export async function readInput(input: string): Promise<{ buf: Buffer; bytes: number; abs: string }> {
28
+ const abs = path.resolve(input);
29
+ await access(abs);
30
+ const s = await stat(abs);
31
+ if (!s.isFile()) throw new Error(`not a file: ${abs}`);
32
+ return { buf: await readFile(abs), bytes: s.size, abs };
33
+ }
34
+
35
+ function open(buf: Buffer): Sharp {
36
+ return sharp(buf, { limitInputPixels: MAX_PIXELS, failOn: "error" });
37
+ }
38
+
39
+ export function formatOf(meta: Metadata): Format {
40
+ const f = meta.format;
41
+ if (f === "jpeg" || f === "png" || f === "webp") return f;
42
+ // sharp reports AVIF as heif + av1 compression; HEIC is heif + hevc.
43
+ if (f === "heif" && meta.compression === "av1") return "avif";
44
+ // HEIC, TIFF, GIF, SVG decode fine but have no matching web encoder: use PNG.
45
+ return "png";
46
+ }
47
+
48
+ /** Default output path: same directory, same stem, a suffix, the target extension. */
49
+ export function outputPathFor(inputAbs: string, suffix: string, format: Format, explicit?: string): string {
50
+ if (explicit) return path.resolve(explicit);
51
+ const dir = path.dirname(inputAbs);
52
+ const stem = path.basename(inputAbs, path.extname(inputAbs));
53
+ return path.join(dir, `${stem}${suffix}${EXT[format]}`);
54
+ }
55
+
56
+ export interface EncodeOptions {
57
+ /** 1..100 for the lossy formats; png ignores it. Default 80. */
58
+ quality?: number;
59
+ /** CSS color used to flatten alpha when the output is jpeg. Default white. */
60
+ background?: string;
61
+ }
62
+
63
+ /** Apply the output encoder. jpeg has no alpha, so transparent pixels are
64
+ * flattened onto `background` instead of turning black. */
65
+ export function encode(img: Sharp, format: Format, opts: EncodeOptions = {}): Sharp {
66
+ const q = opts.quality ?? 80;
67
+ switch (format) {
68
+ case "jpeg":
69
+ return img.flatten({ background: opts.background ?? "#ffffff" }).jpeg({ quality: q, mozjpeg: true });
70
+ case "png":
71
+ return img.png({ compressionLevel: 9, effort: 7 });
72
+ case "webp":
73
+ return img.webp({ quality: q });
74
+ case "avif":
75
+ return img.avif({ quality: q, effort: 4 });
76
+ }
77
+ }
78
+
79
+ async function finish(img: Sharp, format: Format, opts: EncodeOptions, output: string, inputBytes: number): Promise<Report> {
80
+ const { data, info } = await encode(img, format, opts).toBuffer({ resolveWithObject: true });
81
+ await writeFile(output, data);
82
+ return { output, format, width: info.width, height: info.height, bytes: data.length, inputBytes };
83
+ }
84
+
85
+ export async function info(input: string) {
86
+ const { buf, bytes, abs } = await readInput(input);
87
+ const m = await open(buf).metadata();
88
+ return {
89
+ path: abs,
90
+ bytes,
91
+ format: m.format,
92
+ width: m.width,
93
+ height: m.height,
94
+ hasAlpha: m.hasAlpha ?? false,
95
+ orientation: m.orientation ?? 1,
96
+ space: m.space,
97
+ };
98
+ }
99
+
100
+ export interface CircleCropOptions extends EncodeOptions {
101
+ output?: string;
102
+ /** Default png, which keeps the transparent corners. */
103
+ format?: Format;
104
+ /** Output side in px. Default: the largest centered square of the input. */
105
+ size?: number;
106
+ }
107
+
108
+ /** Center square, then a circular alpha mask. png/webp/avif keep the
109
+ * transparency; jpeg gets the corners flattened onto `background`. */
110
+ export async function circleCrop(input: string, o: CircleCropOptions = {}): Promise<Report> {
111
+ const { buf, bytes, abs } = await readInput(input);
112
+ const format = o.format ?? "png";
113
+ const base = open(buf).rotate();
114
+ const m = await base.metadata();
115
+ const side = Math.min(m.width ?? 0, m.height ?? 0);
116
+ if (!side) throw new Error("could not read image dimensions");
117
+ const size = o.size ?? side;
118
+ const r = size / 2;
119
+ const mask = Buffer.from(`<svg width="${size}" height="${size}"><circle cx="${r}" cy="${r}" r="${r}"/></svg>`);
120
+ // sharp applies composite LAST in its pipeline, after flatten: masking and
121
+ // jpeg-flattening in one pass would flatten first and leave the corners
122
+ // transparent, which jpeg then paints black. Two passes keep the order honest.
123
+ const masked = await base
124
+ .resize(size, size, { fit: "cover", position: "centre", kernel: "lanczos3" })
125
+ .ensureAlpha()
126
+ .composite([{ input: mask, blend: "dest-in" }])
127
+ .png()
128
+ .toBuffer();
129
+ return finish(open(masked), format, o, outputPathFor(abs, "-circle", format, o.output), bytes);
130
+ }
131
+
132
+ export interface CropOptions extends EncodeOptions {
133
+ output?: string;
134
+ format?: Format;
135
+ left?: number;
136
+ top?: number;
137
+ width?: number;
138
+ height?: number;
139
+ /** "1:1", "16:9", "4:5": a centered crop to that ratio, overriding the pixel box. */
140
+ aspect?: string;
141
+ }
142
+
143
+ export async function crop(input: string, o: CropOptions = {}): Promise<Report> {
144
+ const { buf, bytes, abs } = await readInput(input);
145
+ const base = open(buf).rotate();
146
+ const m = await base.metadata();
147
+ const W = m.width ?? 0;
148
+ const H = m.height ?? 0;
149
+ const format = o.format ?? formatOf(m);
150
+ let region: Region;
151
+ if (o.aspect) {
152
+ const [aw, ah] = o.aspect.split(":").map(Number);
153
+ if (!aw || !ah) throw new Error(`bad aspect "${o.aspect}", expected like 16:9`);
154
+ let w = W;
155
+ let h = Math.round((W * ah) / aw);
156
+ if (h > H) {
157
+ h = H;
158
+ w = Math.round((H * aw) / ah);
159
+ }
160
+ region = { left: Math.floor((W - w) / 2), top: Math.floor((H - h) / 2), width: w, height: h };
161
+ } else {
162
+ const { left = 0, top = 0, width, height } = o;
163
+ if (!width || !height) throw new Error("crop needs width and height, or an aspect");
164
+ if (left + width > W || top + height > H) throw new Error(`crop box exceeds the ${W}x${H} image`);
165
+ region = { left, top, width, height };
166
+ }
167
+ return finish(base.extract(region), format, o, outputPathFor(abs, "-crop", format, o.output), bytes);
168
+ }
169
+
170
+ export interface ResizeOptions extends EncodeOptions {
171
+ output?: string;
172
+ format?: Format;
173
+ width?: number;
174
+ height?: number;
175
+ /** 1..400, relative to the input size. */
176
+ percent?: number;
177
+ /** Default inside: keeps the aspect ratio and never crops. */
178
+ fit?: "inside" | "cover" | "fill";
179
+ /** Default false: never upscale. */
180
+ enlarge?: boolean;
181
+ }
182
+
183
+ export async function resize(input: string, o: ResizeOptions = {}): Promise<Report> {
184
+ const { buf, bytes, abs } = await readInput(input);
185
+ const base = open(buf).rotate();
186
+ const m = await base.metadata();
187
+ const format = o.format ?? formatOf(m);
188
+ let width = o.width;
189
+ let height = o.height;
190
+ if (o.percent) {
191
+ width = Math.round(((m.width ?? 0) * o.percent) / 100);
192
+ height = Math.round(((m.height ?? 0) * o.percent) / 100);
193
+ }
194
+ if (!width && !height) throw new Error("resize needs width, height or percent");
195
+ const img = base.resize({
196
+ width,
197
+ height,
198
+ fit: o.fit ?? "inside",
199
+ kernel: "lanczos3",
200
+ withoutEnlargement: !(o.enlarge ?? false),
201
+ });
202
+ return finish(img, format, o, outputPathFor(abs, "-resized", format, o.output), bytes);
203
+ }
204
+
205
+ export interface CompressOptions extends EncodeOptions {
206
+ output?: string;
207
+ }
208
+
209
+ /** Same format in and out, smaller file. png is lossless; the others use `quality`. */
210
+ export async function compress(input: string, o: CompressOptions = {}): Promise<Report> {
211
+ const { buf, bytes, abs } = await readInput(input);
212
+ const base = open(buf).rotate();
213
+ const format = formatOf(await base.metadata());
214
+ return finish(base, format, o, outputPathFor(abs, "-compressed", format, o.output), bytes);
215
+ }
216
+
217
+ export interface ConvertOptions extends EncodeOptions {
218
+ output?: string;
219
+ }
220
+
221
+ export async function convert(input: string, format: Format, o: ConvertOptions = {}): Promise<Report> {
222
+ const { buf, bytes, abs } = await readInput(input);
223
+ return finish(open(buf).rotate(), format, o, outputPathFor(abs, "", format, o.output), bytes);
224
+ }
225
+
226
+ /** A small JPEG preview for chat clients that render image content. */
227
+ export async function preview(file: string, max = 320): Promise<{ data: string; mimeType: "image/jpeg" }> {
228
+ const buf = await sharp(file, { limitInputPixels: MAX_PIXELS })
229
+ .resize({ width: max, height: max, fit: "inside", withoutEnlargement: true })
230
+ .flatten({ background: "#ffffff" })
231
+ .jpeg({ quality: 70 })
232
+ .toBuffer();
233
+ return { data: buf.toString("base64"), mimeType: "image/jpeg" };
234
+ }
package/src/index.ts ADDED
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * roundcut-mcp: local image tools as MCP tools, by RoundCut (https://roundcut.app).
4
+ * stdio transport. stdout is the protocol channel: log only through console.error.
5
+ */
6
+ import { McpServer } from "@modelcontextprotocol/server";
7
+ import { serveStdio } from "@modelcontextprotocol/server/stdio";
8
+ import * as z from "zod/v4";
9
+ import { FORMATS, circleCrop, compress, convert, crop, info, preview, resize, type Report } from "./engine.js";
10
+
11
+ const VERSION = "0.1.0";
12
+ const SITE = "https://roundcut.app";
13
+
14
+ const format = z.enum(FORMATS);
15
+ const quality = z.number().int().min(1).max(100).optional().describe("Lossy quality 1-100 (default 80). Ignored for png.");
16
+ const background = z.string().optional().describe("CSS color used to flatten transparency when the output is jpeg (default #ffffff).");
17
+ const output = z.string().optional().describe("Output file path. Default: next to the input, with a suffix and the right extension.");
18
+ const withPreview = z.boolean().optional().describe("Attach a small JPEG preview to the result (default true).");
19
+
20
+ type Content =
21
+ | { type: "text"; text: string }
22
+ | { type: "image"; data: string; mimeType: "image/jpeg" }
23
+ | { type: "resource_link"; uri: string; name: string; mimeType: string };
24
+
25
+ function sizeDelta(r: Report): string {
26
+ if (!r.inputBytes) return "";
27
+ const d = Math.round((1 - r.bytes / r.inputBytes) * 100);
28
+ return d >= 0 ? `${d}% smaller than` : `${-d}% larger than`;
29
+ }
30
+
31
+ async function result(r: Report, wantPreview: boolean | undefined, line: string) {
32
+ const text = [
33
+ line,
34
+ r.output,
35
+ `${r.width}x${r.height} ${r.format}, ${r.bytes.toLocaleString()} bytes (${sizeDelta(r)} the ${r.inputBytes.toLocaleString()}-byte input)`,
36
+ ].join("\n");
37
+ const content: Content[] = [
38
+ { type: "text", text },
39
+ { type: "resource_link", uri: `file://${r.output}`, name: r.output.split("/").pop() ?? r.output, mimeType: `image/${r.format}` },
40
+ ];
41
+ if (wantPreview ?? true) content.push({ type: "image", ...(await preview(r.output)) });
42
+ return { content, structuredContent: { ...r } };
43
+ }
44
+
45
+ function fail(err: unknown) {
46
+ const msg = err instanceof Error ? err.message : String(err);
47
+ return { content: [{ type: "text" as const, text: msg }], isError: true as const };
48
+ }
49
+
50
+ export function buildServer(): McpServer {
51
+ const server = new McpServer({ name: "roundcut-mcp", version: VERSION });
52
+
53
+ server.registerTool(
54
+ "image_info",
55
+ {
56
+ title: "Image info",
57
+ description: "Read format, dimensions, alpha and EXIF orientation of an image file.",
58
+ inputSchema: z.object({ input: z.string().describe("Path to a JPG, PNG, WebP, AVIF, GIF, TIFF or HEIC file.") }),
59
+ annotations: { readOnlyHint: true },
60
+ },
61
+ async ({ input }) => {
62
+ try {
63
+ const i = await info(input);
64
+ return { content: [{ type: "text", text: JSON.stringify(i, null, 2) }], structuredContent: i };
65
+ } catch (e) {
66
+ return fail(e);
67
+ }
68
+ },
69
+ );
70
+
71
+ server.registerTool(
72
+ "circle_crop",
73
+ {
74
+ title: "Circle crop",
75
+ description:
76
+ "Make a round profile picture: center-square crop plus a circular transparent mask. png (default), webp and avif keep the transparent corners; jpeg flattens them onto a background color.",
77
+ inputSchema: z.object({
78
+ input: z.string(),
79
+ output,
80
+ size: z.number().int().min(16).max(8192).optional().describe("Output side in pixels (default: the largest centered square)."),
81
+ format: format.optional(),
82
+ quality,
83
+ background,
84
+ preview: withPreview,
85
+ }),
86
+ },
87
+ async ({ input, preview: p, ...o }) => {
88
+ try {
89
+ return await result(await circleCrop(input, o), p, "Circle crop done.");
90
+ } catch (e) {
91
+ return fail(e);
92
+ }
93
+ },
94
+ );
95
+
96
+ server.registerTool(
97
+ "crop_image",
98
+ {
99
+ title: "Crop image",
100
+ description: "Crop to a pixel box (left, top, width, height) or to a centered aspect ratio such as 1:1, 4:5 or 16:9.",
101
+ inputSchema: z.object({
102
+ input: z.string(),
103
+ output,
104
+ left: z.number().int().min(0).optional(),
105
+ top: z.number().int().min(0).optional(),
106
+ width: z.number().int().min(1).optional(),
107
+ height: z.number().int().min(1).optional(),
108
+ aspect: z.string().regex(/^\d+:\d+$/).optional().describe("Centered crop to this ratio, e.g. 16:9. Overrides the pixel box."),
109
+ format: format.optional(),
110
+ quality,
111
+ background,
112
+ preview: withPreview,
113
+ }),
114
+ },
115
+ async ({ input, preview: p, ...o }) => {
116
+ try {
117
+ return await result(await crop(input, o), p, "Crop done.");
118
+ } catch (e) {
119
+ return fail(e);
120
+ }
121
+ },
122
+ );
123
+
124
+ server.registerTool(
125
+ "resize_image",
126
+ {
127
+ title: "Resize image",
128
+ description: "Resize by width, height or percent. Keeps the aspect ratio (fit=inside) and never enlarges unless enlarge=true. Lanczos3 resampling.",
129
+ inputSchema: z.object({
130
+ input: z.string(),
131
+ output,
132
+ width: z.number().int().min(1).max(16384).optional(),
133
+ height: z.number().int().min(1).max(16384).optional(),
134
+ percent: z.number().min(1).max(400).optional(),
135
+ fit: z.enum(["inside", "cover", "fill"]).optional(),
136
+ enlarge: z.boolean().optional(),
137
+ format: format.optional(),
138
+ quality,
139
+ background,
140
+ preview: withPreview,
141
+ }),
142
+ },
143
+ async ({ input, preview: p, ...o }) => {
144
+ try {
145
+ return await result(await resize(input, o), p, "Resize done.");
146
+ } catch (e) {
147
+ return fail(e);
148
+ }
149
+ },
150
+ );
151
+
152
+ server.registerTool(
153
+ "compress_image",
154
+ {
155
+ title: "Compress image",
156
+ description: "Smaller file, same format. jpg/webp/avif re-encode at `quality` (default 80, mozjpeg for jpg); png is recompressed losslessly.",
157
+ inputSchema: z.object({ input: z.string(), output, quality, preview: withPreview }),
158
+ },
159
+ async ({ input, preview: p, ...o }) => {
160
+ try {
161
+ return await result(await compress(input, o), p, "Compress done.");
162
+ } catch (e) {
163
+ return fail(e);
164
+ }
165
+ },
166
+ );
167
+
168
+ server.registerTool(
169
+ "convert_image",
170
+ {
171
+ title: "Convert image",
172
+ description: "Convert between jpeg, png, webp and avif (the input may also be GIF, TIFF or HEIC). Transparency is kept except for jpeg, which is flattened onto `background`.",
173
+ inputSchema: z.object({ input: z.string(), format, output, quality, background, preview: withPreview }),
174
+ },
175
+ async ({ input, format: f, preview: p, ...o }) => {
176
+ try {
177
+ return await result(await convert(input, f, o), p, `Converted to ${f}.`);
178
+ } catch (e) {
179
+ return fail(e);
180
+ }
181
+ },
182
+ );
183
+
184
+ server.registerTool(
185
+ "roundcut_web_tools",
186
+ {
187
+ title: "RoundCut web tools",
188
+ description:
189
+ "Links to the RoundCut browser tools for what this server does not do locally: AI background removal, AI upscaling, batch conversion and image-to-PDF. Free, 29 languages.",
190
+ inputSchema: z.object({}),
191
+ annotations: { readOnlyHint: true },
192
+ },
193
+ async () => ({
194
+ content: [
195
+ {
196
+ type: "text",
197
+ text: [
198
+ `Background remover: ${SITE}/background-remover/`,
199
+ `AI upscaler: ${SITE}/upscale-2x/`,
200
+ `Batch convert: ${SITE}/convert/`,
201
+ `JPG to PDF: ${SITE}/jpg-to-pdf/`,
202
+ `All tools: ${SITE}/`,
203
+ ].join("\n"),
204
+ },
205
+ ],
206
+ }),
207
+ );
208
+
209
+ return server;
210
+ }
211
+
212
+ const entry = process.argv[1] ? process.argv[1].split("/").pop() ?? "" : "";
213
+ if (entry && import.meta.url.endsWith(entry)) {
214
+ const handle = serveStdio(buildServer);
215
+ process.on("SIGINT", () => {
216
+ void handle.close();
217
+ });
218
+ }