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.
- package/README.md +103 -2
- package/RELEASE.md +24 -8
- package/bun.lock +105 -1
- package/dist/cli.js +954 -399
- package/dist/http-server.js +772 -121
- package/dist/index.js +634 -84
- package/manifest.json +41 -6
- package/package.json +6 -3
- package/plugins/screenpipe-cloud/README.md +65 -0
- package/plugins/screenpipe-cloud/SUBMISSION.md +74 -0
- package/plugins/screenpipe-cloud/mcp.json +9 -0
- package/plugins/screenpipe-cloud/plugin.json +34 -0
- package/scripts/build-installers.mjs +75 -0
- package/scripts/build.mjs +2 -0
- package/scripts/gen-bundled-skills.mjs +17 -0
- package/scripts/hermes-smoke.py +55 -0
- package/scripts/openclaw-smoke.mjs +59 -0
- package/server.json +2 -2
- package/src/activity-summary-format.ts +9 -0
- package/src/activity-summary-tool.test.ts +19 -0
- package/src/bundled-skills-transport.test.ts +52 -0
- package/src/bundled-skills.test.ts +39 -0
- package/src/bundled-skills.ts +34 -0
- package/src/call-observation.test.ts +49 -0
- package/src/call-observation.ts +109 -0
- package/src/generated/bundled-skills.ts +116 -0
- package/src/http-server.test.ts +69 -0
- package/src/http-server.ts +33 -44
- package/src/index.ts +41 -69
- package/src/installers.test.ts +143 -0
- package/src/qualified-value.test.ts +1 -0
- package/src/qualified-value.ts +1 -1
- package/src/search-result.test.ts +22 -0
- package/src/search-result.ts +75 -0
- package/src/stdio-lifecycle.test.ts +48 -0
- package/src/workflow-transport.test.ts +9 -1
package/src/http-server.test.ts
CHANGED
|
@@ -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()));
|
package/src/http-server.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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 (
|
|
1647
|
-
|
|
1648
|
-
|
|
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
|
-
|
|
1702
|
-
|
|
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);
|
package/src/qualified-value.ts
CHANGED
|
@@ -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
|
+
});
|