@jgardner04/ghost-mcp-server 1.1.13 → 1.3.0

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.1.13",
3
+ "version": "1.3.0",
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",
@@ -0,0 +1,756 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ // Mock the McpServer to capture tool registrations
4
+ const mockTools = new Map();
5
+
6
+ vi.mock('@modelcontextprotocol/sdk/server/mcp.js', () => {
7
+ return {
8
+ McpServer: class MockMcpServer {
9
+ constructor(config) {
10
+ this.config = config;
11
+ }
12
+
13
+ tool(name, description, schema, handler) {
14
+ mockTools.set(name, { name, description, schema, handler });
15
+ }
16
+
17
+ connect(_transport) {
18
+ return Promise.resolve();
19
+ }
20
+ },
21
+ };
22
+ });
23
+
24
+ vi.mock('@modelcontextprotocol/sdk/server/stdio.js', () => {
25
+ return {
26
+ StdioServerTransport: class MockStdioServerTransport {},
27
+ };
28
+ });
29
+
30
+ // Mock dotenv
31
+ vi.mock('dotenv', () => ({
32
+ default: { config: vi.fn() },
33
+ }));
34
+
35
+ // Mock crypto
36
+ vi.mock('crypto', () => ({
37
+ default: { randomUUID: vi.fn().mockReturnValue('test-uuid-1234') },
38
+ }));
39
+
40
+ // Mock services - will be lazy loaded
41
+ const mockGetPosts = vi.fn();
42
+ const mockGetPost = vi.fn();
43
+ const mockGetTags = vi.fn();
44
+ const mockCreateTag = vi.fn();
45
+ const mockUploadImage = vi.fn();
46
+ const mockCreatePostService = vi.fn();
47
+ const mockProcessImage = vi.fn();
48
+ const mockValidateImageUrl = vi.fn();
49
+ const mockCreateSecureAxiosConfig = vi.fn();
50
+ const mockUpdatePost = vi.fn();
51
+ const mockDeletePost = vi.fn();
52
+
53
+ vi.mock('../services/ghostService.js', () => ({
54
+ getPosts: (...args) => mockGetPosts(...args),
55
+ getPost: (...args) => mockGetPost(...args),
56
+ getTags: (...args) => mockGetTags(...args),
57
+ createTag: (...args) => mockCreateTag(...args),
58
+ uploadImage: (...args) => mockUploadImage(...args),
59
+ }));
60
+
61
+ vi.mock('../services/postService.js', () => ({
62
+ createPostService: (...args) => mockCreatePostService(...args),
63
+ }));
64
+
65
+ vi.mock('../services/ghostServiceImproved.js', () => ({
66
+ updatePost: (...args) => mockUpdatePost(...args),
67
+ deletePost: (...args) => mockDeletePost(...args),
68
+ }));
69
+
70
+ vi.mock('../services/imageProcessingService.js', () => ({
71
+ processImage: (...args) => mockProcessImage(...args),
72
+ }));
73
+
74
+ vi.mock('../utils/urlValidator.js', () => ({
75
+ validateImageUrl: (...args) => mockValidateImageUrl(...args),
76
+ createSecureAxiosConfig: (...args) => mockCreateSecureAxiosConfig(...args),
77
+ }));
78
+
79
+ // Mock axios
80
+ const mockAxios = vi.fn();
81
+ vi.mock('axios', () => ({
82
+ default: (...args) => mockAxios(...args),
83
+ }));
84
+
85
+ // Mock fs
86
+ const mockUnlink = vi.fn((path, cb) => cb(null));
87
+ const mockCreateWriteStream = vi.fn();
88
+ vi.mock('fs', () => ({
89
+ default: {
90
+ unlink: (...args) => mockUnlink(...args),
91
+ createWriteStream: (...args) => mockCreateWriteStream(...args),
92
+ },
93
+ }));
94
+
95
+ // Mock os
96
+ vi.mock('os', () => ({
97
+ default: { tmpdir: vi.fn().mockReturnValue('/tmp') },
98
+ }));
99
+
100
+ // Mock path
101
+ vi.mock('path', async () => {
102
+ const actual = await vi.importActual('path');
103
+ return {
104
+ default: actual,
105
+ ...actual,
106
+ };
107
+ });
108
+
109
+ describe('mcp_server_improved - ghost_get_posts tool', () => {
110
+ beforeEach(async () => {
111
+ vi.clearAllMocks();
112
+ // Don't clear mockTools - they're registered once on module load
113
+ // Import the module to register tools (only first time)
114
+ if (mockTools.size === 0) {
115
+ await import('../mcp_server_improved.js');
116
+ }
117
+ });
118
+
119
+ it('should register ghost_get_posts tool', () => {
120
+ expect(mockTools.has('ghost_get_posts')).toBe(true);
121
+ });
122
+
123
+ it('should have correct schema with all optional parameters', () => {
124
+ const tool = mockTools.get('ghost_get_posts');
125
+ expect(tool).toBeDefined();
126
+ expect(tool.description).toContain('posts');
127
+ expect(tool.schema).toBeDefined();
128
+ expect(tool.schema.limit).toBeDefined();
129
+ expect(tool.schema.page).toBeDefined();
130
+ expect(tool.schema.status).toBeDefined();
131
+ expect(tool.schema.include).toBeDefined();
132
+ expect(tool.schema.filter).toBeDefined();
133
+ expect(tool.schema.order).toBeDefined();
134
+ });
135
+
136
+ it('should retrieve posts with default options', async () => {
137
+ const mockPosts = [
138
+ { id: '1', title: 'Post 1', slug: 'post-1', status: 'published' },
139
+ { id: '2', title: 'Post 2', slug: 'post-2', status: 'draft' },
140
+ ];
141
+ mockGetPosts.mockResolvedValue(mockPosts);
142
+
143
+ const tool = mockTools.get('ghost_get_posts');
144
+ const result = await tool.handler({});
145
+
146
+ expect(mockGetPosts).toHaveBeenCalledWith({});
147
+ expect(result.content[0].text).toContain('Post 1');
148
+ expect(result.content[0].text).toContain('Post 2');
149
+ });
150
+
151
+ it('should pass limit and page parameters', async () => {
152
+ const mockPosts = [{ id: '1', title: 'Post 1', slug: 'post-1' }];
153
+ mockGetPosts.mockResolvedValue(mockPosts);
154
+
155
+ const tool = mockTools.get('ghost_get_posts');
156
+ await tool.handler({ limit: 10, page: 2 });
157
+
158
+ expect(mockGetPosts).toHaveBeenCalledWith({ limit: 10, page: 2 });
159
+ });
160
+
161
+ it('should validate limit is between 1 and 100', () => {
162
+ const tool = mockTools.get('ghost_get_posts');
163
+ const schema = tool.schema;
164
+
165
+ // Test that limit schema exists and has proper validation
166
+ expect(schema.limit).toBeDefined();
167
+ expect(() => schema.limit.parse(0)).toThrow();
168
+ expect(() => schema.limit.parse(101)).toThrow();
169
+ expect(schema.limit.parse(50)).toBe(50);
170
+ });
171
+
172
+ it('should validate page is at least 1', () => {
173
+ const tool = mockTools.get('ghost_get_posts');
174
+ const schema = tool.schema;
175
+
176
+ expect(schema.page).toBeDefined();
177
+ expect(() => schema.page.parse(0)).toThrow();
178
+ expect(schema.page.parse(1)).toBe(1);
179
+ });
180
+
181
+ it('should pass status filter', async () => {
182
+ const mockPosts = [{ id: '1', title: 'Published Post', status: 'published' }];
183
+ mockGetPosts.mockResolvedValue(mockPosts);
184
+
185
+ const tool = mockTools.get('ghost_get_posts');
186
+ await tool.handler({ status: 'published' });
187
+
188
+ expect(mockGetPosts).toHaveBeenCalledWith({ status: 'published' });
189
+ });
190
+
191
+ it('should validate status enum values', () => {
192
+ const tool = mockTools.get('ghost_get_posts');
193
+ const schema = tool.schema;
194
+
195
+ expect(schema.status).toBeDefined();
196
+ expect(() => schema.status.parse('invalid')).toThrow();
197
+ expect(schema.status.parse('published')).toBe('published');
198
+ expect(schema.status.parse('draft')).toBe('draft');
199
+ expect(schema.status.parse('scheduled')).toBe('scheduled');
200
+ expect(schema.status.parse('all')).toBe('all');
201
+ });
202
+
203
+ it('should pass include parameter', async () => {
204
+ const mockPosts = [
205
+ {
206
+ id: '1',
207
+ title: 'Post with tags',
208
+ tags: [{ name: 'tech' }],
209
+ authors: [{ name: 'John' }],
210
+ },
211
+ ];
212
+ mockGetPosts.mockResolvedValue(mockPosts);
213
+
214
+ const tool = mockTools.get('ghost_get_posts');
215
+ await tool.handler({ include: 'tags,authors' });
216
+
217
+ expect(mockGetPosts).toHaveBeenCalledWith({ include: 'tags,authors' });
218
+ });
219
+
220
+ it('should pass filter parameter (NQL)', async () => {
221
+ const mockPosts = [{ id: '1', title: 'Featured Post', featured: true }];
222
+ mockGetPosts.mockResolvedValue(mockPosts);
223
+
224
+ const tool = mockTools.get('ghost_get_posts');
225
+ await tool.handler({ filter: 'featured:true' });
226
+
227
+ expect(mockGetPosts).toHaveBeenCalledWith({ filter: 'featured:true' });
228
+ });
229
+
230
+ it('should pass order parameter', async () => {
231
+ const mockPosts = [
232
+ { id: '1', title: 'Newest', published_at: '2025-12-10' },
233
+ { id: '2', title: 'Older', published_at: '2025-12-01' },
234
+ ];
235
+ mockGetPosts.mockResolvedValue(mockPosts);
236
+
237
+ const tool = mockTools.get('ghost_get_posts');
238
+ await tool.handler({ order: 'published_at DESC' });
239
+
240
+ expect(mockGetPosts).toHaveBeenCalledWith({ order: 'published_at DESC' });
241
+ });
242
+
243
+ it('should pass all parameters combined', async () => {
244
+ const mockPosts = [{ id: '1', title: 'Test Post' }];
245
+ mockGetPosts.mockResolvedValue(mockPosts);
246
+
247
+ const tool = mockTools.get('ghost_get_posts');
248
+ await tool.handler({
249
+ limit: 20,
250
+ page: 1,
251
+ status: 'published',
252
+ include: 'tags,authors',
253
+ filter: 'featured:true',
254
+ order: 'published_at DESC',
255
+ });
256
+
257
+ expect(mockGetPosts).toHaveBeenCalledWith({
258
+ limit: 20,
259
+ page: 1,
260
+ status: 'published',
261
+ include: 'tags,authors',
262
+ filter: 'featured:true',
263
+ order: 'published_at DESC',
264
+ });
265
+ });
266
+
267
+ it('should handle errors from ghostService', async () => {
268
+ mockGetPosts.mockRejectedValue(new Error('Ghost API error'));
269
+
270
+ const tool = mockTools.get('ghost_get_posts');
271
+ const result = await tool.handler({});
272
+
273
+ expect(result.isError).toBe(true);
274
+ expect(result.content[0].text).toContain('Ghost API error');
275
+ });
276
+
277
+ it('should return formatted JSON response', async () => {
278
+ const mockPosts = [
279
+ {
280
+ id: '1',
281
+ title: 'Test Post',
282
+ slug: 'test-post',
283
+ html: '<p>Content</p>',
284
+ status: 'published',
285
+ },
286
+ ];
287
+ mockGetPosts.mockResolvedValue(mockPosts);
288
+
289
+ const tool = mockTools.get('ghost_get_posts');
290
+ const result = await tool.handler({});
291
+
292
+ expect(result.content).toBeDefined();
293
+ expect(result.content[0].type).toBe('text');
294
+ expect(result.content[0].text).toContain('"id": "1"');
295
+ expect(result.content[0].text).toContain('"title": "Test Post"');
296
+ });
297
+ });
298
+
299
+ describe('mcp_server_improved - ghost_get_post tool', () => {
300
+ beforeEach(async () => {
301
+ vi.clearAllMocks();
302
+ // Don't clear mockTools - they're registered once on module load
303
+ if (mockTools.size === 0) {
304
+ await import('../mcp_server_improved.js');
305
+ }
306
+ });
307
+
308
+ it('should register ghost_get_post tool', () => {
309
+ expect(mockTools.has('ghost_get_post')).toBe(true);
310
+ });
311
+
312
+ it('should have correct schema requiring one of id or slug', () => {
313
+ const tool = mockTools.get('ghost_get_post');
314
+ expect(tool).toBeDefined();
315
+ expect(tool.description).toContain('post');
316
+ expect(tool.schema).toBeDefined();
317
+ expect(tool.schema.id).toBeDefined();
318
+ expect(tool.schema.slug).toBeDefined();
319
+ expect(tool.schema.include).toBeDefined();
320
+ });
321
+
322
+ it('should retrieve post by ID', async () => {
323
+ const mockPost = {
324
+ id: '123',
325
+ title: 'Test Post',
326
+ slug: 'test-post',
327
+ html: '<p>Content</p>',
328
+ status: 'published',
329
+ };
330
+ mockGetPost.mockResolvedValue(mockPost);
331
+
332
+ const tool = mockTools.get('ghost_get_post');
333
+ const result = await tool.handler({ id: '123' });
334
+
335
+ expect(mockGetPost).toHaveBeenCalledWith('123', {});
336
+ expect(result.content[0].text).toContain('"id": "123"');
337
+ expect(result.content[0].text).toContain('"title": "Test Post"');
338
+ });
339
+
340
+ it('should retrieve post by slug', async () => {
341
+ const mockPost = {
342
+ id: '123',
343
+ title: 'Test Post',
344
+ slug: 'test-post',
345
+ html: '<p>Content</p>',
346
+ status: 'published',
347
+ };
348
+ mockGetPost.mockResolvedValue(mockPost);
349
+
350
+ const tool = mockTools.get('ghost_get_post');
351
+ const result = await tool.handler({ slug: 'test-post' });
352
+
353
+ expect(mockGetPost).toHaveBeenCalledWith('slug/test-post', {});
354
+ expect(result.content[0].text).toContain('"title": "Test Post"');
355
+ });
356
+
357
+ it('should pass include parameter with ID', async () => {
358
+ const mockPost = {
359
+ id: '123',
360
+ title: 'Post with relations',
361
+ tags: [{ name: 'tech' }],
362
+ authors: [{ name: 'John' }],
363
+ };
364
+ mockGetPost.mockResolvedValue(mockPost);
365
+
366
+ const tool = mockTools.get('ghost_get_post');
367
+ await tool.handler({ id: '123', include: 'tags,authors' });
368
+
369
+ expect(mockGetPost).toHaveBeenCalledWith('123', { include: 'tags,authors' });
370
+ });
371
+
372
+ it('should pass include parameter with slug', async () => {
373
+ const mockPost = {
374
+ id: '123',
375
+ title: 'Post with relations',
376
+ slug: 'test-post',
377
+ tags: [{ name: 'tech' }],
378
+ };
379
+ mockGetPost.mockResolvedValue(mockPost);
380
+
381
+ const tool = mockTools.get('ghost_get_post');
382
+ await tool.handler({ slug: 'test-post', include: 'tags' });
383
+
384
+ expect(mockGetPost).toHaveBeenCalledWith('slug/test-post', { include: 'tags' });
385
+ });
386
+
387
+ it('should prefer ID over slug when both provided', async () => {
388
+ const mockPost = { id: '123', title: 'Test Post', slug: 'test-post' };
389
+ mockGetPost.mockResolvedValue(mockPost);
390
+
391
+ const tool = mockTools.get('ghost_get_post');
392
+ await tool.handler({ id: '123', slug: 'wrong-slug' });
393
+
394
+ expect(mockGetPost).toHaveBeenCalledWith('123', {});
395
+ });
396
+
397
+ it('should handle not found errors', async () => {
398
+ mockGetPost.mockRejectedValue(new Error('Post not found'));
399
+
400
+ const tool = mockTools.get('ghost_get_post');
401
+ const result = await tool.handler({ id: 'nonexistent' });
402
+
403
+ expect(result.isError).toBe(true);
404
+ expect(result.content[0].text).toContain('Post not found');
405
+ });
406
+
407
+ it('should handle errors from ghostService', async () => {
408
+ mockGetPost.mockRejectedValue(new Error('Ghost API error'));
409
+
410
+ const tool = mockTools.get('ghost_get_post');
411
+ const result = await tool.handler({ slug: 'test' });
412
+
413
+ expect(result.isError).toBe(true);
414
+ expect(result.content[0].text).toContain('Ghost API error');
415
+ });
416
+
417
+ it('should return formatted JSON response', async () => {
418
+ const mockPost = {
419
+ id: '1',
420
+ uuid: 'uuid-123',
421
+ title: 'Test Post',
422
+ slug: 'test-post',
423
+ html: '<p>Content</p>',
424
+ status: 'published',
425
+ created_at: '2025-12-10T00:00:00.000Z',
426
+ updated_at: '2025-12-10T00:00:00.000Z',
427
+ };
428
+ mockGetPost.mockResolvedValue(mockPost);
429
+
430
+ const tool = mockTools.get('ghost_get_post');
431
+ const result = await tool.handler({ id: '1' });
432
+
433
+ expect(result.content).toBeDefined();
434
+ expect(result.content[0].type).toBe('text');
435
+ expect(result.content[0].text).toContain('"id": "1"');
436
+ expect(result.content[0].text).toContain('"title": "Test Post"');
437
+ expect(result.content[0].text).toContain('"status": "published"');
438
+ });
439
+
440
+ it('should handle validation error when neither id nor slug provided', async () => {
441
+ const tool = mockTools.get('ghost_get_post');
442
+ const result = await tool.handler({});
443
+
444
+ expect(result.isError).toBe(true);
445
+ expect(result.content[0].text).toContain('Either id or slug is required');
446
+ });
447
+ });
448
+
449
+ describe('mcp_server_improved - ghost_update_post tool', () => {
450
+ beforeEach(async () => {
451
+ vi.clearAllMocks();
452
+ // Don't clear mockTools - they're registered once on module load
453
+ if (mockTools.size === 0) {
454
+ await import('../mcp_server_improved.js');
455
+ }
456
+ });
457
+
458
+ it('should register ghost_update_post tool', () => {
459
+ expect(mockTools.has('ghost_update_post')).toBe(true);
460
+ });
461
+
462
+ it('should have correct schema with required id field', () => {
463
+ const tool = mockTools.get('ghost_update_post');
464
+ expect(tool).toBeDefined();
465
+ expect(tool.description).toContain('Updates an existing post');
466
+ expect(tool.schema).toBeDefined();
467
+ expect(tool.schema.id).toBeDefined();
468
+ expect(tool.schema.title).toBeDefined();
469
+ expect(tool.schema.html).toBeDefined();
470
+ expect(tool.schema.status).toBeDefined();
471
+ expect(tool.schema.tags).toBeDefined();
472
+ expect(tool.schema.feature_image).toBeDefined();
473
+ expect(tool.schema.feature_image_alt).toBeDefined();
474
+ expect(tool.schema.feature_image_caption).toBeDefined();
475
+ expect(tool.schema.meta_title).toBeDefined();
476
+ expect(tool.schema.meta_description).toBeDefined();
477
+ expect(tool.schema.published_at).toBeDefined();
478
+ expect(tool.schema.custom_excerpt).toBeDefined();
479
+ });
480
+
481
+ it('should update post title', async () => {
482
+ const mockUpdatedPost = {
483
+ id: '123',
484
+ title: 'Updated Title',
485
+ slug: 'test-post',
486
+ html: '<p>Content</p>',
487
+ status: 'published',
488
+ updated_at: '2025-12-10T12:00:00.000Z',
489
+ };
490
+ mockUpdatePost.mockResolvedValue(mockUpdatedPost);
491
+
492
+ const tool = mockTools.get('ghost_update_post');
493
+ const result = await tool.handler({ id: '123', title: 'Updated Title' });
494
+
495
+ expect(mockUpdatePost).toHaveBeenCalledWith('123', { title: 'Updated Title' });
496
+ expect(result.content[0].text).toContain('"title": "Updated Title"');
497
+ });
498
+
499
+ it('should update post content', async () => {
500
+ const mockUpdatedPost = {
501
+ id: '123',
502
+ title: 'Test Post',
503
+ html: '<p>Updated content</p>',
504
+ status: 'published',
505
+ updated_at: '2025-12-10T12:00:00.000Z',
506
+ };
507
+ mockUpdatePost.mockResolvedValue(mockUpdatedPost);
508
+
509
+ const tool = mockTools.get('ghost_update_post');
510
+ const result = await tool.handler({ id: '123', html: '<p>Updated content</p>' });
511
+
512
+ expect(mockUpdatePost).toHaveBeenCalledWith('123', { html: '<p>Updated content</p>' });
513
+ expect(result.content[0].text).toContain('Updated content');
514
+ });
515
+
516
+ it('should update post status', async () => {
517
+ const mockUpdatedPost = {
518
+ id: '123',
519
+ title: 'Test Post',
520
+ html: '<p>Content</p>',
521
+ status: 'published',
522
+ updated_at: '2025-12-10T12:00:00.000Z',
523
+ };
524
+ mockUpdatePost.mockResolvedValue(mockUpdatedPost);
525
+
526
+ const tool = mockTools.get('ghost_update_post');
527
+ const result = await tool.handler({ id: '123', status: 'published' });
528
+
529
+ expect(mockUpdatePost).toHaveBeenCalledWith('123', { status: 'published' });
530
+ expect(result.content[0].text).toContain('"status": "published"');
531
+ });
532
+
533
+ it('should update post tags', async () => {
534
+ const mockUpdatedPost = {
535
+ id: '123',
536
+ title: 'Test Post',
537
+ html: '<p>Content</p>',
538
+ tags: [{ name: 'tech' }, { name: 'javascript' }],
539
+ updated_at: '2025-12-10T12:00:00.000Z',
540
+ };
541
+ mockUpdatePost.mockResolvedValue(mockUpdatedPost);
542
+
543
+ const tool = mockTools.get('ghost_update_post');
544
+ const result = await tool.handler({ id: '123', tags: ['tech', 'javascript'] });
545
+
546
+ expect(mockUpdatePost).toHaveBeenCalledWith('123', { tags: ['tech', 'javascript'] });
547
+ expect(result.content[0].text).toContain('tech');
548
+ expect(result.content[0].text).toContain('javascript');
549
+ });
550
+
551
+ it('should update post featured image', async () => {
552
+ const mockUpdatedPost = {
553
+ id: '123',
554
+ title: 'Test Post',
555
+ feature_image: 'https://example.com/new-image.jpg',
556
+ feature_image_alt: 'New image',
557
+ updated_at: '2025-12-10T12:00:00.000Z',
558
+ };
559
+ mockUpdatePost.mockResolvedValue(mockUpdatedPost);
560
+
561
+ const tool = mockTools.get('ghost_update_post');
562
+ const result = await tool.handler({
563
+ id: '123',
564
+ feature_image: 'https://example.com/new-image.jpg',
565
+ feature_image_alt: 'New image',
566
+ });
567
+
568
+ expect(mockUpdatePost).toHaveBeenCalledWith('123', {
569
+ feature_image: 'https://example.com/new-image.jpg',
570
+ feature_image_alt: 'New image',
571
+ });
572
+ expect(result.content[0].text).toContain('new-image.jpg');
573
+ });
574
+
575
+ it('should update SEO meta fields', async () => {
576
+ const mockUpdatedPost = {
577
+ id: '123',
578
+ title: 'Test Post',
579
+ meta_title: 'SEO Title',
580
+ meta_description: 'SEO Description',
581
+ updated_at: '2025-12-10T12:00:00.000Z',
582
+ };
583
+ mockUpdatePost.mockResolvedValue(mockUpdatedPost);
584
+
585
+ const tool = mockTools.get('ghost_update_post');
586
+ const result = await tool.handler({
587
+ id: '123',
588
+ meta_title: 'SEO Title',
589
+ meta_description: 'SEO Description',
590
+ });
591
+
592
+ expect(mockUpdatePost).toHaveBeenCalledWith('123', {
593
+ meta_title: 'SEO Title',
594
+ meta_description: 'SEO Description',
595
+ });
596
+ expect(result.content[0].text).toContain('SEO Title');
597
+ expect(result.content[0].text).toContain('SEO Description');
598
+ });
599
+
600
+ it('should update multiple fields at once', async () => {
601
+ const mockUpdatedPost = {
602
+ id: '123',
603
+ title: 'Updated Title',
604
+ html: '<p>Updated content</p>',
605
+ status: 'published',
606
+ tags: [{ name: 'tech' }],
607
+ updated_at: '2025-12-10T12:00:00.000Z',
608
+ };
609
+ mockUpdatePost.mockResolvedValue(mockUpdatedPost);
610
+
611
+ const tool = mockTools.get('ghost_update_post');
612
+ const result = await tool.handler({
613
+ id: '123',
614
+ title: 'Updated Title',
615
+ html: '<p>Updated content</p>',
616
+ status: 'published',
617
+ tags: ['tech'],
618
+ });
619
+
620
+ expect(mockUpdatePost).toHaveBeenCalledWith('123', {
621
+ title: 'Updated Title',
622
+ html: '<p>Updated content</p>',
623
+ status: 'published',
624
+ tags: ['tech'],
625
+ });
626
+ expect(result.content[0].text).toContain('Updated Title');
627
+ });
628
+
629
+ it('should handle not found errors', async () => {
630
+ mockUpdatePost.mockRejectedValue(new Error('Post not found'));
631
+
632
+ const tool = mockTools.get('ghost_update_post');
633
+ const result = await tool.handler({ id: 'nonexistent', title: 'New Title' });
634
+
635
+ expect(result.isError).toBe(true);
636
+ expect(result.content[0].text).toContain('Post not found');
637
+ });
638
+
639
+ it('should handle validation errors', async () => {
640
+ mockUpdatePost.mockRejectedValue(new Error('Validation failed: Title is required'));
641
+
642
+ const tool = mockTools.get('ghost_update_post');
643
+ const result = await tool.handler({ id: '123', title: '' });
644
+
645
+ expect(result.isError).toBe(true);
646
+ expect(result.content[0].text).toContain('Validation failed');
647
+ });
648
+
649
+ it('should handle Ghost API errors', async () => {
650
+ mockUpdatePost.mockRejectedValue(new Error('Ghost API error: Server timeout'));
651
+
652
+ const tool = mockTools.get('ghost_update_post');
653
+ const result = await tool.handler({ id: '123', title: 'Updated' });
654
+
655
+ expect(result.isError).toBe(true);
656
+ expect(result.content[0].text).toContain('Ghost API error');
657
+ });
658
+
659
+ it('should return formatted JSON response', async () => {
660
+ const mockUpdatedPost = {
661
+ id: '123',
662
+ uuid: 'uuid-123',
663
+ title: 'Updated Post',
664
+ slug: 'updated-post',
665
+ html: '<p>Updated content</p>',
666
+ status: 'published',
667
+ created_at: '2025-12-09T00:00:00.000Z',
668
+ updated_at: '2025-12-10T12:00:00.000Z',
669
+ };
670
+ mockUpdatePost.mockResolvedValue(mockUpdatedPost);
671
+
672
+ const tool = mockTools.get('ghost_update_post');
673
+ const result = await tool.handler({ id: '123', title: 'Updated Post' });
674
+
675
+ expect(result.content).toBeDefined();
676
+ expect(result.content[0].type).toBe('text');
677
+ expect(result.content[0].text).toContain('"id": "123"');
678
+ expect(result.content[0].text).toContain('"title": "Updated Post"');
679
+ expect(result.content[0].text).toContain('"status": "published"');
680
+ });
681
+ });
682
+
683
+ describe('mcp_server_improved - ghost_delete_post tool', () => {
684
+ beforeEach(async () => {
685
+ vi.clearAllMocks();
686
+ // Don't clear mockTools - they're registered once on module load
687
+ if (mockTools.size === 0) {
688
+ await import('../mcp_server_improved.js');
689
+ }
690
+ });
691
+
692
+ it('should register ghost_delete_post tool', () => {
693
+ expect(mockTools.has('ghost_delete_post')).toBe(true);
694
+ });
695
+
696
+ it('should have correct schema with required id field', () => {
697
+ const tool = mockTools.get('ghost_delete_post');
698
+ expect(tool).toBeDefined();
699
+ expect(tool.description).toContain('Deletes a post');
700
+ expect(tool.description).toContain('permanent');
701
+ expect(tool.schema).toBeDefined();
702
+ expect(tool.schema.id).toBeDefined();
703
+ });
704
+
705
+ it('should delete post by ID', async () => {
706
+ mockDeletePost.mockResolvedValue({ deleted: true });
707
+
708
+ const tool = mockTools.get('ghost_delete_post');
709
+ const result = await tool.handler({ id: '123' });
710
+
711
+ expect(mockDeletePost).toHaveBeenCalledWith('123');
712
+ expect(result.content[0].text).toContain('Post 123 has been successfully deleted');
713
+ expect(result.isError).toBeUndefined();
714
+ });
715
+
716
+ it('should handle not found errors', async () => {
717
+ mockDeletePost.mockRejectedValue(new Error('Post not found'));
718
+
719
+ const tool = mockTools.get('ghost_delete_post');
720
+ const result = await tool.handler({ id: 'nonexistent' });
721
+
722
+ expect(result.isError).toBe(true);
723
+ expect(result.content[0].text).toContain('Post not found');
724
+ });
725
+
726
+ it('should handle Ghost API errors', async () => {
727
+ mockDeletePost.mockRejectedValue(new Error('Ghost API error: Permission denied'));
728
+
729
+ const tool = mockTools.get('ghost_delete_post');
730
+ const result = await tool.handler({ id: '123' });
731
+
732
+ expect(result.isError).toBe(true);
733
+ expect(result.content[0].text).toContain('Ghost API error');
734
+ });
735
+
736
+ it('should return success message on successful deletion', async () => {
737
+ mockDeletePost.mockResolvedValue({ deleted: true });
738
+
739
+ const tool = mockTools.get('ghost_delete_post');
740
+ const result = await tool.handler({ id: 'test-post-id' });
741
+
742
+ expect(result.content).toBeDefined();
743
+ expect(result.content[0].type).toBe('text');
744
+ expect(result.content[0].text).toBe('Post test-post-id has been successfully deleted.');
745
+ });
746
+
747
+ it('should handle network errors', async () => {
748
+ mockDeletePost.mockRejectedValue(new Error('Network error: Connection refused'));
749
+
750
+ const tool = mockTools.get('ghost_delete_post');
751
+ const result = await tool.handler({ id: '123' });
752
+
753
+ expect(result.isError).toBe(true);
754
+ expect(result.content[0].text).toContain('Network error');
755
+ });
756
+ });
@@ -272,6 +272,198 @@ server.tool(
272
272
  }
