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