screenpipe-mcp 0.16.3 → 0.18.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.
@@ -7,10 +7,18 @@
7
7
  * HTTP Server for Screenpipe MCP
8
8
  *
9
9
  * This allows web apps to call MCP tools over HTTP instead of stdio.
10
- * Run with: npx ts-node src/http-server.ts --port 3031
10
+ *
11
+ * Run on localhost (default):
12
+ * npx ts-node src/http-server.ts --port 3031
13
+ *
14
+ * Expose to your LAN (requires --api-key):
15
+ * npx ts-node src/http-server.ts --listen-on-lan --api-key <secret>
16
+ *
17
+ * Loopback callers are always allowed without auth. Non-loopback callers
18
+ * must send `Authorization: Bearer <secret>` whenever --api-key is set.
11
19
  */
12
20
 
13
- import { createServer } from "http";
21
+ import { createServer, type IncomingMessage, type ServerResponse } from "http";
14
22
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
15
23
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
16
24
  import {
@@ -18,23 +26,121 @@ import {
18
26
  ListToolsRequestSchema,
19
27
  } from "@modelcontextprotocol/sdk/types.js";
20
28
 
21
- // Parse command line arguments
22
- const args = process.argv.slice(2);
23
- let mcpPort = 3031;
24
- let screenpipePort = 3030;
29
+ // ── CLI parsing ─────────────────────────────────────────────────────────
30
+
31
+ export interface CliConfig {
32
+ mcpPort: number;
33
+ screenpipePort: number;
34
+ /** Bind address: "127.0.0.1" (default) or "0.0.0.0" when --listen-on-lan. */
35
+ host: string;
36
+ /** Required bearer token for non-loopback requests. Loopback skips auth. */
37
+ apiKey?: string;
38
+ }
39
+
40
+ export class CliError extends Error {}
41
+
42
+ /**
43
+ * Parse CLI args. Pure for testability.
44
+ *
45
+ * Mirrors the screenpipe-engine CLI: --listen-on-lan flips bind to 0.0.0.0
46
+ * and *requires* --api-key so we never accidentally expose an unauthenticated
47
+ * MCP endpoint on the user's network.
48
+ */
49
+ export function parseArgs(argv: string[]): CliConfig {
50
+ let mcpPort = 3031;
51
+ let screenpipePort = 3030;
52
+ let listenOnLan = false;
53
+ let apiKey: string | undefined;
25
54
 
26
- for (let i = 0; i < args.length; i++) {
27
- if (args[i] === "--port" && args[i + 1]) {
28
- mcpPort = parseInt(args[i + 1], 10);
55
+ for (let i = 0; i < argv.length; i++) {
56
+ const a = argv[i];
57
+ if (a === "--port" && argv[i + 1]) {
58
+ mcpPort = parseInt(argv[++i], 10);
59
+ } else if (a === "--screenpipe-port" && argv[i + 1]) {
60
+ screenpipePort = parseInt(argv[++i], 10);
61
+ } else if (a === "--listen-on-lan") {
62
+ listenOnLan = true;
63
+ } else if (a === "--api-key" && argv[i + 1]) {
64
+ apiKey = argv[++i];
65
+ } else if (a === "--help" || a === "-h") {
66
+ throw new CliError(usage());
67
+ }
68
+ }
69
+
70
+ if (Number.isNaN(mcpPort) || mcpPort <= 0 || mcpPort > 65535) {
71
+ throw new CliError(`invalid --port: ${mcpPort}`);
72
+ }
73
+ if (Number.isNaN(screenpipePort) || screenpipePort <= 0 || screenpipePort > 65535) {
74
+ throw new CliError(`invalid --screenpipe-port: ${screenpipePort}`);
29
75
  }
30
- if (args[i] === "--screenpipe-port" && args[i + 1]) {
31
- screenpipePort = parseInt(args[i + 1], 10);
76
+ if (listenOnLan && !apiKey) {
77
+ throw new CliError(
78
+ "--listen-on-lan requires --api-key <secret> — refusing to expose " +
79
+ "an unauthenticated MCP endpoint on your network."
80
+ );
32
81
  }
82
+
83
+ return {
84
+ mcpPort,
85
+ screenpipePort,
86
+ host: listenOnLan ? "0.0.0.0" : "127.0.0.1",
87
+ apiKey,
88
+ };
89
+ }
90
+
91
+ function usage(): string {
92
+ return [
93
+ "screenpipe-mcp http server",
94
+ "",
95
+ " --port <n> listen port (default 3031)",
96
+ " --screenpipe-port <n> upstream screenpipe API port (default 3030)",
97
+ " --listen-on-lan bind 0.0.0.0 instead of 127.0.0.1",
98
+ " (requires --api-key)",
99
+ " --api-key <secret> bearer token for non-loopback requests",
100
+ " --help, -h show this message",
101
+ ].join("\n");
102
+ }
103
+
104
+ // ── Auth ────────────────────────────────────────────────────────────────
105
+
106
+ /**
107
+ * True if `req` came from the local machine. Covers IPv4 loopback,
108
+ * IPv6 loopback, and IPv4-mapped-IPv6 loopback (`::ffff:127.x`).
109
+ */
110
+ export function isLoopbackRequest(req: { socket: { remoteAddress?: string } }): boolean {
111
+ const addr = req.socket.remoteAddress ?? "";
112
+ if (addr === "127.0.0.1" || addr === "::1") return true;
113
+ if (addr.startsWith("::ffff:127.")) return true;
114
+ return false;
33
115
  }
34
116
 
35
- const SCREENPIPE_API = `http://localhost:${screenpipePort}`;
117
+ /**
118
+ * Authorization decision. Loopback is always allowed; non-loopback requires
119
+ * a matching bearer token when one is configured. If no api key is set
120
+ * (loopback-only deployment), non-loopback shouldn't even be reachable —
121
+ * but we still 401 it as belt-and-suspenders.
122
+ */
123
+ export function isAuthorized(
124
+ req: { socket: { remoteAddress?: string }; headers: { authorization?: string } },
125
+ apiKey: string | undefined
126
+ ): boolean {
127
+ if (isLoopbackRequest(req)) return true;
128
+ if (!apiKey) return false;
129
+ const expected = `Bearer ${apiKey}`;
130
+ const got = req.headers.authorization ?? "";
131
+ return constantTimeEq(got, expected);
132
+ }
133
+
134
+ /** Constant-time string compare to keep timing attacks off the table. */
135
+ function constantTimeEq(a: string, b: string): boolean {
136
+ if (a.length !== b.length) return false;
137
+ let diff = 0;
138
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
139
+ return diff === 0;
140
+ }
141
+
142
+ // ── Tool definitions ────────────────────────────────────────────────────
36
143
 
37
- // Tool definitions
38
144
  const TOOLS = [
39
145
  {
40
146
  name: "search_content",
@@ -52,17 +158,12 @@ const TOOLS = [
52
158
  content_type: {
53
159
  type: "string",
54
160
  enum: ["all", "ocr", "audio", "input", "accessibility"],
55
- description: "Content type filter: 'ocr' (screen text), 'audio' (transcriptions), 'input' (clicks, keystrokes, clipboard, app switches), 'accessibility' (accessibility tree text), 'all'. Default: 'all'",
161
+ description:
162
+ "Content type filter: 'ocr' (screen text), 'audio' (transcriptions), 'input' (clicks, keystrokes, clipboard, app switches), 'accessibility' (accessibility tree text), 'all'. Default: 'all'",
56
163
  default: "all",
57
164
  },
58
- limit: {
59
- type: "integer",
60
- description: "Max results. Default: 10",
61
- },
62
- offset: {
63
- type: "integer",
64
- description: "Skip N results for pagination. Default: 0",
65
- },
165
+ limit: { type: "integer", description: "Max results. Default: 10" },
166
+ offset: { type: "integer", description: "Skip N results for pagination. Default: 0" },
66
167
  start_time: {
67
168
  type: "string",
68
169
  description: "ISO 8601 UTC start time (e.g., 2024-01-15T10:00:00Z)",
@@ -75,29 +176,27 @@ const TOOLS = [
75
176
  type: "string",
76
177
  description: "Filter by app (e.g., 'Google Chrome', 'Slack', 'zoom.us')",
77
178
  },
78
- window_name: {
79
- type: "string",
80
- description: "Filter by window title",
81
- },
179
+ window_name: { type: "string", description: "Filter by window title" },
82
180
  },
83
181
  },
84
182
  },
85
183
  ];
86
184
 
87
- // Helper function to make HTTP requests
88
- async function fetchAPI(endpoint: string, options: RequestInit = {}): Promise<Response> {
89
- const url = `${SCREENPIPE_API}${endpoint}`;
90
- return fetch(url, {
91
- ...options,
92
- headers: {
93
- "Content-Type": "application/json",
94
- ...options.headers,
95
- },
96
- });
185
+ // ── Tool handlers ───────────────────────────────────────────────────────
186
+
187
+ function makeFetchAPI(screenpipePort: number) {
188
+ const base = `http://localhost:${screenpipePort}`;
189
+ return async (endpoint: string, options: RequestInit = {}): Promise<Response> =>
190
+ fetch(`${base}${endpoint}`, {
191
+ ...options,
192
+ headers: { "Content-Type": "application/json", ...options.headers },
193
+ });
97
194
  }
98
195
 
99
- // Tool handler for search_content
100
- async function handleSearchContent(args: Record<string, unknown>) {
196
+ async function handleSearchContent(
197
+ fetchAPI: ReturnType<typeof makeFetchAPI>,
198
+ args: Record<string, unknown>
199
+ ) {
101
200
  const params = new URLSearchParams();
102
201
  for (const [key, value] of Object.entries(args)) {
103
202
  if (value !== null && value !== undefined) {
@@ -133,26 +232,29 @@ async function handleSearchContent(args: Record<string, unknown>) {
133
232
  if (result.type === "OCR") {
134
233
  formattedResults.push(
135
234
  `[OCR] ${content.app_name || "?"} | ${content.window_name || "?"}\n` +
136
- `${content.timestamp || ""}\n` +
137
- `${content.text || ""}`
235
+ `${content.timestamp || ""}\n` +
236
+ `${content.text || ""}`
138
237
  );
139
238
  } else if (result.type === "Audio") {
140
239
  formattedResults.push(
141
240
  `[Audio] ${content.device_name || "?"}\n` +
142
- `${content.timestamp || ""}\n` +
143
- `${content.transcription || ""}`
241
+ `${content.timestamp || ""}\n` +
242
+ `${content.transcription || ""}`
144
243
  );
145
244
  } else if (result.type === "UI" || result.type === "Accessibility") {
146
245
  formattedResults.push(
147
246
  `[Accessibility] ${content.app_name || "?"} | ${content.window_name || "?"}\n` +
148
- `${content.timestamp || ""}\n` +
149
- `${content.text || ""}`
247
+ `${content.timestamp || ""}\n` +
248
+ `${content.text || ""}`
150
249
  );
151
250
  }
152
251
  }
153
252
 
154
- const header = `Results: ${results.length}/${pagination.total || "?"}` +
155
- (pagination.total > results.length ? ` (use offset=${(pagination.offset || 0) + results.length} for more)` : "");
253
+ const header =
254
+ `Results: ${results.length}/${pagination.total || "?"}` +
255
+ (pagination.total > results.length
256
+ ? ` (use offset=${(pagination.offset || 0) + results.length} for more)`
257
+ : "");
156
258
 
157
259
  return {
158
260
  content: [
@@ -164,11 +266,12 @@ async function handleSearchContent(args: Record<string, unknown>) {
164
266
  };
165
267
  }
166
268
 
167
- // Create a fresh MCP Server instance with handlers registered.
269
+ // ── MCP server factory ──────────────────────────────────────────────────
270
+
168
271
  // Each HTTP session gets its own Server — the MCP SDK requires a 1:1
169
272
  // mapping between Server and transport (reusing a Server across
170
273
  // transports throws "Already connected to a transport").
171
- function createMcpServer(): Server {
274
+ function createMcpServer(fetchAPI: ReturnType<typeof makeFetchAPI>): Server {
172
275
  const s = new Server(
173
276
  { name: "screenpipe-http", version: "0.14.0" },
174
277
  { capabilities: { tools: {} } }
@@ -179,65 +282,111 @@ function createMcpServer(): Server {
179
282
  s.setRequestHandler(CallToolRequestSchema, async (request) => {
180
283
  const { name, arguments: args } = request.params;
181
284
  if (!args) throw new Error("Missing arguments");
182
- if (name === "search_content") return handleSearchContent(args);
285
+ if (name === "search_content") return handleSearchContent(fetchAPI, args);
183
286
  throw new Error(`Unknown tool: ${name}`);
184
287
  });
185
288
 
186
289
  return s;
187
290
  }
188
291
 
189
- // Per-session state: each session gets its own Server + transport pair.
190
- const sessions = new Map<string, { server: Server; transport: StreamableHTTPServerTransport }>();
292
+ // ── HTTP server ─────────────────────────────────────────────────────────
191
293
 
192
- const httpServer = createServer(async (req, res) => {
193
- // CORS headers
194
- res.setHeader("Access-Control-Allow-Origin", "*");
195
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
196
- res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, mcp-session-id");
294
+ export function buildHttpServer(config: CliConfig) {
295
+ const fetchAPI = makeFetchAPI(config.screenpipePort);
296
+ const sessions = new Map<
297
+ string,
298
+ { server: Server; transport: StreamableHTTPServerTransport }
299
+ >();
197
300
 
198
- if (req.method === "OPTIONS") {
199
- res.writeHead(204);
200
- res.end();
201
- return;
202
- }
301
+ return createServer(async (req: IncomingMessage, res: ServerResponse) => {
302
+ // CORS
303
+ res.setHeader("Access-Control-Allow-Origin", "*");
304
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
305
+ res.setHeader(
306
+ "Access-Control-Allow-Headers",
307
+ "Content-Type, Authorization, mcp-session-id"
308
+ );
203
309
 
204
- // Health check
205
- if (req.url === "/health") {
206
- res.writeHead(200, { "Content-Type": "application/json" });
207
- res.end(JSON.stringify({ status: "ok", sessions: sessions.size }));
208
- return;
209
- }
310
+ if (req.method === "OPTIONS") {
311
+ res.writeHead(204);
312
+ res.end();
313
+ return;
314
+ }
210
315
 
211
- // MCP endpoint
212
- if (req.url === "/mcp" || req.url?.startsWith("/mcp?")) {
213
- const sessionId = req.headers["mcp-session-id"] as string | undefined;
316
+ // Health check is unauthenticated — monitors / load balancers need it.
317
+ // It only reveals session count, no user data.
318
+ if (req.url === "/health") {
319
+ res.writeHead(200, { "Content-Type": "application/json" });
320
+ res.end(JSON.stringify({ status: "ok", sessions: sessions.size }));
321
+ return;
322
+ }
214
323
 
215
- let session = sessionId ? sessions.get(sessionId) : undefined;
324
+ // Auth gate for everything else.
325
+ if (!isAuthorized(req, config.apiKey)) {
326
+ res.writeHead(401, { "Content-Type": "application/json" });
327
+ res.end(JSON.stringify({ error: "unauthorized" }));
328
+ return;
329
+ }
330
+
331
+ // MCP endpoint
332
+ if (req.url === "/mcp" || req.url?.startsWith("/mcp?")) {
333
+ const sessionId = req.headers["mcp-session-id"] as string | undefined;
334
+ let session = sessionId ? sessions.get(sessionId) : undefined;
216
335
 
217
- if (!session) {
218
- const server = createMcpServer();
219
- const transport = new StreamableHTTPServerTransport({
220
- sessionIdGenerator: () => crypto.randomUUID(),
221
- });
336
+ if (!session) {
337
+ const server = createMcpServer(fetchAPI);
338
+ const transport = new StreamableHTTPServerTransport({
339
+ sessionIdGenerator: () => crypto.randomUUID(),
340
+ });
222
341
 
223
- await server.connect(transport);
342
+ await server.connect(transport);
224
343
 
225
- if (transport.sessionId) {
226
- sessions.set(transport.sessionId, { server, transport });
344
+ if (transport.sessionId) {
345
+ sessions.set(transport.sessionId, { server, transport });
346
+ }
347
+ session = { server, transport };
227
348
  }
228
- session = { server, transport };
349
+
350
+ await session.transport.handleRequest(req, res);
351
+ return;
229
352
  }
230
353
 
231
- await session.transport.handleRequest(req, res);
232
- return;
233
- }
354
+ res.writeHead(404, { "Content-Type": "application/json" });
355
+ res.end(JSON.stringify({ error: "Not found" }));
356
+ });
357
+ }
358
+
359
+ // ── Entry point ─────────────────────────────────────────────────────────
234
360
 
235
- res.writeHead(404, { "Content-Type": "application/json" });
236
- res.end(JSON.stringify({ error: "Not found" }));
237
- });
361
+ // Don't auto-start when imported (e.g. by tests). Compare to argv[1] so
362
+ // `node dist/http-server.js` and `npx ts-node src/http-server.ts` both
363
+ // match, but `import "./http-server"` from a test does not.
364
+ const isMain =
365
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
366
+ (typeof require !== "undefined" && (require as any).main === module) ||
367
+ process.argv[1]?.endsWith("http-server.ts") ||
368
+ process.argv[1]?.endsWith("http-server.js");
238
369
 
239
- httpServer.listen(mcpPort, () => {
240
- console.log(`Screenpipe MCP HTTP server running on http://localhost:${mcpPort}`);
241
- console.log(`MCP endpoint: http://localhost:${mcpPort}/mcp`);
242
- console.log(`Health check: http://localhost:${mcpPort}/health`);
243
- });
370
+ if (isMain) {
371
+ let config: CliConfig;
372
+ try {
373
+ config = parseArgs(process.argv.slice(2));
374
+ } catch (e) {
375
+ if (e instanceof CliError) {
376
+ console.error(e.message);
377
+ process.exit(2);
378
+ }
379
+ throw e;
380
+ }
381
+
382
+ const server = buildHttpServer(config);
383
+ server.listen(config.mcpPort, config.host, () => {
384
+ const printable = config.host === "0.0.0.0" ? "0.0.0.0 (LAN)" : config.host;
385
+ console.log(`Screenpipe MCP HTTP server listening on ${printable}:${config.mcpPort}`);
386
+ console.log(` MCP endpoint: http://${config.host}:${config.mcpPort}/mcp`);
387
+ console.log(` Health check: http://${config.host}:${config.mcpPort}/health`);
388
+ if (config.apiKey) {
389
+ console.log(" Auth required for non-loopback requests (Authorization: Bearer …)");
390
+ }
391
+ });
392
+ }
package/src/index.ts CHANGED
@@ -389,10 +389,10 @@ const TOOLS: Tool[] = [
389
389
  inputSchema: {
390
390
  type: "object",
391
391
  properties: {
392
- speaker_to_keep: { type: "integer", description: "Speaker ID to keep" },
393
- speaker_to_merge: { type: "integer", description: "Speaker ID to merge into the kept one" },
392
+ speaker_to_keep_id: { type: "integer", description: "Speaker ID to keep" },
393
+ speaker_to_merge_id: { type: "integer", description: "Speaker ID to merge into the kept one" },
394
394
  },
395
- required: ["speaker_to_keep", "speaker_to_merge"],
395
+ required: ["speaker_to_keep_id", "speaker_to_merge_id"],
396
396
  },
397
397
  },
398
398
  {
@@ -426,6 +426,32 @@ const TOOLS: Tool[] = [
426
426
  required: ["id"],
427
427
  },
428
428
  },
429
+ {
430
+ name: "update-meeting",
431
+ description:
432
+ "Update a meeting's mutable fields (title, attendees, note, app, start/end). Partial: only the fields you pass are written, " +
433
+ "others stay as-is. Use this to save an AI-generated summary into the meeting note — read the current note first via get-meeting " +
434
+ "and pass the existing notes plus your additions so you don't overwrite the user's writing. " +
435
+ "Convention: append AI-generated summary text under a `## Summary` heading at the bottom of the existing note.",
436
+ annotations: { title: "Update Meeting", readOnlyHint: false, destructiveHint: false, openWorldHint: false, idempotentHint: true },
437
+ inputSchema: {
438
+ type: "object",
439
+ properties: {
440
+ id: { type: "integer", description: "Meeting ID" },
441
+ title: { type: "string", description: "Meeting title" },
442
+ attendees: { type: "string", description: "Comma-separated attendee names" },
443
+ note: {
444
+ type: "string",
445
+ description:
446
+ "Full new note body. To preserve existing notes, fetch them first via get-meeting and concatenate before passing.",
447
+ },
448
+ meeting_app: { type: "string", description: "App / source name (e.g. 'meet.google.com', 'manual')" },
449
+ meeting_start: { type: "string", description: "ISO 8601 start time (rarely needed)" },
450
+ meeting_end: { type: "string", description: "ISO 8601 end time (rarely needed)" },
451
+ },
452
+ required: ["id"],
453
+ },
454
+ },
429
455
  {
430
456
  name: "keyword-search",
431
457
  description:
@@ -1244,14 +1270,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1244
1270
  }
1245
1271
 
1246
1272
  case "merge-speakers": {
1247
- const keepId = args.speaker_to_keep as number;
1248
- const mergeId = args.speaker_to_merge as number;
1273
+ const keepId = args.speaker_to_keep_id as number;
1274
+ const mergeId = args.speaker_to_merge_id as number;
1249
1275
  if (!keepId || !mergeId) {
1250
- return { content: [{ type: "text", text: "Error: speaker_to_keep and speaker_to_merge are required" }] };
1276
+ return { content: [{ type: "text", text: "Error: speaker_to_keep_id and speaker_to_merge_id are required" }] };
1251
1277
  }
1252
1278
  const response = await fetchAPI("/speakers/merge", {
1253
1279
  method: "POST",
1254
- body: JSON.stringify({ speaker_to_keep: keepId, speaker_to_merge: mergeId }),
1280
+ body: JSON.stringify({ speaker_to_keep_id: keepId, speaker_to_merge_id: mergeId }),
1255
1281
  });
1256
1282
  if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
1257
1283
  return {
@@ -1296,6 +1322,38 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1296
1322
  };
1297
1323
  }
1298
1324
 
1325
+ case "update-meeting": {
1326
+ const meetingId = args.id as number;
1327
+ if (!meetingId) {
1328
+ return { content: [{ type: "text", text: "Error: id is required" }] };
1329
+ }
1330
+ // Build partial body — only forward fields the caller provided.
1331
+ const body: Record<string, unknown> = {};
1332
+ for (const k of ["title", "attendees", "note", "meeting_app", "meeting_start", "meeting_end"] as const) {
1333
+ if (args[k] !== undefined && args[k] !== null) body[k] = args[k];
1334
+ }
1335
+ if (Object.keys(body).length === 0) {
1336
+ return {
1337
+ content: [
1338
+ {
1339
+ type: "text",
1340
+ text: "Error: pass at least one field to update (title, attendees, note, meeting_app, meeting_start, meeting_end).",
1341
+ },
1342
+ ],
1343
+ };
1344
+ }
1345
+ const response = await fetchAPI(`/meetings/${meetingId}`, {
1346
+ method: "PATCH",
1347
+ headers: { "Content-Type": "application/json" },
1348
+ body: JSON.stringify(body),
1349
+ });
1350
+ if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
1351
+ const updated = await response.json();
1352
+ return {
1353
+ content: [{ type: "text", text: JSON.stringify(updated, null, 2) }],
1354
+ };
1355
+ }
1356
+
1299
1357
  case "keyword-search": {
1300
1358
  const params = new URLSearchParams();
1301
1359
  for (const [key, value] of Object.entries(args)) {
Binary file