@jgardner04/ghost-mcp-server 1.13.2 → 1.13.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/package.json +5 -13
  2. package/src/__tests__/mcp_server.test.js +204 -117
  3. package/src/__tests__/mcp_server_pages.test.js +32 -18
  4. package/src/config/mcp-config.js +1 -1
  5. package/src/controllers/__tests__/tagController.test.js +12 -8
  6. package/src/controllers/tagController.js +2 -2
  7. package/src/errors/__tests__/index.test.js +3 -3
  8. package/src/errors/index.js +1 -1
  9. package/src/index.js +1 -1
  10. package/src/mcp_server.js +35 -31
  11. package/src/schemas/__tests__/postSchemas.test.js +19 -0
  12. package/src/schemas/__tests__/tagSchemas.test.js +1 -1
  13. package/src/schemas/common.js +2 -2
  14. package/src/schemas/memberSchemas.js +20 -8
  15. package/src/schemas/newsletterSchemas.js +10 -10
  16. package/src/schemas/pageSchemas.js +16 -11
  17. package/src/schemas/postSchemas.js +22 -15
  18. package/src/schemas/tagSchemas.js +12 -7
  19. package/src/schemas/tierSchemas.js +17 -8
  20. package/src/services/__tests__/ghostServiceImproved.members.test.js +7 -2
  21. package/src/services/__tests__/ghostServiceImproved.newsletters.test.js +10 -6
  22. package/src/services/__tests__/ghostServiceImproved.pages.test.js +4 -4
  23. package/src/services/__tests__/ghostServiceImproved.posts.test.js +4 -4
  24. package/src/services/__tests__/ghostServiceImproved.tags.test.js +2 -2
  25. package/src/services/__tests__/ghostServiceImproved.tiers.test.js +9 -14
  26. package/src/services/__tests__/memberService.test.js +0 -28
  27. package/src/services/__tests__/tierService.test.js +0 -28
  28. package/src/services/ghostServiceImproved.js +69 -217
  29. package/src/services/imageProcessingService.js +1 -1
  30. package/src/services/memberService.js +0 -13
  31. package/src/services/tierService.js +0 -13
  32. package/src/utils/__tests__/nqlSanitizer.test.js +38 -0
  33. package/src/utils/nqlSanitizer.js +11 -0
@@ -11,6 +11,7 @@ import {
11
11
  retryWithBackoff,
12
12
  } from '../errors/index.js';
13
13
  import { createContextLogger } from '../utils/logger.js';
14
+ import { sanitizeNqlValue } from '../utils/nqlSanitizer.js';
14
15
 
15
16
  dotenv.config();
16
17
 
@@ -257,6 +258,53 @@ const validators = {
257
258
  },
258
259
  };
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
+
260
308
  /**
261
309
  * Service functions with enhanced error handling
262
310
  */
@@ -305,53 +353,15 @@ export async function updatePost(postId, updateData, options = {}) {
305
353
  validators.validateScheduledStatus(updateData, 'Post');
306
354
  }
307
355
 
308
- // Get the current post first to ensure it exists
309
- try {
310
- const existingPost = await handleApiRequest('posts', 'read', { id: postId });
311
-
312
- // Send only changed fields + updated_at for OCC (optimistic concurrency control)
313
- const editData = {
314
- ...updateData,
315
- updated_at: existingPost.updated_at,
316
- };
317
-
318
- return await handleApiRequest('posts', 'edit', editData, { id: postId, ...options });
319
- } catch (error) {
320
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
321
- throw new NotFoundError('Post', postId);
322
- }
323
- throw error;
324
- }
356
+ return updateWithOCC('posts', postId, updateData, options, 'Post');
325
357
  }
326
358
 
327
359
  export async function deletePost(postId) {
328
- if (!postId) {
329
- throw new ValidationError('Post ID is required for deletion');
330
- }
331
-
332
- try {
333
- return await handleApiRequest('posts', 'delete', { id: postId });
334
- } catch (error) {
335
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
336
- throw new NotFoundError('Post', postId);
337
- }
338
- throw error;
339
- }
360
+ return deleteResource('posts', postId, 'Post');
340
361
  }
