screenpipe-mcp 0.18.2 → 0.18.5

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.
Files changed (4) hide show
  1. package/bun.lock +0 -11
  2. package/dist/index.js +434 -130
  3. package/package.json +1 -2
  4. 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
- "Returns timestamped results with app context. " +
224
- "IMPORTANT: prefer activity-summary for broad questions ('what was I doing?'). " +
225
- "Use search-content only when you need specific text/content. " +
226
- "Start with limit=5, increase only if needed. Results can be large use max_content_length=500 to truncate.",
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: "Filter by content type. 'accessibility' is preferred for screen text (OS-native). 'ocr' is fallback for apps without accessibility support. Default: 'all'.",
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 UTC or relative (e.g. '2h ago', '1d ago'). Always provide to avoid scanning entire history.",
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 THIS FIRST for broad questions: 'what was I doing?', 'how long on X?', 'which apps?'. " +
290
- "The 'windows' field shows exactly what the user worked on (e.g. 'Debug crash issue 20 min', 'Stripe pricing page — 5 min'). " +
291
- "Usually sufficient without further searches.",
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 questionsusually 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
- "Lighter than search-content for targeted UI lookups. " +
308
- "Use when you need to find specific UI controls or page structure, not general content.",
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 using FTS index. Faster than search-content for exact keyword matching. " +
557
- "Returns frame IDs and matched text.",
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 search query" },
563
- content_type: { type: "string", enum: ["ocr", "audio", "all"], description: "Content type filter", default: "all" },
564
- start_time: { type: "string", description: "ISO 8601 UTC or relative" },
565
- end_time: { type: "string", description: "ISO 8601 UTC or relative" },
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
- return { tools: TOOLS };
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
- - **content_type=accessibility is preferred** for screen text (OS-native). ocr is fallback for apps without accessibility support
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
- // Helper
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
- return fetch(url, {
723
- ...options,
724
- headers: {
725
- "Content-Type": "application/json",
726
- ...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}),
727
- ...options.headers,
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(args)) {
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 fetchAPI(`/search?${params.toString()}`);
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
- `[OCR] ${content.app_name || "?"} | ${content.window_name || "?"}\n` +
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(args)) {
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 fetchAPI(`/meetings?${params.toString()}`);
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(args)) {
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 fetchAPI(`/activity-summary?${params.toString()}`);
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(args)) {
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 fetchAPI(`/elements?${params.toString()}`);
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 fetchAPI(`/frames/${frameId}/context`);
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 fetchAPI(`/search?${searchParams.toString()}`);
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 fetchAPI(`/memories/${args.id}`, { method: "DELETE" });
1193
- if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
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 fetchAPI(`/memories/${args.id}`, {
1453
+ const response = await callAPI(`/memories/${args.id}`, {
1203
1454
  method: "PUT",
1204
1455
  body: JSON.stringify(body),
1205
1456
  });
1206
- if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
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 fetchAPI("/memories", {
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
- const notifResponse = await fetch("http://localhost:11435/notify", {
1246
- method: "POST",
1247
- headers: { "Content-Type": "application/json" },
1248
- body: JSON.stringify(notifBody),
1249
- });
1250
- if (!notifResponse.ok) throw new Error(`HTTP error: ${notifResponse.status}`);
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 fetchAPI("/health");
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 fetchAPI("/audio/list");
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 fetchAPI("/vision/list");
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 fetchAPI(`/tags/${contentType}/${id}`, {
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 fetchAPI(`/speakers/search?name=${encodeURIComponent(nameQuery)}`);
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 fetchAPI(`/speakers/unnamed?limit=${limit}&offset=${offset}`);
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 fetchAPI("/speakers/update", {
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 fetchAPI("/speakers/merge", {
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 fetchAPI("/meetings/start", {
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 fetchAPI("/meetings/stop", { method: "POST" });
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 fetchAPI(`/meetings/${meetingId}`);
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 fetchAPI(`/meetings/${meetingId}`, {
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
- const params = new URLSearchParams();
1457
- for (const [key, value] of Object.entries(args)) {
1458
- if (value !== null && value !== undefined) {
1459
- params.append(key, String(value));
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 response = await fetchAPI(`/search/keyword?${params.toString()}`);
1463
- if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
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
- const results = data.data || [];
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: Record<string, unknown>) => {
1470
- const content = r.content as Record<string, unknown> | undefined;
1471
- return `[${r.type}] ${content?.app_name || "?"} | ${content?.timestamp || ""}\n${content?.text || content?.transcription || ""}`;
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 fetchAPI(`/frames/${frameId}/elements`);
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
- const response = await fetchAPI(endpoint, { method: "POST" });
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
  }