@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,79 @@
1
+ import {
2
+ getTags as getGhostTags,
3
+ createTag as createGhostTag,
4
+ } from "../services/ghostService.js";
5
+ import { createContextLogger } from "../utils/logger.js";
6
+
7
+ /**
8
+ * Controller to handle fetching tags.
9
+ * Can optionally filter by tag name via query parameter.
10
+ */
11
+ const getTags = async (req, res, next) => {
12
+ const logger = createContextLogger('tag-controller');
13
+
14
+ try {
15
+ const { name } = req.query; // Get name from query params like /api/tags?name=some-tag
16
+ logger.info('Fetching tags', {
17
+ filtered: !!name,
18
+ filterName: name
19
+ });
20
+
21
+ const tags = await getGhostTags(name);
22
+
23
+ logger.info('Tags retrieved successfully', {
24
+ count: tags.length,
25
+ filtered: !!name
26
+ });
27
+
28
+ res.status(200).json(tags);
29
+ } catch (error) {
30
+ logger.error('Get tags failed', {
31
+ error: error.message,
32
+ filterName: req.query?.name
33
+ });
34
+ next(error);
35
+ }
36
+ };
37
+
38
+ /**
39
+ * Controller to handle creating a new tag.
40
+ */
41
+ const createTag = async (req, res, next) => {
42
+ const logger = createContextLogger('tag-controller');
43
+
44
+ try {
45
+ // Basic validation (more could be added via express-validator)
46
+ const { name, description, slug, ...otherData } = req.body;
47
+ if (!name) {
48
+ logger.warn('Tag creation attempted without name');
49
+ return res.status(400).json({ message: "Tag name is required." });
50
+ }
51
+ const tagData = { name, description, slug, ...otherData };
52
+
53
+ logger.info('Creating tag', {
54
+ name,
55
+ hasDescription: !!description,
56
+ hasSlug: !!slug
57
+ });
58
+
59
+ const newTag = await createGhostTag(tagData);
60
+
61
+ logger.info('Tag created successfully', {
62
+ tagId: newTag.id,
63
+ name: newTag.name,
64
+ slug: newTag.slug
65
+ });
66
+
67
+ res.status(201).json(newTag);
68
+ } catch (error) {
69
+ logger.error('Tag creation failed', {
70
+ error: error.message,
71
+ tagName: req.body?.name
72
+ });
73
+ next(error);
74
+ }
75
+ };
76
+
77
+ // Add controllers for other CRUD operations (getTagById, updateTag, deleteTag) if needed later
78
+
79
+ export { getTags, createTag };
@@ -0,0 +1,447 @@
1
+ /**
2
+ * Comprehensive Error Handling System for MCP Server
3
+ * Following best practices for error handling in Node.js applications
4
+ */
5
+
6
+ /**
7
+ * Base error class with structured error information
8
+ */
9
+ export class BaseError extends Error {
10
+ constructor(message, statusCode = 500, code = 'INTERNAL_ERROR', isOperational = true) {
11
+ super(message);
12
+ this.name = this.constructor.name;
13
+ this.statusCode = statusCode;
14
+ this.code = code;
15
+ this.isOperational = isOperational;
16
+ this.timestamp = new Date().toISOString();
17
+
18
+ // Capture stack trace
19
+ Error.captureStackTrace(this, this.constructor);
20
+ }
21
+
22
+ toJSON() {
23
+ return {
24
+ name: this.name,
25
+ message: this.message,
26
+ code: this.code,
27
+ statusCode: this.statusCode,
28
+ timestamp: this.timestamp,
29
+ ...(process.env.NODE_ENV === 'development' && { stack: this.stack })
30
+ };
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Validation Error - 400
36
+ */
37
+ export class ValidationError extends BaseError {
38
+ constructor(message, errors = []) {
39
+ super(message, 400, 'VALIDATION_ERROR');
40
+ this.errors = errors;
41
+ }
42
+
43
+ static fromJoi(joiError) {
44
+ const errors = joiError.details.map(detail => ({
45
+ field: detail.path.join('.'),
46
+ message: detail.message,
47
+ type: detail.type
48
+ }));
49
+ return new ValidationError('Validation failed', errors);
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Authentication Error - 401
55
+ */
56
+ export class AuthenticationError extends BaseError {
57
+ constructor(message = 'Authentication failed') {
58
+ super(message, 401, 'AUTHENTICATION_ERROR');
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Authorization Error - 403
64
+ */
65
+ export class AuthorizationError extends BaseError {
66
+ constructor(message = 'Access denied') {
67
+ super(message, 403, 'AUTHORIZATION_ERROR');
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Not Found Error - 404
73
+ */
74
+ export class NotFoundError extends BaseError {
75
+ constructor(resource, identifier) {
76
+ super(`${resource} not found: ${identifier}`, 404, 'NOT_FOUND');
77
+ this.resource = resource;
78
+ this.identifier = identifier;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Conflict Error - 409
84
+ */
85
+ export class ConflictError extends BaseError {
86
+ constructor(message, resource) {
87
+ super(message, 409, 'CONFLICT');
88
+ this.resource = resource;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Rate Limit Error - 429
94
+ */
95
+ export class RateLimitError extends BaseError {
96
+ constructor(retryAfter = 60) {
97
+ super('Rate limit exceeded', 429, 'RATE_LIMIT_EXCEEDED');
98
+ this.retryAfter = retryAfter;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * External Service Error - 502
104
+ */
105
+ export class ExternalServiceError extends BaseError {
106
+ constructor(service, originalError) {
107
+ super(`External service error: ${service}`, 502, 'EXTERNAL_SERVICE_ERROR');
108
+ this.service = service;
109
+ this.originalError = originalError?.message || originalError;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Ghost API specific errors
115
+ */
116
+ export class GhostAPIError extends ExternalServiceError {
117
+ constructor(operation, originalError, statusCode) {
118
+ super('Ghost API', originalError);
119
+ this.operation = operation;
120
+ this.ghostStatusCode = statusCode;
121
+
122
+ // Map Ghost API status codes to our error types
123
+ if (statusCode === 401) {
124
+ this.statusCode = 401;
125
+ this.code = 'GHOST_AUTH_ERROR';
126
+ } else if (statusCode === 404) {
127
+ this.statusCode = 404;
128
+ this.code = 'GHOST_NOT_FOUND';
129
+ } else if (statusCode === 422) {
130
+ this.statusCode = 400;
131
+ this.code = 'GHOST_VALIDATION_ERROR';
132
+ } else if (statusCode === 429) {
133
+ this.statusCode = 429;
134
+ this.code = 'GHOST_RATE_LIMIT';
135
+ }
136
+ }
137
+ }
138
+
139
+ /**
140
+ * MCP Protocol Error
141
+ */
142
+ export class MCPProtocolError extends BaseError {
143
+ constructor(message, details = {}) {
144
+ super(message, 400, 'MCP_PROTOCOL_ERROR');
145
+ this.details = details;
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Tool Execution Error
151
+ */
152
+ export class ToolExecutionError extends BaseError {
153
+ constructor(toolName, originalError, input = {}) {
154
+ const message = `Tool execution failed: ${toolName}`;
155
+ super(message, 500, 'TOOL_EXECUTION_ERROR');
156
+ this.toolName = toolName;
157
+ this.originalError = originalError?.message || originalError;
158
+ this.input = input;
159
+
160
+ // Don't expose sensitive data in production
161
+ if (process.env.NODE_ENV === 'production') {
162
+ delete this.input.apiKey;
163
+ delete this.input.password;
164
+ delete this.input.token;
165
+ }
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Image Processing Error
171
+ */
172
+ export class ImageProcessingError extends BaseError {
173
+ constructor(operation, originalError) {
174
+ super(`Image processing failed: ${operation}`, 422, 'IMAGE_PROCESSING_ERROR');
175
+ this.operation = operation;
176
+ this.originalError = originalError?.message || originalError;
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Configuration Error
182
+ */
183
+ export class ConfigurationError extends BaseError {
184
+ constructor(message, missingFields = []) {
185
+ super(message, 500, 'CONFIGURATION_ERROR', false);
186
+ this.missingFields = missingFields;
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Error handler utility functions
192
+ */
193
+ export class ErrorHandler {
194
+ /**
195
+ * Determine if error is operational (expected) or programming error
196
+ */
197
+ static isOperationalError(error) {
198
+ if (error instanceof BaseError) {
199
+ return error.isOperational;
200
+ }
201
+ return false;
202
+ }
203
+
204
+ /**
205
+ * Format error for MCP response
206
+ */
207
+ static formatMCPError(error, toolName = null) {
208
+ if (error instanceof BaseError) {
209
+ return {
210
+ error: {
211
+ code: error.code,
212
+ message: error.message,
213
+ statusCode: error.statusCode,
214
+ ...(toolName && { tool: toolName }),
215
+ ...(error.errors && { validationErrors: error.errors }),
216
+ ...(error.retryAfter && { retryAfter: error.retryAfter }),
217
+ timestamp: error.timestamp
218
+ }
219
+ };
220
+ }
221
+
222
+ // Unknown error - be careful not to leak sensitive info
223
+ return {
224
+ error: {
225
+ code: 'UNKNOWN_ERROR',
226
+ message: process.env.NODE_ENV === 'production'
227
+ ? 'An unexpected error occurred'
228
+ : error.message,
229
+ statusCode: 500,
230
+ ...(toolName && { tool: toolName }),
231
+ timestamp: new Date().toISOString()
232
+ }
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Format error for HTTP response
238
+ */
239
+ static formatHTTPError(error) {
240
+ if (error instanceof BaseError) {
241
+ const response = {
242
+ error: {
243
+ code: error.code,
244
+ message: error.message,
245
+ ...(error.errors && { errors: error.errors }),
246
+ ...(error.retryAfter && { retryAfter: error.retryAfter }),
247
+ ...(error.resource && { resource: error.resource })
248
+ }
249
+ };
250
+
251
+ return {
252
+ statusCode: error.statusCode,
253
+ body: response
254
+ };
255
+ }
256
+
257
+ // Unknown error
258
+ return {
259
+ statusCode: 500,
260
+ body: {
261
+ error: {
262
+ code: 'INTERNAL_ERROR',
263
+ message: process.env.NODE_ENV === 'production'
264
+ ? 'An internal error occurred'
265
+ : error.message
266
+ }
267
+ }
268
+ };
269
+ }
270
+
271
+ /**
272
+ * Wrap async functions with error handling
273
+ */
274
+ static asyncWrapper(fn) {
275
+ return async (...args) => {
276
+ try {
277
+ return await fn(...args);
278
+ } catch (error) {
279
+ if (!ErrorHandler.isOperationalError(error)) {
280
+ // Log programming errors
281
+ console.error('Unexpected error:', error);
282
+ }
283
+ throw error;
284
+ }
285
+ };
286
+ }
287
+
288
+ /**
289
+ * Create error from Ghost API response
290
+ */
291
+ static fromGhostError(error, operation) {
292
+ const statusCode = error.response?.status || error.statusCode;
293
+ const message = error.response?.data?.errors?.[0]?.message || error.message;
294
+
295
+ return new GhostAPIError(operation, message, statusCode);
296
+ }
297
+
298
+ /**
299
+ * Check if error is retryable
300
+ */
301
+ static isRetryable(error) {
302
+ if (error instanceof RateLimitError) return true;
303
+ if (error instanceof ExternalServiceError) return true;
304
+ if (error instanceof GhostAPIError) {
305
+ return [429, 502, 503, 504].includes(error.ghostStatusCode);
306
+ }
307
+
308
+ // Network errors
309
+ if (error.code === 'ECONNREFUSED' ||
310
+ error.code === 'ETIMEDOUT' ||
311
+ error.code === 'ECONNRESET') {
312
+ return true;
313
+ }
314
+
315
+ return false;
316
+ }
317
+
318
+ /**
319
+ * Calculate retry delay with exponential backoff
320
+ */
321
+ static getRetryDelay(attempt, error) {
322
+ if (error instanceof RateLimitError) {
323
+ return error.retryAfter * 1000; // Convert to milliseconds
324
+ }
325
+
326
+ // Exponential backoff: 1s, 2s, 4s, 8s...
327
+ const baseDelay = 1000;
328
+ const maxDelay = 30000;
329
+ const delay = Math.min(baseDelay * Math.pow(2, attempt - 1), maxDelay);
330
+
331
+ // Add jitter to prevent thundering herd
332
+ const jitter = Math.random() * 0.3 * delay;
333
+
334
+ return Math.round(delay + jitter);
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Circuit Breaker for external services
340
+ */
341
+ export class CircuitBreaker {
342
+ constructor(options = {}) {
343
+ this.failureThreshold = options.failureThreshold || 5;
344
+ this.resetTimeout = options.resetTimeout || 60000; // 1 minute
345
+ this.monitoringPeriod = options.monitoringPeriod || 10000; // 10 seconds
346
+
347
+ this.state = 'CLOSED'; // CLOSED, OPEN, HALF_OPEN
348
+ this.failureCount = 0;
349
+ this.lastFailureTime = null;
350
+ this.nextAttempt = null;
351
+ }
352
+
353
+ async execute(fn, ...args) {
354
+ if (this.state === 'OPEN') {
355
+ if (Date.now() < this.nextAttempt) {
356
+ throw new ExternalServiceError('Circuit breaker is OPEN', 'Service temporarily unavailable');
357
+ }
358
+ this.state = 'HALF_OPEN';
359
+ }
360
+
361
+ try {
362
+ const result = await fn(...args);
363
+ this.onSuccess();
364
+ return result;
365
+ } catch (error) {
366
+ this.onFailure();
367
+ throw error;
368
+ }
369
+ }
370
+
371
+ onSuccess() {
372
+ if (this.state === 'HALF_OPEN') {
373
+ this.state = 'CLOSED';
374
+ }
375
+ this.failureCount = 0;
376
+ this.lastFailureTime = null;
377
+ }
378
+
379
+ onFailure() {
380
+ this.failureCount++;
381
+ this.lastFailureTime = Date.now();
382
+
383
+ if (this.failureCount >= this.failureThreshold) {
384
+ this.state = 'OPEN';
385
+ this.nextAttempt = Date.now() + this.resetTimeout;
386
+ console.error(`Circuit breaker opened. Will retry at ${new Date(this.nextAttempt).toISOString()}`);
387
+ }
388
+ }
389
+
390
+ getState() {
391
+ return {
392
+ state: this.state,
393
+ failureCount: this.failureCount,
394
+ lastFailureTime: this.lastFailureTime,
395
+ nextAttempt: this.nextAttempt
396
+ };
397
+ }
398
+ }
399
+
400
+ /**
401
+ * Retry mechanism with exponential backoff
402
+ */
403
+ export async function retryWithBackoff(fn, options = {}) {
404
+ const maxAttempts = options.maxAttempts || 3;
405
+ const onRetry = options.onRetry || (() => {});
406
+
407
+ let lastError;
408
+
409
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
410
+ try {
411
+ return await fn();
412
+ } catch (error) {
413
+ lastError = error;
414
+
415
+ if (attempt === maxAttempts || !ErrorHandler.isRetryable(error)) {
416
+ throw error;
417
+ }
418
+
419
+ const delay = ErrorHandler.getRetryDelay(attempt, error);
420
+ console.log(`Retry attempt ${attempt}/${maxAttempts} after ${delay}ms`);
421
+
422
+ await new Promise(resolve => setTimeout(resolve, delay));
423
+ onRetry(attempt, error);
424
+ }
425
+ }
426
+
427
+ throw lastError;
428
+ }
429
+
430
+ export default {
431
+ BaseError,
432
+ ValidationError,
433
+ AuthenticationError,
434
+ AuthorizationError,
435
+ NotFoundError,
436
+ ConflictError,
437
+ RateLimitError,
438
+ ExternalServiceError,
439
+ GhostAPIError,
440
+ MCPProtocolError,
441
+ ToolExecutionError,
442
+ ImageProcessingError,
443
+ ConfigurationError,
444
+ ErrorHandler,
445
+ CircuitBreaker,
446
+ retryWithBackoff
447
+ };
package/src/index.js ADDED
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env node
2
+ import express from "express";
3
+ import helmet from "helmet";
4
+ import dotenv from "dotenv";
5
+ import postRoutes from "./routes/postRoutes.js"; // Import post routes
6
+ import imageRoutes from "./routes/imageRoutes.js"; // Import image routes
7
+ import tagRoutes from "./routes/tagRoutes.js"; // Import tag routes
8
+ import { startMCPServer } from "./mcp_server.js"; // Import MCP server start function
9
+ import { createContextLogger } from "./utils/logger.js";
10
+
11
+ // Load environment variables from .env file
12
+ dotenv.config();
13
+
14
+ // Initialize logger for main server
15
+ const logger = createContextLogger('main');
16
+
17
+ const app = express();
18
+ const restApiPort = process.env.PORT || 3000;
19
+ const mcpPort = process.env.MCP_PORT || 3001; // Allow configuring MCP port
20
+
21
+ // Apply security headers with Helmet (OWASP recommended)
22
+ app.use(helmet({
23
+ contentSecurityPolicy: {
24
+ directives: {
25
+ defaultSrc: ["'self'"],
26
+ styleSrc: ["'self'", "'unsafe-inline'"],
27
+ scriptSrc: ["'self'"],
28
+ imgSrc: ["'self'", "data:", "https:"],
29
+ connectSrc: ["'self'"],
30
+ fontSrc: ["'self'"],
31
+ objectSrc: ["'none'"],
32
+ mediaSrc: ["'self'"],
33
+ frameSrc: ["'none'"],
34
+ },
35
+ },
36
+ hsts: {
37
+ maxAge: 31536000,
38
+ includeSubDomains: true,
39
+ preload: true
40
+ }
41
+ }))
42
+
43
+ // Middleware to parse JSON bodies with size limits
44
+ app.use(express.json({
45
+ limit: '1mb',
46
+ strict: true,
47
+ type: 'application/json'
48
+ }));
49
+ // Middleware to parse URL-encoded bodies with size limits
50
+ app.use(express.urlencoded({
51
+ extended: true,
52
+ limit: '1mb',
53
+ parameterLimit: 100
54
+ }));
55
+
56
+ // Health check endpoint
57
+ app.get("/health", (req, res) => {
58
+ res.status(200).json({ status: "ok", message: "Server is running" });
59
+ });
60
+
61
+ // Mount the post routes
62
+ app.use("/api/posts", postRoutes); // All post routes will be prefixed with /api/posts
63
+ // Mount the image routes
64
+ app.use("/api/images", imageRoutes);
65
+ // Mount the tag routes
66
+ app.use("/api/tags", tagRoutes);
67
+
68
+ // Global error handler for Express
69
+ app.use((err, req, res, next) => {
70
+ const statusCode = err.statusCode || err.response?.status || 500;
71
+
72
+ logger.error('Express error handler triggered', {
73
+ error: err.message,
74
+ statusCode,
75
+ stack: err.stack,
76
+ url: req.url,
77
+ method: req.method,
78
+ type: 'express_error'
79
+ });
80
+
81
+ res.status(statusCode).json({
82
+ message: err.message || "Internal Server Error",
83
+ // Optionally include stack trace in development
84
+ stack: process.env.NODE_ENV === "development" ? err.stack : undefined,
85
+ });
86
+ });
87
+
88
+ // Start both servers
89
+ const startServers = async () => {
90
+ // Start Express server
91
+ app.listen(restApiPort, () => {
92
+ logger.info('Express REST API server started', {
93
+ port: restApiPort,
94
+ url: `http://localhost:${restApiPort}`,
95
+ type: 'server_start'
96
+ });
97
+ });
98
+
99
+ // Start MCP server
100
+ await startMCPServer(mcpPort);
101
+ };
102
+
103
+ startServers().catch((error) => {
104
+ logger.error('Failed to start servers', {
105
+ error: error.message,
106
+ stack: error.stack,
107
+ type: 'server_start_error'
108
+ });
109
+ process.exit(1);
110
+ });