273
273
  );
274
274
 
275
+ // Get Posts Tool
276
+ server.tool(
277
+ 'ghost_get_posts',
278
+ 'Retrieves a list of posts from Ghost CMS with pagination, filtering, and sorting options.',
279
+ {
280
+ limit: z
281
+ .number()
282
+ .min(1)
283
+ .max(100)
284
+ .optional()
285
+ .describe('Number of posts to retrieve (1-100). Default is 15.'),
286
+ page: z.number().min(1).optional().describe('Page number for pagination. Default is 1.'),
287
+ status: z
288
+ .enum(['published', 'draft', 'scheduled', 'all'])
289
+ .optional()
290
+ .describe('Filter posts by status. Options: published, draft, scheduled, all.'),
291
+ include: z
292
+ .string()
293
+ .optional()
294
+ .describe('Comma-separated list of relations to include (e.g., "tags,authors").'),
295
+ filter: z
296
+ .string()
297
+ .optional()
298
+ .describe('Ghost NQL filter string for advanced filtering (e.g., "featured:true").'),
299
+ order: z
300
+ .string()
301
+ .optional()
302
+ .describe('Sort order for results (e.g., "published_at DESC", "title ASC").'),
303
+ },
304
+ async (input) => {
305
+ console.error(`Executing tool: ghost_get_posts`);
306
+ try {
307
+ await loadServices();
308
+
309
+ // Build options object with provided parameters
310
+ const options = {};
311
+ if (input.limit !== undefined) options.limit = input.limit;
312
+ if (input.page !== undefined) options.page = input.page;
313
+ if (input.status !== undefined) options.status = input.status;
314
+ if (input.include !== undefined) options.include = input.include;
315
+ if (input.filter !== undefined) options.filter = input.filter;
316
+ if (input.order !== undefined) options.order = input.order;
317
+
318
+ const posts = await ghostService.getPosts(options);
319
+ console.error(`Retrieved ${posts.length} posts from Ghost.`);
320
+
321
+ return {
322
+ content: [{ type: 'text', text: JSON.stringify(posts, null, 2) }],
323
+ };
324
+ } catch (error) {
325
+ console.error(`Error in ghost_get_posts:`, error);
326
+ return {
327
+ content: [{ type: 'text', text: `Error retrieving posts: ${error.message}` }],
328
+ isError: true,
329
+ };
330
+ }
331
+ }
332
+ );
333
+
334
+ // Get Post Tool
335
+ server.tool(
336
+ 'ghost_get_post',
337
+ 'Retrieves a single post from Ghost CMS by ID or slug.',
338
+ {
339
+ id: z.string().optional().describe('The ID of the post to retrieve.'),
340
+ slug: z.string().optional().describe('The slug of the post to retrieve.'),
341
+ include: z
342
+ .string()
343
+ .optional()
344
+ .describe('Comma-separated list of relations to include (e.g., "tags,authors").'),
345
+ },
346
+ async (input) => {
347
+ console.error(`Executing tool: ghost_get_post`);
348
+ try {
349
+ // Validate that at least one of id or slug is provided
350
+ if (!input.id && !input.slug) {
351
+ throw new Error('Either id or slug is required to retrieve a post');
352
+ }
353
+
354
+ await loadServices();
355
+
356
+ // Build options object
357
+ const options = {};
358
+ if (input.include !== undefined) options.include = input.include;
359
+
360
+ // Determine identifier (prefer ID over slug)
361
+ const identifier = input.id || `slug/${input.slug}`;
362
+
363
+ const post = await ghostService.getPost(identifier, options);
364
+ console.error(`Retrieved post: ${post.title} (ID: ${post.id})`);
365
+
366
+ return {
367
+ content: [{ type: 'text', text: JSON.stringify(post, null, 2) }],
368
+ };
369
+ } catch (error) {
370
+ console.error(`Error in ghost_get_post:`, error);
371
+ return {
372
+ content: [{ type: 'text', text: `Error retrieving post: ${error.message}` }],
373
+ isError: true,
374
+ };
375
+ }
376
+ }
377
+ );
378
+
379
+ // Update Post Tool
380
+ server.tool(
381
+ 'ghost_update_post',
382
+ 'Updates an existing post in Ghost CMS. Can update title, content, status, tags, images, and SEO fields.',
383
+ {
384
+ id: z.string().describe('The ID of the post to update.'),
385
+ title: z.string().optional().describe('New title for the post.'),
386
+ html: z.string().optional().describe('New HTML content for the post.'),
387
+ status: z
388
+ .enum(['draft', 'published', 'scheduled'])
389
+ .optional()
390
+ .describe('New status for the post.'),
391
+ tags: z
392
+ .array(z.string())
393
+ .optional()
394
+ .describe('New list of tag names to associate with the post.'),
395
+ feature_image: z.string().optional().describe('New featured image URL.'),
396
+ feature_image_alt: z.string().optional().describe('New alt text for the featured image.'),
397
+ feature_image_caption: z.string().optional().describe('New caption for the featured image.'),
398
+ meta_title: z.string().optional().describe('New custom title for SEO (max 300 chars).'),
399
+ meta_description: z
400
+ .string()
401
+ .optional()
402
+ .describe('New custom description for SEO (max 500 chars).'),
403
+ published_at: z
404
+ .string()
405
+ .optional()
406
+ .describe(
407
+ "New publication date/time in ISO 8601 format. Required if changing status to 'scheduled'."
408
+ ),
409
+ custom_excerpt: z.string().optional().describe('New custom short summary for the post.'),
410
+ },
411
+ async (input) => {
412
+ console.error(`Executing tool: ghost_update_post for post ID: ${input.id}`);
413
+ try {
414
+ await loadServices();
415
+
416
+ // Extract ID from input and build update data
417
+ const { id, ...updateData } = input;
418
+
419
+ // Update the post using ghostServiceImproved
420
+ const ghostServiceImproved = await import('./services/ghostServiceImproved.js');
421
+ const updatedPost = await ghostServiceImproved.updatePost(id, updateData);
422
+ console.error(`Post updated successfully. Post ID: ${updatedPost.id}`);
423
+
424
+ return {
425
+ content: [{ type: 'text', text: JSON.stringify(updatedPost, null, 2) }],
426
+ };
427
+ } catch (error) {
428
+ console.error(`Error in ghost_update_post:`, error);
429
+ return {
430
+ content: [{ type: 'text', text: `Error updating post: ${error.message}` }],
431
+ isError: true,
432
+ };
433
+ }
434
+ }
435
+ );
436
+
437
+ // Delete Post Tool
438
+ server.tool(
439
+ 'ghost_delete_post',
440
+ 'Deletes a post from Ghost CMS by ID. This operation is permanent and cannot be undone.',
441
+ {
442
+ id: z.string().describe('The ID of the post to delete.'),
443
+ },
444
+ async ({ id }) => {
445
+ console.error(`Executing tool: ghost_delete_post for post ID: ${id}`);
446
+ try {
447
+ await loadServices();
448
+
449
+ // Delete the post using ghostServiceImproved
450
+ const ghostServiceImproved = await import('./services/ghostServiceImproved.js');
451
+ await ghostServiceImproved.deletePost(id);
452
+ console.error(`Post deleted successfully. Post ID: ${id}`);
453
+
454
+ return {
455
+ content: [{ type: 'text', text: `Post ${id} has been successfully deleted.` }],
456
+ };
457
+ } catch (error) {
458
+ console.error(`Error in ghost_delete_post:`, error);
459
+ return {
460
+ content: [{ type: 'text', text: `Error deleting post: ${error.message}` }],
461
+ isError: true,
462
+ };
463
+ }
464
+ }
465
+ );
466
+
275
467
  // --- Main Entry Point ---
276
468
 
277
469
  async function main() {
@@ -282,7 +474,7 @@ async function main() {
282
474
 
283
475
  console.error('Ghost MCP Server running on stdio transport');
284
476
  console.error(
285
- 'Available tools: ghost_get_tags, ghost_create_tag, ghost_upload_image, ghost_create_post'
477
+ 'Available tools: ghost_get_tags, ghost_create_tag, ghost_upload_image, ghost_create_post, ghost_get_posts, ghost_get_post, ghost_update_post, ghost_delete_post'
286
478
  );
287
479
  }
288
480