@jgardner04/ghost-mcp-server 1.11.0 → 1.12.1

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.
@@ -128,24 +128,9 @@ describe('ghostServiceImproved - Members', () => {
128
128
  expect(result).toEqual(mockCreatedMember);
129
129
  });
130
130
 
131
- it('should throw validation error for missing email', async () => {
132
- await expect(createMember({})).rejects.toThrow('Member validation failed');
133
- });
134
-
135
- it('should throw validation error for invalid email', async () => {
136
- await expect(createMember({ email: 'invalid-email' })).rejects.toThrow(
137
- 'Member validation failed'
138
- );
139
- });
140
-
141
- it('should throw validation error for invalid labels type', async () => {
142
- await expect(
143
- createMember({
144
- email: 'test@example.com',
145
- labels: 'premium',
146
- })
147
- ).rejects.toThrow('Member validation failed');
148
- });
131
+ // NOTE: Input validation tests (missing email, invalid email, invalid labels)
132
+ // have been moved to MCP layer tests. The service layer now relies on
133
+ // Zod schema validation at the MCP tool layer.
149
134
 
150
135
  it('should handle Ghost API errors', async () => {
151
136
  const memberData = {
@@ -225,11 +210,8 @@ describe('ghostServiceImproved - Members', () => {
225
210
  );
226
211
  });
227
212
 
228
- it('should throw validation error for invalid email in update', async () => {
229
- await expect(updateMember('member-1', { email: 'invalid-email' })).rejects.toThrow(
230
- 'Member validation failed'
231
- );
232
- });
213
+ // NOTE: Input validation tests (invalid email in update) have been moved to
214
+ // MCP layer tests. The service layer now relies on Zod schema validation.
233
215
 
234
216
  it('should throw not found error if member does not exist', async () => {
235
217
  api.members.read.mockRejectedValue({
@@ -345,14 +327,8 @@ describe('ghostServiceImproved - Members', () => {
345
327
  );
346
328
  });
347
329
 
348
- it('should throw validation error for invalid limit', async () => {
349
- await expect(getMembers({ limit: 0 })).rejects.toThrow('Member query validation failed');
350
- await expect(getMembers({ limit: 101 })).rejects.toThrow('Member query validation failed');
351
- });
352
-
353
- it('should throw validation error for invalid page', async () => {
354
- await expect(getMembers({ page: 0 })).rejects.toThrow('Member query validation failed');
355
- });
330
+ // NOTE: Input validation tests (invalid limit, invalid page) have been moved to
331
+ // MCP layer tests. The service layer now relies on Zod schema validation.
356
332
 
357
333
  it('should return empty array when no members found', async () => {
358
334
  api.members.browse.mockResolvedValue([]);
@@ -407,15 +383,8 @@ describe('ghostServiceImproved - Members', () => {
407
383
  expect(result).toEqual(mockMember);
408
384
  });
409
385
 
410
- it('should throw validation error when neither id nor email provided', async () => {
411
- await expect(getMember({})).rejects.toThrow('Member lookup validation failed');
412
- });
413
-
414
- it('should throw validation error for invalid email format', async () => {
415
- await expect(getMember({ email: 'invalid-email' })).rejects.toThrow(
416
- 'Member lookup validation failed'
417
- );
418
- });
386
+ // NOTE: Input validation tests (missing id/email, invalid email format) have been
387
+ // moved to MCP layer tests. The service layer now relies on Zod schema validation.
419
388
 
420
389
  it('should throw not found error when member not found by ID', async () => {
421
390
  api.members.read.mockRejectedValue({
@@ -493,27 +462,9 @@ describe('ghostServiceImproved - Members', () => {
493
462
  );
494
463
  });
495
464
 
496
- it('should throw validation error for empty query', async () => {
497
- await expect(searchMembers('')).rejects.toThrow('Search query validation failed');
498
- await expect(searchMembers(' ')).rejects.toThrow('Search query validation failed');
499
- });
500
-
501
- it('should throw validation error for non-string query', async () => {
502
- await expect(searchMembers(123)).rejects.toThrow('Search query validation failed');
503
- await expect(searchMembers(null)).rejects.toThrow('Search query validation failed');
504
- });
505
-
506
- it('should throw validation error for invalid limit', async () => {
507
- await expect(searchMembers('test', { limit: 0 })).rejects.toThrow(
508
- 'Search options validation failed'
509
- );
510
- await expect(searchMembers('test', { limit: 51 })).rejects.toThrow(
511
- 'Search options validation failed'
512
- );
513
- await expect(searchMembers('test', { limit: 100 })).rejects.toThrow(
514
- 'Search options validation failed'
515
- );
516
- });
465
+ // NOTE: Input validation tests (empty query, non-string query, invalid limit)
466
+ // have been moved to MCP layer tests. The service layer now relies on
467
+ // Zod schema validation at the MCP tool layer.
517
468
 
518
469
  it('should sanitize query to prevent NQL injection', async () => {
519
470
  const mockMembers = [];
@@ -26,7 +26,11 @@ describe('postService', () => {
26
26
  vi.clearAllMocks();
27
27
  });
28
28
 
29
- describe('createPostService - validation', () => {
29
+ // NOTE: Input validation tests have been moved to MCP layer tests.
30
+ // The postService no longer performs Joi validation - input is validated
31
+ // by Zod schemas at the MCP tool layer (see mcp_server_improved.js).
32
+
33
+ describe('createPostService - basic functionality', () => {
30
34
  it('should accept valid input and create a post', async () => {
31
35
  const validInput = {
32
36
  title: 'Test Post',
@@ -47,41 +51,6 @@ describe('postService', () => {
47
51
  );
48
52
  });
49
53
 
50
- it('should reject input with missing title', async () => {
51
- const invalidInput = {
52
- html: '<p>Test content</p>',
53
- };
54
-
55
- await expect(createPostService(invalidInput)).rejects.toThrow(
56
- 'Invalid post input: "title" is required'
57
- );
58
- expect(createPost).not.toHaveBeenCalled();
59
- });
60
-
61
- it('should reject input with missing html', async () => {
62
- const invalidInput = {
63
- title: 'Test Post',
64
- };
65
-
66
- await expect(createPostService(invalidInput)).rejects.toThrow(
67
- 'Invalid post input: "html" is required'
68
- );
69
- expect(createPost).not.toHaveBeenCalled();
70
- });
71
-
72
- it('should reject input with invalid status', async () => {
73
- const invalidInput = {
74
- title: 'Test Post',
75
- html: '<p>Content</p>',
76
- status: 'invalid-status',
77
- };
78
-
79
- await expect(createPostService(invalidInput)).rejects.toThrow(
80
- 'Invalid post input: "status" must be one of [draft, published, scheduled]'
81
- );
82
- expect(createPost).not.toHaveBeenCalled();
83
- });
84
-
85
54
  it('should accept valid status values', async () => {
86
55
  const statuses = ['draft', 'published', 'scheduled'];
87
56
  createPost.mockResolvedValue({ id: '1', title: 'Test' });
@@ -100,39 +69,6 @@ describe('postService', () => {
100
69
  }
101
70
  });
102
71
 
103
- it('should validate tags array with maximum length', async () => {
104
- const invalidInput = {
105
- title: 'Test Post',
106
- html: '<p>Content</p>',
107
- tags: Array(11).fill('tag'), // 11 tags exceeds max of 10
108
- };
109
-
110
- await expect(createPostService(invalidInput)).rejects.toThrow('Invalid post input:');
111
- expect(createPost).not.toHaveBeenCalled();
112
- });
113
-
114
- it('should validate tag string max length', async () => {
115
- const invalidInput = {
116
- title: 'Test Post',
117
- html: '<p>Content</p>',
118
- tags: ['a'.repeat(51)], // 51 chars exceeds max of 50
119
- };
120
-
121
- await expect(createPostService(invalidInput)).rejects.toThrow('Invalid post input:');
122
- expect(createPost).not.toHaveBeenCalled();
123
- });
124
-
125
- it('should validate feature_image is a valid URI', async () => {
126
- const invalidInput = {
127
- title: 'Test Post',
128
- html: '<p>Content</p>',
129
- feature_image: 'not-a-valid-url',
130
- };
131
-
132
- await expect(createPostService(invalidInput)).rejects.toThrow('Invalid post input:');
133
- expect(createPost).not.toHaveBeenCalled();
134
- });
135
-
136
72
  it('should accept valid feature_image URI', async () => {
137
73
  const validInput = {
138
74
  title: 'Test Post',
@@ -217,27 +153,7 @@ describe('postService', () => {
217
153
  );
218
154
  });
219
155
 
220
- it('should reject tags array with non-string values', async () => {
221
- const input = {
222
- title: 'Test Post',
223
- html: '<p>Content</p>',
224
- tags: [null, 'valid-tag'],
225
- };
226
-
227
- await expect(createPostService(input)).rejects.toThrow('Invalid post input:');
228
- expect(createPost).not.toHaveBeenCalled();
229
- });
230
-
231
- it('should reject tags array with empty strings', async () => {
232
- const input = {
233
- title: 'Test Post',
234
- html: '<p>Content</p>',
235
- tags: ['', 'valid-tag'],
236
- };
237
-
238
- await expect(createPostService(input)).rejects.toThrow('Invalid post input:');
239
- expect(createPost).not.toHaveBeenCalled();
240
- });
156
+ // NOTE: Tag validation tests (non-string values, empty strings) moved to MCP layer
241
157
 
242
158
  it('should trim whitespace from tag names', async () => {
243
159
  const input = {
@@ -383,15 +299,7 @@ describe('postService', () => {
383
299
  expect(calledDescription).toBe('a'.repeat(497) + '...');
384
300
  });
385
301
 
386
- it('should reject empty HTML content', async () => {
387
- const input = {
388
- title: 'Test Post',
389
- html: '',
390
- };
391
-
392
- await expect(createPostService(input)).rejects.toThrow('Invalid post input:');
393
- expect(createPost).not.toHaveBeenCalled();
394
- });
302
+ // NOTE: Empty HTML validation test moved to MCP layer
395
303
 
396
304
  it('should strip HTML tags and truncate when generating meta_description', async () => {
397
305
  const longHtml = '<p>' + 'word '.repeat(200) + '</p>';
@@ -792,10 +792,7 @@ export async function deleteTag(tagId) {
792
792
  * @throws {GhostAPIError} If the API request fails
793
793
  */
794
794
  export async function createMember(memberData, options = {}) {
795
- // Import and validate input
796
- const { validateMemberData } = await import('./memberService.js');
797
- validateMemberData(memberData);
798
-
795
+ // Input validation is performed at the MCP tool layer using Zod schemas
799
796
  try {
800
797
  return await handleApiRequest('members', 'add', memberData, options);
801
798
  } catch (error) {
@@ -825,14 +822,11 @@ export async function createMember(memberData, options = {}) {
825
822
  * @throws {GhostAPIError} If the API request fails
826
823
  */
827
824
  export async function updateMember(memberId, updateData, options = {}) {
825
+ // Input validation is performed at the MCP tool layer using Zod schemas
828
826
  if (!memberId) {
829
827
  throw new ValidationError('Member ID is required for update');
830
828
  }
831
829
 
832
- // Import and validate update data
833
- const { validateMemberUpdateData } = await import('./memberService.js');
834
- validateMemberUpdateData(updateData);
835
-
836
830
  try {
837
831
  // Get existing member to retrieve updated_at for conflict resolution
838
832
  const existingMember = await handleApiRequest('members', 'read', { id: memberId });
@@ -889,10 +883,7 @@ export async function deleteMember(memberId) {
889
883
  * @throws {GhostAPIError} If the API request fails
890
884
  */
891
885
  export async function getMembers(options = {}) {
892
- // Import and validate query options
893
- const { validateMemberQueryOptions } = await import('./memberService.js');
894
- validateMemberQueryOptions(options);
895
-
886
+ // Input validation is performed at the MCP tool layer using Zod schemas
896
887
  const defaultOptions = {
897
888
  limit: 15,
898
889
  ...options,
@@ -918,12 +909,12 @@ export async function getMembers(options = {}) {
918
909
  * @throws {GhostAPIError} If the API request fails
919
910
  */
920
911
  export async function getMember(params) {
921
- // Import and validate lookup parameters
922
- const { validateMemberLookup, sanitizeNqlValue } = await import('./memberService.js');
923
- const { lookupType, id, email } = validateMemberLookup(params);
912
+ // Input validation is performed at the MCP tool layer using Zod schemas
913
+ const { sanitizeNqlValue } = await import('./memberService.js');
914
+ const { id, email } = params;
924
915
 
925
916
  try {
926
- if (lookupType === 'id') {
917
+ if (id) {
927
918
  // Lookup by ID using read endpoint
928
919
  return await handleApiRequest('members', 'read', { id }, { id });
929
920
  } else {
@@ -960,11 +951,9 @@ export async function getMember(params) {
960
951
  * @throws {GhostAPIError} If the API request fails
961
952
  */
962
953
  export async function searchMembers(query, options = {}) {
963
- // Import and validate search query and options
964
- const { validateSearchQuery, validateSearchOptions, sanitizeNqlValue } =
965
- await import('./memberService.js');
966
- validateSearchOptions(options);
967
- const sanitizedQuery = sanitizeNqlValue(validateSearchQuery(query));
954
+ // Input validation is performed at the MCP tool layer using Zod schemas
955
+ const { sanitizeNqlValue } = await import('./memberService.js');
956
+ const sanitizedQuery = sanitizeNqlValue(query.trim());
968
957
 
969
958
  const limit = options.limit || 15;
970
959
 
@@ -1,12 +1,10 @@
1
1
  import sanitizeHtml from 'sanitize-html';
2
- import Joi from 'joi';
3
2
  import { createContextLogger } from '../utils/logger.js';
4
3
  import {
5
4
  createPost as createGhostPost,
6
5
  getTags as getGhostTags,
7
6
  createTag as createGhostTag,
8
- // Import other necessary functions from ghostService later
9
- } from './ghostService.js'; // Note the relative path
7
+ } from './ghostService.js';
10
8
 
11
9
  /**
12
10
  * Helper to generate a simple meta description from HTML content.
@@ -37,37 +35,13 @@ const generateSimpleMetaDescription = (htmlContent, maxLength = 500) => {
37
35
  /**
38
36
  * Service layer function to handle the business logic of creating a post.
39
37
  * Transforms input data, handles/resolves tags, includes feature image and metadata.
40
- * @param {object} postInput - Data received from the controller.
38
+ * Input validation is handled at the MCP layer using Zod schemas.
39
+ * @param {object} postInput - Validated data received from the MCP tool.
41
40
  * @returns {Promise<object>} The created post object from the Ghost API.
42
41
  */
43
- // Validation schema for post input
44
- const postInputSchema = Joi.object({
45
- title: Joi.string().max(255).required(),
46
- html: Joi.string().required(),
47
- custom_excerpt: Joi.string().max(500).optional(),
48
- status: Joi.string().valid('draft', 'published', 'scheduled').optional(),
49
- published_at: Joi.string().isoDate().optional(),
50
- tags: Joi.array().items(Joi.string().max(50)).max(10).optional(),
51
- feature_image: Joi.string().uri().optional(),
52
- feature_image_alt: Joi.string().max(255).optional(),
53
- feature_image_caption: Joi.string().max(500).optional(),
54
- meta_title: Joi.string().max(70).optional(),
55
- meta_description: Joi.string().max(160).optional(),
56
- });
57
-
58
42
  const createPostService = async (postInput) => {
59
43
  const logger = createContextLogger('post-service');
60
44
 
61
- // Validate input to prevent format string vulnerabilities
62
- const { error, value: validatedInput } = postInputSchema.validate(postInput);
63
- if (error) {
64
- logger.error('Post input validation failed', {
65
- error: error.details[0].message,
66
- inputKeys: Object.keys(postInput),
67
- });
68
- throw new Error(`Invalid post input: ${error.details[0].message}`);
69
- }
70
-
71
45
  const {
72
46
  title,
73
47
  html,
@@ -80,7 +54,7 @@ const createPostService = async (postInput) => {
80
54
  feature_image_caption,
81
55
  meta_title,
82
56
  meta_description,
83
- } = validatedInput;
57
+ } = postInput;
84
58
 
85
59
  // --- Resolve Tag Names to Tag Objects (ID/Slug/Name) ---
86
60
  let resolvedTags = [];