link-mcp 1.0.0__tar.gz

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.
@@ -0,0 +1,48 @@
1
+ # Personal data — never commit
2
+ raw/*
3
+ !raw/.gitkeep
4
+
5
+ wiki/sources/*
6
+ !wiki/sources/.gitkeep
7
+
8
+ wiki/concepts/*
9
+ !wiki/concepts/.gitkeep
10
+
11
+ wiki/entities/*
12
+ !wiki/entities/.gitkeep
13
+
14
+ wiki/comparisons/*
15
+ !wiki/comparisons/.gitkeep
16
+
17
+ wiki/explorations/*
18
+ !wiki/explorations/.gitkeep
19
+
20
+ # Keep index and log templates but ignore personal content
21
+ # (we ship empty versions in the repo)
22
+
23
+ # OS junk
24
+ .DS_Store
25
+ *.swp
26
+ *~
27
+
28
+ # Python
29
+ __pycache__/
30
+ *.pyc
31
+
32
+ # Autoresearch session files
33
+ autoresearch.jsonl
34
+ autoresearch.md
35
+ autoresearch.sh
36
+ autoresearch.checks.sh
37
+ autoresearch.ideas.md
38
+ autoresearch/
39
+
40
+ # Integration artifacts (generated by install scripts)
41
+ CLAUDE.md
42
+ AGENTS.md
43
+ GEMINI.md
44
+ GEMINI.md
45
+ .cursor/
46
+ .kiro/
47
+ .github/copilot-instructions.md
48
+ .vscode/settings.json
@@ -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,58 @@
1
+ # link-mcp
2
+
3
+ MCP server for the [Link](https://github.com/gowtham0992/link) personal knowledge wiki.
4
+
5
+ Exposes your wiki as MCP tools so any MCP-compatible agent can search, query context, and traverse the knowledge graph without reading files directly.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install link-mcp
11
+ ```
12
+
13
+ ## Setup
14
+
15
+ 1. Install Link and scaffold your wiki:
16
+ ```bash
17
+ git clone https://github.com/gowtham0992/link.git
18
+ bash link/integrations/kiro/install.sh
19
+ ```
20
+
21
+ 2. Add to your MCP client config:
22
+ ```json
23
+ {
24
+ "mcpServers": {
25
+ "link": {
26
+ "command": "python3",
27
+ "args": ["-m", "link_mcp"]
28
+ }
29
+ }
30
+ }
31
+ ```
32
+
33
+ Or with a custom wiki path:
34
+ ```json
35
+ {
36
+ "mcpServers": {
37
+ "link": {
38
+ "command": "python3",
39
+ "args": ["-m", "link_mcp", "--wiki", "/path/to/wiki"]
40
+ }
41
+ }
42
+ }
43
+ ```
44
+
45
+ ## Tools
46
+
47
+ | Tool | Description |
48
+ |------|-------------|
49
+ | `search_wiki` | Ranked search by title, alias, tag, fulltext |
50
+ | `get_context` | Topic + full graph neighborhood in one call |
51
+ | `get_pages` | List all pages with metadata |
52
+ | `get_backlinks` | Inbound + forward links for a page |
53
+ | `get_graph` | All nodes + edges |
54
+ | `rebuild_backlinks` | Rebuild the link index |
55
+
56
+ ## Wiki location
57
+
58
+ By default uses `~/link/wiki/`. Override with `--wiki /path/to/wiki`.
@@ -0,0 +1,2 @@
1
+ """Link MCP Server — personal knowledge wiki as MCP tools."""
2
+ __version__ = "1.0.0"
@@ -0,0 +1,5 @@
1
+ """Entry point: python -m link_mcp"""
2
+ from link_mcp.server import mcp
3
+
4
+ if __name__ == "__main__":
5
+ mcp.run(transport="stdio")
@@ -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,33 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "link-mcp"
7
+ version = "1.0.0"
8
+ description = "MCP server for the Link personal knowledge wiki — search, context, and graph traversal"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.10"
12
+ dependencies = ["mcp>=1.0.0"]
13
+ keywords = ["mcp", "knowledge-base", "wiki", "llm", "ai"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
23
+ ]
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/gowtham0992/link"
27
+ Repository = "https://github.com/gowtham0992/link"
28
+
29
+ [project.scripts]
30
+ link-mcp = "link_mcp.server:main"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["link_mcp"]
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "io.github.gowtham0992/link",
3
+ "description": "Personal knowledge wiki as MCP tools. Search, query context, and traverse the knowledge graph of your Link wiki. Tools: search_wiki, get_context, get_pages, get_backlinks, get_graph, rebuild_backlinks.",
4
+ "repository": {
5
+ "url": "https://github.com/gowtham0992/link",
6
+ "source": "github"
7
+ },
8
+ "version": "1.0.0",
9
+ "packages": [
10
+ {
11
+ "registryType": "pypi",
12
+ "identifier": "link-mcp",
13
+ "version": "1.0.0",
14
+ "transport": {
15
+ "type": "stdio"
16
+ }
17
+ }
18
+ ]
19
+ }