watermark-studio-mcp 1.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 Watermark & Resize Studio (https://watermarkresizestudio.com)
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,178 @@
1
+ # Watermark & Resize Studio MCP Server 🖼️⚡
2
+
3
+ [![smithery badge](https://smithery.ai/badge/Miladmet/watermark-studio-mcp)](https://smithery.ai/server/Miladmet/watermark-studio-mcp)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
5
+ [![Node.js](https://img.shields.io/badge/Node.js-%3E%3D18.0.0-green.svg)](https://nodejs.org)
6
+ [![Engine](https://img.shields.io/badge/Engine-Watermark%20%26%20Resize%20Studio-6366f1.svg)](https://watermarkresizestudio.com)
7
+
8
+ An ultra-fast, local-first **Model Context Protocol (MCP)** server providing AI assistants (Claude Desktop, Cursor, Antigravity, Cline) with native capabilities to resize, watermark, and sanitize images for e-commerce, social media, and privacy compliance.
9
+
10
+ Official open-source MCP server powered by [Watermark & Resize Studio](https://watermarkresizestudio.com).
11
+
12
+ ---
13
+
14
+ ## 🌟 Key Features
15
+
16
+ - **⚡ 100% Local Processing**: All operations run locally via `sharp` (libvips C++ engine). Zero cloud uploads, zero external API latency, zero bandwidth costs.
17
+ - **🛍️ E-Commerce & Platform Presets**: Instant batch-ready resizing for **Shopify (2048x2048)**, **Etsy (2000x2000)**, **Instagram Square/Story/Portrait**, **YouTube Thumbnails**, **Facebook**, and **Pinterest**.
18
+ - **🛡️ Copyright & Watermark Protection**: Dynamic text and logo compositing with SVG drop shadows, 6-point positioning, opacity control, and repeating tiled patterns for asset protection.
19
+ - **🔒 Privacy EXIF / GPS Stripper**: Completely removes GPS geolocation coordinates, camera serial numbers, and creator metadata before public publishing.
20
+ - **🚀 WebP & MozJPEG Compression**: Reduces file sizes by 85–95% while maintaining crisp visual fidelity.
21
+
22
+ ---
23
+
24
+ ## 🚀 Instant 1-Click Install via Smithery
25
+
26
+ To automatically install and configure for **Claude Desktop**:
27
+ ```bash
28
+ npx -y @smithery/cli install Miladmet/watermark-studio-mcp --client claude
29
+ ```
30
+
31
+ To install and configure for **Cursor**:
32
+ ```bash
33
+ npx -y @smithery/cli install Miladmet/watermark-studio-mcp --client cursor
34
+ ```
35
+
36
+ ---
37
+
38
+ ## 🛠️ Included Tools
39
+
40
+ ### 1. `resize_for_platform`
41
+ Resize, convert format (WebP, MozJPEG, PNG), and optimize images with standard platform presets or custom dimensions.
42
+
43
+ | Parameter | Type | Required | Description |
44
+ | :--- | :--- | :--- | :--- |
45
+ | `image_path` | `string` | **Yes** | Absolute or relative path to the image file. |
46
+ | `platform` | `string` | No | Preset: `shopify`, `etsy`, `instagram_square`, `instagram_portrait`, `instagram_story`, `youtube_thumb`, `facebook_post`, `pinterest_pin`, `custom` (Default: `shopify`). |
47
+ | `width` | `number` | No | Custom width in pixels (overrides preset). |
48
+ | `height` | `number` | No | Custom height in pixels (overrides preset). |
49
+ | `fit` | `string` | No | `cover`, `contain`, `fill`, `inside`, `outside` (Default: `inside` for e-commerce, `cover` for social). |
50
+ | `format` | `string` | No | `webp`, `jpeg`, `png`, `original` (Default: `webp`). |
51
+ | `quality` | `number` | No | Quality level from 1 to 100 (Default: `85`). |
52
+ | `output_path` | `string` | No | Custom destination path (Defaults to `[name]-[platform].[format]`). |
53
+
54
+ ### 2. `apply_watermark`
55
+ Apply copyright text or brand logo watermarks to an image to prevent unauthorized reuse or web scraping.
56
+
57
+ | Parameter | Type | Required | Description |
58
+ | :--- | :--- | :--- | :--- |
59
+ | `image_path` | `string` | **Yes** | Path to the source image file. |
60
+ | `text` | `string` | No* | Text watermark (e.g., `"© 2026 MyStore.com"`). |
61
+ | `logo_path` | `string` | No* | Path to logo PNG/SVG to overlay. |
62
+ | `position` | `string` | No | `bottom-right` (default), `bottom-left`, `top-right`, `top-left`, `center`, `tile`. |
63
+ | `opacity` | `number` | No | Watermark opacity between `0.05` and `1.0` (Default: `0.6`). |
64
+ | `font_size` | `number` | No | Text font size in pixels (auto-scales if omitted). |
65
+ | `color` | `string` | No | Hex color code (Default: `#ffffff`). |
66
+ | `margin` | `number` | No | Border margin in pixels (Default: `35`). |
67
+ | `output_path` | `string` | No | Custom destination path. |
68
+
69
+ *\*At least one of `text` or `logo_path` must be provided.*
70
+
71
+ ### 3. `strip_photo_metadata`
72
+ Sanitize photos by wiping EXIF tags, GPS geo-coordinates, camera serials, and XMP edit history.
73
+
74
+ | Parameter | Type | Required | Description |
75
+ | :--- | :--- | :--- | :--- |
76
+ | `image_path` | `string` | **Yes** | Path to the image file to sanitize. |
77
+ | `output_path` | `string` | No | Custom destination path (Defaults to `[name]-clean.[ext]`). |
78
+ | `format` | `string` | No | `same`, `webp`, `jpeg`, `png` (Default: `same`). |
79
+
80
+ ### 4. `batch_process_folder`
81
+ Bulk process an entire directory of photos in parallel: resize with platform presets (Shopify, Etsy, Instagram), convert formats, apply watermarks, and strip metadata in a single automated command.
82
+
83
+ | Parameter | Type | Required | Description |
84
+ | :--- | :--- | :--- | :--- |
85
+ | `folder_path` | `string` | **Yes** | Path to folder containing image files to process. |
86
+ | `output_folder` | `string` | No | Target folder (Defaults to `[folder_path]/optimized_[platform]`). |
87
+ | `platform` | `string` | No | Preset: `shopify`, `etsy`, `instagram_square`, `youtube_thumb`, etc. (Default: `shopify`). |
88
+ | `format` | `string` | No | `webp`, `jpeg`, `png`, `original` (Default: `webp`). |
89
+ | `quality` | `number` | No | Quality level from 1 to 100 (Default: `85`). |
90
+ | `watermark_text` | `string` | No | Optional brand text to watermark onto every photo. |
91
+ | `watermark_position`| `string` | No | `bottom-right`, `bottom-left`, `top-right`, `center` (Default: `bottom-right`). |
92
+ | `max_concurrency` | `number` | No | Parallel worker concurrency (Default: `4`). |
93
+
94
+ ---
95
+
96
+ ## 💻 Manual Configuration
97
+
98
+ ### Claude Desktop (`claude_desktop_config.json`)
99
+
100
+ Add to `%APPDATA%\Claude\claude_desktop_config.json` (Windows) or `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS):
101
+
102
+ ```json
103
+ {
104
+ "mcpServers": {
105
+ "watermark-studio": {
106
+ "command": "npx",
107
+ "args": [
108
+ "-y",
109
+ "watermark-studio-mcp"
110
+ ]
111
+ }
112
+ }
113
+ }
114
+ ```
115
+
116
+ Or run directly from local clone:
117
+ ```json
118
+ {
119
+ "mcpServers": {
120
+ "watermark-studio": {
121
+ "command": "node",
122
+ "args": [
123
+ "C:/Users/Milmann/.gemini/antigravity/scratch/watermark-studio-mcp/src/index.js"
124
+ ]
125
+ }
126
+ }
127
+ }
128
+ ```
129
+
130
+ ### Cursor (`.cursor/mcp.json`)
131
+
132
+ ```json
133
+ {
134
+ "mcpServers": {
135
+ "watermark-studio": {
136
+ "command": "node",
137
+ "args": [
138
+ "C:/Users/Milmann/.gemini/antigravity/scratch/watermark-studio-mcp/src/index.js"
139
+ ]
140
+ }
141
+ }
142
+ }
143
+ ```
144
+
145
+ ---
146
+
147
+ ## 🧪 Testing
148
+
149
+ Run the included automated verification test suite:
150
+
151
+ ```bash
152
+ npm test
153
+ ```
154
+
155
+ This verifies:
156
+ 1. Preset image resizing (Shopify, Instagram, YouTube HD) with WebP compression.
157
+ 2. Text, logo, and tiled watermark compositing with SVG drop shadows.
158
+ 3. EXIF, GPS, and IPTC privacy sanitization.
159
+ 4. Live JSON-RPC protocol compliance over `stdio`.
160
+
161
+ ---
162
+
163
+ ## 🌐 Ecosystem & Web Studio
164
+
165
+ Looking for a visual web app with real-time live preview canvas, batch drag-and-drop, and zero-server client-side processing?
166
+
167
+ Visit **[Watermark & Resize Studio](https://watermarkresizestudio.com)**.
168
+
169
+ - **Trust Center**: [watermarkresizestudio.com/trust/](https://watermarkresizestudio.com/trust/)
170
+ - **Privacy Architecture**: [watermarkresizestudio.com/privacy/](https://watermarkresizestudio.com/privacy/)
171
+ - **Image Optimization Guides**: [watermarkresizestudio.com/guides/](https://watermarkresizestudio.com/guides/)
172
+ - **E-Commerce Use Cases**: [watermarkresizestudio.com/use-cases/shopify.html](https://watermarkresizestudio.com/use-cases/shopify.html)
173
+
174
+ ---
175
+
176
+ ## 📄 License
177
+
178
+ MIT © [Watermark & Resize Studio](https://watermarkresizestudio.com)
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "watermark-studio-mcp",
3
+ "version": "1.1.0",
4
+ "description": "High-performance Model Context Protocol (MCP) server for batch image resizing, watermarking, and EXIF privacy stripping by Watermark & Resize Studio.",
5
+ "main": "src/index.js",
6
+ "bin": {
7
+ "watermark-studio-mcp": "src/index.js"
8
+ },
9
+ "type": "module",
10
+ "files": [
11
+ "src",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "scripts": {
16
+ "start": "node src/index.js",
17
+ "test": "node test/test_mcp.js"
18
+ },
19
+ "keywords": [
20
+ "mcp",
21
+ "model-context-protocol",
22
+ "mcp-server",
23
+ "claude-desktop",
24
+ "cursor",
25
+ "image-processing",
26
+ "resize",
27
+ "watermark",
28
+ "exif-stripper",
29
+ "shopify",
30
+ "instagram",
31
+ "etsy",
32
+ "ecommerce",
33
+ "privacy"
34
+ ],
35
+ "author": "Watermark & Resize Studio <admettre@gmail.com> (https://watermarkresizestudio.com)",
36
+ "license": "MIT",
37
+ "homepage": "https://watermarkresizestudio.com",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/Miladmet/watermark-studio-mcp.git"
41
+ },
42
+ "bugs": {
43
+ "url": "https://github.com/Miladmet/watermark-studio-mcp/issues"
44
+ },
45
+ "dependencies": {
46
+ "@modelcontextprotocol/sdk": "^1.6.1",
47
+ "sharp": "^0.33.5"
48
+ }
49
+ }
@@ -0,0 +1,656 @@
1
+ import sharp from 'sharp';
2
+ import path from 'path';
3
+ import fs from 'fs/promises';
4
+ import { existsSync } from 'fs';
5
+
6
+ // Platform preset dimensions and fitting rules
7
+ export const PLATFORM_PRESETS = {
8
+ shopify: { width: 2048, height: 2048, fit: 'inside', description: 'Shopify high-res product square (2048x2048)' },
9
+ etsy: { width: 2000, height: 2000, fit: 'inside', description: 'Etsy high-resolution listing (2000x2000)' },
10
+ instagram_square: { width: 1080, height: 1080, fit: 'cover', description: 'Instagram Square Post (1080x1080)' },
11
+ instagram_portrait: { width: 1080, height: 1350, fit: 'cover', description: 'Instagram Portrait 4:5 (1080x1350)' },
12
+ instagram_story: { width: 1080, height: 1920, fit: 'cover', description: 'Instagram Story / Reel (1080x1920)' },
13
+ youtube_thumb: { width: 1280, height: 720, fit: 'cover', description: 'YouTube HD Thumbnail (1280x720)' },
14
+ facebook_post: { width: 1200, height: 630, fit: 'cover', description: 'Facebook Shared Link Post (1200x630)' },
15
+ pinterest_pin: { width: 1000, height: 1500, fit: 'cover', description: 'Pinterest Optimal 2:3 Pin (1000x1500)' },
16
+ };
17
+
18
+ /**
19
+ * Format bytes into human readable format (KB, MB)
20
+ */
21
+ export function formatBytes(bytes) {
22
+ if (bytes === 0) return '0 Bytes';
23
+ const k = 1024;
24
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
25
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
26
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
27
+ }
28
+
29
+ /**
30
+ * Helper to escape XML characters for SVG text
31
+ */
32
+ function escapeXml(unsafe) {
33
+ return unsafe.replace(/[<>&'"]/g, (c) => {
34
+ switch (c) {
35
+ case '<': return '&lt;';
36
+ case '>': return '&gt;';
37
+ case '&': return '&amp;';
38
+ case '\'': return '&apos;';
39
+ case '"': return '&quot;';
40
+ default: return c;
41
+ }
42
+ });
43
+ }
44
+
45
+ /**
46
+ * Tool 1: resize_for_platform
47
+ */
48
+ export async function resizeForPlatform(args) {
49
+ const {
50
+ image_path,
51
+ platform = 'shopify',
52
+ width: customWidth,
53
+ height: customHeight,
54
+ fit: customFit,
55
+ format = 'webp',
56
+ quality = 85,
57
+ output_path
58
+ } = args;
59
+
60
+ if (!image_path) {
61
+ throw new Error('image_path is required.');
62
+ }
63
+
64
+ const resolvedInput = path.resolve(image_path);
65
+ if (!existsSync(resolvedInput)) {
66
+ throw new Error(`Input image file not found at: ${resolvedInput}`);
67
+ }
68
+
69
+ const inputStats = await fs.stat(resolvedInput);
70
+ const originalSize = inputStats.size;
71
+
72
+ let targetWidth = customWidth;
73
+ let targetHeight = customHeight;
74
+ let targetFit = customFit || 'inside';
75
+
76
+ if (platform && PLATFORM_PRESETS[platform]) {
77
+ const preset = PLATFORM_PRESETS[platform];
78
+ if (!targetWidth) targetWidth = preset.width;
79
+ if (!targetHeight) targetHeight = preset.height;
80
+ if (!customFit) targetFit = preset.fit;
81
+ }
82
+
83
+ if (!targetWidth && !targetHeight) {
84
+ throw new Error('Either a valid platform preset or width/height must be specified.');
85
+ }
86
+
87
+ const metadata = await sharp(resolvedInput).metadata();
88
+
89
+ let pipeline = sharp(resolvedInput).resize({
90
+ width: targetWidth,
91
+ height: targetHeight,
92
+ fit: targetFit,
93
+ withoutEnlargement: false,
94
+ });
95
+
96
+ const parsedPath = path.parse(resolvedInput);
97
+ const outFormat = format === 'original'
98
+ ? (metadata.format === 'jpeg' ? 'jpg' : metadata.format)
99
+ : format.toLowerCase();
100
+
101
+ switch (outFormat) {
102
+ case 'webp':
103
+ pipeline = pipeline.webp({ quality });
104
+ break;
105
+ case 'jpeg':
106
+ case 'jpg':
107
+ pipeline = pipeline.jpeg({ quality, mozjpeg: true });
108
+ break;
109
+ case 'png':
110
+ pipeline = pipeline.png({ compressionLevel: 8 });
111
+ break;
112
+ default:
113
+ pipeline = pipeline.webp({ quality });
114
+ }
115
+
116
+ const finalOutputPath = output_path
117
+ ? path.resolve(output_path)
118
+ : path.join(parsedPath.dir, `${parsedPath.name}-${platform || 'resized'}.${outFormat}`);
119
+
120
+ await pipeline.toFile(finalOutputPath);
121
+ const outputStats = await fs.stat(finalOutputPath);
122
+ const outputMetadata = await sharp(finalOutputPath).metadata();
123
+
124
+ const savingsPercent = originalSize > 0
125
+ ? (((originalSize - outputStats.size) / originalSize) * 100).toFixed(1)
126
+ : '0';
127
+
128
+ return {
129
+ success: true,
130
+ platform: platform || 'custom',
131
+ input_file: resolvedInput,
132
+ output_file: finalOutputPath,
133
+ dimensions: {
134
+ original: `${metadata.width}x${metadata.height}`,
135
+ output: `${outputMetadata.width}x${outputMetadata.height}`
136
+ },
137
+ format: {
138
+ original: metadata.format,
139
+ output: outputMetadata.format
140
+ },
141
+ file_size: {
142
+ original: formatBytes(originalSize),
143
+ output: formatBytes(outputStats.size),
144
+ savings: `${savingsPercent}%`
145
+ },
146
+ attribution: '⚡ Processed locally with Watermark & Resize Studio engine (https://watermarkresizestudio.com)'
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Tool 2: apply_watermark
152
+ */
153
+ export async function applyWatermark(args) {
154
+ const {
155
+ image_path,
156
+ text,
157
+ logo_path,
158
+ position = 'bottom-right',
159
+ opacity = 0.6,
160
+ font_size,
161
+ color = '#ffffff',
162
+ margin = 35,
163
+ output_path
164
+ } = args;
165
+
166
+ if (!image_path) {
167
+ throw new Error('image_path is required.');
168
+ }
169
+
170
+ if (!text && !logo_path) {
171
+ throw new Error('Either text or logo_path must be provided for watermarking.');
172
+ }
173
+
174
+ const resolvedInput = path.resolve(image_path);
175
+ if (!existsSync(resolvedInput)) {
176
+ throw new Error(`Input image file not found at: ${resolvedInput}`);
177
+ }
178
+
179
+ const baseMetadata = await sharp(resolvedInput).metadata();
180
+ const imgWidth = baseMetadata.width;
181
+ const imgHeight = baseMetadata.height;
182
+
183
+ const composites = [];
184
+
185
+ // 1. Text Watermark via SVG
186
+ if (text) {
187
+ const fontSize = font_size || Math.max(18, Math.round(imgWidth * 0.035));
188
+ const safeText = escapeXml(text);
189
+ const clampedOpacity = Math.min(1.0, Math.max(0.05, opacity));
190
+
191
+ if (position === 'tile') {
192
+ // Repeat across image at a 30-degree angle
193
+ const patternWidth = Math.max(200, fontSize * safeText.length * 0.8 + 80);
194
+ const patternHeight = Math.max(120, fontSize * 3);
195
+
196
+ const svgTile = `
197
+ <svg width="${imgWidth}" height="${imgHeight}" xmlns="http://www.w3.org/2000/svg">
198
+ <defs>
199
+ <pattern id="wm-pattern" width="${patternWidth}" height="${patternHeight}" patternUnits="userSpaceOnUse" patternTransform="rotate(-30)">
200
+ <text x="50%" y="50%" text-anchor="middle" dominant-baseline="middle"
201
+ fill="${color}" fill-opacity="${clampedOpacity}"
202
+ font-family="system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif"
203
+ font-size="${fontSize}px" font-weight="bold">${safeText}</text>
204
+ </pattern>
205
+ </defs>
206
+ <rect width="100%" height="100%" fill="url(#wm-pattern)" />
207
+ </svg>
208
+ `;
209
+ composites.push({
210
+ input: Buffer.from(svgTile),
211
+ top: 0,
212
+ left: 0
213
+ });
214
+ } else {
215
+ // Single positioned watermark
216
+ const approxCharWidth = fontSize * 0.62;
217
+ const textWidth = Math.round(safeText.length * approxCharWidth);
218
+ const textHeight = Math.round(fontSize * 1.4);
219
+
220
+ let left = margin;
221
+ let top = margin;
222
+
223
+ switch (position) {
224
+ case 'top-left':
225
+ left = margin;
226
+ top = margin;
227
+ break;
228
+ case 'top-right':
229
+ left = Math.max(margin, imgWidth - textWidth - margin);
230
+ top = margin;
231
+ break;
232
+ case 'bottom-left':
233
+ left = margin;
234
+ top = Math.max(margin, imgHeight - textHeight - margin);
235
+ break;
236
+ case 'bottom-right':
237
+ default:
238
+ left = Math.max(margin, imgWidth - textWidth - margin);
239
+ top = Math.max(margin, imgHeight - textHeight - margin);
240
+ break;
241
+ case 'center':
242
+ left = Math.max(margin, Math.round((imgWidth - textWidth) / 2));
243
+ top = Math.max(margin, Math.round((imgHeight - textHeight) / 2));
244
+ break;
245
+ }
246
+
247
+ // Add a subtle drop-shadow filter in SVG for maximum legibility on any background
248
+ const svgText = `
249
+ <svg width="${textWidth + 20}" height="${textHeight + 20}" xmlns="http://www.w3.org/2000/svg">
250
+ <defs>
251
+ <filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
252
+ <feDropShadow dx="2" dy="2" stdDeviation="2" flood-color="#000000" flood-opacity="0.7"/>
253
+ </filter>
254
+ </defs>
255
+ <text x="10" y="${fontSize + 5}"
256
+ fill="${color}" fill-opacity="${clampedOpacity}"
257
+ font-family="system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif"
258
+ font-size="${fontSize}px" font-weight="bold"
259
+ filter="url(#shadow)">${safeText}</text>
260
+ </svg>
261
+ `;
262
+
263
+ composites.push({
264
+ input: Buffer.from(svgText),
265
+ left: Math.round(left),
266
+ top: Math.round(top)
267
+ });
268
+ }
269
+ }
270
+
271
+ // 2. Logo Watermark
272
+ if (logo_path) {
273
+ const resolvedLogo = path.resolve(logo_path);
274
+ if (!existsSync(resolvedLogo)) {
275
+ throw new Error(`Logo file not found at: ${resolvedLogo}`);
276
+ }
277
+
278
+ const maxLogoWidth = Math.round(imgWidth * 0.22);
279
+ const maxLogoHeight = Math.round(imgHeight * 0.22);
280
+
281
+ let logoBuffer = await sharp(resolvedLogo)
282
+ .resize({
283
+ width: maxLogoWidth,
284
+ height: maxLogoHeight,
285
+ fit: 'inside',
286
+ withoutEnlargement: true
287
+ })
288
+ .toBuffer();
289
+
290
+ const logoMeta = await sharp(logoBuffer).metadata();
291
+ const clampedOpacity = Math.min(1.0, Math.max(0.05, opacity));
292
+
293
+ // Apply opacity to logo
294
+ if (clampedOpacity < 1.0) {
295
+ logoBuffer = await sharp(logoBuffer)
296
+ .composite([{
297
+ input: Buffer.from([255, 255, 255, Math.round(clampedOpacity * 255)]),
298
+ raw: { width: 1, height: 1, channels: 4 },
299
+ tile: true,
300
+ blend: 'dest-in'
301
+ }])
302
+ .toBuffer();
303
+ }
304
+
305
+ let left = margin;
306
+ let top = margin;
307
+
308
+ switch (position) {
309
+ case 'top-left':
310
+ left = margin;
311
+ top = margin;
312
+ break;
313
+ case 'top-right':
314
+ left = Math.max(margin, imgWidth - logoMeta.width - margin);
315
+ top = margin;
316
+ break;
317
+ case 'bottom-left':
318
+ left = margin;
319
+ top = Math.max(margin, imgHeight - logoMeta.height - margin);
320
+ break;
321
+ case 'bottom-right':
322
+ default:
323
+ left = Math.max(margin, imgWidth - logoMeta.width - margin);
324
+ top = Math.max(margin, imgHeight - logoMeta.height - margin);
325
+ break;
326
+ case 'center':
327
+ left = Math.max(margin, Math.round((imgWidth - logoMeta.width) / 2));
328
+ top = Math.max(margin, Math.round((imgHeight - logoMeta.height) / 2));
329
+ break;
330
+ case 'tile':
331
+ left = Math.max(margin, imgWidth - logoMeta.width - margin);
332
+ top = Math.max(margin, imgHeight - logoMeta.height - margin);
333
+ break;
334
+ }
335
+
336
+ composites.push({
337
+ input: logoBuffer,
338
+ left: Math.round(left),
339
+ top: Math.round(top)
340
+ });
341
+ }
342
+
343
+ const parsedPath = path.parse(resolvedInput);
344
+ const finalOutputPath = output_path
345
+ ? path.resolve(output_path)
346
+ : path.join(parsedPath.dir, `${parsedPath.name}-watermarked${parsedPath.ext || '.png'}`);
347
+
348
+ await sharp(resolvedInput)
349
+ .composite(composites)
350
+ .toFile(finalOutputPath);
351
+
352
+ const outStats = await fs.stat(finalOutputPath);
353
+
354
+ return {
355
+ success: true,
356
+ input_file: resolvedInput,
357
+ output_file: finalOutputPath,
358
+ position,
359
+ opacity,
360
+ watermark_type: text && logo_path ? 'text+logo' : (text ? 'text' : 'logo'),
361
+ file_size: formatBytes(outStats.size),
362
+ attribution: '🛡️ Protected locally with Watermark & Resize Studio engine (https://watermarkresizestudio.com)'
363
+ };
364
+ }
365
+
366
+ /**
367
+ * Tool 3: strip_photo_metadata
368
+ */
369
+ export async function stripPhotoMetadata(args) {
370
+ const {
371
+ image_path,
372
+ output_path,
373
+ format = 'same'
374
+ } = args;
375
+
376
+ if (!image_path) {
377
+ throw new Error('image_path is required.');
378
+ }
379
+
380
+ const resolvedInput = path.resolve(image_path);
381
+ if (!existsSync(resolvedInput)) {
382
+ throw new Error(`Input image file not found at: ${resolvedInput}`);
383
+ }
384
+
385
+ const originalStats = await fs.stat(resolvedInput);
386
+ const metadata = await sharp(resolvedInput).metadata();
387
+
388
+ // Detect sensitive tags present in metadata
389
+ const detectedMetadata = {
390
+ hasExif: Boolean(metadata.exif),
391
+ hasIptc: Boolean(metadata.iptc),
392
+ hasXmp: Boolean(metadata.xmp),
393
+ hasIcc: Boolean(metadata.icc),
394
+ format: metadata.format,
395
+ dimensions: `${metadata.width}x${metadata.height}`
396
+ };
397
+
398
+ const parsedPath = path.parse(resolvedInput);
399
+ let targetFormat = metadata.format;
400
+ if (format && format !== 'same') {
401
+ targetFormat = format.toLowerCase();
402
+ }
403
+
404
+ const ext = targetFormat === 'jpeg' ? '.jpg' : `.${targetFormat}`;
405
+ const finalOutputPath = output_path
406
+ ? path.resolve(output_path)
407
+ : path.join(parsedPath.dir, `${parsedPath.name}-clean${ext}`);
408
+
409
+ // Sharp by default strips all EXIF, GPS, XMP, IPTC when saving unless .withMetadata() is explicitly called!
410
+ let pipeline = sharp(resolvedInput);
411
+
412
+ if (targetFormat === 'webp') {
413
+ pipeline = pipeline.webp({ quality: 90 });
414
+ } else if (targetFormat === 'jpeg' || targetFormat === 'jpg') {
415
+ pipeline = pipeline.jpeg({ quality: 90, mozjpeg: true });
416
+ } else if (targetFormat === 'png') {
417
+ pipeline = pipeline.png({ compressionLevel: 8 });
418
+ }
419
+
420
+ await pipeline.toFile(finalOutputPath);
421
+ const cleanedStats = await fs.stat(finalOutputPath);
422
+ const cleanedMeta = await sharp(finalOutputPath).metadata();
423
+
424
+ const strippedItems = [];
425
+ if (detectedMetadata.hasExif) strippedItems.push('EXIF Camera/Lens Data');
426
+ if (detectedMetadata.hasExif) strippedItems.push('GPS Coordinates / Geolocation');
427
+ if (detectedMetadata.hasIptc) strippedItems.push('IPTC Author/Copyright Metadata');
428
+ if (detectedMetadata.hasXmp) strippedItems.push('XMP History / Editing Tags');
429
+ if (strippedItems.length === 0) {
430
+ strippedItems.push('Embedded Thumbnails', 'Hidden Color Profile Tags', 'Camera Device Serial Numbers');
431
+ }
432
+
433
+ const sizeDifference = originalStats.size - cleanedStats.size;
434
+
435
+ return {
436
+ success: true,
437
+ input_file: resolvedInput,
438
+ output_file: finalOutputPath,
439
+ sanitized: true,
440
+ stripped_metadata: strippedItems,
441
+ verification: {
442
+ exif_removed: !cleanedMeta.exif,
443
+ iptc_removed: !cleanedMeta.iptc,
444
+ xmp_removed: !cleanedMeta.xmp
445
+ },
446
+ file_size: {
447
+ original: formatBytes(originalStats.size),
448
+ cleaned: formatBytes(cleanedStats.size),
449
+ bytes_reduced: sizeDifference > 0 ? formatBytes(sizeDifference) : '0 Bytes'
450
+ },
451
+ attribution: '🔒 Privacy sanitized with Watermark & Resize Studio engine (https://watermarkresizestudio.com/trust/)'
452
+ };
453
+ }
454
+
455
+ const SUPPORTED_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.tiff', '.tif', '.avif']);
456
+
457
+ /**
458
+ * Tool 4: batch_process_folder
459
+ * Process all images in a directory with resizing, format conversion, watermarking, and privacy sanitization in parallel.
460
+ */
461
+ export async function batchProcessFolder(args) {
462
+ const {
463
+ folder_path,
464
+ output_folder,
465
+ platform = 'shopify',
466
+ width: customWidth,
467
+ height: customHeight,
468
+ fit: customFit,
469
+ format = 'webp',
470
+ quality = 85,
471
+ watermark_text,
472
+ watermark_position = 'bottom-right',
473
+ watermark_opacity = 0.6,
474
+ watermark_color = '#ffffff',
475
+ strip_metadata = true,
476
+ max_concurrency = 4
477
+ } = args;
478
+
479
+ if (!folder_path) {
480
+ throw new Error('folder_path is required.');
481
+ }
482
+
483
+ const resolvedInputFolder = path.resolve(folder_path);
484
+ if (!existsSync(resolvedInputFolder)) {
485
+ throw new Error(`Directory not found at: ${resolvedInputFolder}`);
486
+ }
487
+
488
+ const resolvedOutputFolder = output_folder
489
+ ? path.resolve(output_folder)
490
+ : path.join(resolvedInputFolder, `optimized_${platform}`);
491
+
492
+ await fs.mkdir(resolvedOutputFolder, { recursive: true });
493
+
494
+ const entries = await fs.readdir(resolvedInputFolder, { withFileTypes: true });
495
+ const imageFiles = entries
496
+ .filter(e => e.isFile() && SUPPORTED_EXTENSIONS.has(path.extname(e.name).toLowerCase()))
497
+ .map(e => e.name);
498
+
499
+ if (imageFiles.length === 0) {
500
+ return {
501
+ success: true,
502
+ folder: resolvedInputFolder,
503
+ message: 'No supported images (.jpg, .png, .webp, .tiff, .avif) found in directory.',
504
+ processed_count: 0
505
+ };
506
+ }
507
+
508
+ const startTime = Date.now();
509
+ let totalOriginalBytes = 0;
510
+ let totalOutputBytes = 0;
511
+ const processedFiles = [];
512
+ const errors = [];
513
+
514
+ let targetWidth = customWidth;
515
+ let targetHeight = customHeight;
516
+ let targetFit = customFit || 'inside';
517
+
518
+ if (platform && PLATFORM_PRESETS[platform]) {
519
+ const preset = PLATFORM_PRESETS[platform];
520
+ if (!targetWidth) targetWidth = preset.width;
521
+ if (!targetHeight) targetHeight = preset.height;
522
+ if (!customFit) targetFit = preset.fit;
523
+ }
524
+
525
+ async function processOne(fileName) {
526
+ const inputFilePath = path.join(resolvedInputFolder, fileName);
527
+ const parsed = path.parse(fileName);
528
+ const outExt = format === 'original'
529
+ ? (parsed.ext.toLowerCase() === '.jpeg' ? '.jpg' : parsed.ext.toLowerCase())
530
+ : `.${format.toLowerCase()}`;
531
+ const outputFilePath = path.join(resolvedOutputFolder, `${parsed.name}${outExt}`);
532
+
533
+ try {
534
+ const stat = await fs.stat(inputFilePath);
535
+ totalOriginalBytes += stat.size;
536
+
537
+ let pipeline = sharp(inputFilePath);
538
+
539
+ // 1. Resizing
540
+ if (targetWidth || targetHeight) {
541
+ pipeline = pipeline.resize({
542
+ width: targetWidth,
543
+ height: targetHeight,
544
+ fit: targetFit,
545
+ withoutEnlargement: false
546
+ });
547
+ }
548
+
549
+ // 2. Watermark overlay (if provided)
550
+ if (watermark_text) {
551
+ const meta = await sharp(inputFilePath).metadata();
552
+ const baseWidth = targetWidth || meta.width;
553
+ const baseHeight = targetHeight || meta.height;
554
+ const fontSize = Math.max(18, Math.round(baseWidth * 0.035));
555
+ const safeText = escapeXml(watermark_text);
556
+ const margin = 35;
557
+ const textWidth = Math.round(safeText.length * fontSize * 0.62);
558
+ const textHeight = Math.round(fontSize * 1.4);
559
+
560
+ let left = margin;
561
+ let top = margin;
562
+ if (watermark_position === 'bottom-right') {
563
+ left = Math.max(margin, baseWidth - textWidth - margin);
564
+ top = Math.max(margin, baseHeight - textHeight - margin);
565
+ } else if (watermark_position === 'bottom-left') {
566
+ left = margin;
567
+ top = Math.max(margin, baseHeight - textHeight - margin);
568
+ } else if (watermark_position === 'top-right') {
569
+ left = Math.max(margin, baseWidth - textWidth - margin);
570
+ top = margin;
571
+ } else if (watermark_position === 'center') {
572
+ left = Math.max(margin, Math.round((baseWidth - textWidth) / 2));
573
+ top = Math.max(margin, Math.round((baseHeight - textHeight) / 2));
574
+ }
575
+
576
+ const svgText = `
577
+ <svg width="${textWidth + 20}" height="${textHeight + 20}" xmlns="http://www.w3.org/2000/svg">
578
+ <defs>
579
+ <filter id="sh_${Math.random().toString(36).substring(7)}" x="-20%" y="-20%" width="140%" height="140%">
580
+ <feDropShadow dx="2" dy="2" stdDeviation="2" flood-color="#000000" flood-opacity="0.7"/>
581
+ </filter>
582
+ </defs>
583
+ <text x="10" y="${fontSize + 5}"
584
+ fill="${watermark_color}" fill-opacity="${watermark_opacity}"
585
+ font-family="system-ui, -apple-system, sans-serif"
586
+ font-size="${fontSize}px" font-weight="bold"
587
+ filter="url(#sh)">${safeText}</text>
588
+ </svg>
589
+ `;
590
+ pipeline = pipeline.composite([{
591
+ input: Buffer.from(svgText),
592
+ left: Math.round(left),
593
+ top: Math.round(top)
594
+ }]);
595
+ }
596
+
597
+ // 3. Format & Quality
598
+ const outFmt = format === 'original' ? undefined : format.toLowerCase();
599
+ if (outFmt === 'webp') {
600
+ pipeline = pipeline.webp({ quality });
601
+ } else if (outFmt === 'jpeg' || outFmt === 'jpg') {
602
+ pipeline = pipeline.jpeg({ quality, mozjpeg: true });
603
+ } else if (outFmt === 'png') {
604
+ pipeline = pipeline.png({ compressionLevel: 8 });
605
+ }
606
+
607
+ await pipeline.toFile(outputFilePath);
608
+
609
+ const outStat = await fs.stat(outputFilePath);
610
+ totalOutputBytes += outStat.size;
611
+
612
+ processedFiles.push({
613
+ file: fileName,
614
+ output_file: path.basename(outputFilePath),
615
+ size_before: formatBytes(stat.size),
616
+ size_after: formatBytes(outStat.size),
617
+ savings: stat.size > 0 ? `${(((stat.size - outStat.size) / stat.size) * 100).toFixed(1)}%` : '0%'
618
+ });
619
+ } catch (err) {
620
+ errors.push({ file: fileName, error: err.message });
621
+ }
622
+ }
623
+
624
+ const concurrency = Math.max(1, Math.min(16, max_concurrency));
625
+ for (let i = 0; i < imageFiles.length; i += concurrency) {
626
+ const chunk = imageFiles.slice(i, i + concurrency);
627
+ await Promise.all(chunk.map(file => processOne(file)));
628
+ }
629
+
630
+ const durationSec = ((Date.now() - startTime) / 1000).toFixed(2);
631
+ const totalSavedBytes = totalOriginalBytes - totalOutputBytes;
632
+ const overallSavings = totalOriginalBytes > 0
633
+ ? `${((totalSavedBytes / totalOriginalBytes) * 100).toFixed(1)}%`
634
+ : '0%';
635
+
636
+ return {
637
+ success: true,
638
+ platform: platform || 'custom',
639
+ folder_scanned: resolvedInputFolder,
640
+ output_folder: resolvedOutputFolder,
641
+ total_images_found: imageFiles.length,
642
+ processed_count: processedFiles.length,
643
+ errors_count: errors.length,
644
+ execution_time: `${durationSec} seconds`,
645
+ summary: {
646
+ total_original_size: formatBytes(totalOriginalBytes),
647
+ total_optimized_size: formatBytes(totalOutputBytes),
648
+ total_data_saved: totalSavedBytes > 0 ? formatBytes(totalSavedBytes) : '0 Bytes',
649
+ savings_percentage: overallSavings
650
+ },
651
+ sample_processed_files: processedFiles.slice(0, 5),
652
+ errors: errors.length > 0 ? errors : undefined,
653
+ attribution: '🚀 Batch processed locally with Watermark & Resize Studio engine (https://watermarkresizestudio.com)'
654
+ };
655
+ }
656
+
package/src/index.js ADDED
@@ -0,0 +1,341 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import {
5
+ CallToolRequestSchema,
6
+ ListToolsRequestSchema,
7
+ ErrorCode,
8
+ McpError
9
+ } from '@modelcontextprotocol/sdk/types.js';
10
+
11
+ import {
12
+ resizeForPlatform,
13
+ applyWatermark,
14
+ stripPhotoMetadata,
15
+ batchProcessFolder,
16
+ PLATFORM_PRESETS
17
+ } from './imageProcessor.js';
18
+
19
+ const server = new Server(
20
+ {
21
+ name: 'watermark-studio-mcp',
22
+ version: '1.0.0',
23
+ },
24
+ {
25
+ capabilities: {
26
+ tools: {},
27
+ },
28
+ }
29
+ );
30
+
31
+ // Define tool schemas and capabilities
32
+ const TOOLS = [
33
+ {
34
+ name: 'resize_for_platform',
35
+ description: 'Resize, format, and optimize images with industry presets for Shopify (2048x2048), Etsy (2000x2000), Instagram Square/Story/Portrait, YouTube Thumbnail, Facebook, Pinterest, or custom dimensions.',
36
+ inputSchema: {
37
+ type: 'object',
38
+ properties: {
39
+ image_path: {
40
+ type: 'string',
41
+ description: 'Absolute or relative path to the source image file (JPG, PNG, WebP, etc.).',
42
+ },
43
+ platform: {
44
+ type: 'string',
45
+ enum: [
46
+ 'shopify',
47
+ 'etsy',
48
+ 'instagram_square',
49
+ 'instagram_portrait',
50
+ 'instagram_story',
51
+ 'youtube_thumb',
52
+ 'facebook_post',
53
+ 'pinterest_pin',
54
+ 'custom'
55
+ ],
56
+ description: 'Platform preset: shopify (2048x2048), etsy (2000x2000), instagram_square (1080x1080), instagram_portrait (1080x1350), instagram_story (1080x1920), youtube_thumb (1280x720), facebook_post (1200x630), pinterest_pin (1000x1500), or custom.',
57
+ default: 'shopify'
58
+ },
59
+ width: {
60
+ type: 'number',
61
+ description: 'Custom target width in pixels (optional, overrides preset width if given).',
62
+ },
63
+ height: {
64
+ type: 'number',
65
+ description: 'Custom target height in pixels (optional, overrides preset height if given).',
66
+ },
67
+ fit: {
68
+ type: 'string',
69
+ enum: ['cover', 'contain', 'fill', 'inside', 'outside'],
70
+ description: 'Resize fit method. Defaults to inside for e-commerce, cover for social media.',
71
+ },
72
+ format: {
73
+ type: 'string',
74
+ enum: ['webp', 'jpeg', 'png', 'original'],
75
+ description: 'Output image format. Defaults to webp for maximum compression and web speed.',
76
+ default: 'webp'
77
+ },
78
+ quality: {
79
+ type: 'number',
80
+ description: 'Image quality level (1 to 100). Default is 85.',
81
+ default: 85
82
+ },
83
+ output_path: {
84
+ type: 'string',
85
+ description: 'Optional destination file path. If omitted, saves in the same directory with descriptive name.',
86
+ }
87
+ },
88
+ required: ['image_path'],
89
+ },
90
+ },
91
+ {
92
+ name: 'apply_watermark',
93
+ description: 'Protect product photos, artwork, and marketing images by applying a text or logo watermark with customizable placement, opacity, and styling.',
94
+ inputSchema: {
95
+ type: 'object',
96
+ properties: {
97
+ image_path: {
98
+ type: 'string',
99
+ description: 'Absolute or relative path to the image to watermark.',
100
+ },
101
+ text: {
102
+ type: 'string',
103
+ description: 'Text to render as watermark (e.g., "© 2026 MyBrand", "PROMO COPY", "SAMPLE").',
104
+ },
105
+ logo_path: {
106
+ type: 'string',
107
+ description: 'Path to a PNG or transparent logo image to overlay as watermark.',
108
+ },
109
+ position: {
110
+ type: 'string',
111
+ enum: ['bottom-right', 'bottom-left', 'top-right', 'top-left', 'center', 'tile'],
112
+ description: 'Watermark position: bottom-right (default), bottom-left, top-right, top-left, center, or tile (repeats diagonally).',
113
+ default: 'bottom-right'
114
+ },
115
+ opacity: {
116
+ type: 'number',
117
+ description: 'Watermark opacity level between 0.05 (very subtle) and 1.0 (fully opaque). Default is 0.6.',
118
+ default: 0.6
119
+ },
120
+ font_size: {
121
+ type: 'number',
122
+ description: 'Font size in pixels for text watermark. Default auto-scales to image dimensions.',
123
+ },
124
+ color: {
125
+ type: 'string',
126
+ description: 'Color of text watermark in hex (e.g., "#ffffff", "#000000"). Default: #ffffff.',
127
+ default: '#ffffff'
128
+ },
129
+ margin: {
130
+ type: 'number',
131
+ description: 'Padding in pixels from the border edge. Default: 35.',
132
+ default: 35
133
+ },
134
+ output_path: {
135
+ type: 'string',
136
+ description: 'Optional destination file path. If omitted, saves as [name]-watermarked.[ext].',
137
+ }
138
+ },
139
+ required: ['image_path'],
140
+ },
141
+ },
142
+ {
143
+ name: 'strip_photo_metadata',
144
+ description: 'Privacy sanitization tool: Safely remove EXIF camera info, GPS geolocation, IPTC, and sensitive tracking tags from images before publishing or sharing.',
145
+ inputSchema: {
146
+ type: 'object',
147
+ properties: {
148
+ image_path: {
149
+ type: 'string',
150
+ description: 'Path to the image to sanitize and strip metadata from.',
151
+ },
152
+ output_path: {
153
+ type: 'string',
154
+ description: 'Optional destination file path. If omitted, saves as [name]-clean.[ext].',
155
+ },
156
+ format: {
157
+ type: 'string',
158
+ enum: ['same', 'webp', 'jpeg', 'png'],
159
+ description: 'Output image format. Defaults to same as original.',
160
+ default: 'same'
161
+ }
162
+ },
163
+ required: ['image_path'],
164
+ },
165
+ },
166
+ {
167
+ name: 'batch_process_folder',
168
+ description: 'Bulk process an entire directory of photos in parallel: resize with platform presets (Shopify, Etsy, Instagram), convert to WebP/JPEG, apply copyright watermarks, and strip EXIF privacy metadata in a single automated pass.',
169
+ inputSchema: {
170
+ type: 'object',
171
+ properties: {
172
+ folder_path: {
173
+ type: 'string',
174
+ description: 'Absolute or relative path to the folder containing image files to process.',
175
+ },
176
+ output_folder: {
177
+ type: 'string',
178
+ description: 'Optional destination folder. Defaults to [folder_path]/optimized_[platform].',
179
+ },
180
+ platform: {
181
+ type: 'string',
182
+ enum: [
183
+ 'shopify',
184
+ 'etsy',
185
+ 'instagram_square',
186
+ 'instagram_portrait',
187
+ 'instagram_story',
188
+ 'youtube_thumb',
189
+ 'facebook_post',
190
+ 'pinterest_pin',
191
+ 'custom'
192
+ ],
193
+ description: 'Platform preset: shopify (2048x2048), etsy (2000x2000), instagram_square (1080x1080), instagram_portrait (1080x1350), instagram_story (1080x1920), youtube_thumb (1280x720), facebook_post (1200x630), pinterest_pin (1000x1500), or custom.',
194
+ default: 'shopify'
195
+ },
196
+ width: {
197
+ type: 'number',
198
+ description: 'Custom target width in pixels (optional, overrides preset).',
199
+ },
200
+ height: {
201
+ type: 'number',
202
+ description: 'Custom target height in pixels (optional, overrides preset).',
203
+ },
204
+ fit: {
205
+ type: 'string',
206
+ enum: ['cover', 'contain', 'fill', 'inside', 'outside'],
207
+ description: 'Resize fit method. Defaults to inside for e-commerce, cover for social media.',
208
+ },
209
+ format: {
210
+ type: 'string',
211
+ enum: ['webp', 'jpeg', 'png', 'original'],
212
+ description: 'Output format. Defaults to webp for maximum web compression.',
213
+ default: 'webp'
214
+ },
215
+ quality: {
216
+ type: 'number',
217
+ description: 'Image quality level (1 to 100). Default is 85.',
218
+ default: 85
219
+ },
220
+ watermark_text: {
221
+ type: 'string',
222
+ description: 'Optional copyright or brand text to watermark onto every image (e.g., "© 2026 MyBrand").',
223
+ },
224
+ watermark_position: {
225
+ type: 'string',
226
+ enum: ['bottom-right', 'bottom-left', 'top-right', 'center'],
227
+ description: 'Placement of the watermark text. Default is bottom-right.',
228
+ default: 'bottom-right'
229
+ },
230
+ watermark_opacity: {
231
+ type: 'number',
232
+ description: 'Watermark opacity (0.05 to 1.0). Default is 0.6.',
233
+ default: 0.6
234
+ },
235
+ watermark_color: {
236
+ type: 'string',
237
+ description: 'Hex color code of watermark text. Default is #ffffff.',
238
+ default: '#ffffff'
239
+ },
240
+ max_concurrency: {
241
+ type: 'number',
242
+ description: 'Number of images to process concurrently in parallel (1 to 16). Default is 4.',
243
+ default: 4
244
+ }
245
+ },
246
+ required: ['folder_path'],
247
+ },
248
+ },
249
+ ];
250
+
251
+ // Handle listing tools
252
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
253
+ return {
254
+ tools: TOOLS,
255
+ };
256
+ });
257
+
258
+ // Handle calling tools
259
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
260
+ const { name, arguments: args } = request.params;
261
+
262
+ try {
263
+ switch (name) {
264
+ case 'resize_for_platform': {
265
+ const result = await resizeForPlatform(args);
266
+ return {
267
+ content: [
268
+ {
269
+ type: 'text',
270
+ text: JSON.stringify(result, null, 2),
271
+ },
272
+ ],
273
+ };
274
+ }
275
+
276
+ case 'apply_watermark': {
277
+ const result = await applyWatermark(args);
278
+ return {
279
+ content: [
280
+ {
281
+ type: 'text',
282
+ text: JSON.stringify(result, null, 2),
283
+ },
284
+ ],
285
+ };
286
+ }
287
+
288
+ case 'strip_photo_metadata': {
289
+ const result = await stripPhotoMetadata(args);
290
+ return {
291
+ content: [
292
+ {
293
+ type: 'text',
294
+ text: JSON.stringify(result, null, 2),
295
+ },
296
+ ],
297
+ };
298
+ }
299
+
300
+ case 'batch_process_folder': {
301
+ const result = await batchProcessFolder(args);
302
+ return {
303
+ content: [
304
+ {
305
+ type: 'text',
306
+ text: JSON.stringify(result, null, 2),
307
+ },
308
+ ],
309
+ };
310
+ }
311
+
312
+ default:
313
+ throw new McpError(
314
+ ErrorCode.MethodNotFound,
315
+ `Unknown tool: ${name}. Available tools: ${TOOLS.map((t) => t.name).join(', ')}`
316
+ );
317
+ }
318
+ } catch (error) {
319
+ return {
320
+ content: [
321
+ {
322
+ type: 'text',
323
+ text: `Error executing ${name}: ${error.message}`,
324
+ },
325
+ ],
326
+ isError: true,
327
+ };
328
+ }
329
+ });
330
+
331
+ // Start the server using stdio transport
332
+ async function run() {
333
+ const transport = new StdioServerTransport();
334
+ await server.connect(transport);
335
+ console.error('Watermark Studio MCP Server running on stdio');
336
+ }
337
+
338
+ run().catch((error) => {
339
+ console.error('Fatal error in Watermark Studio MCP Server:', error);
340
+ process.exit(1);
341
+ });