screenpipe-mcp 0.20.2 → 0.20.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -32,8 +32,9 @@ import {
32
32
  } from "./qualified-value";
33
33
  import { discoverTeamConfig } from "./team-config";
34
34
  import { teamFrameContent, teamFramePath } from "./team-frame";
35
- import { WORKFLOW_TOOLS, readWorkflowTool, frameAutomationContent, inputEventContent } from "./workflow-tools";
35
+ import { WORKFLOW_TOOLS, readWorkflowTool, frameAutomationContent } from "./workflow-tools";
36
36
  import { PKG_VERSION } from "./version";
37
+ import { BUNDLED_SKILLS_TOOL, readBundledSkills } from "./bundled-skills";
37
38
  import { formatForElementPurpose } from "./element-format";
38
39
  import { buildActivitySummaryResult } from "./activity-summary-tool";
39
40
  import {
@@ -41,6 +42,8 @@ import {
41
42
  normalizeTime,
42
43
  normalizeTimeFields,
43
44
  } from "./time-normalization";
45
+ import { createCallObserver, currentMcpRequestId, observeMcpError, observeSearchResult } from "./call-observation";
46
+ import { formatSearchHit, searchContentCap, searchResultHeader } from "./search-result";
44
47
  import { resolveScreenpipeApiBase } from "./api-base";
45
48
 
46
49
  initMcpTelemetry({ transport: "stdio" });
@@ -323,10 +326,11 @@ const server = new Server(
323
326
  // ---------------------------------------------------------------------------
324
327
  const TOOLS: Tool[] = [
325
328
  ...WORKFLOW_TOOLS,
329
+ BUNDLED_SKILLS_TOOL,
326
330
  {
327
331
  name: "search-content",
328
332
  description:
329
- "Search screen text, audio transcriptions, input events, memories, and parsed app data. Returns timestamped results with app context. " +
333
+ "Search screen text, audio transcriptions, input events, memories, and parsed app data. Returns timestamped results with app context and starred markers. For starred work, set starred_only=true and a time range; no session lookup needed. " +
330
334
  "USE WHEN: you need the actual text/content of a moment — quotes, screen text, transcript lines, or compact parsed messages, emails, tasks, documents, and code review — or want to filter by speaker/window. " +
331
335
  "DO NOT USE for: broad questions like 'what was I doing?' (use activity-summary, it pre-summarizes apps + windows + transcripts). " +
332
336
  "Also DO NOT USE for: targeted UI controls (use search-elements). " +
@@ -356,6 +360,8 @@ const TOOLS: Tool[] = [
356
360
  type: "string",
357
361
  description: "ISO 8601, relative time, or local calendar ('today', 'yesterday', 'tomorrow', 'YYYY-MM-DD'). Defaults to now.",
358
362
  },
363
+ starred_only: { type: "boolean", description: "Only captures inside starred intervals. Use for starred work; omit for ordinary searches. Applied before pagination." },
364
+ starred_session_id: { type: "string", description: "Optional exact session ID already known from activity-summary. Usually use starred_only instead." },
359
365
  app_name: { type: "string", description: "Filter by app name (e.g. 'Google Chrome', 'Slack', 'zoom.us'). Case-sensitive." },
360
366
  window_name: { type: "string", description: "Filter by window title substring" },
361
367
  frame_id: { type: "integer", description: "With content_type='parsed', return parsed data attached to one frame." },
@@ -441,6 +447,7 @@ const TOOLS: Tool[] = [
441
447
  {
442
448
  name: "activity-summary",
443
449
  description:
450
+ "Includes up to ten recent starred intervals for this time range, even without captured content. Stars indicate user intent, not duration or completion. " +
444
451
  "Rich activity overview: authoritative active minutes, app/window time, edited document paths, key text, and audio transcriptions, with optional parsed task context when available. " +
445
452
  "USE WHEN: any broad question about what the user did — 'what was I doing?', 'how long on X?', 'which apps?', 'recap my morning'. " +
446
453
  "This is almost always the right first call for time-range questions — usually sufficient without follow-up searches. " +
@@ -452,6 +459,7 @@ const TOOLS: Tool[] = [
452
459
  properties: {
453
460
  start_time: { type: "string", description: "ISO 8601, relative (e.g. '3h ago'), or local calendar ('today', 'yesterday', 'tomorrow', 'YYYY-MM-DD')" },
454
461
  end_time: { type: "string", description: "ISO 8601, relative (e.g. 'now'), or local calendar ('today', 'yesterday', 'tomorrow', 'YYYY-MM-DD')" },
462
+ include_starred: { type: "boolean", description: "Include bounded starred-session context (default true; omitted for app/data-restricted reads)." },
455
463
  app_name: { type: "string", description: "Optional app name filter to focus on one app" },
456
464
  include_parsed_context: {
457
465
  type: "boolean",
@@ -1318,6 +1326,7 @@ async function fetchAPI(
1318
1326
  headers: {
1319
1327
  "Content-Type": "application/json",
1320
1328
  ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
1329
+ ...(currentMcpRequestId() ? { "x-screenpipe-request-id": currentMcpRequestId()! } : {}),
1321
1330
  "x-screenpipe-client": "mcp",
1322
1331
  "x-screenpipe-agent": currentMcpClient(),
1323
1332
  ...options.headers,
@@ -1399,11 +1408,18 @@ function screenTag(textSource: unknown): string {
1399
1408
  // ---------------------------------------------------------------------------
1400
1409
  // Tool handlers
1401
1410
  // ---------------------------------------------------------------------------
1402
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
1411
+ const observeCall = createCallObserver({
1412
+ tools: [...TOOLS.map(tool => tool.name), ...TEAM_TOOLS.map(tool => tool.name), ...WORKFLOW_TOOLS.map(tool => tool.name), BUNDLED_SKILLS_TOOL.name],
1413
+ transport: "stdio", client: currentMcpClient,
1414
+ send: (payload, signal) => callAPI("/internal/telemetry/mcp-call", { method: "POST", body: JSON.stringify(payload), signal }),
1415
+ });
1416
+ server.setRequestHandler(CallToolRequestSchema, async (request) => observeCall(request.params.name, async () => {
1403
1417
  const { name, arguments: args } = request.params;
1404
1418
 
1419
+ if (name === BUNDLED_SKILLS_TOOL.name) return readBundledSkills(args);
1420
+
1405
1421
  if (!args) {
1406
- throw new Error("Missing arguments");
1422
+ throw Object.assign(new Error("Missing arguments"), { status: 400 });
1407
1423
  }
1408
1424
 
1409
1425
  try {
@@ -1600,12 +1616,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1600
1616
  // Default text cap if the caller didn't pass max_content_length.
1601
1617
  // Keeps single calls under Claude Code's per-tool output limit.
1602
1618
  const userCap = normalized.max_content_length;
1603
- const effectiveCap =
1604
- typeof userCap === "number"
1605
- ? userCap
1606
- : userCap === undefined
1607
- ? DEFAULT_SEARCH_CONTENT_TRUNCATE
1608
- : Number(userCap);
1619
+ const effectiveCap = searchContentCap(userCap);
1609
1620
  const params = new URLSearchParams();
1610
1621
  for (const [key, value] of Object.entries(normalized)) {
1611
1622
  if (value !== null && value !== undefined) {
@@ -1617,6 +1628,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1617
1628
  const data = await response.json();
1618
1629
  const results = data.data || [];
1619
1630
  const pagination = data.pagination || {};
1631
+ observeSearchResult(results.length, false);
1620
1632
 
1621
1633
  if (results.length === 0) {
1622
1634
  return {
@@ -1638,71 +1650,20 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1638
1650
 
1639
1651
  const formattedResults: string[] = [];
1640
1652
  const images: Array<{ data: string; context: string }> = [];
1653
+ let truncated = false;
1641
1654
 
1642
1655
  for (const result of results) {
1643
1656
  const content = result.content;
1644
1657
  if (!content) continue;
1645
-
1646
- if (result.type === "OCR") {
1647
- const tagsStr = content.tags?.length ? `\nTags: ${content.tags.join(", ")}` : "";
1648
- // result.type is "OCR" by historical naming, but content.text_source
1649
- // tells us if the text actually came from the accessibility tree
1650
- // (primary path) or OCR (fallback). Use it to label honestly.
1651
- const tag = screenTag(content.text_source);
1652
- formattedResults.push(
1653
- `${tag} ${content.app_name || "?"} | ${content.window_name || "?"}\n` +
1654
- `${content.timestamp || ""}\n` +
1655
- `${truncateMiddle(content.text || "", effectiveCap)}` +
1656
- tagsStr
1657
- );
1658
- if (includeFrames && content.frame) {
1659
- images.push({
1660
- data: content.frame,
1661
- context: `${content.app_name} at ${content.timestamp}`,
1662
- });
1663
- }
1664
- } else if (result.type === "Audio") {
1665
- const tagsStr = content.tags?.length ? `\nTags: ${content.tags.join(", ")}` : "";
1666
- formattedResults.push(
1667
- `[Audio] ${content.device_name || "?"}\n` +
1668
- `${content.timestamp || ""}\n` +
1669
- `${truncateMiddle(content.transcription || "", effectiveCap)}` +
1670
- tagsStr
1671
- );
1672
- } else if (result.type === "UI" || result.type === "Accessibility") {
1673
- formattedResults.push(
1674
- `[Accessibility] ${content.app_name || "?"} | ${content.window_name || "?"}\n` +
1675
- `${content.timestamp || ""}\n` +
1676
- `${truncateMiddle(content.text || "", effectiveCap)}`
1677
- );
1678
- } else if (result.type === "Input") {
1679
- formattedResults.push(inputEventContent(content, effectiveCap));
1680
- } else if (result.type === "Memory") {
1681
- const tagsStr = content.tags?.length ? ` [${content.tags.join(", ")}]` : "";
1682
- const importance =
1683
- content.importance != null ? ` (importance: ${content.importance})` : "";
1684
- // frame_id links a memory back to the exact moment — jump there with
1685
- // frame-context / get-frame-elements (frame_id=N).
1686
- const frameRef = content.frame_id != null ? ` frame:${content.frame_id}` : "";
1687
- formattedResults.push(
1688
- `[Memory #${content.id}]${tagsStr}${importance}${frameRef}\n` +
1689
- `${content.created_at || ""}\n` +
1690
- `${truncateMiddle(content.content || "", effectiveCap)}`
1691
- );
1692
- } else if (result.type === "Parsed") {
1693
- formattedResults.push(
1694
- `[Parsed] ${content.app_name || "?"} | ${content.window_name || "?"} | frame ${content.frame_id || "?"}\n` +
1695
- `${content.timestamp || ""}\n` +
1696
- `${truncateMiddle(content.text || "", effectiveCap)}`
1697
- );
1658
+ const hit = formatSearchHit(result, effectiveCap);
1659
+ if (hit) { formattedResults.push(hit.text); truncated ||= hit.truncated; }
1660
+ if (includeFrames && result.type === "OCR" && content.frame) {
1661
+ images.push({ data: content.frame, context: `${content.app_name} at ${content.timestamp}` });
1698
1662
  }
1699
1663
  }
1700
1664
 
1701
- const header =
1702
- `Results: ${results.length}/${pagination.total || "?"}` +
1703
- (pagination.total > results.length
1704
- ? ` (use offset=${(pagination.offset || 0) + results.length} for more)`
1705
- : "");
1665
+ observeSearchResult(results.length, truncated);
1666
+ const header = searchResultHeader(results.length, pagination);
1706
1667
 
1707
1668
  // Co-occurring tags (only present when include_related=true + tags set).
1708
1669
  // Compact one-liner per namespace so it's cheap to read.
@@ -2432,6 +2393,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2432
2393
  throw new Error(`Unknown tool: ${name}`);
2433
2394
  }
2434
2395
  } catch (error) {
2396
+ observeMcpError(error);
2435
2397
  const errorMessage = error instanceof Error ? error.message : "Unknown error";
2436
2398
  if (!(error instanceof BackendDownError) && !(error instanceof BackendHttpError)) {
2437
2399
  captureMcpMessage("tool call failed", "error", { phase: "tool_call", tool: name });
@@ -2443,7 +2405,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2443
2405
  content: [{ type: "text", text: `Error executing ${name}: ${errorMessage}` }],
2444
2406
  };
2445
2407
  }
2446
- });
2408
+ }));
2447
2409
 
2448
2410
  // Run the server
2449
2411
  async function main() {
@@ -2453,6 +2415,16 @@ async function main() {
2453
2415
  console.error(`[screenpipe-mcp] v${PKG_VERSION} phase=connect target=${SCREENPIPE_API}`);
2454
2416
  const transport = new StdioServerTransport();
2455
2417
  await server.connect(transport);
2418
+ // The SDK does not close its transport on stdin EOF. An outstanding fetch
2419
+ // can otherwise keep an abandoned helper alive indefinitely after its host
2420
+ // exits. This process belongs to this one stdio client only.
2421
+ process.stdin.once("end", () => {
2422
+ const deadline = setTimeout(() => process.exit(0), 500);
2423
+ void server.close()
2424
+ .then(() => flushMcpTelemetry(250))
2425
+ .catch(() => {})
2426
+ .finally(() => { clearTimeout(deadline); process.exit(0); });
2427
+ });
2456
2428
  console.error("[screenpipe-mcp] phase=connected transport=stdio");
2457
2429
  // Warm the API key in the background so the first tool call doesn't pay the
2458
2430
  // discovery latency. Never awaited here — key discovery must not gate attach.
@@ -0,0 +1,22 @@
1
+ // screenpipe — AI that knows everything you've seen, said, or heard
2
+ // https://screenpipe.com
3
+ import { expect, it } from "vitest";
4
+ import { formatSearchHit, searchContentCap, searchResultHeader } from "./search-result";
5
+ it("preserves real references through truncation, without inventing missing IDs", () => {
6
+ const hit = formatSearchHit({type:"OCR",content:{frame_id:72,text:"HEAD"+"x".repeat(3000)+"TAIL",text_source:"accessibility"}},10)!;
7
+ expect(hit.truncated).toBe(true); expect(hit.text).toContain("frame_id=72"); expect(hit.text).toContain("screenpipe://frame/72");
8
+ expect(hit.text).toContain("HEAD"); expect(hit.text).toContain("TAIL"); expect(hit.text.length).toBeLessThan(250);
9
+ expect(formatSearchHit({type:"OCR",content:{text:"no source"}},100)!.text).not.toContain("Source:");
10
+ });
11
+ it("keeps audio references and input text, including explicit unlimited reads", () => {
12
+ expect(formatSearchHit({type:"Audio",content:{chunk_id:31,timestamp:"2026-10-01T00:00:00Z",transcription:"speech"}},100)!.text).toContain("chunk_id=31");
13
+ const input={type:"Input",content:{frame_id:72,text_content:"long input"}};
14
+ expect(formatSearchHit(input,3)!.truncated).toBe(true);
15
+ expect(formatSearchHit(input,0)!.text).toContain("long input"); expect(formatSearchHit(input,0)!.truncated).toBe(false);
16
+ });
17
+ it("only suggests an offset when a next page exists", () => {
18
+ expect(searchResultHeader(5,{total:10,offset:5})).not.toContain("offset=");
19
+ expect(searchResultHeader(5,{total:10,offset:0})).toContain("offset=5");
20
+ expect(searchResultHeader(0,{total:10,offset:0})).not.toContain("offset=");
21
+ expect(searchContentCap(undefined)).toBe(1000); expect(searchContentCap(-1)).toBe(1000);
22
+ });
@@ -0,0 +1,75 @@
1
+ // screenpipe — AI that knows everything you've seen, said, or heard
2
+ // https://screenpipe.com
3
+
4
+ import { inputEventContent } from "./workflow-tools";
5
+
6
+ export const DEFAULT_SEARCH_CONTENT_TRUNCATE = 1000;
7
+
8
+ export function searchContentCap(value: unknown): number {
9
+ const cap = typeof value === "number" ? value : Number(value);
10
+ return value !== undefined && Number.isFinite(cap) && cap >= 0
11
+ ? Math.floor(cap) : DEFAULT_SEARCH_CONTENT_TRUNCATE;
12
+ }
13
+
14
+ export function truncateSearchText(text: string, cap: number): string {
15
+ if (cap === 0 || text.length <= cap) return text;
16
+ const left = Math.floor(cap / 2);
17
+ return text.slice(0, left) + `…[${text.length - cap} chars truncated; inspect the source or narrow the search]…` + text.slice(text.length - (cap - left));
18
+ }
19
+
20
+ function numericId(value: unknown): number | undefined {
21
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
22
+ }
23
+
24
+ /** Source identifiers come from the backend, never from result position or text. */
25
+ export function formatSearchHit(result: any, cap: number): { text: string; truncated: boolean } | null {
26
+ const c = result.content;
27
+ if (!c || typeof c !== "object") return null;
28
+ const frame = numericId(c.frame_id);
29
+ const chunk = numericId(c.chunk_id);
30
+ const frameRef = frame === undefined ? "" : `\nSource: frame_id=${frame}; screenpipe://frame/${frame}`;
31
+ const tags = Array.isArray(c.tags) && c.tags.length ? `\nTags: ${c.tags.join(", ")}` : "";
32
+ let label: string, body: string, source = frameRef;
33
+ switch (result.type) {
34
+ case "OCR":
35
+ label = c.text_source === "accessibility" ? "[Screen·a11y]" : c.text_source === "ocr" ? "[Screen·ocr]" : "[Screen]";
36
+ label += ` ${c.app_name || "?"} | ${c.window_name || "?"}`;
37
+ body = c.text || "";
38
+ break;
39
+ case "UI": case "Accessibility":
40
+ label = `[Accessibility] ${c.app_name || "?"} | ${c.window_name || "?"}`;
41
+ body = c.text || "";
42
+ break;
43
+ case "Audio":
44
+ label = `[Audio] ${c.device_name || "?"}`;
45
+ body = c.transcription || "";
46
+ source = chunk === undefined ? "" : `\nSource: chunk_id=${chunk}`;
47
+ if (typeof c.timestamp === "string" && Number.isFinite(Date.parse(c.timestamp))) {
48
+ source += `${source ? "; " : "\nSource: "}screenpipe://timeline?timestamp=${encodeURIComponent(c.timestamp)}`;
49
+ }
50
+ break;
51
+ case "Parsed":
52
+ label = `[Parsed] ${c.app_name || "?"} | ${c.window_name || "?"} | frame ${frame ?? "?"}`;
53
+ body = c.text || "";
54
+ break;
55
+ case "Memory":
56
+ label = `[Memory #${c.id}]${c.importance != null ? ` (importance: ${c.importance})` : ""}`;
57
+ body = c.content || "";
58
+ break;
59
+ case "Input":
60
+ return { text: `${result.starred ? "[Starred moment]\n" : ""}${inputEventContent(c, cap === 0 ? Number.MAX_SAFE_INTEGER : cap)}${source}`, truncated: cap > 0 && typeof c.text_content === "string" && c.text_content.length > cap };
61
+ default: return null;
62
+ }
63
+ body = String(body);
64
+ return {
65
+ text: `${result.starred ? "[Starred moment]\n" : ""}${label}\n${c.timestamp || c.created_at || ""}${source}\n${truncateSearchText(body, cap)}${tags}`,
66
+ truncated: cap > 0 && body.length > cap,
67
+ };
68
+ }
69
+
70
+ export function searchResultHeader(count: number, pagination: any): string {
71
+ const total = typeof pagination.total === "number" ? pagination.total : undefined;
72
+ const offset = typeof pagination.offset === "number" ? pagination.offset : 0;
73
+ return `Results: ${count}/${total ?? "?"}` +
74
+ (count > 0 && total !== undefined && offset + count < total ? ` (use offset=${offset + count} for more)` : "");
75
+ }
@@ -0,0 +1,48 @@
1
+ // screenpipe — AI that knows everything you've seen, said, or heard
2
+ // https://screenpipe.com
3
+ import { it, expect } from "vitest";
4
+ import { spawn, execFileSync, type ChildProcessWithoutNullStreams } from "node:child_process";
5
+ import { createServer } from "node:http";
6
+ import { once } from "node:events";
7
+ import path from "node:path";
8
+ const sleep=(ms:number)=>new Promise(resolve=>setTimeout(resolve,ms));
9
+ async function until(predicate:()=>boolean, timeout=2000) {
10
+ const end=Date.now()+timeout;
11
+ while(!predicate() && Date.now()<end) await sleep(10);
12
+ expect(predicate()).toBe(true);
13
+ }
14
+ it("reaps a helper on EOF during a hung read while another client remains usable",async()=>{
15
+ const root=path.resolve(__dirname,"..");
16
+ execFileSync("bun",["run","build"],{cwd:root,stdio:"pipe",timeout:120000});
17
+ let hanging=false;
18
+ const api=createServer((req,res)=>{
19
+ if(req.url?.includes("q=hang")){hanging=true;return;}
20
+ res.setHeader("Content-Type","application/json");res.end(JSON.stringify({data:[],pagination:{total:0}}));
21
+ });
22
+ await new Promise<void>(resolve=>api.listen(0,"127.0.0.1",resolve));
23
+ const address=api.address();if(!address||typeof address==='string')throw new Error('fixture unavailable');
24
+ const port=address.port;
25
+ const children:ChildProcessWithoutNullStreams[]=[];
26
+ async function start(){
27
+ const child=spawn(process.execPath,[path.join(root,'dist/cli.js')],{env:{PATH:process.env.PATH||'',SCREENPIPE_LOCAL_API_KEY:'fixture',SCREENPIPE_LOCAL_API_URL:`http://127.0.0.1:${port}`,SCREENPIPE_DISABLE_TELEMETRY:'1'},stdio:'pipe'});
28
+ children.push(child);let output='';child.stdout.on('data',part=>output+=part);child.stderr.resume();
29
+ const send=(value:unknown)=>child.stdin.write(JSON.stringify(value)+'\n');
30
+ send({jsonrpc:'2.0',id:1,method:'initialize',params:{protocolVersion:'2024-11-05',capabilities:{},clientInfo:{name:'test',version:'1'}}});
31
+ await until(()=>output.includes('"id":1'));
32
+ send({jsonrpc:'2.0',method:'notifications/initialized'});
33
+ return {child,send,output:()=>output};
34
+ }
35
+ try{
36
+ const abandoned=await start(), live=await start();
37
+ abandoned.send({jsonrpc:'2.0',id:2,method:'tools/call',params:{name:'search-content',arguments:{q:'hang'}}});
38
+ await until(()=>hanging);abandoned.child.stdin.end();
39
+ await until(()=>abandoned.child.exitCode!==null);
40
+ expect(live.child.exitCode).toBeNull();
41
+ live.send({jsonrpc:'2.0',id:2,method:'tools/call',params:{name:'search-content',arguments:{}}});
42
+ await until(()=>live.output().includes('No results'));
43
+ live.child.stdin.end();await until(()=>live.child.exitCode!==null);
44
+ } finally {
45
+ for(const child of children)if(child.exitCode===null&&child.signalCode===null){child.kill('SIGKILL');await once(child,'exit');}
46
+ api.closeAllConnections();await new Promise<void>(resolve=>api.close(()=>resolve()));
47
+ }
48
+ },15000);
@@ -20,7 +20,7 @@ it("discovers and retrieves workflow targeting context through the built MCP tra
20
20
  if(req.url?.startsWith("/workflows?")) res.end(JSON.stringify({data:[{id,title:"Invoice review"},{id:uuidId,title:"Maintained workflow"}]}));
21
21
  else if(req.url?.startsWith(`/workflows/${id}?`)) res.end(JSON.stringify({id,workflow:{stages:[{name:"Review"}]},automationEvidence:[{status:"no_captured_frame",actionTarget:"unknown"}]}));
22
22
  else if(req.url?.startsWith(`/workflows/${uuidId}?`)) res.end(JSON.stringify({id:uuidId,workflow:{stages:[{name:"Review maintained workflow"}]}}));
23
- else if(req.url?.startsWith("/search?")) res.end(JSON.stringify({data:[{type:"Input",content:{id:8,event_type:"click",frame_id:7,x:40,y:60,element_role:"AXButton",element_name:"Approve"}}],pagination:{total:1,offset:0,limit:5}}));
23
+ else if(req.url?.startsWith("/search?")) res.end(JSON.stringify({data:[{starred:req.url.includes("starred_only=true"),type:"Input",content:{id:8,event_type:"click",frame_id:7,x:40,y:60,element_role:"AXButton",element_name:"Approve"}}],pagination:{total:1,offset:0,limit:5}}));
24
24
  else if(req.url === "/frames/7/context?include_empty=true") res.end(JSON.stringify({frame_id:7,text_source:"accessibility",nodes:[{role:"AXButton",text:"",bounds:{left:0.25,top:0.5,width:0.125,height:0.0625},properties:{automation_id:"approve"}}]}));
25
25
  else { res.writeHead(404);res.end("{}"); }
26
26
  });
@@ -33,6 +33,14 @@ it("discovers and retrieves workflow targeting context through the built MCP tra
33
33
  await client.connect(transport);
34
34
  const tools=await client.listTools();
35
35
  expect(tools.tools.map(t=>t.name)).toEqual(expect.arrayContaining(["list-workflows","get-workflow"]));
36
+ expect(tools.tools.some(t=>t.name === "list-starred-sessions")).toBe(false);
37
+ expect(tools.tools.find(t=>t.name === "search-content")?.inputSchema.properties).toHaveProperty("starred_only");
38
+ const before = requests.length;
39
+ const starred = await client.callTool({name:"search-content",arguments:{starred_only:true,start_time:"2026-10-02T00:00:00Z",end_time:"2026-10-03T00:00:00Z",limit:5}});
40
+ expect(starred.isError).not.toBe(true);
41
+ expect(JSON.stringify(starred)).toContain("[Starred moment]");
42
+ expect(requests.slice(before)).toHaveLength(1);
43
+ expect(requests.at(-1)).toContain("starred_only=true");
36
44
  const listed=await client.callTool({name:"list-workflows",arguments:{q:"invoice"}});
37
45
  expect(JSON.stringify(listed)).toContain(id);
38
46
  const detail=await client.callTool({name:"get-workflow",arguments:{id}});