screenpipe-mcp 0.20.0 → 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.
@@ -185,6 +185,69 @@ describe("isAuthorized", () => {
185
185
  });
186
186
 
187
187
  describe("buildHttpServer", () => {
188
+ it.each([
189
+ { name: "local API key", localKey: "upstream-secret", apiKey: "other-key", succeeds: true },
190
+ { name: "API key fallback", localKey: "", apiKey: "upstream-secret", succeeds: true },
191
+ { name: "missing upstream key", localKey: "", apiKey: "", succeeds: false },
192
+ { name: "invalid upstream key", localKey: "wrong-key", apiKey: "", succeeds: false },
193
+ ])("authenticates upstream independently of the MCP bearer: $name", async ({ localKey, apiKey, succeeds }) => {
194
+ const requests: Array<{ path: string; authorization: string | undefined }> = [];
195
+ const api = createServer((request, response) => {
196
+ requests.push({ path: request.url!, authorization: request.headers.authorization });
197
+ response.setHeader("content-type", "application/json");
198
+ if (request.headers.authorization !== "Bearer upstream-secret") {
199
+ response.writeHead(403).end(JSON.stringify({ error: "unauthorized" }));
200
+ return;
201
+ }
202
+ response.end(JSON.stringify({
203
+ data: [{ starred: true, type: "OCR", content: { text: "synthetic authenticated result" } }],
204
+ pagination: { total: 1 },
205
+ }));
206
+ });
207
+ await new Promise<void>(resolve => api.listen(0, "127.0.0.1", resolve));
208
+ const apiPort = (api.address() as { port: number }).port;
209
+ vi.stubEnv("SCREENPIPE_API_URL", `http://127.0.0.1:${apiPort}`);
210
+ vi.stubEnv("SCREENPIPE_LOCAL_API_KEY", localKey);
211
+ vi.stubEnv("SCREENPIPE_API_KEY", apiKey);
212
+ const server = buildHttpServer({
213
+ mcpPort: 0,
214
+ screenpipePort: apiPort,
215
+ host: "127.0.0.1",
216
+ apiKey: "mcp-client-secret",
217
+ });
218
+ const client = new Client({ name: "integration-test", version: "1.0.0" });
219
+ try {
220
+ await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
221
+ const url = new URL(`http://127.0.0.1:${(server.address() as { port: number }).port}/mcp`);
222
+ await client.connect(new StreamableHTTPClientTransport(url, {
223
+ requestInit: { headers: { authorization: "Bearer mcp-client-secret" } },
224
+ }));
225
+ const call = client.callTool({ name: "search_content", arguments: { q: "fixture", limit: 1, starred_only: true } });
226
+ if (succeeds) {
227
+ const result = await call;
228
+ expect(JSON.stringify(result)).toContain("[Starred moment]");
229
+ expect(requests[0].path).toContain("starred_only=true");
230
+ expect(result.isError).not.toBe(true);
231
+ expect(result.content).toEqual([expect.objectContaining({
232
+ type: "text", text: expect.stringContaining("synthetic authenticated result"),
233
+ })]);
234
+ await vi.waitFor(() => expect(requests.some(request => request.path === "/internal/telemetry/mcp-value")).toBe(true));
235
+ expect(requests.every(request => request.authorization === "Bearer upstream-secret")).toBe(true);
236
+ } else {
237
+ await expect(call).rejects.toThrow("HTTP error: 403");
238
+ expect(requests).toHaveLength(1);
239
+ }
240
+ expect(requests[0].path).toBe("/search?q=fixture&limit=1&starred_only=true");
241
+ expect(requests.some(request => request.authorization === "Bearer mcp-client-secret")).toBe(false);
242
+ } finally {
243
+ await client.close();
244
+ server.closeAllConnections();
245
+ api.closeAllConnections();
246
+ await Promise.all([server, api].map(s => new Promise<void>(resolve => s.close(() => resolve()))));
247
+ vi.unstubAllEnvs();
248
+ }
249
+ });
250
+
188
251
  it("attributes retrievals per HTTP session and excludes empty or failed searches", async () => {
189
252
  const searches: Array<{ source: unknown; agent: unknown }> = [];
190
253
  const outcomes: unknown[] = [];
@@ -193,6 +256,11 @@ describe("buildHttpServer", () => {
193
256
  if (request.url?.startsWith("/search?")) {
194
257
  searches.push({ source: request.headers["x-screenpipe-client"], agent: request.headers["x-screenpipe-agent"] });
195
258
  const q = new URL(request.url, "http://fixture").searchParams.get("q");
259
+ if (q === "broad") {
260
+ response.statusCode = 400;
261
+ response.end(JSON.stringify({ error: "starred search covers more than 100 disjoint intervals; narrow start_time/end_time" }));
262
+ return;
263
+ }
196
264
  if (q === "failed") response.statusCode = 500;
197
265
  response.end(JSON.stringify({ data: q === "empty" ? [] : [{ type: "OCR", content: { text: "fixture result" } }] }));
198
266
  } else if (request.url === "/internal/telemetry/mcp-value") {
@@ -237,6 +305,7 @@ describe("buildHttpServer", () => {
237
305
  ]));
238
306
  await clients[0].callTool({ name: "search_content", arguments: { q: "empty" } });
239
307
  await expect(clients[0].callTool({ name: "search_content", arguments: { q: "failed" } })).rejects.toThrow();
308
+ await expect(clients[0].callTool({ name: "search_content", arguments: { q: "broad", starred_only: true } })).rejects.toThrow("narrow start_time/end_time");
240
309
  expect(outcomes).toHaveLength(3);
241
310
  } finally {
242
311
  await Promise.all(clients.map(client => client.close()));
@@ -30,7 +30,10 @@ import {
30
30
  flushMcpTelemetry,
31
31
  initMcpTelemetry,
32
32
  } from "./telemetry";
33
+ import { createCallObserver, currentMcpRequestId, observeSearchResult } from "./call-observation";
34
+ import { formatSearchHit, searchContentCap, searchResultHeader } from "./search-result";
33
35
  import { PKG_VERSION } from "./version";
36
+ import { BUNDLED_SKILLS_TOOL, readBundledSkills } from "./bundled-skills";
34
37
  import { normalizeTimeFields } from "./time-normalization";
35
38
  import { createMcpQualifiedValueReporter, resolveMcpClient, type McpClient } from "./qualified-value";
36
39
 
@@ -154,7 +157,7 @@ const TOOLS = [
154
157
  name: "search_content",
155
158
  description:
156
159
  "Search screenpipe's recorded content: screen text, audio transcriptions, input events, and parsed app data. " +
157
- "Returns timestamped results with app context. " +
160
+ "Returns timestamped results with app context and starred markers. For starred work, set starred_only=true and a time range. " +
158
161
  "Call with no parameters to get recent activity.",
159
162
  inputSchema: {
160
163
  type: "object" as const,
@@ -171,6 +174,7 @@ const TOOLS = [
171
174
  default: "all",
172
175
  },
173
176
  limit: { type: "integer", description: "Max results. Default: 10" },
177
+ max_content_length: { type: "integer", minimum: 0, description: "Text characters per result; default 1000. Use 0 only for an explicitly scoped full-text read." },
174
178
  offset: { type: "integer", description: "Skip N results for pagination. Default: 0" },
175
179
  start_time: {
176
180
  type: "string",
@@ -180,6 +184,8 @@ const TOOLS = [
180
184
  type: "string",
181
185
  description: "ISO 8601, relative, or local calendar ('today', 'yesterday', 'tomorrow', 'YYYY-MM-DD')",
182
186
  },
187
+ starred_only: { type: "boolean", description: "Only captures inside starred intervals; applied before pagination. Omit for unrelated searches." },
188
+ starred_session_id: { type: "string", description: "Optional known session ID to restrict the search." },
183
189
  app_name: {
184
190
  type: "string",
185
191
  description: "Filter by app (e.g., 'Google Chrome', 'Slack', 'zoom.us')",
@@ -201,11 +207,14 @@ function makeFetchAPI(screenpipePort: number, client: () => McpClient) {
201
207
  /\/+$/,
202
208
  "",
203
209
  );
210
+ const apiKey = process.env.SCREENPIPE_LOCAL_API_KEY || process.env.SCREENPIPE_API_KEY;
204
211
  return async (endpoint: string, options: RequestInit = {}): Promise<Response> =>
205
212
  fetch(`${base}${endpoint}`, {
206
213
  ...options,
207
214
  headers: {
208
215
  "Content-Type": "application/json",
216
+ ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
217
+ ...(currentMcpRequestId() ? { "x-screenpipe-request-id": currentMcpRequestId()! } : {}),
209
218
  "x-screenpipe-client": "mcp",
210
219
  "x-screenpipe-agent": client(),
211
220
  ...options.headers,
@@ -227,12 +236,15 @@ async function handleSearchContent(
227
236
 
228
237
  const response = await fetchAPI(`/search?${params.toString()}`);
229
238
  if (!response.ok) {
230
- throw new Error(`HTTP error: ${response.status}`);
239
+ const body = await response.json().catch(() => ({}));
240
+ const detail = typeof body?.error === "string" ? `: ${body.error.slice(0, 500)}` : "";
241
+ throw Object.assign(new Error(`HTTP error: ${response.status}${detail}`), { status: response.status });
231
242
  }
232
243
 
233
244
  const data = await response.json();
234
245
  const results = data.data || [];
235
246
  const pagination = data.pagination || {};
247
+ observeSearchResult(results.length, false);
236
248
 
237
249
  if (results.length === 0) {
238
250
  return {
@@ -245,43 +257,12 @@ async function handleSearchContent(
245
257
  };
246
258
  }
247
259
 
248
- const formattedResults: string[] = [];
249
- for (const result of results) {
250
- const content = result.content;
251
- if (!content) continue;
252
-
253
- if (result.type === "OCR") {
254
- formattedResults.push(
255
- `[OCR] ${content.app_name || "?"} | ${content.window_name || "?"}\n` +
256
- `${content.timestamp || ""}\n` +
257
- `${content.text || ""}`
258
- );
259
- } else if (result.type === "Audio") {
260
- formattedResults.push(
261
- `[Audio] ${content.device_name || "?"}\n` +
262
- `${content.timestamp || ""}\n` +
263
- `${content.transcription || ""}`
264
- );
265
- } else if (result.type === "UI" || result.type === "Accessibility") {
266
- formattedResults.push(
267
- `[Accessibility] ${content.app_name || "?"} | ${content.window_name || "?"}\n` +
268
- `${content.timestamp || ""}\n` +
269
- `${content.text || ""}`
270
- );
271
- } else if (result.type === "Parsed") {
272
- formattedResults.push(
273
- `[Parsed] ${content.app_name || "?"} | ${content.window_name || "?"} | frame ${content.frame_id || "?"}\n` +
274
- `${content.timestamp || ""}\n` +
275
- `${content.text || ""}`
276
- );
277
- }
278
- }
279
-
280
- const header =
281
- `Results: ${results.length}/${pagination.total || "?"}` +
282
- (pagination.total > results.length
283
- ? ` (use offset=${(pagination.offset || 0) + results.length} for more)`
284
- : "");
260
+ const cap = searchContentCap(args.max_content_length);
261
+ const hits = results.map((result: unknown) => formatSearchHit(result, cap))
262
+ .filter((hit: ReturnType<typeof formatSearchHit>) => hit !== null);
263
+ observeSearchResult(results.length, hits.some((hit: NonNullable<ReturnType<typeof formatSearchHit>>) => hit.truncated));
264
+ const formattedResults = hits.map((hit: NonNullable<ReturnType<typeof formatSearchHit>>) => hit.text);
265
+ const header = searchResultHeader(results.length, pagination);
285
266
 
286
267
  if (formattedResults.length > 0) qualifiedValue.searchResult();
287
268
 
@@ -317,14 +298,22 @@ function createMcpServer(screenpipePort: number): Server {
317
298
  client,
318
299
  );
319
300
 
320
- s.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
301
+ s.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [...TOOLS, BUNDLED_SKILLS_TOOL] }));
321
302
 
322
- s.setRequestHandler(CallToolRequestSchema, async (request) => {
303
+ const observeCall = createCallObserver({
304
+ tools: [...TOOLS.map(tool => tool.name), BUNDLED_SKILLS_TOOL.name], transport: "http", client,
305
+ send: async (payload, signal) => {
306
+ const response = await fetchAPI("/internal/telemetry/mcp-call", { method: "POST", body: JSON.stringify(payload), signal });
307
+ if (!response.ok) throw new Error("Telemetry unavailable");
308
+ },
309
+ });
310
+ s.setRequestHandler(CallToolRequestSchema, async (request) => observeCall(request.params.name, async () => {
323
311
  const { name, arguments: args } = request.params;
324
- if (!args) throw new Error("Missing arguments");
312
+ if (name === BUNDLED_SKILLS_TOOL.name) return readBundledSkills(args);
313
+ if (!args) throw Object.assign(new Error("Missing arguments"), { status: 400 });
325
314
  if (name === "search_content") return handleSearchContent(fetchAPI, args, qualifiedValue);
326
- throw new Error(`Unknown tool: ${name}`);
327
- });
315
+ throw Object.assign(new Error(`Unknown tool: ${name}`), { status: 400 });
316
+ }));
328
317
 
329
318
  return s;
330
319
  }
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,143 @@
1
+ // screenpipe — AI that knows everything you've seen, said, or heard
2
+ // https://screenpipe.com
3
+
4
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
5
+ import { execFileSync } from "node:child_process";
6
+ import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import path from "node:path";
9
+ import { createServer, type Server } from "node:http";
10
+ import { unzipSync } from "fflate";
11
+ import { VERSIONED_MANIFEST_SCHEMAS } from "@anthropic-ai/mcpb/schemas";
12
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
13
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
14
+
15
+ const root = path.resolve(__dirname, "..");
16
+ const sandbox = mkdtempSync(path.join(tmpdir(), "screenpipe-installer-test-"));
17
+ let local: Record<string, Uint8Array>;
18
+ let cloud: Record<string, Uint8Array>;
19
+ let api: Server;
20
+ let baseUrl: string;
21
+ let status = 200;
22
+ let seenAuth: string | undefined;
23
+ let seenQuery: string | null;
24
+ const parse = (archive: Record<string, Uint8Array>, file: string) =>
25
+ JSON.parse(Buffer.from(archive[file]).toString("utf8"));
26
+
27
+ beforeAll(async () => {
28
+ execFileSync("bun", ["run", "build:installers"], { cwd: root, timeout: 120_000, stdio: "inherit" });
29
+ local = unzipSync(readFileSync(path.join(root, "installers/screenpipe-local.mcpb")));
30
+ cloud = unzipSync(readFileSync(path.join(root, "installers/screenpipe-cloud.zip")));
31
+ for (const [file, bytes] of Object.entries(local)) {
32
+ const target = path.join(sandbox, file);
33
+ mkdirSync(path.dirname(target), { recursive: true });
34
+ writeFileSync(target, bytes);
35
+ }
36
+ api = createServer((req, res) => {
37
+ const url = new URL(req.url!, "http://localhost");
38
+ if (url.pathname !== "/search") {
39
+ res.writeHead(200, { "Content-Type": "application/json" });
40
+ res.end("{}");
41
+ return;
42
+ }
43
+ seenAuth = req.headers.authorization;
44
+ seenQuery = url.searchParams.get("q");
45
+ res.writeHead(status, { "Content-Type": "application/json" });
46
+ res.end(JSON.stringify(status === 200 ? {
47
+ data: [{ type: "OCR", content: { text: "synthetic installer evidence", timestamp: "2026-01-02T12:00:00Z", app_name: "Fixture", frame_id: 1 } }],
48
+ pagination: { total: 1, limit: 5, offset: 0 },
49
+ } : { error: "unauthorized" }));
50
+ });
51
+ await new Promise<void>((resolve) => api.listen(0, "127.0.0.1", resolve));
52
+ baseUrl = `http://127.0.0.1:${(api.address() as { port: number }).port}`;
53
+ }, 130_000);
54
+
55
+ afterAll(async () => {
56
+ if (api) await new Promise<void>((resolve) => api.close(() => resolve()));
57
+ rmSync(sandbox, { recursive: true, force: true });
58
+ });
59
+
60
+ async function withInstalledClient(run: (client: Client) => Promise<void>) {
61
+ const manifest = parse(local, "manifest.json");
62
+ const config = manifest.server.mcp_config;
63
+ const settings: Record<string, string> = { api_url: baseUrl, api_key: "sp-installer-fixture" };
64
+ const env: Record<string, string> = {
65
+ PATH: "", NODE_PATH: "", SCREENPIPE_DISABLE_TELEMETRY: "1",
66
+ SCREENPIPE_ENTERPRISE_TOKEN: "sk_ent_fixture", SCREENPIPE_TEAM_API_URL: baseUrl,
67
+ };
68
+ for (const [key, value] of Object.entries(config.env)) {
69
+ env[key] = String(value).replace(/\$\{user_config\.(\w+)\}/g, (_, name) => settings[name]);
70
+ }
71
+ const transport = new StdioClientTransport({
72
+ command: process.execPath,
73
+ args: config.args.map((arg: string) => arg.replace("${__dirname}", sandbox)),
74
+ cwd: sandbox, env, stderr: "pipe",
75
+ });
76
+ const client = new Client({ name: "installer-fixture", version: "1.0.0" });
77
+ try {
78
+ await client.connect(transport);
79
+ await run(client);
80
+ } finally {
81
+ await transport.close();
82
+ }
83
+ }
84
+
85
+ describe("installable MCP artifacts", () => {
86
+ it("ships a schema-valid local extension with only the required files", () => {
87
+ const manifest = parse(local, "manifest.json");
88
+ expect(VERSIONED_MANIFEST_SCHEMAS["0.3"].safeParse(manifest).success).toBe(true);
89
+ expect(Object.keys(local).sort()).toEqual([
90
+ "LICENSE.md", "README.md", "dist/index.js", "icon.png", "manifest.json", "package.json",
91
+ ]);
92
+ expect(manifest.version).toBe(JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).version);
93
+ expect(manifest.user_config.api_key.sensitive).toBe(true);
94
+ expect(manifest.user_config.api_key.default).toBeUndefined();
95
+ expect(manifest.user_config.api_key.required).toBe(false);
96
+ expect(manifest.user_config.api_url.default).toBe("http://127.0.0.1:3030");
97
+ expect(local[manifest.server.entry_point]).toBeDefined();
98
+ });
99
+
100
+ it("initializes and searches from the extracted archive with no runtime dependencies", async () => {
101
+ status = 200;
102
+ await withInstalledClient(async (client) => {
103
+ const list = await client.listTools();
104
+ expect(list.tools.some((tool) => tool.name === "search-content")).toBe(true);
105
+ const result = await client.callTool({ name: "search-content", arguments: {
106
+ q: "synthetic", start_time: "2026-01-02T00:00:00Z", end_time: "2026-01-03T00:00:00Z", limit: 5,
107
+ } });
108
+ expect(result.isError).not.toBe(true);
109
+ expect(JSON.stringify(result)).toContain("synthetic installer evidence");
110
+ expect(seenAuth).toBe("Bearer sp-installer-fixture");
111
+ expect(seenQuery).toBe("synthetic");
112
+ });
113
+ }, 15_000);
114
+
115
+ it("reports rejected authentication rather than successful retrieval", async () => {
116
+ status = 401;
117
+ await withInstalledClient(async (client) => {
118
+ const result = await client.callTool({ name: "search-content", arguments: { q: "synthetic", limit: 1 } });
119
+ expect(result.isError).toBe(true);
120
+ expect(JSON.stringify(result)).not.toContain("synthetic installer evidence");
121
+ });
122
+ }, 15_000);
123
+
124
+ it("keeps all cloud hosts on the same OAuth endpoint without embedding credentials", () => {
125
+ expect(Object.keys(cloud).sort()).toEqual([
126
+ ".claude-plugin/plugin.json", ".codex-plugin/plugin.json", ".mcp.json",
127
+ "LICENSE.md", "README.md", "assets/icon.png", "mcp.json", "plugin.json",
128
+ ]);
129
+ const portable = parse(cloud, "plugin.json");
130
+ const codex = parse(cloud, ".codex-plugin/plugin.json");
131
+ const claude = parse(cloud, ".claude-plugin/plugin.json");
132
+ for (const manifest of [portable, codex, claude]) {
133
+ expect(manifest.name).toBe("screenpipe-cloud");
134
+ expect(manifest.version).toBe(portable.version);
135
+ }
136
+ const endpoint = "https://screenpipe.com/api/user/data-sync/mcp";
137
+ expect(parse(cloud, "mcp.json").mcpServers).toEqual({ "screenpipe-cloud": { type: "streamable-http", url: endpoint } });
138
+ expect(parse(cloud, ".mcp.json").mcpServers).toEqual({ "screenpipe-cloud": { type: "http", url: endpoint } });
139
+ expect(codex.mcpServers).toBe("./.mcp.json");
140
+ expect(codex.interface.capabilities).toEqual(["Read"]);
141
+ expect(cloud[codex.interface.logo.replace(/^\.\//, "")]).toBeDefined();
142
+ });
143
+ });
@@ -55,6 +55,7 @@ describe("createMcpQualifiedValueReporter", () => {
55
55
  ["chatgpt", "ChatGPT", "chatgpt"],
56
56
  ["gemini", "gemini-cli", "gemini"],
57
57
  ["runner", "Runner", "runner"],
58
+ ["vscode", "vscode", "vscode"],
58
59
  ["grokbot", "Grok Bot", "grokbot"],
59
60
  ])("recognizes configured %s and its handshake name", (configured, protocol, expected) => {
60
61
  expect(resolveMcpClient(configured, "unrecognized-client")).toBe(expected);
@@ -4,7 +4,7 @@
4
4
 
5
5
  const CLIENTS = [
6
6
  "claude", "chatgpt", "codex", "cursor", "gemini", "openclaw",
7
- "hermes", "runner", "windsurf", "grok", "grokbot",
7
+ "hermes", "runner", "windsurf", "vscode", "grok", "grokbot",
8
8
  ] as const;
9
9
  export type McpClient = (typeof CLIENTS)[number] | "unknown";
10
10
 
@@ -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
+ });