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/bun.lock +0 -10
- package/dist/export-video.test.js +79 -271
- package/dist/index.js +74 -110
- package/manifest.json +1 -1
- package/package.json +2 -4
- package/src/export-video.test.ts +89 -317
- package/src/index.ts +84 -126
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
|
|
393
|
-
"
|
|
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:
|
|
399
|
-
end_time: { type: "string", description:
|
|
400
|
-
|
|
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
|
|
694
|
-
"
|
|
695
|
-
"
|
|
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: {
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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
|
-
//
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
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:
|
|
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: "
|
|
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.
|