fastgraph-mcp 0.2.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.
- fastgraph/__init__.py +3 -0
- fastgraph/__main__.py +7 -0
- fastgraph/cli.py +106 -0
- fastgraph/config.py +206 -0
- fastgraph/db.py +563 -0
- fastgraph/gitutil.py +135 -0
- fastgraph/graph.py +1918 -0
- fastgraph/index.py +965 -0
- fastgraph/parsers/__init__.py +28 -0
- fastgraph/parsers/base.py +65 -0
- fastgraph/parsers/cpp.py +134 -0
- fastgraph/parsers/frontend.py +103 -0
- fastgraph/parsers/go.py +170 -0
- fastgraph/parsers/java.py +162 -0
- fastgraph/parsers/python.py +232 -0
- fastgraph/parsers/registry.py +28 -0
- fastgraph/parsers/rust.py +104 -0
- fastgraph/parsers/typescript.py +336 -0
- fastgraph/parsers/util.py +103 -0
- fastgraph/parsers/wxml.py +55 -0
- fastgraph/search.py +202 -0
- fastgraph/server.py +257 -0
- fastgraph/tools.py +617 -0
- fastgraph_mcp-0.2.0.dist-info/METADATA +157 -0
- fastgraph_mcp-0.2.0.dist-info/RECORD +28 -0
- fastgraph_mcp-0.2.0.dist-info/WHEEL +4 -0
- fastgraph_mcp-0.2.0.dist-info/entry_points.txt +2 -0
- fastgraph_mcp-0.2.0.dist-info/licenses/LICENSE +21 -0
fastgraph/__init__.py
ADDED
fastgraph/__main__.py
ADDED
fastgraph/cli.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""CLI entrypoint: fastgraph [--root <path>] (stdio MCP server).
|
|
2
|
+
|
|
3
|
+
Without --root, the project root is auto-detected: walk up from the current
|
|
4
|
+
directory to the nearest git root (repo boundary); non-git locations fall
|
|
5
|
+
back to the current directory. MCP clients that launch the server with their
|
|
6
|
+
working directory set to the opened folder therefore index that folder -
|
|
7
|
+
open any project, no config per project.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def detect_root(start: Path) -> Path:
|
|
18
|
+
"""Nearest git root above ``start``, or ``start`` itself if none found.
|
|
19
|
+
|
|
20
|
+
``.git`` may be a directory (normal repo) or a file (gitfile in
|
|
21
|
+
submodules / worktrees), so ``.exists()`` covers both.
|
|
22
|
+
"""
|
|
23
|
+
cur = start
|
|
24
|
+
while True:
|
|
25
|
+
if (cur / ".git").exists():
|
|
26
|
+
return cur
|
|
27
|
+
parent = cur.parent
|
|
28
|
+
if parent == cur: # filesystem root: nothing found above
|
|
29
|
+
return start
|
|
30
|
+
cur = parent
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _writable(path: Path) -> bool:
|
|
34
|
+
"""True if a `.fastgraph` index dir can be created under ``path``."""
|
|
35
|
+
try:
|
|
36
|
+
probe = path / ".fastgraph_probe"
|
|
37
|
+
probe.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
probe.rmdir()
|
|
39
|
+
return True
|
|
40
|
+
except OSError:
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def resolve_root(start: Path) -> Path:
|
|
45
|
+
"""Root to index: nearest git root above ``start`` if writable.
|
|
46
|
+
|
|
47
|
+
Falls back through ancestors to the first writable dir (desktop apps
|
|
48
|
+
launch stdio servers with cwd=C:\\Windows\\System32 etc., which is not
|
|
49
|
+
writable); final fallback is the user home dir. Prints a hint to stderr
|
|
50
|
+
whenever the detected root is not `start` itself.
|
|
51
|
+
"""
|
|
52
|
+
from os import getenv
|
|
53
|
+
|
|
54
|
+
home = Path(getenv("USERPROFILE") or Path.home())
|
|
55
|
+
root = detect_root(start)
|
|
56
|
+
if not _writable(root):
|
|
57
|
+
# Desktop apps launch stdio servers with cwd=C:\Windows\System32 etc.
|
|
58
|
+
# - not writable. Prefer the user home over climbing to a system root.
|
|
59
|
+
print(
|
|
60
|
+
f"notice: {root} is not writable, falling back to {home}",
|
|
61
|
+
file=sys.stderr,
|
|
62
|
+
)
|
|
63
|
+
root = home
|
|
64
|
+
elif root != start:
|
|
65
|
+
print(f"notice: indexing git root {root} (cwd: {start})", file=sys.stderr)
|
|
66
|
+
return root
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def main(argv: list[str] | None = None) -> int:
|
|
70
|
+
from os import getenv
|
|
71
|
+
|
|
72
|
+
parser = argparse.ArgumentParser(
|
|
73
|
+
prog="fastgraph",
|
|
74
|
+
description="FastGraph-MCP stdio server (default root: auto-detected from cwd)",
|
|
75
|
+
)
|
|
76
|
+
parser.add_argument(
|
|
77
|
+
"--root",
|
|
78
|
+
type=Path,
|
|
79
|
+
default=None,
|
|
80
|
+
help="project root to index (default: $FASTGRAPH_ROOT, else nearest git root above cwd, else cwd)",
|
|
81
|
+
)
|
|
82
|
+
args = parser.parse_args(argv)
|
|
83
|
+
|
|
84
|
+
explicit = args.root or (
|
|
85
|
+
Path(getenv("FASTGRAPH_ROOT")) if getenv("FASTGRAPH_ROOT") else None
|
|
86
|
+
)
|
|
87
|
+
root = (
|
|
88
|
+
explicit.resolve()
|
|
89
|
+
if explicit is not None
|
|
90
|
+
else resolve_root(Path.cwd())
|
|
91
|
+
)
|
|
92
|
+
if not root.is_dir():
|
|
93
|
+
print(f"error: {root} is not a directory", file=sys.stderr)
|
|
94
|
+
return 1
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
from fastgraph.server import run_stdio
|
|
98
|
+
|
|
99
|
+
run_stdio(root)
|
|
100
|
+
except KeyboardInterrupt:
|
|
101
|
+
return 0
|
|
102
|
+
return 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
if __name__ == "__main__":
|
|
106
|
+
raise SystemExit(main())
|
fastgraph/config.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Project configuration and defaults."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from os import getenv
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
DEFAULT_EXCLUDES = {
|
|
12
|
+
".git",
|
|
13
|
+
".hg",
|
|
14
|
+
".svn",
|
|
15
|
+
"node_modules",
|
|
16
|
+
"__pycache__",
|
|
17
|
+
".venv",
|
|
18
|
+
"venv",
|
|
19
|
+
"env",
|
|
20
|
+
"dist",
|
|
21
|
+
"build",
|
|
22
|
+
"target",
|
|
23
|
+
".next",
|
|
24
|
+
".nuxt",
|
|
25
|
+
".svelte-kit",
|
|
26
|
+
".turbo",
|
|
27
|
+
"vendor",
|
|
28
|
+
"third_party",
|
|
29
|
+
".fastgraph",
|
|
30
|
+
"coverage",
|
|
31
|
+
".pytest_cache",
|
|
32
|
+
".mypy_cache",
|
|
33
|
+
".ruff_cache",
|
|
34
|
+
".idea",
|
|
35
|
+
".vscode",
|
|
36
|
+
"Pods",
|
|
37
|
+
".gradle",
|
|
38
|
+
".cargo",
|
|
39
|
+
".tox",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
MAX_FILE_SIZE = 2 * 1024 * 1024 # skip files larger than 2MB
|
|
43
|
+
|
|
44
|
+
RESERVED_ENTRIES = {
|
|
45
|
+
".fastgraph",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def user_home() -> Path:
|
|
50
|
+
"""Resolve the user's home dir (Windows-safe USERPROFILE check)."""
|
|
51
|
+
return Path(getenv("USERPROFILE") or Path.home()).resolve()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# Local-only ignore file inside the index dir; auto-created with a template on
|
|
55
|
+
# first index. (The project-root variant was dropped: local-only keeps the
|
|
56
|
+
# project tree clean and the rules per-developer.)
|
|
57
|
+
IGNORE_FILENAME = ".fastgraphignore"
|
|
58
|
+
|
|
59
|
+
IGNORE_TEMPLATE = """# FastGraph 忽略规则(gitignore 风格,仅本机生效)
|
|
60
|
+
# 一行一个模式:匹配目录/文件名(任意深度)或相对路径通配
|
|
61
|
+
# 下面默认项已生效;删掉某行即恢复索引该目录,按需增删。
|
|
62
|
+
#
|
|
63
|
+
# ---- 通用 ----
|
|
64
|
+
node_modules/
|
|
65
|
+
dist/
|
|
66
|
+
build/
|
|
67
|
+
out/
|
|
68
|
+
target/
|
|
69
|
+
vendor/
|
|
70
|
+
third_party/
|
|
71
|
+
*.min.js
|
|
72
|
+
*.min.css
|
|
73
|
+
*.map
|
|
74
|
+
# ---- Python ----
|
|
75
|
+
__pycache__/
|
|
76
|
+
*.pyc
|
|
77
|
+
*.egg-info/
|
|
78
|
+
.venv/
|
|
79
|
+
venv/
|
|
80
|
+
env/
|
|
81
|
+
.pytest_cache/
|
|
82
|
+
.mypy_cache/
|
|
83
|
+
.ruff_cache/
|
|
84
|
+
# ---- Node / 前端 ----
|
|
85
|
+
.next/
|
|
86
|
+
.nuxt/
|
|
87
|
+
.svelte-kit/
|
|
88
|
+
.turbo/
|
|
89
|
+
coverage/
|
|
90
|
+
.cache/
|
|
91
|
+
# ---- Java ----
|
|
92
|
+
.gradle/
|
|
93
|
+
# ---- 敏感文件(密钥/证书/凭据,避免内容搜索泄漏)----
|
|
94
|
+
*.pem
|
|
95
|
+
*.key
|
|
96
|
+
*.p12
|
|
97
|
+
*.pfx
|
|
98
|
+
*.jks
|
|
99
|
+
*secret*.json
|
|
100
|
+
*credential*.json
|
|
101
|
+
service-account*.json
|
|
102
|
+
id_rsa*
|
|
103
|
+
id_ed25519*
|
|
104
|
+
# ---- 示例:你自己项目里的第三方大库 ----
|
|
105
|
+
# towxml
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def ensure_ignore_template(index_dir: Path) -> None:
|
|
110
|
+
"""Write the commented template once; never overwrite user rules."""
|
|
111
|
+
f = index_dir / IGNORE_FILENAME
|
|
112
|
+
if not f.is_file():
|
|
113
|
+
try:
|
|
114
|
+
f.write_text(IGNORE_TEMPLATE, encoding="utf-8")
|
|
115
|
+
except OSError:
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def parse_ignore(text: str) -> list[str]:
|
|
120
|
+
"""One pattern per line; '#' comments. A pattern matches a directory/file
|
|
121
|
+
name at any depth, or a relative path, via fnmatch globbing (`towxml`,
|
|
122
|
+
`miniprogram/vendor/*`, `*.min.js`)."""
|
|
123
|
+
out: list[str] = []
|
|
124
|
+
for line in text.splitlines():
|
|
125
|
+
line = line.strip()
|
|
126
|
+
if not line or line.startswith("#"):
|
|
127
|
+
continue
|
|
128
|
+
out.append(line)
|
|
129
|
+
return out
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
_MAGIC_RE = re.compile(r"[*?\[\]]")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class IgnoreMatcher:
|
|
136
|
+
"""Precompiled `.fastgraphignore` rules.
|
|
137
|
+
|
|
138
|
+
``fnmatch`` normalizes case on every call -- on Windows through the kernel's
|
|
139
|
+
``LCMapStringEx`` -- so testing ~36 patterns against every scanned entry cost
|
|
140
|
+
hundreds of thousands of normalizations per refresh. Profiling a no-op
|
|
141
|
+
refresh of a 264-file repo put ``matches_ignore`` at ~90% of the total
|
|
142
|
+
(124k ``fnmatch`` + 256k ``normcase`` calls). Rules are therefore compiled
|
|
143
|
+
once: exact names become a set, and the glob rules of each category are fused
|
|
144
|
+
into a single alternation regex, so an entry costs a handful of C-level
|
|
145
|
+
regex matches instead of one ``fnmatch`` per rule. Semantics are unchanged
|
|
146
|
+
(same ``normcase`` case-folding, same parent-directory rule).
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
def __init__(self, patterns: list[str]):
|
|
150
|
+
names: list[str] = []
|
|
151
|
+
name_globs: list[str] = []
|
|
152
|
+
path_globs: list[str] = []
|
|
153
|
+
for pat in patterns:
|
|
154
|
+
p = os.path.normcase(pat.rstrip("/"))
|
|
155
|
+
if not p:
|
|
156
|
+
continue
|
|
157
|
+
if "/" in p or "\\" in p:
|
|
158
|
+
path_globs.append(p)
|
|
159
|
+
elif _MAGIC_RE.search(p):
|
|
160
|
+
name_globs.append(p)
|
|
161
|
+
else:
|
|
162
|
+
names.append(p)
|
|
163
|
+
self.names: set[str] = set(names)
|
|
164
|
+
# one alternation instead of N fnmatch calls (each translate() is
|
|
165
|
+
# already anchored, so joining with | preserves match semantics)
|
|
166
|
+
self.name_re = _compile_globs(name_globs)
|
|
167
|
+
self.path_re = _compile_globs(path_globs)
|
|
168
|
+
|
|
169
|
+
def __bool__(self) -> bool:
|
|
170
|
+
return bool(self.names or self.name_re or self.path_re)
|
|
171
|
+
|
|
172
|
+
def matches(self, rel: str, name: str) -> bool:
|
|
173
|
+
"""True when this entry (project-relative path + bare name) is ignored."""
|
|
174
|
+
n = os.path.normcase(name)
|
|
175
|
+
if n in self.names:
|
|
176
|
+
return True
|
|
177
|
+
name_re = self.name_re
|
|
178
|
+
path_re = self.path_re
|
|
179
|
+
if name_re is None and path_re is None:
|
|
180
|
+
return False
|
|
181
|
+
nrel = os.path.normcase(rel)
|
|
182
|
+
if name_re is not None and (name_re.match(n) or name_re.match(nrel)):
|
|
183
|
+
return True
|
|
184
|
+
if self.names or name_re is not None:
|
|
185
|
+
# a pattern without a separator also matches any parent directory
|
|
186
|
+
for seg in rel.split("/")[:-1]:
|
|
187
|
+
nseg = os.path.normcase(seg)
|
|
188
|
+
if nseg in self.names or (name_re is not None and name_re.match(nseg)):
|
|
189
|
+
return True
|
|
190
|
+
return path_re is not None and bool(path_re.match(nrel))
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _compile_globs(patterns: list[str]) -> re.Pattern | None:
|
|
194
|
+
"""Fuse glob patterns into one regex, or None when there are none."""
|
|
195
|
+
if not patterns:
|
|
196
|
+
return None
|
|
197
|
+
return re.compile("|".join(fnmatch.translate(p) for p in patterns))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def matches_ignore(patterns: list[str], rel: str, name: str) -> bool:
|
|
201
|
+
"""True when an entry (project-relative path + bare name) is ignored.
|
|
202
|
+
|
|
203
|
+
Convenience wrapper for one-off checks; callers in a loop should build an
|
|
204
|
+
:class:`IgnoreMatcher` once and reuse it.
|
|
205
|
+
"""
|
|
206
|
+
return IgnoreMatcher(patterns).matches(rel, name)
|