rewind-mcp-server 0.1.1 → 0.4.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.
Files changed (53) hide show
  1. package/README.md +84 -11
  2. package/dist/__tests__/helpers.test.d.ts +1 -0
  3. package/dist/__tests__/helpers.test.js +131 -0
  4. package/dist/__tests__/helpers.test.js.map +1 -0
  5. package/dist/__tests__/image-block.live.d.ts +1 -0
  6. package/dist/__tests__/image-block.live.js +41 -0
  7. package/dist/__tests__/image-block.live.js.map +1 -0
  8. package/dist/__tests__/server.test.js +713 -16
  9. package/dist/__tests__/server.test.js.map +1 -1
  10. package/dist/__tests__/stdio-e2e.live.d.ts +1 -0
  11. package/dist/__tests__/stdio-e2e.live.js +60 -0
  12. package/dist/__tests__/stdio-e2e.live.js.map +1 -0
  13. package/dist/__tests__/ui-resource.live.d.ts +1 -0
  14. package/dist/__tests__/ui-resource.live.js +71 -0
  15. package/dist/__tests__/ui-resource.live.js.map +1 -0
  16. package/dist/client.d.ts +9 -0
  17. package/dist/client.js +15 -0
  18. package/dist/client.js.map +1 -1
  19. package/dist/index.js +0 -0
  20. package/dist/prompts.js +132 -0
  21. package/dist/prompts.js.map +1 -1
  22. package/dist/resources/ui.d.ts +33 -0
  23. package/dist/resources/ui.js +14 -0
  24. package/dist/resources/ui.js.map +1 -0
  25. package/dist/resources.js +144 -0
  26. package/dist/resources.js.map +1 -1
  27. package/dist/server.js +55 -1
  28. package/dist/server.js.map +1 -1
  29. package/dist/tools/collecting.js +78 -37
  30. package/dist/tools/collecting.js.map +1 -1
  31. package/dist/tools/cross-domain.js +147 -19
  32. package/dist/tools/cross-domain.js.map +1 -1
  33. package/dist/tools/debug.d.ts +6 -0
  34. package/dist/tools/debug.js +26 -0
  35. package/dist/tools/debug.js.map +1 -0
  36. package/dist/tools/helpers.d.ts +81 -5
  37. package/dist/tools/helpers.js +138 -4
  38. package/dist/tools/helpers.js.map +1 -1
  39. package/dist/tools/listening.js +223 -52
  40. package/dist/tools/listening.js.map +1 -1
  41. package/dist/tools/reading.js +122 -22
  42. package/dist/tools/reading.js.map +1 -1
  43. package/dist/tools/running.js +87 -43
  44. package/dist/tools/running.js.map +1 -1
  45. package/dist/tools/watching.js +199 -32
  46. package/dist/tools/watching.js.map +1 -1
  47. package/dist/ui-bundles.d.ts +3 -0
  48. package/dist/ui-bundles.js +8 -0
  49. package/dist/ui-bundles.js.map +1 -0
  50. package/dist/worker.d.ts +2 -1
  51. package/dist/worker.js +55 -30
  52. package/dist/worker.js.map +1 -1
  53. package/package.json +13 -2
