@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.
@@ -1,928 +1,89 @@
1
- import GhostAdminAPI from '@tryghost/admin-api';
2
- import dotenv from 'dotenv';
3
- import { promises as fs } from 'fs';
4
- import {
5
- GhostAPIError,
6
- ConfigurationError,
7
- ValidationError,
8
- NotFoundError,
9
- ErrorHandler,
10
- CircuitBreaker,
11
- retryWithBackoff,
12
- } from '../errors/index.js';
13
- import { createContextLogger } from '../utils/logger.js';
14
- import { sanitizeNqlValue } from '../utils/nqlSanitizer.js';
15
-
16
- dotenv.config();
17
-
18
- const logger = createContextLogger('ghost-service-improved');
19
-
20
- const { GHOST_ADMIN_API_URL, GHOST_ADMIN_API_KEY } = process.env;
21
-
22
- // Validate configuration at startup
23
- if (!GHOST_ADMIN_API_URL || !GHOST_ADMIN_API_KEY) {
24
- throw new ConfigurationError(
25
- 'Ghost Admin API configuration is incomplete',
26
- ['GHOST_ADMIN_API_URL', 'GHOST_ADMIN_API_KEY'].filter((key) => !process.env[key])
27
- );
28
- }
29
-
30
- // Configure the Ghost Admin API client
31
- const api = new GhostAdminAPI({
32
- url: GHOST_ADMIN_API_URL,
33
- key: GHOST_ADMIN_API_KEY,
34
- version: 'v5.0',
35
- });
36
-
37
- // Circuit breaker for Ghost API
38
- const ghostCircuitBreaker = new CircuitBreaker({
39
- failureThreshold: 5,
40
- resetTimeout: 60000, // 1 minute
41
- monitoringPeriod: 10000, // 10 seconds
42
- });
43
-
44
1
  /**
45
- * Enhanced handler for Ghost Admin API requests with proper error handling
46
- */
47
- const handleApiRequest = async (resource, action, data = {}, options = {}, config = {}) => {
48
- // Validate inputs
49
- if (!api[resource] || typeof api[resource][action] !== 'function') {
50
- throw new ValidationError(`Invalid Ghost API resource or action: ${resource}.${action}`);
51
- }
52
-
53
- const operation = `${resource}.${action}`;
54
- const maxRetries = config.maxRetries ?? 3;
55
- const useCircuitBreaker = config.useCircuitBreaker ?? true;
56
-
57
- // Main execution function
58
- const executeRequest = async () => {
59
- try {
60
- logger.info('Executing Ghost API request', { operation });
61
-
62
- let result;
63
-
64
- // Handle different action signatures
65
- switch (action) {
66
- case 'add':
67
- case 'edit':
68
- result = await api[resource][action](data, options);
69
- break;
70
- case 'upload':
71
- result = await api[resource][action](data);
72
- break;
73
- case 'browse':
74
- case 'read':
75
- result = await api[resource][action](options, data);
76
- break;
77
- case 'delete':
78
- result = await api[resource][action](data.id || data, options);
79
- break;
80
- default:
81
- result = await api[resource][action](data);
82
- }
83
-
84
- logger.info('Successfully executed Ghost API request', { operation });
85
- return result;
86
- } catch (error) {
87
- // Transform Ghost API errors into our error types
88
- throw ErrorHandler.fromGhostError(error, operation);
89
- }
90
- };
91
-
92
- // Wrap with circuit breaker if enabled
93
- const wrappedExecute = useCircuitBreaker
94
- ? () => ghostCircuitBreaker.execute(executeRequest)
95
- : executeRequest;
96
-
97
- // Execute with retry logic
98
- try {
99
- return await retryWithBackoff(wrappedExecute, {
100
- maxAttempts: maxRetries,
101
- onRetry: (attempt, _error) => {
102
- logger.info('Retrying Ghost API request', { operation, attempt, maxRetries });
103
-
104
- // Log circuit breaker state if relevant
105
- if (useCircuitBreaker) {
106
- const state = ghostCircuitBreaker.getState();
107
- logger.info('Circuit breaker state', { operation, state });
108
- }
109
- },
110
- });
111
- } catch (error) {
112
- logger.error('Failed to execute Ghost API request', {
113
- operation,
114
- maxRetries,
115
- error: error.message,
116
- });
117
- throw error;
118
- }
119
- };
120
-
121
- /**
122
- * Input validation helpers
123
- */
124
- const validators = {
125
- validateScheduledStatus(data, resourceLabel = 'Resource') {
126
- const errors = [];
127
-
128
- if (data.status === 'scheduled' && !data.published_at) {
129
- errors.push({
130
- field: 'published_at',
131
- message: 'published_at is required when status is scheduled',
132
- });
133
- }
134
-
135
- if (data.published_at) {
136
- const publishDate = new Date(data.published_at);
137
- if (isNaN(publishDate.getTime())) {
138
- errors.push({ field: 'published_at', message: 'Invalid date format' });
139
- } else if (data.status === 'scheduled' && publishDate <= new Date()) {
140
- errors.push({ field: 'published_at', message: 'Scheduled date must be in the future' });
141
- }
142
- }
143
-
144
- if (errors.length > 0) {
145
- throw new ValidationError(`${resourceLabel} validation failed`, errors);
146
- }
147
- },
148
-
149
- validatePostData(postData) {
150
- const errors = [];
151
-
152
- if (!postData.title || postData.title.trim().length === 0) {
153
- errors.push({ field: 'title', message: 'Title is required' });
154
- }
155
-
156
- if (!postData.html && !postData.mobiledoc) {
157
- errors.push({ field: 'content', message: 'Either html or mobiledoc content is required' });
158
- }
159
-
160
- if (postData.status && !['draft', 'published', 'scheduled'].includes(postData.status)) {
161
- errors.push({
162
- field: 'status',
163
- message: 'Invalid status. Must be draft, published, or scheduled',
164
- });
165
- }
166
-
167
- if (errors.length > 0) {
168
- throw new ValidationError('Post validation failed', errors);
169
- }
170
-
171
- this.validateScheduledStatus(postData, 'Post');
172
- },
173
-
174
- validateTagData(tagData) {
175
- const errors = [];
176
-
177
- if (!tagData.name || tagData.name.trim().length === 0) {
178
- errors.push({ field: 'name', message: 'Tag name is required' });
179
- }
180
-
181
- if (tagData.slug && !/^[a-z0-9-]+$/.test(tagData.slug)) {
182
- errors.push({
183
- field: 'slug',
184
- message: 'Slug must contain only lowercase letters, numbers, and hyphens',
185
- });
186
- }
187
-
188
- if (errors.length > 0) {
189
- throw new ValidationError('Tag validation failed', errors);
190
- }
191
- },
192
-
193
- validateTagUpdateData(updateData) {
194
- const errors = [];
195
-
196
- // Name is optional in updates, but if provided, it cannot be empty
197
- if (updateData.name !== undefined && updateData.name.trim().length === 0) {
198
- errors.push({ field: 'name', message: 'Tag name cannot be empty' });
199
- }
200
-
201
- // Validate slug format if provided
202
- if (updateData.slug && !/^[a-z0-9-]+$/.test(updateData.slug)) {
203
- errors.push({
204
- field: 'slug',
205
- message: 'Slug must contain only lowercase letters, numbers, and hyphens',
206
- });
207
- }
208
-
209
- if (errors.length > 0) {
210
- throw new ValidationError('Tag update validation failed', errors);
211
- }
212
- },
213
-
214
- async validateImagePath(imagePath) {
215
- if (!imagePath || typeof imagePath !== 'string') {
216
- throw new ValidationError('Image path is required and must be a string');
217
- }
218
-
219
- // Check if file exists
220
- try {
221
- await fs.access(imagePath);
222
- } catch {
223
- throw new NotFoundError('Image file', imagePath);
224
- }
225
- },
226
-
227
- validatePageData(pageData) {
228
- const errors = [];
229
-
230
- if (!pageData.title || pageData.title.trim().length === 0) {
231
- errors.push({ field: 'title', message: 'Title is required' });
232
- }
233
-
234
- if (!pageData.html && !pageData.mobiledoc) {
235
- errors.push({ field: 'content', message: 'Either html or mobiledoc content is required' });
236
- }
237
-
238
- if (pageData.status && !['draft', 'published', 'scheduled'].includes(pageData.status)) {
239
- errors.push({
240
- field: 'status',
241
- message: 'Invalid status. Must be draft, published, or scheduled',
242
- });
243
- }
244
-
245
- if (errors.length > 0) {
246
- throw new ValidationError('Page validation failed', errors);
247
- }
248
-
249
- this.validateScheduledStatus(pageData, 'Page');
250
- },
251
-
252
- validateNewsletterData(newsletterData) {
253
- const errors = [];
254
-
255
- if (!newsletterData.name || newsletterData.name.trim().length === 0) {
256
- errors.push({ field: 'name', message: 'Newsletter name is required' });
257
- }
258
-
259
- if (errors.length > 0) {
260
- throw new ValidationError('Newsletter validation failed', errors);
261
- }
262
- },
263
- };
264
-
265
- /**
266
- * Shared CRUD helper: read a single resource by ID with 404→NotFoundError handling
267
- */
268
- async function readResource(resource, id, label, options = {}) {
269
- if (!id) throw new ValidationError(`${label} ID is required`);
270
- try {
271
- return await handleApiRequest(resource, 'read', { id }, options);
272
- } catch (error) {
273
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
274
- throw new NotFoundError(label, id);
275
- }
276
- throw error;
277
- }
278
- }
279
-
280
- /**
281
- * Shared CRUD helper: update a resource with optimistic concurrency control (OCC)
282
- * Reads the current version, merges updated_at, then edits.
283
- */
284
- async function updateWithOCC(resource, id, updateData, options = {}, label = resource) {
285
- const existing = await readResource(resource, id, label);
286
- const editData = { ...updateData, updated_at: existing.updated_at };
287
- try {
288
- return await handleApiRequest(resource, 'edit', { id, ...editData }, options);
289
- } catch (error) {
290
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
291
- throw new NotFoundError(label, id);
292
- }
293
- throw error;
294
- }
295
- }
296
-
297
- /**
298
- * Shared CRUD helper: delete a resource by ID with 404→NotFoundError handling
299
- */
300
- async function deleteResource(resource, id, label) {
301
- if (!id) throw new ValidationError(`${label} ID is required for deletion`);
302
- try {
303
- return await handleApiRequest(resource, 'delete', { id });
304
- } catch (error) {
305
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
306
- throw new NotFoundError(label, id);
307
- }
308
- throw error;
309
- }
310
- }
311
-
312
- /**
313
- * Service functions with enhanced error handling
314
- */
315
-
316
- export async function getSiteInfo() {
317
- return handleApiRequest('site', 'read');
318
- }
319
-
320
- export async function createPost(postData, options = { source: 'html' }) {
321
- // Validate input
322
- validators.validatePostData(postData);
323
-
324
- // Add defaults
325
- const dataWithDefaults = {
326
- status: 'draft',
327
- ...postData,
328
- };
329
-
330
- // SECURITY: HTML must be sanitized before reaching this function. See htmlContentSchema in schemas/common.js
331
-
332
- try {
333
- return await handleApiRequest('posts', 'add', dataWithDefaults, options);
334
- } catch (error) {
335
- if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
336
- // Transform Ghost validation errors into our format
337
- throw new ValidationError('Post creation failed due to validation errors', [
338
- { field: 'post', message: error.originalError },
339
- ]);
340
- }
341
- throw error;
342
- }
343
- }
344
-
345
- export async function updatePost(postId, updateData, options = {}) {
346
- if (!postId) {
347
- throw new ValidationError('Post ID is required for update');
348
- }
349
-
350
- // Validate scheduled status when status or published_at is being updated
351
- if (updateData.status || updateData.published_at) {
352
- let validationData = updateData;
353
- // When only published_at changes, fetch existing status to check if post is scheduled
354
- if (!updateData.status && updateData.published_at) {
355
- const existing = await readResource('posts', postId, 'Post');
356
- validationData = { ...updateData, status: existing.status };
357
- }
358
- validators.validateScheduledStatus(validationData, 'Post');
359
- }
360
-
361
- return updateWithOCC('posts', postId, updateData, options, 'Post');
362
- }
363
-
364
- export async function deletePost(postId) {
365
- return deleteResource('posts', postId, 'Post');
366
- }
367
-
368
- export async function getPost(postId, options = {}) {
369
- return readResource('posts', postId, 'Post', options);
370
- }
371
-
372
- export async function getPosts(options = {}) {
373
- const defaultOptions = {
374
- limit: 15,
375
- include: 'tags,authors',
376
- ...options,
377
- };
378
-
379
- return handleApiRequest('posts', 'browse', {}, defaultOptions);
380
- }
381
-
382
- export async function searchPosts(query, options = {}) {
383
- // Validate query
384
- if (!query || query.trim().length === 0) {
385
- throw new ValidationError('Search query is required');
386
- }
387
-
388
- // Sanitize query - escape special NQL characters to prevent injection
389
- const sanitizedQuery = sanitizeNqlValue(query);
390
-
391
- // Build filter with fuzzy title match using Ghost NQL
392
- const filterParts = [`title:~'${sanitizedQuery}'`];
393
-
394
- // Add status filter if provided and not 'all'
395
- if (options.status && options.status !== 'all') {
396
- filterParts.push(`status:${options.status}`);
397
- }
398
-
399
- const searchOptions = {
400
- limit: options.limit || 15,
401
- include: 'tags,authors',
402
- filter: filterParts.join('+'),
403
- };
404
-
405
- return handleApiRequest('posts', 'browse', {}, searchOptions);
406
- }
407
-
408
- /**
409
- * Page CRUD Operations
410
- * Pages are similar to posts but do NOT support tags
411
- */
412
-
413
- export async function createPage(pageData, options = { source: 'html' }) {
414
- // Validate input
415
- validators.validatePageData(pageData);
416
-
417
- // Add defaults
418
- const dataWithDefaults = {
419
- status: 'draft',
420
- ...pageData,
421
- };
422
-
423
- // SECURITY: HTML must be sanitized before reaching this function. See htmlContentSchema in schemas/common.js
424
-
425
- try {
426
- return await handleApiRequest('pages', 'add', dataWithDefaults, options);
427
- } catch (error) {
428
- if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
429
- throw new ValidationError('Page creation failed due to validation errors', [
430
- { field: 'page', message: error.originalError },
431
- ]);
432
- }
433
- throw error;
434
- }
435
- }
436
-
437
- export async function updatePage(pageId, updateData, options = {}) {
438
- if (!pageId) {
439
- throw new ValidationError('Page ID is required for update');
440
- }
441
-
442
- // SECURITY: HTML must be sanitized before reaching this function. See htmlContentSchema in schemas/common.js
443
-
444
- // Validate scheduled status when status or published_at is being updated
445
- if (updateData.status || updateData.published_at) {
446
- let validationData = updateData;
447
- // When only published_at changes, fetch existing status to check if page is scheduled
448
- if (!updateData.status && updateData.published_at) {
449
- const existing = await readResource('pages', pageId, 'Page');
450
- validationData = { ...updateData, status: existing.status };
451
- }
452
- validators.validateScheduledStatus(validationData, 'Page');
453
- }
454
-
455
- return updateWithOCC('pages', pageId, updateData, options, 'Page');
456
- }
457
-
458
- export async function deletePage(pageId) {
459
- return deleteResource('pages', pageId, 'Page');
460
- }
461
-
462
- export async function getPage(pageId, options = {}) {
463
- return readResource('pages', pageId, 'Page', options);
464
- }
465
-
466
- export async function getPages(options = {}) {
467
- const defaultOptions = {
468
- limit: 15,
469
- include: 'authors',
470
- ...options,
471
- };
472
-
473
- return handleApiRequest('pages', 'browse', {}, defaultOptions);
474
- }
475
-
476
- export async function searchPages(query, options = {}) {
477
- // Validate query
478
- if (!query || query.trim().length === 0) {
479
- throw new ValidationError('Search query is required');
480
- }
481
-
482
- // Sanitize query - escape special NQL characters to prevent injection
483
- const sanitizedQuery = sanitizeNqlValue(query);
484
-
485
- // Build filter with fuzzy title match using Ghost NQL
486
- const filterParts = [`title:~'${sanitizedQuery}'`];
487
-
488
- // Add status filter if provided and not 'all'
489
- if (options.status && options.status !== 'all') {
490
- filterParts.push(`status:${options.status}`);
491
- }
492
-
493
- const searchOptions = {
494
- limit: options.limit || 15,
495
- include: 'authors',
496
- filter: filterParts.join('+'),
497
- };
498
-
499
- return handleApiRequest('pages', 'browse', {}, searchOptions);
500
- }
501
-
502
- export async function uploadImage(imagePath) {
503
- // Validate input
504
- await validators.validateImagePath(imagePath);
505
-
506
- const imageData = { file: imagePath };
507
-
508
- try {
509
- return await handleApiRequest('images', 'upload', imageData);
510
- } catch (error) {
511
- if (error instanceof GhostAPIError) {
512
- throw new ValidationError(`Image upload failed: ${error.originalError}`);
513
- }
514
- throw error;
515
- }
516
- }
517
-
518
- export async function createTag(tagData) {
519
- // Validate input
520
- validators.validateTagData(tagData);
521
-
522
- // Auto-generate slug if not provided
523
- if (!tagData.slug) {
524
- tagData.slug = tagData.name
525
- .toLowerCase()
526
- .replace(/[^a-z0-9]+/g, '-')
527
- .replace(/^-+|-+$/g, '');
528
- }
529
-
530
- try {
531
- return await handleApiRequest('tags', 'add', tagData);
532
- } catch (error) {
533
- if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
534
- // Check if it's a duplicate tag error
535
- if (error.originalError.includes('already exists')) {
536
- // Try to fetch the existing tag by name filter
537
- const existingTags = await getTags({ filter: `name:'${tagData.name}'` });
538
- if (existingTags.length > 0) {
539
- return existingTags[0]; // Return existing tag instead of failing
540
- }
541
- }
542
- throw new ValidationError('Tag creation failed', [
543
- { field: 'tag', message: error.originalError },
544
- ]);
545
- }
546
- throw error;
547
- }
548
- }
549
-
550
- export async function getTags(options = {}) {
551
- const tags = await handleApiRequest(
552
- 'tags',
553
- 'browse',
554
- {},
555
- {
556
- limit: 15,
557
- ...options,
558
- }
559
- );
560
- return tags || [];
561
- }
562
-
563
- export async function getTag(tagId, options = {}) {
564
- return readResource('tags', tagId, 'Tag', options);
565
- }
566
-
567
- export async function updateTag(tagId, updateData) {
568
- if (!tagId) {
569
- throw new ValidationError('Tag ID is required for update');
570
- }
571
-
572
- validators.validateTagUpdateData(updateData);
573
-
574
- try {
575
- await readResource('tags', tagId, 'Tag');
576
- return await handleApiRequest('tags', 'edit', { id: tagId, ...updateData });
577
- } catch (error) {
578
- if (error instanceof NotFoundError) {
579
- throw error;
580
- }
581
- if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
582
- throw new ValidationError('Tag update failed', [
583
- { field: 'tag', message: error.originalError },
584
- ]);
585
- }
586
- throw error;
587
- }
588
- }
589
-
590
- export async function deleteTag(tagId) {
591
- return deleteResource('tags', tagId, 'Tag');
592
- }
593
-
594
- /**
595
- * Member CRUD Operations
596
- * Members represent subscribers/users in Ghost CMS
597
- */
598
-
599
- /**
600
- * Creates a new member (subscriber) in Ghost CMS
601
- * @param {Object} memberData - The member data
602
- * @param {string} memberData.email - Member email (required)
603
- * @param {string} [memberData.name] - Member name
604
- * @param {string} [memberData.note] - Notes about the member (HTML will be sanitized)
605
- * @param {string[]} [memberData.labels] - Array of label names
606
- * @param {Object[]} [memberData.newsletters] - Array of newsletter objects with id
607
- * @param {boolean} [memberData.subscribed] - Email subscription status
608
- * @param {Object} [options] - Additional options for the API request
609
- * @returns {Promise<Object>} The created member object
610
- * @throws {ValidationError} If validation fails
611
- * @throws {GhostAPIError} If the API request fails
612
- */
613
- export async function createMember(memberData, options = {}) {
614
- // Input validation is performed at the MCP tool layer using Zod schemas
615
- try {
616
- return await handleApiRequest('members', 'add', memberData, options);
617
- } catch (error) {
618
- if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
619
- throw new ValidationError('Member creation failed due to validation errors', [
620
- { field: 'member', message: error.originalError },
621
- ]);
622
- }
623
- throw error;
624
- }
625
- }
626
-
627
- /**
628
- * Updates an existing member in Ghost CMS
629
- * @param {string} memberId - The member ID to update
630
- * @param {Object} updateData - The member update data
631
- * @param {string} [updateData.email] - Member email
632
- * @param {string} [updateData.name] - Member name
633
- * @param {string} [updateData.note] - Notes about the member (HTML will be sanitized)
634
- * @param {string[]} [updateData.labels] - Array of label names
635
- * @param {Object[]} [updateData.newsletters] - Array of newsletter objects with id
636
- * @param {boolean} [updateData.subscribed] - Email subscription status
637
- * @param {Object} [options] - Additional options for the API request
638
- * @returns {Promise<Object>} The updated member object
639
- * @throws {ValidationError} If validation fails
640
- * @throws {NotFoundError} If the member is not found
641
- * @throws {GhostAPIError} If the API request fails
642
- */
643
- export async function updateMember(memberId, updateData, options = {}) {
644
- // Input validation is performed at the MCP tool layer using Zod schemas
645
- if (!memberId) {
646
- throw new ValidationError('Member ID is required for update');
647
- }
648
-
649
- return updateWithOCC('members', memberId, updateData, options, 'Member');
650
- }
651
-
652
- /**
653
- * Deletes a member from Ghost CMS
654
- * @param {string} memberId - The member ID to delete
655
- * @returns {Promise<Object>} Deletion confirmation object
656
- * @throws {ValidationError} If member ID is not provided
657
- * @throws {NotFoundError} If the member is not found
658
- * @throws {GhostAPIError} If the API request fails
659
- */
660
- export async function deleteMember(memberId) {
661
- return deleteResource('members', memberId, 'Member');
662
- }
663
-
664
- /**
665
- * List members from Ghost CMS with optional filtering and pagination
666
- * @param {Object} [options] - Query options
667
- * @param {number} [options.limit] - Number of members to return (1-100)
668
- * @param {number} [options.page] - Page number (1+)
669
- * @param {string} [options.filter] - NQL filter string (e.g., 'status:paid')
670
- * @param {string} [options.order] - Order string (e.g., 'created_at desc')
671
- * @param {string} [options.include] - Include string (e.g., 'labels,newsletters')
672
- * @returns {Promise<Array>} Array of member objects
673
- * @throws {ValidationError} If validation fails
674
- * @throws {GhostAPIError} If the API request fails
675
- */
676
- export async function getMembers(options = {}) {
677
- // Input validation is performed at the MCP tool layer using Zod schemas
678
- const defaultOptions = {
679
- limit: 15,
680
- ...options,
681
- };
682
-
683
- const members = await handleApiRequest('members', 'browse', {}, defaultOptions);
684
- return members || [];
685
- }
686
-
687
- /**
688
- * Get a single member from Ghost CMS by ID or email
689
- * @param {Object} params - Lookup parameters (id OR email required)
690
- * @param {string} [params.id] - Member ID
691
- * @param {string} [params.email] - Member email
692
- * @returns {Promise<Object>} The member object
693
- * @throws {ValidationError} If validation fails
694
- * @throws {NotFoundError} If the member is not found
695
- * @throws {GhostAPIError} If the API request fails
696
- */
697
- export async function getMember(params) {
698
- // Input validation is performed at the MCP tool layer using Zod schemas
699
- const { id, email } = params;
700
-
701
- try {
702
- if (id) {
703
- // Lookup by ID using read endpoint
704
- return await handleApiRequest('members', 'read', { id }, { id });
705
- } else {
706
- // Lookup by email using browse with filter
707
- const sanitizedEmail = sanitizeNqlValue(email);
708
- const members = await handleApiRequest(
709
- 'members',
710
- 'browse',
711
- {},
712
- { filter: `email:'${sanitizedEmail}'`, limit: 1 }
713
- );
714
-
715
- if (!members || members.length === 0) {
716
- throw new NotFoundError('Member', email);
717
- }
718
-
719
- return members[0];
720
- }
721
- } catch (error) {
722
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
723
- throw new NotFoundError('Member', id || email);
724
- }
725
- throw error;
726
- }
727
- }
728
-
729
- /**
730
- * Search members by name or email
731
- * @param {string} query - Search query (searches name and email fields)
732
- * @param {Object} [options] - Additional options
733
- * @param {number} [options.limit] - Maximum number of results (default: 15)
734
- * @returns {Promise<Array>} Array of matching member objects
735
- * @throws {ValidationError} If validation fails
736
- * @throws {GhostAPIError} If the API request fails
737
- */
738
- export async function searchMembers(query, options = {}) {
739
- // Input validation is performed at the MCP tool layer using Zod schemas
740
- const sanitizedQuery = sanitizeNqlValue(query.trim());
741
-
742
- const limit = options.limit || 15;
743
-
744
- // Build NQL filter for name or email containing the query
745
- // Ghost uses ~ for contains/like matching
746
- const filter = `name:~'${sanitizedQuery}',email:~'${sanitizedQuery}'`;
747
-
748
- const members = await handleApiRequest('members', 'browse', {}, { filter, limit });
749
- return members || [];
750
- }
751
-
752
- /**
753
- * Newsletter CRUD Operations
754
- */
755
-
756
- export async function getNewsletters(options = {}) {
757
- const defaultOptions = {
758
- limit: 'all',
759
- ...options,
760
- };
761
-
762
- const newsletters = await handleApiRequest('newsletters', 'browse', {}, defaultOptions);
763
- return newsletters || [];
764
- }
765
-
766
- export async function getNewsletter(newsletterId) {
767
- return readResource('newsletters', newsletterId, 'Newsletter');
768
- }
769
-
770
- export async function createNewsletter(newsletterData) {
771
- // Validate input
772
- validators.validateNewsletterData(newsletterData);
773
-
774
- try {
775
- return await handleApiRequest('newsletters', 'add', newsletterData);
776
- } catch (error) {
777
- if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
778
- throw new ValidationError('Newsletter creation failed', [
779
- { field: 'newsletter', message: error.originalError },
780
- ]);
781
- }
782
- throw error;
783
- }
784
- }
785
-
786
- export async function updateNewsletter(newsletterId, updateData) {
787
- if (!newsletterId) {
788
- throw new ValidationError('Newsletter ID is required for update');
789
- }
790
-
791
- try {
792
- return await updateWithOCC('newsletters', newsletterId, updateData, {}, 'Newsletter');
793
- } catch (error) {
794
- if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
795
- throw new ValidationError('Newsletter update failed', [
796
- { field: 'newsletter', message: error.originalError },
797
- ]);
798
- }
799
- throw error;
800
- }
801
- }
802
-
803
- export async function deleteNewsletter(newsletterId) {
804
- return deleteResource('newsletters', newsletterId, 'Newsletter');
805
- }
806
-
807
- /**
808
- * Create a new tier (membership level)
809
- * @param {Object} tierData - Tier data
810
- * @param {Object} [options={}] - Options for the API request
811
- * @returns {Promise<Object>} Created tier
812
- */
813
- export async function createTier(tierData, options = {}) {
814
- const { validateTierData } = await import('./tierService.js');
815
- validateTierData(tierData);
816
-
817
- try {
818
- return await handleApiRequest('tiers', 'add', tierData, options);
819
- } catch (error) {
820
- if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
821
- throw new ValidationError('Tier creation failed due to validation errors', [
822
- { field: 'tier', message: error.originalError },
823
- ]);
824
- }
825
- throw error;
826
- }
827
- }
828
-
829
- /**
830
- * Update an existing tier
831
- * @param {string} id - Tier ID
832
- * @param {Object} updateData - Tier update data
833
- * @param {Object} [options={}] - Options for the API request
834
- * @returns {Promise<Object>} Updated tier
835
- */
836
- export async function updateTier(id, updateData, options = {}) {
837
- if (!id || typeof id !== 'string' || id.trim().length === 0) {
838
- throw new ValidationError('Tier ID is required for update');
839
- }
840
-
841
- const { validateTierUpdateData } = await import('./tierService.js');
842
- validateTierUpdateData(updateData);
843
-
844
- return updateWithOCC('tiers', id, updateData, options, 'Tier');
845
- }
846
-
847
- /**
848
- * Delete a tier
849
- * @param {string} id - Tier ID
850
- * @returns {Promise<Object>} Deletion result
851
- */
852
- export async function deleteTier(id) {
853
- if (!id || typeof id !== 'string' || id.trim().length === 0) {
854
- throw new ValidationError('Tier ID is required for deletion');
855
- }
2
+ * Barrel re-export for backward compatibility.
3
+ *
4
+ * The implementation has been split into focused modules:
5
+ * - ghostApiClient.js API client, circuit breaker, retry logic, CRUD helpers
6
+ * - validators.js — Input validation helpers
7
+ * - posts.js — Post CRUD operations
8
+ * - pages.js — Page CRUD operations
9
+ * - tags.js — Tag CRUD operations
10
+ * - members.js — Member CRUD operations
11
+ * - newsletters.js — Newsletter CRUD operations
12
+ * - tiers.js — Tier CRUD operations
13
+ * - images.js — Image upload
14
+ */
15
+
16
+ // Infrastructure
17
+ export {
18
+ api,
19
+ ghostCircuitBreaker,
20
+ handleApiRequest,
21
+ readResource,
22
+ updateWithOCC,
23
+ deleteResource,
24
+ getSiteInfo,
25
+ checkHealth,
26
+ } from './ghostApiClient.js';
856
27
 
857
- return deleteResource('tiers', id, 'Tier');
858
- }
28
+ // Validators
29
+ export { validators } from './validators.js';
859
30
 
