@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
@@ -35,7 +35,7 @@ import {
35
35
  */
36
36
  export const createPostSchema = z.object({
37
37
  title: titleSchema,
38
- html: htmlContentSchema.describe('HTML content of the post'),
38
+ html: htmlContentSchema.meta({ description: 'HTML content of the post' }),
39
39
  slug: slugSchema.optional(),
40
40
  status: postStatusSchema.default('draft'),
41
41
  visibility: visibilitySchema.default('public'),
@@ -57,17 +57,21 @@ export const createPostSchema = z.object({
57
57
  .max(500, 'Twitter description cannot exceed 500 characters')
58
58
  .optional(),
59
59
  canonical_url: canonicalUrlSchema,
60
- tags: tagsSchema.describe(
61
- 'Array of tag names or IDs to associate with the post. On update, this fully replaces the existing tags array (not merged).'
62
- ),
63
- authors: authorsSchema.describe(
64
- 'Array of author IDs or emails. On update, this fully replaces the existing authors array (not merged).'
65
- ),
66
- published_at: isoDateSchema.optional().describe('Scheduled publish time (ISO 8601 format)'),
60
+ tags: tagsSchema.meta({
61
+ description:
62
+ 'Array of tag names or IDs to associate with the post. On update, this fully replaces the existing tags array (not merged).',
63
+ }),
64
+ authors: authorsSchema.meta({
65
+ description:
66
+ 'Array of author IDs or emails. On update, this fully replaces the existing authors array (not merged).',
67
+ }),
68
+ published_at: isoDateSchema
69
+ .optional()
70
+ .meta({ description: 'Scheduled publish time (ISO 8601 format)' }),
67
71
  codeinjection_head: z.string().optional(),
68
72
  codeinjection_foot: z.string().optional(),
69
- custom_template: z.string().optional().describe('Custom template filename'),
70
- email_only: z.boolean().default(false).describe('Whether post is email-only'),
73
+ custom_template: z.string().optional().meta({ description: 'Custom template filename' }),
74
+ email_only: z.boolean().default(false).meta({ description: 'Whether post is email-only' }),
71
75
  });
72
76
 
73
77
  /**
@@ -86,17 +90,20 @@ export const postQuerySchema = z.object({
86
90
  .string()
87
91
  .regex(/^[a-zA-Z0-9_\-:.'"\s,[\]<>=!+]+$/, 'Invalid filter: contains disallowed characters')
88
92
  .optional()
89
- .describe('NQL filter string (e.g., "status:published+featured:true")'),
93
+ .meta({ description: 'NQL filter string (e.g., "status:published+featured:true")' }),
90
94
  include: z
91
95
  .string()
92
96
  .optional()
93
- .describe('Comma-separated list of relations (e.g., "tags,authors")'),
94
- fields: z.string().optional().describe('Comma-separated list of fields to return'),
97
+ .meta({ description: 'Comma-separated list of relations (e.g., "tags,authors")' }),
98
+ fields: z.string().optional().meta({ description: 'Comma-separated list of fields to return' }),
95
99
  formats: z
96
100
  .string()
97
101
  .optional()
98
- .describe('Comma-separated list of formats (html, plaintext, mobiledoc)'),
99
- order: z.string().optional().describe('Order results (e.g., "published_at DESC", "title ASC")'),
102
+ .meta({ description: 'Comma-separated list of formats (html, plaintext, mobiledoc)' }),
103
+ order: z
104
+ .string()
105
+ .optional()
106
+ .meta({ description: 'Order results (e.g., "published_at DESC", "title ASC")' }),
100
107
  });
101
108
 
102
109
  /**
@@ -68,9 +68,11 @@ export const tagQueryBaseSchema = z.object({
68
68
  'Tag name contains invalid characters. Only letters, numbers, spaces, hyphens, underscores, and apostrophes are allowed'
69
69
  )
70
70
  .optional()
71
- .describe('Filter by exact tag name (legacy parameter, converted to filter internally)'),
72
- slug: z.string().optional().describe('Filter by tag slug'),
73
- visibility: visibilitySchema.optional().describe('Filter by visibility'),
71
+ .meta({
72
+ description: 'Filter by exact tag name (legacy parameter, converted to filter internally)',
73
+ }),
74
+ slug: z.string().optional().meta({ description: 'Filter by tag slug' }),
75
+ visibility: visibilitySchema.optional().meta({ description: 'Filter by visibility' }),
74
76
  limit: z
75
77
  .union([
76
78
  z.number().int().min(1).max(100),
@@ -79,7 +81,7 @@ export const tagQueryBaseSchema = z.object({
79
81
  ])
80
82
  .default(15)
81
83
  .optional()
82
- .describe('Number of tags to return (1-100) or "all" for all tags'),
84
+ .meta({ description: 'Number of tags to return (1-100) or "all" for all tags' }),
83
85
  page: z
84
86
  .union([z.number().int().min(1), z.string().regex(/^\d+$/).transform(Number)])
85
87
  .default(1)
@@ -88,12 +90,15 @@ export const tagQueryBaseSchema = z.object({
88
90
  .string()
89
91
  .regex(/^[a-zA-Z0-9_\-:.'"\s,[\]<>=!+]+$/, 'Invalid filter: contains disallowed characters')
90
92
  .optional()
91
- .describe('NQL filter string'),
93
+ .meta({ description: 'NQL filter string' }),
92
94
  include: z
93
95
  .string()
94
96
  .optional()
95
- .describe('Comma-separated list of relations to include (e.g., "count.posts")'),
96
- order: z.string().optional().describe('Order results (e.g., "name ASC", "created_at DESC")'),
97
+ .meta({ description: 'Comma-separated list of relations to include (e.g., "count.posts")' }),
98
+ order: z
99
+ .string()
100
+ .optional()
101
+ .meta({ description: 'Order results (e.g., "name ASC", "created_at DESC")' }),
97
102
  });
98
103
 
99
104
  /**
@@ -47,16 +47,19 @@ export const createTierSchema = z.object({
47
47
  name: z.string().min(1, 'Name cannot be empty').max(191, 'Name cannot exceed 191 characters'),
48
48
  description: z.string().max(2000, 'Description cannot exceed 2000 characters').optional(),
49
49
  slug: slugSchema.optional(),
50
- active: z.boolean().default(true).describe('Whether tier is currently active/available'),
50
+ active: z
51
+ .boolean()
52
+ .default(true)
53
+ .meta({ description: 'Whether tier is currently active/available' }),
51
54
  type: z
52
55
  .enum(['free', 'paid'], {
53
- errorMap: () => ({ message: 'Type must be free or paid' }),
56
+ error: () => ({ message: 'Type must be free or paid' }),
54
57
  })
55
58
  .default('paid'),
56
59
  welcome_page_url: z.string().url('Invalid welcome page URL').optional(),
57
60
  visibility: z
58
61
  .enum(['public', 'none'], {
59
- errorMap: () => ({ message: 'Visibility must be public or none' }),
62
+ error: () => ({ message: 'Visibility must be public or none' }),
60
63
  })
61
64
  .default('public'),
62
65
  trial_days: z
@@ -64,7 +67,7 @@ export const createTierSchema = z.object({
64
67
  .int()
65
68
  .min(0, 'Trial days must be non-negative')
66
69
  .default(0)
67
- .describe('Number of trial days for paid tiers'),
70
+ .meta({ description: 'Number of trial days for paid tiers' }),
68
71
  currency: z
69
72
  .string()
70
73
  .length(3, 'Currency must be 3-letter ISO code')
@@ -72,7 +75,10 @@ export const createTierSchema = z.object({
72
75
  .optional(),
73
76
  monthly_price: z.number().int().min(0, 'Monthly price must be non-negative').optional(),
74
77
  yearly_price: z.number().int().min(0, 'Yearly price must be non-negative').optional(),
75
- benefits: z.array(z.string()).optional().describe('Array of benefit names/descriptions'),
78
+ benefits: z
79
+ .array(z.string())
80
+ .optional()
81
+ .meta({ description: 'Array of benefit names/descriptions' }),
76
82
  });
77
83
 
78
84
  /**
@@ -91,12 +97,15 @@ export const tierQuerySchema = z.object({
91
97
  .string()
92
98
  .regex(/^[a-zA-Z0-9_\-:.'"\s,[\]<>=!+]+$/, 'Invalid filter: contains disallowed characters')
93
99
  .optional()
94
- .describe('NQL filter string (e.g., "type:paid+active:true")'),
95
- include: z.string().optional().describe('Comma-separated list of relations to include'),
100
+ .meta({ description: 'NQL filter string (e.g., "type:paid+active:true")' }),
101
+ include: z
102
+ .string()
103
+ .optional()
104
+ .meta({ description: 'Comma-separated list of relations to include' }),
96
105
  order: z
97
106
  .string()
98
107
  .optional()
99
- .describe('Order results (e.g., "monthly_price ASC", "created_at DESC")'),
108
+ .meta({ description: 'Order results (e.g., "monthly_price ASC", "created_at DESC")' }),
100
109
  });
101
110
 
102
111
  /**
@@ -173,8 +173,13 @@ describe('ghostServiceImproved - Members', () => {
173
173
  expect(api.members.read).toHaveBeenCalledWith(expect.any(Object), { id: memberId });
174
174
  // Should send ONLY updateData + updated_at, NOT the full existing member
175
175
  expect(api.members.edit).toHaveBeenCalledWith(
176
- { name: 'Jane Doe', note: 'Updated note', updated_at: '2023-01-01T00:00:00.000Z' },
177
- expect.objectContaining({ id: memberId })
176
+ {
177
+ id: memberId,
178
+ name: 'Jane Doe',
179
+ note: 'Updated note',
180
+ updated_at: '2023-01-01T00:00:00.000Z',
181
+ },
182
+ {}
178
183
  );
179
184
  // Verify read-only fields are NOT sent
180
185
  const editCallData = api.members.edit.mock.calls[0][0];
@@ -214,8 +214,8 @@ describe('ghostServiceImproved - Newsletter Operations', () => {
214
214
  expect(mockNewslettersApi.read).toHaveBeenCalledWith({}, { id: 'newsletter-123' });
215
215
  // Should send ONLY updateData + updated_at, NOT the full existing newsletter
216
216
  expect(mockNewslettersApi.edit).toHaveBeenCalledWith(
217
- { name: 'New Name', updated_at: '2024-01-01T00:00:00.000Z' },
218
- { id: 'newsletter-123' }
217
+ { id: 'newsletter-123', name: 'New Name', updated_at: '2024-01-01T00:00:00.000Z' },
218
+ {}
219
219
  );
220
220
  // Verify read-only fields are NOT sent
221
221
  const editCallData = mockNewslettersApi.edit.mock.calls[0][0];
@@ -242,8 +242,8 @@ describe('ghostServiceImproved - Newsletter Operations', () => {
242
242
 
243
243
  // Should send ONLY updateData + updated_at
244
244
  expect(mockNewslettersApi.edit).toHaveBeenCalledWith(
245
- { ...updateData, updated_at: '2024-01-01T00:00:00.000Z' },
246
- { id: 'newsletter-123' }
245
+ { id: 'newsletter-123', ...updateData, updated_at: '2024-01-01T00:00:00.000Z' },
246
+ {}
247
247
  );
248
248
  });
249
249
 
@@ -278,8 +278,12 @@ describe('ghostServiceImproved - Newsletter Operations', () => {
278
278
 
279
279
  // Should send ONLY updateData + updated_at
280
280
  expect(mockNewslettersApi.edit).toHaveBeenCalledWith(
281
- { description: 'Updated description', updated_at: '2024-01-01T00:00:00.000Z' },
282
- { id: 'newsletter-123' }
281
+ {
282
+ id: 'newsletter-123',
283
+ description: 'Updated description',
284
+ updated_at: '2024-01-01T00:00:00.000Z',
285
+ },
286
+ {}
283
287
  );
284
288
  });
285
289
  });
@@ -292,8 +292,8 @@ describe('ghostServiceImproved - Pages', () => {
292
292
  expect(api.pages.read).toHaveBeenCalledWith({}, { id: pageId });
293
293
  // Should send ONLY updateData + updated_at, NOT the full existing page
294
294
  expect(api.pages.edit).toHaveBeenCalledWith(
295
- { title: 'Updated Title', updated_at: '2024-01-01T00:00:00.000Z' },
296
- { id: pageId }
295
+ { id: pageId, title: 'Updated Title', updated_at: '2024-01-01T00:00:00.000Z' },
296
+ {}
297
297
  );
298
298
  // Verify read-only fields are NOT sent
299
299
  const editCallData = api.pages.edit.mock.calls[0][0];
@@ -345,8 +345,8 @@ describe('ghostServiceImproved - Pages', () => {
345
345
 
346
346
  describe('deletePage', () => {
347
347
  it('should throw error when page ID is missing', async () => {
348
- await expect(deletePage(null)).rejects.toThrow('Page ID is required');
349
- await expect(deletePage('')).rejects.toThrow('Page ID is required');
348
+ await expect(deletePage(null)).rejects.toThrow('Page ID is required for deletion');
349
+ await expect(deletePage('')).rejects.toThrow('Page ID is required for deletion');
350
350
  });
351
351
 
352
352
  it('should delete page successfully', async () => {
@@ -95,8 +95,8 @@ describe('ghostServiceImproved - Posts (updatePost)', () => {
95
95
  expect(result).toEqual(expectedResult);
96
96
  // Should send ONLY updateData + updated_at, NOT the full existing post
97
97
  expect(api.posts.edit).toHaveBeenCalledWith(
98
- { title: 'Updated Title', updated_at: '2024-01-01T00:00:00.000Z' },
99
- { id: postId }
98
+ { id: postId, title: 'Updated Title', updated_at: '2024-01-01T00:00:00.000Z' },
99
+ {}
100
100
  );
101
101
  // Verify read-only fields are NOT sent
102
102
  const editCallData = api.posts.edit.mock.calls[0][0];
@@ -173,8 +173,8 @@ describe('ghostServiceImproved - Posts (updatePost)', () => {
173
173
 
174
174
  expect(result).toBeDefined();
175
175
  expect(api.posts.edit).toHaveBeenCalledWith(
176
- { ...updateData, updated_at: existingPost.updated_at },
177
- { id: postId }
176
+ { id: postId, ...updateData, updated_at: existingPost.updated_at },
177
+ {}
178
178
  );
179
179
  });
180
180
  });
@@ -431,8 +431,8 @@ describe('ghostServiceImproved - Tags', () => {
431
431
  expect(api.tags.read).toHaveBeenCalled();
432
432
  // Should send ONLY updateData, NOT the full existing tag
433
433
  expect(api.tags.edit).toHaveBeenCalledWith(
434
- { name: 'Updated JavaScript', description: 'Updated description' },
435
- { id: tagId }
434
+ { id: tagId, name: 'Updated JavaScript', description: 'Updated description' },
435
+ {}
436
436
  );
437
437
  // Verify read-only fields are NOT sent
438
438
  const editCallData = api.tags.edit.mock.calls[0][0];
@@ -270,14 +270,7 @@ describe('ghostServiceImproved - Tiers', () => {
270
270
 
271
271
  const result = await getTier('tier-1');
272
272
 
273
- expect(api.tiers.read).toHaveBeenCalledWith(
274
- expect.objectContaining({
275
- id: 'tier-1',
276
- }),
277
- expect.objectContaining({
278
- id: 'tier-1',
279
- })
280
- );
273
+ expect(api.tiers.read).toHaveBeenCalledWith({}, { id: 'tier-1' });
281
274
  expect(result).toEqual(mockTier);
282
275
  });
283
276
 
@@ -327,14 +320,16 @@ describe('ghostServiceImproved - Tiers', () => {
327
320
 
328
321
  const result = await updateTier('tier-1', updateData);
329
322
 
330
- expect(api.tiers.read).toHaveBeenCalledWith(
331
- expect.objectContaining({ id: 'tier-1' }),
332
- expect.objectContaining({ id: 'tier-1' })
333
- );
323
+ expect(api.tiers.read).toHaveBeenCalledWith({}, { id: 'tier-1' });
334
324
  // Should send ONLY updateData + updated_at, NOT the full existing tier
335
325
  expect(api.tiers.edit).toHaveBeenCalledWith(
336
- { name: 'Premium Plus', monthly_price: 1299, updated_at: '2024-01-01T00:00:00.000Z' },
337
- expect.objectContaining({ id: 'tier-1' })
326
+ {
327
+ id: 'tier-1',
328
+ name: 'Premium Plus',
329
+ monthly_price: 1299,
330
+ updated_at: '2024-01-01T00:00:00.000Z',
331
+ },
332
+ {}
338
333
  );
339
334
  // Verify read-only fields are NOT sent
340
335
  const editCallData = api.tiers.edit.mock.calls[0][0];
@@ -6,7 +6,6 @@ import {
6
6
  validateMemberLookup,
7
7
  validateSearchQuery,
8
8
  validateSearchOptions,
9
- sanitizeNqlValue,
10
9
  } from '../memberService.js';
11
10
 
12
11
  describe('memberService - Validation', () => {
@@ -447,31 +446,4 @@ describe('memberService - Validation', () => {
447
446
  );
448
447
  });
449
448
  });
450
-
451
- describe('sanitizeNqlValue', () => {
452
- it('should escape backslashes', () => {
453
- expect(sanitizeNqlValue('test\\value')).toBe('test\\\\value');
454
- });
455
-
456
- it('should escape single quotes', () => {
457
- expect(sanitizeNqlValue("test'value")).toBe("test\\'value");
458
- });
459
-
460
- it('should escape double quotes', () => {
461
- expect(sanitizeNqlValue('test"value')).toBe('test\\"value');
462
- });
463
-
464
- it('should handle multiple special characters', () => {
465
- expect(sanitizeNqlValue('test\'value"with\\chars')).toBe('test\\\'value\\"with\\\\chars');
466
- });
467
-
468
- it('should not modify strings without special characters', () => {
469
- expect(sanitizeNqlValue('normalvalue')).toBe('normalvalue');
470
- expect(sanitizeNqlValue('test@example.com')).toBe('test@example.com');
471
- });
472
-
473
- it('should handle empty string', () => {
474
- expect(sanitizeNqlValue('')).toBe('');
475
- });
476
- });
477
449
  });
@@ -3,7 +3,6 @@ import {
3
3
  validateTierData,
4
4
  validateTierUpdateData,
5
5
  validateTierQueryOptions,
6
- sanitizeNqlValue,
7
6
  } from '../tierService.js';
8
7
  import { ValidationError } from '../../errors/index.js';
9
8
 
@@ -342,31 +341,4 @@ describe('tierService - Validation', () => {
342
341
  ).not.toThrow();
343
342
  });
344
343
  });
345
-
346
- describe('sanitizeNqlValue', () => {
347
- it('should return value if undefined or null', () => {
348
- expect(sanitizeNqlValue(null)).toBe(null);
349
- expect(sanitizeNqlValue(undefined)).toBe(undefined);
350
- });
351
-
352
- it('should escape backslashes', () => {
353
- expect(sanitizeNqlValue('test\\value')).toBe('test\\\\value');
354
- });
355
-
356
- it('should escape single quotes', () => {
357
- expect(sanitizeNqlValue("test'value")).toBe("test\\'value");
358
- });
359
-
360
- it('should escape double quotes', () => {
361
- expect(sanitizeNqlValue('test"value')).toBe('test\\"value');
362
- });
363
-
364
- it('should escape multiple special characters', () => {
365
- expect(sanitizeNqlValue('test\\value"with\'quotes')).toBe('test\\\\value\\"with\\\'quotes');
366
- });
367
-
368
- it('should handle strings without special characters', () => {
369
- expect(sanitizeNqlValue('simple-value')).toBe('simple-value');
370
- });
371
- });
372
344
  });