@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.
@@ -1,6 +1,6 @@
1
- import express from "express";
2
- import rateLimit from "express-rate-limit";
3
- import { upload, handleImageUpload } from "../controllers/imageController.js";
1
+ import express from 'express';
2
+ import rateLimit from 'express-rate-limit';
3
+ import { upload, handleImageUpload } from '../controllers/imageController.js';
4
4
 
5
5
  const router = express.Router();
6
6
 
@@ -9,16 +9,16 @@ const router = express.Router();
9
9
  const imageUploadRateLimiter = rateLimit({
10
10
  windowMs: 60 * 1000, // 1 minute
11
11
  max: 10, // Limit each IP to 10 requests per windowMs
12
- message: "Too many image upload attempts from this IP, please try again after a minute",
12
+ message: 'Too many image upload attempts from this IP, please try again after a minute',
13
13
  standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
14
14
  legacyHeaders: false, // Disable the `X-RateLimit-*` headers
15
15
  handler: (req, res) => {
16
16
  res.status(429).json({
17
- error: "Too many requests",
18
- message: "You have exceeded the 10 image uploads per minute limit. Please try again later.",
19
- retryAfter: 60
17
+ error: 'Too many requests',
18
+ message: 'You have exceeded the 10 image uploads per minute limit. Please try again later.',
19
+ retryAfter: 60,
20
20
  });
21
- }
21
+ },
22
22
  });
23
23
 
24
24
  // Define the route for uploading an image
