@loupekit/mcp 0.10.16 → 0.10.17-next.63
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/dist/index.js +444 -4
- 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:
|
|
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.
|
|
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(
|
|
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.
|
|
8
|
+
"version": "0.10.17-next.63",
|
|
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.
|
|
49
|
+
"@loupekit/shared": "0.10.17-next.63"
|
|
50
50
|
}
|
|
51
51
|
}
|