@jgardner04/ghost-mcp-server 1.1.0 → 1.1.2
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 +0 -3
- package/package.json +20 -2
- package/src/config/mcp-config.js +31 -22
- package/src/controllers/imageController.js +62 -62
- package/src/controllers/postController.js +8 -8
- package/src/controllers/tagController.js +17 -20
- package/src/errors/index.js +49 -44
- package/src/index.js +56 -50
- package/src/mcp_server.js +151 -178
- package/src/mcp_server_enhanced.js +265 -259
- package/src/mcp_server_improved.js +104 -71
- package/src/middleware/errorMiddleware.js +69 -70
- package/src/resources/ResourceManager.js +143 -134
- package/src/routes/imageRoutes.js +9 -9
- package/src/routes/postRoutes.js +22 -28
- package/src/routes/tagRoutes.js +12 -14
- package/src/services/__tests__/ghostService.test.js +20 -18
- package/src/services/ghostService.js +34 -46
- package/src/services/ghostServiceImproved.js +125 -109
- package/src/services/imageProcessingService.js +15 -15
- package/src/services/postService.js +22 -22
- package/src/utils/logger.js +50 -50
- package/src/utils/urlValidator.js +37 -38
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { McpServer } from
|
|
3
|
-
import { StdioServerTransport } from
|
|
4
|
-
import { z } from
|
|
5
|
-
import dotenv from
|
|
6
|
-
import axios from
|
|
7
|
-
import fs from
|
|
8
|
-
import path from
|
|
9
|
-
import os from
|
|
10
|
-
import crypto from
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import dotenv from 'dotenv';
|
|
6
|
+
import axios from 'axios';
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import os from 'os';
|
|
10
|
+
import crypto from 'crypto';
|
|
11
11
|
|
|
12
12
|
// Load environment variables
|
|
13
13
|
dotenv.config();
|
|
@@ -20,10 +20,10 @@ let urlValidator = null;
|
|
|
20
20
|
|
|
21
21
|
const loadServices = async () => {
|
|
22
22
|
if (!ghostService) {
|
|
23
|
-
ghostService = await import(
|
|
24
|
-
postService = await import(
|
|
25
|
-
imageProcessingService = await import(
|
|
26
|
-
urlValidator = await import(
|
|
23
|
+
ghostService = await import('./services/ghostService.js');
|
|
24
|
+
postService = await import('./services/postService.js');
|
|
25
|
+
imageProcessingService = await import('./services/imageProcessingService.js');
|
|
26
|
+
urlValidator = await import('./utils/urlValidator.js');
|
|
27
27
|
}
|
|
28
28
|
};
|
|
29
29
|
|
|
@@ -33,34 +33,33 @@ const generateUuid = () => crypto.randomUUID();
|
|
|
33
33
|
// Helper function for default alt text
|
|
34
34
|
const getDefaultAltText = (filePath) => {
|
|
35
35
|
try {
|
|
36
|
-
const originalFilename = path
|
|
37
|
-
.basename(filePath)
|
|
38
|
-
.split(".")
|
|
39
|
-
.slice(0, -1)
|
|
40
|
-
.join(".");
|
|
36
|
+
const originalFilename = path.basename(filePath).split('.').slice(0, -1).join('.');
|
|
41
37
|
const nameWithoutIds = originalFilename
|
|
42
|
-
.replace(/^(processed-|mcp-download-|mcp-upload-)\d+-\d+-?/,
|
|
43
|
-
.replace(/^[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}-?/,
|
|
44
|
-
return nameWithoutIds.replace(/[-_]/g,
|
|
45
|
-
} catch (
|
|
46
|
-
return
|
|
38
|
+
.replace(/^(processed-|mcp-download-|mcp-upload-)\d+-\d+-?/, '')
|
|
39
|
+
.replace(/^[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}-?/, '');
|
|
40
|
+
return nameWithoutIds.replace(/[-_]/g, ' ').trim() || 'Uploaded image';
|
|
41
|
+
} catch (_e) {
|
|
42
|
+
return 'Uploaded image';
|
|
47
43
|
}
|
|
48
44
|
};
|
|
49
45
|
|
|
50
46
|
// Create server instance with new API
|
|
51
47
|
const server = new McpServer({
|
|
52
|
-
name:
|
|
53
|
-
version:
|
|
48
|
+
name: 'ghost-mcp-server',
|
|
49
|
+
version: '1.0.0',
|
|
54
50
|
});
|
|
55
51
|
|
|
56
52
|
// --- Register Tools ---
|
|
57
53
|
|
|
58
54
|
// Get Tags Tool
|
|
59
55
|
server.tool(
|
|
60
|
-
|
|
61
|
-
|
|
56
|
+
'ghost_get_tags',
|
|
57
|
+
'Retrieves a list of tags from Ghost CMS. Can optionally filter by tag name.',
|
|
62
58
|
{
|
|
63
|
-
name: z
|
|
59
|
+
name: z
|
|
60
|
+
.string()
|
|
61
|
+
.optional()
|
|
62
|
+
.describe('Filter tags by exact name. If omitted, all tags are returned.'),
|
|
64
63
|
},
|
|
65
64
|
async ({ name }) => {
|
|
66
65
|
console.error(`Executing tool: ghost_get_tags`);
|
|
@@ -70,21 +69,19 @@ server.tool(
|
|
|
70
69
|
let result = tags;
|
|
71
70
|
|
|
72
71
|
if (name) {
|
|
73
|
-
result = tags.filter(
|
|
74
|
-
(tag) => tag.name.toLowerCase() === name.toLowerCase()
|
|
75
|
-
);
|
|
72
|
+
result = tags.filter((tag) => tag.name.toLowerCase() === name.toLowerCase());
|
|
76
73
|
console.error(`Filtered tags by name "${name}". Found ${result.length} match(es).`);
|
|
77
74
|
} else {
|
|
78
75
|
console.error(`Retrieved ${tags.length} tags from Ghost.`);
|
|
79
76
|
}
|
|
80
77
|
|
|
81
78
|
return {
|
|
82
|
-
content: [{ type:
|
|
79
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
83
80
|
};
|
|
84
81
|
} catch (error) {
|
|
85
82
|
console.error(`Error in ghost_get_tags:`, error);
|
|
86
83
|
return {
|
|
87
|
-
content: [{ type:
|
|
84
|
+
content: [{ type: 'text', text: `Error: ${error.message}` }],
|
|
88
85
|
isError: true,
|
|
89
86
|
};
|
|
90
87
|
}
|
|
@@ -93,12 +90,17 @@ server.tool(
|
|
|
93
90
|
|
|
94
91
|
// Create Tag Tool
|
|
95
92
|
server.tool(
|
|
96
|
-
|
|
97
|
-
|
|
93
|
+
'ghost_create_tag',
|
|
94
|
+
'Creates a new tag in Ghost CMS.',
|
|
98
95
|
{
|
|
99
|
-
name: z.string().describe(
|
|
100
|
-
description: z.string().optional().describe(
|
|
101
|
-
slug: z
|
|
96
|
+
name: z.string().describe('The name of the tag.'),
|
|
97
|
+
description: z.string().optional().describe('A description for the tag.'),
|
|
98
|
+
slug: z
|
|
99
|
+
.string()
|
|
100
|
+
.optional()
|
|
101
|
+
.describe(
|
|
102
|
+
'A URL-friendly slug for the tag. Will be auto-generated from the name if omitted.'
|
|
103
|
+
),
|
|
102
104
|
},
|
|
103
105
|
async ({ name, description, slug }) => {
|
|
104
106
|
console.error(`Executing tool: ghost_create_tag with name: ${name}`);
|
|
@@ -108,12 +110,12 @@ server.tool(
|
|
|
108
110
|
console.error(`Tag created successfully. Tag ID: ${createdTag.id}`);
|
|
109
111
|
|
|
110
112
|
return {
|
|
111
|
-
content: [{ type:
|
|
113
|
+
content: [{ type: 'text', text: JSON.stringify(createdTag, null, 2) }],
|
|
112
114
|
};
|
|
113
115
|
} catch (error) {
|
|
114
116
|
console.error(`Error in ghost_create_tag:`, error);
|
|
115
117
|
return {
|
|
116
|
-
content: [{ type:
|
|
118
|
+
content: [{ type: 'text', text: `Error: ${error.message}` }],
|
|
117
119
|
isError: true,
|
|
118
120
|
};
|
|
119
121
|
}
|
|
@@ -122,11 +124,16 @@ server.tool(
|
|
|
122
124
|
|
|
123
125
|
// Upload Image Tool
|
|
124
126
|
server.tool(
|
|
125
|
-
|
|
126
|
-
|
|
127
|
+
'ghost_upload_image',
|
|
128
|
+
'Downloads an image from a URL, processes it, uploads it to Ghost CMS, and returns the final Ghost image URL and alt text.',
|
|
127
129
|
{
|
|
128
|
-
imageUrl: z.string().describe(
|
|
129
|
-
alt: z
|
|
130
|
+
imageUrl: z.string().describe('The publicly accessible URL of the image to upload.'),
|
|
131
|
+
alt: z
|
|
132
|
+
.string()
|
|
133
|
+
.optional()
|
|
134
|
+
.describe(
|
|
135
|
+
'Alt text for the image. If omitted, a default will be generated from the filename.'
|
|
136
|
+
),
|
|
130
137
|
},
|
|
131
138
|
async ({ imageUrl, alt }) => {
|
|
132
139
|
console.error(`Executing tool: ghost_upload_image for URL: ${imageUrl}`);
|
|
@@ -146,17 +153,17 @@ server.tool(
|
|
|
146
153
|
const axiosConfig = urlValidator.createSecureAxiosConfig(urlValidation.sanitizedUrl);
|
|
147
154
|
const response = await axios(axiosConfig);
|
|
148
155
|
const tempDir = os.tmpdir();
|
|
149
|
-
const extension = path.extname(imageUrl.split(
|
|
156
|
+
const extension = path.extname(imageUrl.split('?')[0]) || '.tmp';
|
|
150
157
|
const originalFilenameHint =
|
|
151
|
-
path.basename(imageUrl.split(
|
|
158
|
+
path.basename(imageUrl.split('?')[0]) || `image-${generateUuid()}${extension}`;
|
|
152
159
|
downloadedPath = path.join(tempDir, `mcp-download-${generateUuid()}${extension}`);
|
|
153
160
|
|
|
154
161
|
const writer = fs.createWriteStream(downloadedPath);
|
|
155
162
|
response.data.pipe(writer);
|
|
156
163
|
|
|
157
164
|
await new Promise((resolve, reject) => {
|
|
158
|
-
writer.on(
|
|
159
|
-
writer.on(
|
|
165
|
+
writer.on('finish', resolve);
|
|
166
|
+
writer.on('error', reject);
|
|
160
167
|
});
|
|
161
168
|
console.error(`Downloaded image to temporary path: ${downloadedPath}`);
|
|
162
169
|
|
|
@@ -180,24 +187,24 @@ server.tool(
|
|
|
180
187
|
};
|
|
181
188
|
|
|
182
189
|
return {
|
|
183
|
-
content: [{ type:
|
|
190
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
184
191
|
};
|
|
185
192
|
} catch (error) {
|
|
186
193
|
console.error(`Error in ghost_upload_image:`, error);
|
|
187
194
|
return {
|
|
188
|
-
content: [{ type:
|
|
195
|
+
content: [{ type: 'text', text: `Error uploading image: ${error.message}` }],
|
|
189
196
|
isError: true,
|
|
190
197
|
};
|
|
191
198
|
} finally {
|
|
192
199
|
// Cleanup temporary files
|
|
193
200
|
if (downloadedPath) {
|
|
194
201
|
fs.unlink(downloadedPath, (err) => {
|
|
195
|
-
if (err) console.error(
|
|
202
|
+
if (err) console.error('Error deleting temporary downloaded file:', downloadedPath, err);
|
|
196
203
|
});
|
|
197
204
|
}
|
|
198
205
|
if (processedPath && processedPath !== downloadedPath) {
|
|
199
206
|
fs.unlink(processedPath, (err) => {
|
|
200
|
-
if (err) console.error(
|
|
207
|
+
if (err) console.error('Error deleting temporary processed file:', processedPath, err);
|
|
201
208
|
});
|
|
202
209
|
}
|
|
203
210
|
}
|
|
@@ -206,20 +213,44 @@ server.tool(
|
|
|
206
213
|
|
|
207
214
|
// Create Post Tool
|
|
208
215
|
server.tool(
|
|
209
|
-
|
|
210
|
-
|
|
216
|
+
'ghost_create_post',
|
|
217
|
+
'Creates a new post in Ghost CMS.',
|
|
211
218
|
{
|
|
212
|
-
title: z.string().describe(
|
|
213
|
-
html: z.string().describe(
|
|
214
|
-
status: z
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
219
|
+
title: z.string().describe('The title of the post.'),
|
|
220
|
+
html: z.string().describe('The HTML content of the post.'),
|
|
221
|
+
status: z
|
|
222
|
+
.enum(['draft', 'published', 'scheduled'])
|
|
223
|
+
.optional()
|
|
224
|
+
.describe("The status of the post. Defaults to 'draft'."),
|
|
225
|
+
tags: z
|
|
226
|
+
.array(z.string())
|
|
227
|
+
.optional()
|
|
228
|
+
.describe(
|
|
229
|
+
"List of tag names to associate with the post. Tags will be created if they don't exist."
|
|
230
|
+
),
|
|
231
|
+
published_at: z
|
|
232
|
+
.string()
|
|
233
|
+
.optional()
|
|
234
|
+
.describe("ISO 8601 date/time to publish the post. Required if status is 'scheduled'."),
|
|
235
|
+
custom_excerpt: z.string().optional().describe('A custom short summary for the post.'),
|
|
236
|
+
feature_image: z
|
|
237
|
+
.string()
|
|
238
|
+
.optional()
|
|
239
|
+
.describe(
|
|
240
|
+
'URL of the image (e.g., from ghost_upload_image tool) to use as the featured image.'
|
|
241
|
+
),
|
|
242
|
+
feature_image_alt: z.string().optional().describe('Alt text for the featured image.'),
|
|
243
|
+
feature_image_caption: z.string().optional().describe('Caption for the featured image.'),
|
|
244
|
+
meta_title: z
|
|
245
|
+
.string()
|
|
246
|
+
.optional()
|
|
247
|
+
.describe('Custom title for SEO (max 300 chars). Defaults to post title if omitted.'),
|
|
248
|
+
meta_description: z
|
|
249
|
+
.string()
|
|
250
|
+
.optional()
|
|
251
|
+
.describe(
|
|
252
|
+
'Custom description for SEO (max 500 chars). Defaults to excerpt or generated summary if omitted.'
|
|
253
|
+
),
|
|
223
254
|
},
|
|
224
255
|
async (input) => {
|
|
225
256
|
console.error(`Executing tool: ghost_create_post with title: ${input.title}`);
|
|
@@ -229,12 +260,12 @@ server.tool(
|
|
|
229
260
|
console.error(`Post created successfully. Post ID: ${createdPost.id}`);
|
|
230
261
|
|
|
231
262
|
return {
|
|
232
|
-
content: [{ type:
|
|
263
|
+
content: [{ type: 'text', text: JSON.stringify(createdPost, null, 2) }],
|
|
233
264
|
};
|
|
234
265
|
} catch (error) {
|
|
235
266
|
console.error(`Error in ghost_create_post:`, error);
|
|
236
267
|
return {
|
|
237
|
-
content: [{ type:
|
|
268
|
+
content: [{ type: 'text', text: `Error creating post: ${error.message}` }],
|
|
238
269
|
isError: true,
|
|
239
270
|
};
|
|
240
271
|
}
|
|
@@ -244,16 +275,18 @@ server.tool(
|
|
|
244
275
|
// --- Main Entry Point ---
|
|
245
276
|
|
|
246
277
|
async function main() {
|
|
247
|
-
console.error(
|
|
278
|
+
console.error('Starting Ghost MCP Server...');
|
|
248
279
|
|
|
249
280
|
const transport = new StdioServerTransport();
|
|
250
281
|
await server.connect(transport);
|
|
251
282
|
|
|
252
|
-
console.error(
|
|
253
|
-
console.error(
|
|
283
|
+
console.error('Ghost MCP Server running on stdio transport');
|
|
284
|
+
console.error(
|
|
285
|
+
'Available tools: ghost_get_tags, ghost_create_tag, ghost_upload_image, ghost_create_post'
|
|
286
|
+
);
|
|
254
287
|
}
|
|
255
288
|
|
|
256
289
|
main().catch((error) => {
|
|
257
|
-
console.error(
|
|
290
|
+
console.error('Fatal error starting MCP server:', error);
|
|
258
291
|
process.exit(1);
|
|
259
292
|
});
|