@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,675 @@
1
+ /**
2
+ * Enhanced MCP Server with Advanced Resource Management
3
+ */
4
+
5
+ import {
6
+ MCPServer,
7
+ Tool,
8
+ } from "@modelcontextprotocol/sdk/server/index.js";
9
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
10
+ import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
11
+ import { WebSocketServerTransport } from "@modelcontextprotocol/sdk/server/websocket.js";
12
+ import dotenv from "dotenv";
13
+ import express from "express";
14
+ import { WebSocketServer } from "ws";
15
+
16
+ // Import services
17
+ import ghostService from "./services/ghostServiceImproved.js";
18
+ import { ResourceManager } from "./resources/ResourceManager.js";
19
+ import { ErrorHandler, ValidationError } from "./errors/index.js";
20
+ import {
21
+ errorLogger,
22
+ mcpCors,
23
+ RateLimiter,
24
+ healthCheck,
25
+ GracefulShutdown
26
+ } from "./middleware/errorMiddleware.js";
27
+
28
+ dotenv.config();
29
+
30
+ console.log("Initializing Enhanced MCP Server...");
31
+
32
+ // Initialize components
33
+ const resourceManager = new ResourceManager(ghostService);
34
+ const rateLimiter = new RateLimiter();
35
+ const gracefulShutdown = new GracefulShutdown();
36
+
37
+ // Create MCP Server
38
+ const mcpServer = new MCPServer({
39
+ metadata: {
40
+ name: "Ghost CMS Manager",
41
+ description: "Enhanced MCP Server for Ghost CMS with advanced resource management",
42
+ version: "2.0.0",
43
+ capabilities: {
44
+ resources: true,
45
+ tools: true,
46
+ subscriptions: true,
47
+ batch: true
48
+ }
49
+ },
50
+ });
51
+
52
+ // --- Register Resources with Enhanced Fetching ---
53
+
54
+ console.log("Registering enhanced resources...");
55
+
56
+ // Ghost Post Resource
57
+ const postResource = resourceManager.registerResource(
58
+ "ghost/post",
59
+ {
60
+ type: "object",
61
+ properties: {
62
+ id: { type: "string" },
63
+ uuid: { type: "string" },
64
+ title: { type: "string" },
65
+ slug: { type: "string" },
66
+ html: { type: ["string", "null"] },
67
+ status: {
68
+ type: "string",
69
+ enum: ["draft", "published", "scheduled"]
70
+ },
71
+ feature_image: { type: ["string", "null"] },
72
+ published_at: {
73
+ type: ["string", "null"],
74
+ format: "date-time"
75
+ },
76
+ tags: {
77
+ type: "array",
78
+ items: { $ref: "ghost/tag#/schema" }
79
+ },
80
+ meta_title: { type: ["string", "null"] },
81
+ meta_description: { type: ["string", "null"] }
82
+ },
83
+ required: ["id", "uuid", "title", "slug", "status"]
84
+ },
85
+ {
86
+ description: "Ghost blog post with support for multiple identifier types",
87
+ examples: [
88
+ "ghost/post/123",
89
+ "ghost/post/slug:my-awesome-post",
90
+ "ghost/post/uuid:550e8400-e29b-41d4-a716-446655440000",
91
+ "ghost/posts?status=published&limit=10&page=1"
92
+ ]
93
+ }
94
+ );
95
+
96
+ mcpServer.addResource(postResource);
97
+
98
+ // Ghost Tag Resource
99
+ const tagResource = resourceManager.registerResource(
100
+ "ghost/tag",
101
+ {
102
+ type: "object",
103
+ properties: {
104
+ id: { type: "string" },
105
+ name: { type: "string" },
106
+ slug: { type: "string" },
107
+ description: { type: ["string", "null"] },
108
+ feature_image: { type: ["string", "null"] },
109
+ visibility: {
110
+ type: "string",
111
+ enum: ["public", "internal"]
112
+ },
113
+ meta_title: { type: ["string", "null"] },
114
+ meta_description: { type: ["string", "null"] }
115
+ },
116
+ required: ["id", "name", "slug"]
117
+ },
118
+ {
119
+ description: "Ghost tag for categorizing posts",
120
+ examples: [
121
+ "ghost/tag/technology",
122
+ "ghost/tag/slug:web-development",
123
+ "ghost/tag/name:JavaScript",
124
+ "ghost/tags?limit=20"
125
+ ]
126
+ }
127
+ );
128
+
129
+ mcpServer.addResource(tagResource);
130
+
131
+ // --- Enhanced Tools ---
132
+
133
+ console.log("Registering enhanced tools...");
134
+
135
+ // Batch Operations Tool
136
+ const batchOperationsTool = new Tool({
137
+ name: "ghost_batch_operations",
138
+ description: "Execute multiple Ghost operations in a single request",
139
+ inputSchema: {
140
+ type: "object",
141
+ properties: {
142
+ operations: {
143
+ type: "array",
144
+ items: {
145
+ type: "object",
146
+ properties: {
147
+ id: { type: "string", description: "Operation ID for reference" },
148
+ type: {
149
+ type: "string",
150
+ enum: ["create_post", "update_post", "create_tag", "fetch_resource"]
151
+ },
152
+ data: { type: "object", description: "Operation-specific data" }
153
+ },
154
+ required: ["id", "type", "data"]
155
+ },
156
+ minItems: 1,
157
+ maxItems: 10
158
+ },
159
+ stopOnError: {
160
+ type: "boolean",
161
+ default: false,
162
+ description: "Stop processing on first error"
163
+ }
164
+ },
165
+ required: ["operations"]
166
+ },
167
+ outputSchema: {
168
+ type: "object",
169
+ properties: {
170
+ results: {
171
+ type: "object",
172
+ additionalProperties: {
173
+ type: "object",
174
+ properties: {
175
+ success: { type: "boolean" },
176
+ data: { type: "object" },
177
+ error: { type: "object" }
178
+ }
179
+ }
180
+ }
181
+ }
182
+ },
183
+ implementation: async (input) => {
184
+ const results = {};
185
+
186
+ for (const operation of input.operations) {
187
+ try {
188
+ let result;
189
+
190
+ switch (operation.type) {
191
+ case 'create_post':
192
+ result = await ghostService.createPost(operation.data);
193
+ break;
194
+ case 'update_post':
195
+ result = await ghostService.updatePost(
196
+ operation.data.id,
197
+ operation.data
198
+ );
199
+ break;
200
+ case 'create_tag':
201
+ result = await ghostService.createTag(operation.data);
202
+ break;
203
+ case 'fetch_resource':
204
+ result = await resourceManager.fetchResource(operation.data.uri);
205
+ break;
206
+ default:
207
+ throw new ValidationError(`Unknown operation type: ${operation.type}`);
208
+ }
209
+
210
+ results[operation.id] = {
211
+ success: true,
212
+ data: result
213
+ };
214
+
215
+ } catch (error) {
216
+ results[operation.id] = {
217
+ success: false,
218
+ error: ErrorHandler.formatMCPError(error)
219
+ };
220
+
221
+ if (input.stopOnError) {
222
+ break;
223
+ }
224
+ }
225
+ }
226
+
227
+ return { results };
228
+ }
229
+ });
230
+
231
+ mcpServer.addTool(batchOperationsTool);
232
+
233
+ // Resource Search Tool
234
+ const searchResourcesTool = new Tool({
235
+ name: "ghost_search_resources",
236
+ description: "Search for Ghost resources with advanced filtering",
237
+ inputSchema: {
238
+ type: "object",
239
+ properties: {
240
+ resourceType: {
241
+ type: "string",
242
+ enum: ["posts", "tags"],
243
+ description: "Type of resource to search"
244
+ },
245
+ query: {
246
+ type: "string",
247
+ description: "Search query"
248
+ },
249
+ filters: {
250
+ type: "object",
251
+ properties: {
252
+ status: { type: "string" },
253
+ visibility: { type: "string" },
254
+ tag: { type: "string" },
255
+ author: { type: "string" },
256
+ published_after: { type: "string", format: "date-time" },
257
+ published_before: { type: "string", format: "date-time" }
258
+ }
259
+ },
260
+ sort: {
261
+ type: "string",
262
+ default: "published_at desc",
263
+ description: "Sort order"
264
+ },
265
+ limit: {
266
+ type: "integer",
267
+ minimum: 1,
268
+ maximum: 100,
269
+ default: 15
270
+ },
271
+ page: {
272
+ type: "integer",
273
+ minimum: 1,
274
+ default: 1
275
+ }
276
+ },
277
+ required: ["resourceType"]
278
+ },
279
+ implementation: async (input) => {
280
+ const { resourceType, query, filters = {}, sort, limit, page } = input;
281
+
282
+ // Build Ghost filter string
283
+ let filterParts = [];
284
+
285
+ if (query) {
286
+ filterParts.push(`title:~'${query}'`);
287
+ }
288
+
289
+ if (filters.status) {
290
+ filterParts.push(`status:${filters.status}`);
291
+ }
292
+
293
+ if (filters.visibility) {
294
+ filterParts.push(`visibility:${filters.visibility}`);
295
+ }
296
+
297
+ if (filters.tag) {
298
+ filterParts.push(`tag:${filters.tag}`);
299
+ }
300
+
301
+ if (filters.author) {
302
+ filterParts.push(`author:${filters.author}`);
303
+ }
304
+
305
+ if (filters.published_after) {
306
+ filterParts.push(`published_at:>='${filters.published_after}'`);
307
+ }
308
+
309
+ if (filters.published_before) {
310
+ filterParts.push(`published_at:<='${filters.published_before}'`);
311
+ }
312
+
313
+ const ghostFilter = filterParts.join('+');
314
+
315
+ // Build resource URI
316
+ const uri = `ghost/${resourceType}?${new URLSearchParams({
317
+ filter: ghostFilter,
318
+ order: sort,
319
+ limit,
320
+ page
321
+ }).toString()}`;
322
+
323
+ // Fetch using ResourceManager
324
+ return await resourceManager.fetchResource(uri);
325
+ }
326
+ });
327
+
328
+ mcpServer.addTool(searchResourcesTool);
329
+
330
+ // Cache Management Tool
331
+ const cacheManagementTool = new Tool({
332
+ name: "ghost_cache_management",
333
+ description: "Manage resource cache",
334
+ inputSchema: {
335
+ type: "object",
336
+ properties: {
337
+ action: {
338
+ type: "string",
339
+ enum: ["invalidate", "stats", "prefetch"],
340
+ description: "Cache action to perform"
341
+ },
342
+ pattern: {
343
+ type: "string",
344
+ description: "Pattern for invalidation (optional)"
345
+ },
346
+ uris: {
347
+ type: "array",
348
+ items: { type: "string" },
349
+ description: "URIs to prefetch (for prefetch action)"
350
+ }
351
+ },
352
+ required: ["action"]
353
+ },
354
+ implementation: async (input) => {
355
+ switch (input.action) {
356
+ case 'invalidate':
357
+ resourceManager.invalidateCache(input.pattern);
358
+ return {
359
+ success: true,
360
+ message: `Cache invalidated${input.pattern ? ` for pattern: ${input.pattern}` : ''}`
361
+ };
362
+
363
+ case 'stats':
364
+ return resourceManager.getCacheStats();
365
+
366
+ case 'prefetch':
367
+ if (!input.uris || input.uris.length === 0) {
368
+ throw new ValidationError('URIs required for prefetch action');
369
+ }
370
+ return await resourceManager.prefetch(input.uris);
371
+
372
+ default:
373
+ throw new ValidationError(`Unknown action: ${input.action}`);
374
+ }
375
+ }
376
+ });
377
+
378
+ mcpServer.addTool(cacheManagementTool);
379
+
380
+ // Resource Subscription Tool
381
+ const subscriptionTool = new Tool({
382
+ name: "ghost_subscribe",
383
+ description: "Subscribe to resource changes",
384
+ inputSchema: {
385
+ type: "object",
386
+ properties: {
387
+ action: {
388
+ type: "string",
389
+ enum: ["subscribe", "unsubscribe"],
390
+ description: "Subscription action"
391
+ },
392
+ uri: {
393
+ type: "string",
394
+ description: "Resource URI to subscribe to (required for subscribe action)"
395
+ },
396
+ subscriptionId: {
397
+ type: "string",
398
+ description: "Subscription ID (required for unsubscribe action)"
399
+ },
400
+ options: {
401
+ type: "object",
402
+ properties: {
403
+ pollingInterval: {
404
+ type: "integer",
405
+ minimum: 5000,
406
+ default: 30000,
407
+ description: "Polling interval in milliseconds"
408
+ },
409
+ enablePolling: {
410
+ type: "boolean",
411
+ default: false,
412
+ description: "Enable automatic polling"
413
+ }
414
+ }
415
+ }
416
+ },
417
+ required: ["action"],
418
+ // Add conditional validation using JSON Schema if/then/else
419
+ if: {
420
+ properties: { action: { const: "subscribe" } }
421
+ },
422
+ then: {
423
+ required: ["uri"]
424
+ },
425
+ else: {
426
+ if: {
427
+ properties: { action: { const: "unsubscribe" } }
428
+ },
429
+ then: {
430
+ required: ["subscriptionId"]
431
+ }
432
+ }
433
+ },
434
+ implementation: async (input) => {
435
+ if (input.action === 'subscribe') {
436
+ if (!input.uri) {
437
+ throw new ValidationError('URI required for subscribe action');
438
+ }
439
+
440
+ const subscriptionId = resourceManager.subscribe(
441
+ input.uri,
442
+ (event) => {
443
+ // In a real implementation, this would send events to the client
444
+ console.log('Resource update:', event);
445
+ },
446
+ input.options || {}
447
+ );
448
+
449
+ return {
450
+ success: true,
451
+ subscriptionId,
452
+ message: `Subscribed to ${input.uri}`
453
+ };
454
+
455
+ } else if (input.action === 'unsubscribe') {
456
+ if (!input.subscriptionId) {
457
+ throw new ValidationError('Subscription ID required for unsubscribe action');
458
+ }
459
+
460
+ resourceManager.unsubscribe(input.subscriptionId);
461
+
462
+ return {
463
+ success: true,
464
+ message: `Unsubscribed from ${input.subscriptionId}`
465
+ };
466
+ }
467
+ }
468
+ });
469
+
470
+ mcpServer.addTool(subscriptionTool);
471
+
472
+ // Keep existing tools (create_post, upload_image, etc.) from previous implementation
473
+ // ... (include the tools from mcp_server_improved.js)
474
+
475
+ // --- Enhanced Transport with Middleware ---
476
+
477
+ const startEnhancedMCPServer = async (transport = 'http', options = {}) => {
478
+ try {
479
+ console.log(`Starting Enhanced MCP Server with ${transport} transport...`);
480
+
481
+ switch (transport) {
482
+ case 'stdio':
483
+ const stdioTransport = new StdioServerTransport();
484
+ await mcpServer.connect(stdioTransport);
485
+ console.log("Enhanced MCP Server running on stdio transport");
486
+ break;
487
+
488
+ case 'http':
489
+ case 'sse':
490
+ const port = options.port || 3001;
491
+ const app = express();
492
+
493
+ // Apply middleware
494
+ app.use(gracefulShutdown.middleware());
495
+ app.use(rateLimiter.middleware());
496
+ app.use(mcpCors(options.allowedOrigins));
497
+
498
+ // Health check with Ghost status
499
+ app.get('/health', healthCheck(ghostService));
500
+
501
+ // Resource endpoints
502
+ app.get('/resources', async (req, res) => {
503
+ try {
504
+ const resources = resourceManager.listResources(req.query);
505
+ res.json(resources);
506
+ } catch (error) {
507
+ const formatted = ErrorHandler.formatHTTPError(error);
508
+ res.status(formatted.statusCode).json(formatted.body);
509
+ }
510
+ });
511
+
512
+ app.get('/resources/*', async (req, res) => {
513
+ try {
514
+ const uri = req.params[0];
515
+
516
+ // Validate and sanitize the URI to prevent path traversal
517
+ if (!uri || typeof uri !== 'string') {
518
+ throw new ValidationError('Invalid resource URI');
519
+ }
520
+
521
+ // Ensure the URI doesn't contain path traversal attempts
522
+ if (uri.includes('..') || uri.includes('//') || uri.includes('\\')) {
523
+ throw new ValidationError('Invalid resource URI: path traversal detected');
524
+ }
525
+
526
+ // Only allow specific URI patterns for Ghost resources
527
+ const validPatterns = /^ghost\/(post|posts|tag|tags|author|authors|page|pages)/;
528
+ if (!validPatterns.test(uri)) {
529
+ throw new ValidationError('Invalid resource type');
530
+ }
531
+
532
+ const result = await resourceManager.fetchResource(uri);
533
+ res.json(result);
534
+ } catch (error) {
535
+ const formatted = ErrorHandler.formatHTTPError(error);
536
+ res.status(formatted.statusCode).json(formatted.body);
537
+ }
538
+ });
539
+
540
+ // Batch endpoint
541
+ app.post('/batch', async (req, res) => {
542
+ try {
543
+ const result = await resourceManager.batchFetch(req.body.uris);
544
+ res.json(result);
545
+ } catch (error) {
546
+ const formatted = ErrorHandler.formatHTTPError(error);
547
+ res.status(formatted.statusCode).json(formatted.body);
548
+ }
549
+ });
550
+
551
+ // Cache stats endpoint
552
+ app.get('/cache/stats', (req, res) => {
553
+ res.json(resourceManager.getCacheStats());
554
+ });
555
+
556
+ // SSE endpoint for MCP
557
+ const sseTransport = new SSEServerTransport();
558
+ app.get('/mcp/sse', sseTransport.handler());
559
+
560
+ await mcpServer.connect(sseTransport);
561
+
562
+ const server = app.listen(port, () => {
563
+ console.log(`Enhanced MCP Server (SSE) listening on port ${port}`);
564
+ console.log(`Health: http://localhost:${port}/health`);
565
+ console.log(`Resources: http://localhost:${port}/resources`);
566
+ console.log(`SSE: http://localhost:${port}/mcp/sse`);
567
+ });
568
+
569
+ mcpServer._httpServer = server;
570
+
571
+ // Track connections for graceful shutdown
572
+ server.on('connection', (connection) => {
573
+ gracefulShutdown.trackConnection(connection);
574
+ });
575
+
576
+ break;
577
+
578
+ case 'websocket':
579
+ const wsPort = options.port || 3001;
580
+ const wss = new WebSocketServer({ port: wsPort });
581
+
582
+ wss.on('connection', async (ws) => {
583
+ console.log('New WebSocket connection');
584
+
585
+ const wsTransport = new WebSocketServerTransport(ws);
586
+ await mcpServer.connect(wsTransport);
587
+
588
+ // Handle subscriptions over WebSocket
589
+ ws.on('message', async (data) => {
590
+ try {
591
+ const message = JSON.parse(data);
592
+
593
+ if (message.type === 'subscribe') {
594
+ const subscriptionId = resourceManager.subscribe(
595
+ message.uri,
596
+ (event) => {
597
+ ws.send(JSON.stringify({
598
+ type: 'subscription_update',
599
+ ...event
600
+ }));
601
+ },
602
+ message.options || {}
603
+ );
604
+
605
+ ws.send(JSON.stringify({
606
+ type: 'subscription_created',
607
+ subscriptionId
608
+ }));
609
+ }
610
+ } catch (error) {
611
+ ws.send(JSON.stringify({
612
+ type: 'error',
613
+ error: error.message
614
+ }));
615
+ }
616
+ });
617
+ });
618
+
619
+ console.log(`Enhanced MCP Server (WebSocket) listening on port ${wsPort}`);
620
+ mcpServer._wss = wss;
621
+ break;
622
+
623
+ default:
624
+ throw new Error(`Unknown transport type: ${transport}`);
625
+ }
626
+
627
+ // Log capabilities
628
+ console.log("Server Capabilities:");
629
+ console.log("- Resources:", mcpServer.listResources().map(r => r.name));
630
+ console.log("- Tools:", mcpServer.listTools().map(t => t.name));
631
+ console.log("- Cache enabled with LRU eviction");
632
+ console.log("- Subscription support for real-time updates");
633
+ console.log("- Batch operations for efficiency");
634
+
635
+ } catch (error) {
636
+ errorLogger.logError(error);
637
+ console.error("Failed to start Enhanced MCP Server:", error);
638
+ process.exit(1);
639
+ }
640
+ };
641
+
642
+ // Graceful shutdown
643
+ const shutdown = async () => {
644
+ console.log("\nShutting down Enhanced MCP Server...");
645
+
646
+ // Clear all subscriptions
647
+ resourceManager.subscriptionManager.subscriptions.clear();
648
+
649
+ // Close servers
650
+ if (mcpServer._httpServer) {
651
+ await gracefulShutdown.shutdown(mcpServer._httpServer);
652
+ }
653
+
654
+ if (mcpServer._wss) {
655
+ mcpServer._wss.close();
656
+ }
657
+
658
+ await mcpServer.close();
659
+ process.exit(0);
660
+ };
661
+
662
+ process.on('SIGINT', shutdown);
663
+ process.on('SIGTERM', shutdown);
664
+
665
+ // Export
666
+ export { mcpServer, startEnhancedMCPServer, resourceManager };
667
+
668
+ // If running directly
669
+ if (import.meta.url === `file://${process.argv[1]}`) {
670
+ const transport = process.env.MCP_TRANSPORT || 'http';
671
+ const port = parseInt(process.env.MCP_PORT || '3001');
672
+ const allowedOrigins = process.env.MCP_ALLOWED_ORIGINS?.split(',') || ['*'];
673
+
674
+ startEnhancedMCPServer(transport, { port, allowedOrigins });
675
+ }