@@ -13,6 +13,12 @@ async function createTestClient() {
13
13
  vi.spyOn(rewindClient, 'get').mockImplementation(async (path) => {
14
14
  return getMockResponse(path);
15
15
  });
16
+ // Mock image fetches -- return a tiny fake JPEG payload so imageBlock()
17
+ // produces a base64 image content block in tool responses.
18
+ vi.spyOn(rewindClient, 'getBinaryFromUrl').mockResolvedValue({
19
+ bytes: new Uint8Array([0xff, 0xd8, 0xff, 0xe0]),
20
+ mimeType: 'image/jpeg',
21
+ });
16
22
  const server = createServer(rewindClient);
17
23
  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
18
24
  const client = new Client({
@@ -29,9 +35,27 @@ function getMockResponse(path) {
29
35
  if (path === '/listening/now-playing') {
30
36
  return {
31
37
  is_playing: true,
32
- track: { id: 1, name: 'Sabotage' },
33
- artist: { name: 'Beastie Boys' },
34
- album: { name: 'Ill Communication' },
38
+ track: {
39
+ name: 'Sabotage',
40
+ artist: {
41
+ id: 10,
42
+ name: 'Beastie Boys',
43
+ apple_music_url: 'https://music.apple.com/us/artist/beastie-boys/12',
44
+ },
45
+ album: {
46
+ id: 20,
47
+ name: 'Ill Communication',
48
+ image: {
49
+ cdn_url: 'https://cdn.rewind.rest/listening/albums/20/original.jpg',
50
+ thumbhash: 'x',
51
+ dominant_color: '#111',
52
+ accent_color: '#222',
53
+ },
54
+ },
55
+ url: 'https://www.last.fm/music/Beastie+Boys/_/Sabotage',
56
+ apple_music_url: 'https://music.apple.com/us/album/sabotage/30',
57
+ preview_url: null,
58
+ },
35
59
  scrobbled_at: new Date().toISOString(),
36
60
  };
37
61
  }
@@ -39,9 +63,24 @@ function getMockResponse(path) {
39
63
  return {
40
64
  data: [
41
65
  {
42
- track: { name: 'Sabotage' },
43
- artist: { name: 'Beastie Boys' },
44
- album: { name: 'Ill Communication' },
66
+ track: {
67
+ id: 1,
68
+ name: 'Sabotage',
69
+ url: 'https://www.last.fm/x',
70
+ apple_music_url: 'https://music.apple.com/us/album/sabotage/30?i=40',
71
+ preview_url: null,
72
+ },
73
+ artist: { id: 10, name: 'Beastie Boys' },
74
+ album: {
75
+ id: 20,
76
+ name: 'Ill Communication',
77
+ image: {
78
+ cdn_url: 'https://cdn.rewind.rest/listening/albums/20/original.jpg',
79
+ thumbhash: 'x',
80
+ dominant_color: '#111',
81
+ accent_color: '#222',
82
+ },
83
+ },
45
84
  scrobbled_at: new Date().toISOString(),
46
85
  },
47
86
  ],
@@ -53,15 +92,31 @@ function getMockResponse(path) {
53
92
  unique_artists: 2100,
54
93
  unique_albums: 3800,
55
94
  unique_tracks: 12000,
56
- scrobbles_per_day: 12.3,
95
+ registered_date: '2008-01-01',
57
96
  years_tracking: 18,
97
+ scrobbles_per_day: 12.3,
58
98
  };
59
99
  }
60
100
  if (path.startsWith('/listening/top/')) {
61
101
  return {
62
102
  period: '1month',
63
103
  data: [
64
- { rank: 1, name: 'Beastie Boys', detail: 'Artist', playcount: 45 },
104
+ {
105
+ rank: 1,
106
+ id: 10,
107
+ name: 'Beastie Boys',
108
+ detail: 'Artist',
109
+ playcount: 45,
110
+ image: {
111
+ cdn_url: 'https://cdn.rewind.rest/listening/artists/10/original.jpg',
112
+ thumbhash: 'x',
113
+ dominant_color: '#111',
114
+ accent_color: '#222',
115
+ },
116
+ url: 'https://www.last.fm/music/Beastie+Boys',
117
+ apple_music_url: 'https://music.apple.com/us/artist/beastie-boys/12',
118
+ preview_url: null,
119
+ },
65
120
  ],
66
121
  };
67
122
  }
@@ -76,22 +131,74 @@ function getMockResponse(path) {
76
131
  },
77
132
  };
78
133
  }
134
+ if (path === '/listening/genres') {
135
+ return {
136
+ data: [
137
+ { period: '2026-03', genres: { Rock: 120, Pop: 80 }, total: 200 },
138
+ { period: '2026-04', genres: { Rock: 90, 'Hip-Hop': 60 }, total: 150 },
139
+ ],
140
+ };
141
+ }
79
142
  if (path.match(/\/listening\/artists\/\d+/)) {
80
143
  return {
144
+ id: 10,
81
145
  name: 'Beastie Boys',
146
+ mbid: null,
147
+ url: 'https://www.last.fm/music/Beastie+Boys',
148
+ apple_music_url: 'https://music.apple.com/us/artist/beastie-boys/12',
82
149
  playcount: 500,
83
150
  scrobble_count: 500,
84
151
  genre: 'Hip Hop',
85
- top_albums: [{ name: "Paul's Boutique", playcount: 100 }],
86
- top_tracks: [{ name: 'Sabotage', scrobble_count: 50 }],
152
+ image: {
153
+ cdn_url: 'https://cdn.rewind.rest/listening/artists/10/original.jpg',
154
+ thumbhash: 'x',
155
+ dominant_color: '#111',
156
+ accent_color: '#222',
157
+ },
158
+ top_albums: [
159
+ {
160
+ id: 20,
161
+ name: "Paul's Boutique",
162
+ playcount: 100,
163
+ apple_music_url: null,
164
+ image: null,
165
+ },
166
+ ],
167
+ top_tracks: [
168
+ {
169
+ id: 30,
170
+ name: 'Sabotage',
171
+ scrobble_count: 50,
172
+ apple_music_url: null,
173
+ preview_url: null,
174
+ },
175
+ ],
87
176
  };
88
177
  }
89
178
  if (path.match(/\/listening\/albums\/\d+/)) {
90
179
  return {
180
+ id: 20,
91
181
  name: "Paul's Boutique",
92
- artist: { name: 'Beastie Boys' },
182
+ mbid: null,
183
+ url: 'https://www.last.fm/music/Beastie+Boys/Paul%27s+Boutique',
184
+ apple_music_url: 'https://music.apple.com/us/album/pauls-boutique/40',
93
185
  playcount: 100,
94
- tracks: [{ name: 'Shake Your Rump', scrobble_count: 20 }],
186
+ image: {
187
+ cdn_url: 'https://cdn.rewind.rest/listening/albums/20/original.jpg',
188
+ thumbhash: 'x',
189
+ dominant_color: '#111',
190
+ accent_color: '#222',
191
+ },
192
+ artist: { id: 10, name: 'Beastie Boys' },
193
+ tracks: [
194
+ {
195
+ id: 30,
196
+ name: 'Shake Your Rump',
197
+ scrobble_count: 20,
198
+ apple_music_url: null,
199
+ preview_url: null,
200
+ },
201
+ ],
95
202
  };
96
203
  }
97
204
  // Running
@@ -123,6 +230,33 @@ function getMockResponse(path) {
123
230
  city: 'Austin',
124
231
  state: 'TX',
125
232
  is_race: false,
233
+ strava_url: 'https://www.strava.com/activities/17956091264',
234
+ },
235
+ ],
236
+ };
237
+ }
238
+ if (path === '/running/stats/years') {
239
+ return {
240
+ data: [
241
+ {
242
+ year: 2026,
243
+ total_runs: 30,
244
+ total_distance_mi: 120,
245
+ total_elevation_ft: 4000,
246
+ total_duration_s: 54000,
247
+ avg_pace: '8:00/mi',
248
+ longest_run_mi: 13.1,
249
+ race_count: 1,
250
+ },
251
+ {
252
+ year: 2025,
253
+ total_runs: 120,
254
+ total_distance_mi: 540,
255
+ total_elevation_ft: 18000,
256
+ total_duration_s: 240000,
257
+ avg_pace: '7:55/mi',
258
+ longest_run_mi: 13.1,
259
+ race_count: 3,
126
260
  },
127
261
  ],
128
262
  };
@@ -190,20 +324,31 @@ function getMockResponse(path) {
190
324
  data: [
191
325
  {
192
326
  movie: {
327
+ id: 1,
193
328
  title: 'The Royal Tenenbaums',
194
329
  year: 2001,
195
330
  director: 'Wes Anderson',
331
+ tmdb_id: 9428,
332
+ image: {
333
+ url: 'https://cdn.rewind.rest/watching/movies/1/original.jpg',
334
+ thumbhash: 'xhash',
335
+ dominant_color: '#222',
336
+ accent_color: '#c83',
337
+ },
196
338
  },
197
339
  watched_at: new Date().toISOString(),
198
340
  user_rating: 9,
199
341
  rewatch: false,
200
342
  source: 'plex',
343
+ review: null,
344
+ review_url: 'https://letterboxd.com/patdugan/film/the-royal-tenenbaums/',
201
345
  },
202
346
  ],
203
347
  };
204
348
  }
