@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 +70 -20
- package/package.json +1 -1
- package/src/__tests__/mcp_server_pages.test.js +520 -0
- package/src/mcp_server_improved.js +274 -1
- package/src/services/__tests__/ghostServiceImproved.pages.test.js +561 -0
- package/src/services/__tests__/pageService.test.js +400 -0
- package/src/services/ghostServiceImproved.js +257 -0
- package/src/services/pageService.js +121 -0
package/README.md
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
# Ghost MCP Server
|
|
2
2
|
|
|
3
|
+
[](https://badge.fury.io/js/%40jgardner04%2Fghost-mcp-server)
|
|
4
|
+
[](https://github.com/jgardner04/Ghost-MCP-Server/actions)
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
[](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
|
|
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
|
|
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
|
|
71
|
-
cd
|
|
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
|
|
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
|
|
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
|
-
- **
|
|
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
|
@@ -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
|
+
});
|