mcp-vision-web-bridge 0.2.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/.env.example +18 -0
- package/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/README.md +201 -0
- package/SECURITY_REVIEW.md +37 -0
- package/package.json +32 -0
- package/scripts/check-secrets.mjs +73 -0
- package/src/model-client.mjs +757 -0
- package/src/server.mjs +201 -0
- package/src/web-reader.mjs +371 -0
|
@@ -0,0 +1,757 @@
|
|
|
1
|
+
import { execFile as execFileCallback } from 'node:child_process';
|
|
2
|
+
import { readFile, mkdtemp, readdir, rm, stat } from 'node:fs/promises';
|
|
3
|
+
import { isIP } from 'node:net';
|
|
4
|
+
import { homedir, platform, tmpdir } from 'node:os';
|
|
5
|
+
import { extname, join, resolve } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
8
|
+
|
|
9
|
+
const execFile = promisify(execFileCallback);
|
|
10
|
+
|
|
11
|
+
const DEFAULT_SYSTEM_PROMPT = [
|
|
12
|
+
'You are a helpful assistant.',
|
|
13
|
+
'Answer clearly and avoid exposing private data unless the user explicitly included it.'
|
|
14
|
+
].join('\n');
|
|
15
|
+
|
|
16
|
+
const DEFAULT_IMAGE_PROMPT = 'Please describe this image. If it contains text, extract the text.';
|
|
17
|
+
const DEFAULT_MODEL = process.env.MODEL_NAME || 'replace-with-your-vision-model';
|
|
18
|
+
const DEFAULT_BASE_URL = process.env.MODEL_BASE_URL || process.env.OPENAI_BASE_URL || 'https://api.example.com/v1';
|
|
19
|
+
const DEFAULT_MAX_IMAGE_BYTES = Number.parseInt(process.env.MAX_IMAGE_BYTES || '10485760', 10);
|
|
20
|
+
|
|
21
|
+
const MIME_BY_EXTENSION = new Map([
|
|
22
|
+
['.png', 'image/png'],
|
|
23
|
+
['.jpg', 'image/jpeg'],
|
|
24
|
+
['.jpeg', 'image/jpeg'],
|
|
25
|
+
['.gif', 'image/gif'],
|
|
26
|
+
['.webp', 'image/webp'],
|
|
27
|
+
['.tif', 'image/tiff'],
|
|
28
|
+
['.tiff', 'image/tiff']
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
const MACOS_CLIPBOARD_IMAGE_SCRIPT = `
|
|
32
|
+
ObjC.import("AppKit");
|
|
33
|
+
ObjC.import("Foundation");
|
|
34
|
+
|
|
35
|
+
const out = ObjC.unwrap($.NSProcessInfo.processInfo.environment.objectForKey("OUT"));
|
|
36
|
+
const pb = $.NSPasteboard.generalPasteboard;
|
|
37
|
+
const types = ObjC.deepUnwrap(pb.types) || [];
|
|
38
|
+
const candidates = ["public.png", "public.jpeg", "public.tiff", "com.compuserve.gif"];
|
|
39
|
+
const chosen = candidates.find((type) => types.includes(type));
|
|
40
|
+
|
|
41
|
+
if (!chosen) {
|
|
42
|
+
throw new Error("No image found in clipboard");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const data = pb.dataForType(chosen);
|
|
46
|
+
if (!data) {
|
|
47
|
+
throw new Error("Clipboard image data is empty");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!data.writeToFileAtomically(out, true)) {
|
|
51
|
+
throw new Error("Failed to write clipboard image");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
console.log(chosen);
|
|
55
|
+
`;
|
|
56
|
+
|
|
57
|
+
const WINDOWS_CLIPBOARD_IMAGE_SCRIPT = `
|
|
58
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
59
|
+
Add-Type -AssemblyName System.Drawing
|
|
60
|
+
$image = [System.Windows.Forms.Clipboard]::GetImage()
|
|
61
|
+
if ($null -eq $image) {
|
|
62
|
+
throw "No image found in clipboard"
|
|
63
|
+
}
|
|
64
|
+
$image.Save($env:OUT, [System.Drawing.Imaging.ImageFormat]::Png)
|
|
65
|
+
`;
|
|
66
|
+
|
|
67
|
+
export function buildChatMessages({ prompt, system = DEFAULT_SYSTEM_PROMPT }) {
|
|
68
|
+
if (!prompt || typeof prompt !== 'string') {
|
|
69
|
+
throw new Error('prompt is required');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const messages = [];
|
|
73
|
+
if (system) {
|
|
74
|
+
messages.push({
|
|
75
|
+
role: 'system',
|
|
76
|
+
content: system
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
messages.push({
|
|
80
|
+
role: 'user',
|
|
81
|
+
content: prompt
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
return messages;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function buildChatRequest({
|
|
88
|
+
messages,
|
|
89
|
+
model = DEFAULT_MODEL,
|
|
90
|
+
maxTokens = 8192,
|
|
91
|
+
temperature
|
|
92
|
+
}) {
|
|
93
|
+
const request = {
|
|
94
|
+
model,
|
|
95
|
+
messages,
|
|
96
|
+
max_tokens: maxTokens,
|
|
97
|
+
stream: false
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
if (typeof temperature === 'number') {
|
|
101
|
+
request.temperature = temperature;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return request;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function askModel({
|
|
108
|
+
prompt,
|
|
109
|
+
system,
|
|
110
|
+
model = DEFAULT_MODEL,
|
|
111
|
+
baseUrl = DEFAULT_BASE_URL,
|
|
112
|
+
apiKey = process.env.MODEL_API_KEY,
|
|
113
|
+
maxTokens = 8192,
|
|
114
|
+
temperature,
|
|
115
|
+
fetchImpl = fetch
|
|
116
|
+
}) {
|
|
117
|
+
if (!apiKey) {
|
|
118
|
+
throw new Error('Missing MODEL_API_KEY');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const url = new URL('chat/completions', normalizeBaseUrl(baseUrl));
|
|
122
|
+
const request = buildChatRequest({
|
|
123
|
+
messages: buildChatMessages({
|
|
124
|
+
prompt,
|
|
125
|
+
system
|
|
126
|
+
}),
|
|
127
|
+
model,
|
|
128
|
+
maxTokens,
|
|
129
|
+
temperature
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const response = await fetchImpl(url, {
|
|
133
|
+
method: 'POST',
|
|
134
|
+
headers: {
|
|
135
|
+
authorization: `Bearer ${apiKey}`,
|
|
136
|
+
'content-type': 'application/json'
|
|
137
|
+
},
|
|
138
|
+
body: JSON.stringify(request)
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const payload = await safeJson(response);
|
|
142
|
+
if (!response.ok) {
|
|
143
|
+
throw new Error(formatModelError(response, payload, 'Model request'));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return extractChatText(payload);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function askModelWithImage({
|
|
150
|
+
prompt = DEFAULT_IMAGE_PROMPT,
|
|
151
|
+
system,
|
|
152
|
+
imagePath,
|
|
153
|
+
imageUrl,
|
|
154
|
+
imageBase64,
|
|
155
|
+
mimeType = 'image/png',
|
|
156
|
+
fromClipboard = false,
|
|
157
|
+
latestClaudeUpload = false,
|
|
158
|
+
maxAgeMinutes = 240,
|
|
159
|
+
model = DEFAULT_MODEL,
|
|
160
|
+
baseUrl = DEFAULT_BASE_URL,
|
|
161
|
+
apiKey = process.env.MODEL_API_KEY,
|
|
162
|
+
maxTokens = 8192,
|
|
163
|
+
temperature,
|
|
164
|
+
fetchImpl = fetch
|
|
165
|
+
}) {
|
|
166
|
+
if (!apiKey) {
|
|
167
|
+
throw new Error('Missing MODEL_API_KEY');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const imageInput = await resolveImageInput({
|
|
171
|
+
imagePath,
|
|
172
|
+
imageUrl,
|
|
173
|
+
imageBase64,
|
|
174
|
+
mimeType,
|
|
175
|
+
fromClipboard,
|
|
176
|
+
latestClaudeUpload,
|
|
177
|
+
maxAgeMinutes
|
|
178
|
+
});
|
|
179
|
+
const messages = [];
|
|
180
|
+
|
|
181
|
+
if (system) {
|
|
182
|
+
messages.push({
|
|
183
|
+
role: 'system',
|
|
184
|
+
content: system
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
messages.push({
|
|
189
|
+
role: 'user',
|
|
190
|
+
content: [
|
|
191
|
+
{
|
|
192
|
+
type: 'text',
|
|
193
|
+
text: prompt
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
type: 'image_url',
|
|
197
|
+
image_url: {
|
|
198
|
+
url: imageInput.url
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
]
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const url = new URL('chat/completions', normalizeBaseUrl(baseUrl));
|
|
205
|
+
const response = await fetchImpl(url, {
|
|
206
|
+
method: 'POST',
|
|
207
|
+
headers: {
|
|
208
|
+
authorization: `Bearer ${apiKey}`,
|
|
209
|
+
'content-type': 'application/json'
|
|
210
|
+
},
|
|
211
|
+
body: JSON.stringify(buildChatRequest({
|
|
212
|
+
messages,
|
|
213
|
+
model,
|
|
214
|
+
maxTokens,
|
|
215
|
+
temperature
|
|
216
|
+
}))
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const payload = await safeJson(response);
|
|
220
|
+
if (!response.ok) {
|
|
221
|
+
throw new Error(formatModelError(response, payload, 'Vision request'));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
text: extractChatText(payload),
|
|
226
|
+
source: imageInput.source
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export async function buildImageUrl({
|
|
231
|
+
imagePath,
|
|
232
|
+
imageUrl,
|
|
233
|
+
imageBase64,
|
|
234
|
+
mimeType = 'image/png',
|
|
235
|
+
fromClipboard = false,
|
|
236
|
+
latestClaudeUpload = false,
|
|
237
|
+
maxAgeMinutes = 240
|
|
238
|
+
}) {
|
|
239
|
+
const imageInput = await resolveImageInput({
|
|
240
|
+
imagePath,
|
|
241
|
+
imageUrl,
|
|
242
|
+
imageBase64,
|
|
243
|
+
mimeType,
|
|
244
|
+
fromClipboard,
|
|
245
|
+
latestClaudeUpload,
|
|
246
|
+
maxAgeMinutes
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
return imageInput.url;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export async function resolveImageInput({
|
|
253
|
+
imagePath,
|
|
254
|
+
imageUrl,
|
|
255
|
+
imageBase64,
|
|
256
|
+
mimeType = 'image/png',
|
|
257
|
+
fromClipboard = false,
|
|
258
|
+
latestClaudeUpload = false,
|
|
259
|
+
maxAgeMinutes = 240,
|
|
260
|
+
allowPrivateNetworkUrls = process.env.ALLOW_PRIVATE_NETWORK_URLS === 'true'
|
|
261
|
+
}) {
|
|
262
|
+
const provided = [
|
|
263
|
+
imagePath,
|
|
264
|
+
imageUrl,
|
|
265
|
+
imageBase64,
|
|
266
|
+
fromClipboard ? 'clipboard' : '',
|
|
267
|
+
latestClaudeUpload ? 'latestClaudeUpload' : ''
|
|
268
|
+
].filter(Boolean).length;
|
|
269
|
+
|
|
270
|
+
if (provided !== 1) {
|
|
271
|
+
throw new Error('Provide exactly one image source');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (imageUrl) {
|
|
275
|
+
validateImageUrl(imageUrl, {
|
|
276
|
+
allowPrivateNetworkUrls
|
|
277
|
+
});
|
|
278
|
+
return {
|
|
279
|
+
url: imageUrl,
|
|
280
|
+
source: {
|
|
281
|
+
type: imageUrl.startsWith('data:') ? 'data_url' : 'image_url'
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (imageBase64) {
|
|
287
|
+
return {
|
|
288
|
+
url: normalizeBase64Image(imageBase64, mimeType),
|
|
289
|
+
source: {
|
|
290
|
+
type: 'base64',
|
|
291
|
+
mimeType
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (fromClipboard) {
|
|
297
|
+
assertEnabled('ALLOW_CLIPBOARD_IMAGES', 'Clipboard image reading is disabled by default');
|
|
298
|
+
return {
|
|
299
|
+
url: await readClipboardImageAsDataUrl(),
|
|
300
|
+
source: {
|
|
301
|
+
type: 'clipboard'
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (latestClaudeUpload) {
|
|
307
|
+
const latest = await findLatestClaudeUpload({
|
|
308
|
+
maxAgeMinutes
|
|
309
|
+
});
|
|
310
|
+
return {
|
|
311
|
+
url: await readLocalImageAsDataUrl(latest.path),
|
|
312
|
+
source: {
|
|
313
|
+
type: 'latest_upload',
|
|
314
|
+
mimeType: latest.mimeType,
|
|
315
|
+
size: latest.size
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
assertEnabled('ALLOW_LOCAL_IMAGE_PATHS', 'Local image path reading is disabled by default');
|
|
321
|
+
return {
|
|
322
|
+
url: await readLocalImageAsDataUrl(imagePath),
|
|
323
|
+
source: {
|
|
324
|
+
type: 'local_path'
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export async function findLatestClaudeUpload({
|
|
330
|
+
uploadDirs = parseClaudeUploadDirs(),
|
|
331
|
+
maxAgeMinutes = 240,
|
|
332
|
+
now = Date.now()
|
|
333
|
+
} = {}) {
|
|
334
|
+
const maxAgeMs = maxAgeMinutes * 60 * 1000;
|
|
335
|
+
const candidates = [];
|
|
336
|
+
|
|
337
|
+
for (const dir of uploadDirs) {
|
|
338
|
+
const resolvedDir = resolveLocalPath(dir);
|
|
339
|
+
let entries;
|
|
340
|
+
|
|
341
|
+
try {
|
|
342
|
+
entries = await readdir(resolvedDir, {
|
|
343
|
+
withFileTypes: true
|
|
344
|
+
});
|
|
345
|
+
} catch (error) {
|
|
346
|
+
if (error?.code === 'ENOENT') continue;
|
|
347
|
+
throw error;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
for (const entry of entries) {
|
|
351
|
+
if (!entry.isFile()) continue;
|
|
352
|
+
|
|
353
|
+
const path = join(resolvedDir, entry.name);
|
|
354
|
+
const info = await stat(path);
|
|
355
|
+
const ageMs = now - info.mtimeMs;
|
|
356
|
+
|
|
357
|
+
if (ageMs < 0 || ageMs > maxAgeMs) continue;
|
|
358
|
+
if (info.size > getMaxImageBytes()) continue;
|
|
359
|
+
|
|
360
|
+
const mimeType = await detectImageMimeTypeFromFile(path);
|
|
361
|
+
if (!mimeType?.startsWith('image/')) continue;
|
|
362
|
+
|
|
363
|
+
candidates.push({
|
|
364
|
+
path,
|
|
365
|
+
mimeType,
|
|
366
|
+
mtimeMs: info.mtimeMs,
|
|
367
|
+
size: info.size
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
373
|
+
|
|
374
|
+
if (!candidates.length) {
|
|
375
|
+
throw new Error('No recent uploaded image found');
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return candidates[0];
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function getDefaultClaudeUploadDirs(osPlatform = platform()) {
|
|
382
|
+
if (osPlatform === 'darwin') {
|
|
383
|
+
return [
|
|
384
|
+
'~/Library/Application Support/Claude-3p/pending-uploads',
|
|
385
|
+
'~/Library/Application Support/Claude/pending-uploads'
|
|
386
|
+
];
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (osPlatform === 'win32') {
|
|
390
|
+
return [
|
|
391
|
+
'%APPDATA%\\Claude-3p\\pending-uploads',
|
|
392
|
+
'%APPDATA%\\Claude\\pending-uploads',
|
|
393
|
+
'%LOCALAPPDATA%\\Claude-3p\\pending-uploads',
|
|
394
|
+
'%LOCALAPPDATA%\\Claude\\pending-uploads'
|
|
395
|
+
];
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return [
|
|
399
|
+
'~/.config/Claude-3p/pending-uploads',
|
|
400
|
+
'~/.config/Claude/pending-uploads',
|
|
401
|
+
'~/.local/share/Claude-3p/pending-uploads',
|
|
402
|
+
'~/.local/share/Claude/pending-uploads'
|
|
403
|
+
];
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export function extractChatText(payload) {
|
|
407
|
+
const content = payload?.choices?.[0]?.message?.content;
|
|
408
|
+
|
|
409
|
+
if (typeof content === 'string') {
|
|
410
|
+
return content;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (Array.isArray(content)) {
|
|
414
|
+
return content
|
|
415
|
+
.map((part) => {
|
|
416
|
+
if (typeof part === 'string') return part;
|
|
417
|
+
if (part?.type === 'text' && typeof part.text === 'string') return part.text;
|
|
418
|
+
return '';
|
|
419
|
+
})
|
|
420
|
+
.filter(Boolean)
|
|
421
|
+
.join('\n');
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const errorMessage = payload?.error?.message;
|
|
425
|
+
if (errorMessage) {
|
|
426
|
+
throw new Error(errorMessage);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
throw new Error('No text returned from model');
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function safeJson(response) {
|
|
433
|
+
try {
|
|
434
|
+
return await response.json();
|
|
435
|
+
} catch {
|
|
436
|
+
return {};
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function formatModelError(response, payload, label) {
|
|
441
|
+
const providerMessage = sanitizeErrorMessage(payload?.error?.message || payload?.message || '');
|
|
442
|
+
const statusHint = getStatusHint(response.status);
|
|
443
|
+
const details = [statusHint, providerMessage].filter(Boolean).join(' ');
|
|
444
|
+
|
|
445
|
+
return details ? `${label} failed with ${response.status}. ${details}` : `${label} failed with ${response.status}`;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function getStatusHint(status) {
|
|
449
|
+
if (status === 400) return 'Check whether the selected model supports vision and whether the image is a valid supported format.';
|
|
450
|
+
if (status === 401 || status === 403) return 'Check MODEL_API_KEY and provider permissions.';
|
|
451
|
+
if (status === 404) return 'Check MODEL_BASE_URL and MODEL_NAME.';
|
|
452
|
+
if (status === 413) return 'The image or request is too large.';
|
|
453
|
+
if (status === 429) return 'The provider rate limit or quota was reached.';
|
|
454
|
+
if (status >= 500) return 'The provider endpoint returned a server error.';
|
|
455
|
+
return '';
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function sanitizeErrorMessage(value) {
|
|
459
|
+
if (!value || typeof value !== 'string') {
|
|
460
|
+
return '';
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
let sanitized = value;
|
|
464
|
+
for (const secret of [process.env.MODEL_API_KEY, process.env.OPENAI_API_KEY]) {
|
|
465
|
+
if (secret) {
|
|
466
|
+
sanitized = sanitized.split(secret).join('[redacted]');
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return sanitized.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [redacted]');
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function normalizeBase64Image(value, mimeType) {
|
|
473
|
+
const trimmed = value.trim();
|
|
474
|
+
if (trimmed.startsWith('data:')) {
|
|
475
|
+
return trimmed;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return `data:${mimeType};base64,${trimmed.replace(/\s/g, '')}`;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async function readLocalImageAsDataUrl(imagePath) {
|
|
482
|
+
const resolvedPath = resolveLocalPath(imagePath);
|
|
483
|
+
const info = await stat(resolvedPath);
|
|
484
|
+
if (!info.isFile()) {
|
|
485
|
+
throw new Error('Image path must be a file');
|
|
486
|
+
}
|
|
487
|
+
if (info.size > getMaxImageBytes()) {
|
|
488
|
+
throw new Error(`Image exceeds MAX_IMAGE_BYTES (${getMaxImageBytes()})`);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const data = await readFile(resolvedPath);
|
|
492
|
+
const mimeType = detectImageMimeType(data) || MIME_BY_EXTENSION.get(extname(resolvedPath).toLowerCase());
|
|
493
|
+
|
|
494
|
+
if (!mimeType) {
|
|
495
|
+
throw new Error('Unsupported image type. Use PNG, JPEG, GIF, WebP, or TIFF.');
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return `data:${mimeType};base64,${data.toString('base64')}`;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async function detectImageMimeTypeFromFile(path) {
|
|
502
|
+
const data = await readFile(path);
|
|
503
|
+
return detectImageMimeType(data) || MIME_BY_EXTENSION.get(extname(path).toLowerCase());
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function detectImageMimeType(data) {
|
|
507
|
+
if (data.length >= 8 && data.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
|
|
508
|
+
return 'image/png';
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) {
|
|
512
|
+
return 'image/jpeg';
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (data.length >= 6) {
|
|
516
|
+
const signature = data.subarray(0, 6).toString('ascii');
|
|
517
|
+
if (signature === 'GIF87a' || signature === 'GIF89a') {
|
|
518
|
+
return 'image/gif';
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (
|
|
523
|
+
data.length >= 12 &&
|
|
524
|
+
data.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
|
525
|
+
data.subarray(8, 12).toString('ascii') === 'WEBP'
|
|
526
|
+
) {
|
|
527
|
+
return 'image/webp';
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (data.length >= 4) {
|
|
531
|
+
const header = data.subarray(0, 4).toString('hex');
|
|
532
|
+
if (header === '49492a00' || header === '4d4d002a') {
|
|
533
|
+
return 'image/tiff';
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
return undefined;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
async function readClipboardImageAsDataUrl() {
|
|
541
|
+
const osPlatform = platform();
|
|
542
|
+
|
|
543
|
+
if (osPlatform === 'darwin') {
|
|
544
|
+
return readMacosClipboardImageAsDataUrl();
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (osPlatform === 'win32') {
|
|
548
|
+
return readWindowsClipboardImageAsDataUrl();
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
return readLinuxClipboardImageAsDataUrl();
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
async function readMacosClipboardImageAsDataUrl() {
|
|
555
|
+
const dir = await mkdtemp(join(tmpdir(), 'mcp-clipboard-image-'));
|
|
556
|
+
const rawPath = join(dir, 'clipboard-image');
|
|
557
|
+
const pngPath = join(dir, 'clipboard-image.png');
|
|
558
|
+
|
|
559
|
+
try {
|
|
560
|
+
const { stdout } = await execFile(
|
|
561
|
+
'osascript',
|
|
562
|
+
['-l', 'JavaScript', '-e', MACOS_CLIPBOARD_IMAGE_SCRIPT],
|
|
563
|
+
{
|
|
564
|
+
env: {
|
|
565
|
+
...process.env,
|
|
566
|
+
OUT: rawPath
|
|
567
|
+
},
|
|
568
|
+
timeout: 10000
|
|
569
|
+
}
|
|
570
|
+
);
|
|
571
|
+
const pasteboardType = stdout.trim().split(/\s+/).at(-1);
|
|
572
|
+
|
|
573
|
+
if (pasteboardType === 'public.png') {
|
|
574
|
+
const data = await readFile(rawPath);
|
|
575
|
+
return `data:image/png;base64,${data.toString('base64')}`;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (pasteboardType === 'public.jpeg') {
|
|
579
|
+
const data = await readFile(rawPath);
|
|
580
|
+
return `data:image/jpeg;base64,${data.toString('base64')}`;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (pasteboardType === 'com.compuserve.gif') {
|
|
584
|
+
const data = await readFile(rawPath);
|
|
585
|
+
return `data:image/gif;base64,${data.toString('base64')}`;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
await execFile('sips', ['-s', 'format', 'png', rawPath, '--out', pngPath], {
|
|
589
|
+
timeout: 10000
|
|
590
|
+
});
|
|
591
|
+
const data = await readFile(pngPath);
|
|
592
|
+
return `data:image/png;base64,${data.toString('base64')}`;
|
|
593
|
+
} finally {
|
|
594
|
+
await rm(dir, {
|
|
595
|
+
recursive: true,
|
|
596
|
+
force: true
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
async function readWindowsClipboardImageAsDataUrl() {
|
|
602
|
+
const dir = await mkdtemp(join(tmpdir(), 'mcp-clipboard-image-'));
|
|
603
|
+
const pngPath = join(dir, 'clipboard-image.png');
|
|
604
|
+
|
|
605
|
+
try {
|
|
606
|
+
await execFile(
|
|
607
|
+
'powershell.exe',
|
|
608
|
+
['-NoProfile', '-STA', '-Command', WINDOWS_CLIPBOARD_IMAGE_SCRIPT],
|
|
609
|
+
{
|
|
610
|
+
env: {
|
|
611
|
+
...process.env,
|
|
612
|
+
OUT: pngPath
|
|
613
|
+
},
|
|
614
|
+
timeout: 10000
|
|
615
|
+
}
|
|
616
|
+
);
|
|
617
|
+
const data = await readFile(pngPath);
|
|
618
|
+
return `data:image/png;base64,${data.toString('base64')}`;
|
|
619
|
+
} finally {
|
|
620
|
+
await rm(dir, {
|
|
621
|
+
recursive: true,
|
|
622
|
+
force: true
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async function readLinuxClipboardImageAsDataUrl() {
|
|
628
|
+
const wayland = await tryReadLinuxClipboardCommand('wl-paste', ['--type', 'image/png']);
|
|
629
|
+
if (wayland) {
|
|
630
|
+
return wayland;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const x11 = await tryReadLinuxClipboardCommand('xclip', ['-selection', 'clipboard', '-t', 'image/png', '-o']);
|
|
634
|
+
if (x11) {
|
|
635
|
+
return x11;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
throw new Error('No clipboard image found. On Linux, install wl-clipboard or xclip and copy a PNG-compatible image.');
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
async function tryReadLinuxClipboardCommand(command, args) {
|
|
642
|
+
try {
|
|
643
|
+
const { stdout } = await execFile(command, args, {
|
|
644
|
+
encoding: 'buffer',
|
|
645
|
+
maxBuffer: getMaxImageBytes(),
|
|
646
|
+
timeout: 10000
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
if (!Buffer.isBuffer(stdout) || stdout.length === 0) {
|
|
650
|
+
return undefined;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (stdout.length > getMaxImageBytes()) {
|
|
654
|
+
throw new Error(`Clipboard image exceeds MAX_IMAGE_BYTES (${getMaxImageBytes()})`);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
return `data:image/png;base64,${stdout.toString('base64')}`;
|
|
658
|
+
} catch {
|
|
659
|
+
return undefined;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function normalizeBaseUrl(value) {
|
|
664
|
+
const url = new URL(value);
|
|
665
|
+
if (!url.pathname.endsWith('/')) {
|
|
666
|
+
url.pathname += '/';
|
|
667
|
+
}
|
|
668
|
+
return url.href;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function resolveLocalPath(value) {
|
|
672
|
+
if (!value || typeof value !== 'string') {
|
|
673
|
+
throw new Error('imagePath is required');
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const expandedValue = expandEnvironmentVariables(value);
|
|
677
|
+
|
|
678
|
+
if (expandedValue.startsWith('file://')) {
|
|
679
|
+
return fileURLToPath(expandedValue);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
if (expandedValue === '~') {
|
|
683
|
+
return homedir();
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
if (expandedValue.startsWith('~/')) {
|
|
687
|
+
return resolve(join(homedir(), expandedValue.slice(2)));
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
return resolve(expandedValue);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function parseClaudeUploadDirs() {
|
|
694
|
+
const configured = process.env.CLAUDE_UPLOAD_DIRS;
|
|
695
|
+
if (!configured) {
|
|
696
|
+
return getDefaultClaudeUploadDirs();
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const delimiter = process.env.CLAUDE_UPLOAD_DIRS_DELIMITER || (platform() === 'win32' ? ';' : ':');
|
|
700
|
+
|
|
701
|
+
return configured
|
|
702
|
+
.split(delimiter)
|
|
703
|
+
.map((value) => value.trim())
|
|
704
|
+
.filter(Boolean);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function expandEnvironmentVariables(value) {
|
|
708
|
+
return value
|
|
709
|
+
.replace(/%([^%]+)%/g, (match, name) => process.env[name] || match)
|
|
710
|
+
.replace(/\$([A-Z_][A-Z0-9_]*)/gi, (match, name) => process.env[name] || match);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function assertEnabled(name, message) {
|
|
714
|
+
if (process.env[name] !== 'true') {
|
|
715
|
+
throw new Error(`${message}. Set ${name}=true to enable it.`);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function validateImageUrl(value, { allowPrivateNetworkUrls = false } = {}) {
|
|
720
|
+
const url = new URL(value);
|
|
721
|
+
if (!['http:', 'https:', 'data:'].includes(url.protocol)) {
|
|
722
|
+
throw new Error('image_url must be http, https, or data URL');
|
|
723
|
+
}
|
|
724
|
+
if (url.protocol !== 'data:' && !allowPrivateNetworkUrls && isPrivateHostname(url.hostname)) {
|
|
725
|
+
throw new Error('Private network image URLs are disabled by default');
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function getMaxImageBytes() {
|
|
730
|
+
if (!Number.isInteger(DEFAULT_MAX_IMAGE_BYTES) || DEFAULT_MAX_IMAGE_BYTES <= 0) {
|
|
731
|
+
return 10 * 1024 * 1024;
|
|
732
|
+
}
|
|
733
|
+
return DEFAULT_MAX_IMAGE_BYTES;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function isPrivateHostname(hostname) {
|
|
737
|
+
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
738
|
+
if (normalized === 'localhost' || normalized.endsWith('.localhost')) return true;
|
|
739
|
+
|
|
740
|
+
const ipVersion = isIP(normalized);
|
|
741
|
+
if (!ipVersion) return false;
|
|
742
|
+
|
|
743
|
+
if (ipVersion === 6) {
|
|
744
|
+
return normalized === '::1' || normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('fe80:');
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const parts = normalized.split('.').map((part) => Number.parseInt(part, 10));
|
|
748
|
+
const [a, b] = parts;
|
|
749
|
+
return (
|
|
750
|
+
a === 10 ||
|
|
751
|
+
a === 127 ||
|
|
752
|
+
(a === 169 && b === 254) ||
|
|
753
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
754
|
+
(a === 192 && b === 168) ||
|
|
755
|
+
a === 0
|
|
756
|
+
);
|
|
757
|
+
}
|