@jgardner04/ghost-mcp-server 1.13.5 → 1.14.1
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 +270 -84
- 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__/formatErrorResponse.test.js +158 -0
- package/src/utils/__tests__/imageInputResolver.test.js +134 -0
- package/src/utils/__tests__/sanitizeErrorPayload.test.js +130 -0
- package/src/utils/__tests__/validation.test.js +13 -7
- package/src/utils/formatErrorResponse.js +63 -0
- package/src/utils/imageInputResolver.js +127 -0
- package/src/utils/sanitizeErrorPayload.js +67 -0
- package/src/utils/validation.js +2 -4
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
|
@@ -8,9 +8,11 @@ import fs from 'fs';
|
|
|
8
8
|
import path from 'path';
|
|
9
9
|
import os from 'os';
|
|
10
10
|
import crypto from 'crypto';
|
|
11
|
-
import { ValidationError } from './errors/index.js';
|
|
12
11
|
import { validateToolInput } from './utils/validation.js';
|
|
12
|
+
import { formatErrorResponse } from './utils/formatErrorResponse.js';
|
|
13
|
+
import { createContextLogger } from './utils/logger.js';
|
|
13
14
|
import { trackTempFile, cleanupTempFiles } from './utils/tempFileManager.js';
|
|
15
|
+
import { resolveLocalImagePath, decodeBase64ToTempFile } from './utils/imageInputResolver.js';
|
|
14
16
|
import {
|
|
15
17
|
createTagSchema,
|
|
16
18
|
updateTagSchema,
|
|
@@ -37,6 +39,24 @@ import {
|
|
|
37
39
|
// Load environment variables
|
|
38
40
|
dotenv.config({ quiet: true });
|
|
39
41
|
|
|
42
|
+
const mcpLogger = createContextLogger('mcp-server');
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Emit structured log fields for a caught error. Never passes the raw error
|
|
46
|
+
* object to the logger — Ghost SDK errors carry request headers/URLs that
|
|
47
|
+
* include credentials.
|
|
48
|
+
*/
|
|
49
|
+
const logToolError = (toolName, error, extra = {}) => {
|
|
50
|
+
mcpLogger.error(`Tool ${toolName} failed`, {
|
|
51
|
+
tool: toolName,
|
|
52
|
+
errorName: error?.name,
|
|
53
|
+
errorMessage: error?.message,
|
|
54
|
+
errorCode: error?.code,
|
|
55
|
+
ghostStatusCode: error?.ghostStatusCode,
|
|
56
|
+
...extra,
|
|
57
|
+
});
|
|
58
|
+
};
|
|
59
|
+
|
|
40
60
|
// Lazy-loaded modules (to avoid Node.js v25 Buffer compatibility issues at startup)
|
|
41
61
|
let ghostService = null;
|
|
42
62
|
let postService = null;
|
|
@@ -98,7 +118,6 @@ const escapeNqlValue = (value) => {
|
|
|
98
118
|
* @returns {Function} Wrapped async handler for server.registerTool
|
|
99
119
|
*/
|
|
100
120
|
const withErrorHandling = (toolName, schema, handler) => {
|
|
101
|
-
const zodContext = toolName.replace('ghost_', '').replace(/_/g, ' ');
|
|
102
121
|
return async (rawInput) => {
|
|
103
122
|
console.error(`Executing tool: ${toolName}`);
|
|
104
123
|
const validation = validateToolInput(schema, rawInput, toolName);
|
|
@@ -110,18 +129,8 @@ const withErrorHandling = (toolName, schema, handler) => {
|
|
|
110
129
|
await loadServices();
|
|
111
130
|
return await handler(validation.data);
|
|
112
131
|
} catch (error) {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const validationError = ValidationError.fromZod(error, zodContext);
|
|
116
|
-
return {
|
|
117
|
-
content: [{ type: 'text', text: JSON.stringify(validationError.toJSON(), null, 2) }],
|
|
118
|
-
isError: true,
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
return {
|
|
122
|
-
content: [{ type: 'text', text: `Error in ${toolName}: ${error.message}` }],
|
|
123
|
-
isError: true,
|
|
124
|
-
};
|
|
132
|
+
logToolError(toolName, error);
|
|
133
|
+
return formatErrorResponse(error, toolName);
|
|
125
134
|
}
|
|
126
135
|
};
|
|
127
136
|
};
|
|
@@ -267,97 +276,274 @@ server.registerTool(
|
|
|
267
276
|
);
|
|
268
277
|
|
|
269
278
|
// --- Image Schema ---
|
|
270
|
-
|
|
271
|
-
|
|
279
|
+
// Base object (plain ZodObject — keeps `.shape` available for MCP schema
|
|
280
|
+
// introspection). Runtime XOR validation is applied at the tool level
|
|
281
|
+
// via a manual check so we never wrap with ZodEffects.
|
|
282
|
+
const imageInputFields = {
|
|
283
|
+
imageUrl: z.string().optional().meta({
|
|
284
|
+
description: 'The publicly accessible URL of the image to download and upload.',
|
|
285
|
+
}),
|
|
286
|
+
imagePath: z.string().optional().meta({
|
|
287
|
+
description:
|
|
288
|
+
'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.',
|
|
289
|
+
}),
|
|
290
|
+
imageBase64: z.string().optional().meta({
|
|
291
|
+
description:
|
|
292
|
+
'Base64-encoded image bytes (with or without data: URI prefix). Decoded size capped at 5MB to respect MCP transport limits. Requires mimeType.',
|
|
293
|
+
}),
|
|
294
|
+
mimeType: z.string().optional().meta({
|
|
295
|
+
description:
|
|
296
|
+
'MIME type for imageBase64 input (e.g. image/png, image/jpeg, image/svg+xml). Required when imageBase64 is used.',
|
|
297
|
+
}),
|
|
272
298
|
alt: z.string().optional().meta({
|
|
273
299
|
description:
|
|
274
300
|
'Alt text for the image. If omitted, a default will be generated from the filename.',
|
|
275
301
|
}),
|
|
276
|
-
|
|
302
|
+
purpose: z.enum(['image', 'profile_image', 'icon']).optional().meta({
|
|
303
|
+
description:
|
|
304
|
+
'Intended use. Ghost validates format/size per purpose (icon/profile_image must be square; icon also accepts ICO).',
|
|
305
|
+
}),
|
|
306
|
+
ref: z.string().max(200).optional().meta({
|
|
307
|
+
description:
|
|
308
|
+
'Caller-supplied identifier (e.g. original filename). Ghost echoes it back in the response.',
|
|
309
|
+
}),
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const uploadImageSchema = z.object(imageInputFields);
|
|
313
|
+
|
|
314
|
+
function validateImageInputXor(data) {
|
|
315
|
+
const count = Number(!!data.imageUrl) + Number(!!data.imagePath) + Number(!!data.imageBase64);
|
|
316
|
+
if (count !== 1) return 'Provide exactly one of imageUrl, imagePath, or imageBase64.';
|
|
317
|
+
if (data.imageBase64 && !data.mimeType) {
|
|
318
|
+
return 'mimeType is required when imageBase64 is provided.';
|
|
319
|
+
}
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Axios does not enforce `maxContentLength` for responseType: 'stream',
|
|
324
|
+
// so we cap downloads here by watching bytes on the response stream and
|
|
325
|
+
// destroying it on overflow. Mirrors the config value in urlValidator.js.
|
|
326
|
+
const DOWNLOAD_CAP_BYTES = 50 * 1024 * 1024;
|
|
327
|
+
|
|
328
|
+
async function acquireImageForUpload({ imageUrl, imagePath: localPath, imageBase64, mimeType }) {
|
|
329
|
+
const tempDir = os.tmpdir();
|
|
330
|
+
|
|
331
|
+
if (imageUrl) {
|
|
332
|
+
const urlValidation = urlValidator.validateImageUrl(imageUrl);
|
|
333
|
+
if (!urlValidation.isValid) {
|
|
334
|
+
throw new Error(`Invalid image URL: ${urlValidation.error}`);
|
|
335
|
+
}
|
|
336
|
+
const axiosConfig = urlValidator.createSecureAxiosConfig(urlValidation.sanitizedUrl);
|
|
337
|
+
const response = await axios(axiosConfig);
|
|
338
|
+
|
|
339
|
+
const declared = Number(response.headers['content-length']);
|
|
340
|
+
if (Number.isFinite(declared) && declared > DOWNLOAD_CAP_BYTES) {
|
|
341
|
+
response.data.destroy();
|
|
342
|
+
throw new Error(
|
|
343
|
+
`Image exceeds ${DOWNLOAD_CAP_BYTES} byte limit (server declared ${declared})`
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const extension = path.extname(imageUrl.split('?')[0]) || '.tmp';
|
|
348
|
+
const filenameHint =
|
|
349
|
+
path.basename(imageUrl.split('?')[0]) || `image-${generateUuid()}${extension}`;
|
|
350
|
+
const downloadedPath = path.join(tempDir, `mcp-download-${generateUuid()}${extension}`);
|
|
351
|
+
|
|
352
|
+
let bytes = 0;
|
|
353
|
+
response.data.on('data', (chunk) => {
|
|
354
|
+
bytes += chunk.length;
|
|
355
|
+
if (bytes > DOWNLOAD_CAP_BYTES) {
|
|
356
|
+
response.data.destroy(new Error(`Image exceeds ${DOWNLOAD_CAP_BYTES} byte limit`));
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
const writer = fs.createWriteStream(downloadedPath);
|
|
361
|
+
response.data.pipe(writer);
|
|
362
|
+
await new Promise((resolve, reject) => {
|
|
363
|
+
writer.on('finish', resolve);
|
|
364
|
+
writer.on('error', reject);
|
|
365
|
+
response.data.on('error', reject);
|
|
366
|
+
});
|
|
367
|
+
return { acquiredPath: downloadedPath, filenameHint, source: 'url' };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (localPath) {
|
|
371
|
+
const resolved = await resolveLocalImagePath(localPath);
|
|
372
|
+
return { acquiredPath: resolved, filenameHint: path.basename(resolved), source: 'path' };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (imageBase64) {
|
|
376
|
+
const decodedPath = await decodeBase64ToTempFile(imageBase64, mimeType);
|
|
377
|
+
return {
|
|
378
|
+
acquiredPath: decodedPath,
|
|
379
|
+
filenameHint: path.basename(decodedPath),
|
|
380
|
+
source: 'base64',
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
throw new Error('No image input provided'); // unreachable — schema enforces one
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Acquire → process → upload an image from a validated input. Owns its
|
|
389
|
+
* own temp-file lifecycle. Returns { uploadResult, filenameHint, finalAltText }.
|
|
390
|
+
* Does NOT delete the caller's imagePath file.
|
|
391
|
+
*/
|
|
392
|
+
async function performImageUpload(data) {
|
|
393
|
+
await loadServices();
|
|
394
|
+
|
|
395
|
+
let acquiredPath = null;
|
|
396
|
+
let processedPath = null;
|
|
397
|
+
|
|
398
|
+
try {
|
|
399
|
+
const acquired = await acquireImageForUpload(data);
|
|
400
|
+
acquiredPath = acquired.acquiredPath;
|
|
401
|
+
if (acquired.source !== 'path') trackTempFile(acquiredPath);
|
|
402
|
+
|
|
403
|
+
processedPath = await imageProcessingService.processImage(
|
|
404
|
+
acquiredPath,
|
|
405
|
+
os.tmpdir(),
|
|
406
|
+
data.purpose ? { purpose: data.purpose } : {}
|
|
407
|
+
);
|
|
408
|
+
if (processedPath !== acquiredPath) trackTempFile(processedPath);
|
|
409
|
+
|
|
410
|
+
const uploadOpts = {};
|
|
411
|
+
if (data.purpose) uploadOpts.purpose = data.purpose;
|
|
412
|
+
if (data.ref) uploadOpts.ref = data.ref;
|
|
413
|
+
else if (acquired.filenameHint) uploadOpts.ref = acquired.filenameHint.slice(0, 200);
|
|
414
|
+
|
|
415
|
+
const uploadResult = await ghostService.uploadImage(processedPath, uploadOpts);
|
|
416
|
+
const finalAltText = data.alt || getDefaultAltText(acquired.filenameHint);
|
|
417
|
+
|
|
418
|
+
return { uploadResult, filenameHint: acquired.filenameHint, finalAltText };
|
|
419
|
+
} finally {
|
|
420
|
+
const toClean = [];
|
|
421
|
+
if (acquiredPath && !data.imagePath) toClean.push(acquiredPath);
|
|
422
|
+
if (processedPath && processedPath !== acquiredPath) toClean.push(processedPath);
|
|
423
|
+
if (toClean.length > 0) await cleanupTempFiles(toClean, console);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
277
426
|
|
|
278
|
-
|
|
427
|
+
/**
|
|
428
|
+
* Runs the standard tool-input validation then the XOR image-input check.
|
|
429
|
+
* Returns { success: true, data } or { success: false, errorResponse }
|
|
430
|
+
* so both image tools can collapse their validation prelude to one call.
|
|
431
|
+
*/
|
|
432
|
+
function validateAndXorImageInput(schema, rawInput, toolName) {
|
|
433
|
+
const validation = validateToolInput(schema, rawInput, toolName);
|
|
434
|
+
if (!validation.success) return validation;
|
|
435
|
+
const xorError = validateImageInputXor(validation.data);
|
|
436
|
+
if (xorError) {
|
|
437
|
+
return {
|
|
438
|
+
success: false,
|
|
439
|
+
errorResponse: { content: [{ type: 'text', text: xorError }], isError: true },
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
return validation;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Upload Image Tool
|
|
279
446
|
server.registerTool(
|
|
280
447
|
'ghost_upload_image',
|
|
281
448
|
{
|
|
282
449
|
description:
|
|
283
|
-
'
|
|
450
|
+
'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
451
|
inputSchema: uploadImageSchema,
|
|
285
452
|
},
|
|
286
453
|
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;
|
|
454
|
+
const validation = validateAndXorImageInput(uploadImageSchema, rawInput, 'ghost_upload_image');
|
|
455
|
+
if (!validation.success) return validation.errorResponse;
|
|
296
456
|
|
|
297
457
|
try {
|
|
298
|
-
await
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
const response = await axios(axiosConfig);
|
|
309
|
-
const tempDir = os.tmpdir();
|
|
310
|
-
const extension = path.extname(imageUrl.split('?')[0]) || '.tmp';
|
|
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}`);
|
|
458
|
+
const { uploadResult, finalAltText } = await performImageUpload(validation.data);
|
|
459
|
+
const result = { url: uploadResult.url, alt: finalAltText };
|
|
460
|
+
if (uploadResult.ref) result.ref = uploadResult.ref;
|
|
461
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
462
|
+
} catch (error) {
|
|
463
|
+
logToolError('ghost_upload_image', error);
|
|
464
|
+
return formatErrorResponse(error, 'ghost_upload_image');
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
);
|
|
333
468
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
469
|
+
// --- Set Feature Image Tool ---
|
|
470
|
+
// Combined upload-and-assign flow. Ghost has no delete-image endpoint,
|
|
471
|
+
// so when the post/page update fails after a successful upload, the
|
|
472
|
+
// image is orphaned in Ghost's storage — we surface its URL in the
|
|
473
|
+
// error response so the caller can reuse or record it.
|
|
474
|
+
const setFeatureImageSchema = uploadImageSchema.extend({
|
|
475
|
+
type: z.enum(['post', 'page']).meta({
|
|
476
|
+
description: 'Which resource to attach the feature image to.',
|
|
477
|
+
}),
|
|
478
|
+
id: ghostIdSchema.meta({ description: 'ID of the post or page.' }),
|
|
479
|
+
caption: z.string().max(5000).optional().meta({
|
|
480
|
+
description: 'Optional HTML caption for the feature image (max 5000 chars).',
|
|
481
|
+
}),
|
|
482
|
+
});
|
|
338
483
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
484
|
+
server.registerTool(
|
|
485
|
+
'ghost_set_feature_image',
|
|
486
|
+
{
|
|
487
|
+
description:
|
|
488
|
+
'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.',
|
|
489
|
+
inputSchema: setFeatureImageSchema,
|
|
490
|
+
},
|
|
491
|
+
async (rawInput) => {
|
|
492
|
+
const validation = validateAndXorImageInput(
|
|
493
|
+
setFeatureImageSchema,
|
|
494
|
+
rawInput,
|
|
495
|
+
'ghost_set_feature_image'
|
|
496
|
+
);
|
|
497
|
+
if (!validation.success) return validation.errorResponse;
|
|
498
|
+
|
|
499
|
+
const { type, id, caption } = validation.data;
|
|
500
|
+
|
|
501
|
+
let uploadedUrl;
|
|
502
|
+
let uploadedRef;
|
|
503
|
+
let altText;
|
|
504
|
+
try {
|
|
505
|
+
const { uploadResult, finalAltText } = await performImageUpload(validation.data);
|
|
506
|
+
uploadedUrl = uploadResult.url;
|
|
507
|
+
uploadedRef = uploadResult.ref;
|
|
508
|
+
altText = finalAltText;
|
|
509
|
+
} catch (error) {
|
|
510
|
+
logToolError('ghost_set_feature_image', error, { phase: 'upload' });
|
|
511
|
+
return formatErrorResponse(error, 'ghost_set_feature_image');
|
|
512
|
+
}
|
|
342
513
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
514
|
+
const updatePayload = {
|
|
515
|
+
feature_image: uploadedUrl,
|
|
516
|
+
feature_image_alt: altText,
|
|
517
|
+
};
|
|
518
|
+
if (caption !== undefined) updatePayload.feature_image_caption = caption;
|
|
348
519
|
|
|
520
|
+
try {
|
|
521
|
+
const updated =
|
|
522
|
+
type === 'post'
|
|
523
|
+
? await ghostService.updatePost(id, updatePayload)
|
|
524
|
+
: await ghostService.updatePage(id, updatePayload);
|
|
525
|
+
console.error(`ghost_set_feature_image: ${type} ${id} updated with ${uploadedUrl}`);
|
|
349
526
|
return {
|
|
350
|
-
content: [
|
|
527
|
+
content: [
|
|
528
|
+
{
|
|
529
|
+
type: 'text',
|
|
530
|
+
text: JSON.stringify(
|
|
531
|
+
{ uploaded: { url: uploadedUrl, ref: uploadedRef, alt: altText }, [type]: updated },
|
|
532
|
+
null,
|
|
533
|
+
2
|
|
534
|
+
),
|
|
535
|
+
},
|
|
536
|
+
],
|
|
351
537
|
};
|
|
352
538
|
} catch (error) {
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
539
|
+
logToolError('ghost_set_feature_image', error, {
|
|
540
|
+
phase: 'update',
|
|
541
|
+
orphanedUrl: uploadedUrl,
|
|
542
|
+
});
|
|
543
|
+
return formatErrorResponse(error, 'ghost_set_feature_image', {
|
|
544
|
+
orphanedImage: { url: uploadedUrl, ref: uploadedRef, alt: altText },
|
|
545
|
+
hint: 'Ghost does not expose a delete-image endpoint; reuse this URL or leave it orphaned.',
|
|
546
|
+
});
|
|
361
547
|
}
|
|
362
548
|
}
|
|
363
549
|
);
|
|
@@ -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,
|