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