@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,657 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ MCPServer,
4
+ Resource,
5
+ Tool,
6
+ } from "@modelcontextprotocol/sdk/server/index.js";
7
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
+ import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
9
+ import { WebSocketServerTransport } from "@modelcontextprotocol/sdk/server/websocket.js";
10
+ import dotenv from "dotenv";
11
+ import { createPostService } from "./services/postService.js";
12
+ import {
13
+ uploadImage as uploadGhostImage,
14
+ getTags as getGhostTags,
15
+ createTag as createGhostTag,
16
+ } from "./services/ghostService.js";
17
+ import { processImage } from "./services/imageProcessingService.js";
18
+ import axios from "axios";
19
+ import { validateImageUrl, createSecureAxiosConfig } from "./utils/urlValidator.js";
20
+ import fs from "fs";
21
+ import path from "path";
22
+ import os from "os";
23
+ import { v4 as uuidv4 } from "uuid";
24
+ import express from "express";
25
+ import { WebSocketServer } from "ws";
26
+
27
+ // Load environment variables
28
+ dotenv.config();
29
+
30
+ console.log("Initializing MCP Server...");
31
+
32
+ // Define the server instance
33
+ const mcpServer = new MCPServer({
34
+ metadata: {
35
+ name: "Ghost CMS Manager",
36
+ description:
37
+ "MCP Server to manage a Ghost CMS instance using the Admin API.",
38
+ version: "1.0.0",
39
+ },
40
+ });
41
+
42
+ // --- Error Response Standardization ---
43
+ class MCPError extends Error {
44
+ constructor(message, code = "UNKNOWN_ERROR", details = {}) {
45
+ super(message);
46
+ this.code = code;
47
+ this.details = details;
48
+ }
49
+ }
50
+
51
+ const handleToolError = (error, toolName) => {
52
+ console.error(`Error in tool ${toolName}:`, error);
53
+
54
+ // Standardized error response
55
+ return {
56
+ error: {
57
+ code: error.code || "TOOL_EXECUTION_ERROR",
58
+ message: error.message || "An unexpected error occurred",
59
+ tool: toolName,
60
+ details: error.details || {},
61
+ timestamp: new Date().toISOString(),
62
+ }
63
+ };
64
+ };
65
+
66
+ // --- Define Resources ---
67
+
68
+ console.log("Defining MCP Resources...");
69
+
70
+ // Ghost Tag Resource
71
+ const ghostTagResource = new Resource({
72
+ name: "ghost/tag",
73
+ description: "Represents a tag in Ghost CMS.",
74
+ schema: {
75
+ type: "object",
76
+ properties: {
77
+ id: { type: "string", description: "Unique ID of the tag" },
78
+ name: { type: "string", description: "The name of the tag" },
79
+ slug: { type: "string", description: "URL-friendly version of the name" },
80
+ description: {
81
+ type: ["string", "null"],
82
+ description: "Optional description for the tag",
83
+ },
84
+ },
85
+ required: ["id", "name", "slug"],
86
+ },
87
+ // Resource fetching handler
88
+ async fetch(uri) {
89
+ try {
90
+ // Extract tag ID from URI (e.g., "ghost/tag/123")
91
+ const tagId = uri.split("/").pop();
92
+ const tags = await getGhostTags();
93
+ const tag = tags.find(t => t.id === tagId || t.slug === tagId);
94
+
95
+ if (!tag) {
96
+ throw new MCPError(`Tag not found: ${tagId}`, "RESOURCE_NOT_FOUND");
97
+ }
98
+
99
+ return tag;
100
+ } catch (error) {
101
+ return handleToolError(error, "ghost_tag_fetch");
102
+ }
103
+ }
104
+ });
105
+ mcpServer.addResource(ghostTagResource);
106
+ console.log(`Added Resource: ${ghostTagResource.name}`);
107
+
108
+ // Ghost Post Resource
109
+ const ghostPostResource = new Resource({
110
+ name: "ghost/post",
111
+ description: "Represents a post in Ghost CMS.",
112
+ schema: {
113
+ type: "object",
114
+ properties: {
115
+ id: { type: "string", description: "Unique ID of the post" },
116
+ uuid: { type: "string", description: "UUID of the post" },
117
+ title: { type: "string", description: "The title of the post" },
118
+ slug: {
119
+ type: "string",
120
+ description: "URL-friendly version of the title",
121
+ },
122
+ html: {
123
+ type: ["string", "null"],
124
+ description: "The post content as HTML",
125
+ },
126
+ plaintext: {
127
+ type: ["string", "null"],
128
+ description: "The post content as plain text",
129
+ },
130
+ feature_image: {
131
+ type: ["string", "null"],
132
+ description: "URL of the featured image",
133
+ },
134
+ feature_image_alt: {
135
+ type: ["string", "null"],
136
+ description: "Alt text for the featured image",
137
+ },
138
+ feature_image_caption: {
139
+ type: ["string", "null"],
140
+ description: "Caption for the featured image",
141
+ },
142
+ featured: {
143
+ type: "boolean",
144
+ description: "Whether the post is featured",
145
+ },
146
+ status: {
147
+ type: "string",
148
+ enum: ["draft", "published", "scheduled"],
149
+ description: "The status of the post",
150
+ },
151
+ visibility: {
152
+ type: "string",
153
+ enum: ["public", "members", "paid", "tiers"],
154
+ description: "The visibility level of the post",
155
+ },
156
+ created_at: {
157
+ type: "string",
158
+ format: "date-time",
159
+ description: "Date/time when the post was created",
160
+ },
161
+ updated_at: {
162
+ type: "string",
163
+ format: "date-time",
164
+ description: "Date/time when the post was last updated",
165
+ },
166
+ published_at: {
167
+ type: ["string", "null"],
168
+ format: "date-time",
169
+ description: "Date/time when the post was published",
170
+ },
171
+ custom_excerpt: {
172
+ type: ["string", "null"],
173
+ description: "Custom excerpt for the post",
174
+ },
175
+ tags: {
176
+ type: "array",
177
+ items: { $ref: "ghost/tag#/schema" },
178
+ description: "Associated tags",
179
+ },
180
+ meta_title: {
181
+ type: ["string", "null"],
182
+ description: "Custom meta title for SEO",
183
+ },
184
+ meta_description: {
185
+ type: ["string", "null"],
186
+ description: "Custom meta description for SEO",
187
+ },
188
+ },
189
+ required: ["id", "uuid", "title", "slug", "status"],
190
+ },
191
+ // Resource fetching handler
192
+ async fetch(uri) {
193
+ try {
194
+ // Extract post ID from URI (e.g., "ghost/post/123")
195
+ const postId = uri.split("/").pop();
196
+
197
+ // You'll need to implement a getPost service method
198
+ // For now, returning an error as this would require adding to ghostService.js
199
+ throw new MCPError(
200
+ "Post fetching not yet implemented",
201
+ "NOT_IMPLEMENTED",
202
+ { postId }
203
+ );
204
+ } catch (error) {
205
+ return handleToolError(error, "ghost_post_fetch");
206
+ }
207
+ }
208
+ });
209
+ mcpServer.addResource(ghostPostResource);
210
+ console.log(`Added Resource: ${ghostPostResource.name}`);
211
+
212
+ // --- Define Tools (with improved error handling) ---
213
+
214
+ console.log("Defining MCP Tools...");
215
+
216
+ // Create Post Tool
217
+ const createPostTool = new Tool({
218
+ name: "ghost_create_post",
219
+ description: "Creates a new post in Ghost CMS.",
220
+ inputSchema: {
221
+ type: "object",
222
+ properties: {
223
+ title: {
224
+ type: "string",
225
+ description: "The title of the post.",
226
+ },
227
+ html: {
228
+ type: "string",
229
+ description: "The HTML content of the post.",
230
+ },
231
+ status: {
232
+ type: "string",
233
+ enum: ["draft", "published", "scheduled"],
234
+ default: "draft",
235
+ description:
236
+ "The status of the post. Use 'scheduled' with a future published_at date.",
237
+ },
238
+ tags: {
239
+ type: "array",
240
+ items: { type: "string" },
241
+ description:
242
+ "Optional: List of tag names to associate with the post. Tags will be created if they don't exist.",
243
+ },
244
+ published_at: {
245
+ type: "string",
246
+ format: "date-time",
247
+ description:
248
+ "Optional: ISO 8601 date/time to publish the post. Required if status is 'scheduled'.",
249
+ },
250
+ custom_excerpt: {
251
+ type: "string",
252
+ description: "Optional: A custom short summary for the post.",
253
+ },
254
+ feature_image: {
255
+ type: "string",
256
+ format: "url",
257
+ description:
258
+ "Optional: URL of the image (e.g., from ghost_upload_image tool) to use as the featured image.",
259
+ },
260
+ feature_image_alt: {
261
+ type: "string",
262
+ description: "Optional: Alt text for the featured image.",
263
+ },
264
+ feature_image_caption: {
265
+ type: "string",
266
+ description: "Optional: Caption for the featured image.",
267
+ },
268
+ meta_title: {
269
+ type: "string",
270
+ description:
271
+ "Optional: Custom title for SEO (max 300 chars). Defaults to post title if omitted.",
272
+ },
273
+ meta_description: {
274
+ type: "string",
275
+ description:
276
+ "Optional: Custom description for SEO (max 500 chars). Defaults to excerpt or generated summary if omitted.",
277
+ },
278
+ },
279
+ required: ["title", "html"],
280
+ },
281
+ outputSchema: {
282
+ $ref: "ghost/post#/schema",
283
+ },
284
+ implementation: async (input) => {
285
+ console.log(
286
+ `Executing tool: ${createPostTool.name} with input keys:`,
287
+ Object.keys(input)
288
+ );
289
+ try {
290
+ const createdPost = await createPostService(input);
291
+ console.log(
292
+ `Tool ${createPostTool.name} executed successfully. Post ID: ${createdPost.id}`
293
+ );
294
+ return createdPost;
295
+ } catch (error) {
296
+ return handleToolError(error, createPostTool.name);
297
+ }
298
+ },
299
+ });
300
+ mcpServer.addTool(createPostTool);
301
+ console.log(`Added Tool: ${createPostTool.name}`);
302
+
303
+ // Upload Image Tool
304
+ const uploadImageTool = new Tool({
305
+ name: "ghost_upload_image",
306
+ description:
307
+ "Downloads an image from a URL, processes it, uploads it to Ghost CMS, and returns the final Ghost image URL and alt text.",
308
+ inputSchema: {
309
+ type: "object",
310
+ properties: {
311
+ imageUrl: {
312
+ type: "string",
313
+ format: "url",
314
+ description: "The publicly accessible URL of the image to upload.",
315
+ },
316
+ alt: {
317
+ type: "string",
318
+ description:
319
+ "Optional: Alt text for the image. If omitted, a default will be generated from the filename.",
320
+ },
321
+ },
322
+ required: ["imageUrl"],
323
+ },
324
+ outputSchema: {
325
+ type: "object",
326
+ properties: {
327
+ url: {
328
+ type: "string",
329
+ format: "url",
330
+ description: "The final URL of the image hosted on Ghost.",
331
+ },
332
+ alt: {
333
+ type: "string",
334
+ description: "The alt text determined for the image.",
335
+ },
336
+ },
337
+ required: ["url", "alt"],
338
+ },
339
+ implementation: async (input) => {
340
+ console.log(
341
+ `Executing tool: ${uploadImageTool.name} for URL:`,
342
+ input.imageUrl
343
+ );
344
+ const { imageUrl, alt } = input;
345
+ let downloadedPath = null;
346
+ let processedPath = null;
347
+
348
+ try {
349
+ // --- 1. Validate URL for SSRF protection ---
350
+ const urlValidation = validateImageUrl(imageUrl);
351
+ if (!urlValidation.isValid) {
352
+ throw new Error(`Invalid image URL: ${urlValidation.error}`);
353
+ }
354
+
355
+ // --- 2. Download the image with security controls ---
356
+ const axiosConfig = createSecureAxiosConfig(urlValidation.sanitizedUrl);
357
+ const response = await axios(axiosConfig);
358
+ const tempDir = os.tmpdir();
359
+ const extension = path.extname(imageUrl.split("?")[0]) || ".tmp";
360
+ const originalFilenameHint =
361
+ path.basename(imageUrl.split("?")[0]) ||
362
+ `image-${uuidv4()}${extension}`;
363
+ downloadedPath = path.join(
364
+ tempDir,
365
+ `mcp-download-${uuidv4()}${extension}`
366
+ );
367
+
368
+ const writer = fs.createWriteStream(downloadedPath);
369
+ response.data.pipe(writer);
370
+
371
+ await new Promise((resolve, reject) => {
372
+ writer.on("finish", resolve);
373
+ writer.on("error", reject);
374
+ });
375
+ console.log(`Downloaded image to temporary path: ${downloadedPath}`);
376
+
377
+ // --- 2. Process the image ---
378
+ processedPath = await processImage(downloadedPath, tempDir);
379
+ console.log(`Processed image path: ${processedPath}`);
380
+
381
+ // --- 3. Determine Alt Text ---
382
+ const defaultAlt = getDefaultAltText(originalFilenameHint);
383
+ const finalAltText = alt || defaultAlt;
384
+ console.log(`Using alt text: "${finalAltText}"`);
385
+
386
+ // --- 4. Upload processed image to Ghost ---
387
+ const uploadResult = await uploadGhostImage(processedPath);
388
+ console.log(`Uploaded processed image to Ghost: ${uploadResult.url}`);
389
+
390
+ // --- 5. Return result ---
391
+ return {
392
+ url: uploadResult.url,
393
+ alt: finalAltText,
394
+ };
395
+ } catch (error) {
396
+ return handleToolError(
397
+ new MCPError(
398
+ `Failed to upload image from URL ${imageUrl}`,
399
+ "IMAGE_UPLOAD_ERROR",
400
+ { imageUrl, originalError: error.message }
401
+ ),
402
+ uploadImageTool.name
403
+ );
404
+ } finally {
405
+ // --- 6. Cleanup temporary files ---
406
+ if (downloadedPath) {
407
+ fs.unlink(downloadedPath, (err) => {
408
+ if (err)
409
+ console.error(
410
+ "Error deleting temporary downloaded file:",
411
+ downloadedPath,
412
+ err
413
+ );
414
+ });
415
+ }
416
+ if (processedPath && processedPath !== downloadedPath) {
417
+ fs.unlink(processedPath, (err) => {
418
+ if (err)
419
+ console.error(
420
+ "Error deleting temporary processed file:",
421
+ processedPath,
422
+ err
423
+ );
424
+ });
425
+ }
426
+ }
427
+ },
428
+ });
429
+
430
+ // Helper function for default alt text
431
+ const getDefaultAltText = (filePath) => {
432
+ try {
433
+ const originalFilename = path
434
+ .basename(filePath)
435
+ .split(".")
436
+ .slice(0, -1)
437
+ .join(".");
438
+ const nameWithoutIds = originalFilename
439
+ .replace(/^(processed-|mcp-download-|mcp-upload-)\d+-\d+-?/, "")
440
+ .replace(/^[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}-?/, "");
441
+ return nameWithoutIds.replace(/[-_]/g, " ").trim() || "Uploaded image";
442
+ } catch (e) {
443
+ return "Uploaded image";
444
+ }
445
+ };
446
+
447
+ mcpServer.addTool(uploadImageTool);
448
+ console.log(`Added Tool: ${uploadImageTool.name}`);
449
+
450
+ // Get Tags Tool
451
+ const getTagsTool = new Tool({
452
+ name: "ghost_get_tags",
453
+ description:
454
+ "Retrieves a list of tags from Ghost CMS. Can optionally filter by tag name.",
455
+ inputSchema: {
456
+ type: "object",
457
+ properties: {
458
+ name: {
459
+ type: "string",
460
+ description:
461
+ "Optional: Filter tags by exact name. If omitted, all tags are returned.",
462
+ },
463
+ },
464
+ },
465
+ outputSchema: {
466
+ type: "array",
467
+ items: { $ref: "ghost/tag#/schema" },
468
+ },
469
+ implementation: async (input) => {
470
+ console.log(`Executing tool: ${getTagsTool.name}`);
471
+ try {
472
+ const tags = await getGhostTags();
473
+ if (input.name) {
474
+ const filteredTags = tags.filter(
475
+ (tag) => tag.name.toLowerCase() === input.name.toLowerCase()
476
+ );
477
+ console.log(
478
+ `Filtered tags by name "${input.name}". Found ${filteredTags.length} match(es).`
479
+ );
480
+ return filteredTags;
481
+ }
482
+ console.log(`Retrieved ${tags.length} tags from Ghost.`);
483
+ return tags;
484
+ } catch (error) {
485
+ return handleToolError(error, getTagsTool.name);
486
+ }
487
+ },
488
+ });
489
+ mcpServer.addTool(getTagsTool);
490
+ console.log(`Added Tool: ${getTagsTool.name}`);
491
+
492
+ // Create Tag Tool
493
+ const createTagTool = new Tool({
494
+ name: "ghost_create_tag",
495
+ description: "Creates a new tag in Ghost CMS.",
496
+ inputSchema: {
497
+ type: "object",
498
+ properties: {
499
+ name: {
500
+ type: "string",
501
+ description: "The name of the tag.",
502
+ },
503
+ description: {
504
+ type: "string",
505
+ description: "Optional: A description for the tag.",
506
+ },
507
+ slug: {
508
+ type: "string",
509
+ pattern: "^[a-z0-9\\-]+$",
510
+ description:
511
+ "Optional: A URL-friendly slug for the tag. Will be auto-generated from the name if omitted.",
512
+ },
513
+ },
514
+ required: ["name"],
515
+ },
516
+ outputSchema: {
517
+ $ref: "ghost/tag#/schema",
518
+ },
519
+ implementation: async (input) => {
520
+ console.log(
521
+ `Executing tool: ${createTagTool.name} with name:`,
522
+ input.name
523
+ );
524
+ try {
525
+ const createdTag = await createGhostTag(input);
526
+ console.log(
527
+ `Tool ${createTagTool.name} executed successfully. Tag ID: ${createdTag.id}`
528
+ );
529
+ return createdTag;
530
+ } catch (error) {
531
+ return handleToolError(error, createTagTool.name);
532
+ }
533
+ },
534
+ });
535
+ mcpServer.addTool(createTagTool);
536
+ console.log(`Added Tool: ${createTagTool.name}`);
537
+
538
+ // --- Transport Configuration ---
539
+
540
+ /**
541
+ * Start MCP Server with specified transport
542
+ * @param {string} transport - Transport type: 'stdio', 'http', 'websocket'
543
+ * @param {object} options - Transport-specific options
544
+ */
545
+ const startMCPServer = async (transport = 'http', options = {}) => {
546
+ try {
547
+ console.log(`Starting MCP Server with ${transport} transport...`);
548
+
549
+ switch (transport) {
550
+ case 'stdio':
551
+ // Standard I/O transport - best for CLI tools
552
+ const stdioTransport = new StdioServerTransport();
553
+ await mcpServer.connect(stdioTransport);
554
+ console.log("MCP Server running on stdio transport");
555
+ break;
556
+
557
+ case 'http':
558
+ case 'sse':
559
+ // HTTP with Server-Sent Events - good for web clients
560
+ const port = options.port || 3001;
561
+ const app = express();
562
+
563
+ // CORS configuration for web clients
564
+ app.use((req, res, next) => {
565
+ res.header('Access-Control-Allow-Origin', options.cors || '*');
566
+ res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
567
+ res.header('Access-Control-Allow-Headers', 'Content-Type');
568
+ next();
569
+ });
570
+
571
+ // SSE endpoint
572
+ const sseTransport = new SSEServerTransport();
573
+ app.get('/mcp/sse', sseTransport.handler());
574
+
575
+ // Health check
576
+ app.get('/mcp/health', (req, res) => {
577
+ res.json({
578
+ status: 'ok',
579
+ transport: 'sse',
580
+ resources: mcpServer.listResources().map(r => r.name),
581
+ tools: mcpServer.listTools().map(t => t.name),
582
+ });
583
+ });
584
+
585
+ await mcpServer.connect(sseTransport);
586
+
587
+ const server = app.listen(port, () => {
588
+ console.log(`MCP Server (SSE) listening on port ${port}`);
589
+ console.log(`SSE endpoint: http://localhost:${port}/mcp/sse`);
590
+ console.log(`Health check: http://localhost:${port}/mcp/health`);
591
+ });
592
+
593
+ // Store server instance for cleanup
594
+ mcpServer._httpServer = server;
595
+ break;
596
+
597
+ case 'websocket':
598
+ // WebSocket transport - best for real-time bidirectional communication
599
+ const wsPort = options.port || 3001;
600
+ const wss = new WebSocketServer({ port: wsPort });
601
+
602
+ wss.on('connection', async (ws) => {
603
+ console.log('New WebSocket connection');
604
+ const wsTransport = new WebSocketServerTransport(ws);
605
+ await mcpServer.connect(wsTransport);
606
+ });
607
+
608
+ console.log(`MCP Server (WebSocket) listening on port ${wsPort}`);
609
+ console.log(`WebSocket URL: ws://localhost:${wsPort}`);
610
+
611
+ // Store WebSocket server instance for cleanup
612
+ mcpServer._wss = wss;
613
+ break;
614
+
615
+ default:
616
+ throw new Error(`Unknown transport type: ${transport}`);
617
+ }
618
+
619
+ console.log("Available Resources:", mcpServer.listResources().map(r => r.name));
620
+ console.log("Available Tools:", mcpServer.listTools().map(t => t.name));
621
+
622
+ } catch (error) {
623
+ console.error("Failed to start MCP Server:", error);
624
+ process.exit(1);
625
+ }
626
+ };
627
+
628
+ // Graceful shutdown handler
629
+ const shutdown = async () => {
630
+ console.log("\nShutting down MCP Server...");
631
+
632
+ if (mcpServer._httpServer) {
633
+ mcpServer._httpServer.close();
634
+ }
635
+
636
+ if (mcpServer._wss) {
637
+ mcpServer._wss.close();
638
+ }
639
+
640
+ await mcpServer.close();
641
+ process.exit(0);
642
+ };
643
+
644
+ process.on('SIGINT', shutdown);
645
+ process.on('SIGTERM', shutdown);
646
+
647
+ // Export the server instance and start function
648
+ export { mcpServer, startMCPServer, MCPError };
649
+
650
+ // If running directly, start with transport from environment or default to HTTP
651
+ if (import.meta.url === `file://${process.argv[1]}`) {
652
+ const transport = process.env.MCP_TRANSPORT || 'http';
653
+ const port = parseInt(process.env.MCP_PORT || '3001');
654
+ const cors = process.env.MCP_CORS || '*';
655
+
656
+ startMCPServer(transport, { port, cors });
657
+ }