@jgardner04/ghost-mcp-server 1.12.5 → 1.13.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgardner04/ghost-mcp-server",
3
- "version": "1.12.5",
3
+ "version": "1.13.1",
4
4
  "description": "A Model Context Protocol (MCP) server for interacting with Ghost CMS via the Admin API",
5
5
  "author": "Jonathan Gardner",
6
6
  "type": "module",
@@ -19,7 +19,7 @@
19
19
  "LICENSE"
20
20
  ],
21
21
  "engines": {
22
- "node": ">=18.0.0"
22
+ "node": ">=24.0.0"
23
23
  },
24
24
  "repository": {
25
25
  "type": "git",
@@ -51,7 +51,7 @@
51
51
  },
52
52
  "dependencies": {
53
53
  "@anthropic-ai/sdk": "^0.39.0",
54
- "@modelcontextprotocol/sdk": "^1.24.0",
54
+ "@modelcontextprotocol/sdk": "^1.25.2",
55
55
  "@tryghost/admin-api": "^1.13.12",
56
56
  "axios": "^1.12.1",
57
57
  "boxen": "^7.1.1",
@@ -14,6 +14,16 @@ vi.mock('@modelcontextprotocol/sdk/server/mcp.js', () => {
14
14
  mockTools.set(name, { name, description, schema, handler });
15
15
  }
16
16
 
17
+ registerTool(name, options, handler) {
18
+ // Store in the same format for backward compatibility with tests
19
+ mockTools.set(name, {
20
+ name,
21
+ description: options.description,
22
+ schema: options.inputSchema,
23
+ handler,
24
+ });
25
+ }
26
+
17
27
  connect(_transport) {
18
28
  return Promise.resolve();
19
29
  }
@@ -208,6 +218,8 @@ describe('mcp_server - ghost_get_posts tool', () => {
208
218
  expect(tool.schema.shape.include).toBeDefined();
209
219
  expect(tool.schema.shape.filter).toBeDefined();
210
220
  expect(tool.schema.shape.order).toBeDefined();
221
+ expect(tool.schema.shape.fields).toBeDefined();
222
+ expect(tool.schema.shape.formats).toBeDefined();
211
223
  });
212
224
 
213
225
  it('should retrieve posts with default options', async () => {
@@ -320,6 +332,36 @@ describe('mcp_server - ghost_get_posts tool', () => {
320
332
  expect(mockGetPosts).toHaveBeenCalledWith({ order: 'published_at DESC' });
321
333
  });
322
334
 
335
+ it('should pass fields parameter', async () => {
336
+ const mockPosts = [{ id: '1', title: 'Test Post', slug: 'test-post' }];
337
+ mockGetPosts.mockResolvedValue(mockPosts);
338
+
339
+ const tool = mockTools.get('ghost_get_posts');
340
+ await tool.handler({ fields: 'id,title,slug' });
341
+
342
+ expect(mockGetPosts).toHaveBeenCalledWith({ fields: 'id,title,slug' });
343
+ });
344
+
345
+ it('should pass formats parameter', async () => {
346
+ const mockPosts = [{ id: '1', title: 'Test Post', html: '<p>Content</p>' }];
347
+ mockGetPosts.mockResolvedValue(mockPosts);
348
+
349
+ const tool = mockTools.get('ghost_get_posts');
350
+ await tool.handler({ formats: 'html,plaintext' });
351
+
352
+ expect(mockGetPosts).toHaveBeenCalledWith({ formats: 'html,plaintext' });
353
+ });
354
+
355
+ it('should pass both fields and formats parameters', async () => {
356
+ const mockPosts = [{ id: '1', title: 'Test Post' }];
357
+ mockGetPosts.mockResolvedValue(mockPosts);
358
+
359
+ const tool = mockTools.get('ghost_get_posts');
360
+ await tool.handler({ fields: 'id,title', formats: 'html' });
361
+
362
+ expect(mockGetPosts).toHaveBeenCalledWith({ fields: 'id,title', formats: 'html' });
363
+ });
364
+
323
365
  it('should pass all parameters combined', async () => {
324
366
  const mockPosts = [{ id: '1', title: 'Test Post' }];
325
367
  mockGetPosts.mockResolvedValue(mockPosts);
@@ -332,6 +374,8 @@ describe('mcp_server - ghost_get_posts tool', () => {
332
374
  include: 'tags,authors',
333
375
  filter: 'featured:true',
334
376
  order: 'published_at DESC',
377
+ fields: 'id,title,slug',
378
+ formats: 'html,plaintext',
335
379
  });
336
380
 
337
381
  expect(mockGetPosts).toHaveBeenCalledWith({
@@ -341,6 +385,8 @@ describe('mcp_server - ghost_get_posts tool', () => {
341
385
  include: 'tags,authors',
342
386
  filter: 'featured:true',
343
387
  order: 'published_at DESC',
388
+ fields: 'id,title,slug',
389
+ formats: 'html,plaintext',
344
390
  });
345
391
  });
346
392
 
@@ -376,6 +422,218 @@ describe('mcp_server - ghost_get_posts tool', () => {
376
422
  });
377
423
  });
378
424
 
425
+ describe('mcp_server - ghost_get_tags tool', () => {
426
+ beforeEach(async () => {
427
+ vi.clearAllMocks();
428
+ // Don't clear mockTools - they're registered once on module load
429
+ if (mockTools.size === 0) {
430
+ await import('../mcp_server.js');
431
+ }
432
+ });
433
+
434
+ it('should register ghost_get_tags tool', () => {
435
+ expect(mockTools.has('ghost_get_tags')).toBe(true);
436
+ });
437
+
438
+ it('should have correct schema with all optional parameters', () => {
439
+ const tool = mockTools.get('ghost_get_tags');
440
+ expect(tool).toBeDefined();
441
+ expect(tool.description).toContain('tags');
442
+ expect(tool.schema).toBeDefined();
443
+ // Zod schemas store field definitions in schema.shape
444
+ expect(tool.schema.shape.limit).toBeDefined();
445
+ expect(tool.schema.shape.page).toBeDefined();
446
+ expect(tool.schema.shape.order).toBeDefined();
447
+ expect(tool.schema.shape.include).toBeDefined();
448
+ expect(tool.schema.shape.name).toBeDefined();
449
+ expect(tool.schema.shape.slug).toBeDefined();
450
+ expect(tool.schema.shape.visibility).toBeDefined();
451
+ expect(tool.schema.shape.filter).toBeDefined();
452
+ });
453
+
454
+ it('should retrieve tags with default options', async () => {
455
+ const mockTags = [
456
+ { id: '1', name: 'Tag 1', slug: 'tag-1' },
457
+ { id: '2', name: 'Tag 2', slug: 'tag-2' },
458
+ ];
459
+ mockGetTags.mockResolvedValue(mockTags);
460
+
461
+ const tool = mockTools.get('ghost_get_tags');
462
+ const result = await tool.handler({});
463
+
464
+ expect(mockGetTags).toHaveBeenCalledWith({});
465
+ expect(result.content[0].text).toContain('Tag 1');
466
+ expect(result.content[0].text).toContain('Tag 2');
467
+ });
468
+
469
+ it('should pass limit and page parameters', async () => {
470
+ const mockTags = [{ id: '1', name: 'Tag 1', slug: 'tag-1' }];
471
+ mockGetTags.mockResolvedValue(mockTags);
472
+
473
+ const tool = mockTools.get('ghost_get_tags');
474
+ await tool.handler({ limit: 10, page: 2 });
475
+
476
+ expect(mockGetTags).toHaveBeenCalledWith({
477
+ limit: 10,
478
+ page: 2,
479
+ });
480
+ });
481
+
482
+ it('should pass order parameter', async () => {
483
+ const mockTags = [{ id: '1', name: 'Tag 1', slug: 'tag-1' }];
484
+ mockGetTags.mockResolvedValue(mockTags);
485
+
486
+ const tool = mockTools.get('ghost_get_tags');
487
+ await tool.handler({ order: 'name ASC' });
488
+
489
+ expect(mockGetTags).toHaveBeenCalledWith({
490
+ order: 'name ASC',
491
+ });
492
+ });
493
+
494
+ it('should pass include parameter', async () => {
495
+ const mockTags = [{ id: '1', name: 'Tag 1', slug: 'tag-1' }];
496
+ mockGetTags.mockResolvedValue(mockTags);
497
+
498
+ const tool = mockTools.get('ghost_get_tags');
499
+ await tool.handler({ include: 'count.posts' });
500
+
501
+ expect(mockGetTags).toHaveBeenCalledWith({
502
+ include: 'count.posts',
503
+ });
504
+ });
505
+
506
+ it('should filter by name parameter', async () => {
507
+ const mockTags = [{ id: '1', name: 'Test Tag', slug: 'test-tag' }];
508
+ mockGetTags.mockResolvedValue(mockTags);
509
+
510
+ const tool = mockTools.get('ghost_get_tags');
511
+ await tool.handler({ name: 'Test Tag' });
512
+
513
+ expect(mockGetTags).toHaveBeenCalledWith({
514
+ filter: "name:'Test Tag'",
515
+ });
516
+ });
517
+
518
+ it('should filter by slug parameter', async () => {
519
+ const mockTags = [{ id: '1', name: 'Test Tag', slug: 'test-tag' }];
520
+ mockGetTags.mockResolvedValue(mockTags);
521
+
522
+ const tool = mockTools.get('ghost_get_tags');
523
+ await tool.handler({ slug: 'test-tag' });
524
+
525
+ expect(mockGetTags).toHaveBeenCalledWith({
526
+ filter: "slug:'test-tag'",
527
+ });
528
+ });
529
+
530
+ it('should filter by visibility parameter', async () => {
531
+ const mockTags = [{ id: '1', name: 'Test Tag', slug: 'test-tag' }];
532
+ mockGetTags.mockResolvedValue(mockTags);
533
+
534
+ const tool = mockTools.get('ghost_get_tags');
535
+ await tool.handler({ visibility: 'public' });
536
+
537
+ expect(mockGetTags).toHaveBeenCalledWith({
538
+ filter: "visibility:'public'",
539
+ });
540
+ });
541
+
542
+ it('should escape single quotes in name parameter', async () => {
543
+ const mockTags = [{ id: '1', name: "O'Reilly", slug: 'oreilly' }];
544
+ mockGetTags.mockResolvedValue(mockTags);
545
+
546
+ const tool = mockTools.get('ghost_get_tags');
547
+ await tool.handler({ name: "O'Reilly" });
548
+
549
+ expect(mockGetTags).toHaveBeenCalledWith({
550
+ filter: "name:'O''Reilly'",
551
+ });
552
+ });
553
+
554
+ it('should escape single quotes in slug parameter', async () => {
555
+ const mockTags = [{ id: '1', name: 'Test', slug: "test'slug" }];
556
+ mockGetTags.mockResolvedValue(mockTags);
557
+
558
+ const tool = mockTools.get('ghost_get_tags');
559
+ await tool.handler({ slug: "test'slug" });
560
+
561
+ expect(mockGetTags).toHaveBeenCalledWith({
562
+ filter: "slug:'test''slug'",
563
+ });
564
+ });
565
+
566
+ it('should combine multiple filter parameters', async () => {
567
+ const mockTags = [{ id: '1', name: 'News', slug: 'news' }];
568
+ mockGetTags.mockResolvedValue(mockTags);
569
+
570
+ const tool = mockTools.get('ghost_get_tags');
571
+ await tool.handler({ name: 'News', visibility: 'public' });
572
+
573
+ expect(mockGetTags).toHaveBeenCalledWith({
574
+ filter: "name:'News'+visibility:'public'",
575
+ });
576
+ });
577
+
578
+ it('should combine individual filters with custom filter parameter', async () => {
579
+ const mockTags = [{ id: '1', name: 'News', slug: 'news' }];
580
+ mockGetTags.mockResolvedValue(mockTags);
581
+
582
+ const tool = mockTools.get('ghost_get_tags');
583
+ await tool.handler({ name: 'News', filter: 'featured:true' });
584
+
585
+ expect(mockGetTags).toHaveBeenCalledWith({
586
+ filter: "name:'News'+featured:true",
587
+ });
588
+ });
589
+
590
+ it('should pass all parameters combined', async () => {
591
+ const mockTags = [{ id: '1', name: 'News', slug: 'news' }];
592
+ mockGetTags.mockResolvedValue(mockTags);
593
+
594
+ const tool = mockTools.get('ghost_get_tags');
595
+ await tool.handler({
596
+ limit: 20,
597
+ page: 1,
598
+ order: 'name ASC',
599
+ include: 'count.posts',
600
+ name: 'News',
601
+ visibility: 'public',
602
+ });
603
+
604
+ expect(mockGetTags).toHaveBeenCalledWith({
605
+ limit: 20,
606
+ page: 1,
607
+ order: 'name ASC',
608
+ include: 'count.posts',
609
+ filter: "name:'News'+visibility:'public'",
610
+ });
611
+ });
612
+
613
+ it('should handle service errors', async () => {
614
+ mockGetTags.mockRejectedValue(new Error('Service error'));
615
+
616
+ const tool = mockTools.get('ghost_get_tags');
617
+ const result = await tool.handler({});
618
+
619
+ expect(result.isError).toBe(true);
620
+ expect(result.content[0].text).toContain('Service error');
621
+ });
622
+
623
+ it('should return formatted JSON response', async () => {
624
+ const mockTags = [{ id: '1', name: 'Test Tag', slug: 'test-tag' }];
625
+ mockGetTags.mockResolvedValue(mockTags);
626
+
627
+ const tool = mockTools.get('ghost_get_tags');
628
+ const result = await tool.handler({});
629
+
630
+ expect(result.content).toBeDefined();
631
+ expect(result.content[0].type).toBe('text');
632
+ expect(result.content[0].text).toContain('"id": "1"');
633
+ expect(result.content[0].text).toContain('"name": "Test Tag"');
634
+ });
635
+ });
636
+
379
637
  describe('mcp_server - ghost_get_post tool', () => {
380
638
  beforeEach(async () => {
381
639
  vi.clearAllMocks();
@@ -14,6 +14,16 @@ vi.mock('@modelcontextprotocol/sdk/server/mcp.js', () => {
14
14
  mockTools.set(name, { name, description, schema, handler });
15
15
  }
16
16
 
17
+ registerTool(name, options, handler) {
18
+ // Store in the same format for backward compatibility with tests
19
+ mockTools.set(name, {
20
+ name,
21
+ description: options.description,
22
+ schema: options.inputSchema,
23
+ handler,
24
+ });
25
+ }
26
+
17
27
  connect(_transport) {
18
28
  return Promise.resolve();
19
29
  }
@@ -104,12 +114,17 @@ vi.mock('axios', () => ({
104
114
  // Mock fs
105
115
  const mockUnlink = vi.fn((path, cb) => cb(null));
106
116
  const mockCreateWriteStream = vi.fn();
107
- vi.mock('fs', () => ({
108
- default: {
109
- unlink: (...args) => mockUnlink(...args),
110
- createWriteStream: (...args) => mockCreateWriteStream(...args),
111
- },
112
- }));
117
+ vi.mock('fs', async (importOriginal) => {
118
+ const actual = await importOriginal();
119
+ return {
120
+ ...actual,
121
+ default: {
122
+ ...actual.default,
123
+ unlink: (...args) => mockUnlink(...args),
124
+ createWriteStream: (...args) => mockCreateWriteStream(...args),
125
+ },
126
+ };
127
+ });
113
128
 
114
129
  // Mock os
115
130
  vi.mock('os', () => ({
@@ -44,7 +44,7 @@ describe('tagController', () => {
44
44
 
45
45
  await getTags(req, res, next);
46
46
 
47
- expect(ghostService.getTags).toHaveBeenCalledWith(undefined);
47
+ expect(ghostService.getTags).toHaveBeenCalledWith({});
48
48
  expect(res.status).toHaveBeenCalledWith(200);
49
49
  expect(res.json).toHaveBeenCalledWith(mockTags);
50
50
  expect(next).not.toHaveBeenCalled();
@@ -60,7 +60,7 @@ describe('tagController', () => {
60
60
 
61
61
  await getTags(req, res, next);
62
62
 
63
- expect(ghostService.getTags).toHaveBeenCalledWith('Technology');
63
+ expect(ghostService.getTags).toHaveBeenCalledWith({ filter: "name:'Technology'" });
64
64
  expect(res.status).toHaveBeenCalledWith(200);
65
65
  expect(res.json).toHaveBeenCalledWith(mockTags);
66
66
  expect(next).not.toHaveBeenCalled();
@@ -76,11 +76,126 @@ describe('tagController', () => {
76
76
 
77
77
  await getTags(req, res, next);
78
78
 
79
- expect(ghostService.getTags).toHaveBeenCalledWith(undefined);
79
+ expect(ghostService.getTags).toHaveBeenCalledWith({});
80
80
  expect(res.status).not.toHaveBeenCalled();
81
81
  expect(res.json).not.toHaveBeenCalled();
82
82
  expect(next).toHaveBeenCalledWith(mockError);
83
83
  });
84
+
85
+ it('should return 400 when name contains invalid characters', async () => {
86
+ const req = createMockRequest({ query: { name: "'; DROP TABLE tags; --" } });
87
+ const res = createMockResponse();
88
+ const next = createMockNext();
89
+
90
+ await getTags(req, res, next);
91
+
92
+ expect(ghostService.getTags).not.toHaveBeenCalled();
93
+ expect(res.status).toHaveBeenCalledWith(400);
94
+ expect(res.json).toHaveBeenCalledWith(
95
+ expect.objectContaining({
96
+ message: 'Invalid query parameters',
97
+ errors: expect.arrayContaining([
98
+ expect.objectContaining({
99
+ path: 'name',
100
+ message: expect.stringContaining('invalid characters'),
101
+ }),
102
+ ]),
103
+ })
104
+ );
105
+ expect(next).not.toHaveBeenCalled();
106
+ });
107
+
108
+ it('should pass through additional query parameters', async () => {
109
+ const mockTags = [{ id: '1', name: 'Tech', slug: 'tech' }];
110
+ ghostService.getTags.mockResolvedValue(mockTags);
111
+
112
+ const req = createMockRequest({
113
+ query: {
114
+ limit: '10',
115
+ order: 'name asc',
116
+ include: 'count.posts',
117
+ },
118
+ });
119
+ const res = createMockResponse();
120
+ const next = createMockNext();
121
+
122
+ await getTags(req, res, next);
123
+
124
+ expect(ghostService.getTags).toHaveBeenCalledWith({
125
+ limit: 10,
126
+ order: 'name asc',
127
+ include: 'count.posts',
128
+ });
129
+ expect(res.status).toHaveBeenCalledWith(200);
130
+ });
131
+
132
+ it('should return 400 when both name and filter parameters are provided', async () => {
133
+ const req = createMockRequest({
134
+ query: {
135
+ name: 'Technology',
136
+ filter: 'slug:tech',
137
+ },
138
+ });
139
+ const res = createMockResponse();
140
+ const next = createMockNext();
141
+
142
+ await getTags(req, res, next);
143
+
144
+ expect(ghostService.getTags).not.toHaveBeenCalled();
145
+ expect(res.status).toHaveBeenCalledWith(400);
146
+ expect(res.json).toHaveBeenCalledWith(
147
+ expect.objectContaining({
148
+ message: 'Invalid query parameters',
149
+ errors: expect.arrayContaining([
150
+ expect.objectContaining({
151
+ path: 'filter',
152
+ message: expect.stringContaining('Cannot specify both'),
153
+ }),
154
+ ]),
155
+ })
156
+ );
157
+ expect(next).not.toHaveBeenCalled();
158
+ });
159
+
160
+ it('should return 400 for NQL injection attempts in name', async () => {
161
+ const req = createMockRequest({ query: { name: 'test]+[slug:other' } });
162
+ const res = createMockResponse();
163
+ const next = createMockNext();
164
+
165
+ await getTags(req, res, next);
166
+
167
+ expect(ghostService.getTags).not.toHaveBeenCalled();
168
+ expect(res.status).toHaveBeenCalledWith(400);
169
+ expect(res.json).toHaveBeenCalledWith(
170
+ expect.objectContaining({
171
+ message: 'Invalid query parameters',
172
+ errors: expect.arrayContaining([
173
+ expect.objectContaining({
174
+ path: 'name',
175
+ message: expect.stringContaining('invalid characters'),
176
+ }),
177
+ ]),
178
+ })
179
+ );
180
+ expect(next).not.toHaveBeenCalled();
181
+ });
182
+
183
+ it('should return 400 for NQL operator injection in name', async () => {
184
+ const req = createMockRequest({ query: { name: 'name+slug:test' } });
185
+ const res = createMockResponse();
186
+ const next = createMockNext();
187
+
188
+ await getTags(req, res, next);
189
+
190
+ expect(ghostService.getTags).not.toHaveBeenCalled();
191
+ expect(res.status).toHaveBeenCalledWith(400);
192
+ expect(res.json).toHaveBeenCalledWith(
193
+ expect.objectContaining({
194
+ message: 'Invalid query parameters',
195
+ })
196
+ );
197
+ expect(next).not.toHaveBeenCalled();
198
+ });
84
199
  });
85
200
 
86
201
  describe('createTag', () => {
@@ -1,5 +1,7 @@
1
1
  import { getTags as getGhostTags, createTag as createGhostTag } from '../services/ghostService.js';
2
2
  import { createContextLogger } from '../utils/logger.js';
3
+ import { tagQuerySchema } from '../schemas/tagSchemas.js';
4
+ import { ZodError } from 'zod';
3
5
 
4
6
  /**
5
7
  * Controller to handle fetching tags.
@@ -9,24 +11,48 @@ const getTags = async (req, res, next) => {
9
11
  const logger = createContextLogger('tag-controller');
10
12
 
11
13
  try {
12
- const { name } = req.query; // Get name from query params like /api/tags?name=some-tag
14
+ // Validate query parameters using Zod schema
15
+ const validatedQuery = tagQuerySchema.parse(req.query);
16
+
17
+ // Build options object
18
+ const options = {};
19
+
20
+ // Handle legacy name parameter by converting to filter
21
+ if (validatedQuery.name) {
22
+ // Escape single quotes and backslashes to prevent injection
23
+ const safeName = validatedQuery.name.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
24
+ options.filter = `name:'${safeName}'`;
25
+ }
26
+
27
+ // Add other query parameters
28
+ if (validatedQuery.limit) options.limit = validatedQuery.limit;
29
+ if (validatedQuery.filter) options.filter = validatedQuery.filter;
30
+ if (validatedQuery.order) options.order = validatedQuery.order;
31
+ if (validatedQuery.include) options.include = validatedQuery.include;
32
+
13
33
  logger.info('Fetching tags', {
14
- filtered: !!name,
15
- filterName: name,
34
+ options,
16
35
  });
17
36
 
18
- const tags = await getGhostTags(name);
37
+ const tags = await getGhostTags(options);
19
38
 
20
39
  logger.info('Tags retrieved successfully', {
21
40
  count: tags.length,
22
- filtered: !!name,
23
41
  });
24
42
 
25
43
  res.status(200).json(tags);
26
44
  } catch (error) {
45
+ if (error instanceof ZodError) {
46
+ logger.warn('Invalid query parameters', { errors: error.errors });
47
+ return res.status(400).json({
48
+ message: 'Invalid query parameters',
49
+ errors: error.errors.map((e) => ({ path: e.path.join('.'), message: e.message })),
50
+ });
51
+ }
52
+
27
53
  logger.error('Get tags failed', {
28
54
  error: error.message,
29
- filterName: req.query?.name,
55
+ query: req.query,
30
56
  });
31
57
  next(error);
32
58
  }