codemolt-mcp 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -42,7 +42,7 @@ const SETUP_GUIDE = `CodeBlog is not set up yet. To get started, run the codemol
42
42
  `No browser needed — everything happens right here.`;
43
43
  const server = new McpServer({
44
44
  name: "codemolt",
45
- version: "0.6.1",
45
+ version: "0.7.0",
46
46
  });
47
47
  // ═══════════════════════════════════════════════════════════════════
48
48
  // SETUP & STATUS TOOLS
@@ -152,7 +152,7 @@ server.registerTool("codemolt_status", {
152
152
  agentInfo = `\n\n⚠️ Not set up. Run codemolt_setup to get started.`;
153
153
  }
154
154
  return {
155
- content: [text(`CodeBlog MCP Server v0.6.1\n` +
155
+ content: [text(`CodeBlog MCP Server v0.7.0\n` +
156
156
  `Platform: ${platform}\n` +
157
157
  `Server: ${serverUrl}\n\n` +
158
158
  `📡 IDE Scanners:\n${scannerInfo}` +
@@ -10,4 +10,5 @@ export declare function listScannerStatus(): Array<{
10
10
  description: string;
11
11
  available: boolean;
12
12
  dirs: string[];
13
+ error?: string;
13
14
  }>;
@@ -1,4 +1,7 @@
1
1
  // Scanner registry — all IDE scanners register here
2
+ // DESIGN: Every scanner is fully isolated. A single scanner crashing
3
+ // (missing deps, changed file formats, permission errors, etc.)
4
+ // MUST NEVER take down the whole MCP server.
2
5
  const scanners = [];
3
6
  export function registerScanner(scanner) {
4
7
  scanners.push(scanner);
@@ -9,18 +12,22 @@ export function getScanners() {
9
12
  export function getScannerBySource(source) {
10
13
  return scanners.find((s) => s.sourceType === source);
11
14
  }
15
+ // Safe wrapper: calls a scanner method, returns fallback on ANY error
16
+ function safeScannerCall(scannerName, method, fn, fallback) {
17
+ try {
18
+ return fn();
19
+ }
20
+ catch (err) {
21
+ console.error(`[codemolt] Scanner "${scannerName}" ${method} failed:`, err instanceof Error ? err.message : err);
22
+ return fallback;
23
+ }
24
+ }
12
25
  // Scan all registered IDEs, merge and sort results
13
26
  export function scanAll(limit = 20) {
14
27
  const allSessions = [];
15
28
  for (const scanner of scanners) {
16
- try {
17
- const sessions = scanner.scan(limit);
18
- allSessions.push(...sessions);
19
- }
20
- catch (err) {
21
- // Silently skip failing scanners
22
- console.error(`Scanner ${scanner.name} failed:`, err);
23
- }
29
+ const sessions = safeScannerCall(scanner.name, "scan", () => scanner.scan(limit), []);
30
+ allSessions.push(...sessions);
24
31
  }
25
32
  // Sort by modification time (newest first)
26
33
  allSessions.sort((a, b) => b.modifiedAt.getTime() - a.modifiedAt.getTime());
@@ -31,18 +38,30 @@ export function parseSession(filePath, source, maxTurns) {
31
38
  const scanner = getScannerBySource(source);
32
39
  if (!scanner)
33
40
  return null;
34
- return scanner.parse(filePath, maxTurns);
41
+ return safeScannerCall(scanner.name, "parse", () => scanner.parse(filePath, maxTurns), null);
35
42
  }
36
43
  // List available scanners with their status
37
44
  export function listScannerStatus() {
38
45
  return scanners.map((s) => {
39
- const dirs = s.getSessionDirs();
40
- return {
41
- name: s.name,
42
- source: s.sourceType,
43
- description: s.description,
44
- available: dirs.length > 0,
45
- dirs,
46
- };
46
+ try {
47
+ const dirs = s.getSessionDirs();
48
+ return {
49
+ name: s.name,
50
+ source: s.sourceType,
51
+ description: s.description,
52
+ available: dirs.length > 0,
53
+ dirs,
54
+ };
55
+ }
56
+ catch (err) {
57
+ return {
58
+ name: s.name,
59
+ source: s.sourceType,
60
+ description: s.description,
61
+ available: false,
62
+ dirs: [],
63
+ error: err instanceof Error ? err.message : String(err),
64
+ };
65
+ }
47
66
  });
48
67
  }
@@ -1,29 +1,72 @@
1
1
  import * as path from "path";
2
2
  import * as fs from "fs";
3
+ import BetterSqlite3 from "better-sqlite3";
3
4
  import { getHome, getPlatform } from "../lib/platform.js";
4
5
  import { listFiles, listDirs, safeReadFile, safeReadJson, safeStats, extractProjectDescription } from "../lib/fs-utils.js";
5
- // Cursor stores conversations in two places:
6
+ // Cursor stores conversations in THREE places (all supported for version compatibility):
6
7
  //
7
- // 1. Agent transcripts (plain text, XML-like tags):
8
- // ~/.cursor/projects/<project>/agent-transcripts/*.txt
9
- // Format: user: <user_query>...</user_query> \n A: <response>
8
+ // FORMAT 1 Agent transcripts (plain text, XML-like tags):
9
+ // ~/.cursor/projects/<project>/agent-transcripts/*.txt
10
+ // Format: user: <user_query>...</user_query> \n A: <response>
10
11
  //
11
- // 2. Chat sessions (JSON):
12
- // macOS: ~/Library/Application Support/Cursor/User/workspaceStorage/<hash>/chatSessions/*.json
13
- // Windows: %APPDATA%/Cursor/User/workspaceStorage/<hash>/chatSessions/*.json
14
- // Linux: ~/.config/Cursor/User/workspaceStorage/<hash>/chatSessions/*.json
15
- // Format: { requests: [{ message: "...", response: [...] }], sessionId, creationDate }
12
+ // FORMAT 2 Chat sessions (JSON, older Cursor versions):
13
+ // ~/Library/Application Support/Cursor/User/workspaceStorage/<hash>/chatSessions/*.json
14
+ // Format: { requests: [{ message: "...", response: [...] }], sessionId, creationDate }
15
+ //
16
+ // FORMAT 3 Global SQLite (newer Cursor versions, 2025+):
17
+ // ~/Library/Application Support/Cursor/User/globalStorage/state.vscdb
18
+ // Table: cursorDiskKV
19
+ // Keys: composerData:<composerId> — session metadata (name, timestamps, bubble headers)
20
+ // bubbleId:<composerId>:<bubbleId> — individual message content (type 1=user, 2=ai)
21
+ // Safe SQLite query helper — returns empty array on any error
22
+ function safeQuery(dbPath, sql) {
23
+ try {
24
+ const db = new BetterSqlite3(dbPath, { readonly: true, fileMustExist: true });
25
+ try {
26
+ const rows = db.prepare(sql).all();
27
+ return rows;
28
+ }
29
+ finally {
30
+ db.close();
31
+ }
32
+ }
33
+ catch (err) {
34
+ console.error(`[codemolt] Cursor safeQuery error:`, err instanceof Error ? err.message : err);
35
+ return [];
36
+ }
37
+ }
38
+ function getGlobalStoragePath() {
39
+ const home = getHome();
40
+ const platform = getPlatform();
41
+ let p;
42
+ if (platform === "macos") {
43
+ p = path.join(home, "Library", "Application Support", "Cursor", "User", "globalStorage", "state.vscdb");
44
+ }
45
+ else if (platform === "windows") {
46
+ const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming");
47
+ p = path.join(appData, "Cursor", "User", "globalStorage", "state.vscdb");
48
+ }
49
+ else {
50
+ p = path.join(home, ".config", "Cursor", "User", "globalStorage", "state.vscdb");
51
+ }
52
+ try {
53
+ return fs.existsSync(p) ? p : null;
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ }
16
59
  export const cursorScanner = {
17
60
  name: "Cursor",
18
61
  sourceType: "cursor",
19
- description: "Cursor AI IDE sessions (agent transcripts + chat sessions)",
62
+ description: "Cursor AI IDE sessions (agent transcripts + chat sessions + composer)",
20
63
  getSessionDirs() {
21
64
  const home = getHome();
22
65
  const platform = getPlatform();
23
66
  const candidates = [];
24
- // Agent transcripts (all platforms)
67
+ // Format 1: Agent transcripts
25
68
  candidates.push(path.join(home, ".cursor", "projects"));
26
- // Chat sessions in workspaceStorage
69
+ // Format 2 & workspace-level Format 3: workspaceStorage
27
70
  if (platform === "macos") {
28
71
  candidates.push(path.join(home, "Library", "Application Support", "Cursor", "User", "workspaceStorage"));
29
72
  }
@@ -34,6 +77,11 @@ export const cursorScanner = {
34
77
  else {
35
78
  candidates.push(path.join(home, ".config", "Cursor", "User", "workspaceStorage"));
36
79
  }
80
+ // Format 3: globalStorage (just check existence for status reporting)
81
+ const globalDb = getGlobalStoragePath();
82
+ if (globalDb) {
83
+ candidates.push(path.dirname(globalDb));
84
+ }
37
85
  return candidates.filter((d) => {
38
86
  try {
39
87
  return fs.existsSync(d);
@@ -46,34 +94,30 @@ export const cursorScanner = {
46
94
  scan(limit) {
47
95
  const sessions = [];
48
96
  const dirs = this.getSessionDirs();
97
+ const seenIds = new Set();
49
98
  for (const baseDir of dirs) {
99
+ // Skip globalStorage dir — handled separately via Format 3
100
+ if (baseDir.endsWith("globalStorage"))
101
+ continue;
50
102
  const projectDirs = listDirs(baseDir);
51
103
  for (const projectDir of projectDirs) {
52
104
  const dirName = path.basename(projectDir);
53
- // Resolve project path:
54
- // - agent-transcripts dirs: "Users-zhaoyifei-SimenDevelop-Simen" → "/Users/zhaoyifei/SimenDevelop/Simen"
55
- // - workspaceStorage dirs: read workspace.json for folder URI
56
105
  let projectPath;
57
- const workspaceJsonPath = path.join(projectDir, "workspace.json");
58
- const workspaceJson = safeReadJson(workspaceJsonPath);
106
+ const workspaceJson = safeReadJson(path.join(projectDir, "workspace.json"));
59
107
  if (workspaceJson?.folder) {
60
108
  try {
61
109
  projectPath = decodeURIComponent(new URL(workspaceJson.folder).pathname);
62
110
  }
63
- catch { /* ignore */ }
111
+ catch { /* */ }
64
112
  }
65
113
  if (!projectPath && dirName.startsWith("Users-")) {
66
- // Decode hyphenated path: "Users-zhaoyifei-Foo" → "/Users/zhaoyifei/Foo"
67
114
  projectPath = "/" + dirName.replace(/-/g, "/");
68
115
  }
69
116
  const project = projectPath ? path.basename(projectPath) : dirName;
70
- const projectDescription = projectPath
71
- ? extractProjectDescription(projectPath) || undefined
72
- : undefined;
73
- // --- Path 1: agent-transcripts/*.txt ---
117
+ const projectDescription = projectPath ? extractProjectDescription(projectPath) || undefined : undefined;
118
+ // --- FORMAT 1: agent-transcripts/*.txt ---
74
119
  const transcriptsDir = path.join(projectDir, "agent-transcripts");
75
- const txtFiles = listFiles(transcriptsDir, [".txt"]);
76
- for (const filePath of txtFiles) {
120
+ for (const filePath of listFiles(transcriptsDir, [".txt"])) {
77
121
  const stats = safeStats(filePath);
78
122
  if (!stats)
79
123
  continue;
@@ -81,31 +125,30 @@ export const cursorScanner = {
81
125
  if (!content || content.length < 100)
82
126
  continue;
83
127
  const userQueries = content.match(/<user_query>\n?([\s\S]*?)\n?<\/user_query>/g) || [];
84
- const humanCount = userQueries.length;
85
- if (humanCount === 0)
128
+ if (userQueries.length === 0)
86
129
  continue;
87
130
  const firstQuery = content.match(/<user_query>\n?([\s\S]*?)\n?<\/user_query>/);
88
131
  const preview = firstQuery ? firstQuery[1].trim().slice(0, 200) : content.slice(0, 200);
132
+ const id = path.basename(filePath, ".txt");
133
+ seenIds.add(id);
89
134
  sessions.push({
90
- id: path.basename(filePath, ".txt"),
135
+ id,
91
136
  source: "cursor",
92
137
  project,
93
138
  projectPath,
94
139
  projectDescription,
95
140
  title: preview.slice(0, 80) || `Cursor session in ${project}`,
96
- messageCount: humanCount * 2,
97
- humanMessages: humanCount,
98
- aiMessages: humanCount,
141
+ messageCount: userQueries.length * 2,
142
+ humanMessages: userQueries.length,
143
+ aiMessages: userQueries.length,
99
144
  preview,
100
145
  filePath,
101
146
  modifiedAt: stats.mtime,
102
147
  sizeBytes: stats.size,
103
148
  });
104
149
  }
105
- // --- Path 2: chatSessions/*.json (inside workspaceStorage/<hash>/) ---
106
- const chatSessionsDir = path.join(projectDir, "chatSessions");
107
- const jsonFiles = listFiles(chatSessionsDir, [".json"]);
108
- for (const filePath of jsonFiles) {
150
+ // --- FORMAT 2: chatSessions/*.json ---
151
+ for (const filePath of listFiles(path.join(projectDir, "chatSessions"), [".json"])) {
109
152
  const stats = safeStats(filePath);
110
153
  if (!stats || stats.size < 100)
111
154
  continue;
@@ -115,8 +158,10 @@ export const cursorScanner = {
115
158
  const humanCount = data.requests.length;
116
159
  const firstMsg = data.requests[0]?.message || "";
117
160
  const preview = (typeof firstMsg === "string" ? firstMsg : "").slice(0, 200);
161
+ const id = data.sessionId || path.basename(filePath, ".json");
162
+ seenIds.add(id);
118
163
  sessions.push({
119
- id: data.sessionId || path.basename(filePath, ".json"),
164
+ id,
120
165
  source: "cursor",
121
166
  project,
122
167
  projectPath,
@@ -133,15 +178,74 @@ export const cursorScanner = {
133
178
  }
134
179
  }
135
180
  }
181
+ // --- FORMAT 3: globalStorage state.vscdb (newer Cursor) ---
182
+ // This supplements Formats 1 & 2 — adds any sessions not already found
183
+ try {
184
+ const globalDb = getGlobalStoragePath();
185
+ if (globalDb) {
186
+ const rows = safeQuery(globalDb, "SELECT key, value FROM cursorDiskKV WHERE key LIKE 'composerData:%'");
187
+ for (const row of rows) {
188
+ try {
189
+ const data = JSON.parse(row.value);
190
+ const composerId = data.composerId || row.key.replace("composerData:", "");
191
+ if (seenIds.has(composerId))
192
+ continue; // skip duplicates
193
+ const bubbleHeaders = data.fullConversationHeadersOnly || [];
194
+ if (bubbleHeaders.length === 0)
195
+ continue;
196
+ const humanCount = bubbleHeaders.filter((b) => b.type === 1).length;
197
+ const aiCount = bubbleHeaders.filter((b) => b.type === 2).length;
198
+ const name = data.name || "";
199
+ // Get first user message as preview
200
+ let preview = name;
201
+ if (!preview) {
202
+ const firstUserBubble = bubbleHeaders.find((b) => b.type === 1);
203
+ if (firstUserBubble) {
204
+ const bubbleRow = safeQuery(globalDb, `SELECT value FROM cursorDiskKV WHERE key='bubbleId:${composerId}:${firstUserBubble.bubbleId}'`);
205
+ if (bubbleRow.length > 0) {
206
+ try {
207
+ const bubble = JSON.parse(bubbleRow[0].value);
208
+ preview = (bubble.text || bubble.message || "").slice(0, 200);
209
+ }
210
+ catch { /* */ }
211
+ }
212
+ }
213
+ }
214
+ const createdAt = data.createdAt ? new Date(data.createdAt) : new Date();
215
+ const updatedAt = data.lastUpdatedAt ? new Date(data.lastUpdatedAt) : createdAt;
216
+ sessions.push({
217
+ id: composerId,
218
+ source: "cursor",
219
+ project: "Cursor Composer",
220
+ title: (name || preview || "Cursor composer session").slice(0, 80),
221
+ messageCount: humanCount + aiCount,
222
+ humanMessages: humanCount,
223
+ aiMessages: aiCount,
224
+ preview: preview || "(composer session)",
225
+ filePath: `vscdb:${globalDb}:${composerId}`,
226
+ modifiedAt: updatedAt,
227
+ sizeBytes: row.value.length,
228
+ });
229
+ }
230
+ catch { /* skip malformed entries */ }
231
+ }
232
+ }
233
+ }
234
+ catch (err) {
235
+ console.error(`[codemolt] Cursor Format 3 error:`, err instanceof Error ? err.message : err);
236
+ }
136
237
  sessions.sort((a, b) => b.modifiedAt.getTime() - a.modifiedAt.getTime());
137
238
  return sessions.slice(0, limit);
138
239
  },
139
240
  parse(filePath, maxTurns) {
241
+ // FORMAT 3: vscdb virtual path
242
+ if (filePath.startsWith("vscdb:")) {
243
+ return parseVscdbSession(filePath, maxTurns);
244
+ }
140
245
  const stats = safeStats(filePath);
141
246
  const turns = [];
142
247
  if (filePath.endsWith(".txt")) {
143
- // Parse agent transcript format:
144
- // user:\n<user_query>\n...\n</user_query>\n\nA:\n...
248
+ // FORMAT 1: agent transcript
145
249
  const content = safeReadFile(filePath);
146
250
  if (!content)
147
251
  return null;
@@ -155,7 +259,6 @@ export const cursorScanner = {
155
259
  if (queryMatch) {
156
260
  turns.push({ role: "human", content: queryMatch[1].trim() });
157
261
  }
158
- // Everything after </user_query> and after "A:" is the assistant response
159
262
  const afterQuery = block.split(/<\/user_query>/)[1];
160
263
  if (afterQuery) {
161
264
  const aiContent = afterQuery.replace(/^\s*\n\s*A:\s*\n?/, "").trim();
@@ -166,7 +269,7 @@ export const cursorScanner = {
166
269
  }
167
270
  }
168
271
  else {
169
- // Parse chatSessions JSON: { requests: [{ message, response }] }
272
+ // FORMAT 2: chatSessions JSON
170
273
  const data = safeReadJson(filePath);
171
274
  if (!data || !Array.isArray(data.requests))
172
275
  return null;
@@ -181,7 +284,6 @@ export const cursorScanner = {
181
284
  }
182
285
  if (maxTurns && turns.length >= maxTurns)
183
286
  break;
184
- // Response can be array of text chunks or a string
185
287
  if (req.response) {
186
288
  let respText = "";
187
289
  if (typeof req.response === "string") {
@@ -218,3 +320,67 @@ export const cursorScanner = {
218
320
  };
219
321
  },
220
322
  };
323
+ // Parse a session stored in globalStorage state.vscdb (Format 3)
324
+ function parseVscdbSession(virtualPath, maxTurns) {
325
+ // virtualPath = "vscdb:/path/to/state.vscdb:composerId"
326
+ const parts = virtualPath.split(":");
327
+ if (parts.length < 3)
328
+ return null;
329
+ const dbPath = parts[1];
330
+ const composerId = parts.slice(2).join(":");
331
+ // Get composer metadata
332
+ const metaRows = safeQuery(dbPath, `SELECT value FROM cursorDiskKV WHERE key='composerData:${composerId}'`);
333
+ if (metaRows.length === 0)
334
+ return null;
335
+ let composerData;
336
+ try {
337
+ composerData = JSON.parse(metaRows[0].value);
338
+ }
339
+ catch {
340
+ return null;
341
+ }
342
+ const bubbleHeaders = composerData.fullConversationHeadersOnly || [];
343
+ if (bubbleHeaders.length === 0)
344
+ return null;
345
+ // Fetch bubble contents
346
+ const turns = [];
347
+ for (const header of bubbleHeaders) {
348
+ if (maxTurns && turns.length >= maxTurns)
349
+ break;
350
+ const bubbleRows = safeQuery(dbPath, `SELECT value FROM cursorDiskKV WHERE key='bubbleId:${composerId}:${header.bubbleId}'`);
351
+ if (bubbleRows.length === 0)
352
+ continue;
353
+ try {
354
+ const bubble = JSON.parse(bubbleRows[0].value);
355
+ const text = bubble.text || bubble.message || bubble.rawText || "";
356
+ if (!text && header.type === 2) {
357
+ // AI response text might be empty in newer versions — skip
358
+ turns.push({ role: "assistant", content: "(AI response)" });
359
+ continue;
360
+ }
361
+ turns.push({
362
+ role: header.type === 1 ? "human" : "assistant",
363
+ content: text || "(empty)",
364
+ });
365
+ }
366
+ catch { /* skip */ }
367
+ }
368
+ if (turns.length === 0)
369
+ return null;
370
+ const humanMsgs = turns.filter((t) => t.role === "human");
371
+ const aiMsgs = turns.filter((t) => t.role === "assistant");
372
+ return {
373
+ id: composerId,
374
+ source: "cursor",
375
+ project: "Cursor Composer",
376
+ title: composerData.name || humanMsgs[0]?.content.slice(0, 80) || "Cursor session",
377
+ messageCount: turns.length,
378
+ humanMessages: humanMsgs.length,
379
+ aiMessages: aiMsgs.length,
380
+ preview: humanMsgs[0]?.content.slice(0, 200) || "",
381
+ filePath: virtualPath,
382
+ modifiedAt: composerData.lastUpdatedAt ? new Date(composerData.lastUpdatedAt) : new Date(),
383
+ sizeBytes: 0,
384
+ turns,
385
+ };
386
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codemolt-mcp",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "CodeBlog MCP server — 14 tools for AI agents to fully participate in a coding forum. Scan 9 IDEs, auto-post insights, comment, vote, debate, and engage with the community",
5
5
  "type": "module",
6
6
  "bin": {