@jgardner04/ghost-mcp-server 1.13.3 → 1.13.5

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,48 +1,10 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { createMockContextLogger } from '../../__tests__/helpers/mockLogger.js';
3
3
  import { mockDotenv } from '../../__tests__/helpers/testUtils.js';
4
+ import { mockGhostApiModule } from '../../__tests__/helpers/mockGhostApi.js';
4
5
 
5
- // Mock the Ghost Admin API with tags support
6
- vi.mock('@tryghost/admin-api', () => ({
7
- default: vi.fn(function () {
8
- return {
9
- posts: {
10
- add: vi.fn(),
11
- browse: vi.fn(),
12
- read: vi.fn(),
13
- edit: vi.fn(),
14
- delete: vi.fn(),
15
- },
16
- pages: {
17
- add: vi.fn(),
18
- browse: vi.fn(),
19
- read: vi.fn(),
20
- edit: vi.fn(),
21
- delete: vi.fn(),
22
- },
23
- tags: {
24
- add: vi.fn(),
25
- browse: vi.fn(),
26
- read: vi.fn(),
27
- edit: vi.fn(),
28
- delete: vi.fn(),
29
- },
30
- members: {
31
- add: vi.fn(),
32
- browse: vi.fn(),
33
- read: vi.fn(),
34
- edit: vi.fn(),
35
- delete: vi.fn(),
36
- },
37
- site: {
38
- read: vi.fn(),
39
- },
40
- images: {
41
- upload: vi.fn(),
42
- },
43
- };
44
- }),
45
- }));
6
+ // Mock the Ghost Admin API using shared mock factory
7
+ vi.mock('@tryghost/admin-api', () => mockGhostApiModule());
46
8
 
47
9
  // Mock dotenv
48
10
  vi.mock('dotenv', () => mockDotenv());
@@ -69,6 +31,7 @@ import {
69
31
  api,
70
32
  ghostCircuitBreaker,
71
33
  } from '../ghostServiceImproved.js';
34
+ import { GhostAPIError, NotFoundError } from '../../errors/index.js';
72
35
 
