@jgardner04/ghost-mcp-server 1.0.0 → 1.1.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.
@@ -1,6 +1,6 @@
1
- import GhostAdminAPI from "@tryghost/admin-api";
1
+ import GhostAdminAPI from '@tryghost/admin-api';
2
2
  import sanitizeHtml from 'sanitize-html';
3
- import dotenv from "dotenv";
3
+ import dotenv from 'dotenv';
4
4
  import { promises as fs } from 'fs';
5
5
  import {
6
6
  GhostAPIError,
@@ -9,8 +9,8 @@ import {
9
9
  NotFoundError,
10
10
  ErrorHandler,
11
11
  CircuitBreaker,
12
- retryWithBackoff
13
- } from "../errors/index.js";
12
+ retryWithBackoff,
13
+ } from '../errors/index.js';
14
14
 
15
15
  dotenv.config();
16
16
 
@@ -19,10 +19,8 @@ const { GHOST_ADMIN_API_URL, GHOST_ADMIN_API_KEY } = process.env;
19
19
  // Validate configuration at startup
20
20
  if (!GHOST_ADMIN_API_URL || !GHOST_ADMIN_API_KEY) {
21
21
  throw new ConfigurationError(
22
- "Ghost Admin API configuration is incomplete",
23
- ["GHOST_ADMIN_API_URL", "GHOST_ADMIN_API_KEY"].filter(
24
- key => !process.env[key]
25
- )
22
+ 'Ghost Admin API configuration is incomplete',
23
+ ['GHOST_ADMIN_API_URL', 'GHOST_ADMIN_API_KEY'].filter((key) => !process.env[key])
26
24
  );
27
25
  }
28
26
 
