screenpipe-mcp 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,348 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ const vitest_1 = require("vitest");
37
+ const ws_1 = __importStar(require("ws"));
38
+ const http = __importStar(require("http"));
39
+ const fs = __importStar(require("fs"));
40
+ const path = __importStar(require("path"));
41
+ const os = __importStar(require("os"));
42
+ // Mock the search API response
43
+ function createMockSearchResponse(frameIds) {
44
+ return {
45
+ data: frameIds.map((id, index) => ({
46
+ type: "OCR",
47
+ content: {
48
+ frame_id: id,
49
+ text: `Screen content ${index}`,
50
+ timestamp: new Date(Date.now() - (frameIds.length - index) * 60000).toISOString(),
51
+ app_name: "Test App",
52
+ window_name: "Test Window",
53
+ },
54
+ })),
55
+ };
56
+ }
57
+ // Mock video export progress messages
58
+ function createExportProgressMessages(frameCount) {
59
+ const messages = [];
60
+ // Extracting phase
61
+ for (let i = 0; i <= 10; i++) {
62
+ messages.push(JSON.stringify({
63
+ status: "extracting",
64
+ progress: (i / 10) * 0.5,
65
+ video_data: null,
66
+ error: null,
67
+ }));
68
+ }
69
+ // Encoding phase
70
+ for (let i = 0; i <= 10; i++) {
71
+ messages.push(JSON.stringify({
72
+ status: "encoding",
73
+ progress: 0.5 + (i / 10) * 0.5,
74
+ video_data: null,
75
+ error: null,
76
+ }));
77
+ }
78
+ return messages;
79
+ }
80
+ (0, vitest_1.describe)("export-video MCP tool", () => {
81
+ let mockHttpServer;
82
+ let mockWsServer;
83
+ let serverPort;
84
+ (0, vitest_1.beforeEach)(async () => {
85
+ // Create a mock HTTP server for the search API
86
+ mockHttpServer = http.createServer((req, res) => {
87
+ const url = new URL(req.url, `http://localhost`);
88
+ if (url.pathname === "/search") {
89
+ const startTime = url.searchParams.get("start_time");
90
+ const endTime = url.searchParams.get("end_time");
91
+ const contentType = url.searchParams.get("content_type");
92
+ // Validate required parameters
93
+ if (!startTime || !endTime) {
94
+ res.writeHead(400, { "Content-Type": "application/json" });
95
+ res.end(JSON.stringify({ error: "Missing time parameters" }));
96
+ return;
97
+ }
98
+ // Return mock search results with frame IDs
99
+ const mockResponse = createMockSearchResponse([100, 101, 102, 103, 104]);
100
+ res.writeHead(200, { "Content-Type": "application/json" });
101
+ res.end(JSON.stringify(mockResponse));
102
+ }
103
+ else {
104
+ res.writeHead(404);
105
+ res.end();
106
+ }
107
+ });
108
+ // Create WebSocket server for export endpoint
109
+ mockWsServer = new ws_1.WebSocketServer({ noServer: true });
110
+ mockHttpServer.on("upgrade", (request, socket, head) => {
111
+ const url = new URL(request.url, `http://localhost`);
112
+ if (url.pathname === "/frames/export") {
113
+ mockWsServer.handleUpgrade(request, socket, head, (ws) => {
114
+ // Send progress updates
115
+ const progressMessages = createExportProgressMessages(5);
116
+ let messageIndex = 0;
117
+ const sendProgress = setInterval(() => {
118
+ if (messageIndex < progressMessages.length) {
119
+ ws.send(progressMessages[messageIndex]);
120
+ messageIndex++;
121
+ }
122
+ else {
123
+ clearInterval(sendProgress);
124
+ // Send completed message with mock video data
125
+ const mockVideoData = Buffer.from("mock video content for testing");
126
+ ws.send(JSON.stringify({
127
+ status: "completed",
128
+ progress: 1.0,
129
+ video_data: Array.from(mockVideoData),
130
+ error: null,
131
+ }));
132
+ }
133
+ }, 10);
134
+ });
135
+ }
136
+ });
137
+ // Start the server on a random port
138
+ await new Promise((resolve) => {
139
+ mockHttpServer.listen(0, () => {
140
+ const address = mockHttpServer.address();
141
+ serverPort = address.port;
142
+ resolve();
143
+ });
144
+ });
145
+ });
146
+ (0, vitest_1.afterEach)(async () => {
147
+ mockWsServer.close();
148
+ await new Promise((resolve) => {
149
+ mockHttpServer.close(() => resolve());
150
+ });
151
+ });
152
+ (0, vitest_1.it)("should parse ISO 8601 timestamps correctly", () => {
153
+ const startTime = "2024-01-15T10:00:00Z";
154
+ const endTime = "2024-01-15T10:30:00Z";
155
+ const startDate = new Date(startTime);
156
+ const endDate = new Date(endTime);
157
+ (0, vitest_1.expect)(startDate.getTime()).toBeLessThan(endDate.getTime());
158
+ (0, vitest_1.expect)(endDate.getTime() - startDate.getTime()).toBe(30 * 60 * 1000); // 30 minutes
159
+ });
160
+ (0, vitest_1.it)("should extract unique frame IDs from search results", () => {
161
+ const searchResults = createMockSearchResponse([100, 101, 100, 102, 101, 103]);
162
+ const frameIds = [];
163
+ const seenIds = new Set();
164
+ for (const result of searchResults.data) {
165
+ if (result.type === "OCR" && result.content?.frame_id) {
166
+ const frameId = result.content.frame_id;
167
+ if (!seenIds.has(frameId)) {
168
+ seenIds.add(frameId);
169
+ frameIds.push(frameId);
170
+ }
171
+ }
172
+ }
173
+ (0, vitest_1.expect)(frameIds).toEqual([100, 101, 102, 103]);
174
+ (0, vitest_1.expect)(frameIds.length).toBe(4);
175
+ });
176
+ (0, vitest_1.it)("should handle empty search results", () => {
177
+ const emptyResults = { data: [] };
178
+ const frameIds = [];
179
+ for (const result of emptyResults.data) {
180
+ if (result.type === "OCR" && result.content?.frame_id) {
181
+ frameIds.push(result.content.frame_id);
182
+ }
183
+ }
184
+ (0, vitest_1.expect)(frameIds.length).toBe(0);
185
+ });
186
+ (0, vitest_1.it)("should build correct WebSocket URL with frame IDs", () => {
187
+ const frameIds = [100, 101, 102];
188
+ const fps = 1.0;
189
+ const port = 3030;
190
+ const wsUrl = `ws://localhost:${port}/frames/export?frame_ids=${frameIds.join(",")}&fps=${fps}`;
191
+ (0, vitest_1.expect)(wsUrl).toBe("ws://localhost:3030/frames/export?frame_ids=100,101,102&fps=1");
192
+ });
193
+ (0, vitest_1.it)("should connect to mock WebSocket server and receive messages", async () => {
194
+ const wsUrl = `ws://localhost:${serverPort}/frames/export?frame_ids=100,101,102&fps=1`;
195
+ const result = await new Promise((resolve) => {
196
+ const ws = new ws_1.default(wsUrl);
197
+ let lastMessage;
198
+ ws.on("error", (error) => {
199
+ resolve({ success: false, error: error.message });
200
+ });
201
+ ws.on("message", (data) => {
202
+ try {
203
+ lastMessage = JSON.parse(data.toString());
204
+ if (lastMessage.status === "completed") {
205
+ ws.close();
206
+ resolve({ success: true, data: lastMessage });
207
+ }
208
+ }
209
+ catch (e) {
210
+ // Ignore parse errors
211
+ }
212
+ });
213
+ ws.on("close", () => {
214
+ if (!lastMessage || lastMessage.status !== "completed") {
215
+ resolve({ success: false, error: "Connection closed before completion" });
216
+ }
217
+ });
218
+ // Timeout after 5 seconds
219
+ setTimeout(() => {
220
+ ws.close();
221
+ resolve({ success: false, error: "Timeout" });
222
+ }, 5000);
223
+ });
224
+ (0, vitest_1.expect)(result.success).toBe(true);
225
+ (0, vitest_1.expect)(result.data).toBeDefined();
226
+ (0, vitest_1.expect)(result.data.status).toBe("completed");
227
+ (0, vitest_1.expect)(result.data.video_data).toBeDefined();
228
+ (0, vitest_1.expect)(Array.isArray(result.data.video_data)).toBe(true);
229
+ });
230
+ (0, vitest_1.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
+ fs.writeFileSync(filePath, mockVideoData);
237
+ (0, vitest_1.expect)(fs.existsSync(filePath)).toBe(true);
238
+ const readData = fs.readFileSync(filePath);
239
+ (0, vitest_1.expect)(readData.toString()).toBe("mock video content");
240
+ // Cleanup
241
+ fs.unlinkSync(filePath);
242
+ });
243
+ (0, vitest_1.it)("should sort frame IDs in ascending order", () => {
244
+ const unsortedIds = [103, 100, 105, 101, 102];
245
+ const sortedIds = [...unsortedIds].sort((a, b) => a - b);
246
+ (0, vitest_1.expect)(sortedIds).toEqual([100, 101, 102, 103, 105]);
247
+ });
248
+ (0, vitest_1.it)("should handle search API errors gracefully", async () => {
249
+ // Create a server that returns an error
250
+ const errorServer = http.createServer((req, res) => {
251
+ res.writeHead(500, { "Content-Type": "application/json" });
252
+ res.end(JSON.stringify({ error: "Internal server error" }));
253
+ });
254
+ await new Promise((resolve) => {
255
+ errorServer.listen(0, () => resolve());
256
+ });
257
+ const address = errorServer.address();
258
+ const port = address.port;
259
+ try {
260
+ const response = await fetch(`http://localhost:${port}/search?start_time=2024-01-01T00:00:00Z&end_time=2024-01-01T01:00:00Z`);
261
+ (0, vitest_1.expect)(response.ok).toBe(false);
262
+ (0, vitest_1.expect)(response.status).toBe(500);
263
+ }
264
+ finally {
265
+ await new Promise((resolve) => {
266
+ errorServer.close(() => resolve());
267
+ });
268
+ }
269
+ });
270
+ (0, vitest_1.it)("should handle WebSocket connection errors", async () => {
271
+ const ws = new ws_1.default("ws://localhost:59999/invalid"); // Port that's not listening
272
+ const result = await new Promise((resolve) => {
273
+ ws.on("open", () => {
274
+ resolve({ connected: true });
275
+ });
276
+ ws.on("error", (error) => {
277
+ resolve({ connected: false, error: error.message });
278
+ });
279
+ setTimeout(() => {
280
+ resolve({ connected: false, error: "Timeout" });
281
+ }, 2000);
282
+ });
283
+ (0, vitest_1.expect)(result.connected).toBe(false);
284
+ });
285
+ (0, vitest_1.it)("should validate time range parameters", () => {
286
+ const startTime = "2024-01-15T10:30:00Z";
287
+ const endTime = "2024-01-15T10:00:00Z"; // End before start
288
+ const startDate = new Date(startTime);
289
+ const endDate = new Date(endTime);
290
+ // This should be invalid (end before start)
291
+ (0, vitest_1.expect)(endDate.getTime()).toBeLessThan(startDate.getTime());
292
+ });
293
+ (0, vitest_1.it)("should handle audio-only results (no frame IDs)", () => {
294
+ const audioOnlyResults = {
295
+ data: [
296
+ {
297
+ type: "Audio",
298
+ content: {
299
+ transcription: "Hello world",
300
+ timestamp: "2024-01-15T10:00:00Z",
301
+ device_name: "Microphone",
302
+ },
303
+ },
304
+ {
305
+ type: "Audio",
306
+ content: {
307
+ transcription: "How are you",
308
+ timestamp: "2024-01-15T10:01:00Z",
309
+ device_name: "Microphone",
310
+ },
311
+ },
312
+ ],
313
+ };
314
+ const frameIds = [];
315
+ for (const result of audioOnlyResults.data) {
316
+ if (result.type === "OCR" && result.content?.frame_id) {
317
+ frameIds.push(result.content.frame_id);
318
+ }
319
+ }
320
+ (0, vitest_1.expect)(frameIds.length).toBe(0);
321
+ });
322
+ });
323
+ (0, vitest_1.describe)("export-video tool schema validation", () => {
324
+ (0, vitest_1.it)("should have correct input schema", () => {
325
+ const schema = {
326
+ type: "object",
327
+ properties: {
328
+ start_time: {
329
+ type: "string",
330
+ format: "date-time",
331
+ },
332
+ end_time: {
333
+ type: "string",
334
+ format: "date-time",
335
+ },
336
+ fps: {
337
+ type: "number",
338
+ default: 1.0,
339
+ },
340
+ },
341
+ required: ["start_time", "end_time"],
342
+ };
343
+ (0, vitest_1.expect)(schema.required).toContain("start_time");
344
+ (0, vitest_1.expect)(schema.required).toContain("end_time");
345
+ (0, vitest_1.expect)(schema.required).not.toContain("fps"); // fps is optional
346
+ (0, vitest_1.expect)(schema.properties.fps.default).toBe(1.0);
347
+ });
348
+ });
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * HTTP Server for Screenpipe MCP
4
+ *
5
+ * This allows web apps to call MCP tools over HTTP instead of stdio.
6
+ * Run with: npx ts-node src/http-server.ts --port 3031
7
+ */
8
+ export {};
@@ -0,0 +1,203 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ /**
4
+ * HTTP Server for Screenpipe MCP
5
+ *
6
+ * This allows web apps to call MCP tools over HTTP instead of stdio.
7
+ * Run with: npx ts-node src/http-server.ts --port 3031
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ const http_1 = require("http");
11
+ const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js");
12
+ const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
13
+ const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
14
+ // Parse command line arguments
15
+ const args = process.argv.slice(2);
16
+ let mcpPort = 3031;
17
+ let screenpipePort = 3030;
18
+ for (let i = 0; i < args.length; i++) {
19
+ if (args[i] === "--port" && args[i + 1]) {
20
+ mcpPort = parseInt(args[i + 1], 10);
21
+ }
22
+ if (args[i] === "--screenpipe-port" && args[i + 1]) {
23
+ screenpipePort = parseInt(args[i + 1], 10);
24
+ }
25
+ }
26
+ const SCREENPIPE_API = `http://localhost:${screenpipePort}`;
27
+ // Tool definitions
28
+ const TOOLS = [
29
+ {
30
+ name: "search_content",
31
+ description: "Search screenpipe's recorded content: screen text (OCR), audio transcriptions, and UI elements. " +
32
+ "Returns timestamped results with app context. " +
33
+ "Call with no parameters to get recent activity.",
34
+ inputSchema: {
35
+ type: "object",
36
+ properties: {
37
+ q: {
38
+ type: "string",
39
+ description: "Search query. Optional - omit to return all recent content.",
40
+ },
41
+ content_type: {
42
+ type: "string",
43
+ enum: ["all", "ocr", "audio", "ui"],
44
+ description: "Content type filter. Default: 'all'",
45
+ },
46
+ limit: {
47
+ type: "integer",
48
+ description: "Max results. Default: 10",
49
+ },
50
+ offset: {
51
+ type: "integer",
52
+ description: "Skip N results for pagination. Default: 0",
53
+ },
54
+ start_time: {
55
+ type: "string",
56
+ description: "ISO 8601 UTC start time (e.g., 2024-01-15T10:00:00Z)",
57
+ },
58
+ end_time: {
59
+ type: "string",
60
+ description: "ISO 8601 UTC end time (e.g., 2024-01-15T18:00:00Z)",
61
+ },
62
+ app_name: {
63
+ type: "string",
64
+ description: "Filter by app (e.g., 'Google Chrome', 'Slack', 'zoom.us')",
65
+ },
66
+ window_name: {
67
+ type: "string",
68
+ description: "Filter by window title",
69
+ },
70
+ },
71
+ },
72
+ },
73
+ ];
74
+ // Helper function to make HTTP requests
75
+ async function fetchAPI(endpoint, options = {}) {
76
+ const url = `${SCREENPIPE_API}${endpoint}`;
77
+ return fetch(url, {
78
+ ...options,
79
+ headers: {
80
+ "Content-Type": "application/json",
81
+ ...options.headers,
82
+ },
83
+ });
84
+ }
85
+ // Create MCP server
86
+ const server = new index_js_1.Server({
87
+ name: "screenpipe-http",
88
+ version: "0.1.0",
89
+ }, {
90
+ capabilities: {
91
+ tools: {},
92
+ },
93
+ });
94
+ // List tools handler
95
+ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => {
96
+ return { tools: TOOLS };
97
+ });
98
+ // Call tool handler
99
+ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
100
+ const { name, arguments: args } = request.params;
101
+ if (!args) {
102
+ throw new Error("Missing arguments");
103
+ }
104
+ if (name === "search_content") {
105
+ const params = new URLSearchParams();
106
+ for (const [key, value] of Object.entries(args)) {
107
+ if (value !== null && value !== undefined) {
108
+ params.append(key, String(value));
109
+ }
110
+ }
111
+ const response = await fetchAPI(`/search?${params.toString()}`);
112
+ if (!response.ok) {
113
+ throw new Error(`HTTP error: ${response.status}`);
114
+ }
115
+ const data = await response.json();
116
+ const results = data.data || [];
117
+ const pagination = data.pagination || {};
118
+ if (results.length === 0) {
119
+ return {
120
+ content: [
121
+ {
122
+ type: "text",
123
+ text: "No results found. Try: broader search terms, different content_type, or wider time range.",
124
+ },
125
+ ],
126
+ };
127
+ }
128
+ const formattedResults = [];
129
+ for (const result of results) {
130
+ const content = result.content;
131
+ if (!content)
132
+ continue;
133
+ if (result.type === "OCR") {
134
+ formattedResults.push(`[OCR] ${content.app_name || "?"} | ${content.window_name || "?"}\n` +
135
+ `${content.timestamp || ""}\n` +
136
+ `${content.text || ""}`);
137
+ }
138
+ else if (result.type === "Audio") {
139
+ formattedResults.push(`[Audio] ${content.device_name || "?"}\n` +
140
+ `${content.timestamp || ""}\n` +
141
+ `${content.transcription || ""}`);
142
+ }
143
+ else if (result.type === "UI") {
144
+ formattedResults.push(`[UI] ${content.app_name || "?"} | ${content.window_name || "?"}\n` +
145
+ `${content.timestamp || ""}\n` +
146
+ `${content.text || ""}`);
147
+ }
148
+ }
149
+ const header = `Results: ${results.length}/${pagination.total || "?"}` +
150
+ (pagination.total > results.length ? ` (use offset=${(pagination.offset || 0) + results.length} for more)` : "");
151
+ return {
152
+ content: [
153
+ {
154
+ type: "text",
155
+ text: header + "\n\n" + formattedResults.join("\n---\n"),
156
+ },
157
+ ],
158
+ };
159
+ }
160
+ throw new Error(`Unknown tool: ${name}`);
161
+ });
162
+ // Create HTTP server with MCP transport
163
+ const transports = new Map();
164
+ const httpServer = (0, http_1.createServer)(async (req, res) => {
165
+ // CORS headers
166
+ res.setHeader("Access-Control-Allow-Origin", "*");
167
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
168
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, mcp-session-id");
169
+ if (req.method === "OPTIONS") {
170
+ res.writeHead(204);
171
+ res.end();
172
+ return;
173
+ }
174
+ // Health check
175
+ if (req.url === "/health") {
176
+ res.writeHead(200, { "Content-Type": "application/json" });
177
+ res.end(JSON.stringify({ status: "ok" }));
178
+ return;
179
+ }
180
+ // MCP endpoint
181
+ if (req.url === "/mcp" || req.url?.startsWith("/mcp?")) {
182
+ const sessionId = req.headers["mcp-session-id"];
183
+ let transport = sessionId ? transports.get(sessionId) : undefined;
184
+ if (!transport) {
185
+ transport = new streamableHttp_js_1.StreamableHTTPServerTransport({
186
+ sessionIdGenerator: () => crypto.randomUUID(),
187
+ });
188
+ await server.connect(transport);
189
+ if (transport.sessionId) {
190
+ transports.set(transport.sessionId, transport);
191
+ }
192
+ }
193
+ await transport.handleRequest(req, res);
194
+ return;
195
+ }
196
+ res.writeHead(404, { "Content-Type": "application/json" });
197
+ res.end(JSON.stringify({ error: "Not found" }));
198
+ });
199
+ httpServer.listen(mcpPort, () => {
200
+ console.log(`Screenpipe MCP HTTP server running on http://localhost:${mcpPort}`);
201
+ console.log(`MCP endpoint: http://localhost:${mcpPort}/mcp`);
202
+ console.log(`Health check: http://localhost:${mcpPort}/health`);
203
+ });