@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgardner04/ghost-mcp-server",
3
- "version": "1.13.5",
3
+ "version": "1.14.1",
4
4
  "description": "A Model Context Protocol (MCP) server for interacting with Ghost CMS via the Admin API",
5
5
  "author": "Jonathan Gardner",
6
6
  "type": "module",
@@ -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/ghostService.js', () => ({
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(501), // exceeds 500 char limit
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/ghostService.js'; // Assuming uploadImage is in ghostService
9
- import { processImage } from '../services/imageProcessingService.js'; // Import the processing service
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 and sanitize alt text from the request body
198
- const altSchema = Joi.string().max(500).allow('').optional();
199
- const { error, value: sanitizedAlt } = altSchema.validate(req.body.alt);
200
-
201
- if (error) {
202
- return res.status(400).json({ message: `Invalid alt text: ${error.details[0].message}` });
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
- console.error(`Error in ${toolName}:`, error);
114
- if (error.name === 'ZodError') {
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
- const uploadImageSchema = z.object({
271
- imageUrl: z.string().meta({ description: 'The publicly accessible URL of the image to upload.' }),
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
- // Upload Image Tool — unique handler with finally-clause cleanup
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
- 'Downloads an image from a URL, processes it, uploads it to Ghost CMS, and returns the final Ghost image URL and alt text.',
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 = validateToolInput(uploadImageSchema, rawInput, 'ghost_upload_image');
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 loadServices();
299
-
300
- // 1. Validate URL for SSRF protection
301
- const urlValidation = urlValidator.validateImageUrl(imageUrl);
302
- if (!urlValidation.isValid) {
303
- throw new Error(`Invalid image URL: ${urlValidation.error}`);
304
- }
305
-
306
- // 2. Download the image with security controls
307
- const axiosConfig = urlValidator.createSecureAxiosConfig(urlValidation.sanitizedUrl);
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
- // 4. Determine Alt Text
335
- const defaultAlt = getDefaultAltText(originalFilenameHint);
336
- const finalAltText = alt || defaultAlt;
337
- console.error(`Using alt text: "${finalAltText}"`);
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
- // 5. Upload processed image to Ghost
340
- const uploadResult = await ghostService.uploadImage(processedPath);
341
- console.error(`Uploaded processed image to Ghost: ${uploadResult.url}`);
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
- // 6. Return result
344
- const result = {
345
- url: uploadResult.url,
346
- alt: finalAltText,
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: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
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
- console.error(`Error in ghost_upload_image:`, error);
354
- return {
355
- content: [{ type: 'text', text: `Error uploading image: ${error.message}` }],
356
- isError: true,
357
- };
358
- } finally {
359
- // Cleanup temporary files with proper async/await
360
- await cleanupTempFiles([downloadedPath, processedPath], console);
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 ghost service
45
- vi.mock('../../services/ghostService.js', () => ({
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(125))).not.toThrow();
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(126))).toThrow();
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(501),
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',
@@ -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(125, 'Feature image alt text cannot exceed 125 characters')
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(500, 'Caption cannot exceed 500 characters').optional(),
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(500, 'Caption cannot exceed 500 characters').optional(),
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,