@@ -30,31 +28,23 @@ if (!GHOST_ADMIN_API_URL || !GHOST_ADMIN_API_KEY) {
30
28
  const api = new GhostAdminAPI({
31
29
  url: GHOST_ADMIN_API_URL,
32
30
  key: GHOST_ADMIN_API_KEY,
33
- version: "v5.0",
31
+ version: 'v5.0',
34
32
  });
35
33
 
36
34
  // Circuit breaker for Ghost API
37
35
  const ghostCircuitBreaker = new CircuitBreaker({
38
36
  failureThreshold: 5,
39
37
  resetTimeout: 60000, // 1 minute
40
- monitoringPeriod: 10000 // 10 seconds
38
+ monitoringPeriod: 10000, // 10 seconds
41
39
  });
42
40
 
43
41
  /**
44
42
  * Enhanced handler for Ghost Admin API requests with proper error handling
45
43
  */
46
- const handleApiRequest = async (
47
- resource,
48
- action,
49
- data = {},
50
- options = {},
51
- config = {}
52
- ) => {
44
+ const handleApiRequest = async (resource, action, data = {}, options = {}, config = {}) => {
53
45
  // Validate inputs
54
- if (!api[resource] || typeof api[resource][action] !== "function") {
55
- throw new ValidationError(
56
- `Invalid Ghost API resource or action: ${resource}.${action}`
57
- );
46
+ if (!api[resource] || typeof api[resource][action] !== 'function') {
47
+ throw new ValidationError(`Invalid Ghost API resource or action: ${resource}.${action}`);
58
48
  }
59
49
 
60
50
  const operation = `${resource}.${action}`;
@@ -65,23 +55,23 @@ const handleApiRequest = async (
65
55
  const executeRequest = async () => {
66
56
  try {
67
57
  console.log(`Executing Ghost API request: ${operation}`);
68
-
58
+
69
59
  let result;
70
-
60
+
71
61
  // Handle different action signatures
72
62
  switch (action) {
73
- case "add":
74
- case "edit":
63
+ case 'add':
64
+ case 'edit':
75
65
  result = await api[resource][action](data, options);
76
66
  break;
77
- case "upload":
67
+ case 'upload':
78
68
  result = await api[resource][action](data);
79
69
  break;
80
- case "browse":
81
- case "read":
70
+ case 'browse':
71
+ case 'read':
82
72
  result = await api[resource][action](options, data);
83
73
  break;
84
- case "delete":
74
+ case 'delete':
85
75
  result = await api[resource][action](data.id || data, options);
86
76
  break;
87
77
  default:
@@ -90,7 +80,6 @@ const handleApiRequest = async (
90
80
 
91
81
  console.log(`Successfully executed Ghost API request: ${operation}`);
92
82
  return result;
93
-
94
83
  } catch (error) {
95
84
  // Transform Ghost API errors into our error types
96
85
  throw ErrorHandler.fromGhostError(error, operation);
@@ -106,15 +95,15 @@ const handleApiRequest = async (
106
95
  try {
107
96
  return await retryWithBackoff(wrappedExecute, {
108
97
  maxAttempts: maxRetries,
109
- onRetry: (attempt, error) => {
98
+ onRetry: (attempt, _error) => {
110
99
  console.log(`Retrying ${operation} (attempt ${attempt}/${maxRetries})`);
111
-
100
+
112
101
  // Log circuit breaker state if relevant
113
102
  if (useCircuitBreaker) {
114
103
  const state = ghostCircuitBreaker.getState();
115
104
  console.log(`Circuit breaker state:`, state);
116
105
  }
117
- }
106
+ },
118
107
  });
119
108
  } catch (error) {
120
109
  console.error(`Failed to execute ${operation} after ${maxRetries} attempts:`, error.message);
@@ -128,23 +117,29 @@ const handleApiRequest = async (
128
117
  const validators = {
129
118
  validatePostData(postData) {
130
119
  const errors = [];
131
-
120
+
132
121
  if (!postData.title || postData.title.trim().length === 0) {
133
122
  errors.push({ field: 'title', message: 'Title is required' });
134
123
  }
135
-
124
+
136
125
  if (!postData.html && !postData.mobiledoc) {
137
126
  errors.push({ field: 'content', message: 'Either html or mobiledoc content is required' });
138
127
  }
139
-
128
+
140
129
  if (postData.status && !['draft', 'published', 'scheduled'].includes(postData.status)) {
141
- errors.push({ field: 'status', message: 'Invalid status. Must be draft, published, or scheduled' });
130
+ errors.push({
131
+ field: 'status',
132
+ message: 'Invalid status. Must be draft, published, or scheduled',
133
+ });
142
134
  }
143
-
135
+
144
136
  if (postData.status === 'scheduled' && !postData.published_at) {
145
- errors.push({ field: 'published_at', message: 'published_at is required when status is scheduled' });
137
+ errors.push({
138
+ field: 'published_at',
139
+ message: 'published_at is required when status is scheduled',
140
+ });
146
141
  }
147
-
142
+
148
143
  if (postData.published_at) {
149
144
  const publishDate = new Date(postData.published_at);
150
145
  if (isNaN(publishDate.getTime())) {
@@ -153,7 +148,7 @@ const validators = {
153
148
  errors.push({ field: 'published_at', message: 'Scheduled date must be in the future' });
154
149
  }
155
150
  }
156
-
151
+
157
152
  if (errors.length > 0) {
158
153
  throw new ValidationError('Post validation failed', errors);
159
154
  }
@@ -161,15 +156,18 @@ const validators = {
161
156
 
162
157
  validateTagData(tagData) {
163
158
  const errors = [];
164
-
159
+
165
160
  if (!tagData.name || tagData.name.trim().length === 0) {
166
161
  errors.push({ field: 'name', message: 'Tag name is required' });
167
162
  }
168
-
169
- if (tagData.slug && !/^[a-z0-9\-]+$/.test(tagData.slug)) {
170
- errors.push({ field: 'slug', message: 'Slug must contain only lowercase letters, numbers, and hyphens' });
163
+
164
+ if (tagData.slug && !/^[a-z0-9-]+$/.test(tagData.slug)) {
165
+ errors.push({
166
+ field: 'slug',
167
+ message: 'Slug must contain only lowercase letters, numbers, and hyphens',
168
+ });
171
169
  }
172
-
170
+
173
171
  if (errors.length > 0) {
174
172
  throw new ValidationError('Tag validation failed', errors);
175
173
  }
@@ -179,14 +177,14 @@ const validators = {
179
177
  if (!imagePath || typeof imagePath !== 'string') {
180
178
  throw new ValidationError('Image path is required and must be a string');
181
179
  }
182
-
180
+
183
181
  // Check if file exists
184
182
  try {
185
183
  await fs.access(imagePath);
186
184
  } catch {
187
185
  throw new NotFoundError('Image file', imagePath);
188
186
  }
189
- }
187
+ },
190
188
  };
191
189
 
192
190
  /**
@@ -195,20 +193,20 @@ const validators = {
195
193
 
196
194
  export async function getSiteInfo() {
197
195
  try {
198
- return await handleApiRequest("site", "read");
196
+ return await handleApiRequest('site', 'read');
199
197
  } catch (error) {
200
- console.error("Failed to get site info:", error);
198
+ console.error('Failed to get site info:', error);
201
199
  throw error;
202
200
  }
203
201
  }
204
202
 
205
- export async function createPost(postData, options = { source: "html" }) {
203
+ export async function createPost(postData, options = { source: 'html' }) {
206
204
  // Validate input
207
205
  validators.validatePostData(postData);
208
-
206
+
209
207
  // Add defaults
210
208
  const dataWithDefaults = {
211
- status: "draft",
209
+ status: 'draft',
212
210
  ...postData,
213
211
  };
214
212
 
@@ -217,28 +215,51 @@ export async function createPost(postData, options = { source: "html" }) {
217
215
  // Use proper HTML sanitization library to prevent XSS
218
216
  dataWithDefaults.html = sanitizeHtml(dataWithDefaults.html, {
219
217
  allowedTags: [
220
- 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol', 'nl', 'li',
221
- 'b', 'i', 'strong', 'em', 'strike', 'code', 'hr', 'br', 'div', 'span', 'img', 'pre'
218
+ 'h1',
219
+ 'h2',
220
+ 'h3',
221
+ 'h4',
222
+ 'h5',
223
+ 'h6',
224
+ 'blockquote',
225
+ 'p',
226
+ 'a',
227
+ 'ul',
228
+ 'ol',
229
+ 'nl',
230
+ 'li',
231
+ 'b',
232
+ 'i',
233
+ 'strong',
234
+ 'em',
235
+ 'strike',
236
+ 'code',
237
+ 'hr',
238
+ 'br',
239
+ 'div',
240
+ 'span',
241
+ 'img',
242
+ 'pre',
222
243
  ],
223
244
  allowedAttributes: {
224
- 'a': ['href', 'title'],
225
- 'img': ['src', 'alt', 'title', 'width', 'height'],
226
- '*': ['class', 'id']
245
+ a: ['href', 'title'],
246
+ img: ['src', 'alt', 'title', 'width', 'height'],
247
+ '*': ['class', 'id'],
227
248
  },
228
249
  allowedSchemes: ['http', 'https', 'mailto'],
229
250
  allowedSchemesByTag: {
230
- img: ['http', 'https', 'data']
231
- }
251
+ img: ['http', 'https', 'data'],
252
+ },
232
253
  });
233
254
  }
234
255
 
235
256
  try {
236
- return await handleApiRequest("posts", "add", dataWithDefaults, options);
257
+ return await handleApiRequest('posts', 'add', dataWithDefaults, options);
237
258
  } catch (error) {
238
259
  if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
239
260
  // Transform Ghost validation errors into our format
240
261
  throw new ValidationError('Post creation failed due to validation errors', [
241
- { field: 'post', message: error.originalError }
262
+ { field: 'post', message: error.originalError },
242
263
  ]);
243
264
  }
244
265
  throw error;
@@ -249,19 +270,19 @@ export async function updatePost(postId, updateData, options = {}) {
249
270
  if (!postId) {
250
271
  throw new ValidationError('Post ID is required for update');
251
272
  }
252
-
273
+
253
274
  // Get the current post first to ensure it exists
254
275
  try {
255
- const existingPost = await handleApiRequest("posts", "read", { id: postId });
256
-
276
+ const existingPost = await handleApiRequest('posts', 'read', { id: postId });
277
+
257
278
  // Merge with existing data
258
279
  const mergedData = {
259
280
  ...existingPost,
260
281
  ...updateData,
261
- updated_at: existingPost.updated_at // Required for Ghost API
282
+ updated_at: existingPost.updated_at, // Required for Ghost API
262
283
  };
263
-
264
- return await handleApiRequest("posts", "edit", mergedData, { id: postId, ...options });
284
+
285
+ return await handleApiRequest('posts', 'edit', mergedData, { id: postId, ...options });
265
286
  } catch (error) {
266
287
  if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
267
288
  throw new NotFoundError('Post', postId);
@@ -274,9 +295,9 @@ export async function deletePost(postId) {
274
295
  if (!postId) {
275
296
  throw new ValidationError('Post ID is required for deletion');
276
297
  }
277
-
298
+
278
299
  try {
279
- return await handleApiRequest("posts", "delete", { id: postId });
300
+ return await handleApiRequest('posts', 'delete', { id: postId });
280
301
  } catch (error) {
281
302
  if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
282
303
  throw new NotFoundError('Post', postId);
@@ -289,9 +310,9 @@ export async function getPost(postId, options = {}) {
289
310
  if (!postId) {
290
311
  throw new ValidationError('Post ID is required');
291
312
  }
292
-
313
+
293
314
  try {
294
- return await handleApiRequest("posts", "read", { id: postId }, options);
315
+ return await handleApiRequest('posts', 'read', { id: postId }, options);
295
316
  } catch (error) {
296
317
  if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
297
318
  throw new NotFoundError('Post', postId);
@@ -304,13 +325,13 @@ export async function getPosts(options = {}) {
304
325
  const defaultOptions = {
305
326
  limit: 15,
306
327
  include: 'tags,authors',
307
- ...options
328
+ ...options,
308
329
  };
309
-
330
+
310
331
  try {
311
- return await handleApiRequest("posts", "browse", {}, defaultOptions);
332
+ return await handleApiRequest('posts', 'browse', {}, defaultOptions);
312
333
  } catch (error) {
313
- console.error("Failed to get posts:", error);
334
+ console.error('Failed to get posts:', error);
314
335
  throw error;
315
336
  }
316
337
  }
@@ -318,11 +339,11 @@ export async function getPosts(options = {}) {
318
339
  export async function uploadImage(imagePath) {
319
340
  // Validate input
320
341
  await validators.validateImagePath(imagePath);
321
-
342
+
322
343
  const imageData = { file: imagePath };
323
-
344
+
324
345
  try {
325
- return await handleApiRequest("images", "upload", imageData);
346
+ return await handleApiRequest('images', 'upload', imageData);
326
347
  } catch (error) {
327
348
  if (error instanceof GhostAPIError) {
328
349
  throw new ValidationError(`Image upload failed: ${error.originalError}`);
@@ -334,7 +355,7 @@ export async function uploadImage(imagePath) {
334
355
  export async function createTag(tagData) {
335
356
  // Validate input
336
357
  validators.validateTagData(tagData);
337
-
358
+
338
359
  // Auto-generate slug if not provided
339
360
  if (!tagData.slug) {
340
361
  tagData.slug = tagData.name
@@ -342,9 +363,9 @@ export async function createTag(tagData) {
342
363
  .replace(/[^a-z0-9]+/g, '-')
343
364
  .replace(/^-+|-+$/g, '');
344
365
  }
345
-
366
+
346
367
  try {
347
- return await handleApiRequest("tags", "add", tagData);
368
+ return await handleApiRequest('tags', 'add', tagData);
348
369
  } catch (error) {
349
370
  if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
350
371
  // Check if it's a duplicate tag error
@@ -356,7 +377,7 @@ export async function createTag(tagData) {
356
377
  }
357
378
  }
358
379
  throw new ValidationError('Tag creation failed', [
359
- { field: 'tag', message: error.originalError }
380
+ { field: 'tag', message: error.originalError },
360
381
  ]);
361
382
  }
362
383
  throw error;
@@ -365,15 +386,15 @@ export async function createTag(tagData) {
365
386
 
366
387
  export async function getTags(name) {
367
388
  const options = {
368
- limit: "all",
389
+ limit: 'all',
369
390
  ...(name && { filter: `name:'${name}'` }),
370
391
  };
371
-
392
+
372
393
  try {
373
- const tags = await handleApiRequest("tags", "browse", {}, options);
394
+ const tags = await handleApiRequest('tags', 'browse', {}, options);
374
395
  return tags || [];
375
396
  } catch (error) {
376
- console.error("Failed to get tags:", error);
397
+ console.error('Failed to get tags:', error);
377
398
  throw error;
378
399
  }
379
400
  }
@@ -382,9 +403,9 @@ export async function getTag(tagId) {
382
403
  if (!tagId) {
383
404
  throw new ValidationError('Tag ID is required');
384
405
  }
385
-
406
+
386
407
  try {
387
- return await handleApiRequest("tags", "read", { id: tagId });
408
+ return await handleApiRequest('tags', 'read', { id: tagId });
388
409
  } catch (error) {
389
410
  if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
390
411
  throw new NotFoundError('Tag', tagId);
@@ -397,24 +418,24 @@ export async function updateTag(tagId, updateData) {
397
418
  if (!tagId) {
398
419
  throw new ValidationError('Tag ID is required for update');
399
420
  }
400
-
421
+
401
422
  validators.validateTagData({ name: 'dummy', ...updateData }); // Validate update data
402
-
423
+
403
424
  try {
404
425
  const existingTag = await getTag(tagId);
405
426
  const mergedData = {
406
427
  ...existingTag,
407
- ...updateData
428
+ ...updateData,
408
429
  };
409
-
410
- return await handleApiRequest("tags", "edit", mergedData, { id: tagId });
430
+
431
+ return await handleApiRequest('tags', 'edit', mergedData, { id: tagId });
411
432
  } catch (error) {
412
433
  if (error instanceof NotFoundError) {
413
434
  throw error;
414
435
  }
415
436
  if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
416
437
  throw new ValidationError('Tag update failed', [
417
- { field: 'tag', message: error.originalError }
438
+ { field: 'tag', message: error.originalError },
418
439
  ]);
419
440
  }
420
441
  throw error;
@@ -425,9 +446,9 @@ export async function deleteTag(tagId) {
425
446
  if (!tagId) {
426
447
  throw new ValidationError('Tag ID is required for deletion');
427
448
  }
428
-
449
+
429
450
  try {
430
- return await handleApiRequest("tags", "delete", { id: tagId });
451
+ return await handleApiRequest('tags', 'delete', { id: tagId });
431
452
  } catch (error) {
432
453
  if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
433
454
  throw new NotFoundError('Tag', tagId);
@@ -443,34 +464,29 @@ export async function checkHealth() {
443
464
  try {
444
465
  const site = await getSiteInfo();
445
466
  const circuitState = ghostCircuitBreaker.getState();
446
-
467
+
447
468
  return {
448
469
  status: 'healthy',
449
470
  site: {
450
471
  title: site.title,
451
472
  version: site.version,
452
- url: site.url
473
+ url: site.url,
453
474
  },
454
475
  circuitBreaker: circuitState,
455
- timestamp: new Date().toISOString()
476
+ timestamp: new Date().toISOString(),
456
477
  };
457
478
  } catch (error) {
458
479
  return {
459
480
  status: 'unhealthy',
460
481
  error: error.message,
461
482
  circuitBreaker: ghostCircuitBreaker.getState(),
462
- timestamp: new Date().toISOString()
483
+ timestamp: new Date().toISOString(),
463
484
  };
464
485
  }
465
486
  }
466
487
 
467
488
  // Export everything including the API client for backward compatibility
468
- export {
469
- api,
470
- handleApiRequest,
471
- ghostCircuitBreaker,
472
- validators
473
- };
489
+ export { api, handleApiRequest, ghostCircuitBreaker, validators };
474
490
 
475
491
  export default {
476
492
  getSiteInfo,
@@ -485,5 +501,5 @@ export default {
485
501
  getTag,
486
502
  updateTag,
487
503
  deleteTag,
488
- checkHealth
489
- };
504
+ checkHealth,
505
+ };
@@ -1,8 +1,8 @@
1
- import sharp from "sharp";
2
- import path from "path";
3
- import fs from "fs";
4
- import Joi from "joi";
5
- import { createContextLogger } from "../utils/logger.js";
1
+ import sharp from 'sharp';
2
+ import path from 'path';
3
+ import fs from 'fs';
4
+ import Joi from 'joi';
5
+ import { createContextLogger } from '../utils/logger.js';
6
6
 
7
7
  // Define processing parameters (e.g., max width)
8
8
  const MAX_WIDTH = 1200;
@@ -17,32 +17,32 @@ const OUTPUT_QUALITY = 80; // JPEG quality
17
17
  // Validation schema for processing parameters
18
18
  const processImageSchema = Joi.object({
19
19
  inputPath: Joi.string().required(),
20
- outputDir: Joi.string().required()
20
+ outputDir: Joi.string().required(),
21
21
  });
22
22
 
23
23
  const processImage = async (inputPath, outputDir) => {
24
24
  const logger = createContextLogger('image-processing');
25
-
25
+
26
26
  // Validate inputs to prevent path injection
27
27
  const { error } = processImageSchema.validate({ inputPath, outputDir });
28
28
  if (error) {
29
29
  logger.error('Invalid processing parameters', {
30
30
  error: error.details[0].message,
31
31
  inputPath: path.basename(inputPath),
32
- outputDir: path.basename(outputDir)
32
+ outputDir: path.basename(outputDir),
33
33
  });
34
34
  throw new Error('Invalid processing parameters');
35
35
  }
36
-
36
+
37
37
  // Ensure paths are safe
38
38
  const resolvedInputPath = path.resolve(inputPath);
39
39
  const resolvedOutputDir = path.resolve(outputDir);
40
-
40
+
41
41
  // Verify input file exists
42
42
  if (!fs.existsSync(resolvedInputPath)) {
43
43
  throw new Error('Input file does not exist');
44
44
  }
45
-
45
+
46
46
  const filename = path.basename(resolvedInputPath);
47
47
  const nameWithoutExt = filename.split('.').slice(0, -1).join('.');
48
48
  // Use timestamp for unique output filename
@@ -53,7 +53,7 @@ const processImage = async (inputPath, outputDir) => {
53
53
  try {
54
54
  logger.info('Processing image', {
55
55
  inputFile: path.basename(inputPath),
56
- outputDir: path.basename(outputDir)
56
+ outputDir: path.basename(outputDir),
57
57
  });
58
58
  const image = sharp(inputPath);
59
59
  const metadata = await image.metadata();
@@ -65,7 +65,7 @@ const processImage = async (inputPath, outputDir) => {
65
65
  logger.info('Resizing image', {
66
66
  originalWidth: metadata.width,
67
67
  targetWidth: MAX_WIDTH,
68
- inputFile: path.basename(inputPath)
68
+ inputFile: path.basename(inputPath),
69
69
  });
70
70
  processedImage = processedImage.resize({ width: MAX_WIDTH });
71
71
  }
@@ -78,14 +78,14 @@ const processImage = async (inputPath, outputDir) => {
78
78
  inputFile: path.basename(inputPath),
79
79
  outputFile: path.basename(outputPath),
80
80
  originalSize: metadata.size,
81
- quality: OUTPUT_QUALITY
81
+ quality: OUTPUT_QUALITY,
82
82
  });
83
83
  return outputPath;
84
84
  } catch (error) {
85
85
  logger.error('Image processing failed', {
86
86
  inputFile: path.basename(inputPath),
87
87
  error: error.message,
88
- stack: error.stack
88
+ stack: error.stack,
89
89
  });
90
90
  // If processing fails, maybe fall back to using the original?
91
91
  // Or throw the error to fail the upload.