chainlesschain 0.47.7 → 0.47.9

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.
@@ -8,6 +8,7 @@ import crypto from "crypto";
8
8
  /* ── In-memory stores ──────────────────────────────────────── */
9
9
  const _rooms = new Map();
10
10
  const _messages = new Map();
11
+ const _spaceChildren = new Map(); // spaceRoomId → Map<childRoomId, { via: string[] }>
11
12
  let _loginState = {
12
13
  state: "logged_out",
13
14
  userId: null,
@@ -181,11 +182,262 @@ export function getMessages(roomId, filter = {}) {
181
182
  return messages.slice(0, limit);
182
183
  }
183
184
 
185
+ /* ── Threads (MSC3440 / spec §11.38) ───────────────────────── */
186
+
187
+ /**
188
+ * Send a threaded reply. Stores an m.room.message event whose content has
189
+ * m.relates_to: { rel_type: "m.thread", event_id: <root>, is_falling_back, m.in_reply_to }
190
+ * Clients that don't understand threads fall back to rendering as a reply
191
+ * when is_falling_back=true (the default per spec).
192
+ *
193
+ * @param {Object} db
194
+ * @param {Object} params
195
+ * @param {string} params.roomId
196
+ * @param {string} params.rootEventId - Event id of the thread's root message
197
+ * @param {string} params.body
198
+ * @param {string} [params.msgtype="m.text"]
199
+ * @param {string} [params.inReplyTo] - Defaults to rootEventId
200
+ * @param {boolean} [params.isFallingBack=true]
201
+ */
202
+ export function sendThreadReply(
203
+ db,
204
+ { roomId, rootEventId, body, msgtype, inReplyTo, isFallingBack = true },
205
+ ) {
206
+ if (!roomId) throw new Error("Room ID is required");
207
+ if (!rootEventId) throw new Error("rootEventId is required");
208
+ if (!body) throw new Error("Message body is required");
209
+
210
+ const id = crypto.randomUUID();
211
+ const eventId = `$${crypto.randomBytes(16).toString("hex")}`;
212
+ const now = new Date().toISOString();
213
+
214
+ const relatesTo = {
215
+ rel_type: "m.thread",
216
+ event_id: rootEventId,
217
+ is_falling_back: isFallingBack,
218
+ "m.in_reply_to": { event_id: inReplyTo || rootEventId },
219
+ };
220
+
221
+ const message = {
222
+ id,
223
+ eventId,
224
+ roomId,
225
+ sender: _loginState.userId || "cli-user",
226
+ eventType: "m.room.message",
227
+ content: {
228
+ body,
229
+ msgtype: msgtype || "m.text",
230
+ "m.relates_to": relatesTo,
231
+ },
232
+ originServerTs: now,
233
+ isEncrypted: _loginState.e2eeEnabled,
234
+ createdAt: now,
235
+ };
236
+
237
+ _messages.set(id, message);
238
+
239
+ db.prepare(
240
+ `INSERT INTO matrix_events (id, event_id, room_id, sender, event_type, content, origin_server_ts, is_encrypted, decrypted_content, created_at)
241
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
242
+ ).run(
243
+ id,
244
+ eventId,
245
+ roomId,
246
+ message.sender,
247
+ "m.room.message",
248
+ JSON.stringify(message.content),
249
+ now,
250
+ message.isEncrypted ? 1 : 0,
251
+ body,
252
+ now,
253
+ );
254
+
255
+ return { success: true, event: message };
256
+ }
257
+
258
+ /**
259
+ * List all replies to a given thread root (in chronological order).
260
+ */
261
+ export function getThreadMessages(roomId, rootEventId) {
262
+ if (!roomId) throw new Error("Room ID is required");
263
+ if (!rootEventId) throw new Error("rootEventId is required");
264
+
265
+ return [..._messages.values()]
266
+ .filter((m) => {
267
+ if (m.roomId !== roomId) return false;
268
+ const rel = m.content && m.content["m.relates_to"];
269
+ return rel && rel.rel_type === "m.thread" && rel.event_id === rootEventId;
270
+ })
271
+ .sort((a, b) =>
272
+ a.originServerTs < b.originServerTs
273
+ ? -1
274
+ : a.originServerTs > b.originServerTs
275
+ ? 1
276
+ : 0,
277
+ );
278
+ }
279
+
280
+ /**
281
+ * List distinct thread roots within a room (events that have at least one
282
+ * threaded reply). Returns { rootEventId, replyCount, lastReplyAt }.
283
+ */
284
+ export function getThreadRoots(roomId) {
285
+ if (!roomId) throw new Error("Room ID is required");
286
+
287
+ const rollup = new Map();
288
+ for (const m of _messages.values()) {
289
+ if (m.roomId !== roomId) continue;
290
+ const rel = m.content && m.content["m.relates_to"];
291
+ if (!rel || rel.rel_type !== "m.thread") continue;
292
+ const rootId = rel.event_id;
293
+ const existing = rollup.get(rootId) || {
294
+ rootEventId: rootId,
295
+ replyCount: 0,
296
+ lastReplyAt: null,
297
+ };
298
+ existing.replyCount += 1;
299
+ if (!existing.lastReplyAt || m.originServerTs > existing.lastReplyAt) {
300
+ existing.lastReplyAt = m.originServerTs;
301
+ }
302
+ rollup.set(rootId, existing);
303
+ }
304
+ return [...rollup.values()];
305
+ }
306
+
307
+ /* ── Spaces (spec §11.34) ──────────────────────────────────── */
308
+
309
+ /**
310
+ * Create a Matrix Space. A Space is a room whose creation content declares
311
+ * type: "m.space"
312
+ * — we model this by setting room.type on the in-memory room record and
313
+ * persisting it into matrix_rooms (reusing the existing topic column would
314
+ * be wrong, so we encode type by a "#space:" prefix on the room name).
315
+ *
316
+ * @param {Object} db
317
+ * @param {Object} params
318
+ * @param {string} params.name
319
+ * @param {string} [params.topic]
320
+ */
321
+ export function createSpace(db, { name, topic }) {
322
+ if (!name) throw new Error("Space name is required");
323
+
324
+ const id = crypto.randomUUID();
325
+ const roomId = `!space_${crypto.randomBytes(8).toString("hex")}`;
326
+ const now = new Date().toISOString();
327
+
328
+ const space = {
329
+ id,
330
+ roomId,
331
+ name,
332
+ topic: topic || null,
333
+ type: "m.space",
334
+ isEncrypted: false, // Spaces are not encrypted per spec
335
+ memberCount: 1,
336
+ lastEventAt: now,
337
+ joinedAt: now,
338
+ status: "joined",
339
+ createdAt: now,
340
+ };
341
+
342
+ _rooms.set(id, space);
343
+ _spaceChildren.set(roomId, new Map());
344
+
345
+ db.prepare(
346
+ `INSERT INTO matrix_rooms (id, room_id, name, topic, is_encrypted, member_count, last_event_at, joined_at, status, created_at)
347
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
348
+ ).run(id, roomId, name, topic || null, 0, 1, now, now, "joined", now);
349
+
350
+ return { success: true, space };
351
+ }
352
+
353
+ /**
354
+ * Add a child room to a Space. In real Matrix this writes an m.space.child
355
+ * state event to the parent space with state_key = childRoomId.
356
+ *
357
+ * @param {Object} db
358
+ * @param {Object} params
359
+ * @param {string} params.spaceId - Parent space's room id (!space_*)
360
+ * @param {string} params.childRoomId - Child room id
361
+ * @param {string[]} [params.via] - Homeservers via which the child is reachable
362
+ */
363
+ export function addSpaceChild(db, { spaceId, childRoomId, via }) {
364
+ if (!spaceId) throw new Error("spaceId is required");
365
+ if (!childRoomId) throw new Error("childRoomId is required");
366
+
367
+ const children = _spaceChildren.get(spaceId);
368
+ if (!children) {
369
+ throw new Error(`Space not found: ${spaceId}`);
370
+ }
371
+ const viaList = Array.isArray(via) && via.length > 0 ? via : ["matrix.org"];
372
+ children.set(childRoomId, { via: viaList });
373
+
374
+ // Persist as an m.space.child state event in matrix_events
375
+ const id = crypto.randomUUID();
376
+ const eventId = `$${crypto.randomBytes(16).toString("hex")}`;
377
+ const now = new Date().toISOString();
378
+ const content = { via: viaList };
379
+
380
+ db.prepare(
381
+ `INSERT INTO matrix_events (id, event_id, room_id, sender, event_type, content, origin_server_ts, is_encrypted, decrypted_content, created_at)
382
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
383
+ ).run(
384
+ id,
385
+ eventId,
386
+ spaceId,
387
+ _loginState.userId || "cli-user",
388
+ "m.space.child",
389
+ JSON.stringify({ state_key: childRoomId, content }),
390
+ now,
391
+ 0,
392
+ null,
393
+ now,
394
+ );
395
+
396
+ return { success: true, spaceId, childRoomId, via: viaList };
397
+ }
398
+
399
+ /**
400
+ * Remove a child from a Space (equivalent to sending an empty m.space.child
401
+ * state event in real Matrix).
402
+ */
403
+ export function removeSpaceChild(db, { spaceId, childRoomId }) {
404
+ if (!spaceId) throw new Error("spaceId is required");
405
+ if (!childRoomId) throw new Error("childRoomId is required");
406
+
407
+ const children = _spaceChildren.get(spaceId);
408
+ if (!children) {
409
+ throw new Error(`Space not found: ${spaceId}`);
410
+ }
411
+ const removed = children.delete(childRoomId);
412
+ return { success: true, removed };
413
+ }
414
+
415
+ /**
416
+ * List all children of a Space.
417
+ */
418
+ export function listSpaceChildren(spaceId) {
419
+ if (!spaceId) throw new Error("spaceId is required");
420
+ const children = _spaceChildren.get(spaceId);
421
+ if (!children) return [];
422
+ return [...children.entries()].map(([childRoomId, meta]) => ({
423
+ childRoomId,
424
+ via: meta.via,
425
+ }));
426
+ }
427
+
428
+ /**
429
+ * List all Spaces (rooms with type === "m.space").
430
+ */
431
+ export function listSpaces() {
432
+ return [..._rooms.values()].filter((r) => r.type === "m.space");
433
+ }
434
+
184
435
  /* ── Reset (for testing) ───────────────────────────────────── */
185
436
 
186
437
  export function _resetState() {
187
438
  _rooms.clear();
188
439
  _messages.clear();
440
+ _spaceChildren.clear();
189
441
  _loginState = {
190
442
  state: "logged_out",
191
443
  userId: null,
@@ -0,0 +1,347 @@
1
+ /**
2
+ * MCP community server registry — pure read-only catalog.
3
+ *
4
+ * Ships a bundled list of well-known community MCP servers so users can
5
+ * discover, search, and one-shot install them without maintaining a
6
+ * remote index. Mirrors the curated catalog from
7
+ * `desktop-app-vue/src/main/mcp/community-registry.js` but pared down
8
+ * to what the CLI actually needs: browse + search + hand the chosen
9
+ * entry to `mcp add`.
10
+ *
11
+ * The catalog is intentionally small (the 8 first-party
12
+ * `@modelcontextprotocol/server-*` packages). Users who want more can
13
+ * still `cc mcp add` any server by hand — the registry is a
14
+ * convenience, not the source of truth for which servers exist.
15
+ */
16
+
17
+ /* ── constants ──────────────────────────────────────────────── */
18
+
19
+ export const CATEGORIES = Object.freeze([
20
+ "database",
21
+ "filesystem",
22
+ "version-control",
23
+ "search",
24
+ "automation",
25
+ "communication",
26
+ "cloud",
27
+ "productivity",
28
+ ]);
29
+
30
+ export const TRANSPORTS = Object.freeze(["stdio", "http"]);
31
+
32
+ /**
33
+ * Bundled catalog. Frozen at module load — treat as read-only. Each
34
+ * entry carries enough for `cc mcp add` to wire it up verbatim
35
+ * (command + args) plus prose/examples that help the agent REPL
36
+ * decide when to use it.
37
+ */
38
+ export const CATALOG = Object.freeze([
39
+ {
40
+ id: "mcp-server-filesystem",
41
+ name: "filesystem",
42
+ displayName: "File System",
43
+ description:
44
+ "File system access — read, write, list, and manage files and directories.",
45
+ version: "1.0.0",
46
+ author: "Anthropic",
47
+ category: "filesystem",
48
+ tags: ["file", "directory", "read", "write", "filesystem"],
49
+ npmPackage: "@modelcontextprotocol/server-filesystem",
50
+ command: "npx",
51
+ args: ["-y", "@modelcontextprotocol/server-filesystem"],
52
+ transport: "stdio",
53
+ tools: [
54
+ "read_file",
55
+ "write_file",
56
+ "list_directory",
57
+ "create_directory",
58
+ "move_file",
59
+ "search_files",
60
+ "get_file_info",
61
+ ],
62
+ homepage:
63
+ "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem",
64
+ rating: 5.0,
65
+ },
66
+ {
67
+ id: "mcp-server-postgresql",
68
+ name: "postgresql",
69
+ displayName: "PostgreSQL",
70
+ description:
71
+ "PostgreSQL database — execute queries, inspect schema, manage data.",
72
+ version: "1.0.0",
73
+ author: "Anthropic",
74
+ category: "database",
75
+ tags: ["database", "sql", "postgresql", "postgres", "query"],
76
+ npmPackage: "@modelcontextprotocol/server-postgres",
77
+ command: "npx",
78
+ args: ["-y", "@modelcontextprotocol/server-postgres"],
79
+ transport: "stdio",
80
+ tools: ["query", "list_tables", "describe_table"],
81
+ homepage:
82
+ "https://github.com/modelcontextprotocol/servers/tree/main/src/postgres",
83
+ rating: 4.8,
84
+ },
85
+ {
86
+ id: "mcp-server-sqlite",
87
+ name: "sqlite",
88
+ displayName: "SQLite",
89
+ description:
90
+ "SQLite database — local, embedded query execution and schema management.",
91
+ version: "1.0.0",
92
+ author: "Anthropic",
93
+ category: "database",
94
+ tags: ["database", "sql", "sqlite", "local", "embedded"],
95
+ npmPackage: "@modelcontextprotocol/server-sqlite",
96
+ command: "npx",
97
+ args: ["-y", "@modelcontextprotocol/server-sqlite"],
98
+ transport: "stdio",
99
+ tools: [
100
+ "read_query",
101
+ "write_query",
102
+ "create_table",
103
+ "list_tables",
104
+ "describe_table",
105
+ "append_insight",
106
+ ],
107
+ homepage:
108
+ "https://github.com/modelcontextprotocol/servers/tree/main/src/sqlite",
109
+ rating: 4.7,
110
+ },
111
+ {
112
+ id: "mcp-server-git",
113
+ name: "git",
114
+ displayName: "Git",
115
+ description:
116
+ "Git version control — status, diff, log, commit, branch management.",
117
+ version: "1.0.0",
118
+ author: "Anthropic",
119
+ category: "version-control",
120
+ tags: ["git", "version-control", "diff", "commit", "branch"],
121
+ npmPackage: "@modelcontextprotocol/server-git",
122
+ command: "npx",
123
+ args: ["-y", "@modelcontextprotocol/server-git"],
124
+ transport: "stdio",
125
+ tools: [
126
+ "git_status",
127
+ "git_diff",
128
+ "git_log",
129
+ "git_commit",
130
+ "git_branch_list",
131
+ "git_checkout",
132
+ ],
133
+ homepage:
134
+ "https://github.com/modelcontextprotocol/servers/tree/main/src/git",
135
+ rating: 4.9,
136
+ },
137
+ {
138
+ id: "mcp-server-brave-search",
139
+ name: "brave-search",
140
+ displayName: "Brave Search",
141
+ description:
142
+ "Web search via Brave Search API — general web + local business queries.",
143
+ version: "1.0.0",
144
+ author: "Anthropic",
145
+ category: "search",
146
+ tags: ["search", "web", "brave", "internet", "query"],
147
+ npmPackage: "@modelcontextprotocol/server-brave-search",
148
+ command: "npx",
149
+ args: ["-y", "@modelcontextprotocol/server-brave-search"],
150
+ transport: "stdio",
151
+ tools: ["brave_web_search", "brave_local_search"],
152
+ homepage:
153
+ "https://github.com/modelcontextprotocol/servers/tree/main/src/brave-search",
154
+ rating: 4.6,
155
+ },
156
+ {
157
+ id: "mcp-server-puppeteer",
158
+ name: "puppeteer",
159
+ displayName: "Puppeteer",
160
+ description:
161
+ "Browser automation — navigate, screenshot, click, fill forms, evaluate JS.",
162
+ version: "1.0.0",
163
+ author: "Anthropic",
164
+ category: "automation",
165
+ tags: ["browser", "automation", "puppeteer", "scraping", "screenshot"],
166
+ npmPackage: "@modelcontextprotocol/server-puppeteer",
167
+ command: "npx",
168
+ args: ["-y", "@modelcontextprotocol/server-puppeteer"],
169
+ transport: "stdio",
170
+ tools: [
171
+ "puppeteer_navigate",
172
+ "puppeteer_screenshot",
173
+ "puppeteer_click",
174
+ "puppeteer_fill",
175
+ "puppeteer_evaluate",
176
+ ],
177
+ homepage:
178
+ "https://github.com/modelcontextprotocol/servers/tree/main/src/puppeteer",
179
+ rating: 4.5,
180
+ },
181
+ {
182
+ id: "mcp-server-slack",
183
+ name: "slack",
184
+ displayName: "Slack",
185
+ description:
186
+ "Slack workspace — read messages, post updates, manage channels and threads.",
187
+ version: "1.0.0",
188
+ author: "Anthropic",
189
+ category: "communication",
190
+ tags: ["slack", "messaging", "chat", "communication", "team"],
191
+ npmPackage: "@modelcontextprotocol/server-slack",
192
+ command: "npx",
193
+ args: ["-y", "@modelcontextprotocol/server-slack"],
194
+ transport: "stdio",
195
+ tools: [
196
+ "slack_list_channels",
197
+ "slack_post_message",
198
+ "slack_reply_to_thread",
199
+ "slack_get_channel_history",
200
+ "slack_search_messages",
201
+ ],
202
+ homepage:
203
+ "https://github.com/modelcontextprotocol/servers/tree/main/src/slack",
204
+ rating: 4.4,
205
+ },
206
+ {
207
+ id: "mcp-server-github",
208
+ name: "github",
209
+ displayName: "GitHub",
210
+ description:
211
+ "GitHub API — manage repositories, issues, pull requests, and actions.",
212
+ version: "1.0.0",
213
+ author: "Anthropic",
214
+ category: "version-control",
215
+ tags: ["github", "repository", "issues", "pull-request", "api"],
216
+ npmPackage: "@modelcontextprotocol/server-github",
217
+ command: "npx",
218
+ args: ["-y", "@modelcontextprotocol/server-github"],
219
+ transport: "stdio",
220
+ tools: [
221
+ "create_or_update_file",
222
+ "search_repositories",
223
+ "create_repository",
224
+ "get_file_contents",
225
+ "push_files",
226
+ "create_issue",
227
+ "create_pull_request",
228
+ "list_issues",
229
+ "list_commits",
230
+ ],
231
+ homepage:
232
+ "https://github.com/modelcontextprotocol/servers/tree/main/src/github",
233
+ rating: 4.8,
234
+ },
235
+ ]);
236
+
237
+ /* ── public API ─────────────────────────────────────────────── */
238
+
239
+ /**
240
+ * Return the catalog filtered, sorted, and paginated.
241
+ *
242
+ * @param {object} [filters]
243
+ * @param {string} [filters.category]
244
+ * @param {string[]} [filters.tags] — any-match
245
+ * @param {string} [filters.author] — substring, case-insensitive
246
+ * @param {"name"|"rating"|"category"} [filters.sortBy="name"]
247
+ * @param {"asc"|"desc"} [filters.sortOrder="asc"]
248
+ * @param {number} [filters.limit]
249
+ * @param {number} [filters.offset]
250
+ * @returns {{ servers: object[], total: number }}
251
+ */
252
+ export function listServers(filters = {}) {
253
+ let servers = CATALOG.slice();
254
+
255
+ if (filters.category) {
256
+ const cat = String(filters.category).toLowerCase();
257
+ servers = servers.filter((s) => s.category === cat);
258
+ }
259
+
260
+ if (Array.isArray(filters.tags) && filters.tags.length > 0) {
261
+ const wanted = new Set(filters.tags.map((t) => String(t).toLowerCase()));
262
+ servers = servers.filter(
263
+ (s) => s.tags && s.tags.some((tag) => wanted.has(tag.toLowerCase())),
264
+ );
265
+ }
266
+
267
+ if (filters.author) {
268
+ const a = String(filters.author).toLowerCase();
269
+ servers = servers.filter(
270
+ (s) => s.author && s.author.toLowerCase().includes(a),
271
+ );
272
+ }
273
+
274
+ const total = servers.length;
275
+
276
+ const sortBy = filters.sortBy || "name";
277
+ const sortDir = filters.sortOrder === "desc" ? -1 : 1;
278
+ servers = servers.slice().sort((a, b) => {
279
+ const av = a[sortBy];
280
+ const bv = b[sortBy];
281
+ if (typeof av === "number" && typeof bv === "number") {
282
+ return (av - bv) * sortDir;
283
+ }
284
+ return String(av ?? "").localeCompare(String(bv ?? "")) * sortDir;
285
+ });
286
+
287
+ const offset =
288
+ Number.isInteger(filters.offset) && filters.offset > 0 ? filters.offset : 0;
289
+ const limit =
290
+ Number.isInteger(filters.limit) && filters.limit > 0
291
+ ? filters.limit
292
+ : servers.length;
293
+ servers = servers.slice(offset, offset + limit);
294
+
295
+ return { servers, total };
296
+ }
297
+
298
+ /**
299
+ * Keyword search across `name`, `displayName`, `description`, and `tags`.
300
+ * Returns matches ranked by a simple weighted score — name/id are
301
+ * weighted highest, then tags, then description.
302
+ */
303
+ export function searchServers(keyword) {
304
+ if (typeof keyword !== "string" || !keyword.trim()) return [];
305
+ const q = keyword.trim().toLowerCase();
306
+
307
+ const scored = [];
308
+ for (const entry of CATALOG) {
309
+ let score = 0;
310
+ if (entry.name && entry.name.toLowerCase() === q) score += 100;
311
+ else if (entry.name && entry.name.toLowerCase().includes(q)) score += 40;
312
+ if (entry.id.toLowerCase().includes(q)) score += 20;
313
+ if (entry.displayName && entry.displayName.toLowerCase().includes(q)) {
314
+ score += 30;
315
+ }
316
+ if (Array.isArray(entry.tags)) {
317
+ for (const tag of entry.tags) {
318
+ if (tag.toLowerCase() === q) score += 25;
319
+ else if (tag.toLowerCase().includes(q)) score += 10;
320
+ }
321
+ }
322
+ if (entry.description && entry.description.toLowerCase().includes(q)) {
323
+ score += 15;
324
+ }
325
+ if (entry.category && entry.category.toLowerCase().includes(q)) {
326
+ score += 10;
327
+ }
328
+ if (score > 0) scored.push({ entry, score });
329
+ }
330
+
331
+ scored.sort((a, b) => b.score - a.score);
332
+ return scored.map((s) => s.entry);
333
+ }
334
+
335
+ /**
336
+ * Look up a catalog entry by `id` or by `name`. Returns `null` if
337
+ * neither matches. Matching is case-insensitive.
338
+ */
339
+ export function getServer(idOrName) {
340
+ if (typeof idOrName !== "string" || !idOrName.trim()) return null;
341
+ const needle = idOrName.trim().toLowerCase();
342
+ return (
343
+ CATALOG.find(
344
+ (s) => s.id.toLowerCase() === needle || s.name.toLowerCase() === needle,
345
+ ) || null
346
+ );
347
+ }