@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/src/index.ts ADDED
@@ -0,0 +1,697 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Logseq MCP Server
5
+ *
6
+ * Provides tools for interacting with Logseq graphs:
7
+ * - Page CRUD operations
8
+ * - Search functionality
9
+ * - Link/backlink navigation
10
+ * - Graph visualization data
11
+ */
12
+
13
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
14
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
15
+ import {
16
+ CallToolRequestSchema,
17
+ ListToolsRequestSchema,
18
+ ListResourcesRequestSchema,
19
+ ReadResourceRequestSchema,
20
+ } from '@modelcontextprotocol/sdk/types.js';
21
+ import { z } from 'zod';
22
+ import { GraphService } from './graph.js';
23
+
24
+ // 환경 변수에서 graph 경로 가져오기 (필수)
25
+ const GRAPH_PATH = process.env.LOGSEQ_GRAPH_PATH;
26
+ if (!GRAPH_PATH) {
27
+ console.error('Error: LOGSEQ_GRAPH_PATH environment variable is required');
28
+ process.exit(1);
29
+ }
30
+
31
+ const graph = new GraphService(GRAPH_PATH);
32
+
33
+ function createMcpServer() {
34
+ const server = new Server(
35
+ {
36
+ name: 'logseq-mcp',
37
+ version: '1.0.0',
38
+ },
39
+ {
40
+ capabilities: {
41
+ tools: {},
42
+ resources: {},
43
+ },
44
+ }
45
+ );
46
+
47
+ // 보안 상수
48
+ const MAX_PATH_LENGTH = 500;
49
+ const MAX_NAME_LENGTH = 200;
50
+ const MAX_QUERY_LENGTH = 1000;
51
+ const MAX_TAG_LENGTH = 100;
52
+ const MAX_TAGS_COUNT = 50;
53
+ const MAX_CONTENT_LENGTH = 10 * 1024 * 1024; // 10MB
54
+
55
+ // Tool schemas with security limits
56
+ const ListPagesSchema = z.object({
57
+ folder: z.enum(['pages', 'journals']).optional().describe('폴더 필터: pages 또는 journals'),
58
+ });
59
+
60
+ const ReadPageSchema = z.object({
61
+ path: z.string().max(MAX_PATH_LENGTH).describe('페이지 경로 또는 이름 (예: "pages/note" 또는 "note")'),
62
+ });
63
+
64
+ const CreatePageSchema = z.object({
65
+ name: z.string().max(MAX_NAME_LENGTH).describe('생성할 페이지 이름'),
66
+ content: z.string().max(MAX_CONTENT_LENGTH).describe('페이지 내용'),
67
+ properties: z.record(z.string().max(10000)).optional().describe('Logseq 프로퍼티 (선택, 문자열 값만)'),
68
+ });
69
+
70
+ const UpdatePageSchema = z.object({
71
+ path: z.string().max(MAX_PATH_LENGTH).describe('수정할 페이지 경로 또는 이름'),
72
+ content: z.string().max(MAX_CONTENT_LENGTH).describe('새로운 페이지 내용'),
73
+ properties: z.record(z.string().max(10000)).optional().describe('Logseq 프로퍼티 (선택, 문자열 값만)'),
74
+ });
75
+
76
+ const DeletePageSchema = z.object({
77
+ path: z.string().max(MAX_PATH_LENGTH).describe('삭제할 페이지 경로 또는 이름'),
78
+ });
79
+
80
+ const AppendToPageSchema = z.object({
81
+ path: z.string().max(MAX_PATH_LENGTH).describe('페이지 경로 또는 이름'),
82
+ content: z.string().max(MAX_CONTENT_LENGTH).describe('추가할 내용'),
83
+ });
84
+
85
+ const SearchPagesSchema = z.object({
86
+ query: z.string().max(MAX_QUERY_LENGTH).describe('검색어'),
87
+ tags: z.array(z.string().max(MAX_TAG_LENGTH)).max(MAX_TAGS_COUNT).optional().describe('태그 필터 (선택)'),
88
+ folder: z.enum(['pages', 'journals']).optional().describe('폴더 필터 (선택)'),
89
+ });
90
+
91
+ const GetBacklinksSchema = z.object({
92
+ path: z.string().max(MAX_PATH_LENGTH).describe('페이지 경로 또는 이름'),
93
+ });
94
+
95
+ const GetGraphSchema = z.object({
96
+ center: z.string().max(MAX_NAME_LENGTH).optional().describe('중심 페이지 이름 (선택)'),
97
+ depth: z.number().int().min(0).max(10).optional().describe('탐색 깊이 (기본값: 1, 최대: 10)'),
98
+ });
99
+
100
+ const GetJournalSchema = z.object({
101
+ date: z.string().max(10).optional().describe('날짜 (YYYY-MM-DD, 기본값: 오늘)'),
102
+ });
103
+
104
+ const CreateJournalSchema = z.object({
105
+ date: z.string().max(10).optional().describe('날짜 (YYYY-MM-DD, 기본값: 오늘)'),
106
+ template: z.string().max(MAX_CONTENT_LENGTH).optional().describe('템플릿 내용 (선택)'),
107
+ });
108
+
109
+ const AddArticleSchema = z.object({
110
+ title: z.string().max(500).describe('아티클 제목'),
111
+ summary: z.string().max(2000).optional().describe('요약'),
112
+ tags: z.string().max(500).optional().describe('태그 (쉼표 구분)'),
113
+ url: z.string().max(2000).optional().describe('원본 URL'),
114
+ highlights: z.string().max(10000).optional().describe('하이라이트 내용'),
115
+ date: z.string().max(10).optional().describe('날짜 (YYYY-MM-DD, 기본값: 오늘)'),
116
+ });
117
+
118
+ const AddBookSchema = z.object({
119
+ title: z.string().max(500).describe('책 제목'),
120
+ author: z.string().max(200).optional().describe('저자'),
121
+ tags: z.string().max(500).optional().describe('태그 (쉼표 구분)'),
122
+ memo: z.string().max(10000).optional().describe('메모/감상'),
123
+ date: z.string().max(10).optional().describe('날짜 (YYYY-MM-DD, 기본값: 오늘)'),
124
+ });
125
+
126
+ const AddMovieSchema = z.object({
127
+ title: z.string().max(500).describe('영화 제목'),
128
+ director: z.string().max(200).optional().describe('감독'),
129
+ memo: z.string().max(10000).optional().describe('메모/감상'),
130
+ date: z.string().max(10).optional().describe('날짜 (YYYY-MM-DD, 기본값: 오늘)'),
131
+ });
132
+
133
+ const AddExhibitionSchema = z.object({
134
+ title: z.string().max(500).describe('전시회 제목'),
135
+ venue: z.string().max(200).optional().describe('장소'),
136
+ artist: z.string().max(200).optional().describe('작가/아티스트'),
137
+ memo: z.string().max(10000).optional().describe('메모/감상'),
138
+ date: z.string().max(10).optional().describe('날짜 (YYYY-MM-DD, 기본값: 오늘)'),
139
+ });
140
+
141
+ // Tool definitions
142
+ const TOOLS = [
143
+ {
144
+ name: 'list_pages',
145
+ description: 'Graph 내 모든 페이지 목록 조회. 각 페이지의 메타데이터(경로, 이름, 태그, 링크, 백링크) 반환',
146
+ inputSchema: {
147
+ type: 'object' as const,
148
+ properties: {
149
+ folder: { type: 'string', enum: ['pages', 'journals'], description: '폴더 필터: pages 또는 journals' },
150
+ },
151
+ },
152
+ },
153
+ {
154
+ name: 'read_page',
155
+ description: '특정 페이지의 전체 내용과 메타데이터 조회',
156
+ inputSchema: {
157
+ type: 'object' as const,
158
+ properties: {
159
+ path: { type: 'string', description: '페이지 경로 또는 이름 (예: "pages/note" 또는 "note")' },
160
+ },
161
+ required: ['path'],
162
+ },
163
+ },
164
+ {
165
+ name: 'create_page',
166
+ description: '새 페이지 생성. Logseq 프로퍼티 포함 가능',
167
+ inputSchema: {
168
+ type: 'object' as const,
169
+ properties: {
170
+ name: { type: 'string', description: '생성할 페이지 이름' },
171
+ content: { type: 'string', description: '페이지 내용' },
172
+ properties: { type: 'object', description: 'Logseq 프로퍼티 (선택)' },
173
+ },
174
+ required: ['name', 'content'],
175
+ },
176
+ },
177
+ {
178
+ name: 'update_page',
179
+ description: '기존 페이지 내용 수정',
180
+ inputSchema: {
181
+ type: 'object' as const,
182
+ properties: {
183
+ path: { type: 'string', description: '수정할 페이지 경로 또는 이름' },
184
+ content: { type: 'string', description: '새로운 페이지 내용' },
185
+ properties: { type: 'object', description: 'Logseq 프로퍼티 (선택)' },
186
+ },
187
+ required: ['path', 'content'],
188
+ },
189
+ },
190
+ {
191
+ name: 'delete_page',
192
+ description: '페이지 삭제',
193
+ inputSchema: {
194
+ type: 'object' as const,
195
+ properties: {
196
+ path: { type: 'string', description: '삭제할 페이지 경로 또는 이름' },
197
+ },
198
+ required: ['path'],
199
+ },
200
+ },
201
+ {
202
+ name: 'append_to_page',
203
+ description: '기존 페이지 끝에 내용 추가',
204
+ inputSchema: {
205
+ type: 'object' as const,
206
+ properties: {
207
+ path: { type: 'string', description: '페이지 경로 또는 이름' },
208
+ content: { type: 'string', description: '추가할 내용' },
209
+ },
210
+ required: ['path', 'content'],
211
+ },
212
+ },
213
+ {
214
+ name: 'search_pages',
215
+ description: '페이지 내용/제목 검색. 태그 및 폴더 필터 지원',
216
+ inputSchema: {
217
+ type: 'object' as const,
218
+ properties: {
219
+ query: { type: 'string', description: '검색어' },
220
+ tags: { type: 'array', items: { type: 'string' }, description: '태그 필터 (선택)' },
221
+ folder: { type: 'string', enum: ['pages', 'journals'], description: '폴더 필터 (선택)' },
222
+ },
223
+ required: ['query'],
224
+ },
225
+ },
226
+ {
227
+ name: 'get_backlinks',
228
+ description: '특정 페이지를 참조하는 모든 페이지 조회',
229
+ inputSchema: {
230
+ type: 'object' as const,
231
+ properties: {
232
+ path: { type: 'string', description: '페이지 경로 또는 이름' },
233
+ },
234
+ required: ['path'],
235
+ },
236
+ },
237
+ {
238
+ name: 'get_graph',
239
+ description: '페이지 간 연결 그래프 데이터 조회. 링크/백링크/태그 관계 포함',
240
+ inputSchema: {
241
+ type: 'object' as const,
242
+ properties: {
243
+ center: { type: 'string', description: '중심 페이지 이름 (선택)' },
244
+ depth: { type: 'number', description: '탐색 깊이 (기본값: 1)' },
245
+ },
246
+ },
247
+ },
248
+ {
249
+ name: 'get_journal',
250
+ description: '오늘 또는 특정 날짜의 저널 페이지 조회',
251
+ inputSchema: {
252
+ type: 'object' as const,
253
+ properties: {
254
+ date: { type: 'string', description: '날짜 (YYYY-MM-DD, 기본값: 오늘)' },
255
+ },
256
+ },
257
+ },
258
+ {
259
+ name: 'create_journal',
260
+ description: '오늘 또는 특정 날짜의 저널 페이지 생성',
261
+ inputSchema: {
262
+ type: 'object' as const,
263
+ properties: {
264
+ date: { type: 'string', description: '날짜 (YYYY-MM-DD, 기본값: 오늘)' },
265
+ template: { type: 'string', description: '템플릿 내용 (선택)' },
266
+ },
267
+ },
268
+ },
269
+ {
270
+ name: 'add_article',
271
+ description: '오늘 저널에 아티클 정보 추가. 대화 정리, 웹 아티클, 읽은 글 등을 기록',
272
+ inputSchema: {
273
+ type: 'object' as const,
274
+ properties: {
275
+ title: { type: 'string', description: '아티클 제목' },
276
+ summary: { type: 'string', description: '요약 (선택)' },
277
+ tags: { type: 'string', description: '태그 - 쉼표 구분 (선택)' },
278
+ url: { type: 'string', description: '원본 URL (선택)' },
279
+ highlights: { type: 'string', description: '하이라이트/핵심 내용 (선택)' },
280
+ date: { type: 'string', description: '날짜 (YYYY-MM-DD, 기본값: 오늘)' },
281
+ },
282
+ required: ['title'],
283
+ },
284
+ },
285
+ {
286
+ name: 'add_book',
287
+ description: '오늘 저널에 책 정보 추가. 읽은 책, 읽고 싶은 책 기록',
288
+ inputSchema: {
289
+ type: 'object' as const,
290
+ properties: {
291
+ title: { type: 'string', description: '책 제목' },
292
+ author: { type: 'string', description: '저자 (선택)' },
293
+ tags: { type: 'string', description: '태그 - 쉼표 구분 (선택)' },
294
+ memo: { type: 'string', description: '메모/감상 (선택)' },
295
+ date: { type: 'string', description: '날짜 (YYYY-MM-DD, 기본값: 오늘)' },
296
+ },
297
+ required: ['title'],
298
+ },
299
+ },
300
+ {
301
+ name: 'add_movie',
302
+ description: '오늘 저널에 영화 정보 추가. 본 영화, 보고 싶은 영화 기록',
303
+ inputSchema: {
304
+ type: 'object' as const,
305
+ properties: {
306
+ title: { type: 'string', description: '영화 제목' },
307
+ director: { type: 'string', description: '감독 (선택)' },
308
+ memo: { type: 'string', description: '메모/감상 (선택)' },
309
+ date: { type: 'string', description: '날짜 (YYYY-MM-DD, 기본값: 오늘)' },
310
+ },
311
+ required: ['title'],
312
+ },
313
+ },
314
+ {
315
+ name: 'add_exhibition',
316
+ description: '오늘 저널에 전시회 정보 추가. 본 전시회, 보고 싶은 전시회 기록',
317
+ inputSchema: {
318
+ type: 'object' as const,
319
+ properties: {
320
+ title: { type: 'string', description: '전시회 제목' },
321
+ venue: { type: 'string', description: '장소 (선택)' },
322
+ artist: { type: 'string', description: '작가/아티스트 (선택)' },
323
+ memo: { type: 'string', description: '메모/감상 (선택)' },
324
+ date: { type: 'string', description: '날짜 (YYYY-MM-DD, 기본값: 오늘)' },
325
+ },
326
+ required: ['title'],
327
+ },
328
+ },
329
+ ];
330
+
331
+ // List tools handler
332
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
333
+ tools: TOOLS,
334
+ }));
335
+
336
+ // Call tool handler
337
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
338
+ const { name, arguments: args } = request.params;
339
+
340
+ try {
341
+ switch (name) {
342
+ case 'list_pages': {
343
+ const { folder } = ListPagesSchema.parse(args);
344
+ const pages = await graph.listPages(folder);
345
+ return {
346
+ content: [{ type: 'text', text: JSON.stringify(pages, null, 2) }],
347
+ };
348
+ }
349
+
350
+ case 'read_page': {
351
+ const { path } = ReadPageSchema.parse(args);
352
+ const page = await graph.readPage(path);
353
+ return {
354
+ content: [{ type: 'text', text: JSON.stringify(page, null, 2) }],
355
+ };
356
+ }
357
+
358
+ case 'create_page': {
359
+ const { name: pageName, content, properties } = CreatePageSchema.parse(args);
360
+ const page = await graph.createPage(pageName, content, properties);
361
+ return {
362
+ content: [{ type: 'text', text: JSON.stringify(page, null, 2) }],
363
+ };
364
+ }
365
+
366
+ case 'update_page': {
367
+ const { path, content, properties } = UpdatePageSchema.parse(args);
368
+ const page = await graph.updatePage(path, content, properties);
369
+ return {
370
+ content: [{ type: 'text', text: JSON.stringify(page, null, 2) }],
371
+ };
372
+ }
373
+
374
+ case 'delete_page': {
375
+ const { path } = DeletePageSchema.parse(args);
376
+ await graph.deletePage(path);
377
+ return {
378
+ content: [{ type: 'text', text: `페이지 삭제 완료: ${path}` }],
379
+ };
380
+ }
381
+
382
+ case 'append_to_page': {
383
+ const { path, content } = AppendToPageSchema.parse(args);
384
+ const page = await graph.appendToPage(path, content);
385
+ return {
386
+ content: [{ type: 'text', text: JSON.stringify(page, null, 2) }],
387
+ };
388
+ }
389
+
390
+ case 'search_pages': {
391
+ const { query, tags, folder } = SearchPagesSchema.parse(args);
392
+ const results = await graph.searchPages(query, { tags, folder });
393
+ return {
394
+ content: [{ type: 'text', text: JSON.stringify(results, null, 2) }],
395
+ };
396
+ }
397
+
398
+ case 'get_backlinks': {
399
+ const { path } = GetBacklinksSchema.parse(args);
400
+ const backlinks = await graph.getBacklinks(path);
401
+ return {
402
+ content: [{ type: 'text', text: JSON.stringify(backlinks, null, 2) }],
403
+ };
404
+ }
405
+
406
+ case 'get_graph': {
407
+ const { center, depth } = GetGraphSchema.parse(args);
408
+ const graphData = await graph.getGraph({ center, depth });
409
+ return {
410
+ content: [{ type: 'text', text: JSON.stringify(graphData, null, 2) }],
411
+ };
412
+ }
413
+
414
+ case 'get_journal': {
415
+ const { date } = GetJournalSchema.parse(args);
416
+ const page = await graph.getJournalPage(date);
417
+ if (!page) {
418
+ return {
419
+ content: [{ type: 'text', text: '저널 페이지를 찾을 수 없습니다.' }],
420
+ };
421
+ }
422
+ return {
423
+ content: [{ type: 'text', text: JSON.stringify(page, null, 2) }],
424
+ };
425
+ }
426
+
427
+ case 'create_journal': {
428
+ const { date, template } = CreateJournalSchema.parse(args);
429
+ const page = await graph.createJournalPage(date, template);
430
+ return {
431
+ content: [{ type: 'text', text: JSON.stringify(page, null, 2) }],
432
+ };
433
+ }
434
+
435
+ case 'add_article': {
436
+ const { title, summary, tags, url, highlights, date } = AddArticleSchema.parse(args);
437
+
438
+ // 아티클 템플릿 생성 (Logseq indent 형식)
439
+ const lines: string[] = [];
440
+ lines.push('- [[article]]');
441
+ lines.push('\t- #article');
442
+ lines.push('\t\t- meta');
443
+ lines.push(`\t\t\t- title : ${title}`);
444
+ if (summary) {
445
+ lines.push(`\t\t\t- summary : ${summary}`);
446
+ }
447
+ if (tags) {
448
+ lines.push(`\t\t\t- tag : ${tags}`);
449
+ }
450
+ if (url) {
451
+ lines.push(`\t\t\t- url : ${url}`);
452
+ }
453
+ if (highlights) {
454
+ lines.push('\t\t- Highlights :');
455
+ // 여러 줄 하이라이트 처리
456
+ const highlightLines = highlights.split('\n');
457
+ for (const line of highlightLines) {
458
+ if (line.trim()) {
459
+ lines.push(`\t\t\t- ${line.trim()}`);
460
+ }
461
+ }
462
+ }
463
+
464
+ const articleContent = lines.join('\n');
465
+
466
+ // 저널에 추가
467
+ const targetDate = date || undefined;
468
+ const page = await graph.appendToJournalPage(targetDate, articleContent);
469
+
470
+ return {
471
+ content: [{ type: 'text', text: `아티클 추가 완료: ${title}\n\n${JSON.stringify(page, null, 2)}` }],
472
+ };
473
+ }
474
+
475
+ case 'add_book': {
476
+ const { title, author, tags, memo, date } = AddBookSchema.parse(args);
477
+
478
+ // 책 템플릿 생성 (Logseq indent 형식) - [[문화]] 하위 구조
479
+ const lines: string[] = [];
480
+ lines.push('- [[문화]]');
481
+ lines.push('\t- #책');
482
+ lines.push(`\t\t- 제목 : ${title}`);
483
+ if (author) {
484
+ lines.push(`\t\t- 창작자 : ${author}`);
485
+ }
486
+ if (tags) {
487
+ lines.push(`\t\t- 태그 : ${tags}`);
488
+ }
489
+ if (memo) {
490
+ lines.push('\t\t- 메모 :');
491
+ const memoLines = memo.split('\n');
492
+ for (const line of memoLines) {
493
+ if (line.trim()) {
494
+ lines.push(`\t\t\t- ${line.trim()}`);
495
+ }
496
+ }
497
+ }
498
+
499
+ const bookContent = lines.join('\n');
500
+ const targetDate = date || undefined;
501
+ const page = await graph.appendToJournalPage(targetDate, bookContent);
502
+
503
+ return {
504
+ content: [{ type: 'text', text: `책 추가 완료: ${title}\n\n${JSON.stringify(page, null, 2)}` }],
505
+ };
506
+ }
507
+
508
+ case 'add_movie': {
509
+ const { title, director, memo, date } = AddMovieSchema.parse(args);
510
+
511
+ // 영화 템플릿 생성 (Logseq indent 형식) - [[문화]] 하위 구조
512
+ const lines: string[] = [];
513
+ lines.push('- [[문화]]');
514
+ lines.push('\t- #영화');
515
+ lines.push(`\t\t- 제목 : ${title}`);
516
+ if (director) {
517
+ lines.push(`\t\t- 창작자 : ${director}`);
518
+ }
519
+ if (memo) {
520
+ lines.push('\t\t- 메모 :');
521
+ const memoLines = memo.split('\n');
522
+ for (const line of memoLines) {
523
+ if (line.trim()) {
524
+ lines.push(`\t\t\t- ${line.trim()}`);
525
+ }
526
+ }
527
+ }
528
+
529
+ const movieContent = lines.join('\n');
530
+ const targetDate = date || undefined;
531
+ const page = await graph.appendToJournalPage(targetDate, movieContent);
532
+
533
+ return {
534
+ content: [{ type: 'text', text: `영화 추가 완료: ${title}\n\n${JSON.stringify(page, null, 2)}` }],
535
+ };
536
+ }
537
+
538
+ case 'add_exhibition': {
539
+ const { title, venue, artist, memo, date } = AddExhibitionSchema.parse(args);
540
+
541
+ // 전시회 템플릿 생성 (Logseq indent 형식) - [[문화]] 하위 구조
542
+ const lines: string[] = [];
543
+ lines.push('- [[문화]]');
544
+ lines.push('\t- #전시회');
545
+ lines.push(`\t\t- 제목 : ${title}`);
546
+ if (venue) {
547
+ lines.push(`\t\t- 장소 : ${venue}`);
548
+ }
549
+ if (artist) {
550
+ lines.push(`\t\t- 창작자 : ${artist}`);
551
+ }
552
+ if (memo) {
553
+ lines.push('\t\t- 메모 :');
554
+ const memoLines = memo.split('\n');
555
+ for (const line of memoLines) {
556
+ if (line.trim()) {
557
+ lines.push(`\t\t\t- ${line.trim()}`);
558
+ }
559
+ }
560
+ }
561
+
562
+ const exhibitionContent = lines.join('\n');
563
+ const targetDate = date || undefined;
564
+ const page = await graph.appendToJournalPage(targetDate, exhibitionContent);
565
+
566
+ return {
567
+ content: [{ type: 'text', text: `전시회 추가 완료: ${title}\n\n${JSON.stringify(page, null, 2)}` }],
568
+ };
569
+ }
570
+
571
+ default:
572
+ throw new Error(`Unknown tool: ${name}`);
573
+ }
574
+ } catch (error) {
575
+ // 보안: 에러 메시지에서 민감한 경로 정보 제거
576
+ const message = error instanceof Error ? error.message : String(error);
577
+ const sanitizedMessage = sanitizeErrorMessage(message);
578
+ return {
579
+ content: [{ type: 'text', text: `Error: ${sanitizedMessage}` }],
580
+ isError: true,
581
+ };
582
+ }
583
+ });
584
+
585
+ /**
586
+ * Sanitize error messages to prevent path disclosure
587
+ */
588
+ function sanitizeErrorMessage(message: string): string {
589
+ // Node.js 파일 시스템 에러에서 경로 제거
590
+ // 예: "ENOENT: no such file or directory, open '/full/path/to/file'"
591
+ // -> "ENOENT: no such file or directory"
592
+
593
+ // 알려진 안전한 에러 메시지는 그대로 반환
594
+ const safePatterns = [
595
+ 'Invalid page name',
596
+ 'Page already exists',
597
+ 'Page not found',
598
+ 'Access denied',
599
+ 'Invalid date format',
600
+ 'Content too large',
601
+ 'Search query too long',
602
+ 'Invalid property',
603
+ ];
604
+
605
+ for (const pattern of safePatterns) {
606
+ if (message.includes(pattern)) {
607
+ return message;
608
+ }
609
+ }
610
+
611
+ // Node.js 에러 코드만 추출 (경로 정보 제거)
612
+ const errorCodes: Record<string, string> = {
613
+ ENOENT: 'File or directory not found',
614
+ EACCES: 'Permission denied',
615
+ EEXIST: 'File already exists',
616
+ EISDIR: 'Expected file but found directory',
617
+ ENOTDIR: 'Expected directory but found file',
618
+ ENOTEMPTY: 'Directory not empty',
619
+ EPERM: 'Operation not permitted',
620
+ };
621
+
622
+ for (const [code, description] of Object.entries(errorCodes)) {
623
+ if (message.includes(code)) {
624
+ return description;
625
+ }
626
+ }
627
+
628
+ // Zod 유효성 검사 에러는 그대로 반환 (경로 정보 없음)
629
+ if (message.includes('Expected') || message.includes('Invalid')) {
630
+ return message;
631
+ }
632
+
633
+ // 알 수 없는 에러는 일반 메시지로 대체
634
+ return 'An unexpected error occurred';
635
+ }
636
+
637
+ // Resources: expose graph pages as resources
638
+ server.setRequestHandler(ListResourcesRequestSchema, async () => {
639
+ try {
640
+ const pages = await graph.listPages();
641
+ return {
642
+ resources: pages.map((page) => ({
643
+ uri: `logseq://${page.path}`,
644
+ name: page.name,
645
+ mimeType: 'text/markdown',
646
+ description: `${page.isJournal ? '[Journal] ' : ''}Tags: ${page.tags.join(', ') || 'none'}`,
647
+ })),
648
+ };
649
+ } catch (error) {
650
+ // 보안: 에러 메시지 sanitization
651
+ const message = error instanceof Error ? error.message : String(error);
652
+ throw new Error(sanitizeErrorMessage(message));
653
+ }
654
+ });
655
+
656
+ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
657
+ try {
658
+ const uri = request.params.uri;
659
+ // 보안: URI 길이 제한
660
+ if (uri.length > 600) {
661
+ throw new Error('URI too long');
662
+ }
663
+ const path = uri.replace('logseq://', '');
664
+ const page = await graph.readPage(path);
665
+
666
+ return {
667
+ contents: [
668
+ {
669
+ uri,
670
+ mimeType: 'text/markdown',
671
+ text: page.content,
672
+ },
673
+ ],
674
+ };
675
+ } catch (error) {
676
+ // 보안: 에러 메시지 sanitization
677
+ const message = error instanceof Error ? error.message : String(error);
678
+ throw new Error(sanitizeErrorMessage(message));
679
+ }
680
+ });
681
+
682
+ return server;
683
+ }
684
+
685
+ // Start server
686
+ async function main() {
687
+ const server = createMcpServer();
688
+ const transport = new StdioServerTransport();
689
+ await server.connect(transport);
690
+ console.error('Logseq MCP server started (stdio mode)');
691
+ console.error(`Graph path: ${GRAPH_PATH}`);
692
+ }
693
+
694
+ main().catch((error) => {
695
+ console.error('Server error:', error);
696
+ process.exit(1);
697
+ });