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.
- package/README.md +181 -0
- package/package.json +40 -0
- 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());
|