@jgardner04/ghost-mcp-server 1.13.4 → 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.
- 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 +69 -1
- package/src/__tests__/mcp_server_pages.test.js +23 -6
- package/src/mcp_server.js +393 -1143
- package/src/services/__tests__/createResourceService.test.js +468 -0
- 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/createResourceService.js +138 -0
- package/src/services/ghostApiClient.js +240 -0
- package/src/services/ghostServiceImproved.js +76 -915
- package/src/services/images.js +27 -0
- package/src/services/members.js +127 -0
- package/src/services/newsletters.js +63 -0
- 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
|
@@ -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 };
|