341
362
 
342
363
  export async function getPost(postId, options = {}) {
343
- if (!postId) {
344
- throw new ValidationError('Post ID is required');
345
- }
346
-
347
- try {
348
- return await handleApiRequest('posts', 'read', { id: postId }, options);
349
- } catch (error) {
350
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
351
- throw new NotFoundError('Post', postId);
352
- }
353
- throw error;
354
- }
364
+ return readResource('posts', postId, 'Post', options);
355
365
  }
356
366
 
357
367
  export async function getPosts(options = {}) {
@@ -376,7 +386,7 @@ export async function searchPosts(query, options = {}) {
376
386
  }
377
387
 
378
388
  // Sanitize query - escape special NQL characters to prevent injection
379
- const sanitizedQuery = query.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
389
+ const sanitizedQuery = sanitizeNqlValue(query);
380
390
 
381
391
  // Build filter with fuzzy title match using Ghost NQL
382
392
  const filterParts = [`title:~'${sanitizedQuery}'`];
@@ -441,53 +451,15 @@ export async function updatePage(pageId, updateData, options = {}) {
441
451
  validators.validateScheduledStatus(updateData, 'Page');
442
452
  }
443
453
 
444
- try {
445
- // Get existing page to retrieve updated_at for conflict resolution
446
- const existingPage = await handleApiRequest('pages', 'read', { id: pageId });
447
-
448
- // Send only changed fields + updated_at for OCC (optimistic concurrency control)
449
- const editData = {
450
- ...updateData,
451
- updated_at: existingPage.updated_at,
452
- };
453
-
454
- return await handleApiRequest('pages', 'edit', editData, { id: pageId, ...options });
455
- } catch (error) {
456
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
457
- throw new NotFoundError('Page', pageId);
458
- }
459
- throw error;
460
- }
454
+ return updateWithOCC('pages', pageId, updateData, options, 'Page');
461
455
  }
462
456
 
463
457
  export async function deletePage(pageId) {
464
- if (!pageId) {
465
- throw new ValidationError('Page ID is required for delete');
466
- }
467
-
468
- try {
469
- return await handleApiRequest('pages', 'delete', { id: pageId });
470
- } catch (error) {
471
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
472
- throw new NotFoundError('Page', pageId);
473
- }
474
- throw error;
475
- }
458
+ return deleteResource('pages', pageId, 'Page');
476
459
  }
477
460
 
478
461
  export async function getPage(pageId, options = {}) {
479
- if (!pageId) {
480
- throw new ValidationError('Page ID is required');
481
- }
482
-
483
- try {
484
- return await handleApiRequest('pages', 'read', { id: pageId }, options);
485
- } catch (error) {
486
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
487
- throw new NotFoundError('Page', pageId);
488
- }
489
- throw error;
490
- }
462
+ return readResource('pages', pageId, 'Page', options);
491
463
  }
492
464
 
493
465
  export async function getPages(options = {}) {
@@ -512,7 +484,7 @@ export async function searchPages(query, options = {}) {
512
484
  }
513
485
 
514
486
  // Sanitize query - escape special NQL characters to prevent injection
515
- const sanitizedQuery = query.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
487
+ const sanitizedQuery = sanitizeNqlValue(query);
516
488
 
517
489
  // Build filter with fuzzy title match using Ghost NQL
518
490
  const filterParts = [`title:~'${sanitizedQuery}'`];
@@ -603,18 +575,7 @@ export async function getTags(options = {}) {
603
575
  }
604
576
 
605
577
  export async function getTag(tagId, options = {}) {
606
- if (!tagId) {
607
- throw new ValidationError('Tag ID is required');
608
- }
609
-
610
- try {
611
- return await handleApiRequest('tags', 'read', { id: tagId }, options);
612
- } catch (error) {
613
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
614
- throw new NotFoundError('Tag', tagId);
615
- }
616
- throw error;
617
- }
578
+ return readResource('tags', tagId, 'Tag', options);
618
579
  }
619
580
 
