@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.
- package/LICENSE +21 -0
- package/README.md +118 -0
- package/package.json +89 -0
- package/src/config/mcp-config.js +131 -0
- package/src/controllers/imageController.js +271 -0
- package/src/controllers/postController.js +46 -0
- package/src/controllers/tagController.js +79 -0
- package/src/errors/index.js +447 -0
- package/src/index.js +110 -0
- package/src/mcp_server.js +509 -0
- package/src/mcp_server_enhanced.js +675 -0
- package/src/mcp_server_improved.js +657 -0
- package/src/middleware/errorMiddleware.js +489 -0
- package/src/resources/ResourceManager.js +666 -0
- package/src/routes/imageRoutes.js +33 -0
- package/src/routes/postRoutes.js +72 -0
- package/src/routes/tagRoutes.js +47 -0
- package/src/services/ghostService.js +221 -0
- package/src/services/ghostServiceImproved.js +489 -0
- package/src/services/imageProcessingService.js +96 -0
- package/src/services/postService.js +174 -0
- package/src/utils/logger.js +153 -0
- package/src/utils/urlValidator.js +169 -0
|
@@ -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
|
+
};
|