205
349
  if (path.match(/\/watching\/movies\/\d+/)) {
206
350
  return {
351
+ id: 1,
207
352
  title: 'The Royal Tenenbaums',
208
353
  year: 2001,
209
354
  director: 'Wes Anderson',
@@ -211,11 +356,27 @@ function getMockResponse(path) {
211
356
  genres: ['Comedy', 'Drama'],
212
357
  duration_min: 109,
213
358
  rating: 'R',
359
+ tmdb_id: 9428,
214
360
  tmdb_rating: 7.6,
215
361
  tagline: "Family isn't a word. It's a sentence.",
216
362
  summary: 'A family drama.',
217
363
  imdb_id: 'tt0265666',
218
- watch_history: [],
364
+ image: {
365
+ url: 'https://cdn.rewind.rest/watching/movies/1/original.jpg',
366
+ thumbhash: 'xhash',
367
+ dominant_color: '#222',
368
+ accent_color: '#c83',
369
+ },
370
+ watch_history: [
371
+ {
372
+ watched_at: '2026-03-10T02:30:00.000Z',
373
+ user_rating: 9,
374
+ rewatch: false,
375
+ review: null,
376
+ review_url: 'https://letterboxd.com/patdugan/film/the-royal-tenenbaums/',
377
+ source: 'plex',
378
+ },
379
+ ],
219
380
  };
220
381
  }
221
382
  if (path === '/watching/stats') {
@@ -234,6 +395,31 @@ function getMockResponse(path) {
234
395
  },
235
396
  };
236
397
  }
