@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/README.md +0 -3
- package/package.json +21 -8
- package/src/config/mcp-config.js +31 -22
- package/src/controllers/imageController.js +62 -62
- package/src/controllers/postController.js +8 -8
- package/src/controllers/tagController.js +17 -20
- package/src/errors/index.js +49 -44
- package/src/index.js +56 -50
- package/src/mcp_server.js +151 -178
- package/src/mcp_server_enhanced.js +265 -259
- package/src/mcp_server_improved.js +217 -582
- package/src/middleware/errorMiddleware.js +69 -70
- package/src/resources/ResourceManager.js +143 -134
- package/src/routes/imageRoutes.js +9 -9
- package/src/routes/postRoutes.js +22 -28
- package/src/routes/tagRoutes.js +12 -14
- package/src/services/__tests__/ghostService.test.js +118 -0
- package/src/services/ghostService.js +34 -46
- package/src/services/ghostServiceImproved.js +125 -109
- package/src/services/imageProcessingService.js +15 -15
- package/src/services/postService.js +22 -22
- package/src/utils/logger.js +50 -50
- package/src/utils/urlValidator.js +37 -38
package/src/errors/index.js
CHANGED
|
@@ -14,7 +14,7 @@ export class BaseError extends Error {
|
|
|
14
14
|
this.code = code;
|
|
15
15
|
this.isOperational = isOperational;
|
|
16
16
|
this.timestamp = new Date().toISOString();
|
|
17
|
-
|
|
17
|
+
|
|
18
18
|
// Capture stack trace
|
|
19
19
|
Error.captureStackTrace(this, this.constructor);
|
|
20
20
|
}
|
|
@@ -26,7 +26,7 @@ export class BaseError extends Error {
|
|
|
26
26
|
code: this.code,
|
|
27
27
|
statusCode: this.statusCode,
|
|
28
28
|
timestamp: this.timestamp,
|
|
29
|
-
...(process.env.NODE_ENV === 'development' && { stack: this.stack })
|
|
29
|
+
...(process.env.NODE_ENV === 'development' && { stack: this.stack }),
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
32
|
}
|
|
@@ -41,10 +41,10 @@ export class ValidationError extends BaseError {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
static fromJoi(joiError) {
|
|
44
|
-
const errors = joiError.details.map(detail => ({
|
|
44
|
+
const errors = joiError.details.map((detail) => ({
|
|
45
45
|
field: detail.path.join('.'),
|
|
46
46
|
message: detail.message,
|
|
47
|
-
type: detail.type
|
|
47
|
+
type: detail.type,
|
|
48
48
|
}));
|
|
49
49
|
return new ValidationError('Validation failed', errors);
|
|
50
50
|
}
|
|
@@ -118,7 +118,7 @@ export class GhostAPIError extends ExternalServiceError {
|
|
|
118
118
|
super('Ghost API', originalError);
|
|
119
119
|
this.operation = operation;
|
|
120
120
|
this.ghostStatusCode = statusCode;
|
|
121
|
-
|
|
121
|
+
|
|
122
122
|
// Map Ghost API status codes to our error types
|
|
123
123
|
if (statusCode === 401) {
|
|
124
124
|
this.statusCode = 401;
|
|
@@ -156,7 +156,7 @@ export class ToolExecutionError extends BaseError {
|
|
|
156
156
|
this.toolName = toolName;
|
|
157
157
|
this.originalError = originalError?.message || originalError;
|
|
158
158
|
this.input = input;
|
|
159
|
-
|
|
159
|
+
|
|
160
160
|
// Don't expose sensitive data in production
|
|
161
161
|
if (process.env.NODE_ENV === 'production') {
|
|
162
162
|
delete this.input.apiKey;
|
|
@@ -214,8 +214,8 @@ export class ErrorHandler {
|
|
|
214
214
|
...(toolName && { tool: toolName }),
|
|
215
215
|
...(error.errors && { validationErrors: error.errors }),
|
|
216
216
|
...(error.retryAfter && { retryAfter: error.retryAfter }),
|
|
217
|
-
timestamp: error.timestamp
|
|
218
|
-
}
|
|
217
|
+
timestamp: error.timestamp,
|
|
218
|
+
},
|
|
219
219
|
};
|
|
220
220
|
}
|
|
221
221
|
|
|
@@ -223,13 +223,12 @@ export class ErrorHandler {
|
|
|
223
223
|
return {
|
|
224
224
|
error: {
|
|
225
225
|
code: 'UNKNOWN_ERROR',
|
|
226
|
-
message:
|
|
227
|
-
? 'An unexpected error occurred'
|
|
228
|
-
: error.message,
|
|
226
|
+
message:
|
|
227
|
+
process.env.NODE_ENV === 'production' ? 'An unexpected error occurred' : error.message,
|
|
229
228
|
statusCode: 500,
|
|
230
229
|
...(toolName && { tool: toolName }),
|
|
231
|
-
timestamp: new Date().toISOString()
|
|
232
|
-
}
|
|
230
|
+
timestamp: new Date().toISOString(),
|
|
231
|
+
},
|
|
233
232
|
};
|
|
234
233
|
}
|
|
235
234
|
|
|
@@ -244,13 +243,13 @@ export class ErrorHandler {
|
|
|
244
243
|
message: error.message,
|
|
245
244
|
...(error.errors && { errors: error.errors }),
|
|
246
245
|
...(error.retryAfter && { retryAfter: error.retryAfter }),
|
|
247
|
-
...(error.resource && { resource: error.resource })
|
|
248
|
-
}
|
|
246
|
+
...(error.resource && { resource: error.resource }),
|
|
247
|
+
},
|
|
249
248
|
};
|
|
250
|
-
|
|
249
|
+
|
|
251
250
|
return {
|
|
252
251
|
statusCode: error.statusCode,
|
|
253
|
-
body: response
|
|
252
|
+
body: response,
|
|
254
253
|
};
|
|
255
254
|
}
|
|
256
255
|
|
|
@@ -260,11 +259,10 @@ export class ErrorHandler {
|
|
|
260
259
|
body: {
|
|
261
260
|
error: {
|
|
262
261
|
code: 'INTERNAL_ERROR',
|
|
263
|
-
message:
|
|
264
|
-
? 'An internal error occurred'
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
}
|
|
262
|
+
message:
|
|
263
|
+
process.env.NODE_ENV === 'production' ? 'An internal error occurred' : error.message,
|
|
264
|
+
},
|
|
265
|
+
},
|
|
268
266
|
};
|
|
269
267
|
}
|
|
270
268
|
|
|
@@ -291,7 +289,7 @@ export class ErrorHandler {
|
|
|
291
289
|
static fromGhostError(error, operation) {
|
|
292
290
|
const statusCode = error.response?.status || error.statusCode;
|
|
293
291
|
const message = error.response?.data?.errors?.[0]?.message || error.message;
|
|
294
|
-
|
|
292
|
+
|
|
295
293
|
return new GhostAPIError(operation, message, statusCode);
|
|
296
294
|
}
|
|
297
295
|
|
|
@@ -304,14 +302,16 @@ export class ErrorHandler {
|
|
|
304
302
|
if (error instanceof GhostAPIError) {
|
|
305
303
|
return [429, 502, 503, 504].includes(error.ghostStatusCode);
|
|
306
304
|
}
|
|
307
|
-
|
|
305
|
+
|
|
308
306
|
// Network errors
|
|
309
|
-
if (
|
|
310
|
-
|
|
311
|
-
|
|
307
|
+
if (
|
|
308
|
+
error.code === 'ECONNREFUSED' ||
|
|
309
|
+
error.code === 'ETIMEDOUT' ||
|
|
310
|
+
error.code === 'ECONNRESET'
|
|
311
|
+
) {
|
|
312
312
|
return true;
|
|
313
313
|
}
|
|
314
|
-
|
|
314
|
+
|
|
315
315
|
return false;
|
|
316
316
|
}
|
|
317
317
|
|
|
@@ -322,15 +322,15 @@ export class ErrorHandler {
|
|
|
322
322
|
if (error instanceof RateLimitError) {
|
|
323
323
|
return error.retryAfter * 1000; // Convert to milliseconds
|
|
324
324
|
}
|
|
325
|
-
|
|
325
|
+
|
|
326
326
|
// Exponential backoff: 1s, 2s, 4s, 8s...
|
|
327
327
|
const baseDelay = 1000;
|
|
328
328
|
const maxDelay = 30000;
|
|
329
329
|
const delay = Math.min(baseDelay * Math.pow(2, attempt - 1), maxDelay);
|
|
330
|
-
|
|
330
|
+
|
|
331
331
|
// Add jitter to prevent thundering herd
|
|
332
332
|
const jitter = Math.random() * 0.3 * delay;
|
|
333
|
-
|
|
333
|
+
|
|
334
334
|
return Math.round(delay + jitter);
|
|
335
335
|
}
|
|
336
336
|
}
|
|
@@ -343,7 +343,7 @@ export class CircuitBreaker {
|
|
|
343
343
|
this.failureThreshold = options.failureThreshold || 5;
|
|
344
344
|
this.resetTimeout = options.resetTimeout || 60000; // 1 minute
|
|
345
345
|
this.monitoringPeriod = options.monitoringPeriod || 10000; // 10 seconds
|
|
346
|
-
|
|
346
|
+
|
|
347
347
|
this.state = 'CLOSED'; // CLOSED, OPEN, HALF_OPEN
|
|
348
348
|
this.failureCount = 0;
|
|
349
349
|
this.lastFailureTime = null;
|
|
@@ -353,7 +353,10 @@ export class CircuitBreaker {
|
|
|
353
353
|
async execute(fn, ...args) {
|
|
354
354
|
if (this.state === 'OPEN') {
|
|
355
355
|
if (Date.now() < this.nextAttempt) {
|
|
356
|
-
throw new ExternalServiceError(
|
|
356
|
+
throw new ExternalServiceError(
|
|
357
|
+
'Circuit breaker is OPEN',
|
|
358
|
+
'Service temporarily unavailable'
|
|
359
|
+
);
|
|
357
360
|
}
|
|
358
361
|
this.state = 'HALF_OPEN';
|
|
359
362
|
}
|
|
@@ -383,7 +386,9 @@ export class CircuitBreaker {
|
|
|
383
386
|
if (this.failureCount >= this.failureThreshold) {
|
|
384
387
|
this.state = 'OPEN';
|
|
385
388
|
this.nextAttempt = Date.now() + this.resetTimeout;
|
|
386
|
-
console.error(
|
|
389
|
+
console.error(
|
|
390
|
+
`Circuit breaker opened. Will retry at ${new Date(this.nextAttempt).toISOString()}`
|
|
391
|
+
);
|
|
387
392
|
}
|
|
388
393
|
}
|
|
389
394
|
|
|
@@ -392,7 +397,7 @@ export class CircuitBreaker {
|
|
|
392
397
|
state: this.state,
|
|
393
398
|
failureCount: this.failureCount,
|
|
394
399
|
lastFailureTime: this.lastFailureTime,
|
|
395
|
-
nextAttempt: this.nextAttempt
|
|
400
|
+
nextAttempt: this.nextAttempt,
|
|
396
401
|
};
|
|
397
402
|
}
|
|
398
403
|
}
|
|
@@ -403,27 +408,27 @@ export class CircuitBreaker {
|
|
|
403
408
|
export async function retryWithBackoff(fn, options = {}) {
|
|
404
409
|
const maxAttempts = options.maxAttempts || 3;
|
|
405
410
|
const onRetry = options.onRetry || (() => {});
|
|
406
|
-
|
|
411
|
+
|
|
407
412
|
let lastError;
|
|
408
|
-
|
|
413
|
+
|
|
409
414
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
410
415
|
try {
|
|
411
416
|
return await fn();
|
|
412
417
|
} catch (error) {
|
|
413
418
|
lastError = error;
|
|
414
|
-
|
|
419
|
+
|
|
415
420
|
if (attempt === maxAttempts || !ErrorHandler.isRetryable(error)) {
|
|
416
421
|
throw error;
|
|
417
422
|
}
|
|
418
|
-
|
|
423
|
+
|
|
419
424
|
const delay = ErrorHandler.getRetryDelay(attempt, error);
|
|
420
425
|
console.log(`Retry attempt ${attempt}/${maxAttempts} after ${delay}ms`);
|
|
421
|
-
|
|
422
|
-
await new Promise(resolve => setTimeout(resolve, delay));
|
|
426
|
+
|
|
427
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
423
428
|
onRetry(attempt, error);
|
|
424
429
|
}
|
|
425
430
|
}
|
|
426
|
-
|
|
431
|
+
|
|
427
432
|
throw lastError;
|
|
428
433
|
}
|
|
429
434
|
|
|
@@ -443,5 +448,5 @@ export default {
|
|
|
443
448
|
ConfigurationError,
|
|
444
449
|
ErrorHandler,
|
|
445
450
|
CircuitBreaker,
|
|
446
|
-
retryWithBackoff
|
|
447
|
-
};
|
|
451
|
+
retryWithBackoff,
|
|
452
|
+
};
|
package/src/index.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import express from
|
|
3
|
-
import helmet from
|
|
4
|
-
import dotenv from
|
|
5
|
-
import postRoutes from
|
|
6
|
-
import imageRoutes from
|
|
7
|
-
import tagRoutes from
|
|
8
|
-
import { startMCPServer } from
|
|
9
|
-
import { createContextLogger } from
|
|
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
10
|
|
|
11
11
|
// Load environment variables from .env file
|
|
12
12
|
dotenv.config();
|
|
@@ -19,69 +19,75 @@ const restApiPort = process.env.PORT || 3000;
|
|
|
19
19
|
const mcpPort = process.env.MCP_PORT || 3001; // Allow configuring MCP port
|
|
20
20
|
|
|
21
21
|
// Apply security headers with Helmet (OWASP recommended)
|
|
22
|
-
app.use(
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
22
|
+
app.use(
|
|
23
|
+
helmet({
|
|
24
|
+
contentSecurityPolicy: {
|
|
25
|
+
directives: {
|
|
26
|
+
defaultSrc: ["'self'"],
|
|
27
|
+
styleSrc: ["'self'", "'unsafe-inline'"],
|
|
28
|
+
scriptSrc: ["'self'"],
|
|
29
|
+
imgSrc: ["'self'", 'data:', 'https:'],
|
|
30
|
+
connectSrc: ["'self'"],
|
|
31
|
+
fontSrc: ["'self'"],
|
|
32
|
+
objectSrc: ["'none'"],
|
|
33
|
+
mediaSrc: ["'self'"],
|
|
34
|
+
frameSrc: ["'none'"],
|
|
35
|
+
},
|
|
34
36
|
},
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
|
|
37
|
+
hsts: {
|
|
38
|
+
maxAge: 31536000,
|
|
39
|
+
includeSubDomains: true,
|
|
40
|
+
preload: true,
|
|
41
|
+
},
|
|
42
|
+
})
|
|
43
|
+
);
|
|
42
44
|
|
|
43
45
|
// Middleware to parse JSON bodies with size limits
|
|
44
|
-
app.use(
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
app.use(
|
|
47
|
+
express.json({
|
|
48
|
+
limit: '1mb',
|
|
49
|
+
strict: true,
|
|
50
|
+
type: 'application/json',
|
|
51
|
+
})
|
|
52
|
+
);
|
|
49
53
|
// Middleware to parse URL-encoded bodies with size limits
|
|
50
|
-
app.use(
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
54
|
+
app.use(
|
|
55
|
+
express.urlencoded({
|
|
56
|
+
extended: true,
|
|
57
|
+
limit: '1mb',
|
|
58
|
+
parameterLimit: 100,
|
|
59
|
+
})
|
|
60
|
+
);
|
|
55
61
|
|
|
56
62
|
// Health check endpoint
|
|
57
|
-
app.get(
|
|
58
|
-
res.status(200).json({ status:
|
|
63
|
+
app.get('/health', (req, res) => {
|
|
64
|
+
res.status(200).json({ status: 'ok', message: 'Server is running' });
|
|
59
65
|
});
|
|
60
66
|
|
|
61
67
|
// Mount the post routes
|
|
62
|
-
app.use(
|
|
68
|
+
app.use('/api/posts', postRoutes); // All post routes will be prefixed with /api/posts
|
|
63
69
|
// Mount the image routes
|
|
64
|
-
app.use(
|
|
70
|
+
app.use('/api/images', imageRoutes);
|
|
65
71
|
// Mount the tag routes
|
|
66
|
-
app.use(
|
|
72
|
+
app.use('/api/tags', tagRoutes);
|
|
67
73
|
|
|
68
74
|
// Global error handler for Express
|
|
69
|
-
app.use((err, req, res,
|
|
75
|
+
app.use((err, req, res, _next) => {
|
|
70
76
|
const statusCode = err.statusCode || err.response?.status || 500;
|
|
71
|
-
|
|
77
|
+
|
|
72
78
|
logger.error('Express error handler triggered', {
|
|
73
79
|
error: err.message,
|
|
74
80
|
statusCode,
|
|
75
81
|
stack: err.stack,
|
|
76
82
|
url: req.url,
|
|
77
83
|
method: req.method,
|
|
78
|
-
type: 'express_error'
|
|
84
|
+
type: 'express_error',
|
|
79
85
|
});
|
|
80
|
-
|
|
86
|
+
|
|
81
87
|
res.status(statusCode).json({
|
|
82
|
-
message: err.message ||
|
|
88
|
+
message: err.message || 'Internal Server Error',
|
|
83
89
|
// Optionally include stack trace in development
|
|
84
|
-
stack: process.env.NODE_ENV ===
|
|
90
|
+
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined,
|
|
85
91
|
});
|
|
86
92
|
});
|
|
87
93
|
|
|
@@ -92,7 +98,7 @@ const startServers = async () => {
|
|
|
92
98
|
logger.info('Express REST API server started', {
|
|
93
99
|
port: restApiPort,
|
|
94
100
|
url: `http://localhost:${restApiPort}`,
|
|
95
|
-
type: 'server_start'
|
|
101
|
+
type: 'server_start',
|
|
96
102
|
});
|
|
97
103
|
});
|
|
98
104
|
|
|
@@ -104,7 +110,7 @@ startServers().catch((error) => {
|
|
|
104
110
|
logger.error('Failed to start servers', {
|
|
105
111
|
error: error.message,
|
|
106
112
|
stack: error.stack,
|
|
107
|
-
type: 'server_start_error'
|
|
113
|
+
type: 'server_start_error',
|
|
108
114
|
});
|
|
109
115
|
process.exit(1);
|
|
110
116
|
});
|