@useclickly/mcp-server 1.0.0 → 1.0.2
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/cli.cjs +621 -6
- package/dist/cli.cjs.map +7 -1
- package/dist/cli.d.ts +10 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +598 -8
- package/dist/cli.js.map +7 -1
- package/dist/http-bridge.d.ts +33 -0
- package/dist/http-bridge.d.ts.map +1 -0
- package/dist/index.cjs +472 -7
- package/dist/index.cjs.map +7 -1
- package/dist/index.d.ts +16 -19
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +435 -7
- package/dist/index.js.map +7 -1
- package/dist/server.d.ts +33 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/store.d.ts +46 -0
- package/dist/store.d.ts.map +1 -0
- package/package.json +12 -11
- package/LICENSE +0 -21
package/dist/cli.cjs
CHANGED
|
@@ -1,17 +1,632 @@
|
|
|
1
|
-
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __copyProps = (to, from, except, desc) => {
|
|
9
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
10
|
+
for (let key of __getOwnPropNames(from))
|
|
11
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
12
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
13
|
+
}
|
|
14
|
+
return to;
|
|
15
|
+
};
|
|
16
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
17
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
18
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
19
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
20
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
21
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
22
|
+
mod
|
|
23
|
+
));
|
|
2
24
|
|
|
3
25
|
// src/cli.ts
|
|
4
|
-
var
|
|
26
|
+
var import_node_fs2 = __toESM(require("node:fs"), 1);
|
|
27
|
+
var import_node_net = __toESM(require("node:net"), 1);
|
|
28
|
+
var import_node_os2 = __toESM(require("node:os"), 1);
|
|
29
|
+
var import_node_path2 = __toESM(require("node:path"), 1);
|
|
30
|
+
|
|
31
|
+
// src/server.ts
|
|
32
|
+
var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
33
|
+
var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
34
|
+
var import_zod = require("zod");
|
|
35
|
+
|
|
36
|
+
// src/store.ts
|
|
37
|
+
var import_node_fs = __toESM(require("node:fs"), 1);
|
|
38
|
+
var import_node_os = __toESM(require("node:os"), 1);
|
|
39
|
+
var import_node_path = __toESM(require("node:path"), 1);
|
|
40
|
+
var AnnotationStore = class {
|
|
41
|
+
sessions = /* @__PURE__ */ new Map();
|
|
42
|
+
/** sessionId → (annotationId → AnnotationRecord) */
|
|
43
|
+
annotations = /* @__PURE__ */ new Map();
|
|
44
|
+
/** Listeners waiting for new annotations in a session (SSE bridge) */
|
|
45
|
+
listeners = /* @__PURE__ */ new Map();
|
|
46
|
+
storePath;
|
|
47
|
+
constructor(options = {}) {
|
|
48
|
+
this.storePath = options.storePath ?? import_node_path.default.join(import_node_os.default.homedir(), ".clickly", "sessions.json");
|
|
49
|
+
this._load();
|
|
50
|
+
}
|
|
51
|
+
/* ── Sessions ─────────────────────────────────────────────────────── */
|
|
52
|
+
createSession(url) {
|
|
53
|
+
const id = crypto.randomUUID();
|
|
54
|
+
const session = { id, url, createdAt: Date.now(), seq: 0 };
|
|
55
|
+
this.sessions.set(id, session);
|
|
56
|
+
this.annotations.set(id, /* @__PURE__ */ new Map());
|
|
57
|
+
this._persist();
|
|
58
|
+
return session;
|
|
59
|
+
}
|
|
60
|
+
getSession(sessionId) {
|
|
61
|
+
return this.sessions.get(sessionId);
|
|
62
|
+
}
|
|
63
|
+
listSessions() {
|
|
64
|
+
return [...this.sessions.values()].sort((a, b) => b.createdAt - a.createdAt);
|
|
65
|
+
}
|
|
66
|
+
/* ── Annotations ──────────────────────────────────────────────────── */
|
|
67
|
+
addAnnotation(sessionId, annotation) {
|
|
68
|
+
const session = this.sessions.get(sessionId);
|
|
69
|
+
if (!session) throw new Error(`Session not found: ${sessionId}`);
|
|
70
|
+
const bucket = this.annotations.get(sessionId);
|
|
71
|
+
const record = { ...annotation, sessionId };
|
|
72
|
+
bucket.set(annotation.id, record);
|
|
73
|
+
session.seq += 1;
|
|
74
|
+
const seq = session.seq;
|
|
75
|
+
this._persist();
|
|
76
|
+
this._emit(sessionId, record, seq);
|
|
77
|
+
return record;
|
|
78
|
+
}
|
|
79
|
+
getAnnotation(sessionId, annotationId) {
|
|
80
|
+
return this.annotations.get(sessionId)?.get(annotationId);
|
|
81
|
+
}
|
|
82
|
+
listAnnotations(sessionId) {
|
|
83
|
+
const bucket = this.annotations.get(sessionId);
|
|
84
|
+
if (!bucket) return [];
|
|
85
|
+
return [...bucket.values()].sort((a, b) => a.timestamp - b.timestamp);
|
|
86
|
+
}
|
|
87
|
+
updateAnnotation(sessionId, annotationId, patch) {
|
|
88
|
+
const session = this.sessions.get(sessionId);
|
|
89
|
+
if (!session) throw new Error(`Session not found: ${sessionId}`);
|
|
90
|
+
const bucket = this.annotations.get(sessionId);
|
|
91
|
+
const existing = bucket?.get(annotationId);
|
|
92
|
+
if (!existing) throw new Error(`Annotation not found: ${annotationId}`);
|
|
93
|
+
const updated = { ...existing, ...patch, sessionId };
|
|
94
|
+
bucket.set(annotationId, updated);
|
|
95
|
+
session.seq += 1;
|
|
96
|
+
const seq = session.seq;
|
|
97
|
+
this._persist();
|
|
98
|
+
this._emit(sessionId, updated, seq);
|
|
99
|
+
return updated;
|
|
100
|
+
}
|
|
101
|
+
/* ── SSE subscriptions ────────────────────────────────────────────── */
|
|
102
|
+
subscribe(sessionId, cb) {
|
|
103
|
+
if (!this.listeners.has(sessionId)) {
|
|
104
|
+
this.listeners.set(sessionId, /* @__PURE__ */ new Set());
|
|
105
|
+
}
|
|
106
|
+
this.listeners.get(sessionId).add(cb);
|
|
107
|
+
return () => this.listeners.get(sessionId)?.delete(cb);
|
|
108
|
+
}
|
|
109
|
+
_emit(sessionId, annotation, seq) {
|
|
110
|
+
this.listeners.get(sessionId)?.forEach((cb) => cb(annotation, seq));
|
|
111
|
+
}
|
|
112
|
+
/* ── Persistence ──────────────────────────────────────────────────── */
|
|
113
|
+
_persist() {
|
|
114
|
+
try {
|
|
115
|
+
const dir = import_node_path.default.dirname(this.storePath);
|
|
116
|
+
if (!import_node_fs.default.existsSync(dir)) import_node_fs.default.mkdirSync(dir, { recursive: true });
|
|
117
|
+
const data = {
|
|
118
|
+
sessions: [...this.sessions.values()],
|
|
119
|
+
annotations: [...this.annotations.values()].flatMap((m) => [...m.values()])
|
|
120
|
+
};
|
|
121
|
+
import_node_fs.default.writeFileSync(this.storePath, JSON.stringify(data, null, 2), "utf-8");
|
|
122
|
+
} catch {
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
_load() {
|
|
126
|
+
try {
|
|
127
|
+
if (!import_node_fs.default.existsSync(this.storePath)) return;
|
|
128
|
+
const raw = import_node_fs.default.readFileSync(this.storePath, "utf-8");
|
|
129
|
+
const data = JSON.parse(raw);
|
|
130
|
+
for (const session of data.sessions ?? []) {
|
|
131
|
+
this.sessions.set(session.id, session);
|
|
132
|
+
this.annotations.set(session.id, /* @__PURE__ */ new Map());
|
|
133
|
+
}
|
|
134
|
+
for (const annotation of data.annotations ?? []) {
|
|
135
|
+
this.annotations.get(annotation.sessionId)?.set(annotation.id, annotation);
|
|
136
|
+
}
|
|
137
|
+
} catch {
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// src/http-bridge.ts
|
|
143
|
+
var import_node_http = __toESM(require("node:http"), 1);
|
|
144
|
+
var VERSION = "1.0.0";
|
|
145
|
+
function cors(res) {
|
|
146
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
147
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
148
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
149
|
+
}
|
|
150
|
+
function json(res, status, body) {
|
|
151
|
+
cors(res);
|
|
152
|
+
const payload = JSON.stringify(body);
|
|
153
|
+
res.writeHead(status, {
|
|
154
|
+
"Content-Type": "application/json",
|
|
155
|
+
"Content-Length": Buffer.byteLength(payload)
|
|
156
|
+
});
|
|
157
|
+
res.end(payload);
|
|
158
|
+
}
|
|
159
|
+
function notFound(res) {
|
|
160
|
+
json(res, 404, { error: "not_found" });
|
|
161
|
+
}
|
|
162
|
+
function badRequest(res, message) {
|
|
163
|
+
json(res, 400, { error: "bad_request", message });
|
|
164
|
+
}
|
|
165
|
+
function readBody(req) {
|
|
166
|
+
return new Promise((resolve, reject) => {
|
|
167
|
+
let raw = "";
|
|
168
|
+
req.on("data", (chunk) => raw += chunk);
|
|
169
|
+
req.on("end", () => {
|
|
170
|
+
try {
|
|
171
|
+
resolve(raw ? JSON.parse(raw) : {});
|
|
172
|
+
} catch {
|
|
173
|
+
reject(new Error("Invalid JSON body"));
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
req.on("error", reject);
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
function route(req) {
|
|
180
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
181
|
+
const segments = url.pathname.replace(/^\//, "").split("/").filter(Boolean);
|
|
182
|
+
return { method: req.method?.toUpperCase() ?? "GET", segments };
|
|
183
|
+
}
|
|
184
|
+
function createHttpBridge(store, port) {
|
|
185
|
+
const server = import_node_http.default.createServer(async (req, res) => {
|
|
186
|
+
if (req.method === "OPTIONS") {
|
|
187
|
+
cors(res);
|
|
188
|
+
res.writeHead(204);
|
|
189
|
+
res.end();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const r = route(req);
|
|
193
|
+
if (!r) return notFound(res);
|
|
194
|
+
const { method, segments } = r;
|
|
195
|
+
try {
|
|
196
|
+
if (method === "GET" && segments[0] === "health") {
|
|
197
|
+
return json(res, 200, { ok: true, version: VERSION });
|
|
198
|
+
}
|
|
199
|
+
if (method === "GET" && segments.length === 1 && segments[0] === "sessions") {
|
|
200
|
+
return json(res, 200, store.listSessions());
|
|
201
|
+
}
|
|
202
|
+
if (method === "POST" && segments.length === 1 && segments[0] === "sessions") {
|
|
203
|
+
const body = await readBody(req);
|
|
204
|
+
if (!body.url) return badRequest(res, "url is required");
|
|
205
|
+
const session = store.createSession(body.url);
|
|
206
|
+
return json(res, 201, { sessionId: session.id });
|
|
207
|
+
}
|
|
208
|
+
if (segments[0] === "sessions" && segments[1]) {
|
|
209
|
+
const sessionId = segments[1];
|
|
210
|
+
const session = store.getSession(sessionId);
|
|
211
|
+
if (!session) return json(res, 404, { error: "session_not_found" });
|
|
212
|
+
const sub = segments[2];
|
|
213
|
+
if (method === "GET" && sub === "annotations" && segments.length === 3) {
|
|
214
|
+
return json(res, 200, store.listAnnotations(sessionId));
|
|
215
|
+
}
|
|
216
|
+
if (method === "POST" && sub === "annotations" && segments.length === 3) {
|
|
217
|
+
const body = await readBody(req);
|
|
218
|
+
if (!body || typeof body !== "object") return badRequest(res, "body required");
|
|
219
|
+
const annotation = body;
|
|
220
|
+
if (!annotation.id || !annotation.comment || !annotation.elementPath) {
|
|
221
|
+
return badRequest(res, "id, comment, elementPath are required");
|
|
222
|
+
}
|
|
223
|
+
const record = store.addAnnotation(sessionId, annotation);
|
|
224
|
+
return json(res, 201, { ok: true, id: record.id });
|
|
225
|
+
}
|
|
226
|
+
if (method === "GET" && sub === "events" && segments.length === 3) {
|
|
227
|
+
cors(res);
|
|
228
|
+
res.writeHead(200, {
|
|
229
|
+
"Content-Type": "text/event-stream",
|
|
230
|
+
"Cache-Control": "no-cache",
|
|
231
|
+
Connection: "keep-alive",
|
|
232
|
+
"X-Accel-Buffering": "no"
|
|
233
|
+
// disable nginx buffering
|
|
234
|
+
});
|
|
235
|
+
res.write(`event: connected
|
|
236
|
+
data: ${JSON.stringify({ seq: session.seq })}
|
|
237
|
+
|
|
238
|
+
`);
|
|
239
|
+
const unsubscribe = store.subscribe(sessionId, (annotation, seq) => {
|
|
240
|
+
const payload = JSON.stringify({ annotation, seq });
|
|
241
|
+
res.write(`event: annotation
|
|
242
|
+
data: ${payload}
|
|
243
|
+
|
|
244
|
+
`);
|
|
245
|
+
});
|
|
246
|
+
const heartbeat = setInterval(() => {
|
|
247
|
+
res.write(`: heartbeat
|
|
248
|
+
|
|
249
|
+
`);
|
|
250
|
+
}, 15e3);
|
|
251
|
+
req.on("close", () => {
|
|
252
|
+
clearInterval(heartbeat);
|
|
253
|
+
unsubscribe();
|
|
254
|
+
});
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
notFound(res);
|
|
259
|
+
} catch (err) {
|
|
260
|
+
const message = err instanceof Error ? err.message : "internal_error";
|
|
261
|
+
json(res, 500, { error: "internal_error", message });
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
server.listen(port);
|
|
265
|
+
return {
|
|
266
|
+
port,
|
|
267
|
+
close: () => new Promise(
|
|
268
|
+
(resolve, reject) => server.close((err) => err ? reject(err) : resolve())
|
|
269
|
+
)
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// src/server.ts
|
|
274
|
+
var SessionIdSchema = {
|
|
275
|
+
sessionId: import_zod.z.string().describe("Session ID returned by the browser bridge")
|
|
276
|
+
};
|
|
277
|
+
var AnnotationIdSchema = {
|
|
278
|
+
...SessionIdSchema,
|
|
279
|
+
annotationId: import_zod.z.string().describe("Annotation ID")
|
|
280
|
+
};
|
|
281
|
+
function errResult(message) {
|
|
282
|
+
return {
|
|
283
|
+
isError: true,
|
|
284
|
+
content: [{ type: "text", text: message }]
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
function okResult(data) {
|
|
288
|
+
return {
|
|
289
|
+
content: [
|
|
290
|
+
{
|
|
291
|
+
type: "text",
|
|
292
|
+
text: typeof data === "string" ? data : JSON.stringify(data, null, 2)
|
|
293
|
+
}
|
|
294
|
+
]
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
async function createServer(options = {}) {
|
|
298
|
+
const port = options.port ?? 4747;
|
|
299
|
+
const store = options.store ?? new AnnotationStore({ storePath: options.storePath });
|
|
300
|
+
const bridge = createHttpBridge(store, port);
|
|
301
|
+
const mcp = new import_mcp.McpServer(
|
|
302
|
+
{ name: "clickly", version: "1.0.0" },
|
|
303
|
+
{
|
|
304
|
+
capabilities: { tools: {} },
|
|
305
|
+
instructions: "Clickly exposes UI annotations captured in a running React development app. Use these tools to read feedback, acknowledge issues, resolve them, or reply to start a conversation thread with the developer."
|
|
306
|
+
}
|
|
307
|
+
);
|
|
308
|
+
mcp.registerTool(
|
|
309
|
+
"clickly_list_sessions",
|
|
310
|
+
{
|
|
311
|
+
description: "List all Clickly annotation sessions. Each session corresponds to a page/URL where the developer opened the Clickly toolbar. Returns session IDs, URLs, creation timestamps, and annotation counts.",
|
|
312
|
+
inputSchema: {}
|
|
313
|
+
},
|
|
314
|
+
async () => {
|
|
315
|
+
const sessions = store.listSessions();
|
|
316
|
+
const rows = sessions.map((s) => ({
|
|
317
|
+
sessionId: s.id,
|
|
318
|
+
url: s.url,
|
|
319
|
+
createdAt: new Date(s.createdAt).toISOString(),
|
|
320
|
+
annotationCount: store.listAnnotations(s.id).length
|
|
321
|
+
}));
|
|
322
|
+
return okResult(rows);
|
|
323
|
+
}
|
|
324
|
+
);
|
|
325
|
+
mcp.registerTool(
|
|
326
|
+
"clickly_list_annotations",
|
|
327
|
+
{
|
|
328
|
+
description: "List all annotations in a session. Includes element path, position, React component tree, source file/line, feedback comment, and lifecycle status.",
|
|
329
|
+
inputSchema: SessionIdSchema
|
|
330
|
+
},
|
|
331
|
+
async ({ sessionId }) => {
|
|
332
|
+
const session = store.getSession(sessionId);
|
|
333
|
+
if (!session) return errResult(`Session not found: ${sessionId}`);
|
|
334
|
+
return okResult(store.listAnnotations(sessionId));
|
|
335
|
+
}
|
|
336
|
+
);
|
|
337
|
+
mcp.registerTool(
|
|
338
|
+
"clickly_get_annotation",
|
|
339
|
+
{
|
|
340
|
+
description: "Fetch a single annotation by ID. Returns the full AFS 1.1 record including element metadata, bounding box, React component chain, source location, and any thread messages.",
|
|
341
|
+
inputSchema: AnnotationIdSchema
|
|
342
|
+
},
|
|
343
|
+
async ({ sessionId, annotationId }) => {
|
|
344
|
+
const annotation = store.getAnnotation(sessionId, annotationId);
|
|
345
|
+
if (!annotation) {
|
|
346
|
+
return errResult(
|
|
347
|
+
`Annotation not found: ${annotationId} in session ${sessionId}`
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
return okResult(annotation);
|
|
351
|
+
}
|
|
352
|
+
);
|
|
353
|
+
mcp.registerTool(
|
|
354
|
+
"clickly_acknowledge",
|
|
355
|
+
{
|
|
356
|
+
description: "Mark an annotation as 'acknowledged' \u2014 you have seen it and are working on it. The developer's UI will show an acknowledged badge on the annotation pin.",
|
|
357
|
+
inputSchema: AnnotationIdSchema
|
|
358
|
+
},
|
|
359
|
+
async ({ sessionId, annotationId }) => {
|
|
360
|
+
try {
|
|
361
|
+
const updated = store.updateAnnotation(sessionId, annotationId, {
|
|
362
|
+
status: "acknowledged"
|
|
363
|
+
});
|
|
364
|
+
return okResult(
|
|
365
|
+
`Acknowledged annotation ${updated.id} in session ${sessionId}.`
|
|
366
|
+
);
|
|
367
|
+
} catch (err) {
|
|
368
|
+
return errResult(err instanceof Error ? err.message : String(err));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
);
|
|
372
|
+
mcp.registerTool(
|
|
373
|
+
"clickly_resolve",
|
|
374
|
+
{
|
|
375
|
+
description: "Mark an annotation as 'resolved' \u2014 the issue has been fixed. Optionally include a resolution note. The pin will show as resolved in the developer's browser.",
|
|
376
|
+
inputSchema: {
|
|
377
|
+
...AnnotationIdSchema,
|
|
378
|
+
note: import_zod.z.string().optional().describe("Optional resolution note to append to the thread")
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
async ({ sessionId, annotationId, note }) => {
|
|
382
|
+
try {
|
|
383
|
+
const existing = store.getAnnotation(sessionId, annotationId);
|
|
384
|
+
if (!existing) return errResult(`Annotation not found: ${annotationId}`);
|
|
385
|
+
const thread = existing.thread ? [...existing.thread] : [];
|
|
386
|
+
if (note) {
|
|
387
|
+
thread.push({
|
|
388
|
+
id: crypto.randomUUID(),
|
|
389
|
+
role: "agent",
|
|
390
|
+
content: note,
|
|
391
|
+
timestamp: Date.now()
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
const updated = store.updateAnnotation(sessionId, annotationId, {
|
|
395
|
+
status: "resolved",
|
|
396
|
+
resolvedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
397
|
+
resolvedBy: "agent",
|
|
398
|
+
...note ? { thread } : {}
|
|
399
|
+
});
|
|
400
|
+
return okResult(
|
|
401
|
+
`Resolved annotation ${updated.id}.${note ? ` Note: "${note}"` : ""}`
|
|
402
|
+
);
|
|
403
|
+
} catch (err) {
|
|
404
|
+
return errResult(err instanceof Error ? err.message : String(err));
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
);
|
|
408
|
+
mcp.registerTool(
|
|
409
|
+
"clickly_dismiss",
|
|
410
|
+
{
|
|
411
|
+
description: "Mark an annotation as 'dismissed' \u2014 it is not actionable or is a duplicate. Dismissed annotations are hidden from the default list view.",
|
|
412
|
+
inputSchema: AnnotationIdSchema
|
|
413
|
+
},
|
|
414
|
+
async ({ sessionId, annotationId }) => {
|
|
415
|
+
try {
|
|
416
|
+
const updated = store.updateAnnotation(sessionId, annotationId, {
|
|
417
|
+
status: "dismissed"
|
|
418
|
+
});
|
|
419
|
+
return okResult(`Dismissed annotation ${updated.id}.`);
|
|
420
|
+
} catch (err) {
|
|
421
|
+
return errResult(err instanceof Error ? err.message : String(err));
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
);
|
|
425
|
+
mcp.registerTool(
|
|
426
|
+
"clickly_reply",
|
|
427
|
+
{
|
|
428
|
+
description: "Append a thread message to an annotation. Use this to ask a clarifying question, describe what you changed, or communicate back to the developer who left feedback.",
|
|
429
|
+
inputSchema: {
|
|
430
|
+
...AnnotationIdSchema,
|
|
431
|
+
message: import_zod.z.string().describe("Your reply message")
|
|
432
|
+
}
|
|
433
|
+
},
|
|
434
|
+
async ({ sessionId, annotationId, message }) => {
|
|
435
|
+
try {
|
|
436
|
+
const existing = store.getAnnotation(sessionId, annotationId);
|
|
437
|
+
if (!existing) return errResult(`Annotation not found: ${annotationId}`);
|
|
438
|
+
const thread = [
|
|
439
|
+
...existing.thread ?? [],
|
|
440
|
+
{
|
|
441
|
+
id: crypto.randomUUID(),
|
|
442
|
+
role: "agent",
|
|
443
|
+
content: message,
|
|
444
|
+
timestamp: Date.now()
|
|
445
|
+
}
|
|
446
|
+
];
|
|
447
|
+
store.updateAnnotation(sessionId, annotationId, { thread });
|
|
448
|
+
return okResult(`Reply added to annotation ${annotationId}.`);
|
|
449
|
+
} catch (err) {
|
|
450
|
+
return errResult(err instanceof Error ? err.message : String(err));
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
);
|
|
454
|
+
const transport = new import_stdio.StdioServerTransport();
|
|
455
|
+
await mcp.connect(transport);
|
|
456
|
+
return {
|
|
457
|
+
port,
|
|
458
|
+
close: async () => {
|
|
459
|
+
await mcp.close();
|
|
460
|
+
await bridge.close();
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// src/cli.ts
|
|
466
|
+
var import_meta = {};
|
|
467
|
+
var [, , cmd, ...args] = process.argv;
|
|
468
|
+
function log(msg) {
|
|
469
|
+
process.stderr.write(msg + "\n");
|
|
470
|
+
}
|
|
471
|
+
function out(msg) {
|
|
472
|
+
process.stdout.write(msg + "\n");
|
|
473
|
+
}
|
|
474
|
+
function parseFlag(flag, fallback) {
|
|
475
|
+
const idx = args.indexOf(flag);
|
|
476
|
+
return idx !== -1 && args[idx + 1] != null ? args[idx + 1] : fallback;
|
|
477
|
+
}
|
|
478
|
+
function isPortFree(port) {
|
|
479
|
+
return new Promise((resolve) => {
|
|
480
|
+
const srv = import_node_net.default.createServer();
|
|
481
|
+
srv.once("error", () => resolve(false));
|
|
482
|
+
srv.once("listening", () => srv.close(() => resolve(true)));
|
|
483
|
+
srv.listen(port, "127.0.0.1");
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
async function cmdServer() {
|
|
487
|
+
const port = parseInt(parseFlag("--port", "4747"), 10);
|
|
488
|
+
const storePath = parseFlag("--store", import_node_path2.default.join(import_node_os2.default.homedir(), ".clickly", "sessions.json"));
|
|
489
|
+
log(`[clickly-mcp] Starting MCP server (stdio) + HTTP bridge on :${port}`);
|
|
490
|
+
log(`[clickly-mcp] Persistence: ${storePath}`);
|
|
491
|
+
log(`[clickly-mcp] Ready. Connect your AI agent via stdio.`);
|
|
492
|
+
const handle = await createServer({ port, storePath });
|
|
493
|
+
process.on("SIGINT", async () => {
|
|
494
|
+
log("\n[clickly-mcp] Shutting down\u2026");
|
|
495
|
+
await handle.close();
|
|
496
|
+
process.exit(0);
|
|
497
|
+
});
|
|
498
|
+
process.on("SIGTERM", async () => {
|
|
499
|
+
await handle.close();
|
|
500
|
+
process.exit(0);
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
async function cmdDoctor() {
|
|
504
|
+
let allOk = true;
|
|
505
|
+
const port = parseInt(parseFlag("--port", "4747"), 10);
|
|
506
|
+
const storePath = import_node_path2.default.join(import_node_os2.default.homedir(), ".clickly", "sessions.json");
|
|
507
|
+
out("clickly-mcp doctor\n");
|
|
508
|
+
const nodeVersion = process.versions.node;
|
|
509
|
+
const major = parseInt(nodeVersion.split(".")[0] ?? "0", 10);
|
|
510
|
+
const nodeOk = major >= 18;
|
|
511
|
+
out(` Node.js: ${nodeVersion} ${nodeOk ? "\u2713" : "\u2717 (need \u2265 18)"}`);
|
|
512
|
+
if (!nodeOk) allOk = false;
|
|
513
|
+
const portFree = await isPortFree(port);
|
|
514
|
+
out(` Port ${port}: ${portFree ? "available \u2713" : "IN USE \u2717 (another process is running on this port)"}`);
|
|
515
|
+
if (!portFree) allOk = false;
|
|
516
|
+
const storeDir = import_node_path2.default.dirname(storePath);
|
|
517
|
+
let storeOk = false;
|
|
518
|
+
try {
|
|
519
|
+
import_node_fs2.default.mkdirSync(storeDir, { recursive: true });
|
|
520
|
+
const testFile = import_node_path2.default.join(storeDir, ".write-test");
|
|
521
|
+
import_node_fs2.default.writeFileSync(testFile, "ok");
|
|
522
|
+
import_node_fs2.default.unlinkSync(testFile);
|
|
523
|
+
storeOk = true;
|
|
524
|
+
} catch {
|
|
525
|
+
storeOk = false;
|
|
526
|
+
}
|
|
527
|
+
out(` Store dir (${storeDir}): ${storeOk ? "writable \u2713" : "NOT writable \u2717"}`);
|
|
528
|
+
if (!storeOk) allOk = false;
|
|
529
|
+
if (import_node_fs2.default.existsSync(storePath)) {
|
|
530
|
+
try {
|
|
531
|
+
const data = JSON.parse(import_node_fs2.default.readFileSync(storePath, "utf-8"));
|
|
532
|
+
const count = (data.sessions ?? []).length;
|
|
533
|
+
out(` Persisted sessions: ${count}`);
|
|
534
|
+
} catch {
|
|
535
|
+
out(` Persisted sessions: (could not parse ${storePath})`);
|
|
536
|
+
}
|
|
537
|
+
} else {
|
|
538
|
+
out(` Persisted sessions: none yet (store will be created on first use)`);
|
|
539
|
+
}
|
|
540
|
+
out(`
|
|
541
|
+
${allOk ? "All checks passed \u2713" : "Some checks failed \u2717 \u2014 fix the issues above before starting"}`);
|
|
542
|
+
process.exit(allOk ? 0 : 1);
|
|
543
|
+
}
|
|
544
|
+
function cmdInit() {
|
|
545
|
+
const write = args.includes("--write");
|
|
546
|
+
const binPath = process.execPath;
|
|
547
|
+
const mcpBin = new URL("../dist/cli.js", import_meta.url).pathname;
|
|
548
|
+
const claudeSnippet = JSON.stringify(
|
|
549
|
+
{
|
|
550
|
+
mcpServers: {
|
|
551
|
+
clickly: {
|
|
552
|
+
command: "node",
|
|
553
|
+
args: [mcpBin, "server"],
|
|
554
|
+
env: {}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
},
|
|
558
|
+
null,
|
|
559
|
+
2
|
|
560
|
+
);
|
|
561
|
+
const cursorSnippet = JSON.stringify(
|
|
562
|
+
{
|
|
563
|
+
mcpServers: {
|
|
564
|
+
clickly: {
|
|
565
|
+
command: "node",
|
|
566
|
+
args: [mcpBin, "server"]
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
},
|
|
570
|
+
null,
|
|
571
|
+
2
|
|
572
|
+
);
|
|
573
|
+
if (!write) {
|
|
574
|
+
out("# Clickly MCP \u2014 setup snippets\n");
|
|
575
|
+
out("## Claude Code (~/.claude.json or claude_desktop_config.json)\n");
|
|
576
|
+
out(claudeSnippet);
|
|
577
|
+
out("\n## Cursor (.cursor/mcp.json)\n");
|
|
578
|
+
out(cursorSnippet);
|
|
579
|
+
out(
|
|
580
|
+
"\nRun `clickly-mcp init --write` to automatically write the Claude Code config.\n"
|
|
581
|
+
);
|
|
582
|
+
out(`(node binary: ${binPath})`);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
const claudeConfigDir = import_node_path2.default.join(import_node_os2.default.homedir(), ".claude");
|
|
586
|
+
const claudeConfigPath = import_node_path2.default.join(claudeConfigDir, "claude_desktop_config.json");
|
|
587
|
+
try {
|
|
588
|
+
import_node_fs2.default.mkdirSync(claudeConfigDir, { recursive: true });
|
|
589
|
+
let existing = {};
|
|
590
|
+
if (import_node_fs2.default.existsSync(claudeConfigPath)) {
|
|
591
|
+
existing = JSON.parse(import_node_fs2.default.readFileSync(claudeConfigPath, "utf-8"));
|
|
592
|
+
}
|
|
593
|
+
const mcpServers = existing.mcpServers ?? {};
|
|
594
|
+
mcpServers.clickly = { command: "node", args: [mcpBin, "server"], env: {} };
|
|
595
|
+
existing.mcpServers = mcpServers;
|
|
596
|
+
import_node_fs2.default.writeFileSync(claudeConfigPath, JSON.stringify(existing, null, 2), "utf-8");
|
|
597
|
+
out(`\u2713 Written to ${claudeConfigPath}`);
|
|
598
|
+
out(" Restart Claude Code (or your agent) to pick up the new MCP server.");
|
|
599
|
+
} catch (err) {
|
|
600
|
+
out(`\u2717 Could not write config: ${err instanceof Error ? err.message : err}`);
|
|
601
|
+
process.exit(1);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
5
604
|
switch (cmd) {
|
|
6
605
|
case "server":
|
|
606
|
+
cmdServer().catch((err) => {
|
|
607
|
+
log(`[clickly-mcp] Fatal: ${err instanceof Error ? err.message : err}`);
|
|
608
|
+
process.exit(1);
|
|
609
|
+
});
|
|
610
|
+
break;
|
|
7
611
|
case "doctor":
|
|
612
|
+
cmdDoctor().catch((err) => {
|
|
613
|
+
log(`doctor error: ${err instanceof Error ? err.message : err}`);
|
|
614
|
+
process.exit(1);
|
|
615
|
+
});
|
|
616
|
+
break;
|
|
8
617
|
case "init":
|
|
9
|
-
|
|
10
|
-
process.exit(2);
|
|
618
|
+
cmdInit();
|
|
11
619
|
break;
|
|
12
620
|
default:
|
|
13
|
-
|
|
621
|
+
out("clickly-mcp \u2014 MCP server for the Clickly annotation toolbar\n");
|
|
622
|
+
out("Usage: clickly-mcp <command> [options]\n");
|
|
623
|
+
out("Commands:");
|
|
624
|
+
out(" server Start the MCP stdio server (+ HTTP bridge on :4747)");
|
|
625
|
+
out(" Options: --port <n> HTTP bridge port (default: 4747)");
|
|
626
|
+
out(" --store <path> Persistence file (default: ~/.clickly/sessions.json)");
|
|
627
|
+
out(" doctor Check environment (Node version, port, store writability)");
|
|
628
|
+
out(" init Print config snippets for Claude Code / Cursor");
|
|
629
|
+
out(" Options: --write Write directly to ~/.claude/claude_desktop_config.json");
|
|
14
630
|
process.exit(cmd ? 1 : 0);
|
|
15
631
|
}
|
|
16
632
|
//# sourceMappingURL=cli.cjs.map
|
|
17
|
-
//# sourceMappingURL=cli.cjs.map
|