-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.py
More file actions
443 lines (328 loc) · 12.6 KB
/
Copy pathserver.py
File metadata and controls
443 lines (328 loc) · 12.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
"""DocsHaven — local knowledge base for AI agents with SQLite FTS5 search."""
import logging
import threading
from pathlib import Path
from typing import Any
from mcp.server.fastmcp import FastMCP
from conflicts import ConflictDetector
from import_guard import check_imports
from storage import Storage
from sync import Syncer, get_username
from uri import URIRouter
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("docs-haven")
_ERR_INVALID_PATH = "Invalid file path"
mcp = FastMCP("docs-haven")
def _unwrap(result: Any) -> dict:
"""Unwrap a Result at the MCP boundary. Returns value or error dict."""
if not hasattr(result, "is_err"):
return result if isinstance(result, dict) else {"value": result}
if result.is_err: # type: ignore[union-attr]
return {"error": result.error, "code": getattr(result, "code", None)} # type: ignore[union-attr]
return result.value # type: ignore[union-attr]
def _is_unsafe_path(path: str) -> bool:
"""Check if a path contains traversal or injection attempts."""
return ".." in path or path.startswith("/") or "\x00" in path or "\\" in path or "%2e" in path.lower() or "%2f" in path.lower()
# Thread-safe singleton: double-checked locking pattern.
# First check avoids lock contention on hot path.
# Second check inside lock prevents double-creation.
_storage: Storage | None = None
_storage_lock = threading.Lock()
_sync_dir = Path.home() / ".docshaven-sync"
def _get_storage() -> Storage:
global _storage
if _storage is None:
with _storage_lock:
if _storage is None:
_storage = Storage.default()
return _storage
def _get_syncer() -> Syncer:
return Syncer(_sync_dir)
def _get_router() -> URIRouter:
return URIRouter(_get_storage())
def _get_detector() -> ConflictDetector:
return ConflictDetector(_get_storage())
# ── Core Search & Retrieval ────────────────────────────────────────────────
@mcp.tool()
async def kb_search(
query: str,
collections: list[str] | None = None,
limit: int = 10,
min_score: float = 0.0,
*,
explain: bool = False,
) -> list[dict] | dict:
"""Search knowledge base using BM25 full-text search.
Args:
query: Search query (keywords, phrase, or natural language)
collections: Filter to specific collections (optional)
limit: Max results (default: 10)
min_score: Minimum relevance score (default: 0)
explain: Include scoring breakdown in results (default: false)
"""
storage = _get_storage()
if collections and len(collections) > 100:
collections = collections[:100]
result = storage.search(query, collections, limit, explain=explain, min_score=min_score)
return _unwrap(result)
@mcp.tool()
async def kb_add_repo(
url: str,
tags: list[str] | None = None,
description: str | None = None,
mask: str | None = None,
) -> dict:
"""Add a GitHub repository to the knowledge base.
Clones the repo (shallow), indexes documents into FTS5.
Args:
url: GitHub repo URL
tags: Optional tags
description: Optional description
mask: File pattern (default: **/*.md). Use **/*.rst for Sphinx, **/*.py for Python.
"""
import asyncio
storage = _get_storage()
# Run blocking git clone in executor to avoid blocking event loop
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(None, storage.add_repo, url, tags, description, mask)
return _unwrap(result)
@mcp.tool()
async def kb_get(file_path: str) -> dict:
"""Get full content of a document.
Args:
file_path: Document path (e.g., 'repo/README.md')
"""
# Path traversal protection
if _is_unsafe_path(file_path):
return {"error": _ERR_INVALID_PATH}
result = _get_storage().get(file_path)
return _unwrap(result)
@mcp.tool()
async def kb_update(file_path: str, content: str, title: str | None = None) -> dict:
"""Update an existing document's content.
Args:
file_path: Document path to update
content: New content for the document
title: Optional new title
"""
if _is_unsafe_path(file_path):
return {"error": _ERR_INVALID_PATH}
if len(content) > 10_000_000: # 10MB limit
return {"error": "Content too large (max 10MB)"}
storage = _get_storage()
result = storage.update_document(file_path, content, title)
return _unwrap(result)
@mcp.tool()
async def kb_delete(file_path: str, collection: str | None = None) -> dict:
"""Delete a document from the knowledge base.
Args:
file_path: Document path to delete
collection: Optional collection scope (prevents cross-collection deletes)
"""
if _is_unsafe_path(file_path):
return {"error": _ERR_INVALID_PATH}
storage = _get_storage()
if collection:
return _unwrap(storage.delete_documents_scoped(file_path, collection))
result = storage.delete_document(file_path)
return _unwrap(result)
@mcp.tool()
async def kb_collection_rename(old_name: str, new_name: str) -> dict:
"""Rename a collection across all documents.
Args:
old_name: Current collection name
new_name: New collection name
"""
return _unwrap(_get_storage().rename_collection(old_name, new_name))
@mcp.tool()
async def kb_list_collections() -> list[dict] | dict:
"""List all knowledge base collections with document counts."""
result = _get_storage().list_collections()
return _unwrap(result)
@mcp.tool()
async def kb_check_imports(file_path: str, repo_root: str = ".") -> dict:
"""Validate that imports in a file reference real modules/packages.
Args:
file_path: Path to the file to check (e.g., 'src/app.py')
repo_root: Repository root directory (default: current dir)
"""
if _is_unsafe_path(file_path):
return {"error": _ERR_INVALID_PATH}
if _is_unsafe_path(repo_root):
return {"error": "Invalid repo root path"}
return check_imports(file_path, repo_root, _get_storage())
@mcp.tool()
async def kb_stats() -> dict:
"""Get knowledge base statistics."""
result = _get_storage().stats()
return _unwrap(result)
# ── URI Routing Tools ──────────────────────────────────────────────────────
@mcp.tool()
async def kb_uri_resolve(uri: str) -> dict:
"""Resolve a URI to its collection and metadata.
URI format: domain://path/to/doc
Domains: core, ref, guide, lib, src, test, note
Example: kb_uri_resolve("core://fastapi/dependencies")
"""
return _get_router().resolve(uri)
@mcp.tool()
async def kb_uri_search(uri: str, limit: int = 10) -> list[dict]:
"""Search within a URI scope.
Example: kb_uri_search("core://fastapi")
"""
return _get_router().search_by_uri(uri, limit)
@mcp.tool()
async def kb_uri_list(domain: str) -> list[dict]:
"""List all URIs in a domain.
Example: kb_uri_list("core")
"""
return _get_router().list_by_domain(domain)
@mcp.tool()
async def kb_uri_domains() -> dict:
"""List all domains with their collection counts."""
return _get_router().list_all_domains()
# ── Git Sync Tools ─────────────────────────────────────────────────────────
@mcp.tool()
async def kb_sync_export(created_by: str | None = None) -> dict:
"""Export knowledge base as compressed chunk for sync.
Creates a compressed JSONL chunk in .docshaven-sync/chunks/.
Each export is a NEW chunk (no merge conflicts).
"""
syncer = _get_syncer()
storage = _get_storage()
result = storage.list_collections()
if result.is_err: # type: ignore[union-attr]
return {"error": result.error} # type: ignore[union-attr]
collections_data = {c["name"]: [c] for c in result.value if c.get("name")} # type: ignore[union-attr]
return syncer.export(collections_data, created_by or get_username())
@mcp.tool()
async def kb_sync_import() -> dict:
"""Import compressed chunks from sync directory."""
return _get_syncer().import_chunks(_get_storage())
@mcp.tool()
async def kb_sync_status() -> dict:
"""Get sync status - chunks, manifest size, pending imports."""
return _get_syncer().status()
# ── Conflict Surfacing Tools ───────────────────────────────────────────────
@mcp.tool()
async def kb_conflict_check(
title: str,
content: str,
collections: list[str] | None = None,
) -> dict:
"""Check for conflicts before adding a new document.
Searches for similar documents and returns candidates for review.
Args:
title: Document title
content: Document content
collections: Optional collection filter
"""
if len(content) > 1_000_000: # 1MB — conflict check only needs preview
content = content[:1_000_000]
detector = _get_detector()
result = detector.detect(title, content, collections)
return result.to_dict()
@mcp.tool()
async def kb_conflict_judge(
new_id: str,
candidate_id: str,
judgment: str,
) -> dict:
"""Record a judgment on a conflict candidate.
Args:
new_id: ID of the new document
candidate_id: ID of the conflicting document
judgment: 'supersedes', 'conflicts_with', or 'unrelated'
"""
return _unwrap(_get_detector().judge(new_id, candidate_id, judgment))
@mcp.tool()
async def kb_conflict_details(new_id: str) -> dict:
"""Get details about a conflict for resolution.
Args:
new_id: ID of the new document
"""
return _get_detector().get_conflict_details(new_id)
@mcp.tool()
async def kb_conflict_suggest(new_id: str) -> dict:
"""Suggest a resolution strategy for a conflict.
Args:
new_id: ID of the new document
"""
return _get_detector().suggest_resolution(new_id)
@mcp.tool()
async def kb_template_list() -> list[dict]:
"""List all available collection templates."""
from templates import list_templates
return list_templates()
@mcp.tool()
async def kb_template_apply(
template_name: str,
repo_url: str | None = None,
) -> dict:
"""Apply a collection template.
Args:
template_name: Template name (e.g., 'python-docs', 'api-docs', 'wiki')
repo_url: Optional repository URL to clone and index
"""
from templates import apply_template
return apply_template(_get_storage(), template_name, repo_url)
# ── Context Attachments ────────────────────────────────────────────────────
@mcp.tool()
async def kb_context_add(
collection: str,
path: str,
summary: str,
) -> dict:
"""Add a context attachment (human-written summary) to a collection.
Args:
collection: Collection name
path: Context path (e.g., 'overview', 'quickstart')
summary: Human-written summary text
"""
return _unwrap(_get_storage().add_context(collection, path, summary))
@mcp.tool()
async def kb_context_list(
collection: str | None = None,
) -> list[dict] | dict:
"""List context attachments.
Args:
collection: Filter by collection (optional, lists all if omitted)
"""
storage = _get_storage()
if collection:
return _unwrap(storage.get_context(collection))
return _unwrap(storage.list_contexts())
@mcp.tool()
async def kb_context_rm(
collection: str,
path: str | None = None,
) -> dict:
"""Remove context attachment(s).
Args:
collection: Collection name
path: Specific path to remove (optional, removes all in collection if omitted)
"""
return _unwrap(_get_storage().remove_context(collection, path))
def _run_server() -> None:
import atexit
import sys
def _shutdown():
s = _get_storage()
if s is not None:
s.close()
logger.info("Storage connection closed.")
atexit.register(_shutdown)
_HTTP_FLAG = "--http"
if _HTTP_FLAG in sys.argv:
import uvicorn
idx = sys.argv.index(_HTTP_FLAG)
try:
port = int(sys.argv[idx + 1]) if len(sys.argv) > idx + 1 else 8000
except (ValueError, IndexError):
port = 8000
logger.info("Starting HTTP server on port %d...", port)
uvicorn.run(mcp.streamable_http_app(), host="127.0.0.1", port=port)
else:
mcp.run()
if __name__ == "__main__":
_run_server()