398
+ if (path === '/watching/stats/genres') {
399
+ return {
400
+ data: [
401
+ { name: 'Drama', count: 120, percentage: 38.5 },
402
+ { name: 'Comedy', count: 80, percentage: 25.6 },
403
+ ],
404
+ };
405
+ }
406
+ if (path === '/watching/stats/decades') {
407
+ return {
408
+ data: [
409
+ { decade: 2020, count: 45 },
410
+ { decade: 2010, count: 90 },
411
+ { decade: 2000, count: 60 },
412
+ ],
413
+ };
414
+ }
415
+ if (path === '/watching/stats/directors') {
416
+ return {
417
+ data: [
418
+ { name: 'Wes Anderson', count: 8 },
419
+ { name: 'Christopher Nolan', count: 6 },
420
+ ],
421
+ };
422
+ }
237
423
  if (path === '/watching/movies') {
238
424
  return {
239
425
  data: [
@@ -242,9 +428,18 @@ function getMockResponse(path) {
242
428
  title: 'The Royal Tenenbaums',
243
429
  year: 2001,
244
430
  director: 'Wes Anderson',
431
+ directors: ['Wes Anderson'],
245
432
  genres: ['Comedy'],
246
433
  duration_min: 109,
434
+ rating: 'R',
435
+ tmdb_id: 9428,
247
436
  tmdb_rating: 7.6,
437
+ image: {
438
+ url: 'https://cdn.rewind.rest/watching/movies/1/original.jpg',
439
+ thumbhash: 'xhash',
440
+ dominant_color: '#222',
441
+ accent_color: '#c83',
442
+ },
248
443
  },
249
444
  ],
250
445
  pagination: { page: 1, limit: 10, total: 1, total_pages: 1 },
@@ -256,6 +451,7 @@ function getMockResponse(path) {
256
451
  data: [
257
452
  {
258
453
  id: 1,
454
+ discogs_id: 100,
259
455
  title: 'Hello Nasty',
260
456
  artists: ['Beastie Boys'],
261
457
  year: 2009,
@@ -263,12 +459,42 @@ function getMockResponse(path) {
263
459
  format_detail: 'LP, Reissue',
264
460
  label: 'Capitol Records',
265
461
  genres: ['Hip Hop'],
462
+ styles: [],
463
+ image: {
464
+ cdn_url: 'https://cdn.rewind.rest/collecting/releases/1/original.jpg',
465
+ thumbhash: 'x',
466
+ dominant_color: '#111',
467
+ accent_color: '#222',
468
+ },
266
469
  date_added: '2024-01-15',
470
+ rating: null,
471
+ discogs_url: 'https://www.discogs.com/release/100',
267
472
  },
268
473
  ],
269
474
  pagination: { page: 1, limit: 10, total: 1, total_pages: 1 },
270
475
  };
271
476
  }
477
+ if (path.match(/\/collecting\/vinyl\/\d+/)) {
478
+ return {
479
+ id: 1,
480
+ discogs_id: 100,
481
+ title: 'Hello Nasty',
482
+ artists: ['Beastie Boys'],
483
+ year: 2009,
484
+ format: 'Vinyl',
485
+ format_detail: 'LP, Reissue',
486
+ label: 'Capitol Records',
487
+ genres: ['Hip Hop'],
488
+ styles: [],
489
+ image: {
490
+ cdn_url: 'https://cdn.rewind.rest/collecting/releases/1/original.jpg',
491
+ },
492
+ date_added: '2024-01-15',
493
+ rating: null,
494
+ discogs_url: 'https://www.discogs.com/release/100',
495
+ tracklist: [],
496
+ };
497
+ }
272
498
  if (path === '/collecting/stats') {
273
499
  return {
274
500
  data: {
@@ -276,9 +502,11 @@ function getMockResponse(path) {
276
502
  by_format: { vinyl: 120, cd: 25, cassette: 5 },
277
503
  wantlist_count: 30,
278
504
  unique_artists: 95,
505
+ estimated_value: null,
279
506
  top_genre: 'Rock',
280
507
  oldest_release_year: 1967,
281
508
  newest_release_year: 2025,
509
+ most_collected_artist: null,
282
510
  added_this_year: 12,
283
511
  },
284
512
  };
@@ -290,14 +518,48 @@ function getMockResponse(path) {
290
518
  id: 1,
291
519
  title: 'The Killing',
292
520
  year: 1956,
293
- media_type: 'bluray',
521
+ tmdb_id: 100,
522
+ imdb_id: null,
523
+ image: {
524
+ cdn_url: 'https://cdn.rewind.rest/collecting/releases/1/original.jpg',
525
+ },
526
+ runtime: null,
294
527
  tmdb_rating: 7.6,
528
+ media_type: 'bluray',
529
+ resolution: null,
530
+ hdr: null,
531
+ audio: null,
532
+ audio_channels: null,
295
533
  collected_at: '2026-03-26',
296
534
  },
297
535
  ],
298
536
  pagination: { page: 1, limit: 10, total: 1, total_pages: 1 },
299
537
  };
300
538
  }
539
+ if (path.match(/\/collecting\/media\/\d+$/)) {
540
+ return {
541
+ id: 1,
542
+ title: 'The Killing',
543
+ year: 1956,
544
+ tmdb_id: 100,
545
+ imdb_id: null,
546
+ tagline: null,
547
+ summary: null,
548
+ image: {
549
+ cdn_url: 'https://cdn.rewind.rest/collecting/releases/1/original.jpg',
550
+ },
551
+ runtime: null,
552
+ tmdb_rating: 7.6,
553
+ content_rating: null,
554
+ media_type: 'bluray',
555
+ resolution: null,
556
+ hdr: null,
557
+ audio: null,
558
+ audio_channels: null,
559
+ collected_at: '2026-03-26',
560
+ watch_history: [],
561
+ };
562
+ }
301
563
  if (path === '/collecting/media/formats') {
302
564
  return {
303
565
  data: [
@@ -315,15 +577,37 @@ function getMockResponse(path) {
315
577
  id: 1,
316
578
  title: 'How to Build an MCP Server',
317
579
  author: 'Anthropic',
580
+ url: 'https://www.anthropic.com/blog/how-to-build-an-mcp-server',
318
581
  domain: 'anthropic.com',
319
582
  estimated_read_min: 12,
320
583
  status: 'archived',
321
584
  progress: 1,
585
+ image: {
586
+ cdn_url: 'https://cdn.rewind.rest/reading/articles/1/original.jpg',
587
+ thumbhash: 'x',
588
+ dominant_color: '#111',
589
+ accent_color: '#222',
590
+ },
322
591
  saved_at: new Date().toISOString(),
323
592
  },
324
593
  ],
325
594
  };
326
595
  }
596
+ if (path.match(/\/reading\/articles\/\d+/)) {
597
+ return {
598
+ id: 1,
599
+ title: 'How to Build an MCP Server',
600
+ author: 'Anthropic',
601
+ url: 'https://www.anthropic.com/blog/how-to-build-an-mcp-server',
602
+ domain: 'anthropic.com',
603
+ estimated_read_min: 12,
604
+ status: 'archived',
605
+ progress: 1,
606
+ image: null,
607
+ highlights: [],
608
+ saved_at: new Date().toISOString(),
609
+ };
610
+ }
327
611
  if (path === '/reading/highlights') {
328
612
  return {
329
613
  data: [
@@ -332,9 +616,11 @@ function getMockResponse(path) {
332
616
  note: null,
333
617
  created_at: '2026-01-15',
334
618
  article: {
619
+ id: 99,
335
620
  title: 'Less is More',
336
621
  author: 'Jeff Atwood',
337
622
  domain: 'blog.codinghorror.com',
623
+ url: 'https://blog.codinghorror.com/less-is-more/',
338
624
  },
339
625
  },
340
626
  ],
@@ -347,9 +633,11 @@ function getMockResponse(path) {
347
633
  note: 'Leonardo da Vinci',
348
634
  created_at: '2025-06-01',
349
635
  article: {
636
+ id: 100,
350
637
  title: 'Design Principles',
351
638
  author: null,
352
639
  domain: 'example.com',
640
+ url: 'https://example.com/design-principles',
353
641
  },
354
642
  };
355
643
  }
@@ -404,11 +692,19 @@ function getMockResponse(path) {
404
692
  {
405
693
  domain: 'listening',
406
694
  entity_type: 'artist',
695
+ entity_id: '10',
407
696
  title: 'Beastie Boys',
408
697
  subtitle: null,
409
698
  },
699
+ {
700
+ domain: 'watching',
701
+ entity_type: 'movie',
702
+ entity_id: '1',
703
+ title: 'The Royal Tenenbaums',
704
+ subtitle: '2001',
705
+ },
410
706
  ],
411
- pagination: { total: 1 },
707
+ pagination: { total: 2 },
412
708
  };
413
709
  }
