@jgardner04/ghost-mcp-server 1.2.0 → 1.4.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
|
@@ -47,6 +47,9 @@ const mockCreatePostService = vi.fn();
|
|
|
47
47
|
const mockProcessImage = vi.fn();
|
|
48
48
|
const mockValidateImageUrl = vi.fn();
|
|
49
49
|
const mockCreateSecureAxiosConfig = vi.fn();
|
|
50
|
+
const mockUpdatePost = vi.fn();
|
|
51
|
+
const mockDeletePost = vi.fn();
|
|
52
|
+
const mockSearchPosts = vi.fn();
|
|
50
53
|
|
|
51
54
|
vi.mock('../services/ghostService.js', () => ({
|
|
52
55
|
getPosts: (...args) => mockGetPosts(...args),
|
|
@@ -60,6 +63,12 @@ vi.mock('../services/postService.js', () => ({
|
|
|
60
63
|
createPostService: (...args) => mockCreatePostService(...args),
|
|
61
64
|
}));
|
|
62
65
|
|
|
66
|
+
vi.mock('../services/ghostServiceImproved.js', () => ({
|
|
67
|
+
updatePost: (...args) => mockUpdatePost(...args),
|
|
68
|
+
deletePost: (...args) => mockDeletePost(...args),
|
|
69
|
+
searchPosts: (...args) => mockSearchPosts(...args),
|
|
70
|
+
}));
|
|
71
|
+
|
|
63
72
|
vi.mock('../services/imageProcessingService.js', () => ({
|
|
64
73
|
processImage: (...args) => mockProcessImage(...args),
|
|
65
74
|
}));
|
|
@@ -438,3 +447,463 @@ describe('mcp_server_improved - ghost_get_post tool', () => {
|
|
|
438
447
|
expect(result.content[0].text).toContain('Either id or slug is required');
|
|
439
448
|
});
|
|
440
449
|
});
|
|
450
|
+
|
|
451
|
+
describe('mcp_server_improved - ghost_update_post tool', () => {
|
|
452
|
+
beforeEach(async () => {
|
|
453
|
+
vi.clearAllMocks();
|
|
454
|
+
// Don't clear mockTools - they're registered once on module load
|
|
455
|
+
if (mockTools.size === 0) {
|
|
456
|
+
await import('../mcp_server_improved.js');
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('should register ghost_update_post tool', () => {
|
|
461
|
+
expect(mockTools.has('ghost_update_post')).toBe(true);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it('should have correct schema with required id field', () => {
|
|
465
|
+
const tool = mockTools.get('ghost_update_post');
|
|
466
|
+
expect(tool).toBeDefined();
|
|
467
|
+
expect(tool.description).toContain('Updates an existing post');
|
|
468
|
+
expect(tool.schema).toBeDefined();
|
|
469
|
+
expect(tool.schema.id).toBeDefined();
|
|
470
|
+
expect(tool.schema.title).toBeDefined();
|
|
471
|
+
expect(tool.schema.html).toBeDefined();
|
|
472
|
+
expect(tool.schema.status).toBeDefined();
|
|
473
|
+
expect(tool.schema.tags).toBeDefined();
|
|
474
|
+
expect(tool.schema.feature_image).toBeDefined();
|
|
475
|
+
expect(tool.schema.feature_image_alt).toBeDefined();
|
|
476
|
+
expect(tool.schema.feature_image_caption).toBeDefined();
|
|
477
|
+
expect(tool.schema.meta_title).toBeDefined();
|
|
478
|
+
expect(tool.schema.meta_description).toBeDefined();
|
|
479
|
+
expect(tool.schema.published_at).toBeDefined();
|
|
480
|
+
expect(tool.schema.custom_excerpt).toBeDefined();
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it('should update post title', async () => {
|
|
484
|
+
const mockUpdatedPost = {
|
|
485
|
+
id: '123',
|
|
486
|
+
title: 'Updated Title',
|
|
487
|
+
slug: 'test-post',
|
|
488
|
+
html: '<p>Content</p>',
|
|
489
|
+
status: 'published',
|
|
490
|
+
updated_at: '2025-12-10T12:00:00.000Z',
|
|
491
|
+
};
|
|
492
|
+
mockUpdatePost.mockResolvedValue(mockUpdatedPost);
|
|
493
|
+
|
|
494
|
+
const tool = mockTools.get('ghost_update_post');
|
|
495
|
+
const result = await tool.handler({ id: '123', title: 'Updated Title' });
|
|
496
|
+
|
|
497
|
+
expect(mockUpdatePost).toHaveBeenCalledWith('123', { title: 'Updated Title' });
|
|
498
|
+
expect(result.content[0].text).toContain('"title": "Updated Title"');
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it('should update post content', async () => {
|
|
502
|
+
const mockUpdatedPost = {
|
|
503
|
+
id: '123',
|
|
504
|
+
title: 'Test Post',
|
|
505
|
+
html: '<p>Updated content</p>',
|
|
506
|
+
status: 'published',
|
|
507
|
+
updated_at: '2025-12-10T12:00:00.000Z',
|
|
508
|
+
};
|
|
509
|
+
mockUpdatePost.mockResolvedValue(mockUpdatedPost);
|
|
510
|
+
|
|
511
|
+
const tool = mockTools.get('ghost_update_post');
|
|
512
|
+
const result = await tool.handler({ id: '123', html: '<p>Updated content</p>' });
|
|
513
|
+
|
|
514
|
+
expect(mockUpdatePost).toHaveBeenCalledWith('123', { html: '<p>Updated content</p>' });
|
|
515
|
+
expect(result.content[0].text).toContain('Updated content');
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it('should update post status', async () => {
|
|
519
|
+
const mockUpdatedPost = {
|
|
520
|
+
id: '123',
|
|
521
|
+
title: 'Test Post',
|
|
522
|
+
html: '<p>Content</p>',
|
|
523
|
+
status: 'published',
|
|
524
|
+
updated_at: '2025-12-10T12:00:00.000Z',
|
|
525
|
+
};
|
|
526
|
+
mockUpdatePost.mockResolvedValue(mockUpdatedPost);
|
|
527
|
+
|
|
528
|
+
const tool = mockTools.get('ghost_update_post');
|
|
529
|
+
const result = await tool.handler({ id: '123', status: 'published' });
|
|
530
|
+
|
|
531
|
+
expect(mockUpdatePost).toHaveBeenCalledWith('123', { status: 'published' });
|
|
532
|
+
expect(result.content[0].text).toContain('"status": "published"');
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it('should update post tags', async () => {
|
|
536
|
+
const mockUpdatedPost = {
|
|
537
|
+
id: '123',
|
|
538
|
+
title: 'Test Post',
|
|
539
|
+
html: '<p>Content</p>',
|
|
540
|
+
tags: [{ name: 'tech' }, { name: 'javascript' }],
|
|
541
|
+
updated_at: '2025-12-10T12:00:00.000Z',
|
|
542
|
+
};
|
|
543
|
+
mockUpdatePost.mockResolvedValue(mockUpdatedPost);
|
|
544
|
+
|
|
545
|
+
const tool = mockTools.get('ghost_update_post');
|
|
546
|
+
const result = await tool.handler({ id: '123', tags: ['tech', 'javascript'] });
|
|
547
|
+
|
|
548
|
+
expect(mockUpdatePost).toHaveBeenCalledWith('123', { tags: ['tech', 'javascript'] });
|
|
549
|
+
expect(result.content[0].text).toContain('tech');
|
|
550
|
+
expect(result.content[0].text).toContain('javascript');
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it('should update post featured image', async () => {
|
|
554
|
+
const mockUpdatedPost = {
|
|
555
|
+
id: '123',
|
|
556
|
+
title: 'Test Post',
|
|
557
|
+
feature_image: 'https://example.com/new-image.jpg',
|
|
558
|
+
feature_image_alt: 'New image',
|
|
559
|
+
updated_at: '2025-12-10T12:00:00.000Z',
|
|
560
|
+
};
|
|
561
|
+
mockUpdatePost.mockResolvedValue(mockUpdatedPost);
|
|
562
|
+
|
|
563
|
+
const tool = mockTools.get('ghost_update_post');
|
|
564
|
+
const result = await tool.handler({
|
|
565
|
+
id: '123',
|
|
566
|
+
feature_image: 'https://example.com/new-image.jpg',
|
|
567
|
+
feature_image_alt: 'New image',
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
expect(mockUpdatePost).toHaveBeenCalledWith('123', {
|
|
571
|
+
feature_image: 'https://example.com/new-image.jpg',
|
|
572
|
+
feature_image_alt: 'New image',
|
|
573
|
+
});
|
|
574
|
+
expect(result.content[0].text).toContain('new-image.jpg');
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it('should update SEO meta fields', async () => {
|
|
578
|
+
const mockUpdatedPost = {
|
|
579
|
+
id: '123',
|
|
580
|
+
title: 'Test Post',
|
|
581
|
+
meta_title: 'SEO Title',
|
|
582
|
+
meta_description: 'SEO Description',
|
|
583
|
+
updated_at: '2025-12-10T12:00:00.000Z',
|
|
584
|
+
};
|
|
585
|
+
mockUpdatePost.mockResolvedValue(mockUpdatedPost);
|
|
586
|
+
|
|
587
|
+
const tool = mockTools.get('ghost_update_post');
|
|
588
|
+
const result = await tool.handler({
|
|
589
|
+
id: '123',
|
|
590
|
+
meta_title: 'SEO Title',
|
|
591
|
+
meta_description: 'SEO Description',
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
expect(mockUpdatePost).toHaveBeenCalledWith('123', {
|
|
595
|
+
meta_title: 'SEO Title',
|
|
596
|
+
meta_description: 'SEO Description',
|
|
597
|
+
});
|
|
598
|
+
expect(result.content[0].text).toContain('SEO Title');
|
|
599
|
+
expect(result.content[0].text).toContain('SEO Description');
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it('should update multiple fields at once', async () => {
|
|
603
|
+
const mockUpdatedPost = {
|
|
604
|
+
id: '123',
|
|
605
|
+
title: 'Updated Title',
|
|
606
|
+
html: '<p>Updated content</p>',
|
|
607
|
+
status: 'published',
|
|
608
|
+
tags: [{ name: 'tech' }],
|
|
609
|
+
updated_at: '2025-12-10T12:00:00.000Z',
|
|
610
|
+
};
|
|
611
|
+
mockUpdatePost.mockResolvedValue(mockUpdatedPost);
|
|
612
|
+
|
|
613
|
+
const tool = mockTools.get('ghost_update_post');
|
|
614
|
+
const result = await tool.handler({
|
|
615
|
+
id: '123',
|
|
616
|
+
title: 'Updated Title',
|
|
617
|
+
html: '<p>Updated content</p>',
|
|
618
|
+
status: 'published',
|
|
619
|
+
tags: ['tech'],
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
expect(mockUpdatePost).toHaveBeenCalledWith('123', {
|
|
623
|
+
title: 'Updated Title',
|
|
624
|
+
html: '<p>Updated content</p>',
|
|
625
|
+
status: 'published',
|
|
626
|
+
tags: ['tech'],
|
|
627
|
+
});
|
|
628
|
+
expect(result.content[0].text).toContain('Updated Title');
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it('should handle not found errors', async () => {
|
|
632
|
+
mockUpdatePost.mockRejectedValue(new Error('Post not found'));
|
|
633
|
+
|
|
634
|
+
const tool = mockTools.get('ghost_update_post');
|
|
635
|
+
const result = await tool.handler({ id: 'nonexistent', title: 'New Title' });
|
|
636
|
+
|
|
637
|
+
expect(result.isError).toBe(true);
|
|
638
|
+
expect(result.content[0].text).toContain('Post not found');
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
it('should handle validation errors', async () => {
|
|
642
|
+
mockUpdatePost.mockRejectedValue(new Error('Validation failed: Title is required'));
|
|
643
|
+
|
|
644
|
+
const tool = mockTools.get('ghost_update_post');
|
|
645
|
+
const result = await tool.handler({ id: '123', title: '' });
|
|
646
|
+
|
|
647
|
+
expect(result.isError).toBe(true);
|
|
648
|
+
expect(result.content[0].text).toContain('Validation failed');
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
it('should handle Ghost API errors', async () => {
|
|
652
|
+
mockUpdatePost.mockRejectedValue(new Error('Ghost API error: Server timeout'));
|
|
653
|
+
|
|
654
|
+
const tool = mockTools.get('ghost_update_post');
|
|
655
|
+
const result = await tool.handler({ id: '123', title: 'Updated' });
|
|
656
|
+
|
|
657
|
+
expect(result.isError).toBe(true);
|
|
658
|
+
expect(result.content[0].text).toContain('Ghost API error');
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
it('should return formatted JSON response', async () => {
|
|
662
|
+
const mockUpdatedPost = {
|
|
663
|
+
id: '123',
|
|
664
|
+
uuid: 'uuid-123',
|
|
665
|
+
title: 'Updated Post',
|
|
666
|
+
slug: 'updated-post',
|
|
667
|
+
html: '<p>Updated content</p>',
|
|
668
|
+
status: 'published',
|
|
669
|
+
created_at: '2025-12-09T00:00:00.000Z',
|
|
670
|
+
updated_at: '2025-12-10T12:00:00.000Z',
|
|
671
|
+
};
|
|
672
|
+
mockUpdatePost.mockResolvedValue(mockUpdatedPost);
|
|
673
|
+
|
|
674
|
+
const tool = mockTools.get('ghost_update_post');
|
|
675
|
+
const result = await tool.handler({ id: '123', title: 'Updated Post' });
|
|
676
|
+
|
|
677
|
+
expect(result.content).toBeDefined();
|
|
678
|
+
expect(result.content[0].type).toBe('text');
|
|
679
|
+
expect(result.content[0].text).toContain('"id": "123"');
|
|
680
|
+
expect(result.content[0].text).toContain('"title": "Updated Post"');
|
|
681
|
+
expect(result.content[0].text).toContain('"status": "published"');
|
|
682
|
+
});
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
describe('mcp_server_improved - ghost_delete_post tool', () => {
|
|
686
|
+
beforeEach(async () => {
|
|
687
|
+
vi.clearAllMocks();
|
|
688
|
+
// Don't clear mockTools - they're registered once on module load
|
|
689
|
+
if (mockTools.size === 0) {
|
|
690
|
+
await import('../mcp_server_improved.js');
|
|
691
|
+
}
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
it('should register ghost_delete_post tool', () => {
|
|
695
|
+
expect(mockTools.has('ghost_delete_post')).toBe(true);
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
it('should have correct schema with required id field', () => {
|
|
699
|
+
const tool = mockTools.get('ghost_delete_post');
|
|
700
|
+
expect(tool).toBeDefined();
|
|
701
|
+
expect(tool.description).toContain('Deletes a post');
|
|
702
|
+
expect(tool.description).toContain('permanent');
|
|
703
|
+
expect(tool.schema).toBeDefined();
|
|
704
|
+
expect(tool.schema.id).toBeDefined();
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
it('should delete post by ID', async () => {
|
|
708
|
+
mockDeletePost.mockResolvedValue({ deleted: true });
|
|
709
|
+
|
|
710
|
+
const tool = mockTools.get('ghost_delete_post');
|
|
711
|
+
const result = await tool.handler({ id: '123' });
|
|
712
|
+
|
|
713
|
+
expect(mockDeletePost).toHaveBeenCalledWith('123');
|
|
714
|
+
expect(result.content[0].text).toContain('Post 123 has been successfully deleted');
|
|
715
|
+
expect(result.isError).toBeUndefined();
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
it('should handle not found errors', async () => {
|
|
719
|
+
mockDeletePost.mockRejectedValue(new Error('Post not found'));
|
|
720
|
+
|
|
721
|
+
const tool = mockTools.get('ghost_delete_post');
|
|
722
|
+
const result = await tool.handler({ id: 'nonexistent' });
|
|
723
|
+
|
|
724
|
+
expect(result.isError).toBe(true);
|
|
725
|
+
expect(result.content[0].text).toContain('Post not found');
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
it('should handle Ghost API errors', async () => {
|
|
729
|
+
mockDeletePost.mockRejectedValue(new Error('Ghost API error: Permission denied'));
|
|
730
|
+
|
|
731
|
+
const tool = mockTools.get('ghost_delete_post');
|
|
732
|
+
const result = await tool.handler({ id: '123' });
|
|
733
|
+
|
|
734
|
+
expect(result.isError).toBe(true);
|
|
735
|
+
expect(result.content[0].text).toContain('Ghost API error');
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
it('should return success message on successful deletion', async () => {
|
|
739
|
+
mockDeletePost.mockResolvedValue({ deleted: true });
|
|
740
|
+
|
|
741
|
+
const tool = mockTools.get('ghost_delete_post');
|
|
742
|
+
const result = await tool.handler({ id: 'test-post-id' });
|
|
743
|
+
|
|
744
|
+
expect(result.content).toBeDefined();
|
|
745
|
+
expect(result.content[0].type).toBe('text');
|
|
746
|
+
expect(result.content[0].text).toBe('Post test-post-id has been successfully deleted.');
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
it('should handle network errors', async () => {
|
|
750
|
+
mockDeletePost.mockRejectedValue(new Error('Network error: Connection refused'));
|
|
751
|
+
|
|
752
|
+
const tool = mockTools.get('ghost_delete_post');
|
|
753
|
+
const result = await tool.handler({ id: '123' });
|
|
754
|
+
|
|
755
|
+
expect(result.isError).toBe(true);
|
|
756
|
+
expect(result.content[0].text).toContain('Network error');
|
|
757
|
+
});
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
describe('mcp_server_improved - ghost_search_posts tool', () => {
|
|
761
|
+
beforeEach(async () => {
|
|
762
|
+
vi.clearAllMocks();
|
|
763
|
+
// Don't clear mockTools - they're registered once on module load
|
|
764
|
+
if (mockTools.size === 0) {
|
|
765
|
+
await import('../mcp_server_improved.js');
|
|
766
|
+
}
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
it('should register ghost_search_posts tool', () => {
|
|
770
|
+
expect(mockTools.has('ghost_search_posts')).toBe(true);
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
it('should have correct schema with required query and optional parameters', () => {
|
|
774
|
+
const tool = mockTools.get('ghost_search_posts');
|
|
775
|
+
expect(tool).toBeDefined();
|
|
776
|
+
expect(tool.description).toContain('Search');
|
|
777
|
+
expect(tool.schema).toBeDefined();
|
|
778
|
+
expect(tool.schema.query).toBeDefined();
|
|
779
|
+
expect(tool.schema.status).toBeDefined();
|
|
780
|
+
expect(tool.schema.limit).toBeDefined();
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
it('should search posts with query only', async () => {
|
|
784
|
+
const mockPosts = [
|
|
785
|
+
{ id: '1', title: 'JavaScript Tips', slug: 'javascript-tips', status: 'published' },
|
|
786
|
+
{ id: '2', title: 'JavaScript Tricks', slug: 'javascript-tricks', status: 'published' },
|
|
787
|
+
];
|
|
788
|
+
mockSearchPosts.mockResolvedValue(mockPosts);
|
|
789
|
+
|
|
790
|
+
const tool = mockTools.get('ghost_search_posts');
|
|
791
|
+
const result = await tool.handler({ query: 'JavaScript' });
|
|
792
|
+
|
|
793
|
+
expect(mockSearchPosts).toHaveBeenCalledWith('JavaScript', {});
|
|
794
|
+
expect(result.content[0].text).toContain('JavaScript Tips');
|
|
795
|
+
expect(result.content[0].text).toContain('JavaScript Tricks');
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
it('should search posts with query and status filter', async () => {
|
|
799
|
+
const mockPosts = [
|
|
800
|
+
{ id: '1', title: 'Published Post', slug: 'published-post', status: 'published' },
|
|
801
|
+
];
|
|
802
|
+
mockSearchPosts.mockResolvedValue(mockPosts);
|
|
803
|
+
|
|
804
|
+
const tool = mockTools.get('ghost_search_posts');
|
|
805
|
+
await tool.handler({ query: 'test', status: 'published' });
|
|
806
|
+
|
|
807
|
+
expect(mockSearchPosts).toHaveBeenCalledWith('test', { status: 'published' });
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
it('should search posts with query and limit', async () => {
|
|
811
|
+
const mockPosts = [{ id: '1', title: 'Test Post', slug: 'test-post' }];
|
|
812
|
+
mockSearchPosts.mockResolvedValue(mockPosts);
|
|
813
|
+
|
|
814
|
+
const tool = mockTools.get('ghost_search_posts');
|
|
815
|
+
await tool.handler({ query: 'test', limit: 10 });
|
|
816
|
+
|
|
817
|
+
expect(mockSearchPosts).toHaveBeenCalledWith('test', { limit: 10 });
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
it('should validate limit is between 1 and 50', () => {
|
|
821
|
+
const tool = mockTools.get('ghost_search_posts');
|
|
822
|
+
const schema = tool.schema;
|
|
823
|
+
|
|
824
|
+
expect(schema.limit).toBeDefined();
|
|
825
|
+
expect(() => schema.limit.parse(0)).toThrow();
|
|
826
|
+
expect(() => schema.limit.parse(51)).toThrow();
|
|
827
|
+
expect(schema.limit.parse(25)).toBe(25);
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
it('should validate status enum values', () => {
|
|
831
|
+
const tool = mockTools.get('ghost_search_posts');
|
|
832
|
+
const schema = tool.schema;
|
|
833
|
+
|
|
834
|
+
expect(schema.status).toBeDefined();
|
|
835
|
+
expect(() => schema.status.parse('invalid')).toThrow();
|
|
836
|
+
expect(schema.status.parse('published')).toBe('published');
|
|
837
|
+
expect(schema.status.parse('draft')).toBe('draft');
|
|
838
|
+
expect(schema.status.parse('scheduled')).toBe('scheduled');
|
|
839
|
+
expect(schema.status.parse('all')).toBe('all');
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
it('should pass all parameters combined', async () => {
|
|
843
|
+
const mockPosts = [{ id: '1', title: 'Test Post' }];
|
|
844
|
+
mockSearchPosts.mockResolvedValue(mockPosts);
|
|
845
|
+
|
|
846
|
+
const tool = mockTools.get('ghost_search_posts');
|
|
847
|
+
await tool.handler({
|
|
848
|
+
query: 'JavaScript',
|
|
849
|
+
status: 'published',
|
|
850
|
+
limit: 20,
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
expect(mockSearchPosts).toHaveBeenCalledWith('JavaScript', {
|
|
854
|
+
status: 'published',
|
|
855
|
+
limit: 20,
|
|
856
|
+
});
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
it('should handle errors from searchPosts', async () => {
|
|
860
|
+
mockSearchPosts.mockRejectedValue(new Error('Search query is required'));
|
|
861
|
+
|
|
862
|
+
const tool = mockTools.get('ghost_search_posts');
|
|
863
|
+
const result = await tool.handler({ query: '' });
|
|
864
|
+
|
|
865
|
+
expect(result.isError).toBe(true);
|
|
866
|
+
expect(result.content[0].text).toContain('Search query is required');
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
it('should handle Ghost API errors', async () => {
|
|
870
|
+
mockSearchPosts.mockRejectedValue(new Error('Ghost API error'));
|
|
871
|
+
|
|
872
|
+
const tool = mockTools.get('ghost_search_posts');
|
|
873
|
+
const result = await tool.handler({ query: 'test' });
|
|
874
|
+
|
|
875
|
+
expect(result.isError).toBe(true);
|
|
876
|
+
expect(result.content[0].text).toContain('Ghost API error');
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
it('should return formatted JSON response', async () => {
|
|
880
|
+
const mockPosts = [
|
|
881
|
+
{
|
|
882
|
+
id: '1',
|
|
883
|
+
title: 'Test Post',
|
|
884
|
+
slug: 'test-post',
|
|
885
|
+
html: '<p>Content</p>',
|
|
886
|
+
status: 'published',
|
|
887
|
+
},
|
|
888
|
+
];
|
|
889
|
+
mockSearchPosts.mockResolvedValue(mockPosts);
|
|
890
|
+
|
|
891
|
+
const tool = mockTools.get('ghost_search_posts');
|
|
892
|
+
const result = await tool.handler({ query: 'Test' });
|
|
893
|
+
|
|
894
|
+
expect(result.content).toBeDefined();
|
|
895
|
+
expect(result.content[0].type).toBe('text');
|
|
896
|
+
expect(result.content[0].text).toContain('"id": "1"');
|
|
897
|
+
expect(result.content[0].text).toContain('"title": "Test Post"');
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
it('should return empty array when no results found', async () => {
|
|
901
|
+
mockSearchPosts.mockResolvedValue([]);
|
|
902
|
+
|
|
903
|
+
const tool = mockTools.get('ghost_search_posts');
|
|
904
|
+
const result = await tool.handler({ query: 'nonexistent' });
|
|
905
|
+
|
|
906
|
+
expect(result.content[0].text).toBe('[]');
|
|
907
|
+
expect(result.isError).toBeUndefined();
|
|
908
|
+
});
|
|
909
|
+
});
|
|
@@ -376,6 +376,139 @@ server.tool(
|
|
|
376
376
|
}
|
|
377
377
|
);
|
|
378
378
|
|
|
379
|
+
// Search Posts Tool
|
|
380
|
+
server.tool(
|
|
381
|
+
'ghost_search_posts',
|
|
382
|
+
'Search for posts in Ghost CMS by query string with optional status filtering.',
|
|
383
|
+
{
|
|
384
|
+
query: z.string().describe('Search query to find in post titles.'),
|
|
385
|
+
status: z
|
|
386
|
+
.enum(['published', 'draft', 'scheduled', 'all'])
|
|
387
|
+
.optional()
|
|
388
|
+
.describe('Filter by post status. Default searches all statuses.'),
|
|
389
|
+
limit: z
|
|
390
|
+
.number()
|
|
391
|
+
.min(1)
|
|
392
|
+
.max(50)
|
|
393
|
+
.optional()
|
|
394
|
+
.describe('Maximum number of results (1-50). Default is 15.'),
|
|
395
|
+
},
|
|
396
|
+
async (input) => {
|
|
397
|
+
console.error(`Executing tool: ghost_search_posts with query: ${input.query}`);
|
|
398
|
+
try {
|
|
399
|
+
await loadServices();
|
|
400
|
+
|
|
401
|
+
// Build options object with provided parameters
|
|
402
|
+
const options = {};
|
|
403
|
+
if (input.status !== undefined) options.status = input.status;
|
|
404
|
+
if (input.limit !== undefined) options.limit = input.limit;
|
|
405
|
+
|
|
406
|
+
// Search posts using ghostServiceImproved
|
|
407
|
+
const ghostServiceImproved = await import('./services/ghostServiceImproved.js');
|
|
408
|
+
const posts = await ghostServiceImproved.searchPosts(input.query, options);
|
|
409
|
+
console.error(`Found ${posts.length} posts matching "${input.query}".`);
|
|
410
|
+
|
|
411
|
+
return {
|
|
412
|
+
content: [{ type: 'text', text: JSON.stringify(posts, null, 2) }],
|
|
413
|
+
};
|
|
414
|
+
} catch (error) {
|
|
415
|
+
console.error(`Error in ghost_search_posts:`, error);
|
|
416
|
+
return {
|
|
417
|
+
content: [{ type: 'text', text: `Error searching posts: ${error.message}` }],
|
|
418
|
+
isError: true,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
// Update Post Tool
|
|
425
|
+
server.tool(
|
|
426
|
+
'ghost_update_post',
|
|
427
|
+
'Updates an existing post in Ghost CMS. Can update title, content, status, tags, images, and SEO fields.',
|
|
428
|
+
{
|
|
429
|
+
id: z.string().describe('The ID of the post to update.'),
|
|
430
|
+
title: z.string().optional().describe('New title for the post.'),
|
|
431
|
+
html: z.string().optional().describe('New HTML content for the post.'),
|
|
432
|
+
status: z
|
|
433
|
+
.enum(['draft', 'published', 'scheduled'])
|
|
434
|
+
.optional()
|
|
435
|
+
.describe('New status for the post.'),
|
|
436
|
+
tags: z
|
|
437
|
+
.array(z.string())
|
|
438
|
+
.optional()
|
|
439
|
+
.describe('New list of tag names to associate with the post.'),
|
|
440
|
+
feature_image: z.string().optional().describe('New featured image URL.'),
|
|
441
|
+
feature_image_alt: z.string().optional().describe('New alt text for the featured image.'),
|
|
442
|
+
feature_image_caption: z.string().optional().describe('New caption for the featured image.'),
|
|
443
|
+
meta_title: z.string().optional().describe('New custom title for SEO (max 300 chars).'),
|
|
444
|
+
meta_description: z
|
|
445
|
+
.string()
|
|
446
|
+
.optional()
|
|
447
|
+
.describe('New custom description for SEO (max 500 chars).'),
|
|
448
|
+
published_at: z
|
|
449
|
+
.string()
|
|
450
|
+
.optional()
|
|
451
|
+
.describe(
|
|
452
|
+
"New publication date/time in ISO 8601 format. Required if changing status to 'scheduled'."
|
|
453
|
+
),
|
|
454
|
+
custom_excerpt: z.string().optional().describe('New custom short summary for the post.'),
|
|
455
|
+
},
|
|
456
|
+
async (input) => {
|
|
457
|
+
console.error(`Executing tool: ghost_update_post for post ID: ${input.id}`);
|
|
458
|
+
try {
|
|
459
|
+
await loadServices();
|
|
460
|
+
|
|
461
|
+
// Extract ID from input and build update data
|
|
462
|
+
const { id, ...updateData } = input;
|
|
463
|
+
|
|
464
|
+
// Update the post using ghostServiceImproved
|
|
465
|
+
const ghostServiceImproved = await import('./services/ghostServiceImproved.js');
|
|
466
|
+
const updatedPost = await ghostServiceImproved.updatePost(id, updateData);
|
|
467
|
+
console.error(`Post updated successfully. Post ID: ${updatedPost.id}`);
|
|
468
|
+
|
|
469
|
+
return {
|
|
470
|
+
content: [{ type: 'text', text: JSON.stringify(updatedPost, null, 2) }],
|
|
471
|
+
};
|
|
472
|
+
} catch (error) {
|
|
473
|
+
console.error(`Error in ghost_update_post:`, error);
|
|
474
|
+
return {
|
|
475
|
+
content: [{ type: 'text', text: `Error updating post: ${error.message}` }],
|
|
476
|
+
isError: true,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
// Delete Post Tool
|
|
483
|
+
server.tool(
|
|
484
|
+
'ghost_delete_post',
|
|
485
|
+
'Deletes a post from Ghost CMS by ID. This operation is permanent and cannot be undone.',
|
|
486
|
+
{
|
|
487
|
+
id: z.string().describe('The ID of the post to delete.'),
|
|
488
|
+
},
|
|
489
|
+
async ({ id }) => {
|
|
490
|
+
console.error(`Executing tool: ghost_delete_post for post ID: ${id}`);
|
|
491
|
+
try {
|
|
492
|
+
await loadServices();
|
|
493
|
+
|
|
494
|
+
// Delete the post using ghostServiceImproved
|
|
495
|
+
const ghostServiceImproved = await import('./services/ghostServiceImproved.js');
|
|
496
|
+
await ghostServiceImproved.deletePost(id);
|
|
497
|
+
console.error(`Post deleted successfully. Post ID: ${id}`);
|
|
498
|
+
|
|
499
|
+
return {
|
|
500
|
+
content: [{ type: 'text', text: `Post ${id} has been successfully deleted.` }],
|
|
501
|
+
};
|
|
502
|
+
} catch (error) {
|
|
503
|
+
console.error(`Error in ghost_delete_post:`, error);
|
|
504
|
+
return {
|
|
505
|
+
content: [{ type: 'text', text: `Error deleting post: ${error.message}` }],
|
|
506
|
+
isError: true,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
);
|
|
511
|
+
|
|
379
512
|
// --- Main Entry Point ---
|
|
380
513
|
|
|
381
514
|
async function main() {
|
|
@@ -386,7 +519,7 @@ async function main() {
|
|
|
386
519
|
|
|
387
520
|
console.error('Ghost MCP Server running on stdio transport');
|
|
388
521
|
console.error(
|
|
389
|
-
'Available tools: ghost_get_tags, ghost_create_tag, ghost_upload_image, ghost_create_post, ghost_get_posts, ghost_get_post'
|
|
522
|
+
'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'
|
|
390
523
|
);
|
|
391
524
|
}
|
|
392
525
|
|
|
@@ -336,6 +336,37 @@ export async function getPosts(options = {}) {
|
|
|
336
336
|
}
|
|
337
337
|
}
|
|
338
338
|
|
|
339
|
+
export async function searchPosts(query, options = {}) {
|
|
340
|
+
// Validate query
|
|
341
|
+
if (!query || query.trim().length === 0) {
|
|
342
|
+
throw new ValidationError('Search query is required');
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Sanitize query - escape special NQL characters to prevent injection
|
|
346
|
+
const sanitizedQuery = query.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
347
|
+
|
|
348
|
+
// Build filter with fuzzy title match using Ghost NQL
|
|
349
|
+
const filterParts = [`title:~'${sanitizedQuery}'`];
|
|
350
|
+
|
|
351
|
+
// Add status filter if provided and not 'all'
|
|
352
|
+
if (options.status && options.status !== 'all') {
|
|
353
|
+
filterParts.push(`status:${options.status}`);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const searchOptions = {
|
|
357
|
+
limit: options.limit || 15,
|
|
358
|
+
include: 'tags,authors',
|
|
359
|
+
filter: filterParts.join('+'),
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
try {
|
|
363
|
+
return await handleApiRequest('posts', 'browse', {}, searchOptions);
|
|
364
|
+
} catch (error) {
|
|
365
|
+
console.error('Failed to search posts:', error);
|
|
366
|
+
throw error;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
339
370
|
export async function uploadImage(imagePath) {
|
|
340
371
|
// Validate input
|
|
341
372
|
await validators.validateImagePath(imagePath);
|
|
@@ -495,6 +526,7 @@ export default {
|
|
|
495
526
|
deletePost,
|
|
496
527
|
getPost,
|
|
497
528
|
getPosts,
|
|
529
|
+
searchPosts,
|
|
498
530
|
uploadImage,
|
|
499
531
|
createTag,
|
|
500
532
|
getTags,
|