860
- /**
861
- * Get all tiers with optional filtering
862
- * @param {Object} [options={}] - Query options
863
- * @param {number} [options.limit] - Number of tiers to return (1-100, default 15)
864
- * @param {number} [options.page] - Page number
865
- * @param {string} [options.filter] - NQL filter string (e.g., "type:paid", "type:free")
866
- * @param {string} [options.order] - Order string
867
- * @param {string} [options.include] - Include string
868
- * @returns {Promise<Array>} Array of tiers
869
- */
870
- export async function getTiers(options = {}) {
871
- const { validateTierQueryOptions } = await import('./tierService.js');
872
- validateTierQueryOptions(options);
31
+ // Posts
32
+ export { createPost, updatePost, deletePost, getPost, getPosts, searchPosts } from './posts.js';
873
33
 
874
- const defaultOptions = {
875
- limit: 15,
876
- ...options,
877
- };
34
+ // Pages
35
+ export { createPage, updatePage, deletePage, getPage, getPages, searchPages } from './pages.js';
878
36
 
879
- const tiers = await handleApiRequest('tiers', 'browse', {}, defaultOptions);
880
- return tiers || [];
881
- }
37
+ // Tags
38
+ export { createTag, getTags, getTag, updateTag, deleteTag } from './tags.js';
882
39
 
