@loupekit/mcp 0.10.16 → 0.10.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +444 -4
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -7,7 +7,419 @@ import { pathToFileURL } from "url";
7
7
  import { realpathSync } from "fs";
8
8
  import { argv } from "process";
9
9
  import { z } from "zod";
10
- import { Buffer } from "buffer";
10
+ import { Buffer as Buffer2 } from "buffer";
11
+
12
+ // src/bridge/selection-store.ts
13
+ var isNonEmptyString = (v) => typeof v === "string" && v.trim().length > 0;
14
+ var isStringMap = (v) => !!v && typeof v === "object" && !Array.isArray(v) && Object.values(v).every((x) => typeof x === "string");
15
+ var isBox = (v) => {
16
+ if (!v || typeof v !== "object" || Array.isArray(v)) return false;
17
+ const b = v;
18
+ return ["x", "y", "w", "h"].every((k) => typeof b[k] === "number" && Number.isFinite(b[k]));
19
+ };
20
+ function validateSelection(input) {
21
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
22
+ return { ok: false, error: "body must be a JSON object" };
23
+ }
24
+ const p = input;
25
+ for (const key of ["correlationId", "url", "tag"]) {
26
+ if (!isNonEmptyString(p[key])) return { ok: false, error: `${key} is required` };
27
+ }
28
+ for (const key of ["selector", "text", "at"]) {
29
+ if (p[key] !== void 0 && typeof p[key] !== "string") return { ok: false, error: `${key} must be a string` };
30
+ }
31
+ for (const key of ["testid", "id"]) {
32
+ if (p[key] !== void 0 && p[key] !== null && typeof p[key] !== "string") {
33
+ return { ok: false, error: `${key} must be a string or null` };
34
+ }
35
+ }
36
+ if (p.attrs !== void 0 && !isStringMap(p.attrs)) return { ok: false, error: "attrs must be a string map" };
37
+ if (p.styles !== void 0 && !isStringMap(p.styles)) return { ok: false, error: "styles must be a string map" };
38
+ if (p.box !== void 0 && !isBox(p.box)) return { ok: false, error: "box must be { x, y, w, h }" };
39
+ if (p.screenshot !== void 0 && typeof p.screenshot !== "string") {
40
+ return { ok: false, error: "screenshot must be a string" };
41
+ }
42
+ if (p.classes !== void 0 && (!Array.isArray(p.classes) || !p.classes.every((c) => typeof c === "string"))) {
43
+ return { ok: false, error: "classes must be a string array" };
44
+ }
45
+ return {
46
+ ok: true,
47
+ value: {
48
+ correlationId: p.correlationId,
49
+ url: p.url,
50
+ tag: p.tag,
51
+ selector: p.selector,
52
+ testid: p.testid ?? null,
53
+ text: p.text,
54
+ id: p.id ?? null,
55
+ classes: p.classes,
56
+ attrs: p.attrs,
57
+ styles: p.styles,
58
+ box: p.box,
59
+ screenshot: p.screenshot,
60
+ at: p.at
61
+ }
62
+ };
63
+ }
64
+ var SelectionStore = class {
65
+ items = [];
66
+ capacity;
67
+ // Declared and assigned explicitly rather than as a constructor parameter
68
+ // property: Node runs these files with type-stripping only, and
69
+ // `constructor(private x)` is not supported in strip-only mode.
70
+ constructor(capacity = 50) {
71
+ if (!Number.isInteger(capacity) || capacity < 1) throw new Error("capacity must be a positive integer");
72
+ this.capacity = capacity;
73
+ }
74
+ /** Add a selection, evicting the oldest once the ring is full. Newest last. */
75
+ add(selection) {
76
+ const stored = { ...selection, at: selection.at ?? (/* @__PURE__ */ new Date()).toISOString() };
77
+ this.items.push(stored);
78
+ if (this.items.length > this.capacity) this.items.splice(0, this.items.length - this.capacity);
79
+ return stored;
80
+ }
81
+ latest() {
82
+ return this.items.length ? this.items[this.items.length - 1] : null;
83
+ }
84
+ /** Newest first — the order a caller wants them in. */
85
+ history(limit = this.capacity) {
86
+ const n = Math.max(0, Math.min(limit, this.items.length));
87
+ return this.items.slice(-n).reverse();
88
+ }
89
+ get(correlationId) {
90
+ return this.items.find((s) => s.correlationId === correlationId) ?? null;
91
+ }
92
+ size() {
93
+ return this.items.length;
94
+ }
95
+ clear() {
96
+ this.items = [];
97
+ }
98
+ };
99
+
100
+ // src/bridge/agent-registry.ts
101
+ var AGENT_STALE_MS = 3e4;
102
+ var AGENT_SWEEP_MS = 1e4;
103
+ function agentId(reg) {
104
+ const input = `${reg.type}\0${reg.workspace}\0${reg.name}`;
105
+ let h = 2166136261;
106
+ for (let i = 0; i < input.length; i++) {
107
+ h ^= input.charCodeAt(i);
108
+ h = Math.imul(h, 16777619) >>> 0;
109
+ }
110
+ return `ag_${h.toString(16).padStart(8, "0")}`;
111
+ }
112
+ var AgentRegistry = class {
113
+ agents = /* @__PURE__ */ new Map();
114
+ staleMs;
115
+ now;
116
+ // Explicit fields, not constructor parameter properties — Node's strip-only
117
+ // TypeScript mode rejects those, and these files run unbundled from source.
118
+ constructor(staleMs = AGENT_STALE_MS, now = Date.now) {
119
+ if (!Number.isFinite(staleMs) || staleMs <= 0) throw new Error("staleMs must be positive");
120
+ this.staleMs = staleMs;
121
+ this.now = now;
122
+ }
123
+ /** Register or refresh. Re-registering the same identity is a heartbeat. */
124
+ register(reg) {
125
+ const id = agentId(reg);
126
+ const info = { ...reg, id, lastSeen: this.now() };
127
+ this.agents.set(id, info);
128
+ return info;
129
+ }
130
+ /** Refresh an existing agent. Unknown ids are ignored — the next register revives it. */
131
+ heartbeat(id) {
132
+ const existing = this.agents.get(id);
133
+ if (!existing) return null;
134
+ const info = { ...existing, lastSeen: this.now() };
135
+ this.agents.set(id, info);
136
+ return info;
137
+ }
138
+ /** Live agents only, oldest registration order. */
139
+ list() {
140
+ return [...this.agents.values()];
141
+ }
142
+ unregister(id) {
143
+ return this.agents.delete(id);
144
+ }
145
+ /**
146
+ * Drop anything silent for the TTL. Returns what was evicted.
147
+ *
148
+ * Inclusive at the boundary: "30s of silence" means an agent that last spoke
149
+ * exactly 30s ago is gone, which is what anyone reading the constant expects.
150
+ */
151
+ sweep() {
152
+ const now = this.now();
153
+ const gone = [];
154
+ for (const [id, info] of this.agents) {
155
+ if (now - info.lastSeen >= this.staleMs) {
156
+ this.agents.delete(id);
157
+ gone.push(info);
158
+ }
159
+ }
160
+ return gone;
161
+ }
162
+ size() {
163
+ return this.agents.size;
164
+ }
165
+ };
166
+
167
+ // src/bridge/events.ts
168
+ var EventBus = class {
169
+ subscribers = /* @__PURE__ */ new Set();
170
+ /** Returns an unsubscribe function; calling it twice is safe. */
171
+ subscribe(fn) {
172
+ this.subscribers.add(fn);
173
+ return () => {
174
+ this.subscribers.delete(fn);
175
+ };
176
+ }
177
+ count() {
178
+ return this.subscribers.size;
179
+ }
180
+ /**
181
+ * Broadcast to everyone. A subscriber that throws is dropped rather than allowed
182
+ * to break the loop — a dead SSE connection would otherwise silence every other
183
+ * client behind it.
184
+ */
185
+ publish(event) {
186
+ for (const fn of [...this.subscribers]) {
187
+ try {
188
+ fn(event);
189
+ } catch {
190
+ this.subscribers.delete(fn);
191
+ }
192
+ }
193
+ }
194
+ /** Publish a thread update. The seam the MCP tools use. */
195
+ publishThread(threadId, eventType, data) {
196
+ this.publish({ type: "thread", threadId, eventType, data, at: (/* @__PURE__ */ new Date()).toISOString() });
197
+ }
198
+ clear() {
199
+ this.subscribers.clear();
200
+ }
201
+ };
202
+
203
+ // src/bridge/http-bridge.ts
204
+ import { createServer } from "http";
205
+ var MAX_BODY_BYTES = 1024 * 1024;
206
+ var SSE_KEEPALIVE_MS = 15e3;
207
+ async function startHttpBridge(port, deps, opts = {}) {
208
+ const retries = opts.retries ?? 5;
209
+ const retryDelayMs = opts.retryDelayMs ?? 1500;
210
+ for (let attempt = 0; attempt <= retries; attempt++) {
211
+ const server2 = createServer((req, res) => {
212
+ void handle(req, res, deps);
213
+ });
214
+ try {
215
+ await listen(server2, port);
216
+ const bound = server2.address()?.port ?? port;
217
+ return {
218
+ port: bound,
219
+ url: `http://127.0.0.1:${bound}`,
220
+ close: () => close(server2)
221
+ };
222
+ } catch (e) {
223
+ const code = e?.code;
224
+ if (code !== "EADDRINUSE" || attempt === retries) {
225
+ if (code === "EADDRINUSE") {
226
+ console.error(`[loupe] bridge port ${port} is busy after ${retries + 1} attempts \u2014 continuing without it`);
227
+ return null;
228
+ }
229
+ throw e;
230
+ }
231
+ await delay(retryDelayMs);
232
+ }
233
+ }
234
+ return null;
235
+ }
236
+ function listen(server2, port) {
237
+ return new Promise((resolve, reject) => {
238
+ const onError = (e) => {
239
+ server2.removeListener("listening", onListening);
240
+ reject(e);
241
+ };
242
+ const onListening = () => {
243
+ server2.removeListener("error", onError);
244
+ resolve();
245
+ };
246
+ server2.once("error", onError);
247
+ server2.once("listening", onListening);
248
+ server2.listen(port, "127.0.0.1");
249
+ });
250
+ }
251
+ function close(server2) {
252
+ return new Promise((resolve) => {
253
+ server2.close(() => resolve());
254
+ server2.closeAllConnections?.();
255
+ });
256
+ }
257
+ var delay = (ms) => new Promise((r) => setTimeout(r, ms));
258
+ function allowedOrigin(origin) {
259
+ if (!origin) return null;
260
+ if (origin.startsWith("chrome-extension://")) return origin;
261
+ try {
262
+ const u = new URL(origin);
263
+ if (u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "[::1]") return origin;
264
+ } catch {
265
+ return null;
266
+ }
267
+ return null;
268
+ }
269
+ function cors(res, origin) {
270
+ const allowed = allowedOrigin(origin);
271
+ if (allowed) {
272
+ res.setHeader("Access-Control-Allow-Origin", allowed);
273
+ res.setHeader("Vary", "Origin");
274
+ }
275
+ }
276
+ function send(res, status, body, origin) {
277
+ cors(res, origin);
278
+ const text = JSON.stringify(body);
279
+ res.writeHead(status, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(text) });
280
+ res.end(text);
281
+ }
282
+ function readBody(req) {
283
+ return new Promise((resolve) => {
284
+ let size = 0;
285
+ const chunks = [];
286
+ req.on("data", (chunk) => {
287
+ size += chunk.length;
288
+ if (size > MAX_BODY_BYTES) {
289
+ resolve({ ok: false, error: `body exceeds ${MAX_BODY_BYTES} bytes` });
290
+ req.destroy();
291
+ return;
292
+ }
293
+ chunks.push(chunk);
294
+ });
295
+ req.on("end", () => resolve({ ok: true, text: Buffer.concat(chunks).toString("utf8") }));
296
+ req.on("error", () => resolve({ ok: false, error: "could not read the request body" }));
297
+ });
298
+ }
299
+ async function handle(req, res, deps) {
300
+ const origin = req.headers.origin;
301
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
302
+ const path = url.pathname.replace(/\/+$/, "") || "/";
303
+ const method = req.method ?? "GET";
304
+ if (method === "OPTIONS") {
305
+ cors(res, origin);
306
+ res.setHeader("Access-Control-Allow-Methods", "GET,POST,DELETE,OPTIONS");
307
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
308
+ res.writeHead(204);
309
+ res.end();
310
+ return;
311
+ }
312
+ try {
313
+ if (method === "GET" && (path === "/health" || path === "/")) {
314
+ return send(res, 200, {
315
+ ok: true,
316
+ selections: deps.store.size(),
317
+ agents: deps.registry.size(),
318
+ subscribers: deps.bus.count()
319
+ }, origin);
320
+ }
321
+ if (method === "POST" && path === "/selection") {
322
+ const body = await readBody(req);
323
+ if (!body.ok) return send(res, 400, { error: body.error }, origin);
324
+ let parsed;
325
+ try {
326
+ parsed = JSON.parse(body.text);
327
+ } catch {
328
+ return send(res, 400, { error: "body is not valid JSON" }, origin);
329
+ }
330
+ const valid = validateSelection(parsed);
331
+ if (!valid.ok) return send(res, 400, { error: valid.error }, origin);
332
+ const stored = deps.store.add(valid.value);
333
+ deps.bus.publish({ type: "selection", data: stored, at: stored.at });
334
+ return send(res, 201, { ok: true, correlationId: stored.correlationId, at: stored.at }, origin);
335
+ }
336
+ if (method === "GET" && path === "/selection/latest") {
337
+ const latest = deps.store.latest();
338
+ return send(res, 200, { selection: latest }, origin);
339
+ }
340
+ if (method === "GET" && path === "/selection/history") {
341
+ const limit = Number(url.searchParams.get("limit") ?? deps.store.size());
342
+ return send(res, 200, {
343
+ selections: deps.store.history(Number.isFinite(limit) ? limit : deps.store.size())
344
+ }, origin);
345
+ }
346
+ if (method === "GET" && path === "/selection") {
347
+ const correlationId = url.searchParams.get("correlationId") ?? "";
348
+ return send(res, 200, { selection: deps.store.get(correlationId) }, origin);
349
+ }
350
+ if (method === "DELETE" && path === "/selection") {
351
+ deps.store.clear();
352
+ return send(res, 200, { ok: true, selections: 0 }, origin);
353
+ }
354
+ if (method === "POST" && path === "/agents") {
355
+ const body = await readBody(req);
356
+ if (!body.ok) return send(res, 400, { error: body.error }, origin);
357
+ let parsed;
358
+ try {
359
+ parsed = JSON.parse(body.text);
360
+ } catch {
361
+ return send(res, 400, { error: "body is not valid JSON" }, origin);
362
+ }
363
+ const reg = parsed;
364
+ if (!reg || typeof reg !== "object" || !reg.name || !reg.type || !reg.workspace) {
365
+ return send(res, 400, { error: "name, type and workspace are required" }, origin);
366
+ }
367
+ const agent = deps.registry.register({ name: reg.name, type: reg.type, workspace: reg.workspace, cwd: reg.cwd });
368
+ deps.bus.publish({ type: "agents", data: deps.registry.list(), at: (/* @__PURE__ */ new Date()).toISOString() });
369
+ return send(res, 201, { ok: true, agent }, origin);
370
+ }
371
+ if (method === "GET" && path === "/agents") {
372
+ return send(res, 200, { agents: deps.registry.list() }, origin);
373
+ }
374
+ if (method === "POST" && path.startsWith("/agents/") && path.endsWith("/heartbeat")) {
375
+ const id = path.slice("/agents/".length, -"/heartbeat".length);
376
+ const agent = deps.registry.heartbeat(id);
377
+ if (!agent) return send(res, 404, { error: "unknown agent" }, origin);
378
+ return send(res, 200, { ok: true, agent }, origin);
379
+ }
380
+ if (method === "DELETE" && path.startsWith("/agents/")) {
381
+ const id = path.slice("/agents/".length);
382
+ return send(res, 200, { ok: deps.registry.unregister(id) }, origin);
383
+ }
384
+ if (method === "GET" && (path === "/events" || path === "/thread-updates")) {
385
+ return streamEvents(req, res, deps, path === "/thread-updates" ? "thread" : null, origin);
386
+ }
387
+ return send(res, 404, { error: `no route for ${method} ${path}` }, origin);
388
+ } catch (e) {
389
+ if (!res.headersSent) send(res, 500, { error: e instanceof Error ? e.message : "bridge error" }, origin);
390
+ else res.end();
391
+ }
392
+ }
393
+ function streamEvents(req, res, deps, only, origin) {
394
+ cors(res, origin);
395
+ res.writeHead(200, {
396
+ "Content-Type": "text/event-stream",
397
+ "Cache-Control": "no-cache, no-transform",
398
+ Connection: "keep-alive"
399
+ });
400
+ res.write(": loupe bridge\n\n");
401
+ const write = (chunk) => {
402
+ res.write(chunk);
403
+ };
404
+ const unsubscribe = deps.bus.subscribe((event) => {
405
+ if (only && event.type !== only) return;
406
+ write(`event: ${event.type}
407
+ data: ${JSON.stringify(event)}
408
+
409
+ `);
410
+ });
411
+ const keepAlive = setInterval(() => write(": ping\n\n"), SSE_KEEPALIVE_MS);
412
+ if (typeof keepAlive.unref === "function") keepAlive.unref();
413
+ const done = () => {
414
+ clearInterval(keepAlive);
415
+ unsubscribe();
416
+ };
417
+ req.on("close", done);
418
+ req.on("error", done);
419
+ res.on("close", done);
420
+ }
421
+
422
+ // index.ts
11
423
  import {
12
424
  CHANGE_TYPE_LABELS,
13
425
  CHANGE_TYPES,
@@ -24,6 +436,10 @@ var PRIORITY_ARG = z.string().describe(`Priority, most urgent first: ${COMMENT_P
24
436
  var CHANGE_TYPE_ARG = z.string().describe(`Change type: ${CHANGE_TYPES.join(" / ")}.`);
25
437
  var API = (process.env.LOUPE_API || "http://localhost:8787").replace(/\/$/, "");
26
438
  var PROJECT_KEY = process.env.LOUPE_PROJECT_KEY || "pk_demo_acme";
439
+ var BRIDGE_PORT = Number(process.env.LOUPE_BRIDGE_PORT ?? 9800);
440
+ var store = new SelectionStore(Number(process.env.LOUPE_SELECTION_CAP ?? 50));
441
+ var registry = new AgentRegistry();
442
+ var bus = new EventBus();
27
443
  var ADMIN = process.env.LOUPE_ADMIN_KEY || "";
28
444
  async function api(path, init) {
29
445
  const res = await fetch(`${API}${path}`, {
@@ -47,7 +463,7 @@ async function fetchImage(url) {
47
463
  if (!res.ok) return null;
48
464
  const mimeType = res.headers.get("content-type") || "image/png";
49
465
  if (!mimeType.startsWith("image/")) return null;
50
- return { mimeType, data: Buffer.from(await res.arrayBuffer()).toString("base64") };
466
+ return { mimeType, data: Buffer2.from(await res.arrayBuffer()).toString("base64") };
51
467
  } catch {
52
468
  return null;
53
469
  }
@@ -157,6 +573,7 @@ async function getComment({ id }) {
157
573
  async function updateStatus({ id, status }) {
158
574
  const stage = normalizeStatus(status);
159
575
  await api(`/v1/comments/${encodeURIComponent(id)}`, { method: "PATCH", body: JSON.stringify({ status: stage }) });
576
+ bus.publishThread(id, stage === "resolved" ? "thread_resolved" : "status_changed", { status: stage });
160
577
  return wrap(`#${id} \u2192 ${STAGE_LABELS[stage]}`);
161
578
  }
162
579
  async function proposeChange({ id, html, css, notes }) {
@@ -168,9 +585,10 @@ async function proposeChange({ id, html, css, notes }) {
168
585
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
169
586
  };
170
587
  await api(`/v1/comments/${encodeURIComponent(id)}`, { method: "PATCH", body: JSON.stringify({ proposal }) });
588
+ bus.publishThread(id, "preview_live", { author: proposal.author });
171
589
  return wrap(`Proposal saved for #${id}. The dev team can now review your modified HTML/CSS in the dashboard.`);
172
590
  }
173
- var server = new McpServer({ name: "loupe", version: "0.10.16" });
591
+ var server = new McpServer({ name: "loupe", version: "0.10.17" });
174
592
  server.tool(
175
593
  "list_comments",
176
594
  "List Loupe product-feedback comments for the project as a task backlog. Each item carries its board stage, priority and change type, so you can start with the most urgent. Use this to see what a PM has flagged, then work through the items.",
@@ -215,8 +633,30 @@ function isEntrypoint() {
215
633
  }
216
634
  }
217
635
  if (isEntrypoint()) {
636
+ const bridge = BRIDGE_PORT ? await startHttpBridge(BRIDGE_PORT, { store, registry, bus }) : null;
637
+ const self = registry.register({
638
+ name: process.env.LOUPE_AGENT_NAME || "loupe-mcp",
639
+ type: process.env.LOUPE_AGENT_TYPE || "claude-code",
640
+ workspace: process.env.LOUPE_WORKSPACE || process.cwd(),
641
+ cwd: process.cwd()
642
+ });
643
+ const sweep = setInterval(() => {
644
+ if (registry.sweep().length) bus.publish({ type: "agents", data: registry.list(), at: (/* @__PURE__ */ new Date()).toISOString() });
645
+ registry.heartbeat(self.id);
646
+ }, AGENT_SWEEP_MS);
647
+ if (typeof sweep.unref === "function") sweep.unref();
648
+ const shutdown = async () => {
649
+ clearInterval(sweep);
650
+ registry.unregister(self.id);
651
+ await bridge?.close();
652
+ process.exit(0);
653
+ };
654
+ process.on("SIGINT", () => void shutdown());
655
+ process.on("SIGTERM", () => void shutdown());
218
656
  await server.connect(new StdioServerTransport());
219
- console.error(`[loupe-mcp] connected \xB7 project=${PROJECT_KEY} \xB7 api=${API}`);
657
+ console.error(
658
+ `[loupe-mcp] connected \xB7 project=${PROJECT_KEY} \xB7 api=${API}` + (bridge ? ` \xB7 bridge=${bridge.url}` : " \xB7 bridge=disabled")
659
+ );
220
660
  }
221
661
  export {
222
662
  getComment,
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "email": "m.ashraf.saed@gmail.com",
6
6
  "url": "https://www.linkedin.com/in/mohamedashrafelsaed/"
7
7
  },
8
- "version": "0.10.16",
8
+ "version": "0.10.17",
9
9
  "description": "MCP server that exposes Loupe comments to Claude Code as an actionable, fully-contextualized backlog.",
10
10
  "keywords": [
11
11
  "loupe",
@@ -46,6 +46,6 @@
46
46
  "zod": "^3.24.1"
47
47
  },
48
48
  "devDependencies": {
49
- "@loupekit/shared": "0.10.16"
49
+ "@loupekit/shared": "0.10.17"
50
50
  }
51
51
  }