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 ADDED
@@ -0,0 +1,3 @@
1
+ """FastGraph-MCP: lightweight code intelligence for coding agents."""
2
+
3
+ __version__ = "0.1.0"
fastgraph/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ # mypy: disable-error-code=import-untyped
2
+ """Entry point for `python -m fastgraph`."""
3
+
4
+ from fastgraph.cli import main
5
+
6
+ if __name__ == "__main__":
7
+ raise SystemExit(main())
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)