screenpipe-mcp 0.3.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "screenpipe-mcp",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "MCP server for screenpipe - search your screen recordings, audio transcriptions, and control your computer",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -10,6 +10,8 @@
10
10
  "build": "tsc",
11
11
  "start": "node dist/index.js",
12
12
  "dev": "ts-node src/index.ts",
13
+ "test": "vitest run",
14
+ "test:watch": "vitest",
13
15
  "prepublishOnly": "npm run build"
14
16
  },
15
17
  "keywords": [
@@ -25,12 +27,15 @@
25
27
  "author": "Mediar AI",
26
28
  "license": "MIT",
27
29
  "dependencies": {
28
- "@modelcontextprotocol/sdk": "^1.0.0"
30
+ "@modelcontextprotocol/sdk": "^1.0.0",
31
+ "ws": "^8.18.0"
29
32
  },
30
33
  "devDependencies": {
31
34
  "@types/node": "^20.0.0",
35
+ "@types/ws": "^8.5.13",
32
36
  "typescript": "^5.0.0",
33
- "ts-node": "^10.9.0"
37
+ "ts-node": "^10.9.0",
38
+ "vitest": "^2.1.0"
34
39
  },
35
40
  "engines": {
36
41
  "node": ">=18.0.0"
@@ -0,0 +1,370 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import WebSocket, { WebSocketServer } from "ws";
3
+ import * as http from "http";
4
+ import * as fs from "fs";
5
+ import * as path from "path";
6
+ import * as os from "os";
7
+
8
+ // Mock the search API response
9
+ function createMockSearchResponse(frameIds: number[]) {
10
+ return {
11
+ data: frameIds.map((id, index) => ({
12
+ type: "OCR",
13
+ content: {
14
+ frame_id: id,
15
+ text: `Screen content ${index}`,
16
+ timestamp: new Date(Date.now() - (frameIds.length - index) * 60000).toISOString(),
17
+ app_name: "Test App",
18
+ window_name: "Test Window",
19
+ },
20
+ })),
21
+ };
22
+ }
23
+
24
+ // Mock video export progress messages
25
+ function createExportProgressMessages(frameCount: number): string[] {
26
+ const messages = [];
27
+
28
+ // Extracting phase
29
+ for (let i = 0; i <= 10; i++) {
30
+ messages.push(JSON.stringify({
31
+ status: "extracting",
32
+ progress: (i / 10) * 0.5,
33
+ video_data: null,
34
+ error: null,
35
+ }));
36
+ }
37
+
38
+ // Encoding phase
39
+ for (let i = 0; i <= 10; i++) {
40
+ messages.push(JSON.stringify({
41
+ status: "encoding",
42
+ progress: 0.5 + (i / 10) * 0.5,
43
+ video_data: null,
44
+ error: null,
45
+ }));
46
+ }
47
+
48
+ return messages;
49
+ }
50
+
51
+ describe("export-video MCP tool", () => {
52
+ let mockHttpServer: http.Server;
53
+ let mockWsServer: WebSocketServer;
54
+ let serverPort: number;
55
+
56
+ beforeEach(async () => {
57
+ // Create a mock HTTP server for the search API
58
+ mockHttpServer = http.createServer((req, res) => {
59
+ const url = new URL(req.url!, `http://localhost`);
60
+
61
+ if (url.pathname === "/search") {
62
+ const startTime = url.searchParams.get("start_time");
63
+ const endTime = url.searchParams.get("end_time");
64
+ const contentType = url.searchParams.get("content_type");
65
+
66
+ // Validate required parameters
67
+ if (!startTime || !endTime) {
68
+ res.writeHead(400, { "Content-Type": "application/json" });
69
+ res.end(JSON.stringify({ error: "Missing time parameters" }));
70
+ return;
71
+ }
72
+
73
+ // Return mock search results with frame IDs
74
+ const mockResponse = createMockSearchResponse([100, 101, 102, 103, 104]);
75
+ res.writeHead(200, { "Content-Type": "application/json" });
76
+ res.end(JSON.stringify(mockResponse));
77
+ } else {
78
+ res.writeHead(404);
79
+ res.end();
80
+ }
81
+ });
82
+
83
+ // Create WebSocket server for export endpoint
84
+ mockWsServer = new WebSocketServer({ noServer: true });
85
+
86
+ mockHttpServer.on("upgrade", (request, socket, head) => {
87
+ const url = new URL(request.url!, `http://localhost`);
88
+
89
+ if (url.pathname === "/frames/export") {
90
+ mockWsServer.handleUpgrade(request, socket, head, (ws) => {
91
+ // Send progress updates
92
+ const progressMessages = createExportProgressMessages(5);
93
+ let messageIndex = 0;
94
+
95
+ const sendProgress = setInterval(() => {
96
+ if (messageIndex < progressMessages.length) {
97
+ ws.send(progressMessages[messageIndex]);
98
+ messageIndex++;
99
+ } else {
100
+ clearInterval(sendProgress);
101
+
102
+ // Send completed message with mock video data
103
+ const mockVideoData = Buffer.from("mock video content for testing");
104
+ ws.send(JSON.stringify({
105
+ status: "completed",
106
+ progress: 1.0,
107
+ video_data: Array.from(mockVideoData),
108
+ error: null,
109
+ }));
110
+ }
111
+ }, 10);
112
+ });
113
+ }
114
+ });
115
+
116
+ // Start the server on a random port
117
+ await new Promise<void>((resolve) => {
118
+ mockHttpServer.listen(0, () => {
119
+ const address = mockHttpServer.address() as { port: number };
120
+ serverPort = address.port;
121
+ resolve();
122
+ });
123
+ });
124
+ });
125
+
126
+ afterEach(async () => {
127
+ mockWsServer.close();
128
+ await new Promise<void>((resolve) => {
129
+ mockHttpServer.close(() => resolve());
130
+ });
131
+ });
132
+
133
+ it("should parse ISO 8601 timestamps correctly", () => {
134
+ const startTime = "2024-01-15T10:00:00Z";
135
+ const endTime = "2024-01-15T10:30:00Z";
136
+
137
+ const startDate = new Date(startTime);
138
+ const endDate = new Date(endTime);
139
+
140
+ expect(startDate.getTime()).toBeLessThan(endDate.getTime());
141
+ expect(endDate.getTime() - startDate.getTime()).toBe(30 * 60 * 1000); // 30 minutes
142
+ });
143
+
144
+ it("should extract unique frame IDs from search results", () => {
145
+ const searchResults = createMockSearchResponse([100, 101, 100, 102, 101, 103]);
146
+ const frameIds: number[] = [];
147
+ const seenIds = new Set<number>();
148
+
149
+ for (const result of searchResults.data) {
150
+ if (result.type === "OCR" && result.content?.frame_id) {
151
+ const frameId = result.content.frame_id;
152
+ if (!seenIds.has(frameId)) {
153
+ seenIds.add(frameId);
154
+ frameIds.push(frameId);
155
+ }
156
+ }
157
+ }
158
+
159
+ expect(frameIds).toEqual([100, 101, 102, 103]);
160
+ expect(frameIds.length).toBe(4);
161
+ });
162
+
163
+ it("should handle empty search results", () => {
164
+ const emptyResults = { data: [] };
165
+
166
+ const frameIds: number[] = [];
167
+ for (const result of emptyResults.data) {
168
+ if ((result as any).type === "OCR" && (result as any).content?.frame_id) {
169
+ frameIds.push((result as any).content.frame_id);
170
+ }
171
+ }
172
+
173
+ expect(frameIds.length).toBe(0);
174
+ });
175
+
176
+ it("should build correct WebSocket URL with frame IDs", () => {
177
+ const frameIds = [100, 101, 102];
178
+ const fps = 1.0;
179
+ const port = 3030;
180
+
181
+ const wsUrl = `ws://localhost:${port}/frames/export?frame_ids=${frameIds.join(",")}&fps=${fps}`;
182
+
183
+ expect(wsUrl).toBe("ws://localhost:3030/frames/export?frame_ids=100,101,102&fps=1");
184
+ });
185
+
186
+ it("should connect to mock WebSocket server and receive messages", async () => {
187
+ const wsUrl = `ws://localhost:${serverPort}/frames/export?frame_ids=100,101,102&fps=1`;
188
+
189
+ const result = await new Promise<{ success: boolean; data?: any; error?: string }>((resolve) => {
190
+ const ws = new WebSocket(wsUrl);
191
+ let lastMessage: any;
192
+
193
+ ws.on("error", (error) => {
194
+ resolve({ success: false, error: error.message });
195
+ });
196
+
197
+ ws.on("message", (data) => {
198
+ try {
199
+ lastMessage = JSON.parse(data.toString());
200
+
201
+ if (lastMessage.status === "completed") {
202
+ ws.close();
203
+ resolve({ success: true, data: lastMessage });
204
+ }
205
+ } catch (e) {
206
+ // Ignore parse errors
207
+ }
208
+ });
209
+
210
+ ws.on("close", () => {
211
+ if (!lastMessage || lastMessage.status !== "completed") {
212
+ resolve({ success: false, error: "Connection closed before completion" });
213
+ }
214
+ });
215
+
216
+ // Timeout after 5 seconds
217
+ setTimeout(() => {
218
+ ws.close();
219
+ resolve({ success: false, error: "Timeout" });
220
+ }, 5000);
221
+ });
222
+
223
+ expect(result.success).toBe(true);
224
+ expect(result.data).toBeDefined();
225
+ expect(result.data.status).toBe("completed");
226
+ expect(result.data.video_data).toBeDefined();
227
+ expect(Array.isArray(result.data.video_data)).toBe(true);
228
+ });
229
+
230
+ it("should save video data to temp file", () => {
231
+ const mockVideoData = Buffer.from("mock video content");
232
+ const tempDir = os.tmpdir();
233
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
234
+ const filename = `screenpipe_export_test_${timestamp}.mp4`;
235
+ const filePath = path.join(tempDir, filename);
236
+
237
+ fs.writeFileSync(filePath, mockVideoData);
238
+
239
+ expect(fs.existsSync(filePath)).toBe(true);
240
+
241
+ const readData = fs.readFileSync(filePath);
242
+ expect(readData.toString()).toBe("mock video content");
243
+
244
+ // Cleanup
245
+ fs.unlinkSync(filePath);
246
+ });
247
+
248
+ it("should sort frame IDs in ascending order", () => {
249
+ const unsortedIds = [103, 100, 105, 101, 102];
250
+ const sortedIds = [...unsortedIds].sort((a, b) => a - b);
251
+
252
+ expect(sortedIds).toEqual([100, 101, 102, 103, 105]);
253
+ });
254
+
255
+ it("should handle search API errors gracefully", async () => {
256
+ // Create a server that returns an error
257
+ const errorServer = http.createServer((req, res) => {
258
+ res.writeHead(500, { "Content-Type": "application/json" });
259
+ res.end(JSON.stringify({ error: "Internal server error" }));
260
+ });
261
+
262
+ await new Promise<void>((resolve) => {
263
+ errorServer.listen(0, () => resolve());
264
+ });
265
+
266
+ const address = errorServer.address() as { port: number };
267
+ const port = address.port;
268
+
269
+ try {
270
+ const response = await fetch(`http://localhost:${port}/search?start_time=2024-01-01T00:00:00Z&end_time=2024-01-01T01:00:00Z`);
271
+ expect(response.ok).toBe(false);
272
+ expect(response.status).toBe(500);
273
+ } finally {
274
+ await new Promise<void>((resolve) => {
275
+ errorServer.close(() => resolve());
276
+ });
277
+ }
278
+ });
279
+
280
+ it("should handle WebSocket connection errors", async () => {
281
+ const ws = new WebSocket("ws://localhost:59999/invalid"); // Port that's not listening
282
+
283
+ const result = await new Promise<{ connected: boolean; error?: string }>((resolve) => {
284
+ ws.on("open", () => {
285
+ resolve({ connected: true });
286
+ });
287
+
288
+ ws.on("error", (error) => {
289
+ resolve({ connected: false, error: error.message });
290
+ });
291
+
292
+ setTimeout(() => {
293
+ resolve({ connected: false, error: "Timeout" });
294
+ }, 2000);
295
+ });
296
+
297
+ expect(result.connected).toBe(false);
298
+ });
299
+
300
+ it("should validate time range parameters", () => {
301
+ const startTime = "2024-01-15T10:30:00Z";
302
+ const endTime = "2024-01-15T10:00:00Z"; // End before start
303
+
304
+ const startDate = new Date(startTime);
305
+ const endDate = new Date(endTime);
306
+
307
+ // This should be invalid (end before start)
308
+ expect(endDate.getTime()).toBeLessThan(startDate.getTime());
309
+ });
310
+
311
+ it("should handle audio-only results (no frame IDs)", () => {
312
+ const audioOnlyResults = {
313
+ data: [
314
+ {
315
+ type: "Audio",
316
+ content: {
317
+ transcription: "Hello world",
318
+ timestamp: "2024-01-15T10:00:00Z",
319
+ device_name: "Microphone",
320
+ },
321
+ },
322
+ {
323
+ type: "Audio",
324
+ content: {
325
+ transcription: "How are you",
326
+ timestamp: "2024-01-15T10:01:00Z",
327
+ device_name: "Microphone",
328
+ },
329
+ },
330
+ ],
331
+ };
332
+
333
+ const frameIds: number[] = [];
334
+ for (const result of audioOnlyResults.data) {
335
+ if (result.type === "OCR" && (result.content as any)?.frame_id) {
336
+ frameIds.push((result.content as any).frame_id);
337
+ }
338
+ }
339
+
340
+ expect(frameIds.length).toBe(0);
341
+ });
342
+ });
343
+
344
+ describe("export-video tool schema validation", () => {
345
+ it("should have correct input schema", () => {
346
+ const schema = {
347
+ type: "object",
348
+ properties: {
349
+ start_time: {
350
+ type: "string",
351
+ format: "date-time",
352
+ },
353
+ end_time: {
354
+ type: "string",
355
+ format: "date-time",
356
+ },
357
+ fps: {
358
+ type: "number",
359
+ default: 1.0,
360
+ },
361
+ },
362
+ required: ["start_time", "end_time"],
363
+ };
364
+
365
+ expect(schema.required).toContain("start_time");
366
+ expect(schema.required).toContain("end_time");
367
+ expect(schema.required).not.toContain("fps"); // fps is optional
368
+ expect(schema.properties.fps.default).toBe(1.0);
369
+ });
370
+ });