omrify-mcp 1.0.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.
Files changed (3) hide show
  1. package/README.md +181 -0
  2. package/package.json +40 -0
  3. package/server.mjs +488 -0
package/README.md ADDED
@@ -0,0 +1,181 @@
1
+ # omrify-mcp
2
+
3
+ An MCP server that lets an AI coding agent turn images on your machine into public URLs.
4
+
5
+ ```
6
+ Agent ──stdio──> omrify-mcp ──reads──> C:\project\assets\hero.png
7
+
8
+ └──POST bytes──> Omrify Worker /api/upload
9
+ │ Cloudflare Images → WebP
10
+ │ put → R2 bucket
11
+ └──> https://cdn.example.com/…/hero.webp
12
+ ```
13
+
14
+ The MCP process only reads files and forwards bytes. Conversion and storage
15
+ credentials live on the Worker, so no API key ever sits in an MCP config or in
16
+ another project. Image bytes never pass through the model as base64.
17
+
18
+ ## Tools
19
+
20
+ | Tool | What it does |
21
+ | ----------------- | ------------------------------------------------------------------ |
22
+ | `upload_image` | One local file → hosted URL |
23
+ | `upload_images` | Files and/or directories (`recursive` optional) → one URL per image |
24
+ | `upload_from_url` | Download a remote image, re-encode it, re-host it |
25
+ | `recent_uploads` | Look up links from earlier uploads instead of re-uploading |
26
+
27
+ All upload tools accept `format` (`webp` \| `png` \| `jpeg` \| `original`),
28
+ `quality` (1–100) and `maxDimension` (longest edge in px, `0` keeps the original).
29
+
30
+ ---
31
+
32
+ # For users
33
+
34
+ You need the upload token from whoever runs the Worker. Then add this to your MCP config —
35
+ `~/.claude.json` for every project, or `.mcp.json` in one project root:
36
+
37
+ ```json
38
+ {
39
+ "mcpServers": {
40
+ "omrify": {
41
+ "type": "stdio",
42
+ "command": "npx",
43
+ "args": ["-y", "omrify-mcp"],
44
+ "env": {
45
+ "OMRIFY_ENDPOINT": "https://<the-worker>.workers.dev/api/upload",
46
+ "OMRIFY_TOKEN": "<your token>"
47
+ }
48
+ }
49
+ }
50
+ }
51
+ ```
52
+
53
+ Restart your editor — MCP servers connect at session start. In Claude Code, `/mcp`
54
+ should list `omrify` as connected with four tools. Then just ask:
55
+
56
+ > “Upload `assets/logo.png` and use it as the hero background.”
57
+
58
+ > **Don't commit `.mcp.json`** if you put your token in it. `~/.claude.json` is the
59
+ > safer home.
60
+
61
+ ## Environment
62
+
63
+ | Variable | Default | Purpose |
64
+ | ------------------------ | ---------------------------------- | ----------------------------- |
65
+ | `OMRIFY_ENDPOINT` | `http://localhost:8080/api/upload` | Where uploads are POSTed |
66
+ | `OMRIFY_TOKEN` | — | The shared upload token |
67
+ | `OMRIFY_ROOT` | process cwd | Base for relative paths |
68
+ | `OMRIFY_DEFAULT_FORMAT` | `webp` | Default output format |
69
+ | `OMRIFY_DEFAULT_QUALITY` | `85` | Default lossy quality |
70
+ | `OMRIFY_MAX_DIMENSION` | `0` | Default longest-edge cap in px |
71
+ | `OMRIFY_HISTORY_FILE` | `~/.omrify-mcp/uploads.json` | Where `recent_uploads` reads |
72
+
73
+ ---
74
+
75
+ # For whoever runs the Worker
76
+
77
+ ## Deploy
78
+
79
+ ```bash
80
+ npm run build
81
+ npx wrangler deploy -c dist/server/wrangler.json
82
+ ```
83
+
84
+ ## Set the token
85
+
86
+ One shared token that everybody uses:
87
+
88
+ ```bash
89
+ node -e "console.log(require('crypto').randomBytes(24).toString('base64url'))"
90
+ npx wrangler secret put MCP_UPLOAD_TOKEN
91
+ ```
92
+
93
+ It takes effect immediately — no redeploy. To cut off access, `secret put` a new
94
+ value; everyone then needs the new token, so hand it out somewhere you can update.
95
+
96
+ `/api/upload` returns 503 while the token is unset — without one the endpoint
97
+ would be an open proxy onto your storage.
98
+
99
+ ## Set up storage (R2)
100
+
101
+ Uploads are stored in an R2 bucket bound as `UPLOADS`. Create it, expose it, and
102
+ tell the Worker which origin serves it:
103
+
104
+ ```bash
105
+ npx wrangler r2 bucket create omrify-uploads
106
+ npx wrangler r2 bucket dev-url enable omrify-uploads # prints https://pub-<id>.r2.dev
107
+ npx wrangler secret put R2_PUBLIC_BASE # paste that origin
108
+ ```
109
+
110
+ For a real deployment, attach a custom domain to the bucket in the Cloudflare
111
+ dashboard and use that as `R2_PUBLIC_BASE` instead — the `r2.dev` dev URL is rate
112
+ limited and not meant for production traffic.
113
+
114
+ > **Why not imgbb?** It blocks by source IP, and Workers egress from shared
115
+ > Cloudflare addresses that it rejects, so uploads failed at random with
116
+ > `400 code 103` even with a valid key. R2 keeps the write inside Cloudflare.
117
+ > imgbb remains only as a `npm run dev` fallback, where requests leave from your
118
+ > own machine.
119
+
120
+ ## Health check
121
+
122
+ ```bash
123
+ curl -H "Authorization: Bearer $OMRIFY_TOKEN" https://<the-worker>.workers.dev/api/upload
124
+ # {"ok":true,"service":"omrify-upload","storage":"r2","r2Bucket":true,
125
+ # "r2PublicBase":true,"uploadToken":true,"cloudflareImages":true}
126
+ ```
127
+
128
+ `storage` must read `r2`. If `r2Bucket` is true but `r2PublicBase` is false the
129
+ endpoint 503s on upload, because stored objects would have no reachable URL.
130
+
131
+ `cloudflareImages: false` is not fatal — uploads still succeed, but the bytes go up
132
+ unconverted and results are flagged `converted: false`. Re-enable it by keeping the
133
+ `images` binding in `wrangler.jsonc` and having Images available on the account.
134
+
135
+ ## Troubleshooting
136
+
137
+ **Uploads fail at random with `502 ... imgbb ... code 103`.** `storage` is `imgbb`,
138
+ not `r2` — the Worker has no bucket bound and fell back, and imgbb blocks Cloudflare's
139
+ egress IPs. It hits every tool equally (`upload_image` as much as `upload_from_url`),
140
+ and because some attempts get through it can look like only one of them is broken.
141
+ Fix it with [Set up storage (R2)](#set-up-storage-r2) above. Note that R2 has to be
142
+ enabled on the account in the Cloudflare dashboard first — `wrangler r2 bucket create`
143
+ fails with `code: 10042` until it is.
144
+
145
+ **`upload_from_url` says the source "returned text/html, not an image".** The link
146
+ points at a gallery or share page rather than the file. Open it, right-click the
147
+ image, and copy the direct image address.
148
+
149
+ **`upload_from_url` gets a 403.** The host blocks hotlinking. Save the image locally
150
+ and use `upload_image` instead.
151
+
152
+ ## Publishing
153
+
154
+ ```bash
155
+ cd mcp
156
+ npm publish --access public
157
+ ```
158
+
159
+ Bump `version` first. `files` limits the tarball to `server.mjs` and this README.
160
+
161
+ ## Local development
162
+
163
+ Point `OMRIFY_ENDPOINT` at `http://localhost:8080/api/upload` and run `npm run dev`
164
+ in the repo root, with the token in `.dev.vars`. Note that `npm run dev` runs under
165
+ plain Node, so there is neither an Images nor an R2 binding: uploads come back
166
+ `converted: false` and fall back to imgbb via `VITE_IMGBB_API_KEY`. To exercise the
167
+ real conversion and R2 path locally:
168
+
169
+ ```bash
170
+ npm run build
171
+ cd dist/server && npx wrangler dev -c wrangler.json --port 8789
172
+ ```
173
+
174
+ ## Notes
175
+
176
+ - Max upload size is 100 MB on R2, 32 MB on the imgbb dev fallback.
177
+ - R2 object keys are content-addressed, so re-uploading identical bytes reuses the
178
+ same URL instead of creating a duplicate.
179
+ - `recent_uploads` is capped at 300 entries.
180
+ - Animated GIF/WebP: the Worker path re-encodes a still frame. Use
181
+ `format: "original"` to upload an animation untouched.
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "omrify-mcp",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "MCP server that turns local image files into hosted, WebP-optimised URLs via an Omrify Cloudflare Worker.",
6
+ "keywords": [
7
+ "mcp",
8
+ "model-context-protocol",
9
+ "claude",
10
+ "image",
11
+ "webp",
12
+ "upload",
13
+ "cloudflare"
14
+ ],
15
+ "license": "MIT",
16
+ "author": "Omar Khaled",
17
+ "homepage": "https://github.com/okhaled11/omrify/tree/main/mcp#readme",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/okhaled11/omrify.git",
21
+ "directory": "mcp"
22
+ },
23
+ "bugs": {
24
+ "url": "https://github.com/okhaled11/omrify/issues"
25
+ },
26
+ "bin": {
27
+ "omrify-mcp": "server.mjs"
28
+ },
29
+ "files": [
30
+ "server.mjs",
31
+ "README.md"
32
+ ],
33
+ "dependencies": {
34
+ "@modelcontextprotocol/sdk": "^1.29.0",
35
+ "zod": "^3.25.76"
36
+ },
37
+ "engines": {
38
+ "node": ">=20"
39
+ }
40
+ }
package/server.mjs ADDED
@@ -0,0 +1,488 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Omrify MCP server — stdio.
4
+ *
5
+ * Reads image files straight off the local disk (so bytes never travel through the
6
+ * model as base64) and POSTs them to the Omrify Cloudflare Worker at
7
+ * `POST /api/upload`, which re-encodes them and returns a public hosted URL.
8
+ *
9
+ * Configuration comes from the environment:
10
+ * OMRIFY_ENDPOINT upload endpoint (default: http://localhost:8080/api/upload)
11
+ * OMRIFY_TOKEN must match MCP_UPLOAD_TOKEN on the Worker (required)
12
+ * OMRIFY_ROOT base for relative paths (default: process.cwd())
13
+ * OMRIFY_DEFAULT_FORMAT webp | png | jpeg | original (default: webp)
14
+ * OMRIFY_DEFAULT_QUALITY 1-100 (default: 85)
15
+ * OMRIFY_MAX_DIMENSION longest edge in px, 0 = keep (default: 0)
16
+ */
17
+
18
+ import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
19
+ import os from "node:os";
20
+ import path from "node:path";
21
+ import { basename, extname, isAbsolute, join, resolve } from "node:path";
22
+
23
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
24
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
25
+ import { z } from "zod";
26
+
27
+ /* ───────────────────────── config ───────────────────────── */
28
+
29
+ const CONFIG = {
30
+ endpoint: process.env.OMRIFY_ENDPOINT?.trim() || "http://localhost:8080/api/upload",
31
+ token: process.env.OMRIFY_TOKEN?.trim() || "",
32
+ root: process.env.OMRIFY_ROOT?.trim() || process.cwd(),
33
+ format: process.env.OMRIFY_DEFAULT_FORMAT?.trim() || "webp",
34
+ quality: Number(process.env.OMRIFY_DEFAULT_QUALITY) || 85,
35
+ maxDimension: Number(process.env.OMRIFY_MAX_DIMENSION) || 0,
36
+ };
37
+
38
+ /**
39
+ * Uploads log, so `recent_uploads` survives restarts. Kept in the user's home
40
+ * directory rather than beside this file — under `npx` the package lives in a
41
+ * read-only cache that is wiped between versions.
42
+ */
43
+ const HISTORY_FILE =
44
+ process.env.OMRIFY_HISTORY_FILE?.trim() || path.join(os.homedir(), ".omrify-mcp", "uploads.json");
45
+ const HISTORY_LIMIT = 300;
46
+
47
+ const IMAGE_EXTS = new Set([
48
+ ".png",
49
+ ".jpg",
50
+ ".jpeg",
51
+ ".webp",
52
+ ".gif",
53
+ ".bmp",
54
+ ".avif",
55
+ ".tif",
56
+ ".tiff",
57
+ ".svg",
58
+ ]);
59
+
60
+ const MIME_BY_EXT = {
61
+ ".png": "image/png",
62
+ ".jpg": "image/jpeg",
63
+ ".jpeg": "image/jpeg",
64
+ ".webp": "image/webp",
65
+ ".gif": "image/gif",
66
+ ".bmp": "image/bmp",
67
+ ".avif": "image/avif",
68
+ ".tif": "image/tiff",
69
+ ".tiff": "image/tiff",
70
+ ".svg": "image/svg+xml",
71
+ };
72
+
73
+ /** Reverse of MIME_BY_EXT — CDN image URLs are usually extensionless. */
74
+ const EXT_BY_MIME = {
75
+ "image/png": ".png",
76
+ "image/jpeg": ".jpg",
77
+ "image/jpg": ".jpg",
78
+ "image/webp": ".webp",
79
+ "image/gif": ".gif",
80
+ "image/bmp": ".bmp",
81
+ "image/avif": ".avif",
82
+ "image/tiff": ".tif",
83
+ "image/svg+xml": ".svg",
84
+ };
85
+
86
+ /**
87
+ * Headers for fetching a remote image. Several hosts (Wikimedia among them) answer
88
+ * 403 to a request with no User-Agent, and some CDNs content-negotiate on Accept.
89
+ */
90
+ const SOURCE_FETCH_HEADERS = {
91
+ "user-agent": "omrify-mcp (+https://github.com/okhaled11/omrify)",
92
+ accept: "image/*,*/*;q=0.8",
93
+ };
94
+
95
+ /** A wrong URL should fail fast rather than hang the tool call. */
96
+ const SOURCE_FETCH_TIMEOUT_MS = 30_000;
97
+
98
+ /* ───────────────────────── small utilities ───────────────────────── */
99
+
100
+ class ToolError extends Error {}
101
+
102
+ function formatBytes(n) {
103
+ if (!Number.isFinite(n)) return "?";
104
+ if (n < 1024) return `${n} B`;
105
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
106
+ return `${(n / (1024 * 1024)).toFixed(2)} MB`;
107
+ }
108
+
109
+ function savings(before, after) {
110
+ if (!before || !after || after >= before) return "";
111
+ // Floor, so a 99.6% saving never reads as a misleading "-100%".
112
+ return ` (-${Math.floor((1 - after / before) * 100)}%)`;
113
+ }
114
+
115
+ /** Resolve a user-supplied path against OMRIFY_ROOT and confirm it exists. */
116
+ async function resolveExisting(inputPath) {
117
+ const full = isAbsolute(inputPath) ? resolve(inputPath) : resolve(CONFIG.root, inputPath);
118
+ try {
119
+ return { path: full, stats: await stat(full) };
120
+ } catch {
121
+ throw new ToolError(`No such file or directory: ${full}`);
122
+ }
123
+ }
124
+
125
+ /** Expand a mix of files and directories into a flat list of image file paths. */
126
+ async function collectImages(paths, recursive) {
127
+ const found = [];
128
+ for (const input of paths) {
129
+ const { path: full, stats } = await resolveExisting(input);
130
+ if (stats.isDirectory()) {
131
+ found.push(...(await walkDirectory(full, recursive)));
132
+ } else {
133
+ found.push(full);
134
+ }
135
+ }
136
+ // Directories can overlap; keep the first occurrence of each file.
137
+ return [...new Set(found)];
138
+ }
139
+
140
+ async function walkDirectory(dir, recursive) {
141
+ const entries = await readdir(dir, { withFileTypes: true });
142
+ const out = [];
143
+ for (const entry of entries) {
144
+ const full = join(dir, entry.name);
145
+ if (entry.isDirectory()) {
146
+ if (recursive && entry.name !== "node_modules" && !entry.name.startsWith(".")) {
147
+ out.push(...(await walkDirectory(full, recursive)));
148
+ }
149
+ } else if (IMAGE_EXTS.has(extname(entry.name).toLowerCase())) {
150
+ out.push(full);
151
+ }
152
+ }
153
+ return out.sort();
154
+ }
155
+
156
+ /* ───────────────────────── history ───────────────────────── */
157
+
158
+ async function readHistory() {
159
+ try {
160
+ return JSON.parse(await readFile(HISTORY_FILE, "utf8"));
161
+ } catch {
162
+ return [];
163
+ }
164
+ }
165
+
166
+ async function recordUploads(entries) {
167
+ if (!entries.length) return;
168
+ try {
169
+ const history = await readHistory();
170
+ await mkdir(path.dirname(HISTORY_FILE), { recursive: true });
171
+ await writeFile(
172
+ HISTORY_FILE,
173
+ JSON.stringify([...entries, ...history].slice(0, HISTORY_LIMIT), null, 2),
174
+ );
175
+ } catch {
176
+ /* the log is a convenience — never fail an upload over it */
177
+ }
178
+ }
179
+
180
+ /* ───────────────────────── upload ───────────────────────── */
181
+
182
+ /** POST raw bytes to the Worker and normalise its JSON reply. */
183
+ async function uploadBytes(bytes, fileName, options) {
184
+ if (!CONFIG.token) {
185
+ throw new ToolError(
186
+ "OMRIFY_TOKEN is not set for this MCP server. Add it to the server's env block and make it " +
187
+ "match MCP_UPLOAD_TOKEN on the Worker (.dev.vars locally, `wrangler secret put` in production).",
188
+ );
189
+ }
190
+
191
+ const form = new FormData();
192
+ const mime = MIME_BY_EXT[extname(fileName).toLowerCase()] || "application/octet-stream";
193
+ form.append("image", new Blob([bytes], { type: mime }), fileName);
194
+ form.append("name", options.name || fileName);
195
+ form.append("format", options.format ?? CONFIG.format);
196
+ form.append("quality", String(options.quality ?? CONFIG.quality));
197
+ form.append("maxDimension", String(options.maxDimension ?? CONFIG.maxDimension));
198
+
199
+ let res;
200
+ try {
201
+ res = await fetch(CONFIG.endpoint, {
202
+ method: "POST",
203
+ headers: { authorization: `Bearer ${CONFIG.token}` },
204
+ body: form,
205
+ });
206
+ } catch (error) {
207
+ throw new ToolError(
208
+ `Could not reach ${CONFIG.endpoint} (${error?.message ?? "network error"}). ` +
209
+ "Is the Omrify app running (`npm run dev`) or deployed at OMRIFY_ENDPOINT?",
210
+ );
211
+ }
212
+
213
+ const payload = await res.json().catch(() => null);
214
+ if (!res.ok || !payload?.ok) {
215
+ throw new ToolError(
216
+ `Upload failed (${res.status}): ${payload?.error ?? (await res.text().catch(() => ""))}`,
217
+ );
218
+ }
219
+ return payload;
220
+ }
221
+
222
+ /**
223
+ * Download a remote image, failing loudly on the things that silently produced a
224
+ * broken upload before: a non-image response body, an empty body, or a hang.
225
+ */
226
+ async function fetchImage(url) {
227
+ let res;
228
+ try {
229
+ res = await fetch(url, {
230
+ headers: SOURCE_FETCH_HEADERS,
231
+ redirect: "follow",
232
+ signal: AbortSignal.timeout(SOURCE_FETCH_TIMEOUT_MS),
233
+ });
234
+ } catch (error) {
235
+ if (error?.name === "TimeoutError" || error?.name === "AbortError") {
236
+ throw new ToolError(`${url} did not respond within ${SOURCE_FETCH_TIMEOUT_MS / 1000}s.`);
237
+ }
238
+ throw new ToolError(`Could not fetch ${url}: ${error?.message ?? "network error"}`);
239
+ }
240
+
241
+ if (!res.ok) {
242
+ throw new ToolError(
243
+ `Source responded ${res.status} for ${url}.` +
244
+ (res.status === 403 || res.status === 401
245
+ ? " The host is blocking direct downloads (hotlink protection) — save the image and use upload_image instead."
246
+ : ""),
247
+ );
248
+ }
249
+
250
+ const contentType = (res.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
251
+ // A share/gallery page returns HTML; uploading that would produce a corrupt "image".
252
+ if (contentType && !contentType.startsWith("image/")) {
253
+ throw new ToolError(
254
+ `${url} returned ${contentType}, not an image. That is usually a gallery or share page — ` +
255
+ "open it and copy the direct image URL (the one ending in .jpg/.png/.webp).",
256
+ );
257
+ }
258
+
259
+ const data = new Uint8Array(await res.arrayBuffer());
260
+ if (!data.length) throw new ToolError(`${url} returned an empty response body.`);
261
+ return { data, contentType };
262
+ }
263
+
264
+ /**
265
+ * Name for a downloaded image. The server's content type wins over the URL, because
266
+ * CDN URLs are routinely extensionless (`/photo-1493246507139?w=400`) or carry an
267
+ * extension that disagrees with the bytes actually served.
268
+ */
269
+ function remoteFileName(url, contentType) {
270
+ let fromPath = "";
271
+ try {
272
+ fromPath = decodeURIComponent(basename(new URL(url).pathname));
273
+ } catch {
274
+ /* undecodable path — fall through to the default stem */
275
+ }
276
+
277
+ const urlExt = extname(fromPath).toLowerCase();
278
+ const stem = (urlExt ? fromPath.slice(0, -urlExt.length) : fromPath) || "image";
279
+ const ext = EXT_BY_MIME[contentType] ?? (urlExt || ".png");
280
+ return `${stem}${ext}`;
281
+ }
282
+
283
+ async function uploadOne(sourcePath, options) {
284
+ const { path: full, stats } = await resolveExisting(sourcePath);
285
+ if (stats.isDirectory())
286
+ throw new ToolError(`${full} is a directory — use upload_images instead.`);
287
+
288
+ const result = await uploadBytes(await readFile(full), basename(full), options);
289
+ return { ...result, source: full };
290
+ }
291
+
292
+ /* ───────────────────────── rendering ───────────────────────── */
293
+
294
+ function describe(result) {
295
+ const dimensions = result.width && result.height ? ` ${result.width}×${result.height}` : "";
296
+ const note = result.converted ? "" : " [not re-encoded — Cloudflare Images unavailable]";
297
+ return (
298
+ `✔ ${basename(result.source ?? result.name)} → ${result.name}\n` +
299
+ ` ${formatBytes(result.originalSize)} → ${formatBytes(result.size)}${savings(result.originalSize, result.size)}${dimensions}${note}\n` +
300
+ ` ${result.url}`
301
+ );
302
+ }
303
+
304
+ function textResult(text) {
305
+ return { content: [{ type: "text", text }] };
306
+ }
307
+
308
+ function errorResult(error) {
309
+ return {
310
+ content: [{ type: "text", text: `✖ ${error?.message ?? String(error)}` }],
311
+ isError: true,
312
+ };
313
+ }
314
+
315
+ /** Run `worker` over `items` with a small concurrency cap, preserving input order. */
316
+ async function mapLimit(items, limit, worker) {
317
+ const results = new Array(items.length);
318
+ let cursor = 0;
319
+ await Promise.all(
320
+ Array.from({ length: Math.min(limit, items.length) }, async () => {
321
+ while (cursor < items.length) {
322
+ const index = cursor++;
323
+ try {
324
+ results[index] = { ok: true, value: await worker(items[index]) };
325
+ } catch (error) {
326
+ results[index] = { ok: false, error, item: items[index] };
327
+ }
328
+ }
329
+ }),
330
+ );
331
+ return results;
332
+ }
333
+
334
+ /* ───────────────────────── tools ───────────────────────── */
335
+
336
+ const optionShape = {
337
+ format: z
338
+ .enum(["webp", "png", "jpeg", "original"])
339
+ .optional()
340
+ .describe(`Output format. Default ${CONFIG.format}. "original" skips re-encoding.`),
341
+ quality: z
342
+ .number()
343
+ .int()
344
+ .min(1)
345
+ .max(100)
346
+ .optional()
347
+ .describe(`Lossy quality 1-100. Default ${CONFIG.quality}.`),
348
+ maxDimension: z
349
+ .number()
350
+ .int()
351
+ .min(0)
352
+ .max(10000)
353
+ .optional()
354
+ .describe(`Scale down so the longest edge fits this many px. 0 keeps the original size.`),
355
+ };
356
+
357
+ const server = new McpServer({ name: "omrify", version: "1.0.0" });
358
+
359
+ server.registerTool(
360
+ "upload_image",
361
+ {
362
+ title: "Upload an image and get a hosted URL",
363
+ description:
364
+ "Convert a local image to WebP (via the Omrify Cloudflare Worker) and upload it, returning a public URL " +
365
+ "ready to paste into code. Use this whenever an image on disk needs to become a link.",
366
+ inputSchema: {
367
+ path: z
368
+ .string()
369
+ .describe("Path to the image. Absolute, or relative to the current project directory."),
370
+ name: z.string().optional().describe("Override the uploaded file name."),
371
+ ...optionShape,
372
+ },
373
+ },
374
+ async ({ path, ...options }) => {
375
+ try {
376
+ const result = await uploadOne(path, options);
377
+ await recordUploads([{ ...result, at: new Date().toISOString() }]);
378
+ return textResult(describe(result));
379
+ } catch (error) {
380
+ return errorResult(error);
381
+ }
382
+ },
383
+ );
384
+
385
+ server.registerTool(
386
+ "upload_images",
387
+ {
388
+ title: "Upload several images at once",
389
+ description:
390
+ "Batch version of upload_image. Accepts any mix of file paths and directories (directories expand to the " +
391
+ "image files inside) and returns one hosted URL per image.",
392
+ inputSchema: {
393
+ paths: z.array(z.string()).min(1).describe("File and/or directory paths."),
394
+ recursive: z.boolean().optional().describe("Descend into subdirectories. Default false."),
395
+ ...optionShape,
396
+ },
397
+ },
398
+ async ({ paths, recursive = false, ...options }) => {
399
+ try {
400
+ const files = await collectImages(paths, recursive);
401
+ if (!files.length) return textResult("No image files found in the given paths.");
402
+
403
+ const outcomes = await mapLimit(files, 4, (file) => uploadOne(file, options));
404
+ const succeeded = outcomes.filter((o) => o.ok).map((o) => o.value);
405
+ await recordUploads(succeeded.map((r) => ({ ...r, at: new Date().toISOString() })));
406
+
407
+ const lines = outcomes.map((o) =>
408
+ o.ok ? describe(o.value) : `✖ ${basename(o.item)} — ${o.error?.message ?? "failed"}`,
409
+ );
410
+ const urls = succeeded.map((r) => `${r.name}: ${r.url}`).join("\n");
411
+
412
+ return textResult(
413
+ `${succeeded.length}/${files.length} uploaded.\n\n${lines.join("\n\n")}` +
414
+ (urls ? `\n\n--- URLs ---\n${urls}` : ""),
415
+ );
416
+ } catch (error) {
417
+ return errorResult(error);
418
+ }
419
+ },
420
+ );
421
+
422
+ server.registerTool(
423
+ "upload_from_url",
424
+ {
425
+ title: "Re-host an image from a URL",
426
+ description:
427
+ "Download an image from a remote URL, convert it, and re-upload it so it is served from a stable link " +
428
+ "you control rather than the original host.",
429
+ inputSchema: {
430
+ url: z.string().url().describe("Public URL of the source image."),
431
+ name: z.string().optional().describe("Override the uploaded file name."),
432
+ ...optionShape,
433
+ },
434
+ },
435
+ async ({ url, name, ...options }) => {
436
+ try {
437
+ const bytes = await fetchImage(url);
438
+ const result = {
439
+ ...(await uploadBytes(bytes.data, name || remoteFileName(url, bytes.contentType), options)),
440
+ source: url,
441
+ };
442
+ await recordUploads([{ ...result, at: new Date().toISOString() }]);
443
+ return textResult(describe(result));
444
+ } catch (error) {
445
+ return errorResult(error);
446
+ }
447
+ },
448
+ );
449
+
450
+ server.registerTool(
451
+ "recent_uploads",
452
+ {
453
+ title: "List previously uploaded images",
454
+ description:
455
+ "Look up links from earlier uploads instead of re-uploading the same file. Useful when you need a URL " +
456
+ "again later in a session or in a different project.",
457
+ inputSchema: {
458
+ limit: z
459
+ .number()
460
+ .int()
461
+ .min(1)
462
+ .max(100)
463
+ .optional()
464
+ .describe("How many entries to return. Default 20."),
465
+ search: z
466
+ .string()
467
+ .optional()
468
+ .describe("Case-insensitive filter on file name or source path."),
469
+ },
470
+ },
471
+ async ({ limit = 20, search }) => {
472
+ const history = await readHistory();
473
+ const needle = search?.toLowerCase();
474
+ const matches = (
475
+ needle
476
+ ? history.filter((e) => `${e.name} ${e.source ?? ""}`.toLowerCase().includes(needle))
477
+ : history
478
+ ).slice(0, limit);
479
+
480
+ if (!matches.length)
481
+ return textResult(search ? `No uploads matching "${search}".` : "No uploads recorded yet.");
482
+ return textResult(matches.map((e) => `${e.name} (${e.at})\n ${e.url}`).join("\n\n"));
483
+ },
484
+ );
485
+
486
+ /* ───────────────────────── start ───────────────────────── */
487
+
488
+ await server.connect(new StdioServerTransport());