@jgardner04/ghost-mcp-server 1.12.2 → 1.12.4

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.
@@ -1,81 +1,151 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
 
3
- // Store tool implementations for testing
4
- const toolImplementations = {};
5
-
6
- // Mock MCP SDK
7
- vi.mock('@modelcontextprotocol/sdk/server/index.js', () => {
8
- const mockServerInstance = {
9
- addResource: vi.fn(),
10
- addTool: vi.fn(),
11
- listen: vi.fn().mockResolvedValue(undefined),
12
- listResources: vi.fn().mockReturnValue([{ name: 'ghost/tag' }, { name: 'ghost/post' }]),
13
- listTools: vi
14
- .fn()
15
- .mockReturnValue([
16
- { name: 'ghost_create_post' },
17
- { name: 'ghost_upload_image' },
18
- { name: 'ghost_get_tags' },
19
- { name: 'ghost_create_tag' },
20
- ]),
21
- };
3
+ // Mock the McpServer to capture tool registrations
4
+ const mockTools = new Map();
22
5
 
6
+ vi.mock('@modelcontextprotocol/sdk/server/mcp.js', () => {
23
7
  return {
24
- MCPServer: class MockMCPServer {
25
- constructor() {
26
- Object.assign(this, mockServerInstance);
8
+ McpServer: class MockMcpServer {
9
+ constructor(config) {
10
+ this.config = config;
27
11
  }
28
12
 
29
- static getInstance() {
30
- return mockServerInstance;
31
- }
32
- },
33
- Resource: class MockResource {
34
- constructor(config) {
35
- this.name = config.name;
36
- this.description = config.description;
37
- this.schema = config.schema;
13
+ tool(name, description, schema, handler) {
14
+ mockTools.set(name, { name, description, schema, handler });
38
15
  }
39
- },
40
- Tool: class MockTool {
41
- constructor(config) {
42
- toolImplementations[config.name] = config.implementation;
43
- this.name = config.name;
44
- this.description = config.description;
45
- this.inputSchema = config.inputSchema;
46
- this.outputSchema = config.outputSchema;
47
- this.implementation = config.implementation;
16
+
17
+ connect(_transport) {
18
+ return Promise.resolve();
48
19
  }
49
20
  },
50
21
  };
51
22
  });
52
23
 
24
+ vi.mock('@modelcontextprotocol/sdk/server/stdio.js', () => {
25
+ return {
26
+ StdioServerTransport: class MockStdioServerTransport {},
27
+ };
28
+ });
29
+
53
30
  // Mock dotenv
54
31
  vi.mock('dotenv', () => ({
55
32
  default: { config: vi.fn() },
56
33
  }));
57
34
 
58
- // Mock services
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 mockGetTag = vi.fn();
45
+ const mockCreateTag = vi.fn();
46
+ const mockUpdateTag = vi.fn();
47
+ const mockDeleteTag = vi.fn();
48
+ const mockUploadImage = vi.fn();
59
49
  const mockCreatePostService = vi.fn();
60
- const mockUploadGhostImage = vi.fn();
61
- const mockGetGhostTags = vi.fn();
62
- const mockCreateGhostTag = vi.fn();
63
50
  const mockProcessImage = vi.fn();
51
+ const mockValidateImageUrl = vi.fn();
52
+ const mockCreateSecureAxiosConfig = vi.fn();
53
+ const mockUpdatePost = vi.fn();
54
+ const mockDeletePost = vi.fn();
55
+ const mockSearchPosts = vi.fn();
56
+
57
+ // Page mocks
58
+ const mockGetPages = vi.fn();
59
+ const mockGetPage = vi.fn();
60
+ const mockCreatePageService = vi.fn();
61
+ const mockUpdatePage = vi.fn();
62
+ const mockDeletePage = vi.fn();
63
+ const mockSearchPages = vi.fn();
64
+
65
+ // Member mocks
66
+ const mockCreateMember = vi.fn();
67
+ const mockUpdateMember = vi.fn();
68
+ const mockDeleteMember = vi.fn();
69
+ const mockGetMembers = vi.fn();
70
+ const mockGetMember = vi.fn();
71
+ const mockSearchMembers = vi.fn();
72
+
73
+ // Newsletter mocks
74
+ const mockGetNewsletters = vi.fn();
75
+ const mockGetNewsletter = vi.fn();
76
+ const mockCreateNewsletterService = vi.fn();
77
+ const mockUpdateNewsletter = vi.fn();
78
+ const mockDeleteNewsletter = vi.fn();
79
+
80
+ // Tier mocks
81
+ const mockGetTiers = vi.fn();
82
+ const mockGetTier = vi.fn();
83
+ const mockCreateTier = vi.fn();
84
+ const mockUpdateTier = vi.fn();
85
+ const mockDeleteTier = vi.fn();
64
86
 
65
87
  vi.mock('../services/postService.js', () => ({
66
88
  createPostService: (...args) => mockCreatePostService(...args),
67
89
  }));
68
90
 
69
- vi.mock('../services/ghostService.js', () => ({
70
- uploadImage: (...args) => mockUploadGhostImage(...args),
71
- getTags: (...args) => mockGetGhostTags(...args),
72
- createTag: (...args) => mockCreateGhostTag(...args),
91
+ vi.mock('../services/pageService.js', () => ({
92
+ createPageService: (...args) => mockCreatePageService(...args),
93
+ }));
94
+
95
+ vi.mock('../services/newsletterService.js', () => ({
96
+ createNewsletterService: (...args) => mockCreateNewsletterService(...args),
97
+ }));
98
+
99
+ vi.mock('../services/ghostServiceImproved.js', () => ({
100
+ // Posts
101
+ getPosts: (...args) => mockGetPosts(...args),
102
+ getPost: (...args) => mockGetPost(...args),
103
+ updatePost: (...args) => mockUpdatePost(...args),
104
+ deletePost: (...args) => mockDeletePost(...args),
105
+ searchPosts: (...args) => mockSearchPosts(...args),
106
+ // Tags
107
+ getTags: (...args) => mockGetTags(...args),
108
+ getTag: (...args) => mockGetTag(...args),
109
+ createTag: (...args) => mockCreateTag(...args),
110
+ updateTag: (...args) => mockUpdateTag(...args),
111
+ deleteTag: (...args) => mockDeleteTag(...args),
112
+ // Images
113
+ uploadImage: (...args) => mockUploadImage(...args),
114
+ // Pages
115
+ getPages: (...args) => mockGetPages(...args),
116
+ getPage: (...args) => mockGetPage(...args),
117
+ updatePage: (...args) => mockUpdatePage(...args),
118
+ deletePage: (...args) => mockDeletePage(...args),
119
+ searchPages: (...args) => mockSearchPages(...args),
120
+ // Members
121
+ createMember: (...args) => mockCreateMember(...args),
122
+ updateMember: (...args) => mockUpdateMember(...args),
123
+ deleteMember: (...args) => mockDeleteMember(...args),
124
+ getMembers: (...args) => mockGetMembers(...args),
125
+ getMember: (...args) => mockGetMember(...args),
126
+ searchMembers: (...args) => mockSearchMembers(...args),
127
+ // Newsletters
128
+ getNewsletters: (...args) => mockGetNewsletters(...args),
129
+ getNewsletter: (...args) => mockGetNewsletter(...args),
130
+ updateNewsletter: (...args) => mockUpdateNewsletter(...args),
131
+ deleteNewsletter: (...args) => mockDeleteNewsletter(...args),
132
+ // Tiers
133
+ getTiers: (...args) => mockGetTiers(...args),
134
+ getTier: (...args) => mockGetTier(...args),
135
+ createTier: (...args) => mockCreateTier(...args),
136
+ updateTier: (...args) => mockUpdateTier(...args),
137
+ deleteTier: (...args) => mockDeleteTier(...args),
73
138
  }));
74
139
 
75
140
  vi.mock('../services/imageProcessingService.js', () => ({
76
141
  processImage: (...args) => mockProcessImage(...args),
77
142
  }));
78
143
 
144
+ vi.mock('../utils/urlValidator.js', () => ({
145
+ validateImageUrl: (...args) => mockValidateImageUrl(...args),
146
+ createSecureAxiosConfig: (...args) => mockCreateSecureAxiosConfig(...args),
147
+ }));
148
+
79
149
  // Mock axios
80
150
  const mockAxios = vi.fn();
81
151
  vi.mock('axios', () => ({
@@ -103,285 +173,1129 @@ vi.mock('os', () => ({
103
173
  default: { tmpdir: vi.fn().mockReturnValue('/tmp') },
104
174
  }));
105
175
 
106
- // Mock uuid
107
- vi.mock('uuid', () => ({
108
- v4: vi.fn().mockReturnValue('test-uuid-1234'),
109
- }));
110
-
111
- // Mock urlValidator
112
- const mockValidateImageUrl = vi.fn();
113
- const mockCreateSecureAxiosConfig = vi.fn();
114
- vi.mock('../utils/urlValidator.js', () => ({
115
- validateImageUrl: (...args) => mockValidateImageUrl(...args),
116
- createSecureAxiosConfig: (...args) => mockCreateSecureAxiosConfig(...args),
117
- }));
118
-
119
- // Mock logger
120
- vi.mock('../utils/logger.js', () => ({
121
- createContextLogger: vi.fn().mockReturnValue({
122
- info: vi.fn(),
123
- error: vi.fn(),
124
- warn: vi.fn(),
125
- debug: vi.fn(),
126
- toolExecution: vi.fn(),
127
- toolSuccess: vi.fn(),
128
- toolError: vi.fn(),
129
- fileOperation: vi.fn(),
130
- }),
131
- }));
132
-
133
- describe('mcp_server', () => {
134
- let mcpServerModule;
176
+ // Mock path
177
+ vi.mock('path', async () => {
178
+ const actual = await vi.importActual('path');
179
+ return {
180
+ default: actual,
181
+ ...actual,
182
+ };
183
+ });
135
184
 
185
+ describe('mcp_server - ghost_get_posts tool', () => {
136
186
  beforeEach(async () => {
137
187
  vi.clearAllMocks();
138
- // Import module once to register tools
139
- if (!mcpServerModule) {
140
- mcpServerModule = await import('../mcp_server.js');
188
+ // Don't clear mockTools - they're registered once on module load
189
+ // Import the module to register tools (only first time)
190
+ if (mockTools.size === 0) {
191
+ await import('../mcp_server.js');
141
192
  }
142
193
  });
143
194
 
144
- describe('ghost_create_post tool', () => {
145
- it('should call createPostService with input and return result', async () => {
146
- const createdPost = {
195
+ it('should register ghost_get_posts tool', () => {
196
+ expect(mockTools.has('ghost_get_posts')).toBe(true);
197
+ });
198
+
199
+ it('should have correct schema with all optional parameters', () => {
200
+ const tool = mockTools.get('ghost_get_posts');
201
+ expect(tool).toBeDefined();
202
+ expect(tool.description).toContain('posts');
203
+ expect(tool.schema).toBeDefined();
204
+ // Zod schemas store field definitions in schema.shape
205
+ expect(tool.schema.shape.limit).toBeDefined();
206
+ expect(tool.schema.shape.page).toBeDefined();
207
+ expect(tool.schema.shape.status).toBeDefined();
208
+ expect(tool.schema.shape.include).toBeDefined();
209
+ expect(tool.schema.shape.filter).toBeDefined();
210
+ expect(tool.schema.shape.order).toBeDefined();
211
+ });
212
+
213
+ it('should retrieve posts with default options', async () => {
214
+ const mockPosts = [
215
+ { id: '1', title: 'Post 1', slug: 'post-1', status: 'published' },
216
+ { id: '2', title: 'Post 2', slug: 'post-2', status: 'draft' },
217
+ ];
218
+ mockGetPosts.mockResolvedValue(mockPosts);
219
+
220
+ const tool = mockTools.get('ghost_get_posts');
221
+ const result = await tool.handler({});
222
+
223
+ expect(mockGetPosts).toHaveBeenCalledWith({});
224
+ expect(result.content[0].text).toContain('Post 1');
225
+ expect(result.content[0].text).toContain('Post 2');
226
+ });
227
+
228
+ it('should pass limit and page parameters', async () => {
229
+ const mockPosts = [{ id: '1', title: 'Post 1', slug: 'post-1' }];
230
+ mockGetPosts.mockResolvedValue(mockPosts);
231
+
232
+ const tool = mockTools.get('ghost_get_posts');
233
+ await tool.handler({ limit: 10, page: 2 });
234
+
235
+ expect(mockGetPosts).toHaveBeenCalledWith({ limit: 10, page: 2 });
236
+ });
237
+
238
+ it('should validate limit is between 1 and 100', () => {
239
+ const tool = mockTools.get('ghost_get_posts');
240
+ // Zod schemas store field definitions in schema.shape
241
+ const shape = tool.schema.shape;
242
+
243
+ // Test that limit schema exists and has proper validation
244
+ expect(shape.limit).toBeDefined();
245
+ expect(() => shape.limit.parse(0)).toThrow();
246
+ expect(() => shape.limit.parse(101)).toThrow();
247
+ expect(shape.limit.parse(50)).toBe(50);
248
+ });
249
+
250
+ it('should validate page is at least 1', () => {
251
+ const tool = mockTools.get('ghost_get_posts');
252
+ // Zod schemas store field definitions in schema.shape
253
+ const shape = tool.schema.shape;
254
+
255
+ expect(shape.page).toBeDefined();
256
+ expect(() => shape.page.parse(0)).toThrow();
257
+ expect(shape.page.parse(1)).toBe(1);
258
+ });
259
+
260
+ it('should pass status filter', async () => {
261
+ const mockPosts = [{ id: '1', title: 'Published Post', status: 'published' }];
262
+ mockGetPosts.mockResolvedValue(mockPosts);
263
+
264
+ const tool = mockTools.get('ghost_get_posts');
265
+ await tool.handler({ status: 'published' });
266
+
267
+ expect(mockGetPosts).toHaveBeenCalledWith({ status: 'published' });
268
+ });
269
+
270
+ it('should validate status enum values', () => {
271
+ const tool = mockTools.get('ghost_get_posts');
272
+ // Zod schemas store field definitions in schema.shape
273
+ const shape = tool.schema.shape;
274
+
275
+ expect(shape.status).toBeDefined();
276
+ expect(() => shape.status.parse('invalid')).toThrow();
277
+ expect(shape.status.parse('published')).toBe('published');
278
+ expect(shape.status.parse('draft')).toBe('draft');
279
+ expect(shape.status.parse('scheduled')).toBe('scheduled');
280
+ expect(shape.status.parse('all')).toBe('all');
281
+ });
282
+
283
+ it('should pass include parameter', async () => {
284
+ const mockPosts = [
285
+ {
147
286
  id: '1',
148
- title: 'Test Post',
149
- html: '<p>Content</p>',
150
- status: 'draft',
151
- };
152
- mockCreatePostService.mockResolvedValue(createdPost);
287
+ title: 'Post with tags',
288
+ tags: [{ name: 'tech' }],
289
+ authors: [{ name: 'John' }],
290
+ },
291
+ ];
292
+ mockGetPosts.mockResolvedValue(mockPosts);
153
293
 
154
- const input = { title: 'Test Post', html: '<p>Content</p>' };
155
- const result = await toolImplementations.ghost_create_post(input);
294
+ const tool = mockTools.get('ghost_get_posts');
295
+ await tool.handler({ include: 'tags,authors' });
156
296
 
157
- expect(mockCreatePostService).toHaveBeenCalledWith(input);
158
- expect(result).toEqual(createdPost);
297
+ expect(mockGetPosts).toHaveBeenCalledWith({ include: 'tags,authors' });
298
+ });
299
+
300
+ it('should pass filter parameter (NQL)', async () => {
301
+ const mockPosts = [{ id: '1', title: 'Featured Post', featured: true }];
302
+ mockGetPosts.mockResolvedValue(mockPosts);
303
+
304
+ const tool = mockTools.get('ghost_get_posts');
305
+ await tool.handler({ filter: 'featured:true' });
306
+
307
+ expect(mockGetPosts).toHaveBeenCalledWith({ filter: 'featured:true' });
308
+ });
309
+
310
+ it('should pass order parameter', async () => {
311
+ const mockPosts = [
312
+ { id: '1', title: 'Newest', published_at: '2025-12-10' },
313
+ { id: '2', title: 'Older', published_at: '2025-12-01' },
314
+ ];
315
+ mockGetPosts.mockResolvedValue(mockPosts);
316
+
317
+ const tool = mockTools.get('ghost_get_posts');
318
+ await tool.handler({ order: 'published_at DESC' });
319
+
320
+ expect(mockGetPosts).toHaveBeenCalledWith({ order: 'published_at DESC' });
321
+ });
322
+
323
+ it('should pass all parameters combined', async () => {
324
+ const mockPosts = [{ id: '1', title: 'Test Post' }];
325
+ mockGetPosts.mockResolvedValue(mockPosts);
326
+
327
+ const tool = mockTools.get('ghost_get_posts');
328
+ await tool.handler({
329
+ limit: 20,
330
+ page: 1,
331
+ status: 'published',
332
+ include: 'tags,authors',
333
+ filter: 'featured:true',
334
+ order: 'published_at DESC',
335
+ });
336
+
337
+ expect(mockGetPosts).toHaveBeenCalledWith({
338
+ limit: 20,
339
+ page: 1,
340
+ status: 'published',
341
+ include: 'tags,authors',
342
+ filter: 'featured:true',
343
+ order: 'published_at DESC',
159
344
  });
345
+ });
346
+
347
+ it('should handle errors from ghostService', async () => {
348
+ mockGetPosts.mockRejectedValue(new Error('Ghost API error'));
160
349
 
161
- it('should handle tags and other optional fields', async () => {
162
- const createdPost = { id: '1', title: 'Test', tags: ['tech'] };
163
- mockCreatePostService.mockResolvedValue(createdPost);
350
+ const tool = mockTools.get('ghost_get_posts');
351
+ const result = await tool.handler({});
164
352
 
165
- const input = {
166
- title: 'Test',
353
+ expect(result.isError).toBe(true);
354
+ expect(result.content[0].text).toContain('Ghost API error');
355
+ });
356
+
357
+ it('should return formatted JSON response', async () => {
358
+ const mockPosts = [
359
+ {
360
+ id: '1',
361
+ title: 'Test Post',
362
+ slug: 'test-post',
167
363
  html: '<p>Content</p>',
168
- tags: ['tech'],
169
364
  status: 'published',
170
- feature_image: 'https://example.com/image.jpg',
171
- };
172
- const result = await toolImplementations.ghost_create_post(input);
365
+ },
366
+ ];
367
+ mockGetPosts.mockResolvedValue(mockPosts);
173
368
 
174
- expect(mockCreatePostService).toHaveBeenCalledWith(input);
175
- expect(result).toEqual(createdPost);
176
- });
369
+ const tool = mockTools.get('ghost_get_posts');
370
+ const result = await tool.handler({});
177
371
 
178
- it('should throw wrapped error when service fails', async () => {
179
- mockCreatePostService.mockRejectedValue(new Error('Database error'));
372
+ expect(result.content).toBeDefined();
373
+ expect(result.content[0].type).toBe('text');
374
+ expect(result.content[0].text).toContain('"id": "1"');
375
+ expect(result.content[0].text).toContain('"title": "Test Post"');
376
+ });
377
+ });
180
378
 
181
- const input = { title: 'Test Post', html: '<p>Content</p>' };
379
+ describe('mcp_server - ghost_get_post tool', () => {
380
+ beforeEach(async () => {
381
+ vi.clearAllMocks();
382
+ // Don't clear mockTools - they're registered once on module load
383
+ if (mockTools.size === 0) {
384
+ await import('../mcp_server.js');
385
+ }
386
+ });
182
387
 
183
- await expect(toolImplementations.ghost_create_post(input)).rejects.toThrow(
184
- 'Failed to create Ghost post: Database error'
185
- );
186
- });
388
+ it('should register ghost_get_post tool', () => {
389
+ expect(mockTools.has('ghost_get_post')).toBe(true);
187
390
  });
188
391
 
189
- describe('ghost_upload_image tool', () => {
190
- beforeEach(() => {
191
- mockValidateImageUrl.mockReturnValue({
192
- isValid: true,
193
- sanitizedUrl: 'https://example.com/image.jpg',
194
- });
195
- mockCreateSecureAxiosConfig.mockReturnValue({
196
- url: 'https://example.com/image.jpg',
197
- responseType: 'stream',
198
- });
199
-
200
- const mockWriter = {
201
- on: vi.fn((event, cb) => {
202
- if (event === 'finish') setTimeout(cb, 0);
203
- return mockWriter;
204
- }),
205
- };
206
- mockCreateWriteStream.mockReturnValue(mockWriter);
207
-
208
- const mockStream = { pipe: vi.fn().mockReturnValue(mockWriter) };
209
- mockAxios.mockResolvedValue({ data: mockStream });
210
-
211
- mockProcessImage.mockResolvedValue('/tmp/processed-image.jpg');
212
- mockUploadGhostImage.mockResolvedValue({
213
- url: 'https://ghost.com/content/images/image.jpg',
214
- });
215
- });
392
+ it('should have correct schema requiring one of id or slug', () => {
393
+ const tool = mockTools.get('ghost_get_post');
394
+ expect(tool).toBeDefined();
395
+ expect(tool.description).toContain('post');
396
+ expect(tool.schema).toBeDefined();
397
+ // ghost_get_post uses a refined schema, access via _def.schema.shape
398
+ const shape = tool.schema._def.schema.shape;
399
+ expect(shape.id).toBeDefined();
400
+ expect(shape.slug).toBeDefined();
401
+ expect(shape.include).toBeDefined();
402
+ });
216
403
 
217
- it('should validate image URL for SSRF protection', async () => {
218
- mockValidateImageUrl.mockReturnValue({
219
- isValid: false,
220
- error: 'Private IP address not allowed',
221
- });
404
+ it('should retrieve post by ID', async () => {
405
+ const mockPost = {
406
+ id: '507f1f77bcf86cd799439011',
407
+ title: 'Test Post',
408
+ slug: 'test-post',
409
+ html: '<p>Content</p>',
410
+ status: 'published',
411
+ };
412
+ mockGetPost.mockResolvedValue(mockPost);
413
+
414
+ const tool = mockTools.get('ghost_get_post');
415
+ const result = await tool.handler({ id: '507f1f77bcf86cd799439011' });
416
+
417
+ expect(mockGetPost).toHaveBeenCalledWith('507f1f77bcf86cd799439011', {});
418
+ expect(result.content[0].text).toContain('"id": "507f1f77bcf86cd799439011"');
419
+ expect(result.content[0].text).toContain('"title": "Test Post"');
420
+ });
222
421
 
223
- const input = { imageUrl: 'http://192.168.1.1/image.jpg' };
422
+ it('should retrieve post by slug', async () => {
423
+ const mockPost = {
424
+ id: '507f1f77bcf86cd799439011',
425
+ title: 'Test Post',
426
+ slug: 'test-post',
427
+ html: '<p>Content</p>',
428
+ status: 'published',
429
+ };
430
+ mockGetPost.mockResolvedValue(mockPost);
431
+
432
+ const tool = mockTools.get('ghost_get_post');
433
+ const result = await tool.handler({ slug: 'test-post' });
434
+
435
+ expect(mockGetPost).toHaveBeenCalledWith('slug/test-post', {});
436
+ expect(result.content[0].text).toContain('"title": "Test Post"');
437
+ });
224
438
 
225
- await expect(toolImplementations.ghost_upload_image(input)).rejects.toThrow(
226
- 'Invalid image URL: Private IP address not allowed'
227
- );
228
- });
439
+ it('should pass include parameter with ID', async () => {
440
+ const mockPost = {
441
+ id: '507f1f77bcf86cd799439011',
442
+ title: 'Post with relations',
443
+ tags: [{ name: 'tech' }],
444
+ authors: [{ name: 'John' }],
445
+ };
446
+ mockGetPost.mockResolvedValue(mockPost);
229
447
 
230
- it('should download, process, and upload image successfully', async () => {
231
- const input = { imageUrl: 'https://example.com/image.jpg' };
232
- const result = await toolImplementations.ghost_upload_image(input);
448
+ const tool = mockTools.get('ghost_get_post');
449
+ await tool.handler({ id: '507f1f77bcf86cd799439011', include: 'tags,authors' });
233
450
 
234
- expect(mockValidateImageUrl).toHaveBeenCalledWith(input.imageUrl);
235
- expect(mockAxios).toHaveBeenCalled();
236
- expect(mockProcessImage).toHaveBeenCalled();
237
- expect(mockUploadGhostImage).toHaveBeenCalled();
238
- expect(result.url).toBe('https://ghost.com/content/images/image.jpg');
451
+ expect(mockGetPost).toHaveBeenCalledWith('507f1f77bcf86cd799439011', {
452
+ include: 'tags,authors',
239
453
  });
454
+ });
455
+
456
+ it('should pass include parameter with slug', async () => {
457
+ const mockPost = {
458
+ id: '507f1f77bcf86cd799439011',
459
+ title: 'Post with relations',
460
+ slug: 'test-post',
461
+ tags: [{ name: 'tech' }],
462
+ };
463
+ mockGetPost.mockResolvedValue(mockPost);
464
+
465
+ const tool = mockTools.get('ghost_get_post');
466
+ await tool.handler({ slug: 'test-post', include: 'tags' });
467
+
468
+ expect(mockGetPost).toHaveBeenCalledWith('slug/test-post', { include: 'tags' });
469
+ });
470
+
471
+ it('should prefer ID over slug when both provided', async () => {
472
+ const mockPost = { id: '507f1f77bcf86cd799439011', title: 'Test Post', slug: 'test-post' };
473
+ mockGetPost.mockResolvedValue(mockPost);
240
474
 
241
- it('should use provided alt text', async () => {
242
- const input = { imageUrl: 'https://example.com/image.jpg', alt: 'My custom alt' };
243
- const result = await toolImplementations.ghost_upload_image(input);
475
+ const tool = mockTools.get('ghost_get_post');
476
+ await tool.handler({ id: '507f1f77bcf86cd799439011', slug: 'wrong-slug' });
244
477
 
245
- expect(result.alt).toBe('My custom alt');
478
+ expect(mockGetPost).toHaveBeenCalledWith('507f1f77bcf86cd799439011', {});
479
+ });
480
+
481
+ it('should handle not found errors', async () => {
482
+ mockGetPost.mockRejectedValue(new Error('Post not found'));
483
+
484
+ const tool = mockTools.get('ghost_get_post');
485
+ const result = await tool.handler({ id: '507f1f77bcf86cd799439099' });
486
+
487
+ expect(result.isError).toBe(true);
488
+ expect(result.content[0].text).toContain('Post not found');
489
+ });
490
+
491
+ it('should handle errors from ghostService', async () => {
492
+ mockGetPost.mockRejectedValue(new Error('Ghost API error'));
493
+
494
+ const tool = mockTools.get('ghost_get_post');
495
+ const result = await tool.handler({ slug: 'test' });
496
+
497
+ expect(result.isError).toBe(true);
498
+ expect(result.content[0].text).toContain('Ghost API error');
499
+ });
500
+
501
+ it('should return formatted JSON response', async () => {
502
+ const mockPost = {
503
+ id: '507f1f77bcf86cd799439011',
504
+ uuid: 'uuid-123',
505
+ title: 'Test Post',
506
+ slug: 'test-post',
507
+ html: '<p>Content</p>',
508
+ status: 'published',
509
+ created_at: '2025-12-10T00:00:00.000Z',
510
+ updated_at: '2025-12-10T00:00:00.000Z',
511
+ };
512
+ mockGetPost.mockResolvedValue(mockPost);
513
+
514
+ const tool = mockTools.get('ghost_get_post');
515
+ const result = await tool.handler({ id: '507f1f77bcf86cd799439011' });
516
+
517
+ expect(result.content).toBeDefined();
518
+ expect(result.content[0].type).toBe('text');
519
+ expect(result.content[0].text).toContain('"id": "507f1f77bcf86cd799439011"');
520
+ expect(result.content[0].text).toContain('"title": "Test Post"');
521
+ expect(result.content[0].text).toContain('"status": "published"');
522
+ });
523
+
524
+ it('should handle validation error when neither id nor slug provided', async () => {
525
+ const tool = mockTools.get('ghost_get_post');
526
+ const result = await tool.handler({});
527
+
528
+ expect(result.isError).toBe(true);
529
+ expect(result.content[0].text).toContain('Either id or slug is required');
530
+ });
531
+ });
532
+
533
+ describe('mcp_server - ghost_update_post tool', () => {
534
+ beforeEach(async () => {
535
+ vi.clearAllMocks();
536
+ // Don't clear mockTools - they're registered once on module load
537
+ if (mockTools.size === 0) {
538
+ await import('../mcp_server.js');
539
+ }
540
+ });
541
+
542
+ it('should register ghost_update_post tool', () => {
543
+ expect(mockTools.has('ghost_update_post')).toBe(true);
544
+ });
545
+
546
+ it('should have correct schema with required id field', () => {
547
+ const tool = mockTools.get('ghost_update_post');
548
+ expect(tool).toBeDefined();
549
+ expect(tool.description).toContain('Updates an existing post');
550
+ expect(tool.schema).toBeDefined();
551
+ // Zod schemas store field definitions in schema.shape
552
+ expect(tool.schema.shape.id).toBeDefined();
553
+ expect(tool.schema.shape.title).toBeDefined();
554
+ expect(tool.schema.shape.html).toBeDefined();
555
+ expect(tool.schema.shape.status).toBeDefined();
556
+ expect(tool.schema.shape.tags).toBeDefined();
557
+ expect(tool.schema.shape.feature_image).toBeDefined();
558
+ expect(tool.schema.shape.feature_image_alt).toBeDefined();
559
+ expect(tool.schema.shape.feature_image_caption).toBeDefined();
560
+ expect(tool.schema.shape.meta_title).toBeDefined();
561
+ expect(tool.schema.shape.meta_description).toBeDefined();
562
+ expect(tool.schema.shape.published_at).toBeDefined();
563
+ expect(tool.schema.shape.custom_excerpt).toBeDefined();
564
+ });
565
+
566
+ it('should update post title', async () => {
567
+ const mockUpdatedPost = {
568
+ id: '507f1f77bcf86cd799439011',
569
+ title: 'Updated Title',
570
+ slug: 'test-post',
571
+ html: '<p>Content</p>',
572
+ status: 'published',
573
+ updated_at: '2025-12-10T12:00:00.000Z',
574
+ };
575
+ mockUpdatePost.mockResolvedValue(mockUpdatedPost);
576
+
577
+ const tool = mockTools.get('ghost_update_post');
578
+ const result = await tool.handler({ id: '507f1f77bcf86cd799439011', title: 'Updated Title' });
579
+
580
+ expect(mockUpdatePost).toHaveBeenCalledWith('507f1f77bcf86cd799439011', {
581
+ title: 'Updated Title',
246
582
  });
583
+ expect(result.content[0].text).toContain('"title": "Updated Title"');
584
+ });
247
585
 
248
- it('should generate default alt text from filename', async () => {
249
- const input = { imageUrl: 'https://example.com/beautiful-sunset.jpg' };
250
- const result = await toolImplementations.ghost_upload_image(input);
586
+ it('should update post content', async () => {
587
+ const mockUpdatedPost = {
588
+ id: '507f1f77bcf86cd799439011',
589
+ title: 'Test Post',
590
+ html: '<p>Updated content</p>',
591
+ status: 'published',
592
+ updated_at: '2025-12-10T12:00:00.000Z',
593
+ };
594
+ mockUpdatePost.mockResolvedValue(mockUpdatedPost);
595
+
596
+ const tool = mockTools.get('ghost_update_post');
597
+ const result = await tool.handler({
598
+ id: '507f1f77bcf86cd799439011',
599
+ html: '<p>Updated content</p>',
600
+ });
251
601
 
252
- expect(result.alt).toBeTruthy();
253
- expect(result.alt).not.toBe('');
602
+ expect(mockUpdatePost).toHaveBeenCalledWith('507f1f77bcf86cd799439011', {
603
+ html: '<p>Updated content</p>',
254
604
  });
605
+ expect(result.content[0].text).toContain('Updated content');
606
+ });
255
607
 
256
- it('should cleanup temporary files on success', async () => {
257
- const input = { imageUrl: 'https://example.com/image.jpg' };
258
- await toolImplementations.ghost_upload_image(input);
608
+ it('should update post status', async () => {
609
+ const mockUpdatedPost = {
610
+ id: '507f1f77bcf86cd799439011',
611
+ title: 'Test Post',
612
+ html: '<p>Content</p>',
613
+ status: 'published',
614
+ updated_at: '2025-12-10T12:00:00.000Z',
615
+ };
616
+ mockUpdatePost.mockResolvedValue(mockUpdatedPost);
617
+
618
+ const tool = mockTools.get('ghost_update_post');
619
+ const result = await tool.handler({ id: '507f1f77bcf86cd799439011', status: 'published' });
620
+
621
+ expect(mockUpdatePost).toHaveBeenCalledWith('507f1f77bcf86cd799439011', {
622
+ status: 'published',
623
+ });
624
+ expect(result.content[0].text).toContain('"status": "published"');
625
+ });
259
626
 
260
- expect(mockCleanupTempFiles).toHaveBeenCalled();
627
+ it('should update post tags', async () => {
628
+ const mockUpdatedPost = {
629
+ id: '507f1f77bcf86cd799439011',
630
+ title: 'Test Post',
631
+ html: '<p>Content</p>',
632
+ tags: [{ name: 'tech' }, { name: 'javascript' }],
633
+ updated_at: '2025-12-10T12:00:00.000Z',
634
+ };
635
+ mockUpdatePost.mockResolvedValue(mockUpdatedPost);
636
+
637
+ const tool = mockTools.get('ghost_update_post');
638
+ const result = await tool.handler({
639
+ id: '507f1f77bcf86cd799439011',
640
+ tags: ['tech', 'javascript'],
261
641
  });
262
642
 
263
- it('should cleanup temporary files on error', async () => {
264
- mockUploadGhostImage.mockRejectedValue(new Error('Upload failed'));
643
+ expect(mockUpdatePost).toHaveBeenCalledWith('507f1f77bcf86cd799439011', {
644
+ tags: ['tech', 'javascript'],
645
+ });
646
+ expect(result.content[0].text).toContain('tech');
647
+ expect(result.content[0].text).toContain('javascript');
648
+ });
265
649
 
266
- const input = { imageUrl: 'https://example.com/image.jpg' };
267
- await expect(toolImplementations.ghost_upload_image(input)).rejects.toThrow();
650
+ it('should update post featured image', async () => {
651
+ const mockUpdatedPost = {
652
+ id: '507f1f77bcf86cd799439011',
653
+ title: 'Test Post',
654
+ feature_image: 'https://example.com/new-image.jpg',
655
+ feature_image_alt: 'New image',
656
+ updated_at: '2025-12-10T12:00:00.000Z',
657
+ };
658
+ mockUpdatePost.mockResolvedValue(mockUpdatedPost);
659
+
660
+ const tool = mockTools.get('ghost_update_post');
661
+ const result = await tool.handler({
662
+ id: '507f1f77bcf86cd799439011',
663
+ feature_image: 'https://example.com/new-image.jpg',
664
+ feature_image_alt: 'New image',
665
+ });
268
666
 
269
- expect(mockCleanupTempFiles).toHaveBeenCalled();
667
+ expect(mockUpdatePost).toHaveBeenCalledWith('507f1f77bcf86cd799439011', {
668
+ feature_image: 'https://example.com/new-image.jpg',
669
+ feature_image_alt: 'New image',
270
670
  });
671
+ expect(result.content[0].text).toContain('new-image.jpg');
672
+ });
271
673
 
272
- it('should handle download errors', async () => {
273
- mockAxios.mockRejectedValue(new Error('Network error'));
674
+ it('should update SEO meta fields', async () => {
675
+ const mockUpdatedPost = {
676
+ id: '507f1f77bcf86cd799439011',
677
+ title: 'Test Post',
678
+ meta_title: 'SEO Title',
679
+ meta_description: 'SEO Description',
680
+ updated_at: '2025-12-10T12:00:00.000Z',
681
+ };
682
+ mockUpdatePost.mockResolvedValue(mockUpdatedPost);
683
+
684
+ const tool = mockTools.get('ghost_update_post');
685
+ const result = await tool.handler({
686
+ id: '507f1f77bcf86cd799439011',
687
+ meta_title: 'SEO Title',
688
+ meta_description: 'SEO Description',
689
+ });
274
690
 
275
- const input = { imageUrl: 'https://example.com/image.jpg' };
276
- await expect(toolImplementations.ghost_upload_image(input)).rejects.toThrow(
277
- 'Failed to upload image from URL'
278
- );
691
+ expect(mockUpdatePost).toHaveBeenCalledWith('507f1f77bcf86cd799439011', {
692
+ meta_title: 'SEO Title',
693
+ meta_description: 'SEO Description',
279
694
  });
695
+ expect(result.content[0].text).toContain('SEO Title');
696
+ expect(result.content[0].text).toContain('SEO Description');
697
+ });
280
698
 
281
- it('should handle processing errors', async () => {
282
- mockProcessImage.mockRejectedValue(new Error('Invalid image format'));
699
+ it('should update multiple fields at once', async () => {
700
+ const mockUpdatedPost = {
701
+ id: '507f1f77bcf86cd799439011',
702
+ title: 'Updated Title',
703
+ html: '<p>Updated content</p>',
704
+ status: 'published',
705
+ tags: [{ name: 'tech' }],
706
+ updated_at: '2025-12-10T12:00:00.000Z',
707
+ };
708
+ mockUpdatePost.mockResolvedValue(mockUpdatedPost);
709
+
710
+ const tool = mockTools.get('ghost_update_post');
711
+ const result = await tool.handler({
712
+ id: '507f1f77bcf86cd799439011',
713
+ title: 'Updated Title',
714
+ html: '<p>Updated content</p>',
715
+ status: 'published',
716
+ tags: ['tech'],
717
+ });
283
718
 
284
- const input = { imageUrl: 'https://example.com/image.jpg' };
285
- await expect(toolImplementations.ghost_upload_image(input)).rejects.toThrow(
286
- 'Failed to upload image from URL'
287
- );
719
+ expect(mockUpdatePost).toHaveBeenCalledWith('507f1f77bcf86cd799439011', {
720
+ title: 'Updated Title',
721
+ html: '<p>Updated content</p>',
722
+ status: 'published',
723
+ tags: ['tech'],
288
724
  });
725
+ expect(result.content[0].text).toContain('Updated Title');
289
726
  });
290
727
 
291
- describe('ghost_get_tags tool', () => {
292
- it('should get all tags without filter', async () => {
293
- const tags = [
294
- { id: '1', name: 'Tag1', slug: 'tag1' },
295
- { id: '2', name: 'Tag2', slug: 'tag2' },
296
- ];
297
- mockGetGhostTags.mockResolvedValue(tags);
728
+ it('should handle not found errors', async () => {
729
+ mockUpdatePost.mockRejectedValue(new Error('Post not found'));
298
730
 
299
- const result = await toolImplementations.ghost_get_tags({});
731
+ const tool = mockTools.get('ghost_update_post');
732
+ const result = await tool.handler({ id: '507f1f77bcf86cd799439099', title: 'New Title' });
300
733
 
301
- expect(mockGetGhostTags).toHaveBeenCalledWith(undefined);
302
- expect(result).toEqual(tags);
303
- });
734
+ expect(result.isError).toBe(true);
735
+ expect(result.content[0].text).toContain('Post not found');
736
+ });
304
737
 
305
- it('should filter tags by name', async () => {
306
- const tags = [{ id: '1', name: 'Technology', slug: 'technology' }];
307
- mockGetGhostTags.mockResolvedValue(tags);
738
+ it('should handle validation errors', async () => {
739
+ mockUpdatePost.mockRejectedValue(new Error('Validation failed: Title is required'));
308
740
 
309
- const result = await toolImplementations.ghost_get_tags({ name: 'Technology' });
741
+ const tool = mockTools.get('ghost_update_post');
742
+ const result = await tool.handler({ id: '507f1f77bcf86cd799439011', title: '' });
310
743
 
311
- expect(mockGetGhostTags).toHaveBeenCalledWith('Technology');
312
- expect(result).toEqual(tags);
313
- });
744
+ expect(result.isError).toBe(true);
745
+ expect(result.content[0].text).toContain('Validation failed');
746
+ });
314
747
 
315
- it('should return empty array when no tags match', async () => {
316
- mockGetGhostTags.mockResolvedValue([]);
748
+ it('should handle Ghost API errors', async () => {
749
+ mockUpdatePost.mockRejectedValue(new Error('Ghost API error: Server timeout'));
317
750
 
318
- const result = await toolImplementations.ghost_get_tags({ name: 'NonExistent' });
751
+ const tool = mockTools.get('ghost_update_post');
752
+ const result = await tool.handler({ id: '507f1f77bcf86cd799439011', title: 'Updated' });
319
753
 
320
- expect(result).toEqual([]);
321
- });
754
+ expect(result.isError).toBe(true);
755
+ expect(result.content[0].text).toContain('Ghost API error');
756
+ });
757
+
758
+ it('should return formatted JSON response', async () => {
759
+ const mockUpdatedPost = {
760
+ id: '507f1f77bcf86cd799439011',
761
+ uuid: 'uuid-123',
762
+ title: 'Updated Post',
763
+ slug: 'updated-post',
764
+ html: '<p>Updated content</p>',
765
+ status: 'published',
766
+ created_at: '2025-12-09T00:00:00.000Z',
767
+ updated_at: '2025-12-10T12:00:00.000Z',
768
+ };
769
+ mockUpdatePost.mockResolvedValue(mockUpdatedPost);
770
+
771
+ const tool = mockTools.get('ghost_update_post');
772
+ const result = await tool.handler({ id: '507f1f77bcf86cd799439011', title: 'Updated Post' });
773
+
774
+ expect(result.content).toBeDefined();
775
+ expect(result.content[0].type).toBe('text');
776
+ expect(result.content[0].text).toContain('"id": "507f1f77bcf86cd799439011"');
777
+ expect(result.content[0].text).toContain('"title": "Updated Post"');
778
+ expect(result.content[0].text).toContain('"status": "published"');
779
+ });
780
+ });
781
+
782
+ describe('mcp_server - ghost_delete_post tool', () => {
783
+ beforeEach(async () => {
784
+ vi.clearAllMocks();
785
+ // Don't clear mockTools - they're registered once on module load
786
+ if (mockTools.size === 0) {
787
+ await import('../mcp_server.js');
788
+ }
789
+ });
322
790
 
323
- it('should throw wrapped error when service fails', async () => {
324
- mockGetGhostTags.mockRejectedValue(new Error('API error'));
791
+ it('should register ghost_delete_post tool', () => {
792
+ expect(mockTools.has('ghost_delete_post')).toBe(true);
793
+ });
325
794
 
326
- await expect(toolImplementations.ghost_get_tags({})).rejects.toThrow(
327
- 'Failed to get Ghost tags: API error'
328
- );
329
- });
795
+ it('should have correct schema with required id field', () => {
796
+ const tool = mockTools.get('ghost_delete_post');
797
+ expect(tool).toBeDefined();
798
+ expect(tool.description).toContain('Deletes a post');
799
+ expect(tool.description).toContain('permanent');
800
+ expect(tool.schema).toBeDefined();
801
+ // Zod schemas store field definitions in schema.shape
802
+ expect(tool.schema.shape.id).toBeDefined();
803
+ });
804
+
805
+ it('should delete post by ID', async () => {
806
+ mockDeletePost.mockResolvedValue({ deleted: true });
807
+
808
+ const tool = mockTools.get('ghost_delete_post');
809
+ const result = await tool.handler({ id: '507f1f77bcf86cd799439011' });
810
+
811
+ expect(mockDeletePost).toHaveBeenCalledWith('507f1f77bcf86cd799439011');
812
+ expect(result.content[0].text).toContain(
813
+ 'Post 507f1f77bcf86cd799439011 has been successfully deleted'
814
+ );
815
+ expect(result.isError).toBeUndefined();
816
+ });
817
+
818
+ it('should handle not found errors', async () => {
819
+ mockDeletePost.mockRejectedValue(new Error('Post not found'));
820
+
821
+ const tool = mockTools.get('ghost_delete_post');
822
+ const result = await tool.handler({ id: '507f1f77bcf86cd799439099' });
823
+
824
+ expect(result.isError).toBe(true);
825
+ expect(result.content[0].text).toContain('Post not found');
826
+ });
827
+
828
+ it('should handle Ghost API errors', async () => {
829
+ mockDeletePost.mockRejectedValue(new Error('Ghost API error: Permission denied'));
830
+
831
+ const tool = mockTools.get('ghost_delete_post');
832
+ const result = await tool.handler({ id: '507f1f77bcf86cd799439011' });
833
+
834
+ expect(result.isError).toBe(true);
835
+ expect(result.content[0].text).toContain('Ghost API error');
836
+ });
837
+
838
+ it('should return success message on successful deletion', async () => {
839
+ mockDeletePost.mockResolvedValue({ deleted: true });
840
+
841
+ const tool = mockTools.get('ghost_delete_post');
842
+ const result = await tool.handler({ id: '507f1f77bcf86cd799439011' });
843
+
844
+ expect(result.content).toBeDefined();
845
+ expect(result.content[0].type).toBe('text');
846
+ expect(result.content[0].text).toBe(
847
+ 'Post 507f1f77bcf86cd799439011 has been successfully deleted.'
848
+ );
849
+ });
850
+
851
+ it('should handle network errors', async () => {
852
+ mockDeletePost.mockRejectedValue(new Error('Network error: Connection refused'));
853
+
854
+ const tool = mockTools.get('ghost_delete_post');
855
+ const result = await tool.handler({ id: '507f1f77bcf86cd799439012' });
856
+
857
+ expect(result.isError).toBe(true);
858
+ expect(result.content[0].text).toContain('Network error');
859
+ });
860
+ });
861
+
862
+ describe('mcp_server - ghost_search_posts tool', () => {
863
+ beforeEach(async () => {
864
+ vi.clearAllMocks();
865
+ // Don't clear mockTools - they're registered once on module load
866
+ if (mockTools.size === 0) {
867
+ await import('../mcp_server.js');
868
+ }
869
+ });
870
+
871
+ it('should register ghost_search_posts tool', () => {
872
+ expect(mockTools.has('ghost_search_posts')).toBe(true);
873
+ });
874
+
875
+ it('should have correct schema with required query and optional parameters', () => {
876
+ const tool = mockTools.get('ghost_search_posts');
877
+ expect(tool).toBeDefined();
878
+ expect(tool.description).toContain('Search');
879
+ expect(tool.schema).toBeDefined();
880
+ // Zod schemas store field definitions in schema.shape
881
+ expect(tool.schema.shape.query).toBeDefined();
882
+ expect(tool.schema.shape.status).toBeDefined();
883
+ expect(tool.schema.shape.limit).toBeDefined();
884
+ });
885
+
886
+ it('should search posts with query only', async () => {
887
+ const mockPosts = [
888
+ { id: '1', title: 'JavaScript Tips', slug: 'javascript-tips', status: 'published' },
889
+ { id: '2', title: 'JavaScript Tricks', slug: 'javascript-tricks', status: 'published' },
890
+ ];
891
+ mockSearchPosts.mockResolvedValue(mockPosts);
892
+
893
+ const tool = mockTools.get('ghost_search_posts');
894
+ const result = await tool.handler({ query: 'JavaScript' });
895
+
896
+ expect(mockSearchPosts).toHaveBeenCalledWith('JavaScript', {});
897
+ expect(result.content[0].text).toContain('JavaScript Tips');
898
+ expect(result.content[0].text).toContain('JavaScript Tricks');
330
899
  });
331
900
 
332
- describe('ghost_create_tag tool', () => {
333
- it('should create tag with name only', async () => {
334
- const newTag = { id: '1', name: 'NewTag', slug: 'newtag' };
335
- mockCreateGhostTag.mockResolvedValue(newTag);
901
+ it('should search posts with query and status filter', async () => {
902
+ const mockPosts = [
903
+ { id: '1', title: 'Published Post', slug: 'published-post', status: 'published' },
904
+ ];
905
+ mockSearchPosts.mockResolvedValue(mockPosts);
336
906
 
337
- const input = { name: 'NewTag' };
338
- const result = await toolImplementations.ghost_create_tag(input);
907
+ const tool = mockTools.get('ghost_search_posts');
908
+ await tool.handler({ query: 'test', status: 'published' });
339
909
 
340
- expect(mockCreateGhostTag).toHaveBeenCalledWith(input);
341
- expect(result).toEqual(newTag);
910
+ expect(mockSearchPosts).toHaveBeenCalledWith('test', { status: 'published' });
911
+ });
912
+
913
+ it('should search posts with query and limit', async () => {
914
+ const mockPosts = [{ id: '1', title: 'Test Post', slug: 'test-post' }];
915
+ mockSearchPosts.mockResolvedValue(mockPosts);
916
+
917
+ const tool = mockTools.get('ghost_search_posts');
918
+ await tool.handler({ query: 'test', limit: 10 });
919
+
920
+ expect(mockSearchPosts).toHaveBeenCalledWith('test', { limit: 10 });
921
+ });
922
+
923
+ it('should validate limit is between 1 and 50', () => {
924
+ const tool = mockTools.get('ghost_search_posts');
925
+ // Zod schemas store field definitions in schema.shape
926
+ const shape = tool.schema.shape;
927
+
928
+ expect(shape.limit).toBeDefined();
929
+ expect(() => shape.limit.parse(0)).toThrow();
930
+ expect(() => shape.limit.parse(51)).toThrow();
931
+ expect(shape.limit.parse(25)).toBe(25);
932
+ });
933
+
934
+ it('should validate status enum values', () => {
935
+ const tool = mockTools.get('ghost_search_posts');
936
+ // Zod schemas store field definitions in schema.shape
937
+ const shape = tool.schema.shape;
938
+
939
+ expect(shape.status).toBeDefined();
940
+ expect(() => shape.status.parse('invalid')).toThrow();
941
+ expect(shape.status.parse('published')).toBe('published');
942
+ expect(shape.status.parse('draft')).toBe('draft');
943
+ expect(shape.status.parse('scheduled')).toBe('scheduled');
944
+ expect(shape.status.parse('all')).toBe('all');
945
+ });
946
+
947
+ it('should pass all parameters combined', async () => {
948
+ const mockPosts = [{ id: '1', title: 'Test Post' }];
949
+ mockSearchPosts.mockResolvedValue(mockPosts);
950
+
951
+ const tool = mockTools.get('ghost_search_posts');
952
+ await tool.handler({
953
+ query: 'JavaScript',
954
+ status: 'published',
955
+ limit: 20,
342
956
  });
343
957
 
344
- it('should create tag with all fields', async () => {
345
- const newTag = {
958
+ expect(mockSearchPosts).toHaveBeenCalledWith('JavaScript', {
959
+ status: 'published',
960
+ limit: 20,
961
+ });
962
+ });
963
+
964
+ it('should handle errors from searchPosts', async () => {
965
+ // Empty query is now caught by Zod validation
966
+ const tool = mockTools.get('ghost_search_posts');
967
+ const result = await tool.handler({ query: '' });
968
+
969
+ expect(result.isError).toBe(true);
970
+ expect(result.content[0].text).toContain('VALIDATION_ERROR');
971
+ });
972
+
973
+ it('should handle Ghost API errors', async () => {
974
+ mockSearchPosts.mockRejectedValue(new Error('Ghost API error'));
975
+
976
+ const tool = mockTools.get('ghost_search_posts');
977
+ const result = await tool.handler({ query: 'test' });
978
+
979
+ expect(result.isError).toBe(true);
980
+ expect(result.content[0].text).toContain('Ghost API error');
981
+ });
982
+
983
+ it('should return formatted JSON response', async () => {
984
+ const mockPosts = [
985
+ {
346
986
  id: '1',
347
- name: 'Tech',
348
- slug: 'technology',
349
- description: 'Tech articles',
350
- };
351
- mockCreateGhostTag.mockResolvedValue(newTag);
987
+ title: 'Test Post',
988
+ slug: 'test-post',
989
+ html: '<p>Content</p>',
990
+ status: 'published',
991
+ },
992
+ ];
993
+ mockSearchPosts.mockResolvedValue(mockPosts);
352
994
 
353
- const input = { name: 'Tech', slug: 'technology', description: 'Tech articles' };
354
- const result = await toolImplementations.ghost_create_tag(input);
995
+ const tool = mockTools.get('ghost_search_posts');
996
+ const result = await tool.handler({ query: 'Test' });
355
997
 
356
- expect(mockCreateGhostTag).toHaveBeenCalledWith(input);
357
- expect(result).toEqual(newTag);
358
- });
998
+ expect(result.content).toBeDefined();
999
+ expect(result.content[0].type).toBe('text');
1000
+ expect(result.content[0].text).toContain('"id": "1"');
1001
+ expect(result.content[0].text).toContain('"title": "Test Post"');
1002
+ });
1003
+
1004
+ it('should return empty array when no results found', async () => {
1005
+ mockSearchPosts.mockResolvedValue([]);
359
1006
 
360
- it('should throw wrapped error when service fails', async () => {
361
- mockCreateGhostTag.mockRejectedValue(new Error('Duplicate tag'));
1007
+ const tool = mockTools.get('ghost_search_posts');
1008
+ const result = await tool.handler({ query: 'nonexistent' });
362
1009
 
363
- await expect(toolImplementations.ghost_create_tag({ name: 'Existing' })).rejects.toThrow(
364
- 'Failed to create Ghost tag: Duplicate tag'
365
- );
1010
+ expect(result.content[0].text).toBe('[]');
1011
+ expect(result.isError).toBeUndefined();
1012
+ });
1013
+ });
1014
+
1015
+ describe('ghost_get_tag', () => {
1016
+ beforeEach(() => {
1017
+ vi.clearAllMocks();
1018
+ });
1019
+
1020
+ it('should be registered as a tool', () => {
1021
+ expect(mockTools.has('ghost_get_tag')).toBe(true);
1022
+ const tool = mockTools.get('ghost_get_tag');
1023
+ expect(tool.name).toBe('ghost_get_tag');
1024
+ expect(tool.description).toBeDefined();
1025
+ expect(tool.schema).toBeDefined();
1026
+ expect(tool.handler).toBeDefined();
1027
+ });
1028
+
1029
+ it('should have correct schema with id and slug as optional', () => {
1030
+ const tool = mockTools.get('ghost_get_tag');
1031
+ // ghost_get_tag uses a refined schema, access via _def.schema.shape
1032
+ const shape = tool.schema._def.schema.shape;
1033
+ expect(shape.id).toBeDefined();
1034
+ expect(shape.slug).toBeDefined();
1035
+ expect(shape.include).toBeDefined();
1036
+ });
1037
+
1038
+ it('should retrieve tag by ID', async () => {
1039
+ const mockTag = {
1040
+ id: '507f1f77bcf86cd799439011',
1041
+ name: 'Test Tag',
1042
+ slug: 'test-tag',
1043
+ description: 'A test tag',
1044
+ };
1045
+ mockGetTag.mockResolvedValue(mockTag);
1046
+
1047
+ const tool = mockTools.get('ghost_get_tag');
1048
+ const result = await tool.handler({ id: '507f1f77bcf86cd799439011' });
1049
+
1050
+ expect(mockGetTag).toHaveBeenCalledWith('507f1f77bcf86cd799439011', {});
1051
+ expect(result.content).toBeDefined();
1052
+ expect(result.content[0].type).toBe('text');
1053
+ expect(result.content[0].text).toContain('"id": "507f1f77bcf86cd799439011"');
1054
+ expect(result.content[0].text).toContain('"name": "Test Tag"');
1055
+ });
1056
+
1057
+ it('should retrieve tag by slug', async () => {
1058
+ const mockTag = {
1059
+ id: '507f1f77bcf86cd799439011',
1060
+ name: 'Test Tag',
1061
+ slug: 'test-tag',
1062
+ description: 'A test tag',
1063
+ };
1064
+ mockGetTag.mockResolvedValue(mockTag);
1065
+
1066
+ const tool = mockTools.get('ghost_get_tag');
1067
+ const result = await tool.handler({ slug: 'test-tag' });
1068
+
1069
+ expect(mockGetTag).toHaveBeenCalledWith('slug/test-tag', {});
1070
+ expect(result.content[0].text).toContain('"slug": "test-tag"');
1071
+ });
1072
+
1073
+ it('should support include parameter for post count', async () => {
1074
+ const mockTag = {
1075
+ id: '507f1f77bcf86cd799439011',
1076
+ name: 'Test Tag',
1077
+ slug: 'test-tag',
1078
+ count: { posts: 5 },
1079
+ };
1080
+ mockGetTag.mockResolvedValue(mockTag);
1081
+
1082
+ const tool = mockTools.get('ghost_get_tag');
1083
+ const result = await tool.handler({ id: '507f1f77bcf86cd799439011', include: 'count.posts' });
1084
+
1085
+ expect(mockGetTag).toHaveBeenCalledWith('507f1f77bcf86cd799439011', { include: 'count.posts' });
1086
+ expect(result.content[0].text).toContain('"count"');
1087
+ });
1088
+
1089
+ it('should return error when neither id nor slug provided', async () => {
1090
+ const tool = mockTools.get('ghost_get_tag');
1091
+ const result = await tool.handler({});
1092
+
1093
+ expect(result.content[0].type).toBe('text');
1094
+ expect(result.content[0].text).toContain('Either id or slug is required');
1095
+ expect(result.isError).toBe(true);
1096
+ });
1097
+
1098
+ it('should handle not found error', async () => {
1099
+ mockGetTag.mockRejectedValue(new Error('Tag not found'));
1100
+
1101
+ const tool = mockTools.get('ghost_get_tag');
1102
+ const result = await tool.handler({ id: '507f1f77bcf86cd799439099' });
1103
+
1104
+ expect(result.isError).toBe(true);
1105
+ expect(result.content[0].text).toContain('Tag not found');
1106
+ });
1107
+ });
1108
+
1109
+ describe('ghost_update_tag', () => {
1110
+ beforeEach(() => {
1111
+ vi.clearAllMocks();
1112
+ });
1113
+
1114
+ it('should be registered as a tool', () => {
1115
+ expect(mockTools.has('ghost_update_tag')).toBe(true);
1116
+ const tool = mockTools.get('ghost_update_tag');
1117
+ expect(tool.name).toBe('ghost_update_tag');
1118
+ expect(tool.description).toBeDefined();
1119
+ expect(tool.schema).toBeDefined();
1120
+ expect(tool.handler).toBeDefined();
1121
+ });
1122
+
1123
+ it('should have correct schema with all update fields', () => {
1124
+ const tool = mockTools.get('ghost_update_tag');
1125
+ // Zod schemas store field definitions in schema.shape
1126
+ expect(tool.schema.shape.id).toBeDefined();
1127
+ expect(tool.schema.shape.name).toBeDefined();
1128
+ expect(tool.schema.shape.slug).toBeDefined();
1129
+ expect(tool.schema.shape.description).toBeDefined();
1130
+ expect(tool.schema.shape.feature_image).toBeDefined();
1131
+ expect(tool.schema.shape.meta_title).toBeDefined();
1132
+ expect(tool.schema.shape.meta_description).toBeDefined();
1133
+ });
1134
+
1135
+ it('should update tag name', async () => {
1136
+ const mockUpdatedTag = {
1137
+ id: '507f1f77bcf86cd799439011',
1138
+ name: 'Updated Tag',
1139
+ slug: 'updated-tag',
1140
+ };
1141
+ mockUpdateTag.mockResolvedValue(mockUpdatedTag);
1142
+
1143
+ const tool = mockTools.get('ghost_update_tag');
1144
+ const result = await tool.handler({ id: '507f1f77bcf86cd799439011', name: 'Updated Tag' });
1145
+
1146
+ expect(mockUpdateTag).toHaveBeenCalledWith('507f1f77bcf86cd799439011', { name: 'Updated Tag' });
1147
+ expect(result.content[0].text).toContain('"name": "Updated Tag"');
1148
+ });
1149
+
1150
+ it('should update tag description', async () => {
1151
+ const mockUpdatedTag = {
1152
+ id: '507f1f77bcf86cd799439011',
1153
+ name: 'Test Tag',
1154
+ description: 'New description',
1155
+ };
1156
+ mockUpdateTag.mockResolvedValue(mockUpdatedTag);
1157
+
1158
+ const tool = mockTools.get('ghost_update_tag');
1159
+ const result = await tool.handler({
1160
+ id: '507f1f77bcf86cd799439011',
1161
+ description: 'New description',
1162
+ });
1163
+
1164
+ expect(mockUpdateTag).toHaveBeenCalledWith('507f1f77bcf86cd799439011', {
1165
+ description: 'New description',
366
1166
  });
1167
+ expect(result.content[0].text).toContain('"description": "New description"');
367
1168
  });
368
1169
 
369
- describe('startMCPServer', () => {
370
- it('should export startMCPServer function', async () => {
371
- const { startMCPServer } = await import('../mcp_server.js');
372
- expect(typeof startMCPServer).toBe('function');
1170
+ it('should update multiple fields at once', async () => {
1171
+ const mockUpdatedTag = {
1172
+ id: '507f1f77bcf86cd799439011',
1173
+ name: 'Updated Tag',
1174
+ slug: 'updated-tag',
1175
+ description: 'Updated description',
1176
+ meta_title: 'Updated Meta',
1177
+ };
1178
+ mockUpdateTag.mockResolvedValue(mockUpdatedTag);
1179
+
1180
+ const tool = mockTools.get('ghost_update_tag');
1181
+ await tool.handler({
1182
+ id: '507f1f77bcf86cd799439011',
1183
+ name: 'Updated Tag',
1184
+ description: 'Updated description',
1185
+ meta_title: 'Updated Meta',
1186
+ });
1187
+
1188
+ expect(mockUpdateTag).toHaveBeenCalledWith('507f1f77bcf86cd799439011', {
1189
+ name: 'Updated Tag',
1190
+ description: 'Updated description',
1191
+ meta_title: 'Updated Meta',
373
1192
  });
374
1193
  });
375
1194
 
376
- describe('module exports', () => {
377
- it('should export mcpServer', async () => {
378
- const module = await import('../mcp_server.js');
379
- expect(module.mcpServer).toBeDefined();
1195
+ it('should update tag feature image', async () => {
1196
+ const mockUpdatedTag = {
1197
+ id: '507f1f77bcf86cd799439011',
1198
+ name: 'Test Tag',
1199
+ feature_image: 'https://example.com/image.jpg',
1200
+ };
1201
+ mockUpdateTag.mockResolvedValue(mockUpdatedTag);
1202
+
1203
+ const tool = mockTools.get('ghost_update_tag');
1204
+ await tool.handler({
1205
+ id: '507f1f77bcf86cd799439011',
1206
+ feature_image: 'https://example.com/image.jpg',
380
1207
  });
381
1208
 
382
- it('should export startMCPServer', async () => {
383
- const module = await import('../mcp_server.js');
384
- expect(module.startMCPServer).toBeDefined();
1209
+ expect(mockUpdateTag).toHaveBeenCalledWith('507f1f77bcf86cd799439011', {
1210
+ feature_image: 'https://example.com/image.jpg',
385
1211
  });
386
1212
  });
1213
+
1214
+ it('should return error when id is missing', async () => {
1215
+ const tool = mockTools.get('ghost_update_tag');
1216
+ const result = await tool.handler({ name: 'Test' });
1217
+
1218
+ expect(result.isError).toBe(true);
1219
+ expect(result.content[0].text).toContain('VALIDATION_ERROR');
1220
+ });
1221
+
1222
+ it('should handle validation error', async () => {
1223
+ mockUpdateTag.mockRejectedValue(new Error('Validation failed'));
1224
+
1225
+ const tool = mockTools.get('ghost_update_tag');
1226
+ const result = await tool.handler({ id: '507f1f77bcf86cd799439011', name: '' });
1227
+
1228
+ expect(result.isError).toBe(true);
1229
+ expect(result.content[0].text).toContain('Validation failed');
1230
+ });
1231
+
1232
+ it('should handle not found error', async () => {
1233
+ mockUpdateTag.mockRejectedValue(new Error('Tag not found'));
1234
+
1235
+ const tool = mockTools.get('ghost_update_tag');
1236
+ const result = await tool.handler({ id: '507f1f77bcf86cd799439099', name: 'Test' });
1237
+
1238
+ expect(result.isError).toBe(true);
1239
+ expect(result.content[0].text).toContain('Tag not found');
1240
+ });
1241
+ });
1242
+
1243
+ describe('ghost_delete_tag', () => {
1244
+ beforeEach(() => {
1245
+ vi.clearAllMocks();
1246
+ });
1247
+
1248
+ it('should be registered as a tool', () => {
1249
+ expect(mockTools.has('ghost_delete_tag')).toBe(true);
1250
+ const tool = mockTools.get('ghost_delete_tag');
1251
+ expect(tool.name).toBe('ghost_delete_tag');
1252
+ expect(tool.description).toBeDefined();
1253
+ expect(tool.schema).toBeDefined();
1254
+ expect(tool.handler).toBeDefined();
1255
+ });
1256
+
1257
+ it('should have correct schema with id field', () => {
1258
+ const tool = mockTools.get('ghost_delete_tag');
1259
+ // Zod schemas store field definitions in schema.shape
1260
+ expect(tool.schema.shape.id).toBeDefined();
1261
+ });
1262
+
1263
+ it('should delete tag successfully', async () => {
1264
+ mockDeleteTag.mockResolvedValue({ success: true });
1265
+
1266
+ const tool = mockTools.get('ghost_delete_tag');
1267
+ const result = await tool.handler({ id: '507f1f77bcf86cd799439011' });
1268
+
1269
+ expect(mockDeleteTag).toHaveBeenCalledWith('507f1f77bcf86cd799439011');
1270
+ expect(result.content[0].text).toContain('successfully deleted');
1271
+ expect(result.isError).toBeUndefined();
1272
+ });
1273
+
1274
+ it('should return error when id is missing', async () => {
1275
+ const tool = mockTools.get('ghost_delete_tag');
1276
+ const result = await tool.handler({});
1277
+
1278
+ expect(result.isError).toBe(true);
1279
+ expect(result.content[0].text).toContain('VALIDATION_ERROR');
1280
+ });
1281
+
1282
+ it('should handle not found error', async () => {
1283
+ mockDeleteTag.mockRejectedValue(new Error('Tag not found'));
1284
+
1285
+ const tool = mockTools.get('ghost_delete_tag');
1286
+ const result = await tool.handler({ id: '507f1f77bcf86cd799439099' });
1287
+
1288
+ expect(result.isError).toBe(true);
1289
+ expect(result.content[0].text).toContain('Tag not found');
1290
+ });
1291
+
1292
+ it('should handle deletion error', async () => {
1293
+ mockDeleteTag.mockRejectedValue(new Error('Failed to delete tag'));
1294
+
1295
+ const tool = mockTools.get('ghost_delete_tag');
1296
+ const result = await tool.handler({ id: '507f1f77bcf86cd799439011' });
1297
+
1298
+ expect(result.isError).toBe(true);
1299
+ expect(result.content[0].text).toContain('Failed to delete tag');
1300
+ });
387
1301
  });