@jgardner04/ghost-mcp-server 1.13.5 → 1.14.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/package.json +1 -1
- package/src/__tests__/mcp_server.test.js +83 -0
- package/src/controllers/__tests__/imageController.test.js +2 -2
- package/src/controllers/imageController.js +11 -10
- package/src/mcp_server.js +261 -67
- package/src/routes/__tests__/imageRoutes.test.js +2 -2
- package/src/schemas/__tests__/common.test.js +3 -3
- package/src/schemas/__tests__/pageSchemas.test.js +11 -2
- package/src/schemas/common.js +3 -2
- package/src/schemas/pageSchemas.js +1 -1
- package/src/schemas/postSchemas.js +1 -1
- package/src/services/__tests__/ghostService.test.js +0 -19
- package/src/services/__tests__/imageProcessingService.test.js +148 -177
- package/src/services/__tests__/images.test.js +78 -0
- package/src/services/ghostService.js +1 -19
- package/src/services/imageProcessingService.js +100 -56
- package/src/services/images.js +34 -7
- package/src/services/pageService.js +2 -2
- package/src/utils/__tests__/imageInputResolver.test.js +134 -0
- package/src/utils/imageInputResolver.js +127 -0
package/package.json
CHANGED
|
@@ -1712,3 +1712,86 @@ describe('tool schema JSON Schema output', () => {
|
|
|
1712
1712
|
}
|
|
1713
1713
|
});
|
|
1714
1714
|
});
|
|
1715
|
+
|
|
1716
|
+
// Regression tests for PR B — download size cap enforcement in ghost_upload_image.
|
|
1717
|
+
// axios does NOT enforce maxContentLength for responseType: 'stream', so the tool
|
|
1718
|
+
// must cap bytes itself via Content-Length pre-check and mid-stream byte tracking.
|
|
1719
|
+
describe('mcp_server - ghost_upload_image download size cap', () => {
|
|
1720
|
+
const CAP = 50 * 1024 * 1024;
|
|
1721
|
+
let handler;
|
|
1722
|
+
|
|
1723
|
+
beforeAll(async () => {
|
|
1724
|
+
if (mockTools.size === 0) await import('../mcp_server.js');
|
|
1725
|
+
handler = mockTools.get('ghost_upload_image').handler;
|
|
1726
|
+
});
|
|
1727
|
+
|
|
1728
|
+
beforeEach(() => {
|
|
1729
|
+
vi.clearAllMocks();
|
|
1730
|
+
mockValidateImageUrl.mockReturnValue({
|
|
1731
|
+
isValid: true,
|
|
1732
|
+
sanitizedUrl: 'https://imgur.com/x.jpg',
|
|
1733
|
+
});
|
|
1734
|
+
mockCreateSecureAxiosConfig.mockReturnValue({ url: 'https://imgur.com/x.jpg' });
|
|
1735
|
+
});
|
|
1736
|
+
|
|
1737
|
+
it('rejects up front when Content-Length header exceeds the cap', async () => {
|
|
1738
|
+
const destroy = vi.fn();
|
|
1739
|
+
mockAxios.mockResolvedValue({
|
|
1740
|
+
headers: { 'content-length': String(CAP + 1) },
|
|
1741
|
+
data: { destroy, on: vi.fn(), pipe: vi.fn() },
|
|
1742
|
+
});
|
|
1743
|
+
|
|
1744
|
+
const result = await handler({ imageUrl: 'https://imgur.com/huge.jpg' });
|
|
1745
|
+
|
|
1746
|
+
expect(result.isError).toBe(true);
|
|
1747
|
+
expect(result.content[0].text).toMatch(/exceeds .* byte limit/);
|
|
1748
|
+
expect(destroy).toHaveBeenCalled();
|
|
1749
|
+
});
|
|
1750
|
+
|
|
1751
|
+
it('aborts mid-stream when bytes exceed the cap', async () => {
|
|
1752
|
+
// Build a controllable fake stream that the tool's `.on('data', ...)` hook
|
|
1753
|
+
// can drive past the cap without allocating 50MB.
|
|
1754
|
+
const listeners = {};
|
|
1755
|
+
const destroyCalls = [];
|
|
1756
|
+
const dataStream = {
|
|
1757
|
+
on: (event, cb) => {
|
|
1758
|
+
(listeners[event] ||= []).push(cb);
|
|
1759
|
+
return dataStream;
|
|
1760
|
+
},
|
|
1761
|
+
pipe: vi.fn(),
|
|
1762
|
+
destroy: (err) => {
|
|
1763
|
+
destroyCalls.push(err);
|
|
1764
|
+
(listeners.error || []).forEach((cb) => cb(err));
|
|
1765
|
+
},
|
|
1766
|
+
};
|
|
1767
|
+
const writer = {
|
|
1768
|
+
on: (event, cb) => {
|
|
1769
|
+
// Resolve the finish/error promise via the writer path.
|
|
1770
|
+
if (event === 'error' && destroyCalls.length > 0) {
|
|
1771
|
+
cb(destroyCalls[0]);
|
|
1772
|
+
}
|
|
1773
|
+
return writer;
|
|
1774
|
+
},
|
|
1775
|
+
};
|
|
1776
|
+
mockCreateWriteStream.mockReturnValue(writer);
|
|
1777
|
+
mockAxios.mockResolvedValue({
|
|
1778
|
+
headers: {},
|
|
1779
|
+
data: dataStream,
|
|
1780
|
+
});
|
|
1781
|
+
|
|
1782
|
+
// Kick off the handler, then simulate a single >cap chunk landing.
|
|
1783
|
+
const promise = handler({ imageUrl: 'https://imgur.com/x.jpg' });
|
|
1784
|
+
// Let all the handler's awaits resolve (loadServices, axios, Promise
|
|
1785
|
+
// construction with listener attachments) before emitting the chunk.
|
|
1786
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
1787
|
+
const dataCbs = listeners.data || [];
|
|
1788
|
+
expect(dataCbs.length).toBeGreaterThan(0);
|
|
1789
|
+
dataCbs.forEach((cb) => cb({ length: CAP + 1 }));
|
|
1790
|
+
|
|
1791
|
+
const result = await promise;
|
|
1792
|
+
expect(result.isError).toBe(true);
|
|
1793
|
+
expect(result.content[0].text).toMatch(/exceeds .* byte limit|Error uploading image/);
|
|
1794
|
+
expect(destroyCalls.length).toBeGreaterThan(0);
|
|
1795
|
+
expect(destroyCalls[0]).toBeInstanceOf(Error);
|
|
1796
|
+
});
|
|
1797
|
+
});
|
|
@@ -25,7 +25,7 @@ vi.mock('crypto', () => ({
|
|
|
25
25
|
const mockUploadGhostImage = vi.fn();
|
|
26
26
|
const mockProcessImage = vi.fn();
|
|
27
27
|
|
|
28
|
-
vi.mock('../../services/
|
|
28
|
+
vi.mock('../../services/images.js', () => ({
|
|
29
29
|
uploadImage: (...args) => mockUploadGhostImage(...args),
|
|
30
30
|
}));
|
|
31
31
|
|
|
@@ -167,7 +167,7 @@ describe('imageController', () => {
|
|
|
167
167
|
path: '/tmp/mcp-upload-123-abc.jpg',
|
|
168
168
|
},
|
|
169
169
|
body: {
|
|
170
|
-
alt: 'a'.repeat(
|
|
170
|
+
alt: 'a'.repeat(192), // exceeds 191 char limit (Ghost varchar(191))
|
|
171
171
|
},
|
|
172
172
|
});
|
|
173
173
|
const res = createMockResponse();
|
|
@@ -5,8 +5,9 @@ import os from 'os'; // Import the os module
|
|
|
5
5
|
import Joi from 'joi';
|
|
6
6
|
import crypto from 'crypto';
|
|
7
7
|
import { createContextLogger } from '../utils/logger.js';
|
|
8
|
-
import { uploadImage as uploadGhostImage } from '../services/
|
|
9
|
-
import { processImage } from '../services/imageProcessingService.js';
|
|
8
|
+
import { uploadImage as uploadGhostImage } from '../services/images.js';
|
|
9
|
+
import { processImage } from '../services/imageProcessingService.js';
|
|
10
|
+
import { featureImageAltSchema } from '../schemas/common.js';
|
|
10
11
|
|
|
11
12
|
// --- Use OS temporary directory for uploads ---
|
|
12
13
|
const uploadDir = os.tmpdir(); // Use the OS default temp directory
|
|
@@ -194,15 +195,15 @@ const handleImageUpload = async (req, res, next) => {
|
|
|
194
195
|
processedPath = await processImage(originalPath, uploadDir);
|
|
195
196
|
|
|
196
197
|
// --- Handle Alt Text ---
|
|
197
|
-
// Validate
|
|
198
|
-
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
198
|
+
// Validate via the canonical schema shared with the MCP tool.
|
|
199
|
+
// Ghost's posts.feature_image_alt is varchar(191).
|
|
200
|
+
const altParse = featureImageAltSchema.safeParse(req.body.alt ?? undefined);
|
|
201
|
+
if (!altParse.success) {
|
|
202
|
+
return res
|
|
203
|
+
.status(400)
|
|
204
|
+
.json({ message: `Invalid alt text: ${altParse.error.issues[0].message}` });
|
|
203
205
|
}
|
|
204
|
-
|
|
205
|
-
const providedAlt = sanitizedAlt;
|
|
206
|
+
const providedAlt = altParse.data;
|
|
206
207
|
// Generate a default alt text from the original filename if none provided
|
|
207
208
|
const defaultAlt = getDefaultAltText(req.file.originalname);
|
|
208
209
|
const altText = providedAlt || defaultAlt;
|
package/src/mcp_server.js
CHANGED
|
@@ -11,6 +11,7 @@ import crypto from 'crypto';
|
|
|
11
11
|
import { ValidationError } from './errors/index.js';
|
|
12
12
|
import { validateToolInput } from './utils/validation.js';
|
|
13
13
|
import { trackTempFile, cleanupTempFiles } from './utils/tempFileManager.js';
|
|
14
|
+
import { resolveLocalImagePath, decodeBase64ToTempFile } from './utils/imageInputResolver.js';
|
|
14
15
|
import {
|
|
15
16
|
createTagSchema,
|
|
16
17
|
updateTagSchema,
|
|
@@ -267,97 +268,290 @@ server.registerTool(
|
|
|
267
268
|
);
|
|
268
269
|
|
|
269
270
|
// --- Image Schema ---
|
|
270
|
-
|
|
271
|
-
|
|
271
|
+
// Base object (plain ZodObject — keeps `.shape` available for MCP schema
|
|
272
|
+
// introspection). Runtime XOR validation is applied at the tool level
|
|
273
|
+
// via a manual check so we never wrap with ZodEffects.
|
|
274
|
+
const imageInputFields = {
|
|
275
|
+
imageUrl: z.string().optional().meta({
|
|
276
|
+
description: 'The publicly accessible URL of the image to download and upload.',
|
|
277
|
+
}),
|
|
278
|
+
imagePath: z.string().optional().meta({
|
|
279
|
+
description:
|
|
280
|
+
'Absolute path to a local image file. Only accepted when the GHOST_MCP_IMAGE_ROOT env var is set; paths must resolve inside that root.',
|
|
281
|
+
}),
|
|
282
|
+
imageBase64: z.string().optional().meta({
|
|
283
|
+
description:
|
|
284
|
+
'Base64-encoded image bytes (with or without data: URI prefix). Decoded size capped at 5MB to respect MCP transport limits. Requires mimeType.',
|
|
285
|
+
}),
|
|
286
|
+
mimeType: z.string().optional().meta({
|
|
287
|
+
description:
|
|
288
|
+
'MIME type for imageBase64 input (e.g. image/png, image/jpeg, image/svg+xml). Required when imageBase64 is used.',
|
|
289
|
+
}),
|
|
272
290
|
alt: z.string().optional().meta({
|
|
273
291
|
description:
|
|
274
292
|
'Alt text for the image. If omitted, a default will be generated from the filename.',
|
|
275
293
|
}),
|
|
276
|
-
|
|
294
|
+
purpose: z.enum(['image', 'profile_image', 'icon']).optional().meta({
|
|
295
|
+
description:
|
|
296
|
+
'Intended use. Ghost validates format/size per purpose (icon/profile_image must be square; icon also accepts ICO).',
|
|
297
|
+
}),
|
|
298
|
+
ref: z.string().max(200).optional().meta({
|
|
299
|
+
description:
|
|
300
|
+
'Caller-supplied identifier (e.g. original filename). Ghost echoes it back in the response.',
|
|
301
|
+
}),
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const uploadImageSchema = z.object(imageInputFields);
|
|
305
|
+
|
|
306
|
+
function validateImageInputXor(data) {
|
|
307
|
+
const count = Number(!!data.imageUrl) + Number(!!data.imagePath) + Number(!!data.imageBase64);
|
|
308
|
+
if (count !== 1) return 'Provide exactly one of imageUrl, imagePath, or imageBase64.';
|
|
309
|
+
if (data.imageBase64 && !data.mimeType) {
|
|
310
|
+
return 'mimeType is required when imageBase64 is provided.';
|
|
311
|
+
}
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Axios does not enforce `maxContentLength` for responseType: 'stream',
|
|
316
|
+
// so we cap downloads here by watching bytes on the response stream and
|
|
317
|
+
// destroying it on overflow. Mirrors the config value in urlValidator.js.
|
|
318
|
+
const DOWNLOAD_CAP_BYTES = 50 * 1024 * 1024;
|
|
319
|
+
|
|
320
|
+
async function acquireImageForUpload({ imageUrl, imagePath: localPath, imageBase64, mimeType }) {
|
|
321
|
+
const tempDir = os.tmpdir();
|
|
322
|
+
|
|
323
|
+
if (imageUrl) {
|
|
324
|
+
const urlValidation = urlValidator.validateImageUrl(imageUrl);
|
|
325
|
+
if (!urlValidation.isValid) {
|
|
326
|
+
throw new Error(`Invalid image URL: ${urlValidation.error}`);
|
|
327
|
+
}
|
|
328
|
+
const axiosConfig = urlValidator.createSecureAxiosConfig(urlValidation.sanitizedUrl);
|
|
329
|
+
const response = await axios(axiosConfig);
|
|
330
|
+
|
|
331
|
+
const declared = Number(response.headers['content-length']);
|
|
332
|
+
if (Number.isFinite(declared) && declared > DOWNLOAD_CAP_BYTES) {
|
|
333
|
+
response.data.destroy();
|
|
334
|
+
throw new Error(
|
|
335
|
+
`Image exceeds ${DOWNLOAD_CAP_BYTES} byte limit (server declared ${declared})`
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const extension = path.extname(imageUrl.split('?')[0]) || '.tmp';
|
|
340
|
+
const filenameHint =
|
|
341
|
+
path.basename(imageUrl.split('?')[0]) || `image-${generateUuid()}${extension}`;
|
|
342
|
+
const downloadedPath = path.join(tempDir, `mcp-download-${generateUuid()}${extension}`);
|
|
343
|
+
|
|
344
|
+
let bytes = 0;
|
|
345
|
+
response.data.on('data', (chunk) => {
|
|
346
|
+
bytes += chunk.length;
|
|
347
|
+
if (bytes > DOWNLOAD_CAP_BYTES) {
|
|
348
|
+
response.data.destroy(new Error(`Image exceeds ${DOWNLOAD_CAP_BYTES} byte limit`));
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
const writer = fs.createWriteStream(downloadedPath);
|
|
353
|
+
response.data.pipe(writer);
|
|
354
|
+
await new Promise((resolve, reject) => {
|
|
355
|
+
writer.on('finish', resolve);
|
|
356
|
+
writer.on('error', reject);
|
|
357
|
+
response.data.on('error', reject);
|
|
358
|
+
});
|
|
359
|
+
return { acquiredPath: downloadedPath, filenameHint, source: 'url' };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (localPath) {
|
|
363
|
+
const resolved = await resolveLocalImagePath(localPath);
|
|
364
|
+
return { acquiredPath: resolved, filenameHint: path.basename(resolved), source: 'path' };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (imageBase64) {
|
|
368
|
+
const decodedPath = await decodeBase64ToTempFile(imageBase64, mimeType);
|
|
369
|
+
return {
|
|
370
|
+
acquiredPath: decodedPath,
|
|
371
|
+
filenameHint: path.basename(decodedPath),
|
|
372
|
+
source: 'base64',
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
throw new Error('No image input provided'); // unreachable — schema enforces one
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Acquire → process → upload an image from a validated input. Owns its
|
|
381
|
+
* own temp-file lifecycle. Returns { uploadResult, filenameHint, finalAltText }.
|
|
382
|
+
* Does NOT delete the caller's imagePath file.
|
|
383
|
+
*/
|
|
384
|
+
async function performImageUpload(data) {
|
|
385
|
+
await loadServices();
|
|
386
|
+
|
|
387
|
+
let acquiredPath = null;
|
|
388
|
+
let processedPath = null;
|
|
389
|
+
|
|
390
|
+
try {
|
|
391
|
+
const acquired = await acquireImageForUpload(data);
|
|
392
|
+
acquiredPath = acquired.acquiredPath;
|
|
393
|
+
if (acquired.source !== 'path') trackTempFile(acquiredPath);
|
|
394
|
+
|
|
395
|
+
processedPath = await imageProcessingService.processImage(
|
|
396
|
+
acquiredPath,
|
|
397
|
+
os.tmpdir(),
|
|
398
|
+
data.purpose ? { purpose: data.purpose } : {}
|
|
399
|
+
);
|
|
400
|
+
if (processedPath !== acquiredPath) trackTempFile(processedPath);
|
|
401
|
+
|
|
402
|
+
const uploadOpts = {};
|
|
403
|
+
if (data.purpose) uploadOpts.purpose = data.purpose;
|
|
404
|
+
if (data.ref) uploadOpts.ref = data.ref;
|
|
405
|
+
else if (acquired.filenameHint) uploadOpts.ref = acquired.filenameHint.slice(0, 200);
|
|
406
|
+
|
|
407
|
+
const uploadResult = await ghostService.uploadImage(processedPath, uploadOpts);
|
|
408
|
+
const finalAltText = data.alt || getDefaultAltText(acquired.filenameHint);
|
|
409
|
+
|
|
410
|
+
return { uploadResult, filenameHint: acquired.filenameHint, finalAltText };
|
|
411
|
+
} finally {
|
|
412
|
+
const toClean = [];
|
|
413
|
+
if (acquiredPath && !data.imagePath) toClean.push(acquiredPath);
|
|
414
|
+
if (processedPath && processedPath !== acquiredPath) toClean.push(processedPath);
|
|
415
|
+
if (toClean.length > 0) await cleanupTempFiles(toClean, console);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Runs the standard tool-input validation then the XOR image-input check.
|
|
421
|
+
* Returns { success: true, data } or { success: false, errorResponse }
|
|
422
|
+
* so both image tools can collapse their validation prelude to one call.
|
|
423
|
+
*/
|
|
424
|
+
function validateAndXorImageInput(schema, rawInput, toolName) {
|
|
425
|
+
const validation = validateToolInput(schema, rawInput, toolName);
|
|
426
|
+
if (!validation.success) return validation;
|
|
427
|
+
const xorError = validateImageInputXor(validation.data);
|
|
428
|
+
if (xorError) {
|
|
429
|
+
return {
|
|
430
|
+
success: false,
|
|
431
|
+
errorResponse: { content: [{ type: 'text', text: xorError }], isError: true },
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
return validation;
|
|
435
|
+
}
|
|
277
436
|
|
|
278
|
-
// Upload Image Tool
|
|
437
|
+
// Upload Image Tool
|
|
279
438
|
server.registerTool(
|
|
280
439
|
'ghost_upload_image',
|
|
281
440
|
{
|
|
282
441
|
description:
|
|
283
|
-
'
|
|
442
|
+
'Uploads an image to Ghost CMS. Accepts a remote URL, a local file path (when GHOST_MCP_IMAGE_ROOT is configured), or a base64 payload. Returns the Ghost image URL, alt text, and ref (when Ghost echoes it).',
|
|
284
443
|
inputSchema: uploadImageSchema,
|
|
285
444
|
},
|
|
286
445
|
async (rawInput) => {
|
|
287
|
-
const validation =
|
|
288
|
-
if (!validation.success)
|
|
289
|
-
return validation.errorResponse;
|
|
290
|
-
}
|
|
291
|
-
const { imageUrl, alt } = validation.data;
|
|
292
|
-
|
|
293
|
-
console.error(`Executing tool: ghost_upload_image for URL: ${imageUrl}`);
|
|
294
|
-
let downloadedPath = null;
|
|
295
|
-
let processedPath = null;
|
|
446
|
+
const validation = validateAndXorImageInput(uploadImageSchema, rawInput, 'ghost_upload_image');
|
|
447
|
+
if (!validation.success) return validation.errorResponse;
|
|
296
448
|
|
|
297
449
|
try {
|
|
298
|
-
await
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
const originalFilenameHint =
|
|
312
|
-
path.basename(imageUrl.split('?')[0]) || `image-${generateUuid()}${extension}`;
|
|
313
|
-
downloadedPath = path.join(tempDir, `mcp-download-${generateUuid()}${extension}`);
|
|
314
|
-
|
|
315
|
-
const writer = fs.createWriteStream(downloadedPath);
|
|
316
|
-
response.data.pipe(writer);
|
|
317
|
-
|
|
318
|
-
await new Promise((resolve, reject) => {
|
|
319
|
-
writer.on('finish', resolve);
|
|
320
|
-
writer.on('error', reject);
|
|
321
|
-
});
|
|
322
|
-
// Track temp file for cleanup on process exit
|
|
323
|
-
trackTempFile(downloadedPath);
|
|
324
|
-
console.error(`Downloaded image to temporary path: ${downloadedPath}`);
|
|
325
|
-
|
|
326
|
-
// 3. Process the image
|
|
327
|
-
processedPath = await imageProcessingService.processImage(downloadedPath, tempDir);
|
|
328
|
-
// Track processed file for cleanup on process exit
|
|
329
|
-
if (processedPath !== downloadedPath) {
|
|
330
|
-
trackTempFile(processedPath);
|
|
331
|
-
}
|
|
332
|
-
console.error(`Processed image path: ${processedPath}`);
|
|
333
|
-
|
|
334
|
-
// 4. Determine Alt Text
|
|
335
|
-
const defaultAlt = getDefaultAltText(originalFilenameHint);
|
|
336
|
-
const finalAltText = alt || defaultAlt;
|
|
337
|
-
console.error(`Using alt text: "${finalAltText}"`);
|
|
450
|
+
const { uploadResult, finalAltText } = await performImageUpload(validation.data);
|
|
451
|
+
const result = { url: uploadResult.url, alt: finalAltText };
|
|
452
|
+
if (uploadResult.ref) result.ref = uploadResult.ref;
|
|
453
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
454
|
+
} catch (error) {
|
|
455
|
+
console.error(`Error in ghost_upload_image:`, error);
|
|
456
|
+
return {
|
|
457
|
+
content: [{ type: 'text', text: `Error uploading image: ${error.message}` }],
|
|
458
|
+
isError: true,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
);
|
|
338
463
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
464
|
+
// --- Set Feature Image Tool ---
|
|
465
|
+
// Combined upload-and-assign flow. Ghost has no delete-image endpoint,
|
|
466
|
+
// so when the post/page update fails after a successful upload, the
|
|
467
|
+
// image is orphaned in Ghost's storage — we surface its URL in the
|
|
468
|
+
// error response so the caller can reuse or record it.
|
|
469
|
+
const setFeatureImageSchema = uploadImageSchema.extend({
|
|
470
|
+
type: z.enum(['post', 'page']).meta({
|
|
471
|
+
description: 'Which resource to attach the feature image to.',
|
|
472
|
+
}),
|
|
473
|
+
id: ghostIdSchema.meta({ description: 'ID of the post or page.' }),
|
|
474
|
+
caption: z.string().max(5000).optional().meta({
|
|
475
|
+
description: 'Optional HTML caption for the feature image (max 5000 chars).',
|
|
476
|
+
}),
|
|
477
|
+
});
|
|
342
478
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
479
|
+
server.registerTool(
|
|
480
|
+
'ghost_set_feature_image',
|
|
481
|
+
{
|
|
482
|
+
description:
|
|
483
|
+
'Uploads an image and assigns it as the feature image of a post or page (with optional alt text and caption) in one call. Accepts the same imageUrl/imagePath/imageBase64 input modes as ghost_upload_image. Returns the updated resource. If the update fails after the upload, the error response includes the orphaned image URL.',
|
|
484
|
+
inputSchema: setFeatureImageSchema,
|
|
485
|
+
},
|
|
486
|
+
async (rawInput) => {
|
|
487
|
+
const validation = validateAndXorImageInput(
|
|
488
|
+
setFeatureImageSchema,
|
|
489
|
+
rawInput,
|
|
490
|
+
'ghost_set_feature_image'
|
|
491
|
+
);
|
|
492
|
+
if (!validation.success) return validation.errorResponse;
|
|
493
|
+
|
|
494
|
+
const { type, id, caption } = validation.data;
|
|
495
|
+
|
|
496
|
+
let uploadedUrl;
|
|
497
|
+
let uploadedRef;
|
|
498
|
+
let altText;
|
|
499
|
+
try {
|
|
500
|
+
const { uploadResult, finalAltText } = await performImageUpload(validation.data);
|
|
501
|
+
uploadedUrl = uploadResult.url;
|
|
502
|
+
uploadedRef = uploadResult.ref;
|
|
503
|
+
altText = finalAltText;
|
|
504
|
+
} catch (error) {
|
|
505
|
+
console.error(`ghost_set_feature_image: upload failed`, error);
|
|
506
|
+
return {
|
|
507
|
+
content: [{ type: 'text', text: `Upload failed: ${error.message}` }],
|
|
508
|
+
isError: true,
|
|
347
509
|
};
|
|
510
|
+
}
|
|
348
511
|
|
|
512
|
+
const updatePayload = {
|
|
513
|
+
feature_image: uploadedUrl,
|
|
514
|
+
feature_image_alt: altText,
|
|
515
|
+
};
|
|
516
|
+
if (caption !== undefined) updatePayload.feature_image_caption = caption;
|
|
517
|
+
|
|
518
|
+
try {
|
|
519
|
+
const updated =
|
|
520
|
+
type === 'post'
|
|
521
|
+
? await ghostService.updatePost(id, updatePayload)
|
|
522
|
+
: await ghostService.updatePage(id, updatePayload);
|
|
523
|
+
console.error(`ghost_set_feature_image: ${type} ${id} updated with ${uploadedUrl}`);
|
|
349
524
|
return {
|
|
350
|
-
content: [
|
|
525
|
+
content: [
|
|
526
|
+
{
|
|
527
|
+
type: 'text',
|
|
528
|
+
text: JSON.stringify(
|
|
529
|
+
{ uploaded: { url: uploadedUrl, ref: uploadedRef, alt: altText }, [type]: updated },
|
|
530
|
+
null,
|
|
531
|
+
2
|
|
532
|
+
),
|
|
533
|
+
},
|
|
534
|
+
],
|
|
351
535
|
};
|
|
352
536
|
} catch (error) {
|
|
353
|
-
console.error(`
|
|
537
|
+
console.error(`ghost_set_feature_image: update failed (orphaned ${uploadedUrl})`, error);
|
|
354
538
|
return {
|
|
355
|
-
content: [
|
|
539
|
+
content: [
|
|
540
|
+
{
|
|
541
|
+
type: 'text',
|
|
542
|
+
text: JSON.stringify(
|
|
543
|
+
{
|
|
544
|
+
error: `Upload succeeded but ${type} update failed: ${error.message}`,
|
|
545
|
+
orphanedImage: { url: uploadedUrl, ref: uploadedRef, alt: altText },
|
|
546
|
+
hint: 'Ghost does not expose a delete-image endpoint; reuse this URL or leave it orphaned.',
|
|
547
|
+
},
|
|
548
|
+
null,
|
|
549
|
+
2
|
|
550
|
+
),
|
|
551
|
+
},
|
|
552
|
+
],
|
|
356
553
|
isError: true,
|
|
357
554
|
};
|
|
358
|
-
} finally {
|
|
359
|
-
// Cleanup temporary files with proper async/await
|
|
360
|
-
await cleanupTempFiles([downloadedPath, processedPath], console);
|
|
361
555
|
}
|
|
362
556
|
}
|
|
363
557
|
);
|
|
@@ -41,8 +41,8 @@ vi.mock('../../services/imageProcessingService.js', () => ({
|
|
|
41
41
|
processImage: vi.fn().mockResolvedValue('/tmp/processed-image.jpg'),
|
|
42
42
|
}));
|
|
43
43
|
|
|
44
|
-
// Mock the
|
|
45
|
-
vi.mock('../../services/
|
|
44
|
+
// Mock the image upload service (now in images.js, re-exported via ghostServiceImproved.js)
|
|
45
|
+
vi.mock('../../services/images.js', () => ({
|
|
46
46
|
uploadImage: vi.fn().mockResolvedValue({ url: 'https://ghost.com/image.jpg' }),
|
|
47
47
|
}));
|
|
48
48
|
|
|
@@ -336,12 +336,12 @@ describe('Common Schemas', () => {
|
|
|
336
336
|
describe('featureImageAltSchema', () => {
|
|
337
337
|
it('should accept valid alt text', () => {
|
|
338
338
|
expect(() => featureImageAltSchema.parse('Image description')).not.toThrow();
|
|
339
|
-
expect(() => featureImageAltSchema.parse('A'.repeat(
|
|
339
|
+
expect(() => featureImageAltSchema.parse('A'.repeat(191))).not.toThrow();
|
|
340
340
|
expect(() => featureImageAltSchema.parse(undefined)).not.toThrow();
|
|
341
341
|
});
|
|
342
342
|
|
|
343
|
-
it('should reject too long alt text', () => {
|
|
344
|
-
expect(() => featureImageAltSchema.parse('A'.repeat(
|
|
343
|
+
it('should reject too long alt text (>191, matches Ghost varchar(191))', () => {
|
|
344
|
+
expect(() => featureImageAltSchema.parse('A'.repeat(192))).toThrow();
|
|
345
345
|
});
|
|
346
346
|
});
|
|
347
347
|
|
|
@@ -115,16 +115,25 @@ describe('Page Schemas', () => {
|
|
|
115
115
|
expect(() => createPageSchema.parse(invalidPage)).toThrow();
|
|
116
116
|
});
|
|
117
117
|
|
|
118
|
-
it('should reject page with too long feature_image_caption', () => {
|
|
118
|
+
it('should reject page with too long feature_image_caption (>5000)', () => {
|
|
119
119
|
const invalidPage = {
|
|
120
120
|
title: 'Page',
|
|
121
121
|
html: '<p>Content</p>',
|
|
122
|
-
feature_image_caption: 'A'.repeat(
|
|
122
|
+
feature_image_caption: 'A'.repeat(5001),
|
|
123
123
|
};
|
|
124
124
|
|
|
125
125
|
expect(() => createPageSchema.parse(invalidPage)).toThrow();
|
|
126
126
|
});
|
|
127
127
|
|
|
128
|
+
it('should accept a 5000-char feature_image_caption', () => {
|
|
129
|
+
const page = {
|
|
130
|
+
title: 'Page',
|
|
131
|
+
html: '<p>Content</p>',
|
|
132
|
+
feature_image_caption: 'A'.repeat(5000),
|
|
133
|
+
};
|
|
134
|
+
expect(() => createPageSchema.parse(page)).not.toThrow();
|
|
135
|
+
});
|
|
136
|
+
|
|
128
137
|
it('should reject page with too long og_title', () => {
|
|
129
138
|
const invalidPage = {
|
|
130
139
|
title: 'Page',
|
package/src/schemas/common.js
CHANGED
|
@@ -205,11 +205,12 @@ export const featureImageSchema = z.string().url('Invalid feature image URL').op
|
|
|
205
205
|
|
|
206
206
|
/**
|
|
207
207
|
* Feature image alt text validation schema
|
|
208
|
-
* Optional alt text for accessibility
|
|
208
|
+
* Optional alt text for accessibility. Ghost's posts.feature_image_alt
|
|
209
|
+
* column is varchar(191); anything longer is rejected server-side.
|
|
209
210
|
*/
|
|
210
211
|
export const featureImageAltSchema = z
|
|
211
212
|
.string()
|
|
212
|
-
.max(
|
|
213
|
+
.max(191, 'Feature image alt text cannot exceed 191 characters')
|
|
213
214
|
.optional();
|
|
214
215
|
|
|
215
216
|
/**
|
|
@@ -44,7 +44,7 @@ export const createPageSchema = z.object({
|
|
|
44
44
|
featured: featuredSchema,
|
|
45
45
|
feature_image: featureImageSchema,
|
|
46
46
|
feature_image_alt: featureImageAltSchema,
|
|
47
|
-
feature_image_caption: z.string().max(
|
|
47
|
+
feature_image_caption: z.string().max(5000, 'Caption cannot exceed 5000 characters').optional(),
|
|
48
48
|
excerpt: excerptSchema,
|
|
49
49
|
custom_excerpt: customExcerptSchema,
|
|
50
50
|
meta_title: metaTitleSchema,
|
|
@@ -42,7 +42,7 @@ export const createPostSchema = z.object({
|
|
|
42
42
|
featured: featuredSchema,
|
|
43
43
|
feature_image: featureImageSchema,
|
|
44
44
|
feature_image_alt: featureImageAltSchema,
|
|
45
|
-
feature_image_caption: z.string().max(
|
|
45
|
+
feature_image_caption: z.string().max(5000, 'Caption cannot exceed 5000 characters').optional(),
|
|
46
46
|
excerpt: excerptSchema,
|
|
47
47
|
custom_excerpt: customExcerptSchema,
|
|
48
48
|
meta_title: metaTitleSchema,
|
|
@@ -20,7 +20,6 @@ import {
|
|
|
20
20
|
createTag,
|
|
21
21
|
getTags,
|
|
22
22
|
getSiteInfo,
|
|
23
|
-
uploadImage,
|
|
24
23
|
handleApiRequest,
|
|
25
24
|
api,
|
|
26
25
|
} from '../ghostService.js';
|
|
@@ -209,24 +208,6 @@ describe('ghostService', () => {
|
|
|
209
208
|
});
|
|
210
209
|
});
|
|
211
210
|
|
|
212
|
-
describe('uploadImage', () => {
|
|
213
|
-
it('should throw error when image path is missing', async () => {
|
|
214
|
-
await expect(uploadImage()).rejects.toThrow('Image path is required for upload');
|
|
215
|
-
await expect(uploadImage('')).rejects.toThrow('Image path is required for upload');
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
it('should successfully upload image with valid path', async () => {
|
|
219
|
-
const imagePath = '/path/to/image.jpg';
|
|
220
|
-
const expectedResult = { url: 'https://example.com/uploaded-image.jpg' };
|
|
221
|
-
api.images.upload.mockResolvedValue(expectedResult);
|
|
222
|
-
|
|
223
|
-
const result = await uploadImage(imagePath);
|
|
224
|
-
|
|
225
|
-
expect(result).toEqual(expectedResult);
|
|
226
|
-
expect(api.images.upload).toHaveBeenCalledWith({ file: imagePath });
|
|
227
|
-
});
|
|
228
|
-
});
|
|
229
|
-
|
|
230
211
|
describe('createPost', () => {
|
|
231
212
|
it('should throw error when title is missing', async () => {
|
|
232
213
|
await expect(createPost({})).rejects.toThrow('Post title is required');
|