@jgardner04/ghost-mcp-server 1.11.0 → 1.12.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.
@@ -143,12 +143,11 @@ describe('mcp_server_improved - ghost_get_pages tool', () => {
143
143
  expect(tool).toBeDefined();
144
144
  expect(tool.description).toContain('pages');
145
145
  expect(tool.schema).toBeDefined();
146
- expect(tool.schema.limit).toBeDefined();
147
- expect(tool.schema.page).toBeDefined();
148
- expect(tool.schema.status).toBeDefined();
149
- expect(tool.schema.include).toBeDefined();
150
- expect(tool.schema.filter).toBeDefined();
151
- expect(tool.schema.order).toBeDefined();
146
+ expect(tool.schema.shape.limit).toBeDefined();
147
+ expect(tool.schema.shape.page).toBeDefined();
148
+ expect(tool.schema.shape.include).toBeDefined();
149
+ expect(tool.schema.shape.filter).toBeDefined();
150
+ expect(tool.schema.shape.order).toBeDefined();
152
151
  });
153
152
 
154
153
  it('should retrieve pages with default options', async () => {
@@ -180,20 +179,20 @@ describe('mcp_server_improved - ghost_get_pages tool', () => {
180
179
  const tool = mockTools.get('ghost_get_pages');
181
180
  const schema = tool.schema;
182
181
 
183
- expect(schema.limit).toBeDefined();
184
- expect(() => schema.limit.parse(0)).toThrow();
185
- expect(() => schema.limit.parse(101)).toThrow();
186
- expect(schema.limit.parse(50)).toBe(50);
182
+ expect(schema.shape.limit).toBeDefined();
183
+ expect(() => schema.shape.limit.parse(0)).toThrow();
184
+ expect(() => schema.shape.limit.parse(101)).toThrow();
185
+ expect(schema.shape.limit.parse(50)).toBe(50);
187
186
  });
188
187
 
189
- it('should pass status filter', async () => {
188
+ it('should pass filter parameter', async () => {
190
189
  const mockPages = [{ id: '1', title: 'Published Page', status: 'published' }];
191
190
  mockGetPages.mockResolvedValue(mockPages);
192
191
 
193
192
  const tool = mockTools.get('ghost_get_pages');
194
- await tool.handler({ status: 'published' });
193
+ await tool.handler({ filter: 'status:published' });
195
194
 
196
- expect(mockGetPages).toHaveBeenCalledWith({ status: 'published' });
195
+ expect(mockGetPages).toHaveBeenCalledWith({ filter: 'status:published' });
197
196
  });
198
197
 
199
198
  it('should handle errors gracefully', async () => {
@@ -222,24 +221,26 @@ describe('mcp_server_improved - ghost_get_page tool', () => {
222
221
  it('should have correct schema with id and slug options', () => {
223
222
  const tool = mockTools.get('ghost_get_page');
224
223
  expect(tool).toBeDefined();
225
- expect(tool.schema.id).toBeDefined();
226
- expect(tool.schema.slug).toBeDefined();
227
- expect(tool.schema.include).toBeDefined();
224
+ // ghost_get_page uses a refined schema, access via _def.schema.shape
225
+ const shape = tool.schema._def.schema.shape;
226
+ expect(shape.id).toBeDefined();
227
+ expect(shape.slug).toBeDefined();
228
+ expect(shape.include).toBeDefined();
228
229
  });
229
230
 
230
231
  it('should retrieve page by ID', async () => {
231
- const mockPage = { id: 'page-123', title: 'About Us', slug: 'about-us' };
232
+ const mockPage = { id: '507f1f77bcf86cd799439011', title: 'About Us', slug: 'about-us' };
232
233
  mockGetPage.mockResolvedValue(mockPage);
233
234
 
234
235
  const tool = mockTools.get('ghost_get_page');
235
- const result = await tool.handler({ id: 'page-123' });
236
+ const result = await tool.handler({ id: '507f1f77bcf86cd799439011' });
236
237
 
237
- expect(mockGetPage).toHaveBeenCalledWith('page-123', {});
238
+ expect(mockGetPage).toHaveBeenCalledWith('507f1f77bcf86cd799439011', {});
238
239
  expect(result.content[0].text).toContain('About Us');
239
240
  });
240
241
 
241
242
  it('should retrieve page by slug', async () => {
242
- const mockPage = { id: 'page-123', title: 'About Us', slug: 'about-us' };
243
+ const mockPage = { id: '507f1f77bcf86cd799439011', title: 'About Us', slug: 'about-us' };
243
244
  mockGetPage.mockResolvedValue(mockPage);
244
245
 
245
246
  const tool = mockTools.get('ghost_get_page');
@@ -249,19 +250,20 @@ describe('mcp_server_improved - ghost_get_page tool', () => {
249
250
  expect(result.content[0].text).toContain('About Us');
250
251
  });
251
252
 
252
- it('should require either id or slug', async () => {
253
+ it('should require either id or slug', () => {
253
254
  const tool = mockTools.get('ghost_get_page');
254
- const result = await tool.handler({});
255
-
256
- expect(result.isError).toBe(true);
257
- expect(result.content[0].text).toContain('Either id or slug is required');
255
+ // Test schema validation - the refine check requires id or slug
256
+ expect(() => tool.schema.parse({})).toThrow();
257
+ // Valid inputs should parse successfully (Ghost IDs are 24 hex chars)
258
+ expect(() => tool.schema.parse({ id: '507f1f77bcf86cd799439011' })).not.toThrow();
259
+ expect(() => tool.schema.parse({ slug: 'about-us' })).not.toThrow();
258
260
  });
259
261
 
260
262
  it('should handle errors gracefully', async () => {
261
263
  mockGetPage.mockRejectedValue(new Error('Page not found'));
262
264
 
263
265
  const tool = mockTools.get('ghost_get_page');
264
- const result = await tool.handler({ id: 'nonexistent' });
266
+ const result = await tool.handler({ id: '507f1f77bcf86cd799439099' });
265
267
 
266
268
  expect(result.isError).toBe(true);
267
269
  expect(result.content[0].text).toContain('Error retrieving page');
@@ -283,28 +285,29 @@ describe('mcp_server_improved - ghost_create_page tool', () => {
283
285
  it('should have correct schema with required and optional fields', () => {
284
286
  const tool = mockTools.get('ghost_create_page');
285
287
  expect(tool).toBeDefined();
286
- expect(tool.description).toContain('NOT support tags');
287
- expect(tool.schema.title).toBeDefined();
288
- expect(tool.schema.html).toBeDefined();
289
- expect(tool.schema.status).toBeDefined();
290
- expect(tool.schema.feature_image).toBeDefined();
291
- expect(tool.schema.meta_title).toBeDefined();
292
- expect(tool.schema.meta_description).toBeDefined();
293
- // Should NOT have tags
294
- expect(tool.schema.tags).toBeUndefined();
288
+ expect(tool.description).toContain('page');
289
+ expect(tool.schema.shape.title).toBeDefined();
290
+ expect(tool.schema.shape.html).toBeDefined();
291
+ expect(tool.schema.shape.status).toBeDefined();
292
+ expect(tool.schema.shape.feature_image).toBeDefined();
293
+ expect(tool.schema.shape.meta_title).toBeDefined();
294
+ expect(tool.schema.shape.meta_description).toBeDefined();
295
295
  });
296
296
 
297
297
  it('should create page with minimal input', async () => {
298
- const createdPage = { id: 'page-123', title: 'New Page', status: 'draft' };
298
+ const createdPage = { id: '507f1f77bcf86cd799439011', title: 'New Page', status: 'draft' };
299
299
  mockCreatePageService.mockResolvedValue(createdPage);
300
300
 
301
301
  const tool = mockTools.get('ghost_create_page');
302
302
  const result = await tool.handler({ title: 'New Page', html: '<p>Content</p>' });
303
303
 
304
- expect(mockCreatePageService).toHaveBeenCalledWith({
305
- title: 'New Page',
306
- html: '<p>Content</p>',
307
- });
304
+ // Schema adds default values, so use objectContaining for the key fields
305
+ expect(mockCreatePageService).toHaveBeenCalledWith(
306
+ expect.objectContaining({
307
+ title: 'New Page',
308
+ html: '<p>Content</p>',
309
+ })
310
+ );
308
311
  expect(result.content[0].text).toContain('New Page');
309
312
  });
310
313
 
@@ -320,13 +323,14 @@ describe('mcp_server_improved - ghost_create_page tool', () => {
320
323
  meta_title: 'SEO Title',
321
324
  meta_description: 'SEO Description',
322
325
  };
323
- const createdPage = { id: 'page-123', ...fullInput };
326
+ const createdPage = { id: '507f1f77bcf86cd799439011', ...fullInput };
324
327
  mockCreatePageService.mockResolvedValue(createdPage);
325
328
 
326
329
  const tool = mockTools.get('ghost_create_page');
327
330
  const result = await tool.handler(fullInput);
328
331
 
329
- expect(mockCreatePageService).toHaveBeenCalledWith(fullInput);
332
+ // Schema adds default values, so use objectContaining for the key fields
333
+ expect(mockCreatePageService).toHaveBeenCalledWith(expect.objectContaining(fullInput));
330
334
  expect(result.content[0].text).toContain('Complete Page');
331
335
  });
332
336
 
@@ -356,39 +360,39 @@ describe('mcp_server_improved - ghost_update_page tool', () => {
356
360
  it('should have correct schema with id required and other fields optional', () => {
357
361
  const tool = mockTools.get('ghost_update_page');
358
362
  expect(tool).toBeDefined();
359
- expect(tool.description).toContain('NOT support tags');
360
- expect(tool.schema.id).toBeDefined();
361
- expect(tool.schema.title).toBeDefined();
362
- expect(tool.schema.html).toBeDefined();
363
- expect(tool.schema.status).toBeDefined();
364
- // Should NOT have tags
365
- expect(tool.schema.tags).toBeUndefined();
363
+ expect(tool.description).toContain('page');
364
+ expect(tool.schema.shape.id).toBeDefined();
365
+ expect(tool.schema.shape.title).toBeDefined();
366
+ expect(tool.schema.shape.html).toBeDefined();
367
+ expect(tool.schema.shape.status).toBeDefined();
366
368
  });
367
369
 
368
370
  it('should update page with new title', async () => {
369
- const updatedPage = { id: 'page-123', title: 'Updated Title' };
371
+ const updatedPage = { id: '507f1f77bcf86cd799439011', title: 'Updated Title' };
370
372
  mockUpdatePage.mockResolvedValue(updatedPage);
371
373
 
372
374
  const tool = mockTools.get('ghost_update_page');
373
- const result = await tool.handler({ id: 'page-123', title: 'Updated Title' });
375
+ const result = await tool.handler({ id: '507f1f77bcf86cd799439011', title: 'Updated Title' });
374
376
 
375
- expect(mockUpdatePage).toHaveBeenCalledWith('page-123', { title: 'Updated Title' });
377
+ expect(mockUpdatePage).toHaveBeenCalledWith('507f1f77bcf86cd799439011', {
378
+ title: 'Updated Title',
379
+ });
376
380
  expect(result.content[0].text).toContain('Updated Title');
377
381
  });
378
382
 
379
383
  it('should update page with multiple fields', async () => {
380
- const updatedPage = { id: 'page-123', title: 'New Title', status: 'published' };
384
+ const updatedPage = { id: '507f1f77bcf86cd799439011', title: 'New Title', status: 'published' };
381
385
  mockUpdatePage.mockResolvedValue(updatedPage);
382
386
 
383
387
  const tool = mockTools.get('ghost_update_page');
384
388
  const result = await tool.handler({
385
- id: 'page-123',
389
+ id: '507f1f77bcf86cd799439011',
386
390
  title: 'New Title',
387
391
  status: 'published',
388
392
  html: '<p>Updated content</p>',
389
393
  });
390
394
 
391
- expect(mockUpdatePage).toHaveBeenCalledWith('page-123', {
395
+ expect(mockUpdatePage).toHaveBeenCalledWith('507f1f77bcf86cd799439011', {
392
396
  title: 'New Title',
393
397
  status: 'published',
394
398
  html: '<p>Updated content</p>',
@@ -400,7 +404,7 @@ describe('mcp_server_improved - ghost_update_page tool', () => {
400
404
  mockUpdatePage.mockRejectedValue(new Error('Page not found'));
401
405
 
402
406
  const tool = mockTools.get('ghost_update_page');
403
- const result = await tool.handler({ id: 'nonexistent', title: 'Test' });
407
+ const result = await tool.handler({ id: '507f1f77bcf86cd799439099', title: 'Test' });
404
408
 
405
409
  expect(result.isError).toBe(true);
406
410
  expect(result.content[0].text).toContain('Error updating page');
@@ -422,17 +426,17 @@ describe('mcp_server_improved - ghost_delete_page tool', () => {
422
426
  it('should have correct schema with id required', () => {
423
427
  const tool = mockTools.get('ghost_delete_page');
424
428
  expect(tool).toBeDefined();
425
- expect(tool.schema.id).toBeDefined();
429
+ expect(tool.schema.shape.id).toBeDefined();
426
430
  expect(tool.description).toContain('permanent');
427
431
  });
428
432
 
429
433
  it('should delete page by ID', async () => {
430
- mockDeletePage.mockResolvedValue({ id: 'page-123' });
434
+ mockDeletePage.mockResolvedValue({ id: '507f1f77bcf86cd799439011' });
431
435
 
432
436
  const tool = mockTools.get('ghost_delete_page');
433
- const result = await tool.handler({ id: 'page-123' });
437
+ const result = await tool.handler({ id: '507f1f77bcf86cd799439011' });
434
438
 
435
- expect(mockDeletePage).toHaveBeenCalledWith('page-123');
439
+ expect(mockDeletePage).toHaveBeenCalledWith('507f1f77bcf86cd799439011');
436
440
  expect(result.content[0].text).toContain('successfully deleted');
437
441
  });
438
442
 
@@ -440,7 +444,7 @@ describe('mcp_server_improved - ghost_delete_page tool', () => {
440
444
  mockDeletePage.mockRejectedValue(new Error('Page not found'));
441
445
 
442
446
  const tool = mockTools.get('ghost_delete_page');
443
- const result = await tool.handler({ id: 'nonexistent' });
447
+ const result = await tool.handler({ id: '507f1f77bcf86cd799439099' });
444
448
 
445
449
  expect(result.isError).toBe(true);
446
450
  expect(result.content[0].text).toContain('Error deleting page');
@@ -462,9 +466,9 @@ describe('mcp_server_improved - ghost_search_pages tool', () => {
462
466
  it('should have correct schema with query required', () => {
463
467
  const tool = mockTools.get('ghost_search_pages');
464
468
  expect(tool).toBeDefined();
465
- expect(tool.schema.query).toBeDefined();
466
- expect(tool.schema.status).toBeDefined();
467
- expect(tool.schema.limit).toBeDefined();
469
+ expect(tool.schema.shape.query).toBeDefined();
470
+ expect(tool.schema.shape.status).toBeDefined();
471
+ expect(tool.schema.shape.limit).toBeDefined();
468
472
  });
469
473
 
470
474
  it('should search pages with query', async () => {
@@ -502,10 +506,10 @@ describe('mcp_server_improved - ghost_search_pages tool', () => {
502
506
  const tool = mockTools.get('ghost_search_pages');
503
507
  const schema = tool.schema;
504
508
 
505
- expect(schema.limit).toBeDefined();
506
- expect(() => schema.limit.parse(0)).toThrow();
507
- expect(() => schema.limit.parse(51)).toThrow();
508
- expect(schema.limit.parse(25)).toBe(25);
509
+ expect(schema.shape.limit).toBeDefined();
510
+ expect(() => schema.shape.limit.parse(0)).toThrow();
511
+ expect(() => schema.shape.limit.parse(51)).toThrow();
512
+ expect(schema.shape.limit.parse(25)).toBe(25);
509
513
  });
510
514
 
511
515
  it('should handle errors gracefully', async () => {
@@ -122,6 +122,76 @@ describe('Error Handling System', () => {
122
122
  type: 'number.base',
123
123
  });
124
124
  });
125
+
126
+ it('should create validation error from Zod error', () => {
127
+ const zodError = {
128
+ errors: [
129
+ {
130
+ path: ['user', 'email'],
131
+ message: 'Invalid email',
132
+ code: 'invalid_string',
133
+ },
134
+ {
135
+ path: ['age'],
136
+ message: 'Expected number, received string',
137
+ code: 'invalid_type',
138
+ },
139
+ ],
140
+ };
141
+
142
+ const error = ValidationError.fromZod(zodError);
143
+
144
+ expect(error.message).toBe('Validation failed');
145
+ expect(error.errors).toHaveLength(2);
146
+ expect(error.errors[0]).toEqual({
147
+ field: 'user.email',
148
+ message: 'Invalid email',
149
+ type: 'invalid_string',
150
+ });
151
+ expect(error.errors[1]).toEqual({
152
+ field: 'age',
153
+ message: 'Expected number, received string',
154
+ type: 'invalid_type',
155
+ });
156
+ });
157
+
158
+ it('should create validation error from Zod error with context', () => {
159
+ const zodError = {
160
+ errors: [
161
+ {
162
+ path: ['name'],
163
+ message: 'String must contain at least 1 character(s)',
164
+ code: 'too_small',
165
+ },
166
+ ],
167
+ };
168
+
169
+ const error = ValidationError.fromZod(zodError, 'Tag creation');
170
+
171
+ expect(error.message).toBe('Tag creation: Validation failed');
172
+ expect(error.errors).toHaveLength(1);
173
+ expect(error.errors[0]).toEqual({
174
+ field: 'name',
175
+ message: 'String must contain at least 1 character(s)',
176
+ type: 'too_small',
177
+ });
178
+ });
179
+
180
+ it('should create validation error from Zod error with empty path', () => {
181
+ const zodError = {
182
+ errors: [
183
+ {
184
+ path: [],
185
+ message: 'Invalid input',
186
+ code: 'custom',
187
+ },
188
+ ],
189
+ };
190
+
191
+ const error = ValidationError.fromZod(zodError);
192
+
193
+ expect(error.errors[0].field).toBe('');
194
+ });
125
195
  });
126
196
 
127
197
  describe('AuthenticationError', () => {
@@ -48,6 +48,16 @@ export class ValidationError extends BaseError {
48
48
  }));
49
49
  return new ValidationError('Validation failed', errors);
50
50
  }
51
+
52
+ static fromZod(zodError, context = '') {
53
+ const errors = zodError.errors.map((err) => ({
54
+ field: err.path.join('.'),
55
+ message: err.message,
56
+ type: err.code,
57
+ }));
58
+ const message = context ? `${context}: Validation failed` : 'Validation failed';
59
+ return new ValidationError(message, errors);
60
+ }
51
61
  }
52
62
 
53
63
  /**
package/src/mcp_server.js CHANGED
@@ -14,6 +14,7 @@ import os from 'os';
14
14
  import { v4 as uuidv4 } from 'uuid';
15
15
  import { validateImageUrl, createSecureAxiosConfig } from './utils/urlValidator.js';
16
16
  import { createContextLogger } from './utils/logger.js';
17
+ import { trackTempFile, cleanupTempFiles } from './utils/tempFileManager.js';
17
18
 
18
19
  // Load environment variables (might be redundant if loaded elsewhere, but safe)
19
20
  dotenv.config();
@@ -300,11 +301,17 @@ const uploadImageTool = new Tool({
300
301
  writer.on('finish', resolve);
301
302
  writer.on('error', reject);
302
303
  });
304
+ // Track temp file for cleanup on process exit
305
+ trackTempFile(downloadedPath);
303
306
  logger.fileOperation('download', downloadedPath);
304
307
 
305
308
  // --- 3. Process the image (Optional) ---
306
309
  // Using the service from subtask 4.2
307
310
  processedPath = await processImage(downloadedPath, tempDir);
311
+ // Track processed file for cleanup on process exit
312
+ if (processedPath !== downloadedPath) {
313
+ trackTempFile(processedPath);
314
+ }
308
315
  logger.fileOperation('process', processedPath);
309
316
 
310
317
  // --- 4. Determine Alt Text ---
@@ -327,25 +334,8 @@ const uploadImageTool = new Tool({
327
334
  // Add more specific error handling (download failed, processing failed, upload failed)
328
335
  throw new Error(`Failed to upload image from URL ${imageUrl}: ${error.message}`);
329
336
  } finally {
330
- // --- 7. Cleanup temporary files ---
331
- if (downloadedPath) {
332
- fs.unlink(downloadedPath, (err) => {
333
- if (err)
334
- logger.warn('Failed to delete temporary downloaded file', {
335
- file: path.basename(downloadedPath),
336
- error: err.message,
337
- });
338
- });
339
- }
340
- if (processedPath && processedPath !== downloadedPath) {
341
- fs.unlink(processedPath, (err) => {
342
- if (err)
343
- logger.warn('Failed to delete temporary processed file', {
344
- file: path.basename(processedPath),
345
- error: err.message,
346
- });
347
- });
348
- }
337
+ // --- 7. Cleanup temporary files with proper async/await ---
338
+ await cleanupTempFiles([downloadedPath, processedPath], logger);
349
339
  }
350
340
  },
351
341
  });