73
36
  describe('ghostServiceImproved - Tags', () => {
74
37
  beforeEach(() => {
@@ -288,12 +251,12 @@ describe('ghostServiceImproved - Tags', () => {
288
251
  });
289
252
 
290
253
  it('should throw not found error when tag does not exist', async () => {
291
- api.tags.read.mockRejectedValue({
292
- response: { status: 404 },
293
- message: 'Tag not found',
294
- });
254
+ const error404 = new GhostAPIError('tags.read', 'Tag not found', 404);
255
+ api.tags.read.mockRejectedValue(error404);
295
256
 
296
- await expect(getTag('non-existent')).rejects.toThrow();
257
+ const rejection = getTag('non-existent');
258
+ await expect(rejection).rejects.toBeInstanceOf(NotFoundError);
259
+ await expect(rejection).rejects.toThrow('Tag not found');
297
260
  });
298
261
  });
299
262
 
@@ -381,10 +344,9 @@ describe('ghostServiceImproved - Tags', () => {
381
344
  };
382
345
 
383
346
  // First call fails with duplicate error
384
- api.tags.add.mockRejectedValue({
385
- response: { status: 422 },
386
- message: 'Tag already exists',
387
- });
347
+ const error422 = new GhostAPIError('tags.add', 'Tag already exists', 422);
348
+ error422.response = { status: 422, data: { errors: [{ message: 'Tag already exists' }] } };
349
+ api.tags.add.mockRejectedValue(error422);
388
350
 
389
351
  // getTags returns existing tag when called with name filter
390
352
  api.tags.browse.mockResolvedValue([{ id: 'tag-1', name: 'JavaScript', slug: 'javascript' }]);
@@ -443,18 +405,16 @@ describe('ghostServiceImproved - Tags', () => {
443
405
  });
444
406
 
445
407
  it('should throw validation error for missing tag ID', async () => {
446
- await expect(updateTag(null, { name: 'Test' })).rejects.toThrow(
447
- 'Tag ID is required for update'
448
- );
408
+ await expect(updateTag(null, { name: 'Test' })).rejects.toThrow('Tag ID is required');
449
409
  });
450
410
 
451
411
  it('should throw not found error if tag does not exist', async () => {
452
- api.tags.read.mockRejectedValue({
453
- response: { status: 404 },
454
- message: 'Tag not found',
455
- });
412
+ const error404 = new GhostAPIError('tags.read', 'Tag not found', 404);
413
+ api.tags.read.mockRejectedValue(error404);
456
414
 
457
- await expect(updateTag('non-existent', { name: 'Test' })).rejects.toThrow();
415
+ const rejection = updateTag('non-existent', { name: 'Test' });
416
+ await expect(rejection).rejects.toBeInstanceOf(NotFoundError);
417
+ await expect(rejection).rejects.toThrow('Tag not found');
458
418
  });
459
419
  });
460
420
 
@@ -471,16 +431,16 @@ describe('ghostServiceImproved - Tags', () => {
471
431
  });
472
432
 
473
433
  it('should throw validation error for missing tag ID', async () => {
474
- await expect(deleteTag(null)).rejects.toThrow('Tag ID is required for deletion');
434
+ await expect(deleteTag(null)).rejects.toThrow('Tag ID is required');
475
435
  });
476
436
 
477
437
  it('should throw not found error if tag does not exist', async () => {
478
- api.tags.delete.mockRejectedValue({
479
- response: { status: 404 },
480
- message: 'Tag not found',
481
- });
438
+ const error404 = new GhostAPIError('tags.delete', 'Tag not found', 404);
439
+ api.tags.delete.mockRejectedValue(error404);
482
440
 
483
- await expect(deleteTag('non-existent')).rejects.toThrow();
441
+ const rejection = deleteTag('non-existent');
442
+ await expect(rejection).rejects.toBeInstanceOf(NotFoundError);
443
+ await expect(rejection).rejects.toThrow('Tag not found');
484
444
  });
485
445
  });
486
446
  });
@@ -1,55 +1,10 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { createMockContextLogger } from '../../__tests__/helpers/mockLogger.js';
3
3
  import { mockDotenv } from '../../__tests__/helpers/testUtils.js';
4
+ import { mockGhostApiModule } from '../../__tests__/helpers/mockGhostApi.js';
4
5
 
5
- // Mock the Ghost Admin API with tiers support
6
- vi.mock('@tryghost/admin-api', () => ({
7
- default: vi.fn(function () {
8
- return {
9
- posts: {
10
- add: vi.fn(),
11
- browse: vi.fn(),
12
- read: vi.fn(),
13
- edit: vi.fn(),
14
- delete: vi.fn(),
15
- },
16
- pages: {
17
- add: vi.fn(),
18
- browse: vi.fn(),
19
- read: vi.fn(),
20
- edit: vi.fn(),
21
- delete: vi.fn(),
22
- },
23
- tags: {
24
- add: vi.fn(),
25
- browse: vi.fn(),
26
- read: vi.fn(),
27
- edit: vi.fn(),
28
- delete: vi.fn(),
29
- },
30
- members: {
31
- add: vi.fn(),
32
- browse: vi.fn(),
33
- read: vi.fn(),
34
- edit: vi.fn(),
35
- delete: vi.fn(),
36
- },
37
- tiers: {
38
- add: vi.fn(),
39
- browse: vi.fn(),
40
- read: vi.fn(),
41
- edit: vi.fn(),
42
- delete: vi.fn(),
43
- },
44
- site: {
45
- read: vi.fn(),
46
- },
47
- images: {
48
- upload: vi.fn(),
49
- },
50
- };
51
- }),
52
- }));
6
+ // Mock the Ghost Admin API using shared mock factory
7
+ vi.mock('@tryghost/admin-api', () => mockGhostApiModule());
53
8
 
54
9
  // Mock dotenv
55
10
  vi.mock('dotenv', () => mockDotenv());
