@jgardner04/ghost-mcp-server 1.4.0 → 1.5.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/README.md CHANGED
@@ -1,10 +1,15 @@
1
1
  # Ghost MCP Server
2
2
 
3
+ [![npm version](https://badge.fury.io/js/%40jgardner04%2Fghost-mcp-server.svg)](https://badge.fury.io/js/%40jgardner04%2Fghost-mcp-server)
4
+ [![CI](https://github.com/jgardner04/Ghost-MCP-Server/actions/workflows/ci.yml/badge.svg)](https://github.com/jgardner04/Ghost-MCP-Server/actions)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
+ [![Coverage](https://img.shields.io/badge/coverage-90%25-brightgreen.svg)](https://github.com/jgardner04/Ghost-MCP-Server)
7
+
3
8
  This project (`ghost-mcp-server`) implements a **Model Context Protocol (MCP) Server** that allows an MCP client (like Cursor or Claude Desktop) to interact with a Ghost CMS instance via defined tools.
4
9
 
5
10
  ## Requirements
6
11
 
7
- - Node.js 14.0.0 or higher
12
+ - Node.js 18.0.0 or higher
8
13
  - Ghost Admin API URL and Key
9
14
 
10
15
  ## Ghost MCP Server Details
@@ -62,13 +67,70 @@ Below is a guide for using the available MCP tools:
62
67
  - `meta_description` (string, optional): Custom SEO description.
63
68
  - **Output**: The created `ghost/post` resource.
64
69
 
65
- ## Installation and Running
70
+ ## Installation
71
+
72
+ ### NPM Installation (Recommended)
73
+
74
+ Install globally using npm:
75
+
76
+ ```bash
77
+ npm install -g @jgardner04/ghost-mcp-server
78
+ ```
79
+
80
+ Or use npx to run without installing:
81
+
82
+ ```bash
83
+ npx @jgardner04/ghost-mcp-server
84
+ ```
85
+
86
+ ### Available Commands
87
+
88
+ After installation, the following CLI commands are available:
89
+
90
+ - **`ghost-mcp-server`**: Starts the Express REST API server and MCP server (default)
91
+ - **`ghost-mcp`**: Starts the improved MCP server with transport configuration support
92
+
93
+ ### Configuration
94
+
95
+ Before running the server, configure your Ghost Admin API credentials:
96
+
97
+ 1. Create a `.env` file in your working directory:
98
+
99
+ ```dotenv
100
+ # Required:
101
+ GHOST_ADMIN_API_URL=https://your-ghost-site.com
102
+ GHOST_ADMIN_API_KEY=your_admin_api_key
103
+ ```
104
+
105
+ 2. Find your Ghost Admin API URL and Key in your Ghost Admin settings under Integrations -> Custom Integrations.
106
+
107
+ ### Running the Server
108
+
109
+ After installation and configuration:
110
+
111
+ ```bash
112
+ # Using the global installation
113
+ ghost-mcp-server
114
+
115
+ # Or using npx
116
+ npx @jgardner04/ghost-mcp-server
117
+
118
+ # Run the MCP server with transport options
119
+ ghost-mcp
120
+
121
+ # Or with specific transport
122
+ MCP_TRANSPORT=stdio ghost-mcp
123
+ ```
124
+
125
+ ## Development Setup
126
+
127
+ For contributors or advanced users who want to modify the source code:
66
128
 
67
129
  1. **Clone the Repository**:
68
130
 
69
131
  ```bash
70
- git clone <repository_url>
71
- cd ghost-mcp-server
132
+ git clone https://github.com/jgardner04/Ghost-MCP-Server.git
133
+ cd Ghost-MCP-Server
72
134
  ```
73
135
 
74
136
  2. **Install Dependencies**:
@@ -78,20 +140,9 @@ Below is a guide for using the available MCP tools:
78
140
  ```
79
141
 
80
142
  3. **Configure Environment Variables**:
81
- Create a `.env` file in the project root and add your Ghost Admin API credentials:
82
-
83
- ```dotenv
84
- # Required:
85
- GHOST_ADMIN_API_URL=https://your-ghost-site.com
86
- GHOST_ADMIN_API_KEY=your_admin_api_key
87
-
88
- # If using 1Password CLI for secrets:
89
- # You might store the API key in 1Password and use `op run --env-file=.env -- ...`
90
- ```
91
-
92
- - Find your Ghost Admin API URL and Key in your Ghost Admin settings under Integrations -> Custom Integrations.
143
+ Create a `.env` file in the project root (see Configuration section above).
93
144
 
94
- 4. **Run the Server**:
145
+ 4. **Run from Source**:
95
146
 
96
147
  ```bash
97
148
  npm start
@@ -99,8 +150,6 @@ Below is a guide for using the available MCP tools:
99
150
  # node src/index.js
100
151
  ```
101
152
 
102
- This command will start the MCP server.
103
-
104
153
  5. **Development Mode (using nodemon)**:
105
154
  For development with automatic restarting:
106
155
  ```bash
@@ -112,4 +161,5 @@ Below is a guide for using the available MCP tools:
112
161
  - **401 Unauthorized Error from Ghost:** Check that your `GHOST_ADMIN_API_URL` and `GHOST_ADMIN_API_KEY` in the `.env` file are correct and that the Custom Integration in Ghost is enabled.
113
162
  - **MCP Server Connection Issues:** Ensure the MCP server is running (check console logs). Verify the port (`MCP_PORT`, default 3001) is not blocked by a firewall. Check that the client is connecting to the correct address and port.
114
163
  - **Tool Execution Errors:** Check the server console logs for detailed error messages from the specific tool implementation (e.g., `ghost_create_post`, `ghost_upload_image`). Common issues include invalid input (check against tool schemas in `src/mcp_server.js` and the README guide), problems downloading from `imageUrl`, image processing failures, or upstream errors from the Ghost API.
115
- - **Dependency Installation Issues:** Ensure you have a compatible Node.js version installed (see Requirements section). Try removing `node_modules` and `package-lock.json` and running `npm install` again.
164
+ - **Command Not Found:** If `ghost-mcp-server` or `ghost-mcp` commands are not found after global installation, ensure npm's global bin directory is in your PATH. You can find it with `npm bin -g`.
165
+ - **Dependency Installation Issues:** Ensure you have a compatible Node.js version installed (Node.js 18.0.0 or higher - see Requirements section). For global installation issues, try `npm install -g @jgardner04/ghost-mcp-server --force`. For development setup, try removing `node_modules` and `package-lock.json` and running `npm install` again.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgardner04/ghost-mcp-server",
3
- "version": "1.4.0",
3
+ "version": "1.5.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",
@@ -0,0 +1,520 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ // Mock the McpServer to capture tool registrations
4
+ const mockTools = new Map();
5
+
6
+ vi.mock('@modelcontextprotocol/sdk/server/mcp.js', () => {
7
+ return {
8
+ McpServer: class MockMcpServer {
9
+ constructor(config) {
10
+ this.config = config;
11
+ }
12
+
13
+ tool(name, description, schema, handler) {
14
+ mockTools.set(name, { name, description, schema, handler });
15
+ }
16
+
17
+ connect(_transport) {
18
+ return Promise.resolve();
19
+ }
20
+ },
21
+ };
22
+ });
23
+
24
+ vi.mock('@modelcontextprotocol/sdk/server/stdio.js', () => {
25
+ return {
26
+ StdioServerTransport: class MockStdioServerTransport {},
27
+ };
28
+ });
29
+
30
+ // Mock dotenv
31
+ vi.mock('dotenv', () => ({
32
+ default: { config: vi.fn() },
33
+ }));
34
+
35
+ // Mock crypto
36
+ vi.mock('crypto', () => ({
37
+ default: { randomUUID: vi.fn().mockReturnValue('test-uuid-1234') },
38
+ }));
39
+
40
+ // Mock services for pages
41
+ const mockGetPages = vi.fn();
42
+ const mockGetPage = vi.fn();
43
+ const mockUpdatePage = vi.fn();
44
+ const mockDeletePage = vi.fn();
45
+ const mockSearchPages = vi.fn();
46
+ const mockCreatePageService = vi.fn();
47
+
48
+ // Also mock post and tag services to avoid errors
49
+ const mockGetPosts = vi.fn();
50
+ const mockGetPost = vi.fn();
51
+ const mockGetTags = vi.fn();
52
+ const mockCreateTag = vi.fn();
53
+ const mockUploadImage = vi.fn();
54
+ const mockCreatePostService = vi.fn();
55
+ const mockUpdatePost = vi.fn();
56
+ const mockDeletePost = vi.fn();
57
+ const mockSearchPosts = vi.fn();
58
+ const mockProcessImage = vi.fn();
59
+ const mockValidateImageUrl = vi.fn();
60
+ const mockCreateSecureAxiosConfig = vi.fn();
61
+
62
+ vi.mock('../services/ghostService.js', () => ({
63
+ getPosts: (...args) => mockGetPosts(...args),
64
+ getPost: (...args) => mockGetPost(...args),
65
+ getTags: (...args) => mockGetTags(...args),
66
+ createTag: (...args) => mockCreateTag(...args),
67
+ uploadImage: (...args) => mockUploadImage(...args),
68
+ }));
69
+
70
+ vi.mock('../services/postService.js', () => ({
71
+ createPostService: (...args) => mockCreatePostService(...args),
72
+ }));
73
+
74
+ vi.mock('../services/pageService.js', () => ({
75
+ createPageService: (...args) => mockCreatePageService(...args),
76
+ }));
77
+
78
+ vi.mock('../services/ghostServiceImproved.js', () => ({
79
+ updatePost: (...args) => mockUpdatePost(...args),
80
+ deletePost: (...args) => mockDeletePost(...args),
81
+ searchPosts: (...args) => mockSearchPosts(...args),
82
+ getPages: (...args) => mockGetPages(...args),
83
+ getPage: (...args) => mockGetPage(...args),
84
+ updatePage: (...args) => mockUpdatePage(...args),
85
+ deletePage: (...args) => mockDeletePage(...args),
86
+ searchPages: (...args) => mockSearchPages(...args),
87
+ }));
88
+
89
+ vi.mock('../services/imageProcessingService.js', () => ({
90
+ processImage: (...args) => mockProcessImage(...args),
91
+ }));
92
+
93
+ vi.mock('../utils/urlValidator.js', () => ({
94
+ validateImageUrl: (...args) => mockValidateImageUrl(...args),
95
+ createSecureAxiosConfig: (...args) => mockCreateSecureAxiosConfig(...args),
96
+ }));
97
+
98
+ // Mock axios
99
+ const mockAxios = vi.fn();
100
+ vi.mock('axios', () => ({
101
+ default: (...args) => mockAxios(...args),
102
+ }));
103
+
104
+ // Mock fs
105
+ const mockUnlink = vi.fn((path, cb) => cb(null));
106
+ const mockCreateWriteStream = vi.fn();
107
+ vi.mock('fs', () => ({
108
+ default: {
109
+ unlink: (...args) => mockUnlink(...args),
110
+ createWriteStream: (...args) => mockCreateWriteStream(...args),
111
+ },
112
+ }));
113
+
114
+ // Mock os
115
+ vi.mock('os', () => ({
116
+ default: { tmpdir: vi.fn().mockReturnValue('/tmp') },
117
+ }));
118
+
119
+ // Mock path
120
+ vi.mock('path', async () => {
121
+ const actual = await vi.importActual('path');
122
+ return {
123
+ default: actual,
124
+ ...actual,
125
+ };
126
+ });
127
+
128
+ describe('mcp_server_improved - ghost_get_pages tool', () => {
129
+ beforeEach(async () => {
130
+ vi.clearAllMocks();
131
+ // Import the module to register tools (only first time)
132
+ if (mockTools.size === 0) {
133
+ await import('../mcp_server_improved.js');
134
+ }
135
+ });
136
+
137
+ it('should register ghost_get_pages tool', () => {
138
+ expect(mockTools.has('ghost_get_pages')).toBe(true);
139
+ });
140
+
141
+ it('should have correct schema with all optional parameters', () => {
142
+ const tool = mockTools.get('ghost_get_pages');
143
+ expect(tool).toBeDefined();
144
+ expect(tool.description).toContain('pages');
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();
152
+ });
153
+
154
+ it('should retrieve pages with default options', async () => {
155
+ const mockPages = [
156
+ { id: '1', title: 'About Us', slug: 'about-us', status: 'published' },
157
+ { id: '2', title: 'Contact', slug: 'contact', status: 'draft' },
158
+ ];
159
+ mockGetPages.mockResolvedValue(mockPages);
160
+
161
+ const tool = mockTools.get('ghost_get_pages');
162
+ const result = await tool.handler({});
163
+
164
+ expect(mockGetPages).toHaveBeenCalledWith({});
165
+ expect(result.content[0].text).toContain('About Us');
166
+ expect(result.content[0].text).toContain('Contact');
167
+ });
168
+
169
+ it('should pass limit and page parameters', async () => {
170
+ const mockPages = [{ id: '1', title: 'Page 1', slug: 'page-1' }];
171
+ mockGetPages.mockResolvedValue(mockPages);
172
+
173
+ const tool = mockTools.get('ghost_get_pages');
174
+ await tool.handler({ limit: 10, page: 2 });
175
+
176
+ expect(mockGetPages).toHaveBeenCalledWith({ limit: 10, page: 2 });
177
+ });
178
+
179
+ it('should validate limit is between 1 and 100', () => {
180
+ const tool = mockTools.get('ghost_get_pages');
181
+ const schema = tool.schema;
182
+
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);
187
+ });
188
+
189
+ it('should pass status filter', async () => {
190
+ const mockPages = [{ id: '1', title: 'Published Page', status: 'published' }];
191
+ mockGetPages.mockResolvedValue(mockPages);
192
+
193
+ const tool = mockTools.get('ghost_get_pages');
194
+ await tool.handler({ status: 'published' });
195
+
196
+ expect(mockGetPages).toHaveBeenCalledWith({ status: 'published' });
197
+ });
198
+
199
+ it('should handle errors gracefully', async () => {
200
+ mockGetPages.mockRejectedValue(new Error('API error'));
201
+
202
+ const tool = mockTools.get('ghost_get_pages');
203
+ const result = await tool.handler({});
204
+
205
+ expect(result.isError).toBe(true);
206
+ expect(result.content[0].text).toContain('Error retrieving pages');
207
+ });
208
+ });
209
+
210
+ describe('mcp_server_improved - ghost_get_page tool', () => {
211
+ beforeEach(async () => {
212
+ vi.clearAllMocks();
213
+ if (mockTools.size === 0) {
214
+ await import('../mcp_server_improved.js');
215
+ }
216
+ });
217
+
218
+ it('should register ghost_get_page tool', () => {
219
+ expect(mockTools.has('ghost_get_page')).toBe(true);
220
+ });
221
+
222
+ it('should have correct schema with id and slug options', () => {
223
+ const tool = mockTools.get('ghost_get_page');
224
+ expect(tool).toBeDefined();
225
+ expect(tool.schema.id).toBeDefined();
226
+ expect(tool.schema.slug).toBeDefined();
227
+ expect(tool.schema.include).toBeDefined();
228
+ });
229
+
230
+ it('should retrieve page by ID', async () => {
231
+ const mockPage = { id: 'page-123', title: 'About Us', slug: 'about-us' };
232
+ mockGetPage.mockResolvedValue(mockPage);
233
+
234
+ const tool = mockTools.get('ghost_get_page');
235
+ const result = await tool.handler({ id: 'page-123' });
236
+
237
+ expect(mockGetPage).toHaveBeenCalledWith('page-123', {});
238
+ expect(result.content[0].text).toContain('About Us');
239
+ });
240
+
241
+ it('should retrieve page by slug', async () => {
242
+ const mockPage = { id: 'page-123', title: 'About Us', slug: 'about-us' };
243
+ mockGetPage.mockResolvedValue(mockPage);
244
+
245
+ const tool = mockTools.get('ghost_get_page');
246
+ const result = await tool.handler({ slug: 'about-us' });
247
+
248
+ expect(mockGetPage).toHaveBeenCalledWith('slug/about-us', {});
249
+ expect(result.content[0].text).toContain('About Us');
250
+ });
251
+
252
+ it('should require either id or slug', async () => {
253
+ 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');
258
+ });
259
+
260
+ it('should handle errors gracefully', async () => {
261
+ mockGetPage.mockRejectedValue(new Error('Page not found'));
262
+
263
+ const tool = mockTools.get('ghost_get_page');
264
+ const result = await tool.handler({ id: 'nonexistent' });
265
+
266
+ expect(result.isError).toBe(true);
267
+ expect(result.content[0].text).toContain('Error retrieving page');
268
+ });
269
+ });
270
+
271
+ describe('mcp_server_improved - ghost_create_page tool', () => {
272
+ beforeEach(async () => {
273
+ vi.clearAllMocks();
274
+ if (mockTools.size === 0) {
275
+ await import('../mcp_server_improved.js');
276
+ }
277
+ });
278
+
279
+ it('should register ghost_create_page tool', () => {
280
+ expect(mockTools.has('ghost_create_page')).toBe(true);
281
+ });
282
+
283
+ it('should have correct schema with required and optional fields', () => {
284
+ const tool = mockTools.get('ghost_create_page');
285
+ 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();
295
+ });
296
+
297
+ it('should create page with minimal input', async () => {
298
+ const createdPage = { id: 'page-123', title: 'New Page', status: 'draft' };
299
+ mockCreatePageService.mockResolvedValue(createdPage);
300
+
301
+ const tool = mockTools.get('ghost_create_page');
302
+ const result = await tool.handler({ title: 'New Page', html: '<p>Content</p>' });
303
+
304
+ expect(mockCreatePageService).toHaveBeenCalledWith({
305
+ title: 'New Page',
306
+ html: '<p>Content</p>',
307
+ });
308
+ expect(result.content[0].text).toContain('New Page');
309
+ });
310
+
311
+ it('should create page with all optional fields', async () => {
312
+ const fullInput = {
313
+ title: 'Complete Page',
314
+ html: '<p>Content</p>',
315
+ status: 'published',
316
+ custom_excerpt: 'Excerpt',
317
+ feature_image: 'https://example.com/image.jpg',
318
+ feature_image_alt: 'Alt text',
319
+ feature_image_caption: 'Caption',
320
+ meta_title: 'SEO Title',
321
+ meta_description: 'SEO Description',
322
+ };
323
+ const createdPage = { id: 'page-123', ...fullInput };
324
+ mockCreatePageService.mockResolvedValue(createdPage);
325
+
326
+ const tool = mockTools.get('ghost_create_page');
327
+ const result = await tool.handler(fullInput);
328
+
329
+ expect(mockCreatePageService).toHaveBeenCalledWith(fullInput);
330
+ expect(result.content[0].text).toContain('Complete Page');
331
+ });
332
+
333
+ it('should handle errors gracefully', async () => {
334
+ mockCreatePageService.mockRejectedValue(new Error('Invalid input'));
335
+
336
+ const tool = mockTools.get('ghost_create_page');
337
+ const result = await tool.handler({ title: 'Test', html: '<p>Content</p>' });
338
+
339
+ expect(result.isError).toBe(true);
340
+ expect(result.content[0].text).toContain('Error creating page');
341
+ });
342
+ });
343
+
344
+ describe('mcp_server_improved - ghost_update_page tool', () => {
345
+ beforeEach(async () => {
346
+ vi.clearAllMocks();
347
+ if (mockTools.size === 0) {
348
+ await import('../mcp_server_improved.js');
349
+ }
350
+ });
351
+
352
+ it('should register ghost_update_page tool', () => {
353
+ expect(mockTools.has('ghost_update_page')).toBe(true);
354
+ });
355
+
356
+ it('should have correct schema with id required and other fields optional', () => {
357
+ const tool = mockTools.get('ghost_update_page');
358
+ 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();
366
+ });
367
+
368
+ it('should update page with new title', async () => {
369
+ const updatedPage = { id: 'page-123', title: 'Updated Title' };
370
+ mockUpdatePage.mockResolvedValue(updatedPage);
371
+
372
+ const tool = mockTools.get('ghost_update_page');
373
+ const result = await tool.handler({ id: 'page-123', title: 'Updated Title' });
374
+
375
+ expect(mockUpdatePage).toHaveBeenCalledWith('page-123', { title: 'Updated Title' });
376
+ expect(result.content[0].text).toContain('Updated Title');
377
+ });
378
+
379
+ it('should update page with multiple fields', async () => {
380
+ const updatedPage = { id: 'page-123', title: 'New Title', status: 'published' };
381
+ mockUpdatePage.mockResolvedValue(updatedPage);
382
+
383
+ const tool = mockTools.get('ghost_update_page');
384
+ const result = await tool.handler({
385
+ id: 'page-123',
386
+ title: 'New Title',
387
+ status: 'published',
388
+ html: '<p>Updated content</p>',
389
+ });
390
+
391
+ expect(mockUpdatePage).toHaveBeenCalledWith('page-123', {
392
+ title: 'New Title',
393
+ status: 'published',
394
+ html: '<p>Updated content</p>',
395
+ });
396
+ expect(result.content[0].text).toContain('New Title');
397
+ });
398
+
399
+ it('should handle errors gracefully', async () => {
400
+ mockUpdatePage.mockRejectedValue(new Error('Page not found'));
401
+
402
+ const tool = mockTools.get('ghost_update_page');
403
+ const result = await tool.handler({ id: 'nonexistent', title: 'Test' });
404
+
405
+ expect(result.isError).toBe(true);
406
+ expect(result.content[0].text).toContain('Error updating page');
407
+ });
408
+ });
409
+
410
+ describe('mcp_server_improved - ghost_delete_page tool', () => {
411
+ beforeEach(async () => {
412
+ vi.clearAllMocks();
413
+ if (mockTools.size === 0) {
414
+ await import('../mcp_server_improved.js');
415
+ }
416
+ });
417
+
418
+ it('should register ghost_delete_page tool', () => {
419
+ expect(mockTools.has('ghost_delete_page')).toBe(true);
420
+ });
421
+
422
+ it('should have correct schema with id required', () => {
423
+ const tool = mockTools.get('ghost_delete_page');
424
+ expect(tool).toBeDefined();
425
+ expect(tool.schema.id).toBeDefined();
426
+ expect(tool.description).toContain('permanent');
427
+ });
428
+
429
+ it('should delete page by ID', async () => {
430
+ mockDeletePage.mockResolvedValue({ id: 'page-123' });
431
+
432
+ const tool = mockTools.get('ghost_delete_page');
433
+ const result = await tool.handler({ id: 'page-123' });
434
+
435
+ expect(mockDeletePage).toHaveBeenCalledWith('page-123');
436
+ expect(result.content[0].text).toContain('successfully deleted');
437
+ });
438
+
439
+ it('should handle errors gracefully', async () => {
440
+ mockDeletePage.mockRejectedValue(new Error('Page not found'));
441
+
442
+ const tool = mockTools.get('ghost_delete_page');
443
+ const result = await tool.handler({ id: 'nonexistent' });
444
+
445
+ expect(result.isError).toBe(true);
446
+ expect(result.content[0].text).toContain('Error deleting page');
447
+ });
448
+ });
449
+
450
+ describe('mcp_server_improved - ghost_search_pages tool', () => {
451
+ beforeEach(async () => {
452
+ vi.clearAllMocks();
453
+ if (mockTools.size === 0) {
454
+ await import('../mcp_server_improved.js');
455
+ }
456
+ });
457
+
458
+ it('should register ghost_search_pages tool', () => {
459
+ expect(mockTools.has('ghost_search_pages')).toBe(true);
460
+ });
461
+
462
+ it('should have correct schema with query required', () => {
463
+ const tool = mockTools.get('ghost_search_pages');
464
+ expect(tool).toBeDefined();
465
+ expect(tool.schema.query).toBeDefined();
466
+ expect(tool.schema.status).toBeDefined();
467
+ expect(tool.schema.limit).toBeDefined();
468
+ });
469
+
470
+ it('should search pages with query', async () => {
471
+ const mockPages = [{ id: '1', title: 'About Us', slug: 'about-us' }];
472
+ mockSearchPages.mockResolvedValue(mockPages);
473
+
474
+ const tool = mockTools.get('ghost_search_pages');
475
+ const result = await tool.handler({ query: 'about' });
476
+
477
+ expect(mockSearchPages).toHaveBeenCalledWith('about', {});
478
+ expect(result.content[0].text).toContain('About Us');
479
+ });
480
+
481
+ it('should pass status filter', async () => {
482
+ const mockPages = [{ id: '1', title: 'Published Page' }];
483
+ mockSearchPages.mockResolvedValue(mockPages);
484
+
485
+ const tool = mockTools.get('ghost_search_pages');
486
+ await tool.handler({ query: 'test', status: 'published' });
487
+
488
+ expect(mockSearchPages).toHaveBeenCalledWith('test', { status: 'published' });
489
+ });
490
+
491
+ it('should pass limit option', async () => {
492
+ const mockPages = [];
493
+ mockSearchPages.mockResolvedValue(mockPages);
494
+
495
+ const tool = mockTools.get('ghost_search_pages');
496
+ await tool.handler({ query: 'test', limit: 5 });
497
+
498
+ expect(mockSearchPages).toHaveBeenCalledWith('test', { limit: 5 });
499
+ });
500
+
501
+ it('should validate limit is between 1 and 50', () => {
502
+ const tool = mockTools.get('ghost_search_pages');
503
+ const schema = tool.schema;
504
+
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
+ });
510
+
511
+ it('should handle errors gracefully', async () => {
512
+ mockSearchPages.mockRejectedValue(new Error('Search failed'));
513
+
514
+ const tool = mockTools.get('ghost_search_pages');
515
+ const result = await tool.handler({ query: 'test' });
516
+
517
+ expect(result.isError).toBe(true);
518
+ expect(result.content[0].text).toContain('Error searching pages');
519
+ });
520
+ });