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