414
710
  // Health
@@ -457,6 +753,8 @@ describe('MCP Server', () => {
457
753
  expect(names).toContain('get_vinyl_collection');
458
754
  expect(names).toContain('get_physical_media');
459
755
  expect(names).toContain('search');
756
+ expect(names).toContain('semantic_search');
757
+ expect(names).toContain('find_similar_articles');
460
758
  expect(names).toContain('get_feed');
461
759
  });
462
760
  it('all tools have readOnlyHint annotation', async () => {
@@ -472,11 +770,24 @@ describe('MCP Server', () => {
472
770
  });
473
771
  it('lists prompts', async () => {
474
772
  const { prompts } = await client.listPrompts();
475
- expect(prompts.length).toBe(3);
773
+ expect(prompts.length).toBe(7);
476
774
  const names = prompts.map((p) => p.name);
477
775
  expect(names).toContain('weekly-summary');
478
776
  expect(names).toContain('year-in-review');
479
777
  expect(names).toContain('compare-periods');
778
+ expect(names).toContain('letterboxd-review-draft');
779
+ expect(names).toContain('training-report');
780
+ expect(names).toContain('film-diet');
781
+ expect(names).toContain('find-article');
782
+ });
783
+ it('exposes server instructions', () => {
784
+ const instructions = client.getInstructions();
785
+ expect(instructions).toBeTruthy();
786
+ expect(instructions).toContain('Rewind');
787
+ expect(instructions).toContain('listening');
788
+ expect(instructions).toContain('resource link');
789
+ // Keep under 2KB (Claude Code truncates above that)
790
+ expect(instructions.length).toBeLessThan(2048);
480
791
  });
481
792
  });
482
793
  describe('tool responses are text, not JSON', () => {
@@ -610,5 +921,391 @@ describe('MCP Server', () => {
610
921
  expect(text).toContain('2025');
611
922
  });
612
923
  });