620
581
  export async function updateTag(tagId, updateData) {
@@ -622,14 +583,11 @@ export async function updateTag(tagId, updateData) {
622
583
  throw new ValidationError('Tag ID is required for update');
623
584
  }
624
585
 
625
- validators.validateTagUpdateData(updateData); // Validate update data
586
+ validators.validateTagUpdateData(updateData);
626
587
 
627
588
  try {
628
- // Verify tag exists before updating
629
- await getTag(tagId);
630
-
631
- // Send only changed fields (tags don't use updated_at for OCC)
632
- return await handleApiRequest('tags', 'edit', { ...updateData }, { id: tagId });
589
+ await readResource('tags', tagId, 'Tag');
590
+ return await handleApiRequest('tags', 'edit', { id: tagId, ...updateData });
633
591
  } catch (error) {
634
592
  if (error instanceof NotFoundError) {
635
593
  throw error;
@@ -644,18 +602,7 @@ export async function updateTag(tagId, updateData) {
644
602
  }
645
603
 
646
604
  export async function deleteTag(tagId) {
647
- if (!tagId) {
648
- throw new ValidationError('Tag ID is required for deletion');
649
- }
650
-
651
- try {
652
- return await handleApiRequest('tags', 'delete', { id: tagId });
653
- } catch (error) {
654
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
655
- throw new NotFoundError('Tag', tagId);
656
- }
657
- throw error;
658
- }
605
+ return deleteResource('tags', tagId, 'Tag');
659
606
  }
660
607
 
661
608
  /**
@@ -713,23 +660,7 @@ export async function updateMember(memberId, updateData, options = {}) {
713
660
  throw new ValidationError('Member ID is required for update');
714
661
  }
715
662
 
716
- try {
717
- // Get existing member to retrieve updated_at for conflict resolution
718
- const existingMember = await handleApiRequest('members', 'read', { id: memberId });
719
-
720
- // Send only changed fields + updated_at for OCC (optimistic concurrency control)
721
- const editData = {
722
- ...updateData,
723
- updated_at: existingMember.updated_at,
724
- };
725
-
726
- return await handleApiRequest('members', 'edit', editData, { id: memberId, ...options });
727
- } catch (error) {
728
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
729
- throw new NotFoundError('Member', memberId);
730
- }
731
- throw error;
732
- }
663
+ return updateWithOCC('members', memberId, updateData, options, 'Member');
733
664
  }
734
665
 
735
666
  /**
@@ -741,18 +672,7 @@ export async function updateMember(memberId, updateData, options = {}) {
741
672
  * @throws {GhostAPIError} If the API request fails
742
673
  */
743
674
  export async function deleteMember(memberId) {
744
- if (!memberId) {
745
- throw new ValidationError('Member ID is required for deletion');
746
- }
747
-
748
- try {
749
- return await handleApiRequest('members', 'delete', { id: memberId });
750
- } catch (error) {
751
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
752
- throw new NotFoundError('Member', memberId);
753
- }
754
- throw error;
755
- }
675
+ return deleteResource('members', memberId, 'Member');
756
676
  }
757
677
 
758
678
  /**
@@ -795,7 +715,6 @@ export async function getMembers(options = {}) {
795
715
  */
796
716
  export async function getMember(params) {
797
717
  // Input validation is performed at the MCP tool layer using Zod schemas
798
- const { sanitizeNqlValue } = await import('./memberService.js');
799
718
  const { id, email } = params;
800
719
 
801
720
  try {
@@ -837,7 +756,6 @@ export async function getMember(params) {
837
756
  */
838
757
  export async function searchMembers(query, options = {}) {
839
758
  // Input validation is performed at the MCP tool layer using Zod schemas
840
- const { sanitizeNqlValue } = await import('./memberService.js');
841
759
  const sanitizedQuery = sanitizeNqlValue(query.trim());
842
760
 
843
761
  const limit = options.limit || 15;
@@ -875,18 +793,7 @@ export async function getNewsletters(options = {}) {
875
793
  }
876
794
 
877
795
  export async function getNewsletter(newsletterId) {
878
- if (!newsletterId) {
879
- throw new ValidationError('Newsletter ID is required');
880
- }
881
-
882
- try {
883
- return await handleApiRequest('newsletters', 'read', { id: newsletterId });
884
- } catch (error) {
885
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
886
- throw new NotFoundError('Newsletter', newsletterId);
887
- }
888
- throw error;
889
- }
796
+ return readResource('newsletters', newsletterId, 'Newsletter');
890
797
  }
891
798
 
892
799
  export async function createNewsletter(newsletterData) {
@@ -911,22 +818,8 @@ export async function updateNewsletter(newsletterId, updateData) {
911
818
  }
912
819
 
913
820
  try {
914
- // Get existing newsletter to retrieve updated_at for conflict resolution
915
- const existingNewsletter = await handleApiRequest('newsletters', 'read', {
916
- id: newsletterId,
917
- });
918
-
919
- // Send only changed fields + updated_at for OCC (optimistic concurrency control)
920
- const editData = {
921
- ...updateData,
922
- updated_at: existingNewsletter.updated_at,
923
- };
924
-
925
- return await handleApiRequest('newsletters', 'edit', editData, { id: newsletterId });
821
+ return await updateWithOCC('newsletters', newsletterId, updateData, {}, 'Newsletter');
926
822
  } catch (error) {
927
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
928
- throw new NotFoundError('Newsletter', newsletterId);
929
- }
930
823
  if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
931
824
  throw new ValidationError('Newsletter update failed', [
932
825
  { field: 'newsletter', message: error.originalError },
@@ -937,18 +830,7 @@ export async function updateNewsletter(newsletterId, updateData) {
937
830
  }
938
831
 
939
832
  export async function deleteNewsletter(newsletterId) {
940
- if (!newsletterId) {
941
- throw new ValidationError('Newsletter ID is required for deletion');
942
- }
943
-
944
- try {
945
- return await handleApiRequest('newsletters', 'delete', newsletterId);
946
- } catch (error) {
947
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
948
- throw new NotFoundError('Newsletter', newsletterId);
949
- }
950
- throw error;
951
- }
833
+ return deleteResource('newsletters', newsletterId, 'Newsletter');
952
834
  }
953
835
 
954
836
  /**
@@ -988,23 +870,7 @@ export async function updateTier(id, updateData, options = {}) {
988
870
  const { validateTierUpdateData } = await import('./tierService.js');
989
871
  validateTierUpdateData(updateData);
990
872
 
991
- try {
992
- // Get existing tier to retrieve updated_at for conflict resolution
993
- const existingTier = await handleApiRequest('tiers', 'read', { id }, { id });
994
-
995
- // Send only changed fields + updated_at for OCC (optimistic concurrency control)
996
- const editData = {
997
- ...updateData,
998
- updated_at: existingTier.updated_at,
999
- };
1000
-
1001
- return await handleApiRequest('tiers', 'edit', editData, { id, ...options });
1002
- } catch (error) {
1003
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
1004
- throw new NotFoundError('Tier', id);
1005
- }
1006
- throw error;
1007
- }
873
+ return updateWithOCC('tiers', id, updateData, options, 'Tier');
1008
874
  }
1009
875
 
1010
876
  /**
@@ -1017,14 +883,7 @@ export async function deleteTier(id) {
1017
883
  throw new ValidationError('Tier ID is required for deletion');
1018
884
  }
1019
885
 
1020
- try {
1021
- return await handleApiRequest('tiers', 'delete', { id });
1022
- } catch (error) {
1023
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
1024
- throw new NotFoundError('Tier', id);
1025
- }
1026
- throw error;
1027
- }
886
+ return deleteResource('tiers', id, 'Tier');
1028
887
  }
1029
888
 
1030
889
  /**
@@ -1065,14 +924,7 @@ export async function getTier(id) {
1065
924
  throw new ValidationError('Tier ID is required and must be a non-empty string');
1066
925
  }
1067
926
 
1068
- try {
1069
- return await handleApiRequest('tiers', 'read', { id }, { id });
1070
- } catch (error) {
1071
- if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
1072
- throw new NotFoundError('Tier', id);
1073
- }
1074
- throw error;
1075
- }
927
+ return readResource('tiers', id, 'Tier');
1076
928
  }
1077
929
 
1078
930
  /**
@@ -89,7 +89,7 @@ const processImage = async (inputPath, outputDir) => {
89
89
  });
90
90
  // If processing fails, maybe fall back to using the original?
91
91
  // Or throw the error to fail the upload.
92
- throw new Error('Image processing failed: ' + error.message);
92
+ throw new Error('Image processing failed: ' + error.message, { cause: error });
93
93
  }
94
94
  };
95
95
 
@@ -204,18 +204,6 @@ export function validateMemberUpdateData(updateData) {
204
204
  }
205
205
  }
206
206
 
207
- /**
208
- * Sanitizes a value for use in NQL filters to prevent injection
209
- * Escapes backslashes, single quotes, and double quotes
210
- * @param {string} value - The value to sanitize
211
- * @returns {string} The sanitized value
212
- */
213
- export function sanitizeNqlValue(value) {
214
- if (!value) return value;
215
- // Escape backslashes first, then quotes
216
- return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"');
217
- }
218
-
219
207
  /**
220
208
  * Validates query options for member browsing
221
209
  * @param {Object} options - The query options to validate
@@ -388,5 +376,4 @@ export default {
388
376
  validateMemberLookup,
389
377
  validateSearchQuery,
390
378
  validateSearchOptions,
391
- sanitizeNqlValue,
392
379
  };
@@ -284,21 +284,8 @@ export function validateTierQueryOptions(options) {
284
284
  }
285
285
  }
286
286
 
287
- /**
288
- * Sanitizes a value for use in NQL filters to prevent injection
289
- * Escapes backslashes, single quotes, and double quotes
290
- * @param {string} value - The value to sanitize
291
- * @returns {string} The sanitized value
292
- */
293
- export function sanitizeNqlValue(value) {
294
- if (!value) return value;
295
- // Escape backslashes first, then quotes
296
- return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"');
297
- }
298
-
299
287
  export default {
300
288
  validateTierData,
301
289
  validateTierUpdateData,
302
290
  validateTierQueryOptions,
303
- sanitizeNqlValue,
304
291
  };
@@ -0,0 +1,38 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { sanitizeNqlValue } from '../nqlSanitizer.js';
3
+
4
+ describe('sanitizeNqlValue', () => {
5
+ it('should escape backslashes', () => {
6
+ expect(sanitizeNqlValue('hello\\world')).toBe('hello\\\\world');
7
+ });
8
+
9
+ it('should escape single quotes', () => {
10
+ expect(sanitizeNqlValue("it's")).toBe("it\\'s");
11
+ });
12
+
13
+ it('should escape double quotes', () => {
14
+ expect(sanitizeNqlValue('say "hello"')).toBe('say \\"hello\\"');
15
+ });
16
+
17
+ it('should escape all three special characters combined', () => {
18
+ expect(sanitizeNqlValue('a\\b\'c"d')).toBe('a\\\\b\\\'c\\"d');
19
+ });
20
+
21
+ it('should return null as-is', () => {
22
+ expect(sanitizeNqlValue(null)).toBe(null);
23
+ });
24
+
25
+ it('should return undefined as-is', () => {
26
+ expect(sanitizeNqlValue(undefined)).toBe(undefined);
27
+ });
28
+
29
+ it('should return empty string as-is', () => {
30
+ expect(sanitizeNqlValue('')).toBe('');
31
+ });
32
+
33
+ it('should pass through normal strings without special characters', () => {
34
+ expect(sanitizeNqlValue('simple-value')).toBe('simple-value');
35
+ expect(sanitizeNqlValue('test@example.com')).toBe('test@example.com');
36
+ expect(sanitizeNqlValue('hello world 123')).toBe('hello world 123');
37
+ });
38
+ });
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Sanitizes a value for use in NQL (Ghost's filter query language) to prevent injection.
3
+ * Escapes backslashes, single quotes, and double quotes.
4
+ * @param {string} value - The value to sanitize
5
+ * @returns {string} The sanitized value
6
+ */
7
+ export function sanitizeNqlValue(value) {
8
+ if (!value) return value;
9
+ // Escape backslashes first, then quotes
10
+ return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"');
11
+ }