screenpipe-mcp 0.4.0 → 0.5.0

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.
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * HTTP Server for Screenpipe MCP
4
+ *
5
+ * This allows web apps to call MCP tools over HTTP instead of stdio.
6
+ * Run with: npx ts-node src/http-server.ts --port 3031
7
+ */
8
+ export {};
@@ -0,0 +1,203 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ /**
4
+ * HTTP Server for Screenpipe MCP
5
+ *
6
+ * This allows web apps to call MCP tools over HTTP instead of stdio.
7
+ * Run with: npx ts-node src/http-server.ts --port 3031
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ const http_1 = require("http");
11
+ const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js");
12
+ const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
13
+ const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
14
+ // Parse command line arguments
15
+ const args = process.argv.slice(2);
16
+ let mcpPort = 3031;
17
+ let screenpipePort = 3030;
18
+ for (let i = 0; i < args.length; i++) {
19
+ if (args[i] === "--port" && args[i + 1]) {
20
+ mcpPort = parseInt(args[i + 1], 10);
21
+ }
22
+ if (args[i] === "--screenpipe-port" && args[i + 1]) {
23
+ screenpipePort = parseInt(args[i + 1], 10);
24
+ }
25
+ }
26
+ const SCREENPIPE_API = `http://localhost:${screenpipePort}`;
27
+ // Tool definitions
28
+ const TOOLS = [
29
+ {
30
+ name: "search_content",
31
+ description: "Search screenpipe's recorded content: screen text (OCR), audio transcriptions, and UI elements. " +
32
+ "Returns timestamped results with app context. " +
33
+ "Call with no parameters to get recent activity.",
34
+ inputSchema: {
35
+ type: "object",
36
+ properties: {
37
+ q: {
38
+ type: "string",
39
+ description: "Search query. Optional - omit to return all recent content.",
40
+ },
41
+ content_type: {
42
+ type: "string",
43
+ enum: ["all", "ocr", "audio", "ui"],
44
+ description: "Content type filter. Default: 'all'",
45
+ },
46
+ limit: {
47
+ type: "integer",
48
+ description: "Max results. Default: 10",
49
+ },
50
+ offset: {
51
+ type: "integer",
52
+ description: "Skip N results for pagination. Default: 0",
53
+ },
54
+ start_time: {
55
+ type: "string",
56
+ description: "ISO 8601 UTC start time (e.g., 2024-01-15T10:00:00Z)",
57
+ },
58
+ end_time: {
59
+ type: "string",
60
+ description: "ISO 8601 UTC end time (e.g., 2024-01-15T18:00:00Z)",
61
+ },
62
+ app_name: {
63
+ type: "string",
64
+ description: "Filter by app (e.g., 'Google Chrome', 'Slack', 'zoom.us')",
65
+ },
66
+ window_name: {
67
+ type: "string",
68
+ description: "Filter by window title",
69
+ },
70
+ },
71
+ },
72
+ },
73
+ ];
74
+ // Helper function to make HTTP requests
75
+ async function fetchAPI(endpoint, options = {}) {
76
+ const url = `${SCREENPIPE_API}${endpoint}`;
77
+ return fetch(url, {
78
+ ...options,
79
+ headers: {
80
+ "Content-Type": "application/json",
81
+ ...options.headers,
82
+ },
83
+ });
84
+ }
85
+ // Create MCP server
86
+ const server = new index_js_1.Server({
87
+ name: "screenpipe-http",
88
+ version: "0.1.0",
89
+ }, {
90
+ capabilities: {
91
+ tools: {},
92
+ },
93
+ });
94
+ // List tools handler
95
+ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => {
96
+ return { tools: TOOLS };
97
+ });
98
+ // Call tool handler
99
+ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
100
+ const { name, arguments: args } = request.params;
101
+ if (!args) {
102
+ throw new Error("Missing arguments");
103
+ }
104
+ if (name === "search_content") {
105
+ const params = new URLSearchParams();
106
+ for (const [key, value] of Object.entries(args)) {
107
+ if (value !== null && value !== undefined) {
108
+ params.append(key, String(value));
109
+ }
110
+ }
111
+ const response = await fetchAPI(`/search?${params.toString()}`);
112
+ if (!response.ok) {
113
+ throw new Error(`HTTP error: ${response.status}`);
114
+ }
115
+ const data = await response.json();
116
+ const results = data.data || [];
117
+ const pagination = data.pagination || {};
118
+ if (results.length === 0) {
119
+ return {
120
+ content: [
121
+ {
122
+ type: "text",
123
+ text: "No results found. Try: broader search terms, different content_type, or wider time range.",
124
+ },
125
+ ],
126
+ };
127
+ }
128
+ const formattedResults = [];
129
+ for (const result of results) {
130
+ const content = result.content;
131
+ if (!content)
132
+ continue;
133
+ if (result.type === "OCR") {
134
+ formattedResults.push(`[OCR] ${content.app_name || "?"} | ${content.window_name || "?"}\n` +
135
+ `${content.timestamp || ""}\n` +
136
+ `${content.text || ""}`);
137
+ }
138
+ else if (result.type === "Audio") {
139
+ formattedResults.push(`[Audio] ${content.device_name || "?"}\n` +
140
+ `${content.timestamp || ""}\n` +
141
+ `${content.transcription || ""}`);
142
+ }
143
+ else if (result.type === "UI") {
144
+ formattedResults.push(`[UI] ${content.app_name || "?"} | ${content.window_name || "?"}\n` +
145
+ `${content.timestamp || ""}\n` +
146
+ `${content.text || ""}`);
147
+ }
148
+ }
149
+ const header = `Results: ${results.length}/${pagination.total || "?"}` +
150
+ (pagination.total > results.length ? ` (use offset=${(pagination.offset || 0) + results.length} for more)` : "");
151
+ return {
152
+ content: [
153
+ {
154
+ type: "text",
155
+ text: header + "\n\n" + formattedResults.join("\n---\n"),
156
+ },
157
+ ],
158
+ };
159
+ }
160
+ throw new Error(`Unknown tool: ${name}`);
161
+ });
162
+ // Create HTTP server with MCP transport
163
+ const transports = new Map();
164
+ const httpServer = (0, http_1.createServer)(async (req, res) => {
165
+ // CORS headers
166
+ res.setHeader("Access-Control-Allow-Origin", "*");
167
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
168
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, mcp-session-id");
169
+ if (req.method === "OPTIONS") {
170
+ res.writeHead(204);
171
+ res.end();
172
+ return;
173
+ }
174
+ // Health check
175
+ if (req.url === "/health") {
176
+ res.writeHead(200, { "Content-Type": "application/json" });
177
+ res.end(JSON.stringify({ status: "ok" }));
178
+ return;
179
+ }
180
+ // MCP endpoint
181
+ if (req.url === "/mcp" || req.url?.startsWith("/mcp?")) {
182
+ const sessionId = req.headers["mcp-session-id"];
183
+ let transport = sessionId ? transports.get(sessionId) : undefined;
184
+ if (!transport) {
185
+ transport = new streamableHttp_js_1.StreamableHTTPServerTransport({
186
+ sessionIdGenerator: () => crypto.randomUUID(),
187
+ });
188
+ await server.connect(transport);
189
+ if (transport.sessionId) {
190
+ transports.set(transport.sessionId, transport);
191
+ }
192
+ }
193
+ await transport.handleRequest(req, res);
194
+ return;
195
+ }
196
+ res.writeHead(404, { "Content-Type": "application/json" });
197
+ res.end(JSON.stringify({ error: "Not found" }));
198
+ });
199
+ httpServer.listen(mcpPort, () => {
200
+ console.log(`Screenpipe MCP HTTP server running on http://localhost:${mcpPort}`);
201
+ console.log(`MCP endpoint: http://localhost:${mcpPort}/mcp`);
202
+ console.log(`Health check: http://localhost:${mcpPort}/health`);
203
+ });
package/dist/index.js CHANGED
@@ -71,7 +71,7 @@ const SCREENPIPE_API = `http://localhost:${port}`;
71
71
  // Initialize server
72
72
  const server = new index_js_1.Server({
73
73
  name: "screenpipe",
74
- version: "0.4.0",
74
+ version: "0.5.0",
75
75
  }, {
76
76
  capabilities: {
77
77
  tools: {},
@@ -87,6 +87,10 @@ const BASE_TOOLS = [
87
87
  "Returns timestamped results with app context. " +
88
88
  "Call with no parameters to get recent activity. " +
89
89
  "Use the 'screenpipe://context' resource for current time when building time-based queries.",
90
+ annotations: {
91
+ title: "Search Content",
92
+ readOnlyHint: true,
93
+ },
90
94
  inputSchema: {
91
95
  type: "object",
92
96
  properties: {
@@ -148,6 +152,10 @@ const BASE_TOOLS = [
148
152
  name: "pixel-control",
149
153
  description: "Control mouse and keyboard at the pixel level. This is a cross-platform tool that works on all operating systems. " +
150
154
  "Use this to type text, press keys, move the mouse, and click buttons.",
155
+ annotations: {
156
+ title: "Pixel Control",
157
+ destructiveHint: true,
158
+ },
151
159
  inputSchema: {
152
160
  type: "object",
153
161
  properties: {
@@ -196,6 +204,10 @@ const BASE_TOOLS = [
196
204
  "EXAMPLES:\n" +
197
205
  "- Last 30 minutes: Calculate timestamps from current time\n" +
198
206
  "- Specific meeting: Use the meeting's start and end times in UTC",
207
+ annotations: {
208
+ title: "Export Video",
209
+ destructiveHint: true,
210
+ },
199
211
  inputSchema: {
200
212
  type: "object",
201
213
  properties: {
@@ -231,6 +243,10 @@ const MACOS_TOOLS = [
231
243
  "- Clickable items: 'AXButton', 'AXMenuItem', 'AXMenuBarItem', 'AXImage', 'AXStaticText'\n" +
232
244
  "- Web content may use: 'AXWebArea', 'AXLink', 'AXHeading', 'AXRadioButton'\n\n" +
233
245
  "Use MacOS Accessibility Inspector app to identify the exact roles in your target application.",
246
+ annotations: {
247
+ title: "Find Elements",
248
+ readOnlyHint: true,
249
+ },
234
250
  inputSchema: {
235
251
  type: "object",
236
252
  properties: {
@@ -272,6 +288,10 @@ const MACOS_TOOLS = [
272
288
  {
273
289
  name: "click-element",
274
290
  description: "Click an element in an application using its id (MacOS only)",
291
+ annotations: {
292
+ title: "Click Element",
293
+ destructiveHint: true,
294
+ },
275
295
  inputSchema: {
276
296
  type: "object",
277
297
  properties: {
@@ -304,6 +324,10 @@ const MACOS_TOOLS = [
304
324
  {
305
325
  name: "fill-element",
306
326
  description: "Type text into an element in an application (MacOS only)",
327
+ annotations: {
328
+ title: "Fill Element",
329
+ destructiveHint: true,
330
+ },
307
331
  inputSchema: {
308
332
  type: "object",
309
333
  properties: {
@@ -340,6 +364,10 @@ const MACOS_TOOLS = [
340
364
  {
341
365
  name: "scroll-element",
342
366
  description: "Scroll an element in a specific direction (MacOS only)",
367
+ annotations: {
368
+ title: "Scroll Element",
369
+ destructiveHint: true,
370
+ },
343
371
  inputSchema: {
344
372
  type: "object",
345
373
  properties: {
@@ -381,6 +409,10 @@ const MACOS_TOOLS = [
381
409
  {
382
410
  name: "open-application",
383
411
  description: "Open an application by name",
412
+ annotations: {
413
+ title: "Open Application",
414
+ destructiveHint: true,
415
+ },
384
416
  inputSchema: {
385
417
  type: "object",
386
418
  properties: {
@@ -395,6 +427,10 @@ const MACOS_TOOLS = [
395
427
  {
396
428
  name: "open-url",
397
429
  description: "Open a URL in a browser",
430
+ annotations: {
431
+ title: "Open URL",
432
+ destructiveHint: true,
433
+ },
398
434
  inputSchema: {
399
435
  type: "object",
400
436
  properties: {
@@ -433,6 +469,12 @@ const RESOURCES = [
433
469
  description: "How to use screenpipe search effectively",
434
470
  mimeType: "text/markdown",
435
471
  },
472
+ {
473
+ uri: "ui://search",
474
+ name: "Search Dashboard",
475
+ description: "Interactive search UI for exploring screen recordings and audio transcriptions",
476
+ mimeType: "text/html",
477
+ },
436
478
  ];
437
479
  // List resources handler
438
480
  server.setRequestHandler(types_js_1.ListResourcesRequestSchema, async () => {
@@ -505,6 +547,56 @@ server.setRequestHandler(types_js_1.ReadResourceRequestSchema, async (request) =
505
547
  },
506
548
  ],
507
549
  };
550
+ case "ui://search": {
551
+ // MCP App UI - Interactive search dashboard
552
+ const uiHtmlPath = path.join(__dirname, "..", "ui", "search.html");
553
+ let htmlContent;
554
+ try {
555
+ htmlContent = fs.readFileSync(uiHtmlPath, "utf-8");
556
+ }
557
+ catch {
558
+ // Fallback: serve embedded minimal UI if file not found
559
+ htmlContent = `<!DOCTYPE html>
560
+ <html>
561
+ <head>
562
+ <style>
563
+ body { font-family: system-ui; background: #0a0a0a; color: #fff; padding: 20px; }
564
+ input { width: 100%; padding: 10px; margin-bottom: 10px; background: #1a1a1a; border: 1px solid #333; color: #fff; border-radius: 6px; }
565
+ button { padding: 10px 20px; background: #fff; color: #000; border: none; border-radius: 6px; cursor: pointer; }
566
+ #results { margin-top: 20px; }
567
+ .result { background: #1a1a1a; padding: 12px; margin: 8px 0; border-radius: 8px; border: 1px solid #333; }
568
+ </style>
569
+ </head>
570
+ <body>
571
+ <h2>screenpipe search</h2>
572
+ <input id="q" placeholder="search..." onkeydown="if(event.key==='Enter')search()"/>
573
+ <button onclick="search()">search</button>
574
+ <div id="results"></div>
575
+ <script>
576
+ function search() {
577
+ window.parent.postMessage({jsonrpc:'2.0',method:'tools/call',params:{name:'search-content',arguments:{q:document.getElementById('q').value,limit:20}}},'*');
578
+ }
579
+ window.addEventListener('message',e=>{
580
+ if(e.data?.result||e.data?.method==='tool/result'){
581
+ const r=e.data.result||e.data.params?.result;
582
+ const d=r?.data||r||[];
583
+ document.getElementById('results').innerHTML=d.map(x=>'<div class="result"><b>'+((x.type||'')+'</b> '+(x.content?.app_name||'')+': '+(x.content?.text||x.content?.transcription||'').substring(0,200))+'</div>').join('');
584
+ }
585
+ });
586
+ </script>
587
+ </body>
588
+ </html>`;
589
+ }
590
+ return {
591
+ contents: [
592
+ {
593
+ uri,
594
+ mimeType: "text/html",
595
+ text: htmlContent,
596
+ },
597
+ ],
598
+ };
599
+ }
508
600
  default:
509
601
  throw new Error(`Unknown resource: ${uri}`);
510
602
  }
package/manifest.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "manifest_version": "0.3",
3
3
  "name": "screenpipe",
4
4
  "display_name": "Screenpipe",
5
- "version": "0.3.1",
5
+ "version": "0.5.0",
6
6
  "description": "Search your screen recordings, audio transcriptions, and control your computer with AI",
7
7
  "long_description": "Screenpipe is a 24/7 screen and audio recorder that lets you search everything you've seen or heard. This extension connects Claude to your local screenpipe instance, enabling AI-powered search through your digital memory and computer control capabilities.",
8
8
  "author": {
@@ -30,6 +30,10 @@
30
30
  "name": "search-content",
31
31
  "description": "Search through recorded screen content, audio transcriptions, and UI elements"
32
32
  },
33
+ {
34
+ "name": "export-video",
35
+ "description": "Export screen recordings as MP4 video for a specific time range"
36
+ },
33
37
  {
34
38
  "name": "pixel-control",
35
39
  "description": "Control mouse and keyboard (type text, press keys, move mouse, click)"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "screenpipe-mcp",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "MCP server for screenpipe - search your screen recordings, audio transcriptions, and control your computer",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -9,7 +9,9 @@
9
9
  "scripts": {
10
10
  "build": "tsc",
11
11
  "start": "node dist/index.js",
12
+ "start:http": "node dist/http-server.js",
12
13
  "dev": "ts-node src/index.ts",
14
+ "dev:http": "ts-node src/http-server.ts",
13
15
  "test": "vitest run",
14
16
  "test:watch": "vitest",
15
17
  "prepublishOnly": "npm run build"
@@ -0,0 +1,243 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * HTTP Server for Screenpipe MCP
5
+ *
6
+ * This allows web apps to call MCP tools over HTTP instead of stdio.
7
+ * Run with: npx ts-node src/http-server.ts --port 3031
8
+ */
9
+
10
+ import { createServer } from "http";
11
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
12
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
13
+ import {
14
+ CallToolRequestSchema,
15
+ ListToolsRequestSchema,
16
+ } from "@modelcontextprotocol/sdk/types.js";
17
+
18
+ // Parse command line arguments
19
+ const args = process.argv.slice(2);
20
+ let mcpPort = 3031;
21
+ let screenpipePort = 3030;
22
+
23
+ for (let i = 0; i < args.length; i++) {
24
+ if (args[i] === "--port" && args[i + 1]) {
25
+ mcpPort = parseInt(args[i + 1], 10);
26
+ }
27
+ if (args[i] === "--screenpipe-port" && args[i + 1]) {
28
+ screenpipePort = parseInt(args[i + 1], 10);
29
+ }
30
+ }
31
+
32
+ const SCREENPIPE_API = `http://localhost:${screenpipePort}`;
33
+
34
+ // Tool definitions
35
+ const TOOLS = [
36
+ {
37
+ name: "search_content",
38
+ description:
39
+ "Search screenpipe's recorded content: screen text (OCR), audio transcriptions, and UI elements. " +
40
+ "Returns timestamped results with app context. " +
41
+ "Call with no parameters to get recent activity.",
42
+ inputSchema: {
43
+ type: "object" as const,
44
+ properties: {
45
+ q: {
46
+ type: "string",
47
+ description: "Search query. Optional - omit to return all recent content.",
48
+ },
49
+ content_type: {
50
+ type: "string",
51
+ enum: ["all", "ocr", "audio", "ui"],
52
+ description: "Content type filter. Default: 'all'",
53
+ },
54
+ limit: {
55
+ type: "integer",
56
+ description: "Max results. Default: 10",
57
+ },
58
+ offset: {
59
+ type: "integer",
60
+ description: "Skip N results for pagination. Default: 0",
61
+ },
62
+ start_time: {
63
+ type: "string",
64
+ description: "ISO 8601 UTC start time (e.g., 2024-01-15T10:00:00Z)",
65
+ },
66
+ end_time: {
67
+ type: "string",
68
+ description: "ISO 8601 UTC end time (e.g., 2024-01-15T18:00:00Z)",
69
+ },
70
+ app_name: {
71
+ type: "string",
72
+ description: "Filter by app (e.g., 'Google Chrome', 'Slack', 'zoom.us')",
73
+ },
74
+ window_name: {
75
+ type: "string",
76
+ description: "Filter by window title",
77
+ },
78
+ },
79
+ },
80
+ },
81
+ ];
82
+
83
+ // Helper function to make HTTP requests
84
+ async function fetchAPI(endpoint: string, options: RequestInit = {}): Promise<Response> {
85
+ const url = `${SCREENPIPE_API}${endpoint}`;
86
+ return fetch(url, {
87
+ ...options,
88
+ headers: {
89
+ "Content-Type": "application/json",
90
+ ...options.headers,
91
+ },
92
+ });
93
+ }
94
+
95
+ // Create MCP server
96
+ const server = new Server(
97
+ {
98
+ name: "screenpipe-http",
99
+ version: "0.1.0",
100
+ },
101
+ {
102
+ capabilities: {
103
+ tools: {},
104
+ },
105
+ }
106
+ );
107
+
108
+ // List tools handler
109
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
110
+ return { tools: TOOLS };
111
+ });
112
+
113
+ // Call tool handler
114
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
115
+ const { name, arguments: args } = request.params;
116
+
117
+ if (!args) {
118
+ throw new Error("Missing arguments");
119
+ }
120
+
121
+ if (name === "search_content") {
122
+ const params = new URLSearchParams();
123
+ for (const [key, value] of Object.entries(args)) {
124
+ if (value !== null && value !== undefined) {
125
+ params.append(key, String(value));
126
+ }
127
+ }
128
+
129
+ const response = await fetchAPI(`/search?${params.toString()}`);
130
+ if (!response.ok) {
131
+ throw new Error(`HTTP error: ${response.status}`);
132
+ }
133
+
134
+ const data = await response.json();
135
+ const results = data.data || [];
136
+ const pagination = data.pagination || {};
137
+
138
+ if (results.length === 0) {
139
+ return {
140
+ content: [
141
+ {
142
+ type: "text",
143
+ text: "No results found. Try: broader search terms, different content_type, or wider time range.",
144
+ },
145
+ ],
146
+ };
147
+ }
148
+
149
+ const formattedResults: string[] = [];
150
+ for (const result of results) {
151
+ const content = result.content;
152
+ if (!content) continue;
153
+
154
+ if (result.type === "OCR") {
155
+ formattedResults.push(
156
+ `[OCR] ${content.app_name || "?"} | ${content.window_name || "?"}\n` +
157
+ `${content.timestamp || ""}\n` +
158
+ `${content.text || ""}`
159
+ );
160
+ } else if (result.type === "Audio") {
161
+ formattedResults.push(
162
+ `[Audio] ${content.device_name || "?"}\n` +
163
+ `${content.timestamp || ""}\n` +
164
+ `${content.transcription || ""}`
165
+ );
166
+ } else if (result.type === "UI") {
167
+ formattedResults.push(
168
+ `[UI] ${content.app_name || "?"} | ${content.window_name || "?"}\n` +
169
+ `${content.timestamp || ""}\n` +
170
+ `${content.text || ""}`
171
+ );
172
+ }
173
+ }
174
+
175
+ const header = `Results: ${results.length}/${pagination.total || "?"}` +
176
+ (pagination.total > results.length ? ` (use offset=${(pagination.offset || 0) + results.length} for more)` : "");
177
+
178
+ return {
179
+ content: [
180
+ {
181
+ type: "text",
182
+ text: header + "\n\n" + formattedResults.join("\n---\n"),
183
+ },
184
+ ],
185
+ };
186
+ }
187
+
188
+ throw new Error(`Unknown tool: ${name}`);
189
+ });
190
+
191
+ // Create HTTP server with MCP transport
192
+ const transports = new Map<string, StreamableHTTPServerTransport>();
193
+
194
+ const httpServer = createServer(async (req, res) => {
195
+ // CORS headers
196
+ res.setHeader("Access-Control-Allow-Origin", "*");
197
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
198
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, mcp-session-id");
199
+
200
+ if (req.method === "OPTIONS") {
201
+ res.writeHead(204);
202
+ res.end();
203
+ return;
204
+ }
205
+
206
+ // Health check
207
+ if (req.url === "/health") {
208
+ res.writeHead(200, { "Content-Type": "application/json" });
209
+ res.end(JSON.stringify({ status: "ok" }));
210
+ return;
211
+ }
212
+
213
+ // MCP endpoint
214
+ if (req.url === "/mcp" || req.url?.startsWith("/mcp?")) {
215
+ const sessionId = req.headers["mcp-session-id"] as string | undefined;
216
+
217
+ let transport = sessionId ? transports.get(sessionId) : undefined;
218
+
219
+ if (!transport) {
220
+ transport = new StreamableHTTPServerTransport({
221
+ sessionIdGenerator: () => crypto.randomUUID(),
222
+ });
223
+
224
+ await server.connect(transport);
225
+
226
+ if (transport.sessionId) {
227
+ transports.set(transport.sessionId, transport);
228
+ }
229
+ }
230
+
231
+ await transport.handleRequest(req, res);
232
+ return;
233
+ }
234
+
235
+ res.writeHead(404, { "Content-Type": "application/json" });
236
+ res.end(JSON.stringify({ error: "Not found" }));
237
+ });
238
+
239
+ httpServer.listen(mcpPort, () => {
240
+ console.log(`Screenpipe MCP HTTP server running on http://localhost:${mcpPort}`);
241
+ console.log(`MCP endpoint: http://localhost:${mcpPort}/mcp`);
242
+ console.log(`Health check: http://localhost:${mcpPort}/health`);
243
+ });
package/src/index.ts CHANGED
@@ -51,7 +51,7 @@ const SCREENPIPE_API = `http://localhost:${port}`;
51
51
  const server = new Server(
52
52
  {
53
53
  name: "screenpipe",
54
- version: "0.4.0",
54
+ version: "0.5.0",
55
55
  },
56
56
  {
57
57
  capabilities: {
@@ -71,6 +71,10 @@ const BASE_TOOLS: Tool[] = [
71
71
  "Returns timestamped results with app context. " +
72
72
  "Call with no parameters to get recent activity. " +
73
73
  "Use the 'screenpipe://context' resource for current time when building time-based queries.",
74
+ annotations: {
75
+ title: "Search Content",
76
+ readOnlyHint: true,
77
+ },
74
78
  inputSchema: {
75
79
  type: "object",
76
80
  properties: {
@@ -133,6 +137,10 @@ const BASE_TOOLS: Tool[] = [
133
137
  description:
134
138
  "Control mouse and keyboard at the pixel level. This is a cross-platform tool that works on all operating systems. " +
135
139
  "Use this to type text, press keys, move the mouse, and click buttons.",
140
+ annotations: {
141
+ title: "Pixel Control",
142
+ destructiveHint: true,
143
+ },
136
144
  inputSchema: {
137
145
  type: "object",
138
146
  properties: {
@@ -183,6 +191,10 @@ const BASE_TOOLS: Tool[] = [
183
191
  "EXAMPLES:\n" +
184
192
  "- Last 30 minutes: Calculate timestamps from current time\n" +
185
193
  "- Specific meeting: Use the meeting's start and end times in UTC",
194
+ annotations: {
195
+ title: "Export Video",
196
+ destructiveHint: true,
197
+ },
186
198
  inputSchema: {
187
199
  type: "object",
188
200
  properties: {
@@ -223,6 +235,10 @@ const MACOS_TOOLS: Tool[] = [
223
235
  "- Clickable items: 'AXButton', 'AXMenuItem', 'AXMenuBarItem', 'AXImage', 'AXStaticText'\n" +
224
236
  "- Web content may use: 'AXWebArea', 'AXLink', 'AXHeading', 'AXRadioButton'\n\n" +
225
237
  "Use MacOS Accessibility Inspector app to identify the exact roles in your target application.",
238
+ annotations: {
239
+ title: "Find Elements",
240
+ readOnlyHint: true,
241
+ },
226
242
  inputSchema: {
227
243
  type: "object",
228
244
  properties: {
@@ -267,6 +283,10 @@ const MACOS_TOOLS: Tool[] = [
267
283
  name: "click-element",
268
284
  description:
269
285
  "Click an element in an application using its id (MacOS only)",
286
+ annotations: {
287
+ title: "Click Element",
288
+ destructiveHint: true,
289
+ },
270
290
  inputSchema: {
271
291
  type: "object",
272
292
  properties: {
@@ -299,6 +319,10 @@ const MACOS_TOOLS: Tool[] = [
299
319
  {
300
320
  name: "fill-element",
301
321
  description: "Type text into an element in an application (MacOS only)",
322
+ annotations: {
323
+ title: "Fill Element",
324
+ destructiveHint: true,
325
+ },
302
326
  inputSchema: {
303
327
  type: "object",
304
328
  properties: {
@@ -335,6 +359,10 @@ const MACOS_TOOLS: Tool[] = [
335
359
  {
336
360
  name: "scroll-element",
337
361
  description: "Scroll an element in a specific direction (MacOS only)",
362
+ annotations: {
363
+ title: "Scroll Element",
364
+ destructiveHint: true,
365
+ },
338
366
  inputSchema: {
339
367
  type: "object",
340
368
  properties: {
@@ -376,6 +404,10 @@ const MACOS_TOOLS: Tool[] = [
376
404
  {
377
405
  name: "open-application",
378
406
  description: "Open an application by name",
407
+ annotations: {
408
+ title: "Open Application",
409
+ destructiveHint: true,
410
+ },
379
411
  inputSchema: {
380
412
  type: "object",
381
413
  properties: {
@@ -390,6 +422,10 @@ const MACOS_TOOLS: Tool[] = [
390
422
  {
391
423
  name: "open-url",
392
424
  description: "Open a URL in a browser",
425
+ annotations: {
426
+ title: "Open URL",
427
+ destructiveHint: true,
428
+ },
393
429
  inputSchema: {
394
430
  type: "object",
395
431
  properties: {
@@ -430,6 +466,12 @@ const RESOURCES = [
430
466
  description: "How to use screenpipe search effectively",
431
467
  mimeType: "text/markdown",
432
468
  },
469
+ {
470
+ uri: "ui://search",
471
+ name: "Search Dashboard",
472
+ description: "Interactive search UI for exploring screen recordings and audio transcriptions",
473
+ mimeType: "text/html",
474
+ },
433
475
  ];
434
476
 
435
477
  // List resources handler
@@ -507,6 +549,56 @@ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
507
549
  ],
508
550
  };
509
551
 
552
+ case "ui://search": {
553
+ // MCP App UI - Interactive search dashboard
554
+ const uiHtmlPath = path.join(__dirname, "..", "ui", "search.html");
555
+ let htmlContent: string;
556
+ try {
557
+ htmlContent = fs.readFileSync(uiHtmlPath, "utf-8");
558
+ } catch {
559
+ // Fallback: serve embedded minimal UI if file not found
560
+ htmlContent = `<!DOCTYPE html>
561
+ <html>
562
+ <head>
563
+ <style>
564
+ body { font-family: system-ui; background: #0a0a0a; color: #fff; padding: 20px; }
565
+ input { width: 100%; padding: 10px; margin-bottom: 10px; background: #1a1a1a; border: 1px solid #333; color: #fff; border-radius: 6px; }
566
+ button { padding: 10px 20px; background: #fff; color: #000; border: none; border-radius: 6px; cursor: pointer; }
567
+ #results { margin-top: 20px; }
568
+ .result { background: #1a1a1a; padding: 12px; margin: 8px 0; border-radius: 8px; border: 1px solid #333; }
569
+ </style>
570
+ </head>
571
+ <body>
572
+ <h2>screenpipe search</h2>
573
+ <input id="q" placeholder="search..." onkeydown="if(event.key==='Enter')search()"/>
574
+ <button onclick="search()">search</button>
575
+ <div id="results"></div>
576
+ <script>
577
+ function search() {
578
+ window.parent.postMessage({jsonrpc:'2.0',method:'tools/call',params:{name:'search-content',arguments:{q:document.getElementById('q').value,limit:20}}},'*');
579
+ }
580
+ window.addEventListener('message',e=>{
581
+ if(e.data?.result||e.data?.method==='tool/result'){
582
+ const r=e.data.result||e.data.params?.result;
583
+ const d=r?.data||r||[];
584
+ document.getElementById('results').innerHTML=d.map(x=>'<div class="result"><b>'+((x.type||'')+'</b> '+(x.content?.app_name||'')+': '+(x.content?.text||x.content?.transcription||'').substring(0,200))+'</div>').join('');
585
+ }
586
+ });
587
+ </script>
588
+ </body>
589
+ </html>`;
590
+ }
591
+ return {
592
+ contents: [
593
+ {
594
+ uri,
595
+ mimeType: "text/html",
596
+ text: htmlContent,
597
+ },
598
+ ],
599
+ };
600
+ }
601
+
510
602
  default:
511
603
  throw new Error(`Unknown resource: ${uri}`);
512
604
  }
package/ui/search.html ADDED
@@ -0,0 +1,559 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>screenpipe search</title>
7
+ <style>
8
+ * {
9
+ box-sizing: border-box;
10
+ margin: 0;
11
+ padding: 0;
12
+ }
13
+
14
+ body {
15
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
16
+ background: #0a0a0a;
17
+ color: #fafafa;
18
+ padding: 16px;
19
+ min-height: 100vh;
20
+ }
21
+
22
+ .container {
23
+ max-width: 700px;
24
+ margin: 0 auto;
25
+ }
26
+
27
+ .header {
28
+ display: flex;
29
+ align-items: center;
30
+ gap: 10px;
31
+ margin-bottom: 20px;
32
+ }
33
+
34
+ .header h1 {
35
+ font-size: 18px;
36
+ font-weight: 600;
37
+ }
38
+
39
+ .logo {
40
+ width: 24px;
41
+ height: 24px;
42
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
43
+ border-radius: 6px;
44
+ }
45
+
46
+ .search-box {
47
+ display: flex;
48
+ gap: 8px;
49
+ margin-bottom: 16px;
50
+ }
51
+
52
+ .search-input {
53
+ flex: 1;
54
+ padding: 12px 16px;
55
+ border: 1px solid #333;
56
+ border-radius: 8px;
57
+ background: #1a1a1a;
58
+ color: #fff;
59
+ font-size: 14px;
60
+ outline: none;
61
+ transition: border-color 0.2s;
62
+ }
63
+
64
+ .search-input:focus {
65
+ border-color: #666;
66
+ }
67
+
68
+ .search-input::placeholder {
69
+ color: #666;
70
+ }
71
+
72
+ .btn {
73
+ padding: 12px 20px;
74
+ border: none;
75
+ border-radius: 8px;
76
+ font-size: 14px;
77
+ font-weight: 500;
78
+ cursor: pointer;
79
+ transition: all 0.2s;
80
+ }
81
+
82
+ .btn-primary {
83
+ background: #fff;
84
+ color: #000;
85
+ }
86
+
87
+ .btn-primary:hover {
88
+ background: #e0e0e0;
89
+ }
90
+
91
+ .btn-secondary {
92
+ background: #1a1a1a;
93
+ color: #fff;
94
+ border: 1px solid #333;
95
+ }
96
+
97
+ .btn-secondary:hover {
98
+ background: #252525;
99
+ }
100
+
101
+ .filters {
102
+ display: flex;
103
+ gap: 8px;
104
+ margin-bottom: 20px;
105
+ flex-wrap: wrap;
106
+ }
107
+
108
+ .filter-btn {
109
+ padding: 6px 14px;
110
+ border: 1px solid #333;
111
+ border-radius: 20px;
112
+ background: transparent;
113
+ color: #888;
114
+ font-size: 13px;
115
+ cursor: pointer;
116
+ transition: all 0.2s;
117
+ }
118
+
119
+ .filter-btn:hover {
120
+ border-color: #555;
121
+ color: #fff;
122
+ }
123
+
124
+ .filter-btn.active {
125
+ background: #fff;
126
+ color: #000;
127
+ border-color: #fff;
128
+ }
129
+
130
+ .stats {
131
+ display: flex;
132
+ gap: 20px;
133
+ padding: 12px 16px;
134
+ background: #1a1a1a;
135
+ border-radius: 8px;
136
+ margin-bottom: 20px;
137
+ font-size: 13px;
138
+ }
139
+
140
+ .stat {
141
+ display: flex;
142
+ align-items: center;
143
+ gap: 6px;
144
+ }
145
+
146
+ .stat-value {
147
+ font-weight: 600;
148
+ color: #fff;
149
+ }
150
+
151
+ .stat-label {
152
+ color: #666;
153
+ }
154
+
155
+ .results {
156
+ display: flex;
157
+ flex-direction: column;
158
+ gap: 12px;
159
+ }
160
+
161
+ .result-card {
162
+ background: #1a1a1a;
163
+ border: 1px solid #252525;
164
+ border-radius: 10px;
165
+ padding: 16px;
166
+ transition: border-color 0.2s;
167
+ }
168
+
169
+ .result-card:hover {
170
+ border-color: #333;
171
+ }
172
+
173
+ .result-header {
174
+ display: flex;
175
+ justify-content: space-between;
176
+ align-items: flex-start;
177
+ margin-bottom: 10px;
178
+ }
179
+
180
+ .result-type {
181
+ display: inline-flex;
182
+ align-items: center;
183
+ gap: 6px;
184
+ padding: 4px 10px;
185
+ border-radius: 4px;
186
+ font-size: 11px;
187
+ font-weight: 600;
188
+ text-transform: uppercase;
189
+ letter-spacing: 0.5px;
190
+ }
191
+
192
+ .type-ocr {
193
+ background: rgba(59, 130, 246, 0.2);
194
+ color: #60a5fa;
195
+ }
196
+
197
+ .type-audio {
198
+ background: rgba(34, 197, 94, 0.2);
199
+ color: #4ade80;
200
+ }
201
+
202
+ .type-ui {
203
+ background: rgba(168, 85, 247, 0.2);
204
+ color: #c084fc;
205
+ }
206
+
207
+ .result-time {
208
+ font-size: 12px;
209
+ color: #666;
210
+ }
211
+
212
+ .result-app {
213
+ font-size: 13px;
214
+ color: #888;
215
+ margin-bottom: 8px;
216
+ }
217
+
218
+ .result-text {
219
+ font-size: 14px;
220
+ line-height: 1.5;
221
+ color: #ccc;
222
+ }
223
+
224
+ .result-actions {
225
+ display: flex;
226
+ gap: 8px;
227
+ margin-top: 12px;
228
+ padding-top: 12px;
229
+ border-top: 1px solid #252525;
230
+ }
231
+
232
+ .action-btn {
233
+ padding: 6px 12px;
234
+ border: 1px solid #333;
235
+ border-radius: 6px;
236
+ background: transparent;
237
+ color: #888;
238
+ font-size: 12px;
239
+ cursor: pointer;
240
+ transition: all 0.2s;
241
+ }
242
+
243
+ .action-btn:hover {
244
+ border-color: #555;
245
+ color: #fff;
246
+ }
247
+
248
+ .empty-state {
249
+ text-align: center;
250
+ padding: 60px 20px;
251
+ color: #666;
252
+ }
253
+
254
+ .empty-state h3 {
255
+ font-size: 16px;
256
+ margin-bottom: 8px;
257
+ color: #888;
258
+ }
259
+
260
+ .loading {
261
+ display: flex;
262
+ justify-content: center;
263
+ padding: 40px;
264
+ }
265
+
266
+ .spinner {
267
+ width: 24px;
268
+ height: 24px;
269
+ border: 2px solid #333;
270
+ border-top-color: #fff;
271
+ border-radius: 50%;
272
+ animation: spin 0.8s linear infinite;
273
+ }
274
+
275
+ @keyframes spin {
276
+ to { transform: rotate(360deg); }
277
+ }
278
+
279
+ .hidden {
280
+ display: none;
281
+ }
282
+ </style>
283
+ </head>
284
+ <body>
285
+ <div class="container">
286
+ <div class="header">
287
+ <div class="logo"></div>
288
+ <h1>screenpipe search</h1>
289
+ </div>
290
+
291
+ <div class="search-box">
292
+ <input
293
+ type="text"
294
+ id="query"
295
+ class="search-input"
296
+ placeholder="search your screen history, audio, and more..."
297
+ onkeydown="if(event.key==='Enter')search()"
298
+ />
299
+ <button class="btn btn-primary" onclick="search()">search</button>
300
+ </div>
301
+
302
+ <div class="filters">
303
+ <button class="filter-btn active" data-type="all" onclick="setFilter('all')">all</button>
304
+ <button class="filter-btn" data-type="ocr" onclick="setFilter('ocr')">screen</button>
305
+ <button class="filter-btn" data-type="audio" onclick="setFilter('audio')">audio</button>
306
+ <button class="filter-btn" data-type="ui" onclick="setFilter('ui')">ui elements</button>
307
+ </div>
308
+
309
+ <div id="stats" class="stats hidden">
310
+ <div class="stat">
311
+ <span class="stat-value" id="result-count">0</span>
312
+ <span class="stat-label">results</span>
313
+ </div>
314
+ <div class="stat">
315
+ <span class="stat-value" id="time-range">-</span>
316
+ <span class="stat-label">time range</span>
317
+ </div>
318
+ </div>
319
+
320
+ <div id="loading" class="loading hidden">
321
+ <div class="spinner"></div>
322
+ </div>
323
+
324
+ <div id="results" class="results">
325
+ <div class="empty-state">
326
+ <h3>search your digital history</h3>
327
+ <p>type a query above to search through your screen recordings and audio transcriptions</p>
328
+ </div>
329
+ </div>
330
+ </div>
331
+
332
+ <script>
333
+ let currentFilter = 'all';
334
+ let pendingRequestId = null;
335
+
336
+ // MCP App communication layer
337
+ const mcp = {
338
+ requestId: 0,
339
+
340
+ callTool: function(name, args) {
341
+ this.requestId++;
342
+ pendingRequestId = this.requestId;
343
+ window.parent.postMessage({
344
+ jsonrpc: '2.0',
345
+ id: this.requestId,
346
+ method: 'tools/call',
347
+ params: { name, arguments: args }
348
+ }, '*');
349
+ return this.requestId;
350
+ },
351
+
352
+ sendMessage: function(text) {
353
+ window.parent.postMessage({
354
+ jsonrpc: '2.0',
355
+ method: 'message/send',
356
+ params: { content: text }
357
+ }, '*');
358
+ }
359
+ };
360
+
361
+ // listen for messages from host
362
+ window.addEventListener('message', (event) => {
363
+ const data = event.data;
364
+
365
+ // handle tool results
366
+ if (data?.result || data?.method === 'tool/result') {
367
+ hideLoading();
368
+ const result = data.result || data.params?.result;
369
+ if (result) {
370
+ displayResults(result);
371
+ }
372
+ }
373
+
374
+ // handle errors
375
+ if (data?.error) {
376
+ hideLoading();
377
+ showError(data.error.message || 'search failed');
378
+ }
379
+ });
380
+
381
+ function setFilter(type) {
382
+ currentFilter = type;
383
+ document.querySelectorAll('.filter-btn').forEach(btn => {
384
+ btn.classList.toggle('active', btn.dataset.type === type);
385
+ });
386
+ // re-run search with new filter if there's a query
387
+ const query = document.getElementById('query').value;
388
+ if (query) {
389
+ search();
390
+ }
391
+ }
392
+
393
+ function search() {
394
+ const query = document.getElementById('query').value;
395
+ showLoading();
396
+
397
+ const args = {
398
+ q: query || undefined,
399
+ content_type: currentFilter,
400
+ limit: 20
401
+ };
402
+
403
+ mcp.callTool('search-content', args);
404
+ }
405
+
406
+ function showLoading() {
407
+ document.getElementById('loading').classList.remove('hidden');
408
+ document.getElementById('results').innerHTML = '';
409
+ document.getElementById('stats').classList.add('hidden');
410
+ }
411
+
412
+ function hideLoading() {
413
+ document.getElementById('loading').classList.add('hidden');
414
+ }
415
+
416
+ function showError(message) {
417
+ document.getElementById('results').innerHTML = `
418
+ <div class="empty-state">
419
+ <h3>error</h3>
420
+ <p>${escapeHtml(message)}</p>
421
+ </div>
422
+ `;
423
+ }
424
+
425
+ function displayResults(data) {
426
+ const results = data.data || data || [];
427
+ const container = document.getElementById('results');
428
+ const statsEl = document.getElementById('stats');
429
+
430
+ if (!Array.isArray(results) || results.length === 0) {
431
+ container.innerHTML = `
432
+ <div class="empty-state">
433
+ <h3>no results found</h3>
434
+ <p>try a different search term or adjust your filters</p>
435
+ </div>
436
+ `;
437
+ statsEl.classList.add('hidden');
438
+ return;
439
+ }
440
+
441
+ // update stats
442
+ document.getElementById('result-count').textContent = results.length;
443
+
444
+ // calculate time range
445
+ const timestamps = results
446
+ .map(r => r.content?.timestamp)
447
+ .filter(Boolean)
448
+ .map(t => new Date(t).getTime())
449
+ .sort((a, b) => a - b);
450
+
451
+ if (timestamps.length > 1) {
452
+ const start = new Date(timestamps[0]);
453
+ const end = new Date(timestamps[timestamps.length - 1]);
454
+ document.getElementById('time-range').textContent = formatTimeRange(start, end);
455
+ } else {
456
+ document.getElementById('time-range').textContent = 'now';
457
+ }
458
+
459
+ statsEl.classList.remove('hidden');
460
+
461
+ // render results
462
+ container.innerHTML = results.map(result => {
463
+ const content = result.content || {};
464
+ const type = result.type?.toLowerCase() || 'unknown';
465
+ const text = content.text || content.transcription || '';
466
+ const app = content.app_name || content.device_name || 'unknown';
467
+ const window = content.window_name || '';
468
+ const time = content.timestamp ? formatTime(new Date(content.timestamp)) : '';
469
+
470
+ return `
471
+ <div class="result-card">
472
+ <div class="result-header">
473
+ <span class="result-type type-${type}">${getTypeIcon(type)} ${type}</span>
474
+ <span class="result-time">${time}</span>
475
+ </div>
476
+ <div class="result-app">${escapeHtml(app)}${window ? ' - ' + escapeHtml(window) : ''}</div>
477
+ <div class="result-text">${escapeHtml(truncate(text, 300))}</div>
478
+ <div class="result-actions">
479
+ <button class="action-btn" onclick="copyText('${escapeJs(text)}')">copy</button>
480
+ <button class="action-btn" onclick="askAbout('${escapeJs(text.substring(0, 100))}')">ask AI</button>
481
+ </div>
482
+ </div>
483
+ `;
484
+ }).join('');
485
+ }
486
+
487
+ function getTypeIcon(type) {
488
+ switch(type) {
489
+ case 'ocr': return '';
490
+ case 'audio': return '';
491
+ case 'ui': return '';
492
+ default: return '';
493
+ }
494
+ }
495
+
496
+ function formatTime(date) {
497
+ const now = new Date();
498
+ const diff = now - date;
499
+
500
+ if (diff < 60000) return 'just now';
501
+ if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago';
502
+ if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago';
503
+
504
+ return date.toLocaleDateString('en-US', {
505
+ month: 'short',
506
+ day: 'numeric',
507
+ hour: 'numeric',
508
+ minute: '2-digit'
509
+ });
510
+ }
511
+
512
+ function formatTimeRange(start, end) {
513
+ const diff = end - start;
514
+ if (diff < 3600000) return Math.floor(diff / 60000) + ' min';
515
+ if (diff < 86400000) return Math.floor(diff / 3600000) + ' hours';
516
+ return Math.floor(diff / 86400000) + ' days';
517
+ }
518
+
519
+ function truncate(str, len) {
520
+ if (!str) return '';
521
+ if (str.length <= len) return str;
522
+ return str.substring(0, len) + '...';
523
+ }
524
+
525
+ function escapeHtml(str) {
526
+ if (!str) return '';
527
+ return str
528
+ .replace(/&/g, '&amp;')
529
+ .replace(/</g, '&lt;')
530
+ .replace(/>/g, '&gt;')
531
+ .replace(/"/g, '&quot;')
532
+ .replace(/'/g, '&#039;');
533
+ }
534
+
535
+ function escapeJs(str) {
536
+ if (!str) return '';
537
+ return str
538
+ .replace(/\\/g, '\\\\')
539
+ .replace(/'/g, "\\'")
540
+ .replace(/"/g, '\\"')
541
+ .replace(/\n/g, '\\n')
542
+ .replace(/\r/g, '\\r');
543
+ }
544
+
545
+ function copyText(text) {
546
+ navigator.clipboard.writeText(text).then(() => {
547
+ // could show a toast here
548
+ });
549
+ }
550
+
551
+ function askAbout(text) {
552
+ mcp.sendMessage(`Tell me more about: "${text}"`);
553
+ }
554
+
555
+ // auto-focus search input
556
+ document.getElementById('query').focus();
557
+ </script>
558
+ </body>
559
+ </html>