link-mcp 1.0.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.
- link_mcp/__init__.py +2 -0
- link_mcp/__main__.py +5 -0
- link_mcp/server.py +494 -0
- link_mcp-1.0.0.dist-info/METADATA +78 -0
- link_mcp-1.0.0.dist-info/RECORD +7 -0
- link_mcp-1.0.0.dist-info/WHEEL +4 -0
- link_mcp-1.0.0.dist-info/entry_points.txt +2 -0
link_mcp/__init__.py
ADDED
link_mcp/__main__.py
ADDED
link_mcp/server.py
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Link MCP Server
|
|
4
|
+
|
|
5
|
+
Exposes the Link personal knowledge wiki as MCP tools.
|
|
6
|
+
Agents can search, query context, and traverse the knowledge graph
|
|
7
|
+
without reading files directly.
|
|
8
|
+
|
|
9
|
+
Install:
|
|
10
|
+
pip install link-mcp
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
python -m link_mcp # uses ~/link/wiki/
|
|
14
|
+
python -m link_mcp --wiki /path/wiki # custom wiki path
|
|
15
|
+
|
|
16
|
+
Add to your MCP client config:
|
|
17
|
+
{
|
|
18
|
+
"mcpServers": {
|
|
19
|
+
"link": {
|
|
20
|
+
"command": "python3",
|
|
21
|
+
"args": ["-m", "link_mcp"]
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
import argparse, json, re, sys
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
# ── Resolve wiki directory ────────────────────────────────────────────
|
|
31
|
+
parser = argparse.ArgumentParser(add_help=False)
|
|
32
|
+
parser.add_argument("--wiki", default=None)
|
|
33
|
+
args, _ = parser.parse_known_args()
|
|
34
|
+
|
|
35
|
+
if args.wiki:
|
|
36
|
+
WIKI_DIR = Path(args.wiki).expanduser().resolve()
|
|
37
|
+
else:
|
|
38
|
+
WIKI_DIR = Path.home() / "link" / "wiki"
|
|
39
|
+
|
|
40
|
+
if not WIKI_DIR.exists():
|
|
41
|
+
print(f"[link-mcp] Wiki not found at {WIKI_DIR}. Run install.sh first.", file=sys.stderr)
|
|
42
|
+
sys.exit(1)
|
|
43
|
+
|
|
44
|
+
# ── Import MCP SDK ────────────────────────────────────────────────────
|
|
45
|
+
try:
|
|
46
|
+
from mcp.server.fastmcp import FastMCP
|
|
47
|
+
except ImportError:
|
|
48
|
+
print("[link-mcp] mcp package not found. Install with: pip install mcp", file=sys.stderr)
|
|
49
|
+
sys.exit(1)
|
|
50
|
+
|
|
51
|
+
mcp = FastMCP(
|
|
52
|
+
"link",
|
|
53
|
+
instructions=(
|
|
54
|
+
"Link is a personal knowledge wiki. Use search_wiki to find pages, "
|
|
55
|
+
"get_context to retrieve a topic with its full graph neighborhood, "
|
|
56
|
+
"and get_pages to browse all pages. Always prefer get_context over "
|
|
57
|
+
"reading files directly — it returns the primary page plus related "
|
|
58
|
+
"pages via graph traversal in one call."
|
|
59
|
+
),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# ── In-memory indexes (built on first use, invalidated by mtime) ──────
|
|
63
|
+
_cache: dict = {}
|
|
64
|
+
_cache_mtime: float = 0.0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _wiki_mtime() -> float:
|
|
68
|
+
try:
|
|
69
|
+
t = WIKI_DIR.stat().st_mtime
|
|
70
|
+
for child in WIKI_DIR.iterdir():
|
|
71
|
+
if child.is_dir():
|
|
72
|
+
t = max(t, child.stat().st_mtime)
|
|
73
|
+
for name in ("index.md", "log.md", "_backlinks.json"):
|
|
74
|
+
p = WIKI_DIR / name
|
|
75
|
+
if p.exists():
|
|
76
|
+
t = max(t, p.stat().st_mtime)
|
|
77
|
+
return t
|
|
78
|
+
except Exception:
|
|
79
|
+
return 0.0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _parse_frontmatter(text: str) -> tuple[dict, str]:
|
|
83
|
+
if not text.startswith("---"):
|
|
84
|
+
return {}, text
|
|
85
|
+
end = text.find("---", 3)
|
|
86
|
+
if end == -1:
|
|
87
|
+
return {}, text
|
|
88
|
+
meta: dict = {}
|
|
89
|
+
for line in text[3:end].strip().splitlines():
|
|
90
|
+
if ":" in line:
|
|
91
|
+
k, v = line.split(":", 1)
|
|
92
|
+
v = v.strip().strip('"').strip("'")
|
|
93
|
+
if v.startswith("[") and v.endswith("]"):
|
|
94
|
+
v = [x.strip().strip('"').strip("'") for x in v[1:-1].split(",")]
|
|
95
|
+
meta[k.strip()] = v
|
|
96
|
+
return meta, text[end + 3:].strip()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _build_cache() -> dict:
|
|
100
|
+
global _cache, _cache_mtime
|
|
101
|
+
mtime = _wiki_mtime()
|
|
102
|
+
if _cache and mtime == _cache_mtime:
|
|
103
|
+
return _cache
|
|
104
|
+
|
|
105
|
+
pages = []
|
|
106
|
+
page_index: dict[str, Path] = {}
|
|
107
|
+
fulltext: dict[str, str] = {}
|
|
108
|
+
snippet_index: dict[str, str] = {}
|
|
109
|
+
token_index: dict[str, set] = {}
|
|
110
|
+
meta_token_index: dict[str, set] = {}
|
|
111
|
+
|
|
112
|
+
for md in sorted(WIKI_DIR.rglob("*.md")):
|
|
113
|
+
if md.name.startswith("."):
|
|
114
|
+
continue
|
|
115
|
+
rel = md.relative_to(WIKI_DIR)
|
|
116
|
+
text = md.read_text(encoding="utf-8", errors="replace")
|
|
117
|
+
meta, body = _parse_frontmatter(text)
|
|
118
|
+
|
|
119
|
+
title = meta.get("title", "")
|
|
120
|
+
if not title:
|
|
121
|
+
m = re.search(r"^#\s+(.+)", body, re.MULTILINE)
|
|
122
|
+
title = m.group(1) if m else md.stem
|
|
123
|
+
|
|
124
|
+
tldr = ""
|
|
125
|
+
tldr_m = re.search(r">\s*\*\*TLDR:\*\*\s*(.+)", body)
|
|
126
|
+
if tldr_m:
|
|
127
|
+
tldr = tldr_m.group(1).strip()
|
|
128
|
+
|
|
129
|
+
aliases_raw = meta.get("aliases", [])
|
|
130
|
+
if isinstance(aliases_raw, str):
|
|
131
|
+
aliases_raw = [a.strip() for a in aliases_raw.split(",") if a.strip()]
|
|
132
|
+
aliases = [a.lower() for a in aliases_raw]
|
|
133
|
+
|
|
134
|
+
tags_raw = meta.get("tags", [])
|
|
135
|
+
if isinstance(tags_raw, str):
|
|
136
|
+
tags_raw = [t.strip() for t in tags_raw.split(",") if t.strip()]
|
|
137
|
+
|
|
138
|
+
cat = rel.parts[0] if len(rel.parts) > 1 else "root"
|
|
139
|
+
stem = md.stem.lower()
|
|
140
|
+
|
|
141
|
+
page = {
|
|
142
|
+
"name": md.stem,
|
|
143
|
+
"title": title,
|
|
144
|
+
"category": cat,
|
|
145
|
+
"type": meta.get("type", ""),
|
|
146
|
+
"tags": tags_raw,
|
|
147
|
+
"aliases": aliases,
|
|
148
|
+
"maturity": meta.get("maturity", ""),
|
|
149
|
+
"source_count": meta.get("source_count", ""),
|
|
150
|
+
"tldr": tldr,
|
|
151
|
+
"date_updated": meta.get("date_updated", ""),
|
|
152
|
+
"date_published": meta.get("date_published", ""),
|
|
153
|
+
}
|
|
154
|
+
pages.append(page)
|
|
155
|
+
page_index[stem] = md
|
|
156
|
+
for alias in aliases:
|
|
157
|
+
if alias not in page_index:
|
|
158
|
+
page_index[alias] = md
|
|
159
|
+
|
|
160
|
+
text_lower = text.lower()
|
|
161
|
+
fulltext[stem] = text_lower
|
|
162
|
+
body_lines = [l.strip() for l in body.split("\n") if l.strip() and not l.startswith("#") and not l.startswith(">")]
|
|
163
|
+
snippet_index[stem] = body_lines[0][:200] if body_lines else ""
|
|
164
|
+
|
|
165
|
+
for token in re.split(r"\W+", text_lower):
|
|
166
|
+
if len(token) >= 3:
|
|
167
|
+
token_index.setdefault(token, set()).add(stem)
|
|
168
|
+
|
|
169
|
+
meta_tokens: set = set()
|
|
170
|
+
for word in re.split(r"\W+", title.lower()):
|
|
171
|
+
if len(word) >= 3:
|
|
172
|
+
meta_tokens.add(word)
|
|
173
|
+
for alias in aliases:
|
|
174
|
+
for word in re.split(r"\W+", alias):
|
|
175
|
+
if len(word) >= 3:
|
|
176
|
+
meta_tokens.add(word)
|
|
177
|
+
for tag in tags_raw:
|
|
178
|
+
for word in re.split(r"\W+", str(tag).lower()):
|
|
179
|
+
if len(word) >= 3:
|
|
180
|
+
meta_tokens.add(word)
|
|
181
|
+
if tldr:
|
|
182
|
+
for word in re.split(r"\W+", tldr.lower()):
|
|
183
|
+
if len(word) >= 3:
|
|
184
|
+
meta_tokens.add(word)
|
|
185
|
+
for token in meta_tokens:
|
|
186
|
+
meta_token_index.setdefault(token, set()).add(stem)
|
|
187
|
+
|
|
188
|
+
page_map = {p["name"].lower(): p for p in pages}
|
|
189
|
+
|
|
190
|
+
_cache = {
|
|
191
|
+
"pages": pages,
|
|
192
|
+
"page_index": page_index,
|
|
193
|
+
"fulltext": fulltext,
|
|
194
|
+
"snippet_index": snippet_index,
|
|
195
|
+
"token_index": token_index,
|
|
196
|
+
"meta_token_index": meta_token_index,
|
|
197
|
+
"page_map": page_map,
|
|
198
|
+
}
|
|
199
|
+
_cache_mtime = mtime
|
|
200
|
+
return _cache
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _search(q: str, limit: int = 20) -> list[dict]:
|
|
204
|
+
q_lower = q.lower()
|
|
205
|
+
c = _build_cache()
|
|
206
|
+
pages = c["pages"]
|
|
207
|
+
page_map = c["page_map"]
|
|
208
|
+
token_index = c["token_index"]
|
|
209
|
+
meta_token_index = c["meta_token_index"]
|
|
210
|
+
fulltext = c["fulltext"]
|
|
211
|
+
snippet_index = c["snippet_index"]
|
|
212
|
+
|
|
213
|
+
is_single = bool(re.match(r"^\w+$", q_lower))
|
|
214
|
+
if is_single and q_lower in token_index:
|
|
215
|
+
candidates = token_index[q_lower] | meta_token_index.get(q_lower, set())
|
|
216
|
+
else:
|
|
217
|
+
candidates = {p["name"].lower() for p in pages}
|
|
218
|
+
|
|
219
|
+
scored = []
|
|
220
|
+
for stem in candidates:
|
|
221
|
+
p = page_map.get(stem)
|
|
222
|
+
if not p:
|
|
223
|
+
continue
|
|
224
|
+
score = 0
|
|
225
|
+
if q_lower in p["title"].lower():
|
|
226
|
+
score += 10
|
|
227
|
+
if q_lower == stem:
|
|
228
|
+
score += 20
|
|
229
|
+
if any(q_lower in a for a in p.get("aliases", [])):
|
|
230
|
+
score += 8
|
|
231
|
+
if any(q_lower in str(t).lower() for t in p.get("tags", [])):
|
|
232
|
+
score += 5
|
|
233
|
+
if q_lower in p.get("tldr", "").lower():
|
|
234
|
+
score += 3
|
|
235
|
+
if fulltext.get(stem, "") and q_lower in fulltext[stem]:
|
|
236
|
+
score += 2
|
|
237
|
+
if score > 0:
|
|
238
|
+
scored.append((score, {**p, "score": score, "snippet": snippet_index.get(stem, "")}))
|
|
239
|
+
|
|
240
|
+
scored.sort(key=lambda x: (-x[0], x[1]["title"].lower()))
|
|
241
|
+
return [r for _, r in scored[:limit]]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _get_context(topic: str) -> dict:
|
|
245
|
+
c = _build_cache()
|
|
246
|
+
matches = _search(topic, limit=5)
|
|
247
|
+
if not matches:
|
|
248
|
+
return {"topic": topic, "found": False, "pages": []}
|
|
249
|
+
|
|
250
|
+
primary = matches[0]
|
|
251
|
+
primary_name = primary["name"].lower()
|
|
252
|
+
|
|
253
|
+
bl_path = WIKI_DIR / "_backlinks.json"
|
|
254
|
+
backlinks_data: dict = {}
|
|
255
|
+
if bl_path.exists():
|
|
256
|
+
try:
|
|
257
|
+
raw = json.loads(bl_path.read_text(encoding="utf-8"))
|
|
258
|
+
backlinks_data = raw.get("backlinks", raw)
|
|
259
|
+
except Exception:
|
|
260
|
+
pass
|
|
261
|
+
|
|
262
|
+
inbound = backlinks_data.get(primary_name, [])
|
|
263
|
+
|
|
264
|
+
forward: list[str] = []
|
|
265
|
+
path = c["page_index"].get(primary_name)
|
|
266
|
+
if path and path.exists():
|
|
267
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
268
|
+
_, body = _parse_frontmatter(text)
|
|
269
|
+
page_set = {p["name"].lower() for p in c["pages"]}
|
|
270
|
+
for m in re.finditer(r"\[\[([^\]|]+)(?:\|[^\]]*)?\]\]", body):
|
|
271
|
+
target = m.group(1).strip().lower()
|
|
272
|
+
if target in page_set and target != primary_name:
|
|
273
|
+
forward.append(target)
|
|
274
|
+
|
|
275
|
+
seen = {primary_name}
|
|
276
|
+
context_names = [primary_name]
|
|
277
|
+
for name in inbound + forward:
|
|
278
|
+
if name not in seen:
|
|
279
|
+
seen.add(name)
|
|
280
|
+
context_names.append(name)
|
|
281
|
+
|
|
282
|
+
context_pages = []
|
|
283
|
+
for name in context_names[:10]:
|
|
284
|
+
p_path = c["page_index"].get(name)
|
|
285
|
+
if not p_path or not p_path.exists():
|
|
286
|
+
continue
|
|
287
|
+
text = p_path.read_text(encoding="utf-8", errors="replace")
|
|
288
|
+
meta, body = _parse_frontmatter(text)
|
|
289
|
+
is_primary = name == primary_name
|
|
290
|
+
if is_primary:
|
|
291
|
+
content = body
|
|
292
|
+
else:
|
|
293
|
+
lines = body.split("\n")
|
|
294
|
+
summary = []
|
|
295
|
+
for line in lines[:20]:
|
|
296
|
+
summary.append(line)
|
|
297
|
+
if line.startswith("## ") and len(summary) > 3:
|
|
298
|
+
break
|
|
299
|
+
content = "\n".join(summary)
|
|
300
|
+
|
|
301
|
+
page_meta = c["page_map"].get(name, {})
|
|
302
|
+
context_pages.append({
|
|
303
|
+
"name": name,
|
|
304
|
+
"title": meta.get("title", name),
|
|
305
|
+
"type": meta.get("type", ""),
|
|
306
|
+
"is_primary": is_primary,
|
|
307
|
+
"relationship": "primary" if is_primary else ("inbound" if name in inbound else "forward"),
|
|
308
|
+
"content": content,
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
"topic": topic,
|
|
313
|
+
"found": True,
|
|
314
|
+
"primary": primary["name"],
|
|
315
|
+
"inbound_count": len(inbound),
|
|
316
|
+
"forward_count": len(forward),
|
|
317
|
+
"pages": context_pages,
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# ── MCP Tools ─────────────────────────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
@mcp.tool()
|
|
324
|
+
def search_wiki(query: str, limit: int = 20) -> str:
|
|
325
|
+
"""Search the Link wiki by title, alias, tag, and full-text content.
|
|
326
|
+
|
|
327
|
+
Returns ranked results with scores and snippets. Scoring:
|
|
328
|
+
- Exact name match: 20pts
|
|
329
|
+
- Title match: 10pts
|
|
330
|
+
- Alias match: 8pts
|
|
331
|
+
- Tag match: 5pts
|
|
332
|
+
- TLDR match: 3pts
|
|
333
|
+
- Full-text match: 2pts
|
|
334
|
+
|
|
335
|
+
Use this to find relevant pages before calling get_context.
|
|
336
|
+
"""
|
|
337
|
+
results = _search(query, limit=min(limit, 50))
|
|
338
|
+
if not results:
|
|
339
|
+
return json.dumps({"query": query, "count": 0, "results": []})
|
|
340
|
+
# Strip heavy fields for the search response
|
|
341
|
+
slim = [{k: v for k, v in r.items() if k not in ("aliases",)} for r in results]
|
|
342
|
+
return json.dumps({"query": query, "count": len(slim), "results": slim}, ensure_ascii=False)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
@mcp.tool()
|
|
346
|
+
def get_context(topic: str) -> str:
|
|
347
|
+
"""Get full context for a topic from the Link wiki.
|
|
348
|
+
|
|
349
|
+
Returns the best matching page (full content) plus all related pages
|
|
350
|
+
via graph traversal (inbound links + forward links). This is the
|
|
351
|
+
primary tool for answering questions — one call gives you everything
|
|
352
|
+
needed to synthesize an answer.
|
|
353
|
+
|
|
354
|
+
The response includes:
|
|
355
|
+
- primary: the best matching page with full markdown content
|
|
356
|
+
- inbound: pages that link TO this page
|
|
357
|
+
- forward: pages this page links TO
|
|
358
|
+
- relationship field on each page: "primary", "inbound", or "forward"
|
|
359
|
+
"""
|
|
360
|
+
result = _get_context(topic)
|
|
361
|
+
return json.dumps(result, ensure_ascii=False)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@mcp.tool()
|
|
365
|
+
def get_pages(category: str = "", page_type: str = "", maturity: str = "") -> str:
|
|
366
|
+
"""List all pages in the Link wiki with metadata.
|
|
367
|
+
|
|
368
|
+
Optional filters:
|
|
369
|
+
- category: "concepts", "entities", "sources", "comparisons", "explorations"
|
|
370
|
+
- page_type: "concept", "entity", "source", "comparison", "exploration"
|
|
371
|
+
- maturity: "seed", "growing", "mature", "established"
|
|
372
|
+
|
|
373
|
+
Returns pages with: name, title, category, type, tags, aliases, maturity,
|
|
374
|
+
source_count, tldr, date_updated. Does not include full page content.
|
|
375
|
+
"""
|
|
376
|
+
c = _build_cache()
|
|
377
|
+
pages = c["pages"]
|
|
378
|
+
if category:
|
|
379
|
+
pages = [p for p in pages if p["category"] == category]
|
|
380
|
+
if page_type:
|
|
381
|
+
pages = [p for p in pages if p["type"] == page_type]
|
|
382
|
+
if maturity:
|
|
383
|
+
pages = [p for p in pages if p["maturity"] == maturity]
|
|
384
|
+
return json.dumps({"count": len(pages), "pages": pages}, ensure_ascii=False)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
@mcp.tool()
|
|
388
|
+
def get_backlinks(page_name: str) -> str:
|
|
389
|
+
"""Get all pages that link to or from a given wiki page.
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
- inbound: pages that link TO this page (who references it)
|
|
393
|
+
- forward: pages this page links TO (what it references)
|
|
394
|
+
|
|
395
|
+
Useful for understanding a page's position in the knowledge graph.
|
|
396
|
+
"""
|
|
397
|
+
bl_path = WIKI_DIR / "_backlinks.json"
|
|
398
|
+
if not bl_path.exists():
|
|
399
|
+
return json.dumps({"error": "backlinks not built — run rebuild_backlinks first"})
|
|
400
|
+
try:
|
|
401
|
+
raw = json.loads(bl_path.read_text(encoding="utf-8"))
|
|
402
|
+
except Exception as e:
|
|
403
|
+
return json.dumps({"error": str(e)})
|
|
404
|
+
|
|
405
|
+
name = page_name.lower().replace(" ", "-")
|
|
406
|
+
backlinks = raw.get("backlinks", raw)
|
|
407
|
+
forward = raw.get("forward", {})
|
|
408
|
+
return json.dumps({
|
|
409
|
+
"page": page_name,
|
|
410
|
+
"inbound": backlinks.get(name, []),
|
|
411
|
+
"forward": forward.get(name, []),
|
|
412
|
+
}, ensure_ascii=False)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
@mcp.tool()
|
|
416
|
+
def get_graph() -> str:
|
|
417
|
+
"""Get the full knowledge graph as nodes and edges.
|
|
418
|
+
|
|
419
|
+
Returns:
|
|
420
|
+
- nodes: all wiki pages with id, title, category, type
|
|
421
|
+
- edges: all [[wikilinks]] as {source, target} pairs
|
|
422
|
+
|
|
423
|
+
Useful for understanding the overall structure of the wiki,
|
|
424
|
+
finding highly-connected pages, or detecting isolated clusters.
|
|
425
|
+
"""
|
|
426
|
+
c = _build_cache()
|
|
427
|
+
pages = c["pages"]
|
|
428
|
+
page_set = {p["name"].lower() for p in pages}
|
|
429
|
+
nodes = [{"id": p["name"], "title": p["title"], "category": p["category"], "type": p["type"]} for p in pages]
|
|
430
|
+
|
|
431
|
+
edges = []
|
|
432
|
+
wl_re = re.compile(r"\[\[([^\]|]+)(?:\|[^\]]*)?\]\]")
|
|
433
|
+
for p in pages:
|
|
434
|
+
source = p["name"]
|
|
435
|
+
path = c["page_index"].get(source.lower())
|
|
436
|
+
if not path or not path.exists():
|
|
437
|
+
continue
|
|
438
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
439
|
+
_, body = _parse_frontmatter(text)
|
|
440
|
+
for m in wl_re.finditer(body):
|
|
441
|
+
target = m.group(1).strip()
|
|
442
|
+
if target.lower() in page_set and target.lower() != source.lower():
|
|
443
|
+
edges.append({"source": source, "target": target})
|
|
444
|
+
|
|
445
|
+
return json.dumps({"nodes": nodes, "edges": edges}, ensure_ascii=False)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
@mcp.tool()
|
|
449
|
+
def rebuild_backlinks() -> str:
|
|
450
|
+
"""Rebuild the wiki's backlink index by scanning all [[wikilinks]].
|
|
451
|
+
|
|
452
|
+
Call this after ingesting new sources or running lint to ensure
|
|
453
|
+
the graph index is up to date. Updates wiki/_backlinks.json with
|
|
454
|
+
both reverse links (backlinks) and forward links.
|
|
455
|
+
"""
|
|
456
|
+
backlinks: dict[str, list] = {}
|
|
457
|
+
forward_links: dict[str, list] = {}
|
|
458
|
+
wl_re = re.compile(r"\[\[([^\]|]+)(?:\|[^\]]*)?\]\]")
|
|
459
|
+
|
|
460
|
+
for md in WIKI_DIR.rglob("*.md"):
|
|
461
|
+
if md.name.startswith("."):
|
|
462
|
+
continue
|
|
463
|
+
text = md.read_text(encoding="utf-8", errors="replace")
|
|
464
|
+
_, body = _parse_frontmatter(text)
|
|
465
|
+
source = md.stem.lower()
|
|
466
|
+
for m in wl_re.finditer(body):
|
|
467
|
+
target = m.group(1).strip().lower()
|
|
468
|
+
if target != source:
|
|
469
|
+
backlinks.setdefault(target, [])
|
|
470
|
+
if source not in backlinks[target]:
|
|
471
|
+
backlinks[target].append(source)
|
|
472
|
+
forward_links.setdefault(source, [])
|
|
473
|
+
if target not in forward_links[source]:
|
|
474
|
+
forward_links[source].append(target)
|
|
475
|
+
|
|
476
|
+
result = {"backlinks": backlinks, "forward": forward_links}
|
|
477
|
+
bl_path = WIKI_DIR / "_backlinks.json"
|
|
478
|
+
bl_path.write_text(json.dumps(result, indent=2), encoding="utf-8")
|
|
479
|
+
|
|
480
|
+
# Invalidate cache
|
|
481
|
+
global _cache, _cache_mtime
|
|
482
|
+
_cache = {}
|
|
483
|
+
_cache_mtime = 0.0
|
|
484
|
+
|
|
485
|
+
return json.dumps({"rebuilt": True, "pages_indexed": len(backlinks)})
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
# ── Entry point ───────────────────────────────────────────────────────
|
|
489
|
+
|
|
490
|
+
def main():
|
|
491
|
+
mcp.run(transport="stdio")
|
|
492
|
+
|
|
493
|
+
if __name__ == "__main__":
|
|
494
|
+
main()
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: link-mcp
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: MCP server for the Link personal knowledge wiki — search, context, and graph traversal
|
|
5
|
+
Project-URL: Homepage, https://github.com/gowtham0992/link
|
|
6
|
+
Project-URL: Repository, https://github.com/gowtham0992/link
|
|
7
|
+
License: MIT
|
|
8
|
+
Keywords: ai,knowledge-base,llm,mcp,wiki
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: mcp>=1.0.0
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# link-mcp
|
|
22
|
+
|
|
23
|
+
MCP server for the [Link](https://github.com/gowtham0992/link) personal knowledge wiki.
|
|
24
|
+
|
|
25
|
+
Exposes your wiki as MCP tools so any MCP-compatible agent can search, query context, and traverse the knowledge graph without reading files directly.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install link-mcp
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Setup
|
|
34
|
+
|
|
35
|
+
1. Install Link and scaffold your wiki:
|
|
36
|
+
```bash
|
|
37
|
+
git clone https://github.com/gowtham0992/link.git
|
|
38
|
+
bash link/integrations/kiro/install.sh
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
2. Add to your MCP client config:
|
|
42
|
+
```json
|
|
43
|
+
{
|
|
44
|
+
"mcpServers": {
|
|
45
|
+
"link": {
|
|
46
|
+
"command": "python3",
|
|
47
|
+
"args": ["-m", "link_mcp"]
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Or with a custom wiki path:
|
|
54
|
+
```json
|
|
55
|
+
{
|
|
56
|
+
"mcpServers": {
|
|
57
|
+
"link": {
|
|
58
|
+
"command": "python3",
|
|
59
|
+
"args": ["-m", "link_mcp", "--wiki", "/path/to/wiki"]
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Tools
|
|
66
|
+
|
|
67
|
+
| Tool | Description |
|
|
68
|
+
|------|-------------|
|
|
69
|
+
| `search_wiki` | Ranked search by title, alias, tag, fulltext |
|
|
70
|
+
| `get_context` | Topic + full graph neighborhood in one call |
|
|
71
|
+
| `get_pages` | List all pages with metadata |
|
|
72
|
+
| `get_backlinks` | Inbound + forward links for a page |
|
|
73
|
+
| `get_graph` | All nodes + edges |
|
|
74
|
+
| `rebuild_backlinks` | Rebuild the link index |
|
|
75
|
+
|
|
76
|
+
## Wiki location
|
|
77
|
+
|
|
78
|
+
By default uses `~/link/wiki/`. Override with `--wiki /path/to/wiki`.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
link_mcp/__init__.py,sha256=yR4J6X51ZBPGK-y8aeVOtTPDssUS-4nuDubfb8oZg5o,86
|
|
2
|
+
link_mcp/__main__.py,sha256=dMxm-RU0vYcWXnEn3P8QKLtKzTMMF0Phmfari_4S-Qw,129
|
|
3
|
+
link_mcp/server.py,sha256=Pa9ZZqMHD5ZGz46fupdTzFTLWTmyAcOGXZCXByYyZV0,16989
|
|
4
|
+
link_mcp-1.0.0.dist-info/METADATA,sha256=u84xocZW2EycuH76A7VPH87VQghtH2rUdrzxqWanCZ0,2068
|
|
5
|
+
link_mcp-1.0.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
6
|
+
link_mcp-1.0.0.dist-info/entry_points.txt,sha256=cQK105AqdfV3kwdw2_sO6qBp9msFdJEsTBbzrBBnZyY,50
|
|
7
|
+
link_mcp-1.0.0.dist-info/RECORD,,
|