@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,666 @@
1
+ /**
2
+ * Enhanced Resource Management System for MCP Server
3
+ * Provides caching, pagination, filtering, and subscription capabilities
4
+ */
5
+
6
+ import { Resource } from "@modelcontextprotocol/sdk/server/index.js";
7
+ import { NotFoundError, ValidationError, ErrorHandler } from "../errors/index.js";
8
+ import EventEmitter from 'events';
9
+
10
+ /**
11
+ * LRU Cache implementation for resource caching
12
+ */
13
+ class LRUCache {
14
+ constructor(maxSize = 100, ttl = 300000) { // 5 minutes default TTL
15
+ this.maxSize = maxSize;
16
+ this.ttl = ttl;
17
+ this.cache = new Map();
18
+ this.accessOrder = [];
19
+ }
20
+
21
+ get(key) {
22
+ const item = this.cache.get(key);
23
+
24
+ if (!item) return null;
25
+
26
+ // Check if expired
27
+ if (Date.now() > item.expiry) {
28
+ this.cache.delete(key);
29
+ this.accessOrder = this.accessOrder.filter(k => k !== key);
30
+ return null;
31
+ }
32
+
33
+ // Update access order
34
+ this.accessOrder = this.accessOrder.filter(k => k !== key);
35
+ this.accessOrder.push(key);
36
+
37
+ return item.value;
38
+ }
39
+
40
+ set(key, value, customTTL = null) {
41
+ // Remove oldest items if at capacity
42
+ while (this.cache.size >= this.maxSize && this.accessOrder.length > 0) {
43
+ const oldestKey = this.accessOrder.shift();
44
+ this.cache.delete(oldestKey);
45
+ }
46
+
47
+ const ttl = customTTL || this.ttl;
48
+ this.cache.set(key, {
49
+ value,
50
+ expiry: Date.now() + ttl
51
+ });
52
+
53
+ this.accessOrder.push(key);
54
+ }
55
+
56
+ invalidate(pattern = null) {
57
+ if (!pattern) {
58
+ this.cache.clear();
59
+ this.accessOrder = [];
60
+ return;
61
+ }
62
+
63
+ // Invalidate entries matching pattern
64
+ const regex = new RegExp(pattern);
65
+ for (const [key] of this.cache) {
66
+ if (regex.test(key)) {
67
+ this.cache.delete(key);
68
+ this.accessOrder = this.accessOrder.filter(k => k !== key);
69
+ }
70
+ }
71
+ }
72
+
73
+ getStats() {
74
+ return {
75
+ size: this.cache.size,
76
+ maxSize: this.maxSize,
77
+ ttl: this.ttl,
78
+ keys: Array.from(this.cache.keys())
79
+ };
80
+ }
81
+ }
82
+
83
+ /**
84
+ * URI Parser for resource URIs
85
+ */
86
+ class ResourceURIParser {
87
+ static parse(uri) {
88
+ // Support various URI formats:
89
+ // - ghost/post/123
90
+ // - ghost/post/slug:my-post-slug
91
+ // - ghost/post/uuid:550e8400-e29b-41d4-a716-446655440000
92
+ // - ghost/posts?status=published&limit=10&page=2
93
+ // - ghost/tag/technology
94
+
95
+ const url = new URL(uri, 'resource://');
96
+ const pathParts = url.pathname.split('/').filter(Boolean);
97
+
98
+ if (pathParts.length < 2) {
99
+ throw new ValidationError('Invalid resource URI format');
100
+ }
101
+
102
+ const [namespace, resourceType, ...identifierParts] = pathParts;
103
+ const identifier = identifierParts.join('/');
104
+
105
+ // Parse query parameters
106
+ const query = {};
107
+ for (const [key, value] of url.searchParams) {
108
+ query[key] = value;
109
+ }
110
+
111
+ // Parse identifier type
112
+ let identifierType = 'id';
113
+ let identifierValue = identifier;
114
+
115
+ if (identifier && identifier.includes(':')) {
116
+ const [type, ...valueParts] = identifier.split(':');
117
+ identifierType = type;
118
+ identifierValue = valueParts.join(':');
119
+ }
120
+
121
+ return {
122
+ namespace,
123
+ resourceType,
124
+ identifier: identifierValue,
125
+ identifierType,
126
+ query,
127
+ isCollection: !identifier || Object.keys(query).length > 0
128
+ };
129
+ }
130
+
131
+ static build(parts) {
132
+ const { namespace, resourceType, identifier, query = {} } = parts;
133
+
134
+ let uri = `${namespace}/${resourceType}`;
135
+
136
+ if (identifier) {
137
+ uri += `/${identifier}`;
138
+ }
139
+
140
+ const queryString = new URLSearchParams(query).toString();
141
+ if (queryString) {
142
+ uri += `?${queryString}`;
143
+ }
144
+
145
+ return uri;
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Resource Fetcher with advanced capabilities
151
+ */
152
+ class ResourceFetcher {
153
+ constructor(ghostService, cache) {
154
+ this.ghostService = ghostService;
155
+ this.cache = cache;
156
+ }
157
+
158
+ async fetchPost(parsedURI) {
159
+ const { identifier, identifierType, query, isCollection } = parsedURI;
160
+
161
+ if (isCollection) {
162
+ return await this.fetchPosts(query);
163
+ }
164
+
165
+ const cacheKey = `post:${identifierType}:${identifier}`;
166
+
167
+ // Check cache
168
+ const cached = this.cache.get(cacheKey);
169
+ if (cached) {
170
+ console.log(`Cache hit for ${cacheKey}`);
171
+ return cached;
172
+ }
173
+
174
+ // Fetch from Ghost
175
+ let post;
176
+
177
+ switch (identifierType) {
178
+ case 'id':
179
+ post = await this.ghostService.getPost(identifier, { include: 'tags,authors' });
180
+ break;
181
+
182
+ case 'slug':
183
+ const posts = await this.ghostService.getPosts({
184
+ filter: `slug:${identifier}`,
185
+ include: 'tags,authors',
186
+ limit: 1
187
+ });
188
+ post = posts[0];
189
+ break;
190
+
191
+ case 'uuid':
192
+ const postsByUuid = await this.ghostService.getPosts({
193
+ filter: `uuid:${identifier}`,
194
+ include: 'tags,authors',
195
+ limit: 1
196
+ });
197
+ post = postsByUuid[0];
198
+ break;
199
+
200
+ default:
201
+ throw new ValidationError(`Unknown identifier type: ${identifierType}`);
202
+ }
203
+
204
+ if (!post) {
205
+ throw new NotFoundError('Post', identifier);
206
+ }
207
+
208
+ // Cache the result
209
+ this.cache.set(cacheKey, post);
210
+
211
+ return post;
212
+ }
213
+
214
+ async fetchPosts(query = {}) {
215
+ // Build cache key from query
216
+ const cacheKey = `posts:${JSON.stringify(query)}`;
217
+
218
+ // Check cache
219
+ const cached = this.cache.get(cacheKey);
220
+ if (cached) {
221
+ console.log(`Cache hit for posts query`);
222
+ return cached;
223
+ }
224
+
225
+ // Parse query parameters
226
+ const options = {
227
+ limit: parseInt(query.limit) || 15,
228
+ page: parseInt(query.page) || 1,
229
+ include: query.include || 'tags,authors',
230
+ filter: query.filter,
231
+ order: query.order || 'published_at desc'
232
+ };
233
+
234
+ // Add status filter if provided
235
+ if (query.status) {
236
+ options.filter = options.filter
237
+ ? `${options.filter}+status:${query.status}`
238
+ : `status:${query.status}`;
239
+ }
240
+
241
+ // Fetch from Ghost
242
+ const result = await this.ghostService.getPosts(options);
243
+
244
+ // Format response with pagination metadata
245
+ const response = {
246
+ data: result,
247
+ meta: {
248
+ pagination: {
249
+ page: options.page,
250
+ limit: options.limit,
251
+ pages: Math.ceil(result.meta?.pagination?.total / options.limit) || 1,
252
+ total: result.meta?.pagination?.total || result.length,
253
+ next: result.meta?.pagination?.next || null,
254
+ prev: result.meta?.pagination?.prev || null
255
+ }
256
+ }
257
+ };
258
+
259
+ // Cache with shorter TTL for collections
260
+ this.cache.set(cacheKey, response, 60000); // 1 minute for collections
261
+
262
+ return response;
263
+ }
264
+
265
+ async fetchTag(parsedURI) {
266
+ const { identifier, identifierType, query, isCollection } = parsedURI;
267
+
268
+ if (isCollection) {
269
+ return await this.fetchTags(query);
270
+ }
271
+
272
+ const cacheKey = `tag:${identifierType}:${identifier}`;
273
+
274
+ // Check cache
275
+ const cached = this.cache.get(cacheKey);
276
+ if (cached) {
277
+ console.log(`Cache hit for ${cacheKey}`);
278
+ return cached;
279
+ }
280
+
281
+ // Fetch from Ghost
282
+ let tag;
283
+
284
+ switch (identifierType) {
285
+ case 'id':
286
+ tag = await this.ghostService.getTag(identifier);
287
+ break;
288
+
289
+ case 'slug':
290
+ const tags = await this.ghostService.getTags();
291
+ tag = tags.find(t => t.slug === identifier);
292
+ break;
293
+
294
+ case 'name':
295
+ const tagsByName = await this.ghostService.getTags(identifier);
296
+ tag = tagsByName[0];
297
+ break;
298
+
299
+ default:
300
+ // Assume it's a slug if no type specified
301
+ const tagsBySlug = await this.ghostService.getTags();
302
+ tag = tagsBySlug.find(t => t.slug === identifier || t.id === identifier);
303
+ }
304
+
305
+ if (!tag) {
306
+ throw new NotFoundError('Tag', identifier);
307
+ }
308
+
309
+ // Cache the result
310
+ this.cache.set(cacheKey, tag);
311
+
312
+ return tag;
313
+ }
314
+
315
+ async fetchTags(query = {}) {
316
+ const cacheKey = `tags:${JSON.stringify(query)}`;
317
+
318
+ // Check cache
319
+ const cached = this.cache.get(cacheKey);
320
+ if (cached) {
321
+ console.log(`Cache hit for tags query`);
322
+ return cached;
323
+ }
324
+
325
+ // Fetch from Ghost
326
+ const tags = await this.ghostService.getTags(query.name);
327
+
328
+ // Apply client-side filtering if needed
329
+ let filteredTags = tags;
330
+
331
+ if (query.filter) {
332
+ // Simple filtering implementation
333
+ const filters = query.filter.split('+');
334
+ filteredTags = tags.filter(tag => {
335
+ return filters.every(filter => {
336
+ const [field, value] = filter.split(':');
337
+ return tag[field]?.toString().toLowerCase().includes(value.toLowerCase());
338
+ });
339
+ });
340
+ }
341
+
342
+ // Apply pagination
343
+ const limit = parseInt(query.limit) || 50;
344
+ const page = parseInt(query.page) || 1;
345
+ const start = (page - 1) * limit;
346
+ const paginatedTags = filteredTags.slice(start, start + limit);
347
+
348
+ const response = {
349
+ data: paginatedTags,
350
+ meta: {
351
+ pagination: {
352
+ page,
353
+ limit,
354
+ pages: Math.ceil(filteredTags.length / limit),
355
+ total: filteredTags.length
356
+ }
357
+ }
358
+ };
359
+
360
+ // Cache with shorter TTL for collections
361
+ this.cache.set(cacheKey, response, 60000);
362
+
363
+ return response;
364
+ }
365
+ }
366
+
367
+ /**
368
+ * Resource Subscription Manager
369
+ */
370
+ class ResourceSubscriptionManager extends EventEmitter {
371
+ constructor(resourceFetcher = null) {
372
+ super();
373
+ this.subscriptions = new Map();
374
+ this.pollingIntervals = new Map();
375
+ this.resourceFetcher = resourceFetcher;
376
+ }
377
+
378
+ subscribe(uri, callback, options = {}) {
379
+ const subscriptionId = `sub_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
380
+ const pollingInterval = options.pollingInterval || 30000; // 30 seconds default
381
+
382
+ const subscription = {
383
+ id: subscriptionId,
384
+ uri,
385
+ callback,
386
+ lastValue: null,
387
+ options
388
+ };
389
+
390
+ this.subscriptions.set(subscriptionId, subscription);
391
+
392
+ // Start polling if requested
393
+ if (options.enablePolling) {
394
+ this.startPolling(subscriptionId, pollingInterval);
395
+ }
396
+
397
+ console.log(`Created subscription ${subscriptionId} for ${uri}`);
398
+
399
+ return subscriptionId;
400
+ }
401
+
402
+ unsubscribe(subscriptionId) {
403
+ const subscription = this.subscriptions.get(subscriptionId);
404
+
405
+ if (!subscription) {
406
+ throw new NotFoundError('Subscription', subscriptionId);
407
+ }
408
+
409
+ // Stop polling if active
410
+ this.stopPolling(subscriptionId);
411
+
412
+ // Remove subscription
413
+ this.subscriptions.delete(subscriptionId);
414
+
415
+ console.log(`Removed subscription ${subscriptionId}`);
416
+ }
417
+
418
+ startPolling(subscriptionId, interval) {
419
+ const subscription = this.subscriptions.get(subscriptionId);
420
+
421
+ if (!subscription) return;
422
+
423
+ const pollFunc = async () => {
424
+ try {
425
+ const currentValue = await this.fetchResource(subscription.uri);
426
+
427
+ // Check if value changed
428
+ if (JSON.stringify(currentValue) !== JSON.stringify(subscription.lastValue)) {
429
+ subscription.lastValue = currentValue;
430
+ subscription.callback({
431
+ type: 'update',
432
+ uri: subscription.uri,
433
+ data: currentValue,
434
+ timestamp: new Date().toISOString()
435
+ });
436
+ }
437
+ } catch (error) {
438
+ subscription.callback({
439
+ type: 'error',
440
+ uri: subscription.uri,
441
+ error: error.message,
442
+ timestamp: new Date().toISOString()
443
+ });
444
+ }
445
+ };
446
+
447
+ // Initial fetch
448
+ pollFunc();
449
+
450
+ // Set up interval
451
+ const intervalId = setInterval(pollFunc, interval);
452
+ this.pollingIntervals.set(subscriptionId, intervalId);
453
+ }
454
+
455
+ stopPolling(subscriptionId) {
456
+ const intervalId = this.pollingIntervals.get(subscriptionId);
457
+
458
+ if (intervalId) {
459
+ clearInterval(intervalId);
460
+ this.pollingIntervals.delete(subscriptionId);
461
+ }
462
+ }
463
+
464
+ async fetchResource(uri) {
465
+ // Use the injected resource fetcher if available
466
+ if (this.resourceFetcher && typeof this.resourceFetcher === 'function') {
467
+ return await this.resourceFetcher(uri);
468
+ }
469
+
470
+ // Fallback error if no fetcher is configured
471
+ throw new Error('Resource fetcher not configured for subscription manager');
472
+ }
473
+
474
+ notifySubscribers(uri, data, eventType = 'update') {
475
+ for (const [, subscription] of this.subscriptions) {
476
+ if (this.matchesSubscription(subscription.uri, uri)) {
477
+ subscription.callback({
478
+ type: eventType,
479
+ uri,
480
+ data,
481
+ timestamp: new Date().toISOString()
482
+ });
483
+ }
484
+ }
485
+ }
486
+
487
+ matchesSubscription(subscriptionURI, eventURI) {
488
+ // Simple matching - could be enhanced with wildcards
489
+ return subscriptionURI === eventURI ||
490
+ subscriptionURI.startsWith(eventURI) ||
491
+ eventURI.startsWith(subscriptionURI);
492
+ }
493
+ }
494
+
495
+ /**
496
+ * Main Resource Manager
497
+ */
498
+ export class ResourceManager {
499
+ constructor(ghostService) {
500
+ this.ghostService = ghostService;
501
+ this.cache = new LRUCache(100, 300000); // 100 items, 5 min TTL
502
+ this.fetcher = new ResourceFetcher(ghostService, this.cache);
503
+ // Pass a bound fetchResource method to the subscription manager
504
+ this.subscriptionManager = new ResourceSubscriptionManager(
505
+ (uri) => this.fetchResource(uri)
506
+ );
507
+ this.resources = new Map();
508
+ }
509
+
510
+ /**
511
+ * Register a resource with enhanced fetching
512
+ */
513
+ registerResource(name, schema, options = {}) {
514
+ const resource = new Resource({
515
+ name,
516
+ description: options.description,
517
+ schema,
518
+ fetch: async (uri) => this.fetchResource(uri)
519
+ });
520
+
521
+ this.resources.set(name, {
522
+ resource,
523
+ options
524
+ });
525
+
526
+ return resource;
527
+ }
528
+
529
+ /**
530
+ * Main resource fetching method
531
+ */
532
+ async fetchResource(uri) {
533
+ try {
534
+ const parsed = ResourceURIParser.parse(uri);
535
+
536
+ console.log('Fetching resource:', { uri: uri.substring(0, 100), parsed });
537
+
538
+ // Route to appropriate fetcher
539
+ switch (parsed.resourceType) {
540
+ case 'post':
541
+ case 'posts':
542
+ return await this.fetcher.fetchPost(parsed);
543
+
544
+ case 'tag':
545
+ case 'tags':
546
+ return await this.fetcher.fetchTag(parsed);
547
+
548
+ default:
549
+ throw new ValidationError(`Unknown resource type: ${parsed.resourceType}`);
550
+ }
551
+ } catch (error) {
552
+ console.error('Error fetching resource:', { uri: uri.substring(0, 100), error: error.message });
553
+ throw error;
554
+ }
555
+ }
556
+
557
+ /**
558
+ * List all available resources
559
+ */
560
+ listResources(filter = {}) {
561
+ const resources = [];
562
+
563
+ for (const [name, { resource, options }] of this.resources) {
564
+ // Apply filter if provided
565
+ if (filter.namespace && !name.startsWith(filter.namespace)) {
566
+ continue;
567
+ }
568
+
569
+ resources.push({
570
+ uri: name,
571
+ name: resource.name,
572
+ description: resource.description,
573
+ ...options
574
+ });
575
+ }
576
+
577
+ return resources;
578
+ }
579
+
580
+ /**
581
+ * Subscribe to resource changes
582
+ */
583
+ subscribe(uri, callback, options = {}) {
584
+ return this.subscriptionManager.subscribe(uri, callback, options);
585
+ }
586
+
587
+ /**
588
+ * Unsubscribe from resource changes
589
+ */
590
+ unsubscribe(subscriptionId) {
591
+ return this.subscriptionManager.unsubscribe(subscriptionId);
592
+ }
593
+
594
+ /**
595
+ * Invalidate cache
596
+ */
597
+ invalidateCache(pattern = null) {
598
+ this.cache.invalidate(pattern);
599
+ console.log(`Cache invalidated${pattern ? ` for pattern: ${pattern}` : ''}`);
600
+ }
601
+
602
+ /**
603
+ * Notify about resource changes (for webhooks)
604
+ */
605
+ notifyChange(uri, data, eventType = 'update') {
606
+ // Invalidate cache for this resource
607
+ this.cache.invalidate(uri);
608
+
609
+ // Notify subscribers
610
+ this.subscriptionManager.notifySubscribers(uri, data, eventType);
611
+ }
612
+
613
+ /**
614
+ * Get cache statistics
615
+ */
616
+ getCacheStats() {
617
+ return this.cache.getStats();
618
+ }
619
+
620
+ /**
621
+ * Batch fetch multiple resources
622
+ */
623
+ async batchFetch(uris) {
624
+ const results = {};
625
+ const errors = {};
626
+
627
+ await Promise.all(
628
+ uris.map(async (uri) => {
629
+ try {
630
+ results[uri] = await this.fetchResource(uri);
631
+ } catch (error) {
632
+ errors[uri] = {
633
+ message: error.message,
634
+ code: error.code
635
+ };
636
+ }
637
+ })
638
+ );
639
+
640
+ return { results, errors };
641
+ }
642
+
643
+ /**
644
+ * Prefetch resources for warming cache
645
+ */
646
+ async prefetch(patterns) {
647
+ const prefetched = [];
648
+
649
+ for (const pattern of patterns) {
650
+ try {
651
+ const result = await this.fetchResource(pattern);
652
+ prefetched.push({ pattern, status: 'success' });
653
+ } catch (error) {
654
+ prefetched.push({
655
+ pattern,
656
+ status: 'error',
657
+ error: error.message
658
+ });
659
+ }
660
+ }
661
+
662
+ return prefetched;
663
+ }
664
+ }
665
+
666
+ export default ResourceManager;
@@ -0,0 +1,33 @@
1
+ import express from "express";
2
+ import rateLimit from "express-rate-limit";
3
+ import { upload, handleImageUpload } from "../controllers/imageController.js";
4
+
5
+ const router = express.Router();
6
+
7
+ // Create rate limiter for image uploads using express-rate-limit
8
+ // More restrictive than general API: 10 uploads per minute
9
+ const imageUploadRateLimiter = rateLimit({
10
+ windowMs: 60 * 1000, // 1 minute
11
+ max: 10, // Limit each IP to 10 requests per windowMs
12
+ message: "Too many image upload attempts from this IP, please try again after a minute",
13
+ standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
14
+ legacyHeaders: false, // Disable the `X-RateLimit-*` headers
15
+ handler: (req, res) => {
16
+ res.status(429).json({
17
+ error: "Too many requests",
18
+ message: "You have exceeded the 10 image uploads per minute limit. Please try again later.",
19
+ retryAfter: 60
20
+ });
21
+ }
22
+ });
23
+
24
+ // Define the route for uploading an image
25
+ // POST /api/images
26
+ // The `upload.single('image')` middleware handles the file upload.
27
+ // 'image' should match the field name in the form-data request.
28
+ // Added rate limiting to prevent abuse of file system operations
29
+ router.post("/", imageUploadRateLimiter, upload.single("image"), handleImageUpload);
30
+
31
+ // Add other image-related routes here later if needed
32
+
33
+ export default router;