docgraphical 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.
- docgraph/__init__.py +13 -0
- docgraph/cli.py +61 -0
- docgraph/config.py +66 -0
- docgraph/constants.py +13 -0
- docgraph/db.py +300 -0
- docgraph/mcp_server.py +69 -0
- docgraph/parser.py +209 -0
- docgraph/scanner.py +113 -0
- docgraph/server.py +187 -0
- docgraphical/__init__.py +13 -0
- docgraphical/cli.py +61 -0
- docgraphical/config.py +112 -0
- docgraphical/constants.py +13 -0
- docgraphical/db.py +355 -0
- docgraphical/mcp_server.py +69 -0
- docgraphical/parser.py +209 -0
- docgraphical/scanner.py +118 -0
- docgraphical/server.py +219 -0
- docgraphical-1.0.0.dist-info/METADATA +303 -0
- docgraphical-1.0.0.dist-info/RECORD +24 -0
- docgraphical-1.0.0.dist-info/WHEEL +5 -0
- docgraphical-1.0.0.dist-info/entry_points.txt +4 -0
- docgraphical-1.0.0.dist-info/licenses/LICENSE +21 -0
- docgraphical-1.0.0.dist-info/top_level.txt +2 -0
docgraph/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# DocGraphical Package Entry
|
|
2
|
+
from .parser import parse_headings, extract_toc, extract_section, search_doc
|
|
3
|
+
|
|
4
|
+
search_file = search_doc
|
|
5
|
+
|
|
6
|
+
__version__ = "1.0.0"
|
|
7
|
+
__all__ = [
|
|
8
|
+
"parse_headings",
|
|
9
|
+
"extract_toc",
|
|
10
|
+
"extract_section",
|
|
11
|
+
"search_doc",
|
|
12
|
+
"search_file",
|
|
13
|
+
]
|
docgraph/cli.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""DocGraph Command Line Interface (CLI)."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from docgraph import __version__
|
|
6
|
+
from docgraph.parser import extract_toc, extract_section, search_doc
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main():
|
|
10
|
+
parser = argparse.ArgumentParser(
|
|
11
|
+
prog="docgraph",
|
|
12
|
+
description="DocGraph: Precision Markdown AST, TOC & Section Slicer for AI Agents & Developers."
|
|
13
|
+
)
|
|
14
|
+
parser.add_argument("-v", "--version", action="version", version=f"%(prog)s {__version__}")
|
|
15
|
+
|
|
16
|
+
subparsers = parser.add_subparsers(dest="command", help="Available subcommands")
|
|
17
|
+
|
|
18
|
+
# Command: toc
|
|
19
|
+
toc_parser = subparsers.add_parser("toc", help="Extract Table of Contents (TOC) with line numbers")
|
|
20
|
+
toc_parser.add_argument("file", help="Path to the Markdown file")
|
|
21
|
+
toc_parser.add_argument("--json", action="store_true", help="Output TOC as JSON format")
|
|
22
|
+
toc_parser.add_argument("--md", action="store_true", help="Output TOC as Markdown list")
|
|
23
|
+
|
|
24
|
+
# Command: section
|
|
25
|
+
sec_parser = subparsers.add_parser("section", help="Extract a specific section by heading")
|
|
26
|
+
sec_parser.add_argument("file", help="Path to the Markdown file")
|
|
27
|
+
sec_parser.add_argument("heading", help="Target heading title (e.g. 'Installation' or '## API')")
|
|
28
|
+
sec_parser.add_argument("--no-sub", action="store_true", help="Exclude subsections under this heading")
|
|
29
|
+
|
|
30
|
+
# Command: search
|
|
31
|
+
search_parser = subparsers.add_parser("search", help="Search keywords across Markdown file or folder")
|
|
32
|
+
search_parser.add_argument("path", help="Path to Markdown file or root directory")
|
|
33
|
+
search_parser.add_argument("query", help="Search keyword or term")
|
|
34
|
+
search_parser.add_argument("--limit", type=int, default=30, help="Maximum number of search results")
|
|
35
|
+
|
|
36
|
+
# Command: mcp
|
|
37
|
+
subparsers.add_parser("mcp", help="Start DocGraph Model Context Protocol (MCP) server")
|
|
38
|
+
|
|
39
|
+
args = parser.parse_args()
|
|
40
|
+
|
|
41
|
+
if not args.command:
|
|
42
|
+
parser.print_help()
|
|
43
|
+
sys.exit(0)
|
|
44
|
+
|
|
45
|
+
if args.command == "toc":
|
|
46
|
+
fmt = "json" if args.json else ("markdown" if args.md else "text")
|
|
47
|
+
print(extract_toc(args.file, format_type=fmt))
|
|
48
|
+
|
|
49
|
+
elif args.command == "section":
|
|
50
|
+
print(extract_section(args.file, args.heading, include_subsections=not args.no_sub))
|
|
51
|
+
|
|
52
|
+
elif args.command == "search":
|
|
53
|
+
print(search_doc(args.path, args.query, max_results=args.limit))
|
|
54
|
+
|
|
55
|
+
elif args.command == "mcp":
|
|
56
|
+
from docgraph.mcp_server import run_mcp
|
|
57
|
+
run_mcp()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
if __name__ == "__main__":
|
|
61
|
+
main()
|
docgraph/config.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import json
|
|
4
|
+
from typing import Dict, List, Optional
|
|
5
|
+
from .constants import CONFIG_FILE
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_default_repo_root() -> str:
|
|
9
|
+
"""Return default repository root."""
|
|
10
|
+
return os.getcwd()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_docgraph_dir(repo_path: Optional[str] = None) -> str:
|
|
14
|
+
"""Return .docgraph directory path."""
|
|
15
|
+
base = repo_path or get_default_repo_root()
|
|
16
|
+
return os.path.join(base, ".docgraph")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_default_db_path(repo_path: Optional[str] = None) -> str:
|
|
20
|
+
"""Return default SQLite database path."""
|
|
21
|
+
return os.path.join(get_docgraph_dir(repo_path), "docgraph.db")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_config() -> Dict[str, List[str]]:
|
|
25
|
+
"""Load configuration from user home directory."""
|
|
26
|
+
if os.path.exists(CONFIG_FILE):
|
|
27
|
+
try:
|
|
28
|
+
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
|
29
|
+
data = json.load(f)
|
|
30
|
+
roots = data.get("custom_roots") or []
|
|
31
|
+
excluded = data.get("excluded_paths") or []
|
|
32
|
+
return {"custom_roots": roots, "excluded_paths": excluded}
|
|
33
|
+
except Exception:
|
|
34
|
+
pass
|
|
35
|
+
return {"custom_roots": [], "excluded_paths": []}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def save_config(cfg: Dict[str, List[str]]) -> None:
|
|
39
|
+
"""Save configuration to user home directory."""
|
|
40
|
+
try:
|
|
41
|
+
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
|
42
|
+
json.dump(cfg, f, indent=2, ensure_ascii=False)
|
|
43
|
+
except Exception as e:
|
|
44
|
+
print(f"Failed to save docgraph config: {e}", file=sys.stderr)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_search_roots(extra_paths: Optional[List[str]] = None) -> List[str]:
|
|
48
|
+
"""Resolve and deduplicate all documentation search roots."""
|
|
49
|
+
roots: List[str] = []
|
|
50
|
+
if extra_paths:
|
|
51
|
+
for p in extra_paths:
|
|
52
|
+
abs_p = os.path.abspath(p)
|
|
53
|
+
if abs_p not in roots and os.path.exists(abs_p):
|
|
54
|
+
roots.append(abs_p)
|
|
55
|
+
|
|
56
|
+
cfg = load_config()
|
|
57
|
+
for p in cfg.get("custom_roots", []):
|
|
58
|
+
abs_p = os.path.abspath(p)
|
|
59
|
+
if abs_p not in roots and os.path.exists(abs_p):
|
|
60
|
+
roots.append(abs_p)
|
|
61
|
+
|
|
62
|
+
# If empty, default to current working directory
|
|
63
|
+
if not roots:
|
|
64
|
+
roots.append(os.path.abspath(os.getcwd()))
|
|
65
|
+
|
|
66
|
+
return roots
|
docgraph/constants.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Constants and default configuration for DocGraph."""
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
IGNORE_DIRS = {
|
|
5
|
+
".git", ".codegraph", "node_modules", "dist", "build", ".venv", "venv", "env",
|
|
6
|
+
"__pycache__", ".pytest_cache", ".mypy_cache", ".idea", ".vscode", "target", "vendor"
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
DOC_EXTS = (".md", ".markdown", ".mdown", ".txt")
|
|
10
|
+
|
|
11
|
+
CONFIG_FILE = os.path.expanduser("~/.docgraph_config.json")
|
|
12
|
+
DEFAULT_PORT = 5002
|
|
13
|
+
DEFAULT_HOST = "127.0.0.1"
|
docgraph/db.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""DocGraph SQLite Indexing & Graph Storage Engine.
|
|
2
|
+
Stores Markdown files, heading AST nodes, cross-document links, and FTS fulltext search
|
|
3
|
+
inside <project_root>/.docgraph/docgraph.db.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import math
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import sqlite3
|
|
13
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
14
|
+
from .constants import IGNORE_DIRS, DOC_EXTS
|
|
15
|
+
|
|
16
|
+
SCHEMA_SQL = """
|
|
17
|
+
CREATE TABLE IF NOT EXISTS files (
|
|
18
|
+
path TEXT PRIMARY KEY,
|
|
19
|
+
content_hash TEXT,
|
|
20
|
+
size INTEGER,
|
|
21
|
+
modified_at REAL,
|
|
22
|
+
node_count INTEGER
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
CREATE TABLE IF NOT EXISTS nodes (
|
|
26
|
+
id TEXT PRIMARY KEY,
|
|
27
|
+
file_path TEXT,
|
|
28
|
+
kind TEXT, -- 'file', 'heading_1', 'heading_2', 'heading_3', 'code_block'
|
|
29
|
+
name TEXT,
|
|
30
|
+
level INTEGER,
|
|
31
|
+
start_line INTEGER,
|
|
32
|
+
end_line INTEGER,
|
|
33
|
+
token_estimate INTEGER,
|
|
34
|
+
content TEXT,
|
|
35
|
+
FOREIGN KEY(file_path) REFERENCES files(path) ON DELETE CASCADE
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
CREATE TABLE IF NOT EXISTS edges (
|
|
39
|
+
id TEXT PRIMARY KEY,
|
|
40
|
+
source TEXT,
|
|
41
|
+
target TEXT,
|
|
42
|
+
kind TEXT, -- 'contains', 'parent_child', 'doc_link'
|
|
43
|
+
line INTEGER,
|
|
44
|
+
FOREIGN KEY(source) REFERENCES nodes(id) ON DELETE CASCADE
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(
|
|
48
|
+
id UNINDEXED,
|
|
49
|
+
name,
|
|
50
|
+
content,
|
|
51
|
+
tokenize = 'porter unicode61'
|
|
52
|
+
);
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def get_db_path(repo_path: str) -> str:
|
|
56
|
+
dot_dir = os.path.join(os.path.abspath(repo_path), ".docgraph")
|
|
57
|
+
os.makedirs(dot_dir, exist_ok=True)
|
|
58
|
+
return os.path.join(dot_dir, "docgraph.db")
|
|
59
|
+
|
|
60
|
+
def init_db(db_path: str) -> sqlite3.Connection:
|
|
61
|
+
conn = sqlite3.connect(db_path)
|
|
62
|
+
conn.execute("PRAGMA journal_mode=WAL;")
|
|
63
|
+
conn.execute("PRAGMA foreign_keys=ON;")
|
|
64
|
+
conn.executescript(SCHEMA_SQL)
|
|
65
|
+
conn.commit()
|
|
66
|
+
return conn
|
|
67
|
+
|
|
68
|
+
def compute_hash(content: str) -> str:
|
|
69
|
+
return hashlib.sha256(content.encode("utf-8", errors="replace")).hexdigest()
|
|
70
|
+
|
|
71
|
+
def index_repository(repo_path: str) -> Tuple[int, int, int]:
|
|
72
|
+
repo_path = os.path.abspath(repo_path)
|
|
73
|
+
db_path = get_db_path(repo_path)
|
|
74
|
+
conn = init_db(db_path)
|
|
75
|
+
cur = conn.cursor()
|
|
76
|
+
|
|
77
|
+
md_files: List[str] = []
|
|
78
|
+
for root, dirs, files in os.walk(repo_path):
|
|
79
|
+
dirs[:] = [d for d in dirs if d not in IGNORE_DIRS and not d.startswith(".")]
|
|
80
|
+
for f in files:
|
|
81
|
+
if f.lower().endswith(DOC_EXTS):
|
|
82
|
+
md_files.append(os.path.join(root, f))
|
|
83
|
+
|
|
84
|
+
cur.execute("DELETE FROM edges;")
|
|
85
|
+
cur.execute("DELETE FROM nodes;")
|
|
86
|
+
cur.execute("DELETE FROM files;")
|
|
87
|
+
try:
|
|
88
|
+
cur.execute("DELETE FROM nodes_fts;")
|
|
89
|
+
except Exception:
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
total_nodes = 0
|
|
93
|
+
total_edges = 0
|
|
94
|
+
pending_edges: List[Tuple[str, str, str, str, int]] = []
|
|
95
|
+
|
|
96
|
+
all_rel_files = set(os.path.relpath(f, repo_path).replace("\\", "/") for f in md_files)
|
|
97
|
+
all_rel_lower = {f.lower(): f for f in all_rel_files}
|
|
98
|
+
|
|
99
|
+
# Count unique basenames for cross-doc mention resolution
|
|
100
|
+
basename_counts: Dict[str, int] = {}
|
|
101
|
+
for f in all_rel_files:
|
|
102
|
+
b = os.path.basename(f)
|
|
103
|
+
basename_counts[b] = basename_counts.get(b, 0) + 1
|
|
104
|
+
unique_basenames = {b: f for b, f in [(os.path.basename(f), f) for f in all_rel_files] if basename_counts[b] == 1}
|
|
105
|
+
|
|
106
|
+
link_pattern = re.compile(r"\[([^\]]+)\]\(([^)#\s]+)(?:#[^)]*)?\)")
|
|
107
|
+
|
|
108
|
+
for file_path in md_files:
|
|
109
|
+
rel_path = os.path.relpath(file_path, repo_path).replace("\\", "/")
|
|
110
|
+
try:
|
|
111
|
+
stat = os.stat(file_path)
|
|
112
|
+
with open(file_path, "r", encoding="utf-8", errors="replace") as f:
|
|
113
|
+
content = f.read()
|
|
114
|
+
lines = content.splitlines(keepends=True)
|
|
115
|
+
except Exception:
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
c_hash = compute_hash(content)
|
|
119
|
+
cur.execute(
|
|
120
|
+
"INSERT INTO files VALUES (?, ?, ?, ?, ?)",
|
|
121
|
+
(rel_path, c_hash, stat.st_size, stat.st_mtime, 0)
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
file_node_id = f"file::{rel_path}"
|
|
125
|
+
file_tokens = math.ceil(len(content) / 3.8)
|
|
126
|
+
|
|
127
|
+
cur.execute(
|
|
128
|
+
"INSERT INTO nodes VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
129
|
+
(file_node_id, rel_path, "file", os.path.basename(rel_path), 0, 1, len(lines), file_tokens, content)
|
|
130
|
+
)
|
|
131
|
+
total_nodes += 1
|
|
132
|
+
|
|
133
|
+
heading_stack: List[Tuple[int, str]] = [(0, file_node_id)]
|
|
134
|
+
headings_in_file = 0
|
|
135
|
+
in_code_block = False
|
|
136
|
+
|
|
137
|
+
for idx, line in enumerate(lines, 1):
|
|
138
|
+
stripped = line.strip()
|
|
139
|
+
if stripped.startswith("```") or stripped.startswith("~~~"):
|
|
140
|
+
in_code_block = not in_code_block
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
if in_code_block:
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
m = re.match(r"^(#{1,6})\s+(.+)$", stripped)
|
|
147
|
+
if m:
|
|
148
|
+
level = len(m.group(1))
|
|
149
|
+
title = m.group(2).strip()
|
|
150
|
+
title = re.sub(r"\s+#+$", "", title)
|
|
151
|
+
node_id = f"heading::{rel_path}::L{idx}::{title[:30]}"
|
|
152
|
+
headings_in_file += 1
|
|
153
|
+
|
|
154
|
+
# Extract section preview snippet
|
|
155
|
+
sec_lines = [line]
|
|
156
|
+
for nxt in range(idx, min(len(lines), idx + 20)):
|
|
157
|
+
sec_lines.append(lines[nxt])
|
|
158
|
+
sec_content = "".join(sec_lines)
|
|
159
|
+
|
|
160
|
+
cur.execute(
|
|
161
|
+
"INSERT INTO nodes VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
162
|
+
(node_id, rel_path, f"heading_{level}", title, level, idx, idx, math.ceil(len(sec_content)/3.8), sec_content)
|
|
163
|
+
)
|
|
164
|
+
total_nodes += 1
|
|
165
|
+
|
|
166
|
+
while heading_stack and heading_stack[-1][0] >= level:
|
|
167
|
+
heading_stack.pop()
|
|
168
|
+
|
|
169
|
+
parent_id = heading_stack[-1][1] if heading_stack else file_node_id
|
|
170
|
+
edge_id = f"edge::{parent_id}->{node_id}"
|
|
171
|
+
pending_edges.append((edge_id, parent_id, node_id, "parent_child", idx))
|
|
172
|
+
total_edges += 1
|
|
173
|
+
|
|
174
|
+
heading_stack.append((level, node_id))
|
|
175
|
+
|
|
176
|
+
cur.execute("UPDATE files SET node_count = ? WHERE path = ?", (headings_in_file + 1, rel_path))
|
|
177
|
+
|
|
178
|
+
# 1. Comprehensive cross-doc Markdown links resolution
|
|
179
|
+
current_dir = os.path.dirname(rel_path)
|
|
180
|
+
linked_targets = set()
|
|
181
|
+
|
|
182
|
+
for lm in link_pattern.finditer(content):
|
|
183
|
+
raw_target = lm.group(2).strip().replace("\\", "/")
|
|
184
|
+
if re.match(r"^(?:https?|mailto|ftp):", raw_target):
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
candidates = [
|
|
188
|
+
os.path.normpath(os.path.join(current_dir, raw_target)).replace("\\", "/"),
|
|
189
|
+
os.path.normpath(os.path.join(current_dir, raw_target + ".md")).replace("\\", "/"),
|
|
190
|
+
os.path.normpath(os.path.join(current_dir, raw_target, "README.md")).replace("\\", "/"),
|
|
191
|
+
os.path.normpath(raw_target).replace("\\", "/"),
|
|
192
|
+
os.path.normpath(raw_target + ".md").replace("\\", "/")
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
target_match = None
|
|
196
|
+
for cand in candidates:
|
|
197
|
+
cand_l = cand.lower()
|
|
198
|
+
if cand_l in all_rel_lower and all_rel_lower[cand_l] != rel_path:
|
|
199
|
+
target_match = all_rel_lower[cand_l]
|
|
200
|
+
break
|
|
201
|
+
|
|
202
|
+
if target_match and target_match not in linked_targets:
|
|
203
|
+
linked_targets.add(target_match)
|
|
204
|
+
link_edge_id = f"link::{file_node_id}->file::{target_match}"
|
|
205
|
+
pending_edges.append((link_edge_id, file_node_id, f"file::{target_match}", "doc_link", 0))
|
|
206
|
+
total_edges += 1
|
|
207
|
+
|
|
208
|
+
# 2. Textual path and unique doc mentions (forming rich knowledge topology)
|
|
209
|
+
for other_f in all_rel_files:
|
|
210
|
+
if other_f != rel_path and other_f not in linked_targets and other_f in content:
|
|
211
|
+
linked_targets.add(other_f)
|
|
212
|
+
link_edge_id = f"path::{file_node_id}->file::{other_f}"
|
|
213
|
+
pending_edges.append((link_edge_id, file_node_id, f"file::{other_f}", "doc_link", 0))
|
|
214
|
+
total_edges += 1
|
|
215
|
+
|
|
216
|
+
for b, target_f in unique_basenames.items():
|
|
217
|
+
if target_f != rel_path and target_f not in linked_targets and len(b) > 6:
|
|
218
|
+
if b not in ["README.md", "requirements.txt", "SKILL.md"] and b in content:
|
|
219
|
+
linked_targets.add(target_f)
|
|
220
|
+
link_edge_id = f"mention::{file_node_id}->file::{target_f}"
|
|
221
|
+
pending_edges.append((link_edge_id, file_node_id, f"file::{target_f}", "doc_link", 0))
|
|
222
|
+
total_edges += 1
|
|
223
|
+
|
|
224
|
+
for e in pending_edges:
|
|
225
|
+
cur.execute("INSERT OR IGNORE INTO edges VALUES (?, ?, ?, ?, ?)", e)
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
cur.execute("INSERT INTO nodes_fts(id, name, content) SELECT id, name, content FROM nodes;")
|
|
229
|
+
except Exception:
|
|
230
|
+
pass
|
|
231
|
+
|
|
232
|
+
conn.commit()
|
|
233
|
+
conn.close()
|
|
234
|
+
return len(md_files), total_nodes, total_edges
|
|
235
|
+
|
|
236
|
+
def fetch_graph_data(repo_path: str) -> Dict[str, Any]:
|
|
237
|
+
repo_path = os.path.abspath(repo_path)
|
|
238
|
+
db_path = get_db_path(repo_path)
|
|
239
|
+
if not os.path.exists(db_path):
|
|
240
|
+
index_repository(repo_path)
|
|
241
|
+
|
|
242
|
+
conn = sqlite3.connect(db_path)
|
|
243
|
+
cur = conn.cursor()
|
|
244
|
+
|
|
245
|
+
cur.execute("SELECT id, name, kind, level, start_line, end_line, token_estimate, file_path, content FROM nodes;")
|
|
246
|
+
rows = cur.fetchall()
|
|
247
|
+
|
|
248
|
+
nodes = []
|
|
249
|
+
KIND_COLORS = {
|
|
250
|
+
"file": "#f0883e", # Document (Warm Cyber Orange)
|
|
251
|
+
"heading_1": "#58a6ff", # H1 Primary (Electric Blue)
|
|
252
|
+
"heading_2": "#3fb950", # H2 Major (Emerald Green)
|
|
253
|
+
"heading_3": "#bc8cff", # H3 Subsection (Vivid Purple)
|
|
254
|
+
"heading_4": "#ff7bba", # H4 Detail (Vibrant Rose Pink - 100% distinct from Document Orange!)
|
|
255
|
+
"heading_5": "#00d2d3", # H5 Fine (Cyan / Turquoise)
|
|
256
|
+
"heading_6": "#ffd700", # H6 Micro (Bright Gold)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
# Hierarchy-based gradual sizing: File(12) -> H1(8.5) -> H2(6.0) -> H3(4.2) -> H4(3.0) -> H5/6(2.2)
|
|
260
|
+
KIND_VALS = {
|
|
261
|
+
"file": 12.0,
|
|
262
|
+
"heading_1": 8.5,
|
|
263
|
+
"heading_2": 6.0,
|
|
264
|
+
"heading_3": 4.2,
|
|
265
|
+
"heading_4": 3.0,
|
|
266
|
+
"heading_5": 2.2,
|
|
267
|
+
"heading_6": 1.8
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
for r in rows:
|
|
271
|
+
nid, name, kind, level, start_l, end_l, tokens, fpath, content = r
|
|
272
|
+
val = KIND_VALS.get(kind, 3.0)
|
|
273
|
+
nodes.append({
|
|
274
|
+
"id": nid,
|
|
275
|
+
"name": name,
|
|
276
|
+
"kind": kind,
|
|
277
|
+
"level": level,
|
|
278
|
+
"line": start_l,
|
|
279
|
+
"file": fpath,
|
|
280
|
+
"abs_path": os.path.normpath(os.path.join(repo_path, fpath)) if not os.path.isabs(fpath) else fpath,
|
|
281
|
+
"tokens": tokens,
|
|
282
|
+
"val": val,
|
|
283
|
+
"color": KIND_COLORS.get(kind, "#8b949e"),
|
|
284
|
+
"content": content
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
cur.execute("SELECT id, source, target, kind FROM edges;")
|
|
288
|
+
edges = []
|
|
289
|
+
for r in cur.fetchall():
|
|
290
|
+
eid, src, tgt, kind = r
|
|
291
|
+
edges.append({
|
|
292
|
+
"id": eid,
|
|
293
|
+
"source": src,
|
|
294
|
+
"target": tgt,
|
|
295
|
+
"kind": kind,
|
|
296
|
+
"color": "rgba(88, 166, 255, 0.45)" if kind == "parent_child" else "rgba(0, 255, 170, 0.75)"
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
conn.close()
|
|
300
|
+
return {"nodes": nodes, "links": edges}
|
docgraph/mcp_server.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""DocGraph Native Model Context Protocol (MCP) Server.
|
|
2
|
+
Full integration with SQLite Graph Topology (.docgraph/docgraph.db), TOC Slicing, and AST Indexing.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from docgraph.parser import extract_toc, extract_section, search_doc
|
|
10
|
+
from docgraph.db import index_repository, fetch_graph_data, get_db_path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run_mcp():
|
|
14
|
+
"""Start MCP Server using official MCP SDK."""
|
|
15
|
+
try:
|
|
16
|
+
from mcp.server import MCPServer
|
|
17
|
+
except ImportError:
|
|
18
|
+
print(
|
|
19
|
+
"Error: 'mcp' SDK is required to run the MCP server.\n"
|
|
20
|
+
"Install it via: pip install mcp",
|
|
21
|
+
file=sys.stderr
|
|
22
|
+
)
|
|
23
|
+
sys.exit(1)
|
|
24
|
+
|
|
25
|
+
app = MCPServer("docgraph")
|
|
26
|
+
|
|
27
|
+
@app.tool()
|
|
28
|
+
def docgraph_toc(filePath: str, format: str = "text") -> str:
|
|
29
|
+
"""Extract Table of Contents (TOC) with line anchors from a Markdown file to save 97% context tokens."""
|
|
30
|
+
return extract_toc(filePath, format_type=format)
|
|
31
|
+
|
|
32
|
+
@app.tool()
|
|
33
|
+
def docgraph_section(filePath: str, heading: str, includeSubsections: bool = True) -> str:
|
|
34
|
+
"""Surgically extract the content of a specific heading section without reading the whole file."""
|
|
35
|
+
return extract_section(filePath, heading, include_subsections=includeSubsections)
|
|
36
|
+
|
|
37
|
+
@app.tool()
|
|
38
|
+
def docgraph_search(filePath: str, query: str, limit: int = 30) -> str:
|
|
39
|
+
"""Search across Markdown documents or folders and locate exact line numbers and matches."""
|
|
40
|
+
return search_doc(filePath, query, max_results=limit)
|
|
41
|
+
|
|
42
|
+
@app.tool()
|
|
43
|
+
def docgraph_graph(repoPath: str) -> str:
|
|
44
|
+
"""Retrieve 3D knowledge topology graph (nodes, edges, cross-document links) from .docgraph/docgraph.db."""
|
|
45
|
+
try:
|
|
46
|
+
data = fetch_graph_data(repoPath)
|
|
47
|
+
return json.dumps({
|
|
48
|
+
"nodes_count": len(data["nodes"]),
|
|
49
|
+
"links_count": len(data["links"]),
|
|
50
|
+
"nodes": data["nodes"][:50], # summary sample
|
|
51
|
+
"sample_links": data["links"][:50]
|
|
52
|
+
}, indent=2, ensure_ascii=False)
|
|
53
|
+
except Exception as e:
|
|
54
|
+
return f"Error fetching graph: {e}"
|
|
55
|
+
|
|
56
|
+
@app.tool()
|
|
57
|
+
def docgraph_index(repoPath: str) -> str:
|
|
58
|
+
"""Scan and build/refresh .docgraph/docgraph.db SQLite AST graph index for a project."""
|
|
59
|
+
try:
|
|
60
|
+
f, n, e = index_repository(repoPath)
|
|
61
|
+
return f"Successfully indexed {repoPath}: {f} files, {n} nodes, {e} edges stored in .docgraph/docgraph.db"
|
|
62
|
+
except Exception as e:
|
|
63
|
+
return f"Error indexing repository: {e}"
|
|
64
|
+
|
|
65
|
+
app.run(transport="stdio")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
if __name__ == "__main__":
|
|
69
|
+
run_mcp()
|