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 +0 -0
- scope/__main__.py +17 -0
- scope/lsp_client.py +168 -0
- scope/lsp_registry.py +58 -0
- scope/project.py +134 -0
- scope/server.py +683 -0
- scope_mcp-0.1.0.dist-info/METADATA +144 -0
- scope_mcp-0.1.0.dist-info/RECORD +12 -0
- scope_mcp-0.1.0.dist-info/WHEEL +5 -0
- scope_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- scope_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
- scope_mcp-0.1.0.dist-info/top_level.txt +1 -0
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,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
|