screenpipe-mcp 0.18.2 → 0.18.4
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/dist/index.js +434 -130
- package/package.json +1 -1
- package/src/index.ts +441 -111
package/dist/index.js
CHANGED
|
@@ -214,6 +214,49 @@ function discoverApiKey() {
|
|
|
214
214
|
return "";
|
|
215
215
|
}
|
|
216
216
|
const API_KEY = discoverApiKey();
|
|
217
|
+
// Enterprise team token — when present, this MCP additionally registers
|
|
218
|
+
// `team-*` tools that query the org-wide telemetry control plane
|
|
219
|
+
// (https://screenpi.pe/api/enterprise/v1/*) instead of just the local
|
|
220
|
+
// recordings. Same audience: an enterprise admin running screenpipe-mcp
|
|
221
|
+
// inside Claude Desktop / Cursor / Windsurf wants to ask "what did MY
|
|
222
|
+
// machine do" AND "what did MY TEAM do" without juggling two MCPs.
|
|
223
|
+
//
|
|
224
|
+
// Resolution order matches discoverApiKey() in spirit:
|
|
225
|
+
// 1. SCREENPIPE_ENTERPRISE_TOKEN env var (Claude config, terminal)
|
|
226
|
+
// 2. team_api_token field in ~/.screenpipe/enterprise.json (written by
|
|
227
|
+
// the desktop app's Settings → Privacy → Admin Team API Token)
|
|
228
|
+
//
|
|
229
|
+
// Token format is `sk_ent_…`. Empty / missing → team tools are not
|
|
230
|
+
// registered; non-admin users of screenpipe-mcp see exactly what they
|
|
231
|
+
// see today.
|
|
232
|
+
function discoverTeamToken() {
|
|
233
|
+
const envTok = process.env.SCREENPIPE_ENTERPRISE_TOKEN;
|
|
234
|
+
if (envTok && envTok.startsWith("sk_ent_"))
|
|
235
|
+
return envTok;
|
|
236
|
+
try {
|
|
237
|
+
const entPath = path.join(os.homedir(), ".screenpipe", "enterprise.json");
|
|
238
|
+
if (fs.existsSync(entPath)) {
|
|
239
|
+
const raw = fs.readFileSync(entPath, "utf-8");
|
|
240
|
+
const parsed = JSON.parse(raw);
|
|
241
|
+
const tok = typeof parsed?.team_api_token === "string" ? parsed.team_api_token : "";
|
|
242
|
+
if (tok && tok.startsWith("sk_ent_"))
|
|
243
|
+
return tok;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
catch { }
|
|
247
|
+
return "";
|
|
248
|
+
}
|
|
249
|
+
const TEAM_TOKEN = discoverTeamToken();
|
|
250
|
+
const TEAM_API = "https://screenpi.pe/api/enterprise/v1";
|
|
251
|
+
async function fetchTeam(p, init = {}) {
|
|
252
|
+
return fetch(`${TEAM_API}${p}`, {
|
|
253
|
+
...init,
|
|
254
|
+
headers: {
|
|
255
|
+
Authorization: `Bearer ${TEAM_TOKEN}`,
|
|
256
|
+
...(init.headers || {}),
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
}
|
|
217
260
|
// Read version from package.json (single source of truth)
|
|
218
261
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
219
262
|
const PKG_VERSION = require("../package.json").version;
|
|
@@ -233,11 +276,11 @@ const server = new index_js_1.Server({
|
|
|
233
276
|
const TOOLS = [
|
|
234
277
|
{
|
|
235
278
|
name: "search-content",
|
|
236
|
-
description: "Search screen text, audio transcriptions, input events, and memories. " +
|
|
237
|
-
"
|
|
238
|
-
"
|
|
239
|
-
"
|
|
240
|
-
"Start with limit=5, increase only if needed.
|
|
279
|
+
description: "Search screen text, audio transcriptions, input events, and memories. Returns timestamped results with app context. " +
|
|
280
|
+
"USE WHEN: you need the actual text/content of a moment — quotes, OCR snippets, transcript lines — or want to filter by speaker/window. " +
|
|
281
|
+
"DO NOT USE for: broad questions like 'what was I doing?' (use activity-summary, it pre-summarizes apps + windows + transcripts). " +
|
|
282
|
+
"Also DO NOT USE for: targeted UI controls (use search-elements). " +
|
|
283
|
+
"Start with limit=5, increase only if needed. Per-result text is auto-truncated to 1000 chars; pass max_content_length=0 to opt out, or a custom integer to override.",
|
|
241
284
|
annotations: { title: "Search Content", readOnlyHint: true, openWorldHint: false, idempotentHint: true },
|
|
242
285
|
inputSchema: {
|
|
243
286
|
type: "object",
|
|
@@ -249,14 +292,14 @@ const TOOLS = [
|
|
|
249
292
|
content_type: {
|
|
250
293
|
type: "string",
|
|
251
294
|
enum: ["all", "ocr", "audio", "input", "accessibility", "memory"],
|
|
252
|
-
description: "Filter by content type. '
|
|
295
|
+
description: "Filter by content type. NOTE on screen text: 'ocr' is a legacy label — it returns ALL screen-text rows, which are accessibility-derived for most apps (the result tag [Screen·a11y] vs [Screen·ocr] tells you which). Use 'ocr' for screen text (covers both paths), 'audio' for transcriptions, 'input' for keyboard/mouse events, 'memory' for stored facts. Default: 'all'.",
|
|
253
296
|
default: "all",
|
|
254
297
|
},
|
|
255
298
|
limit: { type: "integer", description: "Max results (default 10, max 20). Start with 5 for exploration.", default: 10 },
|
|
256
299
|
offset: { type: "integer", description: "Pagination offset. Use when results say 'use offset=N for more'.", default: 0 },
|
|
257
300
|
start_time: {
|
|
258
301
|
type: "string",
|
|
259
|
-
description: "ISO 8601
|
|
302
|
+
description: "Accepted: ISO 8601 ('2024-01-15T10:00:00Z'), 'Nh ago' / 'Nd ago' / 'Nw ago', 'now', 'yesterday', 'today', or bare 'YYYY-MM-DD'. Always provide to avoid scanning entire history.",
|
|
260
303
|
},
|
|
261
304
|
end_time: {
|
|
262
305
|
type: "string",
|
|
@@ -298,9 +341,9 @@ const TOOLS = [
|
|
|
298
341
|
{
|
|
299
342
|
name: "activity-summary",
|
|
300
343
|
description: "Rich activity overview: app usage, window/tab titles with URLs and time spent, key text per context, audio transcriptions. " +
|
|
301
|
-
"USE
|
|
302
|
-
"
|
|
303
|
-
"
|
|
344
|
+
"USE WHEN: any broad question about what the user did — 'what was I doing?', 'how long on X?', 'which apps?', 'recap my morning'. " +
|
|
345
|
+
"This is almost always the right first call for time-range questions — usually sufficient without follow-up searches. " +
|
|
346
|
+
"DO NOT USE for: finding a specific keyword (use keyword-search) or a specific UI control (use search-elements).",
|
|
304
347
|
annotations: { title: "Activity Summary", readOnlyHint: true, openWorldHint: false, idempotentHint: true },
|
|
305
348
|
inputSchema: {
|
|
306
349
|
type: "object",
|
|
@@ -314,9 +357,9 @@ const TOOLS = [
|
|
|
314
357
|
},
|
|
315
358
|
{
|
|
316
359
|
name: "search-elements",
|
|
317
|
-
description: "Search UI elements (buttons, links, text fields) from the accessibility tree. " +
|
|
318
|
-
"
|
|
319
|
-
"
|
|
360
|
+
description: "Search UI elements (buttons, links, text fields) from the accessibility tree, filterable by role. " +
|
|
361
|
+
"USE WHEN: you want a specific UI control or page-structure question — 'find every Submit button I saw', 'list the links in that page'. " +
|
|
362
|
+
"DO NOT USE for: general text/content (use search-content) or fast keyword lookup (use keyword-search).",
|
|
320
363
|
annotations: { title: "Search Elements", readOnlyHint: true, openWorldHint: false, idempotentHint: true },
|
|
321
364
|
inputSchema: {
|
|
322
365
|
type: "object",
|
|
@@ -555,19 +598,21 @@ const TOOLS = [
|
|
|
555
598
|
},
|
|
556
599
|
{
|
|
557
600
|
name: "keyword-search",
|
|
558
|
-
description: "Fast keyword search
|
|
559
|
-
"
|
|
601
|
+
description: "Fast FTS5 keyword search across OCR + audio combined. Returns matches with frame_id, app, timestamp, and text positions. " +
|
|
602
|
+
"USE WHEN: you have a specific keyword/phrase and want the fastest hit-list (e.g. 'find every screen where I typed \"stripe\"'). " +
|
|
603
|
+
"DO NOT USE for: structured filters by content_type / speaker / window — this endpoint ignores those (use search-content instead). " +
|
|
604
|
+
"DO NOT USE for: broad questions like 'what was I doing' (use activity-summary).",
|
|
560
605
|
annotations: { title: "Keyword Search", readOnlyHint: true, openWorldHint: false, idempotentHint: true },
|
|
561
606
|
inputSchema: {
|
|
562
607
|
type: "object",
|
|
563
608
|
properties: {
|
|
564
|
-
q: { type: "string", description: "Keyword
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
app_name: { type: "string", description: "Filter by app name" },
|
|
609
|
+
q: { type: "string", description: "Keyword query (FTS5 syntax: quoted phrases, AND/OR, prefix*)" },
|
|
610
|
+
start_time: { type: "string", description: "ISO 8601 UTC, 'Nh ago' / 'Nd ago' / 'Nw ago', 'now', 'yesterday', 'today', or 'YYYY-MM-DD'" },
|
|
611
|
+
end_time: { type: "string", description: "Same formats as start_time" },
|
|
612
|
+
app_name: { type: "string", description: "Filter by exact app name (case-sensitive, e.g. 'Google Chrome')" },
|
|
569
613
|
limit: { type: "integer", description: "Max results (default 20)", default: 20 },
|
|
570
614
|
offset: { type: "integer", description: "Pagination offset", default: 0 },
|
|
615
|
+
fuzzy_match: { type: "boolean", description: "Enable typo-tolerant matching", default: false },
|
|
571
616
|
},
|
|
572
617
|
required: ["q"],
|
|
573
618
|
},
|
|
@@ -597,8 +642,70 @@ const TOOLS = [
|
|
|
597
642
|
},
|
|
598
643
|
},
|
|
599
644
|
];
|
|
645
|
+
// ---------------------------------------------------------------------------
|
|
646
|
+
// Enterprise team tools — registered only when a team API token is present.
|
|
647
|
+
// Same endpoint surface as the desktop `screenpipe-team` pi-agent skill:
|
|
648
|
+
// proxy GETs to https://screenpi.pe/api/enterprise/v1/* with Bearer auth.
|
|
649
|
+
//
|
|
650
|
+
// Naming convention: every team tool is `team-*` so it's obvious at a glance
|
|
651
|
+
// which scope (just-me vs the-whole-org) any given call is hitting.
|
|
652
|
+
// ---------------------------------------------------------------------------
|
|
653
|
+
const TEAM_TOOLS = [
|
|
654
|
+
{
|
|
655
|
+
name: "team-search",
|
|
656
|
+
description: "Substring-search across the ENTIRE ORG's telemetry (every enrolled " +
|
|
657
|
+
"device). Use when the question is about the team or another teammate " +
|
|
658
|
+
"(\"what did engineering work on yesterday\", \"did alice touch the auth code\"). " +
|
|
659
|
+
"For your own machine only, use search-content. " +
|
|
660
|
+
"Auth: enterprise admin token (sk_ent_…). " +
|
|
661
|
+
"Defaults: since=now-24h, limit=50. Returns matched records with device + timestamp.",
|
|
662
|
+
annotations: { title: "Team Search", readOnlyHint: true, openWorldHint: true, idempotentHint: true },
|
|
663
|
+
inputSchema: {
|
|
664
|
+
type: "object",
|
|
665
|
+
properties: {
|
|
666
|
+
q: { type: "string", description: "Substring to match (case-insensitive). Empty = all records in window." },
|
|
667
|
+
device_id: { type: "string", description: "Restrict to one device. Get the ID from team-devices." },
|
|
668
|
+
app_name: { type: "string", description: "Restrict to records whose app_name equals this (case-insensitive)." },
|
|
669
|
+
since: { type: "string", description: "ISO 8601 lower bound. Default = now - 24h." },
|
|
670
|
+
until: { type: "string", description: "ISO 8601 upper bound. Default = now." },
|
|
671
|
+
since_hours_ago: { type: "integer", description: "Convenience: equivalent to since=now-N*h." },
|
|
672
|
+
limit: { type: "integer", description: "Max records (default 50, max 200).", default: 50 },
|
|
673
|
+
},
|
|
674
|
+
},
|
|
675
|
+
},
|
|
676
|
+
{
|
|
677
|
+
name: "team-devices",
|
|
678
|
+
description: "List all devices enrolled under this org's license — hostname, OS, " +
|
|
679
|
+
"app version, last-seen timestamp. Use to discover device IDs to pass " +
|
|
680
|
+
"to team-search or team-records, or to spot stale machines.",
|
|
681
|
+
annotations: { title: "Team Devices", readOnlyHint: true, openWorldHint: true, idempotentHint: true },
|
|
682
|
+
inputSchema: { type: "object", properties: {} },
|
|
683
|
+
},
|
|
684
|
+
{
|
|
685
|
+
name: "team-records",
|
|
686
|
+
description: "Chronological raw dump of the org's telemetry for a time window. " +
|
|
687
|
+
"Returns oldest → newest (vs team-search which is recency-ranked). " +
|
|
688
|
+
"Use for ETL or \"walk me through X from Y to Z\" — NOT for question-answering, use team-search for that. " +
|
|
689
|
+
"Auth: enterprise admin token.",
|
|
690
|
+
annotations: { title: "Team Records", readOnlyHint: true, openWorldHint: true, idempotentHint: true },
|
|
691
|
+
inputSchema: {
|
|
692
|
+
type: "object",
|
|
693
|
+
properties: {
|
|
694
|
+
device_id: { type: "string", description: "Restrict to one device (optional)." },
|
|
695
|
+
kind: { type: "string", enum: ["frame", "audio", "all"], description: "Record kind filter. Default: all.", default: "all" },
|
|
696
|
+
since: { type: "string", description: "ISO 8601 lower bound." },
|
|
697
|
+
until: { type: "string", description: "ISO 8601 upper bound." },
|
|
698
|
+
since_hours_ago: { type: "integer", description: "Convenience: equivalent to since=now-N*h." },
|
|
699
|
+
limit: { type: "integer", description: "Max records (default 50, max 200).", default: 50 },
|
|
700
|
+
},
|
|
701
|
+
},
|
|
702
|
+
},
|
|
703
|
+
];
|
|
600
704
|
server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => {
|
|
601
|
-
|
|
705
|
+
// Team tools only surface when an enterprise token was discovered at boot.
|
|
706
|
+
// No token = consumer / non-admin user; their MCP looks identical to today.
|
|
707
|
+
const tools = TEAM_TOKEN ? [...TOOLS, ...TEAM_TOOLS] : TOOLS;
|
|
708
|
+
return { tools };
|
|
602
709
|
});
|
|
603
710
|
// ---------------------------------------------------------------------------
|
|
604
711
|
// Resources — dynamic context only (no duplicated reference docs)
|
|
@@ -676,7 +783,7 @@ server.setRequestHandler(types_js_1.ReadResourceRequestSchema, async (request) =
|
|
|
676
783
|
- **Use max_content_length=500** to keep responses compact
|
|
677
784
|
- **Don't use q for audio** — transcriptions are noisy, q filters too aggressively. Search audio by time range and speaker instead
|
|
678
785
|
- **app_name is case-sensitive** — use exact names: "Google Chrome" not "chrome"
|
|
679
|
-
- **
|
|
786
|
+
- **Screen text is mostly accessibility-derived, not OCR.** Screenpipe walks the OS accessibility tree first; OCR is only a fallback (terminals, canvas-rendered apps, games). \`content_type=ocr\` returns both paths — the result label \`[Screen·a11y]\` vs \`[Screen·ocr]\` tells you which produced the row. Don't pre-filter to a11y/ocr unless you specifically need one or the other
|
|
680
787
|
|
|
681
788
|
## Common Patterns
|
|
682
789
|
|
|
@@ -700,18 +807,151 @@ Never fabricate IDs or timestamps — only use values from actual results.
|
|
|
700
807
|
throw new Error(`Unknown resource: ${uri}`);
|
|
701
808
|
});
|
|
702
809
|
// ---------------------------------------------------------------------------
|
|
703
|
-
//
|
|
810
|
+
// Helpers
|
|
704
811
|
// ---------------------------------------------------------------------------
|
|
812
|
+
// Thrown by fetchAPI / callAPI when the backend is unreachable. Caught in the
|
|
813
|
+
// tool dispatcher to surface an actionable hint ("backend not running")
|
|
814
|
+
// instead of the opaque "fetch failed" the model used to see.
|
|
815
|
+
class BackendDownError extends Error {
|
|
816
|
+
cause;
|
|
817
|
+
constructor(cause) {
|
|
818
|
+
super(`screenpipe backend not running on ${SCREENPIPE_API}. ` +
|
|
819
|
+
`Start it with \`screenpipe\` in a terminal, or open the screenpipe desktop app.`);
|
|
820
|
+
this.cause = cause;
|
|
821
|
+
this.name = "BackendDownError";
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
// Thrown when the backend returns a non-2xx. Carries the server's response
|
|
825
|
+
// body so the dispatcher can include it in the user-visible error message.
|
|
826
|
+
class BackendHttpError extends Error {
|
|
827
|
+
status;
|
|
828
|
+
bodyText;
|
|
829
|
+
constructor(status, bodyText, endpoint) {
|
|
830
|
+
let hint = "";
|
|
831
|
+
if (status === 401 || status === 403) {
|
|
832
|
+
hint =
|
|
833
|
+
" — API key not accepted. Set SCREENPIPE_LOCAL_API_KEY in your MCP " +
|
|
834
|
+
"launcher env, or install the screenpipe desktop app so the MCP can " +
|
|
835
|
+
"discover the key automatically.";
|
|
836
|
+
}
|
|
837
|
+
else if (status === 404) {
|
|
838
|
+
hint =
|
|
839
|
+
" — endpoint not found. The backend may be on a different version than this MCP.";
|
|
840
|
+
}
|
|
841
|
+
else if (status === 400) {
|
|
842
|
+
hint = " — bad request. Check argument names and types against the tool schema.";
|
|
843
|
+
}
|
|
844
|
+
else if (status >= 500) {
|
|
845
|
+
hint = " — backend error. Check screenpipe logs.";
|
|
846
|
+
}
|
|
847
|
+
const trimmed = bodyText.trim().slice(0, 300);
|
|
848
|
+
const bodyPart = trimmed ? ` body: ${trimmed}` : "";
|
|
849
|
+
super(`HTTP ${status} from ${endpoint}${hint}${bodyPart}`);
|
|
850
|
+
this.status = status;
|
|
851
|
+
this.bodyText = bodyText;
|
|
852
|
+
this.name = "BackendHttpError";
|
|
853
|
+
}
|
|
854
|
+
}
|
|
705
855
|
async function fetchAPI(endpoint, options = {}) {
|
|
706
856
|
const url = `${SCREENPIPE_API}${endpoint}`;
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
857
|
+
try {
|
|
858
|
+
return await fetch(url, {
|
|
859
|
+
...options,
|
|
860
|
+
headers: {
|
|
861
|
+
"Content-Type": "application/json",
|
|
862
|
+
...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}),
|
|
863
|
+
...options.headers,
|
|
864
|
+
},
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
catch (e) {
|
|
868
|
+
throw new BackendDownError(e);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
// Wrap a fetchAPI call: throw BackendHttpError on non-2xx with body included.
|
|
872
|
+
// Use from handlers instead of `if (!response.ok) throw new Error(...)`.
|
|
873
|
+
async function callAPI(endpoint, options = {}) {
|
|
874
|
+
const response = await fetchAPI(endpoint, options);
|
|
875
|
+
if (!response.ok) {
|
|
876
|
+
let body = "";
|
|
877
|
+
try {
|
|
878
|
+
body = await response.text();
|
|
879
|
+
}
|
|
880
|
+
catch {
|
|
881
|
+
// body may not be readable; that's fine
|
|
882
|
+
}
|
|
883
|
+
throw new BackendHttpError(response.status, body, endpoint);
|
|
884
|
+
}
|
|
885
|
+
return response;
|
|
886
|
+
}
|
|
887
|
+
// Server's deserialize_flexible_datetime accepts ISO 8601 + "Nh ago" / "Nd ago"
|
|
888
|
+
// / "Nw ago" / "now". Models also try "yesterday", "today", and bare dates
|
|
889
|
+
// ("2026-05-17") — normalize those here so the request doesn't 400.
|
|
890
|
+
function normalizeTime(input) {
|
|
891
|
+
if (!input)
|
|
892
|
+
return input;
|
|
893
|
+
const s = input.trim();
|
|
894
|
+
if (!s)
|
|
895
|
+
return input;
|
|
896
|
+
const lower = s.toLowerCase();
|
|
897
|
+
if (lower === "yesterday")
|
|
898
|
+
return "1d ago";
|
|
899
|
+
if (lower === "today") {
|
|
900
|
+
return `${new Date().toISOString().split("T")[0]}T00:00:00Z`;
|
|
901
|
+
}
|
|
902
|
+
if (lower === "tomorrow") {
|
|
903
|
+
const t = new Date();
|
|
904
|
+
t.setUTCDate(t.getUTCDate() + 1);
|
|
905
|
+
return `${t.toISOString().split("T")[0]}T00:00:00Z`;
|
|
906
|
+
}
|
|
907
|
+
// Bare YYYY-MM-DD → start of day UTC
|
|
908
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(s))
|
|
909
|
+
return `${s}T00:00:00Z`;
|
|
910
|
+
return s;
|
|
911
|
+
}
|
|
912
|
+
// Apply normalizeTime to start_time/end_time fields in an args object.
|
|
913
|
+
// Returns a new object — does not mutate the input.
|
|
914
|
+
function normalizeTimeFields(args) {
|
|
915
|
+
const out = { ...args };
|
|
916
|
+
for (const k of ["start_time", "end_time"]) {
|
|
917
|
+
if (typeof out[k] === "string") {
|
|
918
|
+
out[k] = normalizeTime(out[k]);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
return out;
|
|
922
|
+
}
|
|
923
|
+
// Middle-truncate long strings: keep head + tail, mark the gap with how much
|
|
924
|
+
// was cut. Used to cap OCR/transcription text in search-content responses
|
|
925
|
+
// so a single call doesn't blow past Claude Code's per-tool output limit
|
|
926
|
+
// (one logged call returned 131k chars from a limit:10 search).
|
|
927
|
+
function truncateMiddle(text, max) {
|
|
928
|
+
if (!text)
|
|
929
|
+
return text ?? "";
|
|
930
|
+
if (max <= 0 || text.length <= max)
|
|
931
|
+
return text;
|
|
932
|
+
const halfLeft = Math.floor(max / 2);
|
|
933
|
+
const halfRight = max - halfLeft;
|
|
934
|
+
const cut = text.length - max;
|
|
935
|
+
return (text.slice(0, halfLeft) +
|
|
936
|
+
`…[${cut} chars truncated — pass max_content_length=0 for full text]…` +
|
|
937
|
+
text.slice(text.length - halfRight));
|
|
938
|
+
}
|
|
939
|
+
// Default per-result text cap for search-content when the caller didn't
|
|
940
|
+
// specify one. Tuned to keep limit=10 responses well under tool-output limits
|
|
941
|
+
// while still giving the model enough text to reason over.
|
|
942
|
+
const DEFAULT_SEARCH_CONTENT_TRUNCATE = 1000;
|
|
943
|
+
// Format the screen-text tag for a result. The server's `text_source` is
|
|
944
|
+
// "accessibility" (OS-native tree, primary path) or "ocr" (fallback for
|
|
945
|
+
// terminals, canvas, weak a11y). Older rows have no text_source, so we
|
|
946
|
+
// fall back to a bare `[Screen]`. The result type is historically called
|
|
947
|
+
// OCR in the engine but most captures are accessibility-derived — surface
|
|
948
|
+
// the actual source so the model picks filters correctly.
|
|
949
|
+
function screenTag(textSource) {
|
|
950
|
+
if (textSource === "accessibility")
|
|
951
|
+
return "[Screen·a11y]";
|
|
952
|
+
if (textSource === "ocr")
|
|
953
|
+
return "[Screen·ocr]";
|
|
954
|
+
return "[Screen]";
|
|
715
955
|
}
|
|
716
956
|
// ---------------------------------------------------------------------------
|
|
717
957
|
// Tool handlers
|
|
@@ -725,15 +965,22 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
725
965
|
switch (name) {
|
|
726
966
|
case "search-content": {
|
|
727
967
|
const includeFrames = args.include_frames === true;
|
|
968
|
+
const normalized = normalizeTimeFields(args);
|
|
969
|
+
// Default text cap if the caller didn't pass max_content_length.
|
|
970
|
+
// Keeps single calls under Claude Code's per-tool output limit.
|
|
971
|
+
const userCap = normalized.max_content_length;
|
|
972
|
+
const effectiveCap = typeof userCap === "number"
|
|
973
|
+
? userCap
|
|
974
|
+
: userCap === undefined
|
|
975
|
+
? DEFAULT_SEARCH_CONTENT_TRUNCATE
|
|
976
|
+
: Number(userCap);
|
|
728
977
|
const params = new URLSearchParams();
|
|
729
|
-
for (const [key, value] of Object.entries(
|
|
978
|
+
for (const [key, value] of Object.entries(normalized)) {
|
|
730
979
|
if (value !== null && value !== undefined) {
|
|
731
980
|
params.append(key, String(value));
|
|
732
981
|
}
|
|
733
982
|
}
|
|
734
|
-
const response = await
|
|
735
|
-
if (!response.ok)
|
|
736
|
-
throw new Error(`HTTP error: ${response.status}`);
|
|
983
|
+
const response = await callAPI(`/search?${params.toString()}`);
|
|
737
984
|
const data = await response.json();
|
|
738
985
|
const results = data.data || [];
|
|
739
986
|
const pagination = data.pagination || {};
|
|
@@ -756,9 +1003,13 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
756
1003
|
continue;
|
|
757
1004
|
if (result.type === "OCR") {
|
|
758
1005
|
const tagsStr = content.tags?.length ? `\nTags: ${content.tags.join(", ")}` : "";
|
|
759
|
-
|
|
1006
|
+
// result.type is "OCR" by historical naming, but content.text_source
|
|
1007
|
+
// tells us if the text actually came from the accessibility tree
|
|
1008
|
+
// (primary path) or OCR (fallback). Use it to label honestly.
|
|
1009
|
+
const tag = screenTag(content.text_source);
|
|
1010
|
+
formattedResults.push(`${tag} ${content.app_name || "?"} | ${content.window_name || "?"}\n` +
|
|
760
1011
|
`${content.timestamp || ""}\n` +
|
|
761
|
-
`${content.text || ""}` +
|
|
1012
|
+
`${truncateMiddle(content.text || "", effectiveCap)}` +
|
|
762
1013
|
tagsStr);
|
|
763
1014
|
if (includeFrames && content.frame) {
|
|
764
1015
|
images.push({
|
|
@@ -771,20 +1022,20 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
771
1022
|
const tagsStr = content.tags?.length ? `\nTags: ${content.tags.join(", ")}` : "";
|
|
772
1023
|
formattedResults.push(`[Audio] ${content.device_name || "?"}\n` +
|
|
773
1024
|
`${content.timestamp || ""}\n` +
|
|
774
|
-
`${content.transcription || ""}` +
|
|
1025
|
+
`${truncateMiddle(content.transcription || "", effectiveCap)}` +
|
|
775
1026
|
tagsStr);
|
|
776
1027
|
}
|
|
777
1028
|
else if (result.type === "UI" || result.type === "Accessibility") {
|
|
778
1029
|
formattedResults.push(`[Accessibility] ${content.app_name || "?"} | ${content.window_name || "?"}\n` +
|
|
779
1030
|
`${content.timestamp || ""}\n` +
|
|
780
|
-
`${content.text || ""}`);
|
|
1031
|
+
`${truncateMiddle(content.text || "", effectiveCap)}`);
|
|
781
1032
|
}
|
|
782
1033
|
else if (result.type === "Memory") {
|
|
783
1034
|
const tagsStr = content.tags?.length ? ` [${content.tags.join(", ")}]` : "";
|
|
784
1035
|
const importance = content.importance != null ? ` (importance: ${content.importance})` : "";
|
|
785
1036
|
formattedResults.push(`[Memory #${content.id}]${tagsStr}${importance}\n` +
|
|
786
1037
|
`${content.created_at || ""}\n` +
|
|
787
|
-
`${content.content || ""}`);
|
|
1038
|
+
`${truncateMiddle(content.content || "", effectiveCap)}`);
|
|
788
1039
|
}
|
|
789
1040
|
}
|
|
790
1041
|
const header = `Results: ${results.length}/${pagination.total || "?"}` +
|
|
@@ -802,15 +1053,14 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
802
1053
|
return { content: contentItems };
|
|
803
1054
|
}
|
|
804
1055
|
case "list-meetings": {
|
|
1056
|
+
const normalized = normalizeTimeFields(args);
|
|
805
1057
|
const params = new URLSearchParams();
|
|
806
|
-
for (const [key, value] of Object.entries(
|
|
1058
|
+
for (const [key, value] of Object.entries(normalized)) {
|
|
807
1059
|
if (value !== null && value !== undefined) {
|
|
808
1060
|
params.append(key, String(value));
|
|
809
1061
|
}
|
|
810
1062
|
}
|
|
811
|
-
const response = await
|
|
812
|
-
if (!response.ok)
|
|
813
|
-
throw new Error(`HTTP error: ${response.status}`);
|
|
1063
|
+
const response = await callAPI(`/meetings?${params.toString()}`);
|
|
814
1064
|
const meetings = await response.json();
|
|
815
1065
|
if (!Array.isArray(meetings) || meetings.length === 0) {
|
|
816
1066
|
return {
|
|
@@ -832,15 +1082,14 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
832
1082
|
};
|
|
833
1083
|
}
|
|
834
1084
|
case "activity-summary": {
|
|
1085
|
+
const normalized = normalizeTimeFields(args);
|
|
835
1086
|
const params = new URLSearchParams();
|
|
836
|
-
for (const [key, value] of Object.entries(
|
|
1087
|
+
for (const [key, value] of Object.entries(normalized)) {
|
|
837
1088
|
if (value !== null && value !== undefined) {
|
|
838
1089
|
params.append(key, String(value));
|
|
839
1090
|
}
|
|
840
1091
|
}
|
|
841
|
-
const response = await
|
|
842
|
-
if (!response.ok)
|
|
843
|
-
throw new Error(`HTTP error: ${response.status}`);
|
|
1092
|
+
const response = await callAPI(`/activity-summary?${params.toString()}`);
|
|
844
1093
|
const data = await response.json();
|
|
845
1094
|
const appsLines = (data.apps || []).map((a) => {
|
|
846
1095
|
const timeSpan = a.first_seen && a.last_seen
|
|
@@ -881,15 +1130,14 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
881
1130
|
return { content: [{ type: "text", text: summary }] };
|
|
882
1131
|
}
|
|
883
1132
|
case "search-elements": {
|
|
1133
|
+
const normalized = normalizeTimeFields(args);
|
|
884
1134
|
const params = new URLSearchParams();
|
|
885
|
-
for (const [key, value] of Object.entries(
|
|
1135
|
+
for (const [key, value] of Object.entries(normalized)) {
|
|
886
1136
|
if (value !== null && value !== undefined) {
|
|
887
1137
|
params.append(key, String(value));
|
|
888
1138
|
}
|
|
889
1139
|
}
|
|
890
|
-
const response = await
|
|
891
|
-
if (!response.ok)
|
|
892
|
-
throw new Error(`HTTP error: ${response.status}`);
|
|
1140
|
+
const response = await callAPI(`/elements?${params.toString()}`);
|
|
893
1141
|
const data = await response.json();
|
|
894
1142
|
const elements = data.data || [];
|
|
895
1143
|
const pagination = data.pagination || {};
|
|
@@ -922,9 +1170,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
922
1170
|
if (!frameId) {
|
|
923
1171
|
return { content: [{ type: "text", text: "Error: frame_id is required" }] };
|
|
924
1172
|
}
|
|
925
|
-
const response = await
|
|
926
|
-
if (!response.ok)
|
|
927
|
-
throw new Error(`HTTP error: ${response.status}`);
|
|
1173
|
+
const response = await callAPI(`/frames/${frameId}/context`);
|
|
928
1174
|
const data = await response.json();
|
|
929
1175
|
const lines = [`Frame ${data.frame_id} (source: ${data.text_source})`];
|
|
930
1176
|
if (data.urls?.length) {
|
|
@@ -947,8 +1193,8 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
947
1193
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
948
1194
|
}
|
|
949
1195
|
case "export-video": {
|
|
950
|
-
const startTime = args.start_time;
|
|
951
|
-
const endTime = args.end_time;
|
|
1196
|
+
const startTime = normalizeTime(args.start_time);
|
|
1197
|
+
const endTime = normalizeTime(args.end_time);
|
|
952
1198
|
const fps = args.fps || 1.0;
|
|
953
1199
|
if (!startTime || !endTime) {
|
|
954
1200
|
return {
|
|
@@ -962,10 +1208,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
962
1208
|
end_time: endTime,
|
|
963
1209
|
limit: "10000",
|
|
964
1210
|
});
|
|
965
|
-
const searchResponse = await
|
|
966
|
-
if (!searchResponse.ok) {
|
|
967
|
-
throw new Error(`Failed to search for frames: HTTP ${searchResponse.status}`);
|
|
968
|
-
}
|
|
1211
|
+
const searchResponse = await callAPI(`/search?${searchParams.toString()}`);
|
|
969
1212
|
const searchData = await searchResponse.json();
|
|
970
1213
|
const results = searchData.data || [];
|
|
971
1214
|
if (results.length === 0) {
|
|
@@ -1068,9 +1311,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1068
1311
|
}
|
|
1069
1312
|
case "update-memory": {
|
|
1070
1313
|
if (args.delete && args.id) {
|
|
1071
|
-
const response = await
|
|
1072
|
-
if (!response.ok)
|
|
1073
|
-
throw new Error(`HTTP error: ${response.status}`);
|
|
1314
|
+
const response = await callAPI(`/memories/${args.id}`, { method: "DELETE" });
|
|
1074
1315
|
return { content: [{ type: "text", text: `Memory ${args.id} deleted.` }] };
|
|
1075
1316
|
}
|
|
1076
1317
|
if (args.id) {
|
|
@@ -1083,12 +1324,10 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1083
1324
|
body.importance = args.importance;
|
|
1084
1325
|
if (args.source_context !== undefined)
|
|
1085
1326
|
body.source_context = args.source_context;
|
|
1086
|
-
const response = await
|
|
1327
|
+
const response = await callAPI(`/memories/${args.id}`, {
|
|
1087
1328
|
method: "PUT",
|
|
1088
1329
|
body: JSON.stringify(body),
|
|
1089
1330
|
});
|
|
1090
|
-
if (!response.ok)
|
|
1091
|
-
throw new Error(`HTTP error: ${response.status}`);
|
|
1092
1331
|
const memory = await response.json();
|
|
1093
1332
|
return {
|
|
1094
1333
|
content: [{ type: "text", text: `Memory ${memory.id} updated: "${memory.content}"` }],
|
|
@@ -1107,12 +1346,10 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1107
1346
|
};
|
|
1108
1347
|
if (args.source_context)
|
|
1109
1348
|
memoryBody.source_context = args.source_context;
|
|
1110
|
-
const memoryResponse = await
|
|
1349
|
+
const memoryResponse = await callAPI("/memories", {
|
|
1111
1350
|
method: "POST",
|
|
1112
1351
|
body: JSON.stringify(memoryBody),
|
|
1113
1352
|
});
|
|
1114
|
-
if (!memoryResponse.ok)
|
|
1115
|
-
throw new Error(`HTTP error: ${memoryResponse.status}`);
|
|
1116
1353
|
const newMemory = await memoryResponse.json();
|
|
1117
1354
|
return {
|
|
1118
1355
|
content: [
|
|
@@ -1130,31 +1367,42 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1130
1367
|
notifBody.timeout = Number(args.timeout_secs) * 1000;
|
|
1131
1368
|
if (args.actions)
|
|
1132
1369
|
notifBody.actions = args.actions;
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1370
|
+
// send-notification hits the desktop notify daemon on a separate port
|
|
1371
|
+
// (11435), not the screenpipe API. Keep direct fetch with friendlier
|
|
1372
|
+
// error so the model sees an actionable message if the daemon's down.
|
|
1373
|
+
let notifResponse;
|
|
1374
|
+
try {
|
|
1375
|
+
notifResponse = await fetch("http://localhost:11435/notify", {
|
|
1376
|
+
method: "POST",
|
|
1377
|
+
headers: { "Content-Type": "application/json" },
|
|
1378
|
+
body: JSON.stringify(notifBody),
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
catch (e) {
|
|
1382
|
+
throw new Error("notification daemon not reachable on localhost:11435 — is the screenpipe desktop app running?");
|
|
1383
|
+
}
|
|
1384
|
+
if (!notifResponse.ok) {
|
|
1385
|
+
let body = "";
|
|
1386
|
+
try {
|
|
1387
|
+
body = await notifResponse.text();
|
|
1388
|
+
}
|
|
1389
|
+
catch { }
|
|
1390
|
+
throw new Error(`notify daemon HTTP ${notifResponse.status}${body ? `: ${body.slice(0, 200)}` : ""}`);
|
|
1391
|
+
}
|
|
1140
1392
|
const notifResult = await notifResponse.json();
|
|
1141
1393
|
return {
|
|
1142
1394
|
content: [{ type: "text", text: `Notification sent: ${notifResult.message}` }],
|
|
1143
1395
|
};
|
|
1144
1396
|
}
|
|
1145
1397
|
case "health-check": {
|
|
1146
|
-
const response = await
|
|
1147
|
-
if (!response.ok)
|
|
1148
|
-
throw new Error(`HTTP error: ${response.status}`);
|
|
1398
|
+
const response = await callAPI("/health");
|
|
1149
1399
|
const data = await response.json();
|
|
1150
1400
|
return {
|
|
1151
1401
|
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
1152
1402
|
};
|
|
1153
1403
|
}
|
|
1154
1404
|
case "list-audio-devices": {
|
|
1155
|
-
const response = await
|
|
1156
|
-
if (!response.ok)
|
|
1157
|
-
throw new Error(`HTTP error: ${response.status}`);
|
|
1405
|
+
const response = await callAPI("/audio/list");
|
|
1158
1406
|
const devices = await response.json();
|
|
1159
1407
|
if (!Array.isArray(devices) || devices.length === 0) {
|
|
1160
1408
|
return { content: [{ type: "text", text: "No audio devices found." }] };
|
|
@@ -1165,9 +1413,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1165
1413
|
};
|
|
1166
1414
|
}
|
|
1167
1415
|
case "list-monitors": {
|
|
1168
|
-
const response = await
|
|
1169
|
-
if (!response.ok)
|
|
1170
|
-
throw new Error(`HTTP error: ${response.status}`);
|
|
1416
|
+
const response = await callAPI("/vision/list");
|
|
1171
1417
|
const monitors = await response.json();
|
|
1172
1418
|
if (!Array.isArray(monitors) || monitors.length === 0) {
|
|
1173
1419
|
return { content: [{ type: "text", text: "No monitors found." }] };
|
|
@@ -1184,12 +1430,10 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1184
1430
|
if (!contentType || !id || !tags) {
|
|
1185
1431
|
return { content: [{ type: "text", text: "Error: content_type, id, and tags are required" }] };
|
|
1186
1432
|
}
|
|
1187
|
-
const response = await
|
|
1433
|
+
const response = await callAPI(`/tags/${contentType}/${id}`, {
|
|
1188
1434
|
method: "POST",
|
|
1189
1435
|
body: JSON.stringify({ tags }),
|
|
1190
1436
|
});
|
|
1191
|
-
if (!response.ok)
|
|
1192
|
-
throw new Error(`HTTP error: ${response.status}`);
|
|
1193
1437
|
return {
|
|
1194
1438
|
content: [{ type: "text", text: `Tags added to ${contentType}/${id}: ${tags.join(", ")}` }],
|
|
1195
1439
|
};
|
|
@@ -1199,9 +1443,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1199
1443
|
if (!nameQuery) {
|
|
1200
1444
|
return { content: [{ type: "text", text: "Error: name is required" }] };
|
|
1201
1445
|
}
|
|
1202
|
-
const response = await
|
|
1203
|
-
if (!response.ok)
|
|
1204
|
-
throw new Error(`HTTP error: ${response.status}`);
|
|
1446
|
+
const response = await callAPI(`/speakers/search?name=${encodeURIComponent(nameQuery)}`);
|
|
1205
1447
|
const speakers = await response.json();
|
|
1206
1448
|
if (!Array.isArray(speakers) || speakers.length === 0) {
|
|
1207
1449
|
return { content: [{ type: "text", text: "No speakers found." }] };
|
|
@@ -1214,9 +1456,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1214
1456
|
case "list-unnamed-speakers": {
|
|
1215
1457
|
const limit = args.limit || 10;
|
|
1216
1458
|
const offset = args.offset || 0;
|
|
1217
|
-
const response = await
|
|
1218
|
-
if (!response.ok)
|
|
1219
|
-
throw new Error(`HTTP error: ${response.status}`);
|
|
1459
|
+
const response = await callAPI(`/speakers/unnamed?limit=${limit}&offset=${offset}`);
|
|
1220
1460
|
const speakers = await response.json();
|
|
1221
1461
|
if (!Array.isArray(speakers) || speakers.length === 0) {
|
|
1222
1462
|
return { content: [{ type: "text", text: "No unnamed speakers found." }] };
|
|
@@ -1236,12 +1476,10 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1236
1476
|
body.name = args.name;
|
|
1237
1477
|
if (args.metadata !== undefined)
|
|
1238
1478
|
body.metadata = args.metadata;
|
|
1239
|
-
const response = await
|
|
1479
|
+
const response = await callAPI("/speakers/update", {
|
|
1240
1480
|
method: "POST",
|
|
1241
1481
|
body: JSON.stringify(body),
|
|
1242
1482
|
});
|
|
1243
|
-
if (!response.ok)
|
|
1244
|
-
throw new Error(`HTTP error: ${response.status}`);
|
|
1245
1483
|
return {
|
|
1246
1484
|
content: [{ type: "text", text: `Speaker ${speakerId} updated.` }],
|
|
1247
1485
|
};
|
|
@@ -1252,12 +1490,10 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1252
1490
|
if (!keepId || !mergeId) {
|
|
1253
1491
|
return { content: [{ type: "text", text: "Error: speaker_to_keep_id and speaker_to_merge_id are required" }] };
|
|
1254
1492
|
}
|
|
1255
|
-
const response = await
|
|
1493
|
+
const response = await callAPI("/speakers/merge", {
|
|
1256
1494
|
method: "POST",
|
|
1257
1495
|
body: JSON.stringify({ speaker_to_keep_id: keepId, speaker_to_merge_id: mergeId }),
|
|
1258
1496
|
});
|
|
1259
|
-
if (!response.ok)
|
|
1260
|
-
throw new Error(`HTTP error: ${response.status}`);
|
|
1261
1497
|
return {
|
|
1262
1498
|
content: [{ type: "text", text: `Merged speaker ${mergeId} into ${keepId}.` }],
|
|
1263
1499
|
};
|
|
@@ -1270,21 +1506,17 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1270
1506
|
body.title = args.title;
|
|
1271
1507
|
if (args.attendees)
|
|
1272
1508
|
body.attendees = args.attendees;
|
|
1273
|
-
const response = await
|
|
1509
|
+
const response = await callAPI("/meetings/start", {
|
|
1274
1510
|
method: "POST",
|
|
1275
1511
|
body: JSON.stringify(body),
|
|
1276
1512
|
});
|
|
1277
|
-
if (!response.ok)
|
|
1278
|
-
throw new Error(`HTTP error: ${response.status}`);
|
|
1279
1513
|
const meeting = await response.json();
|
|
1280
1514
|
return {
|
|
1281
1515
|
content: [{ type: "text", text: `Meeting started (id: ${meeting.id || "ok"}).` }],
|
|
1282
1516
|
};
|
|
1283
1517
|
}
|
|
1284
1518
|
case "stop-meeting": {
|
|
1285
|
-
const response = await
|
|
1286
|
-
if (!response.ok)
|
|
1287
|
-
throw new Error(`HTTP error: ${response.status}`);
|
|
1519
|
+
const response = await callAPI("/meetings/stop", { method: "POST" });
|
|
1288
1520
|
return {
|
|
1289
1521
|
content: [{ type: "text", text: "Meeting stopped." }],
|
|
1290
1522
|
};
|
|
@@ -1294,9 +1526,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1294
1526
|
if (!meetingId) {
|
|
1295
1527
|
return { content: [{ type: "text", text: "Error: id is required" }] };
|
|
1296
1528
|
}
|
|
1297
|
-
const response = await
|
|
1298
|
-
if (!response.ok)
|
|
1299
|
-
throw new Error(`HTTP error: ${response.status}`);
|
|
1529
|
+
const response = await callAPI(`/meetings/${meetingId}`);
|
|
1300
1530
|
const meeting = await response.json();
|
|
1301
1531
|
return {
|
|
1302
1532
|
content: [{ type: "text", text: JSON.stringify(meeting, null, 2) }],
|
|
@@ -1323,36 +1553,68 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1323
1553
|
],
|
|
1324
1554
|
};
|
|
1325
1555
|
}
|
|
1326
|
-
const response = await
|
|
1556
|
+
const response = await callAPI(`/meetings/${meetingId}`, {
|
|
1327
1557
|
method: "PATCH",
|
|
1328
1558
|
headers: { "Content-Type": "application/json" },
|
|
1329
1559
|
body: JSON.stringify(body),
|
|
1330
1560
|
});
|
|
1331
|
-
if (!response.ok)
|
|
1332
|
-
throw new Error(`HTTP error: ${response.status}`);
|
|
1333
1561
|
const updated = await response.json();
|
|
1334
1562
|
return {
|
|
1335
1563
|
content: [{ type: "text", text: JSON.stringify(updated, null, 2) }],
|
|
1336
1564
|
};
|
|
1337
1565
|
}
|
|
1338
1566
|
case "keyword-search": {
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1567
|
+
// Translate model-facing arg names to what the engine actually
|
|
1568
|
+
// accepts (KeywordSearchRequest in routes/search.rs):
|
|
1569
|
+
// q -> query (mandatory; the field is literally named `query`)
|
|
1570
|
+
// app_name -> app_names (comma-separated; serde splits it)
|
|
1571
|
+
// content_type: dropped — the keyword endpoint doesn't filter by type.
|
|
1572
|
+
// It searches OCR + audio together via the FTS index.
|
|
1573
|
+
// Without these mappings every keyword-search request 400s (and used
|
|
1574
|
+
// to: in logs, 25/25 calls failed before this fix).
|
|
1575
|
+
const queryStr = args.query ?? args.q;
|
|
1576
|
+
if (!queryStr) {
|
|
1577
|
+
return {
|
|
1578
|
+
content: [{ type: "text", text: "Error: 'q' (search query) is required" }],
|
|
1579
|
+
};
|
|
1344
1580
|
}
|
|
1345
|
-
const
|
|
1346
|
-
|
|
1347
|
-
|
|
1581
|
+
const normalized = normalizeTimeFields(args);
|
|
1582
|
+
const params = new URLSearchParams();
|
|
1583
|
+
params.append("query", queryStr);
|
|
1584
|
+
if (normalized.start_time)
|
|
1585
|
+
params.append("start_time", String(normalized.start_time));
|
|
1586
|
+
if (normalized.end_time)
|
|
1587
|
+
params.append("end_time", String(normalized.end_time));
|
|
1588
|
+
if (normalized.limit !== undefined)
|
|
1589
|
+
params.append("limit", String(normalized.limit));
|
|
1590
|
+
if (normalized.offset !== undefined)
|
|
1591
|
+
params.append("offset", String(normalized.offset));
|
|
1592
|
+
if (normalized.app_name)
|
|
1593
|
+
params.append("app_names", String(normalized.app_name));
|
|
1594
|
+
if (normalized.app_names)
|
|
1595
|
+
params.append("app_names", String(normalized.app_names));
|
|
1596
|
+
if (args.fuzzy_match !== undefined)
|
|
1597
|
+
params.append("fuzzy_match", String(args.fuzzy_match));
|
|
1598
|
+
const response = await callAPI(`/search/keyword?${params.toString()}`);
|
|
1348
1599
|
const data = await response.json();
|
|
1349
|
-
|
|
1600
|
+
// /search/keyword returns a bare array (Vec<KeywordSearchMatch> from
|
|
1601
|
+
// routes/search.rs), not the {data, pagination} shape /search uses.
|
|
1602
|
+
// The old `data.data || []` always lost results.
|
|
1603
|
+
const results = Array.isArray(data)
|
|
1604
|
+
? data
|
|
1605
|
+
: (data.data ?? []);
|
|
1350
1606
|
if (results.length === 0) {
|
|
1351
1607
|
return { content: [{ type: "text", text: "No keyword search results found." }] };
|
|
1352
1608
|
}
|
|
1353
1609
|
const formatted = results.map((r) => {
|
|
1354
|
-
|
|
1355
|
-
|
|
1610
|
+
// Flat shape from search_with_text_positions: { app_name, frame_id,
|
|
1611
|
+
// timestamp, text, text_source, ... }. Truncate to keep responses
|
|
1612
|
+
// under tool-output limits. text_source is "accessibility" (primary)
|
|
1613
|
+
// or "ocr" (fallback) — show it so the model knows which path hit.
|
|
1614
|
+
const text = r.text || r.transcription || "";
|
|
1615
|
+
const tag = screenTag(r.text_source);
|
|
1616
|
+
return (`${tag} [frame:${r.frame_id ?? "?"}] ${r.app_name ?? "?"} | ${r.timestamp ?? ""}\n` +
|
|
1617
|
+
truncateMiddle(text, DEFAULT_SEARCH_CONTENT_TRUNCATE));
|
|
1356
1618
|
});
|
|
1357
1619
|
return {
|
|
1358
1620
|
content: [{ type: "text", text: `Results: ${results.length}\n\n${formatted.join("\n---\n")}` }],
|
|
@@ -1363,9 +1625,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1363
1625
|
if (!frameId) {
|
|
1364
1626
|
return { content: [{ type: "text", text: "Error: frame_id is required" }] };
|
|
1365
1627
|
}
|
|
1366
|
-
const response = await
|
|
1367
|
-
if (!response.ok)
|
|
1368
|
-
throw new Error(`HTTP error: ${response.status}`);
|
|
1628
|
+
const response = await callAPI(`/frames/${frameId}/elements`);
|
|
1369
1629
|
const elements = await response.json();
|
|
1370
1630
|
if (!Array.isArray(elements) || elements.length === 0) {
|
|
1371
1631
|
return { content: [{ type: "text", text: `No elements found for frame ${frameId}.` }] };
|
|
@@ -1391,20 +1651,64 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
1391
1651
|
else {
|
|
1392
1652
|
return { content: [{ type: "text", text: `Error: unknown action '${action}'` }] };
|
|
1393
1653
|
}
|
|
1394
|
-
|
|
1395
|
-
if (!response.ok)
|
|
1396
|
-
throw new Error(`HTTP error: ${response.status}`);
|
|
1654
|
+
await callAPI(endpoint, { method: "POST" });
|
|
1397
1655
|
return {
|
|
1398
1656
|
content: [{ type: "text", text: `Recording action '${action}' executed.` }],
|
|
1399
1657
|
};
|
|
1400
1658
|
}
|
|
1659
|
+
// ---------------------------------------------------------------------
|
|
1660
|
+
// Enterprise team tools — only callable when TEAM_TOKEN is set at boot.
|
|
1661
|
+
// If we got this far without one, the tool wasn't in the listed set the
|
|
1662
|
+
// host saw, but a misbehaving client could still try to call it. Fail
|
|
1663
|
+
// loudly so the host surfaces the misconfiguration.
|
|
1664
|
+
// ---------------------------------------------------------------------
|
|
1665
|
+
case "team-search":
|
|
1666
|
+
case "team-devices":
|
|
1667
|
+
case "team-records": {
|
|
1668
|
+
if (!TEAM_TOKEN) {
|
|
1669
|
+
return {
|
|
1670
|
+
content: [
|
|
1671
|
+
{
|
|
1672
|
+
type: "text",
|
|
1673
|
+
text: `team-* tools require an enterprise admin token. Set ` +
|
|
1674
|
+
`SCREENPIPE_ENTERPRISE_TOKEN in your MCP env, or mint one ` +
|
|
1675
|
+
`at https://screenpi.pe/enterprise → API Tokens and paste ` +
|
|
1676
|
+
`it into Settings → Privacy → Admin Team API Token in the ` +
|
|
1677
|
+
`screenpipe desktop app.`,
|
|
1678
|
+
},
|
|
1679
|
+
],
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1682
|
+
// Map MCP tool name → /api/enterprise/v1 path
|
|
1683
|
+
const subpath = name === "team-search" ? "/search"
|
|
1684
|
+
: name === "team-devices" ? "/devices"
|
|
1685
|
+
: "/records";
|
|
1686
|
+
// Forward every primitive arg as a query param. The server validates;
|
|
1687
|
+
// unknown params are ignored, so we don't need to gatekeep here.
|
|
1688
|
+
const params = new URLSearchParams();
|
|
1689
|
+
for (const [k, v] of Object.entries(args)) {
|
|
1690
|
+
if (v !== null && v !== undefined && v !== "") {
|
|
1691
|
+
params.append(k, String(v));
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
const query = params.toString();
|
|
1695
|
+
const response = await fetchTeam(`${subpath}${query ? `?${query}` : ""}`);
|
|
1696
|
+
const body = await response.text();
|
|
1697
|
+
if (!response.ok) {
|
|
1698
|
+
throw new Error(`${name} failed: HTTP ${response.status} ${response.statusText} — ${body.slice(0, 300)}`);
|
|
1699
|
+
}
|
|
1700
|
+
return { content: [{ type: "text", text: body }] };
|
|
1701
|
+
}
|
|
1401
1702
|
default:
|
|
1402
1703
|
throw new Error(`Unknown tool: ${name}`);
|
|
1403
1704
|
}
|
|
1404
1705
|
}
|
|
1405
1706
|
catch (error) {
|
|
1406
1707
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
1708
|
+
// isError flags the result as a failure so the model retries with a
|
|
1709
|
+
// different approach instead of treating the error text as data.
|
|
1407
1710
|
return {
|
|
1711
|
+
isError: true,
|
|
1408
1712
|
content: [{ type: "text", text: `Error executing ${name}: ${errorMessage}` }],
|
|
1409
1713
|
};
|
|
1410
1714
|
}
|