@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
|
@@ -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,
|