@@ -26,7 +26,7 @@ const imageUploadRateLimiter = rateLimit({
26
26
  // The `upload.single('image')` middleware handles the file upload.
27
27
  // 'image' should match the field name in the form-data request.
28
28
  // Added rate limiting to prevent abuse of file system operations
29
- router.post("/", imageUploadRateLimiter, upload.single("image"), handleImageUpload);
29
+ router.post('/', imageUploadRateLimiter, upload.single('image'), handleImageUpload);
30
30
 
31
31
  // Add other image-related routes here later if needed
32
32
 
@@ -1,51 +1,45 @@
1
- import express from "express";
2
- import { body, validationResult } from "express-validator";
3
- import { createPost } from "../controllers/postController.js";
4
- import { createContextLogger } from "../utils/logger.js";
1
+ import express from 'express';
2
+ import { body, validationResult } from 'express-validator';
3
+ import { createPost } from '../controllers/postController.js';
4
+ import { createContextLogger } from '../utils/logger.js';
5
5
 
6
6
  const router = express.Router();
7
7
 
8
8
  // Validation middleware for post creation
9
9
  const validatePostCreation = [
10
10
  // Title must exist and be a non-empty string
11
- body("title").notEmpty().withMessage("Post title is required.").isString(),
11
+ body('title').notEmpty().withMessage('Post title is required.').isString(),
12
12
  // HTML content must exist and be a non-empty string
13
- body("html")
14
- .notEmpty()
15
- .withMessage("Post HTML content is required.")
16
- .isString(),
13
+ body('html').notEmpty().withMessage('Post HTML content is required.').isString(),
17
14
  // Status must be one of the allowed values if provided
18
- body("status")
15
+ body('status')
19
16
  .optional()
20
- .isIn(["published", "draft", "scheduled"])
21
- .withMessage("Invalid status value."),
17
+ .isIn(['published', 'draft', 'scheduled'])
18
+ .withMessage('Invalid status value.'),
22
19
  // custom_excerpt should be a string if provided
23
- body("custom_excerpt").optional().isString(),
20
+ body('custom_excerpt').optional().isString(),
24
21
  // published_at should be a valid ISO 8601 date if provided
25
- body("published_at")
22
+ body('published_at')
26
23
  .optional()
27
24
  .isISO8601()
28
- .withMessage("Invalid date format for published_at (should be ISO 8601)."),
25
+ .withMessage('Invalid date format for published_at (should be ISO 8601).'),
29
26
  // tags should be an array if provided
30
- body("tags").optional().isArray().withMessage("Tags must be an array."),
27
+ body('tags').optional().isArray().withMessage('Tags must be an array.'),
31
28
  // Add validation for featured image fields (optional)
32
- body("feature_image")
33
- .optional()
34
- .isURL()
35
- .withMessage("Feature image must be a valid URL."),
36
- body("feature_image_alt").optional().isString(),
37
- body("feature_image_caption").optional().isString(),
29
+ body('feature_image').optional().isURL().withMessage('Feature image must be a valid URL.'),
30
+ body('feature_image_alt').optional().isString(),
31
+ body('feature_image_caption').optional().isString(),
38
32
  // Add validation for metadata fields (optional strings)
39
- body("meta_title")
33
+ body('meta_title')
40
34
  .optional()
41
35
  .isString()
42
36
  .isLength({ max: 300 })
43
- .withMessage("Meta title cannot exceed 300 characters."),
44
- body("meta_description")
37
+ .withMessage('Meta title cannot exceed 300 characters.'),
38
+ body('meta_description')
45
39
  .optional()
46
40
  .isString()
47
41
  .isLength({ max: 500 })
48
- .withMessage("Meta description cannot exceed 500 characters."),
42
+ .withMessage('Meta description cannot exceed 500 characters.'),
49
43
  // Handle validation results
50
44
  (req, res, next) => {
51
45
  const logger = createContextLogger('post-routes');
@@ -54,7 +48,7 @@ const validatePostCreation = [
54
48
  // Log the validation errors
55
49
  logger.warn('Post validation errors', {
56
50
  errors: errors.array(),
57
- title: req.body?.title
51
+ title: req.body?.title,
58
52
  });
59
53
  return res.status(400).json({ errors: errors.array() });
60
54
  }
@@ -65,7 +59,7 @@ const validatePostCreation = [
65
59
  // Define the route for creating a post
66
60
  // POST /api/posts
67
61
  // Apply the validation middleware before the controller
68
- router.post("/", validatePostCreation, createPost);
62
+ router.post('/', validatePostCreation, createPost);
69
63
 
70
64
  // Add other post-related routes here later (e.g., GET /posts/:id, PUT /posts/:id)
71
65
 
@@ -1,25 +1,23 @@
1
- import express from "express";
2
- import { body, validationResult } from "express-validator"; // Import for validation
3
- import { getTags, createTag } from "../controllers/tagController.js";
4
- import { createContextLogger } from "../utils/logger.js";
1
+ import express from 'express';
2
+ import { body, validationResult } from 'express-validator'; // Import for validation
3
+ import { getTags, createTag } from '../controllers/tagController.js';
4
+ import { createContextLogger } from '../utils/logger.js';
5
5
 
6
6
  const router = express.Router();
7
7
 
8
8
  // Validation middleware for tag creation
9
9
  const validateTagCreation = [
10
- body("name").notEmpty().withMessage("Tag name is required.").isString(),
11
- body("description")
10
+ body('name').notEmpty().withMessage('Tag name is required.').isString(),
11
+ body('description')
12
12
  .optional()
13
13
  .isString()
14
14
  .isLength({ max: 500 })
15
- .withMessage("Tag description cannot exceed 500 characters."),
16
- body("slug")
15
+ .withMessage('Tag description cannot exceed 500 characters.'),
16
+ body('slug')
17
17
  .optional()
18
18
  .isString()
19
19
  .matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
20
- .withMessage(
21
- "Tag slug can only contain lower-case letters, numbers, and hyphens."
22
- ),
20
+ .withMessage('Tag slug can only contain lower-case letters, numbers, and hyphens.'),
23
21
  // Handle validation results
24
22
  (req, res, next) => {
25
23
  const logger = createContextLogger('tag-routes');
@@ -27,7 +25,7 @@ const validateTagCreation = [
27
25
  if (!errors.isEmpty()) {
28
26
  logger.warn('Tag validation errors', {
29
27
  errors: errors.array(),
30
- tagName: req.body?.name
28
+ tagName: req.body?.name,
31
29
  });
32
30
  return res.status(400).json({ errors: errors.array() });
33
31
  }
@@ -37,10 +35,10 @@ const validateTagCreation = [
37
35
 
38
36
  // Define routes
39
37
  // GET /api/tags (supports ?name=... query for filtering)
40
- router.get("/", getTags);
38
+ router.get('/', getTags);
41
39
 
42
40
  // POST /api/tags
43
- router.post("/", validateTagCreation, createTag);
41
+ router.post('/', validateTagCreation, createTag);
44
42
 
45
43
  // Add routes for other CRUD operations (GET /:id, PUT /:id, DELETE /:id) later if needed
46
44
 
@@ -0,0 +1,118 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+
3
+ // Mock the Ghost Admin API
4
+ vi.mock('@tryghost/admin-api', () => {
5
+ const GhostAdminAPI = vi.fn(function () {
6
+ return {
7
+ posts: {
8
+ add: vi.fn(),
9
+ },
10
+ tags: {
11
+ add: vi.fn(),
12
+ browse: vi.fn(),
13
+ },
14
+ site: {
15
+ read: vi.fn(),
16
+ },
17
+ images: {
18
+ upload: vi.fn(),
19
+ },
20
+ };
21
+ });
22
+
23
+ return {
24
+ default: GhostAdminAPI,
25
+ };
26
+ });
27
+
28
+ // Mock dotenv
29
+ vi.mock('dotenv', () => ({
30
+ default: {
31
+ config: vi.fn(),
32
+ },
33
+ }));
34
+
35
+ // Mock logger
36
+ vi.mock('../../utils/logger.js', () => ({
37
+ createContextLogger: vi.fn(() => ({
38
+ apiRequest: vi.fn(),
39
+ apiResponse: vi.fn(),
40
+ apiError: vi.fn(),
41
+ warn: vi.fn(),
42
+ error: vi.fn(),
43
+ })),
44
+ }));
45
+
46
+ // Import after setting up mocks and environment
47
+ import { createPost, createTag, getTags } from '../ghostService.js';
48
+
49
+ describe('ghostService', () => {
50
+ describe('createPost', () => {
51
+ it('should throw error when title is missing', async () => {
52
+ await expect(createPost({})).rejects.toThrow('Post title is required');
53
+ });
54
+
55
+ it('should set default status to draft when not provided', async () => {
56
+ const postData = { title: 'Test Post', html: '<p>Content</p>' };
57
+
58
+ // The function should call the API with default status
59
+ try {
60
+ await createPost(postData);
61
+ } catch (_error) {
62
+ // Expected to fail since we're using a mock, but we can verify the behavior
63
+ }
64
+
65
+ expect(postData.title).toBe('Test Post');
66
+ });
67
+ });
68
+
69
+ describe('createTag', () => {
70
+ it('should throw error when tag name is missing', async () => {
71
+ await expect(createTag({})).rejects.toThrow('Tag name is required');
72
+ });
73
+
74
+ it('should accept valid tag data', async () => {
75
+ const tagData = { name: 'Test Tag', slug: 'test-tag' };
76
+
77
+ try {
78
+ await createTag(tagData);
79
+ } catch (_error) {
80
+ // Expected to fail with mock, but validates input handling
81
+ }
82
+
83
+ expect(tagData.name).toBe('Test Tag');
84
+ });
85
+ });
86
+
87
+ describe('getTags', () => {
88
+ it('should reject tag names with invalid characters', async () => {
89
+ await expect(getTags("'; DROP TABLE tags; --")).rejects.toThrow(
90
+ 'Tag name contains invalid characters'
91
+ );
92
+ });
93
+
94
+ it('should accept valid tag names', async () => {
95
+ const validNames = ['Test Tag', 'test-tag', 'test_tag', 'Tag123'];
96
+
97
+ for (const name of validNames) {
98
+ try {
99
+ await getTags(name);
100
+ } catch (_error) {
101
+ // Expected to fail with mock, but should not throw validation error
102
+ expect(_error.message).not.toContain('invalid characters');
103
+ }
104
+ }
105
+ });
106
+
107
+ it('should handle tag names without filter when name is not provided', async () => {
108
+ try {
109
+ await getTags();
110
+ } catch (_error) {
111
+ // Expected to fail with mock
112
+ }
113
+
114
+ // Should not throw validation error
115
+ expect(true).toBe(true);
116
+ });
117
+ });
118
+ });
@@ -1,6 +1,6 @@
1
- import GhostAdminAPI from "@tryghost/admin-api";
2
- import dotenv from "dotenv";
3
- import { createContextLogger } from "../utils/logger.js";
1
+ import GhostAdminAPI from '@tryghost/admin-api';
2
+ import dotenv from 'dotenv';
3
+ import { createContextLogger } from '../utils/logger.js';
4
4
 
5
5
  dotenv.config();
6
6
 
@@ -8,7 +8,7 @@ const logger = createContextLogger('ghost-service');
8
8
  const { GHOST_ADMIN_API_URL, GHOST_ADMIN_API_KEY } = process.env;
9
9
 
10
10
  if (!GHOST_ADMIN_API_URL || !GHOST_ADMIN_API_KEY) {
11
- throw new Error("Ghost Admin API URL and Key must be provided in .env file");
11
+ throw new Error('Ghost Admin API URL and Key must be provided in .env file');
12
12
  }
13
13
 
14
14
  // Configure the Ghost Admin API client
@@ -17,7 +17,7 @@ const api = new GhostAdminAPI({
17
17
  key: GHOST_ADMIN_API_KEY,
18
18
  // Specify the Ghost Admin API version
19
19
  // Check your Ghost installation for the correct version
20
- version: "v5.0", // Adjust if necessary
20
+ version: 'v5.0', // Adjust if necessary
21
21
  });
22
22
 
23
23
  /**
@@ -30,21 +30,18 @@ const api = new GhostAdminAPI({
30
30
  * @param {number} retries - The number of retry attempts remaining.
31
31
  * @returns {Promise<object>} The result from the Ghost Admin API.
32
32
  */
33
- const handleApiRequest = async (
34
- resource,
35
- action,
36
- data = {},
37
- options = {},
38
- retries = 3
39
- ) => {
40
- if (!api[resource] || typeof api[resource][action] !== "function") {
33
+ const handleApiRequest = async (resource, action, data = {}, options = {}, retries = 3) => {
34
+ if (!api[resource] || typeof api[resource][action] !== 'function') {
41
35
  const errorMsg = `Invalid Ghost API resource or action: ${resource}.${action}`;
42
36
  console.error(errorMsg);
43
37
  throw new Error(errorMsg);
44
38
  }
45
39
 
46
40
  try {
47
- logger.apiRequest(`${resource}.${action}`, '', { retries, hasData: !!Object.keys(data).length });
41
+ logger.apiRequest(`${resource}.${action}`, '', {
42
+ retries,
43
+ hasData: !!Object.keys(data).length,
44
+ });
48
45
  // Log data payload carefully, avoiding sensitive info if necessary
49
46
  // logger.debug('API request payload', { resource, action, dataKeys: Object.keys(data) });
50
47
 
@@ -54,9 +51,9 @@ const handleApiRequest = async (
54
51
  // Actions like 'browse', 'read' might take options first, then data (like an ID)
55
52
  // The Ghost Admin API library structure varies slightly, this is a basic attempt
56
53
  // We might need more specific handlers if this proves too simple.
57
- if (action === "add" || action === "edit") {
54
+ if (action === 'add' || action === 'edit') {
58
55
  result = await api[resource][action](data, options);
59
- } else if (action === "upload") {
56
+ } else if (action === 'upload') {
60
57
  // Upload action has a specific signature
61
58
  result = await api[resource][action](data); // data here is { ref, file } or similar
62
59
  } else {
@@ -68,9 +65,9 @@ const handleApiRequest = async (
68
65
  result = await api[resource][action](data);
69
66
  }
70
67
 
71
- logger.apiResponse(`${resource}.${action}`, '', 200, {
68
+ logger.apiResponse(`${resource}.${action}`, '', 200, {
72
69
  resultType: typeof result,
73
- hasResult: !!result
70
+ hasResult: !!result,
74
71
  });
75
72
  return result;
76
73
  } catch (error) {
@@ -81,8 +78,7 @@ const handleApiRequest = async (
81
78
  const statusCode = error.response?.status; // Example: Check for Axios-like error structure
82
79
  const isRateLimit = statusCode === 429;
83
80
  const isServerError = statusCode >= 500;
84
- const isNetworkError =
85
- error.code === "ECONNREFUSED" || error.code === "ETIMEDOUT"; // Example network errors
81
+ const isNetworkError = error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT'; // Example network errors
86
82
 
87
83
  if ((isRateLimit || isServerError || isNetworkError) && retries > 0) {
88
84
  const delay = isRateLimit ? 5000 : 1000 * (4 - retries); // Longer delay for rate limit, increasing delay for others
@@ -91,7 +87,7 @@ const handleApiRequest = async (
91
87
  action,
92
88
  delay,
93
89
  retriesLeft: retries - 1,
94
- reason: isRateLimit ? 'rate_limit' : isServerError ? 'server_error' : 'network_error'
90
+ reason: isRateLimit ? 'rate_limit' : isServerError ? 'server_error' : 'network_error',
95
91
  });
96
92
  await new Promise((resolve) => setTimeout(resolve, delay));
97
93
  // Recursively call with decremented retries
@@ -101,7 +97,7 @@ const handleApiRequest = async (
101
97
  resource,
102
98
  action,
103
99
  id: data.id || 'N/A',
104
- statusCode
100
+ statusCode,
105
101
  });
106
102
  // Decide how to handle 404 - maybe return null or let the error propagate
107
103
  throw error; // Or return null;
@@ -110,7 +106,7 @@ const handleApiRequest = async (
110
106
  resource,
111
107
  action,
112
108
  statusCode,
113
- error: error.message
109
+ error: error.message,
114
110
  });
115
111
  throw error; // Re-throw for upstream handling
116
112
  }
@@ -119,7 +115,7 @@ const handleApiRequest = async (
119
115
 
120
116
  // Example function (will be expanded later)
121
117
  const getSiteInfo = async () => {
122
- return handleApiRequest("site", "read");
118
+ return handleApiRequest('site', 'read');
123
119
  // try {
124
120
  // const site = await api.site.read();
125
121
  // console.log("Connected to Ghost site:", site.title);
@@ -137,19 +133,19 @@ const getSiteInfo = async () => {
137
133
  * @param {object} options - Optional parameters like source: 'html'.
138
134
  * @returns {Promise<object>} The created post object.
139
135
  */
140
- const createPost = async (postData, options = { source: "html" }) => {
136
+ const createPost = async (postData, options = { source: 'html' }) => {
141
137
  if (!postData.title) {
142
- throw new Error("Post title is required.");
138
+ throw new Error('Post title is required.');
143
139
  }
144
140
  // Add more validation as needed (e.g., for content)
145
141
 
146
142
  // Default status to draft if not provided
147
143
  const dataWithDefaults = {
148
- status: "draft",
144
+ status: 'draft',
149
145
  ...postData,
150
146
  };
151
147
 
152
- return handleApiRequest("posts", "add", dataWithDefaults, options);
148
+ return handleApiRequest('posts', 'add', dataWithDefaults, options);
153
149
  };
154
150
 
155
151
  /**
@@ -160,14 +156,14 @@ const createPost = async (postData, options = { source: "html" }) => {
160
156
  */
161
157
  const uploadImage = async (imagePath) => {
162
158
  if (!imagePath) {
163
- throw new Error("Image path is required for upload.");
159
+ throw new Error('Image path is required for upload.');
164
160
  }
165
161
 
166
162
  // The Ghost Admin API expects an object with a 'file' property containing the path
167
163
  const imageData = { file: imagePath };
168
164
 
169
165
  // Use the handleApiRequest function for consistency
170
- return handleApiRequest("images", "upload", imageData);
166
+ return handleApiRequest('images', 'upload', imageData);
171
167
  };
172
168
 
173
169
  /**
@@ -177,10 +173,10 @@ const uploadImage = async (imagePath) => {
177
173
  */
178
174
  const createTag = async (tagData) => {
179
175
  if (!tagData.name) {
180
- throw new Error("Tag name is required.");
176
+ throw new Error('Tag name is required.');
181
177
  }
182
178
  // Ghost automatically generates slug if not provided, but providing is good practice
183
- return handleApiRequest("tags", "add", tagData);
179
+ return handleApiRequest('tags', 'add', tagData);
184
180
  };
185
181
 
186
182
  /**
@@ -190,32 +186,24 @@ const createTag = async (tagData) => {
190
186
  */
191
187
  const getTags = async (name) => {
192
188
  const options = {
193
- limit: "all", // Get all tags
189
+ limit: 'all', // Get all tags
194
190
  };
195
-
191
+
196
192
  // Safely construct filter to prevent injection
197
193
  if (name) {
198
194
  // Additional validation: only allow alphanumeric, spaces, hyphens, underscores
199
195
  if (!/^[a-zA-Z0-9\s\-_]+$/.test(name)) {
200
- throw new Error("Tag name contains invalid characters");
196
+ throw new Error('Tag name contains invalid characters');
201
197
  }
202
198
  // Escape single quotes and backslashes to prevent injection
203
199
  const safeName = name.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
204
200
  options.filter = `name:'${safeName}'`;
205
201
  }
206
-
207
- return handleApiRequest("tags", "browse", {}, options);
202
+
203
+ return handleApiRequest('tags', 'browse', {}, options);
208
204
  };
209
205
 
210
206
  // Add other content management functions here (createTag, etc.)
211
207
 
212
208
  // Export the API client instance and any service functions
213
- export {
214
- api,
215
- getSiteInfo,
216
- handleApiRequest,
217
- createPost,
218
- uploadImage,
219
- createTag,
220
- getTags,
221
- };
209
+ export { api, getSiteInfo, handleApiRequest, createPost, uploadImage, createTag, getTags };