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/src/index.ts
CHANGED
|
@@ -194,6 +194,49 @@ function discoverApiKey(): string {
|
|
|
194
194
|
|
|
195
195
|
const API_KEY = discoverApiKey();
|
|
196
196
|
|
|
197
|
+
// Enterprise team token — when present, this MCP additionally registers
|
|
198
|
+
// `team-*` tools that query the org-wide telemetry control plane
|
|
199
|
+
// (https://screenpi.pe/api/enterprise/v1/*) instead of just the local
|
|
200
|
+
// recordings. Same audience: an enterprise admin running screenpipe-mcp
|
|
201
|
+
// inside Claude Desktop / Cursor / Windsurf wants to ask "what did MY
|
|
202
|
+
// machine do" AND "what did MY TEAM do" without juggling two MCPs.
|
|
203
|
+
//
|
|
204
|
+
// Resolution order matches discoverApiKey() in spirit:
|
|
205
|
+
// 1. SCREENPIPE_ENTERPRISE_TOKEN env var (Claude config, terminal)
|
|
206
|
+
// 2. team_api_token field in ~/.screenpipe/enterprise.json (written by
|
|
207
|
+
// the desktop app's Settings → Privacy → Admin Team API Token)
|
|
208
|
+
//
|
|
209
|
+
// Token format is `sk_ent_…`. Empty / missing → team tools are not
|
|
210
|
+
// registered; non-admin users of screenpipe-mcp see exactly what they
|
|
211
|
+
// see today.
|
|
212
|
+
function discoverTeamToken(): string {
|
|
213
|
+
const envTok = process.env.SCREENPIPE_ENTERPRISE_TOKEN;
|
|
214
|
+
if (envTok && envTok.startsWith("sk_ent_")) return envTok;
|
|
215
|
+
try {
|
|
216
|
+
const entPath = path.join(os.homedir(), ".screenpipe", "enterprise.json");
|
|
217
|
+
if (fs.existsSync(entPath)) {
|
|
218
|
+
const raw = fs.readFileSync(entPath, "utf-8");
|
|
219
|
+
const parsed = JSON.parse(raw);
|
|
220
|
+
const tok = typeof parsed?.team_api_token === "string" ? parsed.team_api_token : "";
|
|
221
|
+
if (tok && tok.startsWith("sk_ent_")) return tok;
|
|
222
|
+
}
|
|
223
|
+
} catch {}
|
|
224
|
+
return "";
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const TEAM_TOKEN = discoverTeamToken();
|
|
228
|
+
const TEAM_API = "https://screenpi.pe/api/enterprise/v1";
|
|
229
|
+
|
|
230
|
+
async function fetchTeam(p: string, init: RequestInit = {}): Promise<Response> {
|
|
231
|
+
return fetch(`${TEAM_API}${p}`, {
|
|
232
|
+
...init,
|
|
233
|
+
headers: {
|
|
234
|
+
Authorization: `Bearer ${TEAM_TOKEN}`,
|
|
235
|
+
...(init.headers || {}),
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
197
240
|
// Read version from package.json (single source of truth)
|
|
198
241
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
199
242
|
const PKG_VERSION: string = require("../package.json").version;
|
|
@@ -219,11 +262,11 @@ const TOOLS: Tool[] = [
|
|
|
219
262
|
{
|
|
220
263
|
name: "search-content",
|
|
221
264
|
description:
|
|
222
|
-
"Search screen text, audio transcriptions, input events, and memories. " +
|
|
223
|
-
"
|
|
224
|
-
"
|
|
225
|
-
"
|
|
226
|
-
"Start with limit=5, increase only if needed.
|
|
265
|
+
"Search screen text, audio transcriptions, input events, and memories. Returns timestamped results with app context. " +
|
|
266
|
+
"USE WHEN: you need the actual text/content of a moment — quotes, OCR snippets, transcript lines — or want to filter by speaker/window. " +
|
|
267
|
+
"DO NOT USE for: broad questions like 'what was I doing?' (use activity-summary, it pre-summarizes apps + windows + transcripts). " +
|
|
268
|
+
"Also DO NOT USE for: targeted UI controls (use search-elements). " +
|
|
269
|
+
"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.",
|
|
227
270
|
annotations: { title: "Search Content", readOnlyHint: true, openWorldHint: false, idempotentHint: true },
|
|
228
271
|
inputSchema: {
|
|
229
272
|
type: "object",
|
|
@@ -235,14 +278,15 @@ const TOOLS: Tool[] = [
|
|
|
235
278
|
content_type: {
|
|
236
279
|
type: "string",
|
|
237
280
|
enum: ["all", "ocr", "audio", "input", "accessibility", "memory"],
|
|
238
|
-
description:
|
|
281
|
+
description:
|
|
282
|
+
"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'.",
|
|
239
283
|
default: "all",
|
|
240
284
|
},
|
|
241
285
|
limit: { type: "integer", description: "Max results (default 10, max 20). Start with 5 for exploration.", default: 10 },
|
|
242
286
|
offset: { type: "integer", description: "Pagination offset. Use when results say 'use offset=N for more'.", default: 0 },
|
|
243
287
|
start_time: {
|
|
244
288
|
type: "string",
|
|
245
|
-
description: "ISO 8601
|
|
289
|
+
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.",
|
|
246
290
|
},
|
|
247
291
|
end_time: {
|
|
248
292
|
type: "string",
|
|
@@ -286,9 +330,9 @@ const TOOLS: Tool[] = [
|
|
|
286
330
|
name: "activity-summary",
|
|
287
331
|
description:
|
|
288
332
|
"Rich activity overview: app usage, window/tab titles with URLs and time spent, key text per context, audio transcriptions. " +
|
|
289
|
-
"USE
|
|
290
|
-
"
|
|
291
|
-
"
|
|
333
|
+
"USE WHEN: any broad question about what the user did — 'what was I doing?', 'how long on X?', 'which apps?', 'recap my morning'. " +
|
|
334
|
+
"This is almost always the right first call for time-range questions — usually sufficient without follow-up searches. " +
|
|
335
|
+
"DO NOT USE for: finding a specific keyword (use keyword-search) or a specific UI control (use search-elements).",
|
|
292
336
|
annotations: { title: "Activity Summary", readOnlyHint: true, openWorldHint: false, idempotentHint: true },
|
|
293
337
|
inputSchema: {
|
|
294
338
|
type: "object",
|
|
@@ -303,9 +347,9 @@ const TOOLS: Tool[] = [
|
|
|
303
347
|
{
|
|
304
348
|
name: "search-elements",
|
|
305
349
|
description:
|
|
306
|
-
"Search UI elements (buttons, links, text fields) from the accessibility tree. " +
|
|
307
|
-
"
|
|
308
|
-
"
|
|
350
|
+
"Search UI elements (buttons, links, text fields) from the accessibility tree, filterable by role. " +
|
|
351
|
+
"USE WHEN: you want a specific UI control or page-structure question — 'find every Submit button I saw', 'list the links in that page'. " +
|
|
352
|
+
"DO NOT USE for: general text/content (use search-content) or fast keyword lookup (use keyword-search).",
|
|
309
353
|
annotations: { title: "Search Elements", readOnlyHint: true, openWorldHint: false, idempotentHint: true },
|
|
310
354
|
inputSchema: {
|
|
311
355
|
type: "object",
|
|
@@ -553,19 +597,21 @@ const TOOLS: Tool[] = [
|
|
|
553
597
|
{
|
|
554
598
|
name: "keyword-search",
|
|
555
599
|
description:
|
|
556
|
-
"Fast keyword search
|
|
557
|
-
"
|
|
600
|
+
"Fast FTS5 keyword search across OCR + audio combined. Returns matches with frame_id, app, timestamp, and text positions. " +
|
|
601
|
+
"USE WHEN: you have a specific keyword/phrase and want the fastest hit-list (e.g. 'find every screen where I typed \"stripe\"'). " +
|
|
602
|
+
"DO NOT USE for: structured filters by content_type / speaker / window — this endpoint ignores those (use search-content instead). " +
|
|
603
|
+
"DO NOT USE for: broad questions like 'what was I doing' (use activity-summary).",
|
|
558
604
|
annotations: { title: "Keyword Search", readOnlyHint: true, openWorldHint: false, idempotentHint: true },
|
|
559
605
|
inputSchema: {
|
|
560
606
|
type: "object",
|
|
561
607
|
properties: {
|
|
562
|
-
q: { type: "string", description: "Keyword
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
app_name: { type: "string", description: "Filter by app name" },
|
|
608
|
+
q: { type: "string", description: "Keyword query (FTS5 syntax: quoted phrases, AND/OR, prefix*)" },
|
|
609
|
+
start_time: { type: "string", description: "ISO 8601 UTC, 'Nh ago' / 'Nd ago' / 'Nw ago', 'now', 'yesterday', 'today', or 'YYYY-MM-DD'" },
|
|
610
|
+
end_time: { type: "string", description: "Same formats as start_time" },
|
|
611
|
+
app_name: { type: "string", description: "Filter by exact app name (case-sensitive, e.g. 'Google Chrome')" },
|
|
567
612
|
limit: { type: "integer", description: "Max results (default 20)", default: 20 },
|
|
568
613
|
offset: { type: "integer", description: "Pagination offset", default: 0 },
|
|
614
|
+
fuzzy_match: { type: "boolean", description: "Enable typo-tolerant matching", default: false },
|
|
569
615
|
},
|
|
570
616
|
required: ["q"],
|
|
571
617
|
},
|
|
@@ -598,8 +644,74 @@ const TOOLS: Tool[] = [
|
|
|
598
644
|
},
|
|
599
645
|
];
|
|
600
646
|
|
|
647
|
+
// ---------------------------------------------------------------------------
|
|
648
|
+
// Enterprise team tools — registered only when a team API token is present.
|
|
649
|
+
// Same endpoint surface as the desktop `screenpipe-team` pi-agent skill:
|
|
650
|
+
// proxy GETs to https://screenpi.pe/api/enterprise/v1/* with Bearer auth.
|
|
651
|
+
//
|
|
652
|
+
// Naming convention: every team tool is `team-*` so it's obvious at a glance
|
|
653
|
+
// which scope (just-me vs the-whole-org) any given call is hitting.
|
|
654
|
+
// ---------------------------------------------------------------------------
|
|
655
|
+
const TEAM_TOOLS: Tool[] = [
|
|
656
|
+
{
|
|
657
|
+
name: "team-search",
|
|
658
|
+
description:
|
|
659
|
+
"Substring-search across the ENTIRE ORG's telemetry (every enrolled " +
|
|
660
|
+
"device). Use when the question is about the team or another teammate " +
|
|
661
|
+
"(\"what did engineering work on yesterday\", \"did alice touch the auth code\"). " +
|
|
662
|
+
"For your own machine only, use search-content. " +
|
|
663
|
+
"Auth: enterprise admin token (sk_ent_…). " +
|
|
664
|
+
"Defaults: since=now-24h, limit=50. Returns matched records with device + timestamp.",
|
|
665
|
+
annotations: { title: "Team Search", readOnlyHint: true, openWorldHint: true, idempotentHint: true },
|
|
666
|
+
inputSchema: {
|
|
667
|
+
type: "object",
|
|
668
|
+
properties: {
|
|
669
|
+
q: { type: "string", description: "Substring to match (case-insensitive). Empty = all records in window." },
|
|
670
|
+
device_id: { type: "string", description: "Restrict to one device. Get the ID from team-devices." },
|
|
671
|
+
app_name: { type: "string", description: "Restrict to records whose app_name equals this (case-insensitive)." },
|
|
672
|
+
since: { type: "string", description: "ISO 8601 lower bound. Default = now - 24h." },
|
|
673
|
+
until: { type: "string", description: "ISO 8601 upper bound. Default = now." },
|
|
674
|
+
since_hours_ago: { type: "integer", description: "Convenience: equivalent to since=now-N*h." },
|
|
675
|
+
limit: { type: "integer", description: "Max records (default 50, max 200).", default: 50 },
|
|
676
|
+
},
|
|
677
|
+
},
|
|
678
|
+
},
|
|
679
|
+
{
|
|
680
|
+
name: "team-devices",
|
|
681
|
+
description:
|
|
682
|
+
"List all devices enrolled under this org's license — hostname, OS, " +
|
|
683
|
+
"app version, last-seen timestamp. Use to discover device IDs to pass " +
|
|
684
|
+
"to team-search or team-records, or to spot stale machines.",
|
|
685
|
+
annotations: { title: "Team Devices", readOnlyHint: true, openWorldHint: true, idempotentHint: true },
|
|
686
|
+
inputSchema: { type: "object", properties: {} },
|
|
687
|
+
},
|
|
688
|
+
{
|
|
689
|
+
name: "team-records",
|
|
690
|
+
description:
|
|
691
|
+
"Chronological raw dump of the org's telemetry for a time window. " +
|
|
692
|
+
"Returns oldest → newest (vs team-search which is recency-ranked). " +
|
|
693
|
+
"Use for ETL or \"walk me through X from Y to Z\" — NOT for question-answering, use team-search for that. " +
|
|
694
|
+
"Auth: enterprise admin token.",
|
|
695
|
+
annotations: { title: "Team Records", readOnlyHint: true, openWorldHint: true, idempotentHint: true },
|
|
696
|
+
inputSchema: {
|
|
697
|
+
type: "object",
|
|
698
|
+
properties: {
|
|
699
|
+
device_id: { type: "string", description: "Restrict to one device (optional)." },
|
|
700
|
+
kind: { type: "string", enum: ["frame", "audio", "all"], description: "Record kind filter. Default: all.", default: "all" },
|
|
701
|
+
since: { type: "string", description: "ISO 8601 lower bound." },
|
|
702
|
+
until: { type: "string", description: "ISO 8601 upper bound." },
|
|
703
|
+
since_hours_ago: { type: "integer", description: "Convenience: equivalent to since=now-N*h." },
|
|
704
|
+
limit: { type: "integer", description: "Max records (default 50, max 200).", default: 50 },
|
|
705
|
+
},
|
|
706
|
+
},
|
|
707
|
+
},
|
|
708
|
+
];
|
|
709
|
+
|
|
601
710
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
602
|
-
|
|
711
|
+
// Team tools only surface when an enterprise token was discovered at boot.
|
|
712
|
+
// No token = consumer / non-admin user; their MCP looks identical to today.
|
|
713
|
+
const tools = TEAM_TOKEN ? [...TOOLS, ...TEAM_TOOLS] : TOOLS;
|
|
714
|
+
return { tools };
|
|
603
715
|
});
|
|
604
716
|
|
|
605
717
|
// ---------------------------------------------------------------------------
|
|
@@ -686,7 +798,7 @@ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
|
686
798
|
- **Use max_content_length=500** to keep responses compact
|
|
687
799
|
- **Don't use q for audio** — transcriptions are noisy, q filters too aggressively. Search audio by time range and speaker instead
|
|
688
800
|
- **app_name is case-sensitive** — use exact names: "Google Chrome" not "chrome"
|
|
689
|
-
- **
|
|
801
|
+
- **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
|
|
690
802
|
|
|
691
803
|
## Common Patterns
|
|
692
804
|
|
|
@@ -712,21 +824,154 @@ Never fabricate IDs or timestamps — only use values from actual results.
|
|
|
712
824
|
});
|
|
713
825
|
|
|
714
826
|
// ---------------------------------------------------------------------------
|
|
715
|
-
//
|
|
827
|
+
// Helpers
|
|
716
828
|
// ---------------------------------------------------------------------------
|
|
829
|
+
|
|
830
|
+
// Thrown by fetchAPI / callAPI when the backend is unreachable. Caught in the
|
|
831
|
+
// tool dispatcher to surface an actionable hint ("backend not running")
|
|
832
|
+
// instead of the opaque "fetch failed" the model used to see.
|
|
833
|
+
class BackendDownError extends Error {
|
|
834
|
+
constructor(public readonly cause: unknown) {
|
|
835
|
+
super(
|
|
836
|
+
`screenpipe backend not running on ${SCREENPIPE_API}. ` +
|
|
837
|
+
`Start it with \`screenpipe\` in a terminal, or open the screenpipe desktop app.`,
|
|
838
|
+
);
|
|
839
|
+
this.name = "BackendDownError";
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// Thrown when the backend returns a non-2xx. Carries the server's response
|
|
844
|
+
// body so the dispatcher can include it in the user-visible error message.
|
|
845
|
+
class BackendHttpError extends Error {
|
|
846
|
+
constructor(
|
|
847
|
+
public readonly status: number,
|
|
848
|
+
public readonly bodyText: string,
|
|
849
|
+
endpoint: string,
|
|
850
|
+
) {
|
|
851
|
+
let hint = "";
|
|
852
|
+
if (status === 401 || status === 403) {
|
|
853
|
+
hint =
|
|
854
|
+
" — API key not accepted. Set SCREENPIPE_LOCAL_API_KEY in your MCP " +
|
|
855
|
+
"launcher env, or install the screenpipe desktop app so the MCP can " +
|
|
856
|
+
"discover the key automatically.";
|
|
857
|
+
} else if (status === 404) {
|
|
858
|
+
hint =
|
|
859
|
+
" — endpoint not found. The backend may be on a different version than this MCP.";
|
|
860
|
+
} else if (status === 400) {
|
|
861
|
+
hint = " — bad request. Check argument names and types against the tool schema.";
|
|
862
|
+
} else if (status >= 500) {
|
|
863
|
+
hint = " — backend error. Check screenpipe logs.";
|
|
864
|
+
}
|
|
865
|
+
const trimmed = bodyText.trim().slice(0, 300);
|
|
866
|
+
const bodyPart = trimmed ? ` body: ${trimmed}` : "";
|
|
867
|
+
super(`HTTP ${status} from ${endpoint}${hint}${bodyPart}`);
|
|
868
|
+
this.name = "BackendHttpError";
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
717
872
|
async function fetchAPI(
|
|
718
873
|
endpoint: string,
|
|
719
874
|
options: RequestInit = {}
|
|
720
875
|
): Promise<Response> {
|
|
721
876
|
const url = `${SCREENPIPE_API}${endpoint}`;
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
877
|
+
try {
|
|
878
|
+
return await fetch(url, {
|
|
879
|
+
...options,
|
|
880
|
+
headers: {
|
|
881
|
+
"Content-Type": "application/json",
|
|
882
|
+
...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}),
|
|
883
|
+
...options.headers,
|
|
884
|
+
},
|
|
885
|
+
});
|
|
886
|
+
} catch (e) {
|
|
887
|
+
throw new BackendDownError(e);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// Wrap a fetchAPI call: throw BackendHttpError on non-2xx with body included.
|
|
892
|
+
// Use from handlers instead of `if (!response.ok) throw new Error(...)`.
|
|
893
|
+
async function callAPI(endpoint: string, options: RequestInit = {}): Promise<Response> {
|
|
894
|
+
const response = await fetchAPI(endpoint, options);
|
|
895
|
+
if (!response.ok) {
|
|
896
|
+
let body = "";
|
|
897
|
+
try {
|
|
898
|
+
body = await response.text();
|
|
899
|
+
} catch {
|
|
900
|
+
// body may not be readable; that's fine
|
|
901
|
+
}
|
|
902
|
+
throw new BackendHttpError(response.status, body, endpoint);
|
|
903
|
+
}
|
|
904
|
+
return response;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// Server's deserialize_flexible_datetime accepts ISO 8601 + "Nh ago" / "Nd ago"
|
|
908
|
+
// / "Nw ago" / "now". Models also try "yesterday", "today", and bare dates
|
|
909
|
+
// ("2026-05-17") — normalize those here so the request doesn't 400.
|
|
910
|
+
function normalizeTime(input: string | undefined): string | undefined {
|
|
911
|
+
if (!input) return input;
|
|
912
|
+
const s = input.trim();
|
|
913
|
+
if (!s) return input;
|
|
914
|
+
const lower = s.toLowerCase();
|
|
915
|
+
if (lower === "yesterday") return "1d ago";
|
|
916
|
+
if (lower === "today") {
|
|
917
|
+
return `${new Date().toISOString().split("T")[0]}T00:00:00Z`;
|
|
918
|
+
}
|
|
919
|
+
if (lower === "tomorrow") {
|
|
920
|
+
const t = new Date();
|
|
921
|
+
t.setUTCDate(t.getUTCDate() + 1);
|
|
922
|
+
return `${t.toISOString().split("T")[0]}T00:00:00Z`;
|
|
923
|
+
}
|
|
924
|
+
// Bare YYYY-MM-DD → start of day UTC
|
|
925
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return `${s}T00:00:00Z`;
|
|
926
|
+
return s;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// Apply normalizeTime to start_time/end_time fields in an args object.
|
|
930
|
+
// Returns a new object — does not mutate the input.
|
|
931
|
+
function normalizeTimeFields(
|
|
932
|
+
args: Record<string, unknown>,
|
|
933
|
+
): Record<string, unknown> {
|
|
934
|
+
const out = { ...args };
|
|
935
|
+
for (const k of ["start_time", "end_time"] as const) {
|
|
936
|
+
if (typeof out[k] === "string") {
|
|
937
|
+
out[k] = normalizeTime(out[k] as string);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
return out;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// Middle-truncate long strings: keep head + tail, mark the gap with how much
|
|
944
|
+
// was cut. Used to cap OCR/transcription text in search-content responses
|
|
945
|
+
// so a single call doesn't blow past Claude Code's per-tool output limit
|
|
946
|
+
// (one logged call returned 131k chars from a limit:10 search).
|
|
947
|
+
function truncateMiddle(text: string | null | undefined, max: number): string {
|
|
948
|
+
if (!text) return text ?? "";
|
|
949
|
+
if (max <= 0 || text.length <= max) return text;
|
|
950
|
+
const halfLeft = Math.floor(max / 2);
|
|
951
|
+
const halfRight = max - halfLeft;
|
|
952
|
+
const cut = text.length - max;
|
|
953
|
+
return (
|
|
954
|
+
text.slice(0, halfLeft) +
|
|
955
|
+
`…[${cut} chars truncated — pass max_content_length=0 for full text]…` +
|
|
956
|
+
text.slice(text.length - halfRight)
|
|
957
|
+
);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// Default per-result text cap for search-content when the caller didn't
|
|
961
|
+
// specify one. Tuned to keep limit=10 responses well under tool-output limits
|
|
962
|
+
// while still giving the model enough text to reason over.
|
|
963
|
+
const DEFAULT_SEARCH_CONTENT_TRUNCATE = 1000;
|
|
964
|
+
|
|
965
|
+
// Format the screen-text tag for a result. The server's `text_source` is
|
|
966
|
+
// "accessibility" (OS-native tree, primary path) or "ocr" (fallback for
|
|
967
|
+
// terminals, canvas, weak a11y). Older rows have no text_source, so we
|
|
968
|
+
// fall back to a bare `[Screen]`. The result type is historically called
|
|
969
|
+
// OCR in the engine but most captures are accessibility-derived — surface
|
|
970
|
+
// the actual source so the model picks filters correctly.
|
|
971
|
+
function screenTag(textSource: unknown): string {
|
|
972
|
+
if (textSource === "accessibility") return "[Screen·a11y]";
|
|
973
|
+
if (textSource === "ocr") return "[Screen·ocr]";
|
|
974
|
+
return "[Screen]";
|
|
730
975
|
}
|
|
731
976
|
|
|
732
977
|
// ---------------------------------------------------------------------------
|
|
@@ -743,16 +988,24 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
743
988
|
switch (name) {
|
|
744
989
|
case "search-content": {
|
|
745
990
|
const includeFrames = args.include_frames === true;
|
|
991
|
+
const normalized = normalizeTimeFields(args);
|
|
992
|
+
// Default text cap if the caller didn't pass max_content_length.
|
|
993
|
+
// Keeps single calls under Claude Code's per-tool output limit.
|
|
994
|
+
const userCap = normalized.max_content_length;
|
|
995
|
+
const effectiveCap =
|
|
996
|
+
typeof userCap === "number"
|
|
997
|
+
? userCap
|
|
998
|
+
: userCap === undefined
|
|
999
|
+
? DEFAULT_SEARCH_CONTENT_TRUNCATE
|
|
1000
|
+
: Number(userCap);
|
|
746
1001
|
const params = new URLSearchParams();
|
|
747
|
-
for (const [key, value] of Object.entries(
|
|
1002
|
+
for (const [key, value] of Object.entries(normalized)) {
|
|
748
1003
|
if (value !== null && value !== undefined) {
|
|
749
1004
|
params.append(key, String(value));
|
|
750
1005
|
}
|
|
751
1006
|
}
|
|
752
1007
|
|
|
753
|
-
const response = await
|
|
754
|
-
if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
|
|
755
|
-
|
|
1008
|
+
const response = await callAPI(`/search?${params.toString()}`);
|
|
756
1009
|
const data = await response.json();
|
|
757
1010
|
const results = data.data || [];
|
|
758
1011
|
const pagination = data.pagination || {};
|
|
@@ -782,10 +1035,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
782
1035
|
|
|
783
1036
|
if (result.type === "OCR") {
|
|
784
1037
|
const tagsStr = content.tags?.length ? `\nTags: ${content.tags.join(", ")}` : "";
|
|
1038
|
+
// result.type is "OCR" by historical naming, but content.text_source
|
|
1039
|
+
// tells us if the text actually came from the accessibility tree
|
|
1040
|
+
// (primary path) or OCR (fallback). Use it to label honestly.
|
|
1041
|
+
const tag = screenTag(content.text_source);
|
|
785
1042
|
formattedResults.push(
|
|
786
|
-
|
|
1043
|
+
`${tag} ${content.app_name || "?"} | ${content.window_name || "?"}\n` +
|
|
787
1044
|
`${content.timestamp || ""}\n` +
|
|
788
|
-
`${content.text || ""}` +
|
|
1045
|
+
`${truncateMiddle(content.text || "", effectiveCap)}` +
|
|
789
1046
|
tagsStr
|
|
790
1047
|
);
|
|
791
1048
|
if (includeFrames && content.frame) {
|
|
@@ -799,14 +1056,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
799
1056
|
formattedResults.push(
|
|
800
1057
|
`[Audio] ${content.device_name || "?"}\n` +
|
|
801
1058
|
`${content.timestamp || ""}\n` +
|
|
802
|
-
`${content.transcription || ""}` +
|
|
1059
|
+
`${truncateMiddle(content.transcription || "", effectiveCap)}` +
|
|
803
1060
|
tagsStr
|
|
804
1061
|
);
|
|
805
1062
|
} else if (result.type === "UI" || result.type === "Accessibility") {
|
|
806
1063
|
formattedResults.push(
|
|
807
1064
|
`[Accessibility] ${content.app_name || "?"} | ${content.window_name || "?"}\n` +
|
|
808
1065
|
`${content.timestamp || ""}\n` +
|
|
809
|
-
`${content.text || ""}`
|
|
1066
|
+
`${truncateMiddle(content.text || "", effectiveCap)}`
|
|
810
1067
|
);
|
|
811
1068
|
} else if (result.type === "Memory") {
|
|
812
1069
|
const tagsStr = content.tags?.length ? ` [${content.tags.join(", ")}]` : "";
|
|
@@ -815,7 +1072,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
815
1072
|
formattedResults.push(
|
|
816
1073
|
`[Memory #${content.id}]${tagsStr}${importance}\n` +
|
|
817
1074
|
`${content.created_at || ""}\n` +
|
|
818
|
-
`${content.content || ""}`
|
|
1075
|
+
`${truncateMiddle(content.content || "", effectiveCap)}`
|
|
819
1076
|
);
|
|
820
1077
|
}
|
|
821
1078
|
}
|
|
@@ -840,15 +1097,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
840
1097
|
}
|
|
841
1098
|
|
|
842
1099
|
case "list-meetings": {
|
|
1100
|
+
const normalized = normalizeTimeFields(args);
|
|
843
1101
|
const params = new URLSearchParams();
|
|
844
|
-
for (const [key, value] of Object.entries(
|
|
1102
|
+
for (const [key, value] of Object.entries(normalized)) {
|
|
845
1103
|
if (value !== null && value !== undefined) {
|
|
846
1104
|
params.append(key, String(value));
|
|
847
1105
|
}
|
|
848
1106
|
}
|
|
849
1107
|
|
|
850
|
-
const response = await
|
|
851
|
-
if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
|
|
1108
|
+
const response = await callAPI(`/meetings?${params.toString()}`);
|
|
852
1109
|
|
|
853
1110
|
const meetings = await response.json();
|
|
854
1111
|
|
|
@@ -875,15 +1132,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
875
1132
|
}
|
|
876
1133
|
|
|
877
1134
|
case "activity-summary": {
|
|
1135
|
+
const normalized = normalizeTimeFields(args);
|
|
878
1136
|
const params = new URLSearchParams();
|
|
879
|
-
for (const [key, value] of Object.entries(
|
|
1137
|
+
for (const [key, value] of Object.entries(normalized)) {
|
|
880
1138
|
if (value !== null && value !== undefined) {
|
|
881
1139
|
params.append(key, String(value));
|
|
882
1140
|
}
|
|
883
1141
|
}
|
|
884
1142
|
|
|
885
|
-
const response = await
|
|
886
|
-
if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
|
|
1143
|
+
const response = await callAPI(`/activity-summary?${params.toString()}`);
|
|
887
1144
|
|
|
888
1145
|
const data = await response.json();
|
|
889
1146
|
|
|
@@ -958,15 +1215,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
958
1215
|
}
|
|
959
1216
|
|
|
960
1217
|
case "search-elements": {
|
|
1218
|
+
const normalized = normalizeTimeFields(args);
|
|
961
1219
|
const params = new URLSearchParams();
|
|
962
|
-
for (const [key, value] of Object.entries(
|
|
1220
|
+
for (const [key, value] of Object.entries(normalized)) {
|
|
963
1221
|
if (value !== null && value !== undefined) {
|
|
964
1222
|
params.append(key, String(value));
|
|
965
1223
|
}
|
|
966
1224
|
}
|
|
967
1225
|
|
|
968
|
-
const response = await
|
|
969
|
-
if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
|
|
1226
|
+
const response = await callAPI(`/elements?${params.toString()}`);
|
|
970
1227
|
|
|
971
1228
|
const data = await response.json();
|
|
972
1229
|
const elements = data.data || [];
|
|
@@ -1017,8 +1274,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1017
1274
|
return { content: [{ type: "text", text: "Error: frame_id is required" }] };
|
|
1018
1275
|
}
|
|
1019
1276
|
|
|
1020
|
-
const response = await
|
|
1021
|
-
if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
|
|
1277
|
+
const response = await callAPI(`/frames/${frameId}/context`);
|
|
1022
1278
|
|
|
1023
1279
|
const data = await response.json();
|
|
1024
1280
|
const lines = [`Frame ${data.frame_id} (source: ${data.text_source})`];
|
|
@@ -1048,8 +1304,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1048
1304
|
}
|
|
1049
1305
|
|
|
1050
1306
|
case "export-video": {
|
|
1051
|
-
const startTime = args.start_time as string;
|
|
1052
|
-
const endTime = args.end_time as string;
|
|
1307
|
+
const startTime = normalizeTime(args.start_time as string);
|
|
1308
|
+
const endTime = normalizeTime(args.end_time as string);
|
|
1053
1309
|
const fps = (args.fps as number) || 1.0;
|
|
1054
1310
|
|
|
1055
1311
|
if (!startTime || !endTime) {
|
|
@@ -1066,11 +1322,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1066
1322
|
limit: "10000",
|
|
1067
1323
|
});
|
|
1068
1324
|
|
|
1069
|
-
const searchResponse = await
|
|
1070
|
-
if (!searchResponse.ok) {
|
|
1071
|
-
throw new Error(`Failed to search for frames: HTTP ${searchResponse.status}`);
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1325
|
+
const searchResponse = await callAPI(`/search?${searchParams.toString()}`);
|
|
1074
1326
|
const searchData = await searchResponse.json();
|
|
1075
1327
|
const results = searchData.data || [];
|
|
1076
1328
|
|
|
@@ -1189,9 +1441,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1189
1441
|
|
|
1190
1442
|
case "update-memory": {
|
|
1191
1443
|
if (args.delete && args.id) {
|
|
1192
|
-
const response = await
|
|
1193
|
-
|
|
1194
|
-
return { content: [{ type: "text", text: `Memory ${args.id} deleted.` }] };
|
|
1444
|
+
const response = await callAPI(`/memories/${args.id}`, { method: "DELETE" });
|
|
1445
|
+
return { content: [{ type: "text", text: `Memory ${args.id} deleted.` }] };
|
|
1195
1446
|
}
|
|
1196
1447
|
if (args.id) {
|
|
1197
1448
|
const body: Record<string, unknown> = {};
|
|
@@ -1199,12 +1450,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1199
1450
|
if (args.tags !== undefined) body.tags = args.tags;
|
|
1200
1451
|
if (args.importance !== undefined) body.importance = args.importance;
|
|
1201
1452
|
if (args.source_context !== undefined) body.source_context = args.source_context;
|
|
1202
|
-
const response = await
|
|
1453
|
+
const response = await callAPI(`/memories/${args.id}`, {
|
|
1203
1454
|
method: "PUT",
|
|
1204
1455
|
body: JSON.stringify(body),
|
|
1205
1456
|
});
|
|
1206
|
-
|
|
1207
|
-
const memory = await response.json();
|
|
1457
|
+
const memory = await response.json();
|
|
1208
1458
|
return {
|
|
1209
1459
|
content: [{ type: "text", text: `Memory ${memory.id} updated: "${memory.content}"` }],
|
|
1210
1460
|
};
|
|
@@ -1221,11 +1471,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1221
1471
|
importance: args.importance ?? 0.5,
|
|
1222
1472
|
};
|
|
1223
1473
|
if (args.source_context) memoryBody.source_context = args.source_context;
|
|
1224
|
-
const memoryResponse = await
|
|
1474
|
+
const memoryResponse = await callAPI("/memories", {
|
|
1225
1475
|
method: "POST",
|
|
1226
1476
|
body: JSON.stringify(memoryBody),
|
|
1227
1477
|
});
|
|
1228
|
-
if (!memoryResponse.ok) throw new Error(`HTTP error: ${memoryResponse.status}`);
|
|
1229
1478
|
const newMemory = await memoryResponse.json();
|
|
1230
1479
|
return {
|
|
1231
1480
|
content: [
|
|
@@ -1242,12 +1491,26 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1242
1491
|
};
|
|
1243
1492
|
if (args.timeout_secs) notifBody.timeout = Number(args.timeout_secs) * 1000;
|
|
1244
1493
|
if (args.actions) notifBody.actions = args.actions;
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1494
|
+
// send-notification hits the desktop notify daemon on a separate port
|
|
1495
|
+
// (11435), not the screenpipe API. Keep direct fetch with friendlier
|
|
1496
|
+
// error so the model sees an actionable message if the daemon's down.
|
|
1497
|
+
let notifResponse: Response;
|
|
1498
|
+
try {
|
|
1499
|
+
notifResponse = await fetch("http://localhost:11435/notify", {
|
|
1500
|
+
method: "POST",
|
|
1501
|
+
headers: { "Content-Type": "application/json" },
|
|
1502
|
+
body: JSON.stringify(notifBody),
|
|
1503
|
+
});
|
|
1504
|
+
} catch (e) {
|
|
1505
|
+
throw new Error(
|
|
1506
|
+
"notification daemon not reachable on localhost:11435 — is the screenpipe desktop app running?",
|
|
1507
|
+
);
|
|
1508
|
+
}
|
|
1509
|
+
if (!notifResponse.ok) {
|
|
1510
|
+
let body = "";
|
|
1511
|
+
try { body = await notifResponse.text(); } catch {}
|
|
1512
|
+
throw new Error(`notify daemon HTTP ${notifResponse.status}${body ? `: ${body.slice(0, 200)}` : ""}`);
|
|
1513
|
+
}
|
|
1251
1514
|
const notifResult = await notifResponse.json();
|
|
1252
1515
|
return {
|
|
1253
1516
|
content: [{ type: "text", text: `Notification sent: ${notifResult.message}` }],
|
|
@@ -1255,8 +1518,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1255
1518
|
}
|
|
1256
1519
|
|
|
1257
1520
|
case "health-check": {
|
|
1258
|
-
const response = await
|
|
1259
|
-
if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
|
|
1521
|
+
const response = await callAPI("/health");
|
|
1260
1522
|
const data = await response.json();
|
|
1261
1523
|
return {
|
|
1262
1524
|
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
@@ -1264,8 +1526,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1264
1526
|
}
|
|
1265
1527
|
|
|
1266
1528
|
case "list-audio-devices": {
|
|
1267
|
-
const response = await
|
|
1268
|
-
if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
|
|
1529
|
+
const response = await callAPI("/audio/list");
|
|
1269
1530
|
const devices = await response.json();
|
|
1270
1531
|
if (!Array.isArray(devices) || devices.length === 0) {
|
|
1271
1532
|
return { content: [{ type: "text", text: "No audio devices found." }] };
|
|
@@ -1280,8 +1541,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1280
1541
|
}
|
|
1281
1542
|
|
|
1282
1543
|
case "list-monitors": {
|
|
1283
|
-
const response = await
|
|
1284
|
-
if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
|
|
1544
|
+
const response = await callAPI("/vision/list");
|
|
1285
1545
|
const monitors = await response.json();
|
|
1286
1546
|
if (!Array.isArray(monitors) || monitors.length === 0) {
|
|
1287
1547
|
return { content: [{ type: "text", text: "No monitors found." }] };
|
|
@@ -1302,11 +1562,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1302
1562
|
if (!contentType || !id || !tags) {
|
|
1303
1563
|
return { content: [{ type: "text", text: "Error: content_type, id, and tags are required" }] };
|
|
1304
1564
|
}
|
|
1305
|
-
const response = await
|
|
1565
|
+
const response = await callAPI(`/tags/${contentType}/${id}`, {
|
|
1306
1566
|
method: "POST",
|
|
1307
1567
|
body: JSON.stringify({ tags }),
|
|
1308
1568
|
});
|
|
1309
|
-
if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
|
|
1310
1569
|
return {
|
|
1311
1570
|
content: [{ type: "text", text: `Tags added to ${contentType}/${id}: ${tags.join(", ")}` }],
|
|
1312
1571
|
};
|
|
@@ -1317,8 +1576,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1317
1576
|
if (!nameQuery) {
|
|
1318
1577
|
return { content: [{ type: "text", text: "Error: name is required" }] };
|
|
1319
1578
|
}
|
|
1320
|
-
const response = await
|
|
1321
|
-
if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
|
|
1579
|
+
const response = await callAPI(`/speakers/search?name=${encodeURIComponent(nameQuery)}`);
|
|
1322
1580
|
const speakers = await response.json();
|
|
1323
1581
|
if (!Array.isArray(speakers) || speakers.length === 0) {
|
|
1324
1582
|
return { content: [{ type: "text", text: "No speakers found." }] };
|
|
@@ -1335,8 +1593,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1335
1593
|
case "list-unnamed-speakers": {
|
|
1336
1594
|
const limit = (args.limit as number) || 10;
|
|
1337
1595
|
const offset = (args.offset as number) || 0;
|
|
1338
|
-
const response = await
|
|
1339
|
-
if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
|
|
1596
|
+
const response = await callAPI(`/speakers/unnamed?limit=${limit}&offset=${offset}`);
|
|
1340
1597
|
const speakers = await response.json();
|
|
1341
1598
|
if (!Array.isArray(speakers) || speakers.length === 0) {
|
|
1342
1599
|
return { content: [{ type: "text", text: "No unnamed speakers found." }] };
|
|
@@ -1357,11 +1614,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1357
1614
|
const body: Record<string, unknown> = { id: speakerId };
|
|
1358
1615
|
if (args.name !== undefined) body.name = args.name;
|
|
1359
1616
|
if (args.metadata !== undefined) body.metadata = args.metadata;
|
|
1360
|
-
const response = await
|
|
1617
|
+
const response = await callAPI("/speakers/update", {
|
|
1361
1618
|
method: "POST",
|
|
1362
1619
|
body: JSON.stringify(body),
|
|
1363
1620
|
});
|
|
1364
|
-
if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
|
|
1365
1621
|
return {
|
|
1366
1622
|
content: [{ type: "text", text: `Speaker ${speakerId} updated.` }],
|
|
1367
1623
|
};
|
|
@@ -1373,11 +1629,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1373
1629
|
if (!keepId || !mergeId) {
|
|
1374
1630
|
return { content: [{ type: "text", text: "Error: speaker_to_keep_id and speaker_to_merge_id are required" }] };
|
|
1375
1631
|
}
|
|
1376
|
-
const response = await
|
|
1632
|
+
const response = await callAPI("/speakers/merge", {
|
|
1377
1633
|
method: "POST",
|
|
1378
1634
|
body: JSON.stringify({ speaker_to_keep_id: keepId, speaker_to_merge_id: mergeId }),
|
|
1379
1635
|
});
|
|
1380
|
-
if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
|
|
1381
1636
|
return {
|
|
1382
1637
|
content: [{ type: "text", text: `Merged speaker ${mergeId} into ${keepId}.` }],
|
|
1383
1638
|
};
|
|
@@ -1388,11 +1643,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1388
1643
|
if (args.app) body.app = args.app;
|
|
1389
1644
|
if (args.title) body.title = args.title;
|
|
1390
1645
|
if (args.attendees) body.attendees = args.attendees;
|
|
1391
|
-
const response = await
|
|
1646
|
+
const response = await callAPI("/meetings/start", {
|
|
1392
1647
|
method: "POST",
|
|
1393
1648
|
body: JSON.stringify(body),
|
|
1394
1649
|
});
|
|
1395
|
-
if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
|
|
1396
1650
|
const meeting = await response.json();
|
|
1397
1651
|
return {
|
|
1398
1652
|
content: [{ type: "text", text: `Meeting started (id: ${meeting.id || "ok"}).` }],
|
|
@@ -1400,8 +1654,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1400
1654
|
}
|
|
1401
1655
|
|
|
1402
1656
|
case "stop-meeting": {
|
|
1403
|
-
const response = await
|
|
1404
|
-
if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
|
|
1657
|
+
const response = await callAPI("/meetings/stop", { method: "POST" });
|
|
1405
1658
|
return {
|
|
1406
1659
|
content: [{ type: "text", text: "Meeting stopped." }],
|
|
1407
1660
|
};
|
|
@@ -1412,8 +1665,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1412
1665
|
if (!meetingId) {
|
|
1413
1666
|
return { content: [{ type: "text", text: "Error: id is required" }] };
|
|
1414
1667
|
}
|
|
1415
|
-
const response = await
|
|
1416
|
-
if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
|
|
1668
|
+
const response = await callAPI(`/meetings/${meetingId}`);
|
|
1417
1669
|
const meeting = await response.json();
|
|
1418
1670
|
return {
|
|
1419
1671
|
content: [{ type: "text", text: JSON.stringify(meeting, null, 2) }],
|
|
@@ -1440,12 +1692,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1440
1692
|
],
|
|
1441
1693
|
};
|
|
1442
1694
|
}
|
|
1443
|
-
const response = await
|
|
1695
|
+
const response = await callAPI(`/meetings/${meetingId}`, {
|
|
1444
1696
|
method: "PATCH",
|
|
1445
1697
|
headers: { "Content-Type": "application/json" },
|
|
1446
1698
|
body: JSON.stringify(body),
|
|
1447
1699
|
});
|
|
1448
|
-
if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
|
|
1449
1700
|
const updated = await response.json();
|
|
1450
1701
|
return {
|
|
1451
1702
|
content: [{ type: "text", text: JSON.stringify(updated, null, 2) }],
|
|
@@ -1453,22 +1704,52 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1453
1704
|
}
|
|
1454
1705
|
|
|
1455
1706
|
case "keyword-search": {
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1707
|
+
// Translate model-facing arg names to what the engine actually
|
|
1708
|
+
// accepts (KeywordSearchRequest in routes/search.rs):
|
|
1709
|
+
// q -> query (mandatory; the field is literally named `query`)
|
|
1710
|
+
// app_name -> app_names (comma-separated; serde splits it)
|
|
1711
|
+
// content_type: dropped — the keyword endpoint doesn't filter by type.
|
|
1712
|
+
// It searches OCR + audio together via the FTS index.
|
|
1713
|
+
// Without these mappings every keyword-search request 400s (and used
|
|
1714
|
+
// to: in logs, 25/25 calls failed before this fix).
|
|
1715
|
+
const queryStr = (args.query as string) ?? (args.q as string);
|
|
1716
|
+
if (!queryStr) {
|
|
1717
|
+
return {
|
|
1718
|
+
content: [{ type: "text", text: "Error: 'q' (search query) is required" }],
|
|
1719
|
+
};
|
|
1461
1720
|
}
|
|
1462
|
-
const
|
|
1463
|
-
|
|
1721
|
+
const normalized = normalizeTimeFields(args);
|
|
1722
|
+
const params = new URLSearchParams();
|
|
1723
|
+
params.append("query", queryStr);
|
|
1724
|
+
if (normalized.start_time) params.append("start_time", String(normalized.start_time));
|
|
1725
|
+
if (normalized.end_time) params.append("end_time", String(normalized.end_time));
|
|
1726
|
+
if (normalized.limit !== undefined) params.append("limit", String(normalized.limit));
|
|
1727
|
+
if (normalized.offset !== undefined) params.append("offset", String(normalized.offset));
|
|
1728
|
+
if (normalized.app_name) params.append("app_names", String(normalized.app_name));
|
|
1729
|
+
if (normalized.app_names) params.append("app_names", String(normalized.app_names));
|
|
1730
|
+
if (args.fuzzy_match !== undefined) params.append("fuzzy_match", String(args.fuzzy_match));
|
|
1731
|
+
const response = await callAPI(`/search/keyword?${params.toString()}`);
|
|
1464
1732
|
const data = await response.json();
|
|
1465
|
-
|
|
1733
|
+
// /search/keyword returns a bare array (Vec<KeywordSearchMatch> from
|
|
1734
|
+
// routes/search.rs), not the {data, pagination} shape /search uses.
|
|
1735
|
+
// The old `data.data || []` always lost results.
|
|
1736
|
+
const results: Array<Record<string, unknown>> = Array.isArray(data)
|
|
1737
|
+
? data
|
|
1738
|
+
: (data.data ?? []);
|
|
1466
1739
|
if (results.length === 0) {
|
|
1467
1740
|
return { content: [{ type: "text", text: "No keyword search results found." }] };
|
|
1468
1741
|
}
|
|
1469
|
-
const formatted = results.map((r
|
|
1470
|
-
|
|
1471
|
-
|
|
1742
|
+
const formatted = results.map((r) => {
|
|
1743
|
+
// Flat shape from search_with_text_positions: { app_name, frame_id,
|
|
1744
|
+
// timestamp, text, text_source, ... }. Truncate to keep responses
|
|
1745
|
+
// under tool-output limits. text_source is "accessibility" (primary)
|
|
1746
|
+
// or "ocr" (fallback) — show it so the model knows which path hit.
|
|
1747
|
+
const text = (r.text as string) || (r.transcription as string) || "";
|
|
1748
|
+
const tag = screenTag(r.text_source);
|
|
1749
|
+
return (
|
|
1750
|
+
`${tag} [frame:${r.frame_id ?? "?"}] ${r.app_name ?? "?"} | ${r.timestamp ?? ""}\n` +
|
|
1751
|
+
truncateMiddle(text, DEFAULT_SEARCH_CONTENT_TRUNCATE)
|
|
1752
|
+
);
|
|
1472
1753
|
});
|
|
1473
1754
|
return {
|
|
1474
1755
|
content: [{ type: "text", text: `Results: ${results.length}\n\n${formatted.join("\n---\n")}` }],
|
|
@@ -1480,8 +1761,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1480
1761
|
if (!frameId) {
|
|
1481
1762
|
return { content: [{ type: "text", text: "Error: frame_id is required" }] };
|
|
1482
1763
|
}
|
|
1483
|
-
const response = await
|
|
1484
|
-
if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
|
|
1764
|
+
const response = await callAPI(`/frames/${frameId}/elements`);
|
|
1485
1765
|
const elements = await response.json();
|
|
1486
1766
|
if (!Array.isArray(elements) || elements.length === 0) {
|
|
1487
1767
|
return { content: [{ type: "text", text: `No elements found for frame ${frameId}.` }] };
|
|
@@ -1508,19 +1788,69 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1508
1788
|
else {
|
|
1509
1789
|
return { content: [{ type: "text", text: `Error: unknown action '${action}'` }] };
|
|
1510
1790
|
}
|
|
1511
|
-
|
|
1512
|
-
if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
|
|
1791
|
+
await callAPI(endpoint, { method: "POST" });
|
|
1513
1792
|
return {
|
|
1514
1793
|
content: [{ type: "text", text: `Recording action '${action}' executed.` }],
|
|
1515
1794
|
};
|
|
1516
1795
|
}
|
|
1517
1796
|
|
|
1797
|
+
// ---------------------------------------------------------------------
|
|
1798
|
+
// Enterprise team tools — only callable when TEAM_TOKEN is set at boot.
|
|
1799
|
+
// If we got this far without one, the tool wasn't in the listed set the
|
|
1800
|
+
// host saw, but a misbehaving client could still try to call it. Fail
|
|
1801
|
+
// loudly so the host surfaces the misconfiguration.
|
|
1802
|
+
// ---------------------------------------------------------------------
|
|
1803
|
+
case "team-search":
|
|
1804
|
+
case "team-devices":
|
|
1805
|
+
case "team-records": {
|
|
1806
|
+
if (!TEAM_TOKEN) {
|
|
1807
|
+
return {
|
|
1808
|
+
content: [
|
|
1809
|
+
{
|
|
1810
|
+
type: "text",
|
|
1811
|
+
text:
|
|
1812
|
+
`team-* tools require an enterprise admin token. Set ` +
|
|
1813
|
+
`SCREENPIPE_ENTERPRISE_TOKEN in your MCP env, or mint one ` +
|
|
1814
|
+
`at https://screenpi.pe/enterprise → API Tokens and paste ` +
|
|
1815
|
+
`it into Settings → Privacy → Admin Team API Token in the ` +
|
|
1816
|
+
`screenpipe desktop app.`,
|
|
1817
|
+
},
|
|
1818
|
+
],
|
|
1819
|
+
};
|
|
1820
|
+
}
|
|
1821
|
+
// Map MCP tool name → /api/enterprise/v1 path
|
|
1822
|
+
const subpath =
|
|
1823
|
+
name === "team-search" ? "/search"
|
|
1824
|
+
: name === "team-devices" ? "/devices"
|
|
1825
|
+
: "/records";
|
|
1826
|
+
// Forward every primitive arg as a query param. The server validates;
|
|
1827
|
+
// unknown params are ignored, so we don't need to gatekeep here.
|
|
1828
|
+
const params = new URLSearchParams();
|
|
1829
|
+
for (const [k, v] of Object.entries(args)) {
|
|
1830
|
+
if (v !== null && v !== undefined && v !== "") {
|
|
1831
|
+
params.append(k, String(v));
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
const query = params.toString();
|
|
1835
|
+
const response = await fetchTeam(`${subpath}${query ? `?${query}` : ""}`);
|
|
1836
|
+
const body = await response.text();
|
|
1837
|
+
if (!response.ok) {
|
|
1838
|
+
throw new Error(
|
|
1839
|
+
`${name} failed: HTTP ${response.status} ${response.statusText} — ${body.slice(0, 300)}`
|
|
1840
|
+
);
|
|
1841
|
+
}
|
|
1842
|
+
return { content: [{ type: "text", text: body }] };
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1518
1845
|
default:
|
|
1519
1846
|
throw new Error(`Unknown tool: ${name}`);
|
|
1520
1847
|
}
|
|
1521
1848
|
} catch (error) {
|
|
1522
1849
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
1850
|
+
// isError flags the result as a failure so the model retries with a
|
|
1851
|
+
// different approach instead of treating the error text as data.
|
|
1523
1852
|
return {
|
|
1853
|
+
isError: true,
|
|
1524
1854
|
content: [{ type: "text", text: `Error executing ${name}: ${errorMessage}` }],
|
|
1525
1855
|
};
|
|
1526
1856
|
}
|