@jgardner04/ghost-mcp-server 1.3.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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgardner04/ghost-mcp-server",
3
- "version": "1.3.0",
3
+ "version": "1.4.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",
@@ -49,6 +49,7 @@ const mockValidateImageUrl = vi.fn();
49
49
  const mockCreateSecureAxiosConfig = vi.fn();
50
50
  const mockUpdatePost = vi.fn();
51
51
  const mockDeletePost = vi.fn();
52
+ const mockSearchPosts = vi.fn();
52
53
 
53
54
  vi.mock('../services/ghostService.js', () => ({
54
55
  getPosts: (...args) => mockGetPosts(...args),
@@ -65,6 +66,7 @@ vi.mock('../services/postService.js', () => ({
65
66
  vi.mock('../services/ghostServiceImproved.js', () => ({
66
67
  updatePost: (...args) => mockUpdatePost(...args),
67
68
  deletePost: (...args) => mockDeletePost(...args),
69
+ searchPosts: (...args) => mockSearchPosts(...args),
68
70
  }));
69
71
 
70
72
  vi.mock('../services/imageProcessingService.js', () => ({
@@ -754,3 +756,154 @@ describe('mcp_server_improved - ghost_delete_post tool', () => {
754
756
  expect(result.content[0].text).toContain('Network error');
755
757
  });
756
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,51 @@ 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
+
379
424
  // Update Post Tool
380
425
  server.tool(
381
426
  'ghost_update_post',
@@ -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,