883
- /**
884
- * Get a single tier by ID
885
- * @param {string} id - Tier ID
886
- * @returns {Promise<Object>} Tier object
887
- */
888
- export async function getTier(id) {
889
- if (!id || typeof id !== 'string' || id.trim().length === 0) {
890
- throw new ValidationError('Tier ID is required and must be a non-empty string');
891
- }
40
+ // Members
41
+ export {
42
+ createMember,
43
+ updateMember,
44
+ deleteMember,
45
+ getMembers,
46
+ getMember,
47
+ searchMembers,
48
+ } from './members.js';
892
49
 
893
- return readResource('tiers', id, 'Tier');
894
- }
50
+ // Newsletters
51
+ export {
52
+ getNewsletters,
53
+ getNewsletter,
54
+ createNewsletter,
55
+ updateNewsletter,
56
+ deleteNewsletter,
57
+ } from './newsletters.js';
895
58
 
896
- /**
897
- * Health check for Ghost API connection
898
- */
899
- export async function checkHealth() {
900
- try {
901
- const site = await getSiteInfo();
902
- const circuitState = ghostCircuitBreaker.getState();
59
+ // Tiers
60
+ export { createTier, updateTier, deleteTier, getTiers, getTier } from './tiers.js';
903
61
 
904
- return {
905
- status: 'healthy',
906
- site: {
907
- title: site.title,
908
- version: site.version,
909
- url: site.url,
910
- },
911
- circuitBreaker: circuitState,
912
- timestamp: new Date().toISOString(),
913
- };
914
- } catch (error) {
915
- return {
916
- status: 'unhealthy',
917
- error: error.message,
918
- circuitBreaker: ghostCircuitBreaker.getState(),
919
- timestamp: new Date().toISOString(),
920
- };
921
- }
922
- }
62
+ // Images
63
+ export { uploadImage } from './images.js';
923
64
 
924
- // Export everything including the API client for backward compatibility
925
- export { api, handleApiRequest, ghostCircuitBreaker, validators };
65
+ // Re-import for default export object
66
+ import { getSiteInfo, checkHealth } from './ghostApiClient.js';
67
+ import { createPost, updatePost, deletePost, getPost, getPosts, searchPosts } from './posts.js';
68
+ import { createPage, updatePage, deletePage, getPage, getPages, searchPages } from './pages.js';
69
+ import { createTag, getTags, getTag, updateTag, deleteTag } from './tags.js';
70
+ import {
71
+ createMember,
72
+ updateMember,
73
+ deleteMember,
74
+ getMembers,
75
+ getMember,
76
+ searchMembers,
77
+ } from './members.js';
78
+ import {
79
+ getNewsletters,
80
+ getNewsletter,
81
+ createNewsletter,
82
+ updateNewsletter,
83
+ deleteNewsletter,
84
+ } from './newsletters.js';
85
+ import { createTier, updateTier, deleteTier, getTiers, getTier } from './tiers.js';
86
+ import { uploadImage } from './images.js';
926
87
 
927
88
  export default {
928
89
  getSiteInfo,