@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,489 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ErrorHandler,
|
|
3
|
+
BaseError,
|
|
4
|
+
ValidationError,
|
|
5
|
+
NotFoundError,
|
|
6
|
+
AuthenticationError,
|
|
7
|
+
AuthorizationError,
|
|
8
|
+
RateLimitError
|
|
9
|
+
} from '../errors/index.js';
|
|
10
|
+
import fs from 'fs/promises';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
import crypto from 'crypto';
|
|
13
|
+
import { fileURLToPath } from 'url';
|
|
14
|
+
|
|
15
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Error Logger - Logs errors to file and console
|
|
19
|
+
*/
|
|
20
|
+
export class ErrorLogger {
|
|
21
|
+
constructor(options = {}) {
|
|
22
|
+
this.logDir = options.logDir || path.join(__dirname, '../../logs');
|
|
23
|
+
this.maxLogSize = options.maxLogSize || 10 * 1024 * 1024; // 10MB
|
|
24
|
+
this.logLevel = options.logLevel || process.env.LOG_LEVEL || 'info';
|
|
25
|
+
this.enableFileLogging = options.enableFileLogging ?? true;
|
|
26
|
+
|
|
27
|
+
// Ensure log directory exists
|
|
28
|
+
if (this.enableFileLogging) {
|
|
29
|
+
this.ensureLogDirectory();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async ensureLogDirectory() {
|
|
34
|
+
try {
|
|
35
|
+
await fs.mkdir(this.logDir, { recursive: true });
|
|
36
|
+
} catch (error) {
|
|
37
|
+
console.error('Failed to create log directory:', error);
|
|
38
|
+
this.enableFileLogging = false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
getLogFilePath(type = 'error') {
|
|
43
|
+
const date = new Date().toISOString().split('T')[0];
|
|
44
|
+
return path.join(this.logDir, `${type}-${date}.log`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async rotateLogIfNeeded(filePath) {
|
|
48
|
+
try {
|
|
49
|
+
const stats = await fs.stat(filePath);
|
|
50
|
+
if (stats.size > this.maxLogSize) {
|
|
51
|
+
const timestamp = Date.now();
|
|
52
|
+
const rotatedPath = filePath.replace('.log', `-${timestamp}.log`);
|
|
53
|
+
await fs.rename(filePath, rotatedPath);
|
|
54
|
+
}
|
|
55
|
+
} catch (error) {
|
|
56
|
+
// File doesn't exist yet, which is fine
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
formatLogEntry(level, message, meta = {}) {
|
|
61
|
+
return JSON.stringify({
|
|
62
|
+
timestamp: new Date().toISOString(),
|
|
63
|
+
level,
|
|
64
|
+
message,
|
|
65
|
+
...meta,
|
|
66
|
+
environment: process.env.NODE_ENV || 'development',
|
|
67
|
+
pid: process.pid
|
|
68
|
+
}) + '\n';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async writeToFile(type, entry) {
|
|
72
|
+
if (!this.enableFileLogging) return;
|
|
73
|
+
|
|
74
|
+
const filePath = this.getLogFilePath(type);
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
await this.rotateLogIfNeeded(filePath);
|
|
78
|
+
await fs.appendFile(filePath, entry);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
console.error('Failed to write to log file:', error);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async logError(error, context = {}) {
|
|
85
|
+
const isOperational = ErrorHandler.isOperationalError(error);
|
|
86
|
+
const level = isOperational ? 'error' : 'fatal';
|
|
87
|
+
|
|
88
|
+
const logData = {
|
|
89
|
+
name: error.name || 'Error',
|
|
90
|
+
message: error.message,
|
|
91
|
+
code: error.code,
|
|
92
|
+
statusCode: error.statusCode,
|
|
93
|
+
stack: error.stack,
|
|
94
|
+
isOperational,
|
|
95
|
+
...context
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// Console logging
|
|
99
|
+
if (level === 'fatal' || this.logLevel === 'debug') {
|
|
100
|
+
console.error(`[${level.toUpperCase()}]`, error.message, logData);
|
|
101
|
+
} else {
|
|
102
|
+
console.error(`[${level.toUpperCase()}]`, error.message);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// File logging
|
|
106
|
+
const entry = this.formatLogEntry(level, error.message, logData);
|
|
107
|
+
await this.writeToFile('error', entry);
|
|
108
|
+
|
|
109
|
+
// Also log to general log
|
|
110
|
+
await this.writeToFile('app', entry);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async logInfo(message, meta = {}) {
|
|
114
|
+
if (['info', 'debug'].includes(this.logLevel)) {
|
|
115
|
+
console.log(`[INFO] ${message}`);
|
|
116
|
+
const entry = this.formatLogEntry('info', message, meta);
|
|
117
|
+
await this.writeToFile('app', entry);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async logWarning(message, meta = {}) {
|
|
122
|
+
if (['warning', 'info', 'debug'].includes(this.logLevel)) {
|
|
123
|
+
console.warn(`[WARNING] ${message}`);
|
|
124
|
+
const entry = this.formatLogEntry('warning', message, meta);
|
|
125
|
+
await this.writeToFile('app', entry);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async logDebug(message, meta = {}) {
|
|
130
|
+
if (this.logLevel === 'debug') {
|
|
131
|
+
console.log(`[DEBUG] ${message}`);
|
|
132
|
+
const entry = this.formatLogEntry('debug', message, meta);
|
|
133
|
+
await this.writeToFile('debug', entry);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Error Metrics Collector
|
|
140
|
+
*/
|
|
141
|
+
export class ErrorMetrics {
|
|
142
|
+
constructor() {
|
|
143
|
+
this.metrics = {
|
|
144
|
+
totalErrors: 0,
|
|
145
|
+
errorsByType: {},
|
|
146
|
+
errorsByStatusCode: {},
|
|
147
|
+
errorsByEndpoint: {},
|
|
148
|
+
lastReset: new Date().toISOString()
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
recordError(error, endpoint = null) {
|
|
153
|
+
this.metrics.totalErrors++;
|
|
154
|
+
|
|
155
|
+
// Count by error type
|
|
156
|
+
const errorType = error.constructor.name;
|
|
157
|
+
this.metrics.errorsByType[errorType] = (this.metrics.errorsByType[errorType] || 0) + 1;
|
|
158
|
+
|
|
159
|
+
// Count by status code
|
|
160
|
+
const statusCode = error.statusCode || 500;
|
|
161
|
+
this.metrics.errorsByStatusCode[statusCode] = (this.metrics.errorsByStatusCode[statusCode] || 0) + 1;
|
|
162
|
+
|
|
163
|
+
// Count by endpoint
|
|
164
|
+
if (endpoint) {
|
|
165
|
+
this.metrics.errorsByEndpoint[endpoint] = (this.metrics.errorsByEndpoint[endpoint] || 0) + 1;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
getMetrics() {
|
|
170
|
+
return {
|
|
171
|
+
...this.metrics,
|
|
172
|
+
uptime: process.uptime(),
|
|
173
|
+
memoryUsage: process.memoryUsage(),
|
|
174
|
+
timestamp: new Date().toISOString()
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
reset() {
|
|
179
|
+
this.metrics = {
|
|
180
|
+
totalErrors: 0,
|
|
181
|
+
errorsByType: {},
|
|
182
|
+
errorsByStatusCode: {},
|
|
183
|
+
errorsByEndpoint: {},
|
|
184
|
+
lastReset: new Date().toISOString()
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Global instances
|
|
190
|
+
const errorLogger = new ErrorLogger();
|
|
191
|
+
const errorMetrics = new ErrorMetrics();
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Express Error Middleware
|
|
195
|
+
*/
|
|
196
|
+
export function expressErrorHandler(err, req, res, next) {
|
|
197
|
+
// Log the error
|
|
198
|
+
errorLogger.logError(err, {
|
|
199
|
+
method: req.method,
|
|
200
|
+
url: req.url,
|
|
201
|
+
ip: req.ip,
|
|
202
|
+
userAgent: req.get('user-agent')
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// Record metrics
|
|
206
|
+
errorMetrics.recordError(err, `${req.method} ${req.path}`);
|
|
207
|
+
|
|
208
|
+
// Format response
|
|
209
|
+
const { statusCode, body } = ErrorHandler.formatHTTPError(err);
|
|
210
|
+
|
|
211
|
+
// Set security headers
|
|
212
|
+
res.set({
|
|
213
|
+
'X-Content-Type-Options': 'nosniff',
|
|
214
|
+
'X-Frame-Options': 'DENY',
|
|
215
|
+
'X-XSS-Protection': '1; mode=block'
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// Send response
|
|
219
|
+
res.status(statusCode).json(body);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Async route wrapper to catch errors
|
|
224
|
+
*/
|
|
225
|
+
export function asyncHandler(fn) {
|
|
226
|
+
return (req, res, next) => {
|
|
227
|
+
Promise.resolve(fn(req, res, next)).catch(next);
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Request validation middleware
|
|
233
|
+
* @param {Object|Function} schema - Validation schema object or validation function
|
|
234
|
+
*/
|
|
235
|
+
export function validateRequest(schema) {
|
|
236
|
+
return (req, res, next) => {
|
|
237
|
+
try {
|
|
238
|
+
// If schema is a function, call it with the request body
|
|
239
|
+
if (typeof schema === 'function') {
|
|
240
|
+
const validationResult = schema(req.body);
|
|
241
|
+
if (validationResult && validationResult.error) {
|
|
242
|
+
throw new ValidationError(validationResult.error);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// If schema has a validate method (e.g., Joi schema)
|
|
246
|
+
else if (schema && typeof schema.validate === 'function') {
|
|
247
|
+
const { error } = schema.validate(req.body, { abortEarly: false });
|
|
248
|
+
if (error) {
|
|
249
|
+
// Create ValidationError from Joi error details
|
|
250
|
+
const errors = error.details ? error.details.map(detail => detail.message) : [error.message];
|
|
251
|
+
throw new ValidationError('Validation failed', errors);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
// If schema is a simple object with required fields
|
|
255
|
+
else if (schema && typeof schema === 'object') {
|
|
256
|
+
const errors = [];
|
|
257
|
+
for (const [field, rules] of Object.entries(schema)) {
|
|
258
|
+
if (rules.required && !req.body[field]) {
|
|
259
|
+
errors.push(`${field} is required`);
|
|
260
|
+
}
|
|
261
|
+
if (rules.type && req.body[field] && typeof req.body[field] !== rules.type) {
|
|
262
|
+
errors.push(`${field} must be of type ${rules.type}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (errors.length > 0) {
|
|
266
|
+
throw new ValidationError('Validation failed', errors);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
next();
|
|
271
|
+
} catch (error) {
|
|
272
|
+
next(error);
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Rate limiting middleware
|
|
279
|
+
*/
|
|
280
|
+
export class RateLimiter {
|
|
281
|
+
constructor(options = {}) {
|
|
282
|
+
this.windowMs = options.windowMs || 60000; // 1 minute
|
|
283
|
+
this.maxRequests = options.maxRequests || 100;
|
|
284
|
+
this.requests = new Map();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
middleware() {
|
|
288
|
+
return (req, res, next) => {
|
|
289
|
+
const key = req.ip;
|
|
290
|
+
const now = Date.now();
|
|
291
|
+
|
|
292
|
+
// Clean old entries
|
|
293
|
+
this.cleanup(now);
|
|
294
|
+
|
|
295
|
+
// Get or create request list for this IP
|
|
296
|
+
if (!this.requests.has(key)) {
|
|
297
|
+
this.requests.set(key, []);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const requestTimes = this.requests.get(key);
|
|
301
|
+
requestTimes.push(now);
|
|
302
|
+
|
|
303
|
+
if (requestTimes.length > this.maxRequests) {
|
|
304
|
+
const retryAfter = Math.ceil((this.windowMs - (now - requestTimes[0])) / 1000);
|
|
305
|
+
return next(new RateLimitError(retryAfter));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
next();
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
cleanup(now) {
|
|
313
|
+
const cutoff = now - this.windowMs;
|
|
314
|
+
|
|
315
|
+
for (const [key, times] of this.requests.entries()) {
|
|
316
|
+
const filtered = times.filter(time => time > cutoff);
|
|
317
|
+
|
|
318
|
+
if (filtered.length === 0) {
|
|
319
|
+
this.requests.delete(key);
|
|
320
|
+
} else {
|
|
321
|
+
this.requests.set(key, filtered);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* API Key authentication middleware
|
|
329
|
+
*/
|
|
330
|
+
export function apiKeyAuth(apiKey) {
|
|
331
|
+
return (req, res, next) => {
|
|
332
|
+
const providedKey = req.headers['x-api-key'] ||
|
|
333
|
+
req.headers['authorization']?.replace('Bearer ', '');
|
|
334
|
+
|
|
335
|
+
if (!providedKey) {
|
|
336
|
+
return next(new AuthenticationError('API key is required'));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Use timing-safe comparison to prevent timing attacks
|
|
340
|
+
const expectedKeyBuffer = Buffer.from(apiKey, 'utf8');
|
|
341
|
+
const providedKeyBuffer = Buffer.from(providedKey, 'utf8');
|
|
342
|
+
|
|
343
|
+
// Ensure buffers are same length to prevent timing attacks
|
|
344
|
+
if (expectedKeyBuffer.length !== providedKeyBuffer.length) {
|
|
345
|
+
return next(new AuthenticationError('Invalid API key'));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Use constant-time comparison
|
|
349
|
+
const isValid = crypto.timingSafeEqual(expectedKeyBuffer, providedKeyBuffer);
|
|
350
|
+
|
|
351
|
+
if (!isValid) {
|
|
352
|
+
return next(new AuthenticationError('Invalid API key'));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
next();
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* CORS middleware for MCP
|
|
361
|
+
*/
|
|
362
|
+
export function mcpCors(allowedOrigins = ['*']) {
|
|
363
|
+
return (req, res, next) => {
|
|
364
|
+
const origin = req.headers.origin;
|
|
365
|
+
|
|
366
|
+
if (allowedOrigins.includes('*') || allowedOrigins.includes(origin)) {
|
|
367
|
+
res.header('Access-Control-Allow-Origin', origin || '*');
|
|
368
|
+
res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
369
|
+
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key');
|
|
370
|
+
res.header('Access-Control-Max-Age', '86400');
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (req.method === 'OPTIONS') {
|
|
374
|
+
return res.sendStatus(204);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
next();
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Health check endpoint
|
|
383
|
+
*/
|
|
384
|
+
export function healthCheck(ghostService) {
|
|
385
|
+
return async (req, res) => {
|
|
386
|
+
try {
|
|
387
|
+
const health = await ghostService.checkHealth();
|
|
388
|
+
const metrics = errorMetrics.getMetrics();
|
|
389
|
+
|
|
390
|
+
const status = health.status === 'healthy' ? 200 : 503;
|
|
391
|
+
|
|
392
|
+
res.status(status).json({
|
|
393
|
+
...health,
|
|
394
|
+
metrics: {
|
|
395
|
+
errors: metrics.totalErrors,
|
|
396
|
+
uptime: metrics.uptime,
|
|
397
|
+
memory: metrics.memoryUsage
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
} catch (error) {
|
|
401
|
+
res.status(503).json({
|
|
402
|
+
status: 'unhealthy',
|
|
403
|
+
error: error.message,
|
|
404
|
+
timestamp: new Date().toISOString()
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Graceful shutdown handler
|
|
412
|
+
*/
|
|
413
|
+
export class GracefulShutdown {
|
|
414
|
+
constructor() {
|
|
415
|
+
this.isShuttingDown = false;
|
|
416
|
+
this.connections = new Set();
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
trackConnection(connection) {
|
|
420
|
+
this.connections.add(connection);
|
|
421
|
+
connection.on('close', () => this.connections.delete(connection));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
middleware() {
|
|
425
|
+
return (req, res, next) => {
|
|
426
|
+
if (this.isShuttingDown) {
|
|
427
|
+
res.set('Connection', 'close');
|
|
428
|
+
res.status(503).json({
|
|
429
|
+
error: {
|
|
430
|
+
code: 'SERVER_SHUTTING_DOWN',
|
|
431
|
+
message: 'Server is shutting down'
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Track the connection
|
|
438
|
+
this.trackConnection(req.socket);
|
|
439
|
+
next();
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async shutdown(server) {
|
|
444
|
+
if (this.isShuttingDown) return;
|
|
445
|
+
|
|
446
|
+
this.isShuttingDown = true;
|
|
447
|
+
console.log('Graceful shutdown initiated...');
|
|
448
|
+
|
|
449
|
+
// Stop accepting new connections
|
|
450
|
+
server.close(() => {
|
|
451
|
+
console.log('Server closed to new connections');
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
// Close existing connections
|
|
455
|
+
for (const connection of this.connections) {
|
|
456
|
+
connection.end();
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Force close after timeout
|
|
460
|
+
setTimeout(() => {
|
|
461
|
+
for (const connection of this.connections) {
|
|
462
|
+
connection.destroy();
|
|
463
|
+
}
|
|
464
|
+
}, 10000);
|
|
465
|
+
|
|
466
|
+
// Log final metrics
|
|
467
|
+
await errorLogger.logInfo('Shutdown metrics', errorMetrics.getMetrics());
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export {
|
|
472
|
+
errorLogger,
|
|
473
|
+
errorMetrics
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
export default {
|
|
477
|
+
expressErrorHandler,
|
|
478
|
+
asyncHandler,
|
|
479
|
+
validateRequest,
|
|
480
|
+
RateLimiter,
|
|
481
|
+
apiKeyAuth,
|
|
482
|
+
mcpCors,
|
|
483
|
+
healthCheck,
|
|
484
|
+
GracefulShutdown,
|
|
485
|
+
ErrorLogger,
|
|
486
|
+
ErrorMetrics,
|
|
487
|
+
errorLogger,
|
|
488
|
+
errorMetrics
|
|
489
|
+
};
|