@jgardner04/ghost-mcp-server 1.0.0 → 1.1.1

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