924
+ describe('Phase 1 -- watching rich responses', () => {
925
+ it('lists the three new aggregate watching tools', async () => {
926
+ const { tools } = await client.listTools();
927
+ const names = tools.map((t) => t.name);
928
+ expect(names).toContain('get_watching_genres');
929
+ expect(names).toContain('get_watching_decades');
930
+ expect(names).toContain('get_watching_directors');
931
+ });
932
+ it('get_movie_details returns text, image, resource_link, and structuredContent', async () => {
933
+ const result = await client.callTool({
934
+ name: 'get_movie_details',
935
+ arguments: { id: 1 },
936
+ });
937
+ const content = result.content;
938
+ const textBlock = content.find((b) => b.type === 'text');
939
+ const image = content.find((b) => b.type === 'image');
940
+ const link = content.find((b) => b.type === 'resource_link');
941
+ expect(textBlock?.text).toContain('The Royal Tenenbaums');
942
+ expect(image?.mimeType).toBe('image/jpeg');
943
+ expect(image?.data).toBeTruthy();
944
+ expect(link?.uri).toContain('letterboxd.com');
945
+ const structured = result
946
+ .structuredContent;
947
+ expect(structured?.id).toBe(1);
948
+ });
949
+ it('get_movie_details with include_images=false omits image blocks', async () => {
950
+ const result = await client.callTool({
951
+ name: 'get_movie_details',
952
+ arguments: { id: 1, include_images: false },
953
+ });
954
+ const content = result.content;
955
+ expect(content.find((b) => b.type === 'image')).toBeUndefined();
956
+ // resource_link should still be present
957
+ expect(content.find((b) => b.type === 'resource_link')).toBeDefined();
958
+ });
959
+ it('get_recent_watches emits posters and Letterboxd links', async () => {
960
+ const result = await client.callTool({
961
+ name: 'get_recent_watches',
962
+ arguments: {},
963
+ });
964
+ const content = result.content;
965
+ expect(content.find((b) => b.type === 'image')).toBeDefined();
966
+ const link = content.find((b) => b.type === 'resource_link');
967
+ expect(link?.uri).toContain('letterboxd.com');
968
+ expect(link?.name).toContain('Letterboxd');
969
+ });
970
+ it('browse_movies emits posters and structuredContent with pagination', async () => {
971
+ const result = await client.callTool({
972
+ name: 'browse_movies',
973
+ arguments: {},
974
+ });
975
+ const content = result.content;
976
+ expect(content.find((b) => b.type === 'image')).toBeDefined();
977
+ const structured = result.structuredContent;
978
+ expect(structured?.pagination?.total).toBe(1);
979
+ });
980
+ it('get_watching_stats returns structuredContent mirroring the API', async () => {
981
+ const result = await client.callTool({
982
+ name: 'get_watching_stats',
983
+ arguments: {},
984
+ });
985
+ const structured = result.structuredContent;
986
+ expect(structured?.total_movies).toBe(312);
987
+ expect(structured?.top_director).toBe('Wes Anderson');
988
+ // No image blocks on stats tools
989
+ const content = result.content;
990
+ expect(content.find((b) => b.type === 'image')).toBeUndefined();
991
+ });
992
+ it('get_watching_genres returns percentages and structuredContent', async () => {
993
+ const result = await client.callTool({
994
+ name: 'get_watching_genres',
995
+ arguments: {},
996
+ });
997
+ const text = result.content[0].text;
998
+ expect(text).toContain('Drama');
999
+ expect(text).toContain('38.5%');
1000
+ const structured = result.structuredContent;
1001
+ expect(structured?.items[0].name).toBe('Drama');
1002
+ });
1003
+ it('get_watching_decades returns decade breakdown', async () => {
1004
+ const result = await client.callTool({
1005
+ name: 'get_watching_decades',
1006
+ arguments: {},
1007
+ });
1008
+ const text = result.content[0].text;
1009
+ expect(text).toContain('2020s');
1010
+ expect(text).toContain('2010s');
1011
+ });
1012
+ it('get_watching_directors returns ranked directors', async () => {
1013
+ const result = await client.callTool({
1014
+ name: 'get_watching_directors',
1015
+ arguments: {},
1016
+ });
1017
+ const text = result.content[0].text;
1018
+ expect(text).toContain('Wes Anderson');
1019
+ expect(text).toContain('1. Wes Anderson');
1020
+ });
1021
+ it('registers movie and show entity resource templates', async () => {
1022
+ const { resourceTemplates } = await client.listResourceTemplates();
1023
+ const uris = resourceTemplates.map((t) => t.uriTemplate);
1024
+ expect(uris).toContain('rewind://movie/{id}');
1025
+ expect(uris).toContain('rewind://show/{id}');
1026
+ });
1027
+ it('reads a movie entity via rewind://movie/{id}', async () => {
1028
+ const result = await client.readResource({ uri: 'rewind://movie/1' });
1029
+ expect(result.contents.length).toBe(1);
1030
+ const content = result.contents[0];
1031
+ expect(content.mimeType).toBe('application/json');
1032
+ const data = JSON.parse(content.text);
1033
+ expect(data.title).toBe('The Royal Tenenbaums');
1034
+ });
1035
+ });
1036
+ describe('Phase 2 -- listening rich responses', () => {
1037
+ it('lists get_listening_genres tool', async () => {
1038
+ const { tools } = await client.listTools();
1039
+ expect(tools.map((t) => t.name)).toContain('get_listening_genres');
1040
+ });
1041
+ it('get_now_playing returns text, album cover image, and Apple Music links', async () => {
1042
+ const result = await client.callTool({
1043
+ name: 'get_now_playing',
1044
+ arguments: {},
1045
+ });
1046
+ const content = result.content;
1047
+ expect(content.find((b) => b.type === 'image')).toBeDefined();
1048
+ const links = content.filter((b) => b.type === 'resource_link');
1049
+ expect(links.length).toBeGreaterThanOrEqual(2);
1050
+ expect(links.some((b) => b.name?.includes('Apple Music'))).toBe(true);
1051
+ });
1052
+ it('get_album_details returns cover, Apple Music link, and structuredContent', async () => {
1053
+ const result = await client.callTool({
1054
+ name: 'get_album_details',
1055
+ arguments: { id: 20 },
1056
+ });
1057
+ const content = result.content;
1058
+ expect(content.find((b) => b.type === 'image')).toBeDefined();
1059
+ expect(content.find((b) => b.type === 'resource_link' && b.name?.includes('Apple Music'))).toBeDefined();
1060
+ const structured = result
1061
+ .structuredContent;
1062
+ expect(structured?.id).toBe(20);
1063
+ });
1064
+ it('get_artist_details returns artist image and links', async () => {
1065
+ const result = await client.callTool({
1066
+ name: 'get_artist_details',
1067
+ arguments: { id: 10 },
1068
+ });
1069
+ const content = result.content;
1070
+ expect(content.find((b) => b.type === 'image')).toBeDefined();
1071
+ expect(content.find((b) => b.type === 'resource_link' && b.name?.includes('Apple Music'))).toBeDefined();
1072
+ });
1073
+ it('get_recent_listens emits top-N covers and Apple Music links', async () => {
1074
+ const result = await client.callTool({
1075
+ name: 'get_recent_listens',
1076
+ arguments: {},
1077
+ });
1078
+ const content = result.content;
1079
+ expect(content.find((b) => b.type === 'image')).toBeDefined();
1080
+ expect(content.find((b) => b.type === 'resource_link')).toBeDefined();
1081
+ });
1082
+ it('get_top_artists includes structuredContent mirroring API shape', async () => {
1083
+ const result = await client.callTool({
1084
+ name: 'get_top_artists',
1085
+ arguments: {},
1086
+ });
1087
+ const structured = result.structuredContent;
1088
+ expect(structured?.period).toBe('1month');
1089
+ expect(structured?.data[0].id).toBe(10);
1090
+ });
1091
+ it('get_listening_genres returns period breakdown and structuredContent', async () => {
1092
+ const result = await client.callTool({
1093
+ name: 'get_listening_genres',
1094
+ arguments: {},
1095
+ });
1096
+ const text = result.content[0].text;
1097
+ expect(text).toContain('Rock');
1098
+ const structured = result.structuredContent;
1099
+ expect(structured?.items[0].period).toBe('2026-03');
1100
+ });
1101
+ it('registers album and artist entity resource templates', async () => {
1102
+ const { resourceTemplates } = await client.listResourceTemplates();
1103
+ const uris = resourceTemplates.map((t) => t.uriTemplate);
1104
+ expect(uris).toContain('rewind://album/{id}');
1105
+ expect(uris).toContain('rewind://artist/{id}');
1106
+ });
1107
+ it('reads an album entity via rewind://album/{id}', async () => {
1108
+ const result = await client.readResource({ uri: 'rewind://album/20' });
1109
+ const content = result.contents[0];
1110
+ expect(content.mimeType).toBe('application/json');
1111
+ const data = JSON.parse(content.text);
1112
+ expect(data.name).toBe("Paul's Boutique");
1113
+ });
1114
+ });
1115
+ describe('Phase 3 -- collecting rich responses', () => {
1116
+ it('get_vinyl_collection emits top-N covers + Discogs resource_links', async () => {
1117
+ const result = await client.callTool({
1118
+ name: 'get_vinyl_collection',
1119
+ arguments: {},
1120
+ });
1121
+ const content = result.content;
1122
+ expect(content.find((b) => b.type === 'image')).toBeDefined();
1123
+ const link = content.find((b) => b.type === 'resource_link');
1124
+ expect(link?.uri).toContain('discogs.com');
1125
+ expect(link?.name).toContain('Discogs');
1126
+ });
1127
+ it('get_collecting_stats includes structuredContent mirroring the API', async () => {
1128
+ const result = await client.callTool({
1129
+ name: 'get_collecting_stats',
1130
+ arguments: {},
1131
+ });
1132
+ const structured = result.structuredContent;
1133
+ expect(structured?.total_items).toBe(150);
1134
+ expect(structured?.by_format.vinyl).toBe(120);
1135
+ });
1136
+ it('get_physical_media emits covers and pagination', async () => {
1137
+ const result = await client.callTool({
1138
+ name: 'get_physical_media',
1139
+ arguments: {},
1140
+ });
1141
+ const content = result.content;
1142
+ expect(content.find((b) => b.type === 'image')).toBeDefined();
1143
+ const structured = result.structuredContent;
1144
+ expect(structured?.pagination?.total).toBe(1);
1145
+ });
1146
+ it('get_physical_media_stats returns total + per-format structuredContent', async () => {
1147
+ const result = await client.callTool({
1148
+ name: 'get_physical_media_stats',
1149
+ arguments: {},
1150
+ });
1151
+ const structured = result.structuredContent;
1152
+ expect(structured?.total).toBe(97);
1153
+ expect(structured?.formats.length).toBe(3);
1154
+ });
1155
+ it('registers vinyl and physical-media entity templates', async () => {
1156
+ const { resourceTemplates } = await client.listResourceTemplates();
1157
+ const uris = resourceTemplates.map((t) => t.uriTemplate);
1158
+ expect(uris).toContain('rewind://vinyl/{id}');
1159
+ expect(uris).toContain('rewind://physical-media/{id}');
1160
+ });
1161
+ it('reads a vinyl entity via rewind://vinyl/{id}', async () => {
1162
+ const result = await client.readResource({ uri: 'rewind://vinyl/1' });
1163
+ const content = result.contents[0];
1164
+ expect(content.mimeType).toBe('application/json');
1165
+ const data = JSON.parse(content.text);
1166
+ expect(data.title).toBe('Hello Nasty');
1167
+ });
1168
+ });
1169
+ describe('Phase 4 -- reading rich responses', () => {
1170
+ it('get_recent_reads emits article URL resource_links and structuredContent', async () => {
1171
+ const result = await client.callTool({
1172
+ name: 'get_recent_reads',
1173
+ arguments: {},
1174
+ });
1175
+ const content = result.content;
1176
+ const link = content.find((b) => b.type === 'resource_link');
1177
+ expect(link?.uri).toContain('anthropic.com');
1178
+ const structured = result.structuredContent;
1179
+ expect(structured?.items[0].id).toBe(1);
1180
+ });
1181
+ it('get_reading_highlights emits article URLs as resource_links', async () => {
1182
+ const result = await client.callTool({
1183
+ name: 'get_reading_highlights',
1184
+ arguments: {},
1185
+ });
1186
+ const content = result.content;
1187
+ const link = content.find((b) => b.type === 'resource_link');
1188
+ expect(link?.uri).toContain('codinghorror.com');
1189
+ });
1190
+ it('get_random_highlight emits article URL resource_link', async () => {
1191
+ const result = await client.callTool({
1192
+ name: 'get_random_highlight',
1193
+ arguments: {},
1194
+ });
1195
+ const content = result.content;
1196
+ const link = content.find((b) => b.type === 'resource_link');
1197
+ expect(link?.uri).toContain('example.com');
1198
+ });
1199
+ it('get_reading_stats returns structuredContent mirroring API', async () => {
1200
+ const result = await client.callTool({
1201
+ name: 'get_reading_stats',
1202
+ arguments: {},
1203
+ });
1204
+ const structured = result.structuredContent;
1205
+ expect(structured?.total_articles).toBe(350);
1206
+ expect(structured?.total_highlights).toBe(420);
1207
+ });
1208
+ it('registers article entity template', async () => {
1209
+ const { resourceTemplates } = await client.listResourceTemplates();
1210
+ const uris = resourceTemplates.map((t) => t.uriTemplate);
1211
+ expect(uris).toContain('rewind://article/{id}');
1212
+ });
1213
+ it('reads an article entity via rewind://article/{id}', async () => {
1214
+ const result = await client.readResource({ uri: 'rewind://article/1' });
1215
+ const content = result.contents[0];
1216
+ expect(content.mimeType).toBe('application/json');
1217
+ const data = JSON.parse(content.text);
1218
+ expect(data.title).toBe('How to Build an MCP Server');
1219
+ });
1220
+ });
1221
+ describe('Phase 5 -- running rich responses', () => {
1222
+ it('lists the new get_running_years tool', async () => {
1223
+ const { tools } = await client.listTools();
1224
+ expect(tools.map((t) => t.name)).toContain('get_running_years');
1225
+ });
1226
+ it('get_recent_runs emits Strava resource_links', async () => {
1227
+ const result = await client.callTool({
1228
+ name: 'get_recent_runs',
1229
+ arguments: {},
1230
+ });
1231
+ const content = result.content;
1232
+ const link = content.find((b) => b.type === 'resource_link');
1233
+ expect(link?.uri).toContain('strava.com');
1234
+ });
1235
+ it('get_activity_details emits Strava resource_link and structuredContent', async () => {
1236
+ const result = await client.callTool({
1237
+ name: 'get_activity_details',
1238
+ arguments: { id: 123 },
1239
+ });
1240
+ const content = result.content;
1241
+ const link = content.find((b) => b.type === 'resource_link');
1242
+ expect(link?.uri).toContain('strava.com');
1243
+ expect(link?.name).toContain('Strava');
1244
+ const structured = result
1245
+ .structuredContent;
1246
+ expect(structured?.name).toBe('Thursday Afternoon Run');
1247
+ });
1248
+ it('get_running_stats returns structuredContent mirroring API', async () => {
1249
+ const result = await client.callTool({
1250
+ name: 'get_running_stats',
1251
+ arguments: {},
1252
+ });
1253
+ const structured = result.structuredContent;
1254
+ expect(structured?.total_runs).toBe(423);
1255
+ expect(structured?.eddington_number).toBe(12);
1256
+ });
1257
+ it('get_running_years returns per-year breakdown', async () => {
1258
+ const result = await client.callTool({
1259
+ name: 'get_running_years',
1260
+ arguments: {},
1261
+ });
1262
+ const structured = result.structuredContent;
1263
+ expect(structured?.items.length).toBe(2);
1264
+ expect(structured?.items[0].year).toBe(2026);
1265
+ });
1266
+ it('registers activity entity template', async () => {
1267
+ const { resourceTemplates } = await client.listResourceTemplates();
1268
+ const uris = resourceTemplates.map((t) => t.uriTemplate);
1269
+ expect(uris).toContain('rewind://activity/{id}');
1270
+ });
1271
+ });
1272
+ describe('Phase 6 -- cross-domain rich responses', () => {
1273
+ it('search emits rewind:// resource_links per match', async () => {
1274
+ const result = await client.callTool({
1275
+ name: 'search',
1276
+ arguments: { query: 'beastie' },
1277
+ });
1278
+ const content = result.content;
1279
+ const links = content.filter((b) => b.type === 'resource_link');
1280
+ expect(links.length).toBeGreaterThanOrEqual(2);
1281
+ expect(links.map((l) => l.uri)).toContain('rewind://artist/10');
1282
+ expect(links.map((l) => l.uri)).toContain('rewind://movie/1');
1283
+ });
1284
+ it('search structuredContent mirrors API', async () => {
1285
+ const result = await client.callTool({
1286
+ name: 'search',
1287
+ arguments: { query: 'beastie' },
1288
+ });
1289
+ const structured = result.structuredContent;
1290
+ expect(structured?.items.length).toBe(2);
1291
+ expect(structured?.pagination.total).toBe(2);
1292
+ });
1293
+ it('get_feed returns structuredContent with items + pagination', async () => {
1294
+ const result = await client.callTool({
1295
+ name: 'get_feed',
1296
+ arguments: {},
1297
+ });
1298
+ const structured = result.structuredContent;
1299
+ expect(structured?.items[0].domain).toBe('listening');
1300
+ });
1301
+ it('get_on_this_day returns structuredContent grouped by year', async () => {
1302
+ const result = await client.callTool({
1303
+ name: 'get_on_this_day',
1304
+ arguments: {},
1305
+ });
1306
+ const structured = result.structuredContent;
1307
+ expect(structured?.years[0].year).toBe(2025);
1308
+ });
1309
+ });
613
1310
  });
614
1311
  //# sourceMappingURL=server.test.js.map