til-server 0.1.0__py3-none-any.whl

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.
til_server/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ # TIL(Today I Learned) 지식 관리 MCP 서버
2
+ # MCP(Model Context Protocol)를 통해 Claude가 TIL 데이터를 관리할 수 있게 해주는 서버
til_server/db.py ADDED
@@ -0,0 +1,398 @@
1
+ """
2
+ db.py - SQLite 데이터베이스 관리 모듈
3
+
4
+ MCP 원리 설명:
5
+ MCP 서버는 데이터를 영속적으로 저장해야 한다.
6
+ 이 모듈은 SQLite를 사용하여 TIL 데이터를 로컬 파일에 저장한다.
7
+ 동기 sqlite3를 사용하는 이유: stdio 전송 방식에서 단일 클라이언트만 접속하므로
8
+ 비동기(aiosqlite)가 불필요하며, 코드가 훨씬 간단해진다.
9
+ """
10
+ import sqlite3
11
+ from pathlib import Path
12
+
13
+ # DB 파일 경로: 프로젝트 루트의 data/til.db
14
+ DB_PATH = Path(__file__).parent.parent.parent / "data" / "til.db"
15
+
16
+
17
+ def get_connection() -> sqlite3.Connection:
18
+ """SQLite 연결을 생성하고 반환한다.
19
+
20
+ Row 팩토리를 설정하여 딕셔너리처럼 컬럼명으로 접근 가능하게 한다.
21
+ WAL 모드를 사용하여 읽기/쓰기 동시성을 높인다.
22
+ 외래 키 제약 조건을 활성화한다.
23
+ """
24
+ DB_PATH.parent.mkdir(parents=True, exist_ok=True)
25
+ conn = sqlite3.connect(str(DB_PATH))
26
+ conn.row_factory = sqlite3.Row
27
+ conn.execute("PRAGMA journal_mode=WAL")
28
+ conn.execute("PRAGMA foreign_keys=ON")
29
+ return conn
30
+
31
+
32
+ def init_db() -> None:
33
+ """데이터베이스 테이블을 초기화한다.
34
+
35
+ IF NOT EXISTS를 사용하여 이미 테이블이 있으면 건너뛴다.
36
+ 서버 시작 시 자동으로 호출된다.
37
+ """
38
+ conn = get_connection()
39
+ try:
40
+ conn.executescript("""
41
+ CREATE TABLE IF NOT EXISTS tils (
42
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
43
+ title TEXT NOT NULL,
44
+ content TEXT NOT NULL,
45
+ category TEXT DEFAULT 'general',
46
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
47
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
48
+ );
49
+
50
+ CREATE TABLE IF NOT EXISTS tags (
51
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
52
+ name TEXT UNIQUE NOT NULL
53
+ );
54
+
55
+ CREATE TABLE IF NOT EXISTS til_tags (
56
+ til_id INTEGER REFERENCES tils(id) ON DELETE CASCADE,
57
+ tag_id INTEGER REFERENCES tags(id) ON DELETE CASCADE,
58
+ PRIMARY KEY (til_id, tag_id)
59
+ );
60
+ """)
61
+ conn.commit()
62
+ finally:
63
+ conn.close()
64
+
65
+
66
+ # --- TIL CRUD 함수 ---
67
+
68
+ def create_til(title: str, content: str, category: str = "general",
69
+ tags: list[str] | None = None) -> dict:
70
+ """새 TIL 항목을 DB에 저장한다."""
71
+ conn = get_connection()
72
+ try:
73
+ cursor = conn.execute(
74
+ "INSERT INTO tils (title, content, category) VALUES (?, ?, ?)",
75
+ (title, content, category),
76
+ )
77
+ til_id = cursor.lastrowid
78
+
79
+ # 태그가 있으면 연결
80
+ if tags:
81
+ _attach_tags(conn, til_id, tags)
82
+
83
+ conn.commit()
84
+ return get_til_by_id(til_id, conn=conn)
85
+ finally:
86
+ conn.close()
87
+
88
+
89
+ def update_til(til_id: int, title: str | None = None,
90
+ content: str | None = None, category: str | None = None,
91
+ tags: list[str] | None = None) -> dict:
92
+ """기존 TIL을 수정한다. 전달된 필드만 업데이트한다."""
93
+ conn = get_connection()
94
+ try:
95
+ # 존재 확인
96
+ existing = get_til_by_id(til_id, conn=conn)
97
+ if not existing:
98
+ raise LookupError(f"TIL #{til_id}을(를) 찾을 수 없습니다")
99
+
100
+ updates = []
101
+ params = []
102
+ if title is not None:
103
+ updates.append("title = ?")
104
+ params.append(title)
105
+ if content is not None:
106
+ updates.append("content = ?")
107
+ params.append(content)
108
+ if category is not None:
109
+ updates.append("category = ?")
110
+ params.append(category)
111
+
112
+ if updates:
113
+ updates.append("updated_at = CURRENT_TIMESTAMP")
114
+ params.append(til_id)
115
+ conn.execute(
116
+ f"UPDATE tils SET {', '.join(updates)} WHERE id = ?",
117
+ params,
118
+ )
119
+
120
+ # 태그가 명시적으로 전달되면 교체
121
+ if tags is not None:
122
+ conn.execute("DELETE FROM til_tags WHERE til_id = ?", (til_id,))
123
+ _attach_tags(conn, til_id, tags)
124
+
125
+ conn.commit()
126
+ return get_til_by_id(til_id, conn=conn)
127
+ finally:
128
+ conn.close()
129
+
130
+
131
+ def delete_til(til_id: int) -> bool:
132
+ """TIL을 삭제한다. 삭제 성공 여부를 반환한다."""
133
+ conn = get_connection()
134
+ try:
135
+ cursor = conn.execute("DELETE FROM tils WHERE id = ?", (til_id,))
136
+ conn.commit()
137
+ return cursor.rowcount > 0
138
+ finally:
139
+ conn.close()
140
+
141
+
142
+ def search_tils(query: str, tag: str | None = None,
143
+ category: str | None = None) -> list[dict]:
144
+ """키워드로 TIL을 검색한다. 제목과 내용에서 LIKE 검색을 수행한다."""
145
+ conn = get_connection()
146
+ try:
147
+ sql = """
148
+ SELECT DISTINCT t.id, t.title, t.content, t.category,
149
+ t.created_at, t.updated_at
150
+ FROM tils t
151
+ LEFT JOIN til_tags tt ON t.id = tt.til_id
152
+ LEFT JOIN tags tg ON tt.tag_id = tg.id
153
+ WHERE (t.title LIKE ? OR t.content LIKE ?)
154
+ """
155
+ params: list = [f"%{query}%", f"%{query}%"]
156
+
157
+ if tag:
158
+ sql += " AND tg.name = ?"
159
+ params.append(tag)
160
+ if category:
161
+ sql += " AND t.category = ?"
162
+ params.append(category)
163
+
164
+ sql += " ORDER BY t.created_at DESC"
165
+
166
+ rows = conn.execute(sql, params).fetchall()
167
+ return [_row_to_til(row, conn) for row in rows]
168
+ finally:
169
+ conn.close()
170
+
171
+
172
+ def add_tag(til_id: int, tag: str) -> dict:
173
+ """TIL에 단일 태그를 추가한다."""
174
+ conn = get_connection()
175
+ try:
176
+ existing = get_til_by_id(til_id, conn=conn)
177
+ if not existing:
178
+ raise LookupError(f"TIL #{til_id}을(를) 찾을 수 없습니다")
179
+
180
+ _attach_tags(conn, til_id, [tag])
181
+ conn.commit()
182
+ return get_til_by_id(til_id, conn=conn)
183
+ finally:
184
+ conn.close()
185
+
186
+
187
+ def get_til_by_id(til_id: int, conn: sqlite3.Connection | None = None) -> dict | None:
188
+ """ID로 TIL을 조회한다."""
189
+ should_close = conn is None
190
+ if conn is None:
191
+ conn = get_connection()
192
+ try:
193
+ row = conn.execute("SELECT * FROM tils WHERE id = ?", (til_id,)).fetchone()
194
+ if not row:
195
+ return None
196
+ return _row_to_til(row, conn)
197
+ finally:
198
+ if should_close:
199
+ conn.close()
200
+
201
+
202
+ # --- Resource용 조회 함수 ---
203
+
204
+ def list_all_tils() -> list[dict]:
205
+ """전체 TIL 목록을 최근순으로 반환한다."""
206
+ conn = get_connection()
207
+ try:
208
+ rows = conn.execute(
209
+ "SELECT * FROM tils ORDER BY created_at DESC"
210
+ ).fetchall()
211
+ return [_row_to_til(row, conn) for row in rows]
212
+ finally:
213
+ conn.close()
214
+
215
+
216
+ def list_today_tils() -> list[dict]:
217
+ """오늘 작성된 TIL 목록을 반환한다."""
218
+ conn = get_connection()
219
+ try:
220
+ rows = conn.execute(
221
+ "SELECT * FROM tils WHERE date(created_at) = date('now') ORDER BY created_at DESC"
222
+ ).fetchall()
223
+ return [_row_to_til(row, conn) for row in rows]
224
+ finally:
225
+ conn.close()
226
+
227
+
228
+ def list_week_tils() -> list[dict]:
229
+ """이번 주(월~일) 작성된 TIL 목록을 반환한다."""
230
+ conn = get_connection()
231
+ try:
232
+ rows = conn.execute(
233
+ "SELECT * FROM tils WHERE date(created_at) >= date('now', 'weekday 0', '-6 days') ORDER BY created_at DESC"
234
+ ).fetchall()
235
+ return [_row_to_til(row, conn) for row in rows]
236
+ finally:
237
+ conn.close()
238
+
239
+
240
+ def list_all_tags() -> list[dict]:
241
+ """전체 태그 목록과 사용 횟수를 반환한다."""
242
+ conn = get_connection()
243
+ try:
244
+ rows = conn.execute("""
245
+ SELECT tg.name, COUNT(tt.til_id) as count
246
+ FROM tags tg
247
+ LEFT JOIN til_tags tt ON tg.id = tt.tag_id
248
+ GROUP BY tg.id
249
+ ORDER BY count DESC
250
+ """).fetchall()
251
+ return [{"name": row["name"], "count": row["count"]} for row in rows]
252
+ finally:
253
+ conn.close()
254
+
255
+
256
+ def list_all_categories() -> list[dict]:
257
+ """전체 카테고리 목록과 TIL 개수를 반환한다."""
258
+ conn = get_connection()
259
+ try:
260
+ rows = conn.execute("""
261
+ SELECT category, COUNT(*) as count
262
+ FROM tils
263
+ GROUP BY category
264
+ ORDER BY count DESC
265
+ """).fetchall()
266
+ return [{"category": row["category"], "count": row["count"]} for row in rows]
267
+ finally:
268
+ conn.close()
269
+
270
+
271
+ def get_stats() -> dict:
272
+ """학습 통계를 반환한다."""
273
+ conn = get_connection()
274
+ try:
275
+ total = conn.execute("SELECT COUNT(*) as cnt FROM tils").fetchone()["cnt"]
276
+ today = conn.execute(
277
+ "SELECT COUNT(*) as cnt FROM tils WHERE date(created_at) = date('now')"
278
+ ).fetchone()["cnt"]
279
+ this_week = conn.execute(
280
+ "SELECT COUNT(*) as cnt FROM tils WHERE date(created_at) >= date('now', 'weekday 0', '-6 days')"
281
+ ).fetchone()["cnt"]
282
+
283
+ # 인기 태그 상위 5개
284
+ top_tags = conn.execute("""
285
+ SELECT tg.name, COUNT(tt.til_id) as count
286
+ FROM tags tg
287
+ JOIN til_tags tt ON tg.id = tt.tag_id
288
+ GROUP BY tg.id
289
+ ORDER BY count DESC
290
+ LIMIT 5
291
+ """).fetchall()
292
+
293
+ # 카테고리별 분포
294
+ categories = conn.execute("""
295
+ SELECT category, COUNT(*) as count
296
+ FROM tils GROUP BY category ORDER BY count DESC
297
+ """).fetchall()
298
+
299
+ # 최근 7일 일별 추이
300
+ daily = conn.execute("""
301
+ SELECT date(created_at) as day, COUNT(*) as count
302
+ FROM tils
303
+ WHERE date(created_at) >= date('now', '-6 days')
304
+ GROUP BY date(created_at)
305
+ ORDER BY day
306
+ """).fetchall()
307
+
308
+ return {
309
+ "total": total,
310
+ "today": today,
311
+ "this_week": this_week,
312
+ "top_tags": [{"name": r["name"], "count": r["count"]} for r in top_tags],
313
+ "categories": [{"category": r["category"], "count": r["count"]} for r in categories],
314
+ "daily_trend": [{"date": r["day"], "count": r["count"]} for r in daily],
315
+ }
316
+ finally:
317
+ conn.close()
318
+
319
+
320
+ def get_tils_for_export(til_id: int | None = None,
321
+ date_from: str | None = None,
322
+ date_to: str | None = None) -> list[dict]:
323
+ """내보내기용 TIL 데이터를 조회한다."""
324
+ conn = get_connection()
325
+ try:
326
+ if til_id is not None:
327
+ til = get_til_by_id(til_id, conn=conn)
328
+ return [til] if til else []
329
+
330
+ sql = "SELECT * FROM tils WHERE 1=1"
331
+ params: list = []
332
+ if date_from:
333
+ sql += " AND date(created_at) >= date(?)"
334
+ params.append(date_from)
335
+ if date_to:
336
+ sql += " AND date(created_at) <= date(?)"
337
+ params.append(date_to)
338
+ sql += " ORDER BY created_at DESC"
339
+
340
+ rows = conn.execute(sql, params).fetchall()
341
+ return [_row_to_til(row, conn) for row in rows]
342
+ finally:
343
+ conn.close()
344
+
345
+
346
+ def get_tils_by_date_range(date_from: str, date_to: str) -> list[dict]:
347
+ """특정 기간의 TIL을 조회한다. Prompt에서 사용."""
348
+ conn = get_connection()
349
+ try:
350
+ rows = conn.execute(
351
+ "SELECT * FROM tils WHERE date(created_at) BETWEEN date(?) AND date(?) ORDER BY created_at DESC",
352
+ (date_from, date_to),
353
+ ).fetchall()
354
+ return [_row_to_til(row, conn) for row in rows]
355
+ finally:
356
+ conn.close()
357
+
358
+
359
+ # --- 내부 헬퍼 함수 ---
360
+
361
+ def _attach_tags(conn: sqlite3.Connection, til_id: int, tags: list[str]) -> None:
362
+ """TIL에 태그를 연결한다. 태그가 없으면 새로 생성한다."""
363
+ for tag_name in tags:
364
+ tag_name = tag_name.strip().lower()
365
+ if not tag_name:
366
+ continue
367
+ # INSERT OR IGNORE: 이미 있는 태그면 무시
368
+ conn.execute("INSERT OR IGNORE INTO tags (name) VALUES (?)", (tag_name,))
369
+ tag_row = conn.execute("SELECT id FROM tags WHERE name = ?", (tag_name,)).fetchone()
370
+ # 중복 연결 방지
371
+ conn.execute(
372
+ "INSERT OR IGNORE INTO til_tags (til_id, tag_id) VALUES (?, ?)",
373
+ (til_id, tag_row["id"]),
374
+ )
375
+
376
+
377
+ def _get_tags_for_til(til_id: int, conn: sqlite3.Connection) -> list[str]:
378
+ """특정 TIL에 연결된 태그 이름 목록을 반환한다."""
379
+ rows = conn.execute("""
380
+ SELECT tg.name FROM tags tg
381
+ JOIN til_tags tt ON tg.id = tt.tag_id
382
+ WHERE tt.til_id = ?
383
+ ORDER BY tg.name
384
+ """, (til_id,)).fetchall()
385
+ return [row["name"] for row in rows]
386
+
387
+
388
+ def _row_to_til(row: sqlite3.Row, conn: sqlite3.Connection) -> dict:
389
+ """sqlite3.Row를 태그를 포함한 딕셔너리로 변환한다."""
390
+ return {
391
+ "id": row["id"],
392
+ "title": row["title"],
393
+ "content": row["content"],
394
+ "category": row["category"],
395
+ "tags": _get_tags_for_til(row["id"], conn),
396
+ "created_at": row["created_at"],
397
+ "updated_at": row["updated_at"],
398
+ }