scope-mcp 0.1.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.
scope/__init__.py ADDED
File without changes
scope/__main__.py ADDED
@@ -0,0 +1,17 @@
1
+ import asyncio
2
+ import sys
3
+
4
+
5
+ def main_cli():
6
+ from .server import run
7
+ project_path = "."
8
+ args = sys.argv[1:]
9
+ if "--project" in args:
10
+ i = args.index("--project")
11
+ if i + 1 < len(args):
12
+ project_path = args[i + 1]
13
+ asyncio.run(run(project_path))
14
+
15
+
16
+ if __name__ == "__main__":
17
+ main_cli()
scope/lsp_client.py ADDED
@@ -0,0 +1,168 @@
1
+ import asyncio
2
+ import json
3
+ import pathlib
4
+ import shutil
5
+ from typing import Any
6
+
7
+
8
+ class LSPError(Exception):
9
+ pass
10
+
11
+
12
+ class LSPClient:
13
+ def __init__(self, binary: str, args: list[str], root_path: str):
14
+ self._binary = binary
15
+ self._args = args
16
+ self._root = pathlib.Path(root_path)
17
+ self._proc: asyncio.subprocess.Process | None = None
18
+ self._pending: dict[int, asyncio.Future] = {}
19
+ self._seq = 0
20
+ self.capabilities: dict = {}
21
+ # path -> content hash; tracks what the LSP has open
22
+ self._open_files: dict[str, str] = {}
23
+ self._diagnostics: dict[str, list] = {}
24
+ self._diag_events: dict[str, asyncio.Event] = {}
25
+
26
+ @staticmethod
27
+ def available(binary: str) -> bool:
28
+ return shutil.which(binary) is not None
29
+
30
+ async def start(self) -> None:
31
+ self._proc = await asyncio.create_subprocess_exec(
32
+ self._binary, *self._args,
33
+ stdin=asyncio.subprocess.PIPE,
34
+ stdout=asyncio.subprocess.PIPE,
35
+ stderr=asyncio.subprocess.DEVNULL,
36
+ )
37
+ asyncio.create_task(self._read_loop())
38
+
39
+ root_uri = self._root.as_uri()
40
+ result = await self.request("initialize", {
41
+ "processId": None,
42
+ "rootUri": root_uri,
43
+ "workspaceFolders": [{"uri": root_uri, "name": self._root.name}],
44
+ "capabilities": {
45
+ "textDocument": {
46
+ "hover": {"contentFormat": ["plaintext", "markdown"]},
47
+ "references": {},
48
+ "documentSymbol": {"hierarchicalDocumentSymbolSupport": True},
49
+ "callHierarchy": {},
50
+ "typeHierarchy": {},
51
+ "implementation": {},
52
+ },
53
+ "workspace": {"symbol": {}},
54
+ },
55
+ })
56
+ self.capabilities = result.get("capabilities", {}) if result else {}
57
+ await self.notify("initialized", {})
58
+
59
+ async def request(self, method: str, params: Any, timeout: float = 30.0) -> Any:
60
+ self._seq += 1
61
+ msg_id = self._seq
62
+ loop = asyncio.get_event_loop()
63
+ fut: asyncio.Future = loop.create_future()
64
+ self._pending[msg_id] = fut
65
+ await self._send({"jsonrpc": "2.0", "id": msg_id, "method": method, "params": params})
66
+ try:
67
+ return await asyncio.wait_for(fut, timeout=timeout)
68
+ except TimeoutError:
69
+ self._pending.pop(msg_id, None)
70
+ raise LSPError(f"LSP timeout: {method}")
71
+
72
+ async def notify(self, method: str, params: Any) -> None:
73
+ await self._send({"jsonrpc": "2.0", "method": method, "params": params})
74
+
75
+ async def ensure_open(self, file_path: str, language_id: str) -> None:
76
+ """Open or refresh a file in the LSP, sending didChange only if content changed."""
77
+ path = pathlib.Path(file_path)
78
+ if not path.exists():
79
+ return
80
+ text = path.read_text(encoding="utf-8", errors="replace")
81
+ content_hash = str(hash(text))
82
+ uri = path.as_uri()
83
+
84
+ if file_path in self._open_files:
85
+ if self._open_files[file_path] == content_hash:
86
+ return
87
+ await self.notify("textDocument/didChange", {
88
+ "textDocument": {"uri": uri, "version": 2},
89
+ "contentChanges": [{"text": text}],
90
+ })
91
+ else:
92
+ await self.notify("textDocument/didOpen", {
93
+ "textDocument": {"uri": uri, "languageId": language_id, "version": 1, "text": text},
94
+ })
95
+ self._open_files[file_path] = content_hash
96
+
97
+ async def get_diagnostics(self, file_path: str, timeout: float = 5.0) -> list:
98
+ uri = pathlib.Path(file_path).as_uri()
99
+ if uri not in self._diag_events:
100
+ self._diag_events[uri] = asyncio.Event()
101
+ ev = self._diag_events[uri]
102
+ if not ev.is_set():
103
+ try:
104
+ await asyncio.wait_for(ev.wait(), timeout=timeout)
105
+ except asyncio.TimeoutError:
106
+ pass
107
+ return self._diagnostics.get(uri, [])
108
+
109
+ async def shutdown(self) -> None:
110
+ try:
111
+ await self.request("shutdown", None, timeout=5)
112
+ await self.notify("exit", None)
113
+ except Exception:
114
+ pass
115
+ if self._proc:
116
+ self._proc.terminate()
117
+ try:
118
+ await asyncio.wait_for(self._proc.wait(), timeout=3)
119
+ except Exception:
120
+ self._proc.kill()
121
+
122
+ async def _send(self, msg: dict) -> None:
123
+ if not self._proc or self._proc.stdin.is_closing():
124
+ raise LSPError("LSP process not running")
125
+ data = json.dumps(msg).encode()
126
+ header = f"Content-Length: {len(data)}\r\n\r\n".encode()
127
+ self._proc.stdin.write(header + data)
128
+ await self._proc.stdin.drain()
129
+
130
+ async def _read_loop(self) -> None:
131
+ while self._proc and not self._proc.stdout.at_eof():
132
+ try:
133
+ content_length = None
134
+ while True:
135
+ line = await self._proc.stdout.readline()
136
+ if not line or line == b"\r\n":
137
+ break
138
+ if line.lower().startswith(b"content-length:"):
139
+ content_length = int(line.split(b":")[1].strip())
140
+
141
+ if content_length is None:
142
+ continue
143
+
144
+ body = await self._proc.stdout.readexactly(content_length)
145
+ msg = json.loads(body)
146
+
147
+ msg_id = msg.get("id")
148
+ if msg_id is not None and msg_id in self._pending:
149
+ fut = self._pending.pop(msg_id)
150
+ if not fut.done():
151
+ if "error" in msg:
152
+ fut.set_exception(LSPError(msg["error"].get("message", "LSP error")))
153
+ else:
154
+ fut.set_result(msg.get("result"))
155
+ elif msg.get("method") == "textDocument/publishDiagnostics":
156
+ params = msg.get("params", {})
157
+ uri = params.get("uri", "")
158
+ self._diagnostics[uri] = params.get("diagnostics", [])
159
+ ev = self._diag_events.get(uri)
160
+ if ev:
161
+ ev.set()
162
+ except Exception:
163
+ break
164
+
165
+ for fut in self._pending.values():
166
+ if not fut.done():
167
+ fut.set_exception(LSPError("LSP process disconnected"))
168
+ self._pending.clear()
scope/lsp_registry.py ADDED
@@ -0,0 +1,58 @@
1
+ LSP_REGISTRY = {
2
+ "typescript": {
3
+ "binary": "typescript-language-server",
4
+ "args": ["--stdio"],
5
+ "install": "npm install -g typescript-language-server typescript",
6
+ "root_markers": ["tsconfig.json", "package.json"],
7
+ "extensions": {".ts", ".tsx", ".js", ".jsx"},
8
+ "language_ids": {".ts": "typescript", ".tsx": "typescriptreact", ".js": "javascript", ".jsx": "javascriptreact"},
9
+ },
10
+ "python": {
11
+ "binary": "pyright-langserver",
12
+ "args": ["--stdio"],
13
+ "install": "pip install pyright",
14
+ "root_markers": ["pyproject.toml", "setup.py", "requirements.txt"],
15
+ "extensions": {".py", ".pyi"},
16
+ "language_ids": {".py": "python", ".pyi": "python"},
17
+ },
18
+ "rust": {
19
+ "binary": "rust-analyzer",
20
+ "args": [],
21
+ "install": "rustup component add rust-analyzer",
22
+ "root_markers": ["Cargo.toml"],
23
+ "extensions": {".rs"},
24
+ "language_ids": {".rs": "rust"},
25
+ },
26
+ "go": {
27
+ "binary": "gopls",
28
+ "args": [],
29
+ "install": "go install golang.org/x/tools/gopls@latest",
30
+ "root_markers": ["go.mod"],
31
+ "extensions": {".go"},
32
+ "language_ids": {".go": "go"},
33
+ },
34
+ "c_cpp": {
35
+ "binary": "clangd",
36
+ "args": [],
37
+ "install": "apt install clangd # or: brew install llvm",
38
+ "root_markers": ["CMakeLists.txt", "compile_commands.json"],
39
+ "extensions": {".c", ".cpp", ".h", ".hpp", ".cc", ".cxx"},
40
+ "language_ids": {".c": "c", ".cpp": "cpp", ".h": "c", ".hpp": "cpp", ".cc": "cpp", ".cxx": "cpp"},
41
+ },
42
+ }
43
+
44
+ SYMBOL_KINDS = {
45
+ 1: "file", 2: "module", 3: "namespace", 4: "package", 5: "class",
46
+ 6: "method", 7: "property", 8: "field", 9: "constructor", 10: "enum",
47
+ 11: "interface", 12: "function", 13: "variable", 14: "constant",
48
+ 15: "string", 16: "number", 17: "boolean", 18: "array", 19: "object",
49
+ 20: "key", 21: "null", 22: "enum_member", 23: "struct", 24: "event",
50
+ 25: "operator", 26: "type_parameter",
51
+ }
52
+
53
+ KIND_TO_LSP = {
54
+ "file": 1, "module": 2, "namespace": 3, "package": 4, "class": 5,
55
+ "method": 6, "property": 7, "field": 8, "constructor": 9, "enum": 10,
56
+ "interface": 11, "function": 12, "variable": 13, "constant": 14,
57
+ "struct": 23, "type": 26,
58
+ }
scope/project.py ADDED
@@ -0,0 +1,134 @@
1
+ import asyncio
2
+ import pathlib
3
+ import sys
4
+ from typing import Optional
5
+
6
+ from .lsp_client import LSPClient
7
+ from .lsp_registry import LSP_REGISTRY
8
+
9
+ # Package managers we'll auto-install for; others need manual setup (e.g. apt)
10
+ _AUTO_INSTALL_PREFIXES = ("pip ", "npm ", "rustup ", "go ")
11
+
12
+
13
+ def detect_root(start: str) -> pathlib.Path:
14
+ path = pathlib.Path(start).resolve()
15
+ all_markers = {m for cfg in LSP_REGISTRY.values() for m in cfg["root_markers"]}
16
+ all_markers.add(".git")
17
+ for candidate in [path, *path.parents]:
18
+ if any((candidate / m).exists() for m in all_markers):
19
+ return candidate
20
+ return path
21
+
22
+
23
+ def detect_languages(root: pathlib.Path) -> list[str]:
24
+ langs = [
25
+ lang for lang, cfg in LSP_REGISTRY.items()
26
+ if any((root / m).exists() for m in cfg["root_markers"])
27
+ ]
28
+ return langs or ["python"]
29
+
30
+
31
+ class ProjectManager:
32
+ def __init__(self, project_path: str):
33
+ self.root = detect_root(project_path)
34
+ self.languages = detect_languages(self.root)
35
+ self._lsps: dict[str, LSPClient] = {}
36
+ self._install_locks: dict[str, asyncio.Lock] = {}
37
+
38
+ async def _try_install(self, lang: str, install_cmd: str) -> bool:
39
+ """Run the install command for a language server. Returns True if binary is now available."""
40
+ if not any(install_cmd.startswith(p) for p in _AUTO_INSTALL_PREFIXES):
41
+ print(f"[scope] Cannot auto-install {lang}. Run manually: {install_cmd}", file=sys.stderr)
42
+ return False
43
+
44
+ parts = install_cmd.split()
45
+ # Use current Python for pip installs so the binary lands in the right place
46
+ if parts[0] == "pip":
47
+ parts = [sys.executable, "-m", "pip"] + parts[1:]
48
+
49
+ print(f"[scope] Auto-installing {lang} LSP: {' '.join(parts)}", file=sys.stderr, flush=True)
50
+ try:
51
+ proc = await asyncio.create_subprocess_exec(
52
+ *parts,
53
+ stdout=asyncio.subprocess.PIPE,
54
+ stderr=asyncio.subprocess.PIPE,
55
+ )
56
+ _, stderr = await asyncio.wait_for(proc.communicate(), timeout=120)
57
+ if proc.returncode != 0:
58
+ print(f"[scope] Install failed: {stderr.decode(errors='replace').strip()}", file=sys.stderr)
59
+ return False
60
+ except asyncio.TimeoutError:
61
+ print(f"[scope] Install timed out for {lang}", file=sys.stderr)
62
+ return False
63
+ except FileNotFoundError:
64
+ print(f"[scope] Install command not found: {parts[0]}", file=sys.stderr)
65
+ return False
66
+
67
+ cfg = LSP_REGISTRY[lang]
68
+ ok = LSPClient.available(cfg["binary"])
69
+ if ok:
70
+ print(f"[scope] {lang} LSP installed successfully", file=sys.stderr, flush=True)
71
+ else:
72
+ print(f"[scope] Install ran but binary '{cfg['binary']}' still not found (PATH issue?)", file=sys.stderr)
73
+ return ok
74
+
75
+ async def get_lsp(self, lang: str) -> Optional[LSPClient]:
76
+ if lang not in self.languages:
77
+ return None
78
+ cfg = LSP_REGISTRY.get(lang)
79
+ if not cfg:
80
+ return None
81
+ if not LSPClient.available(cfg["binary"]):
82
+ # Serialize installs per language to avoid double-installing
83
+ if lang not in self._install_locks:
84
+ self._install_locks[lang] = asyncio.Lock()
85
+ async with self._install_locks[lang]:
86
+ if not LSPClient.available(cfg["binary"]):
87
+ if not await self._try_install(lang, cfg["install"]):
88
+ return None
89
+ if lang not in self._lsps:
90
+ client = LSPClient(cfg["binary"], cfg["args"], str(self.root))
91
+ await client.start()
92
+ self._lsps[lang] = client
93
+ return self._lsps[lang]
94
+
95
+ def lang_for_file(self, file_path: str) -> Optional[str]:
96
+ ext = pathlib.Path(file_path).suffix.lower()
97
+ for lang, cfg in LSP_REGISTRY.items():
98
+ if ext in cfg["extensions"]:
99
+ return lang
100
+ return None
101
+
102
+ def lang_id_for_file(self, file_path: str) -> str:
103
+ ext = pathlib.Path(file_path).suffix.lower()
104
+ lang = self.lang_for_file(file_path)
105
+ if lang:
106
+ return LSP_REGISTRY[lang]["language_ids"].get(ext, lang)
107
+ return "plaintext"
108
+
109
+ async def get_lsp_for_file(self, file_path: str) -> tuple[Optional[LSPClient], Optional[str]]:
110
+ lang = self.lang_for_file(file_path)
111
+ if not lang:
112
+ return None, None
113
+ return await self.get_lsp(lang), lang
114
+
115
+ async def get_all_lsps(self) -> dict[str, LSPClient]:
116
+ result = {}
117
+ for lang in self.languages:
118
+ lsp = await self.get_lsp(lang)
119
+ if lsp:
120
+ result[lang] = lsp
121
+ return result
122
+
123
+ def missing_lsps(self) -> list[str]:
124
+ missing = []
125
+ for lang in self.languages:
126
+ cfg = LSP_REGISTRY.get(lang, {})
127
+ if not LSPClient.available(cfg.get("binary", "")):
128
+ missing.append(f"{lang}: install with `{cfg.get('install', '?')}`")
129
+ return missing
130
+
131
+ async def shutdown(self) -> None:
132
+ for lsp in self._lsps.values():
133
+ await lsp.shutdown()
134
+ self._lsps.clear()
scope/server.py ADDED
@@ -0,0 +1,683 @@
1
+ import asyncio
2
+ import json
3
+ import pathlib
4
+ import urllib.parse
5
+ from typing import Optional
6
+
7
+ from mcp.server.fastmcp import FastMCP
8
+
9
+ from .lsp_client import LSPClient, LSPError
10
+ from .lsp_registry import LSP_REGISTRY, SYMBOL_KINDS, KIND_TO_LSP
11
+ from .project import ProjectManager
12
+
13
+ _mcp = FastMCP("scope")
14
+ _project: Optional[ProjectManager] = None
15
+
16
+
17
+ def _proj() -> ProjectManager:
18
+ assert _project is not None, "Project not initialized"
19
+ return _project
20
+
21
+
22
+ def _uri_to_path(uri: str) -> str:
23
+ path = urllib.parse.unquote(urllib.parse.urlparse(uri).path)
24
+ # Windows: /D:/path -> D:/path
25
+ if len(path) > 2 and path[0] == "/" and path[2] == ":":
26
+ path = path[1:]
27
+ return path
28
+
29
+
30
+ def _extract_hover_text(contents) -> str:
31
+ if isinstance(contents, str):
32
+ return contents
33
+ if isinstance(contents, dict):
34
+ return contents.get("value", "")
35
+ if isinstance(contents, list):
36
+ parts = []
37
+ for c in contents:
38
+ parts.append(c.get("value", c) if isinstance(c, dict) else str(c))
39
+ return "\n".join(parts)
40
+ return str(contents)
41
+
42
+
43
+ def _flatten_doc_symbols(symbols: list, depth: int = 0) -> list[dict]:
44
+ """Recursively flatten hierarchical DocumentSymbol results."""
45
+ result = []
46
+ for sym in symbols:
47
+ if not isinstance(sym, dict):
48
+ continue
49
+ entry = {
50
+ "name": sym.get("name"),
51
+ "kind": SYMBOL_KINDS.get(sym.get("kind", 0), "unknown"),
52
+ "line": sym.get("selectionRange", sym.get("range", {})).get("start", {}).get("line", 0) + 1,
53
+ }
54
+ if depth > 0:
55
+ entry["depth"] = depth
56
+ result.append(entry)
57
+ children = sym.get("children", [])
58
+ if children:
59
+ result.extend(_flatten_doc_symbols(children, depth + 1))
60
+ return result
61
+
62
+
63
+ async def _locate_symbol(
64
+ project: ProjectManager,
65
+ name: str,
66
+ file_hint: str = "",
67
+ line_hint: int = 0,
68
+ kinds: set | None = None,
69
+ ) -> Optional[tuple[LSPClient, str, str, int, int]]:
70
+ """Find a symbol by name. Returns (lsp, uri, file_path, line_0indexed, col) or None."""
71
+ lsps = await project.get_all_lsps()
72
+ candidates = []
73
+
74
+ for lang, lsp in lsps.items():
75
+ try:
76
+ symbols = await lsp.request("workspace/symbol", {"query": name}) or []
77
+ for sym in symbols:
78
+ if sym.get("name") != name:
79
+ continue
80
+ if kinds and sym.get("kind") not in kinds:
81
+ continue
82
+ loc = sym.get("location", {})
83
+ uri = loc.get("uri", "")
84
+ sym_file = _uri_to_path(uri)
85
+ sym_line = loc.get("range", {}).get("start", {}).get("line", 0)
86
+ sym_col = loc.get("range", {}).get("start", {}).get("character", 0)
87
+ candidates.append((lsp, uri, sym_file, sym_line, sym_col))
88
+ except LSPError:
89
+ pass
90
+
91
+ if not candidates:
92
+ return None
93
+ if file_hint:
94
+ for c in candidates:
95
+ if file_hint in c[2]:
96
+ return c
97
+ if line_hint:
98
+ for c in candidates:
99
+ if abs(c[3] - (line_hint - 1)) < 5:
100
+ return c
101
+ return candidates[0]
102
+
103
+
104
+ # ── Tools ──────────────────────────────────────────────────────────────────
105
+
106
+
107
+ @_mcp.tool()
108
+ async def find_symbol(name: str, kind: str = "", file: str = "") -> str:
109
+ """Find a symbol by name across the project using the language server.
110
+
111
+ name: symbol name (partial match supported)
112
+ kind: filter by kind — function|class|interface|method|variable|constant|struct|enum|module
113
+ file: filter by file path (partial match)
114
+ Returns up to 50 results with file, line, kind.
115
+ """
116
+ project = _proj()
117
+ lsps = await project.get_all_lsps()
118
+
119
+ if not lsps:
120
+ missing = project.missing_lsps()
121
+ return json.dumps({"error": "No LSP servers running", "install": missing, "results": []})
122
+
123
+ results = []
124
+ seen: set[tuple] = set()
125
+ kind_filter = KIND_TO_LSP.get(kind, -1) if kind else -1
126
+
127
+ for lang, lsp in lsps.items():
128
+ try:
129
+ symbols = await lsp.request("workspace/symbol", {"query": name}) or []
130
+ for sym in symbols:
131
+ sym_name = sym.get("name", "")
132
+ if name.lower() not in sym_name.lower():
133
+ continue
134
+ sym_kind_num = sym.get("kind", 0)
135
+ if kind_filter != -1 and sym_kind_num != kind_filter:
136
+ continue
137
+ loc = sym.get("location", {})
138
+ uri = loc.get("uri", "")
139
+ if file and file not in uri:
140
+ continue
141
+ key = (sym_name, uri, loc.get("range", {}).get("start", {}).get("line", 0))
142
+ if key in seen:
143
+ continue
144
+ seen.add(key)
145
+ results.append({
146
+ "name": sym_name,
147
+ "kind": SYMBOL_KINDS.get(sym_kind_num, "unknown"),
148
+ "file": _uri_to_path(uri),
149
+ "line": loc.get("range", {}).get("start", {}).get("line", 0) + 1,
150
+ "column": loc.get("range", {}).get("start", {}).get("character", 0),
151
+ "container": sym.get("containerName") or None,
152
+ })
153
+ except LSPError:
154
+ pass
155
+
156
+ return json.dumps({"count": len(results), "results": results[:50]})
157
+
158
+
159
+ @_mcp.tool()
160
+ async def find_references(symbol_name: str, file_path: str = "", line: int = 0) -> str:
161
+ """Find all references to a symbol across the project.
162
+
163
+ symbol_name: exact symbol name
164
+ file_path: hint to disambiguate if multiple symbols share the name
165
+ line: line number hint (1-indexed) for further disambiguation
166
+ Returns file, line, column, and the source line for each reference.
167
+ """
168
+ project = _proj()
169
+ found = await _locate_symbol(project, symbol_name, file_hint=file_path, line_hint=line)
170
+
171
+ if not found:
172
+ return json.dumps({"error": f"Symbol '{symbol_name}' not found", "results": []})
173
+
174
+ lsp, uri, sym_file, sym_line, sym_col = found
175
+ lang_id = project.lang_id_for_file(sym_file)
176
+ await lsp.ensure_open(sym_file, lang_id)
177
+
178
+ try:
179
+ refs = await lsp.request("textDocument/references", {
180
+ "textDocument": {"uri": uri},
181
+ "position": {"line": sym_line, "character": sym_col},
182
+ "context": {"includeDeclaration": False},
183
+ }) or []
184
+ except LSPError as e:
185
+ return json.dumps({"error": str(e), "results": []})
186
+
187
+ results = []
188
+ for ref in refs:
189
+ ref_path = _uri_to_path(ref["uri"])
190
+ ref_line = ref["range"]["start"]["line"] + 1
191
+ try:
192
+ lines = pathlib.Path(ref_path).read_text(errors="replace").splitlines()
193
+ context = lines[ref_line - 1].strip() if ref_line <= len(lines) else ""
194
+ except Exception:
195
+ context = ""
196
+ results.append({
197
+ "file": ref_path,
198
+ "line": ref_line,
199
+ "column": ref["range"]["start"]["character"],
200
+ "context": context,
201
+ })
202
+
203
+ return json.dumps({"symbol": symbol_name, "count": len(results), "results": results[:100]})
204
+
205
+
206
+ @_mcp.tool()
207
+ async def hover(file_path: str, line: int, column: int = 0) -> str:
208
+ """Get type info and documentation for a symbol at a specific position.
209
+
210
+ file_path: absolute or project-relative file path
211
+ line: line number (1-indexed)
212
+ column: column number (0-indexed, default 0)
213
+ """
214
+ project = _proj()
215
+ if not pathlib.Path(file_path).is_absolute():
216
+ file_path = str(project.root / file_path)
217
+
218
+ lsp, _ = await project.get_lsp_for_file(file_path)
219
+ if not lsp:
220
+ return json.dumps({"error": f"No LSP available for: {file_path}"})
221
+
222
+ lang_id = project.lang_id_for_file(file_path)
223
+ await lsp.ensure_open(file_path, lang_id)
224
+
225
+ try:
226
+ result = await lsp.request("textDocument/hover", {
227
+ "textDocument": {"uri": pathlib.Path(file_path).as_uri()},
228
+ "position": {"line": line - 1, "character": column},
229
+ })
230
+ except LSPError as e:
231
+ return json.dumps({"error": str(e)})
232
+
233
+ if not result:
234
+ return json.dumps({"error": "No hover information at this position"})
235
+
236
+ return json.dumps({
237
+ "file": file_path,
238
+ "line": line,
239
+ "column": column,
240
+ "info": _extract_hover_text(result.get("contents", "")),
241
+ })
242
+
243
+
244
+ @_mcp.tool()
245
+ async def go_to_definition(file_path: str, line: int, column: int = 0) -> str:
246
+ """Find the definition of a symbol at a specific position.
247
+
248
+ file_path: absolute or project-relative file path
249
+ line: line number (1-indexed)
250
+ column: column number (0-indexed)
251
+ Returns definition location(s) with file and line.
252
+ """
253
+ project = _proj()
254
+ if not pathlib.Path(file_path).is_absolute():
255
+ file_path = str(project.root / file_path)
256
+
257
+ lsp, _ = await project.get_lsp_for_file(file_path)
258
+ if not lsp:
259
+ return json.dumps({"error": f"No LSP available for: {file_path}"})
260
+
261
+ lang_id = project.lang_id_for_file(file_path)
262
+ await lsp.ensure_open(file_path, lang_id)
263
+
264
+ try:
265
+ result = await lsp.request("textDocument/definition", {
266
+ "textDocument": {"uri": pathlib.Path(file_path).as_uri()},
267
+ "position": {"line": line - 1, "character": column},
268
+ })
269
+ except LSPError as e:
270
+ return json.dumps({"error": str(e)})
271
+
272
+ if not result:
273
+ return json.dumps({"error": "No definition found"})
274
+
275
+ if isinstance(result, dict):
276
+ result = [result]
277
+
278
+ locations = []
279
+ for loc in result:
280
+ uri = loc.get("uri") or loc.get("targetUri", "")
281
+ r = loc.get("range") or loc.get("targetSelectionRange") or loc.get("targetRange", {})
282
+ locations.append({
283
+ "file": _uri_to_path(uri),
284
+ "line": r.get("start", {}).get("line", 0) + 1,
285
+ "column": r.get("start", {}).get("character", 0),
286
+ })
287
+
288
+ return json.dumps({"count": len(locations), "results": locations})
289
+
290
+
291
+ @_mcp.tool()
292
+ async def explain_file(file_path: str, detail: str = "summary") -> str:
293
+ """Summarize a file's structure: exports, top-level symbols, language, line count.
294
+
295
+ file_path: absolute or project-relative file path
296
+ detail: 'summary' (top-level only) or 'full' (all nested symbols)
297
+ """
298
+ project = _proj()
299
+ if not pathlib.Path(file_path).is_absolute():
300
+ file_path = str(project.root / file_path)
301
+
302
+ path = pathlib.Path(file_path)
303
+ if not path.exists():
304
+ return json.dumps({"error": f"File not found: {file_path}"})
305
+
306
+ lang_id = project.lang_id_for_file(file_path)
307
+ lines = path.read_text(errors="replace").splitlines()
308
+ result: dict = {"file": file_path, "language": lang_id, "loc": len(lines)}
309
+
310
+ lsp, _ = await project.get_lsp_for_file(file_path)
311
+ if lsp:
312
+ await lsp.ensure_open(file_path, lang_id)
313
+ try:
314
+ symbols = await lsp.request("textDocument/documentSymbol", {
315
+ "textDocument": {"uri": path.as_uri()},
316
+ }) or []
317
+ flat = _flatten_doc_symbols(symbols)
318
+ result["symbols"] = flat if detail == "full" else [s for s in flat if s.get("depth", 0) == 0]
319
+ except LSPError:
320
+ result["symbols"] = []
321
+ else:
322
+ result["note"] = "LSP unavailable — no symbol info"
323
+
324
+ return json.dumps(result)
325
+
326
+
327
+ @_mcp.tool()
328
+ async def call_hierarchy(symbol_name: str, direction: str = "incoming", depth: int = 2) -> str:
329
+ """Show who calls (incoming) or is called by (outgoing) a function.
330
+
331
+ symbol_name: exact function/method name
332
+ direction: 'incoming' | 'outgoing' | 'both'
333
+ depth: recursion depth (max 3 recommended)
334
+ """
335
+ project = _proj()
336
+ # Restrict to callable kinds: method, constructor, function
337
+ found = await _locate_symbol(project, symbol_name, kinds={6, 9, 12})
338
+
339
+ if not found:
340
+ # Retry without kind filter — some LSPs categorize differently
341
+ found = await _locate_symbol(project, symbol_name)
342
+ if not found:
343
+ return json.dumps({"error": f"Symbol '{symbol_name}' not found"})
344
+
345
+ lsp, uri, sym_file, sym_line, sym_col = found
346
+ lang_id = project.lang_id_for_file(sym_file)
347
+ await lsp.ensure_open(sym_file, lang_id)
348
+
349
+ try:
350
+ items = await lsp.request("textDocument/prepareCallHierarchy", {
351
+ "textDocument": {"uri": uri},
352
+ "position": {"line": sym_line, "character": sym_col},
353
+ }) or []
354
+ except LSPError as e:
355
+ return json.dumps({"error": f"Call hierarchy not supported by LSP: {e}"})
356
+
357
+ if not items:
358
+ return json.dumps({"error": f"No call hierarchy item found for '{symbol_name}'"})
359
+
360
+ item = items[0]
361
+ depth = min(depth, 3) # ponytail: cap depth to avoid runaway recursion
362
+
363
+ async def incoming(it, d):
364
+ if d == 0:
365
+ return []
366
+ try:
367
+ calls = await lsp.request("callHierarchy/incomingCalls", {"item": it}) or []
368
+ except LSPError:
369
+ return []
370
+ result = []
371
+ for call in calls:
372
+ caller = call["from"]
373
+ result.append({
374
+ "name": caller["name"],
375
+ "file": _uri_to_path(caller["uri"]),
376
+ "line": caller["range"]["start"]["line"] + 1,
377
+ "callers": await incoming(caller, d - 1),
378
+ })
379
+ return result
380
+
381
+ async def outgoing(it, d):
382
+ if d == 0:
383
+ return []
384
+ try:
385
+ calls = await lsp.request("callHierarchy/outgoingCalls", {"item": it}) or []
386
+ except LSPError:
387
+ return []
388
+ result = []
389
+ for call in calls:
390
+ callee = call["to"]
391
+ result.append({
392
+ "name": callee["name"],
393
+ "file": _uri_to_path(callee["uri"]),
394
+ "line": callee["range"]["start"]["line"] + 1,
395
+ "calls": await outgoing(callee, d - 1),
396
+ })
397
+ return result
398
+
399
+ out: dict = {"symbol": symbol_name}
400
+ if direction in ("incoming", "both"):
401
+ out["incoming"] = await incoming(item, depth)
402
+ if direction in ("outgoing", "both"):
403
+ out["outgoing"] = await outgoing(item, depth)
404
+ return json.dumps(out)
405
+
406
+
407
+ @_mcp.tool()
408
+ async def type_hierarchy(symbol_name: str, direction: str = "both") -> str:
409
+ """Show the type hierarchy (supertypes / subtypes) for a class or interface.
410
+
411
+ symbol_name: exact class/interface/enum name
412
+ direction: 'supertypes' | 'subtypes' | 'both'
413
+ """
414
+ project = _proj()
415
+ found = await _locate_symbol(project, symbol_name, kinds={5, 10, 11}) # class, enum, interface
416
+
417
+ if not found:
418
+ found = await _locate_symbol(project, symbol_name)
419
+ if not found:
420
+ return json.dumps({"error": f"Symbol '{symbol_name}' not found"})
421
+
422
+ lsp, uri, sym_file, sym_line, sym_col = found
423
+ lang_id = project.lang_id_for_file(sym_file)
424
+ await lsp.ensure_open(sym_file, lang_id)
425
+
426
+ try:
427
+ items = await lsp.request("textDocument/prepareTypeHierarchy", {
428
+ "textDocument": {"uri": uri},
429
+ "position": {"line": sym_line, "character": sym_col},
430
+ }) or []
431
+ except LSPError as e:
432
+ return json.dumps({"error": f"Type hierarchy not supported: {e}"})
433
+
434
+ if not items:
435
+ return json.dumps({"error": f"No type hierarchy for '{symbol_name}'"})
436
+
437
+ item = items[0]
438
+
439
+ def _fmt(types_list):
440
+ return [
441
+ {"name": t["name"], "file": _uri_to_path(t["uri"]), "line": t["range"]["start"]["line"] + 1}
442
+ for t in (types_list or [])
443
+ if isinstance(t, dict)
444
+ ]
445
+
446
+ out: dict = {"symbol": symbol_name}
447
+ if direction in ("supertypes", "both"):
448
+ try:
449
+ out["supertypes"] = _fmt(await lsp.request("typeHierarchy/supertypes", {"item": item}))
450
+ except LSPError:
451
+ out["supertypes"] = []
452
+ if direction in ("subtypes", "both"):
453
+ try:
454
+ out["subtypes"] = _fmt(await lsp.request("typeHierarchy/subtypes", {"item": item}))
455
+ except LSPError:
456
+ out["subtypes"] = []
457
+ return json.dumps(out)
458
+
459
+
460
+ @_mcp.tool()
461
+ async def implementations(symbol_name: str, file: str = "") -> str:
462
+ """Find all implementations of an interface, abstract class, or protocol.
463
+
464
+ symbol_name: exact interface/abstract class name
465
+ file: optional file hint for disambiguation
466
+ """
467
+ project = _proj()
468
+ found = await _locate_symbol(project, symbol_name, file_hint=file)
469
+
470
+ if not found:
471
+ return json.dumps({"error": f"Symbol '{symbol_name}' not found", "results": []})
472
+
473
+ lsp, uri, sym_file, sym_line, sym_col = found
474
+ lang_id = project.lang_id_for_file(sym_file)
475
+ await lsp.ensure_open(sym_file, lang_id)
476
+
477
+ try:
478
+ impls = await lsp.request("textDocument/implementation", {
479
+ "textDocument": {"uri": uri},
480
+ "position": {"line": sym_line, "character": sym_col},
481
+ }) or []
482
+ except LSPError as e:
483
+ return json.dumps({"error": str(e), "results": []})
484
+
485
+ if isinstance(impls, dict):
486
+ impls = [impls]
487
+
488
+ results = []
489
+ for impl in impls:
490
+ # Handles both Location and LocationLink shapes
491
+ impl_uri = impl.get("uri") or impl.get("targetUri", "")
492
+ impl_range = impl.get("range") or impl.get("targetRange") or impl.get("targetSelectionRange", {})
493
+ results.append({
494
+ "file": _uri_to_path(impl_uri),
495
+ "line": impl_range.get("start", {}).get("line", 0) + 1,
496
+ })
497
+
498
+ return json.dumps({"symbol": symbol_name, "count": len(results), "results": results})
499
+
500
+
501
+ @_mcp.tool()
502
+ async def get_diagnostics(file_path: str) -> str:
503
+ """Get language-server diagnostics (errors, warnings) for a file.
504
+
505
+ file_path: absolute or project-relative file path
506
+ Opens the file in the LSP and waits up to 5 s for diagnostics to arrive.
507
+ Returns a list with severity, message, and location for each diagnostic.
508
+ """
509
+ project = _proj()
510
+ if not pathlib.Path(file_path).is_absolute():
511
+ file_path = str(project.root / file_path)
512
+
513
+ if not pathlib.Path(file_path).exists():
514
+ return json.dumps({"error": f"File not found: {file_path}"})
515
+
516
+ lsp, _ = await project.get_lsp_for_file(file_path)
517
+ if not lsp:
518
+ return json.dumps({"error": f"No LSP available for: {file_path}"})
519
+
520
+ lang_id = project.lang_id_for_file(file_path)
521
+ await lsp.ensure_open(file_path, lang_id)
522
+ raw = await lsp.get_diagnostics(file_path)
523
+
524
+ SEVERITY = {1: "error", 2: "warning", 3: "information", 4: "hint"}
525
+ results = [
526
+ {
527
+ "severity": SEVERITY.get(d.get("severity", 1), "error"),
528
+ "message": d.get("message", ""),
529
+ "line": d.get("range", {}).get("start", {}).get("line", 0) + 1,
530
+ "column": d.get("range", {}).get("start", {}).get("character", 0),
531
+ "code": d.get("code"),
532
+ "source": d.get("source"),
533
+ }
534
+ for d in raw
535
+ ]
536
+ return json.dumps({"file": file_path, "count": len(results), "diagnostics": results})
537
+
538
+
539
+ @_mcp.tool()
540
+ async def search_pattern(pattern: str, scope: str = "", file_glob: str = "") -> str:
541
+ """Search for a regex or literal pattern in files using ripgrep.
542
+
543
+ pattern: regex or literal string to search for
544
+ scope: directory to search in (defaults to project root)
545
+ file_glob: file filter e.g. '*.py' or '**/*.ts'
546
+ Returns up to 200 matches with file, line, and context.
547
+ """
548
+ project = _proj()
549
+ search_path = scope if scope else str(project.root)
550
+
551
+ cmd = ["rg", "--json", "-e", pattern]
552
+ if file_glob:
553
+ cmd += ["-g", file_glob]
554
+ cmd.append(search_path)
555
+
556
+ try:
557
+ proc = await asyncio.create_subprocess_exec(
558
+ *cmd,
559
+ stdout=asyncio.subprocess.PIPE,
560
+ stderr=asyncio.subprocess.DEVNULL,
561
+ )
562
+ stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=15)
563
+ except FileNotFoundError:
564
+ return json.dumps({"error": "ripgrep (rg) not found — install from https://github.com/BurntSushi/ripgrep"})
565
+ except TimeoutError:
566
+ return json.dumps({"error": "Search timed out"})
567
+
568
+ results = []
569
+ for raw_line in stdout.decode(errors="replace").splitlines():
570
+ try:
571
+ obj = json.loads(raw_line)
572
+ if obj.get("type") != "match":
573
+ continue
574
+ data = obj["data"]
575
+ results.append({
576
+ "file": data["path"]["text"],
577
+ "line": data["line_number"],
578
+ "column": data["submatches"][0]["start"] if data.get("submatches") else 0,
579
+ "context": data["lines"]["text"].rstrip(),
580
+ })
581
+ except Exception:
582
+ pass
583
+
584
+ return json.dumps({"pattern": pattern, "count": len(results), "results": results[:200]})
585
+
586
+
587
+ @_mcp.tool()
588
+ async def changed_since(ref: str = "HEAD", include_symbols: bool = False) -> str:
589
+ """List files changed since a git ref, optionally with their top-level symbols.
590
+
591
+ ref: git ref to diff against (default: HEAD = staged changes).
592
+ Pass "" to get all dirty files (staged + unstaged).
593
+ Pass "HEAD~1" (or any ref) to compare against a prior commit.
594
+ include_symbols: if true, also return top-level symbols for each changed file
595
+ """
596
+ project = _proj()
597
+
598
+ if ref == "":
599
+ cmd = ["git", "-C", str(project.root), "status", "--porcelain"]
600
+ else:
601
+ cmd = ["git", "-C", str(project.root), "diff", "--name-only", ref]
602
+
603
+ try:
604
+ proc = await asyncio.create_subprocess_exec(
605
+ *cmd,
606
+ stdout=asyncio.subprocess.PIPE,
607
+ stderr=asyncio.subprocess.PIPE,
608
+ )
609
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
610
+ except FileNotFoundError:
611
+ return json.dumps({"error": "git not found"})
612
+ except TimeoutError:
613
+ return json.dumps({"error": "git command timed out"})
614
+
615
+ if proc.returncode != 0:
616
+ return json.dumps({"error": stderr.decode(errors="replace").strip()})
617
+
618
+ if ref == "":
619
+ files = []
620
+ for line in stdout.decode().splitlines():
621
+ if len(line) > 3:
622
+ fname = line[3:].strip()
623
+ # Renames: "old -> new" — take the new name
624
+ if " -> " in fname:
625
+ fname = fname.split(" -> ")[-1]
626
+ files.append(fname)
627
+ else:
628
+ files = [f for f in stdout.decode().splitlines() if f]
629
+
630
+ result: dict = {"ref": ref or "working-tree", "files_changed": files}
631
+
632
+ if include_symbols and files:
633
+ affected: dict[str, list] = {}
634
+ for rel_path in files[:20]: # ponytail: cap at 20 to avoid huge responses
635
+ full_path = str(project.root / rel_path)
636
+ lsp, _ = await project.get_lsp_for_file(full_path)
637
+ if lsp and pathlib.Path(full_path).exists():
638
+ try:
639
+ lang_id = project.lang_id_for_file(full_path)
640
+ await lsp.ensure_open(full_path, lang_id)
641
+ syms = await lsp.request("textDocument/documentSymbol", {
642
+ "textDocument": {"uri": pathlib.Path(full_path).as_uri()},
643
+ }) or []
644
+ affected[rel_path] = [s.get("name") for s in syms if isinstance(s, dict)]
645
+ except LSPError:
646
+ pass
647
+ result["symbols_affected"] = affected
648
+
649
+ return json.dumps(result)
650
+
651
+
652
+ @_mcp.tool()
653
+ async def scope_status() -> str:
654
+ """Show project info: detected root, languages, LSP status, and any missing installs."""
655
+ project = _proj()
656
+ lsps = {}
657
+ for lang in project.languages:
658
+ cfg = LSP_REGISTRY.get(lang, {})
659
+ binary = cfg.get("binary", "")
660
+ running = lang in project._lsps
661
+ installed = LSPClient.available(binary)
662
+ lsps[lang] = {
663
+ "binary": binary,
664
+ "installed": installed,
665
+ "running": running,
666
+ "install_cmd": cfg.get("install") if not installed else None,
667
+ }
668
+ return json.dumps({
669
+ "project_root": str(project.root),
670
+ "languages_detected": project.languages,
671
+ "lsp_status": lsps,
672
+ })
673
+
674
+
675
+ # ── Entry point ─────────────────────────────────────────────────────────────
676
+
677
+ async def run(project_path: str = ".") -> None:
678
+ global _project
679
+ _project = ProjectManager(project_path)
680
+ try:
681
+ await _mcp.run_stdio_async()
682
+ finally:
683
+ await _project.shutdown()
@@ -0,0 +1,144 @@
1
+ Metadata-Version: 2.4
2
+ Name: scope-mcp
3
+ Version: 0.1.0
4
+ Summary: Give your AI coding assistant real code understanding — powered by the same engines VS Code uses
5
+ Author: Deviprasad Shetty
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/deviprasadshetty-dev/scope
8
+ Project-URL: Repository, https://github.com/deviprasadshetty-dev/scope.git
9
+ Project-URL: BugTracker, https://github.com/deviprasadshetty-dev/scope/issues
10
+ Keywords: mcp,lsp,ai,code-navigation,claude,coding-assistant
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Software Development
18
+ Classifier: Topic :: Software Development :: Code Generators
19
+ Requires-Python: >=3.11
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: mcp>=1.0.0
23
+ Dynamic: license-file
24
+
25
+ # scope-mcp
26
+
27
+ **A tool that gives your AI coding assistant superpowers.** Instead of guessing what your code does, it gets real answers straight from the same engines VS Code uses — so it actually understands functions, classes, types, and where things connect.
28
+
29
+ No setup. No indexing. Just works.
30
+
31
+ ## What's the problem?
32
+
33
+ When you ask an AI to "find where `get_lsp` is called," most tools do a text search — like Ctrl+Shift+F. That returns *everything* named `get_lsp`: the definition, comments, imports, false positives. You have to dig through the noise.
34
+
35
+ ```
36
+ ┌─ grep (text search) ─────────────────────┐
37
+ │ project.py:75 def get_lsp(self, lang) │ ← definition (not a call)
38
+ │ project.py:110 return await get_lsp(lang)│ ← actual call
39
+ │ project.py:117 lsp = await get_lsp(lang) │ ← actual call
40
+ │ project.py:81 cfg = LSP_REGISTRY... │ ← noise
41
+ │ project.py:109 async def get_lsp_for... │ ← noise
42
+ │ 5 results · 3 are noise │
43
+ └───────────────────────────────────────────┘
44
+
45
+ ┌─ scope (smart search) ────────────────────┐
46
+ │ [call site] project.py:110 │
47
+ │ [call site] project.py:117 │
48
+ │ 2 results · 0 noise │
49
+ └───────────────────────────────────────────┘
50
+ ```
51
+
52
+ Scope asks the compiler instead. You get only what you actually asked for.
53
+
54
+ ## What you can do with it
55
+
56
+ | Instead of digging through files... | Just ask scope... | You get |
57
+ |---|---|---|
58
+ | Grepping for a function name and filtering out junk | `find_references("get_lsp")` | Only the places where it's actually called — no noise |
59
+ | Reading a whole file to figure out what's in it | `explain_file("server.py")` | A clean summary: language, line count, every function and class |
60
+ | Hunting through 50 files to find where an interface is used | `implementations("IEventHandler")` | One answer with all the implementations |
61
+ | Tracing who calls what by hand | `call_hierarchy("validate_token")` | A tree of callers and callees, 3 levels deep |
62
+ | Scanning a big diff to see what changed | `changed_since("HEAD~3")` | Just the changed files and affected symbols |
63
+
64
+ ## How it works
65
+
66
+ 1. **Scope looks at your project** — it spots what languages you're using (Python, TypeScript, Rust, Go, C++) from files like `package.json` or `Cargo.toml`.
67
+ 2. **It sets up the brain** — launches the same language engine your editor uses (pyright, tsserver, rust-analyzer, gopls). If missing, it installs one automatically.
68
+ 3. **Your AI asks, scope answers** — every question hits that engine live. No stale data, no sync jobs, no waiting.
69
+
70
+ ```
71
+ AI Assistant (Claude, Codex, etc.) ←→ scope ←→ Language Engine
72
+ (pyright, tsserver, etc.)
73
+ ```
74
+
75
+ ## Setup
76
+
77
+ ```bash
78
+ pip install scope-mcp
79
+ ```
80
+
81
+ Then add one line to your AI client's config:
82
+
83
+ ### Claude Desktop / Code
84
+
85
+ ```json
86
+ {
87
+ "mcpServers": {
88
+ "scope": {
89
+ "command": "scope",
90
+ "args": ["--project", "."]
91
+ }
92
+ }
93
+ }
94
+ ```
95
+
96
+ ### Any AI coding tool
97
+
98
+ ```
99
+ command: scope
100
+ args: ["--project", "/path/to/your/project"]
101
+ transport: stdio
102
+ ```
103
+
104
+ ### From source
105
+
106
+ ```bash
107
+ git clone https://github.com/yourname/scope-mcp
108
+ cd scope-mcp
109
+ pip install -e .
110
+ ```
111
+
112
+ Optional extras: `rg` (ripgrep) for text searches, `git` for change tracking.
113
+
114
+ ## Languages scope understands
115
+
116
+ | Language | Detected when it sees... | Scope handles setup |
117
+ |---|---|---|
118
+ | Python | `pyproject.toml` · `setup.py` · `requirements.txt` | ✅ Installs pyright automatically |
119
+ | TypeScript / JavaScript | `tsconfig.json` · `package.json` | ✅ Installs tsserver automatically |
120
+ | Rust | `Cargo.toml` | ✅ Installs rust-analyzer automatically |
121
+ | Go | `go.mod` | ✅ Installs gopls automatically |
122
+ | C / C++ | `CMakeLists.txt` · `compile_commands.json` | ⚠️ You install clangd manually |
123
+
124
+ ## Project layout
125
+
126
+ ```
127
+ scope/
128
+ ├── __init__.py
129
+ ├── __main__.py # Where scope starts — just runs `scope --project .`
130
+ ├── server.py # All the commands (tools) your AI can call
131
+ ├── project.py # Figures out your project and starts language engines
132
+ ├── lsp_client.py # Talks to language engines behind the scenes
133
+ └── lsp_registry.py # Knows which engine to use for each language
134
+ ```
135
+
136
+ ## Requirements
137
+
138
+ - Python 3.11 or newer
139
+ - ripgrep (optional, for text search)
140
+ - git (optional, for change tracking)
141
+
142
+ ## License
143
+
144
+ MIT
@@ -0,0 +1,12 @@
1
+ scope/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ scope/__main__.py,sha256=n57cuNx4zzaLAk9SF5R_MNK4b4rstneP2bJOAsZRlpM,331
3
+ scope/lsp_client.py,sha256=EYxkH-4Zv51R94ZkFe6j4dhb4NZWQtdHIKPr1Pv3C30,6518
4
+ scope/lsp_registry.py,sha256=t6ukiUG0A95aCcy9rgJLnDOkOzG7O4b_QRJuYMN7Edo,2288
5
+ scope/project.py,sha256=1GS1eeT1uyzLuFTdDvHlz-NdOdWZrCBLt5JuD10l75E,5227
6
+ scope/server.py,sha256=f1QJ2ewZgb28gttHbn2yvZfBiayKWamsdS8m2CsU30U,24559
7
+ scope_mcp-0.1.0.dist-info/licenses/LICENSE,sha256=4AfpvF_PqbEc3ks1x77JnpsblR-AcB6Ea7TNlzlVENY,1074
8
+ scope_mcp-0.1.0.dist-info/METADATA,sha256=DBzdIWUMyAUwf7S-4JW-eKWdqCmySHzFXOBIf9XB5oo,5859
9
+ scope_mcp-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
10
+ scope_mcp-0.1.0.dist-info/entry_points.txt,sha256=vWgiuSAbQg6PTrtx2x6x6nBKuDRJrMy2h-hv7Dtam-w,50
11
+ scope_mcp-0.1.0.dist-info/top_level.txt,sha256=2CM6KU4wKPVS0iGdr72fQXzMA5Vn0U5FzuzOYCYth_8,6
12
+ scope_mcp-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ scope = scope.__main__:main_cli
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Deviprasad Shetty
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ scope