@iflow-mcp/dearcloud09-logseq-mcp 1.0.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/graph.js ADDED
@@ -0,0 +1,627 @@
1
+ /**
2
+ * Logseq Graph Service
3
+ * Handles file system operations for Logseq graphs
4
+ */
5
+ import { readdir, readFile, writeFile, unlink, mkdir, stat, lstat } from 'node:fs/promises';
6
+ import { join, basename, extname, resolve } from 'node:path';
7
+ // 보안 상수
8
+ const MAX_CONTENT_SIZE = 10 * 1024 * 1024; // 10MB - DoS 방지
9
+ const MAX_DEPTH = 10; // 그래프 탐색 최대 깊이
10
+ export class GraphService {
11
+ graphPath;
12
+ pagesPath;
13
+ journalsPath;
14
+ constructor(graphPath) {
15
+ this.graphPath = graphPath;
16
+ this.pagesPath = join(graphPath, 'pages');
17
+ this.journalsPath = join(graphPath, 'journals');
18
+ }
19
+ /**
20
+ * List all pages in the graph
21
+ */
22
+ async listPages(folder) {
23
+ const pages = [];
24
+ const backlinksMap = new Map();
25
+ // Collect all pages first to build backlinks
26
+ const allPages = await this.collectAllPages();
27
+ // Build backlinks map
28
+ for (const page of allPages) {
29
+ for (const link of page.links) {
30
+ const existing = backlinksMap.get(link) || [];
31
+ existing.push(page.name);
32
+ backlinksMap.set(link, existing);
33
+ }
34
+ }
35
+ // Filter by folder if specified
36
+ let filteredPages = allPages;
37
+ if (folder) {
38
+ if (folder === 'journals') {
39
+ filteredPages = allPages.filter(p => p.isJournal);
40
+ }
41
+ else if (folder === 'pages') {
42
+ filteredPages = allPages.filter(p => !p.isJournal);
43
+ }
44
+ }
45
+ // Add backlinks to each page
46
+ for (const page of filteredPages) {
47
+ pages.push({
48
+ ...page,
49
+ backlinks: backlinksMap.get(page.name) || [],
50
+ });
51
+ }
52
+ return pages;
53
+ }
54
+ async collectAllPages() {
55
+ const pages = [];
56
+ // Read pages folder
57
+ try {
58
+ const pageFiles = await readdir(this.pagesPath);
59
+ for (const file of pageFiles) {
60
+ if (extname(file) === '.md') {
61
+ const filePath = join(this.pagesPath, file);
62
+ // 보안: 심링크 공격 방지 - 심링크는 건너뜀
63
+ try {
64
+ const stats = await lstat(filePath);
65
+ if (stats.isSymbolicLink())
66
+ continue;
67
+ }
68
+ catch {
69
+ continue;
70
+ }
71
+ const content = await readFile(filePath, 'utf-8');
72
+ const name = basename(file, '.md');
73
+ pages.push({
74
+ path: `pages/${file}`,
75
+ name,
76
+ tags: this.extractTags(content),
77
+ links: this.extractLinks(content),
78
+ backlinks: [],
79
+ isJournal: false,
80
+ });
81
+ }
82
+ }
83
+ }
84
+ catch (e) {
85
+ // pages folder might not exist
86
+ }
87
+ // Read journals folder
88
+ try {
89
+ const journalFiles = await readdir(this.journalsPath);
90
+ for (const file of journalFiles) {
91
+ if (extname(file) === '.md') {
92
+ const filePath = join(this.journalsPath, file);
93
+ // 보안: 심링크 공격 방지 - 심링크는 건너뜀
94
+ try {
95
+ const stats = await lstat(filePath);
96
+ if (stats.isSymbolicLink())
97
+ continue;
98
+ }
99
+ catch {
100
+ continue;
101
+ }
102
+ const content = await readFile(filePath, 'utf-8');
103
+ const name = basename(file, '.md');
104
+ pages.push({
105
+ path: `journals/${file}`,
106
+ name,
107
+ tags: this.extractTags(content),
108
+ links: this.extractLinks(content),
109
+ backlinks: [],
110
+ isJournal: true,
111
+ });
112
+ }
113
+ }
114
+ }
115
+ catch (e) {
116
+ // journals folder might not exist
117
+ }
118
+ return pages;
119
+ }
120
+ /**
121
+ * Read a page by path or name
122
+ */
123
+ async readPage(pathOrName) {
124
+ const filePath = await this.resolvePath(pathOrName);
125
+ await this.checkSymlink(filePath); // 심링크 공격 방지
126
+ const content = await readFile(filePath, 'utf-8');
127
+ const name = basename(filePath, '.md');
128
+ const isJournal = filePath.includes('/journals/');
129
+ const { properties, body } = this.parseProperties(content);
130
+ const stats = await stat(filePath);
131
+ // Get backlinks
132
+ const allPages = await this.collectAllPages();
133
+ const backlinks = allPages
134
+ .filter(p => p.links.includes(name))
135
+ .map(p => p.name);
136
+ return {
137
+ path: filePath.replace(this.graphPath + '/', ''),
138
+ name,
139
+ content: body,
140
+ properties,
141
+ tags: this.extractTags(content),
142
+ links: this.extractLinks(content),
143
+ backlinks,
144
+ isJournal,
145
+ createdAt: stats.birthtime,
146
+ modifiedAt: stats.mtime,
147
+ };
148
+ }
149
+ /**
150
+ * Validate page name (whitelist approach for security)
151
+ */
152
+ validatePageName(name) {
153
+ // 최대 길이 제한 (파일시스템 호환성)
154
+ if (name.length > 200) {
155
+ throw new Error('Invalid page name: too long (max 200 characters)');
156
+ }
157
+ // 빈 이름 금지
158
+ if (name.trim().length === 0) {
159
+ throw new Error('Invalid page name: cannot be empty');
160
+ }
161
+ // 화이트리스트: 알파벳, 숫자, 한글, 공백, 일부 안전한 특수문자만 허용
162
+ // 금지: / \ .. : * ? " < > | null문자 등
163
+ const safePattern = /^[a-zA-Z0-9가-힣ㄱ-ㅎㅏ-ㅣ\s_\-().,'!@#$%&+=\[\]{}]+$/;
164
+ if (!safePattern.test(name)) {
165
+ throw new Error('Invalid page name: contains forbidden characters');
166
+ }
167
+ // 명시적으로 위험한 패턴 차단
168
+ const dangerousPatterns = ['..', '/', '\\', '\x00', ':', '*', '?', '"', '<', '>', '|'];
169
+ for (const pattern of dangerousPatterns) {
170
+ if (name.includes(pattern)) {
171
+ throw new Error('Invalid page name: contains forbidden characters');
172
+ }
173
+ }
174
+ }
175
+ /**
176
+ * Create a new page
177
+ */
178
+ async createPage(name, content, properties) {
179
+ // 보안 검증: 화이트리스트 기반 이름 검증
180
+ this.validatePageName(name);
181
+ const filePath = join(this.pagesPath, `${name}.md`);
182
+ this.validatePath(filePath);
183
+ // 보안 검증: 콘텐츠 크기 제한 (DoS 방지)
184
+ this.validateContentSize(content);
185
+ // Ensure pages directory exists
186
+ await mkdir(this.pagesPath, { recursive: true });
187
+ // 보안 검증: 심링크 공격 방지
188
+ await this.checkSymlink(filePath);
189
+ const fullContent = this.buildContent(content, properties);
190
+ // 보안: TOCTOU 방지 - 'wx' 플래그로 원자적 생성 (파일 존재 시 실패)
191
+ try {
192
+ await writeFile(filePath, fullContent, { encoding: 'utf-8', flag: 'wx' });
193
+ }
194
+ catch (e) {
195
+ if (e.code === 'EEXIST') {
196
+ throw new Error(`Page already exists: ${name}`);
197
+ }
198
+ throw e;
199
+ }
200
+ return this.readPage(name);
201
+ }
202
+ /**
203
+ * Update an existing page
204
+ */
205
+ async updatePage(pathOrName, content, properties) {
206
+ // 보안 검증: 콘텐츠 크기 제한 (DoS 방지)
207
+ this.validateContentSize(content);
208
+ const filePath = await this.resolvePath(pathOrName);
209
+ await this.checkSymlink(filePath); // 심링크 공격 방지
210
+ const fullContent = this.buildContent(content, properties);
211
+ await writeFile(filePath, fullContent, 'utf-8');
212
+ return this.readPage(pathOrName);
213
+ }
214
+ /**
215
+ * Delete a page
216
+ */
217
+ async deletePage(pathOrName) {
218
+ const filePath = await this.resolvePath(pathOrName);
219
+ await this.checkSymlink(filePath); // 보안: 심링크 공격 방지
220
+ await unlink(filePath);
221
+ }
222
+ /**
223
+ * Append content to a page
224
+ */
225
+ async appendToPage(pathOrName, content) {
226
+ // 보안 검증: 추가할 콘텐츠 크기 제한 (DoS 방지)
227
+ this.validateContentSize(content);
228
+ const filePath = await this.resolvePath(pathOrName);
229
+ await this.checkSymlink(filePath); // 심링크 공격 방지
230
+ const existing = await readFile(filePath, 'utf-8');
231
+ const newContent = existing.trimEnd() + '\n' + content;
232
+ // 결합된 콘텐츠도 크기 검증
233
+ this.validateContentSize(newContent);
234
+ await writeFile(filePath, newContent, 'utf-8');
235
+ return this.readPage(pathOrName);
236
+ }
237
+ /**
238
+ * Search pages
239
+ */
240
+ async searchPages(query, options) {
241
+ // 보안: 검색어 길이 제한 (DoS 방지)
242
+ if (query.length > 1000) {
243
+ throw new Error('Search query too long (max 1000 characters)');
244
+ }
245
+ const results = [];
246
+ const pages = await this.listPages(options?.folder);
247
+ const queryLower = query.toLowerCase();
248
+ for (const page of pages) {
249
+ // Filter by tags if specified
250
+ if (options?.tags && options.tags.length > 0) {
251
+ const hasTag = options.tags.some(tag => page.tags.includes(tag));
252
+ if (!hasTag)
253
+ continue;
254
+ }
255
+ // Search in content
256
+ const filePath = join(this.graphPath, page.path);
257
+ // 보안: TOCTOU 방지 - 심링크/하드링크 체크
258
+ try {
259
+ await this.checkRegularFile(filePath);
260
+ }
261
+ catch {
262
+ continue; // 심링크/하드링크/특수파일은 건너뜀
263
+ }
264
+ const content = await readFile(filePath, 'utf-8');
265
+ const lines = content.split('\n');
266
+ const matches = [];
267
+ for (let i = 0; i < lines.length; i++) {
268
+ if (lines[i].toLowerCase().includes(queryLower)) {
269
+ matches.push({
270
+ line: i + 1,
271
+ content: lines[i],
272
+ context: lines.slice(Math.max(0, i - 1), i + 2).join('\n'),
273
+ });
274
+ }
275
+ }
276
+ // Also match page name
277
+ if (page.name.toLowerCase().includes(queryLower) || matches.length > 0) {
278
+ results.push({ page, matches });
279
+ }
280
+ }
281
+ return results;
282
+ }
283
+ /**
284
+ * Get backlinks for a page
285
+ */
286
+ async getBacklinks(pathOrName) {
287
+ const page = await this.readPage(pathOrName);
288
+ const allPages = await this.listPages();
289
+ return allPages.filter(p => p.links.includes(page.name));
290
+ }
291
+ /**
292
+ * Get graph data
293
+ */
294
+ async getGraph(options) {
295
+ const nodes = [];
296
+ const edges = [];
297
+ const nodeSet = new Set();
298
+ const pages = await this.listPages();
299
+ const addNode = (id, name, type) => {
300
+ if (!nodeSet.has(id)) {
301
+ nodeSet.add(id);
302
+ nodes.push({ id, name, type });
303
+ }
304
+ };
305
+ if (options?.center) {
306
+ // Build graph around center node
307
+ const visited = new Set();
308
+ const queue = [{ name: options.center, depth: 0 }];
309
+ // 보안: depth는 0 이상, MAX_DEPTH 이하로 제한
310
+ const requestedDepth = options.depth ?? 1;
311
+ const maxDepth = Math.max(0, Math.min(requestedDepth, MAX_DEPTH));
312
+ while (queue.length > 0) {
313
+ const { name, depth } = queue.shift();
314
+ if (visited.has(name) || depth > maxDepth)
315
+ continue;
316
+ visited.add(name);
317
+ const page = pages.find(p => p.name === name);
318
+ if (!page)
319
+ continue;
320
+ const nodeType = page.isJournal ? 'journal' : 'page';
321
+ addNode(name, name, nodeType);
322
+ // Add links
323
+ for (const link of page.links) {
324
+ addNode(link, link, 'page');
325
+ edges.push({ source: name, target: link, type: 'link' });
326
+ if (depth < maxDepth) {
327
+ queue.push({ name: link, depth: depth + 1 });
328
+ }
329
+ }
330
+ // Add backlinks
331
+ for (const backlink of page.backlinks) {
332
+ addNode(backlink, backlink, 'page');
333
+ edges.push({ source: backlink, target: name, type: 'backlink' });
334
+ if (depth < maxDepth) {
335
+ queue.push({ name: backlink, depth: depth + 1 });
336
+ }
337
+ }
338
+ // Add tags
339
+ for (const tag of page.tags) {
340
+ addNode(`tag:${tag}`, tag, 'tag');
341
+ edges.push({ source: name, target: `tag:${tag}`, type: 'tag' });
342
+ }
343
+ }
344
+ }
345
+ else {
346
+ // Full graph
347
+ for (const page of pages) {
348
+ const nodeType = page.isJournal ? 'journal' : 'page';
349
+ addNode(page.name, page.name, nodeType);
350
+ for (const link of page.links) {
351
+ addNode(link, link, 'page');
352
+ edges.push({ source: page.name, target: link, type: 'link' });
353
+ }
354
+ for (const tag of page.tags) {
355
+ addNode(`tag:${tag}`, tag, 'tag');
356
+ edges.push({ source: page.name, target: `tag:${tag}`, type: 'tag' });
357
+ }
358
+ }
359
+ }
360
+ return { nodes, edges };
361
+ }
362
+ /**
363
+ * Get journal page for a date
364
+ */
365
+ async getJournalPage(date) {
366
+ const targetDate = date || this.getTodayString();
367
+ // Validate date format (YYYY-MM-DD only)
368
+ if (date && !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
369
+ throw new Error('Invalid date format: use YYYY-MM-DD');
370
+ }
371
+ const fileName = targetDate.replace(/-/g, '_') + '.md';
372
+ const filePath = join(this.journalsPath, fileName);
373
+ this.validatePath(filePath);
374
+ try {
375
+ await stat(filePath);
376
+ return this.readPage(`journals/${fileName}`);
377
+ }
378
+ catch {
379
+ return null;
380
+ }
381
+ }
382
+ /**
383
+ * Create journal page
384
+ */
385
+ async createJournalPage(date, template) {
386
+ const targetDate = date || this.getTodayString();
387
+ // Validate date format (YYYY-MM-DD only)
388
+ if (date && !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
389
+ throw new Error('Invalid date format: use YYYY-MM-DD');
390
+ }
391
+ // 보안 검증: 템플릿 크기 제한 (DoS 방지)
392
+ if (template) {
393
+ this.validateContentSize(template);
394
+ }
395
+ const fileName = targetDate.replace(/-/g, '_') + '.md';
396
+ const filePath = join(this.journalsPath, fileName);
397
+ this.validatePath(filePath);
398
+ // Ensure journals directory exists
399
+ await mkdir(this.journalsPath, { recursive: true });
400
+ // 보안 검증: 심링크 공격 방지
401
+ await this.checkSymlink(filePath);
402
+ // Check if already exists
403
+ try {
404
+ await stat(filePath);
405
+ return this.readPage(`journals/${fileName}`);
406
+ }
407
+ catch {
408
+ // Create new journal page
409
+ const content = template || `- `;
410
+ await writeFile(filePath, content, 'utf-8');
411
+ return this.readPage(`journals/${fileName}`);
412
+ }
413
+ }
414
+ /**
415
+ * Append content to journal page (creates if doesn't exist)
416
+ */
417
+ async appendToJournalPage(date, content) {
418
+ const targetDate = date || this.getTodayString();
419
+ // Validate date format (YYYY-MM-DD only)
420
+ if (date && !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
421
+ throw new Error('Invalid date format: use YYYY-MM-DD');
422
+ }
423
+ // 보안 검증: 콘텐츠 크기 제한
424
+ if (content) {
425
+ this.validateContentSize(content);
426
+ }
427
+ const fileName = targetDate.replace(/-/g, '_') + '.md';
428
+ const filePath = join(this.journalsPath, fileName);
429
+ this.validatePath(filePath);
430
+ // Ensure journals directory exists
431
+ await mkdir(this.journalsPath, { recursive: true });
432
+ // 보안 검증: 심링크 공격 방지
433
+ await this.checkSymlink(filePath);
434
+ // Get existing content or create new
435
+ let existingContent = '';
436
+ try {
437
+ existingContent = await readFile(filePath, 'utf-8');
438
+ }
439
+ catch {
440
+ // File doesn't exist, start with empty
441
+ }
442
+ // Append content
443
+ const newContent = existingContent
444
+ ? existingContent + '\n' + (content || '')
445
+ : (content || '- ');
446
+ await writeFile(filePath, newContent, 'utf-8');
447
+ return this.readPage(`journals/${fileName}`);
448
+ }
449
+ // Helper methods
450
+ /**
451
+ * Validate content size (prevents DoS attacks)
452
+ */
453
+ validateContentSize(content) {
454
+ const byteSize = Buffer.byteLength(content, 'utf-8');
455
+ if (byteSize > MAX_CONTENT_SIZE) {
456
+ throw new Error(`Content too large: ${Math.round(byteSize / 1024 / 1024)}MB exceeds limit of 10MB`);
457
+ }
458
+ }
459
+ /**
460
+ * Check if path is a symlink (prevents symlink escape attacks)
461
+ */
462
+ async checkSymlink(filePath) {
463
+ try {
464
+ const stats = await lstat(filePath);
465
+ if (stats.isSymbolicLink()) {
466
+ throw new Error(`Access denied: symbolic links not allowed`);
467
+ }
468
+ }
469
+ catch (e) {
470
+ // ENOENT is ok (file doesn't exist yet), rethrow symlink errors
471
+ if (e.message?.includes('symbolic links'))
472
+ throw e;
473
+ }
474
+ }
475
+ /**
476
+ * Check if path is a regular file (prevents symlink/hardlink attacks)
477
+ * More strict than checkSymlink - also detects hardlinks to external files
478
+ */
479
+ async checkRegularFile(filePath) {
480
+ const stats = await lstat(filePath);
481
+ // 심링크 차단
482
+ if (stats.isSymbolicLink()) {
483
+ throw new Error(`Access denied: symbolic links not allowed`);
484
+ }
485
+ // 일반 파일만 허용 (디렉토리, 소켓, 디바이스 등 차단)
486
+ if (!stats.isFile()) {
487
+ throw new Error(`Access denied: not a regular file`);
488
+ }
489
+ // 하드링크 감지: nlink > 1이면 다른 곳에서 링크된 파일
490
+ // 주의: 정상적인 경우에도 nlink > 1일 수 있으나, 보안을 위해 차단
491
+ if (stats.nlink > 1) {
492
+ throw new Error(`Access denied: hardlinks not allowed`);
493
+ }
494
+ }
495
+ /**
496
+ * Validate that a path is within the graph directory (prevents path traversal attacks)
497
+ */
498
+ validatePath(filePath) {
499
+ const normalizedPath = resolve(filePath);
500
+ const normalizedGraphPath = resolve(this.graphPath);
501
+ if (!normalizedPath.startsWith(normalizedGraphPath + '/') && normalizedPath !== normalizedGraphPath) {
502
+ throw new Error(`Access denied: path outside graph directory`);
503
+ }
504
+ return normalizedPath;
505
+ }
506
+ async resolvePath(pathOrName) {
507
+ // If it's already a relative path
508
+ if (pathOrName.includes('/')) {
509
+ const filePath = join(this.graphPath, pathOrName);
510
+ return this.validatePath(filePath);
511
+ }
512
+ // Try pages first
513
+ const pagePath = join(this.pagesPath, `${pathOrName}.md`);
514
+ try {
515
+ const validPath = this.validatePath(pagePath);
516
+ await stat(validPath);
517
+ return validPath;
518
+ }
519
+ catch (e) {
520
+ if (e.message?.includes('Access denied'))
521
+ throw e;
522
+ }
523
+ // Try journals (with underscore format)
524
+ const journalPath = join(this.journalsPath, `${pathOrName.replace(/-/g, '_')}.md`);
525
+ try {
526
+ const validPath = this.validatePath(journalPath);
527
+ await stat(validPath);
528
+ return validPath;
529
+ }
530
+ catch (e) {
531
+ if (e.message?.includes('Access denied'))
532
+ throw e;
533
+ }
534
+ // Try journals with original name
535
+ const journalPath2 = join(this.journalsPath, `${pathOrName}.md`);
536
+ try {
537
+ const validPath = this.validatePath(journalPath2);
538
+ await stat(validPath);
539
+ return validPath;
540
+ }
541
+ catch (e) {
542
+ if (e.message?.includes('Access denied'))
543
+ throw e;
544
+ }
545
+ throw new Error(`Page not found: ${pathOrName}`);
546
+ }
547
+ extractTags(content) {
548
+ const tagRegex = /#([a-zA-Z0-9_\-/\uAC00-\uD7A3]+)/g;
549
+ const tags = new Set();
550
+ let match;
551
+ while ((match = tagRegex.exec(content)) !== null) {
552
+ tags.add(match[1]);
553
+ }
554
+ return Array.from(tags);
555
+ }
556
+ extractLinks(content) {
557
+ const linkRegex = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
558
+ const links = new Set();
559
+ let match;
560
+ while ((match = linkRegex.exec(content)) !== null) {
561
+ links.add(match[1]);
562
+ }
563
+ return Array.from(links);
564
+ }
565
+ parseProperties(content) {
566
+ const lines = content.split('\n');
567
+ const properties = {};
568
+ let bodyStart = 0;
569
+ // Logseq properties are at the start with format: key:: value
570
+ for (let i = 0; i < lines.length; i++) {
571
+ const match = lines[i].match(/^([a-zA-Z_-]+)::\s*(.*)$/);
572
+ if (match) {
573
+ properties[match[1]] = match[2];
574
+ bodyStart = i + 1;
575
+ }
576
+ else if (lines[i].trim() === '') {
577
+ continue;
578
+ }
579
+ else {
580
+ break;
581
+ }
582
+ }
583
+ return {
584
+ properties,
585
+ body: lines.slice(bodyStart).join('\n'),
586
+ };
587
+ }
588
+ /**
589
+ * Validate and sanitize properties (prevents injection attacks)
590
+ */
591
+ validateProperties(properties) {
592
+ for (const [key, value] of Object.entries(properties)) {
593
+ const keyStr = String(key);
594
+ const valueStr = String(value);
595
+ // 키에 개행문자, ::, 특수문자 금지
596
+ if (/[\n\r]/.test(keyStr) || keyStr.includes('::')) {
597
+ throw new Error(`Invalid property key: contains forbidden characters`);
598
+ }
599
+ // 값에 개행문자 금지 (Logseq 구조 오염 방지)
600
+ if (/[\n\r]/.test(valueStr)) {
601
+ throw new Error(`Invalid property value: contains newline characters`);
602
+ }
603
+ // 키는 알파벳, 숫자, -, _ 만 허용
604
+ if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(keyStr)) {
605
+ throw new Error(`Invalid property key: must start with letter and contain only alphanumeric, dash, underscore`);
606
+ }
607
+ }
608
+ }
609
+ buildContent(content, properties) {
610
+ if (!properties || Object.keys(properties).length === 0) {
611
+ return content;
612
+ }
613
+ // 보안 검증
614
+ this.validateProperties(properties);
615
+ const propLines = Object.entries(properties)
616
+ .map(([key, value]) => `${key}:: ${value}`)
617
+ .join('\n');
618
+ return propLines + '\n\n' + content;
619
+ }
620
+ getTodayString() {
621
+ const now = new Date();
622
+ const year = now.getFullYear();
623
+ const month = String(now.getMonth() + 1).padStart(2, '0');
624
+ const day = String(now.getDate()).padStart(2, '0');
625
+ return `${year}-${month}-${day}`;
626
+ }
627
+ }
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Logseq MCP Server
4
+ *
5
+ * Provides tools for interacting with Logseq graphs:
6
+ * - Page CRUD operations
7
+ * - Search functionality
8
+ * - Link/backlink navigation
9
+ * - Graph visualization data
10
+ */
11
+ export {};