@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgardner04/ghost-mcp-server",
3
- "version": "1.13.5",
3
+ "version": "1.14.0",
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
@@ -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
- const uploadImageSchema = z.object({
271
- imageUrl: z.string().meta({ description: 'The publicly accessible URL of the image to upload.' }),
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 — unique handler with finally-clause cleanup
437
+ // Upload Image Tool
279
438
  server.registerTool(
280
439
  'ghost_upload_image',
281
440
  {
282
441
  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.',
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 = 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;
446
+ const validation = validateAndXorImageInput(uploadImageSchema, rawInput, 'ghost_upload_image');
447
+ if (!validation.success) return validation.errorResponse;
296
448
 
297
449
  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}`);
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
- // 5. Upload processed image to Ghost
340
- const uploadResult = await ghostService.uploadImage(processedPath);
341
- console.error(`Uploaded processed image to Ghost: ${uploadResult.url}`);
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
- // 6. Return result
344
- const result = {
345
- url: uploadResult.url,
346
- alt: finalAltText,
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: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
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(`Error in ghost_upload_image:`, error);
537
+ console.error(`ghost_set_feature_image: update failed (orphaned ${uploadedUrl})`, error);
354
538
  return {
355
- content: [{ type: 'text', text: `Error uploading image: ${error.message}` }],
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 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,
@@ -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');