@jgardner04/ghost-mcp-server 1.13.4 → 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.
Files changed (40) hide show
  1. package/README.md +68 -0
  2. package/package.json +7 -3
  3. package/src/__tests__/helpers/testUtils.js +15 -1
  4. package/src/__tests__/mcp_server.test.js +152 -1
  5. package/src/__tests__/mcp_server_pages.test.js +23 -6
  6. package/src/controllers/__tests__/imageController.test.js +2 -2
  7. package/src/controllers/imageController.js +11 -10
  8. package/src/mcp_server.js +647 -1203
  9. package/src/routes/__tests__/imageRoutes.test.js +2 -2
  10. package/src/schemas/__tests__/common.test.js +3 -3
  11. package/src/schemas/__tests__/pageSchemas.test.js +11 -2
  12. package/src/schemas/common.js +3 -2
  13. package/src/schemas/pageSchemas.js +1 -1
  14. package/src/schemas/postSchemas.js +1 -1
  15. package/src/services/__tests__/createResourceService.test.js +468 -0
  16. package/src/services/__tests__/ghostService.test.js +0 -19
  17. package/src/services/__tests__/ghostServiceImproved.members.test.js +0 -3
  18. package/src/services/__tests__/ghostServiceImproved.newsletters.test.js +2 -2
  19. package/src/services/__tests__/ghostServiceImproved.pages.test.js +0 -3
  20. package/src/services/__tests__/ghostServiceImproved.posts.test.js +0 -1
  21. package/src/services/__tests__/ghostServiceImproved.tags.test.js +0 -3
  22. package/src/services/__tests__/ghostServiceImproved.tiers.test.js +3 -5
  23. package/src/services/__tests__/imageProcessingService.test.js +148 -177
  24. package/src/services/__tests__/images.test.js +78 -0
  25. package/src/services/createResourceService.js +138 -0
  26. package/src/services/ghostApiClient.js +240 -0
  27. package/src/services/ghostService.js +1 -19
  28. package/src/services/ghostServiceImproved.js +76 -915
  29. package/src/services/imageProcessingService.js +100 -56
  30. package/src/services/images.js +54 -0
  31. package/src/services/members.js +127 -0
  32. package/src/services/newsletters.js +63 -0
  33. package/src/services/pageService.js +2 -2
  34. package/src/services/pages.js +116 -0
  35. package/src/services/posts.js +116 -0
  36. package/src/services/tags.js +118 -0
  37. package/src/services/tiers.js +72 -0
  38. package/src/services/validators.js +218 -0
  39. package/src/utils/__tests__/imageInputResolver.test.js +134 -0
  40. package/src/utils/imageInputResolver.js +127 -0
@@ -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 };
@@ -148,24 +148,6 @@ const createPost = async (postData, options = { source: 'html' }) => {
148
148
  return handleApiRequest('posts', 'add', dataWithDefaults, options);
149
149
  };
150
150
 
151
- /**
152
- * Uploads an image to Ghost.
153
- * Requires the image file path.
154
- * @param {string} imagePath - The local path to the image file.
155
- * @returns {Promise<object>} The result from the image upload API call, typically includes the URL of the uploaded image.
156
- */
157
- const uploadImage = async (imagePath) => {
158
- if (!imagePath) {
159
- throw new Error('Image path is required for upload.');
160
- }
161
-
162
- // The Ghost Admin API expects an object with a 'file' property containing the path
163
- const imageData = { file: imagePath };
164
-
165
- // Use the handleApiRequest function for consistency
166
- return handleApiRequest('images', 'upload', imageData);
167
- };
168
-
169
151
  /**
170
152
  * Creates a new tag in Ghost.
171
153
  * @param {object} tagData - Data for the new tag (e.g., { name: 'New Tag', slug: 'new-tag' }).
@@ -209,4 +191,4 @@ const getTags = async (options = {}) => {
209
191
  // Add other content management functions here (createTag, etc.)
210
192
 
211
193
  // Export the API client instance and any service functions
212
- export { api, getSiteInfo, handleApiRequest, createPost, uploadImage, createTag, getTags };
194
+ export { api, getSiteInfo, handleApiRequest, createPost, createTag, getTags };