screenpipe-mcp 0.18.8 → 0.18.10

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/src/index.ts CHANGED
@@ -12,7 +12,6 @@ import {
12
12
  ReadResourceRequestSchema,
13
13
  Tool,
14
14
  } from "@modelcontextprotocol/sdk/types.js";
15
- import { WebSocket } from "ws";
16
15
  import * as fs from "fs";
17
16
  import * as path from "path";
18
17
  import * as os from "os";
@@ -389,15 +388,22 @@ const TOOLS: Tool[] = [
389
388
  {
390
389
  name: "export-video",
391
390
  description:
392
- "Export an MP4 video of screen recordings for a time range. " +
393
- "Returns the file path. Can take a few minutes for long ranges.",
391
+ "Export an MP4 of screen recordings for a time range, with synced microphone audio. " +
392
+ "Frames are placed at their real timestamps, so the clip's duration matches the " +
393
+ "wall-clock span you requested (not a sped-up timelapse). Returns the file path. " +
394
+ "Can take a few minutes for long ranges.",
394
395
  annotations: { title: "Export Video", readOnlyHint: false, destructiveHint: false, openWorldHint: false },
395
396
  inputSchema: {
396
397
  type: "object",
397
398
  properties: {
398
- start_time: { type: "string", description: "ISO 8601 UTC or relative" },
399
- end_time: { type: "string", description: "ISO 8601 UTC or relative" },
400
- fps: { type: "number", description: "Output FPS (default 1.0). Higher = smoother but larger file.", default: 1.0 },
399
+ start_time: { type: "string", description: 'ISO 8601 UTC or relative (e.g. "5m ago", "now")' },
400
+ end_time: { type: "string", description: 'ISO 8601 UTC or relative (e.g. "5m ago", "now")' },
401
+ output_path: {
402
+ type: "string",
403
+ description:
404
+ "Optional absolute path for the MP4 (e.g. ~/Downloads/clip.mp4). " +
405
+ "Defaults to the screenpipe data dir's exports/ folder.",
406
+ },
401
407
  },
402
408
  required: ["start_time", "end_time"],
403
409
  },
@@ -690,25 +696,50 @@ const TEAM_TOOLS: Tool[] = [
690
696
  {
691
697
  name: "team-records",
692
698
  description:
693
- "Chronological raw dump of the org's telemetry for a time window. " +
694
- "Returns oldest newest (vs team-search which is recency-ranked). " +
695
- "Use for ETL or \"walk me through X from Y to Z\" — NOT for question-answering, use team-search for that. " +
699
+ "Chronological dump of the org's data for a time window — both raw " +
700
+ "telemetry (frame/audio) and the structured outputs of the enterprise-" +
701
+ "worker pipes (sop/skill/trajectory/memory/workflow). " +
702
+ "Raw kinds return oldest → newest (vs team-search which is recency-ranked). " +
703
+ "Synthesized kinds return one record per device's latest run by default " +
704
+ "(set latest_only=false to walk run history). " +
705
+ "Use raw for ETL / \"walk me through X from Y to Z\". " +
706
+ "Use synthesized for \"what SOPs / skills / trajectories / memories did " +
707
+ "we extract from my team's work\" — each item carries evidence-cited " +
708
+ "event_ids/frame_ids that team-search can resolve back to raw records. " +
696
709
  "Auth: enterprise admin token.",
697
710
  annotations: { title: "Team Records", readOnlyHint: true, openWorldHint: true, idempotentHint: true },
698
711
  inputSchema: {
699
712
  type: "object",
700
713
  properties: {
701
- device_id: { type: "string", description: "Restrict to one device (optional)." },
702
- kind: { type: "string", enum: ["frame", "audio", "all"], description: "Record kind filter. Default: all.", default: "all" },
703
- since: { type: "string", description: "ISO 8601 lower bound." },
704
- until: { type: "string", description: "ISO 8601 upper bound." },
705
- since_hours_ago: { type: "integer", description: "Convenience: equivalent to since=now-N*h." },
706
- limit: { type: "integer", description: "Max records (default 50, max 200).", default: 50 },
714
+ device_id: { type: "string", description: "Restrict to one device (optional). Raw kinds only." },
715
+ kind: {
716
+ type: "string",
717
+ enum: ["frame", "audio", "all", "sop", "skill", "trajectory", "memory", "workflow"],
718
+ description:
719
+ "What to return. Raw: frame|audio|all (telemetry). " +
720
+ "Synthesized: sop|skill|trajectory|memory|workflow (pipe outputs). " +
721
+ "Default: all.",
722
+ default: "all",
723
+ },
724
+ since: { type: "string", description: "ISO 8601 lower bound. Raw kinds only." },
725
+ until: { type: "string", description: "ISO 8601 upper bound. Raw kinds only." },
726
+ since_hours_ago: { type: "integer", description: "Convenience: equivalent to since=now-N*h. Raw kinds only." },
727
+ limit: { type: "integer", description: "Max records (default 50, max 200). Raw kinds only.", default: 50 },
728
+ latest_only: {
729
+ type: "boolean",
730
+ description:
731
+ "Synthesized kinds only: if true (default), collapse to the newest " +
732
+ "run per device. Set false to walk run history.",
733
+ default: true,
734
+ },
707
735
  },
708
736
  },
709
737
  },
710
738
  ];
711
739
 
740
+ // Pipe-output kinds map to /workflows/generated, raw kinds map to /records.
741
+ const SYNTHESIZED_KINDS = new Set(["sop", "skill", "trajectory", "memory", "workflow"]);
742
+
712
743
  server.setRequestHandler(ListToolsRequestSchema, async () => {
713
744
  // Team tools only surface when an enterprise token was discovered at boot.
714
745
  // No token = consumer / non-admin user; their MCP looks identical to today.
@@ -1308,7 +1339,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1308
1339
  case "export-video": {
1309
1340
  const startTime = normalizeTime(args.start_time as string);
1310
1341
  const endTime = normalizeTime(args.end_time as string);
1311
- const fps = (args.fps as number) || 1.0;
1312
1342
 
1313
1343
  if (!startTime || !endTime) {
1314
1344
  return {
@@ -1316,128 +1346,51 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1316
1346
  };
1317
1347
  }
1318
1348
 
1319
- // Get frame IDs for the time range
1320
- const searchParams = new URLSearchParams({
1321
- content_type: "ocr",
1322
- start_time: startTime,
1323
- end_time: endTime,
1324
- limit: "10000",
1325
- });
1326
-
1327
- const searchResponse = await callAPI(`/search?${searchParams.toString()}`);
1328
- const searchData = await searchResponse.json();
1329
- const results = searchData.data || [];
1330
-
1331
- if (results.length === 0) {
1349
+ // A real-time MP4 with synced microphone audio, rendered server-side by the
1350
+ // engine export core (the `screenpipe export` CLI's HTTP twin). MCP runs on the
1351
+ // same host as the backend, so the returned path is a local file. Frames sit at
1352
+ // their real timestamps, so the clip duration matches the wall-clock span.
1353
+ try {
1354
+ const body: Record<string, unknown> = { start: startTime, end: endTime };
1355
+ if (typeof args.output_path === "string" && args.output_path.trim()) {
1356
+ body.output_path = args.output_path;
1357
+ }
1358
+ const response = await callAPI("/export", {
1359
+ method: "POST",
1360
+ body: JSON.stringify(body),
1361
+ });
1362
+ const data = (await response.json()) as {
1363
+ output_path: string;
1364
+ frame_count: number;
1365
+ audio_chunk_count: number;
1366
+ duration_secs: number;
1367
+ file_size_bytes: number;
1368
+ };
1369
+ const sizeMb = data.file_size_bytes
1370
+ ? (data.file_size_bytes / (1024 * 1024)).toFixed(1)
1371
+ : null;
1332
1372
  return {
1333
1373
  content: [
1334
1374
  {
1335
1375
  type: "text",
1336
- text: `No screen recordings found between ${startTime} and ${endTime}.`,
1376
+ text:
1377
+ `Video exported (with audio): ${data.output_path}\n` +
1378
+ `${data.frame_count ?? 0} frames | ${data.audio_chunk_count ?? 0} audio chunks` +
1379
+ (sizeMb ? ` | ${sizeMb} MB` : "") +
1380
+ (data.duration_secs ? ` | ${data.duration_secs}s` : "") +
1381
+ ` | ${startTime} → ${endTime}`,
1337
1382
  },
1338
1383
  ],
1339
1384
  };
1340
- }
1341
-
1342
- const frameIds: number[] = [];
1343
- const seenIds = new Set<number>();
1344
- for (const result of results) {
1345
- if (result.type === "OCR" && result.content?.frame_id) {
1346
- const frameId = result.content.frame_id;
1347
- if (!seenIds.has(frameId)) {
1348
- seenIds.add(frameId);
1349
- frameIds.push(frameId);
1350
- }
1351
- }
1352
- }
1353
-
1354
- if (frameIds.length === 0) {
1355
- return {
1356
- content: [{ type: "text", text: "No valid frame IDs found (audio-only?)." }],
1357
- };
1358
- }
1359
-
1360
- frameIds.sort((a, b) => a - b);
1361
-
1362
- const wsUrl = `ws://localhost:${port}/frames/export?fps=${fps}`;
1363
-
1364
- const exportResult = await new Promise<{
1365
- success: boolean;
1366
- filePath?: string;
1367
- error?: string;
1368
- frameCount?: number;
1369
- }>((resolve) => {
1370
- const ws = new WebSocket(wsUrl);
1371
- let resolved = false;
1372
-
1373
- const timeout = setTimeout(() => {
1374
- if (!resolved) {
1375
- resolved = true;
1376
- ws.close();
1377
- resolve({ success: false, error: "Export timed out after 5 minutes" });
1378
- }
1379
- }, 5 * 60 * 1000);
1380
-
1381
- ws.on("open", () => {
1382
- ws.send(JSON.stringify({ frame_ids: frameIds }));
1383
- });
1384
-
1385
- ws.on("error", (error) => {
1386
- if (!resolved) {
1387
- resolved = true;
1388
- clearTimeout(timeout);
1389
- resolve({ success: false, error: `WebSocket error: ${error.message}` });
1390
- }
1391
- });
1392
-
1393
- ws.on("close", () => {
1394
- if (!resolved) {
1395
- resolved = true;
1396
- clearTimeout(timeout);
1397
- resolve({ success: false, error: "Connection closed unexpectedly" });
1398
- }
1399
- });
1400
-
1401
- ws.on("message", (data) => {
1402
- try {
1403
- const message = JSON.parse(data.toString());
1404
- if (message.status === "completed" && message.video_data) {
1405
- const tempDir = os.tmpdir();
1406
- const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
1407
- const filename = `screenpipe_export_${timestamp}.mp4`;
1408
- const filePath = path.join(tempDir, filename);
1409
- fs.writeFileSync(filePath, Buffer.from(message.video_data));
1410
- resolved = true;
1411
- clearTimeout(timeout);
1412
- ws.close();
1413
- resolve({ success: true, filePath, frameCount: frameIds.length });
1414
- } else if (message.status === "error") {
1415
- resolved = true;
1416
- clearTimeout(timeout);
1417
- ws.close();
1418
- resolve({ success: false, error: message.error || "Export failed" });
1419
- }
1420
- } catch {
1421
- // Ignore parse errors for progress messages
1422
- }
1423
- });
1424
- });
1425
-
1426
- if (exportResult.success && exportResult.filePath) {
1385
+ } catch (err) {
1427
1386
  return {
1428
1387
  content: [
1429
1388
  {
1430
1389
  type: "text",
1431
- text:
1432
- `Video exported: ${exportResult.filePath}\n` +
1433
- `Frames: ${exportResult.frameCount} | ${startTime} → ${endTime} | ${fps} fps`,
1390
+ text: `Export failed: ${err instanceof Error ? err.message : String(err)}`,
1434
1391
  },
1435
1392
  ],
1436
1393
  };
1437
- } else {
1438
- return {
1439
- content: [{ type: "text", text: `Export failed: ${exportResult.error}` }],
1440
- };
1441
1394
  }
1442
1395
  }
1443
1396
 
@@ -1695,7 +1648,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1695
1648
  };
1696
1649
  }
1697
1650
  const response = await callAPI(`/meetings/${meetingId}`, {
1698
- method: "PATCH",
1651
+ method: "PUT",
1699
1652
  headers: { "Content-Type": "application/json" },
1700
1653
  body: JSON.stringify(body),
1701
1654
  });
@@ -1820,10 +1773,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1820
1773
  ],
1821
1774
  };
1822
1775
  }
1823
- // Map MCP tool name → /api/enterprise/v1 path
1776
+ // Map MCP tool name → /api/enterprise/v1 path. team-records also
1777
+ // routes synthesized pipe outputs (kind=sop|skill|...) to the
1778
+ // workflows endpoint so callers see one tool surface for "give me
1779
+ // the org's data."
1780
+ const kindArg = typeof args.kind === "string" ? args.kind : "";
1824
1781
  const subpath =
1825
1782
  name === "team-search" ? "/search"
1826
1783
  : name === "team-devices" ? "/devices"
1784
+ : name === "team-records" && SYNTHESIZED_KINDS.has(kindArg) ? "/workflows/generated"
1827
1785
  : "/records";
1828
1786
  // Forward every primitive arg as a query param. The server validates;
1829
1787
  // unknown params are ignored, so we don't need to gatekeep here.