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