@@ -340,9 +295,7 @@ describe('ghostServiceImproved - Tiers', () => {
340
295
  });
341
296
 
342
297
  it('should throw ValidationError when ID is missing', async () => {
343
- await expect(updateTier('', { name: 'Updated' })).rejects.toThrow(
344
- 'Tier ID is required for update'
345
- );
298
+ await expect(updateTier('', { name: 'Updated' })).rejects.toThrow('Tier ID is required');
346
299
  });
347
300
 
348
301
  it('should throw ValidationError for invalid update data', async () => {
@@ -372,11 +325,11 @@ describe('ghostServiceImproved - Tiers', () => {
372
325
  });
373
326
 
374
327
  it('should throw ValidationError when ID is missing', async () => {
375
- await expect(deleteTier()).rejects.toThrow('Tier ID is required for deletion');
328
+ await expect(deleteTier()).rejects.toThrow('Tier ID is required');
376
329
  });
377
330
 
378
331
  it('should throw ValidationError when ID is empty string', async () => {
379
- await expect(deleteTier('')).rejects.toThrow('Tier ID is required for deletion');
332
+ await expect(deleteTier('')).rejects.toThrow('Tier ID is required');
380
333
  });
381
334
 
382
335
  it('should throw NotFoundError when tier does not exist', async () => {
@@ -0,0 +1,138 @@
1
+ import { GhostAPIError, ValidationError } from '../errors/index.js';
2
+ import { handleApiRequest, readResource, updateWithOCC, deleteResource } from './ghostApiClient.js';
3
+ import { validators } from './validators.js';
4
+
5
+ /**
6
+ * Factory that generates standard CRUD service methods for a Ghost CMS resource.
7
+ *
8
+ * Each domain (posts, pages, tags, etc.) shares the same structural patterns:
9
+ * create → validate → API add → 422 mapping
10
+ * update → requireId → optional validation → OCC edit → optional 422 mapping
11
+ * remove → deleteResource
12
+ * getOne → readResource
13
+ * getList → browse with defaults → empty-array fallback
14
+ *
15
+ * Domain-specific behavior is injected via config hooks.
16
+ *
17
+ * @param {Object} config - Resource configuration
18
+ * @param {string} config.resource - Ghost API resource name (e.g., 'posts')
19
+ * @param {string} config.label - Human-readable label (e.g., 'Post')
20
+ * @param {Object} [config.listDefaults] - Default options for getList (e.g., { limit: 15, include: 'tags,authors' })
21
+ * @param {Object} [config.createDefaults] - Default data merged into create payload (e.g., { status: 'draft' })
22
+ * @param {Object} [config.createOptions] - Default options for create API call (e.g., { source: 'html' })
23
+ * @param {Function} [config.validateCreate] - Validation function called before create: (data) => void | Promise<void>
24
+ * @param {Function} [config.validateUpdate] - Validation function called before update: (id, data) => void | Promise<void>
25
+ * @param {boolean} [config.catch422OnUpdate=false] - Whether to catch 422 errors on update and wrap as ValidationError
26
+ * @returns {Object} Object with { create, update, remove, getOne, getList } methods
27
+ *
28
+ * SECURITY: HTML content must be sanitized before reaching this function.
29
+ * See htmlContentSchema in schemas/common.js for the validation gate.
30
+ */
31
+ export function createResourceService(config) {
32
+ const {
33
+ resource,
34
+ label,
35
+ listDefaults = { limit: 15 },
36
+ createDefaults = {},
37
+ createOptions = {},
38
+ validateCreate,
39
+ validateUpdate,
40
+ catch422OnUpdate = false,
41
+ } = config;
42
+
43
+ /**
44
+ * Creates a new resource.
45
+ * @param {Object} data - Resource data
46
+ * @param {Object} [options] - API request options (merged with createOptions)
47
+ * @returns {Promise<Object>} Created resource
48
+ */
49
+ async function create(data, options = {}) {
50
+ if (validateCreate) {
51
+ await validateCreate(data);
52
+ }
53
+
54
+ const dataWithDefaults = {
55
+ ...createDefaults,
56
+ ...data,
57
+ };
58
+
59
+ const mergedOptions = { ...createOptions, ...options };
60
+
61
+ try {
62
+ return await handleApiRequest(resource, 'add', dataWithDefaults, mergedOptions);
63
+ } catch (error) {
64
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
65
+ throw new ValidationError(`${label} creation failed due to validation errors`, [
66
+ { field: label.toLowerCase(), message: error.originalError },
67
+ ]);
68
+ }
69
+ throw error;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Updates an existing resource with optimistic concurrency control.
75
+ * @param {string} id - Resource ID
76
+ * @param {Object} updateData - Fields to update
77
+ * @param {Object} [options={}] - API request options
78
+ * @returns {Promise<Object>} Updated resource
79
+ */
80
+ async function update(id, updateData, options = {}) {
81
+ validators.requireId(id, label);
82
+
83
+ if (validateUpdate) {
84
+ await validateUpdate(id, updateData);
85
+ }
86
+
87
+ if (catch422OnUpdate) {
88
+ try {
89
+ return await updateWithOCC(resource, id, updateData, options, label);
90
+ } catch (error) {
91
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
92
+ throw new ValidationError(`${label} update failed`, [
93
+ { field: label.toLowerCase(), message: error.originalError },
94
+ ]);
95
+ }
96
+ throw error;
97
+ }
98
+ }
99
+
100
+ return updateWithOCC(resource, id, updateData, options, label);
101
+ }
102
+
103
+ /**
104
+ * Deletes a resource by ID.
105
+ * @param {string} id - Resource ID
106
+ * @returns {Promise<Object>} Deletion confirmation
107
+ */
108
+ async function remove(id) {
109
+ return deleteResource(resource, id, label);
110
+ }
111
+
112
+ /**
113
+ * Retrieves a single resource by ID.
114
+ * @param {string} id - Resource ID
115
+ * @param {Object} [options={}] - API request options
116
+ * @returns {Promise<Object>} Resource object
117
+ */
118
+ async function getOne(id, options = {}) {
119
+ return readResource(resource, id, label, options);
120
+ }
121
+
122
+ /**
123
+ * Lists resources with optional filtering and pagination.
124
+ * @param {Object} [options={}] - Query options
125
+ * @returns {Promise<Array>} Array of resources (empty array if none found)
126
+ */
127
+ async function getList(options = {}) {
128
+ const mergedOptions = {
129
+ ...listDefaults,
130
+ ...options,
131
+ };
132
+
133
+ const result = await handleApiRequest(resource, 'browse', {}, mergedOptions);
134
+ return result || [];
135
+ }
136
+
137
+ return { create, update, remove, getOne, getList };
138
+ }
@@ -0,0 +1,240 @@
1
+ import GhostAdminAPI from '@tryghost/admin-api';
2
+ import dotenv from 'dotenv';
3
+ import {
4
+ GhostAPIError,
5
+ ConfigurationError,
6
+ ValidationError,
7
+ NotFoundError,
8
+ ErrorHandler,
9
+ CircuitBreaker,
10
+ retryWithBackoff,
11
+ } from '../errors/index.js';
12
+ import { createContextLogger } from '../utils/logger.js';
13
+ import { validators } from './validators.js';
14
+
15
+ dotenv.config();
16
+
17
+ const logger = createContextLogger('ghost-service-improved');
18
+
19
+ const { GHOST_ADMIN_API_URL, GHOST_ADMIN_API_KEY } = process.env;
20
+
21
+ // Validate configuration at startup
22
+ if (!GHOST_ADMIN_API_URL || !GHOST_ADMIN_API_KEY) {
23
+ throw new ConfigurationError(
24
+ 'Ghost Admin API configuration is incomplete',
25
+ ['GHOST_ADMIN_API_URL', 'GHOST_ADMIN_API_KEY'].filter((key) => !process.env[key])
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 circuit breaker and retry logic.
45
+ * Routes requests to the appropriate Ghost API method based on the action type.
46
+ * @param {string} resource - Ghost API resource name (e.g., 'posts', 'pages', 'tags')
47
+ * @param {string} action - API action to perform ('add', 'edit', 'upload', 'browse', 'read', 'delete')
48
+ * @param {Object} [data={}] - Request payload data
49
+ * @param {Object} [options={}] - Additional options passed to the Ghost API (e.g., filters, includes)
50
+ * @param {Object} [config={}] - Execution configuration
51
+ * @param {number} [config.maxRetries=3] - Maximum number of retry attempts
52
+ * @param {boolean} [config.useCircuitBreaker=true] - Whether to use the circuit breaker
53
+ * @returns {Promise<Object>} The Ghost API response
54
+ * @throws {ValidationError} If the resource or action is invalid
55
+ * @throws {GhostAPIError} If the Ghost API returns an error after all retries
56
+ */
57
+ const handleApiRequest = async (resource, action, data = {}, options = {}, config = {}) => {
58
+ // Validate inputs
59
+ if (!api[resource] || typeof api[resource][action] !== 'function') {
60
+ throw new ValidationError(`Invalid Ghost API resource or action: ${resource}.${action}`);
61
+ }
62
+
63
+ const operation = `${resource}.${action}`;
64
+ const maxRetries = config.maxRetries ?? 3;
65
+ const useCircuitBreaker = config.useCircuitBreaker ?? true;
66
+
67
+ // Main execution function
68
+ const executeRequest = async () => {
69
+ try {
70
+ logger.info('Executing Ghost API request', { operation });
71
+
72
+ let result;
73
+
74
+ // Handle different action signatures
75
+ switch (action) {
76
+ case 'add':
77
+ case 'edit':
78
+ result = await api[resource][action](data, options);
79
+ break;
80
+ case 'upload':
81
+ result = await api[resource][action](data);
82
+ break;
83
+ case 'browse':
84
+ case 'read':
85
+ result = await api[resource][action](options, data);
86
+ break;
87
+ case 'delete':
88
+ result = await api[resource][action](data.id || data, options);
89
+ break;
90
+ default:
91
+ result = await api[resource][action](data);
92
+ }
93
+
94
+ logger.info('Successfully executed Ghost API request', { operation });
95
+ return result;
96
+ } catch (error) {
97
+ // Transform Ghost API errors into our error types
98
+ throw ErrorHandler.fromGhostError(error, operation);
99
+ }
100
+ };
101
+
102
+ // Wrap with circuit breaker if enabled
103
+ const wrappedExecute = useCircuitBreaker
104
+ ? () => ghostCircuitBreaker.execute(executeRequest)
105
+ : executeRequest;
106
+
107
+ // Execute with retry logic
108
+ try {
109
+ return await retryWithBackoff(wrappedExecute, {
110
+ maxAttempts: maxRetries,
111
+ onRetry: (attempt, _error) => {
112
+ logger.info('Retrying Ghost API request', { operation, attempt, maxRetries });
113
+
114
+ // Log circuit breaker state if relevant
115
+ if (useCircuitBreaker) {
116
+ const state = ghostCircuitBreaker.getState();
117
+ logger.info('Circuit breaker state', { operation, state });
118
+ }
119
+ },
120
+ });
121
+ } catch (error) {
122
+ logger.error('Failed to execute Ghost API request', {
123
+ operation,
124
+ maxRetries,
125
+ error: error.message,
126
+ });
127
+ throw error;
128
+ }
129
+ };
130
+
131
+ /**
132
+ * Reads a single resource by ID with 404-to-NotFoundError handling.
133
+ * @param {string} resource - Ghost API resource name (e.g., 'posts', 'tags')
134
+ * @param {string} id - The resource ID to read
135
+ * @param {string} label - Human-readable resource label for error messages
136
+ * @param {Object} [options={}] - Additional options passed to the Ghost API
137
+ * @returns {Promise<Object>} The resource object from Ghost
138
+ * @throws {ValidationError} If the ID is missing or invalid
139
+ * @throws {NotFoundError} If the resource is not found (404)
140
+ * @throws {GhostAPIError} If the API request fails for other reasons
141
+ */
142
+ async function readResource(resource, id, label, options = {}) {
143
+ validators.requireId(id, label);
144
+ try {
145
+ return await handleApiRequest(resource, 'read', { id }, options);
146
+ } catch (error) {
147
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
148
+ throw new NotFoundError(label, id);
149
+ }
150
+ throw error;
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Updates a resource using optimistic concurrency control (OCC).
156
+ * Reads the current version first to obtain updated_at, then merges it into the edit payload.
157
+ * @param {string} resource - Ghost API resource name (e.g., 'posts', 'tags')
158
+ * @param {string} id - The resource ID to update
159
+ * @param {Object} updateData - Fields to update on the resource
160
+ * @param {Object} [options={}] - Additional options passed to the Ghost API
161
+ * @param {string} [label=resource] - Human-readable resource label for error messages
162
+ * @returns {Promise<Object>} The updated resource object from Ghost
163
+ * @throws {ValidationError} If the ID is missing or invalid
164
+ * @throws {NotFoundError} If the resource is not found (404)
165
+ * @throws {GhostAPIError} If the API request fails for other reasons
166
+ */
167
+ async function updateWithOCC(resource, id, updateData, options = {}, label = resource) {
168
+ const existing = await readResource(resource, id, label);
169
+ const editData = { ...updateData, updated_at: existing.updated_at };
170
+ try {
171
+ return await handleApiRequest(resource, 'edit', { id, ...editData }, options);
172
+ } catch (error) {
173
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
174
+ throw new NotFoundError(label, id);
175
+ }
176
+ throw error;
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Deletes a resource by ID with 404-to-NotFoundError handling.
182
+ * @param {string} resource - Ghost API resource name (e.g., 'posts', 'tags')
183
+ * @param {string} id - The resource ID to delete
184
+ * @param {string} label - Human-readable resource label for error messages
185
+ * @returns {Promise<Object>} The Ghost API deletion response
186
+ * @throws {ValidationError} If the ID is missing or invalid
187
+ * @throws {NotFoundError} If the resource is not found (404)
188
+ * @throws {GhostAPIError} If the API request fails for other reasons
189
+ */
190
+ async function deleteResource(resource, id, label) {
191
+ validators.requireId(id, label);
192
+ try {
193
+ return await handleApiRequest(resource, 'delete', { id });
194
+ } catch (error) {
195
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
196
+ throw new NotFoundError(label, id);
197
+ }
198
+ throw error;
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Retrieves Ghost site metadata (title, version, URL).
204
+ * @returns {Promise<Object>} Site information object
205
+ * @throws {GhostAPIError} If the API request fails
206
+ */
207
+ export async function getSiteInfo() {
208
+ return handleApiRequest('site', 'read');
209
+ }
210
+
211
+ /**
212
+ * Checks the health of the Ghost API connection and circuit breaker state.
213
+ * @returns {Promise<Object>} Health status with site info, circuit breaker state, and timestamp
214
+ */
215
+ export async function checkHealth() {
216
+ try {
217
+ const site = await getSiteInfo();
218
+ const circuitState = ghostCircuitBreaker.getState();
219
+
220
+ return {
221
+ status: 'healthy',
222
+ site: {
223
+ title: site.title,
224
+ version: site.version,
225
+ url: site.url,
226
+ },
227
+ circuitBreaker: circuitState,
228
+ timestamp: new Date().toISOString(),
229
+ };
230
+ } catch (error) {
231
+ return {
232
+ status: 'unhealthy',
233
+ error: error.message,
234
+ circuitBreaker: ghostCircuitBreaker.getState(),
235
+ timestamp: new Date().toISOString(),
236
+ };
237
+ }
238
+ }
239
+
240
+ export { api, ghostCircuitBreaker, handleApiRequest, readResource, updateWithOCC, deleteResource };