@jgardner04/ghost-mcp-server 1.0.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.
@@ -0,0 +1,509 @@
1
+ import {
2
+ MCPServer,
3
+ Resource,
4
+ Tool,
5
+ } from "@modelcontextprotocol/sdk/server/index.js";
6
+ import dotenv from "dotenv";
7
+ import { createPostService } from "./services/postService.js";
8
+ import {
9
+ uploadImage as uploadGhostImage,
10
+ getTags as getGhostTags,
11
+ createTag as createGhostTag,
12
+ } from "./services/ghostService.js";
13
+ import { processImage } from "./services/imageProcessingService.js";
14
+ import axios from "axios";
15
+ import fs from "fs";
16
+ import path from "path";
17
+ import os from "os";
18
+ import { v4 as uuidv4 } from "uuid";
19
+ import { validateImageUrl, createSecureAxiosConfig } from "./utils/urlValidator.js";
20
+ import { createContextLogger } from "./utils/logger.js";
21
+
22
+ // Load environment variables (might be redundant if loaded elsewhere, but safe)
23
+ dotenv.config();
24
+
25
+ // Initialize logger for MCP server
26
+ const logger = createContextLogger('mcp-server');
27
+
28
+ logger.info('Initializing MCP Server');
29
+
30
+ // Define the server instance
31
+ const mcpServer = new MCPServer({
32
+ metadata: {
33
+ name: "Ghost CMS Manager",
34
+ description:
35
+ "MCP Server to manage a Ghost CMS instance using the Admin API.",
36
+ // iconUrl: '...',
37
+ },
38
+ });
39
+
40
+ // --- Define Resources ---
41
+
42
+ logger.info('Defining MCP Resources');
43
+
44
+ // Ghost Tag Resource
45
+ const ghostTagResource = new Resource({
46
+ name: "ghost/tag",
47
+ description: "Represents a tag in Ghost CMS.",
48
+ schema: {
49
+ type: "object",
50
+ properties: {
51
+ id: { type: "string", description: "Unique ID of the tag" },
52
+ name: { type: "string", description: "The name of the tag" },
53
+ slug: { type: "string", description: "URL-friendly version of the name" },
54
+ description: {
55
+ type: ["string", "null"],
56
+ description: "Optional description for the tag",
57
+ },
58
+ // Add other relevant tag fields if needed (e.g., feature_image, visibility)
59
+ },
60
+ required: ["id", "name", "slug"],
61
+ },
62
+ });
63
+ mcpServer.addResource(ghostTagResource);
64
+ logger.info('Added MCP Resource', { resourceName: ghostTagResource.name });
65
+
66
+ // Ghost Post Resource
67
+ const ghostPostResource = new Resource({
68
+ name: "ghost/post",
69
+ description: "Represents a post in Ghost CMS.",
70
+ schema: {
71
+ type: "object",
72
+ properties: {
73
+ id: { type: "string", description: "Unique ID of the post" },
74
+ uuid: { type: "string", description: "UUID of the post" },
75
+ title: { type: "string", description: "The title of the post" },
76
+ slug: {
77
+ type: "string",
78
+ description: "URL-friendly version of the title",
79
+ },
80
+ html: {
81
+ type: ["string", "null"],
82
+ description: "The post content as HTML",
83
+ },
84
+ plaintext: {
85
+ type: ["string", "null"],
86
+ description: "The post content as plain text",
87
+ },
88
+ feature_image: {
89
+ type: ["string", "null"],
90
+ description: "URL of the featured image",
91
+ },
92
+ feature_image_alt: {
93
+ type: ["string", "null"],
94
+ description: "Alt text for the featured image",
95
+ },
96
+ feature_image_caption: {
97
+ type: ["string", "null"],
98
+ description: "Caption for the featured image",
99
+ },
100
+ featured: {
101
+ type: "boolean",
102
+ description: "Whether the post is featured",
103
+ },
104
+ status: {
105
+ type: "string",
106
+ enum: ["published", "draft", "scheduled"],
107
+ description: "Publication status",
108
+ },
109
+ visibility: {
110
+ type: "string",
111
+ enum: ["public", "members", "paid"],
112
+ description: "Access level",
113
+ },
114
+ created_at: {
115
+ type: "string",
116
+ format: "date-time",
117
+ description: "Date/time post was created",
118
+ },
119
+ updated_at: {
120
+ type: "string",
121
+ format: "date-time",
122
+ description: "Date/time post was last updated",
123
+ },
124
+ published_at: {
125
+ type: ["string", "null"],
126
+ format: "date-time",
127
+ description: "Date/time post was published or scheduled",
128
+ },
129
+ custom_excerpt: {
130
+ type: ["string", "null"],
131
+ description: "Custom excerpt for the post",
132
+ },
133
+ meta_title: { type: ["string", "null"], description: "Custom SEO title" },
134
+ meta_description: {
135
+ type: ["string", "null"],
136
+ description: "Custom SEO description",
137
+ },
138
+ tags: {
139
+ type: "array",
140
+ description: "Tags associated with the post",
141
+ items: { $ref: "#/definitions/ghost/tag" }, // Reference the ghost/tag resource
142
+ },
143
+ // Add authors or other relevant fields if needed
144
+ },
145
+ required: [
146
+ "id",
147
+ "uuid",
148
+ "title",
149
+ "slug",
150
+ "status",
151
+ "visibility",
152
+ "created_at",
153
+ "updated_at",
154
+ ],
155
+ definitions: {
156
+ // Make the referenced tag resource available within this schema's scope
157
+ "ghost/tag": ghostTagResource.schema,
158
+ },
159
+ },
160
+ });
161
+ mcpServer.addResource(ghostPostResource);
162
+ logger.info('Added MCP Resource', { resourceName: ghostPostResource.name });
163
+
164
+ // --- Define Tools (Subtasks 8.4 - 8.7) ---
165
+ // Placeholder comments for where tools will be added
166
+
167
+ // --- End Resource/Tool Definitions ---
168
+
169
+ logger.info('Defining MCP Tools');
170
+
171
+ // Create Post Tool (Adding this missing tool)
172
+ const createPostTool = new Tool({
173
+ name: "ghost_create_post",
174
+ description:
175
+ "Creates a new post in Ghost CMS. Handles tag creation/lookup. Returns the created post data.",
176
+ inputSchema: {
177
+ type: "object",
178
+ properties: {
179
+ title: { type: "string", description: "The title for the new post." },
180
+ html: {
181
+ type: "string",
182
+ description: "The HTML content for the new post.",
183
+ },
184
+ status: {
185
+ type: "string",
186
+ enum: ["published", "draft", "scheduled"],
187
+ default: "draft",
188
+ description:
189
+ "The status for the post (published, draft, scheduled). Defaults to draft.",
190
+ },
191
+ tags: {
192
+ type: "array",
193
+ items: { type: "string" },
194
+ description:
195
+ "Optional: An array of tag names (strings) to associate with the post.",
196
+ },
197
+ published_at: {
198
+ type: "string",
199
+ format: "date-time",
200
+ description:
201
+ "Optional: The ISO 8601 date/time for publishing or scheduling. Required if status is scheduled.",
202
+ },
203
+ custom_excerpt: {
204
+ type: "string",
205
+ description: "Optional: A custom short summary for the post.",
206
+ },
207
+ feature_image: {
208
+ type: "string",
209
+ format: "url",
210
+ description:
211
+ "Optional: URL of the image (e.g., from ghost_upload_image tool) to use as the featured image.",
212
+ },
213
+ feature_image_alt: {
214
+ type: "string",
215
+ description: "Optional: Alt text for the featured image.",
216
+ },
217
+ feature_image_caption: {
218
+ type: "string",
219
+ description: "Optional: Caption for the featured image.",
220
+ },
221
+ meta_title: {
222
+ type: "string",
223
+ description:
224
+ "Optional: Custom title for SEO (max 300 chars). Defaults to post title if omitted.",
225
+ },
226
+ meta_description: {
227
+ type: "string",
228
+ description:
229
+ "Optional: Custom description for SEO (max 500 chars). Defaults to excerpt or generated summary if omitted.",
230
+ },
231
+ },
232
+ required: ["title", "html"],
233
+ },
234
+ outputSchema: {
235
+ $ref: "ghost/post#/schema",
236
+ },
237
+ implementation: async (input) => {
238
+ logger.toolExecution(createPostTool.name, input);
239
+ try {
240
+ const createdPost = await createPostService(input);
241
+ logger.toolSuccess(createPostTool.name, createdPost, { postId: createdPost.id });
242
+ return createdPost;
243
+ } catch (error) {
244
+ logger.toolError(createPostTool.name, error);
245
+ throw new Error(`Failed to create Ghost post: ${error.message}`);
246
+ }
247
+ },
248
+ });
249
+ mcpServer.addTool(createPostTool);
250
+ logger.info('Added MCP Tool', { toolName: createPostTool.name });
251
+
252
+ // Upload Image Tool
253
+ const uploadImageTool = new Tool({
254
+ name: "ghost_upload_image",
255
+ description:
256
+ "Downloads an image from a URL, processes it, uploads it to Ghost CMS, and returns the final Ghost image URL and alt text.",
257
+ inputSchema: {
258
+ type: "object",
259
+ properties: {
260
+ imageUrl: {
261
+ type: "string",
262
+ format: "url",
263
+ description: "The publicly accessible URL of the image to upload.",
264
+ },
265
+ alt: {
266
+ type: "string",
267
+ description:
268
+ "Optional: Alt text for the image. If omitted, a default will be generated from the filename.",
269
+ },
270
+ // filenameHint: { type: 'string', description: 'Optional: A hint for the original filename, used for default alt text generation.' }
271
+ },
272
+ required: ["imageUrl"],
273
+ },
274
+ outputSchema: {
275
+ type: "object",
276
+ properties: {
277
+ url: {
278
+ type: "string",
279
+ format: "url",
280
+ description: "The final URL of the image hosted on Ghost.",
281
+ },
282
+ alt: {
283
+ type: "string",
284
+ description: "The alt text determined for the image.",
285
+ },
286
+ },
287
+ required: ["url", "alt"],
288
+ },
289
+ implementation: async (input) => {
290
+ logger.toolExecution(uploadImageTool.name, { imageUrl: input.imageUrl });
291
+ const { imageUrl, alt } = input;
292
+ let downloadedPath = null;
293
+ let processedPath = null;
294
+
295
+ try {
296
+ // --- 1. Validate URL for SSRF protection ---
297
+ const urlValidation = validateImageUrl(imageUrl);
298
+ if (!urlValidation.isValid) {
299
+ throw new Error(`Invalid image URL: ${urlValidation.error}`);
300
+ }
301
+
302
+ // --- 2. Download the image with security controls ---
303
+ const axiosConfig = createSecureAxiosConfig(urlValidation.sanitizedUrl);
304
+ const response = await axios(axiosConfig);
305
+ // Generate a unique temporary filename
306
+ const tempDir = os.tmpdir();
307
+ const extension = path.extname(imageUrl.split("?")[0]) || ".tmp"; // Basic extension extraction
308
+ const originalFilenameHint =
309
+ path.basename(imageUrl.split("?")[0]) ||
310
+ `image-${uuidv4()}${extension}`;
311
+ downloadedPath = path.join(
312
+ tempDir,
313
+ `mcp-download-${uuidv4()}${extension}`
314
+ );
315
+
316
+ const writer = fs.createWriteStream(downloadedPath);
317
+ response.data.pipe(writer);
318
+
319
+ await new Promise((resolve, reject) => {
320
+ writer.on("finish", resolve);
321
+ writer.on("error", reject);
322
+ });
323
+ logger.fileOperation('download', downloadedPath);
324
+
325
+ // --- 3. Process the image (Optional) ---
326
+ // Using the service from subtask 4.2
327
+ processedPath = await processImage(downloadedPath, tempDir);
328
+ logger.fileOperation('process', processedPath);
329
+
330
+ // --- 4. Determine Alt Text ---
331
+ // Using similar logic from subtask 4.4
332
+ const defaultAlt = getDefaultAltText(originalFilenameHint);
333
+ const finalAltText = alt || defaultAlt;
334
+ logger.debug('Generated alt text', { altText: finalAltText });
335
+
336
+ // --- 5. Upload processed image to Ghost ---
337
+ const uploadResult = await uploadGhostImage(processedPath);
338
+ logger.info('Image uploaded to Ghost', { ghostUrl: uploadResult.url });
339
+
340
+ // --- 6. Return result ---
341
+ return {
342
+ url: uploadResult.url,
343
+ alt: finalAltText,
344
+ };
345
+ } catch (error) {
346
+ logger.toolError(uploadImageTool.name, error, { imageUrl });
347
+ // Add more specific error handling (download failed, processing failed, upload failed)
348
+ throw new Error(
349
+ `Failed to upload image from URL ${imageUrl}: ${error.message}`
350
+ );
351
+ } finally {
352
+ // --- 7. Cleanup temporary files ---
353
+ if (downloadedPath) {
354
+ fs.unlink(downloadedPath, (err) => {
355
+ if (err)
356
+ logger.warn('Failed to delete temporary downloaded file', {
357
+ file: path.basename(downloadedPath),
358
+ error: err.message
359
+ });
360
+ });
361
+ }
362
+ if (processedPath && processedPath !== downloadedPath) {
363
+ fs.unlink(processedPath, (err) => {
364
+ if (err)
365
+ logger.warn('Failed to delete temporary processed file', {
366
+ file: path.basename(processedPath),
367
+ error: err.message
368
+ });
369
+ });
370
+ }
371
+ }
372
+ },
373
+ });
374
+
375
+ // Helper function for default alt text (similar to imageController)
376
+ const getDefaultAltText = (filePath) => {
377
+ try {
378
+ const originalFilename = path
379
+ .basename(filePath)
380
+ .split(".")
381
+ .slice(0, -1)
382
+ .join(".");
383
+ const nameWithoutIds = originalFilename
384
+ .replace(/^(processed-|mcp-download-|mcp-upload-)\d+-\d+-?/, "")
385
+ .replace(/^[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}-?/, ""); // Remove UUIDs too
386
+ return nameWithoutIds.replace(/[-_]/g, " ").trim() || "Uploaded image";
387
+ } catch (e) {
388
+ return "Uploaded image";
389
+ }
390
+ };
391
+
392
+ mcpServer.addTool(uploadImageTool);
393
+ logger.info('Added MCP Tool', { toolName: uploadImageTool.name });
394
+
395
+ // Get Tags Tool
396
+ const getTagsTool = new Tool({
397
+ name: "ghost_get_tags",
398
+ description:
399
+ "Retrieves a list of tags from Ghost CMS. Can optionally filter by tag name.",
400
+ inputSchema: {
401
+ type: "object",
402
+ properties: {
403
+ name: {
404
+ type: "string",
405
+ description: "Optional: The exact name of the tag to search for.",
406
+ },
407
+ },
408
+ },
409
+ outputSchema: {
410
+ type: "array",
411
+ items: { $ref: "ghost/tag#/schema" }, // Output is an array of ghost/tag resources
412
+ },
413
+ implementation: async (input) => {
414
+ logger.toolExecution(getTagsTool.name, input);
415
+ try {
416
+ const tags = await getGhostTags(input?.name); // Pass name if provided
417
+ logger.toolSuccess(getTagsTool.name, tags, { tagCount: tags.length });
418
+ // TODO: Validate/map output against schema if necessary
419
+ return tags;
420
+ } catch (error) {
421
+ logger.toolError(getTagsTool.name, error);
422
+ throw new Error(`Failed to get Ghost tags: ${error.message}`);
423
+ }
424
+ },
425
+ });
426
+ mcpServer.addTool(getTagsTool);
427
+ logger.info('Added MCP Tool', { toolName: getTagsTool.name });
428
+
429
+ // Create Tag Tool
430
+ const createTagTool = new Tool({
431
+ name: "ghost_create_tag",
432
+ description: "Creates a new tag in Ghost CMS. Returns the created tag.",
433
+ inputSchema: {
434
+ type: "object",
435
+ properties: {
436
+ name: { type: "string", description: "The name for the new tag." },
437
+ description: {
438
+ type: "string",
439
+ description: "Optional: A description for the tag (max 500 chars).",
440
+ },
441
+ slug: {
442
+ type: "string",
443
+ description:
444
+ "Optional: A URL-friendly slug. If omitted, Ghost generates one from the name.",
445
+ },
446
+ // Add other createable fields like color, feature_image etc. if needed
447
+ },
448
+ required: ["name"],
449
+ },
450
+ outputSchema: {
451
+ $ref: "ghost/tag#/schema", // Output is a single ghost/tag resource
452
+ },
453
+ implementation: async (input) => {
454
+ logger.toolExecution(createTagTool.name, input);
455
+ try {
456
+ // Basic validation happens via inputSchema, more specific validation (like slug format) could be added here if not in service
457
+ const newTag = await createGhostTag(input);
458
+ logger.toolSuccess(createTagTool.name, newTag, { tagId: newTag.id });
459
+ // TODO: Validate/map output against schema if necessary
460
+ return newTag;
461
+ } catch (error) {
462
+ logger.toolError(createTagTool.name, error);
463
+ throw new Error(`Failed to create Ghost tag: ${error.message}`);
464
+ }
465
+ },
466
+ });
467
+ mcpServer.addTool(createTagTool);
468
+ logger.info('Added MCP Tool', { toolName: createTagTool.name });
469
+
470
+ // --- End Tool Definitions ---
471
+
472
+ // Function to start the MCP server
473
+ // We might integrate this with the Express server later or run separately
474
+ const startMCPServer = async (port = 3001) => {
475
+ try {
476
+ // Ensure resources/tools are added before starting
477
+ logger.info('Starting MCP Server', { port });
478
+ await mcpServer.listen({ port });
479
+
480
+ const resources = mcpServer.listResources().map((r) => r.name);
481
+ const tools = mcpServer.listTools().map((t) => t.name);
482
+
483
+ logger.info('MCP Server started successfully', {
484
+ port,
485
+ resourceCount: resources.length,
486
+ toolCount: tools.length,
487
+ resources,
488
+ tools,
489
+ type: 'server_start'
490
+ });
491
+ } catch (error) {
492
+ logger.error('Failed to start MCP Server', {
493
+ port,
494
+ error: error.message,
495
+ stack: error.stack,
496
+ type: 'server_start_error'
497
+ });
498
+ process.exit(1);
499
+ }
500
+ };
501
+
502
+ // Export the server instance and start function if needed elsewhere
503
+ export { mcpServer, startMCPServer };
504
+
505
+ // Optional: Automatically start if this file is run directly
506
+ // This might conflict if we integrate with Express later
507
+ // if (require.main === module) {
508
+ // startMCPServer();
509
+ // }