@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,72 @@
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
+
6
+ const router = express.Router();
7
+
8
+ // Validation middleware for post creation
9
+ const validatePostCreation = [
10
+ // Title must exist and be a non-empty string
11
+ body("title").notEmpty().withMessage("Post title is required.").isString(),
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(),
17
+ // Status must be one of the allowed values if provided
18
+ body("status")
19
+ .optional()
20
+ .isIn(["published", "draft", "scheduled"])
21
+ .withMessage("Invalid status value."),
22
+ // custom_excerpt should be a string if provided
23
+ body("custom_excerpt").optional().isString(),
24
+ // published_at should be a valid ISO 8601 date if provided
25
+ body("published_at")
26
+ .optional()
27
+ .isISO8601()
28
+ .withMessage("Invalid date format for published_at (should be ISO 8601)."),
29
+ // tags should be an array if provided
30
+ body("tags").optional().isArray().withMessage("Tags must be an array."),
31
+ // 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(),
38
+ // Add validation for metadata fields (optional strings)
39
+ body("meta_title")
40
+ .optional()
41
+ .isString()
42
+ .isLength({ max: 300 })
43
+ .withMessage("Meta title cannot exceed 300 characters."),
44
+ body("meta_description")
45
+ .optional()
46
+ .isString()
47
+ .isLength({ max: 500 })
48
+ .withMessage("Meta description cannot exceed 500 characters."),
49
+ // Handle validation results
50
+ (req, res, next) => {
51
+ const logger = createContextLogger('post-routes');
52
+ const errors = validationResult(req);
53
+ if (!errors.isEmpty()) {
54
+ // Log the validation errors
55
+ logger.warn('Post validation errors', {
56
+ errors: errors.array(),
57
+ title: req.body?.title
58
+ });
59
+ return res.status(400).json({ errors: errors.array() });
60
+ }
61
+ next();
62
+ },
63
+ ];
64
+
65
+ // Define the route for creating a post
66
+ // POST /api/posts
67
+ // Apply the validation middleware before the controller
68
+ router.post("/", validatePostCreation, createPost);
69
+
70
+ // Add other post-related routes here later (e.g., GET /posts/:id, PUT /posts/:id)
71
+
72
+ export default router;
@@ -0,0 +1,47 @@
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
+
6
+ const router = express.Router();
7
+
8
+ // Validation middleware for tag creation
9
+ const validateTagCreation = [
10
+ body("name").notEmpty().withMessage("Tag name is required.").isString(),
11
+ body("description")
12
+ .optional()
13
+ .isString()
14
+ .isLength({ max: 500 })
15
+ .withMessage("Tag description cannot exceed 500 characters."),
16
+ body("slug")
17
+ .optional()
18
+ .isString()
19
+ .matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
20
+ .withMessage(
21
+ "Tag slug can only contain lower-case letters, numbers, and hyphens."
22
+ ),
23
+ // Handle validation results
24
+ (req, res, next) => {
25
+ const logger = createContextLogger('tag-routes');
26
+ const errors = validationResult(req);
27
+ if (!errors.isEmpty()) {
28
+ logger.warn('Tag validation errors', {
29
+ errors: errors.array(),
30
+ tagName: req.body?.name
31
+ });
32
+ return res.status(400).json({ errors: errors.array() });
33
+ }
34
+ next();
35
+ },
36
+ ];
37
+
38
+ // Define routes
39
+ // GET /api/tags (supports ?name=... query for filtering)
40
+ router.get("/", getTags);
41
+
42
+ // POST /api/tags
43
+ router.post("/", validateTagCreation, createTag);
44
+
45
+ // Add routes for other CRUD operations (GET /:id, PUT /:id, DELETE /:id) later if needed
46
+
47
+ export default router;
@@ -0,0 +1,221 @@
1
+ import GhostAdminAPI from "@tryghost/admin-api";
2
+ import dotenv from "dotenv";
3
+ import { createContextLogger } from "../utils/logger.js";
4
+
5
+ dotenv.config();
6
+
7
+ const logger = createContextLogger('ghost-service');
8
+ const { GHOST_ADMIN_API_URL, GHOST_ADMIN_API_KEY } = process.env;
9
+
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");
12
+ }
13
+
14
+ // Configure the Ghost Admin API client
15
+ const api = new GhostAdminAPI({
16
+ url: GHOST_ADMIN_API_URL,
17
+ key: GHOST_ADMIN_API_KEY,
18
+ // Specify the Ghost Admin API version
19
+ // Check your Ghost installation for the correct version
20
+ version: "v5.0", // Adjust if necessary
21
+ });
22
+
23
+ /**
24
+ * Generic handler for Ghost Admin API requests.
25
+ * Includes basic error handling and logging.
26
+ * @param {string} resource - The API resource (e.g., 'posts', 'tags', 'images').
27
+ * @param {string} action - The action to perform (e.g., 'add', 'browse', 'read', 'edit', 'delete', 'upload').
28
+ * @param {object} data - The data payload for the request (e.g., post content, image file).
29
+ * @param {object} options - Additional options for the API call (e.g., { include: 'tags' }).
30
+ * @param {number} retries - The number of retry attempts remaining.
31
+ * @returns {Promise<object>} The result from the Ghost Admin API.
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") {
41
+ const errorMsg = `Invalid Ghost API resource or action: ${resource}.${action}`;
42
+ console.error(errorMsg);
43
+ throw new Error(errorMsg);
44
+ }
45
+
46
+ try {
47
+ logger.apiRequest(`${resource}.${action}`, '', { retries, hasData: !!Object.keys(data).length });
48
+ // Log data payload carefully, avoiding sensitive info if necessary
49
+ // logger.debug('API request payload', { resource, action, dataKeys: Object.keys(data) });
50
+
51
+ let result;
52
+ if (Object.keys(options).length > 0) {
53
+ // Actions like 'add', 'edit' might take data first, then options
54
+ // Actions like 'browse', 'read' might take options first, then data (like an ID)
55
+ // The Ghost Admin API library structure varies slightly, this is a basic attempt
56
+ // We might need more specific handlers if this proves too simple.
57
+ if (action === "add" || action === "edit") {
58
+ result = await api[resource][action](data, options);
59
+ } else if (action === "upload") {
60
+ // Upload action has a specific signature
61
+ result = await api[resource][action](data); // data here is { ref, file } or similar
62
+ } else {
63
+ // Assume options come first for browse/read/delete with identifier in data
64
+ result = await api[resource][action](options, data);
65
+ }
66
+ } else {
67
+ // If no options, just pass the data
68
+ result = await api[resource][action](data);
69
+ }
70
+
71
+ logger.apiResponse(`${resource}.${action}`, '', 200, {
72
+ resultType: typeof result,
73
+ hasResult: !!result
74
+ });
75
+ return result;
76
+ } catch (error) {
77
+ logger.apiError(`${resource}.${action}`, '', error);
78
+
79
+ // Check for specific error types or status codes if available in the error object
80
+ // The structure of `error` depends on the Ghost API client library
81
+ const statusCode = error.response?.status; // Example: Check for Axios-like error structure
82
+ const isRateLimit = statusCode === 429;
83
+ const isServerError = statusCode >= 500;
84
+ const isNetworkError =
85
+ error.code === "ECONNREFUSED" || error.code === "ETIMEDOUT"; // Example network errors
86
+
87
+ if ((isRateLimit || isServerError || isNetworkError) && retries > 0) {
88
+ const delay = isRateLimit ? 5000 : 1000 * (4 - retries); // Longer delay for rate limit, increasing delay for others
89
+ logger.warn('Retrying Ghost API request', {
90
+ resource,
91
+ action,
92
+ delay,
93
+ retriesLeft: retries - 1,
94
+ reason: isRateLimit ? 'rate_limit' : isServerError ? 'server_error' : 'network_error'
95
+ });
96
+ await new Promise((resolve) => setTimeout(resolve, delay));
97
+ // Recursively call with decremented retries
98
+ return handleApiRequest(resource, action, data, options, retries - 1);
99
+ } else if (statusCode === 404) {
100
+ logger.warn('Ghost API resource not found', {
101
+ resource,
102
+ action,
103
+ id: data.id || 'N/A',
104
+ statusCode
105
+ });
106
+ // Decide how to handle 404 - maybe return null or let the error propagate
107
+ throw error; // Or return null;
108
+ } else {
109
+ logger.error('Non-retryable error or out of retries', {
110
+ resource,
111
+ action,
112
+ statusCode,
113
+ error: error.message
114
+ });
115
+ throw error; // Re-throw for upstream handling
116
+ }
117
+ }
118
+ };
119
+
120
+ // Example function (will be expanded later)
121
+ const getSiteInfo = async () => {
122
+ return handleApiRequest("site", "read");
123
+ // try {
124
+ // const site = await api.site.read();
125
+ // console.log("Connected to Ghost site:", site.title);
126
+ // return site;
127
+ // } catch (error) {
128
+ // console.error("Error connecting to Ghost Admin API:", error);
129
+ // throw error; // Re-throw the error for handling upstream
130
+ // }
131
+ };
132
+
133
+ /**
134
+ * Creates a new post in Ghost.
135
+ * @param {object} postData - The data for the new post.
136
+ * Should include properties like title, html or mobiledoc, status, etc.
137
+ * @param {object} options - Optional parameters like source: 'html'.
138
+ * @returns {Promise<object>} The created post object.
139
+ */
140
+ const createPost = async (postData, options = { source: "html" }) => {
141
+ if (!postData.title) {
142
+ throw new Error("Post title is required.");
143
+ }
144
+ // Add more validation as needed (e.g., for content)
145
+
146
+ // Default status to draft if not provided
147
+ const dataWithDefaults = {
148
+ status: "draft",
149
+ ...postData,
150
+ };
151
+
152
+ return handleApiRequest("posts", "add", dataWithDefaults, options);
153
+ };
154
+
155
+ /**
156
+ * Uploads an image to Ghost.
157
+ * Requires the image file path.
158
+ * @param {string} imagePath - The local path to the image file.
159
+ * @returns {Promise<object>} The result from the image upload API call, typically includes the URL of the uploaded image.
160
+ */
161
+ const uploadImage = async (imagePath) => {
162
+ if (!imagePath) {
163
+ throw new Error("Image path is required for upload.");
164
+ }
165
+
166
+ // The Ghost Admin API expects an object with a 'file' property containing the path
167
+ const imageData = { file: imagePath };
168
+
169
+ // Use the handleApiRequest function for consistency
170
+ return handleApiRequest("images", "upload", imageData);
171
+ };
172
+
173
+ /**
174
+ * Creates a new tag in Ghost.
175
+ * @param {object} tagData - Data for the new tag (e.g., { name: 'New Tag', slug: 'new-tag' }).
176
+ * @returns {Promise<object>} The created tag object.
177
+ */
178
+ const createTag = async (tagData) => {
179
+ if (!tagData.name) {
180
+ throw new Error("Tag name is required.");
181
+ }
182
+ // Ghost automatically generates slug if not provided, but providing is good practice
183
+ return handleApiRequest("tags", "add", tagData);
184
+ };
185
+
186
+ /**
187
+ * Retrieves tags from Ghost, optionally filtering by name.
188
+ * @param {string} [name] - Optional tag name to filter by.
189
+ * @returns {Promise<Array<object>>} An array of tag objects.
190
+ */
191
+ const getTags = async (name) => {
192
+ const options = {
193
+ limit: "all", // Get all tags
194
+ };
195
+
196
+ // Safely construct filter to prevent injection
197
+ if (name) {
198
+ // Additional validation: only allow alphanumeric, spaces, hyphens, underscores
199
+ if (!/^[a-zA-Z0-9\s\-_]+$/.test(name)) {
200
+ throw new Error("Tag name contains invalid characters");
201
+ }
202
+ // Escape single quotes and backslashes to prevent injection
203
+ const safeName = name.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
204
+ options.filter = `name:'${safeName}'`;
205
+ }
206
+
207
+ return handleApiRequest("tags", "browse", {}, options);
208
+ };
209
+
210
+ // Add other content management functions here (createTag, etc.)
211
+
212
+ // 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
+ };