rewind-mcp-server 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -90
- package/dist/__tests__/helpers.test.d.ts +1 -0
- package/dist/__tests__/helpers.test.js +131 -0
- package/dist/__tests__/helpers.test.js.map +1 -0
- package/dist/__tests__/image-block.live.d.ts +1 -0
- package/dist/__tests__/image-block.live.js +41 -0
- package/dist/__tests__/image-block.live.js.map +1 -0
- package/dist/__tests__/server.test.d.ts +1 -0
- package/dist/__tests__/server.test.js +1308 -0
- package/dist/__tests__/server.test.js.map +1 -0
- package/dist/__tests__/stdio-e2e.live.d.ts +1 -0
- package/dist/__tests__/stdio-e2e.live.js +60 -0
- package/dist/__tests__/stdio-e2e.live.js.map +1 -0
- package/dist/__tests__/ui-resource.live.d.ts +1 -0
- package/dist/__tests__/ui-resource.live.js +71 -0
- package/dist/__tests__/ui-resource.live.js.map +1 -0
- package/dist/client.d.ts +9 -0
- package/dist/client.js +15 -0
- package/dist/client.js.map +1 -1
- package/dist/index.js +0 -0
- package/dist/prompts.js +101 -0
- package/dist/prompts.js.map +1 -1
- package/dist/resources/ui.d.ts +33 -0
- package/dist/resources/ui.js +14 -0
- package/dist/resources/ui.js.map +1 -0
- package/dist/resources.js +128 -0
- package/dist/resources.js.map +1 -1
- package/dist/server.js +55 -1
- package/dist/server.js.map +1 -1
- package/dist/tools/collecting.js +116 -14
- package/dist/tools/collecting.js.map +1 -1
- package/dist/tools/cross-domain.js +74 -17
- package/dist/tools/cross-domain.js.map +1 -1
- package/dist/tools/debug.d.ts +6 -0
- package/dist/tools/debug.js +26 -0
- package/dist/tools/debug.js.map +1 -0
- package/dist/tools/helpers.d.ts +81 -5
- package/dist/tools/helpers.js +138 -4
- package/dist/tools/helpers.js.map +1 -1
- package/dist/tools/listening.js +223 -52
- package/dist/tools/listening.js.map +1 -1
- package/dist/tools/reading.js +74 -22
- package/dist/tools/reading.js.map +1 -1
- package/dist/tools/running.js +87 -43
- package/dist/tools/running.js.map +1 -1
- package/dist/tools/watching.js +199 -32
- package/dist/tools/watching.js.map +1 -1
- package/dist/ui-bundles.d.ts +3 -0
- package/dist/ui-bundles.js +8 -0
- package/dist/ui-bundles.js.map +1 -0
- package/dist/worker.d.ts +2 -1
- package/dist/worker.js +55 -30
- package/dist/worker.js.map +1 -1
- package/package.json +18 -3
|
@@ -0,0 +1,1308 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
|
|
2
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
3
|
+
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
|
4
|
+
import { createServer } from '../server.js';
|
|
5
|
+
import { RewindClient } from '../client.js';
|
|
6
|
+
/**
|
|
7
|
+
* Creates a connected MCP client + server pair using in-memory transport.
|
|
8
|
+
* The RewindClient is mocked so no real API calls are made.
|
|
9
|
+
*/
|
|
10
|
+
async function createTestClient() {
|
|
11
|
+
const rewindClient = new RewindClient('https://api.test', 'rw_test');
|
|
12
|
+
// Mock the HTTP client -- all tools go through client.get()
|
|
13
|
+
vi.spyOn(rewindClient, 'get').mockImplementation(async (path) => {
|
|
14
|
+
return getMockResponse(path);
|
|
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
|
+
});
|
|
22
|
+
const server = createServer(rewindClient);
|
|
23
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
24
|
+
const client = new Client({
|
|
25
|
+
name: 'test-client',
|
|
26
|
+
version: '1.0.0',
|
|
27
|
+
});
|
|
28
|
+
await server.connect(serverTransport);
|
|
29
|
+
await client.connect(clientTransport);
|
|
30
|
+
return { client, server, rewindClient };
|
|
31
|
+
}
|
|
32
|
+
/** Mock responses for each API endpoint. */
|
|
33
|
+
function getMockResponse(path) {
|
|
34
|
+
// Listening
|
|
35
|
+
if (path === '/listening/now-playing') {
|
|
36
|
+
return {
|
|
37
|
+
is_playing: true,
|
|
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
|
+
},
|
|
59
|
+
scrobbled_at: new Date().toISOString(),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
if (path === '/listening/recent') {
|
|
63
|
+
return {
|
|
64
|
+
data: [
|
|
65
|
+
{
|
|
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
|
+
},
|
|
84
|
+
scrobbled_at: new Date().toISOString(),
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
if (path === '/listening/stats') {
|
|
90
|
+
return {
|
|
91
|
+
total_scrobbles: 45000,
|
|
92
|
+
unique_artists: 2100,
|
|
93
|
+
unique_albums: 3800,
|
|
94
|
+
unique_tracks: 12000,
|
|
95
|
+
registered_date: '2008-01-01',
|
|
96
|
+
years_tracking: 18,
|
|
97
|
+
scrobbles_per_day: 12.3,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (path.startsWith('/listening/top/')) {
|
|
101
|
+
return {
|
|
102
|
+
period: '1month',
|
|
103
|
+
data: [
|
|
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
|
+
},
|
|
120
|
+
],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
if (path === '/listening/streaks') {
|
|
124
|
+
return {
|
|
125
|
+
current: { days: 42, start_date: '2025-01-01', total_scrobbles: 500 },
|
|
126
|
+
longest: {
|
|
127
|
+
days: 365,
|
|
128
|
+
start_date: '2020-01-01',
|
|
129
|
+
end_date: '2020-12-31',
|
|
130
|
+
total_scrobbles: 5000,
|
|
131
|
+
},
|
|
132
|
+
};
|
|
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
|
+
}
|
|
142
|
+
if (path.match(/\/listening\/artists\/\d+/)) {
|
|
143
|
+
return {
|
|
144
|
+
id: 10,
|
|
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',
|
|
149
|
+
playcount: 500,
|
|
150
|
+
scrobble_count: 500,
|
|
151
|
+
genre: 'Hip Hop',
|
|
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
|
+
],
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
if (path.match(/\/listening\/albums\/\d+/)) {
|
|
179
|
+
return {
|
|
180
|
+
id: 20,
|
|
181
|
+
name: "Paul's Boutique",
|
|
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',
|
|
185
|
+
playcount: 100,
|
|
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
|
+
],
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
// Running
|
|
205
|
+
if (path === '/running/stats') {
|
|
206
|
+
return {
|
|
207
|
+
data: {
|
|
208
|
+
total_runs: 423,
|
|
209
|
+
total_distance_mi: 1892.4,
|
|
210
|
+
total_elevation_ft: 45000,
|
|
211
|
+
total_duration: '312:45:00',
|
|
212
|
+
avg_pace: '8:32/mi',
|
|
213
|
+
years_active: 5,
|
|
214
|
+
first_run: '2021-01-15',
|
|
215
|
+
eddington_number: 12,
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
if (path === '/running/recent') {
|
|
220
|
+
return {
|
|
221
|
+
data: [
|
|
222
|
+
{
|
|
223
|
+
id: 17956091264,
|
|
224
|
+
name: 'Thursday Afternoon Run',
|
|
225
|
+
date: new Date().toISOString(),
|
|
226
|
+
distance_mi: 4.39,
|
|
227
|
+
duration: '33:59',
|
|
228
|
+
pace: '7:44/mi',
|
|
229
|
+
elevation_ft: 360,
|
|
230
|
+
city: 'Austin',
|
|
231
|
+
state: 'TX',
|
|
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,
|
|
260
|
+
},
|
|
261
|
+
],
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
if (path === '/running/prs') {
|
|
265
|
+
return {
|
|
266
|
+
data: [
|
|
267
|
+
{
|
|
268
|
+
distance_label: 'Mile',
|
|
269
|
+
time: '6:12',
|
|
270
|
+
pace: '6:12/mi',
|
|
271
|
+
date: '2026-03-31',
|
|
272
|
+
activity_name: 'Tuesday Run',
|
|
273
|
+
activity_id: 123,
|
|
274
|
+
},
|
|
275
|
+
],
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
if (path === '/running/streaks') {
|
|
279
|
+
return {
|
|
280
|
+
data: {
|
|
281
|
+
current: { days: 3, start: '2026-04-01', end: '2026-04-03' },
|
|
282
|
+
longest: { days: 30, start: '2025-06-01', end: '2025-06-30' },
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
if (path.match(/\/running\/activities\/\d+$/)) {
|
|
287
|
+
return {
|
|
288
|
+
name: 'Thursday Afternoon Run',
|
|
289
|
+
date: '2026-04-02T16:20:48Z',
|
|
290
|
+
distance_mi: 4.39,
|
|
291
|
+
duration: '33:59',
|
|
292
|
+
pace: '7:44/mi',
|
|
293
|
+
elevation_ft: 360,
|
|
294
|
+
heartrate_avg: 155,
|
|
295
|
+
heartrate_max: 176,
|
|
296
|
+
cadence: 80,
|
|
297
|
+
calories: 514,
|
|
298
|
+
suffer_score: null,
|
|
299
|
+
city: 'Austin',
|
|
300
|
+
state: 'TX',
|
|
301
|
+
is_race: false,
|
|
302
|
+
workout_type: 'default',
|
|
303
|
+
strava_url: 'https://www.strava.com/activities/123',
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
if (path.match(/\/running\/activities\/\d+\/splits/)) {
|
|
307
|
+
return {
|
|
308
|
+
data: [
|
|
309
|
+
{
|
|
310
|
+
split: 1,
|
|
311
|
+
distance_mi: 1,
|
|
312
|
+
moving_time_s: 435,
|
|
313
|
+
elapsed_time_s: 449,
|
|
314
|
+
elevation_ft: -55,
|
|
315
|
+
pace: '7:15/mi',
|
|
316
|
+
heartrate: 145,
|
|
317
|
+
},
|
|
318
|
+
],
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
// Watching
|
|
322
|
+
if (path === '/watching/recent') {
|
|
323
|
+
return {
|
|
324
|
+
data: [
|
|
325
|
+
{
|
|
326
|
+
movie: {
|
|
327
|
+
id: 1,
|
|
328
|
+
title: 'The Royal Tenenbaums',
|
|
329
|
+
year: 2001,
|
|
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
|
+
},
|
|
338
|
+
},
|
|
339
|
+
watched_at: new Date().toISOString(),
|
|
340
|
+
user_rating: 9,
|
|
341
|
+
rewatch: false,
|
|
342
|
+
source: 'plex',
|
|
343
|
+
review: null,
|
|
344
|
+
review_url: 'https://letterboxd.com/patdugan/film/the-royal-tenenbaums/',
|
|
345
|
+
},
|
|
346
|
+
],
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
if (path.match(/\/watching\/movies\/\d+/)) {
|
|
350
|
+
return {
|
|
351
|
+
id: 1,
|
|
352
|
+
title: 'The Royal Tenenbaums',
|
|
353
|
+
year: 2001,
|
|
354
|
+
director: 'Wes Anderson',
|
|
355
|
+
directors: ['Wes Anderson'],
|
|
356
|
+
genres: ['Comedy', 'Drama'],
|
|
357
|
+
duration_min: 109,
|
|
358
|
+
rating: 'R',
|
|
359
|
+
tmdb_id: 9428,
|
|
360
|
+
tmdb_rating: 7.6,
|
|
361
|
+
tagline: "Family isn't a word. It's a sentence.",
|
|
362
|
+
summary: 'A family drama.',
|
|
363
|
+
imdb_id: 'tt0265666',
|
|
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
|
+
],
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
if (path === '/watching/stats') {
|
|
383
|
+
return {
|
|
384
|
+
data: {
|
|
385
|
+
total_movies: 312,
|
|
386
|
+
total_watch_time_hours: 520,
|
|
387
|
+
movies_this_year: 28,
|
|
388
|
+
avg_per_month: 8.5,
|
|
389
|
+
top_genre: 'Drama',
|
|
390
|
+
top_decade: 2000,
|
|
391
|
+
top_director: 'Wes Anderson',
|
|
392
|
+
total_shows: 15,
|
|
393
|
+
total_episodes_watched: 234,
|
|
394
|
+
episodes_this_year: 45,
|
|
395
|
+
},
|
|
396
|
+
};
|
|
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
|
+
}
|
|
423
|
+
if (path === '/watching/movies') {
|
|
424
|
+
return {
|
|
425
|
+
data: [
|
|
426
|
+
{
|
|
427
|
+
id: 1,
|
|
428
|
+
title: 'The Royal Tenenbaums',
|
|
429
|
+
year: 2001,
|
|
430
|
+
director: 'Wes Anderson',
|
|
431
|
+
directors: ['Wes Anderson'],
|
|
432
|
+
genres: ['Comedy'],
|
|
433
|
+
duration_min: 109,
|
|
434
|
+
rating: 'R',
|
|
435
|
+
tmdb_id: 9428,
|
|
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
|
+
},
|
|
443
|
+
},
|
|
444
|
+
],
|
|
445
|
+
pagination: { page: 1, limit: 10, total: 1, total_pages: 1 },
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
// Collecting
|
|
449
|
+
if (path === '/collecting/vinyl') {
|
|
450
|
+
return {
|
|
451
|
+
data: [
|
|
452
|
+
{
|
|
453
|
+
id: 1,
|
|
454
|
+
discogs_id: 100,
|
|
455
|
+
title: 'Hello Nasty',
|
|
456
|
+
artists: ['Beastie Boys'],
|
|
457
|
+
year: 2009,
|
|
458
|
+
format: 'Vinyl',
|
|
459
|
+
format_detail: 'LP, Reissue',
|
|
460
|
+
label: 'Capitol Records',
|
|
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
|
+
},
|
|
469
|
+
date_added: '2024-01-15',
|
|
470
|
+
rating: null,
|
|
471
|
+
discogs_url: 'https://www.discogs.com/release/100',
|
|
472
|
+
},
|
|
473
|
+
],
|
|
474
|
+
pagination: { page: 1, limit: 10, total: 1, total_pages: 1 },
|
|
475
|
+
};
|
|
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
|
+
}
|
|
498
|
+
if (path === '/collecting/stats') {
|
|
499
|
+
return {
|
|
500
|
+
data: {
|
|
501
|
+
total_items: 150,
|
|
502
|
+
by_format: { vinyl: 120, cd: 25, cassette: 5 },
|
|
503
|
+
wantlist_count: 30,
|
|
504
|
+
unique_artists: 95,
|
|
505
|
+
estimated_value: null,
|
|
506
|
+
top_genre: 'Rock',
|
|
507
|
+
oldest_release_year: 1967,
|
|
508
|
+
newest_release_year: 2025,
|
|
509
|
+
most_collected_artist: null,
|
|
510
|
+
added_this_year: 12,
|
|
511
|
+
},
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
if (path === '/collecting/media') {
|
|
515
|
+
return {
|
|
516
|
+
data: [
|
|
517
|
+
{
|
|
518
|
+
id: 1,
|
|
519
|
+
title: 'The Killing',
|
|
520
|
+
year: 1956,
|
|
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,
|
|
527
|
+
tmdb_rating: 7.6,
|
|
528
|
+
media_type: 'bluray',
|
|
529
|
+
resolution: null,
|
|
530
|
+
hdr: null,
|
|
531
|
+
audio: null,
|
|
532
|
+
audio_channels: null,
|
|
533
|
+
collected_at: '2026-03-26',
|
|
534
|
+
},
|
|
535
|
+
],
|
|
536
|
+
pagination: { page: 1, limit: 10, total: 1, total_pages: 1 },
|
|
537
|
+
};
|
|
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
|
+
}
|
|
563
|
+
if (path === '/collecting/media/formats') {
|
|
564
|
+
return {
|
|
565
|
+
data: [
|
|
566
|
+
{ name: 'bluray', count: 56 },
|
|
567
|
+
{ name: 'uhd_bluray', count: 30 },
|
|
568
|
+
{ name: 'hddvd', count: 11 },
|
|
569
|
+
],
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
// Reading
|
|
573
|
+
if (path === '/reading/recent') {
|
|
574
|
+
return {
|
|
575
|
+
data: [
|
|
576
|
+
{
|
|
577
|
+
id: 1,
|
|
578
|
+
title: 'How to Build an MCP Server',
|
|
579
|
+
author: 'Anthropic',
|
|
580
|
+
url: 'https://www.anthropic.com/blog/how-to-build-an-mcp-server',
|
|
581
|
+
domain: 'anthropic.com',
|
|
582
|
+
estimated_read_min: 12,
|
|
583
|
+
status: 'archived',
|
|
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
|
+
},
|
|
591
|
+
saved_at: new Date().toISOString(),
|
|
592
|
+
},
|
|
593
|
+
],
|
|
594
|
+
};
|
|
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
|
+
}
|
|
611
|
+
if (path === '/reading/highlights') {
|
|
612
|
+
return {
|
|
613
|
+
data: [
|
|
614
|
+
{
|
|
615
|
+
text: 'The best code is no code at all.',
|
|
616
|
+
note: null,
|
|
617
|
+
created_at: '2026-01-15',
|
|
618
|
+
article: {
|
|
619
|
+
id: 99,
|
|
620
|
+
title: 'Less is More',
|
|
621
|
+
author: 'Jeff Atwood',
|
|
622
|
+
domain: 'blog.codinghorror.com',
|
|
623
|
+
url: 'https://blog.codinghorror.com/less-is-more/',
|
|
624
|
+
},
|
|
625
|
+
},
|
|
626
|
+
],
|
|
627
|
+
pagination: { page: 1, total: 42, total_pages: 5 },
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
if (path === '/reading/highlights/random') {
|
|
631
|
+
return {
|
|
632
|
+
text: 'Simplicity is the ultimate sophistication.',
|
|
633
|
+
note: 'Leonardo da Vinci',
|
|
634
|
+
created_at: '2025-06-01',
|
|
635
|
+
article: {
|
|
636
|
+
id: 100,
|
|
637
|
+
title: 'Design Principles',
|
|
638
|
+
author: null,
|
|
639
|
+
domain: 'example.com',
|
|
640
|
+
url: 'https://example.com/design-principles',
|
|
641
|
+
},
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
if (path === '/reading/stats') {
|
|
645
|
+
return {
|
|
646
|
+
total_articles: 350,
|
|
647
|
+
finished_count: 280,
|
|
648
|
+
currently_reading_count: 5,
|
|
649
|
+
total_highlights: 420,
|
|
650
|
+
total_word_count: 2500000,
|
|
651
|
+
avg_estimated_read_min: 8,
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
// Feed
|
|
655
|
+
if (path.startsWith('/feed/on-this-day')) {
|
|
656
|
+
return {
|
|
657
|
+
month: 4,
|
|
658
|
+
day: 3,
|
|
659
|
+
years: [
|
|
660
|
+
{
|
|
661
|
+
year: 2025,
|
|
662
|
+
items: [
|
|
663
|
+
{
|
|
664
|
+
domain: 'listening',
|
|
665
|
+
event_type: 'discovery',
|
|
666
|
+
title: 'Listened to Radiohead',
|
|
667
|
+
subtitle: 'OK Computer',
|
|
668
|
+
},
|
|
669
|
+
],
|
|
670
|
+
},
|
|
671
|
+
],
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
if (path.startsWith('/feed')) {
|
|
675
|
+
return {
|
|
676
|
+
data: [
|
|
677
|
+
{
|
|
678
|
+
domain: 'listening',
|
|
679
|
+
event_type: 'scrobble',
|
|
680
|
+
occurred_at: new Date().toISOString(),
|
|
681
|
+
title: 'Sabotage',
|
|
682
|
+
subtitle: 'Beastie Boys',
|
|
683
|
+
},
|
|
684
|
+
],
|
|
685
|
+
pagination: { has_more: false },
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
// Search
|
|
689
|
+
if (path === '/search') {
|
|
690
|
+
return {
|
|
691
|
+
data: [
|
|
692
|
+
{
|
|
693
|
+
domain: 'listening',
|
|
694
|
+
entity_type: 'artist',
|
|
695
|
+
entity_id: '10',
|
|
696
|
+
title: 'Beastie Boys',
|
|
697
|
+
subtitle: null,
|
|
698
|
+
},
|
|
699
|
+
{
|
|
700
|
+
domain: 'watching',
|
|
701
|
+
entity_type: 'movie',
|
|
702
|
+
entity_id: '1',
|
|
703
|
+
title: 'The Royal Tenenbaums',
|
|
704
|
+
subtitle: '2001',
|
|
705
|
+
},
|
|
706
|
+
],
|
|
707
|
+
pagination: { total: 2 },
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
// Health
|
|
711
|
+
if (path === '/health') {
|
|
712
|
+
return { status: 'ok', timestamp: new Date().toISOString() };
|
|
713
|
+
}
|
|
714
|
+
if (path === '/health/sync') {
|
|
715
|
+
return {
|
|
716
|
+
domains: {
|
|
717
|
+
listening: {
|
|
718
|
+
status: 'healthy',
|
|
719
|
+
last_sync: new Date().toISOString(),
|
|
720
|
+
items_synced: 100,
|
|
721
|
+
},
|
|
722
|
+
running: {
|
|
723
|
+
status: 'healthy',
|
|
724
|
+
last_sync: new Date().toISOString(),
|
|
725
|
+
items_synced: 5,
|
|
726
|
+
},
|
|
727
|
+
},
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
throw new Error(`Unmocked endpoint: ${path}`);
|
|
731
|
+
}
|
|
732
|
+
// --- Tests ---
|
|
733
|
+
describe('MCP Server', () => {
|
|
734
|
+
let client;
|
|
735
|
+
beforeAll(async () => {
|
|
736
|
+
const ctx = await createTestClient();
|
|
737
|
+
client = ctx.client;
|
|
738
|
+
});
|
|
739
|
+
afterAll(async () => {
|
|
740
|
+
await client.close();
|
|
741
|
+
});
|
|
742
|
+
describe('initialization', () => {
|
|
743
|
+
it('lists all tools', async () => {
|
|
744
|
+
const { tools } = await client.listTools();
|
|
745
|
+
expect(tools.length).toBeGreaterThanOrEqual(29);
|
|
746
|
+
const names = tools.map((t) => t.name);
|
|
747
|
+
expect(names).toContain('get_health');
|
|
748
|
+
expect(names).toContain('get_now_playing');
|
|
749
|
+
expect(names).toContain('get_recent_listens');
|
|
750
|
+
expect(names).toContain('get_running_stats');
|
|
751
|
+
expect(names).toContain('get_activity_splits');
|
|
752
|
+
expect(names).toContain('browse_movies');
|
|
753
|
+
expect(names).toContain('get_vinyl_collection');
|
|
754
|
+
expect(names).toContain('get_physical_media');
|
|
755
|
+
expect(names).toContain('search');
|
|
756
|
+
expect(names).toContain('get_feed');
|
|
757
|
+
});
|
|
758
|
+
it('all tools have readOnlyHint annotation', async () => {
|
|
759
|
+
const { tools } = await client.listTools();
|
|
760
|
+
for (const tool of tools) {
|
|
761
|
+
expect(tool.annotations?.readOnlyHint).toBe(true);
|
|
762
|
+
expect(tool.annotations?.destructiveHint).toBe(false);
|
|
763
|
+
}
|
|
764
|
+
});
|
|
765
|
+
it('lists resources', async () => {
|
|
766
|
+
const { resources } = await client.listResources();
|
|
767
|
+
expect(resources.length).toBeGreaterThanOrEqual(1);
|
|
768
|
+
});
|
|
769
|
+
it('lists prompts', async () => {
|
|
770
|
+
const { prompts } = await client.listPrompts();
|
|
771
|
+
expect(prompts.length).toBe(6);
|
|
772
|
+
const names = prompts.map((p) => p.name);
|
|
773
|
+
expect(names).toContain('weekly-summary');
|
|
774
|
+
expect(names).toContain('year-in-review');
|
|
775
|
+
expect(names).toContain('compare-periods');
|
|
776
|
+
expect(names).toContain('letterboxd-review-draft');
|
|
777
|
+
expect(names).toContain('training-report');
|
|
778
|
+
expect(names).toContain('film-diet');
|
|
779
|
+
});
|
|
780
|
+
it('exposes server instructions', () => {
|
|
781
|
+
const instructions = client.getInstructions();
|
|
782
|
+
expect(instructions).toBeTruthy();
|
|
783
|
+
expect(instructions).toContain('Rewind');
|
|
784
|
+
expect(instructions).toContain('listening');
|
|
785
|
+
expect(instructions).toContain('resource link');
|
|
786
|
+
// Keep under 2KB (Claude Code truncates above that)
|
|
787
|
+
expect(instructions.length).toBeLessThan(2048);
|
|
788
|
+
});
|
|
789
|
+
});
|
|
790
|
+
describe('tool responses are text, not JSON', () => {
|
|
791
|
+
const toolsToTest = [
|
|
792
|
+
{ name: 'get_health', args: {} },
|
|
793
|
+
{ name: 'get_now_playing', args: {} },
|
|
794
|
+
{ name: 'get_recent_listens', args: {} },
|
|
795
|
+
{ name: 'get_listening_stats', args: {} },
|
|
796
|
+
{ name: 'get_top_artists', args: {} },
|
|
797
|
+
{ name: 'get_listening_streaks', args: {} },
|
|
798
|
+
{ name: 'get_running_stats', args: {} },
|
|
799
|
+
{ name: 'get_recent_runs', args: {} },
|
|
800
|
+
{ name: 'get_personal_records', args: {} },
|
|
801
|
+
{ name: 'get_running_streaks', args: {} },
|
|
802
|
+
{ name: 'get_activity_details', args: { id: 123 } },
|
|
803
|
+
{ name: 'get_activity_splits', args: { id: 123 } },
|
|
804
|
+
{ name: 'get_recent_watches', args: {} },
|
|
805
|
+
{ name: 'get_movie_details', args: { id: 1 } },
|
|
806
|
+
{ name: 'get_watching_stats', args: {} },
|
|
807
|
+
{ name: 'browse_movies', args: {} },
|
|
808
|
+
{ name: 'get_vinyl_collection', args: {} },
|
|
809
|
+
{ name: 'get_collecting_stats', args: {} },
|
|
810
|
+
{ name: 'get_physical_media', args: {} },
|
|
811
|
+
{ name: 'get_physical_media_stats', args: {} },
|
|
812
|
+
{ name: 'get_recent_reads', args: {} },
|
|
813
|
+
{ name: 'get_reading_highlights', args: {} },
|
|
814
|
+
{ name: 'get_random_highlight', args: {} },
|
|
815
|
+
{ name: 'get_reading_stats', args: {} },
|
|
816
|
+
{ name: 'search', args: { query: 'beastie' } },
|
|
817
|
+
{ name: 'get_feed', args: {} },
|
|
818
|
+
{ name: 'get_on_this_day', args: {} },
|
|
819
|
+
];
|
|
820
|
+
for (const { name, args } of toolsToTest) {
|
|
821
|
+
it(`${name} returns text content`, async () => {
|
|
822
|
+
const result = await client.callTool({ name, arguments: args });
|
|
823
|
+
expect(result.content).toBeDefined();
|
|
824
|
+
expect(Array.isArray(result.content)).toBe(true);
|
|
825
|
+
const content = result.content;
|
|
826
|
+
expect(content.length).toBeGreaterThan(0);
|
|
827
|
+
expect(content[0].type).toBe('text');
|
|
828
|
+
expect(typeof content[0].text).toBe('string');
|
|
829
|
+
expect(content[0].text.length).toBeGreaterThan(0);
|
|
830
|
+
// Should NOT be raw JSON
|
|
831
|
+
expect(content[0].text).not.toMatch(/^\s*[\[{]/);
|
|
832
|
+
// Should not have isError
|
|
833
|
+
expect(result.isError).toBeFalsy();
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
});
|
|
837
|
+
describe('error handling', () => {
|
|
838
|
+
it('returns isError for failed API calls', async () => {
|
|
839
|
+
// Artist ID 99999 will hit the mock which throws for unknown paths
|
|
840
|
+
const result = await client.callTool({
|
|
841
|
+
name: 'get_artist_details',
|
|
842
|
+
arguments: { id: 99999 },
|
|
843
|
+
});
|
|
844
|
+
expect(result.content).toBeDefined();
|
|
845
|
+
const content = result.content;
|
|
846
|
+
expect(content[0].type).toBe('text');
|
|
847
|
+
// The mock returns data for any /listening/artists/\d+ so this will succeed
|
|
848
|
+
// That's fine -- the error path is tested by the mock throwing for unknown endpoints
|
|
849
|
+
});
|
|
850
|
+
});
|
|
851
|
+
describe('response content quality', () => {
|
|
852
|
+
it('get_now_playing includes track and artist', async () => {
|
|
853
|
+
const result = await client.callTool({
|
|
854
|
+
name: 'get_now_playing',
|
|
855
|
+
arguments: {},
|
|
856
|
+
});
|
|
857
|
+
const text = result.content[0].text;
|
|
858
|
+
expect(text).toContain('Sabotage');
|
|
859
|
+
expect(text).toContain('Beastie Boys');
|
|
860
|
+
});
|
|
861
|
+
it('get_recent_runs includes activity IDs', async () => {
|
|
862
|
+
const result = await client.callTool({
|
|
863
|
+
name: 'get_recent_runs',
|
|
864
|
+
arguments: {},
|
|
865
|
+
});
|
|
866
|
+
const text = result.content[0].text;
|
|
867
|
+
expect(text).toContain('[ID:');
|
|
868
|
+
});
|
|
869
|
+
it('get_personal_records includes activity IDs', async () => {
|
|
870
|
+
const result = await client.callTool({
|
|
871
|
+
name: 'get_personal_records',
|
|
872
|
+
arguments: {},
|
|
873
|
+
});
|
|
874
|
+
const text = result.content[0].text;
|
|
875
|
+
expect(text).toContain('[ID:');
|
|
876
|
+
});
|
|
877
|
+
it('get_activity_splits includes per-mile paces', async () => {
|
|
878
|
+
const result = await client.callTool({
|
|
879
|
+
name: 'get_activity_splits',
|
|
880
|
+
arguments: { id: 123 },
|
|
881
|
+
});
|
|
882
|
+
const text = result.content[0].text;
|
|
883
|
+
expect(text).toContain('Mile 1');
|
|
884
|
+
expect(text).toContain('7:15');
|
|
885
|
+
});
|
|
886
|
+
it('get_vinyl_collection includes pagination info', async () => {
|
|
887
|
+
const result = await client.callTool({
|
|
888
|
+
name: 'get_vinyl_collection',
|
|
889
|
+
arguments: {},
|
|
890
|
+
});
|
|
891
|
+
const text = result.content[0].text;
|
|
892
|
+
expect(text).toContain('page');
|
|
893
|
+
expect(text).toContain('total');
|
|
894
|
+
});
|
|
895
|
+
it('get_physical_media_stats includes format breakdown', async () => {
|
|
896
|
+
const result = await client.callTool({
|
|
897
|
+
name: 'get_physical_media_stats',
|
|
898
|
+
arguments: {},
|
|
899
|
+
});
|
|
900
|
+
const text = result.content[0].text;
|
|
901
|
+
expect(text).toContain('Blu-ray');
|
|
902
|
+
expect(text).toContain('4K UHD');
|
|
903
|
+
});
|
|
904
|
+
it('search returns domain-labeled results', async () => {
|
|
905
|
+
const result = await client.callTool({
|
|
906
|
+
name: 'search',
|
|
907
|
+
arguments: { query: 'beastie' },
|
|
908
|
+
});
|
|
909
|
+
const text = result.content[0].text;
|
|
910
|
+
expect(text).toContain('[listening/');
|
|
911
|
+
});
|
|
912
|
+
it('get_on_this_day groups by year', async () => {
|
|
913
|
+
const result = await client.callTool({
|
|
914
|
+
name: 'get_on_this_day',
|
|
915
|
+
arguments: {},
|
|
916
|
+
});
|
|
917
|
+
const text = result.content[0].text;
|
|
918
|
+
expect(text).toContain('2025');
|
|
919
|
+
});
|
|
920
|
+
});
|
|
921
|
+
describe('Phase 1 -- watching rich responses', () => {
|
|
922
|
+
it('lists the three new aggregate watching tools', async () => {
|
|
923
|
+
const { tools } = await client.listTools();
|
|
924
|
+
const names = tools.map((t) => t.name);
|
|
925
|
+
expect(names).toContain('get_watching_genres');
|
|
926
|
+
expect(names).toContain('get_watching_decades');
|
|
927
|
+
expect(names).toContain('get_watching_directors');
|
|
928
|
+
});
|
|
929
|
+
it('get_movie_details returns text, image, resource_link, and structuredContent', async () => {
|
|
930
|
+
const result = await client.callTool({
|
|
931
|
+
name: 'get_movie_details',
|
|
932
|
+
arguments: { id: 1 },
|
|
933
|
+
});
|
|
934
|
+
const content = result.content;
|
|
935
|
+
const textBlock = content.find((b) => b.type === 'text');
|
|
936
|
+
const image = content.find((b) => b.type === 'image');
|
|
937
|
+
const link = content.find((b) => b.type === 'resource_link');
|
|
938
|
+
expect(textBlock?.text).toContain('The Royal Tenenbaums');
|
|
939
|
+
expect(image?.mimeType).toBe('image/jpeg');
|
|
940
|
+
expect(image?.data).toBeTruthy();
|
|
941
|
+
expect(link?.uri).toContain('letterboxd.com');
|
|
942
|
+
const structured = result
|
|
943
|
+
.structuredContent;
|
|
944
|
+
expect(structured?.id).toBe(1);
|
|
945
|
+
});
|
|
946
|
+
it('get_movie_details with include_images=false omits image blocks', async () => {
|
|
947
|
+
const result = await client.callTool({
|
|
948
|
+
name: 'get_movie_details',
|
|
949
|
+
arguments: { id: 1, include_images: false },
|
|
950
|
+
});
|
|
951
|
+
const content = result.content;
|
|
952
|
+
expect(content.find((b) => b.type === 'image')).toBeUndefined();
|
|
953
|
+
// resource_link should still be present
|
|
954
|
+
expect(content.find((b) => b.type === 'resource_link')).toBeDefined();
|
|
955
|
+
});
|
|
956
|
+
it('get_recent_watches emits posters and Letterboxd links', async () => {
|
|
957
|
+
const result = await client.callTool({
|
|
958
|
+
name: 'get_recent_watches',
|
|
959
|
+
arguments: {},
|
|
960
|
+
});
|
|
961
|
+
const content = result.content;
|
|
962
|
+
expect(content.find((b) => b.type === 'image')).toBeDefined();
|
|
963
|
+
const link = content.find((b) => b.type === 'resource_link');
|
|
964
|
+
expect(link?.uri).toContain('letterboxd.com');
|
|
965
|
+
expect(link?.name).toContain('Letterboxd');
|
|
966
|
+
});
|
|
967
|
+
it('browse_movies emits posters and structuredContent with pagination', async () => {
|
|
968
|
+
const result = await client.callTool({
|
|
969
|
+
name: 'browse_movies',
|
|
970
|
+
arguments: {},
|
|
971
|
+
});
|
|
972
|
+
const content = result.content;
|
|
973
|
+
expect(content.find((b) => b.type === 'image')).toBeDefined();
|
|
974
|
+
const structured = result.structuredContent;
|
|
975
|
+
expect(structured?.pagination?.total).toBe(1);
|
|
976
|
+
});
|
|
977
|
+
it('get_watching_stats returns structuredContent mirroring the API', async () => {
|
|
978
|
+
const result = await client.callTool({
|
|
979
|
+
name: 'get_watching_stats',
|
|
980
|
+
arguments: {},
|
|
981
|
+
});
|
|
982
|
+
const structured = result.structuredContent;
|
|
983
|
+
expect(structured?.total_movies).toBe(312);
|
|
984
|
+
expect(structured?.top_director).toBe('Wes Anderson');
|
|
985
|
+
// No image blocks on stats tools
|
|
986
|
+
const content = result.content;
|
|
987
|
+
expect(content.find((b) => b.type === 'image')).toBeUndefined();
|
|
988
|
+
});
|
|
989
|
+
it('get_watching_genres returns percentages and structuredContent', async () => {
|
|
990
|
+
const result = await client.callTool({
|
|
991
|
+
name: 'get_watching_genres',
|
|
992
|
+
arguments: {},
|
|
993
|
+
});
|
|
994
|
+
const text = result.content[0].text;
|
|
995
|
+
expect(text).toContain('Drama');
|
|
996
|
+
expect(text).toContain('38.5%');
|
|
997
|
+
const structured = result.structuredContent;
|
|
998
|
+
expect(structured?.items[0].name).toBe('Drama');
|
|
999
|
+
});
|
|
1000
|
+
it('get_watching_decades returns decade breakdown', async () => {
|
|
1001
|
+
const result = await client.callTool({
|
|
1002
|
+
name: 'get_watching_decades',
|
|
1003
|
+
arguments: {},
|
|
1004
|
+
});
|
|
1005
|
+
const text = result.content[0].text;
|
|
1006
|
+
expect(text).toContain('2020s');
|
|
1007
|
+
expect(text).toContain('2010s');
|
|
1008
|
+
});
|
|
1009
|
+
it('get_watching_directors returns ranked directors', async () => {
|
|
1010
|
+
const result = await client.callTool({
|
|
1011
|
+
name: 'get_watching_directors',
|
|
1012
|
+
arguments: {},
|
|
1013
|
+
});
|
|
1014
|
+
const text = result.content[0].text;
|
|
1015
|
+
expect(text).toContain('Wes Anderson');
|
|
1016
|
+
expect(text).toContain('1. Wes Anderson');
|
|
1017
|
+
});
|
|
1018
|
+
it('registers movie and show entity resource templates', async () => {
|
|
1019
|
+
const { resourceTemplates } = await client.listResourceTemplates();
|
|
1020
|
+
const uris = resourceTemplates.map((t) => t.uriTemplate);
|
|
1021
|
+
expect(uris).toContain('rewind://movie/{id}');
|
|
1022
|
+
expect(uris).toContain('rewind://show/{id}');
|
|
1023
|
+
});
|
|
1024
|
+
it('reads a movie entity via rewind://movie/{id}', async () => {
|
|
1025
|
+
const result = await client.readResource({ uri: 'rewind://movie/1' });
|
|
1026
|
+
expect(result.contents.length).toBe(1);
|
|
1027
|
+
const content = result.contents[0];
|
|
1028
|
+
expect(content.mimeType).toBe('application/json');
|
|
1029
|
+
const data = JSON.parse(content.text);
|
|
1030
|
+
expect(data.title).toBe('The Royal Tenenbaums');
|
|
1031
|
+
});
|
|
1032
|
+
});
|
|
1033
|
+
describe('Phase 2 -- listening rich responses', () => {
|
|
1034
|
+
it('lists get_listening_genres tool', async () => {
|
|
1035
|
+
const { tools } = await client.listTools();
|
|
1036
|
+
expect(tools.map((t) => t.name)).toContain('get_listening_genres');
|
|
1037
|
+
});
|
|
1038
|
+
it('get_now_playing returns text, album cover image, and Apple Music links', async () => {
|
|
1039
|
+
const result = await client.callTool({
|
|
1040
|
+
name: 'get_now_playing',
|
|
1041
|
+
arguments: {},
|
|
1042
|
+
});
|
|
1043
|
+
const content = result.content;
|
|
1044
|
+
expect(content.find((b) => b.type === 'image')).toBeDefined();
|
|
1045
|
+
const links = content.filter((b) => b.type === 'resource_link');
|
|
1046
|
+
expect(links.length).toBeGreaterThanOrEqual(2);
|
|
1047
|
+
expect(links.some((b) => b.name?.includes('Apple Music'))).toBe(true);
|
|
1048
|
+
});
|
|
1049
|
+
it('get_album_details returns cover, Apple Music link, and structuredContent', async () => {
|
|
1050
|
+
const result = await client.callTool({
|
|
1051
|
+
name: 'get_album_details',
|
|
1052
|
+
arguments: { id: 20 },
|
|
1053
|
+
});
|
|
1054
|
+
const content = result.content;
|
|
1055
|
+
expect(content.find((b) => b.type === 'image')).toBeDefined();
|
|
1056
|
+
expect(content.find((b) => b.type === 'resource_link' && b.name?.includes('Apple Music'))).toBeDefined();
|
|
1057
|
+
const structured = result
|
|
1058
|
+
.structuredContent;
|
|
1059
|
+
expect(structured?.id).toBe(20);
|
|
1060
|
+
});
|
|
1061
|
+
it('get_artist_details returns artist image and links', async () => {
|
|
1062
|
+
const result = await client.callTool({
|
|
1063
|
+
name: 'get_artist_details',
|
|
1064
|
+
arguments: { id: 10 },
|
|
1065
|
+
});
|
|
1066
|
+
const content = result.content;
|
|
1067
|
+
expect(content.find((b) => b.type === 'image')).toBeDefined();
|
|
1068
|
+
expect(content.find((b) => b.type === 'resource_link' && b.name?.includes('Apple Music'))).toBeDefined();
|
|
1069
|
+
});
|
|
1070
|
+
it('get_recent_listens emits top-N covers and Apple Music links', async () => {
|
|
1071
|
+
const result = await client.callTool({
|
|
1072
|
+
name: 'get_recent_listens',
|
|
1073
|
+
arguments: {},
|
|
1074
|
+
});
|
|
1075
|
+
const content = result.content;
|
|
1076
|
+
expect(content.find((b) => b.type === 'image')).toBeDefined();
|
|
1077
|
+
expect(content.find((b) => b.type === 'resource_link')).toBeDefined();
|
|
1078
|
+
});
|
|
1079
|
+
it('get_top_artists includes structuredContent mirroring API shape', async () => {
|
|
1080
|
+
const result = await client.callTool({
|
|
1081
|
+
name: 'get_top_artists',
|
|
1082
|
+
arguments: {},
|
|
1083
|
+
});
|
|
1084
|
+
const structured = result.structuredContent;
|
|
1085
|
+
expect(structured?.period).toBe('1month');
|
|
1086
|
+
expect(structured?.data[0].id).toBe(10);
|
|
1087
|
+
});
|
|
1088
|
+
it('get_listening_genres returns period breakdown and structuredContent', async () => {
|
|
1089
|
+
const result = await client.callTool({
|
|
1090
|
+
name: 'get_listening_genres',
|
|
1091
|
+
arguments: {},
|
|
1092
|
+
});
|
|
1093
|
+
const text = result.content[0].text;
|
|
1094
|
+
expect(text).toContain('Rock');
|
|
1095
|
+
const structured = result.structuredContent;
|
|
1096
|
+
expect(structured?.items[0].period).toBe('2026-03');
|
|
1097
|
+
});
|
|
1098
|
+
it('registers album and artist entity resource templates', async () => {
|
|
1099
|
+
const { resourceTemplates } = await client.listResourceTemplates();
|
|
1100
|
+
const uris = resourceTemplates.map((t) => t.uriTemplate);
|
|
1101
|
+
expect(uris).toContain('rewind://album/{id}');
|
|
1102
|
+
expect(uris).toContain('rewind://artist/{id}');
|
|
1103
|
+
});
|
|
1104
|
+
it('reads an album entity via rewind://album/{id}', async () => {
|
|
1105
|
+
const result = await client.readResource({ uri: 'rewind://album/20' });
|
|
1106
|
+
const content = result.contents[0];
|
|
1107
|
+
expect(content.mimeType).toBe('application/json');
|
|
1108
|
+
const data = JSON.parse(content.text);
|
|
1109
|
+
expect(data.name).toBe("Paul's Boutique");
|
|
1110
|
+
});
|
|
1111
|
+
});
|
|
1112
|
+
describe('Phase 3 -- collecting rich responses', () => {
|
|
1113
|
+
it('get_vinyl_collection emits top-N covers + Discogs resource_links', async () => {
|
|
1114
|
+
const result = await client.callTool({
|
|
1115
|
+
name: 'get_vinyl_collection',
|
|
1116
|
+
arguments: {},
|
|
1117
|
+
});
|
|
1118
|
+
const content = result.content;
|
|
1119
|
+
expect(content.find((b) => b.type === 'image')).toBeDefined();
|
|
1120
|
+
const link = content.find((b) => b.type === 'resource_link');
|
|
1121
|
+
expect(link?.uri).toContain('discogs.com');
|
|
1122
|
+
expect(link?.name).toContain('Discogs');
|
|
1123
|
+
});
|
|
1124
|
+
it('get_collecting_stats includes structuredContent mirroring the API', async () => {
|
|
1125
|
+
const result = await client.callTool({
|
|
1126
|
+
name: 'get_collecting_stats',
|
|
1127
|
+
arguments: {},
|
|
1128
|
+
});
|
|
1129
|
+
const structured = result.structuredContent;
|
|
1130
|
+
expect(structured?.total_items).toBe(150);
|
|
1131
|
+
expect(structured?.by_format.vinyl).toBe(120);
|
|
1132
|
+
});
|
|
1133
|
+
it('get_physical_media emits covers and pagination', async () => {
|
|
1134
|
+
const result = await client.callTool({
|
|
1135
|
+
name: 'get_physical_media',
|
|
1136
|
+
arguments: {},
|
|
1137
|
+
});
|
|
1138
|
+
const content = result.content;
|
|
1139
|
+
expect(content.find((b) => b.type === 'image')).toBeDefined();
|
|
1140
|
+
const structured = result.structuredContent;
|
|
1141
|
+
expect(structured?.pagination?.total).toBe(1);
|
|
1142
|
+
});
|
|
1143
|
+
it('get_physical_media_stats returns total + per-format structuredContent', async () => {
|
|
1144
|
+
const result = await client.callTool({
|
|
1145
|
+
name: 'get_physical_media_stats',
|
|
1146
|
+
arguments: {},
|
|
1147
|
+
});
|
|
1148
|
+
const structured = result.structuredContent;
|
|
1149
|
+
expect(structured?.total).toBe(97);
|
|
1150
|
+
expect(structured?.formats.length).toBe(3);
|
|
1151
|
+
});
|
|
1152
|
+
it('registers vinyl and physical-media entity templates', async () => {
|
|
1153
|
+
const { resourceTemplates } = await client.listResourceTemplates();
|
|
1154
|
+
const uris = resourceTemplates.map((t) => t.uriTemplate);
|
|
1155
|
+
expect(uris).toContain('rewind://vinyl/{id}');
|
|
1156
|
+
expect(uris).toContain('rewind://physical-media/{id}');
|
|
1157
|
+
});
|
|
1158
|
+
it('reads a vinyl entity via rewind://vinyl/{id}', async () => {
|
|
1159
|
+
const result = await client.readResource({ uri: 'rewind://vinyl/1' });
|
|
1160
|
+
const content = result.contents[0];
|
|
1161
|
+
expect(content.mimeType).toBe('application/json');
|
|
1162
|
+
const data = JSON.parse(content.text);
|
|
1163
|
+
expect(data.title).toBe('Hello Nasty');
|
|
1164
|
+
});
|
|
1165
|
+
});
|
|
1166
|
+
describe('Phase 4 -- reading rich responses', () => {
|
|
1167
|
+
it('get_recent_reads emits article URL resource_links and structuredContent', async () => {
|
|
1168
|
+
const result = await client.callTool({
|
|
1169
|
+
name: 'get_recent_reads',
|
|
1170
|
+
arguments: {},
|
|
1171
|
+
});
|
|
1172
|
+
const content = result.content;
|
|
1173
|
+
const link = content.find((b) => b.type === 'resource_link');
|
|
1174
|
+
expect(link?.uri).toContain('anthropic.com');
|
|
1175
|
+
const structured = result.structuredContent;
|
|
1176
|
+
expect(structured?.items[0].id).toBe(1);
|
|
1177
|
+
});
|
|
1178
|
+
it('get_reading_highlights emits article URLs as resource_links', async () => {
|
|
1179
|
+
const result = await client.callTool({
|
|
1180
|
+
name: 'get_reading_highlights',
|
|
1181
|
+
arguments: {},
|
|
1182
|
+
});
|
|
1183
|
+
const content = result.content;
|
|
1184
|
+
const link = content.find((b) => b.type === 'resource_link');
|
|
1185
|
+
expect(link?.uri).toContain('codinghorror.com');
|
|
1186
|
+
});
|
|
1187
|
+
it('get_random_highlight emits article URL resource_link', async () => {
|
|
1188
|
+
const result = await client.callTool({
|
|
1189
|
+
name: 'get_random_highlight',
|
|
1190
|
+
arguments: {},
|
|
1191
|
+
});
|
|
1192
|
+
const content = result.content;
|
|
1193
|
+
const link = content.find((b) => b.type === 'resource_link');
|
|
1194
|
+
expect(link?.uri).toContain('example.com');
|
|
1195
|
+
});
|
|
1196
|
+
it('get_reading_stats returns structuredContent mirroring API', async () => {
|
|
1197
|
+
const result = await client.callTool({
|
|
1198
|
+
name: 'get_reading_stats',
|
|
1199
|
+
arguments: {},
|
|
1200
|
+
});
|
|
1201
|
+
const structured = result.structuredContent;
|
|
1202
|
+
expect(structured?.total_articles).toBe(350);
|
|
1203
|
+
expect(structured?.total_highlights).toBe(420);
|
|
1204
|
+
});
|
|
1205
|
+
it('registers article entity template', async () => {
|
|
1206
|
+
const { resourceTemplates } = await client.listResourceTemplates();
|
|
1207
|
+
const uris = resourceTemplates.map((t) => t.uriTemplate);
|
|
1208
|
+
expect(uris).toContain('rewind://article/{id}');
|
|
1209
|
+
});
|
|
1210
|
+
it('reads an article entity via rewind://article/{id}', async () => {
|
|
1211
|
+
const result = await client.readResource({ uri: 'rewind://article/1' });
|
|
1212
|
+
const content = result.contents[0];
|
|
1213
|
+
expect(content.mimeType).toBe('application/json');
|
|
1214
|
+
const data = JSON.parse(content.text);
|
|
1215
|
+
expect(data.title).toBe('How to Build an MCP Server');
|
|
1216
|
+
});
|
|
1217
|
+
});
|
|
1218
|
+
describe('Phase 5 -- running rich responses', () => {
|
|
1219
|
+
it('lists the new get_running_years tool', async () => {
|
|
1220
|
+
const { tools } = await client.listTools();
|
|
1221
|
+
expect(tools.map((t) => t.name)).toContain('get_running_years');
|
|
1222
|
+
});
|
|
1223
|
+
it('get_recent_runs emits Strava resource_links', async () => {
|
|
1224
|
+
const result = await client.callTool({
|
|
1225
|
+
name: 'get_recent_runs',
|
|
1226
|
+
arguments: {},
|
|
1227
|
+
});
|
|
1228
|
+
const content = result.content;
|
|
1229
|
+
const link = content.find((b) => b.type === 'resource_link');
|
|
1230
|
+
expect(link?.uri).toContain('strava.com');
|
|
1231
|
+
});
|
|
1232
|
+
it('get_activity_details emits Strava resource_link and structuredContent', async () => {
|
|
1233
|
+
const result = await client.callTool({
|
|
1234
|
+
name: 'get_activity_details',
|
|
1235
|
+
arguments: { id: 123 },
|
|
1236
|
+
});
|
|
1237
|
+
const content = result.content;
|
|
1238
|
+
const link = content.find((b) => b.type === 'resource_link');
|
|
1239
|
+
expect(link?.uri).toContain('strava.com');
|
|
1240
|
+
expect(link?.name).toContain('Strava');
|
|
1241
|
+
const structured = result
|
|
1242
|
+
.structuredContent;
|
|
1243
|
+
expect(structured?.name).toBe('Thursday Afternoon Run');
|
|
1244
|
+
});
|
|
1245
|
+
it('get_running_stats returns structuredContent mirroring API', async () => {
|
|
1246
|
+
const result = await client.callTool({
|
|
1247
|
+
name: 'get_running_stats',
|
|
1248
|
+
arguments: {},
|
|
1249
|
+
});
|
|
1250
|
+
const structured = result.structuredContent;
|
|
1251
|
+
expect(structured?.total_runs).toBe(423);
|
|
1252
|
+
expect(structured?.eddington_number).toBe(12);
|
|
1253
|
+
});
|
|
1254
|
+
it('get_running_years returns per-year breakdown', async () => {
|
|
1255
|
+
const result = await client.callTool({
|
|
1256
|
+
name: 'get_running_years',
|
|
1257
|
+
arguments: {},
|
|
1258
|
+
});
|
|
1259
|
+
const structured = result.structuredContent;
|
|
1260
|
+
expect(structured?.items.length).toBe(2);
|
|
1261
|
+
expect(structured?.items[0].year).toBe(2026);
|
|
1262
|
+
});
|
|
1263
|
+
it('registers activity entity template', async () => {
|
|
1264
|
+
const { resourceTemplates } = await client.listResourceTemplates();
|
|
1265
|
+
const uris = resourceTemplates.map((t) => t.uriTemplate);
|
|
1266
|
+
expect(uris).toContain('rewind://activity/{id}');
|
|
1267
|
+
});
|
|
1268
|
+
});
|
|
1269
|
+
describe('Phase 6 -- cross-domain rich responses', () => {
|
|
1270
|
+
it('search emits rewind:// resource_links per match', async () => {
|
|
1271
|
+
const result = await client.callTool({
|
|
1272
|
+
name: 'search',
|
|
1273
|
+
arguments: { query: 'beastie' },
|
|
1274
|
+
});
|
|
1275
|
+
const content = result.content;
|
|
1276
|
+
const links = content.filter((b) => b.type === 'resource_link');
|
|
1277
|
+
expect(links.length).toBeGreaterThanOrEqual(2);
|
|
1278
|
+
expect(links.map((l) => l.uri)).toContain('rewind://artist/10');
|
|
1279
|
+
expect(links.map((l) => l.uri)).toContain('rewind://movie/1');
|
|
1280
|
+
});
|
|
1281
|
+
it('search structuredContent mirrors API', async () => {
|
|
1282
|
+
const result = await client.callTool({
|
|
1283
|
+
name: 'search',
|
|
1284
|
+
arguments: { query: 'beastie' },
|
|
1285
|
+
});
|
|
1286
|
+
const structured = result.structuredContent;
|
|
1287
|
+
expect(structured?.items.length).toBe(2);
|
|
1288
|
+
expect(structured?.pagination.total).toBe(2);
|
|
1289
|
+
});
|
|
1290
|
+
it('get_feed returns structuredContent with items + pagination', async () => {
|
|
1291
|
+
const result = await client.callTool({
|
|
1292
|
+
name: 'get_feed',
|
|
1293
|
+
arguments: {},
|
|
1294
|
+
});
|
|
1295
|
+
const structured = result.structuredContent;
|
|
1296
|
+
expect(structured?.items[0].domain).toBe('listening');
|
|
1297
|
+
});
|
|
1298
|
+
it('get_on_this_day returns structuredContent grouped by year', async () => {
|
|
1299
|
+
const result = await client.callTool({
|
|
1300
|
+
name: 'get_on_this_day',
|
|
1301
|
+
arguments: {},
|
|
1302
|
+
});
|
|
1303
|
+
const structured = result.structuredContent;
|
|
1304
|
+
expect(structured?.years[0].year).toBe(2025);
|
|
1305
|
+
});
|
|
1306
|
+
});
|
|
1307
|
+
});
|
|
1308
|
+
//# sourceMappingURL=server.test.js.map
|