@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.
- package/README.md +68 -0
- package/package.json +7 -3
- package/src/__tests__/helpers/testUtils.js +15 -1
- package/src/__tests__/mcp_server.test.js +152 -1
- package/src/__tests__/mcp_server_pages.test.js +23 -6
- package/src/controllers/__tests__/imageController.test.js +2 -2
- package/src/controllers/imageController.js +11 -10
- package/src/mcp_server.js +647 -1203
- package/src/routes/__tests__/imageRoutes.test.js +2 -2
- package/src/schemas/__tests__/common.test.js +3 -3
- package/src/schemas/__tests__/pageSchemas.test.js +11 -2
- package/src/schemas/common.js +3 -2
- package/src/schemas/pageSchemas.js +1 -1
- package/src/schemas/postSchemas.js +1 -1
- package/src/services/__tests__/createResourceService.test.js +468 -0
- package/src/services/__tests__/ghostService.test.js +0 -19
- package/src/services/__tests__/ghostServiceImproved.members.test.js +0 -3
- package/src/services/__tests__/ghostServiceImproved.newsletters.test.js +2 -2
- package/src/services/__tests__/ghostServiceImproved.pages.test.js +0 -3
- package/src/services/__tests__/ghostServiceImproved.posts.test.js +0 -1
- package/src/services/__tests__/ghostServiceImproved.tags.test.js +0 -3
- package/src/services/__tests__/ghostServiceImproved.tiers.test.js +3 -5
- package/src/services/__tests__/imageProcessingService.test.js +148 -177
- package/src/services/__tests__/images.test.js +78 -0
- package/src/services/createResourceService.js +138 -0
- package/src/services/ghostApiClient.js +240 -0
- package/src/services/ghostService.js +1 -19
- package/src/services/ghostServiceImproved.js +76 -915
- package/src/services/imageProcessingService.js +100 -56
- package/src/services/images.js +54 -0
- package/src/services/members.js +127 -0
- package/src/services/newsletters.js +63 -0
- package/src/services/pageService.js +2 -2
- package/src/services/pages.js +116 -0
- package/src/services/posts.js +116 -0
- package/src/services/tags.js +118 -0
- package/src/services/tiers.js +72 -0
- package/src/services/validators.js +218 -0
- package/src/utils/__tests__/imageInputResolver.test.js +134 -0
- 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,
|
|
194
|
+
export { api, getSiteInfo, handleApiRequest, createPost, createTag, getTags };
|